Compare commits

..
877 Commits
Author SHA1 Message Date
Niko1221 ffa80d7313 App: a layer split runs DFlash2 alone, one request at a time. chooseParallel kept the app's default of 4 slots on two or more cards, so the Python plan (drafting.blocked_by) refused DFlash2 for slots > 1, the MTP layer was not on the disk (a DFlash2 install leaves it out) and the start ran with no drafter. The one-request rule for DFlash2 no longer asks for one card; a number the person chose still stays, and the MTP keeps its slots. Test: two cards with DFlash2 give one slot and the note, two cards with the MTP keep more than one. 2026-11-02 23:37:27 +01:00
Niko1221 974074c549 0.2.10: the all-cards split engine is Terminal-only; the app's drafting plan and fetch ask without it; the split's lanes are cut to 1..8
App: `strata drafting plan/fetch` gain --no-split, and every drafting call the host makes (control-runtime.mjs draftingPlan, draftingFetch) carries it, so Auto in the app on
2 NVIDIA / 4 R9700 cards is the layer split with DFlash2 alone, no draft layer is planned or fetched for the split, and the Models screen label and fit say DFlash2 again
(capabilities.dflash2Split stays false). The Terminal's plan is unchanged (split + MTP). The gguf_dir fix of installMtpLayer and the explicit-MTP path stay.
Lanes: split_config cuts the lanes to 1..8 (the engine refuses "--batch must be 1..8"): the AMD four-card default PARALLEL 32, a layer config's own --batch (IQ3_S on 4 R9700
gave 32 and fell back to the layer split) and a profile parallel above 8; serve/server.py parallel_args' split branch cuts the same.
Tests: tests/terminal/test_split_parallel.py (AppDoesNotSplit, LaneClamp), apps/host/tests/drafting.test.mjs.
2026-11-02 22:45:11 +01:00
Niko1221 3482b3dff8 Merge f-0210-ds-mac-vision: the Mac DeepSeek picture tower (engine commit f457ed7b9) 2026-11-02 22:04:03 +01:00
Niko1221 4045621eba docs/SPLIT_ENGINE.md describes what 0.2.10 does: Auto starts the all-cards split on 2 RTX A6000 or RTX PRO 6000 cards and on 4 R9700 cards with a UD-Q4_K_XL-type file, the MTP draft layer is fetched for it, --split-engine off turns it off, other AMD cards are opt-in with the self-test line 2026-11-02 22:04:03 +01:00
Niko1221 904b2a723f App: the MTP draft layer fetch names the model's own files folder (--native), so a plain Auto load never downloads the model again
Found live on the AppImage (srv118): after an app install from the person's own GGUF folder, a plain Auto load on two cards (the plan says split + MTP, the layer is missing) ran installMtpLayer, which asked the installer for the model by name without gguf_dir; the installer started downloading the 111 GB model into the app's data folder (stopped after 3.5 GB). installMtpLayer now passes gguf_dir = the folder of the run config's --native. Test: apps/host/tests/drafting.test.mjs.
2026-11-02 21:05:56 +01:00
Niko1221 b1eee01074 test: the Mac vision setup-order window allows the download-total correction between the engine line and step 5 2026-11-02 21:04:16 +01:00
Niko1221 f457ed7b98 Mac DeepSeek vision tower: the GEMM choice is made by the engine at the first picture (Metal 4 present, the tensor-op library compiles, a small self-test equals the plain kernel), logged once; otherwise the plain kernel. mtl::Context::compile_try (compile that returns instead of exiting) 2026-11-02 20:52:11 +01:00
Niko1221 c5ab5d4003 Merge f-0291-rc1-ds-vision-reserve: DeepSeek pictures on a discrete card get --reserve-mib 900 (lazy tower stays) 2026-11-02 20:26:53 +01:00
Niko1221 0659c12582 --reserve-mib 900 with pictures on a discrete card (36 experts, 1.2% of the resident ones, for a worst-case margin of ~10 MiB at the default 600) 2026-11-02 20:24:20 +01:00
Niko1221 e6c0cd41d3 test: the Strix stand-in card has no unified_gb (that is a Mac card) 2026-11-02 20:09:42 +01:00
Niko1221 434c6221b6 Setup: a picture file the person already has is used, not fetched again; the Mac's download total drops the picture file once the engine says no
The Qwen family's picture file is also looked for in the folder above --gguf-dir and in that folder's models/ (pictures.places() already did);
Qwen3.6 links another size's copy of its picture file instead of fetching it again; on a Mac, when the installed DeepSeek engine reads no pictures,
the download total printed before the engine step is corrected (nothing is fetched before that point); the early Coder builds' temporary-repository
picture file (972,207,968 B, the same tensors) is accepted as the shared one (DSV41_ALT_FILES).  test_dsv41_mac_vision's step(5) search starts at
the engine line (it found an earlier family's step 5).
2026-11-02 20:03:42 +01:00
Niko1221 006f0ff258 The Coder's Mac measured line names the memory it was measured with (M5 Max, 128 GB): a 64 GB Mac streams part of the experts from the SSD 2026-11-02 20:01:19 +01:00
Niko1221 45ae55c038 test: the Strix stand-in card carries the uma mark setup gives it 2026-11-02 20:00:50 +01:00
Niko1221 82a028f96e The app and the launcher follow the plan: a plain Auto load fetches the MTP draft layer when the split will engage, the Models screen and the fit say split + MTP, and the summary says what runs after a split fallback
App (apps/host): a plain Auto load (no choice carried, none saved but Auto) fetches the draft layer when `strata drafting plan` says mtp_needed and split,
the same as the Terminal; a Load that carries a choice is as before. probeDrafting reads the plan's split word (capabilities.dflash2Split); the install's drafter
(resolveDrafter) takes it too, so a new install on 2 cards leaves DFlash2 out and gets the MTP layer. drafting.ts / native-settings.mjs
resolveDrafter(choice, family, served, split): Auto is the MTP where the split takes the start, a DFlash2 the person chose stays; NativeModels.tsx passes it, so the
label and the fit use the MTP. `strata drafting plan` gains "split".
Launcher: when the server starts the normal layer split because the all-cards split engine would not come up (metrics engine.split_fallback), the final summary
replaces "Using the all-cards split engine on N cards" with "The all-cards split engine could not start (<the engine's own reason>); running the normal layer split."
Tests: apps/host/tests/drafting.test.mjs; tests/terminal/test_split_parallel.py (PlanTellsTheApp, SummaryTellsWhatRuns).
2026-11-02 19:59:57 +01:00
Niko1221 607d015590 CANDIDATE (not sent): DeepSeek with pictures on a card with memory of its own starts the engine with --reserve-mib 1100 (provisional) so the first picture's hipBLASLt start and the tower's buffers have room; Mac, unified-memory APUs and pictures off unchanged 2026-11-02 19:57:37 +01:00
Niko1221 e483f7835c Mac DeepSeek vision tower: GEMM on the Metal 4 tensor ops (BF16 operands read in place, FP32 accumulation; 798x448 in 93 ms, 1344x1260 in 1.2 s, was 2.6 s and 29 s), DS4_VISION_NO_MPP=1 for the plain kernel, sequential-FMA test GEMM (DS4_VISION_REF_GEMM) 2026-11-02 19:42:56 +01:00
Niko1221 63422c2443 Split engine as the automatic default really works on a fresh install: Auto that picks the split fetches the MTP draft layer, and a missing draft layer is a quiet layer split
The split runs the model's draft layer (--mtp) and exits at start without it ("sfnx: --native and --mtp are required"). The plan and the fetch for Auto
resolved to DFlash2 (drafter 1.2 GB, no draft layer), so on 2 x sm_86/sm_120 or 4 x gfx1201 the split was chosen at start, failed, and the server fell back
to the layer split after "Using the all-cards split engine on 2 cards".

(B) strata_control/split_engine.expected(): the rule apply_to_config uses, asked before anything is on disk (model assumed when its file is not there yet).
Runner.split_expected / resolved_drafting and drafting_inputs: Auto where the split will take the start is the MTP, so download_plan and the fetch include the
draft layer (5.2 GB) and not DFlash2's drafter, setup gets --mtp on without --drafter dflash2, and the plan says "Drafting: MTP (the all-cards split engine uses
the MTP draft layer, 5.2 GB)". The app's `strata drafting plan/fetch` (drafting_cmd._inputs) follows the same rule. A person's own DFlash2 / off, --split-engine
off, one card, other cards, three cards keep what they had.
(A) Runner.apply_split_engine: when the --mtp folder is missing or empty at start the split is not picked (Auto: silently, the layer split with DFlash2 starts as
the plan said; "on": one line saying to add --drafting mtp). _auto_gates() is the Auto-only part of the rule, shared by apply_to_config and expected().
Tests: tests/terminal/test_split_parallel.py (Terminal and app plan paths, AMD 4 x gfx1201 and 2, one card, sm_89, own DFlash2, off, missing draft layer).
2026-11-02 19:41:45 +01:00
Niko1221 742863adf3 Revert "Split engine as the automatic default really works on a fresh install: Auto that picks the split fetches the MTP draft layer, and a missing draft layer is a quiet layer split"
This reverts commit 770a9b8dc8.
2026-11-02 19:41:42 +01:00
Niko1221 0bab1534be test: the old-launcher test pins the product version 0.2.10 (it still said 0.2.9) 2026-11-02 19:39:43 +01:00
Niko1221 45c71cf9c3 Merge f-0210-draftsw-dl: switching the model resets Drafting to Auto (Terminal and app); the Q4_0 selector pack is converted only for an engine that carries it 2026-11-02 19:37:43 +01:00
Niko1221 fca8b48c00 Drafting follows the model: the Terminal goes back to Auto when the model changes to one without a Drafting choice (DeepSeek, Coder, Swift, NVFP4) and a launch writes none into a DeepSeek run config; the app's Models page resets to Auto for a model that is not installed; the Q4_0-selector pack is made only where the installed engine reads it (never for the SYCL engine); tests 2026-11-02 19:37:22 +01:00
Niko1221 2e00d75f99 Merge f-0291-rc1-coder-final: test fix for the Mac vision setup-order test 2026-11-02 19:36:46 +01:00
Niko1221 fbc5c8e1d9 test fix 2026-11-02 19:34:02 +01:00
Niko1221 200f639432 test: the setup-order check looks for the step 5 after the engine line 2026-11-02 19:34:01 +01:00
Niko1221 1f7640a8e5 serve: take SPLIT-HIP's server key fix as amended (cf9406ea9) over the earlier 6174eed4a 2026-11-02 19:31:56 +01:00
Niko1221 379e893f84 Merge f-0291-rc1-split-keys: the server stops sending pen_from to the split engine; logprobs/logit_bias answer 400 naming --split-engine off 2026-11-02 19:24:55 +01:00
Niko1221 6174eed4ac serve: never send the all-cards split engine a GEN key its parser refuses: pen_from is dropped for it (its penalty window is the last penalty_last_n tokens of the whole context, so a continued prompt gives the same window), logprobs and logit_bias get a 400 naming --split-engine off; a test pins the set of keys the server can send against sfnx.cpp's parse_gen 2026-11-02 19:23:52 +01:00
Niko1221 6456e3f242 Merge f-0291-rc1-split-draft: Auto picks the MTP draft layer when the all-cards split engine will engage; quiet fallback when --mtp is missing 2026-11-02 19:21:24 +01:00
Niko1221 770a9b8dc8 Split engine as the automatic default really works on a fresh install: Auto that picks the split fetches the MTP draft layer, and a missing draft layer is a quiet layer split
The split runs the model's draft layer (--mtp) and exits at start without it ("sfnx: --native and --mtp are required"). The plan and the fetch for Auto
resolved to DFlash2 (drafter 1.2 GB, no draft layer), so on 2 x sm_86/sm_120 or 4 x gfx1201 the split was chosen at start, failed, and the server fell back
to the layer split after "Using the all-cards split engine on 2 cards".

(B) strata_control/split_engine.expected(): the rule apply_to_config uses, asked before anything is on disk (model assumed when its file is not there yet).
Runner.split_expected / resolved_drafting and drafting_inputs: Auto where the split will take the start is the MTP, so download_plan and the fetch include the
draft layer (5.2 GB) and not DFlash2's drafter, setup gets --mtp on without --drafter dflash2, and the plan says "Drafting: MTP (the all-cards split engine uses
the MTP draft layer, 5.2 GB)". The app's `strata drafting plan/fetch` (drafting_cmd._inputs) follows the same rule. A person's own DFlash2 / off, --split-engine
off, one card, other cards, three cards keep what they had.
(A) Runner.apply_split_engine: when the --mtp folder is missing or empty at start the split is not picked (Auto: silently, the layer split with DFlash2 starts as
the plan said; "on": one line saying to add --drafting mtp). _auto_gates() is the Auto-only part of the rule, shared by apply_to_config and expected().
Tests: tests/terminal/test_split_parallel.py (Terminal and app plan paths, AMD 4 x gfx1201 and 2, one card, sm_89, own DFlash2, off, missing draft layer).
2026-11-02 19:21:07 +01:00
Niko1221 6ca50487ef Mac DeepSeek: the vision tower on Metal (kernels, BF16 GEMM, host side), capability marker STRATA_DS4_METAL_VISION_V1; credit to antirez/ds4 (MIT, 890898cf) 2026-11-02 19:16:42 +01:00
Niko1221 079d7a6244 Merge f-0291-rc1-mac-vision-marker: the Coder's own picture file accepted beside the shards; DeepSeek's Mac pictures follow the installed engine (STRATA_DS4_METAL_VISION_V1) 2026-11-02 19:13:25 +01:00
Niko1221 541eb00c91 DeepSeek's pictures on a Mac follow the installed engine: on exactly when the strata-dsv41 program carries STRATA_DS4_METAL_VISION_V1 (read, never started, cached per path; no engine yet: on, and setup looks again once it is fetched). Setup settles it after the engine step (no marker: the picture file is not fetched, pack and engine text only, with the plain line), the Mac model rows and the install tool ask the same function, the Coder's Mac line and docs/DEEPSEEK_MAC.md follow. The static DSV41_MAC_VISION is gone; other platforms are unchanged 2026-11-02 19:05:35 +01:00
Niko1221 43bc45f893 Merge f-0291-rc1-split-parallel: a parallel override on the split's run config is its lane count, not a conflict 2026-11-02 19:04:58 +01:00
Niko1221 cd35829bb8 Split engine and parallel: no collision on the first 2-card Auto start; --parallel N and Concurrent requests set the split's lanes
The split's run config always carries --batch N. ensure_profile still wrote the launch profile's parallel=<card fit> (4 on 32 GB or more) beside
it, and strata serve --profile (cli._effective) refuses a parallel override next to --batch: every 2-card NVIDIA start with the split on Auto
stopped with "parallel override conflicts with --batch/--slots in run config args" before the engine ran. Same for the AMD four-card default.

- runner.ensure_profile: no parallel override when the run config already names its slots (run_config_owns_slots)
- runner.apply_split_engine: the person's --parallel N is the split's lane count (cfg["parallel"] for the rule; taken out again on a refusal)
- cli._effective: a parallel override on the split's run config (split_engine_note) is not a conflict (the app's Concurrent requests); other configs keep the check
- server.parallel_args: "parallel" on the split's run config sets its --batch
- tests/terminal/test_split_parallel.py: Terminal path, app path, AMD four-card, lanes from the flag, the check kept for other configs
2026-11-02 19:04:34 +01:00
Niko1221 0e3b06f03f The Coder's own picture file (mmproj-DeepSeek-V4.1-Flash-1017-BF16.gguf, beside the shards or in --model-path) is found as the picture file with its own pin, so setup does not fetch mmproj-BF16.gguf a second time and the plan agrees (DSV41_ALT_NAMES; DF2-STRIX 2026-11-02) 2026-11-02 19:03:29 +01:00
Niko1221 ed619c8341 Merge f-0210-draftsw-split: the layer-split gate lift (DFlash2 alone on several cards) 2026-11-02 18:57:13 +01:00
Niko1221 d040f0b660 Auto and an explicit DFlash2 run DFlash2 alone on a layer split over several cards, with the same arguments as one card: blocked_by no longer counts a layer split (several requests at once and --pipeline-windows still keep the MTP); the all-cards split engine still runs its MTP; comments and help text follow; tests for layer split x CUDA/HIP x choices, split engine, slots 2026-11-02 18:56:47 +01:00
Niko1221 65d0b031d6 Merge f-0291-rc1-coder-hf: Coder HF layout (subdir IQ2_XXS) and the Mac/Strix plan words 2026-11-02 18:30:56 +01:00
Niko1221 50af790125 The Coder's Mac plan words, menu text and docs say what was measured (M5 Max 19-22 tok/s with the published 0.2.9 Metal engine, Ryzen AI Max about 20) instead of 'not run yet' 2026-11-02 18:26:19 +01:00
Niko1221 89515548bd The ISTA-DAS Coder's setup entry names its folder (subdir IQ2_XXS from coder-set.json): --model-path at the Hugging Face layout (IQ2_XXS/ plus the Engram and the picture file at the top) is found by setup's step 5 as the launch plan already found it. Before: the plan said 'used where they are' and step 5 said shard 1 is missing (DF2-STRIX, Strix, 0.2.10 Terminal) 2026-11-02 18:26:19 +01:00
Niko1221 5514024c47 Terminal Drafting step: Auto overrides a choice the app saved in the model's file (written as auto, not dropped); tests lock in one drafter per run for every choice on cuda/hip/metal/sycl, old saved values, and the server and Terminal paths 2026-11-02 18:26:17 +01:00
Niko1221 9bc2a65c0a Merge f-0291-rc1-dl-plan: the launch plan counts the shared DeepSeek files setup still fetches 2026-11-02 18:12:03 +01:00
Niko1221 b1043775d9 DeepSeek with your own shards: the launch plan counts the shared files setup still fetches (the Engram, the DSpark drafter, the picture file), with the same search setup makes (setup.dsv41_shared_missing). Before: the plan said 'Nothing to download' and setup then fetched the 8 GB drafter, even with --dspark off (found on Strix, 2026-11-02; the drafter is part of the model's prepared data, so --dspark off does not skip it) 2026-11-02 18:09:02 +01:00
Niko1221 c75ab76550 HIP package: SPLIT_DIR ships the strata-split-N-amd binaries beside strata 2026-11-02 17:05:08 +01:00
Niko1221 95901fe8a4 Merge f-0291-split-hip e3563ff3d: AMD split final candidate (batched draft layer, RCCL sums, strata-split-2/3/4-amd) 2026-11-02 17:03:21 +01:00
Niko1221 e3563ff3dd split HIP: the RCCL default stays quiet when the peer push is off 2026-11-02 16:46:12 +01:00
Niko1221 d889208a0e split HIP: batched draft layer and RCCL prompt sums on by default (AMD); NVIDIA unchanged 2026-11-02 16:45:58 +01:00
Niko1221 8dd8f11a81 split: FNX_ADMIT_INTERLEAVE default off (no measured gain) 2026-11-02 16:24:46 +01:00
Niko1221 9ecdfa0df7 split: batched draft layer covers sampled lanes and draft penalties (per-lane penalize_row and candidates) 2026-11-02 16:23:05 +01:00
Niko1221 7ccfdb833c split: FNX_BATCH_DRAFT=1 (screen): the draft layer's steps of all the lanes in one window (Engine::draft_lanes, BatchDecoder::draft_batched) 2026-11-02 16:17:59 +01:00
Niko1221 dc54f94ad9 split HIP: FNX_PRECAP=1 and FNX_CAPTURE_AFTER=2 are the defaults on AMD (NVIDIA unchanged) 2026-11-02 16:12:45 +01:00
Niko1221 c3c76f0ad0 Merge f-0291-spill: fit_start for discrete AMD cards (Python only) 2026-11-02 15:54:47 +01:00
Niko1221 076a195b79 fit_start: discrete HIP cards get the window walk, with a base measured on an R9700
fit_start treats a discrete AMD card (backend hip; an APU gives no card memory and is left alone) like CUDA: the KV window walk, the working-cache threshold and
single requests priced from a measured base. The HIP base is 3900 MiB, measured on an R9700 (gfx1201) held by a memory hog: the engine's free VRAM before the expert cache is
the squeeze minus 5045 MiB from 7000 to 5200 MiB (CUDA: minus 6324), so a HIP start holds about 1.28 GiB less. DFlash2 alone at 128K with setup's window of 32768:
7942 MiB 787 slots 864 MiB free 48-52 tok/s; 7000 439 slots; 6400 273 slots (loan keeps 16); 5800 head on the host (31 tok/s); 5200 and 4600 refused with the plain line.
An 8 GB AMD card therefore starts as set up; a 6 GB one walks the window.
2026-11-02 15:54:12 +01:00
Niko1221 06698c6746 DS Coder points at the official repository ISTA-DASLab/DeepSeek-V4.1-Flash-1017-GSQ-RCO-Coder-GGUF, revision 0b1d5da5, every file's size and LFS sha256 from the Hub: five shards in IQ2_XXS/, the Engram at the root is the shared Engram (same name, size and SHA-256 as the unpruned release's converted one, used where it is, on the SSD), the repository's mmproj is accepted beside the shared one; tests pin the Engram rule 2026-11-02 15:40:48 +01:00
Niko1221 7b57fbaacd split: FNX_CAPTURE_AFTER=N (N >= 2, default 0 = off): a batched window whose layout has no graph runs eagerly (kernels launched one by one, its own lane tables, no capture) until the layout has been seen N times, then it is captured; counters for held, captured and eager windows (by rows too) in FNX_BATCH_STATS 2026-11-02 15:32:14 +01:00
Niko1221 09c76c323d split: FNX_PRECAP=1 keeps the batched window graphs by layout (up to FNX_PRECAP_GRAPHS=64, least recently used out) and pre-captures the uniform layouts at the start, charges the planner the measured capture cost; capture counters in the batch stats (FNX_BATCH_STATS) and FNX_PRECAP_TRACE 2026-11-02 15:32:14 +01:00
Niko1221 9d03dba69b Spill gate: the output head's host fallback (native_head.cpp) passes the Metal build flag too, so it is refused on Metal like the rest of the spill logic (it asked spill_allowed(false, integrated) and the Metal shim reports integrated 0) 2026-11-02 15:31:31 +01:00
Niko1221 3096e0d0af split: FNX_BATCH_STATS window-cycle phases (between windows, verify, draft) 2026-11-02 15:24:02 +01:00
Niko1221 9d1ee9b191 split HIP: auto on AMD only for the UD-Q4 expert family (IQ3_S set stays on the layer split; on still runs it) 2026-11-02 15:22:21 +01:00
Niko1221 7bf446b528 rc1 merge spill (2e7db84bc) 2026-11-02 15:19:15 +01:00
Niko1221 df8c28e879 pin_coder.py reads one folder level of a repository (the official Coder repository keeps its model shards in IQ2_XXS/ and the Engram and picture file at the root): subdir, engram_path, mmproj_path 2026-11-02 15:19:14 +01:00
Niko1221 2e7db84bc0 Spill gated to discrete GPUs; KV window floor 4096 walked by fit_start; output head to pinned host memory as the last step
The draft-layer drop, the 16-slot prompt-path loan, the own-buffer fallback and the head-to-host move run only on a discrete GPU (strata::spill_allowed:
not Metal, not an integrated GPU such as Strix Halo), with a CHECK for the gate in arch_defaults_test. qsa_kv_resident_min is 4096 (20480 on a Metal build;
STRATA_KV_RESIDENT_MIN overrides): windows of 4096 and 8192 gave the same tokens as 20480 with the cache fixed. fit_start walks the window 20480 / 8192 / 4096 only as
far as the card needs. When every step leaves the cache no slot, the output head (Q6_K, 0.41 GiB) moves to mapped pinned host memory (about 3x slower decode).
Measured on the 8 GB mimic at 128K: DFlash2 alone 391 MiB free, 63-70 tok/s, 32K prompt 19.8 s; MTP asked: layer dropped, 555 MiB free, 61-71 tok/s, 32K prompt 13.4 s;
2 requests 277 MiB free, 46 tok/s per user; 4 requests 171 MiB free, 15 tok/s per user. 12 GB cells unchanged.
2026-11-02 15:17:02 +01:00
Niko1221 a4b4e06fa7 split HIP: auto on four cards only (AUTO_SPLIT_AMD_CARD_COUNTS = (4,)); on still runs 2 and 3 2026-11-02 15:12:40 +01:00
Niko1221 f31abf7bd2 Version 0.2.10 / engine 0.1.56 follow-through: test_tui2_023_versions and AGENTS.md follow the bump; the '0.2.9.1' tags that meant this release now read 0.2.10; the test comment names 0.2.9.1 as the unpublished DeepSeek hotfix label 2026-11-02 14:54:37 +01:00
Niko1221 aa471de5d6 rc1 merge version bump: product 0.2.10, engine 0.1.56 (be7d719e0 on 72a0cc1d5) 2026-11-02 14:53:54 +01:00
Niko1221 be7d719e0a Product version 0.2.10 (option B): strata_cli, apps/host, apps/desktop, Cargo, the version test, the bug-report template 2026-11-02 14:46:13 +01:00
Niko1221 72a0cc1d5e Engine component version 0.1.56 (CMake, Metal, packaging defaults, RAM_KEEP_MIN_ENGINE and its test) 2026-11-02 14:45:39 +01:00
Niko1221 984f977d66 split: FNX_ADMIT_INTERLEAVE defaults on for AMD only (NVIDIA split frozen for 0.2.9) 2026-11-02 14:43:58 +01:00
Niko1221 9666d1e760 split HIP: FNX_HC_GRID and FNX_RING_GRID (screen switches) 2026-11-02 14:42:49 +01:00
Niko1221 da20fc301d split: FNX_ADMIT_INTERLEAVE (default 1): one decode window between two admissions; AMD auto card-count constant; tests 2026-11-02 14:41:28 +01:00
Niko1221 b90ee03815 rc1 merge dl-fast (21d53d981) 2026-11-02 14:20:00 +01:00
Niko1221 6527e33c7c rc1 merge metal-selq4 (371a28ed1) 2026-11-02 14:17:01 +01:00
Niko1221 ecf7424b53 8 GB NVIDIA starts at 128K: the prompt-path loan may leave 16 cache slots, the draft layer is dropped when it cannot fit
A card whose expert cache stays below the 128 slots a prompt-path loan keeps behind used to be squeezed (reserve lowered to 100 MiB free, then the first long
prompt aborted in an mmq allocation) or gave the prompt path 600 MiB of its own buffers and stopped. The loan now leaves 16 slots, the auto lend cap is lifted
for such a card, and every 128-slot floor in the lend, request-time lend and VRAM-shrink paths follows it (g_lend_keep). When the draft layer, the head and a
working cache with room left do not fit in the free VRAM before the layer loads, the start runs without the layer and says so (STRATA_MTP_KEEP=1 keeps it).
An out-of-memory in the layer or head load with --batch names --batch and --max-context. Measured on an 8 GB mimic at 128K with --kv-resident 20480: DFlash2
alone 391 MiB free, 69-73 tok/s, a 32K-token prompt in 19.8 s; MTP asked: layer dropped, 557 MiB free, 62-70 tok/s, 32K prompt in 13.4 s. 12 GB unchanged.
2026-11-02 14:13:19 +01:00
Niko1221 21d53d9812 docs: the hf command as the manual way to fetch model files (DETAILS.md)
A short section next to the Linux setup: what Strata's own downloader does, the measured speeds (anonymous, 16-24 MB/s whatever the method), and the manual route
with `hf download` and `--gguf-dir`, which setup already supports (checks the SHA-256, never deletes a file in your own folder). No code change.
2026-11-02 14:05:10 +01:00
Niko1221 d78aa598c6 test_drafting_selector_q4: the corruption test lets the file clock move on (Linux ticks in ~4 ms steps, a same-size rewrite right away kept the marker's mtime_ns) 2026-11-02 14:02:29 +01:00
Niko1221 58169905bb rc1 merge df2-vocab (1063d6fa4) 2026-11-02 13:56:44 +01:00
Niko1221 4a6fd9b0e1 rc1 merge df2-sycl Arc engine-side sizing (42a9c25aa) 2026-11-02 13:51:31 +01:00
Niko1221 42a9c25aa5 SYCL: a start short of VRAM runs slower instead of stopping. Requests at once (--batch N) and a long context no longer end in a refusal on an 8 GB A750: when the expert cache sizing leaves no working cache (or only one sized for a prompt loan that cannot happen), the prompt path's own chunk goes down (to 256), then a reserve that was asked for (to 300 MiB); a context that does not fit in VRAM turns KV streaming on by itself (the largest resident window that fits, 20480 at the least); the person's own --prefill N and --kv-resident N are kept. Only a start with no slot even then is refused, with one plain line saying what is taken and what to lower, and an out-of-memory during the load ends the same way. short_cache.hpp holds the arithmetic, short_cache_test (CPU) the A750 figures from the engine logs 2026-11-02 13:50:34 +01:00
Niko1221 1063d6fa48 DFlash2-only installs use the draft-vocabulary subset the person chose in setup: the choice (en / fr / cyrillic) is copied beside the DFlash2 pack, refreshed when it changes and on update and start, kept by the drafter's fetch; tests for DFlash2-only and MTP-folder installs 2026-11-02 13:37:26 +01:00
Niko1221 6467ec76aa cuda_spill_guard table: gr_down_v3_kernel on sm_120 (8 B, df2-more's register-blocked instance) and DFlash2's rope_table_inv_kernel (32 B) measured on l620 2026-11-02 13:28:35 +01:00
Niko1221 892cdbad7f rc1 merge df2-strix-pf (b1d9ebb4c) 2026-11-02 13:27:17 +01:00
Niko1221 9ed67ba318 split HIP: FNX_PF_RCCL_F32=1 (prompt sums in f32 through RCCL, screen) 2026-11-02 13:22:33 +01:00
Niko1221 7a6e77a625 fit_start never changes --max-context or the requests at once: it moves work off the GPU
The context ladder, the fewer-requests step and the one-request-at-a-time fallback of the earlier fit are gone. A start short of VRAM keeps its
context and its number of requests and is moved off the GPU by the backend's knobs (CUDA: the KV window down to the engine's 20480; Arc: the prompt
chunk, then the reserve, then the window), with one plain note. CUDA single requests are priced from the measured base per drafter (DFlash2 alone,
DFlash2 with the MTP layer, MTP, none). Measured on the 8 GB mimic: DFlash2 alone starts at 128K with --kv-resident 20480 and not with 32768.
2026-11-02 13:21:43 +01:00
Niko1221 418982072b tests: AMD architecture sources pinned (self-test before load, code objects, package script, issue template) 2026-11-02 13:06:04 +01:00
Niko1221 e3ca02ec09 split HIP CMake: STRATA_SPLIT_HIP_ARCHS names the split's code objects (gfx11-generic is not in the main backend's arch list); amd_split_report issue template 2026-11-02 13:02:58 +01:00
Niko1221 a0247cdee8 split HIP: gfx11-generic code object for RDNA3 (self-test passes on a 780M), self-test tolerance for the WMMA accumulate, p2006 self-test scripts 2026-11-02 13:01:04 +01:00
Niko1221 6dd725980e model_pins guard: the credit link to the community DFlash2 GGUF in a comment and a docstring is not a model host the runtime reaches 2026-11-02 12:55:28 +01:00
Niko1221 837909fdd1 test_home_hermetic: raw docstring 2026-11-02 12:49:36 +01:00
Niko1221 f3a43da42a rc1 merge fit8 (fd818c7bd) 2026-11-02 12:49:29 +01:00
Niko1221 e4a68ec6fe Launch tests are hermetic on the per-user settings: Home() sets APPDATA and XDG_CONFIG_HOME to its temp folder and drops a developer's STRATA_SETTINGS_FILE / STRATA_CONNECTION_DIR; test_home_hermetic.py fails if a settings, profile or connection location is outside it 2026-11-02 12:47:04 +01:00
Niko1221 fd818c7bd2 fit_start: CUDA Flash-Next request-slot figures (8 GB mimic), fewer requests before one at a time, plain hint on a slot-session OOM
BASE_SLOTTED_MIB gains (cuda, flash-next) 4990 from the 12 GB and 8 GB CUDA cells; a CUDA start with request slots needs a working cache (263 slots) not the one-slot refusal limit; the one-request fallback keeps the largest context a single request runs (16K on 8 GB); plan() tries fewer requests (down to 2) before one at a time; the draft-layer and head loads name --batch/--max-context when they run out of memory.
2026-11-02 12:45:08 +01:00
Niko1221 6cafbe2c74 Split: ATTN_DMA stays off by default on HIP (FNX_PFX_ATTN_DMA=1 opts in), on by default on NVIDIA as in e074b8e53 2026-11-02 12:41:58 +01:00
Niko1221 e52ae14ab0 rc1 merge split up to e074b8e53 (ATTN_DMA) 2026-11-02 12:41:33 +01:00
Niko1221 5f82379cf6 NVIDIA split engine compiles again after split-hip: FNX_HIP is defined only by the HIP build (prefill.cu used it as an expression) and k_pfr_cmp read host memory with HIP intrinsics (NVIDIA uses __ldcv) 2026-11-02 12:39:13 +01:00
Niko1221 83cadd32fe split HIP: gfx11/gfx12 WMMA paths, one binary with code objects for gfx1100-1103 gfx1200 gfx1201, kernel self-test on cards not run (hip/selftest*), exe lookup strata-split-N-amd, experimental AMD only on explicit on, packaging for N=2,3,4 2026-11-02 12:39:12 +01:00
Niko1221 950038a0a8 NVIDIA split engine compiles again after split-hip: FNX_HIP is defined only by the HIP build (prefill.cu used it as an expression) and k_pfr_cmp read host memory with HIP intrinsics (NVIDIA uses __ldcv) 2026-11-02 12:31:38 +01:00
Niko1221 b0bbba5f8b packaging: build_split_hip.sh builds N = 2, 3, 4 2026-11-02 12:27:12 +01:00
Niko1221 202bb94ae6 split HIP: AMD gfx1201 starts the split by default (AUTO_SPLIT_AMD_ARCHS), off by key, preset or STRATA_SPLIT_ENGINE=0; tests and docs 2026-11-02 12:27:01 +01:00
Niko1221 3227bd2187 rc1 merge split-hip (2c1771dce): AMD split, opt-in 2026-11-02 12:23:11 +01:00
Niko1221 09859d0bfe rc1 merge df2-sycl tip (8cb6e82de: vram_fit calibration) 2026-11-02 12:19:54 +01:00
Niko1221 8cb6e82dea vram_fit: a streamed slot session at 128K is 748 MiB, as DF2-MEM measured on the CUDA engine (0.73 GiB with 32768 resident; 0.56 GiB at 32K): the per-cell extra a streamed session keeps is 1400 bytes, not the 380 derived from the code 2026-11-02 12:19:36 +01:00
Niko1221 4d27740022 rc1 merge df2-sycl vram_fit fix (e4be9e50c) 2026-11-02 12:19:13 +01:00
Niko1221 a47e5634ee Node tests that read source follow the q4sel code: the capability line carries dflash2Selq4, and the version list's cache uses fitDrafter 2026-11-02 12:18:47 +01:00
Niko1221 e4be9e50c1 vram_fit: request slots no longer lower a 12 GB card's 128K. A slot's session counts only the KV the engine keeps in VRAM (--kv-resident N: max(N, 20480) cells, not the whole context: 621 MiB instead of 1930 at 128K with 32768 resident), the threshold is the engine's own minimum (the cache keeps a slot) instead of 128 slots, and a card with no measured base for request slots (CUDA / HIP: only the Arc A750's Flash-Next base is measured) is left to the engine as before 2a1042877. The 8 GB Arc that the engine refused (--batch 2 + MTP, 1024 MiB reserve, 8192) still gets a smaller context. Tests for both 2026-11-02 12:18:45 +01:00
Niko1221 523b78e215 SYCL links again after df2-q4sel: df2_select takes the new cb_q4 argument of the shared header and refuses Q4_0 selector codebooks (SYCL stays on the bf16 pack) 2026-11-02 12:15:05 +01:00
Niko1221 2c1771dce2 Merge f-0291-split (up to ea2aed12c: auto/on/off switch, drafting fix, i-quant sets, PF_JOB) into f-0291-split-hip; AMD opt-in rule (AUTO_SPLIT_AMD_ARCHS), strata-split-N-gfx1201 lookup, packaging/build_split_hip.sh, AMD tests 2026-11-02 12:13:42 +01:00
Niko1221 db4b445ba1 rc1 merge df2-sycl vram_fit slot-session term (2a1042877): serve/server.py engine_args conflicted; kept the announce gate of the drafting line (gui-fb3/q4sel) and added fit_start, whose note prints only when announce is on 2026-11-02 12:05:06 +01:00
Niko1221 3dbd3b0538 rc1 merge df2-q4sel pack-aware VRAM (d4c8ca1f6) 2026-11-02 12:04:40 +01:00
Niko1221 d4c8ca1f65 DFlash2 graphics memory follows the pack in use: 0.72 GB (715 MiB) with the Q4_0 selector, 0.89 GB (890 MiB) otherwise; a SYCL build says it cannot read the Q4_0 selector
drafting.DRAFTER_MIB gains dflash2_selq4 (715); drafting.vram_gb(data_dir, engine) gives the true figure (an engine program that carries 'selector codebooks: Q4_0 rows' and a Q4_0 pack that setup / Load converts), strata drafting plan reports vram_gb and selq4 and its text says the same number, Control passes capabilities.dflash2Selq4 to the page and the fit (apps/host/src/lib/native-fit.ts DFLASH2_SELQ4_VRAM_GB, apps/host/server/native-parallel.mjs) uses it; apps/host/tests/drafting.test.mjs reads DRAFTER_MIB from drafting.py and fails when a copy differs.

Marker per backend: CUDA and HIP builds carry the text (src/core/dflash2.cpp); the SYCL drafter (sycl/src/core/dflash2.cpp, bf16 select kernel) does not read Q4_0 rows, its built binary (P1006 rc1sycl) has no such text so it stays on the bf16 pack, and its loader now refuses a Q4_0 codebook with a plain message instead of tiling it as a matrix. Metal: DF2-METAL's f-0291-metal-selq4. The idea of the Q4_0 selector is nazeshinjite's (nazeshinjite/Qwen3.8-Flash-Next-DFlash2-GGUF).
2026-11-02 12:03:30 +01:00
Niko1221 242549e432 split-hip: line endings back to LF in the files my edits had turned to CRLF (diff against f-0291-split is now the real change) 2026-11-02 12:00:59 +01:00
Niko1221 2a1042877d vram_fit: request slots ("Concurrent requests" 2 or more) take their sessions out of the expert cache's room. Each slot is a session the size of the main one on every backend (the engine's 0.23 GiB at 8K int8); the Flash-Next figures measured on the 8 GB A750 (base 4500 MiB, 2.22 MiB per cache slot) are used for these starts only. A start the engine would refuse gets a smaller context (32K, 16K, 8K, 4K, 2K) for that start, or runs one request at a time, with the fit note; the person's own --max-context is kept, and one-card-less, layer-split, DeepSeek and Metal starts are untouched (fit_start, called from serve/server.py engine_args). Tests for an 8 GB Arc at 8K with 2 requests, a 24 GB CUDA card, an explicit context. verify_refusal_test is registered with add_test 2026-11-02 11:59:43 +01:00
Niko1221 371a28ed11 metal DFlash2 reads the Q4_0 selector codebooks (df2_select_q4; the pack loader keeps them raw rows) and prints 'selector codebooks: Q4_0 rows', the text strata_control/drafting.py looks for in an engine before it uses the converted pack; STRATA_DF2_HEAD=native runs the head over the target's whole head and ignores the draft vocabulary 2026-11-02 11:56:44 +01:00
Niko1221 296e6780fa split HIP: RCCL self-test kernels static (two files include the header) 2026-11-02 11:56:17 +01:00
Niko1221 f93c5019f9 rc1 merge df2-q4sel (ebac5686f, incl. df2-comm select kernel) 2026-11-02 11:56:06 +01:00
Niko1221 e074b8e530 split: the attention partials are copied into device memory by the copy engine by default (FNX_PFX_ATTN_DMA=0 turns it off)
IQ3_S on 2 RTX A6000, 32K and 62.9K prompts, four pairs against the same build without it: 3.3 to 5.6% faster. Only the memory path changes: the merge reads
device memory instead of the mapped host buffer, so the logits are unchanged (checked on the 53-prompt set).
2026-11-02 11:55:39 +01:00
Niko1221 a134650e27 rc1 merge df2-sycl Arc start-crash fix (ab7baa5b1) 2026-11-02 11:52:35 +01:00
Niko1221 71120303b1 split HIP: peer push on by default (probe and host fallback stay); RCCL for the prompt sums behind FNX_PF_RCCL (dlopen, self-test, one-line fallback) with a policy test 2026-11-02 11:51:56 +01:00
Niko1221 ab7baa5b17 SYCL: a start refused for lack of VRAM ends with one plain line and a clean exit. A verifier whose init stopped before it made its own queues holds the process's default queue as cs_, sh_cs_ and copy_, and its destructor destroyed that queue; the next verifier's wait died in urQueueFinish (SIGSEGV on an 8 GB A750 with --batch 2 + MTP, where the expert cache gets 0 slots and the verify window refuses to start). The destructor now leaves the default queue alone. The refusal names what is taken and what to lower (--batch / parallel, --max-context, --vram-reserve-mib). verify_refusal_test: two refused verifiers, then the default queue must still run 2026-11-02 11:48:45 +01:00
Niko1221 ebac5686f9 DFlash2 pack with the Q4_0 selector, made on the person's computer at setup / Load from the checked Q4_0 pack and used when it verifies
The selector's two codebooks as Q4_0 rows take 174 MiB (about 88 expert slots) less graphics memory than bf16, with the same drafts: the committed text was byte-identical to the bf16 pack's in 40 of 40 requests (greedy and sampled, everything resident), draft time and acceptance equal, and ahead 1.7-2.2% where the cache is short (RESULTS-DF2MEM.md section 15).

The idea and the bytes are nazeshinjite's: the community GGUF nazeshinjite/Qwen3.8-Flash-Next-DFlash2-GGUF (Apache-2.0) stores the two tables as Q4_0, and tools/dflash2_pack.py --q4_0-selector / --convert-selector writes exactly those bytes (selector_predecessor sha256 55f70049...ffd28, selector_successor 3b596669...a72d2 over the tensor bytes, equal to the GGUF's).

drafting_fetch.convert_selector (called by setup, strata drafting fetch and Load, never by a start or a serve; no network; about 120 MB of memory) checks the Q4_0 pack against PACK_SHA256, converts it in-process and checks the result against CONVERTED_BYTES / CONVERTED_SHA256 (339694592 bytes, 4a42b982...3ecd); anything else is deleted and the bf16-selector pack keeps running. drafting.pack_path returns the converted pack only when it verifies (a marker beside it keeps the hash check off the start path) and the engine program carries the text 'selector codebooks: Q4_0 rows'; a missing, short or corrupt file, an engine without the text or an engine that is not known gets the Q4_0 pack. Includes f-0291-df2-comm (Q4_0 rows in the select kernel, STRATA_DF2_HEAD=native) merged into rc1.
2026-11-02 11:46:16 +01:00
Niko1221 f32c3c4614 rc1 merge df2-sycl slot-penalty port (e8a6afd5f) 2026-11-02 11:40:57 +01:00
Niko1221 4043138e1e split: FNX_PFX_ATTN_DMA=1 (screen): the other cards' attention partials are copied into device memory by the copy engine as their flags arrive, so the merge kernel reads device memory instead of the mapped host buffer (off by default) 2026-11-02 11:39:22 +01:00
Niko1221 f4561cbe02 Merge f-0291-df2-comm (Q4_0 selector codebooks in the select kernel and the packer, STRATA_DF2_HEAD=native) into rc1; the engine says 'selector codebooks: Q4_0 rows' when it loads such a pack 2026-11-02 11:38:53 +01:00
Niko1221 52e791e097 rc1 merge df2-unsloth (d55138e95) 2026-11-02 11:38:53 +01:00
Niko1221 d55138e95a Auto picks DFlash2 for Unsloth's quantizations of Qwen3.8-Flash-Next
DF2-METAL found that SUPPORTED_FAMILIES and the model-name pattern left Unsloth out, so UD-Q4_K_XL and UD-IQ4_XS (the same Qwen3.8-Flash-Next weights, and the case the
DFlash2 numbers on Strix, R9700 and M5 Max were measured on) started the MTP everywhere.
- strata_control/drafting.py: "unsloth" is a supported family; the name pattern also matches qwen3.8-flash-next-unsloth-<model> (what setup.py and metal/setup_mac.py write:
  f"{family name}-{model.lower()}"). Swift (UkisAI's fine-tune), Coder (ISTA-DASLab's coding version) and NVFP4 stay on the MTP. The stale "Unsloth" in the "another model"
  comment is gone.
- The app's two copies of the rule follow (apps/host/server/native-settings.mjs, src/lib/drafting.ts): the Drafting dropdown and the memory estimate treat Unsloth like Qwen.
- Tests: Unsloth names on cuda, hip, metal and sycl resolve to DFlash2 alone with the vocabulary; Swift, Coder and NVFP4 names stay MTP with their arguments untouched; the names
  are read from setup.py and metal/setup_mac.py; the terminal flow gets the Drafting step for Unsloth; tests/drafting.test.mjs.
2026-11-02 11:28:06 +01:00
Niko1221 a2123a73a0 split-hip: fix the log lines of the peer decision 2026-11-02 11:21:18 +01:00
Niko1221 bbfa7c85cf split-hip: FNX_PEER (AMD): peer push of the window sums and the attention merge into the other cards' device memory, behind a start probe of every directed pair (stores, full read-back compare, flag round trips) with the host exchange as the fallback for the whole run; FNX_PEER=0 forces the host exchange; CPU policy test; NoPeerAccess now allows HIP peer calls in the one probed file only 2026-11-02 11:19:35 +01:00
Niko1221 b1d9ebb4c2 gfx1151 Windows: keep the 16,384-token prompt chunk; do not step it down by the unified pool's "free" figure (STRATA_PF_OWN_CHECK=1 restores the check)
On a Strix Halo the prompt path keeps its own buffers at the 16,384-token chunk (prefill_own_keep_chunk) and the cache sizing already reserved them. The
step-down before the prompt path init read cudaMemGetInfo after the pinned RAM complement (which the unified pool counts twice): 860 MiB "free" in the MTP
engine (no chunk fits, the chunk stays, prompts read at 955 tok/s) but 1.7 GiB in the DFlash2-alone engine (no MTP layer, a slightly larger cache), where
1,536 fit: chunk cut to 1,536, prompts at 303 tok/s (UD-Q4_K_XL, 30K prompt 98.9 s against 31.4 s; 8K 28.3 s against 10.8 s). A real allocation failure still
steps the chunk down (the init loop). Only reached when prefill_own_keep_chunk is set: Windows HIP gfx1151 with a bare --prefill auto.
2026-11-02 11:16:47 +01:00
Niko1221 3342e06444 rc1 merge rc1-metal (d0bc95ec5): draft-vocab resolver 2026-11-02 11:13:22 +01:00
Niko1221 ea2aed12cd split: build option STRATA_SPLIT_PF_JOB (64 default, 128): rows of one expert per prompt-path job, so a weight tile is dequantized once for twice the tokens
Default unchanged (64). The expert prompt kernel's token-tile count follows PF_JOB (it was fixed at four).
2026-11-02 11:13:09 +01:00
Niko1221 568127b7a0 Terminal Overview: the ASCII look has no U+00B7 (a redirected stdout in cp852, cp874, cp932 or ascii raised UnicodeEncodeError); test_speed_average decodes node's output as UTF-8 instead of the locale code page 2026-11-02 11:13:00 +01:00
Niko1221 c64702b724 rc1 merge gui-fb3 (6acd50efe) 2026-11-02 11:07:44 +01:00
Niko1221 33bbd39084 Golden setup test: the vision reader's thread count is mocked (6, the 12-thread baseline PC), so the baseline no longer depends on the runner's CPU count (it failed 10 cases x2 on any Linux runner with more than 12 threads, v0.2.9 included) 2026-11-02 11:07:43 +01:00
Niko1221 d0bc95ec5e DFlash2 alone on Metal starts again without <mtp>/draft_vocab.bin: the packaged default subset (data/draft_vocab.bin) is used, as before the merge; CUDA, HIP and SYCL keep the error; a file named with --mtp-draft-vocab that is missing is still an error (draft_vocab_test) 2026-11-02 11:06:29 +01:00
Niko1221 6acd50efe4 GUI-FB3 item 4: the speed cards show the run average when idle
The Monitor tab (serve/web/app.js), the API monitor page (monitor.js, monitor.html) and the Terminal Overview (strata_cli/overview.py) show the token-weighted
average of the run instead of the last request's speed when the server is idle; live values are unchanged. Ported by hand onto rc1 from the community patch
by keinebenutzer: https://github.com/keinebenutzer/STRATA-average-patch (MIT, Copyright (c) 2026 keinebenutzer), made against v0.1.55 c62d5e3a. Read in full
(no network, telemetry, eval or markup in what it adds); none of its files was run.
Tests: serve/test_speed_average.py (the page code run in node, display-only guard), tests/terminal/test_overview.py (edge cases, fallback).
2026-11-02 11:00:20 +01:00
Niko1221 abd9b4581d rc1 merge split switch (15fb6ffe3): strata_cli/launch/cmd.py conflicted at --drafting help text; df2-primary's help kept, SFNX's --split-engine argument kept 2026-11-02 10:59:45 +01:00
Niko1221 7d4fcefe98 Setup golden baseline re-recorded for the approved 128K default: only --max-context, --kv-resident and --resident-budget-gib moved (12 of 25 configs) 2026-11-02 10:59:20 +01:00
Niko1221 15fb6ffe3f split: Auto drafting on a setup the split accepts runs the split with its MTP; DFlash2 or off chosen by the person keeps the layer split
split_config writes "drafting": "mtp" (what the split engine runs), so serve/server.py's drafter step, which resolves Auto to DFlash2 where the
engine has it, cannot add DFlash2 flags to the split engine's arguments; the fallback keeps the person's own drafting word (or none: Auto), so the
layer split that starts instead gets DFlash2 as usual. The rule refuses (quietly in auto) for an explicit dflash2 or off, and for Auto on a card
where the split has not been measured to decode faster than the layer split with DFlash2. Tests: all four cases, the card rule, the server's
drafter step on a split config.
2026-11-02 10:53:28 +01:00
Niko1221 e8a6afd5f6 SYCL: --batch slots honour the request's repeat / frequency / presence penalties: each penalised slot's reply tail is staged before its window (set_slot_history), sample_rows builds the per-row history with slot_penalty_rows (the CUDA/HIP/Metal logic of 2678c9732), pen_from names where a continued request's reply began; a plain greedy slot still keeps the window's argmax token. logit_bias stays refused on SYCL. slot_history_test (CPU) added to the SYCL targets 2026-11-02 10:48:40 +01:00
Niko1221 4a93c5428c GUI-FB3 items 1 and 2: no Recommended badge in the version list; DFlash2 follows what the installed engine takes
1 The version dropdown no longer shows the "Recommended" tag (the order and the Downloaded tag stay).
2 Gyro-S with Auto sent --mtp-draft-vocab FILE and no --mtp; engine 0.1.55.2 only takes --mtp DIR for the vocabulary and ended with exit code 2.
  (a) strata_control/drafting.py reads the engine program (never starts it) for the start message that says what it takes: the vocabulary file alone, --mtp DIR
      (the MTP layer still not loaded), or no --draft-fallback at all (then no DFlash2 flag is sent). serve/server.py hands the program to the rule.
  (b) An Auto start that picked DFlash2 and ended before it was ready is started once more with the MTP, in one quiet line ("Drafting: MTP (DFlash2 didn't start)",
      nine languages); a DFlash2 the person named fails with the engine's reason.
  (c) tests: tests/terminal/test_drafting.py (new class), serve/test_drafting_retry.py (a fake engine process), version-picker test.
2026-11-02 10:45:18 +01:00
Niko1221 c0380157df Tests that pinned old values follow the merged decisions: native-fit (MTP layer 1.08 GB), tune-outcome (calm running step, GUI feedback 2 item 3), setup tests (128K default context on every card; the picture-file host in strata_control/pictures.py is behind acquisition.require) 2026-11-02 10:45:03 +01:00
Niko1221 ac1965edea split: the all-cards split engine is automatic by default, with an off switch
strata launch --split-engine auto|on|off (settings field, saved in the preset and written to the launch config, so an off survives updates);
STRATA_SPLIT_ENGINE=0/1 for testers. auto (the default) uses the split engine where the rule says yes and says nothing where it says no; on says
why it cannot run; off never. The rule also leaves the layer split for choices the split engine does not honour (DFlash2, custom engine arguments,
replicas, a conversation memory format other than int8). The engine is looked up per card count and architecture (strata-split-N-smCC, then
strata-split-N); sm_120 joins sm_86 as a tested architecture. Tests: modes, auto/on/off, silent refusals, the flag, a saved off.
2026-11-02 10:44:43 +01:00
Niko1221 ec1b3b273b split-hip: FNX_DUMP_FIRST_LOGITS (first-token logits per request, for KL between the engines) 2026-11-02 10:36:45 +01:00
Niko1221 168325418a Metal builds again on the merged DFlash2 line: dflash2.inc follows the drafter header of the ring/mgpu branches (cur_.hist for hist_, bind with head_device, reset/init_ring_log/copy_log_state_from/ring byte counts, plain answers for the batch-slot clone/split calls), and Metal never asks for DFlash2 batch slots 2026-11-02 10:19:23 +01:00
Niko1221 575caededc split: the fallback line carries the engine's own reason and keeps its log; FNX_DUMP_FIRST_LOGITS diagnostic
serve/server.py: when the split engine fails to start, the one line now says why in the engine's words (its 'sfnx: failed:' line) and the
split engine's log is kept beside the normal engine's (it wrote the same file). src/split/src/session.cpp: FNX_DUMP_FIRST_LOGITS=PATH writes
each request's first-token full-vocabulary logits to PATH.<n>, for comparing the prompt path with another engine's.
2026-11-02 10:15:30 +01:00
Niko1221 fe57a0cb52 rc1 merge split (1ba231217) 2026-11-02 09:58:44 +01:00
Niko1221 df642fbf77 rc1 merge pf-small-df2-split (dcad09339): generate.cpp conflicted at the DFlash2 serve on_taps line; kept the layer-split install and added the branch's df2_tap_dump (env-gated diagnostic) to the single-card lambda 2026-11-02 09:58:43 +01:00
Niko1221 ee21447ead rc1 merge df2-mgpu 3e7b8343b + HIP GEMM table (b353ce7d4): generate.cpp conflicted in 2 hunks, both sides kept 2026-11-02 09:58:02 +01:00
Niko1221 3dfb046bf1 rc1 merge df2-metal (a9df88afc): window_rule.hpp and its test (add/add) and generate.cpp (8 hunks) conflicted; mainline draft_fallback/draft_on/lookup_default kept, Metal's defer_df2 call taken 2026-11-02 09:57:22 +01:00
Niko1221 b5abd7792f rc1 merge df2-sycl (903badd90): window_rule.hpp/window_rule_test.cpp add/add (kept the mainline draft_on rule, added SYCL's lookup-skip helpers), drafting.py CONF_STEPS (kept the engine-slot steps, added BACKEND_CONF sycl 0.6), SYCL generate.cpp sets wr.draft_on 2026-11-02 09:56:28 +01:00
Niko1221 f7e24459b7 rc1 merge df2-ring (09ec63b88) 2026-11-02 09:54:45 +01:00
Niko1221 d86c6eeee0 rc1 merge df2-confsteps (6140ce1f3): strata_control/drafting.py and tests/terminal/test_drafting_hardware.py conflicted; confsteps' measured slot arithmetic taken (SLOT_BASE_MIB 3800 pays for DFlash2, no DRAFTER_MIB subtraction, MAX_SLOTS), the gui-fb2 side kept elsewhere 2026-11-02 09:54:45 +01:00
Niko1221 a2f602398f rc1 merge gui-fb2 (35d4781f1): two files conflicted (apps/host/server/native-control.mjs, apps/host/src/components/NativeModels.tsx), resolved at token level; images/visionPrepared take both the DeepSeek tower check and picturesMissing 2026-11-02 09:52:17 +01:00
Niko1221 f81f1326f4 rc1 merge vis-ram (e5de74282) 2026-11-02 09:51:08 +01:00
Niko1221 4fb068a8c1 rc1 merge ds-coder (d6a5a7837) 2026-11-02 09:51:07 +01:00
Niko1221 a1cc285fa1 rc1 merge tui-update (3165c2e8a) 2026-11-02 09:51:07 +01:00
Niko1221 0116b0049a rc1 merge slot-penalty (62e861c99) 2026-11-02 09:51:03 +01:00
Niko1221 299fb68eab rc1 merge arc-uc (512333833) 2026-11-02 09:51:02 +01:00
Niko1221 71e26e68f6 rc1 merge cfg-paths (d0d31a419) 2026-11-02 09:51:02 +01:00
Niko1221 8f9e705c67 rc1 merge ds-rx7000 (d12b0508a) 2026-11-02 09:51:02 +01:00
Niko1221 8394297b26 RAM_KEEP_MIN_ENGINE is 0.1.55.1, the engine version of this update (ram-keep); its test follows 2026-11-02 09:51:02 +01:00
Niko1221 b353ce7d4b DFlash2 Q4_0 GEMM on gfx12: variant 26 (two warps a block, two rows a warp, unroll 1) above 640 rows, 31 (one warp a row, unroll 1) at 640 rows and below; bench variants 36-45; STRATA_DF2_GEMM4=N (N>=10) forces a variant
dflash2_gemm_bench q4 on an R9700 (gfx1201, p620, 8 columns, cold weights, bitwise vs variant 1 in every cell): gate+up 56 -> 44.5 us, down 39 -> 23.4, qkv 26.8 -> 21.4, o 23.6 -> 14.9, fc 61.5 -> 35.2, kv 15.2 -> 10.0, akp/mkp 12.1 -> 6.3, selector 11.1 -> 5.2.
Serve A/B (128K, 8 classes, 3 rounds x 2 reps): drafter 2.5-3.3 -> 2.2-2.8 ms a round, +1.0% tok/s over the old variant (n 6 per class). CLI greedy ids identical across old / new. Other cards keep 17. gfx1151 and gfx1100 compile.
2026-11-02 09:50:36 +01:00
Niko1221 3e7b8343b2 DFlash2 batch slot drafts: on by default only on a layer split (the measured case), one card stays opt-in 2026-11-02 09:50:35 +01:00
Niko1221 80e7c27eb7 rc1 merge kv128 (ee455af7f) 2026-11-02 09:50:22 +01:00
Niko1221 96b29327ff rc1 merge ram-keep (4adc047dc) 2026-11-02 09:50:21 +01:00
Niko1221 79a5ad0429 rc1 merge ple128 (78316c8df) 2026-11-02 09:50:21 +01:00
Niko1221 903badd906 SYCL DFlash2: the drafter's work buffers, K/V rings and rebuild scratch come from one device slab (55 allocations down to 5). On the Arc A-series the host cost of a launch grows with the live allocations; the fresh 4K prompt gap to MTP goes from 4.6 s to about 0.6 s (the prompt context update). Ring log unchanged, ring test: 0 K/V rows differ 2026-11-02 09:48:31 +01:00
Niko1221 1ba2312176 split: refuse a model whose routed experts it has no kernel for at once, in one plain line
Before this, a start with such a file (UD-Q6_K_XL) read 55 GB of embeddings and spent more than 25 minutes loading before it gave up, so the
server's fallback to the normal layer split came very late. The check runs when the file is opened, before anything is read or placed.
2026-11-02 09:29:42 +01:00
Niko1221 db865e9b3d split-hip: the stream flags are one-thread kernels by default (the runtime's native stream memory ops made the prompt path's text vary from start to start with the exchange overlapped: 1 distinct output of 5 fresh starts at 6.5K / 26K / 103K now) 2026-11-02 09:28:01 +01:00
Niko1221 7da32daf97 SYCL, Arc A-series (i915): the ~300 native dense matrices come from first-fit 256 MiB blocks, the emptiest block trimmed to its used size; a launch costs the host time in proportion to the live device allocations there (12 us with none, 131 us with 300). A750, 4K-token prompt 112.6 -> 185.6 tok/s (3 interleaved rounds), decode unchanged, --pcie-frac 0 logits and output identical. STRATA_DENSE_SLAB_MIB overrides, 0 = off; other drivers unchanged 2026-11-02 08:59:43 +01:00
Niko1221 b19ca19ac4 SYCL: STRATA_UC_PROBE also prints the live device allocations, GPU-stamped kernel times and the host launch cost, at nine points of start-up (test only, does nothing when unset) 2026-11-02 08:59:43 +01:00
Niko1221 3f455f9a68 SYCL: test-only in-process kernel timer (STRATA_UC_PROBE) for the uncached-host-memory slowdown on Arc Alchemist 2026-11-02 08:59:42 +01:00
Niko1221 8ec2c8a918 DFlash2: Q4_0 selector codebooks (--q4_0-selector, dtype q4_0 rows in block order, dequantized in the select kernel) and STRATA_DF2_HEAD=native (the draft head runs on the target's own head, no pruned copy) 2026-11-02 08:59:20 +01:00
Niko1221 aaa7a8e494 split: the selection rule accepts the i-quant and Q2_0 expert sets the engine now runs
EXPERT_TYPES gains IQ2_S, IQ3_XXS, IQ3_S, IQ4_XS, IQ4_NL and Q2_0; a set with such experts may have embeddings, shared experts and an output
head in any type the loader converts to Q8_0; the memory estimate counts the Q2_0 to Q4_0 repack twice; the per-layer embedding table type is
checked. Tests for the supported sets (the IQ3_S set, UD-IQ4_XS), the plain refusal lines and the scan.
2026-11-02 08:41:19 +01:00
Niko1221 48d05d0735 split-hip: one-hop decode sum and attention merge for NCARD > 2, per-row batch stats, determinism and handshake test tools 2026-11-02 08:33:10 +01:00
Niko1221 d12b0508a5 Tests for the RX 7000 DeepSeek package: each card gets its family's package, an unnamed card is not guessed, the not-tested line in the Terminal and setup 2026-11-02 08:21:52 +01:00
Niko1221 dcad09339e Merge branch 'f-0291-pf-small-df2' into f-0291-pf-small-df2-split
# Conflicts:
#	src/core/arch_defaults_test.cpp
2026-11-02 08:17:43 +01:00
Niko1221 3eb55adffd A CUDA layer split reads prompts chunk-major by default: the layer-major walk is off unless STRATA_PREFILL_LAYER_MAJOR=1 names it
On 2x RTX A6000 (IQ3_S, MTP, three residency levels) a 32K prompt reads 37-55% sooner chunk-major and an 8K prompt is level (DF2-MGPU, boston); the default had been on for a layer split since 0.2.7 from a 2x A4000 measurement with streamed experts only. Owner's yes 2026-11-02. STRATA_PREFILL_LAYER_MAJOR set by hand still wins both ways; a single card, HIP and the small-card rule (which sets the variable itself) are unchanged.
2026-11-02 08:11:10 +01:00
Niko1221 c14eb72d1c DeepSeek for RX 7000 on Linux: build script for the separate rx7900 DeepSeek package (gfx1100 gfx1101 gfx1102), the not-tested line in the Terminal, the app and setup 2026-11-02 08:11:08 +01:00
Niko1221 62e861c995 A request that continues an earlier leg (prompt + what it generated: solo -> batch slot, slot -> solo) names where its reply began (pen_from), so the penalty window keeps the earlier reply tokens 2026-11-02 08:10:06 +01:00
Niko1221 d0d31a4191 A saved run config follows the update: paths into a removed Control, engine or DeepSeek version folder are pointed at the current one (#4891)
Setup writes absolute paths into the run config (cwd and --expert-profile into the Control bundle that ran it, the picture reader and library folders into
an engine package, the DeepSeek package). An update keeps the new Control/engine and the one before, so the second update after setup removed the folder
the config names: the model showed as not installed and a start failed ("Qwen breaks each time Strata is updated").

strata_control/cfg_paths.py is the one resolver: a string that names a file in components/control/<ver>, components/engine/<ver>-<kind> or
engine-dsv41/<ver>-<kind> whose version folder is gone is mapped to the same file in the active Control / the newest engine of its own kind. Paths the
person typed, packs, models, output paths (--expert-profile-save, log) and folders that still exist are left alone.
- profiles.read_run_config applies it in memory, so the doctor, the model list, the launch plan and the host see the current folders.
- runtime.start writes it once into every saved run config, one line in the host log, keeps a caller's expected source digest valid, and stops with the
  file named when it exists in no current version.
Tests: tests/terminal/test_cfg_paths.py (real update_control twice, real runtime.start for the Terminal and the app, DeepSeek); 9 of 11 fail without the wiring.
2026-11-02 08:08:13 +01:00
Niko1221 b1f4c92961 Arc: DFlash2 alone is the default drafter on the SYCL engine
strata_control/drafting.py: "sycl" joins DFLASH2_BACKENDS, so Auto picks DFlash2 (Q4_0 pack, no MTP layer, --draft-fallback none) on an Intel engine.
BACKEND_CONF = {"sycl": 0.6} replaces the CUDA slot steps for Intel (A750, 298 expert slots, serve sweeps: 0.6 was the best of 0.35 / 0.6 / 0.8); a --draft-conf
of the person's own still wins. An Arc A-series card (A3xx / A5xx / A7xx, told by its Windows adapter name or its Linux PCI id) counts as tested; Arc B-series,
Arc Pro and integrated graphics keep "has not been tested on your graphics card yet".

sycl engine: --mtp-draft-vocab is accepted in place of --mtp for the draft vocabulary (the app passes no --mtp with DFlash2); STRATA_DF2_TIMING also prints the
request-end context update and each prompt context update, STRATA_DF2_NO_PROMPT_CTX (measurement only) skips the prompt's context update.
dflash2_bench: a ctx mode that times the prompt's context update.

Measured on the A750 (IQ3_XXS, --prompt-cache 6, 5 rounds): DFlash2 alone is 2.6% behind MTP in decode tokens per second (95% interval -4.8 to -0.4) and 1.1%
behind over time; a fresh 4K prompt reads 4.6 s slower. Tests: tests/terminal/test_drafting.py, test_drafting_hardware.py, test_issues_022.py (117 pass).
2026-11-02 08:08:04 +01:00
Niko1221 4adc047dc8 Arena under a hard memory limit also needs its headroom: a limit just above the arena no longer kills the engine
On l620 a 49 GiB cgroup limit and the 46.84 GiB arena: the engine's check (arena + blob <= limit) passed, the arena is pinned and never given back, the
engine's own memory (about 3 GiB) came on top, and the cgroup OOM killer ended the engine at the start (journal: "A process of this unit has been
killed by the OOM killer", twice). With the fallback allowed the arena now needs arena + blob + max(RAM to keep, the fallback headroom) under the
hard limit (cgroup, container, job object, --memory-limit-mib), else the RAM copy runs. Without a fallback (hand-set --ple-io ram, shared arena file,
STRATA_ARENA_RAM_FALLBACK=0) the comparison is unchanged. arena_hard_limit_need in ple_residency.hpp, unit cases for a limit just above the arena.
2026-11-02 08:02:49 +01:00
Niko1221 b509b2fef5 Metal: a request's logit bias reached no sampler (set_logit_bias / set_slot_logit_bias returned early because the Metal window leaves the last-stage layer end at -1), and the window's bias buffer stayed at its 16-byte stand-in 2026-11-02 07:47:29 +01:00
Niko1221 78316c8df4 prefill: the batched PLE post-ops default off for an engine built with CUDA 12.8 on a compute capability 12.0 card (one log line); the variable still wins
An engine built with CUDA 12.8.61 faults in the prompt path on an RTX PRO 6000 (compute capability 12.0, driver 580) with the batched PLE post-ops on, which is the
default: "an illegal memory access" at every prompt length from 94 tokens, first error in the native PLE postops launch (CUDA_LAUNCH_BLOCKING), and the isolated
verify_batch_parity test faults on native_ple_postops_batch_snap with 4 tokens. STRATA_PLE_BATCH=0 runs. The same source built with CUDA 13.0 (the released
cuda13 package) runs the default at 94 to 20K tokens, where the batch is ~23% faster prompts, so only the 12.8 toolchain changes: ple_batch_default(cudart_version, hip,
major, minor) is false only for CUDART_VERSION 12080..12089 on cc 12.0 (not HIP, not another card, not another toolkit), ple_batch_resolve lets STRATA_PLE_BATCH win in both
directions, and prefill.cpp says once when it turned the batch off. Known issue #220 / #224; CMake already warned for sm_120 builds with CUDA < 13.0. Unit test in
arch_defaults_test; docs/OLDER_GPUS.md.
2026-11-02 07:39:06 +01:00
Niko1221 d8867ef616 Host: the RAM rule depends on the engine in use: STRATA_RAM_KEEP_GIB for engines that know it, the 0.2.9 rule for older ones
"Leave 20% for my work" reads the version of the engine the Engine tab selected (snapshot engine version, or the folder named in the run config's exe).
An engine of RAM_KEEP_MIN_ENGINE (0.1.56) or newer gets the env STRATA_RAM_KEEP_GIB = clamp(ceil(0.2 x RAM GB), 5, 8). An older engine, or one whose
version cannot be read, gets the rule of 0.2.9 unchanged: --resident-budget-gib from the 80% rule and a STRATA_RESIDENT_HEADROOM_GIB of 20%; neither
is ever given an argument it does not know. Switching engines between loads swaps the rule; full performance removes either. Tests at 24, 32, 64 and 128 GB
for both kinds of engine, with the preference on and off.
2026-11-02 07:34:37 +01:00
Niko1221 35d4781f1c Tests follow the GUI feedback 2 changes: the picture choice rule moved to pictures-choice.ts, the status line is flex, the new catalog is known to plain-errors 2026-11-02 07:32:59 +01:00
Niko1221 53a48dbe5f DeepSeek pack check: the reason is never empty, a pack that no longer fits its files is rebuilt once, and a card the Linux package has no code for is said
HF discussion 30 ("the model's pack is not complete ()", 7900 XTX, files converted by ISTA's script):
- dsv41_pack_info: an engine that prints nothing is reported by how it ended (signal 11, exit code N), never as an empty string.
- The engine's file check is repeated in Python (dsv41_pack_problems); a pack that does not fit the model files (missing or short file, a file replaced
  since the pack was made: pack-source.json size and time, unreadable description) is rebuilt once with one line; when only the engine's own check gave
  no answer the pack is kept, one warning carries the reason and the test question decides. The failure line is one line and names the file (the
  launcher shows only the first [X] line).
- Both layouts whole (what ISTA's converter leaves) use the six-shard files; without --gguf-dir six-shard files in setup's own folder are seen when all five are there.
- The 0.2.9 Linux DeepSeek package holds code for gfx1200/gfx1201 only: hip_archs_from_pci is a third probe for the card; an unnamed card gets one plain line,
  a known card without code is told so (install, install_package), setup warns for its own card pick and reads BUILD.json archs beside a hand-made engine.
Tests: tools/test_setup_dsv41.py (PackCheck, CardsAndLayout, CardGate), tests/terminal/test_dsv41_engine.py (HipCards).
2026-11-02 07:31:16 +01:00
Niko1221 96b009d2ef verify.hpp: set_slot_sampling comment no longer says penalties are skipped in batch windows 2026-11-02 07:26:09 +01:00
Niko1221 541a2fac2d Slot penalties and logit bias in batch windows: shared slot_row_draw for CUDA/HIP/Metal, server accepts logit_bias with --batch, docs updated 2026-11-02 07:15:58 +01:00
Niko1221 e5de74282e AMD: the Qwen on-demand picture reader works on HIP (reader with its CPU reader beside it, one GPU) and releases its VRAM when idle, but is off by default (VISION_ON_DEMAND_AMD_BY_DEFAULT): no shrinking expert cache on HIP, so 7,959 slots against 7,943 for +5.4 s on the first picture; docs 2026-11-02 07:13:18 +01:00
Niko1221 067771d800 Merge branch 'f-0291-metal-sample' into f-0291-slot-penalty 2026-11-02 07:12:54 +01:00
Niko1221 2678c9732e --parallel slots honour the request's repeat / frequency / presence penalties and logit bias (CUDA, HIP): each penalised slot's reply tail is staged before its window (set_slot_history), the verifier builds the per-row history (slot_penalty_rows: the tail plus the slot's tokens in the window up to the row) and draws the row with sample_tokens; the slot's own logit bias array (set_slot_logit_bias); a logit_bias request is no longer refused with --batch; plain greedy slots keep the window's argmax. CPU test slot_history_test 2026-11-02 07:12:53 +01:00
Niko1221 56f93d8ee0 Docs: the choice is now called Leave room for my work 2026-11-02 07:12:20 +01:00
Niko1221 ef7668e596 GUI feedback 2, items 1-8: plain Read pictures, white tick, calm progress card, steady checkbox, ring before the chat name, status line without overlap, compact file list
1 Read pictures is a plain checkbox; no hint, no download button (Load does it); a failed picture download keeps one line under the box.
2 A checkbox tick is white in light mode (dark mode unchanged).
3 The progress card shows the step, the numbers and a thin bar; Stop is a text button; no "buttons wait" sentence; reduced motion honoured.
4 The Read pictures choice is kept per version and no longer re-derived from host reports while a job runs; the reader probe keeps its last answer.
5 No separator under Read pictures.
6 Running chat: an SVG ring before the chat's name.
7 The chat's time and token count are a wrapping flex row without fixed widths.
8 A run with many files is one block: delivered-to folder, working folder, and a count that opens the list.
Also: "Leave 20% for my work" is now "Leave room for my work" (hint: keeps 5-8 GB free), nine languages (gui-<lang>.json).
2026-11-02 07:08:18 +01:00
Niko1221 1bdda21126 Metal --parallel slots honour their request's sampling (temperature, top_p, top_k, min_p, seed; penalties are not applied in batch windows, as on CUDA): each slot's row of a batch window is drawn again from the window's logits with its own params, Philox(seed, position) 2026-11-02 07:04:27 +01:00
Niko1221 d292672844 DFlash2 batch slot drafts on by default (STRATA_BATCH_DF2=0 opts out) where the slots run in one serial group 2026-11-02 06:55:08 +01:00
Niko1221 c683fbf0ef DFlash2: prompt taps in the layer-major prefill (single stage, one prompt); STRATA_DF2_TAP_DUMP research dump
lm_prepare no longer declines for the DFlash2 taps on one stage. lm_run takes a layer's completed output when the next layer starts (the hyper-connection write of its FFN half), puts each sub-chunk's rows at positions >= tap_first_ in the staging, and hands the super-chunk's rows to on_taps in position order when all its layers are done. A layer split and a multi-prompt read still decline. Untested on a GPU at this commit: the equivalence test and the A/B are running.
2026-11-02 06:54:11 +01:00
Niko1221 6140ce1f3b DFlash2 confidence steps from the DFlash2-alone sweep, in the engine's slots (12,000+: 0.35, 7,500+: 0.5, 3,000+: 0.7, below: 0.8)
Measured with no MTP layer loaded against the MTP at equal VRAM (l620 mimic, IQ3_S, chat with a rewind plus four cold prompts, 3 paired rounds): best confidence 0.8 at 1.4K and 3.3K engine slots, 0.65 at 6.6K, 0.5 at 9.2K, 0.5 and 0.35 level at 15.7K; 0.35 loses to the MTP up to 6.6K slots. An explicit --expert-cache N is a budget of largest blobs, about 1.3 N engine slots on IQ3_S, now converted before the steps apply. The slot estimate no longer deducts the drafter from a baseline that already holds the MTP layer, because the DFlash2 is the only drafter.
2026-11-02 06:52:59 +01:00
Niko1221 b49e1ffd6b Host: "Leave 20% for my work" passes the RAM to keep as the env STRATA_RAM_KEEP_GIB, not the flag
An engine older than --ram-keep-gib (0.1.52, 0.1.54, 0.1.55, which a person can choose in the Engine tab) stops with "unknown argument";
it ignores an environment variable. The engine still takes the flag for CLI users (the flag wins over the env). Full performance removes the env;
a STRATA_RAM_KEEP_GIB, --ram-keep-gib or RAM mode of the person wins. New host test: an older engine gets no argument it does not know.
2026-11-02 06:51:33 +01:00
Niko1221 ee108afef5 fused_gr: the shared-memory opt-in of the v3 down kernels is set once per DEVICE, not once per process (a layer split on two sm_120 cards answered 0 tokens)
gr_down_pf_launch and gr_down_rb_launch set cudaFuncAttributeMaxDynamicSharedMemorySize inside a function-local `static const bool once` lambda. The attribute belongs to
the device: only the first card to launch got it, and on the second card of a layer split the launch of a window of 5 or more rows (51-82 KB of dynamic shared memory
against the 48 KB default) was rejected with "invalid argument", which inside the graph capture surfaced as "fused_gr_read_multi v3: operation failed due to a previous error
during capture" and an engine that answered every request with no tokens. Reproduced on 2 x RTX PRO 6000 (CUDA 12.8): the int3 head with the default mask fails at the
6-token window capture; STRATA_GR_V3=0 and one card work. set_attr_once_per_device (one flag per device, under a mutex) replaces both lambdas; no kernel, launch geometry or
arithmetic changes. Every other cudaFuncSetAttribute site of the tree was already per device (listed in phaseA-tests/blackwell/RESULTS-DF2NV.md section 9).
tools/test_func_attr_scope.py (pytest, CPU) fails if cudaFuncSetAttribute / hipFuncSetAttribute appears inside a function-local static lambda or a std::call_once.
2026-11-02 06:47:11 +01:00
Niko1221 b537586e6b One RAM-to-keep-free setting: --ram-keep-gib W covers the arena and the RAM-copy fallback; the app passes it for "Leave 20% for my work"
Engine: --ram-keep-gib W (default 0; STRATA_RAM_KEEP_GIB the same). The expert arena is used only if W GiB stay available after it;
otherwise the RAM copy of the hottest experts leaves max(W, the safety headroom) free, and STRATA_RESIDENT_HEADROOM_GIB still wins when set.
The safety headroom is 8 GiB on Windows (the page trim of the copy needs the room) and 4 GiB elsewhere; with W = 0 nothing else changes.
The decision lives in ple_residency.hpp (ram_keep_plan and its parts) and is covered by ple_residency_test over free RAM x W x arena size.
The explicit modes (--resident-budget-gib, --resident-experts) leave max(W, their headroom).

Host: profile 'work' passes --ram-keep-gib W, W = clamp(ceil(0.2 x RAM GB), 5, 8), instead of the 80% budget and the 20% env headroom; the engine
decides from the RAM free at its start. A flag or env of the person wins; full performance passes nothing and takes the earlier version's settings out.
resource-ram.test.mjs covers 24, 32, 64 and 128 GB with the preference on and off. Docs in DETAILS.md.
2026-11-02 06:44:09 +01:00
Niko1221 fd59c9c39b Small-card prompt path: with one CUDA card, a native pack and a default prompt chunk below 8,192 tokens, prompts read with the unfused experts and, from two chunks, the layer-major walk
The default path on a small expert cache streams every expert again for every 512-5,888-token chunk. On an RTX PRO 5000 held to 1.3K / 1.6K / 1.9K engine slots the unfused experts plus layer-major read a 20K / 32K prompt -44% / -50%, -41% / -42%, -14% / -12% sooner (a real 12 GB 5070: -26% at 20K); from a default chunk of 8,192 (2.3K slots) the default path wins and the change costs +1..+25%, so the rule keys on that chunk (strata::small_card_prompt_path, unit-tested at its boundaries). Output changes at rounding level (first-token KL against the FP16 prompt path 0.012-0.018 for every path including the default, top-1 47 of 48), owner's yes 2026-11-02. STRATA_PF_FUSED / STRATA_PREFILL_LAYER_MAJOR set by hand win, STRATA_PF_SMALL=0 turns the rule off; a layer split, HIP, a non-native pack and STRATA_DEFAULTS=026 keep the default.
2026-11-02 06:40:26 +01:00
Niko1221 4d4a59642b Merge f-0291-df2-primary into f-0291-df2-sycl (primary's side on the duplicated cherry-pick hunks of src/program/generate.cpp) 2026-11-02 06:34:26 +01:00
Niko1221 fc453c3d82 generate.cpp: batch DFlash2 confidence cut: real \0 in the env tests 2026-11-02 06:29:57 +01:00
Niko1221 fd3839172c split: tile exactness test and fnx_run (teacher-forced logits) as optional build targets
split_moe_tile_test runs the prompt kernel's weight tiles on a GPU for one expert of each routed-expert type and tests/split/tile_check.py compares them with gguf-py. split_fnx_run is sFNX's logits tool on the same engine objects.
2026-11-02 06:27:49 +01:00
Niko1221 73a470dafc DFlash2 batch slots: per-slot draft rows cut where the selector's probability product falls below STRATA_BATCH_DF2_CONF (default --draft-conf or 0.35), STRATA_BATCH_DF2_MAXX caps rows 2026-11-02 06:27:03 +01:00
Niko1221 5123338339 SYCL: STRATA_UC_PROBE also prints the live device allocations, GPU-stamped kernel times and the host launch cost, at nine points of start-up (test only, does nothing when unset) 2026-11-02 06:06:07 +01:00
Niko1221 f9e14bdf8e SYCL, Arc A-series (i915): the ~300 native dense matrices come from first-fit 256 MiB blocks, the emptiest block trimmed to its used size; a launch costs the host time in proportion to the live device allocations there (12 us with none, 131 us with 300). A750, 4K-token prompt 112.6 -> 185.6 tok/s (3 interleaved rounds), decode unchanged, --pcie-frac 0 logits and output identical. STRATA_DENSE_SLAB_MIB overrides, 0 = off; other drivers unchanged 2026-11-02 06:06:07 +01:00
Niko1221 90cb0c0f5a DFlash2 serve: the end-of-request context update reads the gathered taps (a layer split's drafter is on another card than stage 0's tap buffer) 2026-11-02 06:03:41 +01:00
Niko1221 09ec63b884 DFlash2: STRATA_DF2_END_UPDATE=0 turns the end-of-request context update off (diagnostics) 2026-11-02 06:03:00 +01:00
Niko1221 7b5d1a38da DF2-SYCL ring test: a conversation that resumes at 0 stashes the old lineage as a branch (the check expected a cold start) 2026-11-02 06:01:41 +01:00
Niko1221 2d5a577037 Merge branch 'f-0291-df2-ring' into f-0291-df2-mgpu 2026-11-02 05:59:43 +01:00
Niko1221 4e7866f91b DFlash2 ring log: off until init_ring_log() has allocated the staging (a clone that skips alloc() copied into a null buffer)
log_on_ defaulted to true and alloc() allocated the pinned staging; a drafter that never ran alloc() (the --parallel slots' clone_alloc) therefore ran the
context update's two D2H copies into a null pointer: an illegal memory access on its first draft. The log is now off by default, init_ring_log() (called
by alloc(), callable by a clone's own allocation on its device) allocates the staging and turns it on, and a drafter without it keeps the pre-log rule.
2026-11-02 05:57:13 +01:00
Niko1221 5a73435898 Metal honours the request's sampling: temperature, top_p, top_k, min_p, repetition / frequency / presence penalties, logit bias and seed on the head rows of every window (Window::sample_rows over lm_, set by Verifier::run through window_set_sampling); greedy requests keep the in-graph argmax 2026-11-02 05:55:39 +01:00
Niko1221 d59ab12fc5 metal: the sampler chain on the GPU (sample.metal samp_part + samp_pick: bias, penalties, top_k, top_p, min_p, temperature, Philox draw at (seed, position)) and sample_chain_parity (greedy == argmax, 1800 rows against the CPU chain with 0 mismatches, determinism, chi-square) 2026-11-02 05:52:27 +01:00
Niko1221 d0dcf65435 generate.cpp: fix two broken string literals in the batch DFlash2 path 2026-11-02 05:42:10 +01:00
Niko1221 29d55bd718 DFlash2 batch slots: deferred context when a round has no spare window row (taps parked on the host, replayed on catch-up), draft graphs of all slots launched then finished together, ring log merged 2026-11-02 05:40:37 +01:00
Niko1221 2f608aac30 Merge f-0291-df2-ring (DFlash2 ring log) into f-0291-df2-mgpu; copy_state_from carries the lineage log 2026-11-02 05:32:32 +01:00
Niko1221 1b25b55444 DF2-SYCL: ring log (port of f-0291-df2-ring): host log of the quantized hidden rows, tag shadow, rebuild and stash, request-end context update, dflash2_ring_test 2026-11-02 05:05:11 +01:00
Niko1221 28db88e56f DF2-SYCL serve: note the batched prompt rows in the drafter's token history (live continuations were taken for a foreign conversation) 2026-11-02 05:00:17 +01:00
Niko1221 e8abcadeef split-hip: chunked GDN prompt kernel in 58 KB of LDS (16 state columns, 128 threads, same sums), DMA receive/gather as an opt-in with its self-check (FNX_PF_DMA=1, FNX_PF_DMA_CHECK=1), batch stats (FNX_BATCH_STATS=1) 2026-11-02 04:57:44 +01:00
Niko1221 a9df88afc9 metal DFlash2: the lineage row log and the ring rebuild for a restored conversation: the half hidden_norm row of every context position is logged (10.6 MiB a lineage, STRATA_DF2_LOG_MIB stashes 4); a request that restores a stashed lineage rebuilds the ring rows of its window with the same projections, bitwise equal to the original update (STRATA_DF2_CHECK_REBUILD=1); the drafter's history is the whole prompt 2026-11-02 04:43:36 +01:00
Niko1221 e04ee998f2 DeepSeek picture tower in RAM between pictures on AMD too (R9700: tower-in-RAM 419 vs 386 experts at a 12 GB-like reserve, same tok/s; lender read the no-room picture right), docs 2026-11-02 04:40:39 +01:00
Niko1221 ac23de4c4d A failed MTP download says one short line; tests 2026-11-02 04:32:18 +01:00
Niko1221 72cf167e3b DFlash2 ring log: update the last window's rows when a request ends, quiet small gaps, copy_log_state_from, stats env
A request that ends on EOS or max_new skips the draft that would have made the last accepted window's context rows, so every turn left a hole of a + 1
positions (5 a turn, accumulating) that a later turn resuming after it found blank and the log could not rebuild. The serve loop now updates those rows
(one synchronous context update, ~0.4 ms) before it finishes the request: a four-turn agent loop plus a parked return now rebuilds with 0 rows missing.
The cold line prints only when 64 or more rows of the window are missing. copy_log_state_from(src) moves a lineage's log, tag shadow and epoch with a request
between two drafters (solo <-> a --parallel slot). STRATA_DF2_RING_STATS=1 prints the live log, the stash and each rebuild per request.
2026-11-02 04:18:30 +01:00
Niko1221 d826b02d5b DeepSeek picture tower: when the card has no free graphics memory for a picture turn, the experts' slots lend the room (the prompt path's own lend: the last slots of device 0's tail region, read back from the model files after the turn), so the picture is read instead of refused. Boston A6000 with 59 MiB free: the picture is answered right, 35 experts lent and handed back in 117 ms (12 GB-like reserve: 31 experts, 79 ms) 2026-11-02 04:11:42 +01:00
Niko1221 b1de678f33 DF2-SYCL: --draft-conf defaults to 0.6 on the Arc build 2026-11-02 04:11:39 +01:00
Niko1221 96f5227763 DFlash2 ring log: rebuild the draft ring's window from logged context rows after a restore, a rewind or a repeated prefix
The draft ring has 2,064 slots (position mod 2064). A request that resumes below the highest position written (a repeated or rewound prefix, a
parked conversation brought back) found the part of its first draft's window [n - 2047, resume) overwritten by later positions or never written,
and the drafter drafted poorly until it refilled: on an RTX PRO 6000 with the prompt cache on, DFlash2 alone was +14% over MTP on cold and live
turns but +3% on restored prompts and -2% on an exact repeat (acceptance 0.63 and 0.59 against 0.79 and 0.70 with the cache off). The repeat case
printed no warning at all, because the token history was equal.

The K/V of the five layers is a function of one row per position, the quantized hidden_norm output (2560 int8 codes and 80 block scales, 2.9 KB;
the K/V is 40 KB and the taps 51 KB). Every context update now copies that row into a chunked, copy-on-write host log of the lineage (a
pinned staging copy inside the captured graph, moved to the log after the stream sync). begin_request rebuilds, with the same kernels on the
same bytes, every slot of the window below `resume` whose tag does not match (a host shadow of the tags says which): the rebuilt K/V is bitwise
the original update's (dflash2_ring_test: rewind, parked, branch, evicted; 578 rows in 4.0 ms, a whole window of 2042 rows in 12.7 ms).
A lineage replaced by another is moved, not copied, into a stash (STRATA_DF2_LOG_MIB, default 256, oldest evicted); a request whose prefix matches a
stashed lineage swaps it back in; a rewind or branch keeps the old branch as a copy-on-write stash entry. STRATA_DF2_RING_LOG=0 restores the old
behaviour. Rows never logged are reported (cold) instead of silently blank.
2026-11-02 03:55:35 +01:00
Niko1221 5074caee06 DF2-SYCL: defaults to the SIMD16 pair GEMM (v41, v40 for small matrices) and the 256-work-item attention at 64-key chunks 2026-11-02 03:40:14 +01:00
Niko1221 6de7c8e315 DFlash2: the MTP chain's cost window and q lists apply only to candidates the MTP drew
With DFlash2 as the drafter the MTP stays loaded as the fallback (use_mtp stays true), and three decisions tested use_mtp where they meant
"the MTP chain drew this round's candidates":
- STRATA_SPEC_COST=1 (a GB10 default): T = clamp(chain_rows, 1, S_mtp), but chain_rows is only written by the MTP chain callback, so with DFlash2
  and no --draft-conf the window stayed at one row (CLI and serve; code_py 175 vs 258 tok/s on an RTX PRO 6000, serve 166-230 vs 283-397).
- STRATA_SPEC_PROB=1, sampled requests: ver.set_spec_q(mtp.spec_q()) judged DFlash2's deterministic candidates against the MTP's stale q lists
  (CLI: 107 of 137 windows), and serve's gated path was not declined for DFlash2.
- the lookup choice read mtp.prob() for the same reason.
spec/window_rule.hpp holds the window rule and the q rule, both loops call it. The default path is unchanged: window_rule_test compares the new
rule with a copy of the old code over 9216 inputs (only the DFlash2 + cost rule + no --draft-conf cases differ). The CLI stats also print the
window histogram for any rule that sets the width and, when STRATA_SPEC_PROB judged windows, their count and source.
2026-11-02 03:36:50 +01:00
Niko1221 1acdf93401 DF2-SYCL: attention chunk 64 (33 chunks), SIMD16 pair Q4_0 GEMM variants 40-43 2026-11-02 03:32:30 +01:00
Niko1221 f3face9402 DF2-SYCL: lookup-first rule (c2b9218c7's window_rule.hpp, CLI and serve loops hand-ported), STRATA_DF2_LOOKUP_SKIP default 0 2026-11-02 03:27:11 +01:00
Niko1221 ee455af7fe Setup streams the KV cache from 64K up on a PC with room for the experts plus 10 GB, not only for the model's all-in-RAM need (KV-128)
The rule asked ram_gb + KV + 1 (64.8 GB for IQ3_S, a 64 GB PC reads 63.1), which left a small card with the KV in VRAM and 720 expert slots at 128K. It is now the smaller of that and arena_gb + LOW_RAM_HEADROOM_GB + KV (62.1 GB for IQ3_S; the KV's RAM comes out of the engine's RAM copy or file cache, not on top). It never asks more than before; --kv-streaming on|off and WSL are unchanged; the size stays 32768.

Measured (phaseA-tests/gh-sweep-1031/KV-128.md): IQ3_S, 12 GB mimic on l620 under a 45 GiB cap, 756 -> 1,267 slots, 32K prompt 21-34 s -> 12-17 s, 118K prompt 76-122 s -> 45-63 s, decode flat to +10%, engine RAM unchanged; on the owner's RTX 5070 (single runs) 924 -> 1,585 slots, 32K prompt 130 -> 79 s, RAM copy 30.8 -> 28.9 GiB, no paging. Decode there is within noise.

Tests: need numbers, never-more-than-before over every model/context/KV, the rule at the edges and for WSL, setup writing --kv-resident on a 63.1 GB PC with a 12 GB card and not at 61.9 GB or at 32K. The golden baseline gets --kv-resident 32768 for the 64 GB / 32 GB card IQ3_S and recommended entries. Setup and tuning tests on boston: same 24 failures as f-0291-int3 (not touched), 7 more pass.
2026-11-02 03:01:20 +01:00
Niko1221 f47df12624 metal DFlash2: the attention floor follows the conversation (kept for a request that goes on from the same history, lowered when a request restarts earlier, set to the resume point for a restarted lineage); it stayed at the last restore's resume point and starved every shorter prompt of context rows 2026-11-02 02:52:21 +01:00
Niko1221 17c6c4cd80 split: the prompt ring's receive and gather steps move data to and from device memory by DMA on the copy engines (the add and output kernels read device memory, not host memory over PCIe) 2026-11-02 02:36:08 +01:00
Niko1221 77b4bc42dc split-hip: direct all-gather (every card stores its finished part into all peers' gather buffers), answers bit-identical to the ring; run scripts 2026-11-02 02:36:07 +01:00
Niko1221 cc2dc69184 DF2-SYCL: STRATA_DUMP_WINDOW_TOP2 debug hook 2026-11-02 02:30:50 +01:00
Niko1221 07fb0e7113 Arena RAM fallback: budget form with an 8 GiB headroom, and the Windows copy trims the mapped pages
The fallback's RAM copy (previous commit) now uses the --resident-budget-gib form: the budget is what is available minus the headroom
(default 8 GiB, STRATA_RESIDENT_HEADROOM_GIB changes it), so the experts outside the copy are read unbuffered instead of through the mapped
view whose pages grow the working set until available RAM is near zero.  On Windows the complement copy releases the mapped pages to the
standby list every 4 layers (the VirtualUnlock call that already ran once after the copy): available RAM fell to 0.9 GiB for ~10 s during the copy
on a 64 GB PC with 44.6 GiB available.  No data changes.
Measured on the RTX 5070 PC (128K, 44.6 GiB available): lowest available RAM 4.05 GiB, 2,000-token answer 25.2 tok/s (9.4-10.1 without the fix).
2026-11-02 02:15:46 +01:00
Niko1221 9a35255008 split-hip: side-stream sync copies for HIP graph capture, row-by-row GDN default on HIP, mlock warning 2026-11-02 02:01:42 +01:00
Niko1221 e47ad761fd Docs: Qwen picture reader on demand (DETAILS vision table, vision_reader note) 2026-11-02 02:01:12 +01:00
Niko1221 79dc463760 Picture reader in RAM: Qwen on-demand reader is the NVIDIA one-GPU default (vision.elastic false keeps it on the card; setup's 700 MiB reserve becomes 2048 for it), no repeated VRAM asks to an engine that cannot shrink, 1,536 MiB room check before the card is tried; DeepSeek tower: a failed take no longer leaves a sticky CUDA error, AMD keeps the tower on the card (--vision-lazy asks); docs 2026-11-02 02:00:38 +01:00
Niko1221 86cbd533a4 metal DFlash2: the producers write the half GEMM operand themselves (df2_rms_h, df2_conv_h, df2_swiglu_h, df2_attn_merge_h; STRATA_DF2_FUSE_H=0 restores the 37 df2_f16 dispatches): drafter 2.12 -> 2.08 ms GPU, output ids identical; STRATA_DF2_STATS prints a periodic drafter line (serve leaves through exit()) 2026-11-02 02:00:01 +01:00
Niko1221 3f3b6895e1 Doctor: the MTP folder is optional when the drafter is DFlash2; ui-r3 follows the drafter-aware wording 2026-11-02 01:48:35 +01:00
Niko1221 13edeb7f89 One drafter per run: DFlash2 is the primary drafter where the engine has it (CUDA, HIP, Metal), alone and with --draft-fallback none; the MTP is chosen alone with no DFlash2 option
Auto and the default pick DFlash2 with the Q4_0 pack for Qwen3.8-Flash-Next and Gyro; Intel SYCL stays MTP (one line, DFLASH2_BACKENDS). The GUI dropdown, TUI, strata launch, setup (--drafter), serve/server.py, the host's Load and the drafting command agree. A new install fetches only the selected drafter, a switch fetches the other once (never at start), the draft vocabulary comes with the DFlash2 pack. A layer split or several requests load only the MTP and say so in one line; no drafter on disk says so too. Fit, plan and slot estimates use the chosen drafter (0.89 GB DFlash2, 1.08 GB MTP).
2026-11-02 01:31:26 +01:00
Niko1221 3d08a095ff split: routed expert kernels for the IQ3_S set (first version, to be gated)
Decode: ggml's vec_dot for IQ4_XS, IQ3_S, IQ3_XXS and IQ2_S added to the mmvq expert launchers beside IQ4_NL and Q4_0.
Prompt: weight tiles for Q4_0, IQ4_NL, IQ4_XS, IQ3_S, IQ3_XXS and IQ2_S in moe_mma_iq.cuh (ggml's dequantization rounded once to f16).
Loader: Strata's Q2_0 down experts are repacked to Q4_0 blocks (lossless, twice the bytes).
2026-11-02 01:30:07 +01:00
Niko1221 43543e9b07 Arena RAM fallback keeps a RAM copy of the hottest experts (--resident-experts form)
When the expert arena does not fit the available RAM (#4846), the run read every expert through the plain file source with no RAM copy:
9-10 tok/s on the owner's 5070 PC (44.6 GiB available for a 46.84 GiB arena). The same engine with a RAM copy of the hottest experts
(--resident-budget-gib 40 / --resident-experts) gave 24-31 tok/s at the same RAM. The fallback now sets the --resident-experts flags
(resident_cpu_experts, resident_pin, resident_soft, 4 GiB headroom) when a static expert profile is set; if the file cannot be opened the
flags are cleared with the rest of the fallback. STRATA_ARENA_FALLBACK_RESIDENT=0 keeps the plain file source.
Not built, not run: the patch is untested.
2026-11-02 01:28:07 +01:00
Niko1221 058f65322b split-hip: an mlock that the user's RLIMIT_MEMLOCK refuses is a warning on HIP (the table stays in the page cache), not a failed start 2026-11-02 01:23:48 +01:00
Niko1221 84f16f7a27 split: load-time conversion of the dense and small tensors of the IQ3_S set
Adds the IQ2_S, IQ3_XXS, IQ3_S, IQ4_XS and Q2_0 types to the GGUF type table and a
loader step that, only when a file has routed experts in IQ2_S, IQ3_XXS, IQ3_S or Q2_0,
dequantizes its dense matrices, shared experts, projections and embeddings (K-quants,
IQ4_XS, IQ4_NL, BF16, F16) and writes them again as Q8_0, and the routers, injections
and SSM gates as F32. Other files are not touched. The routed IQ expert kernels are not
there yet, so the set still falls back.

Checked against gguf-py on the ISTA IQ3_S file: 762 tensors converted, the sampled ones
all within the Q8_0 bound (F32 targets exact), every source type covered.
2026-11-02 01:23:25 +01:00
Niko1221 a6fa0fc869 DF2-SYCL: DFlash2 in the serve loop (taps, begin_request, window rule, draft after commit) 2026-11-02 01:18:15 +01:00
Niko1221 46f84d4646 split-hip: native stream memory ops by default (13 us cross-card trip on R9700), the rule accepts the F32 router gate beside the Q8_0 shared expert 2026-11-02 01:14:25 +01:00
Niko1221 c2b9218c76 DFlash2: the lookup first when the suffix match is long (spec/window_rule.hpp skip_draft_for_lookup, STRATA_DF2_LOOKUP_SKIP=N, default 8 on Metal, 0 elsewhere): the round's DFlash2 draft is deferred to the start of the next round; the policy takes the lookup (the committed rows only update the drafter's ring, no draft round) or the draft is made then from the same taps. CLI and serve loops. Based on 34c0ab47b 2026-11-02 01:13:55 +01:00
Niko1221 2fb096b938 Gate check: a Qwen serve engine must answer requests that make prompt lookup propose long windows (the Metal 12-row refusal went unseen because no gate request repeated text) 2026-11-02 01:12:02 +01:00
Niko1221 2f13eb5224 Merge branch 'f-0291-df2-mem' into f-0291-df2-primary 2026-11-02 01:09:39 +01:00
Niko1221 cd9c7e7b57 Merge branch 'f-0291-df2-mem' into f-0291-df2-mgpu 2026-11-02 01:09:03 +01:00
Niko1221 96f0307aa0 --draft dflash2 without the MTP layer: refuse a missing draft vocabulary file instead of running the drafter's head over the whole vocabulary 2026-11-02 01:06:15 +01:00
Niko1221 7e25613634 split-hip: stream memory ops as one-thread kernels by default on HIP (FNX_HIP_MEMOP=native for the runtime's) 2026-11-02 01:03:36 +01:00
Niko1221 f70368b996 DFlash2 on a layer split and in batch slots: stage-wise taps gathered onto the drafter's card (cross-device events), per-stage prompt taps, per-slot drafters sharing the pack and head, spare-row slot windows, solo<->slot ring handoff 2026-11-02 00:59:54 +01:00
Niko1221 415b43f95e metal DFlash2: with no MTP loaded the draft vocabulary falls back to the repository's draft_vocab.bin like the MTP loader (it bound the whole 248,320-row head: +372 MiB and +0.7 ms per round) 2026-11-02 00:47:43 +01:00
Niko1221 285fa48b94 lookup_default.hpp is included on every backend (it sat inside the HIP-only include block) 2026-11-02 00:47:32 +01:00
Niko1221 5a258235c6 split-hip: the launcher rule knows two makes (NVIDIA sm_86 x2, AMD gfx1201): per-make tested sets, KFD-topology card probe for AMD 2026-11-02 00:46:15 +01:00
Niko1221 cf73cc4cb5 DF2-SYCL engine integration, first cut: SYCL Dflash2Drafter, verifier and prompt taps, --draft dflash2 in the CLI loop (no MTP loaded) 2026-11-02 00:45:06 +01:00
Niko1221 fea8a3e633 Metal keeps --lookup-max-t off by default: its windows hold 8 rows and a 12-row lookup window ended the first Qwen serve request (metal window: window size out of range); the default rule is a header with a test 2026-11-02 00:43:09 +01:00
Niko1221 12c2d740c6 split: the prompt ring's receive and gather steps move data to and from device memory by DMA on the copy engines (the add and output kernels read device memory, not host memory over PCIe) 2026-11-02 00:40:54 +01:00
Niko1221 909ab78af8 Metal serve: no default 12-row lookup window (the Metal window holds 8 rows; with the MTP the first suffix-lookup window ended the request with 'metal window: window size out of range') 2026-11-02 00:40:48 +01:00
Niko1221 857d0e3fe9 split-hip: CMake HIP target (strata-split-N for gfx1201 with -DSTRATA_ENABLE_HIP=ON), card check by gfx name 2026-11-02 00:40:32 +01:00
Niko1221 ea2b0d9c63 split-hip: first HIP gfx12 compile of the split engine: gfx12 WMMA expert prompt kernel (replaces mma.m8n8k4), nvcuda::wmma shim for attention and the QSA scores, driver stream-memory-op shim, HIP stubs for the shared-memory holder 2026-11-02 00:37:55 +01:00
Niko1221 857e516621 DFlash2: --draft-fallback none (no MTP layer loaded, DFlash2 the only drafter, an error at start if it cannot run) and the DFlash2 draft head shared with the MTP's byte-identical subset (STRATA_DF2_SHARE_HEAD_OFF=1 keeps its own copy)
Per-run source choice, no mid-run fallback. With the MTP loaded the DFlash2 gathered a second copy of the 106299-row draft head (212.9 MiB on Flash-Next); it now uses the MTP's when the type, the vocabulary ids and the layout match, and the expert cache is sized without the copy. The cache sizing also prices the DFlash2 head from the vocabulary file when there is no MTP.
2026-11-02 00:35:05 +01:00
Niko1221 a6fd5cfe42 DFlash2: the MTP chain's cost window and q lists apply only to candidates the MTP drew
With DFlash2 as the drafter the MTP stays loaded as the fallback (use_mtp stays true), and three decisions tested use_mtp where they meant
"the MTP chain drew this round's candidates":
- STRATA_SPEC_COST=1 (a GB10 default): T = clamp(chain_rows, 1, S_mtp), but chain_rows is only written by the MTP chain callback, so with DFlash2
  and no --draft-conf the window stayed at one row (CLI and serve; code_py 175 vs 258 tok/s on an RTX PRO 6000, serve 166-230 vs 283-397).
- STRATA_SPEC_PROB=1, sampled requests: ver.set_spec_q(mtp.spec_q()) judged DFlash2's deterministic candidates against the MTP's stale q lists
  (CLI: 107 of 137 windows), and serve's gated path was not declined for DFlash2.
- the lookup choice read mtp.prob() for the same reason.
spec/window_rule.hpp holds the window rule and the q rule, both loops call it. The default path is unchanged: window_rule_test compares the new
rule with a copy of the old code over 9216 inputs (only the DFlash2 + cost rule + no --draft-conf cases differ). The CLI stats also print the
window histogram for any rule that sets the width and, when STRATA_SPEC_PROB judged windows, their count and source.
2026-11-02 00:34:36 +01:00
Niko1221 dc9b2a407c DF2-SYCL: attention variant 2 (256 work-items), DF-04 prefix test, replay script 2026-11-02 00:34:36 +01:00
Niko1221 9d642b9eb9 split: the expert prompt kernel on Ampere's m16n8k16 tensor-core instruction for sm_80 and later (the Volta m8n8k4 version stays for older cards); first version, to be gated for exactness and speed 2026-11-02 00:33:26 +01:00
Niko1221 1c2aba90fa Merge branch 'f-0291-df2-mem' into f-0291-df2-primary 2026-11-02 00:32:35 +01:00
Niko1221 962816b409 Merge branch 'f-0291-df2-mem' into f-0291-df2-mgpu 2026-11-02 00:32:35 +01:00
Niko1221 5a048c6c09 --draft dflash2 loads no MTP layer unless --draft-fallback mtp is given (the DFlash2 and the MTP layer are never loaded together by default); --draft auto still loads both 2026-11-02 00:32:01 +01:00
Niko1221 626f9ef009 DFlash2: --draft-fallback none (no MTP layer loaded, DFlash2 the only drafter, an error at start if it cannot run) and the DFlash2 draft head shared with the MTP's byte-identical subset (STRATA_DF2_SHARE_HEAD_OFF=1 keeps its own copy)
Per-run source choice, no mid-run fallback. With the MTP loaded the DFlash2 gathered a second copy of the 106299-row draft head (212.9 MiB on Flash-Next); it now uses the MTP's when the type, the vocabulary ids and the layout match, and the expert cache is sized without the copy. The cache sizing also prices the DFlash2 head from the vocabulary file when there is no MTP.
2026-11-02 00:31:09 +01:00
Niko1221 681e509516 DFlash2: the MTP chain's cost window and q lists apply only to candidates the MTP drew
With DFlash2 as the drafter the MTP stays loaded as the fallback (use_mtp stays true), and three decisions tested use_mtp where they meant
"the MTP chain drew this round's candidates":
- STRATA_SPEC_COST=1 (a GB10 default): T = clamp(chain_rows, 1, S_mtp), but chain_rows is only written by the MTP chain callback, so with DFlash2
  and no --draft-conf the window stayed at one row (CLI and serve; code_py 175 vs 258 tok/s on an RTX PRO 6000, serve 166-230 vs 283-397).
- STRATA_SPEC_PROB=1, sampled requests: ver.set_spec_q(mtp.spec_q()) judged DFlash2's deterministic candidates against the MTP's stale q lists
  (CLI: 107 of 137 windows), and serve's gated path was not declined for DFlash2.
- the lookup choice read mtp.prob() for the same reason.
spec/window_rule.hpp holds the window rule and the q rule, both loops call it. The default path is unchanged: window_rule_test compares the new
rule with a copy of the old code over 9216 inputs (only the DFlash2 + cost rule + no --draft-conf cases differ). The CLI stats also print the
window histogram for any rule that sets the width and, when STRATA_SPEC_PROB judged windows, their count and source.
2026-11-02 00:30:13 +01:00
Niko1221 7dea06f75d Merge f-0291-df2-nv-a (DFlash2 source ownership) into f-0291-df2-mgpu; the window rule reads draft_on for min-p so DFlash2 alone keeps --spec-min-p 2026-11-02 00:30:10 +01:00
Niko1221 0c87b96f8c Merge f-0291-df2-mem (--draft-fallback none, DFlash2 head sharing) into the DFlash2-primary branch 2026-11-02 00:30:07 +01:00
Niko1221 5eb33fe4f3 Merge branch 'f-0291-df2-conf' into f-0291-df2-primary 2026-11-02 00:29:38 +01:00
Niko1221 166dcf0b11 Qwen picture reader on demand by default on an NVIDIA card with one GPU: the first picture starts it (the expert cache gives its room), 120 s without one stops it; it needs no config key (vision.elastic false or vram_elastic false keeps it on the card). The on-demand reader has the same program without --gpu as its CPU reader, so a picture is read whatever the card has left (found on boston: reserve 700 and no CPU reader gave a 503 on every picture) 2026-11-02 00:18:52 +01:00
Niko1221 bb2649ee8b SYCL: test-only in-process kernel timer (STRATA_UC_PROBE) for the uncached-host-memory slowdown on Arc Alchemist 2026-11-01 23:57:37 +01:00
Niko1221 1ada0f6202 SYCL DFlash2 Q4_0 drafter kernels (active-column GEMM, host rope tables) and a drafter-only bench for Intel Arc 2026-11-01 23:54:08 +01:00
Niko1221 09c407d8f4 DFlash2: --draft-fallback none (no MTP layer loaded, DFlash2 the only drafter, an error at start if it cannot run) and the DFlash2 draft head shared with the MTP's byte-identical subset (STRATA_DF2_SHARE_HEAD_OFF=1 keeps its own copy)
Per-run source choice, no mid-run fallback. With the MTP loaded the DFlash2 gathered a second copy of the 106299-row draft head (212.9 MiB on Flash-Next); it now uses the MTP's when the type, the vocabulary ids and the layout match, and the expert cache is sized without the copy. The cache sizing also prices the DFlash2 head from the vocabulary file when there is no MTP.
2026-11-01 23:51:17 +01:00
Niko1221 8bccfe281e docs: SPLIT_ENGINE.md with the copy-engine ring's numbers, the parallel table and the suite result 2026-11-01 23:46:34 +01:00
Niko1221 d4ab9edc3f split: the prompt path's chunk sums use the copy engines through host memory by default (suite 56/61 against 55/61 on the GPU-thread ring, parallel loads equal or better, prompts 17-20% faster); FNX_PF_RING_SM=1 keeps the GPU-thread ring 2026-11-01 23:44:53 +01:00
Niko1221 34c0ab47b2 DFlash2: the MTP chain's cost window and q lists apply only to candidates the MTP drew
With DFlash2 as the drafter the MTP stays loaded as the fallback (use_mtp stays true), and three decisions tested use_mtp where they meant
"the MTP chain drew this round's candidates":
- STRATA_SPEC_COST=1 (a GB10 default): T = clamp(chain_rows, 1, S_mtp), but chain_rows is only written by the MTP chain callback, so with DFlash2
  and no --draft-conf the window stayed at one row (CLI and serve; code_py 175 vs 258 tok/s on an RTX PRO 6000, serve 166-230 vs 283-397).
- STRATA_SPEC_PROB=1, sampled requests: ver.set_spec_q(mtp.spec_q()) judged DFlash2's deterministic candidates against the MTP's stale q lists
  (CLI: 107 of 137 windows), and serve's gated path was not declined for DFlash2.
- the lookup choice read mtp.prob() for the same reason.
spec/window_rule.hpp holds the window rule and the q rule, both loops call it. The default path is unchanged: window_rule_test compares the new
rule with a copy of the old code over 9216 inputs (only the DFlash2 + cost rule + no --draft-conf cases differ). The CLI stats also print the
window histogram for any rule that sets the width and, when STRATA_SPEC_PROB judged windows, their count and source.
2026-11-01 23:41:43 +01:00
Niko1221 2ad5867efb DeepSeek serve: test-only STRATA_TEST_NAN_STOP_AT=<n> takes the NaN stop once on the n-th request (does nothing when unset), to run the post-NaN path on a real engine 2026-11-01 23:32:18 +01:00
Niko1221 0941f1fe83 metal: DFlash2 Q4_0 drafter on the Metal window: fragment-order Q4_0 repack at load, df2_mma4s matrix-unit GEMMs, Q8_0 draft head on the matrix units (df2_mma8), DF-04 candidate cap (set_max_drafts), begin_request/note_rows/prepare, attention floor for a restarted lineage; builds on f-0291-df2-q4 2026-11-01 23:17:17 +01:00
Niko1221 d6a5a7837e README: the ISTA-DAS Coder row 2026-11-01 23:15:09 +01:00
Niko1221 37e7b738f5 metal: DFlash2 Q8_0 GEMM kernel over a repacked pack (df2_mm: 12.6 ms a round against 36), Q8_0 draft head, STRATA_DF2_DUMP and tools/dflash2/check_metal_dump.py (final rows cos 0.9999 against the torch port)
# Conflicts:
#	metal/src/engine/msl/dflash2.metal
2026-11-01 23:12:16 +01:00
Niko1221 1d36e65a14 metal: DFlash2 drafter on the Metal window (taps in the decode window and the prompt chunks, Q8_0 pack through the engine's GEMVs, windowed attention, top-16 selector); same Dflash2Drafter API as the CUDA build 2026-11-01 23:12:07 +01:00
Niko1221 5bb21e170a metal: df2_mma8 (draft head Q8_0 rows on the matrix units), stubs_df2.cpp from the first Metal port 2026-11-01 23:11:58 +01:00
Niko1221 488b67a1a9 metal: df2_mma4 / df2_mma4s, Q4_0 in fragment order on the simdgroup matrix units (split-K variant), df2_bench modes for it, soak and head cap; drafter round 3.0 ms GPU / 3.3 ms wall (was 7.1 / 7.4) 2026-11-01 23:08:27 +01:00
Niko1221 29e8a47ac9 DFlash2 confidence follows the expert slots the card holds after the drafter (9000+: 0.35, 3000+: 0.7, below: 0.8; the person's own --expert-cache N or --draft-conf wins); gfx1201 is a tested card (+7.2% short, +1.35% long), not a slower one 2026-11-01 22:55:53 +01:00
Niko1221 9f0794e993 Qwen picture reader on demand without the shrinking cache: a GPU reader that does not start (no room, card busy) is the CPU reader for that start and the next start tries the card again; a cache that cannot shrink and under elastic_mib free VRAM goes straight to the CPU reader; a CPU reader makes the cache grow back at once; pictures already encoded start no reader; --vram-elastic on a driver without virtual memory management starts as one block with a log line 2026-11-01 22:34:36 +01:00
Niko1221 21df2aa30d DeepSeek picture tower: in RAM between pictures. With --serve on a discrete GPU the tower is read into pinned host memory at the load and takes its ~320 MiB of graphics memory only for a picture turn (out of what --reserve-mib keeps free; kept on the card when the reserve is below its size + 192 MiB, on a GPU that shares the PC's memory, on Metal, or with --vision-resident). The expert split then counts the tower's MiB as experts' room. A card that has no room at the turn gets 3 retries, then the request's ERR names --vision-resident 2026-11-01 22:34:35 +01:00
Niko1221 11e02ea876 ISTA-DAS Coder: measured numbers in the menu text and the docs (KL, perplexity, speed on R9700 and RTX PRO 6000) 2026-11-01 22:29:19 +01:00
Niko1221 4938fcc9f0 metal: df2_bench, the cost of one DFlash2 drafter round on Metal with the Q4_0 pack's shapes (small kernels from the first Metal port, df2_mm4 Q4_0 matrix kernel, engine gemv_mma over native Q4_0 blocks); micro-benchmark only, not in the engine 2026-11-01 22:22:47 +01:00
Niko1221 05e276aa8c ISTA-DAS Coder: dense weights and RAM classes from measured runs on r9700 (placement lines, memory-capped one-card decode), words and menu row of its own 2026-11-01 21:58:06 +01:00
Niko1221 505dd93ed6 Load pictures: one line instead of two, a toast when only the files were fetched, the page driver counts new dialogs only
- With a loaded model that runs without pictures (no internet, no room), the existing "Picture reading needs a few more files" offer says the one short line
  instead of a second warning beside it; the offer is hidden while a download or load runs.
- "Download picture-reading files" ends with a toast; a model that is loaded without pictures is told to load again.
- Nine-language catalog: three more sentences. UI preview fixture: context 128K with its marker; the Chrome driver reports dialogs that are new after the click.
2026-11-01 21:51:15 +01:00
Niko1221 9e10513ad2 Load with Read pictures on and no picture files: fetch only those files, keep the model's pack, draft layer and data folder, no installer run
A model that is downloaded, in its own GGUF folder, with Read pictures on (the default) and no picture file started an install job on Load:
`--data-dir <app models folder>\Strata-data --gguf-dir <own folder> --vision yes`. That re-checked the model files (finish marks written in the
person's own folder), prepared a second pack and downloaded the draft layer into the wrong data folder, and rewrote data_dir in app-settings.json.

- strata_control/pictures.py (new): finds the pinned picture file (size and SHA-256; beside the model, one folder up, in the model's data folder) or
  downloads it once into <data folder>/models, shared; the data folder is the one the model's run config names (packs/<tag>, mtp/rt). Works out the run
  config's vision section for the engine in use with the same rule as setup (CPU reader under 24 GB, GPU reader from 24 GB, AMD/Intel Arc/Mac readers).
  No internet is one plain sentence after a short probe; a start never downloads (acquisition guard).
- native-control.mjs: Load (kind load, vision on) fetches the files through Control, writes the vision section, shows plain-word progress with a bar and
  loads. No internet, no room on the disk or a failed download: the model loads without pictures, nothing saved about pictures, one short line on the
  page. A restart or a tuning run never downloads. kind pictures does the same without a load (the "Download picture-reading files" button).
  A model whose only gap is a deleted picture file is no longer "missing files". installArgs: an installed model is prepared again in the folder its
  own run config names, never the app's models folder, and that folder is never written to the settings; the Drafting plan looks there too.
- Models page: the Load shortcut no longer needs the picture files prepared for the families Load can fetch for; the button and the line use the new kind.
- Tests: tests/terminal/test_pictures.py, apps/host/tests/load-pictures.test.mjs, UI preview fixture scenario, Chrome driver script, nine-language catalog.
2026-11-01 21:29:12 +01:00
Niko1221 f74bceeb6a split: the prompt path's finished part goes to the next card's gather buffer by a kernel, not a host-to-host cudaMemcpyAsync (which blocks the one thread that enqueues the cards in turn: the cause of the copy-engine ring stalling in the first read) 2026-11-01 21:25:01 +01:00
Niko1221 43c40ecb19 split: the launcher side: apply_to_config probes the cards (nvidia-smi) and RAM, reads the GGUF, and turns the run config into the split engine's with the normal one kept as fallback; strata launch calls it only when the config says split_engine true (or STRATA_SPLIT_ENGINE=1 for testers); tests 2026-11-01 21:05:45 +01:00
Niko1221 fcba411c41 split: packaging/build_split_cuda.sh (fails if the engine can call a peer function) and docs/SPLIT_ENGINE.md 2026-11-01 21:01:47 +01:00
Niko1221 ff913c6bf0 serve: a run config for the all-cards split engine starts the normal layer split, with one plain line, if the split engine refuses or fails to come up (start_engine_or_fallback, tests) 2026-11-01 20:58:08 +01:00
Niko1221 28f94d6890 split: the rule for when the all-cards split engine may run (off unless asked; Linux, 2-4 identical cards of a tested kind, UD-Q4_K_XL-type GGUF, every expert fits the cards, RAM free), its run config with the normal config kept as fallback, tests incl. no peer API in src/split; defaults: SM-ring prompt path, Strata's draft-layer files 2026-11-01 20:51:40 +01:00
Niko1221 9aea047eea Fix answers arriving one request late after an aborted request: no DONE after the NaN-stop ERR, server drops a trailing DONE, abort-sync test 2026-11-01 20:47:13 +01:00
Niko1221 333e113d53 split: host-memory exchange between the cards, no peer access anywhere: the ring, attention-exchange and prompt-path buffers live in page-locked mapped host memory; the peer probe, peer enabling, peer copies and the IOMMU/BAR1 start checks are gone; the 27B files are dropped; CMake option STRATA_BUILD_SPLIT (off by default) builds strata-split-N for N cards 2026-11-01 20:46:46 +01:00
Niko1221 b8668d0385 split: sFNX (johnfink8/sfnx f9ba1e9, MIT) as published: engine, engine process, CPU tests, two data files, LICENSE and NOTICE 2026-11-01 20:42:54 +01:00
Niko1221 c021f48d76 ISTA-DAS Coder: its own expert profile (519,422 prompt tokens of the calibration texts) and tests 2026-11-01 20:36:28 +01:00
Niko1221 93970e0a27 DFlash2 Q4_0 GEMM default: two warps a block, two rows a warp (variant 17); one warp a row (28) is best on a 188-SM card but 10-35% slower than 17 on l620's 110 SMs 2026-11-01 20:29:47 +01:00
Niko1221 16f513ad78 HIP: the PcieAuto start share from the pool size (06ebf2e4d, BW-MEASURE E26) is opt-in on AMD (STRATA_PCIE_START=new); CUDA keeps it by default. It flipped near-tie tokens in the first ~50 windows after a start on the R9700 for +-1%, the only default that made merged-with-the-other-defaults-off differ from v0.2.9 there (p620, G2: 21/45 -> 45/45 identical serve answers with the old start) 2026-11-01 19:49:00 +01:00
Niko1221 93e43af9e1 App: DeepSeek reads pictures by default (catalog images true, install keeps vision on, no reader device choice, a pack without the tower offers the missing shared file); load applies the --vision pair 2026-11-01 19:42:20 +01:00
Niko1221 adf7e54127 ISTA-DAS Coder: Strix and Spark note (sizes exact, speed not measured), app label test 2026-11-01 19:33:47 +01:00
Niko1221 4019121f24 DFlash2 Q4_0 GEMM: more rows-a-warp variants; default one warp a row (bench: down 18.7->10.3 us, fc 29.3->16.4, akp 8.2->4.1, o 12.4->8.2, qkv 11.9->9.5; 184/184 bitwise) 2026-11-01 19:28:37 +01:00
Niko1221 f5e2e7a86e comment 2026-11-01 19:24:07 +01:00
Niko1221 0c09456693 DFlash2 Q4_0 GEMM: default rows-a-warp table (bitwise: gemm bench 128/128 on srv118; down 18.7->12.2 us, o 12.4->8.2, fc 29.4->18.4, akp 8.2->4.1) 2026-11-01 19:24:02 +01:00
Niko1221 8dbdcf1b20 DeepSeek engine: an expert profile of another model's size is not read; the router tests run with 192 experts and poisoned padding (CUDA, Metal) 2026-11-01 19:18:48 +01:00
Niko1221 85dd085241 Add the ISTA-DAS Coder set (192 experts a layer): one JSON for the repository, setup recipe, fit numbers, menu rows, Mac note, tests 2026-11-01 19:18:48 +01:00
Niko1221 8752fc37ed Merge f-0291-int3 into f-0291-ds-coder (engine and DeepSeek plan numbers) 2026-11-01 18:49:22 +01:00
Niko1221 909aca3806 Merge branch 'f-0291-ui-final' into f-0291-ui-final2 2026-11-01 18:34:50 +01:00
Niko1221 f88fd15e8b Merge branch 'f-0291-agent-lost' into f-0291-ui-final2 2026-11-01 18:34:50 +01:00
Niko1221 d7d2ffab97 Take the rewritten agent-lost files (the cut sentence says the write did not happen and what the assistant is asked; the e2e script) 2026-11-01 18:34:50 +01:00
Niko1221 68baeabc54 Terminal and MCP agree with 128K: DeepSeek's setup default is 128K (a Mac keeps 8K), setup no longer calls the default untested, the pinned tests follow 2026-11-01 18:20:54 +01:00
Niko1221 601906b4de Merge f-0291-tui-update 3165c2e8a (its own wait for the stopped engine replaces the one found here) 2026-11-01 18:15:24 +01:00
Niko1221 d42341d797 A reply the length limit cuts inside a file write is no longer lost without a word: requests may produce half the window (compaction's reserve is unchanged), a cut reply is announced and the agent is asked to look at what is on disk and go on in smaller parts, and the file being written streams to the page (name, size, lines, tail) 2026-11-01 18:04:34 +01:00
Niko1221 de19caff76 The folder window no longer fails with an error when the Desktop folder is missing (it starts at This PC), and any failure of it now asks for the path instead of leaving no way to choose a folder (found in the review copy, whose profile has no Desktop folder) 2026-11-01 18:04:34 +01:00
Niko1221 6dbf88c784 Line endings of the files the agent-lost branch changed go back to LF (it saved whole files as CRLF; ui-do and anything that reads them by line broke); only its real changes remain 2026-11-01 17:59:32 +01:00
Niko1221 d97d130902 DFlash2 Q4_0 GEMM: rows-a-warp variants (gemm4_rows_kernel, bitwise the same results), STRATA_DF2_GEMM4, gemm bench q4 mode (auto table still the old kernel until measured) 2026-11-01 17:54:08 +01:00
Niko1221 31f0e8647c generate.cpp: fix the merged ERR line 2026-11-01 17:49:41 +01:00
Niko1221 32c37513e7 Merge f-0291-df2-option (Q4_0 drafter pack as shipped) into f-0291-df2-more 2026-11-01 17:47:30 +01:00
Niko1221 d29a6640e4 Chat: a file or command the model is writing right now is drawn from message.drafts through the open activity line: the file and its growing size, the last three lines when they read well, in a box that never changes height; drawn about three times a second, nothing animates 2026-11-01 17:44:39 +01:00
Niko1221 776fb9dbf5 Merge branch 'f-0291-agent-lost' into f-0291-ui-final2 2026-11-01 17:38:30 +01:00
Niko1221 944f06edba Merge branch 'f-0291-df2-option' into f-0291-ui-final2 2026-11-01 17:38:28 +01:00
Niko1221 bd7e44c90d 128K on every card, first-run setup and DeepSeek included: one rule (strata_control/default_context.py) for the app, the Terminal launch default, setup and the MCP install default; the tight-card fallback and DeepSeek's fixed 8K are gone, the inline warning stays 2026-11-01 17:35:17 +01:00
Niko1221 3165c2e8a5 After a model is stopped the update waits for its engine to let go; the start-again question follows what was changed 2026-11-01 17:15:20 +01:00
Niko1221 8550019663 A context picked after this update sticks, 32K included: saved with a marker (ui_context_chosen) by a load, an install or the first-run step; only an old unmarked 32K moves to 128K. The Tune dialog also saves no picture off when the engine has no reader 2026-11-01 17:13:27 +01:00
Niko1221 5ab569aa47 Tests that pinned the old words and places follow the new ones; a DeepSeek card keeps its own SSD line; the status line keeps its 24ch column 2026-11-01 17:02:14 +01:00
Niko1221 b541d7ad66 A reply the length limit cuts inside a file write is no longer lost without a word: requests may produce half the window (compaction's reserve is unchanged), a cut reply is announced and the agent is asked to go on in smaller parts, and the file being written streams to the page (name, size, lines, tail) 2026-11-01 16:56:39 +01:00
Niko1221 0038dbfd44 The folder window no longer fails with an error when the Desktop folder is missing (it starts at This PC), and any failure of it now asks for the path instead of leaving no way to choose a folder (found in the review copy, whose profile has no Desktop folder) 2026-11-01 16:56:39 +01:00
Niko1221 18aacf5dd0 Test: a load without a picture reader runs without pictures and saves no off 2026-11-01 16:55:06 +01:00
Niko1221 5d4183edfa Polish found in the preview: the SSD warning is said once (under the version picker), notes under controls keep their icon beside the words, the empty Subagents row is centred 2026-11-01 16:53:06 +01:00
Niko1221 01dd40aa8f Working ring: its turn is a loop in seconds, as the other loops (the audit wants tokens for transitions) 2026-11-01 16:51:14 +01:00
Niko1221 20c617a8ca Chat while it works: Stop and Send are one height so the box stops moving, one live cue per tool step, the caret follows text only, a slot for the host's live step and output on an open activity line, and the sidebar preview opens and closes in 460 ms 2026-11-01 16:48:58 +01:00
Niko1221 c738d5bf34 A chat that is working shows it on its sidebar row (a slow ring where the unread dot sits), and the model chip names the working chat while you are elsewhere 2026-11-01 16:48:53 +01:00
Niko1221 fbaff162bc DFlash2: graphics memory of the Q4_0 drafter is 0.8 GB (engine: 799 MiB) 2026-11-01 16:38:02 +01:00
Niko1221 8d6351f55f Load model: no pop-up. The press starts it; the plan is read first and a disk that is too small is said in the page and stops it; the download size sits beside the button and the DFlash2 drafter download is said in the page while it runs 2026-11-01 16:36:17 +01:00
Niko1221 04a007771c Tasks: the Board and Schedule help lines move into the header after the Tasks title, smaller, wrapping at narrow widths 2026-11-01 16:34:18 +01:00
Niko1221 e22a48d866 Subagents dropdown: with none, the title says No subagents recorded on the row with the X; no icon, no explanation, no empty body 2026-11-01 16:32:28 +01:00
Niko1221 e60e03762e Model files: the sentence is gone and Other local models... sits inside the component beside Storage settings 2026-11-01 16:32:05 +01:00
Niko1221 53f85fa815 Conversation context opens at 128K for every configuration (the model's largest size when under 128K); a tight card gets an inline warning, nothing is refused or lowered; DeepSeek keeps its checked 8K 2026-11-01 16:31:16 +01:00
Niko1221 943f29853a Version list: our recommended versions first and tagged Recommended, no Fits word anywhere, SSD streaming - will run slower for versions that do not fully fit; NVFP4 no longer says Doesn't fit (the fit follows graphics memory, RAM and SSD; Doesn't fit is left for no graphics card and under 16 GB) 2026-11-01 16:27:47 +01:00
Niko1221 0627b1ec63 This computer with a model loaded: only the free memory is left (no hardware line, name, Loaded, It takes about, numbers or SSD line); a version that fits says nothing 2026-11-01 16:25:17 +01:00
Niko1221 eb87544a58 Read pictures on by default for every model that can read them; a missing picture reader no longer saves an off, and the row has no sentence (tooltip only) 2026-11-01 16:24:20 +01:00
Niko1221 2b9c2980e2 DFlash2 drafter: the Q4_0 pack is made on the person's computer (522,458,112 bytes, pinned size and SHA-256), an existing Q8 pack keeps working 2026-11-01 16:22:06 +01:00
Niko1221 b2365cecf2 Models: no DFlash2 paragraph, no per-card line, no picture-reader sentence; the Drafting dropdown stays 2026-11-01 16:20:02 +01:00
Niko1221 811267e19f Terminal tests do not depend on the box's graphics card (the Drafting step changed the step count on Nvidia/AMD boxes); after a model is stopped for an engine update, wait briefly for its runtime host to release the engine lease (found on a real host) 2026-11-01 16:14:02 +01:00
Niko1221 27a3e056ab Int3 merge f-0291-df2-more (a84eebb51): bit-exact HC down kernel, default mask 133 on sm_120
# Conflicts:
#	src/program/generate.cpp
2026-11-01 16:11:56 +01:00
Niko1221 f7ada7119a Starting a model again waits at most what the runtime accepts (found by the real DeepSeek flow); flow fixes 2026-11-01 15:56:56 +01:00
Niko1221 3a3fd4391b Int3 merge f-0291-mac-q (bef28201f): FP16 default on resident IQ3_S and Q2_0 on Metal through setup's config, Coder on spec 3 2026-11-01 15:52:51 +01:00
Niko1221 bef28201ff Mac config: the fp16 decode (STRATA_METAL_F16=1) is the default of a resident IQ3_S or Q2_0 pack; the Coder joins --spec 3 --spec-min-p 0.5
Coder: expert-pruned 256-expert Coder, M5 Max, 5 interleaved pairs, identical greedy tokens in all 40 runs per class, over --spec 2: story -2.5%
(0/5), code +7.1%, JSON +9.3%, agent turn +12.5% (5/5 each). It has no Mac setup entry yet; the name is what a config for it would pass.

fp16 decode (PR 4807): setup writes STRATA_METAL_F16=1 into the config's env for a resident IQ3_S or Q2_0 pack (STRATA_METAL_F16=0 there turns
it off; streaming and the other sets are untouched). The owner's rule was an option unless it passes the 61-task quality suite: off / on / off
again on IQ3_S 54/61 each, on Q2_0 54/61 each, the same 7 tasks failing in all arms, no family drop, no new hallucination flag, 0 pass/fail
changes between two runs of the same arm; decode +14% (IQ3_S) and +15% (Q2_0) in the suite runs, top-1 96-97% and mean KL 0.009-0.016 against fp32
(phaseA-tests/mac-q/RESULTS.md sections 20, 22, 30).
2026-11-01 15:52:11 +01:00
Niko1221 95597c2dae Windows gfx1151: launch the verify window's graph from a helper thread (fixes the Q4_K_XL first-window start stall)
On a Strix Halo under Windows the first cudaGraphLaunch of a doorbell window sometimes never returned: the GPU had already rung layer 0
("GPU rang 1, flags served 0") while the main thread, which serves the doorbells, was still inside the launch call. HostGraphLaunch (the
CUDA instrumented-launch helper, STRATA_VERIFY_ASYNC_LAUNCH=1) is now compiled for the Windows HIP build too, and arch_defaults sets the
switch for Windows gfx1151 (launch_default_env; STRATA_GFX1151_DEFAULTS=0 or STRATA_VERIFY_ASYNC_LAUNCH=0 turns it off, a user setting is kept).

Measured on the strix box, UD-Q4_K_XL, default queues, 19,853 slots, interleaved within one job: old launch 14 hangs in 75 starts
(t2 4/20, t5 3/25, release build 7/30), helper-thread launch 0 in 75 (+ 8/8 on the final build); decode 48.2 vs 48.4 tok/s; output tokens
identical; IQ3_S 8K prompt 1,413-1,427 vs 1,419-1,423 tok/s with identical output; quality quick subset PASS, agent loops 2/2.

Also STRATA_LAUNCH_PROBE=1 (diagnostic): launch-call time, GPU doorbell state during the call, window graph node count.

(cherry picked from commit 93b9264e30054996d1cb9e89d27d211ed1156b15)
2026-11-01 15:38:54 +01:00
Niko1221 e394c48572 Engine update dialog: space above the progress bar; the failed-update offer shows the reason only under Details 2026-11-01 15:05:56 +01:00
Niko1221 a84eebb513 Remove STRATA_DF2_COUPLED (coupled sampled picks): at a fixed 8-row window it does not raise acceptance (tokens per round -1.7% short / -0.4% long, first-draft match 0.616 vs 0.629; draft temperature 0.6 / 1.5 / 2.5: 2.885 / 2.728 / 2.559 vs 2.868 tokens per round plain), the selector's argmax is the better draft 2026-11-01 15:02:51 +01:00
Niko1221 5f24b96dbd STRATA_GR_V3_PF default on a compute capability 12.0 card: 5 -> 133 (adds the bitwise down-kernel register blocking for windows of 5+ rows; serve A/B on 8 classes, 12 launches per arm: spec 6 +1.21%, spec 8 +1.4%, ids identical 96/96 each) 2026-11-01 15:01:30 +01:00
Niko1221 4deb89ef72 Revert "HC read v3 up kernel, ANOTHER SUMMATION ORDER (STRATA_GR_V3_PF bit 9, off by default, owner-approved item 10): a row per quarter warp, four rows side by side; gr_v3_screen reports its difference from the plain read"
This reverts commit ddd88c9f85.
2026-11-01 15:00:47 +01:00
Niko1221 a000bec3ce Update engine with a model loaded: warn naming it, stop it with the person's yes through strata update --stop-model, progress in words, offer to start it again with the same settings (also on the old engine after a failed update); nine languages 2026-11-01 14:57:08 +01:00
Niko1221 0ffb1d567c DFlash2 falling back to the MTP: the MTP runs its own window (--draft-mtp-spec), not DFlash2's 6, unless the person typed --spec 2026-11-01 14:50:42 +01:00
Niko1221 c70e049954 Default reading size 12 px and interface size 90%: a size the person chose is kept, a save that holds the old defaults moves once; Reset returns to the new ones 2026-11-01 14:50:42 +01:00
Niko1221 052c092856 Merge f-0291-tui-update (engine update beside a loaded model) into the app branch 2026-11-01 14:44:21 +01:00
Niko1221 ddd88c9f85 HC read v3 up kernel, ANOTHER SUMMATION ORDER (STRATA_GR_V3_PF bit 9, off by default, owner-approved item 10): a row per quarter warp, four rows side by side; gr_v3_screen reports its difference from the plain read 2026-11-01 14:29:22 +01:00
Niko1221 e5354698f9 gfx12: the 'matrix-core QSA scorer is on past 28,672 tokens' start line is not printed when STRATA_SELECT_WMMA=0 (the scorer is off then; the other variable of the pair was still being defaulted, which set the flag) (found by G2 on p620) 2026-11-01 14:27:48 +01:00
Niko1221 0e8e4d4c61 HC read v3: drop the up-kernel register blocking (slower: up 9.2 -> 10.6 us at T1, 18.8 -> 23.9 us at T8); the down kernel's register blocking stays: bit 7 = 2 rows a warp for windows of 5+ rows, bit 8 = 4 rows (bitwise; down 16.1/16.2/17.6/19.5 -> 13.3/14.1/14.7/15.6 us at T5-8) 2026-11-01 14:26:53 +01:00
Niko1221 f19d364637 locale-gaps test reads the df2 catalog 2026-11-01 14:22:22 +01:00
Niko1221 f8f4da07df gr_v3_screen: room for 16 variants 2026-11-01 14:20:52 +01:00
Niko1221 beb98760be Int3 merge f-0291-arc-a (a29977bd2): STRATA_SELECT_GEMM default on Battlemage only 2026-11-01 14:12:57 +01:00
Niko1221 26dc2a81c4 Docs: an engine is never updated by itself; flow script takes a prepared data folder 2026-11-01 14:11:12 +01:00
Niko1221 4ab81c7968 HC read v3 down kernel: register-blocked dot phase for 2 / 4 rows a warp (STRATA_GR_V3_PF bits 7 / 8, any window size; same dots, same order) 2026-11-01 14:07:07 +01:00
Niko1221 692b2573ea Int3 merge f-0291-ram-fallback (067b74338): #4846 falls back to --mmap-experts instead of refusing 2026-11-01 14:05:49 +01:00
Niko1221 686c0d42c7 HC read v3 up kernel: register-blocked dot phase (STRATA_GR_V3_PF bit 6, token loop outside the row loop, same dots in the same order); gr_v3_screen compares it bitwise 2026-11-01 14:05:41 +01:00
Niko1221 611ff41d41 DFlash2 option: reachability check through the download's own opener, one-request rule only when the drafter file is there, tests independent of the box's card 2026-11-01 14:00:01 +01:00
Niko1221 a29977bd2a SYCL: STRATA_SELECT_GEMM default (GEMM block scores from 2,048 blocks) only on a Battlemage card; =1 and =0 still decide on every card
The default changes the answers and gained +0.2% to +0.6% on an Arc A750 at 16K, 32K, 64K and 128K (3 runs per arm up to 64K, one pair at 128K; first-token KL 1e-9, same top-1 and tokens): its prompts are bound by expert streaming over PCIe. Under the 5% bar, so Alchemist and other devices keep the warp kernel unless STRATA_SELECT_GEMM=1. Battlemage unchanged (+19% to +28% at 100K to 128K on 2x Arc Pro B65).
2026-11-01 13:52:23 +01:00
Niko1221 e4d2bccd77 Flow scripts: a DeepSeek flow, the python for the throwaway feed 2026-11-01 13:44:52 +01:00
Niko1221 1ebae96167 DFlash2 chosen on purpose starts with one request at a time (request slots keep the MTP), Terminal Drafting step, plan line 2026-11-01 13:43:37 +01:00
Niko1221 067b743385 #4846: when RAM is still short at the expert arena, read the experts from the file like --mmap-experts
After the 20 s wait and the give-back of the PLE lock, a shortage no longer registers the arena anyway (a 60 GiB host stalled in reclaim until it was power-cycled). The run uses the --mmap-experts expert source, says so in one line with one next step, and the start never refuses. A hand-set --ple-io ram and --shared-expert-arena are kept as asked (warn only); a hard cgroup limit below the arena falls back instead of refusing; STRATA_ARENA_RAM_FALLBACK=0 is the old behaviour. If the file cannot be opened either, the arena loads the old way. Decision rule and tests in ple_residency.hpp / ple_residency_test.
2026-11-01 13:43:36 +01:00
Niko1221 e6ef7eed63 Tests follow the new rules: a model beside an update is refused before any download; the launch no longer upgrades DeepSeek; the monitor line 2026-11-01 13:36:10 +01:00
Niko1221 d615a9d643 DFlash2 as a real option: the dropdown only where there is a choice (not DeepSeek), --spec 6 --draft-conf 0.35 with it, only on CUDA and HIP engines, one plain line per card, no-internet message, verified files reused, SSD line label 2026-11-01 13:34:24 +01:00
Niko1221 3b1d9ae360 DF2-MORE: serve STRATA_SPEC_TRACE (per round window, accepted, selector probabilities, drafts, lookup draft, committed tokens; bench only) and STRATA_DF2_COUPLED, the selector's picks drawn with the target's Philox uniform for sampled requests (opt-in, verify unchanged) 2026-11-01 13:32:20 +01:00
Niko1221 687527d7a3 Int3 merge f-0291-ds-left final (ea2e791d8): screens say 5 experts on 3+ RDNA4 cards 2026-11-01 13:15:20 +01:00
Niko1221 ea2e791d8b Screens and app say 5 experts on several RDNA4 cards that hold half of the experts, as the engine does
The engine gives 5 experts per word on two or more RDNA4 cards that together hold at least half of the experts (4 x R9700), but strata launch --plan, the Speed screen and the app's Speed label called Full quality the default there.
dsv41_fit.rdna4_faster() applies the engine's rule with the plan's own numbers: one card the run uses (on Windows one AMD card): yes; several: yes when the plan puts at least half of the set's experts on them (3 or more R9700 for every official set; two hold 37-47%, so they keep 6). strata launch uses it with the chosen model; the model catalog carries rdna4_faster per DeepSeek size for the app (NativeSize, Speed label); --speed help and docs/MODELS.md say it. Other makes and older Radeons are unchanged.
2026-11-01 13:14:36 +01:00
Niko1221 dc96ad7033 Int3 merge f-0291-bw-draft (75d742778): DFlash2 drafter levers (bit-exact), Q4_0 pack path
# Conflicts:
#	src/kernels/cuda/dflash2.cu
2026-11-01 13:10:36 +01:00
Niko1221 89aa30476f Engine update beside a loaded model: never by itself, the person's yes first, stop cleanly, start again with the same settings; a quiet notice and a once-a-day check in the launch monitor 2026-11-01 13:09:21 +01:00
Niko1221 88a79a7efd FP16 weight cache (cda6463e8) OFF by default again, as in v0.2.9: gemm_dequant_side_parity fails with it on sm_86, sm_120 and sm_121 (the cached full-N GEMM rounds differently from the sliced form), so it is not bit-exact and has no 5%+/KL/owner yes. STRATA_PF_W16_CACHE_MIB=N still turns it on by hand. Flip back: set w16_budget_ = 16ll << 30 in the no-env branch of Gemm::native (gemm.cu) 2026-11-01 13:07:37 +01:00
Niko1221 0f7159ca35 Int3 merge f-0291-ds-left (386d2e441): #4677 stop bound, 5 experts by default on one RDNA4 card 2026-11-01 13:07:20 +01:00
Niko1221 4faa0cf0ee Models > This computer: free graphics memory and RAM from real readings, a loaded model changes the card, SSD only when it does not fit 2026-11-01 13:05:58 +01:00
Niko1221 d2c11867c0 Version list: only the chosen base model, recommendation and downloaded versions on top, the rest folded below, a Fits / Fits with SSD streaming word per row 2026-11-01 13:04:58 +01:00
Niko1221 386d2e4416 DeepSeek: 5 experts per word by default on a single RDNA4 card (R9700, RX 9000); --experts N still wins
One R9700 holds under a fifth of a 2.25 bpw set, so the CPU lane and the SSD set the speed, as on the 16 GiB cards and the multi-card RDNA4 rule that already take 5.
1 x R9700 on p620 (125 GB RAM), 2.25 bpw, frozen expert profile, 5 interleaved pairs, 5 against 6 experts: decode 15.24 against 13.08 tok/s (+16%, 5 of 5 pairs), a scripted agent loop -24% wall time (5 of 5), the long-read loop -12% (5 of 5). KL against the 6-expert run 0.030 (6 against 6: 0.008); against the original model 0.1106 against 0.1067-0.1071. quality027 quick: 18 of 19 with 5 experts and with 6, the same task flagged in both.
ds4_experts_rule.hpp: experts_per_word() takes single_rdna4 (the engine passes rdna4() with one device); DS4_DEFAULTS=026 gives the model's 6 back. The Terminal's Speed words (strata launch --plan and screens), the app's Speed label (arch added to RunCard) and docs/MODELS.md say the same. Several RDNA4 cards keep the existing rule (they must hold half of the experts); the screens still do not mirror that one.
2026-11-01 13:04:39 +01:00
Niko1221 142d5c6195 strata tune / tune recheck: the scratch folder the command makes for itself (strata-tune-*, strata-recheck-* in the temp folder) is removed when the run ends; --work-dir keeps its logs (G4 clean-machine finding: it was left behind) 2026-11-01 13:00:42 +01:00
Niko1221 c25a2de200 Sidebar preview: previewable pages open there first, and it comes in and goes out with motion 2026-11-01 12:57:56 +01:00
Niko1221 211ca210fb Code block preview is an icon button left of Copy, with an open and a close state (HTML and Markdown blocks) 2026-11-01 12:57:56 +01:00
Niko1221 b63a82e011 Code blocks: room before and after, a lighter 34px bar and tighter padding (12px floor kept) 2026-11-01 12:57:39 +01:00
Niko1221 e47848a319 Reasoning opens and closes smoothly: grows and fades, hover and press on the line, the rule belongs to the body 2026-11-01 12:57:39 +01:00
Niko1221 0a8499b0a3 Motion system: one press feedback and the reduced-motion gate for it (tokens in liquid.css section 17) 2026-11-01 12:57:39 +01:00
Niko1221 a44bcd4eb5 Copy: no Copied popover at the corner, the icon's check is the feedback (failures still say so) 2026-11-01 12:51:36 +01:00
Niko1221 a8e07c1b52 Version list: tags stay together on one line, a downloaded version shows its size without the word download 2026-11-01 12:51:20 +01:00
Niko1221 6f686536e5 Model versions list: each version once, plain names, small tags, details behind a toggle 2026-11-01 12:50:14 +01:00
Niko1221 f009913c6d Launch: the progress lines name a DeepSeek set as the screen does (ISTA-DAS 2.25 bpw, not 'deepseek41 IST-DAS-2.25bpw'), and the DeepSeek engine package is said to be about 430 MB, not 70 MB (G4 clean-machine findings) 2026-11-01 12:43:37 +01:00
Niko1221 b3dbb2c67c Light mode New chat: the shared primary look, green text on the tint (5.48:1) 2026-11-01 12:41:06 +01:00
Niko1221 63ae1eb3cd Settings: Privacy is one compact paragraph 2026-11-01 12:38:13 +01:00
Niko1221 87610346d6 Skills, Monitor and the other screens share the chat title size (one rule, tested) 2026-11-01 12:37:13 +01:00
Niko1221 7f36ac9b2e Tasks: header is just the title, at the chat's title size 2026-11-01 12:37:08 +01:00
Niko1221 ea6fcea1d8 Window title is Strata on every page 2026-11-01 12:34:39 +01:00
Niko1221 63a9b7d2ee Int3 fix (ds-left 67eb5f1f5 beside fixes 27060866e): the own-files disk verdict reads DeepSeek's memory words, so it only applies to the deepseek41 family; other families keep 27060866e's rule (no disk warning for files used where they are) 2026-11-01 12:34:18 +01:00
Niko1221 13658e773a Engine flow script: the server pid survives (no subshell) 2026-11-01 12:27:09 +01:00
hanchengme 243fdb213c serve: a request commits only the rows it can emit, so the parked snapshot matches what the client got (#4677)
In the single-request speculative paths the accepted rows of a verify window were committed first and the output loop cut to max_new or EOS afterwards, so a request's last window could park a snapshot with tokens the client never received. conversation_prefix() needs the whole snapshot to be a prefix of the next prompt, so the snapshot was refused and the older checkpoint was used (about 6% of a 12K-token prompt read again).
serve_stop_bound() caps the accepted rows at the rows the request can still emit and ends at the first EOS, before pl_commit_async. Merged prompt windows keep committing every row. HIP only (STRATA_USE_HIP), where it was measured.
Patch and policy test (2,736 cases against the old loop) by hanchengme, from the issue's attachment. Two changes on our side: it is on by default, STRATA_SERVE_STOP_BOUND=0 turns it off; the test is registered in CMake.
Measured on 4 x R9700, Qwen3.8-Flash-Next IQ3_S, 2 replicas x 2 cards, --batch 8 --batch-groups 2, four 11-15K-token conversations with 768-token answers, conversations opened one at a time: 4 of 16 continuations fell back to the older checkpoint without it, 0 of 16 with it (reuse 98.36% against 99.74%). Opened together as batch rows both builds miss the same continuations (slot overwritten before it is parked); not changed here.
2026-11-01 12:21:56 +01:00
Niko1221 58aeca07da Int3 merge f-0291-ds-left (67eb5f1f5): DeepSeek plan numbers, false disk warning. dsv41_fit.py: ds-left's accounting replaces the older AMD fit of 896818a3d (AMD_DENSE_ON_GPU_GB / AMD_RESERVE_GB); that commit's test (2,366 / 12,147 within 0.5%) is superseded by ds-left's MeasuredCardAccounting (within 3%, engine startup lines of d93547aed) and is removed 2026-11-01 12:20:52 +01:00
Niko1221 d92b6350ed Engine flow script: release files can be given from a folder (a box without internet) 2026-11-01 12:01:51 +01:00
Niko1221 75d7427789 Revert the T=5 HC down variant choice: in the engine the T5 step stays (hc down 0.77-0.79 ms either way, wall 16.17 against 16.14 ms), the cold screen's 0.7 us did not carry over 2026-11-01 12:01:48 +01:00
Niko1221 098154961d plain-errors test reads the engine catalogs too 2026-11-01 12:01:33 +01:00
Niko1221 295953de95 Engine tab host: a folder name must start with a letter or digit (never an option) before Control is asked 2026-11-01 12:01:33 +01:00
Niko1221 e6bb877df7 Engine tab: real-package flow script, update notes, update-cache test follows the split 2026-11-01 12:01:33 +01:00
Niko1221 5dea0d2abd Settings > Engine lists the installed engines (switch, delete, sizes); About only updates. Updates keep every older engine; strata components engines/use/remove share the app's code; DeepSeek has its own choice that launch, setup and the app follow 2026-11-01 12:01:33 +01:00
Niko1221 b5c237678a First-run model step starts on an installed ready model, else the catalog's recommendation, never the first size
GuidedSetup filled its family and size once at mount. When the catalog had not arrived yet it fell back to the first size in the list (Q2_0, a 66 GB download) even with an installed IQ3_S on the computer. Only the person's own choice is state now; the start is worked out from the catalog on every render (startingChoice in native-catalog.ts): a ready installed version first (the recommended one if it is among them), else the recommendation. No design change.
2026-11-01 12:00:32 +01:00
Niko1221 334e11b794 Int3: test_a_mac_is_stopped_before_anything_else follows the ISTA-DAS spelling of setup's stop message (my change in fbdcd5cbe) 2026-11-01 11:41:51 +01:00
Niko1221 ab583b6ad5 dflash2 GEMM kernels: pick the matrix of a warp's rows with constant indices (the by-value Df2Segs no longer lives in local memory: STACK 104 -> 0 in cuobjdump for every gemm4/gemm8/gemm_rows instantiation) 2026-11-01 11:40:39 +01:00
Niko1221 7d4feb2e8a HC read at T=5: the down kernel takes two rows a warp instead of the batched-staging variant (screen: 15.5 against 16.2 us, bitwise the same dots); mmvq_il_parity gets the 106,299-row draft head case 2026-11-01 11:38:10 +01:00
Niko1221 02996cf733 Int3 fix (test fixture; belongs to f-0291-term-small): StrixHaloCache's fake Strix Halo had vram_gb 2.0 beside shared_gb 112, which setup's detection never produces (vram_gb is the carve-out plus the shared pool); the new uma_cache_guard read the 2 GB as the whole pool and lowered the budget to 4 GiB, so test_cache_is_a_number_that_leaves_room failed. The fixture now says 114 2026-11-01 11:31:43 +01:00
Niko1221 54ef8615bb Int3 merge f-0291-term-small (da8c7f4a4): Terminal fixes and tests 2026-11-01 11:19:09 +01:00
Niko1221 da8c7f4a4f Tests: five terminal tests no longer depend on the box they run on (a system cloudflared, no llama.cpp checkout, Linux library path, the default data folder equal to the typed one) 2026-11-01 11:01:18 +01:00
Niko1221 ce3e621e34 UI merge fixup: five approved-item style blocks brought inside the owner's audit (npm run audit: 6 failures -> 0); behaviour unchanged
The owner's audit now scans every app sheet, and f-0291-ui's appended blocks broke
its rules (flat, tokens only, shadow only on surfaces that overlap content):
- .native-progress (#4802): gradient + --st-sh-pop shadow -> flat --st-accent-tint
  fill, the accent border stays (it still stands out; no shadow).
- .approvaldock > .askcard/.toolcard (#4725) and .approvaldock > .askq (#4244): the
  --st-sh-bar shadow dropped; the accent border that marks them stays.
- .st-code .st-btn--icon[data-copied] (#4748): the literal rgba(255,255,255,.08)
  is now color-mix(--st-code-ink 8%, transparent), the same recipe liquid.css uses
  for the code block edge.
- .quote-selection (#4785) floats beside selected words, so it is added to the
  audit's ELEVATION_ALLOWED list instead of losing its shadow.
2026-11-01 10:59:17 +01:00
Niko1221 fbdcd5cbea ISTA-DAS: the setup message that names the two sets and the DeepSeek doc's setup commands use the on-screen spelling (--model ISTA-DAS-2.25bpw is accepted by model_alias; IST-DAS-... and ist-das-... keep working) 2026-11-01 10:57:29 +01:00
Niko1221 2ff9783ac6 UI merge: the owner's 92 UI passes (f-gui-look working tree, taken read-only) onto f-0291-int3 with the approved UI list
Merged f-gui-look-snap (682c21d64): the owner's uncommitted wt-gui-look work as one
commit on 4cf0d8cd3, 32 files +343/-216 plus the two untracked files
apps/host/src/styles/liquid.css and apps/host/scripts/browser.mjs. Line endings were
normalised from CRLF to LF when the snapshot was taken (the raw working-tree diff of
~14,800 lines is the CRLF flip; nothing of it is carried here). The approved UI list
(f-0291-ui, 14 items) is already in f-0291-int3.

Three-way merge result: no textual conflicts. Files both sides changed, and how each
resolved:
- components/NativeModels.tsx (int3: progress card, context memory + KV hint; owner:
  Manage models ghost/sm + compact dialog, "Other local models" class, Expert scroll
  waits for layout): disjoint hunks, both kept.
- lib/plain-errors.ts (int3: out-of-VRAM rule; owner: unwrap {"error":..} / HTTP 400
  bodies before the rules run): both kept; the unwrap runs first, so a wrapped
  out-of-VRAM message still reaches its rule.
- screens/Serving.tsx (int3: two addresses, CopyBtn; owner: empty-state "Connect an
  app" is the primary button): disjoint, both kept.
- screens/Settings.tsx (int3: While-an-answer-is-written row, Select import; owner:
  Engine panel gets the "Engine" heading): disjoint, both kept.
- styles/app.css (int3: appended blocks + the reading-comfort rule; owner: edits in
  place): auto-merged, both kept.
- styles/onboarding.css (int3: language Select rules appended; owner: welcome
  headline, badge ink, glow removed): disjoint, both kept.
- components/TuneRunView.tsx also auto-merged (int3 +1 line, owner: progress bar uses
  transform).
2026-11-01 10:56:47 +01:00
Niko1221 682c21d641 Snapshot of the owner's UI passes 1-92 (wt-gui-look working tree, uncommitted, taken read-only; line endings normalised to LF; includes untracked liquid.css and scripts/browser.mjs) 2026-11-01 10:54:55 +01:00
Niko1221 8c6c0a8a9f Int3 (test only): cuda_spill_guard's ALLOWED table brought to the measured state. On v0.2.9 the guard already failed on 11 kernel families; now it passes on the merged tree on Windows MSVC sm_120, z440 sm_86+sm_120, boston sm_86, l620 sm_120 and Spark sm_121 and fails again on a real increase. New in 0.2.9: dflash2 gemm4_kernel (STACK 104, Df2Segs struct on the stack: BW-DRAFT) and prefill rms_rope_rows_kernel (STACK 32, RopeTab struct on the stack: STRIX-PF). No kernel changed 2026-11-01 10:52:27 +01:00
Niko1221 424a2af019 Int3 fix (belongs to f-0291-amd-q b9e3e2b86): prefill.cpp's no-fused stub block lacks fused::gfx12_iq2s_default, so a HIP build without STRATA_PREFILL_MMQ (every Windows HIP build that does not pass it) fails to link; release builds pass MMQ=ON and were not affected 2026-11-01 10:48:56 +01:00
Niko1221 7a87440629 DFlash2: drop the 128-thread QK tile (bitwise but 18% slower than the 64-thread tile in the bench) 2026-11-01 10:45:26 +01:00
Niko1221 2b32d0ff0b Int3 merge f-042-maya-rc (690f8b234): DS4_BORROW off by default on Metal 2026-11-01 10:44:03 +01:00
Niko1221 690f8b234a dsv41 Metal: the prompt path does not lend the experts' room by default (DS4_BORROW defaults off on Metal)
M5 Max, DeepSeek 2.25 bpw, 3 interleaved pairs: with the lent room a 639-token prompt took 6.2-7.6 s against 4.1 s with DS4_BORROW=0 (same bytes
read, the compute waited for reads equally long), 8K prompts -3.9%, 32K -20% (borrow also selects the 32,768 chunk); and the engine died
("mtl: wrap 7763574784 failed", exit 4) in 8 of 9 runs of a mixed 165/600-token/8K sequence that the build with DS4_BORROW=0 finished 3 of 3.
Only Metal changes (one_gpu_or is under STRATA_METAL); CUDA and HIP keep devs.size() == 1. DS4_BORROW=1 turns it back on.
2026-11-01 10:42:29 +01:00
Niko1221 9df71d5980 DFlash2: opt-in Q4_0 drafter pack (from 15165e170): dtype q4_0, tools/dflash2_pack.py --q4_0, nibble-packed tiled weights, gemm4_kernel, df2_q4_tile at load, CPU parity test dflash2_q4_parity 2026-11-01 10:20:59 +01:00
Niko1221 d7e50a515b DFlash2 drafter (bitwise): draft head through the interleaved Q5_K kernel, drafts only to the widest window - 1, R-split Q8 GEMM by matrix size, one rope table launch with precomputed inverse frequencies, selector projection beside the head, 128-thread QK tile variant (bench) and bench harnesses that check bitwise equality 2026-11-01 10:20:18 +01:00
Niko1221 0d2ee8defe Int3 fix (test programs only; four were already broken on MSVC in v0.2.9, gr_v3_screen is new): setenv / unsetenv / __builtin_popcount shims so a Windows build with tests ON compiles 2026-11-01 10:05:00 +01:00
Niko1221 4bc7e35e41 Int3 fix (belongs to f-042-strix-pf and f-0291 b89bf5947): Metal links again. stubs_0291.cpp (qsa_prompt_attn_batch_gated answers not taken, rms_rope_rows stops with its name), io_priority.cpp in the Metal host sources (strata_ds4 calls restore_normal_io_priority) 2026-11-01 09:58:02 +01:00
Niko1221 af8b47d45f Int3 fix (belongs to v0.2.9, f-0291 NVFP4 port): mmq_nvfp4_parity uses x86 F16C intrinsics; only built on x86 so an aarch64 CUDA build with tests ON no longer fails on -mf16c 2026-11-01 09:58:02 +01:00
Niko1221 67eb5f1f57 DeepSeek plan: experts on the cards from the engine's own accounting; own files need no free disk
The plan said 2,947 experts for one R9700 and 13,180 for four; the engine held 2,366-2,566 and 12,147-12,192. It took 4.5 GB of dense weights and 1.15 GB of reserve per card and forgot the driver context, the prompt buffers and the dense weights every card repeats. Constants now come from the engine's startup lines (f-042-maya-rc d93547aed): 2.25 bpw one R9700 2,543, four 12,260; boston 2 x A6000 within 1%, A4000 and RTX 5070 within 5%.
strata launch --model-path on files that stay where they are printed 'tight: needs more disk space' with nothing to download: the menu row counted the whole download against Strata's folder. With own files the verdict now comes from the memory words alone.
2026-11-01 09:56:34 +01:00
Niko1221 34c0f7dae7 Launch: a remembered card choice is said when the PC has more cards (NVIDIA DeepSeek after --gpu 0 stayed on one card without a word); Strix Halo: a fixed small expert cache lowers setup's own RAM budget (the person's cache and budget are only warned about) and a silent exit code 1 gets a plain next step 2026-11-01 09:51:26 +01:00
Niko1221 14c15d67a0 Tests: every shared DeepSeek file has a pin, so a copy placed by hand is checked locally and an offline PC with all files can start 2026-11-01 09:51:26 +01:00
Niko1221 ab6dfa03d6 Setup on a Mac: only this user's engine stops a model preparation (pgrep asks for this user's uid), so another person's server on a shared Mac no longer sends setup away with 'stop the server' 2026-11-01 09:51:21 +01:00
Niko1221 3410d5520d Int3 merge f-042-maya-rc (8e8e3cafa): Metal DeepSeek router NaN guard, dense Q4_K dequantizer
# Conflicts:
#	src/dsv41/ds4_gguf.cu
2026-11-01 09:50:55 +01:00
Niko1221 8e8e3cafad dsv41 Metal: router NaN guard in the MSL route kernels, and a dense Q4_K dequantizer; tests for both
Router: ds4_route and ds4_route_publish left an all-NaN gate row's pick at 0x7fffffff and read sc[0x7fffffff] (threadgroup memory) and
vram[0x7fffffff]; the test process dies with SIGBUS on the old kernel. ds4_route_fallback_pick takes the lowest expert not picked yet
(the CUDA/HIP guard's rule) and raises a flag in a buffer of the Metal host; router_nonfinite_take synchronises and reads it
(metal_host::router_bad_take), so ds4_main ends the request with the error it already has. The warning names the GPU on Metal; the
CUDA/HIP text is unchanged. Test: metal/tests/ds4_route_guard_parity.cpp (finite, all NaN, +-inf, half NaN, fewer than k finite).

Dense Q4_K: ds4_gguf.cu rows() sends a dense matrix of a type metal_host::dequant_rows does not know to iq::dequant_rows, whose CUDA
launch has no Metal dispatch and aborts the engine. ds4_dq_q4k (ggml's dequantize_row_q4_K, the Q5_K kernel without the fifth bit) is
added; any other dense type now fails with a plain message naming it. Test: metal/tests/ds4_dq_parity.cpp (Q4_K, Q5_K, Q6_K, Q8_0, BF16
against ref_quant, padded row stride).
2026-11-01 09:49:17 +01:00
Niko1221 02c367d5ce gfx12: fused IQ2_S / IQ4_XS gate/up kernels ON by default (STRATA_PF_FUSED_IQ2S=0 or STRATA_PF_FUSED=0 turn them off); the CPU share limit on gfx12 follows to 1,536 (owner 11-01) 2026-11-01 09:48:15 +01:00
Niko1221 cc703b5e48 #4040 --lookup-max-t 12 by default (serve, 8-row CUDA build, one GPU, no batch/DFlash2/layer split/chain); --lookup-max-t 0 or STRATA_LOOKUP_MAX_T=0 turns it off (owner 11-01) 2026-11-01 09:48:05 +01:00
Niko1221 daed630ed5 ISTA-DAS in the last user-facing lines: the setup --check list and the own-files message print the set's label (ids unchanged); comments 2026-11-01 09:38:03 +01:00
Niko1221 c24a6836aa Int3 merge f-0291-pr-amd (c31f1fa31) 2026-11-01 09:37:11 +01:00
Niko1221 6360274c8d Int3 merge f-0291-df2-spark (15165e170) 2026-11-01 09:37:10 +01:00
Niko1221 de856c7b8c Int3 merge f-0291-bw-next (4c5a55491); generate.cpp: req_df2 and draft_long both kept 2026-11-01 09:37:10 +01:00
Niko1221 ea9a58d8a9 Int3 merge f-0291-bw-df2serve (eeb21d0e4); generate.cpp: draft_df2 and draft_long both kept 2026-11-01 09:36:57 +01:00
Niko1221 0e2f24b6ad Int3 merge f-042-strix-pf (2970fe74a) 2026-11-01 09:36:39 +01:00
Niko1221 34f90e919c Int3 merge f-0291-gh2-speed (899935fae) 2026-11-01 09:36:38 +01:00
Niko1221 9e75dc6cf5 Int3 merge f-0291-pr-nv2 (e20842779) 2026-11-01 09:36:38 +01:00
Niko1221 af1cfe99cb Int3 merge f-0291-pr-nv (5e46c3415) 2026-11-01 09:36:38 +01:00
Niko1221 e208427797 Revert "serve: --lookup-long-match N, a lookup window past 8 rows only where the matched context is N tokens or more (opt-in, with --lookup-max-t)"
This reverts commit 394af4cd0f.
2026-11-01 06:51:24 +01:00
Niko1221 d10d67b005 docs: DETAILS.md, --lookup-max-t row now says 12 and carries the cap and agent-loop measurements 2026-11-01 06:51:24 +01:00
Niko1221 394af4cd0f serve: --lookup-long-match N, a lookup window past 8 rows only where the matched context is N tokens or more (opt-in, with --lookup-max-t)
Keeps the wide lookup sizes for copy-heavy text (a rewrite matches long) and leaves lists and prose, which match short, at the 8-row cap. Off by default. Tried and reverted in the next-but-one commit: it did not remove the prose cost of 16 rows and cost rewrite gain.
2026-11-01 06:51:24 +01:00
Niko1221 5e46c34159 DGX Spark: STRATA_SIDE_MIXER=2 is one of the sm_121 defaults (docs: five speed defaults)
Port of pull request 4737 by InB4DevOps (commit c14ee15d9 on v0.2.8). The mixer's input-only QSA/GDN work runs on graph branches; scheduling only, no arithmetic change.
STRATA_SM121_PERF=0, STRATA_DEFAULTS=026 or STRATA_SIDE_MIXER=0 turn it off. Measured here on a Spark, IQ3_S, greedy: ids identical 48 of 48; alone +1.0..+2.9% per class, with mode 8 +3.6% pooled.
Not yet measured at parallel 4.
2026-11-01 06:06:57 +01:00
Niko1221 f94a27da60 CUDA: grouped native experts use mode 8 on DGX Spark (sm_121) with the sm_121 defaults
Port of pull request 4699 by InB4DevOps (commit 76c0aa601 on v0.2.8). The existing mode 8 kernels are selected for compute capability 12.1 when the sm_121 table is on;
STRATA_SM121_PERF=0, STRATA_DEFAULTS=026 and an explicit STRATA_EXP_MODE keep the old mode 0.
Measured here on a Spark, Qwen IQ3_S, serve, greedy: bit-exact (ids identical 48 of 48), with the side mixer +3.6% decode pooled over 8 prompt classes (4 rounds), mode 8 alone +0.3..+2.4%.
2026-11-01 06:06:56 +01:00
Niko1221 2970fe74a3 QSA prompt attention (matrix-core kernel): less LDS a block and the next chunk's ids read ahead, same output bits
prompt_attn_wmma_kernel was limited by its LDS (21,648 B a block, 5 waves a SIMD), not by VGPRs (amdgpu_waves_per_eu
and amdgpu_num_vgpr change nothing here). Two exact changes, on by default:
- STRATA_PA_TRIM (default 2; 0 off, 1 first level): the score and probability buffers hold the 12 real query rows
  instead of 16 (rows 12..15 of the WMMA are zero queries nothing reads; the 4th wave skips the softmax of them),
  and at level 2 the staged V rows are 72 bytes instead of 80. LDS 21,648 -> 19,008 -> 17,984 B, 7 waves a SIMD.
- STRATA_PA_RPF (default on; 0 off): chunk ci + 1's selected-cell id and page-table reads are issued before chunk
  ci's compute.
The arithmetic and its order do not change. hip_prompt_attn_wmma checksums are identical across all variants (32K
context 8f3a85a9cb885e7e, 8K context de4684fb3f370000), and engine first-token logits with every exact lever of this
branch on against off are byte-identical on a 4.5K and an 8K prompt (IQ3_S pack). Kernel time, gfx1151, int8 KV,
STRATA_PA_FAST: 32K context / 2,048 queries 11.05 -> 9.95 ms, 8K context / 4,096 queries 21.76 -> 18.90 ms.

The idea of raising the kernel's occupancy is the qsa3 change of halo-box/strix-llama.cpp PR 213 (MIT, The ggml authors);
the means here differ and the code is ours.
2026-11-01 05:44:44 +01:00
Niko1221 c31f1fa31b gfx12: matrix-core QSA scorer (STRATA_SELECT_WMMA) past 28,672 tokens by default (changes the answers there; separate, droppable commit)
Needs the owner's yes: the existing opt-in STRATA_SELECT_WMMA=1 with STRATA_SELECT_ALT_MIN_TOKENS=28672 becomes the gfx12 default in
gfx12_extra.hpp, with its own log line.  A chunk whose context is below the floor keeps the warp kernel, so a prompt that stays under about
28K tokens is bit-identical (first-token KL 0.00000 on twelve 8K-24K prompts).  Off: STRATA_SELECT_WMMA=0, STRATA_GFX12_DEFAULTS=0 or
STRATA_DEFAULTS=026; a variable the user sets is kept.  Chosen over PR 3073's simt2 (author Vop3p) on the data.
R9700 (p620, IQ3_S, serve, interleaved pairs, prompt tok/s against unset, 3 pairs): 32K +1.83% (3/3), 98K +5.74% (3/3); first-token KL on seven
30K-96K prompts mean 0.0018, max 0.0055, top-1 7/7; needle recall 25/25 (8K to 96K, 5 depths).  Without a floor: 24K +3.34%, 32K +4.5..6.0%,
98K +6.9..7.3% but values change from 8K up (KL 0.0056); simt2 is slower below 32K.  arch_defaults_test now clears the variables this
default sets before its integrated-GPU checks; gfx12_extra_test covers the default and its off switches.
2026-11-01 04:50:11 +01:00
Niko1221 98d4f2033e Trial fix (belongs to f-0291-pr-intel 8fffe81d4): sycl_queue.hpp line 164 holds a raw NUL byte inside a character constant (the backslash-zero lost), tools/test_source_nul_bytes fails 2026-11-01 04:25:33 +01:00
Niko1221 899935faed Expert arena give-back message: the line ends with a newline escape again (a literal line break had split the string) 2026-11-01 04:03:37 +01:00
Niko1221 10ce6ae508 Trial fix (belongs to f-0291-gh2-speed eb8917247): expert_source.cpp #4846 message has a raw newline inside the string literal (the backslash-n was lost), so the file does not compile on any backend 2026-11-01 04:02:39 +01:00
Niko1221 b25c0c323d Trial2 merge f-0291-dq-test (e6d81d7a2) 2026-11-01 03:46:14 +01:00
Niko1221 0b244c0346 Trial2 merge f-0291-engine-card (27de07cef): NativeModels.tsx imports both EngineUpdateCard and duration; onboarding.css keeps both rule sets 2026-11-01 03:46:14 +01:00
Niko1221 2e8fb221cf Trial2 merge f-0291-gh2-app (0dfa2d9b1) 2026-11-01 03:46:05 +01:00
Niko1221 a17b79a7e8 Trial2 merge f-0291-keepcurrent (94edf723c): ds4_iq.cu keeps dq_old_ok<T>() (already on the line from 9d58393db), which covers keepcurrent's staging-fit condition 2026-11-01 03:46:05 +01:00
Niko1221 01f804ea18 Trial2 merge f-0291-mac-q (8e7ed6818) 2026-11-01 03:45:53 +01:00
Niko1221 9d642d1f71 Trial2 merge f-0291-amd-q (b9e3e2b86): share_rules/prefill.cpp take amd-q's gfx12 share limit; native_bf16.cu keeps bw-dense's per-device mmvf_fast and adds amd-q's STRATA_MMVF_CHECK mode on top (mmvf_fast_bit + mmvf_fast) 2026-11-01 03:45:53 +01:00
Niko1221 3fe5edd315 Trial2 merge f-042-strix-pf (66545a497) 2026-11-01 03:45:21 +01:00
Niko1221 87b2a61251 Trial2 merge f-0291-gh2-speed (eb8917247) 2026-11-01 03:45:21 +01:00
Niko1221 62db0bf10f Trial2 merge f-0291-pr-amd (82eaeb422): verify.cpp head keeps both: pr-nv2's chunked head (h0/HT, up to 8 columns a launch) and pr-amd's fused q8_1 image (q8_head, image at the chunk's own xq_ offset) 2026-11-01 03:45:21 +01:00
Niko1221 5438023a6a Trial2 merge f-0291-pr-intel (2b4d43bac) 2026-11-01 03:44:34 +01:00
Niko1221 2716defb83 Trial2 merge f-0291-pr-nv2 (64bf69080) 2026-11-01 03:44:33 +01:00
Niko1221 1306424beb Trial2 merge f-0291-pr-nv (97b3d614f) 2026-11-01 03:44:30 +01:00
Niko1221 4c5a554912 IQ4_XS 5-column interleaved MMVQ runs as 6 columns with 5 stored (STRATA_IL_PAD, bitwise); --draft auto: MTP requests keep the MTP's prompt-lookup width 2026-11-01 03:28:12 +01:00
Niko1221 d93547aed5 dsv41: no warm_flow on Metal (it aborted the engine at start)
warm_flow() runs cublasSgemm / cublasGemmEx on tiny scratch inputs at load. The Metal compatibility layer (metal/include/metal_compat/cublas_v2.h) only has the dispatches the real flow uses: its sgemm_tn found no host view for the scratch buffers and the call ended in strata_metal_dense_gemm_missing(), an abort (exit code -6) on every start, restarted in a loop by the server. start_warm() now returns at once under STRATA_METAL. Found on an M5 Max with the 2.25 bpw set: the v0.2.9 engine answered the four checks; this tree aborted until DS4_WARM=0 was set.
2026-11-01 03:13:37 +01:00
Niko1221 82eaeb4221 gfx12: STRATA_HEAD_MIX_MULTI=1 by default (exact; own function, droppable alone)
The final hyper-connection mixer of the verify window and of the draft layer as one fused_gr_read_multi instead of one gr_read per
token (PR 3264, author bsorensen110).  Kept apart from the main gfx12 table in gfx12_extra.hpp so it merges with the table on
another branch without touching it; STRATA_GFX12_DEFAULTS=0, STRATA_DEFAULTS=026 and a variable the user sets decide first.
R9700 (p620, IQ3_S, serve, static residency, 8 interleaved pairs with an A/A arm): json_extract +0.82% (6/8), agent +0.63% (6/8),
code_py -0.96% (3/8, that prompt's A/A moves 3.5% a pair); greedy ids identical in 16/16 and 15/16 request sets.
2026-11-01 03:10:59 +01:00
Niko1221 66545a497a QSA norm + rope fusion: keep the square out of the first butterfly add so the sum of squares rounds as rms_rows_kernel's does
On gfx1151 the compiler folded x * x into the first add of block_sum (fma of this lane's x with the other lane's
square), a different rounding than the sum of two rounded squares, so about 4% of rows came out with a scale one ulp
off. An empty asm on the square stops the fold (__fmul_rn compiles to the same fma). Checked in the assembly
(v_mul then v_add) and on the device: hip_qsa_nr_parity 44 of 44 OK, every output element bit for bit, T 1..8191;
engine first-token logits with STRATA_QSA_FUSE_NR / STRATA_QSA_FUSE_GATE on and off are byte-identical on a 4.5K and
an 8K prompt (IQ3_S pack).
2026-11-01 03:06:40 +01:00
Niko1221 64bf690800 window limit: STRATA_WINDOW_MAX_T=12 as a third value (8, 12, 16)
The exact-column instantiations 13..16 of the wide kernels (native MMVQ, the wave variant, the packed Q8_0 and Q6_K launches,
the BF16 multi GEMV) are built only above 12, MAX_NCOLS follows the cap, and kVerifyLongT follows a wide cap (a cap of 12 keeps
--lookup-max-t at 12). Measured for the narrow/wide comparison in phaseA-tests/pr-priorities/RESULTS-NV2.md.
2026-11-01 03:02:28 +01:00
Niko1221 55ab701e1b QSA selector: STRATA_SELECT_ALT_MIN_TOKENS keeps the warp kernel for short reads; the community simt2 default of gfx103x starts at 28,672 tokens 2026-11-01 02:57:52 +01:00
Niko1221 5206a299f8 docs: DETAILS.md rows for --lookup-max-t, STRATA_PF_GROUP_SCRATCH / STRATA_PF_HC_REGION, STRATA_PW_WITH_BATCH, STRATA_MTP_ROOM_MIN, with the numbers measured on f-0291-int-trial
The four switches are ports of PRs 4040 (merbanan), 4039 (merbanan), 3095 (Vop3p) and 3307 (gopinath87607); all stay off by default.
2026-11-01 02:57:12 +01:00
Niko1221 b130965ca6 AMD community tuning: the simt2 QSA selector (PR 3073, author Vop3p) is the default on gfx103x only
Mode 2 of STRATA_SELECT_SIMT was added for HIP in the previous commit as an opt-in.  For the RX 6000 cards its contributor measured it
on (gfx103x) it now joins the one community switch: on by default with STRATA_AMD_COMMUNITY_TUNING on, any STRATA_SELECT_SIMT value
the user sets wins, and no other card changes.  Values change in the last bits (another summation order); the contributor's numbers are
labelled as theirs in docs/AMD_HIP.md and have not been reproduced (no gfx103x card here).  amd_community_test checks the default and
that the switch turns it off.
2026-11-01 02:15:38 +01:00
Niko1221 b3e73dbfb6 Quality ids files: a token id outside the model's vocabulary fails loudly (the engine's --score / --profile-run / --spec-eval, and quant_quality.py run)
data/quant_quality/v1/set.ids.json is tokenized for Qwen (ids up to 248,069); fed to the DeepSeek engine it gave nonsense text and, for the
runs that dump logits, a late "token out of range".  read_score_ids now takes the model's vocabulary and refuses the file at once, naming the
segment, the id, its position and the fix; quant_quality.py run does the same against its config's tokenizer folder (or the vocab_size that
`ids` now records in the ids file it writes).
2026-11-01 01:54:24 +01:00
Niko1221 2b4d43bac2 sycl/CMakeLists.txt: drop the duplicate native_expert_bench target the #4185 port added (this tree has it already) 2026-11-01 01:46:33 +01:00
Niko1221 161eb97fc6 docs/INTEL.md: the #4185 decode kernels, the build option and the controls, with what is on by default 2026-11-01 01:30:29 +01:00
Niko1221 eb8917247a Expert arena: when the RAM still falls short after the wait, the PLE table lock the residency policy took is given back before the arena is registered (#4846) 2026-11-01 01:29:51 +01:00
Niko1221 4a7bb1d06d SYCL: only the six #4185 controls that measured a gain are on by default on Battlemage (MMVQ_SHAPE_RPW, IQ2S/IQ3XXS/IQ3S_GU_ROW_REUSE, GR_LO_REUSE, ATTN_MERGE_ACTIVE); the other eight stay opt-in (=1) 2026-11-01 01:28:51 +01:00
Niko1221 b9e3e2b86a gfx12: opt-in switch STRATA_PF_FUSED_IQ2S=1 for the native fused prompt kernels on the IQ2_S and IQ4_XS gate/up layers (Linux; the IQ3_S pack's 21 layers that keep MMQ), and the CPU share limit drops from 3,072 to 1,536 tokens only when it is on (share_rules::gfx12_share_limit), so a chunk from 1,536 up streams all and runs them. OFF by default: nothing changes without the variable. R9700 (p620, IQ3_S, 54% resident), on against the share-only default: prompt 2K +10.8%, 4K +12.2%, 8K +13.2%, 32K +10.0% (5 pairs each, 5/5), 1K and 1.5K unchanged; values change (first-token KL against the FP16 prompt path 0.0150 vs MMQ 0.0263). The quick quality suite lost 2 of 19 tasks with it on (summ-M-d14, code-S-d04) where off and off again agree, so it stays opt-in until the full 61-task suite has run. Separate commit: drop it alone to go back to the share-only default 2026-11-01 01:16:23 +01:00
Niko1221 8e7ed68181 Metal: STRATA_METAL_NFCHECK=1 counts non-finite logits and the largest |logit| in every head window (test tooling)
The engine's #879 check only sees logits that come back to the host; the Metal engine picks tokens on the GPU, so a half-precision overflow (STRATA_METAL_F16) would show as wrong text, not as an event. With the variable set, each head window's logits are scanned: a line for the first five windows that held a non-finite value, a count every 500 head windows. Off by default, no cost unset.
2026-11-01 01:07:41 +01:00
Niko1221 8fffe81d4b SYCL: the #4185 decode controls default on for Battlemage (STRATA_<name>=0 turns each off), STRATA_SYCL_IQ_DECODE_FAST default ON in CMake and passed by the SYCL build scripts; STRATA_SELECT_GEMM_MIN sets the block threshold of the #3248 GEMM block scores
Controls of glorysdj's pull request #4185: unset means on for a Battlemage device and off for every other device (strata::bm_default_on); a set value still decides. Measured results are in phaseA-tests/pr-priorities/RESULTS-INTEL.md.
2026-11-01 00:50:05 +01:00
Niko1221 3277bfcd71 Metal: opt-in fp16 GEMVs and Q2_0 experts in decode windows (STRATA_METAL_F16=1), from PR 4807 by rasa04
Unset, the output is unchanged (the build with the change, unset, gives the same greedy ids as the build without it in 12 of 12 runs, gemv_parity / gr_parity / expert_parity pass). With STRATA_METAL_F16=1 the decode windows of a resident model use half-precision GEMVs (Q2_0, Q3_K-Q6_K, IQ4_XS) and the Q2_0 routed experts.
M5 Max, spec 3 + min-p 0.5, 3 interleaved pairs, decode tok/s unset -> on: Q2_0 story 105.0 -> 114.9 (+9.4%), code 125.6 -> 143.7 (+15.5%), json 130.8 -> 135.1 (+3.3%, one noisy pair), agent 116.1 -> 131.6 (+13.4%); IQ3_S (dense GEMVs only) story 93.1 -> 104.3 (+8.8%), code 103.6 -> 118.5 (+12.5%), json 122.5 -> 135.4 (+10.6%), agent 103.8 -> 117.8 (+12.3%).
It changes arithmetic: teacher-forced over 4,995 positions against the same build unset, Q2_0 top-1 97.12%, mean KL 0.0094 (p99 0.119), perplexity 4.456 against 4.442; IQ3_S top-1 96.16%, mean KL 0.0160 (p99 0.211), perplexity 4.119 against 4.135. The half range is not guarded (see the PR). Off by default.
2026-11-01 00:39:53 +01:00
Niko1221 7737bca3eb Prompt path: one start-up line says whether the QSA norm + rope and attention-gate fusions are on 2026-11-01 00:34:24 +01:00
Niko1221 27de07ceff App: one card tells a person with an older engine that a newer one is available, with one button (Update engine); #4848
After an app or Control update the engine is never touched, and the app said nothing. Strata Control keeps what the last update or check found newer
(update_available of 'strata components engine', Qwen engine and DeepSeek engine; read without the network); the host passes it to the app as
engine.updateAvailable and the Models screen shows one card in plain words with the existing Update engine job as its button (disabled while a job
runs or a model is loaded). A DeepSeek engine update is reported as an engine update, not as 'Strata is updated'. JavaScript only; the Control side is
on f-0291-keepcurrent.
2026-11-01 00:34:13 +01:00
Niko1221 3304347bde dsv41 (gfx11 dequantizer): dq_old_ok is __host__ __device__, so the CUDA build can call it from dq_rows_kernel
c9e0d6688 uses it in a __global__ function; nvcc refuses a constexpr host function there (error: calling a constexpr __host__ function from a __global__ function is not allowed). HIP accepted it, CUDA did not: the f-042-maya-dq line did not build for NVIDIA.
2026-11-01 00:32:39 +01:00
Niko1221 9d58393dbd dsv41 (gfx11): the dense dequantizer defaults to dq_rows_kernel2 on every type; the old row-per-lane kernel is DS4_DQ_OLD=1 only, and only for types its staging serves
The old kernel (the gfx11 default since 0.2.7, picked for speed) gave different dense weights on every run on a Strix Halo: layer 0 of a
200-token prompt differed by 27% between two runs of the same engine on the 2.25 pack (Q5_K) and by 26% on UD-IQ1_S, while dq_rows_kernel2
is bit-identical run to run and 1.2-1.3% from the window path (GEMV, no dequantizer).  A gfx11 device now defaults to kernel2 whatever
DS4_DEFAULTS says; DS4_DQ_OLD=1 forces the old kernel there and elsewhere, but only for a type whose staging fits (Q4_K, Q5_K, Q2_K:
dq_old_ok); Q6_K (840 bytes against the 704-byte buffer) and the rest always use kernel2.  2.25 prompt speed on the Strix Halo: 4K 166.1 -> 164.9
tok/s, 8K 297.9 -> 294.2 (3 runs each, -0.7% / -1.2%).  UD-IQ1_S's long prompts and agent loop work.
2026-11-01 00:22:11 +01:00
Niko1221 db1bfaaae1 mtp: the draft layer's fused q8_1 images follow STRATA_QFUSE_HC and gfx12 only, not STRATA_QFUSE (gfx1151 keeps what it ran) 2026-10-31 23:58:11 +01:00
Niko1221 606f6272dc serve: STRATA_MTP_ROOM_MIN, a long prompt takes the MTP drafter's 512 decode-only experts as ring (opt-in)
Port of PR 3307 by gopinath87607 (rebased onto f-0291-int-trial, three commits squashed; the mtp.cpp signatures keep the
gated-proposal DraftResult argument and the prompt-helper arming keeps the failed-allocation cudaGetLastError).

STRATA_MTP_ROOM_MIN=TOKENS (--serve with --mtp): a request that reads at least TOKENS prompt tokens points the prompt
path's ring at the drafter's 512 routed experts while it is read. The block is lent in place: never freed, never allocated
again, never moves, so no prompt path is laid out again and the decode's captured graphs stay valid; the decode's own bytes
come back (a DMA from a pinned copy) before the first window. Unset, nothing of it runs.
2026-10-31 23:51:16 +01:00
Niko1221 f7bd10209f ds4_route_guard_test: the non-finite flag 2026-10-31 23:42:48 +01:00
Niko1221 97b3d614fc hc_q8_stream_test: spin on the clock where __nanosleep does not exist (sm_60 / sm_61), so the test target builds with tests on for Pascal
Follow-up to the port of pull request 4715 by cedrik-fuoco: the test failed to compile on sm_60 (identifier __nanosleep is undefined).
2026-10-31 23:42:10 +01:00
Niko1221 c940106485 prefill: say when STRATA_PF_GROUP_SCRATCH / STRATA_PF_HC_REGION took effect, and why GROUP_SCRATCH is ignored where the layout is not all MMQ
Follow-up to the port of PR 4039 by merbanan. The switches stay opt-in and the prompt path is unchanged when they are off;
the lines are the activation evidence the test plan asks for (chunk, rows held).
2026-10-31 23:41:31 +01:00
Niko1221 03627326dd serve: --lookup-max-t N, prompt-lookup windows up to 16 tokens while the MTP keeps --spec (opt-in)
Port of PR 4040 by merbanan (rebased onto f-0291-int-trial, bitwise by the author's checks).

--lookup-max-t N (up to 16) lifts only the prompt-lookup windows to N tokens; the MTP drafts keep --spec. A window
past kVerifyMaxT tokens runs as two token groups of at most kVerifyMaxT (the --spec-split layout), the head runs in
column chunks, the batched GDN commit takes up to 16 kept rows, MtpDrafter::draft_long catches the drafter up past its
window, and every lookup size is its own window graph (--lookup-sizes A,B picks them; one that finds no VRAM is dropped).
A build whose windows already hold 16 rows (STRATA_WINDOW_MAX_T=16, the HIP default) runs the 16 as one group.
Batch, pipelined and layer-split windows stay at kVerifyMaxT. Without the flag nothing changes.

Port notes: kVerifyLongT = 16 for both window widths, the per-window-size graph arrays keep the trunk's
[sel_long_][T] layout, the head chunk loop keeps fused_gr_read_multi's own slicing, the penalty history is sized for
the widest window only when --lookup-max-t is past 8, and draft_long forwards the gated-proposal view.
2026-10-31 23:38:42 +01:00
Niko1221 3b3f3b6bd0 prefill: STRATA_PREFILL_CPU_SHARE_BATCH=1 (opt-in) gives the prompt path the CPU pool beside batch slots, for a read with no slot decoding
Port of pull request 3382 by Vop3p (commit 2b65710f9 on v0.2.0-beta), rebased by hand on the 0.2.9 trial tree.
Conflict in generate.cpp: the share gate goes first in run(), before the co-read branch, and the kva_floors / set_cell_floor bookkeeping after the read is
kept as it is. Addition to the original: a read run on its own thread beside decoding slots (STRATA_BATCH_CONCURRENT_READ=1, run_conc) keeps the share
blocked for the whole read and does not touch the slots' state from the reader thread. Off unless the variable is set.
2026-10-31 23:36:21 +01:00
Niko1221 1f8ca8de1e RDNA4 routed-expert, draft, shared-expert and head kernels (PR 3259 and PR 3264, author bsorensen110)
Ports the two RDNA4 pull requests onto this tree by hand (three-way merge of the rejected hunks).  Where the contributor made a
default depend on a device query, the default is the gfx12 table or the env variable instead, so STRATA_GFX12_DEFAULTS=0 still
gives the old behaviour; the LDS-only barriers and the S2 and head changes that were only run on gfx1201 apply to gfx12 only,
every other target keeps what it ran before.

- iq_kernels.cu (3259): LDS-only barriers in the grid / activation staging, first-pass barrier skipped, vectorised ed_stage_entry,
  expdec_gu_kernel launch bounds in waves per EU on HIP, EXPDEC down read at first use; expdec_bench builds on HIP.
- s2_expert_grouped.cu (3264): single-entry gu/down specialisation for the draft layer, fused swiglu + q8_0 on the hit paths,
  LDS-only barrier on gfx12.
- verify_kernels.cu, quantize_act.cu, elementwise.cu, shared_expert.cu, fused_gr.cu, mtp.cpp, verify.cpp (3264): multi-block argmax
  and top-prob on gfx12, warp-per-block quantize_q8_0_scaled, mapped copies in one launch on HIP, shared expert reuses the q8_1
  image, counter-free q8_mixed in the hyper-connection read, the draft layer and the head write the q8_1 image themselves.
- parity tests of both pull requests (verify_parity on HIP, s2/shared/gr parity cases).
2026-10-31 23:35:50 +01:00
Niko1221 15165e170b DFlash2: opt-in Q4_0 drafter pack (dtype q4_0, tools/dflash2_pack.py --q4_0): nibble-packed tiled weights, gemm4_kernel on the existing dp4a activations path, df2_q4_tile at load, CPU parity test dflash2_q4_parity (40 of 40 cases within 2.4e-6 of 19) 2026-10-31 23:33:22 +01:00
Niko1221 55ae7814d8 HIP SIMT2 selector (PR 3073, author Vop3p) 2026-10-31 23:33:11 +01:00
Niko1221 1db96cda5f serve --draft auto: --draft-rule learn picks per kind of request (first prompt tokens) the drafter with the better measured decode speed, with exploring 2026-10-31 23:32:24 +01:00
Niko1221 3eb91676df AMD community tuning: gfx1010 SDWA dot4 and two gfx1030 rocBLAS tables under one switch
Ports three community changes that we have no card to run (no gfx1010, no gfx1030 here), on by default under the one
setting STRATA_AMD_COMMUNITY_TUNING (=0 turns the tables off; cmake -DSTRATA_AMD_COMMUNITY_TUNING=OFF compiles the portable
dot for gfx1010 again).  Each is limited to the card or exact library build it was made for.

- gfx1010 (RX 5700 / 5700 XT) signed dot4 takes the SDWA byte-select sequence gfx1012 already used: PR 4855, author Keoian.
  Same arithmetic, four signed byte products accumulated modulo 2^32.
- rocBLAS table gfx1030 / 5.4.0.ad84daa81c (TheRock gfx103X-all 7.13 wheels): PR 3343, author elightcap.
- rocBLAS table gfx1030 / 5.2.0.dabb6df2b98-dirty (Arch Linux ROCm 7.2.4): PR 3924, author schmittcn.

include/strata/core/amd_community.hpp holds the switch, the list of community tables and the engine log line (printed only
on gfx1010 and gfx1030); gemm.cu refuses a community table with the switch off, the existing loader still refuses any table
whose architecture or full rocBLAS version string differs.  amd_community_test checks that the switch turns each one off.
The contributors' numbers are labelled as theirs in docs/AMD_HIP.md and have not been reproduced.
2026-10-31 23:30:43 +01:00
Niko1221 7a498a05b4 serve --draft auto: the idle drafter's upkeep is queued on its own stream and waited for before the next window (DFlash2 context_async / context_wait, MTP prefill without sync) 2026-10-31 23:29:51 +01:00
Niko1221 3cc47f0297 prefill: STRATA_PF_HC_REGION=1, the hyper-connection read's scratch in the shared region (opt-in, bitwise)
Port of PR 4039 by merbanan (rebased onto f-0291-int-trial; same bits).

The hyper-connection read's scratch (xn16, lo, lo16, gated and the BF16x2 low halves) is taken as a fourth set of the
region the attention half and the MoE half share: a half's read is done with it before that half's set is written, and
the fused write at a half's end (gr_write_norm_rs: the next half's xn16) runs after the half's last region reader on the
same stream.  The same kernels on the same values: the same bits, ~63 KB a token less outside the region, so --prefill
auto finds a bigger chunk.  Off by default, and off with the unfused hyper-connection path.

bytes_needed also counts STRATA_PF_GROUP_SCRATCH's larger bounds table (bounds_ints), as carve takes it.
2026-10-31 23:29:01 +01:00
Niko1221 fbf6e1c753 prefill: STRATA_PF_GROUP_SCRATCH=1, the MMQ path's GU / H / Hq hold one expert group's rows (opt-in, bitwise)
Port of PR 4039 by merbanan (rebased onto f-0291-int-trial; the group-scratch path stays off for weight-stationary layers, whose launches write the whole chunk's rows into GU; the WS offsets follow bounds_ints).

A streamed prompt chunk sends every non-resident expert of every layer over PCIe once, so the chunk size sets
how often an expert crosses the link per token.  The chunk is capped by its buffers, which live in the cache
slots the prompt path borrows; on an 8 GB card (RTX 2060 SUPER, IQ3_XXS, 1453 slots) a 4096-token chunk took
1198 of the 1325 lendable slots, and --prefill 8192 fell back to 4096.

The MMQ path's gate/up output, its SwiGLU and its q8_1 copy were sized for the whole chunk (T*K rows, ~84 KB
a token), but a group's products read them and are done before the next group starts.  With the switch they
hold one group's rows: gate/up writes its absolute rows at GU - ra, SwiGLU / quantize / down read them from 0.
The cap is T (one expert's most: a token routes to an expert once) plus three times a group's average; a
group that passes it runs as sub-ranges of its experts, each with its own relative bounds appended to the
bounds table.  Only on a layout whose every layer takes MMQ (no fused experts, no FP16 fallback); a layer
that would take another path stops the prompt with a message instead of overrunning the smaller buffers.
STRATA_PF_GROUP_CAP=<rows> forces a smaller cap (test knob).

Measured (RTX 2060 SUPER, Ryzen 9 3900X, Qwen3.8-Flash-Next GSQ-RCO IQ3_XXS, resident RAM, k8v4 + KV
streaming; v11 at --prefill 4096 vs this at 5120, 3 pairs, 3 requests per load):
- --prefill auto: 4096 tokens (1197 slots) -> 5120 tokens (1219 slots); 6144 and 8192 still do not fit
- prefill 4K 445.4 -> 449.3, 16K 456.8 -> 493.5 (+8.1%), 32K 456.2 -> 504.5 (+10.6%); decode +0.1 / +0.4 / -0.9
- first-token logits after a 15.6K-token prompt at the same chunk size: bitwise equal to the full buffers;
  with STRATA_PF_GROUP_CAP forcing sub-ranges on most groups KL 0.001 (MMQ's stream-k order per launch)
2026-10-31 23:29:01 +01:00
Niko1221 5a41f3b391 SYCL: #4185 opt-in mixed-IQ3 decode optimizations on Battlemage (from glorysdj's PR #4185), ported onto this tree, not yet measured
Ported from glorysdj's draft pull request #4185 (commits 961f62963 and 0726ef5ad of that branch; the documentation commit is not taken).
A compile option STRATA_SYCL_IQ_DECODE_FAST (default OFF: packed Q2 expansion, IQ4 bit-select lookup, fast IQ3_S sign arithmetic) and fourteen runtime controls, all default off:
STRATA_IQ3S_GU_SLM, STRATA_IQ4NL_DOWN_SLM, STRATA_RESIDENT_PLAN_COMPACT, STRATA_IQ2S_GU_ROW_REUSE, STRATA_IQ3XXS_GU_ROW_REUSE, STRATA_GR_CACHED_NORM, STRATA_GR_LO_REUSE, STRATA_MMVQ_SHAPE_RPW, STRATA_GDN_QK_REG, STRATA_EXPERT_ROW_E3, STRATA_Q6_SHAPE_GRF, STRATA_IQ3S_GU_ROW_REUSE, STRATA_GR_REDUCE_ILP, STRATA_ATTN_MERGE_ACTIVE.
Conflicts resolved against this tree: the kprof launch wrappers kept (new launches in the conflicted hunks use strata::kprof::kp), the resident-plan launch keeps the kprof wrapper with the compact thread count, native_expert_bench.cpp keeps this tree's fixed bench (the PR's older version of the same hunks is dropped), qsa_prompt_attn_parity.cpp keeps the per-cell check and adds the PR's FP32HASH line and QA_FP32_GRAPH timing.
Also in the diff: docs/INTEL.md section from the PR.
2026-10-31 23:26:20 +01:00
Niko1221 b3ff140d68 Prefill: the fused ring's layer check is cached per CUDA device, so a layer split over unlike cards keeps MMQ's buffers where it must
Port of pull request 4380 by moses-sun (commit 5d5e380c4 on v0.2.7), applied without conflict. Refs issue 3577. Homogeneous rigs answer what the single cached bool used to;
not run on a heterogeneous split (no such box here).
2026-10-31 23:26:18 +01:00
Niko1221 0922b91b64 prefill: layer-major walk expands the hyper-connection projections on the path's stream; test that hc_q8_to_bf16 honours the stream it is given
Port of pull request 4715 by cedrik-fuoco (commits bd1410701 and d5a0d8294 on 0.2.7.1). The three bf16_proj calls of the layer-major walk (now in Prefill::lm_run)
omitted the stream argument; under STRATA_HC_REQ8=1 the int8 hyper-connection codes were expanded on the default stream while the GEMMs read them on another.
2026-10-31 23:26:06 +01:00
Niko1221 1b1a28e77e prefill: STRATA_PREFILL_CPU_SHARE_BATCH=1 (opt-in) gives the prompt path the CPU pool beside batch slots, for a read with no slot decoding
Port of pull request 3382 by Vop3p (commit 2b65710f9 on v0.2.0-beta), rebased by hand on the 0.2.9 trial tree.
Conflict in generate.cpp: the share gate goes first in run(), before the co-read branch, and the kva_floors / set_cell_floor bookkeeping after the read is
kept as it is. Addition to the original: a read run on its own thread beside decoding slots (STRATA_BATCH_CONCURRENT_READ=1, run_conc) keeps the share
blocked for the whole read and does not touch the slots' state from the reader thread. Off unless the variable is set.
2026-10-31 23:25:45 +01:00
Niko1221 5273fb667d prefill: STRATA_PREFILL_ROUTED_STREAM=1 (opt-in) does not copy the streamed experts no token of the chunk routes to; test
Port of pull request 4283 by bitaccesscomau (two commits 3956b94a7 and fcb0e399c on v0.2.8), applied without conflict on the 0.2.9 trial tree.
Off unless the variable is set; STRATA_PREFILL_ROUTED_STREAM_DEPTH sets the copies queued ahead (default 16).
2026-10-31 23:25:11 +01:00
Niko1221 d34c9a31ef SYCL: #3248 prompt kernels (from maxfridbe's PR #3248): per-cell prompt attention fallback, QSA block scores as oneMKL GEMM tiles
Ported from maxfridbe's pull request #3248 (commits e55a11c08 and 8cc654f23 of that branch).
- STRATA_ATTN_PERCELL (default on): qsa_prompt_attn_percell scores one work-item per cell in the prompt path's fallback attention (reached when the Battlemage ESIMD kernel refuses the pools, e.g. FP16 or Q4_0 KV, or STRATA_PROMPT_ATTN_ESIMD=0). =0 keeps the warp kernel. Not bitwise: FP32 in another summation order.
- STRATA_SELECT_GEMM (default: from 2048 blocks): QSA block scores as oneMKL FP32 GEMM tiles plus qsa_block_scores_reduce; =0 the warp kernel, =1 always. Not bitwise.
- qsa_prompt_attn_parity also checks the per-cell kernel and is registered with ctest.
Conflict resolution against the tree: qsa_select.dp.cpp keeps qsa_block_scores_rows beside the new reduce kernel; docs/INTEL.md keeps this tree's text (the PR's table is not copied; measurements are in the Intel notes).
2026-10-31 23:25:08 +01:00
Niko1221 8dcda377a0 SYCL: #3248 correctness fixes (from maxfridbe's PR #3248): host-only copies on the CPU, s2 grouped kernels without FMA contraction, gr_parity QFUSE skip, crossover cache dir
Ported from maxfridbe's pull request #3248 (commits d95a9c4b0, 5fbef34f9, 344e43e1d, c44fb63be of that branch), cherry-picked without the prompt-kernel commits.
- conversation_snapshot: a copy with neither end in device memory (pinned host <-> pageable host) is a memcpy on the CPU; through the queue it runs on the Arc's copy engine, which the PR reports hanging (Engine reset: engine_class=bcs).
- s2_expert_grouped: #pragma clang fp contract(off) in the two transposed grouped kernels (fixups.py 9d keeps it across re-migrations; 9c the snapshot test's sycl::free).
- gr_parity: the STRATA_QFUSE read check is skipped when the SYCL fused GR read writes no q8_1.
- strata-sycl.sh: STRATA_CACHE_DIR defaults to the mounted data root so the int8 crossover cache survives the --rm container.
2026-10-31 23:24:31 +01:00
Niko1221 d17c52e7d0 batch: STRATA_PW_WITH_BATCH=1 keeps --pipeline-windows for the solo path beside --batch slots
Port of PR 3095 by Vop3p (rebased onto f-0291-int-trial; the slot drafter load and bind
conflicts with STRATA_PIPED_MTP resolved by keeping both).

With --batch slots a request that is alone runs the solo path, and that path can keep
--pipeline-windows: both parities' verifiers share the stage's session, so copy_to_slot reads a
whole state after the pipeline drains. With --batch-mtp the slot drafters follow the solo drafter
to the first stage's card when mtp_first. Opt-in; off it is unchanged.
2026-10-31 23:23:52 +01:00
Niko1221 ce2098dcc9 dsv41: the dense-weights check refuses only when the free memory itself is smaller; between that and the reserve it warns
The previous commit stopped whenever the dense weights did not fit in free memory minus --reserve-mib. They would load in the margin, so that case is now a warning (the load goes on) and only need > free memory, where the cudaMalloc cannot succeed, ends the start.
2026-10-31 23:18:37 +01:00
Niko1221 dc1bc4c34d dsv41: the router tells the request when it met NaN scores
The router's stand-in pick (previous commit) keeps a NaN from becoming a memory fault, but the layer's output is garbage and the request would stream nonsense. route / route_publish now set a device flag when no pick came out; ds4_main polls it at the first token and every 8 (one small async copy and a stream wait, not a per-token sync), and the model says once in the log which device met it. The request ends with 'ERR the model's numbers went non-finite ...' and the conversation is dropped (reset), the same ERR-then-DONE shape as a failed prompt. The --parallel loop is not covered; the host-routed window path already errors on a non-finite weight.
2026-10-31 23:18:37 +01:00
Niko1221 94edf723ca DeepSeek engine updates move to the explicit update; a start only repairs a stale path (replaces the start-time feed check)
Starting never reaches the feed again (test_no_acquisition passes unchanged). What a start does now, locally: a saved profile whose DeepSeek engine
folder an update removed is pointed at the newest installed package of its own kind (strata_control/dsv41_paths.py, called from runtime.start), which
fixes the 'engine could not start: file not found' after a strata launch updated the engine beside the app.

The explicit update (strata update, the app's Update engine, updater.run_update with the engine) now also updates an installed DeepSeek engine
(updater.update_dsv41): the feed's newer package of the same kind is downloaded, checked (signature, every file), unpacked next to the old one,
started once (--version), and only then every saved run config is pointed at it; older folders go, except one a running engine uses. update --check
reports it; the check kept after any update or check also feeds engine_component.cached_offers / status()['update_available'], which the app reads
without the network to show one card. strata launch's own upgrade (dsv41_engine.py) is left as it was.

Tests: tests/terminal/test_dsv41_update_path.py (the old keep_current tests, adapted, plus a start that makes no request).
2026-10-31 23:15:10 +01:00
Niko1221 0dfa2d9b19 App: a model install that also updates a working engine says from which version to which, that the old one is kept and that Settings can go back to it (#4823) 2026-10-31 23:12:53 +01:00
Niko1221 2473978007 Server: on /v1/messages a context overflow starts with Anthropic's own wording (prompt is too long: N tokens > M maximum, or input length and max_tokens exceed context limit) and keeps Strata's sentence after it, so Claude Code compacts or shortens max_tokens instead of retrying the same request (#4838) 2026-10-31 23:12:06 +01:00
Niko1221 6bd2565594 App: a tuning that did not finish puts the tuner's own reason and what this PC has (memory, graphics card) behind Details, and says to open it, instead of 'could not start with these settings' alone (#4805) 2026-10-31 23:12:06 +01:00
Niko1221 1ab4c2ce46 serve: --draft auto keeps both drafters current; a request picks the MTP or DFlash2 (draft= key, --draft-rule), own window width and policy per drafter 2026-10-31 23:07:24 +01:00
Niko1221 d2068bde56 CPU prefill share on by default for a single gfx12 card (R9700 / RX 9070 / RX 9060): the CUDA change's logic (share_rules::default_share_limit, staged chunks up to 3,072 tokens, only where experts are missing, one card, no batch slots) now also arms on HIP when arch_defaults::gfx12_prefill_share_default says so; gfx11 (Strix Halo, 780M, RX 7000), other AMD families and multi-card AMD stay off until measured; STRATA_GFX12_DEFAULTS=0, STRATA_DEFAULTS=026 and STRATA_PREFILL_CPU_SHARE (0 / auto / a number) decide first; R9700 (p620, IQ3_S, 54% resident): prompt 500 +18.3%, 1K +30.6%, 2K -0.19%, 8K -0.18% with it set by hand, first-token KL 0.022 at 1K (29 prompts, top-1 29/29) 2026-10-31 23:07:11 +01:00
Niko1221 3410a85881 QSA prompt attention: the output gate in the matrix-core kernel's epilogue (STRATA_QSA_FUSE_GATE)
qsa_prompt_attn_batch_gated writes half(attn * sigmoid(gate)) straight from the epilogue of
prompt_attn_wmma_kernel, with the expressions of gate_attn_kernel (a * (1 / (1 + expf(-g))), round to
FP16), so the image the o-projection reads is the same bits and the FP32 attention buffer is neither
written nor read back (about 0.4 GB a QSA layer at 8K tokens). Used only where that kernel runs (HIP,
int8 KV, STRATA_HIP_WMMA) and the output is not rotated back (kv_rot / hybrid K8V4 keep gate_attn);
anything else, and STRATA_QSA_FUSE_GATE=0, runs the two-step path. Both prompt-layer loops use it.

Exactness is checked end to end (first-token logits and greedy ids with the switch on and off); there is no
kernel-level test for it yet. Idea from halo-box/strix-llama.cpp PR 213 (attention gate fused into the QSA
prefill epilogue, MIT, The ggml authors); the code is ours.
2026-10-31 23:06:54 +01:00
Niko1221 de83080059 CPU prefill share: the default limit goes from 1,024 to 3,072 tokens (CUDA, one GPU, only where experts are missing): TTFT of a 1-2K prompt on a card holding half the experts drops 30-60%, P100 +25% at 1K and +13% at 2K, 4K and above unchanged; share_rules::default_share_limit keeps a class gate, STRATA_PREFILL_CPU_SHARE_MAX=1024 restores the old limit, unit test added 2026-10-31 23:05:20 +01:00
Niko1221 0a02ee4108 Launch: a runtime host left in failed or stale is cleared before the tuning and before the start, the 'profile already has a live runtime' message names launch --stop and --retune, and --save-preset is kept when the start fails after the tuning (#4824) 2026-10-31 23:03:01 +01:00
Niko1221 f6eed469e7 dsv41: a non-finite router input no longer reads outside the expert tables
When every score left is NaN (the gate's input went non-finite), nothing beats -FLT_MAX in route_kernel / route_publish_kernel, the pick stays at its 0x7fffffff start, and the next lines read sc[0x7fffffff] (shared memory), vram[0x7fffffff] and, in the prompt path, vs[0x7fffffff] in moe_sort_kernel: a memory fault in a kernel far from the cause. The thread that picks now takes the lowest expert not picked yet when no pick came out (one compare a pick on the finite path); the weights stay NaN as before. The host-routed window path, which has the picks on the host anyway, ends the request with a clear error on a non-finite weight.

tests/dsv41/ds4_route_guard_test.cu (target ds4_route_guard_test, needs a GPU, no model): valid and distinct picks for NaN / inf / partly NaN logits, the host's top-k when finite, and launch times (DS4_ROUTE_TIMING=1).
2026-10-31 22:57:45 +01:00
Niko1221 8b1cae731c Engine install: a checked engine that Windows will not let Strata rename into place is copied instead after a 40 s wait, and the first-run card says why it stopped (Details, plain next step, Continue named) instead of the generic sentence (#4853) 2026-10-31 22:57:23 +01:00
Niko1221 3949737f1a Trial fix (belongs to f-0291-bw-dense): fused_gr.cu ldg_v4 PTX asm and gr_v3 --resources used cuda-only names on HIP; plain load / no-op on HIP 2026-10-31 22:56:06 +01:00
Niko1221 14a85eca91 QSA prompt layers: K, query and indexer-query norm + rotary in one pass each (STRATA_QSA_FUSE_NR)
rms_rope_rows replaces split_q + rms_rows + rope for the query and rms_rows + rope for K and the indexer query.
It uses the same block_sum, the same (s * x) * w product and rope_kernel's own angle and rotation expressions, so
the outputs are the same bits; the traffic drops from about 1.1 GB to 0.4 GB a QSA layer at 8K tokens. On by
default, STRATA_QSA_FUSE_NR=0 runs the old chain.

hip_qsa_nr_parity compares every output element bit for bit against the old chain (T 1..8191, four start
positions, NaN-poisoned outputs); hip_pf_cover_test poisons every output of the gfx11 prompt GEMMs (wmma_gemm.cu)
and checks that each element is written and that sampled rows match an FP64 reference. Neither has run on a GPU yet.

Idea from halo-box/strix-llama.cpp PR 213 (fused RMS norm + RoPE, MIT, The ggml authors); the code is ours.
2026-10-31 22:52:23 +01:00
Niko1221 d4d0c30da5 DeepSeek engine: the app's start keeps the engine current (not for the 0.2.9.1 hotfix; for the next normal release)
Before runtime start, a saved profile that runs strata-dsv41 from an engine-dsv41 folder is pointed at the newest installed package of its kind
(every saved profile's run config, not only the one started), and the signed feed is asked for a newer package at most once in six hours (half an hour
after a failed check). The package is fetched by a separate background process the start waits for at most 90 seconds; after that the start goes on
with the installed engine and the fetch ends in the background. Nothing in it can fail a start. A package folder a running engine uses is never removed
(also in upgrade() and install(), so a strata launch beside the app is safe), and two fetches never run at once.

Known: tests/terminal/test_no_acquisition.py::test_the_deepseek_engine_check_is_reached_only_from_launch_and_setup fails by design, because a start now
reaches the feed (owner rule: starting never acquires). It needs the owner's decision before this branch merges.
2026-10-31 22:51:27 +01:00
Niko1221 e6d81d7a2a SYCL: dq_dense_parity, the dense dequantizers and mmvq against the CPU dequantizers with random blocks 2026-10-31 22:50:06 +01:00
Niko1221 93b35ee84c dsv41: a GPU too small for its layers' dense weights fails at the start with what it needs, instead of a cudaMalloc error part way into the load
Port of Project Maya 8b56730ad (MIT): the dense weights (attention, shared experts, head) always stay in VRAM, so a device whose free memory cannot hold its whole layers' dense weights is told so before anything loads. Devices with tensor-parallel members are not checked.
2026-10-31 22:47:00 +01:00
Niko1221 cb49530dd6 DeepSeek: a model-free test of the dense dequantizers, GEMVs and chunk GEMM for every dense weight type (ds4_dq_test)
Random legal blocks against a CPU reference: iq::dequant_rows through both dequantizers (DS4_DQ_OLD=0 / 1 / the default), iq::mv with 1..16 columns, f16_rows, ds4_gguf.cu's rows and mv on Q8_0 / BF16 / ggml types, the library GEMM and the WMMA kernel after the dequantizer, and a repeat path that runs one call many times. Shapes include k % 1024 == 0, which ds4_iq_test never ran.
2026-10-31 22:44:22 +01:00
Niko1221 c60833cc38 dsv41: a run of chunks drains the recent slots' fills whenever recent slots exist, not only while the one-GPU path is on
one_gpu_plan may turn ln_one / ln_land off after the lane timing, but recent_n and the slots' fills stay (recent_n is set at the load, before the plan). borrow_begin then skipped rc_drain: a fill done but not yet mapped, or still writing, met the run's buffers in the tail, and the tick after the hand-back mapped its expert over what the run wrote (NaN routing, then an illegal memory access on a long prompt after shorter ones on one GPU). rc_in_tail() (recent_n > 0 and the fill stream exists) now also triggers rc_drain.

Port of the idea of Project Maya v1.1.45 (c968b9486, MIT): our rc_drain has no for_lend argument and this tree has no x3 / copy_flush, so only the condition is taken.
2026-10-31 22:32:46 +01:00
Niko1221 711bba9cc9 Version 0.1.55.1 for the DeepSeek engine hotfix (the CMake project version; the Terminal, the app and Control keep their 0.2.9 / 0.1.55 packages) 2026-10-31 22:32:31 +01:00
Niko1221 8422d864c8 dsv41 (gfx11): the old dense dequantizer stages only what fits its buffer, so a Q6_K dense matrix no longer overflows into the next warp's
dq_rows_kernel, the default dense dequantizer on gfx11 (Strix Halo), stages a warp's 32 sub-blocks in a 44-uint4 (704 byte) shared buffer.
Four Q6_K blocks are 840 bytes, so the staging ran into the next warp's buffer and the dense rows came out wrong: NaN in layer 0 of every
chunked prompt (96 tokens and more) of a UD-IQ1_S or UD-IQ1_M set, whose attention is Q6_K. Q3_K (440 bytes, not a multiple of 16) read 8
bytes past what was staged. Q5_K (704 bytes, the ISTA-DAS sets' dense type) fits and keeps the staged path. Stage only when the span fits
and is a multiple of 16 bytes.

(from d6be7ff6a on f-042-strix-ds)
2026-10-31 22:32:31 +01:00
Niko1221 beae52dfc5 MMVF check: build fixes (volatile loads as two floats, format string) 2026-10-31 22:16:15 +01:00
Niko1221 0ceb8ba68e Trial fix (belongs to f-0291-ui): markdown-images and markdown-soft-breaks stub './ui' without Icon, which CopyBtn (#4748) now imports (reliability.test.mjs already has it) 2026-10-31 22:13:29 +01:00
Niko1221 007fbd1ed2 Trial fix (belongs to f-0291-bw-dense): fused_gr.cu ldg_v4 PTX asm and gr_v3 --resources used cuda-only names on HIP; plain load / no-op on HIP 2026-10-31 22:11:27 +01:00
Niko1221 0f31e635a3 dsv41 (gfx11): the old dense dequantizer stages only what fits its buffer, so a Q6_K dense matrix no longer overflows into the next warp's
dq_rows_kernel (the default on gfx11, e.g. Strix Halo) stages a warp's 32 sub-blocks in a 44-uint4 (704 byte) shared buffer. Four Q6_K
blocks are 840 bytes, so the staging ran into the next warp's buffer and the dense rows came out wrong (NaN in layer 0 of every chunked
prompt of an Unsloth UD-IQ1_S/UD-IQ1_M set, whose attention is all Q6_K; windows of 16 use the GEMV and were fine). Q5_K (704 bytes, the
IST-DAS sets' dense type) fits and keeps the staged path. Stage only when the span fits and is a multiple of 16 bytes.
2026-10-31 22:09:43 +01:00
Niko1221 8eb3b6a8c2 STRATA_MMVF_CHECK=1 diagnostic: the constant-trip router read also runs the plain loop with volatile loads and counts accumulators that differ 2026-10-31 22:02:29 +01:00
Niko1221 b35bbfc2ee Trial merge f-0291-ui (2c2893081) 2026-10-31 22:01:53 +01:00
Niko1221 eaae5a865c Trial merge f-0291-mac-q (5957d10f7) 2026-10-31 22:01:52 +01:00
Niko1221 1b2825712d Trial merge f-0291-amd-q (c0988d553): CMakeLists keeps gr_v3_screen and mmvf_unr_hash; native_bf16.cu and verify_kernels.cu take bw-dense's per-device mmvf_fast/gdn_ab_fast (SMALL_UNR default on for cc 12.0) over amd-q's default-off copy 2026-10-31 22:01:52 +01:00
Niko1221 d4d7f164e9 Trial merge f-042-strix-q (dbf6a6bb6) 2026-10-31 22:01:20 +01:00
Niko1221 76f12eb225 Trial merge f-0291-bw-dense (08b20b104) 2026-10-31 22:01:16 +01:00
Niko1221 656fcfae13 Trial merge f-0291-bw-measure (c4082ce8c) 2026-10-31 22:01:16 +01:00
Niko1221 566e1c7c10 Trial merge f-0291-qwen-par (b19b38aff) 2026-10-31 22:01:12 +01:00
Niko1221 7d94b1d5b9 Trial merge f-0291-gh-app (5c0259e20) 2026-10-31 22:01:09 +01:00
Niko1221 9567238476 Trial merge f-0291-gh-eng (31c3a16f4): NOTICE.md keeps the base's block (thread pool fence #618 already listed; ds4_pool.hpp identical) 2026-10-31 22:01:09 +01:00
Niko1221 0f2778ae22 Trial merge f-0291-fixes (dde320ffa): keep both _parallel_lines and _broken_nvidia_note in launch/cmd.py 2026-10-31 22:00:53 +01:00
Niko1221 2d47b3e96b Trial merge f-041-maya-par-offer (2784dfdcc) 2026-10-31 22:00:46 +01:00
Niko1221 2c2893081b App: a goal, a plan step and a /loop run without anyone there, so the agent's question is declined or skipped at once there and cannot stall them; normal chat still shows the question card (#4244) 2026-10-31 21:59:32 +01:00
Niko1221 c0988d5531 arch_defaults_test: the gfx12 table has twelve switches (SMALL_UNR, GR_Q32_G, GR_Q8_PFS are off under STRATA_DEFAULTS=026) 2026-10-31 21:51:04 +01:00
Niko1221 eeb21d0e4f serve: --draft-conf honored with --draft dflash2 (the window ends where the product of the selector's probabilities falls below P), as in the CLI loop 2026-10-31 21:45:10 +01:00
Niko1221 a9a2939fe9 App: the transcript stops following the newest token while the reader has words selected in it, so a selection is not pulled out from under the pointer while a reply is written; the way back to the newest message is offered (#3818, selection half) 2026-10-31 21:37:47 +01:00
Niko1221 5957d10f75 Mac config: --spec 3 --spec-min-p 0.5 for a resident Q2_0 pack too; IQ3_S numbers re-measured
M5 Max, MTP, 5 interleaved pairs, fixed harness (each run's spec, min-p and context read from the engine's own log), greedy ids identical in every run, over --spec 2:
Q2_0 story 104.4 -> 104.7 (+0.4%), code 114.4 -> 127.3 (+11.5%), JSON 124.6 -> 132.5 (+6.3%), agent turn 115.9 -> 117.9 (+1.9%, 3/5 pairs).
IQ3_S story 94.2 -> 93.3 (-0.9%), code 103.8 -> 106.1 (+2.5%), JSON 105.1 -> 122.6 (+16.7%), agent turn 102.1 -> 107.1 (+4.9%).
The earlier Q2_0 'flat' result and IQ3_S '+1.4..3.6%' came from a harness that gave the spec flags to the first pair of an arm only. Other sets keep --spec 2.
2026-10-31 21:35:23 +01:00
Niko1221 1405970281 App: the agent's own questions (ask_user_question) get a card in the style of the approval card, with options, the person's own words, one answer or a skip; the tool is offered again as the group Questions for you in Settings -> Tools (#4244) 2026-10-31 21:32:33 +01:00
Niko1221 2f4b422968 App: a turn or a selected passage can be quoted into the next message: quotes sit above the box, can be removed or jumped to, go to the model in one fixed form, and show apart from the reply in the transcript (#4785) 2026-10-31 21:23:23 +01:00
Niko1221 51f9e432b9 App: while an answer is being written, Enter can wait (as before), send the message after the answer, or interrupt it (the answer stops where it is and the message goes at once); the choice is in Settings and the waiting message is shown above the box (#4783) 2026-10-31 21:20:04 +01:00
Niko1221 7d8c2a965b App: each helper row in the Subagents panel takes a message (the Harness's own subagents/prompt: steer for a working helper, queue for a finished one) and says plainly when it was sent or refused (#4749) 2026-10-31 21:15:29 +01:00
Niko1221 a75409dc57 mmvf_unr_hash: also hashes gdn_ab_multi (STRATA_SMALL_UNR bit 1) 2026-10-31 21:14:03 +01:00
Niko1221 656ddfd050 docs/MAC.md: --parallel works on Metal; the numbers measured on an M5 Max with IQ3_S 2026-10-31 21:10:12 +01:00
Niko1221 8218055ad3 App: Context memory says in one line what each format costs, and a start that ran out of graphics memory says so in plain words with the next step (#3723, context-memory and out-of-VRAM parts) 2026-10-31 21:06:01 +01:00
Niko1221 cb7491f02d App: a first start opens in the language of the computer, and the first screen carries a language choice (#3723, language part) 2026-10-31 21:05:57 +01:00
Niko1221 91411e17f8 Metal: 256 routed experts (the expert-pruned Coder, #4706): the routers, the prompt chain and the SSD stream take the model's expert count
Window::init accepts 512 or 256 experts a layer; the decode router (router_route / moe_route), the prompt router (route_topk) and the plan buffers use the model's count, the SSD stream reads it from the GGUF. The route kernels are templated on the logits a lane holds (16 for 512, 8 for 256); the 512 path is the old code token for token, the logits rows keep their 512-float stride, the predictor's unused columns are -inf. The MTP layer keeps its own 512 experts.
Tests: prompt_parity route_topk and fuse_parity router_route run both counts (all pass on the M5 Max). IQ3_S 512 experts: greedy ids identical to the build before in every class and prompt (3 interleaved pairs), speed equal (decode +/-0.5% median, prompts -0.5..+5.9%, inside the spread). Not run: an end-to-end 256-expert model (58 GB download).
2026-10-31 20:54:38 +01:00
Niko1221 cab56e7af8 App: paragraphs, lists and quotes in a reply share one width and tables and code blocks keep the full column; the list rule named a class the renderer never emits (#4722) 2026-10-31 20:53:49 +01:00
Niko1221 4473c386a5 App: while sharing is on, Serving shows the address on this computer and the one for other devices, with one plain line on why their ports differ (#4798) 2026-10-31 20:53:48 +01:00
Niko1221 de0608b443 App: the progress card of a download, load or tuning run has its own border and tint, a spinner and a running clock ("Still working for 3m 12s. You don't need to do anything."), and a line saying the buttons below wait until it finishes (#4802) 2026-10-31 20:53:48 +01:00
Niko1221 445baf12e4 App: a plain click on a web address in a reply opens it in the person's own browser (the app window asks the host, a browser tab opens it itself); files keep the side panel (#4721) 2026-10-31 20:53:48 +01:00
Niko1221 3cb03ed32c App: Tune this model says in one plain sentence why Start tuning is greyed out (still reading the saved tunings, could not read them, another operation running), and no longer says "No saved tuning" while it is still reading (#4804) 2026-10-31 20:53:48 +01:00
Niko1221 2506046a60 App: every Copy control shows on itself that the copy worked (a check mark for about two seconds, "Copied" in menus), and the seven that never reported anything now do (#4748) 2026-10-31 20:53:48 +01:00
Niko1221 9197e13a4b App: the Approve and Deny card stays above the message box while a long reply scrolls, and the reply says it is waiting for an answer, so nobody has to scroll up to find it (#4725) 2026-10-31 20:25:16 +01:00
Niko1221 2784dfdcc4 DeepSeek --parallel: the engine flag probe lives in its own module (strata_control/dsv41_flags.py), because serving must not import dsv41_engine.py (it reaches the network) 2026-10-31 20:13:11 +01:00
Niko1221 04e9da601f Mac: the PLE residency policy stays off unless STRATA_PLE_AUTO_RAM=1
Since the conversation-cache fix gave sample_host_memory a macOS branch, the 0.2.7 policy (table in RAM when it fits) started firing on the Mac: the 26.8 GiB n-gram table was locked in RAM, the footprint went from 8 to 35-42 GB and free memory dipped to 19 GB. M5 Max, IQ3_S, 3 interleaved pairs, same binary, same tokens: a cold 4K prompt after decode work is 10.6% slower with the table in RAM (-10.7 -10.6 -10.0% against the v0.2.9 binary, +2.4 +15.2 +12.2% for direct reads against RAM), decode is the same inside the spread. Direct reads are what the Mac had before; an explicit --ple-io still wins.
2026-10-31 20:11:35 +01:00
Niko1221 d8801f628c serve: say when a DeepSeek engine runs fewer conversations than --parallel asked for 2026-10-31 20:05:38 +01:00
Niko1221 09982fff66 gfx12 decode table: STRATA_SMALL_UNR=3, STRATA_GR_Q32_G=2, STRATA_GR_Q8_PFS=1 (bitwise load-ordering variants, measured on an R9700) 2026-10-31 20:03:04 +01:00
Niko1221 93af8275dc launch: DeepSeek Parallel step test; comments say DeepSeek is one at a time unless --parallel is asked for 2026-10-31 19:56:07 +01:00
Niko1221 009491d561 strata launch --parallel for DeepSeek: the launch prints what it does and the numbers, as the plan does; tests; app test fixture lists the setting 2026-10-31 19:50:10 +01:00
Niko1221 cc7857135b mmvf_unr_hash: hashes of the router-shaped bf16 reads for comparing STRATA_SMALL_UNR settings 2026-10-31 19:46:46 +01:00
Niko1221 87ec8fd83e WIP gfx12 HC q8 up read: software-pipelined group loads (STRATA_GR_Q32_PL) 2026-10-31 19:40:59 +01:00
Niko1221 d6912d7744 docs: DeepSeek --parallel (what it does, the numbers, where it was run); app test: Concurrent requests reaches a DeepSeek run config and nothing is added without it 2026-10-31 19:37:26 +01:00
Niko1221 31c3a16f4f Qwen3.6-35B-A3B: the penalties count the reply's tokens by default too; STRATA_PENALTY_PROMPT=1 brings the old window back (#4590)
The Qwen3.6 engine (src/qwen35/q36_engine.cu) started its penalty history from the whole prompt (`hist = ids`), so its model-card presence penalty 1.5 counted the prompt's last tokens as in the other engines. The history now starts at the last prompt token and grows with the tokens written (penalty_history_start in include/strata/qwen35/sampling.hpp), the same window as src/program/generate.cpp; batch slots copy that history. STRATA_PENALTY_PROMPT=1 keeps the whole prompt.

Tested: qwen35_sampling_test (276 checks, 9 new for the default and the opt-out), and a CUDA sm_86 build on z440. Replay on z440, one A4000, UD-Q6_K_XL, the reporter's conversation cut to 250 records, 400 tokens: 16 sampled seeds and 2 greedy requests per arm. This model did not return the old JSON in either arm (0/16 with the default, 0/16 with STRATA_PENALTY_PROMPT=1; 0/2 greedy in both), so this replay does not show a difference between the two windows; no answer in either arm started with the old JSON or a code fence.

docs/DETAILS.md: the sentence saying the Qwen3.6 path still counts the prompt is replaced.
2026-10-31 19:33:33 +01:00
Niko1221 094132ca77 strata launch --parallel N for DeepSeek: passed on instead of dropped, off by default; --plan says what it does with the measured numbers; a pick beyond what was run warns (Windows, Mac, small or unknown card); an engine from before the flag is asked (--help) and the launch goes on one at a time; the TUI step offers it for DeepSeek 2026-10-31 19:33:28 +01:00
Niko1221 8ce20293e5 WIP gfx12 HC q8 read: G rows a warp loaded together in gr_up_q8_q32_kernel (STRATA_GR_Q32_G), staging loads of all tokens together in gr_down_q8_fast_kernel (STRATA_GR_Q8_PFS) 2026-10-31 19:31:44 +01:00
Niko1221 c49eb5d194 WIP: single-column router bf16 read with constant-trip loads (STRATA_SMALL_UNR bit 0), for the HIP A/B 2026-10-31 19:29:54 +01:00
Niko1221 de4d21e6f8 WIP small kernels: STRATA_SMALL_UNR (bit 0 bf16 router read, bit 1 GDN a/b projection) constant-trip fast paths, default off
Same FMAs in the same order as the runtime-bounded loops (bitwise); nsys: gdn_ab 352 -> 290 us/round, router mmvf 533 -> 492 us/round. Engine A/B pending.
2026-10-31 19:29:03 +01:00
Niko1221 3ae0fbfad1 DeepSeek --parallel: a card that cannot hold every conversation's buffers runs as many as it can hold (the failed one's partial buffers freed, said in one line), instead of stopping the engine at the start 2026-10-31 19:25:10 +01:00
Niko1221 c4082ce8c4 CPU prefill share: the default limit goes from 1,024 to 3,072 tokens (CUDA, one GPU, only where experts are missing): TTFT of a 1-2K prompt on a card holding half the experts drops 30-60%, P100 +25% at 1K and +13% at 2K, 4K and above unchanged; share_rules::default_share_limit keeps a class gate, STRATA_PREFILL_CPU_SHARE_MAX=1024 restores the old limit, unit test added 2026-10-31 19:09:33 +01:00
Niko1221 40da04d49a Serve: DFlash2 on (ENGINE_SERVES_DFLASH2 = True); tests cover both states 2026-10-31 19:04:57 +01:00
Niko1221 6667ac43d4 wip: DFlash2 in the serve loop (ring stamps, request history, prompt rows, spec loop), serve test harness 2026-10-31 19:04:57 +01:00
Niko1221 081d1c04d2 PDL: on by default for the all-resident verify graph on a compute capability 12.0 card (bit-identical, +0.3..0.5% decode on an RTX PRO 5000); the doorbell graph and batch windows keep ordinary launches, STRATA_DF_PDL=0/1/2 and STRATA_SM120_PERF=0 are honoured 2026-10-31 18:45:16 +01:00
Niko1221 36d94496fa SYCL: the penalties count the reply's tokens by default too; STRATA_PENALTY_PROMPT=1 brings the old window back (#4590)
sycl/src/program/generate.cpp is its own copy of the serve loop, so the change in ed76b12f9 did not reach Intel cards. Same window: the fed-back last prompt token and the reply's tokens, built from the request's prompt ids on every request (a restored conversation gets the same window as a cold read). The helpers are added to sycl/include/strata/kernels/sampler.hpp, which that build includes first.

Measured on two Arc Pro B65 (SYCL AOT bmg-g31), Qwen3.8-Flash-Next IQ3_S, the reporter's 4-message conversation (32,951 tokens, cached), --spec 2, 16 sampled seeds, 500 tokens: old JSON 0/16 and numbered plan 16/16 with the new default; old JSON 8/16, plan 7/16, other 1/16 with STRATA_PENALTY_PROMPT=1. Greedy, 2 requests per arm: numbered plan 2/2 in both arms on this model.
2026-10-31 18:13:49 +01:00
Niko1221 06ebf2e4d6 PcieAuto: start the PCIe share from the pool size (min(65, 4 GB/s per worker) through the refinement's own formula) instead of the link-only 0.55, so the first answers after a launch do not run ~50 windows at a share the machine does not want; the converged share is unchanged, STRATA_PCIE_START=old keeps the old start 2026-10-31 18:10:43 +01:00
Niko1221 12a1f2c545 Mac config: --spec 3 --spec-min-p 0.5 for a resident IQ3_S pack (+1.4..3.6%, same tokens)
M5 Max, MTP, IQ3_S, 5 interleaved pairs (2 + 3), greedy tokens identical in every run: --spec 3 --spec-min-p 0.5 against --spec 2:
story 93.8 vs 92.4 tok/s (+1.4%), code 103.4 vs 100.4 (+3.0%), JSON 105.3 vs 101.6 (+3.6%), agent turn 100.8 vs 98.0 (+2.8%).
Q2_0 measured flat (-1.0% to 0.0%, 5 pairs; 800-token story -0.7%), so it keeps --spec 2; --spec 3/4 without min-p lose on both.
mac_fit.spec_args picks the flags by model; engine_args takes the model name (setup.py and metal/setup_mac.py pass it); a streaming
Mac keeps STREAM_SPEC, a Mac without the draft layer keeps --spec 2. The engine's own default (--spec 2) and an explicit --spec in a
config are unchanged. Test: tools/test_setup_mac.py.
2026-10-31 18:09:56 +01:00
Niko1221 08b20b104c BW-DENSE: STRATA_SMALL_UNR default 3 and STRATA_GR_V3_PF default 5 on compute capability 12.0 cards (bw_dense_defaults.hpp); STRATA_SM120_PERF=0 / STRATA_DEFAULTS=026 / the variables turn them off
Both are bitwise the loops they replace. Engine A/B: SMALL_UNR on top of the HC change +0.18% agent, +0.28% code_py, 5/5 pairs each, identical text.
2026-10-31 17:54:59 +01:00
Niko1221 381d70902d gr_v3_screen: print registers, local memory, smem and resident blocks of the v3 kernels (--resources) 2026-10-31 17:52:33 +01:00
Niko1221 ed76b12f9d The repeat, frequency and presence penalties count the tokens the reply has written, not the last tokens of the prompt; STRATA_PENALTY_PROMPT=1 brings the old window back (#4590)
With the model-card presence penalty 1.5 over the last 64 tokens, the prompt's own tokens (the digits of a new task, a quoted JSON answer) were penalised, and a conversation whose last turn asks for something new could get its previous answer back. The window now starts at the end of the prompt: the fed-back last prompt token and the reply's tokens. Earlier assistant turns inside the prompt are not counted. The history is built from the request's prompt ids on every request, so a conversation restored from the conversation cache or a saved slot gets the same window as a cold read. The STRATA_ADMIT_MERGE_FIRST merged first window is not used for a request with a penalty window, since its prompt rows would join the reply window.

Measured with the opt-in switch (618cd7a4b) on a P100, IQ2_XS, the reporter's saved 4-message conversation, 32 seeds per arm: old JSON returned 13/32 as shipped, 4/32 reply-only, 3/32 with presence 0. Qwen IQ3_S on an R9700, 16 seeds: old JSON 8/16 as shipped, 0/16 reply-only. 61-task quality027, greedy, one run per arm: 56 and 55 passed as shipped (two runs), 55 reply-only, 54 with presence 0.

Added penalty_window_test (header-only, CPU) and a line in docs/DETAILS.md.
2026-10-31 17:46:28 +01:00
Niko1221 2d381fcf51 WIP small kernels: STRATA_SMALL_UNR (bit 0 bf16 router read, bit 1 GDN a/b projection) constant-trip fast paths, default off
Same FMAs in the same order as the runtime-bounded loops (bitwise); nsys: gdn_ab 352 -> 290 us/round, router mmvf 533 -> 492 us/round. Engine A/B pending.
2026-10-31 17:26:08 +01:00
Niko1221 5c55046d72 Remove dense_tc (E03 rejected): the Q6_K INT8 tensor-core projection is 1.4-2.3x slower than the engine's MMVQ at T 1-4 on every real shape
Measured on an RTX PRO 5000 (cold weights, real shapes, head included); the earlier commits keep the code and its parity test.
2026-10-31 16:38:28 +01:00
Niko1221 97e5a6aeca dense_tc_parity: sync after pageable host-to-device copies (the T=7/8 mismatch was the test reading a column before its copy landed, not a kernel race) 2026-10-31 16:38:28 +01:00
Niko1221 adbe6b9b95 DeepSeek Windows build: ds4_os.hpp includes timeapi.h and links winmm for timeBeginPeriod (the merged include order no longer brought it in with windows.h) 2026-10-31 16:25:36 +01:00
Niko1221 e1b27d54b0 After a fatal GPU error the engine ends itself and the server starts it again, instead of staying up and failing every later request (#4350)
DeepSeek: an illegal memory access, launch failure or hardware exception in a prompt read or a decode round used to turn into an ERR/cancel while the engine kept running on a dead GPU context, so every later request failed with HTTP 400 until someone restarted it. strata-dsv41 now prints 'ERR fatal GPU error: ...' and exits (code 3). Qwen: a failed decode window, commit or final commit wait that leaves a sticky fatal runtime error does the same (the prompt-read path already did, #224), by the runtime's error text so CUDA, HIP and SYCL builds agree.
Server: that ERR line fails the request in flight as an engine death (503, retryable) and marks the engine ended, so the next request starts it again at once, with one plain log line and a plain death note. Restart-loop guard: a third fatal death within ten minutes stops the restarts and answers 503 with the reason until the window has passed; one death counts once.
Test hooks (off unless set): DS4_INJECT_FATAL=N / STRATA_INJECT_FATAL=N fail the Nth request / decode window with a real fatal error's text; *_ONCE=<file> fires it once across restarts.
Tested: serve/test_fatal_gpu_restart.py (7 tests); through the real server, request 2 injected, DeepSeek on boston (IQ1_M pack, 2x A6000): request 2 got a 503, the restart took 44 s, requests 1, 3, 4, 5, 6 answered 42; Qwen on a P100 (IQ2_XS), fault injected mid-decode in request 4: 503, restart, requests 1-3, 5, 6 answered 42. The guard counted '1 of 3' there and '2 of 3' in an earlier run where the injection fired on every restart. NOT tested: a real GPU fault (none reproduces), the Windows and Metal builds, the third-death refusal through a running server (unit-tested only), conversations coming back from saved slots after the restart (no check made).
2026-10-31 16:11:49 +01:00
Niko1221 3e2598a835 Dispatch counters: count the HC v3 read and the Q8 weights of the hyper-connection read, key a doorbell-graph launch as its own kind 2026-10-31 15:58:34 +01:00
Niko1221 3ce5193eb4 WIP HC v3: STRATA_GR_V3_PF default 5 (batched staging loads in gr_down, batched partial/row loads in gr_up) on compute capability 12.0 cards, 0 elsewhere; =0 restores the plain kernels
Bitwise the plain v3 read (gr_v3_screen, T 1-8, 0 differing); 4-8% faster per read on cold weights. Weight prefetch and 2/4 rows a warp measured slower and stay off. Engine A/B pending.
2026-10-31 15:55:17 +01:00
Niko1221 9469a0c264 Metal: token_chain CHAIN_GDNGRID timing probe (gdn_step grid size) 2026-10-31 15:39:20 +01:00
Niko1221 96a85f7fe8 Metal: token_chain probes and the measured-negative kernels of the hyper-connection / loop-batching study
hcf.metal (gr_norm folded into the producer of bo with a last-arrival threadgroup: bit-exact, +1.2 ms slower), gemvu.metal and
moeu.metal (unit loops unrolled x2: +2.3 / +1.5 ms), qsab.metal (batched qsa_attn: +0.07 ms). None is used by the engine; they
stay with their numbers (phaseA-tests/mac-q/RESULTS.md section 7). token_chain gets the CHAIN_* switches that measured them
(NORM2, COMB2, NONORM, NOOPS, SKIPK, HCF, KEEPNORM, GEMVU, MOEU, QB, DEFS).
2026-10-31 15:32:09 +01:00
Niko1221 21c14feb86 Metal: the drafter's prompt rows run on a second queue beside the next prompt chunk (+1.4-2.0%); mtp_rms with batched loads
Window::prompt ran each chunk's MTP-layer K/V rows (9% of an 8K prompt) after the chunk, with the GPU otherwise idle for them.
They now go to a second command queue (Context::run_graph_async / wait_async) while the next chunk's main work runs on the
first. The chunk's final residual rows are double-buffered (pb_.R / pb_.R2); the rows of chunk c+1 wait for chunk c's (shared
drafter cache and scratch); every way out of prompt() joins. Not used with M-RoPE (images). STRATA_METAL_MTP_OVERLAP=0
restores the sequential rows.

mtp_rms_b: mtp_rms with the row loaded up front (same sum, same products, same order); under STRATA_METAL_LDB=0 the original.

Engine, Q2_0, MTP spec 2, 5 interleaved pairs, same binary: p8k 1936 vs 1910 tok/s (+1.4%, 3/5), p32k 1747 vs 1711 (+2.0%, 4/5),
greedy ids identical in every run. The overlap is partial: the two queues share the GPU.
2026-10-31 15:32:09 +01:00
Niko1221 618cd7a4b6 Opt-in STRATA_PENALTY_PROMPT=0: the repeat, frequency and presence penalties count the tokens the reply has written, not the last tokens of the prompt (#4590)
With the model-card default presence penalty 1.5 over the last 64 tokens, the prompt's own tokens (digits of a new task, a quoted JSON) were penalised, and a conversation whose last turn asks for something new could get its previous answer back. Measured on a P100, IQ2_XS, the reporter's saved 4-message conversation, 32 seeds per arm, 400-500 tokens: as shipped 13/32 returned the old JSON and 16/32 a numbered plan; reply-only penalty 4/32 and 25/32; presence 0 3/32 and 26/32. quality027 --quick (19 tasks, greedy, one run each, no noise arm): shipped 15, reply-only 16, presence 0 17 passed; the 1-2 task differences are single tasks and inside run-to-run noise. Default unchanged (switch off). The old JSON still came back in about 1 of 10 in the best arms, so the penalty is a large part of the cause, not all of it.
2026-10-31 15:27:53 +01:00
Niko1221 9abe0d6c45 WIP HC v3 read: bit-exact load-ordering variants of gr_down_v3 / gr_up_v3 behind STRATA_GR_V3_PF (default 0 = the existing kernels), gr_v3_screen bench
bit 0-1 down (batched staging loads, weight prefetch), bits 2-3 up, bits 4-5 rows per warp 2/4 for T<=4. Screen bit-for-bit against the plain v3 read; speed not established yet.
2026-10-31 15:15:40 +01:00
Niko1221 da1339d547 WIP dense_tc: sm_120 INT8 tensor-core Q6_K projections (T 1-8), off unless STRATA_DENSE_TC=1; parity test against a scalar reference of its arithmetic contract
Rounding-changing path (not native_mmvq's order), so opt-in until a KL check and the owner's OK. HIP/other builds get stubs. Not benchmarked yet.
2026-10-31 15:15:40 +01:00
Niko1221 af6ba4c607 Dispatch counters: which kernel path a run really took (dense MMVQ by type/shape/rows, grouped experts and gate/up form, HC read form, window graph kind, layer paths, PDL edges, head), written as JSON at exit with STRATA_DISPATCH_REPORT 2026-10-31 15:03:07 +01:00
Niko1221 1b9a1b235a Metal: gr_norm and the MoE combine with their loads batched (decode +2-3%, same tokens)
gr_norm stored R in place inside its loop, so each of its ten iterations (and the ten of the second loop) waited a full
memory latency for the next load: 98 times a token, ~6 us of a 7.7 us dispatch. gr_norm2 loads R, bo and w_norm first, then
sums, reduces and stores; moe_combine_sh2 loads the ten plan entries, then the ten rows, then sums. The arithmetic and its
order are the originals', so the outputs are bitwise the same. STRATA_METAL_LDB=0 selects the original kernels.

token_chain (Q2_0, 1 token, graph): 10.65 -> 10.14 ms. Engine, MTP spec 2, 4 interleaved pairs: story 100.97 -> 103.90 tok/s
(+2.9%, 4/4), code 108.63 -> 110.84 (+2.0%, 4/4), greedy ids identical in every run.
2026-10-31 14:11:21 +01:00
Niko1221 dbf6a6bb62 Stall report: STRATA_STALL_STACKS=1 lists every thread's call stack (Windows), and the stall dump carries thread, handle and memory data
Off by default. With STRATA_STALL_STACKS=1 the stall report suspends each other thread in turn, unwinds it with the unwind tables
(no heap, no loader lock, no stdio) and prints the frames, named from the PDB when it lies beside strata.exe. The minidump flags now
include process thread data, handle data and memory info. Used to locate the Windows gfx1151 UD-Q4_K_XL stall (host thread inside
the HIP runtime after a graph launch).
2026-10-31 14:09:27 +01:00
Niko1221 5a13c34f1b PLE table lock: raise the soft memlock limit to the hard one first, and when the table still does not fit say how much this program may lock and what to set (#4671)
A systemd service without LimitMEMLOCK may lock 8 MiB, so the 26.8 GiB mlock of the PLE table was refused and the engine ran with a reclaimable table (the reporter measured up to 6% slower decode) behind a two-line message. Before mlock the engine now raises RLIMIT_MEMLOCK's soft value to the hard one (needs no privilege) and says so; if the table still does not fit, the message gives the allowed size and the remedy (LimitMEMLOCK=infinity, or ulimit -l unlimited). The residency policy itself is unchanged. Tested here: the CUDA engine builds (sm_60). The run under 8 MiB soft/hard, 8 MiB soft with a higher hard limit, and unlimited is queued, not yet seen.
2026-10-31 14:06:55 +01:00
Niko1221 b8c69334bc Metal probe: busy_flag_probe shows the DS4 busy-spin kernel does not see the host's flag write (#4771) 2026-10-31 13:34:26 +01:00
Niko1221 d01b181751 Metal: conversations park and restore (#4730, #4144)
The Mac now reads its free RAM for the conversation-cache guard (arena_avail's rule), the window's checkpoint uses the shared
contract (3 tail rows a layer, a scratch block_pos), and the session's QsaStates point at the window's K/V buffers (and the
drafter's), which the generic snapshot copies as cell-major byte prefixes. Before, the session's own pools were never written on
Metal. Two conversations taking turns (11K and 6.5K, 24K and 3K tokens): the second request of each re-read its prompt (6.3 s,
14.9 s); now it reuses it (46-65 ms), with the same answer ids.
2026-10-31 13:30:39 +01:00
Niko1221 b0b942fa9e A pack that keeps a native-eligible projection unquantized (BF16) no longer stops the engine with 'no native GGUF blocks': the matrix is served as Q8_0 like its siblings (#4741)
Heretic v2 IQ4_NL keeps blk.0.attn_gate.weight as BF16 (GGUF type 30) while the other 35 gates are Q8_0. NativeDense has no kernel for type 30 and skipped it, so the prompt path and the verify window, which read the gate through the native route, stopped with 'blk.0.attn_gate.weight has no native GGUF blocks (run with --native)' on the first request. NativeDense::load now makes such a matrix (BF16 in the file, BF16 row in the pack, 2-D, a multiple of 32 wide) into Q8_0 on the host (the same rounding the engine already uses for STRATA_HC_Q8) and binds it, so every dispatcher finds a native projection; weight_bytes_for counts it. The pack's BF16 copy stays. This is not bit-exact against reading the BF16 values (a Q8_0 rounding on one 2560x6144 matrix, the same format its siblings have); reading the BF16 directly would need changes in the prompt path, the verify window (with its side-stream branches) and gdn_layer. Tested here: the CUDA engine builds for sm_60 (p1004). NOT tested: any run, because the 109 GB Heretic IQ4_NL model is not on a test box; the reporter's own run is the first test.
2026-10-31 13:09:49 +01:00
Niko1221 b89bf5947d Windows: an engine started from a low-priority program no longer reads its model at Low disk priority; its disk priority goes back to Normal and one plain line says so (#4733)
A Windows process starts with the I/O priority of the program that started it. Run from a Task Scheduler task, a service, or a terminal that an IDE or coding agent keeps at low priority, the engine inherited Low I/O priority: the reporter measured PLE direct reads about 15x slower, cold prompt reads 3-5x slower and decode 14% slower on the same drive, and the engine blamed the drive. Both engines (strata and strata-dsv41) now read ProcessIoPriority at start and set it to Normal when it is lower (no privilege needed to go from Low to Normal). STRATA_KEEP_IO_PRIORITY=1 leaves it as inherited. The CPU priority class is not touched. New io_priority_test (Windows): started at Low it ends at Normal, started at Normal it is untouched, KEEP leaves Low alone. Tested here: that test passes on Windows; the HIP engine builds. Not tested: the reporter's slowdown itself (needs their drive and a low-priority launcher with an engine run).
2026-10-31 13:01:12 +01:00
Niko1221 5c0259e20c Server: the sampler's and the HIP probe's files are closed after reading, so the ResourceWarning lines of every run are gone; no answer changes (#4654) 2026-10-31 12:58:46 +01:00
Niko1221 c9efd49202 Docker: RETUNE=1 passes --retune to the launcher, so a tuned preset is measured again (#4779) 2026-10-31 12:58:46 +01:00
Niko1221 89b648a65e App: a helper row saved as In progress is shown as stopped when the page loads again, so a stopped helper no longer keeps its row and the running count after a restart; a live update brings back a helper that really runs (#4218) 2026-10-31 12:57:44 +01:00
Niko1221 6d8fbf4194 App agent: the summary that replaces older history is asked for in the language the person used (the Harness's instruction says concise English); paths, commands and identifiers stay exact (#4750) 2026-10-31 12:57:26 +01:00
Niko1221 c59f766a0a App: the Open button of a website preview hands the address to the default browser through the host (the app window drops window.open, so it did nothing); http and https only (#4721) 2026-10-31 12:55:07 +01:00
Niko1221 abd5c8cb8a App serving log: an Anthropic stream's prompt count (message_start) is read too, so tokensIn is no longer left empty while tokensOut is filled (#4678) 2026-10-31 12:54:19 +01:00
Niko1221 788e372792 Own model files: Strata's draft layer (mtp-q2_0.gguf) beside a model is no longer counted as a second model, so the folder is not refused with 'holds more than one model' (#4693) 2026-10-31 12:53:10 +01:00
Niko1221 ecb2fc4502 ds4 thread pool: fence the generation store before reading the sleepers, and start workers from generation 0 (a possible part of #4664, not shown to be its cause)
A worker going to sleep at the moment a batch started could miss both the new generation and the wake-up (the caller then runs the batch alone, slower, not wrong). Taken from Project Maya v1.1.36 (#618, Cody Harrison), MIT, credited in third_party/project-maya/NOTICE.md.
2026-10-31 12:52:56 +01:00
Tycho Rasch f6e3042a61 Windows HIP: keep the pinned expert arena's GPU aliases clear of the process's memory, so one start in ten no longer reads every long prompt wrong (#3431, #2960; PR #4787)
On Windows the HIP runtime gives every cudaHostRegister'ed range a second address for the GPU and takes any host pointer inside it for the registered memory, but it does not reserve that range in the process's address space. In about one start in ten the stack of the thread that pins the expert arena lay there: small cudaMemcpy calls from local variables then read (and wrote) the arena, and every long prompt was read wrong until a restart.

- pinned.cu: each registered range's alias is reserved (MEM_RESERVE); a range whose alias already covers memory in use is unregistered and treated as refused by the driver, so it stays resident but unpinned (logged).
- generate.cpp: main keeps the free address space below 1 TiB reserved for the life of the process and runs the engine on a new thread, so its stack and later allocations lie above the aliases (STRATA_STACK_ISOLATE=0 turns it off).
- pinned_alias_test, docs/AMD_HIP.md.

Windows HIP only; other backends keep their behaviour. Author's measurements: 0 bad starts in 110 (main + change 0 in 70, main alone 5 in 70) on an RX 7900 XTX. Here: the HIP objects, pinned_alias_test and pin_budget_test build for gfx1100 with ROCm 10.2 (no gfx1100 card to run them on). The hunk in pinned.hpp was applied by hand because v0.2.9 has the shared-file fields between the lines the PR touched.
2026-10-31 12:52:41 +01:00
Niko1221 679961662d Control: the tuning probe's child output is read with errors replaced too, the last place of #4675's kind (#4629: the same crash on a Russian-locale PC was fixed in 0.2.9 by 7146d6983) 2026-10-31 12:52:12 +01:00
Niko1221 44bca4099f App: DeepSeek-V4.1-Flash is ready to read pictures without the Qwen reader program; the Models screen no longer tells its owner to update an engine that is fine, and a load with Read pictures on no longer asks for a vision section it never has (#4772)
The screen looked for strata-vision in the engine folder for every model; DeepSeek carries its own picture tower (--vision on|off), so the check always failed for it and Read pictures stayed off and locked.
2026-10-31 12:51:29 +01:00
Niko1221 35057071c1 Tool calls: a call written as tools.write, functions.write or default_api.write is the declared tool write (#4751)
A programmatic-tool-calling harness shows the model its tools as tools.write(...) inside code, and the model then wrote the native call with that spelling; the call came back as text and the run stopped. The prefix is read like the other unambiguous aliases (a declared name always wins, an undeclared name stays text). Also: a tool_reference with a non-string name no longer fails the request. Tests for the prefixes and for the history rule on the Anthropic and Responses shapes.
2026-10-31 12:47:49 +01:00
Niko1221 6a8752f9bb Tool calls: a tool the conversation already called counts as declared on the Anthropic and Responses routes too, not only on Chat Completions (#4663)
The 0.2.9 rule read only assistant messages' tool_calls. It now also reads tool_use blocks (Anthropic) and function_call / custom_tool_call input items (Responses, with the namespace in front of the name). Test on the /v1/messages route added.
2026-10-31 12:47:49 +01:00
xripandNiko1221 9efac55f51 Anthropic tool search: a request with a defer_loading tool returns a call for an undeclared name as a tool_use, and a tool_reference loads the tool's definition where it stands (#4663, PR #4700)
Claude Code declares ToolSearch and a defer_loading placeholder and names its MCP tools only in the conversation; when the model
calls one of them straight away the call came back as text and the session stopped. Ported from PR #4700 by xrip onto v0.2.9
(the server.py hunk for the parser call moved); the PR's own tests are included.

Co-authored-by: Niko1221 <[email protected]>
2026-10-31 12:46:05 +01:00
Niko1221 2fba107658 Merge branch 'f-041-maya-a' into f-041-maya-int 2026-10-31 12:34:50 +01:00
Niko1221 b19b38aff1 server: note that the FP16 weight cache and the no-loan prompt path are the engine's own automatic defaults 2026-10-31 12:33:01 +01:00
Niko1221 dde320ffaf Launch: after a saved --backend choice the launch says which make is used; the plan for an Intel card no longer announces a draft layer download and says Strata builds the pack 2026-10-31 12:18:14 +01:00
Niko1221 b0fde18b16 Launch: a PC whose NVIDIA driver does not answer but has working Intel Arc cards is told so, with the one next step (--backend intel) 2026-10-31 12:15:24 +01:00
Niko1221 caef1e879a Metal: spin-then-block wait in run_graph (decode +5%, same tokens); STRATA_METAL_PROFILE prints periodically and at exit 2026-10-31 12:12:24 +01:00
Niko1221 4f0fafbc8f Warning for UD-Q4_K_XL on a Strix Halo in setup, launch and the plan (can stop on the first request after a start; points to UD-IQ4_XS; warn only, written to be removed with the stall fix) 2026-10-31 12:05:53 +01:00
Niko1221 d88531297c Launch on an Intel Arc card prepares the model itself: setup.py --prepare-pack builds the pack from the GGUF files and fetches gguf-py on its own, the run settings are written as before, and docs/INTEL_ARC.md's quick start is true 2026-10-31 12:05:53 +01:00
Niko1221 17a572f3e3 Launch: --backend nvidia|amd|intel|auto chooses the engine make on a PC with cards of several makes and is remembered; --plan and the Cards step say the Intel cards exist; a chosen make is never refused 2026-10-31 12:05:52 +01:00
Niko1221 e809e67ecf DeepSeek Metal build: the PCIe lane code compiles on macOS (no sched_getaffinity, no PCI bus id or registered-memory attribute, no globaltimer asm); Linux/CUDA/HIP paths unchanged 2026-10-31 11:33:44 +01:00
Niko1221 6a07e8ad72 Setup: the no-internet DeepSeek message keeps its wording and adds the proxy sentence after it; the install.sh hardware-line test puts its fake tools first on PATH 2026-10-31 11:29:40 +01:00
Niko1221 5f19bf5b32 Merge branch 'f-041-maya-par' into f-041-maya-port2 2026-10-31 11:16:35 +01:00
Niko1221 b2c3847fc0 HIP: Q2_K's GEMV always through the 4-column kernel (in slices), so a column does not depend on the rows beside it; ds4_gguf_test --rows says what a differing column depends on 2026-10-31 11:13:41 +01:00
Niko1221 9f86e95a3c serve: STRATA_DS4_RESTORE_SHARED=0 leaves the shared start out of the restore points the server asks for (for A/Bs) 2026-10-31 11:08:42 +01:00
Niko1221 e9d9ed8bb3 DeepSeek engine: a shared start a fresh prompt reaches is kept from the one read, not by stopping there (A3)
A read from token 0 that reaches the shared start (shared=, pin=) no longer stops at it: the start is a restore point inside
the one run of chunks (feed_marks), and the state at it is saved apart for new conversations after the read, from that mark
(Model::slot_save(out, err, at): the window rings and partial groups from the mark, the compressed KV and index keys of rows
0 .. at / ratio, the tokens up to it).  The stop cost a whole extra pass over the streamed experts: a fresh 6000-token prompt
with the shared start at 4500 and a tail point, DGX Spark first token 21.0 s -> 16.4 s (14.35 s with no keys at all).
STRATA_DS4_PREFIX_STOP=1 stops at the start as before.  A new chat behind the start still takes it back in 0.11 s and reads
the same tokens as a cold read; the agent loop total is unchanged (188.8 s against 187.9 s, 3 runs each).

Credit: Project Maya #607 (mw00), MIT, third_party/project-maya/NOTICE.md.
2026-10-31 11:07:52 +01:00
Niko1221 8becec7d5c gfx1151 on Windows: the exact parallel-request switches are on by default for 2 or more batch slots
serve/server.py (0.2.9) sets the nine qualified switches only on Linux; Windows had none. Measured on a Strix Halo Windows PC
(Qwen3.8-Flash-Next IQ3_S, serve/server.py --parallel 4 over the engine, tools/par_load.py w2 at 4 clients, 3 rounds, interleaved,
download paused): 51.0 / 51.2 / 51.7 / 50.7 tok/s without, 55.0 / 55.6 / 55.2 / 55.1 with (+8%), TTFT p50 2.57 -> 2.43 s,
max chunk gap p95 1.97 / 1.37 -> 1.27 s, no errors. Not STRATA_BATCH_CONCURRENT_READ.

parallel_default_env() in arch_defaults.cpp: CKPT_POOL, CKPT_POOL_READY=4, SLOT_CKPT_MOVE, SLOT_COPY_DIRECT, SLOT_COPY_BATCHSYNC,
BATCH_GDN_GROUPS, BATCH_ATTN_ROWS, BATCH_SELF_COMMIT, QSA_SLOTS, set from generate.cpp with --batch / --slots of 2 or more on a
Windows gfx1151; a switch the user set is kept; STRATA_GFX1151_DEFAULTS=0 turns it off. Unit test in arch_defaults_test.cpp.

verify.cpp read STRATA_BATCH_SELF_COMMIT and STRATA_QSA_SLOTS into namespace-scope constants, before main() sets the defaults, so
two of the nine would never have seen them (the first engine build measured 52.2 / 52.6 tok/s, the base). They are read at the
first use now. Also logs the time of each verify window graph capture.
2026-10-31 10:53:26 +01:00
Niko1221 b3b47b108b build_windows.bat: STRATA_CMAKE_EXTRA reaches the cmake configure line (for example -DSTRATA_WINDOW_MAX_T=8 builds the 8-row engine) 2026-10-31 10:53:19 +01:00
Niko1221 cda6463e8e Qwen parallel: FP16 weight cache sized by free VRAM (3 GiB kept free), no loan of cache slots when every expert fits, replica profile keeps 2500 MiB free 2026-10-31 10:47:25 +01:00
Niko1221 169d5d6a9e DeepSeek --parallel: the DSpark drafter in the steps of several requests (Project Maya #611, needmorevram; with mw00's 1434096, 4b2378b, 0d0f261) 2026-10-31 10:16:57 +01:00
Niko1221 ab0ed4feab Launch: the Intel Arc 'cannot set up this card' message points to docs/INTEL_ARC.md and the pack tool 2026-10-31 10:15:14 +01:00
Niko1221 ed3de732cc Tests: install.sh hardware-line test writes its fake tools with real newlines 2026-10-31 10:02:06 +01:00
Niko1221 b3c3909147 Setup: finish marks of a read-only model folder are kept in Strata's own folder (the 110 GB Engram is hashed once, not on every launch); a prepared model starts with no internet: the llama.cpp source is fetched only when a preparation tool runs, and a pinned file copied in by hand is checked locally instead of asking the server 2026-10-31 09:57:47 +01:00
Niko1221 35ca7ec773 App agent: the system prompt says which computer it runs on (Windows/PowerShell, macOS, Linux), so the model stops probing with ls, uname and wc 2026-10-31 09:56:02 +01:00
Niko1221 69896982ae DeepSeek --parallel on an event scheduler: prompts read a piece at a time, a read alone yields to a request, opt-in STRATA_DS4_READ_PIECES (Project Maya #587, mrsirg97-rgb) 2026-10-31 09:55:53 +01:00
Niko1221 896818a3de DeepSeek plan on AMD: the expert count matches the engine's carve-out (2,365 on one R9700 and 12,152 on four, where real runs held 2,366 and 12,147) 2026-10-31 09:54:21 +01:00
Niko1221 86c94204a1 Naming: the DeepSeek sets are ISTA-DAS on screen and in the docs; the ids (--model IST-DAS-2.25bpw) are unchanged and the new spelling is accepted too 2026-10-31 09:51:11 +01:00
Niko1221 939e1acbae install.sh: the graphics line comes from the PCI class and a failed nvidia-smi gives no name (an SSD and an error sentence were printed as the card) 2026-10-31 09:50:49 +01:00
Niko1221 61b39f08b7 Setup: a download that cannot reach its server says what to set behind a proxy 2026-10-31 09:50:49 +01:00
Niko1221 27060866eb Launch: no disk warning for model files used where they are, and the tight-fit notice no longer says tight twice 2026-10-31 09:50:48 +01:00
Niko1221 3da4517962 Update wording: a first install does not say a previous engine is kept; a second rollback says it moved to the newer version and no longer marks it skipped; --check names the command to force a skipped version; the app line says the Terminal cannot tell the installed app version 2026-10-31 09:50:48 +01:00
Niko1221 a5112326a0 Server: a stop that arrives the moment the ready file appears takes the clean stop (file removed, exit 0) instead of an uncaught interrupt; fixes the flaky ready-file test 2026-10-31 09:44:39 +01:00
Niko1221 866681a889 Tests: the encoder-build test no longer reads the real cards of the box it runs on 2026-10-31 09:44:39 +01:00
Niko1221 45f0e48725 Local models: the importer accepts the Flash-Next family's own architecture (qwen4exp) instead of sending it to llama-server (#4726, part 2 of 4) 2026-10-31 09:44:05 +01:00
Niko1221 050741096b Parser: bare and unwrapped calls keep their old closing rule; the DeepSeek plain-parser test settles its call at the end of the turn (#4701 follow-up, full serve suite 1265 passed on p1006) 2026-10-31 09:44:05 +01:00
Niko1221 49b880c01f Parser: a call's closing tags end a value only when no prose and later closing tags follow, so a file that lists them is written whole; a closed call is held until the turn ends, a new call starts or 1500 characters pass (#4701, Option C) 2026-10-31 09:44:05 +01:00
Niko1221 e9b4bf0055 Parser: tests that pin the #4701 decision cases (one expected failure today); options written up in phaseA-tests/urgent029/OPTIONS-4701-4704.md 2026-10-31 09:44:05 +01:00
Niko1221 499a68dba6 DeepSeek --parallel: the prompt read a piece at a time with the others' decode steps between the pieces, opt-in STRATA_DS4_PIECE (the idea of Project Maya #587, mrsirg97-rgb) 2026-10-31 09:42:19 +01:00
Niko1221 331ef6890d DeepSeek prompt ring sized by the GPU's measured host link, opt-in DS4_RING_MB=link (Project Maya #609, mw00) 2026-10-31 09:37:43 +01:00
Niko1221 3ce04cd88d ds4_gguf_test --rows: say which row count and column differ, and the single-column result bits 2026-10-31 09:35:38 +01:00
Niko1221 c85cfc7cc2 ds4_main: --multi-check reads --ids @file after it is expanded 2026-10-31 09:35:38 +01:00
Niko1221 ca89975ec1 serve: DeepSeek engines with --parallel (tagged T/PP/DONE lines, STOP <rid>, parallel -> --parallel) 2026-10-31 09:35:38 +01:00
Niko1221 27f3137493 DeepSeek --parallel: --serve multi protocol (GEN rid=, READY multi=), --multi-check, ds4_gguf_test --rows (Project Maya #332, mw00) 2026-10-31 09:35:38 +01:00
Niko1221 29c2d574f8 DeepSeek --parallel from Project Maya (#332, mw00): per-conversation state, attention split out of layer(), step_multi / sample_multi 2026-10-31 09:35:38 +01:00
Niko1221 f080938676 Merge branch 'f-041-maya-a' into f-041-maya-b
# Conflicts:
#	src/dsv41/ds4_main.cpp
#	src/dsv41/ds4_model.cu
2026-10-31 09:08:07 +01:00
Niko1221 8ea5361bfc Merge tag 'v0.2.9' into f-041-maya-b
Strata 0.2.9 (engines and the SYCL package built from 99866a47a2b8a21b131aad162a584ee9da92f8f0; the Windows and Linux x86 app, Terminal, Control and installers from c62d5e3a15b40ea51c96aae9e8d36928965f53ab; the Mac set from 4cf0d8cd3 and the Spark Control from 38f56619c, which the later card fix does not touch; the later commits change the Python Control, server, setup, tests, tools, docs, the app host and the Mac Metal engine only)
2026-10-31 09:07:42 +01:00
Niko1221 908a4dee7b Merge tag 'v0.2.9' into f-041-maya-a
Strata 0.2.9 (engines and the SYCL package built from 99866a47a2b8a21b131aad162a584ee9da92f8f0; the Windows and Linux x86 app, Terminal, Control and installers from c62d5e3a15b40ea51c96aae9e8d36928965f53ab; the Mac set from 4cf0d8cd3 and the Spark Control from 38f56619c, which the later card fix does not touch; the later commits change the Python Control, server, setup, tests, tools, docs, the app host and the Mac Metal engine only)
2026-10-31 09:07:32 +01:00
Niko1221 7d4465f75d DeepSeek engine: DS4_CHUNK_LOG=1 prints each chunked read's long-prompt skip (rows, kept rows, first restore point) 2026-10-31 09:07:32 +01:00
Niko1221 2bfd207d33 DeepSeek prompt path: Maya's buffered flow reads (DS4_FLOW_BUFFERED, 1024 tokens on one GPU) stay off by default
Measured on Boston (one A6000 as a 12 GB card, ballast leaving 64 GB of RAM, UD-IQ1_M): the first 1000-token prompt read took 17.0-17.5 s with the
B1 default against 11.1 s before B1 and 11.2 s with DS4_FLOW_BUFFERED=0. The setting is Project Maya #312's (Peasant Smith / mw00); it stays available
as DS4_FLOW_BUFFERED=<tokens> until it is measured to win here.
2026-10-31 08:03:15 +01:00
Niko1221 45a38347a5 DeepSeek expert cache: the RAM store's put reads the slot through sat() (tail slots), not vram_base + slot * unit
With a layer's slots partly in the lent tail region (B1, Project Maya #312 tail lending), a swap that hands its outgoing expert to the RAM store
(DS4_STORE_PUT, on by default) read vram_base + slot * unit past the layer's own allocation: on HIP (p620, short prompt, windows path, cache on)
the first swap raised an illegal memory access and poisoned the context. The DBG slot dump had the same address. Adds tests/dsv41/short_prompt_check.py
(a 40-token prompt twice with the default sampler, then greedy; fails on ERR or illegal-access / router-picked lines).
2026-10-31 07:40:38 +01:00
Niko1221 54eff48756 DeepSeek engine: a short chunk streams only the experts it picks on the batches path, opt-in (A4b); DS4_UNPICKED_STAT
Project Maya #691 (needmorevram, c7d08c5), the "a chunk streams only the experts it picks" part, onto src/dsv41's batches
path (Streamer / copy_ahead; DS4_FLOW=0 or a pack the flow does not take): with DS4_SKIP_UNPICKED_ROWS=N (or
DS4_SKIP_UNPICKED=1 for every chunk) a chunk of at most N rows waits for its layer's routing and then reads and copies only
the experts some row of it picked, into the same slots and batches, so the summing order is the same.  Off by default.
Strata's flow path already streams only routed experts, so the default path gains nothing from it.
DS4_UNPICKED_STAT=1 prints, per chunk size, the share of a layer's experts no row of the chunk picked.

Credit: Project Maya #691 (needmorevram), MIT, third_party/project-maya/NOTICE.md.
2026-10-31 07:17:30 +01:00
Niko1221 490b4b0fad Merge f-041-maya-a2 (A2 tail cost, A5 path calibration, small items) into f-041-maya-a
Keeps A3's shared-start store and A4a's feed_marks together with Project Maya #668's tail-cost judgement (mw00).
2026-10-31 04:53:53 +01:00
Niko1221 0be5d73f00 DeepSeek engine: restore points near a prompt's end kept in one read, feed's after (A4a)
Project Maya #691 (needmorevram, c7d08c5), the restore-point part, onto src/dsv41: Model::feed_marks() keeps the restore
points inside a run of chunks from snapshot sets the layers fill on the way (kSnapN = 8 sets; feed_mark's one set became
an array), each the state a read up to that point leaves, instead of a run up to each point; Model::marks_inside() says
whether that is worth it (the long-prompt skip's extra rows at most a chunk, inner_cost); the serve loop reads the rest
of a prompt that way when the points left are inside it (not past a shared start a read from token 0 stops at, #607).
STRATA_DS4_MARKS_INSIDE=0 stops at each point as before.  feed()'s `after` (the rest of the prompt still to come) moves
the long-prompt skip's kept rows to the prompt's end.  Not ported from #691: the chunk streaming only the experts it
picks, the EXL3 lend, DMA read-back of host sums.

Credit: Project Maya #691 (needmorevram), MIT, third_party/project-maya/NOTICE.md.
2026-10-31 04:37:02 +01:00
Niko1221 c62d5e3a15 DeepSeek plan: the NVIDIA cards it counts are the ones setup runs (a card of 8 GB or less starts Off beside a bigger one), and the plan says which card is not used 2026-10-31 03:23:01 +01:00
Niko1221 7b96b21cf9 DeepSeek engine: DS4_TEST_SAMPLE_FAIL test hook for the sampler error handling (Project Maya #619, Cody Harrison) 2026-10-31 03:22:59 +01:00
Niko1221 7aa9a584aa Setup: say which card is not used when a card under 8 GB sits beside a bigger NVIDIA card (a line only, no change to the cards used) 2026-10-31 03:10:24 +01:00
Niko1221 987ebd442f Launch: list the Arc Pro B65 (0xe222) and the other discrete Battlemage/Alchemist ids, so two cards are both named for a launch that names none 2026-10-31 03:10:24 +01:00
Niko1221 c17b458c4f DeepSeek engine: a new conversation behind the same system prompt and tools reads only its own part (A3)
Project Maya #607 (mw00, b2d4c30) onto src/dsv41: when a prompt read from token 0 reaches its shared start (shared=,
pin=) the state the read keeps there is also copied apart from the conversation, in RAM (slot_save).  A prompt that does
not continue the conversation in the model takes back the longest kept start it begins with, when that covers more than
the model's own restore points (a kept conversation slot only when it covers more still), and reads only the rest.  With
or without STRATA_DS4_SLOT_KEEP.  STRATA_DS4_PREFIX_KEEP (2 starts, LRU), STRATA_DS4_PREFIX_GB (2), STRATA_DS4_PREFIX_MIN
(1024); 0 keeps none.  Starts with pictures are not kept.

Credit: Project Maya #607 (mw00), MIT, third_party/project-maya/NOTICE.md.
2026-10-31 03:09:06 +01:00
Niko1221 70f1f90d75 DeepSeek engine: DS4_ASYNC_T stays 8 by default (16 is opt-in; Project Maya #608, mw00): the window-size logits test did not show the claimed independence 2026-10-31 03:07:58 +01:00
Niko1221 e4031fc4d0 AMD cards fix, review round: setup's pinned one-card pick is still written (with gpus_by), only DeepSeek on AMD is judged when a copy adopts an earlier card, the launcher uses the plan's card rule and remembers a named card, Windows uses setup's amd_rank and no Linux arch list, message names strata launch --gpu 2026-10-31 03:05:41 +01:00
Niko1221 dec8f1366c Tests for the AMD card rules (4 cards, APU, mixed architectures, Windows, --gpu, --gpus, old configs, Intel, NVIDIA unchanged); stale-stamp test stubs setup.gpus 2026-10-31 02:56:19 +01:00
Niko1221 6f7f763ce5 DeepSeek on several AMD cards uses every card when none was named: setup's own pick is no longer 'asked'; old configs of that pick move to every card at the start; the launcher and the app stop keeping it 2026-10-31 02:56:19 +01:00
Niko1221 f93ec8a924 DeepSeek card rule shared by the plan, the options and setup: every card that counts, one AMD card on Windows, only cards the engine package has code for, an APU beside a discrete card left out 2026-10-31 02:56:19 +01:00
Niko1221 9edd253c25 NOTICE: fix the B4 credit line 2026-10-31 02:13:51 +01:00
Niko1221 1daf05cb02 Merge branch 'f-041-maya-a' into f-041-maya-b
# Conflicts:
#	third_party/project-maya/NOTICE.md
2026-10-31 02:05:45 +01:00
Niko1221 a280f5bbd0 DeepSeek one-GPU decode from Project Maya: copies pulled in place, windows streamed, landings in recent slots (#476 RubenTorney)
With the lane on (DS4_STREAM=1) and one discrete NVIDIA GPU, the CPU lane's RAM copies are pinned and the fetch kernel pulls a streamed
expert where it lies; a verify window's experts stream too and land in a recent slot, so the link's bytes stay in VRAM.
Gating and follow-ups: one-GPU path only where experts stream (1b60249, 482e46c #496, 7c0ceb1 #551, mw00), no landing while the swap is pending
and a table-update wait (32f3c8c handmade0octopus), a failed fill is never published (#646 handmade0octopus), Windows with pin by need (#567 TheSmokeDev),
HIP pull clock on gfx11/gfx12 and DS4_IQ1S_ROWS (#563 Cass, mw00). Everything stays behind DS4_STREAM=1; DS4_RAM_SKIP_W (changes answers) is opt-in.
Not ported: the helper-GPU condition (#415). Built on CUDA sm_120; model identity/A-B not run yet.
2026-10-31 02:04:11 +01:00
Niko1221 ec723c27e8 DeepSeek expert cache: recent slots from Project Maya (#344, needmorevram), selective port
The layer keeps DS4_RECENT=<n> VRAM slots (taken from its coldest experts) for CPU-lane experts it used twice in its last 8 ticks.
A thread copies each fill on its own stream, the next tick maps it by one tab_set launch a device; a swap leaves recent slots and
experts being filled alone, and a run of chunks that lends the tail waits for the fills first. Opt-in (default 0), values unchanged.
Not taken from #344: all-reduce over DMA, segmented top-k, DS4_INDEX_TC, the cuBLAS workspace pin.
ds4_gguf_test: its Q8_K default now follows the engine (opt-in, DS4_IQ_Q8K=1).
2026-10-31 02:04:03 +01:00
Niko1221 5322f1c2bd NOTICE for libstrix (asf0, MIT): the gfx1151 fused-expert barrier follows its barrier() 2026-10-31 01:21:21 +01:00
Niko1221 c28b5141a0 Project Maya v1.1.19 to v1.1.20 DeepSeek changes (staged merge, B3 prerequisite) 2026-10-31 01:13:52 +01:00
Niko1221 216252f034 DeepSeek PCIe lane and whole-token judge from Project Maya (#312 Peasant Smith, #329 mw00), staged merge v1.1.17 to v1.1.19
Lane and Q8_K rounding opt-in (DS4_STREAM=1, DS4_IQ_Q8K=1) until their gates have run.
2026-10-31 01:10:17 +01:00
Niko1221 abe5e67923 ds4 thread pool: fence the generation store before reading the sleepers, and start workers from generation 0
A worker going to sleep at the moment a batch started could miss both the new generation and the wake-up (the caller then runs the batch alone, slower, not wrong). Taken from Project Maya v1.1.36 (#618, Cody Harrison), MIT, credited in third_party/project-maya/NOTICE.md.
2026-10-31 01:08:49 +01:00
Niko1221 3ec28f5cc0 DeepSeek engine: tail restore point judged by its cost (A2), per-class path calibration (A5), sampler/lane/bf16 fixes, DS4_ASYNC_T 16
Taken from Project Maya (MIT), staged against v1.1.42:
- A2: the tail restore point is left out where its read stop costs more than
  reading its tokens again (TailCost wired into the serve loop; one in 32 still
  kept to measure again; STRATA_DS4_RESTORE_TAIL_AUTO=0 keeps it always). Maya
  #668 (mw00, 8721412 and 66fb17b).
- A5: the flow timing correction of the opt-in timing pick stays in the prompt
  size class that measured it (ds4_path_policy.hpp + test). Maya #612 (Will
  Hampson, 6e0d27d). Our default pick is by length already.
- A sampler CUDA error ends the request instead of sending token -1; logits()
  copies on the head stream. Maya #619 (Cody Harrison, 2040174).
- A CPU-lane layer that cannot compute its experts stops the engine instead of
  answering zeros; a layer with no GPU kernel is refused at load, any other
  warns. Maya #620 (Cody Harrison, e598716).
- bf16_rn by arithmetic on CUDA too. Maya #676 (rilken, 79628d7).
- DS4_ASYNC_T default 8 -> 16: windows of 9-16 rows take the same device path
  as 1-8. Maya #608 (mw00, b9c07e7). DS4_ASYNC_T=8 gives the old path.
2026-10-31 01:08:29 +01:00
Niko1221 e6d9221305 serve: ask the DeepSeek engine for restore points (marks=, tail=, shared=) (A1)
Project Maya #639 (needmorevram) and v1.1.37 / v1.1.39 (mw00), written onto Strata's server: deepseek_restore_points()
reads the last user turn, the first user turn (the end of the system prompt and tools) and the tail from the prompt's ids
as the template wrote them; Service.prepare puts them in the request's _restore (never a client's own), StrataEngine
sampling_keys turns them into marks=<pos>,... tail=<pos> shared=<pos>.  The shared start stays Strata's pin=N where the
request already has one (the system messages); shared= is asked only without it.  STRATA_DS4_RESTORE_POINTS (4, 0 = off)
and STRATA_DS4_RESTORE_TAIL (512, 0 = none).  serve/test_ds4_restore_points.py.

Credit: Project Maya #639 (needmorevram), mw00; third_party/project-maya/NOTICE.md.
2026-10-31 00:56:53 +01:00
Niko1221 6e53c7f8d6 DeepSeek engine: restore points (marks) a request asks for, kept in the conversation and in the slots (A0)
Ports Project Maya's restore points (#639, needmorevram, 50a3313; ds4_marks.hpp v1.1.42 by mw00 and needmorevram) onto
src/dsv41 as a per-file merge, not a commit apply:

- ds4_marks.hpp as in Maya v1.1.42 (latest_point, mark_plan, mark_victim, TailCost; TailCost is not wired in yet, #668).
- Model::mark(shared) / marks_cap() / check_bytes(): up to STRATA_DS4_RESTORE_POINTS (4, 0 = none, at most 16) restore
  points besides checkpoints 0 and 1 and the anchor, the least recently used giving way, the shared start last.
  restore() and best_check() go back to a mark; a slot keeps the marks with their roles (3 mark, 4 shared start), slot
  identity strata-ds4-session-4; the .meta file takes up to 3 + 16 checkpoint positions.
- Replaces Strata's fourth checkpoint (ck[3], the pin=N shared start) by a shared mark: pin=N is now shared=N.
- GEN keys marks=<pos>,... tail=<pos> shared=<pos> (pin=N kept): the prompt read stops at each point mark_plan keeps and
  keeps the state there.
- Tests: tests/dsv41/marks_test.cpp (Maya's ds4_marks_test), slot_test for the longer checkpoint list.

Credit: Project Maya #639 (needmorevram), #668 / ds4_marks.hpp (mw00), MIT, third_party/project-maya/NOTICE.md.
2026-10-31 00:56:53 +01:00
Niko1221 4cf0d8cd38 Merge f-039-mac-dsu-rel (33bbbca44) 2026-10-30 22:59:51 +01:00
Niko1221 33bbbca442 Offer UD-IQ1_M on a Mac too: not run there, said plainly in its plan (the Mac gate stays as an empty mechanism) 2026-10-30 22:59:35 +01:00
Niko1221 38f56619cc Both Unsloth DeepSeek sets are offered on a DGX Spark: both passed a real Spark run (AARCH64_NOT_YET is empty); tests keep the mechanism 2026-10-30 22:50:34 +01:00
Niko1221 1a26492064 Merge f-039-mac-dsu-rel (6a7d75057) 2026-10-30 22:46:09 +01:00
Niko1221 bee469c462 serve: the qualified GDN speedups are on from the server (Linux CUDA: GDN_TC + GDN_CONVL2=2; Linux HIP gfx12 only: GDN_CHUNKED), Qwen3.8 only, explicit env wins 2026-10-30 22:46:09 +01:00
Niko1221 6a7d750578 UD-IQ1_S on a Mac: the measured line from the M5 Max run (alone: 15-18 tok/s, 3K prompt 230-310, 14K 520) 2026-10-30 22:44:19 +01:00
Niko1221 417765b8d8 tests: the Spark gate still holds both Unsloth sets 2026-10-30 22:36:49 +01:00
Niko1221 5c25767fcd Offer Unsloth UD-IQ1_S on a Mac (run on an M5 Max); UD-IQ1_M stays off until it has run 2026-10-30 22:36:26 +01:00
Niko1221 ea5dbffa7f Metal: Q6_K multi-column dense matvec (ds4_q6k_mvn) 2026-10-30 22:36:13 +01:00
Niko1221 a3a1103c13 dsu_parity: --bench and the ds4_mm pipelines 2026-10-30 22:36:13 +01:00
Niko1221 ba3d28b7e3 Q6_K matvec: packed loads via uchar4 2026-10-30 22:36:12 +01:00
Niko1221 0dba618d34 Metal Q6_K dense matvec: unaligned block loads (210-byte blocks), found by dsu_parity; test fix 2026-10-30 22:36:12 +01:00
Niko1221 5ff7da4a70 Metal tests for the Unsloth set kernels: IQ1_S matvec and tile loader, Q6_K dequant and matvec, reference checked against ggml 2026-10-30 22:36:12 +01:00
Niko1221 1b6c67122a Metal DeepSeek engine: IQ1_S expert kernels (decode GEMV, window, prompt tile) and a Q6_K dense dequant for the Unsloth sets 2026-10-30 22:36:12 +01:00
Niko1221 7851a8dab6 Merge f-038-ds-unsloth (final 4148c5125) 2026-10-30 20:26:56 +01:00
Niko1221 4148c51257 launch: ask dsv41_fit.not_here directly (the launch tests stub the setup object) 2026-10-30 20:25:25 +01:00
Niko1221 bd3c5cd4cb Keep Unsloth's 1-bit DeepSeek sets off a DGX Spark until a Spark run passes (AARCH64_NOT_YET, one rule beside the Mac one) 2026-10-30 20:23:59 +01:00
Niko1221 99866a47a2 Merge f-029-urgent (bc694233b) 2026-10-30 20:06:15 +01:00
Niko1221 73e0a3b6ed Merge f-038-ds-unsloth (final 9144da7b1) 2026-10-30 20:00:26 +01:00
Niko1221 9144da7b1a tests: the launch list's download sizes include the two Unsloth sets 2026-10-30 19:59:31 +01:00
Niko1221 a833fb46f3 Merge f-038-ds-unsloth (macOS gate 32806f82a) 2026-10-30 19:58:24 +01:00
Niko1221 32806f82aa Keep Unsloth's 1-bit DeepSeek sets off the Mac until the Metal engine has an IQ1_S kernel (lists, --model, --model-path, app check, setup) 2026-10-30 19:57:24 +01:00
Niko1221 bc694233bf Hardware list: AMD cards are counted from the kernel's KFD files when there is no setup module and no rocm-smi (ROCm from a Python venv), instead of not at all 2026-10-30 19:45:23 +01:00
Niko1221 dd3f9ec82f tests: the catalog download sizes cover the two Unsloth DeepSeek sets (241.0 and 258.4 GB) 2026-10-30 19:35:11 +01:00
Niko1221 47cbb76690 Merge f-038-ds-unsloth (current head) 2026-10-30 19:28:13 +01:00
Niko1221 ab083a693f Chat: streamed text is applied about ten times a second instead of once per token, so a long answer no longer saturates the page (main-thread time 15.7 s to 9.0 s, peak heap 114 MB to 62-75 MB for a 40,000-token answer; Mio's Out of Memory report) 2026-10-30 19:24:02 +01:00
Niko1221 ad4755c249 Say plainly that the 1-bit UD-IQ1 sets are weaker on long or careful work 2026-10-30 19:23:07 +01:00
Niko1221 b60fbb54f7 serve: the parallel stack default is Linux CUDA only (HIP and Windows get nothing: no measurement outside the replica config) 2026-10-30 19:22:21 +01:00
Niko1221 8f13b8c4ad Server: one plain line at start when a graphics card in use is of an architecture the compiled engine was not built for (#4662) 2026-10-30 19:18:45 +01:00
Niko1221 0b1808f072 Update: a failed test start of the new engine names the kept log file to report (#4692) 2026-10-30 19:18:01 +01:00
Niko1221 cfa7f737cc Setup: a compiled engine gets code for every usable NVIDIA card in the PC, not only the card setup picked (a TITAN RTX + RTX 3080 build for sm_75 only failed every long prompt with unspecified launch failure) (#4662) 2026-10-30 19:18:01 +01:00
Niko1221 b3de587e03 Merge f-036-strix-u (7f6bc3308): the Windows gfx1151 default set holds both STRATA_SELECT_WMMA and STRATA_PF_F16 (seven switches); test counts updated 2026-10-30 19:17:27 +01:00
Niko1221 7f6bc33081 gfx1151 on Windows: the prompt experts run on the F16 matrix-core kernels by default (STRATA_PF_F16)
Owner rule: a consistent Strix Halo speed gain goes on by default. Windows gfx1151, engine 0.1.54 builds, interleaved, idle box
(ab_f16def, 2 rounds, no environment on either side): IQ3_S prompts 4K 1,180 -> 1,279, 8K 1,289 -> 1,398, 32K 1,299 -> 1,404 tok/s
(x1.08); UD-IQ4_XS 8K 1,092 -> 1,223, 32K 1,135 -> 1,248 (x1.10-1.12). Decode over 200 tokens unchanged (58.6 -> 58.2, 48.8 -> 48.5),
first greedy ids identical, 38 of 38 runs exit 0. The rounding differs from the int8 kernels; KL is information only.
STRATA_PF_F16=0 turns it off. arch_defaults_test expects six switches on Windows gfx1151.
2026-10-30 19:15:54 +01:00
Niko1221 1ead709790 Control: the time-skip check and tune engine reads also tolerate non-UTF-8 output (#4675) 2026-10-30 19:12:42 +01:00
Niko1221 dd63f88fdd Qwen3.6 engine: the warm-up reads only cells that exist and starts its small probes in a fresh sequence, so a prompt chunk equal to the context no longer writes past the KV cache (#4708) 2026-10-30 19:12:21 +01:00
Niko1221 2a9ccfe3ea serve: the parallel stack default only where it was run (Linux CUDA nine switches, Linux HIP seven, nothing on Windows or macOS) 2026-10-30 19:08:30 +01:00
Niko1221 dd471d3140 serve: the exact parallel stack is the default with batch slots (explicit env still wins)
With --parallel/--batch >= 2 on a CUDA or HIP Qwen engine the server sets CKPT_POOL=1, CKPT_POOL_READY=4, SLOT_CKPT_MOVE, SLOT_COPY_DIRECT, SLOT_COPY_BATCHSYNC, BATCH_GDN_GROUPS, BATCH_ATTN_ROWS, BATCH_SELF_COMMIT and QSA_SLOTS unless the environment or the config's env already names them (=0 turns one off). DeepSeek, SYCL, Metal and Vulkan engines are untouched.
2026-10-30 19:08:30 +01:00
Niko1221 4a75fa2b62 API gateway: a client that hangs up right after data: [DONE] no longer turns a finished request into "Request cancelled." (#4690) 2026-10-30 19:07:42 +01:00
Niko1221 3282c61343 Server: a tool the conversation already called counts as declared, so a call to it comes back as a tool call, not raw text (#4663) 2026-10-30 19:06:50 +01:00
Niko1221 209dcadb3f Parser: a fence is closed only by one as long as it opened with, and an indented code block is a quote, so calls shown in them are not run (#4702 #4703) 2026-10-30 19:06:11 +01:00
Niko1221 e66a445892 Server: a picture reader written into an older engine folder follows the engine in use after an update (#4658) 2026-10-30 19:03:21 +01:00
Niko1221 68718c33fa Monitor: the drive-temperature reader (every physical drive, from the first second) runs only while the Monitor is open, not at app start (#4661) 2026-10-30 19:02:16 +01:00
Niko1221 ea52e55cd7 Chat: only the streaming message renders again on each token, so a long chat no longer redoes its whole history per token (Out of Memory report from Mio; heap and main-thread time measured) 2026-10-30 18:58:34 +01:00
Niko1221 08e8770c6e Prefill (layer-major): an expert with no mapped blob is copied by its source instead of ending the request (#4695) 2026-10-30 18:46:16 +01:00
Niko1221 7146d6983f Setup, Control and Terminal: a child's non-UTF-8 output (GBK adapter names) no longer kills Step 1 or launch (#4675) 2026-10-30 18:44:41 +01:00
Niko1221 01ab6b51bb Merge f-036-strix-ds (957038a1c) 2026-10-30 18:43:54 +01:00
Niko1221 957038a1cd dsv41 (Windows HIP, integrated GPU): the shards' views are dropped after the load by default (DS4_NO_VIEW=0 turns it off, =1 forces it elsewhere)
Unbuffered reads of a file that has a view in the process run at half the drive's rate on Windows (probe on Strix Halo: 6.5 GB/s with no view, 3.2 with one). Default on for an integrated GPU, where the CPU lane's misses read through the store's slots. Decode 9.1 -> 9.4-9.7 tok/s (code, drafter off, 2 interleaved pairs); the 32K prompt is unchanged; no failed reads in any run.
2026-10-30 18:43:29 +01:00
Niko1221 d340dc8dd1 apps/host test: planning a whole-drive models folder no longer deletes <drive>\Strata (#4689) 2026-10-30 18:43:11 +01:00
Niko1221 ffaa6ad78c tests: the app preview and launch list know the two Unsloth sets 2026-10-30 18:41:24 +01:00
Niko1221 4006305476 tests: the lockfile versions are 0.2.9 2026-10-30 18:40:48 +01:00
Niko1221 97e6f1fe19 Version 0.2.9: Terminal and app 0.2.9, engine and Control 0.1.55 (every Qwen and DeepSeek package, Metal and SYCL included) 2026-10-30 18:40:46 +01:00
Niko1221 33bd74182b Merge f-038-ds-unsloth 2026-10-30 18:40:13 +01:00
Niko1221 4d12701181 Merge f-0281-ds 2026-10-30 18:40:13 +01:00
Niko1221 aed382885e Merge f-036-strix-ds 2026-10-30 18:40:12 +01:00
Niko1221 274257630b Merge f-036-strix-u 2026-10-30 18:40:12 +01:00
Niko1221 d96873fc56 Merge f-036-strix-q 2026-10-30 18:40:11 +01:00
Niko1221 86bed1c92f Merge f-037-arc (5eaee81a4) 2026-10-30 18:40:11 +01:00
Niko1221 203df526f1 serve/test_admit_order: the engine stand-in sets the arrivals deque the coalescing code reads 2026-10-30 18:39:58 +01:00
Niko1221 b6fc410ee5 Metal window.cpp: qualify NG_HC_DIM / NG_HIST in finish_rows (the --batch rows code did not compile in the host build) 2026-10-30 18:39:58 +01:00
Niko1221 0a76d62e68 pinned.cu: the shared-arena tmpfs check is Linux-only (sys/vfs.h and statfs f_type), so the Metal host build compiles again 2026-10-30 18:39:58 +01:00
Niko1221 c0e245c8be Merge f-029-par-metal (766b54afb) 2026-10-30 18:39:27 +01:00
Niko1221 16e08b50cd Merge f-029-par-amd (4772f0cd9) 2026-10-30 18:39:27 +01:00
Niko1221 1fb3e9a326 Merge f-029-par-harness 2026-10-30 18:37:54 +01:00
Niko1221 1f8f2fa8ad Merge f-029-par-big 2026-10-30 18:37:53 +01:00
Niko1221 3a76d5eaf6 Merge f-029-gdn-mx 2026-10-30 18:37:53 +01:00
Niko1221 01ef876562 Merge f-029-gdn-amd 2026-10-30 18:37:53 +01:00
Niko1221 4746bfb165 Merge f-029-gdn-cuda 2026-10-30 18:37:52 +01:00
Niko1221 0b1fbf4012 gfx1151 on Windows: STRATA_SELECT_WMMA joins the default prompt set (x1.008 at 8K, x1.037 at 32K, x1.077 at 64K; about 10 of 2,051 selected cells flip at the cutoff for queries past ~5K tokens) 2026-10-30 18:22:35 +01:00
Niko1221 6923421a61 Accept Unsloth's DeepSeek UD-IQ1_S and UD-IQ1_M sets (pins, models, app file check, fit numbers; opt-in names) 2026-10-30 18:05:31 +01:00
Niko1221 09dc5c52b3 gfx11 gr_upmix: 256 tokens a block (16 waves) halves the w_up staging per token; prefill timing splits the QSA projection stage 2026-10-30 17:55:46 +01:00
Niko1221 5eaee81a43 [SYCL] kprof: profiling-tag sections time stretches without events of their own (the oneMKL GEMMs) 2026-10-30 16:33:01 +01:00
Niko1221 d936db6547 gfx1151: a Q8_0 down expert layer takes the F16 matrix-core prompt kernel by default
UD-IQ4_XS and UD-Q4_K_XL keep five layers with Q8_0 down experts; without STRATA_PF_F16 they left the fused path for MMQ.
On those layers only the F16 kernel now runs (STRATA_PF_Q8D_F16=0 turns it off); every other format keeps its kernel.
UD-IQ4_XS on Windows gfx1151, interleaved x3: 4K prompt 1,071 -> 1,120 tok/s, 8K 1,136 -> 1,180, same output ids;
IQ3_S unchanged (1,190 -> 1,186, 1,286 -> 1,284).
2026-10-30 16:15:54 +01:00
Niko1221 ac3e0e17b5 GDN gfx12: the chunked path from 64 tokens, as on gfx11 (owner: every gain counts; final gate at 64: +1.7% to +6.1%, KL 0.0025/0.038, 55/61 tasks, soak clean) 2026-10-30 16:05:14 +01:00
Niko1221 b3bc477512 diagnostics: STRATA_QSA_STAT (overlap of consecutive queries' selections), hip_pa_bench target and PA_BENCH_REF (compare two variants of the prompt attention on a recorded selection) 2026-10-30 15:41:53 +01:00
Niko1221 db729163ce Revert "setup: a Strix Halo's BIOS carve-out holds expert slots of the Unsloth RAM-budget plans"
This reverts commit 94fc01c6fd.
2026-10-30 15:41:41 +01:00
Niko1221 19b5f873f2 dsv41 (Windows): DS4_NO_VIEW=1 drops every view of the shards and the load's buffered handles after the load, reads go unbuffered
A file with a view (or a buffered handle with cached reads) in the process halves unbuffered read speed on Windows (probe: 6.5 GB/s with no view, 3.2 with a view of the whole file or of 1 GB of it, 3.3 with a buffered handle held open, 6.7 on a copy while the original is mapped). The CPU lane reads misses through the store's slots, so the views are not needed after the load: Pack::drop_views() unmaps them, reopens never-read fallback handles and Pack::read_at goes unbuffered. StoreHold re-reads an expert that has no slot instead of falling back to the view.
Also: DS4_IO_STATS (demand read queue/read/copy times), DS4_IO_PRIO, DS4_READ_SPLIT (default 1, off; deeper queues did not help). Decode 8.9 -> 9.6 tok/s (code, drafter off).
2026-10-30 14:56:43 +01:00
Niko1221 0122eceb4c [SYCL] --batch-groups: 'auto' is understood, and two Arc B-series cards with 8 or more slots default to 2 pipelined groups (INFO batch_groups); STRATA_BATCH_GROUPS_AUTO=0 / --batch-groups 1 turn it off 2026-10-30 13:56:52 +01:00
Niko1221 8401c21f9d [SYCL] prompt attention on the int8 DPAS (ESIMD, Battlemage): hi/lo int8 Q and P, 2D block loads for the four-cell selection blocks; default on for Battlemage, STRATA_PROMPT_ATTN_ESIMD=0 off 2026-10-30 13:22:33 +01:00
Niko1221 9c2121cf5c dsv41: the CPU lane's service thread spins through a decode token (DS4_LANE_SPIN_MS, 250) and naps on a high-resolution timer; DS4_PROF_SYNC decode stage profile; DS4_LANE_SKIP timing oracle
The service thread spun 2 ms after its last answer; a Strix Halo decode layer takes ~2.8 ms, so it slept (Windows sleep_for(20 us) wakes after ~1 ms+) before almost every request.
Decode 8.2 -> 9.3 tok/s on pack 2.25 (2 interleaved pairs). DS4_PROF in decode and in the prompt hangs the HIP driver on Windows; DS4_PROF_SYNC drains the stream at each decode stage mark instead.
2026-10-30 12:26:18 +01:00
Niko1221 1cfffa2ede gfx1151 fused experts: one barrier per stage (explicit LDS wait, after libstrix's barrier(), asf0/libstrix MIT) and 3 blocks per WGP for IQ2_S/IQ2_XS gate/up on gfx11 2026-10-30 12:14:41 +01:00
Niko1221 a83252a7b7 gfx1151: the VGPR cap of the occupancy expert kernels applies on clang 24 as on clang 23 (IQ3_S/IQ4_NL layer 79.7 -> 69.9 ms at 16K tokens, parity unchanged) 2026-10-30 11:40:16 +01:00
Niko1221 d8056fbe00 [SYCL] STRATA_KPROF=1: per-kernel GPU timer (event-profiled queues, kp() launch wrapper, decode/prompt tables); tools/arc A/B harness 2026-10-30 11:27:59 +01:00
Niko1221 6fb110e557 Opt-in research switches: STRATA_PAR_COUNTERS, STRATA_PIPED_CONCURRENT_MIN, multi-prompt layer-major read (Prefill::run_multi, BCO co-reads, server STRATA_COALESCE_PREFILL), STRATA_CRASH_BT 2026-10-30 11:08:53 +01:00
Niko1221 28c4543e48 tools: parallelism load harness (par_load, par_analyze, par_run, sweep, recon), E00 capability probe, batch_test --co/--seq/--pad and the phase-1/2 variant lists 2026-10-30 11:08:53 +01:00
Niko1221 e1e7e32680 prefill timing: STRATA_PREFILL_TIMING also splits the hyper-connection read into down, up, inject and mix 2026-10-30 11:03:01 +01:00
Niko1221 94fc01c6fd setup: a Strix Halo's BIOS carve-out holds expert slots of the Unsloth RAM-budget plans
uma_expert_cache counted only the RAM the OS sees, so UD-IQ4_XS (55 GiB of experts) and UD-Q4_K_XL (72 GiB) on a PC with a
64 GB carve-out and 64 GB seen by Windows got no cache slots beside the 40 GiB budget and read the rest from the SSD.
The carve-out (less 14 GB for the dense weights and buffers) now adds its slots. Tests added.
2026-10-30 11:00:42 +01:00
Niko1221 b0ba459514 Merge f-029-par-sycl (SYCL batch windows, GDN/attn rows kernels) 2026-10-30 10:56:29 +01:00
Niko1221 2fef430202 Merge f-034-cand (0.2.9 candidate) into f-036-strix 2026-10-30 10:52:31 +01:00
Niko1221 7a9245b6c0 [SYCL] batch window: port the one-launch-for-all-rows kernels (GDN groups, block scores rows, decode attention rows; opt-in STRATA_BATCH_GDN_GROUPS / STRATA_BATCH_ATTN_ROWS) and the batch-window stage profile; no speed gain on 2x Arc B65 2026-10-30 08:25:37 +01:00
Niko1221 368bcb7a26 serve/test_admit_order: the engine stand-in sets the arrivals deque the coalescing code reads 2026-10-30 07:32:39 +01:00
Niko1221 5ad0135c00 Metal window.cpp: qualify NG_HC_DIM / NG_HIST in finish_rows (the --batch rows code did not compile in the host build) 2026-10-30 07:32:39 +01:00
Niko1221 cdb5470c70 pinned.cu: the shared-arena tmpfs check is Linux-only (sys/vfs.h and statfs f_type), so the Metal host build compiles again 2026-10-30 07:32:39 +01:00
Niko1221 87970c8ae0 Merge f-029-par-metal (766b54afb) 2026-10-30 07:32:39 +01:00
Niko1221 777d11f8a3 Merge f-029-par-amd (4772f0cd9) 2026-10-30 07:32:39 +01:00
Niko1221 dcc79c5230 Merge f-029-par-harness (9b5965c3c) 2026-10-30 07:32:32 +01:00
Niko1221 db0606b4f8 Merge f-029-par-big (714438ec1) 2026-10-30 07:32:31 +01:00
Niko1221 4bfd66686e Merge f-029-gdn-mx (348457223) 2026-10-30 07:32:31 +01:00
Niko1221 5c00af8a1f Merge f-029-gdn-amd (f0a34fa9b) 2026-10-30 07:32:31 +01:00
Niko1221 78859bbcb1 Merge f-029-gdn-cuda (51567de1a) 2026-10-30 07:32:30 +01:00
Niko1221 9b5965c3c1 z_ctx 2026-10-30 07:32:03 +01:00
Niko1221 836dd2d86e sweep: server flags column; stacked-candidate variant lists 2026-10-30 07:21:57 +01:00
Niko1221 6d7255f205 z_pool 2026-10-30 07:20:46 +01:00
Niko1221 97bbd76c92 tools/par/overhead.sh (counters overhead A/B) 2026-10-30 07:20:14 +01:00
Niko1221 36e798f4c5 Opt-in research switches: STRATA_PAR_COUNTERS, STRATA_PIPED_CONCURRENT_MIN, multi-prompt layer-major read (Prefill::run_multi, BCO co-reads, server STRATA_COALESCE_PREFILL), STRATA_CRASH_BT 2026-10-30 07:19:55 +01:00
Niko1221 1510e6d6d2 tools: parallelism load harness (par_load, par_analyze, par_run, sweep), E00 capability probe and the phase-1/2 variant lists 2026-10-30 07:19:55 +01:00
Niko1221 4772f0cd9a co-read adaptive: look 10 ms for the burst's other arrivals before deciding 2026-10-30 06:21:21 +01:00
Niko1221 7aafac422c shared arena on Windows: a named pagefile-backed section (CreateFileMapping/MapViewOfFile) with the same header and load state word, pid election on both OSes, server option enabled, pinned_shared_win_test 2026-10-30 06:02:30 +01:00
Niko1221 62b042e843 co-read: adaptive - the leader waits and the read is shared only when the engine has seen at least 3 requests arrive together (STRATA_COALESCE_MIN_REQS, STRATA_COALESCE_BURST_MS) 2026-10-30 05:58:22 +01:00
Niko1221 9d8337844e replicas: the memfd arena warns when the RAM will not hold all the engines 2026-10-30 05:24:08 +01:00
Niko1221 714438ec19 GDN TC: two value tiles per head on cards with >= 96 SMs (STRATA_GDN_TC_TILES), harness --tiles 2026-10-30 05:12:40 +01:00
Niko1221 d3af2d42ad GDN: single-pass conv+SiLU+L2 (STRATA_GDN_CONVL2=2), output-only reduced-precision experiment mode (STRATA_GDN_TC_NP=2) 2026-10-30 05:12:40 +01:00
Niko1221 1a865fb3db GDN TC: fused output norm for the split scan, NP experiment variants (STRATA_GDN_TC_NP), STRATA_GDN_TC_FUSE 2026-10-30 05:12:40 +01:00
Niko1221 a0e7f5160d GDN harness: tc any_sms bypass for multi-wave tests, output hash 2026-10-30 05:12:40 +01:00
Niko1221 dc149ef003 GDN: production-linked parity/bench harness, dispatch probe, FP64 fixtures, contract doc; opt-in STRATA_GDN_TC on 99-KB cards (split v/T/P buffering) 2026-10-30 05:12:40 +01:00
Niko1221 9f1d0ad041 replicas: a replica that fails to start beside a living one is started again on the same shared arena before the sharing is given up 2026-10-30 05:07:50 +01:00
Niko1221 1064ff7783 STRATA_COALESCE_WAIT_MS 2026-10-30 04:52:38 +01:00
Niko1221 301a09f100 STRATA_PREFILL_LM_FORCE (research reference for the multi-prompt read) 2026-10-30 04:52:38 +01:00
Niko1221 4f88a2fa8b multi-prompt read: relaxed stage-chunk rule, sub-chunks at the smallest stage chunk; gated identity variants 2026-10-30 04:52:37 +01:00
Niko1221 1f05fdc283 [Engine] CUDA hyper-connection read (v3): the transposed warp sums also for 5..7-token launches 2026-10-30 04:52:35 +01:00
Niko1221 a8fba1f5b7 multi-prompt PLE: tables, constants and weights from the main session, the history of the prompt's own 2026-10-30 04:52:32 +01:00
Niko1221 2f0d6a1c15 replica profile (opt-in): setup --replica-profile picks one engine per card + shared expert arena + the host stack when the RAM and VRAM check passes 2026-10-30 04:51:14 +01:00
Niko1221 42aee7a349 [Engine] CUDA hyper-connection read (v3), 8-token launches: the eight warp sums in one transposed butterfly (same additions, same bits; decode c=8 +2%) 2026-10-30 04:47:30 +01:00
Niko1221 3782008944 shared arena: memfd backing (not limited by the /dev/shm size), one engine reads the experts and the others skip the read, RAM check before the pages are taken 2026-10-30 04:46:51 +01:00
Niko1221 05cd12e7a2 [Engine] batch window: STRATA_BATCH_GDN_GROUPS now also works with STRATA_BATCH_SELF_COMMIT (the grouped GDN launch keeps its one-row groups itself) 2026-10-30 04:19:00 +01:00
Niko1221 0c94851835 shared arena: the header is written before any engine reserves pages, an importer waits for a header still being written, and every engine reserves the tmpfs pages 2026-10-30 04:13:24 +01:00
Niko1221 21f950b4b4 [Engine] CUDA hyper-connection read (v3): exact 5..8-token down kernels with two rows per warp (same dots, same bits; decode c=8 +5%) 2026-10-30 03:56:23 +01:00
Niko1221 3ccfd5aed3 replicas: opt-in shared expert arena (one /dev/shm file for all replicas, stale-file sweep, private fallback); the engine reserves a tmpfs arena up front 2026-10-30 03:50:58 +01:00
Niko1221 51567de1a6 GDN TC: two value tiles per head on cards with >= 96 SMs (STRATA_GDN_TC_TILES), harness --tiles 2026-10-30 03:46:18 +01:00
Niko1221 f0a34fa9bf kGdnW12MinT comment: the 8K figure 2026-10-30 03:46:05 +01:00
Niko1221 84aa2d7d57 GDN gfx12: chunked path only from calls of 12000 tokens (measured crossover for the +5% bar); p2006/KL scripts 2026-10-30 03:45:42 +01:00
Niko1221 1d9b4c48e9 prefill: STRATA_LM_HIP=1 opts the layer-major (and multi-prompt) read in on HIP builds 2026-10-30 03:21:45 +01:00
Niko1221 2072510a47 [Engine] --batch on one GPU: opt-in concurrent prompt read beside the decoding slots (STRATA_BATCH_CONCURRENT_READ) 2026-10-30 03:10:51 +01:00
Niko1221 0ea3e455fd GDN: single-pass conv+SiLU+L2 (STRATA_GDN_CONVL2=2), output-only reduced-precision experiment mode (STRATA_GDN_TC_NP=2) 2026-10-30 03:05:51 +01:00
Niko1221 a9f154e82b run_multi: layer-major even for a short total 2026-10-30 03:02:51 +01:00
Niko1221 766b54afb1 [Engine] Metal: --batch N / "parallel": N runs, and the slot rows share one window
- the pdl capability queries answer false instead of aborting the engine (#3981, #2715)
- each batch slot keeps its own running state in the Metal window (GDN state and conv history, QSA K/V, indexer tail and pooled keys,
  PLE history), side by side in bigger buffers; the slot copy, the checkpoints and session_zero follow the session they are for
- a batch window holds one token of each active slot: the GDN step, the QSA kernels and the PLE conv take a per-row slot, cell and
  history parity (function constants, off for every other caller), so the dense and expert weights are read once for all rows
- STRATA_METAL_BATCH_ROWS=0 runs the slots one window each instead (diagnosis)
2026-10-30 02:58:46 +01:00
Niko1221 faa1be268c co-read: server side (STRATA_COALESCE_PREFILL), par_load --keep-text, cmp_texts 2026-10-30 02:53:32 +01:00
Niko1221 47a1047750 WIP multi-prompt layer-major prefill (Prefill::run_multi) and BCO co-reads in the batch admission 2026-10-30 02:53:25 +01:00
Niko1221 ee05114846 GDN TC: fused output norm for the split scan, NP experiment variants (STRATA_GDN_TC_NP), STRATA_GDN_TC_FUSE 2026-10-30 02:12:18 +01:00
Niko1221 d8c94b45c8 [Engine] CUDA prompt path: opt-in FP16 cache of dequantized dense weights (STRATA_PF_W16_CACHE_MIB) 2026-10-30 02:11:45 +01:00
Niko1221 236adb3987 GDN gfx12: conv + SiLU + q/k norm written as FP16 and read by the chunked kernels (one entry, STRATA_GDN_FUSE=0 off); ablation hooks removed 2026-10-30 01:51:05 +01:00
Niko1221 3484572236 Metal: tensor-op chunk-state probe, layer bench with before/after, A/B driver 2026-10-30 01:50:15 +01:00
Niko1221 e0cc4e859e GDN harness: tc any_sms bypass for multi-wave tests, output hash 2026-10-30 01:47:46 +01:00
Niko1221 cea3057e2c Metal: GDN chunked v2 (measured, slower), M5 GEMM tiles + fused GDN input projections (bit-identical), layer bench 2026-10-30 01:42:07 +01:00
Niko1221 5e9995d296 GDN gfx12: LDS-only barrier in the chunked scan; conv + SiLU + q/k norm folded into the chunked kernels (opt-in STRATA_GDN_CHUNKED=1, STRATA_GDN_FUSE=0 off), bench check mode 2026-10-30 01:33:37 +01:00
Niko1221 095464152c Metal: GDN prompt scan chosen per chip (M4 scanr_4_8, M5 Max scanr_4_4 from T 4096), bit-identical 2026-10-30 01:24:05 +01:00
Niko1221 2cf3fd181f Metal: gdn_scan_f (scan + gdn_post fused, bit-identical, slower: kept as a measured negative), bench 2026-10-30 01:22:28 +01:00
Niko1221 06f212aefe GDN: production-linked parity/bench harness, dispatch probe, FP64 fixtures, contract doc; opt-in STRATA_GDN_TC on 99-KB cards (split v/T/P buffering) 2026-10-30 01:12:19 +01:00
Niko1221 4d9d9cefd3 GDN: the chunked WMMA recurrence on gfx12 (opt-in STRATA_GDN_CHUNKED=1), gfx12 fragment layout 2026-10-30 00:59:13 +01:00
Niko1221 f34ab1c8f5 GDN gfx11 WMMA: compile the device pass for gfx1103 too (the runtime gate lists it; the pass trapped there) 2026-10-30 00:55:55 +01:00
Niko1221 280885fda0 Metal: GDN prompt scan benchmark (sequential vs chunked), extra gdn_scanr TB instantiations, opt-in STRATA_METAL_GDN_SCAN 2026-10-30 00:49:57 +01:00
Niko1221 ae2657a19d gdn_stage_bench: fix auto deduction 2026-10-30 00:42:26 +01:00
Niko1221 d4f15adf87 tests/hip: gdn_stage_bench, the GDN prompt stage (gates, conv, recurrence) per piece with output and state hashes 2026-10-30 00:39:06 +01:00
Niko1221 dbdec0bb9e Control 0.1.55 and Terminal 0.2.8.1 (Control-only release: the app stays 0.2.8, the engine packages 0.1.54) 2026-10-30 00:32:32 +01:00
Niko1221 d7cb41ba87 DeepSeek local GGUF: accept both layouts of the IST-DAS sets, the pinned four-shard one and ISTA-DASLab's six-shard one (owner decision, option B)
The download default stays on the four-shard pin cb02422c; nothing is fetched under the new names. The six-shard files (Hub commit a419d74c,
27 October: five shards a set, the Engram as the sixth) and the four-shard files with the first shard ISTA re-published on 26 October (what the
Hub's last four-shard commit 653a9874 serves; #4237 was refused by the app because only the pinned size of shard 1 was known) are taken from
files already here, by size and SHA-256.

- setup.py: DSV41_ISTA6_FILES (15 files), shards6 on the three sets, dsv41_layout_of / dsv41_parts / dsv41_pack_layout; install_dsv41 picks the
  layout of a --gguf-dir folder (both whole: the pinned one), packs the five files with the Engram as --engram, and builds the pack again when it
  was made from the other layout's files. The converted Engram (same name and size, another hash) is accepted beside the pinned one.
- strata_control/dsv41_sets.py, models.py: the app's check and `strata models import` know the six-shard names, the re-published shard 1, and
  that a folder with both whole layouts of one set is that set once.
- strata_cli/launch: --model-path and the guided screen name what is missing in the layout that is there.
- tools/dsv41/pack_gguf.py: --engram as the sixth shard of a six-shard split; the pinned release's Engram serves the six-shard shards too.
- Tests for each; docs/DEEPSEEK41.md.

Builds on the cherry-picks of #4348 (integer tensors in the GGUF reader) and #4632 (StackArc3's six-shard work, option A), which this
commit reworks so the four-shard pin and files keep working unchanged.

Related: #4237, #4256, #4632.
2026-10-30 00:32:32 +01:00
StackArc3 c4e975cc7a Accept ISTA-DASLab's six-shard DeepSeek V4.1 Flash GGUF layout
On 27 October 2026 ISTA-DASLab replaced the four-shard IST-DAS builds on
the Hub's main with six-shard builds (llama.cpp b11870+ layout), and
--model-path with those files failed with "...-00001-of-00004.gguf is
missing". The v0.2.7.1 pin still serves the old files.

- Pin a419d74c: five shards per build named of-00006 and the shared
  Engram as the sixth. The Engram was converted in place (same name and
  size, new hash).
- pack_gguf.py counts an --engram file as the sixth shard only if it is
  one file, not already among the shards, number count-1 of the same
  split, and holds the single tensor per_layer_token_embd.weight.
- An Engram whose finish mark names an old hash is fetched again in the
  same run. One whose mark has no hash is hashed: a match is kept and
  marked, a mismatch is fetched again. A user's own old one is refused
  with the reason.
- A pack from an earlier run is rebuilt unless it names this run's files.
- Six-shard only; four-shard files are ignored and stay on disk. The
  first-release name tables are now empty. No engine code changes.

Related: #4237, #4256.
2026-10-30 00:16:56 +01:00
lu123bing c15a90b28c gguf reader: size the integer tensors, so a complete DeepSeek first shard is accepted and a cut one refused
BLOCK_GEOMETRY had no row for I8/I16/I32/I64, which GGML_TYPES names and which IST-DASLab's DeepSeek-V4.1
first shard uses for the Engram hash parameters (tools/dsv41/pack_gguf.py). The preflight in front of setup
and the app reads "no geometry" as "unsupported file" (strata_control/gguf_preflight.py:165), so a complete
file was refused as "cut short or another version" or as "a format Strata does not read" (#4256).
setup.check_shards sized such a tensor at 0 bytes (setup.py:3324), so a shard cut inside one passed as whole.

The rows are the ones tools/dsv41/ista_intake.py:112 already uses for these types.
2026-10-30 00:08:56 +01:00
Niko1221 b2dbd9420d dsv41 serve: the cache settle runs in the idle between requests (WP-8's option 1)
The prompt-side settle is 800 ms because it sits inside the prompt's own time, and for a run with no next request
(the CLI, a one-shot tool call) that is the right price.  In a server it is not: an agent's turn ends, it runs its
tool for a second or ten, and that time is free for the swap rounds the *next* answer needs.

Measured why it matters (job d18-attrib, 2026-10-28): the same candidate engine with the settle at 6,000 ms
decodes a 256-token code answer at 10.92 / 11.14 tok/s, at 800 ms at 9.82 / 9.62 - 12% paid for the prompt.
The idle settle is meant to give that back without returning the 6 s to the prompt.

How: Model::settle_idle(cancel) runs cache_settle's swap rounds on its own thread, without the RAM store preload,
for at most DS4_SETTLE_IDLE_MS (6,000) and stopping between rounds when cancel is set.  The serve loop starts it
at the top of each iteration (nothing in flight), and cancels and joins it the moment a line arrives, before any
request work touches the cache.  DS4_SETTLE_IDLE=0 turns it off; the prompt-side budget is unchanged.

Not measured yet: the benefit needs a serve-path harness (two GEN requests with an idle between them).  On the CLI
the thread is never started, so no CLI number moves.
2026-10-28 17:00:25 +01:00
Niko1221 c697e859e9 dsv41: the matrix-core prompt attention (DS4_FAST_ATTN) on by default for gfx1151 on Windows
The switch was on for gfx12 only, and ds4_kernels.cu said why: "gfx11 and gfx1151 (Strix Halo) are untested
and keep the scalar kernels".  Measured on Strix Halo 2026-10-28 (2.00 bpw, 32K prompt, the synchronised
chunk profile): the sparse-attention stage is 43.3% of the prompt's GPU time and runs at 2.3 TFLOPS
effective; with DS4_FAST_ATTN=1 the stage falls from 38,340 ms to 6,976 ms and the prompt from 101.6 s to
70.8 s - +43.6%, 322.5 -> 463.0 tok/s - with the same greedy first token (27408,).

The plan's D-P3a had closed this on +1.6 / -0.2 / +1.2 / -2.8% end to end, which is what 4K-8K shows; at
32K the term is five times its 4K size and the switch is the whole difference.

fast_attn_default() takes the gfx1151-on-Windows flag as its own argument (the existing three-argument calls
in the test are updated), ds4_kernels.cu decides it from the device name once, and experts_rule_test covers
the new cell: gfx1151 Windows on, 0.2.6 arithmetic off, DS4_FAST_ATTN=0 off, =1 on anywhere.
2026-10-28 14:04:56 +01:00
Niko1221 2ea46ded73 dsv41: the post-prompt cache settle's budget 6,000 -> 800 ms on every platform
The settle sits inside the prompt's own time: prompt: N tokens in X ms includes it, so a 6 s budget was
6 s of every DeepSeek first token.  Measured on Strix Halo 2026-10-28 (p4k fixture, three interleaved rounds
each, --dspark off): 6,000 ms -> 153.1 tok/s, 800 ms -> 190.0 (+24.1%), 0 -> 197.4 (+29.0%); the decode of
the 256-token answer that follows pays 2.9% at 800 ms and 3.8% at 0.  800 ms keeps 1,412-1,436 swaps against
the 6,000's 987-1,046, so it is still moving the hot experts in, it just stops paying for the last 5 s.

An agent turn's answer is 50-300 tokens (where the swaps buy nothing) and its first token is the 800 ms, so
this is the plan's WP-8 option (2) without the server-loop work of the idle settle (option 1, still the better
answer and not done here).

DS4_SETTLE_MS still overrides it (0 = off, any value in ms).
2026-10-28 14:04:52 +01:00
Niko1221 20bba22af7 gfx1151 on Windows: a 16,384-token prompt chunk, and the docs for the two new prompt defaults
- src/program/generate.cpp: the gfx1151 Windows prompt chunk goes from 8,192 to 16,384.  The prompt reads every expert once per
  chunk, so a bigger chunk reads them fewer times: 32K 1,250.6-1,260.0 -> 1,286.3-1,307.9 tok/s, 64K 1,188.4-1,191.0 ->
  1,234.3-1,238.2 (+3.0% / +3.9%, interleaved pairs); at 4K and 8K the chunk is the prompt and nothing changes.  The buffers'
  reserve grows to ~10.9 GiB and the cache keeps all 24,576 slots (84.7 GiB free at a 64K context).  STRATA_GFX1151_CHUNK=0
  keeps 6,144; auto:32768 is another ~2% at 64K and stays opt-in.
- docs/STRIX_HALO_WINDOWS.md section 4: what 0.2.8 turns on for gfx1151 on Windows (the five-switch matrix-core set and the
  prompt keeping its own buffers), the measured prompt table with the opt-outs, the chunk.
- docs/STRIX_HALO.md section 5: the Windows gfx1151 exception to 'the set is opt-in on an integrated Radeon'.

Numbers: Qwen3.8-Flash-Next IQ3_S, engine 0.1.52, prompts 4K to 64K, x1.59 to x1.72 over 0.2.7; first-token KL 0.0014 mean
(bar 0.0228); the own-buffer path is bit-exact (KL 0.00000 against the lending engine with the same switches).
2026-10-28 11:15:18 +01:00
Niko1221 1493781e91 gfx1151 on Windows: the matrix-core prompt set by default, and the prompt keeps its own buffers
Two prompt defaults for Strix Halo (gfx1151) on Windows, measured on the strix box with the
0.1.52 engine and Qwen3.8-Flash-Next IQ3_S (ids prompts of 4,095 / 8,190 / 11,999 tokens,
interleaved pairs, one engine at a time):

1. The matrix-core prompt set (HIP_WMMA, PF_GEMM, PA_FAST, HC_UPMIX, PF_FUSED) is on by
   default, as it has been on the Radeon 780M since 021-minipc: 4K 688.8 -> 1,192.8 tok/s,
   8K 787.0 -> 1,283.5, 12K 750.4 -> 1,256.5 (x1.63 to x1.72).  SELECT_WMMA stays out
   (x1.003 at 8K on the f-024-strix-pp branch); PF_F16 was measured and adds nothing on
   this pack, so the bit-changing surface is the five switches the branch gated.
   First-token KL against the 0.2.7 default 0.00210 / 0.00039 / 0.00214 at 4K / 8K / 12K,
   top-1 the same, noise floor 0.00000 (the accepted HC_Q8 bar is 0.0228 mean).

2. The prompt path keeps its own buffers instead of lending expert-cache slots.  A lent
   slot's expert is re-read from the host arena during every chunk at ~0.5 GB/s here (the
   arena is unregistered in UMA mode): 823 slots = 2.75 s of a 3.3 s 4K prompt in the
   issuer thread.  With own buffers every expert stays resident (24,576 slots, 'experts
   streamed 0') and the prompt is 8.5% (4K) to 8.9% (8K) faster.  STRATA_PREFILL_BORROW=1
   keeps 0.2.7's lending, STRATA_GFX1151_DEFAULTS=0 the whole block; --prefill auto keeps
   the 8,192-token chunk the gfx1151 rule chose instead of falling back to 2,048.

arch_defaults_test: the Windows gfx1151 expectations and the new opt-outs.
2026-10-28 11:15:18 +01:00
967 changed files with 99076 additions and 5012 deletions

No files matched your search

@@ -0,0 +1,68 @@
name: AMD split engine report
description: Tell us whether the all-cards split engine works on your AMD cards (it prints a line asking for this on cards Strata has not run it on)
title: "[Engine] AMD split engine: <your card> x <count>"
labels: ["engine", "amd-split-report"]
body:
- type: markdown
value: |
Strata runs the all-cards split engine by itself on Radeon AI PRO R9700 / RX 9070 (gfx1201). On other AMD cards (RX 9060, RX 7000, the 780M and similar) it only starts when you ask for it with `strata launch --split-engine on`, and it prints one line asking you to report whether it works. We cannot test those cards, so your report is how we learn.
Write only what you saw. A report that says "it did not start" is as useful as one that says "it works".
- type: input
id: card
attributes:
label: Card model and count
description: For example "2 x RX 7900 XTX 24 GB" or "3 x RX 9060 XT 16 GB"
validations:
required: true
- type: input
id: arch
attributes:
label: Architecture (the gfx name)
description: From the engine's line, or `rocminfo | grep gfx`. For example gfx1100.
validations:
required: true
- type: input
id: rocm
attributes:
label: ROCm / driver version
description: Strata's engine package brings its own ROCm libraries; the kernel driver is yours (`uname -r`, `amd-smi version`).
validations:
required: true
- type: input
id: model
attributes:
label: Model and quantization
description: The model Strata loaded, for example Qwen3.8-Flash-Next UD-Q4_K_XL or the IQ3_S set.
validations:
required: true
- type: textarea
id: selftest
attributes:
label: The self-test line
description: Paste the line from the engine's log that starts "The split engine's self-test" (passed or failed). The log is in the run folder shown at launch; `strata launch` also prints it.
render: text
validations:
required: true
- type: dropdown
id: result
attributes:
label: What happened
options:
- It started and the answers look right
- It started and the answers look wrong
- The self-test failed, so the normal layer split started
- It failed to start (not the self-test)
- It started and crashed or hung later
validations:
required: true
- type: input
id: speed
attributes:
label: Speed, tok/s (the split engine, then the layer split with `--split-engine off` if you tried both)
description: For example "decode 61 tok/s, layer split 48 tok/s, one user".
- type: textarea
id: more
attributes:
label: Anything else
description: Wrong answers (the prompt and what you got), crashes (the last lines of the engine log), or what you changed.
+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.8 · engine 0.1.54"
placeholder: "Strata 0.2.10 · engine 0.1.56"
validations:
required: true
- type: input
+2 -2
View File
@@ -1,6 +1,6 @@
# AGENTS.md
Version 0.2.8, engine 0.1.54. The public model families are DeepSeekV4.1-Flash-1017,
Version 0.2.10, engine 0.1.56. The public model families are DeepSeekV4.1-Flash-1017,
Qwen3.8-Flash-Next and Qwen3.6-35B-A3B. DeepSeek V4.1-Flash runs in the app and in the Terminal
(`strata launch --family deepseek41 --model ist-das-2.00bpw`; sets in
[docs/MODELS.md](docs/MODELS.md#deepseek-v41-flash)). Read **[docs/AI_REFERENCE.md](docs/AI_REFERENCE.md)** for model availability, APIs, hardware limits and
@@ -15,7 +15,7 @@ Strata has three parts:
- **The app** (`apps/desktop`, the Tauri shell; `apps/host`, the Node host and the web UI). It is for people who do
not want a terminal. One installer per system ([latest release](https://github.com/Niko1221/Strata/releases/latest)):
`Strata-0.2.8-windows-x64-setup.exe`, `Strata-0.2.8-linux-x86_64.AppImage`, `Strata-0.2.8-macos-arm64.dmg`.
`Strata-0.2.9-windows-x64-setup.exe`, `Strata-0.2.9-linux-x86_64.AppImage`, `Strata-0.2.9-macos-arm64.dmg`.
The Windows and Linux installers carry no engine: the app downloads the engine for the graphics card on first
start, or installs a file the user already has. The Mac dmg keeps its small Metal engine inside.
- **The Terminal** (`strata.py`, `strata_cli/`, `strata_control/`): the `strata` command. It sets up a model, serves
+204 -20
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.54 LANGUAGES CXX)
project(strata VERSION 0.1.56 LANGUAGES CXX)
# EXPERIMENTAL: the Intel Arc engine, the SYCL port in sycl/ (PR #423; icpx + oneMKL, docs/INTEL_ARC.md). It is its
# own project that takes the unported sources from src/; with this on, nothing below is configured.
option(STRATA_ENABLE_SYCL "EXPERIMENTAL: build the Intel Arc (SYCL) engine from sycl/ - docs/INTEL_ARC.md" OFF)
@@ -92,6 +92,13 @@ if(STRATA_HIP_GFX906)
endif()
option(STRATA_ENABLE_HIP "Build the HIP targets for wave32 AMD GPUs (gfx1100, gfx1201; gfx1012/gfx1030 unvalidated)" OFF)
option(STRATA_PREFILL_MMQ "Build the optional GGML MMQ prefill path for HIP" OFF)
# The community-made AMD changes (include/strata/core/amd_community.hpp): on by default. This option is the build half of the one
# switch STRATA_AMD_COMMUNITY_TUNING: OFF compiles the portable signed-byte dot for gfx1010 again (the SDWA sequence of #4855).
# The rocBLAS tables are switched at run time (STRATA_AMD_COMMUNITY_TUNING=0).
option(STRATA_AMD_COMMUNITY_TUNING "Compile the community-made gfx1010 SDWA dot product (the runtime switch of the same name controls the gfx1030 rocBLAS tables)" ON)
if(NOT STRATA_AMD_COMMUNITY_TUNING)
add_compile_definitions(STRATA_AMD_COMMUNITY_DOT_OFF=1)
endif()
# The rows one verify / batch window holds (include/strata/kernels/window_limits.hpp): 8, the 0.2.7 window, or 16 (four pipelined
# groups of 16 slots = 64 slots on a four-card split, docs/BATCHING.md). Only the CUDA and HIP engines are built from
# these sources with a wide window; the SYCL and Metal builds never read this variable and keep 8. A window of up to
@@ -99,10 +106,10 @@ option(STRATA_PREFILL_MMQ "Build the optional GGML MMQ prefill path for HIP" OFF
# the HIP engines (a HIP code object loads on first use, so the extra kernels cost nothing until a wide window forms)
# and 8 for CUDA, which loads every kernel at start (CUDA_MODULE_LOADING=EAGER: the wide kernels would take VRAM from
# the expert cache of every card); -DSTRATA_WINDOW_MAX_T=16 builds the wide CUDA engine.
set(STRATA_WINDOW_MAX_T "auto" CACHE STRING "rows of a verify / batch window: 8, 16 or auto (HIP 16, CUDA 8)")
set_property(CACHE STRATA_WINDOW_MAX_T PROPERTY STRINGS auto 8 16)
if(NOT STRATA_WINDOW_MAX_T STREQUAL "auto" AND NOT STRATA_WINDOW_MAX_T STREQUAL "8" AND NOT STRATA_WINDOW_MAX_T STREQUAL "16")
message(FATAL_ERROR "STRATA_WINDOW_MAX_T must be auto, 8 or 16 (got '${STRATA_WINDOW_MAX_T}')")
set(STRATA_WINDOW_MAX_T "auto" CACHE STRING "rows of a verify / batch window: 8, 12, 16 or auto (HIP 16, CUDA 8)")
set_property(CACHE STRATA_WINDOW_MAX_T PROPERTY STRINGS auto 8 12 16)
if(NOT STRATA_WINDOW_MAX_T STREQUAL "auto" AND NOT STRATA_WINDOW_MAX_T STREQUAL "8" AND NOT STRATA_WINDOW_MAX_T STREQUAL "12" AND NOT STRATA_WINDOW_MAX_T STREQUAL "16")
message(FATAL_ERROR "STRATA_WINDOW_MAX_T must be auto, 8, 12 or 16 (got '${STRATA_WINDOW_MAX_T}')")
endif()
if(STRATA_ENABLE_HIP OR STRATA_ENABLE_CUDA)
if(STRATA_WINDOW_MAX_T STREQUAL "auto")
@@ -391,10 +398,22 @@ if(STRATA_BUILD_TESTS)
target_include_directories(share_rules_test PRIVATE include)
add_test(NAME share_rules_test COMMAND share_rules_test)
# STRATA_PREFILL_ROUTED_STREAM: the prompt stream's issuer walk and route gate, on a simulated link and consumer
# (header-only, no GPU)
add_executable(routed_stream_test tests/prefill/routed_stream_test.cpp)
target_include_directories(routed_stream_test PRIVATE include)
target_link_libraries(routed_stream_test PRIVATE Threads::Threads)
add_test(NAME routed_stream_test COMMAND routed_stream_test)
# the Windows HIP auto prefill chunk cap (#1630) and the commit-limit verdict (#1607): header-only, no GPU
add_executable(host_limits_test tests/platform/host_limits_test.cpp)
target_include_directories(host_limits_test PRIVATE include)
add_test(NAME host_limits_test COMMAND host_limits_test)
# #4733: an engine started at Low I/O priority goes back to Normal (Windows; else skips)
add_executable(io_priority_test tests/platform/io_priority_test.cpp src/platform/io_priority.cpp)
target_include_directories(io_priority_test PRIVATE include)
add_test(NAME io_priority_test COMMAND io_priority_test)
set_tests_properties(io_priority_test PROPERTIES SKIP_RETURN_CODE 77)
endif()
# ---- strata_plan ----------------------------------------------------------
@@ -410,7 +429,7 @@ target_link_libraries(strata-plan PRIVATE strata_plan)
# ---- strata_core (GPU runtime) ---------------------------------------------
# Only built when a GPU backend is on, so the Phase 1 targets keep working without a toolkit.
if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
add_library(strata_core STATIC src/core/device.cu src/core/pinned.cu src/platform/memory.cpp src/core/graph.cpp src/core/weights.cpp
add_library(strata_core STATIC src/core/device.cu src/core/pinned.cu src/platform/memory.cpp src/platform/io_priority.cpp src/core/graph.cpp src/core/weights.cpp
src/core/layout.cpp)
target_include_directories(strata_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
# the DFlash2 drafter (src/core/dflash2.cpp, src/kernels/cuda/dflash2.cu) exists in the CUDA and HIP builds; the Metal and SYCL builds compile without it
@@ -849,6 +868,12 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
add_executable(dflash2_parity EXCLUDE_FROM_ALL src/core/dflash2_parity.cpp)
target_include_directories(dflash2_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dflash2_parity PRIVATE strata_engine strata_kernels ${_strata_gpu_runtime_target})
add_executable(dflash2_q4_parity EXCLUDE_FROM_ALL src/core/dflash2_q4_parity.cpp)
target_include_directories(dflash2_q4_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dflash2_q4_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_executable(dflash2_ring_test EXCLUDE_FROM_ALL src/core/dflash2_ring_test.cpp) # the drafter's ring log and rebuild against an uninterrupted update sequence
target_include_directories(dflash2_ring_test PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dflash2_ring_test PRIVATE strata_engine strata_kernels ${_strata_gpu_runtime_target})
add_executable(dflash2_gemm_bench EXCLUDE_FROM_ALL src/core/dflash2_gemm_bench.cpp)
target_include_directories(dflash2_gemm_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dflash2_gemm_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
@@ -917,7 +942,7 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(strata_dsv41_kernels PUBLIC ${_strata_gpu_runtime_target})
add_executable(strata-dsv41 src/dsv41/ds4_main.cpp src/dsv41/ds4_model.cu src/dsv41/ds4_kernels.cu src/dsv41/ds4_vision.cu
src/dsv41/ds4_gguf.cu src/dsv41/ds4_gguf_cpu.cpp src/dsv41/ds4_iq.cu src/dsv41/ds4_iq_cpu.cpp
src/dsv41/ds4_tq.cu src/dsv41/ds4_tq_cpu.cpp src/dsv41/ds4_gg_cpu.cpp src/dsv41/ds4_packinfo.cpp)
src/dsv41/ds4_tq.cu src/dsv41/ds4_tq_cpu.cpp src/dsv41/ds4_gg_cpu.cpp src/dsv41/ds4_packinfo.cpp src/platform/io_priority.cpp)
# the commit the binary was built from, readable with --version (set -DSTRATA_BUILD_ID=... for a tree without .git)
if(NOT STRATA_BUILD_ID AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/BUILD_ID) # (a git archive has no .git: the packer writes the commit here)
file(STRINGS ${CMAKE_CURRENT_SOURCE_DIR}/BUILD_ID STRATA_BUILD_ID LIMIT_COUNT 1)
@@ -952,7 +977,31 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml)
target_link_libraries(ds4_gg_bench PRIVATE strata_kernels_cpu Threads::Threads)
target_compile_definitions(ds4_gg_bench PRIVATE STRATA_DSV41_TQ_CPU=1)
# the PCIe lane's cost model on this CPU (Project Maya #312's tests/ds4_lane_bench): no GPU, no model
add_executable(ds4_lane_bench tests/dsv41/ds4_lane_bench.cpp src/dsv41/ds4_gguf_cpu.cpp src/dsv41/ds4_iq_cpu.cpp
src/dsv41/ds4_tq_cpu.cpp src/dsv41/ds4_gg_cpu.cpp)
target_include_directories(ds4_lane_bench PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml)
target_link_libraries(ds4_lane_bench PRIVATE strata_dsv41_kernels strata_kernels_cpu Threads::Threads)
target_compile_definitions(ds4_lane_bench PRIVATE STRATA_DSV41_TQ_CPU=1)
endif()
# the GGUF kernels against a double-precision reference, the PCIe lane's streamed experts against the VRAM ones' bit for
# bit (--lane), and the packed KV caches against the BF16 rows (needs a GPU, no model; built by name)
add_executable(ds4_gguf_test EXCLUDE_FROM_ALL tests/dsv41/ds4_gguf_test.cu src/dsv41/ds4_gguf.cu src/dsv41/ds4_gguf_cpu.cpp
src/dsv41/ds4_iq.cu src/dsv41/ds4_iq_cpu.cpp src/dsv41/ds4_kernels.cu src/dsv41/ds4_tq.cu
src/dsv41/ds4_tq_cpu.cpp src/dsv41/ds4_gg_cpu.cpp)
target_include_directories(ds4_gguf_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories}
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml ${CMAKE_CURRENT_SOURCE_DIR}/src/prefill)
target_link_libraries(ds4_gguf_test PRIVATE strata_prefill strata_dsv41_kernels ${_strata_gpu_runtime_target} Threads::Threads)
if(STRATA_NATIVE_EXPERTS)
target_link_libraries(ds4_gguf_test PRIVATE strata_kernels_cpu)
target_compile_definitions(ds4_gguf_test PRIVATE STRATA_DSV41_TQ_CPU=1)
endif()
# the router on a non-finite gate input (valid, distinct picks; the same picks as the host's top-k when finite) and its
# launch times; needs a GPU, no model; built by name
add_executable(ds4_route_guard_test EXCLUDE_FROM_ALL tests/dsv41/ds4_route_guard_test.cu src/dsv41/ds4_kernels.cu)
target_include_directories(ds4_route_guard_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories})
target_link_libraries(ds4_route_guard_test PRIVATE strata_dsv41_kernels ${_strata_gpu_runtime_target} Threads::Threads)
if(STRATA_NATIVE_EXPERTS)
# the trellis experts (device kernels ds4_tq.cu, CPU lane ds4_tq_cpu.cpp) against a double reference; needs a GPU, no model
# both test .cpp files launch kernels, so they compile as HIP on the AMD backend and as CUDA on the NVIDIA one
@@ -995,13 +1044,44 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
if(STRATA_ENABLE_HIP)
target_compile_options(ds4_iq_test PRIVATE "$<$<COMPILE_LANGUAGE:HIP,CXX>:SHELL:-include ${CMAKE_CURRENT_SOURCE_DIR}/include/strata/dsv41/hip_extra.h>")
endif()
# the dense weights (ds4_iq.cu dequant_rows / mv / f16_rows, ds4_gguf.cu rows / mv, the chunk GEMM) against a CPU reference for every
# dense type and shape, no ggml, no model: tests/dsv41/dq_dense_test.cpp (run_dq_test.sh runs it a process a type and kernel)
set_source_files_properties(tests/dsv41/dq_dense_test.cpp PROPERTIES LANGUAGE ${_ds4_test_lang})
add_executable(ds4_dq_test tests/dsv41/dq_dense_test.cpp src/dsv41/ds4_iq.cu src/dsv41/ds4_tq.cu src/dsv41/ds4_gguf.cu)
target_include_directories(ds4_dq_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories}
${CMAKE_CURRENT_SOURCE_DIR}/third_party/ggml ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
target_link_libraries(ds4_dq_test PRIVATE strata_prefill ${_strata_gpu_runtime_target} ${_strata_gpu_blas_target} Threads::Threads)
target_compile_definitions(ds4_dq_test PRIVATE DQ_TEST_GGUF=1 DQ_TEST_GEMM=1)
if(STRATA_ENABLE_HIP)
target_compile_options(ds4_dq_test PRIVATE "$<$<COMPILE_LANGUAGE:HIP,CXX>:SHELL:-include ${CMAKE_CURRENT_SOURCE_DIR}/include/strata/dsv41/hip_extra.h>")
endif()
# the kept conversations' .meta file and prefix matching (ds4_slotmeta.hpp): no GPU, no model
add_executable(ds4_slot_test tests/dsv41/slot_test.cpp)
target_include_directories(ds4_slot_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
target_compile_features(ds4_slot_test PRIVATE cxx_std_17)
# restore points (ds4_marks.hpp, from Project Maya #639): which point a prompt goes back to, which ones a read keeps
add_executable(ds4_marks_test tests/dsv41/marks_test.cpp)
target_include_directories(ds4_marks_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
target_compile_features(ds4_marks_test PRIVATE cxx_std_17)
# the staging ring's slots by the measured host link (ds4_ring_link.hpp, from Project Maya #609)
add_executable(ds4_ring_link_test tests/dsv41/ring_link_test.cpp)
target_include_directories(ds4_ring_link_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
target_compile_features(ds4_ring_link_test PRIVATE cxx_std_17)
# the serve loop's event queue (ds4_evt.hpp, from Project Maya #587)
add_executable(ds4_evt_test tests/dsv41/evt_test.cpp)
target_include_directories(ds4_evt_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
target_compile_features(ds4_evt_test PRIVATE cxx_std_17)
find_package(Threads)
if(Threads_FOUND)
target_link_libraries(ds4_evt_test PRIVATE Threads::Threads)
endif()
add_executable(ds4_experts_rule_test tests/dsv41/experts_rule_test.cpp)
target_include_directories(ds4_experts_rule_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
target_compile_features(ds4_experts_rule_test PRIVATE cxx_std_17)
# the timing pick's per-size-class flow correction (ds4_path_policy.hpp, from Project Maya #612)
add_executable(ds4_path_policy_test tests/dsv41/path_policy_test.cpp)
target_include_directories(ds4_path_policy_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
target_compile_features(ds4_path_policy_test PRIVATE cxx_std_17)
add_executable(ds4_pick_rule_test tests/dsv41/pick_rule_test.cpp)
target_include_directories(ds4_pick_rule_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/dsv41)
target_compile_features(ds4_pick_rule_test PRIVATE cxx_std_17)
@@ -1028,6 +1108,20 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(hip_q2_zero PRIVATE strata_kernels strata_hip_runtime)
add_test(NAME hip_q2_zero COMMAND hip_q2_zero)
list(APPEND _strata_hip_tests hip_q2_zero)
# the QSA layer's fused norm + rope (rms_rope_rows, STRATA_QSA_FUSE_NR) against split_q + rms_rows + rope, bit for bit over the
# whole output; and the gfx11 prompt GEMMs' full-output coverage (every element written, sampled rows against FP64).
# GPU, synthetic, no model; exit 77 without a gfx11 device.
add_executable(hip_qsa_nr_parity src/prefill/qsa_nr_parity.cu)
target_link_libraries(hip_qsa_nr_parity PRIVATE strata_prefill strata_hip_runtime)
add_test(NAME hip_qsa_nr_parity COMMAND hip_qsa_nr_parity)
set_tests_properties(hip_qsa_nr_parity PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 300)
list(APPEND _strata_hip_tests hip_qsa_nr_parity)
add_executable(hip_pf_cover_test src/prefill/pf_cover_test.cu)
target_include_directories(hip_pf_cover_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/prefill)
target_link_libraries(hip_pf_cover_test PRIVATE strata_prefill strata_hip_runtime)
add_test(NAME hip_pf_cover_test COMMAND hip_pf_cover_test)
set_tests_properties(hip_pf_cover_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 900)
list(APPEND _strata_hip_tests hip_pf_cover_test)
add_executable(hip_expert_cache_staging tests/hip/expert_cache_staging.cpp)
target_link_libraries(hip_expert_cache_staging PRIVATE strata_engine)
add_test(NAME hip_expert_cache_staging COMMAND hip_expert_cache_staging)
@@ -1076,6 +1170,10 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
add_test(NAME hip_gdn_chunked_off COMMAND hip_gdn_chunked --off)
add_test(NAME hip_gdn_chunked_default COMMAND hip_gdn_chunked --default)
list(APPEND _strata_hip_tests hip_gdn_chunked hip_gdn_chunked_off hip_gdn_chunked_default)
# GDN prompt stage bench (gates + conv + recurrence, per-piece times and output/state hashes at 4K/16K/32K); a measurement
# tool, run by hand with the STRATA_GDN_* knobs under test - no add_test
add_executable(hip_gdn_stage_bench tests/hip/gdn_stage_bench.cpp)
target_link_libraries(hip_gdn_stage_bench PRIVATE strata_prefill strata_hip_runtime)
# #313: the RDNA3 / RDNA3.5 WMMA dense GEMMs (opt-in STRATA_WMMA_GEMM=1) against a reference
add_executable(hip_prefill_wmma_gemm_parity tests/hip/prefill_wmma_gemm_parity.cpp)
target_include_directories(hip_prefill_wmma_gemm_parity PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/prefill")
@@ -1098,6 +1196,9 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
add_test(NAME hip_router_fast COMMAND hip_router_fast)
list(APPEND _strata_hip_tests hip_router_fast)
# S6: the RDNA4 matrix-core prompt attention (STRATA_HIP_WMMA) against the FP32 kernel and FP64; skips off gfx12
# the prompt attention on a recorded selection (STRATA_QSA_DUMP), for A/B of kernel variants (STRATA_QSA_UNION=1)
add_executable(hip_pa_bench tests/cuda/pa_bench.cpp)
target_link_libraries(hip_pa_bench PRIVATE strata_kernels strata_hip_runtime)
add_executable(hip_prompt_attn_wmma src/kernels/qsa_prompt_attn_parity.cpp)
target_link_libraries(hip_prompt_attn_wmma PRIVATE strata_kernels strata_hip_runtime)
add_test(NAME hip_prompt_attn_wmma COMMAND hip_prompt_attn_wmma 32768 2048 3)
@@ -1278,6 +1379,20 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
# "not on this card" for the tc variant
add_test(NAME gdn_rec_parity_selftest_frag COMMAND gdn_rec_parity --selftest-frag)
set_tests_properties(gdn_rec_parity_selftest_frag PROPERTIES SKIP_RETURN_CODE 77)
# the production-linked GDN parity / benchmark harness (no kernel copies: it calls gdn_gates, gdn_conv and
# gdn_recurrence of strata_prefill through the GdnProbe of include/strata/prefill/gdn_probe.hpp, against an FP64
# oracle from tests/gdn/gdn_fixture.hpp). Usage: docs/GDN_CONTRACT.md. The smoke test is small and CPU-cheap.
execute_process(COMMAND git -C ${CMAKE_CURRENT_SOURCE_DIR} rev-parse --short=9 HEAD OUTPUT_VARIABLE _gdn_sha
OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
if(NOT _gdn_sha)
set(_gdn_sha unknown)
endif()
add_executable(gdn_harness src/prefill/gdn_harness.cu)
target_include_directories(gdn_harness PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tests/gdn)
target_compile_definitions(gdn_harness PRIVATE GDN_HARNESS_SHA="${_gdn_sha}")
target_link_libraries(gdn_harness PRIVATE strata_prefill)
add_test(NAME gdn_harness_smoke COMMAND gdn_harness --T 400 --chunk 160 --cuts 37 --paths auto,keyhead,pipe --ref-heads 0,17)
set_tests_properties(gdn_harness_smoke PROPERTIES TIMEOUT 300)
# the prompt path's BF16 products against cuBLAS's own BF16 GEMM, within fp32 rounding (sm_7x runs them on the
# FP16 tensor cores; GPU, synthetic, no model; --bench adds timings)
add_executable(gemm_bf16_parity src/prefill/gemm_bf16_parity.cu)
@@ -1307,6 +1422,12 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(lm_kernels_parity PRIVATE strata_prefill )
add_test(NAME lm_kernels_parity COMMAND lm_kernels_parity)
set_tests_properties(lm_kernels_parity PROPERTIES SKIP_RETURN_CODE 77)
# hc_q8_to_bf16 on the stream it is given: the layer-major prompt walk expanded a STRATA_HC_REQ8
# hyper-connection projection on the default stream while its GEMMs read another. Synthetic, no model.
add_executable(hc_q8_stream_test tests/cuda/hc_q8_stream_test.cu)
target_link_libraries(hc_q8_stream_test PRIVATE strata_prefill )
add_test(NAME hc_q8_stream COMMAND hc_q8_stream_test)
set_tests_properties(hc_q8_stream PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 300)
endif()
# native_expert_grouped's group stride and fused SwiGLU + q8_1, bitwise vs the launches before them (GPU, synthetic,
# no model; --bench adds v1/new timings of a window's 48 calls in a graph)
@@ -1341,13 +1462,20 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_include_directories(shared_expert_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(shared_expert_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME shared_expert_parity COMMAND shared_expert_parity --selftest)
# the split / batched verify-window kernels (from eddoursul's fork), bitwise vs the kernels they replace; CUDA only
if(STRATA_ENABLE_CUDA)
# the split / batched verify-window kernels (from eddoursul's fork), bitwise vs the kernels they replace
if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
add_executable(verify_parity src/kernels/verify_parity.cpp)
target_include_directories(verify_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(verify_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME verify_parity COMMAND verify_parity --selftest)
endif()
# BW-DENSE: the v3 read's weight-prefetch variants against the plain v3 read, bit for bit, and their cold-weight time
add_executable(gr_v3_screen src/kernels/gr_v3_screen.cpp)
target_include_directories(gr_v3_screen PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(gr_v3_screen PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_executable(mmvf_unr_hash src/kernels/mmvf_unr_hash.cpp)
target_include_directories(mmvf_unr_hash PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(mmvf_unr_hash PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_executable(fused_gr_bench src/kernels/fused_gr_bench.cpp)
target_include_directories(fused_gr_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(fused_gr_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
@@ -1626,6 +1754,12 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(pin_budget_test PRIVATE strata_core ${_strata_gpu_runtime_target})
add_test(NAME pin_budget_test COMMAND pin_budget_test)
# #3431: the expert arena's registered ranges keep their GPU aliases clear of the process's own memory (Windows HIP; else skips)
add_executable(pinned_alias_test src/core/pinned_alias_test.cpp)
target_link_libraries(pinned_alias_test PRIVATE strata_core ${_strata_gpu_runtime_target})
add_test(NAME pinned_alias_test COMMAND pinned_alias_test)
set_tests_properties(pinned_alias_test PROPERTIES SKIP_RETURN_CODE 77)
# #3350 / #3470: the conversation copy streams and events are pooled across short-lived threads (needs a device, else skips)
add_executable(conversation_copy_streams_test src/core/conversation_copy_streams_test.cpp)
target_include_directories(conversation_copy_streams_test PRIVATE include)
@@ -1638,6 +1772,13 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(pinned_shared_test PRIVATE strata_core ${_strata_gpu_runtime_target})
add_test(NAME pinned_shared_test COMMAND pinned_shared_test)
endif()
if(WIN32) # the replicas' shared expert arena as a named section (src/core/pinned.cu): two arenas, a child process, the last handle
add_executable(pinned_shared_win_test src/core/pinned_shared_win_test.cpp)
target_include_directories(pinned_shared_win_test PRIVATE include)
target_link_libraries(pinned_shared_win_test PRIVATE strata_core ${_strata_gpu_runtime_target})
add_test(NAME pinned_shared_win_test COMMAND pinned_shared_win_test)
set_tests_properties(pinned_shared_win_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 180)
endif()
# Plan v0.3 P2: the n-gram table read straight from the SSD. The selftest needs only the CPU and a temp file.
add_executable(ple_reader_test src/ngram/ple_reader_test.cpp)
@@ -1725,6 +1866,18 @@ if(STRATA_BUILD_TESTS)
add_executable(arch_defaults_test src/core/arch_defaults_test.cpp src/core/arch_defaults.cpp)
target_include_directories(arch_defaults_test PRIVATE include)
add_test(NAME arch_defaults_test COMMAND arch_defaults_test)
# STRATA_AMD_COMMUNITY_TUNING: the switch turns each community-made AMD change off (header only; run from the source tree)
add_executable(amd_community_test src/core/amd_community_test.cpp)
target_include_directories(amd_community_test PRIVATE include)
target_compile_definitions(amd_community_test PRIVATE EXPECT_DOT_BUILT=1)
add_test(NAME amd_community_test COMMAND amd_community_test WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
add_executable(amd_community_test_dot_off src/core/amd_community_test.cpp) # the -DSTRATA_AMD_COMMUNITY_TUNING=OFF build
target_include_directories(amd_community_test_dot_off PRIVATE include)
target_compile_definitions(amd_community_test_dot_off PRIVATE EXPECT_DOT_BUILT=0 STRATA_AMD_COMMUNITY_DOT_OFF=1)
add_test(NAME amd_community_test_dot_off COMMAND amd_community_test_dot_off WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
add_executable(gfx12_extra_test src/core/gfx12_extra_test.cpp src/core/arch_defaults.cpp) # gfx12 extra defaults and their off switches
target_include_directories(gfx12_extra_test PRIVATE include)
add_test(NAME gfx12_extra_test COMMAND gfx12_extra_test)
add_executable(nv_defaults_test src/core/nv_defaults_test.cpp src/core/nv_defaults.cpp) # the sm_121 (GB10) table and its two off switches
target_include_directories(nv_defaults_test PRIVATE include)
add_test(NAME nv_defaults_test COMMAND nv_defaults_test)
@@ -1746,6 +1899,12 @@ if(STRATA_BUILD_TESTS)
add_executable(draft_policy_test src/spec/draft_policy_test.cpp)
target_link_libraries(draft_policy_test PRIVATE strata_spec)
add_test(NAME draft_policy_test COMMAND draft_policy_test)
add_executable(window_rule_test src/spec/window_rule_test.cpp) # which rule picks the window / whose q judges it, by the active draft source
target_include_directories(window_rule_test PRIVATE include)
add_test(NAME window_rule_test COMMAND window_rule_test)
add_executable(draft_vocab_test src/spec/draft_vocab_test.cpp) # the DFlash2-alone start's draft-vocabulary choice (a build's packaged default subset; the error elsewhere)
target_include_directories(draft_vocab_test PRIVATE include)
add_test(NAME draft_vocab_test COMMAND draft_vocab_test)
add_executable(spec_start_test src/spec/spec_start_test.cpp)
target_include_directories(spec_start_test PRIVATE include)
add_test(NAME spec_start_test COMMAND spec_start_test)
@@ -1770,6 +1929,16 @@ if(STRATA_BUILD_TESTS OR STRATA_BUILD_CONVERSATION_TESTS)
add_executable(window_limits_test src/core/window_limits_test.cpp)
target_include_directories(window_limits_test PRIVATE include)
add_test(NAME window_limits_test COMMAND window_limits_test)
# #4677: the accepted rows a request commits are bounded to what it can emit (header-only, CPU; 2,736 cases against the old output loop)
add_executable(serve_stop_bound_test tests/core/serve_stop_bound_test.cpp)
target_include_directories(serve_stop_bound_test PRIVATE include)
target_compile_features(serve_stop_bound_test PRIVATE cxx_std_17)
add_test(NAME serve_stop_bound_test COMMAND serve_stop_bound_test)
# the --lookup-max-t default (12 on the 8-row CUDA serve, off on Metal, HIP and every other case; header-only, CPU)
add_executable(lookup_default_test tests/core/lookup_default_test.cpp)
target_include_directories(lookup_default_test PRIVATE include)
target_compile_features(lookup_default_test PRIVATE cxx_std_17)
add_test(NAME lookup_default_test COMMAND lookup_default_test)
# which window-size graph is freed when the next one does not fit (#3702; header-only, CPU)
add_executable(window_evict_test src/core/window_evict_test.cpp)
target_include_directories(window_evict_test PRIVATE include)
@@ -1782,6 +1951,14 @@ if(STRATA_BUILD_TESTS OR STRATA_BUILD_CONVERSATION_TESTS)
add_executable(coupled_draft_test src/core/coupled_draft_test.cpp)
target_include_directories(coupled_draft_test PRIVATE include)
add_test(NAME coupled_draft_test COMMAND coupled_draft_test)
# #4590: the penalty window is the current reply by default, STRATA_PENALTY_PROMPT=1 the old prompt + reply window (header-only, CPU)
add_executable(penalty_window_test src/kernels/penalty_window_test.cpp)
target_include_directories(penalty_window_test PRIVATE include)
add_test(NAME penalty_window_test COMMAND penalty_window_test)
# the penalty-history rows of a batch window, per slot (header-only, CPU)
add_executable(slot_history_test src/kernels/slot_history_test.cpp)
target_include_directories(slot_history_test PRIVATE include)
add_test(NAME slot_history_test COMMAND slot_history_test)
# probabilistic draft acceptance (STRATA_SPEC_PROB): rejection sampling preserves p - chi-square over 2M trials per case (CPU)
add_executable(spec_prob_test src/core/spec_prob_test.cpp)
target_include_directories(spec_prob_test PRIVATE include)
@@ -2336,10 +2513,12 @@ if(STRATA_NATIVE_EXPERTS)
set_tests_properties(prefill_mmq_kquant_test PROPERTIES TIMEOUT 120 ENVIRONMENT STRATA_MMQ_KQ=1)
endif()
# the NVFP4 expert products (w4a8 / w4a4, STRATA_PREFILL_NVFP4) against a double-precision reference
add_executable(mmq_nvfp4_parity src/prefill/mmq_nvfp4_parity.cpp)
target_link_libraries(mmq_nvfp4_parity PRIVATE strata_mmq ggml-base CUDA::cudart)
if(NOT MSVC) # its FP16 rounding helper uses the F16C intrinsics (GCC/Clang need the target flag)
target_compile_options(mmq_nvfp4_parity PRIVATE $<$<COMPILE_LANGUAGE:CXX>:-mf16c>)
if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64|amd64|i[3-6]86)$") # its FP16 rounding helper is the x86 F16C intrinsics: not built on aarch64
add_executable(mmq_nvfp4_parity src/prefill/mmq_nvfp4_parity.cpp)
target_link_libraries(mmq_nvfp4_parity PRIVATE strata_mmq ggml-base CUDA::cudart)
if(NOT MSVC) # GCC/Clang need the target flag
target_compile_options(mmq_nvfp4_parity PRIVATE $<$<COMPILE_LANGUAGE:CXX>:-mf16c>)
endif()
endif()
# the routed-expert products (MMQ) timed at the prompt path's shapes (16 experts a launch)
add_executable(mmq_expert_bench src/prefill/mmq_expert_bench.cpp)
@@ -2476,13 +2655,11 @@ if(STRATA_NATIVE_EXPERTS)
add_executable(q8_gemv_bench src/kernels/q8_gemv_bench.cpp)
target_include_directories(q8_gemv_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(q8_gemv_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
if(STRATA_ENABLE_CUDA)
# EXPDEC (docs/EXPERTDEC.md): the grouped experts' default kernels against STRATA_EXPDEC=1 at T = 1..4, bit for bit
# and in microseconds, on synthetic blobs read cold from DRAM
add_executable(expdec_bench EXCLUDE_FROM_ALL src/kernels/expdec_bench.cpp)
target_include_directories(expdec_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(expdec_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
endif()
# EXPDEC (docs/EXPERTDEC.md): the grouped experts' default kernels against STRATA_EXPDEC=1 at T = 1..4, bit for bit
# and in microseconds, on synthetic blobs read cold from DRAM
add_executable(expdec_bench EXCLUDE_FROM_ALL src/kernels/expdec_bench.cpp)
target_include_directories(expdec_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(expdec_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
if(STRATA_BUILD_TESTS AND STRATA_ENABLE_CUDA)
# Unsloth UD-Q4_K_XL's three expert pairs (and Q8_0 alone, its embedding) on synthetic ggml-quantized weights
# at 2560/640: GPU and CPU experts against the float reference, GPU dequantizers against to_float.
@@ -2764,3 +2941,10 @@ if(STRATA_BUILD_DSV41 AND NOT STRATA_ENABLE_CUDA AND NOT STRATA_ENABLE_HIP AND N
target_compile_definitions(ds4_gg_bench PRIVATE STRATA_DSV41_TQ_CPU=1)
endif()
endif()
# ---- strata-split: the opt-in engine that splits every layer across 2-4 NVIDIA cards (src/split, from sFNX, MIT, John Fink) ----
# OFF by default, Linux + CUDA only, no other target depends on it; see src/split/CMakeLists.txt and docs/SPLIT_ENGINE.md.
option(STRATA_BUILD_SPLIT "Build strata-split: sFNX's split-every-layer engine, host-memory exchange (Linux, CUDA or HIP gfx1201, 2-4 cards)" OFF)
if(STRATA_BUILD_SPLIT)
add_subdirectory(src/split)
endif()
+2 -2
View File
@@ -56,7 +56,7 @@ Wir empfehlen **zuerst ISTA-DASLab**, danach **Unsloth** und andere Anbieter, so
In der **Desktop-App** und im **Terminal**. Was nicht in den Arbeitsspeicher passt, wird von deiner SSD gelesen. Darum brauchst du viel freien Speicherplatz.
| Set (IST-DAS) | Download | Freier Speicherplatz nötig |
| Set (ISTA-DAS) | Download | Freier Speicherplatz nötig |
| --- | ---: | ---: |
| 1.75 bpw | 239 GB | etwa 245 GB |
| 2.00 bpw | 257 GB | etwa 265 GB |
@@ -95,7 +95,7 @@ Unsicher, was passt? [Wähle ein Modell für deinen Computer](docs/MODELS.md#pic
Installiere die App, wähle ein Modell und leg mit dem Chat los. Es gibt einen Installer pro System. Beim ersten Start lädt die App die Engine für deine Grafikkarte herunter. [Hilfe zum Installer](docs/INSTALL.md#the-app).
<sub>Dateien: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
<sub>Dateien: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Strata-Desktop-App mit Chat und Live-Codevorschau nebeneinander">
+2 -2
View File
@@ -56,7 +56,7 @@ Recomendamos **primero ISTA-DASLab**, después **Unsloth** y otros proveedores,
En la **aplicación de escritorio** y en el **Terminal**. Lo que no cabe en la memoria se lee desde tu SSD, así que hace falta mucho espacio libre en disco.
| Conjunto (IST-DAS) | Descarga | Espacio libre necesario |
| Conjunto (ISTA-DAS) | Descarga | Espacio libre necesario |
| --- | ---: | ---: |
| 1.75 bpw | 239 GB | unos 245 GB |
| 2.00 bpw | 257 GB | unos 265 GB |
@@ -95,7 +95,7 @@ Medido con el conjunto de 2.00 bpw en Strata 0.2.5. «Salida» es la velocidad d
Instala la aplicación, elige un modelo y empieza a chatear. Hay un instalador por sistema. En el primer arranque, la aplicación descarga el motor para tu tarjeta gráfica. [Ayuda con el instalador](docs/INSTALL.md#the-app).
<sub>Archivos: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
<sub>Archivos: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Aplicación de escritorio de Strata con un chat y una vista previa del código en vivo, una junto a otra">
+2 -2
View File
@@ -56,7 +56,7 @@ Nous recommandons **d'abord ISTA-DASLab**, puis **Unsloth** et d'autres fourniss
Dans l'**application de bureau** et dans le **Terminal**. Ce qui ne tient pas en mémoire est lu depuis votre SSD : il faut donc beaucoup d'espace disque libre.
| Jeu (IST-DAS) | Téléchargement | Espace disque libre nécessaire |
| Jeu (ISTA-DAS) | Téléchargement | Espace disque libre nécessaire |
| --- | ---: | ---: |
| 1.75 bpw | 239 GB | environ 245 GB |
| 2.00 bpw | 257 GB | environ 265 GB |
@@ -95,7 +95,7 @@ Vous ne savez pas ce qui tient ? [Choisir un modèle pour votre ordinateur](docs
Installez l'application, choisissez un modèle et commencez à discuter. Il y a un seul installateur par système. Au premier lancement, l'application télécharge le moteur de votre carte graphique. [Aide pour l'installateur](docs/INSTALL.md#the-app).
<sub>Fichiers: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
<sub>Fichiers: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Application de bureau Strata avec une discussion et un aperçu du code en direct côte à côte">
+2 -2
View File
@@ -56,7 +56,7 @@
**デスクトップアプリ**と**ターミナル**で使えます。メモリに収まらない分は SSD から読み込むため、空きディスク容量がたくさん必要です。
| セット(IST-DAS) | ダウンロード | 必要な空き容量 |
| セット(ISTA-DAS) | ダウンロード | 必要な空き容量 |
| --- | ---: | ---: |
| 1.75 bpw | 239 GB | 約245 GB |
| 2.00 bpw | 257 GB | 約265 GB |
@@ -95,7 +95,7 @@ Strata 0.2.5 で 2.00 bpw のセットを使って測定しました。「出力
アプリをインストールし、モデルを選んで、チャットを始めましょう。インストーラーはシステムごとに1つです。初回起動時に、アプリがお使いのグラフィックスカード用のエンジンをダウンロードします。[インストーラーのヘルプ](docs/INSTALL.md#the-app)。
<sub>ファイル: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
<sub>ファイル: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="チャットとライブのコードプレビューを並べて表示した Strata デスクトップアプリ">
+3 -2
View File
@@ -57,11 +57,12 @@ We recommend **ISTA-DASLab first**, then **Unsloth** and other providers, where
In the **desktop app** and the **Terminal**. What does not fit in memory is read from your SSD, so it needs a lot of free
disk space.
| Set (IST-DAS) | Download | Free disk needed |
| Set (ISTA-DAS) | Download | Free disk needed |
| --- | ---: | ---: |
| 1.75 bpw | 239 GB | about 245 GB |
| 2.00 bpw | 257 GB | about 265 GB |
| 2.25 bpw | 274 GB | about 280 GB |
| ISTA-DAS Coder (half of the experts removed) | 191 GB | about 197 GB |
Runs on NVIDIA RTX 30, 40 and 50 series (Windows and Linux), AMD Radeon AI PRO R9700 (Linux), AMD Ryzen AI Max — Strix Halo
(Windows), and Apple Silicon Macs with 64 GB or more, text only ([Mac](docs/DEEPSEEK_MAC.md)). Setup downloads the files and the DeepSeek engine, then starts the model.
@@ -99,7 +100,7 @@ Not sure what fits? [Choose a model for your computer](docs/MODELS.md#pick-by-me
Install the app, choose a model, and start chatting. There is one installer for each system. On first start the app
downloads the engine for your graphics card. [Help with the installer](docs/INSTALL.md#the-app).
<sub>Files: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
<sub>Files: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Strata desktop app with a chat and live code preview side by side">
+2 -2
View File
@@ -57,7 +57,7 @@ Najpierw polecamy **ISTA-DASLab**, potem **Unsloth** i innych dostawców — o i
W **aplikacji na komputer** i w **Terminalu**. To, co nie mieści się w pamięci, jest czytane z dysku SSD, więc potrzebujesz
dużo wolnego miejsca na dysku.
| Zestaw (IST-DAS) | Do pobrania | Potrzebne wolne miejsce |
| Zestaw (ISTA-DAS) | Do pobrania | Potrzebne wolne miejsce |
| --- | ---: | ---: |
| 1.75 bpw | 239 GB | około 245 GB |
| 2.00 bpw | 257 GB | około 265 GB |
@@ -99,7 +99,7 @@ Nie wiesz, co się zmieści? [Wybierz model do swojego komputera](docs/MODELS.md
Zainstaluj aplikację, wybierz model i zacznij rozmawiać. Dla każdego systemu jest jeden instalator. Przy pierwszym
uruchomieniu aplikacja pobiera silnik dla twojej karty graficznej. [Pomoc z instalatorem](docs/INSTALL.md#the-app).
<sub>Pliki: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
<sub>Pliki: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Aplikacja Strata na komputer: czat i podgląd kodu na żywo obok siebie">
+2 -2
View File
@@ -56,7 +56,7 @@ Recomendamos **primeiro a ISTA-DASLab**, depois a **Unsloth** e outros provedore
No **aplicativo de desktop** e no **Terminal**. O que não cabe na memória é lido do seu SSD, por isso é preciso muito espaço livre em disco.
| Conjunto (IST-DAS) | Download | Espaço livre necessário |
| Conjunto (ISTA-DAS) | Download | Espaço livre necessário |
| --- | ---: | ---: |
| 1.75 bpw | 239 GB | cerca de 245 GB |
| 2.00 bpw | 257 GB | cerca de 265 GB |
@@ -95,7 +95,7 @@ Não sabe o que cabe? [Escolha um modelo para o seu computador](docs/MODELS.md#p
Instale o aplicativo, escolha um modelo e comece a conversar. Há um instalador por sistema. Na primeira vez que abre, o aplicativo baixa o motor da sua placa de vídeo. [Ajuda com o instalador](docs/INSTALL.md#the-app).
<sub>Arquivos: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
<sub>Arquivos: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Aplicativo de desktop do Strata com um chat e uma prévia do código ao vivo lado a lado">
+2 -2
View File
@@ -56,7 +56,7 @@
可在**桌面应用**和**终端**中使用。内存放不下的部分会从 SSD 读取,所以需要大量空闲磁盘空间。
| 版本(IST-DAS) | 下载大小 | 所需空闲磁盘 |
| 版本(ISTA-DAS) | 下载大小 | 所需空闲磁盘 |
| --- | ---: | ---: |
| 1.75 bpw | 239 GB | 约245 GB |
| 2.00 bpw | 257 GB | 约265 GB |
@@ -95,7 +95,7 @@ strata launch --family deepseek41 --model ist-das-2.00bpw --plan
安装应用,选择模型,就可以开始聊天。每个系统只有一个安装程序。首次启动时,应用会下载适合你显卡的引擎。[安装程序帮助](docs/INSTALL.md#the-app)。
<sub>文件: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
<sub>文件: `Strata-0.2.9-windows-x64-setup.exe` · `Strata-0.2.9-linux-x86_64.AppImage` · `Strata-0.2.9-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Strata 桌面应用,聊天和实时代码预览并排显示">
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "strata-desktop",
"productName": "Strata",
"version": "0.2.8",
"version": "0.2.10",
"private": true,
"description": "Strata Desktop - local AI, tuned to one model.",
"scripts": {
+1 -1
View File
@@ -3625,7 +3625,7 @@ checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
[[package]]
name = "strata-desktop"
version = "0.2.8"
version = "0.2.10"
dependencies = [
"gtk",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "strata-desktop"
version = "0.2.8"
version = "0.2.10"
description = "Strata Desktop — local AI, tuned to one model."
edition = "2021"
rust-version = "1.77"
+1 -1
View File
@@ -6,7 +6,7 @@ Implemented 14 October 2026 in the React/Node app under `backups/strata`.
The default is one model at a time. First launch uses the visual `GuidedSetup` funnel: **Welcome → Your model → Prepare → Make it yours → Chat**. Welcome has animated vector artwork and one expert skip at the bottom. Model selection offers friendly version cards, context, RAM/VRAM inventory with an aggregate total, and an existing-folder browser. Unified CPU/GPU memory counts once. Memory verdicts are guidance: **May run slower** does not block selection or Continue. There is no compatibility alternatives panel or picture-reading question at this stage. The preparation screen confirms the download size before **Download model**, then shows engine-reported progress alongside a clickable Chat / Files / Your setup tour. Vision-capable families always prepare their mmproj/encoder files. Preparation does not load the model automatically.
After preparation, users choose **Leave 20% for my work** or **Full performance**, and choose whether to **Read pictures when this model loads** (off initially). The optional longer native calibration measures engine settings for approximately 5–10 minutes. **Tune & start chatting** applies the resource and vision preferences, loads one model, performs a short benchmark, and completes setup only when that model is healthy. Chat opens automatically with starter prompts for a new user; selecting a prompt fills the composer without sending it. Progress, choices and operation IDs survive reloads. Finish later preserves the current stage and keeps completed files available.
After preparation, users choose **Leave room for my work** or **Full performance**, and choose whether to **Read pictures when this model loads** (off initially). The optional longer native calibration measures engine settings for approximately 5–10 minutes. **Tune & start chatting** applies the resource and vision preferences, loads one model, performs a short benchmark, and completes setup only when that model is healthy. Chat opens automatically with starter prompts for a new user; selecting a prompt fills the composer without sending it. Progress, choices and operation IDs survive reloads. Finish later preserves the current stage and keeps completed files available.
Normal Models shows Version, optional picture reading, context, Computer use and Load model. The resource preference can be changed here after onboarding. Image generation is a separate collapsed option. The selected model and context remain visible in the toolbar; saved setups and Manage models are available there. Local GGUF import is a secondary entry in Version. Imported models retain the existing metadata/shard validation and reference their files without copying them.
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Strata — local AI, tuned to one model</title>
<title>Strata</title>
<meta name="description" content="Strata runs one local model, tuned to your hardware." />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#f9fafb" />
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "strata",
"version": "0.2.8",
"version": "0.2.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "strata",
"version": "0.2.8",
"version": "0.2.10",
"dependencies": {
"html-to-image": "^1.11.13",
"katex": "^0.19.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "strata",
"private": true,
"version": "0.2.8",
"version": "0.2.10",
"type": "module",
"description": "Strata \u2014 local AI, tuned to one model. Web shell + engine host.",
"scripts": {
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env node
/**
* AGENT-LOST end to end: a long single-file write through the real agent bridge, the real Harness and a real engine.
*
* STRATA_HARNESS_DIR=<harness folder> node scripts/agent-lost-e2e.mjs [pagoda|long|cut ...]
*
* Scenarios (default: all)
* pagoda the owner's own prompt ("Write me a 100 voxel pagoda in one html file with garden."), thinking Medium, bypass mode
* long a deterministic long write: one file of N numbered lines (E2E_LINES, default 1800, about 22K tokens), thinking Off
* mcp Strata's own tool loop (serve runs an MCP tool itself): a call that finishes, and a call the reply is cut inside
* raw the model server alone: a reply cut inside a tool call, as the client receives it
* cut the same long write with the Harness told a small window (E2E_CUT_WINDOW, default 24000), so the reply budget is
* about 7K tokens and the reply is cut inside the tool call: the turn must say so in words and the agent must carry on
*
* A model server that is already running is used when E2E_BASE_URL is set (nothing is started or stopped). Otherwise this
* starts serve/server.py as its own child with the engine named below and stops it by PID.
*
* Environment: E2E_ENGINE (strata executable), E2E_PACK, E2E_GGUF1, E2E_GGUF2, E2E_MTP, E2E_PROFILE, E2E_LIB (library folder),
* E2E_PORT (18590), E2E_WINDOW (65536), E2E_OUT (result folder), E2E_PYTHON.
*
* It prints one table and writes <E2E_OUT>/result.json with the frames summary of each scenario.
*/
import { spawn } from 'node:child_process';
import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createAgentBridge } from '../server/agent/agent-bridge.mjs';
const REPO = join(import.meta.dirname, '..', '..', '..');
const env = (name, fallback) => process.env[name] ?? fallback;
const HOME = process.env.HOME ?? '';
const PORT = Number(env('E2E_PORT', '18590'));
const WINDOW = Number(env('E2E_WINDOW', '65536'));
const EXISTING = process.env.E2E_BASE_URL ?? '';
const PYTHON = env('E2E_PYTHON', 'python3');
const OUT = env('E2E_OUT', mkdtempSync(join(tmpdir(), 'strata-agent-lost-')));
const LINES = Number(env('E2E_LINES', '1100'));
const REPEAT = Number(env('E2E_REPEAT', '1'));
const TURN_S = Number(env('E2E_TURN_S', '900'));
const wanted = process.argv.slice(2);
const want = id => wanted.length === 0 || wanted.includes(id);
mkdirSync(OUT, { recursive: true });
if (!process.env.STRATA_HARNESS_DIR) { console.error('STRATA_HARNESS_DIR is not set'); process.exit(2); }
const sleep = ms => new Promise(r => setTimeout(r, ms));
let server = null, bridge = null;
const finish = async (code) => {
try { await bridge?.close(); } catch { /* going away */ }
if (server?.pid) { try { process.kill(-server.pid, 'SIGTERM'); } catch { try { process.kill(server.pid, 'SIGTERM'); } catch { /* gone */ } } }
await sleep(1500);
console.log(`\nresult folder: ${OUT}`);
process.exit(code);
};
process.on('SIGINT', () => finish(130));
process.on('SIGTERM', () => finish(143));
if (!EXISTING) {
const pack = env('E2E_PACK', join(HOME, '026q/pack-iq3s'));
const g1 = env('E2E_GGUF1', join(HOME, 'models/IQ3_S/Qwen3.8-Flash-Next-GSQ-RCO-IQ3_S-00001-of-00002.gguf'));
const g2 = env('E2E_GGUF2', join(HOME, 'models/IQ3_S/Qwen3.8-Flash-Next-GSQ-RCO-IQ3_S-00002-of-00002.gguf'));
const mtp = env('E2E_MTP', join(HOME, 'pr1426/mtp-rt'));
const profile = env('E2E_PROFILE', join(HOME, 'bwm/cnt3/data/expert-profile.bin'));
const exe = env('E2E_ENGINE', join(HOME, 'agentlost/eng/strata'));
const lib = env('E2E_LIB', join(HOME, 'agentlost/eng'));
for (const f of [pack, g1, g2, mtp, exe]) if (!existsSync(f)) { console.error(`missing: ${f}`); process.exit(2); }
const config = join(OUT, 'server.json');
writeFileSync(config, JSON.stringify({
exe, args: ['--pack', pack, '--native', g1, '--ple-gguf', g2, ...(existsSync(profile) ? ['--expert-profile', profile] : []),
'--prefill', 'auto', '--spec-min-p', '0.5', '--mtp', mtp, '--max-context', String(WINDOW), '--kv', 'int8', '--expert-cache', 'auto', '--spec', '4',
...(process.env.E2E_ENGINE_ARGS ? process.env.E2E_ENGINE_ARGS.split(' ').filter(Boolean) : [])],
cwd: REPO, tokenizer: join(pack, 'tokenizer'), model_name: 'qwen3.8-flash-next-iq3_s', log: join(OUT, 'engine.log'), lib_dirs: [lib], port: PORT, gpu: 0,
fit_max_tokens: true,
// Strata's own tool loop (serve/server.py run_with_mcp): one small MCP server, offered only to requests that ask for it (strata_mcp).
mcp_servers: { fake: { command: PYTHON, args: [join(REPO, 'serve', 'mcp_fake_server.py')] } },
}, null, 2));
server = spawn(PYTHON, ['serve/server.py', '--engine', 'strata', '--config', config, '--port', String(PORT), '--no-api-key', '--ready-file', join(OUT, 'ready.json')], {
cwd: REPO, env: { ...process.env, LD_LIBRARY_PATH: `${lib}:${process.env.LD_LIBRARY_PATH ?? ''}`, STRATA_DATA_DIR: join(OUT, 'data'), PYTHONPATH: `${join(REPO, 'tools')}:${REPO}` }, stdio: ['ignore', 'pipe', 'pipe'], detached: true,
});
const sink = [];
server.stdout.on('data', d => sink.push(String(d)));
server.stderr.on('data', d => sink.push(String(d)));
server.on('exit', code => { sink.push(`[server exited ${code}]`); server = null; writeFileSync(join(OUT, 'server.log'), sink.join('')); });
}
const base = EXISTING || `http://127.0.0.1:${PORT}`;
let ready = false;
for (let i = 0; i < 360 && !ready; i++) {
if (!EXISTING && !server) break;
try { ready = (await fetch(`${base}/health`, { signal: AbortSignal.timeout(2000) })).ok; } catch { /* still loading */ }
if (!ready) await sleep(2000);
}
if (!ready) { console.error('the engine did not become ready'); await finish(4); }
const model = (await (await fetch(`${base}/v1/models`)).json()).data[0].id;
console.log(`engine ready: ${model}`);
const rows = [];
const note = (id, ok, detail) => { rows.push({ id, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${id} ${detail}`); };
/** The bridge for a window as the host would tell it (the engine's own window stays WINDOW). */
function makeBridge(window, dir) {
mkdirSync(join(OUT, dir), { recursive: true });
return createAgentBridge({
dataDir: join(OUT, dir), env: process.env, startTimeoutMs: 180_000, log: l => console.log(' bridge:', l),
localInstances: () => [{ id: 'e2e', label: 'iq3_s', baseUrl: `${base}/v1`, modelId: model, context: window, slots: 1, key: '' }], connections: () => [],
});
}
bridge = makeBridge(WINDOW, 'agent');
/** One agent turn; every frame is kept in a compact timeline so the live data path can be read afterwards. */
async function turn(name, message, { thinking = 'Medium', mode = 'bypass', chatId = name } = {}) {
const workspace = join(OUT, 'ws-' + name);
mkdirSync(workspace, { recursive: true });
const frames = [];
const t0 = Date.now();
let timedOut = false;
const stopTimer = setTimeout(() => { timedOut = true; void bridge.stop(chatId).catch(() => {}); }, TURN_S * 1000);
await bridge.runTurn({ chatId, message, workspacePath: workspace, mode, thinking, onFrame: f => frames.push({ at: Date.now() - t0, f }) });
clearTimeout(stopTimer);
const seconds = (Date.now() - t0) / 1000;
const byType = {};
for (const { f } of frames) byType[f.type] = (byType[f.type] ?? 0) + 1;
const done = frames.map(x => x.f).filter(f => f.type === 'done').at(-1) ?? null;
const error = frames.map(x => x.f).filter(f => f.type === 'error').at(-1) ?? null;
const prepare = frames.map(x => x.f).filter(f => f.type === 'status' && (f.phase === 'preparing' || f.code === 'preparing-tool'));
const cards = frames.map(x => x.f).filter(f => f.type === 'agent-tool').map(f => f.tool);
const text = frames.map(x => x.f).filter(f => f.type === 'delta').map(f => f.text).join('');
const files = readdirSync(workspace).map(n => ({ name: n, bytes: statSync(join(workspace, n)).size }));
const cutNotes = frames.map(x => x.f).filter(f => f.type === 'delta' && f.noteReason === 'cut').map(f => f.text.trim());
const drafts = frames.map(x => x.f).filter(f => f.type === 'agent-tool-draft').map(f => f.draft);
const writes = drafts.filter(d => d.name === 'write' && d.bytes > 0);
const draftStats = { frames: drafts.length, writeFrames: writes.length, lastWrite: writes.filter(d => !d.done).at(-1) ? { target: writes.filter(d => !d.done).at(-1).target, bytes: writes.filter(d => !d.done).at(-1).bytes, lines: writes.filter(d => !d.done).at(-1).lines } : null, previewSample: writes.filter(d => !d.done).at(-1)?.preview.slice(-80) ?? null };
const writesDone = cards.filter(c => c.name === 'write' && c.state === 'done').length;
const summary = { name, seconds, timedOut, writesDone, cutNotes, draftStats, byType, done, error, files, cards: cards.map(c => ({ name: c.name, state: c.state, target: c.target })), prepareStatuses: prepare.length, firstPrepare: prepare[0] ?? null, lastPrepare: prepare.at(-1) ?? null, text: text.slice(-1200) };
writeFileSync(join(OUT, `turn-${name}.json`), JSON.stringify({ summary, frames: frames.slice(0, 4000) }, null, 1));
return { ...summary, frames, workspace };
}
const longPrompt = `Create the file big.txt in the current folder with the write tool, in ONE call. It must hold exactly ${LINES} lines. Line N is "N: " followed by one short true sentence about a different city of the world (a new city on every line, no repeats, no pattern). Write every line out yourself, do not use a script or a loop, and do not shorten it.`;
if (want('raw')) {
// What the model server itself sends when a reply is cut inside a tool call (no Harness): the stream of tool_calls pieces, then finish_reason "length".
const body = { model, stream: true, max_tokens: Number(env('E2E_RAW_CAP', '700')), temperature: 0.6,
chat_template_kwargs: { enable_thinking: false },
tools: [{ type: 'function', function: { name: 'write', description: 'Write a file.', parameters: { type: 'object', properties: { file_path: { type: 'string' }, content: { type: 'string' } }, required: ['file_path', 'content'] } } }],
messages: [{ role: 'user', content: longPrompt }] };
const res = await fetch(`${base}/v1/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const text = await res.text();
const events = text.split(String.fromCharCode(10)).filter(l => l.startsWith('data: {')).map(l => JSON.parse(l.slice(6)));
let args = '', name = '', content = '', finish = null, pieces = 0, ids = new Set();
for (const e of events) for (const c of e.choices ?? []) {
for (const tc of c.delta?.tool_calls ?? []) { pieces += 1; if (tc.id) ids.add(tc.id); if (tc.function?.name) name = tc.function.name; args += tc.function?.arguments ?? ''; }
content += c.delta?.content ?? '';
if (c.finish_reason) finish = c.finish_reason;
}
const usage = events.map(e => e.usage).filter(Boolean).at(-1);
writeFileSync(join(OUT, 'raw-sse.txt'), text);
note('raw: a reply cut inside a tool call reaches the client as pieces of the call and finish_reason length', finish === 'length' && name === 'write' && pieces > 5, `finish ${finish}; tool ${name}; ${pieces} call pieces, ${args.length} argument characters, closed JSON ${(() => { try { JSON.parse(args); return true; } catch { return false; } })()}; plain content ${JSON.stringify(content.slice(0, 80))}; ids ${ids.size}; usage ${JSON.stringify(usage)}`);
}
if (want('mcp')) {
// Strata's own tool loop: the server runs the MCP tool itself. A call that finishes is run; a call the reply is cut inside is announced and never run.
const ask = async (content, max_tokens) => {
const res = await fetch(`${base}/v1/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model, stream: true, strata_mcp: true, max_tokens, temperature: 0.6, chat_template_kwargs: { enable_thinking: false }, messages: [{ role: 'user', content }] }) });
const text = await res.text();
const events = text.split(String.fromCharCode(10)).filter(l => l.startsWith('data: {')).map(l => JSON.parse(l.slice(6)));
const mcp = events.map(e => e.strata_mcp).filter(Boolean);
const finish = events.flatMap(e => e.choices ?? []).map(c => c.finish_reason).filter(Boolean).at(-1);
const said = events.flatMap(e => e.choices ?? []).map(c => c.delta?.content ?? '').join('');
return { mcp, finish, said };
};
const ok = await ask('Use the fake__echo tool to echo the text "hello from the tool", then tell me what it returned.', 600);
note('mcp: a short call is run by the server and answered', ok.mcp.some(m => m.event === 'result' && m.ok) && ok.finish === 'stop', `events ${JSON.stringify(ok.mcp.map(m => m.event))}; finish ${ok.finish}; said ${JSON.stringify(ok.said.slice(-120))}`);
const cut = await ask('Use the fake__echo tool once. Its text must be the sentence "the quick brown fox jumps over the lazy dog" written out 300 times in a row, separated by spaces.', 300);
note('mcp: a call the reply is cut inside is announced and never run (#211), and the stream says length', cut.mcp.some(m => m.event === 'start') && !cut.mcp.some(m => m.event === 'call') && cut.finish === 'length', `events ${JSON.stringify(cut.mcp.map(m => m.event))}; finish ${cut.finish}; said ${JSON.stringify(cut.said.slice(0, 100))}`);
}
if (want('pagoda')) {
const t = await turn('pagoda', 'Write me a 100 voxel pagoda in one html file with garden.');
const html = t.files.find(f => /\.html?$/i.test(f.name));
console.log(` pagoda: ${t.seconds.toFixed(0)} s, frames ${JSON.stringify(t.byType)}, done ${JSON.stringify(t.done && { finishReason: t.done.finishReason, tokensOut: t.done.tokensOut })}, files ${JSON.stringify(t.files)}`);
note('pagoda: a file was written or the turn says why not', !!html || /cut|too long|ran out|limit/i.test(t.text), `file ${html ? html.name + ' ' + html.bytes + ' B' : 'none'}; finish ${t.done?.finishReason}; text tail: ${JSON.stringify(t.text.slice(-240))}`);
}
if (want('long')) for (let rep = 1; rep <= REPEAT; rep++) {
const t = await turn(`long${rep}`, longPrompt, { thinking: 'Off' });
const f = t.files.find(x => x.name === 'big.txt');
const lines = f ? readFileSync(join(t.workspace, 'big.txt'), 'utf8').split(String.fromCharCode(10)).filter(Boolean).length : 0;
console.log(` long${rep}: ${t.seconds.toFixed(0)} s, frames ${JSON.stringify(t.byType)}, done ${JSON.stringify(t.done && { finishReason: t.done.finishReason, tokensOut: t.done.tokensOut })}, draft ${JSON.stringify(t.draftStats)}, cut notes ${JSON.stringify(t.cutNotes)}`);
note(`long${rep}: big.txt written (${LINES} lines asked)`, lines >= LINES * 0.95 || t.writesDone > 0 || t.cutNotes.length > 0 && !!f, `file ${f ? f.bytes + ' B, ' + lines + ' lines' : 'none'}; finish ${t.done?.finishReason}; tokens out ${t.done?.tokensOut}; cards ${JSON.stringify(t.cards.filter(c => c.state !== 'running').map(c => c.name))}; writes done ${t.writesDone}; cut notes ${t.cutNotes.length}; draft frames ${t.draftStats.frames}`);
note(`long${rep}: the turn did not end silently`, !!f || t.cutNotes.length > 0 || t.text.trim().length > 0, `text tail ${JSON.stringify(t.text.slice(-160))}`);
}
if (want('cut')) {
// A small window told to the Harness (E2E_CUT_WINDOW, default 24000; the engine's own stays WINDOW) makes the reply budget about 7K tokens (raised to
// 12K by the fix), so the first reply is cut inside the tool call, as the pagoda's was at 19,660.
await bridge.close();
bridge = makeBridge(Number(env('E2E_CUT_WINDOW', '24000')), 'agent-cut');
const t = await turn('cut', longPrompt, { thinking: 'Off' });
const f = t.files.find(x => x.name === 'big.txt');
const lines = f ? readFileSync(join(t.workspace, 'big.txt'), 'utf8').split(String.fromCharCode(10)).filter(Boolean).length : 0;
console.log(` cut: ${t.seconds.toFixed(0)} s, frames ${JSON.stringify(t.byType)}, done ${JSON.stringify(t.done && { finishReason: t.done.finishReason })}, files ${JSON.stringify(t.files)}, cut notes ${JSON.stringify(t.cutNotes)}, draft ${JSON.stringify(t.draftStats)}`);
note('cut: the chat said the reply was cut, in words', t.cutNotes.length > 0 && t.cutNotes.every(n => /length limit/.test(n)), JSON.stringify(t.cutNotes));
note('cut: the agent went on and the file exists', !!f, `file ${f ? f.bytes + ' B, ' + lines + ' lines' : 'none'}; finish ${t.done?.finishReason}`);
}
writeFileSync(join(OUT, 'result.json'), JSON.stringify(rows, null, 2));
console.log('\n' + rows.map(r => `${r.ok ? 'PASS' : 'FAIL'} ${r.id}`).join('\n'));
await finish(rows.some(r => !r.ok) ? 1 : 0);
+99 -37
View File
@@ -16,10 +16,15 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const APP = join(ROOT, 'src/styles/app.css');
const STYLE_DIR = join(ROOT, 'src/styles');
const KIT_SRC = join(ROOT, '..', 'ui');
const KIT_COPY = join(ROOT, 'src/styles/kit');
/** The app's own stylesheets, in cascade order: the last one wins. */
const APP_SHEETS = ['app.css', 'liquid.css']
.map((name) => ({ name, css: existsSync(join(STYLE_DIR, name)) ? readFileSync(join(STYLE_DIR, name), 'utf8') : null }))
.filter((sheet) => sheet.css !== null);
const KIT_SCALES = {
radius: new Set(['8px', '14px', '20px', '24px', '9999px', '999px', '50%', '6px', '10px', '12px', '15px', '16px', '2px', '1.5px', 'inherit', '0']),
fontSize: new Set(['11px', '12px', '13px', '14px', '15px', '16px', '17px', '18px', '19px', '22px', '24px', '30px', '32px', '40px', 'inherit']),
@@ -66,9 +71,12 @@ const BLUR_ALLOWED = new Set([
'.menu',
'.telepop',
'.scrim',
".scrim[data-open='true']", // the same scrim, once it is showing
'.dialog-scrim', // the sheet's scrim: it frosts the whole working screen
'.config-savebar', // floating commit controls
'.mobile-bar', // compact navigation controls
'.st-toast--toned',
'.nav', // the navigation layer over the ambient depth layer
]);
/**
@@ -83,6 +91,7 @@ const ELEVATION_ALLOWED = new Set([
'.app[data-nav-open=\'true\'] > .nav', // dismissible navigation sheet
'.st-tab[aria-checked=\'true\']', // selected segmented control
'.nav-new', // primary action affordance
'.nav-new.st-btn--primary', // the same affordance, with its accent recipe
'.st-drawer.sheet',
'.slashmenu',
'.select__pop',
@@ -95,9 +104,12 @@ const ELEVATION_ALLOWED = new Set([
// The jump-to-latest pill sits above a scrolling transcript, so it genuinely
// overlaps moving content — the same test the other entries pass.
'.jumplatest',
'.quote-selection', // the Quote button that floats beside selected words (#4785)
'.st-btn--primary', // the accent fill's own lift
'.st-slider::-webkit-slider-thumb', // a control affordance: the thumb needs to read against its track
'.st-toggle::after', // same: the knob needs to read against its track
'.family-switch button[aria-selected="true"]', // a selected segmented control, like .st-tab
'.native-choice__col .native-resource-choice button[data-selected="true"]', // same
]);
/**
@@ -110,25 +122,61 @@ const CAPSULE_CONTROLS = ['.st-btn', '.st-input', '.st-tab', '.navitem', '.navse
/** Literal colours that are deliberate and documented. */
const DOCUMENTED_LITERALS = ['#05261b', '#12c68e', '#0a7a55'];
/**
* The only selector allowed to paint a gradient: the single ambient depth layer.
*
* The flat-surface rule exists so surfaces read as paper rather than decoration.
* Depth was the one thing it could not express, and the navigation material has
* nothing to sample over a flat fill. A fixed, two-field wash on the page — too
* quiet to read as a gradient at ~1.04:1 — is the smallest thing that makes the
* glass mean anything. It is body-only, it never moves, and both reduced-
* transparency gates remove it. See src/styles/liquid.css §1.
*/
const AMBIENT_SELECTORS = new Set(['body']);
const failures = [];
const notes = [];
const fail = (file, line, message) => failures.push(`${file}:${line} ${message}`);
/* ------------------------------------------------------------ app.css ---- */
/* ------------------------------------------------------------ app sheets -- */
const css = readFileSync(APP, 'utf8');
const lines = css.split('\n');
let selector = '(top level)';
let inKeyframes = false;
/**
* Scan one app stylesheet for the design-system, motion and material rules.
*
* Run over every sheet in the cascade, not only app.css: a rule appended in a
* later file ships just the same, and the "audit palette" block that broke AA on
* the primary button survived precisely because it had never been scanned.
*/
function scanStylesheet(file, css) {
const lines = css.split('\n');
let selector = '(top level)';
let inKeyframes = false;
lines.forEach((raw, i) => {
const line = raw.replace(/\/\*.*?\*\//g, '');
const n = i + 1;
lines.forEach((raw, i) => {
const line = raw.replace(/\/\*.*?\*\//g, '');
const n = i + 1;
const sel = line.match(/^([^@{}\s][^{}]*)\{$/);
// Selectors are usually indented inside a media query or a nested block, so
// the line is trimmed before it is recognised. Without this every indented
// rule was attributed to "(top level)", and the material and elevation
// allowlists — which match on the selector — rejected most of the file.
const sel = line.trim().match(/^([^@{}\s][^{}]*)\{$/);
if (sel) selector = sel[1].trim();
if (/@keyframes/.test(line)) inKeyframes = true;
if (inKeyframes && /^\}/.test(line)) inKeyframes = false;
// A whole rule on one line (`a, b { … }`) never ends with `{`, so it used to
// be attributed to whatever rule ran before it — which is why a shadow on a
// segmented control was reported against `.menu .response-stats`. The rule's
// own selector is used for its declarations.
let selectorForLine = selector;
if (!sel && !inKeyframes) {
const inline = line.trim().match(/^([^@{}][^{}]*?)\s*\{/);
if (inline && /\}/.test(line)) selectorForLine = inline[1].trim();
}
// A one-line `@keyframes name { … }` opens and closes on the same line, so it
// must not switch the tracker on: it never sees a closing brace at column 0,
// and every rule after it was then treated as part of an animation — which
// disabled the one-line selector attribution above.
if (/@keyframes/.test(line) && !/\}/.test(line)) inKeyframes = true;
if (inKeyframes && /^\}/.test(raw)) inKeyframes = false;
for (const [, prop, rawValue] of line.matchAll(/([a-z-]+)\s*:\s*([^;]+);/g)) {
const value = rawValue.trim();
@@ -137,24 +185,24 @@ lines.forEach((raw, i) => {
if (prop === 'border-radius') {
for (const part of value.split(/\s+/)) {
if (!KIT_SCALES.radius.has(part) && !part.startsWith('var(') && !part.includes('%')) {
fail('app.css', n, `off-scale radius "${part}" in ${selector}`);
fail(file, n, `off-scale radius "${part}" in ${selectorForLine}`);
}
}
}
if (prop === 'font-size' && !value.startsWith('var(') && !value.includes('clamp') && !KIT_SCALES.fontSize.has(value)) {
fail('app.css', n, `off-scale font-size "${value}" in ${selector}`);
fail(file, n, `off-scale font-size "${value}" in ${selectorForLine}`);
}
if (prop === 'box-shadow' && !KIT_SCALES.shadow.some((re) => re.test(value))) {
fail('app.css', n, `shadow is not a kit token: "${value}" in ${selector}`);
fail(file, n, `shadow is not a kit token: "${value}" in ${selectorForLine}`);
}
if (/gradient|radial|conic/.test(value) && prop.startsWith('background')) {
fail('app.css', n, `gradient in ${selector} — the system is flat`);
if (/gradient|radial|conic/.test(value) && prop.startsWith('background') && !AMBIENT_SELECTORS.has(selector)) {
fail(file, n, `gradient in ${selectorForLine} — the system is flat`);
}
if (/^(color|background|background-color|border-color)$/.test(prop)) {
const literals = value.match(/#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)/g);
if (literals && !value.includes('data:image') && !value.includes('currentColor')
&& !DOCUMENTED_LITERALS.some((d) => value.includes(d))) {
fail('app.css', n, `literal colour "${value}" in ${selector} — use a token`);
fail(file, n, `literal colour "${value}" in ${selectorForLine} — use a token`);
}
}
@@ -163,57 +211,70 @@ lines.forEach((raw, i) => {
// Values must be a --st-material-* token: that is what collapses the blur
// under reduced transparency, so gating cannot be forgotten.
if (prop === 'backdrop-filter' || prop === '-webkit-backdrop-filter') {
if (!/^var\(--st-material-(thin|regular|thick)\)$/.test(value) && !(selector === '.scrim' && value === 'none !important')) {
fail('app.css', n, `blur must use a --st-material-* token (it carries the gating): "${value}"`);
if (!/^var\(--st-material-(thin|regular|thick)\)( !important)?$/.test(value) && !(selector === '.scrim' && value === 'none !important')) {
fail(file, n, `blur must use a --st-material-* token (it carries the gating): "${value}"`);
}
if (!BLUR_ALLOWED.has(selector)) {
fail('app.css', n, `material on "${selector}", which is not an approved surface — it does not overlay moving content`);
if (!BLUR_ALLOWED.has(selectorForLine)) {
fail(file, n, `material on "${selectorForLine}", which is not an approved surface — it does not overlay moving content`);
}
}
if (prop === 'box-shadow' && /var\(--st-sh-(card|bar|pop)\)/.test(value) && !ELEVATION_ALLOWED.has(selector)) {
fail('app.css', n, `elevation shadow on "${selector}" — shadows are reserved for surfaces that overlap content`);
if (prop === 'box-shadow' && /var\(--st-sh-(card|bar|pop)\)/.test(value) && !ELEVATION_ALLOWED.has(selectorForLine)) {
fail(file, n, `elevation shadow on "${selectorForLine}" — shadows are reserved for surfaces that overlap content`);
}
if (prop === 'transition') {
if (/^\d/.test(value) || /(^|[\s,])\d+m?s\b/.test(value)) {
fail('app.css', n, `raw duration in transition — use a --st-t-* token: "${value}"`);
fail(file, n, `raw duration in transition — use a --st-t-* token: "${value}"`);
}
for (const part of value.split(',')) {
const prop0 = part.trim().split(/\s+/)[0];
if (LAYOUT_PROPS.includes(prop0)) {
fail('app.css', n, `transition on layout property "${prop0}" in ${selector}`);
fail(file, n, `transition on layout property "${prop0}" in ${selectorForLine}`);
}
}
}
if (prop === 'transition-property') {
for (const p of value.split(',').map((v) => v.trim())) {
if (LAYOUT_PROPS.includes(p)) fail('app.css', n, `transition on layout property "${p}" in ${selector}`);
if (LAYOUT_PROPS.includes(p)) fail(file, n, `transition on layout property "${p}" in ${selectorForLine}`);
}
}
if (prop === 'animation' || prop === 'animation-name') {
const name = value.match(/^(st-[a-z-]+)/)?.[1];
const infinite = /infinite/.test(value);
if (infinite && name && !INFINITE_OK.has(name)) {
fail('app.css', n, `looping animation "${name}" is not on the allowlist — nothing loops while idle`);
fail(file, n, `looping animation "${name}" is not on the allowlist — nothing loops while idle`);
}
const duration = value.match(/\b(\d+(?:\.\d+)?m?s)\b/)?.[1];
if (duration && !LOOP_DURATION_OK.has(duration)) {
fail('app.css', n, `raw animation duration "${duration}" in ${selector} — use a --st-t-* token`);
fail(file, n, `raw animation duration "${duration}" in ${selectorForLine} — use a --st-t-* token`);
}
}
if (prop === 'animation-duration' && /^\d/.test(value) && !LOOP_DURATION_OK.has(value)) {
fail('app.css', n, `raw animation-duration "${value}" in ${selector}`);
fail(file, n, `raw animation-duration "${value}" in ${selectorForLine}`);
}
}
});
}
});
}
for (const sheet of APP_SHEETS) scanStylesheet(sheet.name, sheet.css);
const css = APP_SHEETS.find((sheet) => sheet.name === 'app.css')?.css ?? '';
const allAppCss = APP_SHEETS.map((sheet) => sheet.css).join('\n');
/* ------------------------------------------------- kit sync + motion scale -- */
for (const file of ['tokens.css', 'components.css']) {
const a = join(KIT_SRC, file);
const b = join(KIT_COPY, file);
if (!existsSync(a) || !existsSync(b)) {
fail('kit', 0, `${file} missing on one side`);
// Some checkouts of this branch do not carry the design-kit source (apps/ui),
// only the synced copy. There is nothing to compare against then, so the check
// reports that instead of failing every run with a condition no one can fix.
if (!existsSync(a)) {
notes.push(`kit copy: ${file} has no source in this checkout — drift check skipped`);
continue;
}
if (!existsSync(b)) {
fail('kit', 0, `${file} missing from src/styles/kit — run npm run sync:kit`);
continue;
}
// The sync rewrites @font-face URLs to absolute; everything else must match.
@@ -223,10 +284,11 @@ for (const file of ['tokens.css', 'components.css']) {
}
}
// Which selectors have been given the capsule radius.
// Which selectors have been given the capsule radius. Checked across the whole
// cascade: a control is a capsule wherever the rule that says so lives.
const capsuled = new Set();
{
const rules = css.replace(/\/\*[\s\S]*?\*\//g, '');
const rules = allAppCss.replace(/\/\*[\s\S]*?\*\//g, '');
for (const [, sel, body] of rules.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (!/border-radius:\s*var\(--st-r-pill\)/.test(body)) continue;
for (const part of sel.split(',')) capsuled.add(part.trim());
@@ -263,7 +325,7 @@ const referenced = new Set();
for (const file of [...readdirSync(join(ROOT, 'src'), { recursive: true })].filter((f) => /\.tsx$/.test(String(f)))) {
const full = join(ROOT, 'src', String(file));
if (!statSync(full).isFile()) continue;
for (const m of readFileSync(full, 'utf8').matchAll(/name="([a-z-]+)"/g)) referenced.add(m[1]);
for (const m of readFileSync(full, 'utf8').matchAll(/<(?:Icon|IconBtn)\s[^>]*?\bname="([a-z-]+)"/g)) referenced.add(m[1]);
}
for (const name of referenced) {
if (!symbols.has(name)) fail('src', 0, `icon "${name}" is referenced but not in the sprite — run npm run sync:kit`);
+54
View File
@@ -0,0 +1,54 @@
/**
* Where the Chromium binary the probes drive actually lives.
*
* The probes launched a fixed macOS cache path, so on Windows and Linux they
* printed "No cached Chromium found" and every runtime gate (contrast, measure,
* luminance, motion) was skipped rather than run. Resolution is now a single
* place with the same order everywhere:
*
* 1. STRATA_CHROME — an explicit override, for a CI image or a second Chrome
* 2. the cached Playwright build (macOS, the original platforms)
* 3. an installed browser: Chrome, Edge, Chromium
*
* `requireChromium` exits with the harness message the scripts already used, so
* a machine with no browser at all still fails loudly instead of silently
* passing a gate.
*/
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
const PLAYWRIGHT_MAC = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
];
const INSTALLED = [
// Windows
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
// macOS
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
// Linux
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
];
export function findChromium() {
const candidates = [process.env.STRATA_CHROME, ...PLAYWRIGHT_MAC, ...INSTALLED].filter(Boolean);
return candidates.find((path) => existsSync(path)) ?? null;
}
export function requireChromium() {
const path = findChromium();
if (!path) {
console.error('No Chromium found. Set STRATA_CHROME, or install Chrome, Edge or Chromium.');
process.exit(2);
}
return path;
}
+26
View File
@@ -0,0 +1,26 @@
// Throwaway: serves the demo host unchanged, except POST /api/chat/stream, which streams a long scripted reply (NDJSON frames like the real host).
import http from 'node:http';
const UP = Number(process.env.UP ?? 18791), PORT = Number(process.env.PORT ?? 18790);
const TOK = Number(process.env.REPLY_TOKENS ?? 12000), REASON = Number(process.env.REASON_TOKENS ?? 3000), RATE = Number(process.env.RATE ?? 400);
const sleep = ms => new Promise(r => setTimeout(r, ms));
const para = i => `### Section ${i}\n\nThis is paragraph ${i} with **bold**, *italic*, \`inline code\` and a link [x](https://example.com/${i}). The measured value is about ${i} units and the results agree with the earlier runs, which is what we expected from the model.
More prose follows here so that a paragraph looks like real writing and not like a stress pattern.\n\n- item one of ${i}\n- item two of ${i}\n\n\`\`\`python\ndef f_${i}(a, b):\n total = a + b * ${i}\n return total\n\`\`\`\n\n| a | b |\n|---|---|\n| ${i} | ${i * 2} |\n\n`;
http.createServer(async (req, res) => {
if (req.method === 'POST' && req.url.startsWith('/api/chat/stream')) {
let raw = ''; for await (const c of req) raw += c;
res.writeHead(200, { 'content-type': 'application/x-ndjson' });
const w = o => res.write(JSON.stringify(o) + '\n');
w({ type: 'start', model: 'Mock', contextMax: 131072, requestId: 'r' + Date.now() });
let sent = 0, i = 0;
const per = Math.max(1, Math.round(RATE / 50));
while (sent < REASON) { w({ type: 'reasoning', text: 'thinking about step ' + (i++) + ' of the problem. '.repeat(1) }); sent += 9; if (sent % per < 9) await sleep(20); }
sent = 0; i = 0;
while (sent < TOK) { const p = para(i++); for (let k = 0; k < p.length; k += 24) { w({ type: 'delta', text: p.slice(k, k + 24) }); sent += 6; if ((sent / 6) % Math.max(1, Math.round(per / 2)) === 0) await sleep(10); } w({ type: 'stats', tokensOut: sent, tps: 90 }); }
w({ type: 'done', tokensIn: 100, tokensOut: sent, tps: 90, durationMs: 1000, usageSource: 'engine' });
return res.end();
}
const up = http.request({ host: '127.0.0.1', port: UP, path: req.url, method: req.method, headers: req.headers }, r => { res.writeHead(r.statusCode, r.headers); r.pipe(res); });
up.on('error', () => { res.writeHead(502); res.end(); });
req.pipe(up);
}).listen(PORT, '127.0.0.1', () => console.log('proxy on', PORT));
+57
View File
@@ -0,0 +1,57 @@
// Measures the chat page's heap, DOM size and main-thread time per streamed reply. Needs a throwaway host (STRATA_REAL_ENGINE=0, port 18791), scripts/chat-growth-proxy.mjs (port 18790) and Chrome.
import { chromium } from 'playwright-core';
import { deflateSync } from 'node:zlib';
import { randomBytes } from 'node:crypto';
import { execSync } from 'node:child_process';
const BASE = process.env.BASE ?? 'http://127.0.0.1:18790';
const TURNS = Number(process.env.TURNS ?? 6), PICS = Number(process.env.PICS ?? 3), MON = process.env.MON !== '0';
const crc = (() => { const t = new Int32Array(256).map((_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c; }); return b => { let c = -1; for (const x of b) c = t[(c ^ x) & 255] ^ (c >>> 8); return (c ^ -1) >>> 0; }; })();
function png(w, h) {
const chunk = (type, data) => { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const body = Buffer.concat([Buffer.from(type), data]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(body)); return Buffer.concat([len, body, c]); };
const raw = Buffer.alloc((w * 3 + 1) * h); for (let y = 0; y < h; y++) { raw[y * (w * 3 + 1)] = 0; randomBytes(w * 3).copy(raw, y * (w * 3 + 1) + 1); }
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw, { level: 1 })), chunk('IEND', Buffer.alloc(0))]);
}
const browser = await chromium.launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true, args: ['--enable-precise-memory-info', '--js-flags=--expose-gc'] });
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
const page = await ctx.newPage();
const cdp = await ctx.newCDPSession(page);
await cdp.send('Performance.enable');
await fetch(BASE + '/api/model-providers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'custom', provider: null, name: 'Mock model', baseUrl: 'http://127.0.0.1:18898/v1', protocol: 'openai-chat', apiKey: '', models: [{ id: 'mock-model', name: 'Mock model' }] }) });
await page.goto(BASE + '/'); await page.waitForTimeout(1500);
const skip = page.getByText('Go straight to the app'); await skip.waitFor({ timeout: 10000 }).catch(() => {}); if (await skip.count()) { await skip.click(); await page.waitForTimeout(800); }
async function metrics(tag) {
await cdp.send('HeapProfiler.collectGarbage').catch(() => {});
const m = Object.fromEntries((await cdp.send('Performance.getMetrics')).metrics.map(x => [x.name, x.value]));
const dom = await cdp.send('Memory.getDOMCounters').catch(() => ({}));
const sess = await page.evaluate(() => { try { return (localStorage.getItem('strata.sessions.v1') ?? '').length; } catch { return -1; } });
console.log(`${tag.padEnd(22)} heap=${(m.JSHeapUsedSize / 1048576).toFixed(0)}MB total=${(m.JSHeapTotalSize / 1048576).toFixed(0)}MB nodes=${dom.nodes ?? m.Nodes} listeners=${dom.jsEventListeners ?? '-'} docs=${m.Documents} ls=${(sess / 1e6).toFixed(2)}M`);
return m.JSHeapUsedSize;
}
await page.locator('.chat-model-picker').first().click(); await page.locator('.model-picker-api').first().click(); await page.waitForTimeout(500);
await metrics('start');
const ta = page.locator('.composer textarea');
for (let t = 1; t <= TURNS; t++) {
if (t <= PICS) {
const b64 = png(900, 900).toString('base64');
await page.evaluate(b64 => { const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0)); const d = new DataTransfer(); d.items.add(new File([bytes], 'image.png', { type: 'image/png' })); document.querySelector('.composer textarea').dispatchEvent(new ClipboardEvent('paste', { clipboardData: d, bubbles: true, cancelable: true })); }, b64);
await page.waitForTimeout(600);
}
await ta.fill('turn ' + t + ' write a long answer');
await page.getByRole('button', { name: 'Send' }).click();
const t0 = Date.now();
let peak = 0; const cpu0 = (await cdp.send('Performance.getMetrics')).metrics.find(x => x.name === 'TaskDuration').value;
for (;;) {
await page.waitForTimeout(2000);
const m = Object.fromEntries((await cdp.send('Performance.getMetrics')).metrics.map(x => [x.name, x.value]));
peak = Math.max(peak, m.JSHeapUsedSize);
const busy = await page.getByRole('button', { name: /Stop/ }).count();
if (!busy || Date.now() - t0 > 240000) break;
}
const cpu1 = (await cdp.send('Performance.getMetrics')).metrics.find(x => x.name === 'TaskDuration').value;
console.log(` turn ${t}: ${(Date.now() - t0) / 1000}s peak-heap=${(peak / 1048576).toFixed(0)}MB main-thread-busy=${(cpu1 - cpu0).toFixed(1)}s`);
await metrics('after turn ' + t);
}
if (MON) { await page.getByText('Monitor', { exact: true }).first().click().catch(() => console.log('no monitor link')); await page.waitForTimeout(25000); await metrics('monitor 25s'); }
try { console.log(execSync('powershell -NoProfile -c "(Get-Process chrome | Measure-Object WorkingSet64 -Sum).Sum/1MB"').toString().trim(), 'MB chrome working set total'); } catch {}
await browser.close();
+2 -4
View File
@@ -19,13 +19,11 @@ import { chromium } from 'playwright-core';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { requireChromium } from './browser.mjs';
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
const executablePath = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
].find((p) => existsSync(p));
const executablePath = requireChromium();
if (!executablePath) {
console.error('No cached Chromium found. Run `npm run shot -- x.png <url>` once to confirm the harness.');
+2 -4
View File
@@ -7,11 +7,9 @@ import { chromium } from 'playwright-core';
import { existsSync, mkdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { requireChromium } from './browser.mjs';
const executablePath = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
].find((p) => existsSync(p));
const executablePath = requireChromium();
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
const flow = process.argv[2] ?? 'chat';
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* LOAD-PICS end to end: the Models page of a running host, driven in a real Chrome (DevTools protocol, no packages), pressing "Load model" on a model that is
* downloaded, with Read pictures on and the picture files not on the computer.
*
* HOST_URL=http://127.0.0.1:18700 CHROME=/usr/bin/google-chrome OUT=/tmp/lp-out node scripts/load-pictures-e2e.mjs
*
* It looks at the page, presses the button, and writes down once per half second what the page shows (the progress card's words and bar, any dialog, the job's kind
* and state), until the job ends or LIMIT_S passes. It writes <OUT>/timeline.json and screenshots; the files on disk are checked by the shell script around it.
*
* Environment: HOST_URL (required), CHROME (default /usr/bin/google-chrome), OUT (default ./lp-out), LIMIT_S (default 900), CDP_PORT (default 9333),
* MODEL (the version's name on the page, default "High quality"), BUTTON (default "Load model").
*/
import { spawn } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const HOST = process.env.HOST_URL;
if (!HOST) { console.error('HOST_URL is required'); process.exit(2); }
const CHROME = process.env.CHROME ?? '/usr/bin/google-chrome';
const OUT = process.env.OUT ?? './lp-out';
const LIMIT = Number(process.env.LIMIT_S ?? 900) * 1000;
const PORT = Number(process.env.CDP_PORT ?? 9333);
const BUTTON = process.env.BUTTON ?? 'Load model';
mkdirSync(OUT, { recursive: true });
const sleep = ms => new Promise(r => setTimeout(r, ms));
const profile = mkdtempSync(join(tmpdir(), 'lp-chrome-'));
const chrome = spawn(CHROME, ['--headless=new', '--no-sandbox', '--disable-gpu', `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, '--window-size=1400,1100', 'about:blank'], { stdio: 'ignore' });
const finish = code => { try { chrome.kill('SIGTERM'); } catch { /* gone */ } setTimeout(() => { try { rmSync(profile, { recursive: true, force: true }); } catch { /* best effort */ } process.exit(code); }, 800); };
process.on('SIGINT', () => finish(130)); process.on('SIGTERM', () => finish(143));
async function target() {
for (let i = 0; i < 80; i++) {
try { const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json(); const page = list.find(t => t.type === 'page'); if (page) return page.webSocketDebuggerUrl; } catch { /* not up yet */ }
await sleep(250);
}
throw new Error('Chrome did not start');
}
const ws = new WebSocket(await target());
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = () => reject(new Error('DevTools socket')); });
let seq = 0; const waiting = new Map();
ws.onmessage = event => { const message = JSON.parse(event.data); const w = waiting.get(message.id); if (w) { waiting.delete(message.id); message.error ? w.reject(new Error(message.error.message)) : w.resolve(message.result); } };
const send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++seq; waiting.set(id, { resolve, reject }); ws.send(JSON.stringify({ id, method, params })); });
const run = async expression => { const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ' ' + (r.exceptionDetails.exception?.description ?? '')); return r.result.value; };
const shot = async name => { const r = await send('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(OUT, `${name}.png`), Buffer.from(r.data, 'base64')); };
try {
await send('Page.enable');
await send('Page.navigate', { url: `${HOST}/#/models` });
// the page is up when the Load button is on it and enabled
const ready = `(() => { const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(BUTTON)}); return !!b && !b.disabled; })()`;
let up = false;
for (let i = 0; i < 240 && !up; i++) { up = await run(ready).catch(() => false); if (!up) await sleep(500); }
if (!up) { await shot('never-ready'); throw new Error(`The "${BUTTON}" button never came up`); }
const before = await run(`(() => { const t = document.body.innerText; const box = document.querySelector('.native-image-reading input[type=checkbox]'); return { readPicturesOn: !!box && box.checked, offersFiles: t.includes('Picture reading needs a few more files'), downloadButton: [...document.querySelectorAll('button')].some(b => b.textContent.trim() === 'Download picture-reading files'), modelFiles: document.querySelector('.native-path')?.textContent ?? null, shownModel: document.querySelector('.native-version')?.innerText?.slice(0, 200) ?? null }; })()`);
await shot('1-before');
const baseDialogs = await run("document.querySelectorAll('[role=dialog], dialog[open]').length"); // dialogs the page always has (closed ones the app keeps mounted)
const clicked = await run(`(() => { const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(BUTTON)}); b.click(); return true; })()`);
const started = Date.now();
const timeline = []; let last = '', shots = 0, ended = null;
while (Date.now() - started < LIMIT) {
const seen = await run(`(async () => {
const card = document.querySelector('.native-progress');
const state = await (await fetch('/api/native')).json();
const job = [...(state.jobs ?? [])].reverse()[0] ?? null;
return { card: card ? card.innerText.replace(/\\s+/g, ' ').trim() : null, dialogs: document.querySelectorAll('[role=dialog], dialog[open]').length - ${baseDialogs},
job: job && { startedAt: job.startedAt, kind: job.kind, state: job.state, summary: job.summary, error: job.error, details: job.details ?? null, bar: job.result?.install?.download ?? null },
instance: (state.instances ?? []).map(i => ({ status: i.status, vision: i.vision, note: i.picturesNote ?? null, gpus: i.gpus })),
note: [...document.querySelectorAll('.native-spill-note')].map(n => n.innerText.trim()), error: [...document.querySelectorAll('.inline-error, [role=alert]')].map(n => n.innerText.trim()).filter(Boolean) };
})()`);
const key = JSON.stringify([seen.card, seen.dialogs, seen.job?.kind, seen.job?.state, seen.job?.summary, seen.instance, seen.note, seen.error, Math.floor((seen.job?.bar?.percent ?? 0) / 25)]);
if (key !== last) { last = key; timeline.push({ t: +((Date.now() - started) / 1000).toFixed(1), ...seen }); if (seen.card && shots < 2 && seen.job?.state === 'running') await shot(`2-running-${++shots}`); }
if (seen.job && Date.parse(seen.job.startedAt) >= started - 2000 && seen.job.state !== 'running') { ended = seen.job.state; break; }
await sleep(500);
}
await sleep(1500);
await shot('3-after');
const after = await run(`(() => ({ text: [...document.querySelectorAll('.native-spill-note, .native-ready, .toast, [role=status]')].map(n => n.innerText.trim()).filter(Boolean), buttons: [...document.querySelectorAll('.native-model-actions button')].map(b => b.textContent.trim()), pictureOffer: document.body.innerText.includes('Picture reading needs a few more files') }))()`);
const posted = await run("fetch('/__fixture/posted').then(r => r.ok ? r.json() : null).catch(() => null)"); // only the UI-preview fixture keeps what the page posted
writeFileSync(join(OUT, 'timeline.json'), JSON.stringify({ clicked, posted, before, ended, seconds: +((Date.now() - started) / 1000).toFixed(1), timeline, after }, null, 1));
console.log(JSON.stringify({ ended, seconds: +((Date.now() - started) / 1000).toFixed(1), steps: timeline.length, before, after }));
finish(ended === 'done' ? 0 : 1);
} catch (error) {
console.error(error.message);
writeFileSync(join(OUT, 'error.txt'), String(error.stack ?? error));
finish(2);
}
+2 -4
View File
@@ -25,6 +25,7 @@ import { chromium } from 'playwright-core';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { requireChromium } from './browser.mjs';
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
/** Anything at or above this projected area is "large". Roughly a 64×64 block. */
@@ -43,10 +44,7 @@ const MIN_THICKNESS = 32;
/** Ceiling for the relative luminance of a large surface, in dark mode. */
const CEILING = 0.15;
const executablePath = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
].find((p) => existsSync(p));
const executablePath = requireChromium();
if (!executablePath) {
console.error('No cached Chromium found.');
+197
View File
@@ -0,0 +1,197 @@
"""Writes src/locales/df2-<lang>.json: the strings of the Drafting choice that came with the DFlash2 option (branch f-0291-df2-option): the plain-words
paragraph under the dropdown, the three lines about the person's graphics card, the "not on this card" and "no internet" lines, and the SSD line of the
This computer panel.
Run from apps/host: python scripts/make-df2-locales.py
Every entry names all nine languages; the keys are df01.. in the order of S. df2-en.json is the English source. Placeholders ({0}) must be kept in every
language. A separate file keeps this work from colliding with the other catalogs (the same reason pick-, tune-, tools-, fix-, gh-, ui- and engine- exist).
The English lines of df03-df07 and df09 are also written in strata_control/drafting.py and strata_control/drafting_fetch.py; tests/drafting.test.mjs keeps the two the same."""
import json
import re
from pathlib import Path
LANGS = ["en", "zh", "ja", "de", "es", "ru", "pt", "ko", "fr"]
S = []
def add(en, zh, ja, de, es, ru, pt, ko, fr):
S.append(dict(en=en, zh=zh, ja=ja, de=de, es=es, ru=ru, pt=pt, ko=ko, fr=fr))
add("MTP is the default. DFlash2 is another way to guess the next words ahead, for Qwen3.8-Flash-Next only. On an RTX PRO 5000 it was 7.8% faster than MTP "
"on 19 short prompts and 1.1% faster on long thinking, and faster on 21 of 23 prompts. On the short prompts the answers were identical.",
"默认使用 MTP。DFlash2 是另一种提前猜测后续词语的方式,仅适用于 Qwen3.8-Flash-Next。在 RTX PRO 5000 上,它在 19 个短提示上比 MTP 快 7.8%,在长篇思考上快 1.1%,"
"并且在 23 个提示中有 21 个更快。在短提示上,回答完全相同。",
"既定では MTP を使います。DFlash2 は次の語を先読みするもう一つの方法で、Qwen3.8-Flash-Next 専用です。RTX PRO 5000 では、短いプロンプト 19 件で MTP より 7.8% 速く、"
"長い思考では 1.1% 速く、23 件中 21 件で速くなりました。短いプロンプトでは回答が同一でした。",
"MTP ist die Voreinstellung. DFlash2 ist eine andere Art, die nächsten Wörter vorauszuraten, nur für Qwen3.8-Flash-Next. Auf einer RTX PRO 5000 war es bei 19 kurzen "
"Prompts 7,8 % schneller als MTP, bei langem Nachdenken 1,1 % schneller und bei 21 von 23 Prompts schneller. Bei den kurzen Prompts waren die Antworten identisch.",
"MTP es la opción predeterminada. DFlash2 es otra forma de adivinar las siguientes palabras por adelantado, solo para Qwen3.8-Flash-Next. En una RTX PRO 5000 fue un 7,8 % "
"más rápido que MTP en 19 prompts cortos y un 1,1 % más rápido en razonamientos largos, y más rápido en 21 de 23 prompts. En los prompts cortos las respuestas fueron idénticas.",
"По умолчанию используется MTP. DFlash2 — другой способ угадывать следующие слова наперёд, только для Qwen3.8-Flash-Next. На RTX PRO 5000 он был на 7,8 % быстрее MTP "
"на 19 коротких запросах и на 1,1 % быстрее на длинных рассуждениях, а на 21 из 23 запросов — быстрее. На коротких запросах ответы были идентичны.",
"MTP é o padrão. DFlash2 é outra forma de adivinhar as próximas palavras antecipadamente, só para o Qwen3.8-Flash-Next. Numa RTX PRO 5000 foi 7,8 % mais rápido que o MTP "
"em 19 prompts curtos e 1,1 % mais rápido em raciocínios longos, e mais rápido em 21 de 23 prompts. Nos prompts curtos as respostas foram idênticas.",
"기본값은 MTP입니다. DFlash2는 다음 단어를 미리 추측하는 또 다른 방식이며 Qwen3.8-Flash-Next 전용입니다. RTX PRO 5000에서 짧은 프롬프트 19개에서는 MTP보다 7.8% 빨랐고, "
"긴 사고 과정에서는 1.1% 빨랐으며, 23개 중 21개 프롬프트에서 더 빨랐습니다. 짧은 프롬프트에서는 답변이 동일했습니다.",
"MTP est le réglage par défaut. DFlash2 est une autre façon de deviner à l'avance les mots suivants, uniquement pour Qwen3.8-Flash-Next. Sur une RTX PRO 5000, il a été 7,8 % "
"plus rapide que MTP sur 19 prompts courts et 1,1 % plus rapide en réflexion longue, et plus rapide sur 21 prompts sur 23. Sur les prompts courts, les réponses étaient identiques.")
add("It downloads about 1.2 GB once, works with one graphics card and one request at a time, and uses MTP otherwise.",
"它会一次性下载约 1.2 GB,仅在使用一张显卡且一次处理一个请求时有效,其他情况下使用 MTP。",
"約 1.2 GB を一度だけダウンロードします。グラフィックスカード 1 枚で一度に 1 件のリクエストを処理するときに動作し、それ以外では MTP を使います。",
"Es lädt einmalig etwa 1,2 GB herunter, funktioniert mit einer Grafikkarte und einer Anfrage zur Zeit und nutzt sonst MTP.",
"Descarga unos 1,2 GB una sola vez, funciona con una tarjeta gráfica y una solicitud a la vez, y en los demás casos usa MTP.",
"Один раз скачивает около 1,2 ГБ, работает с одной видеокартой и одним запросом за раз, в остальных случаях используется MTP.",
"Baixa cerca de 1,2 GB uma única vez, funciona com uma placa de vídeo e um pedido de cada vez, e nos outros casos usa o MTP.",
"약 1.2 GB를 한 번만 내려받으며, 그래픽 카드 한 장과 한 번에 하나의 요청에서 작동하고, 그 외에는 MTP를 사용합니다.",
"Il télécharge environ 1,2 Go une seule fois, fonctionne avec une carte graphique et une requête à la fois, et utilise MTP sinon.")
add("On graphics cards like yours, DFlash2 was faster than MTP on some kinds of text and slower on others in our tests.",
"在与您类似的显卡上,我们的测试中 DFlash2 在某些类型的文本上比 MTP 快,在另一些上更慢。",
"お使いのようなグラフィックスカードでは、私たちのテストで DFlash2 は MTP より速い種類の文章と遅い種類の文章がありました。",
"Auf Grafikkarten wie Ihrer war DFlash2 in unseren Tests bei manchen Textarten schneller als MTP und bei anderen langsamer.",
"En tarjetas gráficas como la suya, en nuestras pruebas DFlash2 fue más rápido que MTP con algunos tipos de texto y más lento con otros.",
"На видеокартах, подобных вашей, в наших тестах DFlash2 был быстрее MTP на одних видах текста и медленнее на других.",
"Em placas de vídeo como a sua, nos nossos testes o DFlash2 foi mais rápido que o MTP em alguns tipos de texto e mais lento em outros.",
"고객님과 비슷한 그래픽 카드에서 저희 테스트 결과 DFlash2는 일부 유형의 글에서는 MTP보다 빨랐고 다른 유형에서는 더 느렸습니다.",
"Sur des cartes graphiques comme la vôtre, DFlash2 a été plus rapide que MTP sur certains types de texte et plus lent sur d'autres lors de nos tests.")
add("On graphics cards like yours, DFlash2 was slower than MTP in our tests.",
"在与您类似的显卡上,我们的测试中 DFlash2 比 MTP 更慢。",
"お使いのようなグラフィックスカードでは、私たちのテストで DFlash2 は MTP より遅くなりました。",
"Auf Grafikkarten wie Ihrer war DFlash2 in unseren Tests langsamer als MTP.",
"En tarjetas gráficas como la suya, en nuestras pruebas DFlash2 fue más lento que MTP.",
"На видеокартах, подобных вашей, в наших тестах DFlash2 был медленнее MTP.",
"Em placas de vídeo como a sua, nos nossos testes o DFlash2 foi mais lento que o MTP.",
"고객님과 비슷한 그래픽 카드에서 저희 테스트 결과 DFlash2는 MTP보다 느렸습니다.",
"Sur des cartes graphiques comme la vôtre, DFlash2 a été plus lent que MTP lors de nos tests.")
add("DFlash2 has not been tested on your graphics card yet.",
"DFlash2 尚未在您的显卡上测试过。",
"DFlash2 はまだお使いのグラフィックスカードでテストされていません。",
"DFlash2 wurde auf Ihrer Grafikkarte noch nicht getestet.",
"DFlash2 todavía no se ha probado en su tarjeta gráfica.",
"DFlash2 ещё не проверялся на вашей видеокарте.",
"O DFlash2 ainda não foi testado na sua placa de vídeo.",
"DFlash2는 아직 고객님의 그래픽 카드에서 테스트되지 않았습니다.",
"DFlash2 n'a pas encore été testé sur votre carte graphique.")
add("Drafting: MTP (DFlash2 isn't available on this graphics card)",
"预测方式:MTP(DFlash2 不适用于这张显卡)",
"先読み:MTP(DFlash2 はこのグラフィックスカードでは使えません)",
"Vorausraten: MTP (DFlash2 ist auf dieser Grafikkarte nicht verfügbar)",
"Predicción: MTP (DFlash2 no está disponible en esta tarjeta gráfica)",
"Упреждающая генерация: MTP (DFlash2 недоступен на этой видеокарте)",
"Previsão: MTP (o DFlash2 não está disponível nesta placa de vídeo)",
"미리 추측: MTP (이 그래픽 카드에서는 DFlash2를 사용할 수 없습니다)",
"Anticipation : MTP (DFlash2 n'est pas disponible sur cette carte graphique)")
add("There is no internet connection, so the DFlash2 drafter can't be downloaded now.",
"没有网络连接,因此现在无法下载 DFlash2 草稿模型。",
"インターネットに接続されていないため、今は DFlash2 のドラフターをダウンロードできません。",
"Es besteht keine Internetverbindung, deshalb kann der DFlash2-Drafter jetzt nicht heruntergeladen werden.",
"No hay conexión a internet, así que ahora no se puede descargar el borrador de DFlash2.",
"Нет подключения к интернету, поэтому сейчас нельзя скачать черновую модель DFlash2.",
"Não há ligação à internet, por isso não é possível transferir agora o rascunhador do DFlash2.",
"인터넷에 연결되어 있지 않아 지금은 DFlash2 드래프터를 내려받을 수 없습니다.",
"Il n'y a pas de connexion internet, le drafter DFlash2 ne peut donc pas être téléchargé pour le moment.")
add("SSD: not needed",
"SSD:不需要", "SSD:不要", "SSD: nicht nötig", "SSD: no hace falta", "SSD: не нужен", "SSD: não é necessário", "SSD: 필요 없음", "SSD : pas nécessaire")
add("DFlash2 answers one request at a time, so Strata set this model to one request at a time.",
"DFlash2 一次只处理一个请求,因此 Strata 将此模型设置为一次一个请求。",
"DFlash2 は一度に 1 件のリクエストしか処理しないため、Strata はこのモデルを一度に 1 件に設定しました。",
"DFlash2 beantwortet eine Anfrage nach der anderen, deshalb hat Strata dieses Modell auf eine Anfrage zur Zeit gestellt.",
"DFlash2 responde una solicitud a la vez, así que Strata configuró este modelo para una solicitud a la vez.",
"DFlash2 отвечает на один запрос за раз, поэтому Strata настроила эту модель на один запрос за раз.",
"O DFlash2 responde a um pedido de cada vez, por isso o Strata configurou este modelo para um pedido de cada vez.",
"DFlash2는 한 번에 하나의 요청만 처리하므로 Strata가 이 모델을 한 번에 하나의 요청으로 설정했습니다.",
"DFlash2 répond à une requête à la fois, Strata a donc réglé ce modèle sur une requête à la fois.")
add("Drafting: off (no drafter is downloaded yet)",
"预测草稿:关闭(尚未下载任何草稿模型)",
"先読み:オフ(ドラフトモデルがまだダウンロードされていません)",
"Vorausraten: aus (noch kein Entwurfsbaustein heruntergeladen)",
"Predicción: desactivada (aún no hay ningún predictor descargado)",
"Предсказание: выключено (черновая модель ещё не скачана)",
"Previsão: desativada (nenhum preditor foi baixado ainda)",
"초안 예측: 꺼짐(초안 모델이 아직 내려받아지지 않았습니다)",
"Anticipation : désactivée (aucun module d'anticipation n'est encore téléchargé)")
add("Drafting: DFlash2 (the MTP drafter isn't downloaded yet)",
"预测草稿:DFlash2(MTP 草稿模型尚未下载)",
"先読み:DFlash2(MTP のドラフトモデルはまだダウンロードされていません)",
"Vorausraten: DFlash2 (der MTP-Entwurfsbaustein ist noch nicht heruntergeladen)",
"Predicción: DFlash2 (el predictor de MTP aún no está descargado)",
"Предсказание: DFlash2 (черновая модель MTP ещё не скачана)",
"Previsão: DFlash2 (o preditor do MTP ainda não foi baixado)",
"초안 예측: DFlash2(MTP 초안 모델이 아직 내려받아지지 않았습니다)",
"Anticipation : DFlash2 (le module d'anticipation MTP n'est pas encore téléchargé)")
add("Choosing DFlash2 downloads its drafter: about {0} GB, once.",
"选择 DFlash2 会下载它的草稿模型:约 {0} GB,只下载一次。",
"DFlash2 を選ぶと、そのドラフトモデルを一度だけダウンロードします(約 {0} GB)。",
"Wenn Sie DFlash2 wählen, wird sein Entwurfsbaustein heruntergeladen: etwa {0} GB, einmalig.",
"Elegir DFlash2 descarga su predictor: unos {0} GB, una sola vez.",
"Если выбрать DFlash2, будет скачана его черновая модель: около {0} ГБ, один раз.",
"Escolher o DFlash2 baixa o preditor dele: cerca de {0} GB, uma vez.",
"DFlash2를 고르면 그 초안 모델을 한 번 내려받습니다(약 {0}GB).",
"Choisir DFlash2 télécharge son module d'anticipation : environ {0} Go, une seule fois.")
add("Choosing MTP downloads its drafter: about {0} GB, once.",
"选择 MTP 会下载它的草稿模型:约 {0} GB,只下载一次。",
"MTP を選ぶと、そのドラフトモデルを一度だけダウンロードします(約 {0} GB)。",
"Wenn Sie MTP wählen, wird sein Entwurfsbaustein heruntergeladen: etwa {0} GB, einmalig.",
"Elegir MTP descarga su predictor: unos {0} GB, una sola vez.",
"Если выбрать MTP, будет скачана его черновая модель: около {0} ГБ, один раз.",
"Escolher o MTP baixa o preditor dele: cerca de {0} GB, uma vez.",
"MTP를 고르면 그 초안 모델을 한 번 내려받습니다(약 {0}GB).",
"Choisir MTP télécharge son module d'anticipation : environ {0} Go, une seule fois.")
add("The DFlash2 drafter could not be downloaded, so the model starts without a drafter. Loading the model…",
"无法下载 DFlash2 草稿模型,因此模型将不使用草稿模型启动。正在加载模型…",
"DFlash2 のドラフトモデルをダウンロードできなかったため、ドラフトモデルなしで起動します。モデルを読み込んでいます…",
"Der DFlash2-Entwurfsbaustein konnte nicht heruntergeladen werden, deshalb startet das Modell ohne Entwurfsbaustein. Das Modell wird geladen …",
"No se pudo descargar el predictor de DFlash2, así que el modelo arranca sin predictor. Cargando el modelo…",
"Не удалось скачать черновую модель DFlash2, поэтому модель запускается без черновой модели. Загружается модель…",
"Não foi possível baixar o preditor do DFlash2, então o modelo inicia sem preditor. Carregando o modelo…",
"DFlash2 초안 모델을 내려받지 못해 초안 모델 없이 시작합니다. 모델을 불러오는 중…",
"Le module d'anticipation DFlash2 n'a pas pu être téléchargé, le modèle démarre donc sans module d'anticipation. Chargement du modèle…")
add("Downloading the MTP drafter…",
"正在下载 MTP 草稿模型…",
"MTP のドラフトモデルをダウンロードしています…",
"MTP-Entwurfsbaustein wird heruntergeladen …",
"Descargando el predictor de MTP…",
"Скачивается черновая модель MTP…",
"Baixando o preditor do MTP…",
"MTP 초안 모델을 내려받는 중…",
"Téléchargement du module d'anticipation MTP…")
add("The MTP drafter could not be downloaded.",
"无法下载 MTP 草稿模型。",
"MTP のドラフトモデルをダウンロードできませんでした。",
"Der MTP-Entwurfsbaustein konnte nicht heruntergeladen werden.",
"No se pudo descargar el predictor de MTP.",
"Не удалось скачать черновую модель MTP.",
"Não foi possível baixar o preditor do MTP.",
"MTP 초안 모델을 내려받지 못했습니다.",
"Le module d'anticipation MTP n'a pas pu être téléchargé.")
add("Drafting: MTP (DFlash2 didn't start)",
"预测草稿:MTP(DFlash2 未能启动)",
"先読み:MTP(DFlash2 は起動しませんでした)",
"Vorausraten: MTP (DFlash2 ist nicht gestartet)",
"Predicción: MTP (DFlash2 no arrancó)",
"Предсказание: MTP (DFlash2 не запустился)",
"Previsão: MTP (o DFlash2 não iniciou)",
"초안 예측: MTP(DFlash2가 시작되지 않았습니다)",
"Anticipation : MTP (DFlash2 n'a pas démarré)")
here = Path(__file__).resolve().parent.parent / "src" / "locales"
NBSP = " "
def french(text):
text = re.sub(r"(?<=[A-Za-zÀ-ſ])'(?=[A-Za-zÀ-ſ{])", "’", text)
text = re.sub(r"(?<=\S) ([:;?!])", NBSP + r"\1", text)
text = re.sub(r"(?<=[\d}]) %", NBSP + "%", text)
return text
for lang in LANGS:
data = {}
for i, entry in enumerate(S):
text = entry[lang]
assert sorted(re.findall(r"\{\d+\}", entry["en"])) == sorted(re.findall(r"\{\d+\}", text)), (lang, entry["en"], text)
data[f"df{i + 1:02d}"] = french(text) if lang == "fr" else text
with (here / f"df2-{lang}.json").open("w", encoding="utf-8", newline="\n") as handle:
handle.write(json.dumps(data, ensure_ascii=False, indent=1) + "\n")
print("written", len(S), "strings in", len(LANGS), "languages")
+226
View File
@@ -0,0 +1,226 @@
"""Writes src/locales/engine-<lang>.json: the strings of Settings > Engine (the list of installed engines: switch, delete) and the words that now point to
About for updating (branch f-0291-engine-tab). Run from apps/host: python scripts/make-engine-locales.py
Every entry names all nine languages; the keys are et01.. in the order of S. engine-en.json is the English source. Placeholders ({0}, {1}) must be kept in
every language. A separate file keeps this work from colliding with the other catalogs (the same reason tune-, tools-, fix-, gh- and ui- exist)."""
import json
import re
from pathlib import Path
LANGS = ["en", "zh", "ja", "de", "es", "ru", "pt", "ko", "fr"]
S = []
def add(en, zh, ja, de, es, ru, pt, ko, fr):
S.append(dict(en=en, zh=zh, ja=ja, de=de, es=es, ru=ru, pt=pt, ko=ko, fr=fr))
# ---- the page
add("Installed engines",
"已安装的引擎", "インストール済みのエンジン", "Installierte Engines", "Motores instalados", "Установленные движки", "Motores instalados", "설치된 엔진", "Moteurs installés")
add("Switch to an older engine to test something, then switch back to the newest. Updating to the latest is in About.",
"切换到较旧的引擎来测试某些内容,然后再切换回最新的。更新到最新版本请前往“关于”。",
"古いエンジンに切り替えて試し、終わったら最新のエンジンに戻せます。最新版への更新は「情報」で行います。",
"Wechsle zu einer älteren Engine, um etwas zu testen, und danach zurück zur neuesten. Das Update auf die neueste Version findest du unter „Über“.",
"Cambia a un motor anterior para probar algo y vuelve después al más reciente. La actualización a la última versión está en «Acerca de».",
"Переключитесь на более старый движок, чтобы что-то проверить, а потом вернитесь на самый новый. Обновление до последней версии — в разделе «О программе».",
"Troque para um motor mais antigo para testar algo e depois volte ao mais recente. A atualização para a versão mais recente fica em «Sobre».",
"이전 엔진으로 바꿔 테스트한 뒤 다시 최신 엔진으로 돌아올 수 있습니다. 최신 버전으로의 업데이트는 '정보'에서 합니다.",
"Passez à un moteur plus ancien pour tester quelque chose, puis revenez au plus récent. La mise à jour vers la dernière version se trouve dans « À propos ».")
add("Engines can be switched and deleted in the installed Strata app.",
"可以在已安装的 Strata 应用中切换和删除引擎。", "エンジンの切り替えと削除は、インストール済みの Strata アプリで行えます。",
"Engines lassen sich in der installierten Strata-App wechseln und löschen.", "Los motores se pueden cambiar y eliminar en la aplicación Strata instalada.",
"Переключать и удалять движки можно в установленном приложении Strata.", "Os motores podem ser trocados e excluídos no aplicativo Strata instalado.",
"엔진 전환과 삭제는 설치된 Strata 앱에서 할 수 있습니다.", "Les moteurs peuvent être changés et supprimés dans l'application Strata installée.")
add("The installed engines couldn't be read. Try again in a moment.",
"无法读取已安装的引擎。请稍后重试。", "インストール済みのエンジンを読み取れませんでした。しばらくしてからもう一度お試しください。",
"Die installierten Engines konnten nicht gelesen werden. Versuche es gleich noch einmal.", "No se pudieron leer los motores instalados. Inténtalo de nuevo en un momento.",
"Не удалось прочитать список установленных движков. Попробуйте ещё раз через минуту.", "Não foi possível ler os motores instalados. Tente novamente em instantes.",
"설치된 엔진을 읽지 못했습니다. 잠시 후 다시 시도하세요.", "Les moteurs installés n'ont pas pu être lus. Réessayez dans un instant.")
add("No engine is installed on this computer yet. Update in About downloads the right one.",
"此电脑上还没有安装引擎。在“关于”中点击更新即可下载合适的引擎。", "このコンピューターにはまだエンジンがありません。「情報」で更新すると、合ったものがダウンロードされます。",
"Auf diesem Computer ist noch keine Engine installiert. Das Update unter „Über“ lädt die passende herunter.",
"Todavía no hay ningún motor instalado en este equipo. Actualizar en «Acerca de» descarga el adecuado.",
"На этом компьютере ещё не установлен движок. Обновление в разделе «О программе» скачает подходящий.",
"Ainda não há nenhum motor instalado neste computador. Atualizar em «Sobre» baixa o adequado.",
"이 컴퓨터에는 아직 설치된 엔진이 없습니다. '정보'에서 업데이트하면 알맞은 엔진을 내려받습니다.",
"Aucun moteur n'est encore installé sur cet ordinateur. La mise à jour dans « À propos » télécharge le bon.")
add("Engines take {0} on this computer.",
"引擎在此电脑上共占用 {0}。", "エンジンはこのコンピューターで {0} を使っています。", "Die Engines belegen auf diesem Computer {0}.", "Los motores ocupan {0} en este equipo.",
"Движки занимают на этом компьютере {0}.", "Os motores ocupam {0} neste computador.", "엔진이 이 컴퓨터에서 {0}을(를) 차지합니다.", "Les moteurs occupent {0} sur cet ordinateur.")
add("Newest", "最新", "最新", "Neueste", "Más reciente", "Новейший", "Mais recente", "최신", "Le plus récent")
add("Use this", "使用此引擎", "これを使う", "Diese verwenden", "Usar este", "Использовать", "Usar este", "이 엔진 사용", "Utiliser celui-ci")
add("Qwen and other models",
"Qwen 及其他模型", "Qwen とその他のモデル", "Qwen und andere Modelle", "Qwen y otros modelos", "Qwen и другие модели", "Qwen e outros modelos", "Qwen 및 기타 모델", "Qwen et autres modèles")
add("A running program is using this engine. Unload your models first.",
"有程序正在使用此引擎。请先卸载你的模型。", "実行中のプログラムがこのエンジンを使っています。先にモデルをアンロードしてください。",
"Ein laufendes Programm benutzt diese Engine. Entlade zuerst deine Modelle.", "Un programa en ejecución está usando este motor. Descarga primero tus modelos.",
"Этот движок используется работающей программой. Сначала выгрузите свои модели.", "Um programa em execução está usando este motor. Descarregue seus modelos primeiro.",
"실행 중인 프로그램이 이 엔진을 사용하고 있습니다. 먼저 모델을 언로드하세요.", "Un programme en cours utilise ce moteur. Déchargez d'abord vos modèles.")
add("Engine {0} is now in use.",
"现在使用引擎 {0}。", "エンジン {0} を使用中です。", "Engine {0} wird jetzt verwendet.", "Ahora se usa el motor {0}.", "Теперь используется движок {0}.", "O motor {0} está em uso agora.",
"이제 엔진 {0}을(를) 사용합니다.", "Le moteur {0} est maintenant utilisé.")
add("Engine {0} was deleted.",
"引擎 {0} 已删除。", "エンジン {0} を削除しました。", "Engine {0} wurde gelöscht.", "Se eliminó el motor {0}.", "Движок {0} удалён.", "O motor {0} foi excluído.",
"엔진 {0}을(를) 삭제했습니다.", "Le moteur {0} a été supprimé.")
add("Use engine {0}?",
"使用引擎 {0}?", "エンジン {0} を使いますか?", "Engine {0} verwenden?", "¿Usar el motor {0}?", "Использовать движок {0}?", "Usar o motor {0}?", "엔진 {0}을(를) 사용할까요?", "Utiliser le moteur {0} ?")
add("Use engine {0}",
"使用引擎 {0}", "エンジン {0} を使う", "Engine {0} verwenden", "Usar el motor {0}", "Использовать движок {0}", "Usar o motor {0}", "엔진 {0} 사용", "Utiliser le moteur {0}")
add("Models you start from now on run on engine {0}. Your models and settings stay as they are. You can switch back at any time.",
"从现在起启动的模型将使用引擎 {0}。你的模型和设置保持不变。你可以随时切换回来。",
"これから起動するモデルはエンジン {0} で動きます。モデルと設定はそのままです。いつでも元に戻せます。",
"Modelle, die du ab jetzt startest, laufen mit Engine {0}. Deine Modelle und Einstellungen bleiben unverändert. Du kannst jederzeit zurückwechseln.",
"Los modelos que inicies a partir de ahora usarán el motor {0}. Tus modelos y ajustes no cambian. Puedes volver cuando quieras.",
"Модели, которые вы запустите с этого момента, будут работать на движке {0}. Ваши модели и настройки останутся как есть. Вернуться можно в любой момент.",
"Os modelos que você iniciar a partir de agora usarão o motor {0}. Seus modelos e ajustes continuam como estão. Você pode voltar quando quiser.",
"지금부터 시작하는 모델은 엔진 {0}에서 실행됩니다. 모델과 설정은 그대로 유지됩니다. 언제든 다시 바꿀 수 있습니다.",
"Les modèles que vous démarrez à partir de maintenant utilisent le moteur {0}. Vos modèles et réglages restent inchangés. Vous pouvez revenir en arrière à tout moment.")
add("These models need a newer engine and may not start on it: {0}.",
"这些模型需要更新的引擎,在此引擎上可能无法启动:{0}。", "次のモデルはより新しいエンジンが必要で、このエンジンでは起動しない可能性があります:{0}。",
"Diese Modelle brauchen eine neuere Engine und starten damit eventuell nicht: {0}.", "Estos modelos necesitan un motor más reciente y puede que no arranquen con este: {0}.",
"Этим моделям нужен более новый движок, и на этом они могут не запуститься: {0}.", "Estes modelos precisam de um motor mais novo e podem não iniciar com este: {0}.",
"다음 모델은 더 새로운 엔진이 필요해 이 엔진에서는 시작되지 않을 수 있습니다: {0}.", "Ces modèles demandent un moteur plus récent et pourraient ne pas démarrer avec celui-ci : {0}.")
add("A model is running. Unload it in Models first, then switch.",
"有模型正在运行。请先在“模型”中卸载它,然后再切换。", "モデルが実行中です。先に「モデル」でアンロードしてから切り替えてください。",
"Ein Modell läuft. Entlade es zuerst unter „Modelle“ und wechsle dann.", "Hay un modelo en ejecución. Descárgalo primero en «Modelos» y luego cambia.",
"Работает модель. Сначала выгрузите её в разделе «Модели», затем переключитесь.", "Há um modelo em execução. Descarregue-o primeiro em «Modelos» e depois troque.",
"모델이 실행 중입니다. 먼저 '모델'에서 언로드한 다음 전환하세요.", "Un modèle est en cours d'exécution. Déchargez-le d'abord dans « Modèles », puis changez de moteur.")
add("Delete engine {0}?",
"删除引擎 {0}?", "エンジン {0} を削除しますか?", "Engine {0} löschen?", "¿Eliminar el motor {0}?", "Удалить движок {0}?", "Excluir o motor {0}?", "엔진 {0}을(를) 삭제할까요?", "Supprimer le moteur {0} ?")
add("Keep engine", "保留引擎", "エンジンを残す", "Engine behalten", "Conservar motor", "Оставить движок", "Manter motor", "엔진 유지", "Garder le moteur")
add("Delete engine", "删除引擎", "エンジンを削除", "Engine löschen", "Eliminar motor", "Удалить движок", "Excluir motor", "엔진 삭제", "Supprimer le moteur")
add("This frees {0}. Your models and settings are kept.",
"这将释放 {0}。你的模型和设置会保留。", "{0} が空きます。モデルと設定はそのまま残ります。", "Das gibt {0} frei. Deine Modelle und Einstellungen bleiben erhalten.",
"Esto libera {0}. Tus modelos y ajustes se conservan.", "Это освободит {0}. Ваши модели и настройки сохранятся.", "Isso libera {0}. Seus modelos e ajustes são mantidos.",
"{0}의 공간이 확보됩니다. 모델과 설정은 그대로 유지됩니다.", "Cela libère {0}. Vos modèles et réglages sont conservés.")
add("The newest engine can be downloaded again with Update in About.",
"最新的引擎可以在“关于”中通过更新重新下载。", "最新のエンジンは、「情報」の更新でもう一度ダウンロードできます。",
"Die neueste Engine lässt sich über das Update unter „Über“ erneut herunterladen.", "El motor más reciente se puede volver a descargar con Actualizar en «Acerca de».",
"Новейший движок можно скачать снова через обновление в разделе «О программе».", "O motor mais recente pode ser baixado de novo com Atualizar em «Sobre».",
"최신 엔진은 '정보'의 업데이트로 다시 내려받을 수 있습니다.", "Le moteur le plus récent peut être retéléchargé avec la mise à jour dans « À propos ».")
add("Strata can only download the newest engine, so a deleted older engine can't be brought back.",
"Strata 只能下载最新的引擎,所以删除后无法找回较旧的引擎。", "Strata がダウンロードできるのは最新のエンジンだけなので、削除した古いエンジンは元に戻せません。",
"Strata kann nur die neueste Engine herunterladen. Eine gelöschte ältere Engine lässt sich deshalb nicht wiederherstellen.",
"Strata solo puede descargar el motor más reciente, así que un motor anterior eliminado no se puede recuperar.",
"Strata может скачать только новейший движок, поэтому удалённый старый движок вернуть нельзя.",
"O Strata só pode baixar o motor mais recente, então um motor antigo excluído não pode ser recuperado.",
"Strata는 최신 엔진만 내려받을 수 있으므로, 삭제한 이전 엔진은 되돌릴 수 없습니다.",
"Strata ne peut télécharger que le moteur le plus récent : un ancien moteur supprimé ne peut donc pas être récupéré.")
# ---- why an engine cannot be used on this computer
add("This engine was made for a different operating system.",
"此引擎是为其他操作系统制作的。", "このエンジンは別のオペレーティングシステム向けです。", "Diese Engine wurde für ein anderes Betriebssystem gebaut.",
"Este motor se creó para otro sistema operativo.", "Этот движок создан для другой операционной системы.", "Este motor foi feito para outro sistema operacional.",
"이 엔진은 다른 운영체제용입니다.", "Ce moteur a été conçu pour un autre système d'exploitation.")
add("This engine was made for a different kind of graphics card than this computer has.",
"此引擎是为与这台电脑不同类型的显卡制作的。", "このエンジンは、このコンピューターとは別の種類のグラフィックスカード向けです。",
"Diese Engine wurde für eine andere Art von Grafikkarte gebaut als die in diesem Computer.", "Este motor se creó para un tipo de tarjeta gráfica distinto del de este equipo.",
"Этот движок создан для другого типа видеокарты, чем в этом компьютере.", "Este motor foi feito para um tipo de placa de vídeo diferente da deste computador.",
"이 엔진은 이 컴퓨터와 다른 종류의 그래픽 카드용입니다.", "Ce moteur a été conçu pour un autre type de carte graphique que celle de cet ordinateur.")
add("This engine needs Strata {0} or newer.",
"此引擎需要 Strata {0} 或更新版本。", "このエンジンには Strata {0} 以降が必要です。", "Diese Engine braucht Strata {0} oder neuer.", "Este motor necesita Strata {0} o posterior.",
"Для этого движка нужна Strata {0} или новее.", "Este motor precisa do Strata {0} ou mais recente.", "이 엔진에는 Strata {0} 이상이 필요합니다.", "Ce moteur nécessite Strata {0} ou plus récent.")
add("This engine doesn't support this computer's graphics card.",
"此引擎不支持这台电脑的显卡。", "このエンジンは、このコンピューターのグラフィックスカードに対応していません。", "Diese Engine unterstützt die Grafikkarte dieses Computers nicht.",
"Este motor no admite la tarjeta gráfica de este equipo.", "Этот движок не поддерживает видеокарту этого компьютера.", "Este motor não oferece suporte à placa de vídeo deste computador.",
"이 엔진은 이 컴퓨터의 그래픽 카드를 지원하지 않습니다.", "Ce moteur ne prend pas en charge la carte graphique de cet ordinateur.")
add("This engine needs NVIDIA driver {0} or newer. This computer has {1}.",
"此引擎需要 NVIDIA 驱动 {0} 或更新版本。这台电脑的驱动是 {1}。", "このエンジンには NVIDIA ドライバー {0} 以降が必要です。このコンピューターは {1} です。",
"Diese Engine braucht den NVIDIA-Treiber {0} oder neuer. Dieser Computer hat {1}.", "Este motor necesita el controlador NVIDIA {0} o posterior. Este equipo tiene {1}.",
"Для этого движка нужен драйвер NVIDIA {0} или новее. На этом компьютере {1}.", "Este motor precisa do driver NVIDIA {0} ou mais recente. Este computador tem o {1}.",
"이 엔진에는 NVIDIA 드라이버 {0} 이상이 필요합니다. 이 컴퓨터는 {1}입니다.", "Ce moteur nécessite le pilote NVIDIA {0} ou plus récent. Cet ordinateur a le {1}.")
add("Some of this engine's files are missing or changed, so it can't be started.",
"此引擎的部分文件缺失或已被更改,因此无法启动。", "このエンジンのファイルの一部が見つからないか変更されているため、起動できません。",
"Einige Dateien dieser Engine fehlen oder wurden verändert, deshalb lässt sie sich nicht starten.", "Faltan archivos de este motor o han cambiado, así que no se puede iniciar.",
"Часть файлов этого движка отсутствует или изменена, поэтому его нельзя запустить.", "Alguns arquivos deste motor estão ausentes ou foram alterados, então ele não pode ser iniciado.",
"이 엔진의 일부 파일이 없거나 변경되어 시작할 수 없습니다.", "Certains fichiers de ce moteur sont absents ou modifiés : il ne peut pas démarrer.")
add("This engine's description is missing or damaged, so it can't be started.",
"此引擎的说明文件缺失或已损坏,因此无法启动。", "このエンジンの説明ファイルが見つからないか壊れているため、起動できません。",
"Die Beschreibung dieser Engine fehlt oder ist beschädigt, deshalb lässt sie sich nicht starten.", "Falta la descripción de este motor o está dañada, así que no se puede iniciar.",
"Описание этого движка отсутствует или повреждено, поэтому его нельзя запустить.", "A descrição deste motor está ausente ou danificada, então ele não pode ser iniciado.",
"이 엔진의 설명 파일이 없거나 손상되어 시작할 수 없습니다.", "La description de ce moteur est absente ou endommagée : il ne peut pas démarrer.")
# ---- updating moved to About: the words that sent people to Settings > Engine
add("Strata didn’t recognise an engine for this computer. The model will download, but it may not run until the right engine is installed (Settings → About).",
"Strata 未能识别这台电脑的引擎。模型会下载,但在安装合适的引擎之前可能无法运行(设置 → 关于)。",
"Strata はこのコンピューター用のエンジンを認識できませんでした。モデルはダウンロードされますが、合ったエンジンをインストールするまで動かない可能性があります(設定 → 情報)。",
"Strata hat für diesen Computer keine Engine erkannt. Das Modell wird heruntergeladen, läuft aber möglicherweise erst, wenn die passende Engine installiert ist (Einstellungen → Über).",
"Strata no reconoció ningún motor para este equipo. El modelo se descargará, pero puede que no funcione hasta que se instale el motor adecuado (Ajustes → Acerca de).",
"Strata не распознала движок для этого компьютера. Модель скачается, но может не запуститься, пока не установлен подходящий движок (Настройки → О программе).",
"O Strata não reconheceu um motor para este computador. O modelo será baixado, mas pode não funcionar até que o motor certo seja instalado (Configurações → Sobre).",
"Strata가 이 컴퓨터용 엔진을 인식하지 못했습니다. 모델은 내려받지만, 알맞은 엔진이 설치되기 전에는 실행되지 않을 수 있습니다(설정 → 정보).",
"Strata n’a pas reconnu de moteur pour cet ordinateur. Le modèle sera téléchargé, mais il risque de ne pas fonctionner tant que le bon moteur n’est pas installé (Réglages → À propos).")
add("Strata didn’t recognise an engine for this computer. The model is downloaded, but it can’t run until the right engine is installed. Open Settings → About.",
"Strata 未能识别这台电脑的引擎。模型已下载,但在安装合适的引擎之前无法运行。请打开 设置 → 关于。",
"Strata はこのコンピューター用のエンジンを認識できませんでした。モデルはダウンロード済みですが、合ったエンジンをインストールするまで動きません。設定 → 情報 を開いてください。",
"Strata hat für diesen Computer keine Engine erkannt. Das Modell ist heruntergeladen, läuft aber erst, wenn die passende Engine installiert ist. Öffne Einstellungen → Über.",
"Strata no reconoció ningún motor para este equipo. El modelo está descargado, pero no puede funcionar hasta que se instale el motor adecuado. Abre Ajustes → Acerca de.",
"Strata не распознала движок для этого компьютера. Модель скачана, но не запустится, пока не установлен подходящий движок. Откройте Настройки → О программе.",
"O Strata não reconheceu um motor para este computador. O modelo foi baixado, mas não pode funcionar até que o motor certo seja instalado. Abra Configurações → Sobre.",
"Strata가 이 컴퓨터용 엔진을 인식하지 못했습니다. 모델은 내려받았지만, 알맞은 엔진이 설치되기 전에는 실행할 수 없습니다. 설정 → 정보를 여세요.",
"Strata n’a pas reconnu de moteur pour cet ordinateur. Le modèle est téléchargé, mais il ne peut pas fonctionner tant que le bon moteur n’est pas installé. Ouvrez Réglages → À propos.")
add("Update the engine (Settings, About), then try again.",
"请更新引擎(设置,关于),然后重试。", "エンジンを更新して(設定、情報)、もう一度お試しください。", "Aktualisiere die Engine (Einstellungen, Über) und versuche es dann erneut.",
"Actualiza el motor (Ajustes, Acerca de) y vuelve a intentarlo.", "Обновите движок (Настройки, О программе) и попробуйте снова.", "Atualize o motor (Configurações, Sobre) e tente novamente.",
"엔진을 업데이트(설정, 정보)한 뒤 다시 시도하세요.", "Mettez le moteur à jour (Réglages, À propos), puis réessayez.")
# ---- answers of the host (shown as they are, so each one is a catalog entry)
add("A model is running, so nothing was changed. Unload the model in Models, then try again.",
"有模型正在运行,所以没有做任何更改。请先在“模型”中卸载模型,然后重试。", "モデルが実行中のため、何も変更していません。「モデル」でモデルをアンロードしてから、もう一度お試しください。",
"Ein Modell läuft, deshalb wurde nichts geändert. Entlade das Modell unter „Modelle“ und versuche es dann erneut.", "Hay un modelo en ejecución, así que no se cambió nada. Descarga el modelo en «Modelos» y vuelve a intentarlo.",
"Работает модель, поэтому ничего не изменено. Выгрузите модель в разделе «Модели» и попробуйте снова.", "Há um modelo em execução, então nada foi alterado. Descarregue o modelo em «Modelos» e tente novamente.",
"모델이 실행 중이라 아무것도 바뀌지 않았습니다. '모델'에서 모델을 언로드한 다음 다시 시도하세요.", "Un modèle est en cours d'exécution, rien n'a donc été modifié. Déchargez le modèle dans « Modèles », puis réessayez.")
add("This is the engine in use. Switch to another one first, then delete it.",
"这是正在使用的引擎。请先切换到另一个引擎,再删除它。", "これは使用中のエンジンです。先に別のエンジンに切り替えてから削除してください。",
"Das ist die verwendete Engine. Wechsle zuerst zu einer anderen und lösche sie dann.", "Este es el motor en uso. Cambia primero a otro y luego elimínalo.",
"Это используемый движок. Сначала переключитесь на другой, затем удалите его.", "Este é o motor em uso. Troque primeiro para outro e depois exclua-o.",
"사용 중인 엔진입니다. 먼저 다른 엔진으로 전환한 다음 삭제하세요.", "C'est le moteur utilisé. Passez d'abord à un autre, puis supprimez-le.")
add("A program is still using this engine, so it was not deleted. Unload your models and try again.",
"仍有程序在使用此引擎,所以没有删除。请卸载你的模型后重试。", "プログラムがまだこのエンジンを使っているため、削除しませんでした。モデルをアンロードしてから、もう一度お試しください。",
"Ein Programm benutzt diese Engine noch, deshalb wurde sie nicht gelöscht. Entlade deine Modelle und versuche es erneut.", "Un programa todavía usa este motor, así que no se eliminó. Descarga tus modelos y vuelve a intentarlo.",
"Этот движок ещё используется программой, поэтому он не удалён. Выгрузите модели и попробуйте снова.", "Um programa ainda está usando este motor, então ele não foi excluído. Descarregue seus modelos e tente novamente.",
"프로그램이 아직 이 엔진을 사용하고 있어 삭제하지 않았습니다. 모델을 언로드한 다음 다시 시도하세요.", "Un programme utilise encore ce moteur : il n'a pas été supprimé. Déchargez vos modèles, puis réessayez.")
add("That engine is already gone. The list has been refreshed.",
"该引擎已经不存在。列表已刷新。", "そのエンジンはすでにありません。一覧を更新しました。", "Diese Engine ist schon nicht mehr da. Die Liste wurde aktualisiert.",
"Ese motor ya no está. La lista se ha actualizado.", "Этого движка уже нет. Список обновлён.", "Esse motor já não existe. A lista foi atualizada.", "해당 엔진은 이미 없습니다. 목록을 새로 고쳤습니다.", "Ce moteur n'existe déjà plus. La liste a été actualisée.")
add("An update is running. Wait for it to finish, then try again.",
"正在进行更新。请等它完成后再试。", "更新を実行中です。終わるまで待ってから、もう一度お試しください。", "Ein Update läuft. Warte, bis es fertig ist, und versuche es dann erneut.",
"Hay una actualización en curso. Espera a que termine y vuelve a intentarlo.", "Идёт обновление. Дождитесь его окончания и попробуйте снова.", "Uma atualização está em andamento. Aguarde o fim e tente novamente.",
"업데이트가 진행 중입니다. 끝날 때까지 기다린 다음 다시 시도하세요.", "Une mise à jour est en cours. Attendez qu'elle se termine, puis réessayez.")
add("This engine can't run on this computer, so nothing was changed.",
"此引擎无法在这台电脑上运行,所以没有做任何更改。", "このエンジンはこのコンピューターでは動かないため、何も変更していません。", "Diese Engine läuft auf diesem Computer nicht, deshalb wurde nichts geändert.",
"Este motor no puede funcionar en este equipo, así que no se cambió nada.", "Этот движок не может работать на этом компьютере, поэтому ничего не изменено.", "Este motor não pode funcionar neste computador, então nada foi alterado.",
"이 엔진은 이 컴퓨터에서 실행할 수 없어 아무것도 바뀌지 않았습니다.", "Ce moteur ne peut pas fonctionner sur cet ordinateur : rien n'a été modifié.")
add("The engine wasn't deleted. Nothing else was changed.",
"引擎没有被删除。其他内容均未更改。", "エンジンは削除されませんでした。ほかには何も変更していません。", "Die Engine wurde nicht gelöscht. Sonst wurde nichts geändert.",
"El motor no se eliminó. No se cambió nada más.", "Движок не удалён. Больше ничего не изменено.", "O motor não foi excluído. Nada mais foi alterado.", "엔진이 삭제되지 않았습니다. 다른 것은 바뀌지 않았습니다.", "Le moteur n'a pas été supprimé. Rien d'autre n'a été modifié.")
add("The engine wasn't switched. Nothing was changed.",
"引擎没有被切换。没有做任何更改。", "エンジンは切り替わりませんでした。何も変更していません。", "Die Engine wurde nicht gewechselt. Es wurde nichts geändert.",
"No se cambió el motor. No se modificó nada.", "Движок не переключён. Ничего не изменено.", "O motor não foi trocado. Nada foi alterado.", "엔진이 전환되지 않았습니다. 아무것도 바뀌지 않았습니다.", "Le moteur n'a pas été changé. Rien n'a été modifié.")
add("Choose an engine from the list.",
"请从列表中选择一个引擎。", "一覧からエンジンを選んでください。", "Wähle eine Engine aus der Liste.", "Elige un motor de la lista.", "Выберите движок из списка.", "Escolha um motor da lista.", "목록에서 엔진을 선택하세요.", "Choisissez un moteur dans la liste.")
add("A newer engine is already on this computer. Update switches to it; nothing is downloaded.",
"这台电脑上已经有更新的引擎。点击更新会切换到它,不会下载任何内容。", "このコンピューターにはすでに新しいエンジンがあります。更新するとそれに切り替わり、ダウンロードはされません。",
"Auf diesem Computer ist schon eine neuere Engine. Das Update wechselt zu ihr; es wird nichts heruntergeladen.", "Este equipo ya tiene un motor más reciente. Actualizar cambia a él; no se descarga nada.",
"На этом компьютере уже есть более новый движок. Обновление переключит на него; ничего не скачивается.", "Este computador já tem um motor mais novo. Atualizar troca para ele; nada é baixado.",
"이 컴퓨터에 이미 더 새로운 엔진이 있습니다. 업데이트하면 그 엔진으로 전환되며, 내려받는 것은 없습니다.", "Cet ordinateur a déjà un moteur plus récent. La mise à jour bascule dessus ; rien n'est téléchargé.")
here = Path(__file__).resolve().parent.parent / "src" / "locales"
NBSP = " "
def french(text):
text = re.sub(r"(?<=[A-Za-zÀ-ſ])'(?=[A-Za-zÀ-ſ{])", "’", text)
text = re.sub(r"(?<=\S) ([:;?!])", NBSP + r"\1", text)
text = re.sub(r"(?<=[\d}]) %", NBSP + "%", text)
return text
for lang in LANGS:
data = {}
for i, entry in enumerate(S):
text = entry[lang]
assert sorted(re.findall(r"\{\d+\}", entry["en"])) == sorted(re.findall(r"\{\d+\}", text)), (lang, entry["en"], text)
data[f"et{i + 1:02d}"] = french(text) if lang == "fr" else text
(here / f"engine-{lang}.json").write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
print("written", len(S), "strings in", len(LANGS), "languages")
+60
View File
@@ -0,0 +1,60 @@
"""Writes src/locales/gui-<lang>.json: the strings of the GUI feedback round 2 (branch f-0291-gui-fb2): the new name and hint of the "Leave room for my work"
choice (it keeps 5-8 GB of memory free; it was "Leave 20% for my work"), the memory-error sentence that names it, and the words of the compact files block
("Delivered to", "Working folder", "{0} files").
Run from apps/host: python scripts/make-gui-locales.py
Every entry names all nine languages; the keys are gf01.. in the order of S. gui-en.json is the English source. Placeholders ({0}) must be kept in every language.
A separate file keeps this work from colliding with the other catalogs (the same reason pick-, tune-, tools-, fix-, gh-, ui-, engine- and df2- exist)."""
import json
import re
from pathlib import Path
LANGS = ["en", "zh", "ja", "de", "es", "ru", "pt", "ko", "fr"]
S = []
def add(en, zh, ja, de, es, ru, pt, ko, fr):
S.append(dict(en=en, zh=zh, ja=ja, de=de, es=es, ru=ru, pt=pt, ko=ko, fr=fr))
add("Leave room for my work",
"为我的工作留出空间", "作業用の余裕を残す", "Platz für meine Arbeit lassen", "Dejar espacio para mi trabajo",
"Оставить запас для моей работы", "Deixar espaço para o meu trabalho", "내 작업을 위한 여유 남기기", "Garder de la place pour mon travail")
add("Keeps 5-8 GB of memory free for other programs.",
"为其他程序保留 5-8 GB 空闲内存。", "他のプログラム用に 5~8 GB のメモリを空けておきます。", "Hält 5-8 GB Arbeitsspeicher für andere Programme frei.",
"Mantiene libres 5-8 GB de memoria para otros programas.", "Оставляет 5-8 ГБ памяти свободными для других программ.",
"Mantém 5-8 GB de memória livres para outros programas.", "다른 프로그램을 위해 메모리 5-8 GB를 비워 둡니다.", "Garde 5-8 Go de mémoire libres pour les autres programmes.")
add("Close other programs and try again, or choose “Leave room for my work”. In Expert mode you can also turn on the low-RAM option, so part of the model is read from the SSD.",
"请关闭其他程序后重试,或选择“为我的工作留出空间”。在专家模式下,你还可以开启低内存选项,让部分模型从 SSD 读取。",
"他のプログラムを閉じてもう一度試すか、「作業用の余裕を残す」を選んでください。エキスパートモードでは低 RAM オプションをオンにして、モデルの一部を SSD から読み込むこともできます。",
"Schließen Sie andere Programme und versuchen Sie es erneut, oder wählen Sie „Platz für meine Arbeit lassen“. Im Expertenmodus können Sie außerdem die Option für wenig Arbeitsspeicher aktivieren, sodass ein Teil des Modells von der SSD gelesen wird.",
"Cierre otros programas e inténtelo de nuevo, o elija «Dejar espacio para mi trabajo». En el modo experto también puede activar la opción de poca RAM, para que una parte del modelo se lea desde el SSD.",
"Закройте другие программы и повторите попытку или выберите «Оставить запас для моей работы». В экспертном режиме можно также включить режим малого объёма ОЗУ, и часть модели будет читаться с SSD.",
"Feche outros programas e tente novamente, ou escolha “Deixar espaço para o meu trabalho”. No modo especialista, você também pode ativar a opção de pouca RAM, para que parte do modelo seja lida do SSD.",
"다른 프로그램을 닫고 다시 시도하거나 “내 작업을 위한 여유 남기기”를 선택하세요. 전문가 모드에서는 저RAM 옵션을 켜서 모델의 일부를 SSD에서 읽게 할 수도 있습니다.",
"Fermez les autres programmes et réessayez, ou choisissez « Garder de la place pour mon travail ». En mode expert, vous pouvez aussi activer l'option de faible RAM : une partie du modèle est alors lue depuis le SSD.")
add("Delivered to",
"已交付到", "保存先", "Geliefert nach", "Entregado en", "Результат в", "Entregue em", "전달 위치", "Livré dans")
add("Working folder",
"工作文件夹", "作業フォルダー", "Arbeitsordner", "Carpeta de trabajo", "Рабочая папка", "Pasta de trabalho", "작업 폴더", "Dossier de travail")
add("{0} files",
"{0} 个文件", "{0} 個のファイル", "{0} Dateien", "{0} archivos", "Файлов: {0}", "{0} arquivos", "파일 {0}개", "{0} fichiers")
here = Path(__file__).resolve().parent.parent / "src" / "locales"
NBSP = " "
def french(text):
text = re.sub(r"(?<=[A-Za-zÀ-ſ])'(?=[A-Za-zÀ-ſ{])", "’", text)
text = re.sub(r"(?<=\S) ([:;?!])", NBSP + r"\1", text)
text = re.sub(r"(?<=[\d}]) %", NBSP + "%", text)
return text
for lang in LANGS:
data = {}
for i, entry in enumerate(S):
text = entry[lang]
assert sorted(re.findall(r"\{\d+\}", entry["en"])) == sorted(re.findall(r"\{\d+\}", text)), (lang, entry["en"], text)
data[f"gf{i + 1:02d}"] = french(text) if lang == "fr" else text
with (here / f"gui-{lang}.json").open("w", encoding="utf-8", newline="\n") as handle:
handle.write(json.dumps(data, ensure_ascii=False, indent=1) + "\n")
print("written", len(S), "strings in", len(LANGS), "languages")
+75
View File
@@ -0,0 +1,75 @@
"""Writes src/locales/pick-<lang>.json: the strings of the model version list (VersionPicker), the code-block preview button and the This computer panel (branch f-0291-ui-fixes2).
Run from apps/host: python scripts/make-pick-locales.py
Every entry names all nine languages; the keys are vp01.. in the order of S. pick-en.json is the English source. Placeholders ({0}, {1}) must be kept
in every language. A separate file keeps this work from colliding with the other catalogs (the same reason tune-, tools-, fix-, gh-, ui- and engine- exist)."""
import json
import re
from pathlib import Path
LANGS = ["en", "zh", "ja", "de", "es", "ru", "pt", "ko", "fr"]
S = []
def add(en, zh, ja, de, es, ru, pt, ko, fr):
S.append(dict(en=en, zh=zh, ja=ja, de=de, es=es, ru=ru, pt=pt, ko=ko, fr=fr))
add("{0} download",
"下载 {0}", "ダウンロード {0}", "{0} Download", "Descarga de {0}", "Загрузка {0}", "Download de {0}", "다운로드 {0}", "Téléchargement de {0}")
add("Needs {0} of memory",
"需要 {0} 内存", "{0} のメモリーが必要", "Braucht {0} Speicher", "Necesita {0} de memoria", "Нужно {0} памяти", "Precisa de {0} de memória", "메모리 {0} 필요", "Nécessite {0} de mémoire")
add("Needs {0} of memory and about {1} GB of disk",
"需要 {0} 内存和约 {1} GB 磁盘空间", "{0} のメモリーと約 {1} GB のディスク容量が必要", "Braucht {0} Speicher und etwa {1} GB Festplattenplatz", "Necesita {0} de memoria y unos {1} GB de disco",
"Нужно {0} памяти и около {1} ГБ на диске", "Precisa de {0} de memória e cerca de {1} GB de disco", "메모리 {0}와 디스크 약 {1} GB 필요", "Nécessite {0} de mémoire et environ {1} Go de disque")
add("Fits",
"可以运行", "問題なく動きます", "Passt", "Cabe", "Помещается", "Cabe", "문제없이 실행됩니다", "Tient")
add("Fits with SSD streaming",
"可以运行,部分内容从 SSD 读取", "SSD から読み込みながら動きます", "Passt mit SSD-Streaming", "Cabe con streaming desde el SSD", "Помещается с потоковой подгрузкой с SSD", "Cabe com streaming do SSD", "SSD 스트리밍으로 실행됩니다", "Tient avec streaming depuis le SSD")
add("Doesn't fit",
"放不下", "収まりません", "Passt nicht", "No cabe", "Не помещается", "Não cabe", "맞지 않습니다", "Ne tient pas")
add("Show details",
"显示详情", "詳細を表示", "Details anzeigen", "Mostrar detalles", "Показать подробности", "Mostrar detalhes", "자세히 보기", "Afficher les détails")
add("Hide details",
"隐藏详情", "詳細を隠す", "Details ausblenden", "Ocultar detalles", "Скрыть подробности", "Ocultar detalhes", "자세히 숨기기", "Masquer les détails")
add("{0} RAM + VRAM",
"{0} 内存 + 显存", "{0} の RAM + VRAM", "{0} RAM + VRAM", "{0} de RAM + VRAM", "{0} RAM + VRAM", "{0} de RAM + VRAM", "RAM + VRAM {0}", "{0} de RAM + VRAM")
add("Close preview",
"关闭预览", "プレビューを閉じる", "Vorschau schließen", "Cerrar vista previa", "Закрыть предпросмотр", "Fechar pré-visualização", "미리보기 닫기", "Fermer l'aperçu")
add("Free right now",
"当前可用", "現在の空き", "Gerade frei", "Libre ahora", "Свободно сейчас", "Livre agora", "지금 비어 있음", "Libre en ce moment")
add("{0} free",
"可用 {0}", "空き {0}", "{0} frei", "{0} libres", "свободно {0}", "{0} livres", "{0} 사용 가능", "{0} libres")
add("{0}: {1} free of {2}",
"{0}:{2} 中有 {1} 可用", "{0}:{2} のうち {1} が空き", "{0}: {1} frei von {2}", "{0}: {1} libres de {2}", "{0}: свободно {1} из {2}", "{0}: {1} livres de {2}", "{0}: {2} 중 {1} 사용 가능", "{0} : {1} libres sur {2}")
add("It takes about",
"它大约占用", "使用量の目安", "Es belegt etwa", "Ocupa aproximadamente", "Занимает примерно", "Ocupa cerca de", "대략 사용하는 양", "Il occupe environ")
add("Nothing. It fits in memory.",
"没有。它完全放得进内存。", "ありません。すべてメモリーに収まっています。", "Nichts. Es passt in den Speicher.", "Nada. Cabe en la memoria.", "Ничего. Всё помещается в память.", "Nada. Cabe na memória.", "없음. 메모리에 모두 들어갑니다.", "Rien. Tout tient en mémoire.")
add("Reading from your SSD now: {0}",
"此刻正从 SSD 读取:{0}", "現在 SSD から読み込み中:{0}", "Gerade vom SSD gelesen: {0}", "Leyendo del SSD ahora: {0}", "Сейчас читается с SSD: {0}", "Lendo do SSD agora: {0}", "지금 SSD에서 읽는 중: {0}", "Lecture depuis le SSD en cours : {0}")
add("This version would take about {0} of graphics memory and {1} of RAM.",
"这个版本大约会占用 {0} 显存和 {1} 内存。", "このバージョンはグラフィックスメモリーを約 {0}、RAM を約 {1} 使います。", "Diese Version würde etwa {0} Grafikspeicher und {1} RAM belegen.",
"Esta versión ocuparía aproximadamente {0} de memoria gráfica y {1} de RAM.", "Эта версия займёт примерно {0} видеопамяти и {1} оперативной памяти.", "Esta versão ocuparia cerca de {0} de memória gráfica e {1} de RAM.",
"이 버전은 그래픽 메모리 약 {0}와 RAM 약 {1}을 사용합니다.", "Cette version occuperait environ {0} de mémoire graphique et {1} de RAM.")
here = Path(__file__).resolve().parent.parent / "src" / "locales"
NBSP = " "
def french(text):
text = re.sub(r"(?<=[A-Za-zÀ-ſ])'(?=[A-Za-zÀ-ſ{])", "’", text)
text = re.sub(r"(?<=\S) ([:;?!])", NBSP + r"\1", text)
text = re.sub(r"(?<=[\d}]) %", NBSP + "%", text)
return text
for lang in LANGS:
data = {}
for i, entry in enumerate(S):
text = entry[lang]
assert sorted(re.findall(r"\{\d+\}", entry["en"])) == sorted(re.findall(r"\{\d+\}", text)), (lang, entry["en"], text)
data[f"vp{i + 1:02d}"] = french(text) if lang == "fr" else text
(here / f"pick-{lang}.json").write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
print("written", len(S), "strings in", len(LANGS), "languages")
+67
View File
@@ -0,0 +1,67 @@
"""Writes src/locales/r3-<lang>.json: the strings added by the owner's GUI feedback round 3 (branch f-0291-ui-r3).
Run from apps/host: python scripts/make-r3-locales.py
Every entry names all nine languages; the keys are r3_01.. in the order of S. r3-en.json is the English source. Placeholders ({0}, {1}) must be kept in every
language. A separate file keeps this work from colliding with the other catalogs (the same reason pick-, upd-, df2- and the others exist)."""
import json
import re
from pathlib import Path
LANGS = ["en", "zh", "ja", "de", "es", "ru", "pt", "ko", "fr"]
S = []
def add(en, zh, ja, de, es, ru, pt, ko, fr):
S.append(dict(en=en, zh=zh, ja=ja, de=de, es=es, ru=ru, pt=pt, ko=ko, fr=fr))
# 2: the Read pictures row while the installed engine has no picture reader (a tooltip, not a paragraph)
add("Needs an engine update",
"需要更新引擎", "エンジンの更新が必要です", "Braucht ein Engine-Update", "Necesita actualizar el motor", "Нужно обновить движок", "Precisa de uma atualização do motor", "엔진 업데이트가 필요합니다", "Nécessite une mise à jour du moteur")
# 5: the one word the version list writes for a version that does not fully fit in memory
add("SSD streaming · will run slower",
"SSD 流式读取 · 运行会更慢", "SSD ストリーミング · 動作は遅くなります", "SSD-Streaming · läuft langsamer", "Streaming desde el SSD · irá más lento", "Потоковая подгрузка с SSD · будет работать медленнее",
"Streaming do SSD · vai rodar mais devagar", "SSD 스트리밍 · 더 느리게 실행됩니다", "Streaming depuis le SSD · plus lent")
# 6: the warning under the conversation context when a long context leaves little graphics memory for the model (nothing is refused or lowered)
add("This much context takes about {0} GB of graphics memory and leaves little room for the model on this card. Strata will try it; if it does not start, choose a shorter context.",
"这么长的上下文大约占用 {0} GB 显存,留给模型的空间很少。Strata 会尝试运行;如果无法启动,请选择更短的上下文。",
"このコンテキストはグラフィックスメモリーを約 {0} GB 使い、このカードではモデルに残る余裕がわずかです。Strata は試しますが、起動しない場合はより短いコンテキストを選んでください。",
"So viel Kontext belegt etwa {0} GB Grafikspeicher und lässt auf dieser Karte wenig Platz für das Modell. Strata versucht es; startet es nicht, wähle einen kürzeren Kontext.",
"Este contexto ocupa unos {0} GB de memoria gráfica y deja poco espacio para el modelo en esta tarjeta. Strata lo intentará; si no arranca, elige un contexto más corto.",
"Такой контекст занимает около {0} ГБ видеопамяти и оставляет модели мало места на этой карте. Strata попробует запустить; если не получится, выберите контекст короче.",
"Esse contexto ocupa cerca de {0} GB de memória gráfica e deixa pouco espaço para o modelo nesta placa. O Strata vai tentar; se não iniciar, escolha um contexto menor.",
"이 정도의 컨텍스트는 그래픽 메모리를 약 {0} GB 사용하며 이 카드에서는 모델이 쓸 공간이 거의 남지 않습니다. Strata가 시도하지만 시작되지 않으면 더 짧은 컨텍스트를 선택하세요.",
"Ce contexte occupe environ {0} Go de mémoire graphique et laisse peu de place au modèle sur cette carte. Strata va essayer ; s'il ne démarre pas, choisissez un contexte plus court.")
# 9: a chat that is working while the person is elsewhere (the ring on its row, and the model chip's line)
add("Working · {0}",
"进行中 · {0}", "作業中 · {0}", "In Arbeit · {0}", "Trabajando · {0}", "В работе · {0}", "Trabalhando · {0}", "작업 중 · {0}", "En cours de travail · {0}")
add("{0} chats working",
"{0} 个对话进行中", "{0} 件のチャットが作業中", "{0} Chats in Arbeit", "{0} chats trabajando", "В работе чатов: {0}", "{0} chats trabalhando", "대화 {0}개 작업 중", "{0} conversations en cours")
# 10: the size line of a file being written
add("{0} lines",
"{0} 行", "{0} 行", "{0} Zeilen", "{0} líneas", "{0} строк", "{0} linhas", "{0}줄", "{0} lignes")
#@@MORE@@
here = Path(__file__).resolve().parent.parent / "src" / "locales"
NBSP = " "
def french(text):
text = re.sub(r"(?<=[A-Za-zÀ-ſ])'(?=[A-Za-zÀ-ſ{])", "’", text)
text = re.sub(r"(?<=\S) ([:;?!])", NBSP + r"\1", text)
text = re.sub(r"(?<=[\d}]) %", NBSP + "%", text)
return text
for lang in LANGS:
data = {}
for i, entry in enumerate(S):
text = entry[lang]
assert sorted(re.findall(r"\{\d+\}", entry["en"])) == sorted(re.findall(r"\{\d+\}", text)), (lang, entry["en"], text)
data[f"r3_{i + 1:02d}"] = french(text) if lang == "fr" else text
(here / f"r3-{lang}.json").write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
print("written", len(S), "strings in", len(LANGS), "languages")
+249
View File
@@ -0,0 +1,249 @@
"""Writes src/locales/ui-<lang>.json: the interface strings of the 0.2.10 interface changes (branch f-0291-ui).
Run from apps/host: python scripts/make-ui-locales.py
Every entry names all nine languages; the keys are ui01.. in the order of S. ui-en.json is the English source. Placeholders ({0}, {1}) must be
kept in every language. A separate file keeps this work from colliding with the other catalogs (the same reason tune-, tools-, fix- and gh- exist)."""
import json
import re
from pathlib import Path
LANGS = ["en", "zh", "ja", "de", "es", "ru", "pt", "ko", "fr"]
S = []
def add(**kw):
assert sorted(kw) == sorted(LANGS), sorted(set(LANGS) ^ set(kw))
S.append(kw)
# #4725 the approval stays in view
add(en="Waiting for your answer",
zh="等待你的回答", ja="あなたの返答を待っています", de="Wartet auf deine Antwort", es="Esperando tu respuesta",
ru="Ждёт вашего ответа", pt="Aguardando a sua resposta", ko="답변을 기다리는 중", fr="En attente de votre réponse")
add(en="Waiting for your answer below",
zh="等待你在下方回答", ja="下で返答をお待ちしています", de="Wartet unten auf deine Antwort", es="Esperando tu respuesta abajo",
ru="Ждёт вашего ответа ниже", pt="Aguardando a sua resposta abaixo", ko="아래에서 답변을 기다리는 중", fr="En attente de votre réponse ci-dessous")
# #4804 why Start tuning is greyed out
add(en="Strata is still checking this model for saved tunings. Start tuning works as soon as that is done.",
zh="Strata 仍在检查此模型是否有已保存的调优。检查完成后即可开始调优。",
ja="Strata がこのモデルの保存済みチューニングを確認しています。確認が終わりしだい、チューニングを開始できます。",
de="Strata prüft dieses Modell noch auf gespeicherte Abstimmungen. Sobald das fertig ist, kannst du die Abstimmung starten.",
es="Strata todavía está comprobando si este modelo tiene ajustes guardados. Podrás empezar el ajuste en cuanto termine.",
ru="Strata ещё проверяет, есть ли у этой модели сохранённые настройки. Как только проверка закончится, можно будет запустить настройку.",
pt="O Strata ainda está verificando se este modelo tem ajustes salvos. Assim que terminar, você poderá iniciar o ajuste.",
ko="Strata가 이 모델의 저장된 최적화를 확인하는 중입니다. 확인이 끝나면 바로 최적화를 시작할 수 있습니다.",
fr="Strata vérifie encore si ce modèle a des optimisations enregistrées. Dès que c’est fini, vous pourrez lancer l’optimisation.")
add(en="Strata could not read the tuning details for this model, so tuning cannot start. Close this window and open it again.",
zh="Strata 无法读取此模型的调优信息,因此无法开始调优。请关闭此窗口后重新打开。",
ja="Strata がこのモデルのチューニング情報を読み取れなかったため、チューニングを開始できません。このウィンドウを閉じて、もう一度開いてください。",
de="Strata konnte die Abstimmungsdaten dieses Modells nicht lesen, deshalb lässt sich die Abstimmung nicht starten. Schließe dieses Fenster und öffne es erneut.",
es="Strata no pudo leer los datos de ajuste de este modelo, así que el ajuste no puede empezar. Cierra esta ventana y ábrela de nuevo.",
ru="Strata не смогла прочитать данные настройки этой модели, поэтому настройку нельзя запустить. Закройте это окно и откройте его снова.",
pt="O Strata não conseguiu ler os dados de ajuste deste modelo, então o ajuste não pode começar. Feche esta janela e abra-a novamente.",
ko="Strata가 이 모델의 최적화 정보를 읽지 못해 최적화를 시작할 수 없습니다. 이 창을 닫았다가 다시 여세요.",
fr="Strata n’a pas pu lire les données d’optimisation de ce modèle, l’optimisation ne peut donc pas démarrer. Fermez cette fenêtre et rouvrez-la.")
add(en="Checking for saved tunings\u2026",
zh="正在检查已保存的调优\u2026", ja="保存済みチューニングを確認しています\u2026", de="Gespeicherte Abstimmungen werden geprüft\u2026", es="Comprobando los ajustes guardados\u2026",
ru="Проверяем сохранённые настройки\u2026", pt="Verificando os ajustes salvos\u2026", ko="저장된 최적화를 확인하는 중\u2026", fr="Vérification des optimisations enregistrées\u2026")
# #4802 the progress card
add(en="Still working for {0}. You don't need to do anything.",
zh="仍在处理,已用时 {0}。你无需做任何操作。",
ja="処理中です({0}経過)。操作は必要ありません。",
de="Läuft noch seit {0}. Du musst nichts tun.",
es="Sigue trabajando desde hace {0}. No tienes que hacer nada.",
ru="Всё ещё работает: {0}. От вас ничего не требуется.",
pt="Ainda trabalhando há {0}. Você não precisa fazer nada.",
ko="{0}째 작업 중입니다. 따로 할 일은 없습니다.",
fr="Toujours en cours depuis {0}. Vous n'avez rien à faire.")
add(en="The buttons below wait until this finishes.",
zh="下方的按钮会在此操作完成后恢复可用。",
ja="この処理が終わるまで、下のボタンは使えません。",
de="Die Schaltflächen unten warten, bis das fertig ist.",
es="Los botones de abajo esperan a que esto termine.",
ru="Кнопки ниже подождут, пока это не закончится.",
pt="Os botões abaixo esperam até isto terminar.",
ko="아래 버튼은 이 작업이 끝날 때까지 기다립니다.",
fr="Les boutons ci-dessous attendent la fin de cette opération.")
# #4798 two addresses
add(en="API base URL on this computer",
zh="此电脑上的 API 基础地址", ja="このパソコンでの API ベース URL", de="API-Basis-URL auf diesem Computer", es="URL base de la API en este equipo",
ru="Базовый URL API на этом компьютере", pt="URL base da API neste computador", ko="이 컴퓨터의 API 기본 URL", fr="URL de base de l'API sur cet ordinateur")
add(en="Address for other devices",
zh="其他设备使用的地址", ja="他のデバイス用のアドレス", de="Adresse für andere Geräte", es="Dirección para otros dispositivos",
ru="Адрес для других устройств", pt="Endereço para outros dispositivos", ko="다른 기기용 주소", fr="Adresse pour les autres appareils")
add(en="Other devices use this address. It has its own port, separate from the one Strata's window uses, because only the model's API is shared on the network, not the app.",
zh="其他设备使用此地址。它有自己的端口,与 Strata 窗口使用的端口分开,因为网络上只共享模型的 API,而不是应用本身。",
ja="他のデバイスはこのアドレスを使います。ネットワークで共有されるのはモデルの API だけでアプリ本体ではないため、Strata のウィンドウが使うポートとは別のポートを使います。",
de="Andere Geräte verwenden diese Adresse. Sie hat einen eigenen Port, getrennt von dem des Strata-Fensters, weil im Netzwerk nur die API des Modells freigegeben wird, nicht die App.",
es="Los demás dispositivos usan esta dirección. Tiene su propio puerto, distinto del que usa la ventana de Strata, porque en la red solo se comparte la API del modelo, no la aplicación.",
ru="Другие устройства используют этот адрес. У него свой порт, отдельный от порта окна Strata, потому что в сети доступен только API модели, а не само приложение.",
pt="Os outros dispositivos usam este endereço. Ele tem a própria porta, separada da que a janela do Strata usa, porque só a API do modelo é compartilhada na rede, não o aplicativo.",
ko="다른 기기는 이 주소를 사용합니다. 네트워크에는 앱이 아니라 모델의 API만 공유되므로, Strata 창이 쓰는 포트와는 별도의 포트를 씁니다.",
fr="Les autres appareils utilisent cette adresse. Elle a son propre port, distinct de celui de la fenêtre de Strata, car seule l'API du modèle est partagée sur le réseau, pas l'application.")
# #3723 Context memory and the out-of-graphics-memory reason
add(en="INT8 is the standard: good quality and a medium amount of memory.",
zh="INT8 是标准选项:质量好,内存占用适中。",
ja="INT8 は標準の設定です。品質が良く、メモリ使用量は中程度です。",
de="INT8 ist der Standard: gute Qualität und mittlerer Speicherbedarf.",
es="INT8 es el estándar: buena calidad y un uso de memoria medio.",
ru="INT8 — стандартный вариант: хорошее качество и средний расход памяти.",
pt="INT8 é o padrão: boa qualidade e uso médio de memória.",
ko="INT8은 표준 설정입니다. 품질이 좋고 메모리 사용량은 보통입니다.",
fr="INT8 est le réglage standard : bonne qualité et une consommation de mémoire moyenne.")
add(en="Q4 uses about half the memory of INT8, but is less precise on very long documents.",
zh="Q4 占用的内存约为 INT8 的一半,但在很长的文档上精度较低。",
ja="Q4 は INT8 の約半分のメモリで済みますが、非常に長い文書では精度が少し下がります。",
de="Q4 braucht etwa halb so viel Speicher wie INT8, ist aber bei sehr langen Dokumenten weniger genau.",
es="Q4 usa aproximadamente la mitad de memoria que INT8, pero es menos preciso con documentos muy largos.",
ru="Q4 занимает примерно вдвое меньше памяти, чем INT8, но менее точен на очень длинных документах.",
pt="Q4 usa cerca de metade da memória do INT8, mas é menos preciso em documentos muito longos.",
ko="Q4는 INT8의 약 절반 메모리를 쓰지만, 아주 긴 문서에서는 정밀도가 떨어집니다.",
fr="Q4 utilise environ moitié moins de mémoire que INT8, mais il est moins précis sur de très longs documents.")
add(en="K8 / V4 uses about a quarter less memory than INT8 and is more precise than Q4. Prompts are 2 to 5% slower.",
zh="K8 / V4 比 INT8 约少占用四分之一的内存,精度高于 Q4。处理提示词会慢 2% 到 5%。",
ja="K8 / V4 は INT8 より約4分の1少ないメモリで済み、Q4 より精度が高い設定です。プロンプトの処理は 2〜5% 遅くなります。",
de="K8 / V4 braucht etwa ein Viertel weniger Speicher als INT8 und ist genauer als Q4. Prompts sind 2 bis 5 % langsamer.",
es="K8 / V4 usa aproximadamente una cuarta parte menos de memoria que INT8 y es más preciso que Q4. Los prompts son entre un 2 y un 5 % más lentos.",
ru="K8 / V4 занимает примерно на четверть меньше памяти, чем INT8, и точнее, чем Q4. Запросы обрабатываются на 2–5 % медленнее.",
pt="K8 / V4 usa cerca de um quarto a menos de memória que o INT8 e é mais preciso que o Q4. Os prompts ficam de 2 a 5 % mais lentos.",
ko="K8 / V4는 INT8보다 메모리를 약 4분의 1 적게 쓰고 Q4보다 정밀합니다. 프롬프트 처리는 2~5% 느려집니다.",
fr="K8 / V4 utilise environ un quart de mémoire en moins que INT8 et est plus précis que Q4. Les prompts sont 2 à 5 % plus lents.")
add(en="The model did not fit in your graphics card’s memory.",
zh="模型放不进你的显卡显存。",
ja="モデルがグラフィックスカードのメモリに収まりませんでした。",
de="Das Modell hat nicht in den Speicher deiner Grafikkarte gepasst.",
es="El modelo no cabía en la memoria de tu tarjeta gráfica.",
ru="Модель не поместилась в память вашей видеокарты.",
pt="O modelo não coube na memória da sua placa de vídeo.",
ko="모델이 그래픽 카드 메모리에 들어가지 않았습니다.",
fr="Le modèle ne tenait pas dans la mémoire de votre carte graphique.")
add(en="Lower the conversation context, or set Context memory to Q4 in Expert settings, then try again.",
zh="请调低对话上下文,或在专家设置中把“上下文内存”设为 Q4,然后重试。",
ja="会話のコンテキストを小さくするか、上級設定で「コンテキストメモリー」を Q4 にして、もう一度お試しください。",
de="Verkleinere den Gesprächskontext oder stelle in den Experteneinstellungen „Kontextspeicher“ auf Q4 und versuche es erneut.",
es="Reduce el contexto de la conversación o pon «Memoria de contexto» en Q4 en Ajustes expertos, y vuelve a intentarlo.",
ru="Уменьшите контекст разговора или установите «Память контекста» в Q4 в разделе «Экспертные настройки» и повторите попытку.",
pt="Reduza o contexto da conversa ou defina «Memória de contexto» como Q4 em Configurações avançadas e tente de novo.",
ko="대화 컨텍스트를 줄이거나 전문가 설정에서 ‘컨텍스트 메모리’를 Q4로 바꾼 뒤 다시 시도하세요.",
fr="Réduisez le contexte de la conversation, ou réglez « Mémoire de contexte » sur Q4 dans Réglages experts, puis réessayez.")
# #4749 a message for a helper
add(en="Message this helper",
zh="给这个助手发消息", ja="このヘルパーにメッセージを送る", de="Diesem Helfer schreiben", es="Escribir a este ayudante",
ru="Написать этому помощнику", pt="Enviar mensagem a este ajudante", ko="이 도우미에게 메시지 보내기", fr="Écrire à cet assistant")
add(en="Message to {0}",
zh="发给 {0} 的消息", ja="{0} へのメッセージ", de="Nachricht an {0}", es="Mensaje para {0}",
ru="Сообщение для {0}", pt="Mensagem para {0}", ko="{0}에게 보낼 메시지", fr="Message pour {0}")
add(en="Write to {0}\u2026",
zh="写给 {0}\u2026", ja="{0} に書く\u2026", de="An {0} schreiben\u2026", es="Escribe a {0}\u2026",
ru="Напишите {0}\u2026", pt="Escreva para {0}\u2026", ko="{0}에게 쓰기\u2026", fr="Écrire à {0}\u2026")
add(en="Sent to {0}.",
zh="已发给 {0}。", ja="{0} に送りました。", de="An {0} gesendet.", es="Enviado a {0}.",
ru="Отправлено: {0}.", pt="Enviado para {0}.", ko="{0}에게 보냈습니다.", fr="Envoyé à {0}.")
# #4783 a message while an answer is being written
add(en="While an answer is being written",
zh="回答生成期间", ja="回答の作成中", de="Während eine Antwort geschrieben wird", es="Mientras se escribe una respuesta",
ru="Пока пишется ответ", pt="Enquanto uma resposta é escrita", ko="답변을 작성하는 동안", fr="Pendant qu'une réponse s'écrit")
add(en="What Enter does when you send a message while the assistant is still answering.",
zh="助手仍在回答时,你发送消息后按回车会发生什么。", ja="アシスタントがまだ回答している間にメッセージを送ったとき、Enter で何が起こるかを選びます。",
de="Was die Eingabetaste bewirkt, wenn du eine Nachricht sendest, während der Assistent noch antwortet.",
es="Qué hace Intro cuando envías un mensaje mientras el asistente todavía está respondiendo.",
ru="Что делает Enter, когда вы отправляете сообщение, пока ассистент ещё отвечает.",
pt="O que o Enter faz quando você envia uma mensagem enquanto o assistente ainda está respondendo.",
ko="어시스턴트가 아직 답하는 중에 메시지를 보낼 때 Enter가 하는 일입니다.",
fr="Ce que fait Entrée quand vous envoyez un message pendant que l'assistant répond encore.")
add(en="Wait for the answer",
zh="等回答完成", ja="回答が終わるまで待つ", de="Auf die Antwort warten", es="Esperar la respuesta",
ru="Дождаться ответа", pt="Esperar a resposta", ko="답변이 끝날 때까지 기다리기", fr="Attendre la réponse")
add(en="Send after the answer",
zh="回答完成后再发送", ja="回答が終わってから送る", de="Nach der Antwort senden", es="Enviar después de la respuesta",
ru="Отправить после ответа", pt="Enviar depois da resposta", ko="답변이 끝난 뒤 보내기", fr="Envoyer après la réponse")
add(en="Interrupt the answer",
zh="打断回答", ja="回答を中断する", de="Die Antwort unterbrechen", es="Interrumpir la respuesta",
ru="Прервать ответ", pt="Interromper a resposta", ko="답변 중단하기", fr="Interrompre la réponse")
add(en="Enter sends it when the answer is done.",
zh="回答完成后,按回车即可发送。", ja="回答が終わったら Enter で送信されます。", de="Die Eingabetaste sendet sie, sobald die Antwort fertig ist.",
es="Intro lo envía cuando termina la respuesta.", ru="Enter отправит сообщение, когда ответ закончится.",
pt="O Enter envia quando a resposta terminar.", ko="답변이 끝나면 Enter로 보냅니다.", fr="Entrée l'envoie quand la réponse est terminée.")
add(en="Enter stops the answer here and sends it now.",
zh="按回车会在此处停止回答并立即发送。", ja="Enter で回答をここで止めて、すぐに送信します。", de="Die Eingabetaste stoppt die Antwort hier und sendet die Nachricht sofort.",
es="Intro detiene la respuesta aquí y lo envía ahora.", ru="Enter остановит ответ здесь и сразу отправит сообщение.",
pt="O Enter para a resposta aqui e envia agora.", ko="Enter를 누르면 여기서 답변을 멈추고 바로 보냅니다.", fr="Entrée arrête la réponse ici et l'envoie tout de suite.")
add(en="Waiting to send",
zh="等待发送", ja="送信待ち", de="Wartet auf das Senden", es="Esperando para enviar",
ru="Ждёт отправки", pt="Aguardando envio", ko="보내기 대기 중", fr="En attente d'envoi")
add(en="Take back",
zh="撤回", ja="取り消す", de="Zurücknehmen", es="Retirar",
ru="Забрать обратно", pt="Desfazer", ko="되돌리기", fr="Reprendre")
# #4785 quoting
add(en="Quote",
zh="引用", ja="引用", de="Zitieren", es="Citar",
ru="Цитировать", pt="Citar", ko="인용", fr="Citer")
add(en="Quoting the assistant",
zh="引用助手的话", ja="アシスタントの発言を引用", de="Zitat des Assistenten", es="Citando al asistente",
ru="Цитата ассистента", pt="Citando o assistente", ko="어시스턴트의 말을 인용", fr="Citation de l'assistant")
add(en="Quoting you",
zh="引用你的话", ja="あなたの発言を引用", de="Zitat von dir", es="Citándote a ti",
ru="Ваша цитата", pt="Citando você", ko="내 말을 인용", fr="Citation de vous")
add(en="Remove this quote",
zh="移除这条引用", ja="この引用を外す", de="Dieses Zitat entfernen", es="Quitar esta cita",
ru="Убрать эту цитату", pt="Remover esta citação", ko="이 인용 제거", fr="Retirer cette citation")
add(en="Go to the quoted message",
zh="跳到被引用的消息", ja="引用元のメッセージへ移動", de="Zur zitierten Nachricht springen", es="Ir al mensaje citado",
ru="Перейти к цитируемому сообщению", pt="Ir para a mensagem citada", ko="인용한 메시지로 이동", fr="Aller au message cité")
add(en="Quoted",
zh="引用", ja="引用", de="Zitiert", es="Citado",
ru="Цитата", pt="Citado", ko="인용됨", fr="Cité")
add(en="Show or hide the rest of the quote",
zh="显示或隐藏引用的其余部分", ja="引用の続きを表示または非表示", de="Rest des Zitats ein- oder ausblenden", es="Mostrar u ocultar el resto de la cita",
ru="Показать или скрыть остальную часть цитаты", pt="Mostrar ou ocultar o resto da citação", ko="인용의 나머지 부분 보기 또는 숨기기", fr="Afficher ou masquer la suite de la citation")
# #4244 the agent's own questions
add(en="Skip",
zh="跳过", ja="スキップ", de="Überspringen", es="Omitir",
ru="Пропустить", pt="Pular", ko="건너뛰기", fr="Passer")
add(en="The assistant has a question for you",
zh="助手有一个问题要问你", ja="アシスタントからの質問があります", de="Der Assistent hat eine Frage an dich", es="El asistente tiene una pregunta para ti",
ru="У ассистента есть вопрос к вам", pt="O assistente tem uma pergunta para você", ko="어시스턴트가 질문이 있습니다", fr="L'assistant a une question pour vous")
add(en="Type your own answer",
zh="输入你自己的回答", ja="自分の答えを入力", de="Eigene Antwort eingeben", es="Escribe tu propia respuesta",
ru="Введите свой ответ", pt="Digite a sua própria resposta", ko="직접 답변 입력", fr="Saisissez votre propre réponse")
add(en="Send answer",
zh="发送回答", ja="回答を送る", de="Antwort senden", es="Enviar respuesta",
ru="Отправить ответ", pt="Enviar resposta", ko="답변 보내기", fr="Envoyer la réponse")
add(en="Questions for you",
zh="向你提问", ja="あなたへの質問", de="Fragen an dich", es="Preguntas para ti",
ru="Вопросы к вам", pt="Perguntas para você", ko="내게 하는 질문", fr="Questions pour vous")
add(en="Lets the assistant stop and ask you a question, with answers to pick from, when it needs your decision.",
zh="当助手需要你做决定时,可以暂停并向你提问,并给出可选的答案。", ja="アシスタントがあなたの判断を必要とするとき、選択肢つきで質問できるようにします。",
de="Der Assistent kann anhalten und dir eine Frage mit Antworten zur Auswahl stellen, wenn er deine Entscheidung braucht.",
es="Permite que el asistente se detenga y te haga una pregunta, con respuestas para elegir, cuando necesita tu decisión.",
ru="Позволяет ассистенту остановиться и задать вам вопрос с вариантами ответа, когда ему нужно ваше решение.",
pt="Permite que o assistente pare e faça uma pergunta, com respostas para escolher, quando precisa da sua decisão.",
ko="어시스턴트가 결정이 필요할 때 멈추고 고를 수 있는 답과 함께 질문할 수 있게 합니다.",
fr="Permet à l'assistant de s'arrêter et de vous poser une question, avec des réponses à choisir, quand il a besoin de votre décision.")
here = Path(__file__).resolve().parent.parent / "src" / "locales"
NBSP = " "
def french(text):
text = re.sub(r"(?<=[A-Za-zÀ-ſ])'(?=[A-Za-zÀ-ſ{])", "’", text)
text = re.sub(r"(?<=\S) ([:;?!])", NBSP + r"\1", text)
text = re.sub(r"(?<=[\d}]) %", NBSP + "%", text)
return text
for lang in LANGS:
data = {}
for i, entry in enumerate(S):
text = entry[lang]
assert sorted(re.findall(r"\{\d+\}", entry["en"])) == sorted(re.findall(r"\{\d+\}", text)), (lang, entry["en"], text)
data[f"ui{i + 1:02d}"] = french(text) if lang == "fr" else text
(here / f"ui-{lang}.json").write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
print("written", len(S), "strings in", len(LANGS), "languages")
+125
View File
@@ -0,0 +1,125 @@
"""Writes src/locales/upd-<lang>.json: the strings of the engine update beside a loaded model (the dialog of Settings > About, branch f-0291-ui-final).
Run from apps/host: python scripts/make-update-locales.py
Every entry names all nine languages; the keys are upd01.. in the order of S. upd-en.json is the English source. Placeholders ({0}) must be kept in every
language. A separate file keeps this work from colliding with the other catalogs (the same reason pick-, tune-, tools-, fix-, gh-, ui-, engine- and df2- exist)."""
import json
import re
from pathlib import Path
LANGS = ["en", "zh", "ja", "de", "es", "ru", "pt", "ko", "fr"]
S = []
def add(en, zh, ja, de, es, ru, pt, ko, fr):
S.append(dict(en=en, zh=zh, ja=ja, de=de, es=es, ru=ru, pt=pt, ko=ko, fr=fr))
add("To update the engine, Strata has to stop the model that is loaded: {0}.",
"要更新引擎,Strata 必须先停止当前已加载的模型:{0}。",
"エンジンを更新するには、読み込み中のモデルを Strata が停止する必要があります:{0}。",
"Um die Engine zu aktualisieren, muss Strata das geladene Modell anhalten: {0}.",
"Para actualizar el motor, Strata tiene que detener el modelo cargado: {0}.",
"Чтобы обновить движок, Strata должна остановить загруженную модель: {0}.",
"Para atualizar o motor, o Strata precisa parar o modelo carregado: {0}.",
"엔진을 업데이트하려면 Strata가 불러온 모델을 중지해야 합니다: {0}.",
"Pour mettre à jour le moteur, Strata doit arrêter le modèle chargé : {0}.")
add("The new engine downloads first while the model keeps answering. Then the model stops (answers in progress finish first), the engine is updated, and you can start the model again with the same settings. Your models and settings are kept.",
"新引擎会先下载,此时模型仍可继续回答。然后模型停止(正在进行的回答会先完成),引擎随即更新,之后你可以用相同的设置再次启动该模型。你的模型和设置都会保留。",
"まず新しいエンジンをダウンロードします。その間、モデルは応答を続けます。その後モデルが停止し(進行中の回答は先に完了します)、エンジンが更新され、同じ設定でモデルをもう一度起動できます。モデルと設定はそのまま残ります。",
"Zuerst wird die neue Engine heruntergeladen, während das Modell weiter antwortet. Danach hält das Modell an (laufende Antworten werden zuerst beendet), die Engine wird aktualisiert, und Sie können das Modell mit denselben Einstellungen wieder starten. Ihre Modelle und Einstellungen bleiben erhalten.",
"Primero se descarga el motor nuevo mientras el modelo sigue respondiendo. Después el modelo se detiene (las respuestas en curso terminan antes), se actualiza el motor y puedes volver a iniciar el modelo con los mismos ajustes. Tus modelos y ajustes se conservan.",
"Сначала новый движок скачивается, пока модель продолжает отвечать. Затем модель останавливается (ответы в процессе сначала завершаются), движок обновляется, и вы можете снова запустить модель с теми же настройками. Ваши модели и настройки сохраняются.",
"Primeiro o novo motor é baixado enquanto o modelo continua a responder. Depois o modelo para (as respostas em curso terminam antes), o motor é atualizado e pode iniciar o modelo de novo com as mesmas definições. Os seus modelos e definições são mantidos.",
"먼저 모델이 계속 응답하는 동안 새 엔진을 다운로드합니다. 그다음 모델이 중지되고(진행 중인 답변은 먼저 끝납니다) 엔진이 업데이트되며, 같은 설정으로 모델을 다시 시작할 수 있습니다. 모델과 설정은 그대로 유지됩니다.",
"Le nouveau moteur est d'abord téléchargé pendant que le modèle continue de répondre. Ensuite le modèle s'arrête (les réponses en cours se terminent d'abord), le moteur est mis à jour, et vous pouvez redémarrer le modèle avec les mêmes réglages. Vos modèles et réglages sont conservés.")
add("Stop and update", "停止并更新", "停止して更新", "Anhalten und aktualisieren", "Detener y actualizar", "Остановить и обновить", "Parar e atualizar", "중지하고 업데이트", "Arrêter et mettre à jour")
add("Getting the new engine ready. {0} keeps working until it is ready.",
"正在准备新引擎。在准备好之前,{0} 会继续工作。",
"新しいエンジンを準備しています。準備ができるまで {0} は動き続けます。",
"Die neue Engine wird vorbereitet. {0} arbeitet weiter, bis sie bereit ist.",
"Preparando el motor nuevo. {0} sigue funcionando hasta que esté listo.",
"Готовим новый движок. {0} продолжает работать, пока он не будет готов.",
"A preparar o novo motor. {0} continua a funcionar até estar pronto.",
"새 엔진을 준비하는 중입니다. 준비될 때까지 {0}은(는) 계속 작동합니다.",
"Préparation du nouveau moteur. {0} continue de fonctionner jusqu'à ce qu'il soit prêt.")
add("Stopping {0}. Answers in progress finish first.",
"正在停止 {0}。正在进行的回答会先完成。",
"{0} を停止しています。進行中の回答は先に完了します。",
"{0} wird angehalten. Laufende Antworten werden zuerst beendet.",
"Deteniendo {0}. Las respuestas en curso terminan antes.",
"Останавливаем {0}. Ответы в процессе сначала завершаются.",
"A parar {0}. As respostas em curso terminam antes.",
"{0}을(를) 중지하는 중입니다. 진행 중인 답변은 먼저 끝납니다.",
"Arrêt de {0}. Les réponses en cours se terminent d'abord.")
add("{0} is stopped. Updating the engine…",
"{0} 已停止。正在更新引擎…",
"{0} を停止しました。エンジンを更新しています…",
"{0} ist angehalten. Die Engine wird aktualisiert…",
"{0} está detenido. Actualizando el motor…",
"{0} остановлена. Обновляем движок…",
"{0} está parado. A atualizar o motor…",
"{0}이(가) 중지되었습니다. 엔진을 업데이트하는 중입니다…",
"{0} est arrêté. Mise à jour du moteur…")
add("Updating the engine", "正在更新引擎", "エンジンを更新しています", "Engine wird aktualisiert", "Actualizando el motor", "Обновление движка", "A atualizar o motor", "엔진 업데이트 중", "Mise à jour du moteur")
add("The engine is updated", "引擎已更新", "エンジンを更新しました", "Die Engine ist aktualisiert", "El motor está actualizado", "Движок обновлён", "O motor está atualizado", "엔진이 업데이트되었습니다", "Le moteur est à jour")
add("The engine was not updated", "引擎未更新", "エンジンは更新されませんでした", "Die Engine wurde nicht aktualisiert", "El motor no se actualizó", "Движок не обновлён", "O motor não foi atualizado", "엔진이 업데이트되지 않았습니다", "Le moteur n'a pas été mis à jour")
add("The engine is updated. Start {0} again with the same settings?",
"引擎已更新。要用相同的设置再次启动 {0} 吗?",
"エンジンを更新しました。同じ設定で {0} をもう一度起動しますか?",
"Die Engine ist aktualisiert. {0} mit denselben Einstellungen wieder starten?",
"El motor está actualizado. ¿Iniciar {0} de nuevo con los mismos ajustes?",
"Движок обновлён. Запустить {0} снова с теми же настройками?",
"O motor está atualizado. Iniciar {0} de novo com as mesmas definições?",
"엔진이 업데이트되었습니다. 같은 설정으로 {0}을(를) 다시 시작할까요?",
"Le moteur est à jour. Redémarrer {0} avec les mêmes réglages ?")
add("The update didn't finish. Your old engine is unchanged and still works. Start {0} again on it?",
"更新没有完成。你原来的引擎没有改动,仍然可用。要在它上面再次启动 {0} 吗?",
"更新は完了しませんでした。以前のエンジンは変更されておらず、そのまま使えます。そのエンジンで {0} をもう一度起動しますか?",
"Die Aktualisierung wurde nicht abgeschlossen. Ihre alte Engine ist unverändert und funktioniert weiter. {0} damit wieder starten?",
"La actualización no terminó. Tu motor anterior no cambió y sigue funcionando. ¿Iniciar {0} de nuevo con él?",
"Обновление не завершилось. Прежний движок не изменён и работает. Запустить {0} снова на нём?",
"A atualização não terminou. O seu motor anterior não foi alterado e continua a funcionar. Iniciar {0} de novo com ele?",
"업데이트가 끝나지 않았습니다. 기존 엔진은 그대로이며 계속 작동합니다. 기존 엔진으로 {0}을(를) 다시 시작할까요?",
"La mise à jour ne s'est pas terminée. Votre ancien moteur est inchangé et fonctionne toujours. Redémarrer {0} avec lui ?")
add("Not now", "暂不", "今はしない", "Jetzt nicht", "Ahora no", "Не сейчас", "Agora não", "나중에", "Pas maintenant")
add("Starting {0}. Loading can take a few minutes.",
"正在启动 {0}。加载可能需要几分钟。",
"{0} を起動しています。読み込みには数分かかることがあります。",
"{0} wird gestartet. Das Laden kann einige Minuten dauern.",
"Iniciando {0}. La carga puede tardar unos minutos.",
"Запускаем {0}. Загрузка может занять несколько минут.",
"A iniciar {0}. O carregamento pode demorar alguns minutos.",
"{0}을(를) 시작하는 중입니다. 불러오는 데 몇 분이 걸릴 수 있습니다.",
"Démarrage de {0}. Le chargement peut prendre quelques minutes.")
add("Starting {0} again", "正在再次启动 {0}", "{0} をもう一度起動しています", "{0} wird wieder gestartet", "Iniciando {0} de nuevo", "Снова запускаем {0}", "A iniciar {0} de novo", "{0}을(를) 다시 시작하는 중", "Redémarrage de {0}")
add("The model did not start again", "模型没有重新启动", "モデルを再起動できませんでした", "Das Modell wurde nicht wieder gestartet", "El modelo no se inició de nuevo", "Модель не запустилась снова", "O modelo não voltou a iniciar", "모델이 다시 시작되지 않았습니다", "Le modèle n'a pas redémarré")
add("You can load it again from Models.", "你可以在“模型”中再次加载它。", "「モデル」からもう一度読み込めます。", "Sie können es unter „Modelle“ erneut laden.", "Puedes cargarlo de nuevo desde Modelos.", "Вы можете загрузить её снова в разделе «Модели».", "Pode carregá-lo de novo em Modelos.", "모델 화면에서 다시 불러올 수 있습니다.", "Vous pouvez le recharger depuis Modèles.")
add("A model that was not started from this app is running, so nothing was changed. Stop it, then update the engine again.",
"有一个不是从此应用启动的模型正在运行,因此没有做任何更改。请先停止它,然后再次更新引擎。",
"このアプリから起動していないモデルが動作しているため、何も変更していません。それを停止してから、もう一度エンジンを更新してください。",
"Ein Modell, das nicht aus dieser App gestartet wurde, läuft noch, daher wurde nichts geändert. Halten Sie es an und aktualisieren Sie die Engine dann erneut.",
"Hay un modelo en ejecución que no se inició desde esta aplicación, así que no se cambió nada. Deténlo y vuelve a actualizar el motor.",
"Работает модель, запущенная не из этого приложения, поэтому ничего не изменено. Остановите её и обновите движок снова.",
"Está em execução um modelo que não foi iniciado a partir desta aplicação, por isso nada foi alterado. Pare-o e atualize o motor de novo.",
"이 앱에서 시작하지 않은 모델이 실행 중이어서 아무것도 변경하지 않았습니다. 그 모델을 중지한 다음 엔진을 다시 업데이트하세요.",
"Un modèle qui n'a pas été démarré depuis cette application est en cours d'exécution, rien n'a donc été modifié. Arrêtez-le, puis mettez à nouveau le moteur à jour.")
here = Path(__file__).resolve().parent.parent / "src" / "locales"
NBSP = " "
def french(text):
text = re.sub(r"(?<=[A-Za-zÀ-ſ])'(?=[A-Za-zÀ-ſ{])", "’", text)
text = re.sub(r"(?<=\S) ([:;?!])", NBSP + r"\1", text)
return text
for lang in LANGS:
data = {}
for i, entry in enumerate(S):
text = entry[lang]
assert sorted(re.findall(r"\{\d+\}", entry["en"])) == sorted(re.findall(r"\{\d+\}", text)), (lang, entry["en"], text)
data[f"upd{i + 1:02d}"] = french(text) if lang == "fr" else text
with (here / f"upd-{lang}.json").open("w", encoding="utf-8", newline="\n") as handle:
handle.write(json.dumps(data, ensure_ascii=False, indent=1) + "\n")
print("written", len(S), "strings in", len(LANGS), "languages")
+2 -4
View File
@@ -20,15 +20,13 @@ import { chromium } from 'playwright-core';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { requireChromium } from './browser.mjs';
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
const MIN = 45;
const MAX = 75;
const executablePath = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
].find((p) => existsSync(p));
const executablePath = requireChromium();
if (!executablePath) {
console.error('No cached Chromium found.');
+2 -4
View File
@@ -21,6 +21,7 @@ import { promisify } from 'node:util';
import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { requireChromium } from './browser.mjs';
const run = promisify(execFile);
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
@@ -28,10 +29,7 @@ const ROOT = resolve(process.env.STRATA_SHOTS ?? '.shots');
const TMP = join(ROOT, 'motion-frames');
mkdirSync(TMP, { recursive: true });
const executablePath = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
].find((p) => existsSync(p));
const executablePath = requireChromium();
const ffmpeg = [
join(homedir(), 'Library/Caches/ms-playwright/ffmpeg-1011/ffmpeg-mac'),
+2 -4
View File
@@ -20,14 +20,12 @@ import { chromium } from 'playwright-core';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { requireChromium } from './browser.mjs';
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
const BUDGET = 1.2;
const executablePath = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
].find((p) => existsSync(p));
const executablePath = requireChromium();
if (!executablePath) {
console.error('No cached Chromium found. Run `npm run shot -- x.png <url>` once to confirm the harness.');
+793
View File
@@ -0,0 +1,793 @@
#!/usr/bin/env node
/**
* Drives the built app in a headless Chrome against a real host with a clean temporary profile (no engine, no model), for the 0.2.10
* interface changes (branch f-0291-ui). Each case seeds the chat history it needs into the page's local storage, so the real screens
* draw real data without a model.
*
* npx vite build && node scripts/ui-do-drive.mjs <shots-dir> [case ...]
*
* Never point it at a real install: the host it starts uses a fresh STRATA_HOME in the temp folder and a random port.
* Light and dark, at a normal and a narrow window. Prints "ok" / "FAIL" lines and exits 1 on a failure.
*/
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { startScriptedWorker } from '../tests/helpers/scripted-worker.mjs';
const HOST_DIR = resolve(import.meta.dirname, '..');
const SHOTS = resolve(process.argv[2] ?? 'ui-shots');
const ONLY = process.argv.slice(3);
mkdirSync(SHOTS, { recursive: true });
const { chromium } = await import(pathToFileURL(join(HOST_DIR, 'node_modules/playwright-core/index.mjs')).href);
const CHROME = process.env.CHROME ?? ['C:/Program Files/Google/Chrome/Application/chrome.exe', '/usr/bin/google-chrome'].find(existsSync);
const freePort = () => new Promise(r => { const s = createServer(); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => r(p)); }); });
const sleep = ms => new Promise(r => setTimeout(r, ms));
let failed = false;
export const check = (ok, label, extra = '') => { console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${extra ? ' ' + extra : ''}`); if (!ok) failed = true; };
const home = mkdtempSync(join(tmpdir(), 'strata-ui-do-'));
const ws = join(home, 'workspace'); mkdirSync(ws, { recursive: true });
const port = await freePort();
const env = { ...process.env, STRATA_HOME: home, STRATA_PORT: String(port), STRATA_WORKSPACE: ws, STRATA_REAL_ENGINE: '0', STRATA_COMPONENTS_DIR: join(home, 'components'), STRATA_SETTINGS_FILE: join(home, 'app-settings.json'), LOCALAPPDATA: join(home, 'Local'), APPDATA: join(home, 'Roaming'), XDG_DATA_HOME: join(home, 'xdg-data'), XDG_CONFIG_HOME: join(home, 'xdg-config'), XDG_STATE_HOME: join(home, 'xdg-state') };
delete env.STRATA_HARNESS_DIR;
const child = spawn(process.execPath, [join(HOST_DIR, 'server', 'index.mjs')], { cwd: HOST_DIR, env, stdio: 'ignore', windowsHide: true });
export const base = `http://127.0.0.1:${port}`;
for (let i = 0; i < 100; i++) { try { if ((await fetch(`${base}/api/health`)).ok) break; } catch { /* starting */ } await sleep(200); }
// A saved API connection to a scripted model gives the app a model to talk to, so it stays on the chat screen (no model loaded would send it to Models).
const worker = await startScriptedWorker({ model: 'cloud-model' });
const saved = await (await fetch(`${base}/api/model-providers`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'custom', name: 'Scripted API', baseUrl: worker.url, protocol: 'openai-chat', apiKey: 'sk-ui-do-test-0123456789', models: [{ id: 'cloud-model', name: 'Cloud model', contextMax: 64000 }] }) })).json();
const remoteModel = { connectionId: saved.connections?.[0]?.id, modelId: 'cloud-model' };
check(!!remoteModel.connectionId, 'a scripted API model is connected');
// "Go straight to the app": the first-run screens are not what these cases look at.
await fetch(`${base}/api/setup/step`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ step: 1, dismissed: true, flowVersion: 2 }) });
const browser = await chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
const iso = (n = 0) => new Date(Date.UTC(2026, 9, 31, 9, 0, n)).toISOString();
export const user = (id, content, n = 0) => ({ id, role: 'user', content, at: iso(n) });
export const reply = (id, content, extra = {}) => ({ id, role: 'assistant', content, at: iso(5), tokens: 120, tps: 40, durationMs: 3000, ...extra });
export const chat = (id, title, messages) => ({ id, title, defaultTitle: false, updatedAt: iso(9), createdAt: iso(0), messages, workspace: ws, tokensIn: 0, tokensOut: 0, preferences: { useTools: true, mode: 'manual', remoteModel } });
/** A page with the given chats already in local storage. `prefs` are merged into the saved preferences. */
export async function open(scheme, width, height, sessions, { hash = '#/chat', prefs = {}, origin = base, before = null, locale = 'en-US', keepSetup = false } = {}) {
const context = await browser.newContext({ viewport: { width, height }, colorScheme: scheme, locale, permissions: ['clipboard-read', 'clipboard-write'] });
const page = await context.newPage();
pages.push(page);
const problems = [];
page.on('pageerror', e => problems.push(e.message));
page.problems = problems;
await page.addInitScript(({ sessions, prefs }) => {
try {
if (sessionStorage.getItem('ui-do-seeded')) return;
sessionStorage.setItem('ui-do-seeded', '1');
if (sessions) localStorage.setItem('strata.sessions.v1', JSON.stringify(sessions));
if (Object.keys(prefs).length) localStorage.setItem('strata.prefs.v1', JSON.stringify(prefs));
} catch { /* storage refused */ }
}, { sessions, prefs });
if (before) await before(page);
await page.goto(`${origin}/${hash}`);
await page.waitForTimeout(1800);
const skip = page.getByText('Go straight to the app');
if (!keepSetup && await skip.count()) { await skip.click(); await page.waitForTimeout(600); }
return page;
}
export const shot = (page, name) => page.screenshot({ path: join(SHOTS, `${name}.png`) });
export const sizes = { wide: [1280, 860], narrow: [390, 800] };
export const cases = {};
const pages = [];
/* ------------------------------------------------------------------------------------------------------------------- #4725 */
const longReply = Array.from({ length: 70 }, (_, i) => `Paragraph ${i + 1}. This is part of a long answer from the assistant that goes on for many screens before it asks to change a file in the project.`).join('\n\n');
const askTool = (id, state = 'proposed', file = 'notes.txt') => ({ id, name: 'write', args: { file_path: file }, preview: `write ${file}\n\na\nb\nc`, effect: 'Creates a file.', scope: 'write', verb: 'Create a file', target: file, state, ask: { id: 'create-file', params: [file], why: 'replaces-if-exists' }, live: false });
const plainTool = (id, state = 'proposed') => ({ id, name: 'bash', args: {}, preview: 'npm install left-pad', effect: 'Runs a command on your computer.', scope: 'shell', verb: 'Run a command', target: 'npm install left-pad', state });
cases.approval = async () => {
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
const sessions = [chat('s1', 'Long reply', [user('u1', 'Please write notes.txt'), reply('a1', longReply, { tools: [askTool('t1')] })])];
const page = await open(scheme, w, h, sessions);
const dock = page.locator('.approvaldock');
await dock.waitFor({ timeout: 15000 });
const view = page.viewportSize();
const inView = async sel => { const b = await page.locator(sel).first().boundingBox(); return !!b && b.y >= 0 && b.y + b.height <= view.height + 1 && b.x >= 0 && b.x + b.width <= view.width + 1; };
check(await page.locator('.askcard').count() === 1, `${tag}: one Approve/Deny card on the page`);
check(await page.getByRole('button', { name: 'Approve' }).count() === 1 && await page.getByRole('button', { name: 'Deny' }).count() === 1, `${tag}: one Approve and one Deny`);
check(await inView('.approvaldock'), `${tag}: the card is inside the window at the end of the reply`);
const scroller = page.locator('.main__body.chat');
const atEnd = () => scroller.evaluate(el => el.scrollHeight - el.scrollTop - el.clientHeight);
check(await atEnd() < 4, `${tag}: the reply is still scrolled to its end`, String(await atEnd()));
check(await page.locator('.approval-wait').count() === 1, `${tag}: the reply says it waits for an answer`);
await shot(page, `4725-${tag}-end`);
await scroller.evaluate(el => { el.scrollTop = 0; });
await page.waitForTimeout(300);
check(await inView('.approvaldock'), `${tag}: the card stays in the window when the reader is at the top`);
const composerTop = await page.locator('.composer .st-composer').boundingBox();
const dockBox = await dock.boundingBox();
check(dockBox.y + dockBox.height <= composerTop.y, `${tag}: the card sits above the message box`);
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
await shot(page, `4725-${tag}-top`);
await page.getByRole('button', { name: 'Approve' }).click();
await page.waitForTimeout(500);
check(await page.locator('.approvaldock').count() === 0, `${tag}: the card goes away after Approve`);
check(await page.locator('.approval-wait').count() === 0, `${tag}: and so does the waiting line`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
// Two waiting at once, one in each card style; Deny works from the dock.
const sessions = [chat('s1', 'Two waiting', [user('u1', 'Do two things'), reply('a1', longReply.slice(0, 1500), { tools: [askTool('t1'), plainTool('t2')] })])];
const page = await open('light', 1280, 860, sessions);
await page.locator('.approvaldock').waitFor({ timeout: 15000 });
check(await page.locator('.approvaldock > section').count() === 2, 'two waiting: both cards are in the dock');
await shot(page, '4725-two-waiting');
await page.getByRole('button', { name: 'Deny' }).first().click();
await page.waitForTimeout(400);
check(await page.locator('.approvaldock > section').count() === 1, 'two waiting: Deny on the first leaves the second');
await page.context().close();
};
/* ------------------------------------------------------------------------------------------------------------------- #4748 */
const codeReply = 'Here is a command.\n\n```bash\nnpm install left-pad\n```\n\nThat is all.';
cases.copy = async () => {
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
const sessions = [chat('s1', 'Copy', [user('u1', 'Show me a command'), reply('a1', codeReply), user('u2', 'Thanks'), reply('a2', 'You are welcome. Second reply.')])];
const page = await open(scheme, w, h, sessions);
await page.locator('.st-msg--assistant').last().waitFor({ timeout: 15000 });
const flag = async loc => loc.evaluate(el => ({ copied: el.hasAttribute('data-copied'), icon: el.querySelector('use')?.getAttribute('href') ?? '' }));
const read = () => page.evaluate(() => navigator.clipboard.readText());
// The last reply's copy button (an icon in the action row).
const copyResponse = page.getByRole('button', { name: 'Copy response' });
await copyResponse.last().click();
await page.mouse.move(2, 2);
await page.waitForTimeout(250);
let f = await flag(copyResponse.last());
check(f.copied && f.icon.endsWith('i-check'), `${tag}: Copy response turns into a check mark`, JSON.stringify(f));
check((await read()) === 'You are welcome. Second reply.', `${tag}: the reply is on the clipboard`);
const row = page.locator('.st-msg--assistant').last().locator('.st-msg__actions');
check(await row.evaluate(el => getComputedStyle(el).opacity) === '1', `${tag}: the action row stays visible while it says so, mouse away`);
check(/Copied/.test(await page.locator('.st-msg--assistant').last().locator('[role=status]').first().innerText()), `${tag}: a screen reader hears Copied`);
await shot(page, `4748-${tag}-reply`);
await page.waitForTimeout(2200);
f = await flag(copyResponse.last());
check(!f.copied && f.icon.endsWith('i-copy'), `${tag}: after about two seconds it is a Copy button again`, JSON.stringify(f));
// The code block.
const code = page.locator('.st-code__head').getByRole('button', { name: 'Copy code' });
await code.click();
await page.waitForTimeout(250);
f = await flag(code);
check(f.copied && f.icon.endsWith('i-check'), `${tag}: the code block's Copy turns into a check mark`, JSON.stringify(f));
check((await read()) === 'npm install left-pad', `${tag}: the code is on the clipboard`);
await shot(page, `4748-${tag}-code`);
await page.waitForTimeout(2200);
// The menu item of an earlier reply: says Copied, then the menu closes.
await page.locator('.st-msg--assistant').first().hover();
await page.locator('.st-msg--assistant').first().getByRole('button', { name: 'More response actions' }).click();
const item = page.getByRole('menuitem', { name: 'Copy response' });
await item.click();
await page.waitForTimeout(250);
check(/Copied/.test(await page.locator('.menu [data-copied]').innerText()), `${tag}: the menu item says Copied`);
check((await read()).startsWith('Here is a command'), `${tag}: the earlier reply is on the clipboard`);
await shot(page, `4748-${tag}-menu`);
await page.waitForTimeout(1300);
check(await page.locator('.menu').count() === 0, `${tag}: and the menu closes by itself`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
// A refused clipboard: nothing says Copied on the button, and the person is told in a toast.
{
const page = await open('light', 1280, 860, [chat('s1', 'Refused', [user('u1', 'Hi'), reply('a1', 'Hello there.')])]);
await page.locator('.st-msg--assistant').last().waitFor({ timeout: 15000 });
await page.evaluate(() => { Object.defineProperty(navigator, 'clipboard', { value: { writeText: () => Promise.reject(new Error('denied')) }, configurable: true }); document.execCommand = () => false; });
const b = page.getByRole('button', { name: 'Copy response' }).last();
await b.click();
await page.waitForTimeout(400);
check(await b.evaluate(el => !el.hasAttribute('data-copied')), 'refused clipboard: the button does not claim success');
check(/Couldn.t copy/.test(await page.locator('.st-toast').first().innerText()), 'refused clipboard: a toast says it did not work');
await page.context().close();
}
// The Serving screen: the address and the request template.
for (const scheme of ['light', 'dark']) {
const page = await open(scheme, 1280, 860, [chat('s1', 'Serving', [user('u1', 'Hi'), reply('a1', 'Hello there.')])], { hash: '#/serving' });
const c = page.getByRole('button', { name: 'Copy API base URL' });
await c.waitFor({ timeout: 15000 });
await c.click();
await page.waitForTimeout(250);
check(await c.evaluate(el => el.hasAttribute('data-copied') && (el.querySelector('use')?.getAttribute('href') ?? '').endsWith('i-check')), `${scheme}: Serving, Copy API base URL turns into a check mark`);
check(/^http:\/\/127\.0\.0\.1:\d+\/v1$/.test(await page.evaluate(() => navigator.clipboard.readText())), `${scheme}: Serving, the address is on the clipboard`);
await shot(page, `4748-serving-${scheme}`);
await page.getByRole('button', { name: 'Connection guide' }).click();
const t = page.getByRole('button', { name: 'Copy request template' });
await t.click();
await page.waitForTimeout(250);
check(await t.evaluate(el => el.hasAttribute('data-copied')), `${scheme}: Serving, Copy request template turns into a check mark`);
check((await page.evaluate(() => navigator.clipboard.readText())).startsWith('curl http'), `${scheme}: Serving, the template is on the clipboard`);
await page.context().close();
}
};
/* ------------------------------------------------------------------------------------------------------------------- #4804 */
let tuneHostInfo = null;
async function tuneHost() {
if (tuneHostInfo) return tuneHostInfo;
const tport = await freePort();
const proc = spawn(process.execPath, [join(HOST_DIR, 'tests/fixtures/tune-ui-host.mjs')], { cwd: HOST_DIR, env: { ...process.env, TUNE_UI_PORT: String(tport), TUNE_UPSTREAM: base, TUNE_SHOT_DIR: SHOTS }, stdio: 'ignore', windowsHide: true });
const origin = `http://127.0.0.1:${tport}`;
for (let i = 0; i < 100; i++) { try { if ((await fetch(`${origin}/api/native`)).ok) break; } catch { /* starting */ } await sleep(200); }
tuneHostInfo = { origin, proc, mode: m => fetch(`${origin}/__fixture/status-mode`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: m }) }) };
return tuneHostInfo;
}
cases.tune = async () => {
const t = await tuneHost();
const sessions = [chat('s1', 'Tune', [user('u1', 'Hi'), reply('a1', 'Hello there.')])];
const openDialog = async (scheme, w, h, mode, inject = false) => {
await t.mode(mode);
const page = await open(scheme, w, h, sessions, { hash: '#/models', origin: t.origin });
if (inject) await page.route(/\/api\/native(\?.*)?$/, async route => {
if (!page.injectJob) return route.continue();
const r = await route.fetch(); const j = await r.json();
j.jobs = [...(j.jobs ?? []), { id: 'job-x', kind: 'download', state: 'running', startedAt: new Date().toISOString(), summary: 'Downloading the model', error: null, result: null, progress: { text: 'Downloading', percent: 12 } }];
return route.fulfill({ response: r, json: j });
});
const tune = page.getByRole('button', { name: 'Tune model' });
await tune.waitFor({ timeout: 20000 });
for (let i = 0; i < 40 && await tune.isDisabled(); i++) await page.waitForTimeout(250);
await tune.click();
await page.getByRole('dialog').waitFor({ timeout: 10000 });
return page;
};
const start = page => page.getByRole('button', { name: 'Start tuning' });
// The sentence must be on screen without scrolling the dialog, beside the button (above it when the window is narrow).
const seen = async (page, w, h) => {
const s = await page.locator('.tune-why').boundingBox(), b = await start(page).boundingBox();
return !!s && !!b && s.y >= 0 && s.y + s.height <= h && s.x >= 0 && s.x + s.width <= w && Math.abs((s.y + s.height / 2) - (b.y + b.height / 2)) < 110;
};
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
// 1. The saved tunings are still being read: greyed out, with the reason, and not "No saved tuning".
let page = await openDialog(scheme, w, h, 'slow');
await page.waitForTimeout(1200);
check(await start(page).isDisabled(), `${tag}: loading: Start tuning is greyed out`);
let why = await page.locator('.tune-why').innerText().catch(() => '');
check(/still checking this model for saved tunings/.test(why), `${tag}: loading: one sentence says why`, why);
check(await page.locator('.tune-why').count() === 1, `${tag}: loading: only one such sentence`);
check(!/No saved tuning for these choices yet/.test(await page.getByRole('dialog').innerText()), `${tag}: loading: it does not claim there is no saved tuning`);
check(await seen(page, w, h), `${tag}: loading: the sentence is on screen next to the button`);
await shot(page, `4804-${tag}-loading`);
await page.context().close();
// 2. Reading them failed.
page = await openDialog(scheme, w, h, 'fail');
await page.waitForTimeout(1200);
check(await start(page).isDisabled(), `${tag}: unreadable: Start tuning is greyed out`);
why = await page.locator('.tune-why').innerText().catch(() => '');
check(/could not read the tuning details/.test(why), `${tag}: unreadable: one sentence says why`, why);
check(await seen(page, w, h), `${tag}: unreadable: the sentence is on screen next to the button`);
check(!/No saved tuning/.test(await page.getByRole('dialog').innerText()), `${tag}: unreadable: it does not claim there is no saved tuning`);
await shot(page, `4804-${tag}-unreadable`);
await page.context().close();
// 3. Another operation is running.
page = await openDialog(scheme, w, h, 'ok', true);
await page.waitForTimeout(800);
check(await start(page).isEnabled() && await page.locator('.tune-why').count() === 0, `${tag}: ready: Start tuning works and shows no sentence`);
page.injectJob = true;
await page.waitForTimeout(3500);
check(await start(page).isDisabled(), `${tag}: another operation: Start tuning is greyed out`);
why = await page.locator('.tune-why').innerText().catch(() => '');
check(/Finish the current operation first/.test(why), `${tag}: another operation: one sentence says why`, why);
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
check(await seen(page, w, h), `${tag}: other: the sentence is on screen next to the button`);
await shot(page, `4804-${tag}-other`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
};
/* ------------------------------------------------------------------------------------------------------------------- #4721 */
cases.links = async () => {
const sessions = () => [chat('s1', 'Links', [user('u1', 'Show links'), reply('a1', `Read [the docs](https://example.com/docs) or open [the logo](${base}/icons/sprite.svg).`)])];
for (const scheme of ['light', 'dark']) {
// A browser tab: the browser opens the address in a new tab; a file stays in the side panel.
let page = await open(scheme, 1280, 860, sessions());
await page.getByRole('link', { name: 'the docs' }).waitFor({ timeout: 15000 });
await page.evaluate(() => { window.__opened = []; window.open = (...a) => { window.__opened.push(a); return null; }; });
await page.getByRole('link', { name: 'the docs' }).click();
await page.waitForTimeout(300);
let opened = await page.evaluate(() => window.__opened);
check(JSON.stringify(opened) === JSON.stringify([['https://example.com/docs', '_blank', 'noopener,noreferrer']]), `${scheme}: a browser tab: a plain click on a web address opens it in a new tab`, JSON.stringify(opened));
check(await page.locator('.artifact-panel').count() === 0, `${scheme}: a browser tab: and no side panel opens`);
await page.getByRole('link', { name: 'the logo' }).click();
await page.locator('.artifact-panel').waitFor({ timeout: 8000 });
check((await page.evaluate(() => window.__opened)).length === 1, `${scheme}: a file link keeps the side panel and opens no tab`);
await shot(page, `4721-${scheme}-file`);
await page.context().close();
// The app window: the host hands the address to the default browser; window.open is not used.
const posts = [];
page = await open(scheme, 1280, 860, sessions(), { before: async p => {
await p.route('**/api/app/mode', r => r.fulfill({ json: { mode: 'window', reason: null, canQuit: true, appUpdate: 'unavailable' } }));
await p.route('**/api/artifacts/open', r => { posts.push(r.request().postDataJSON()); return r.fulfill({ json: { ok: true } }); });
} });
await page.getByRole('link', { name: 'the docs' }).waitFor({ timeout: 15000 });
await page.evaluate(() => { window.__opened = []; window.open = (...a) => { window.__opened.push(a); return null; }; });
await page.getByRole('link', { name: 'the docs' }).click();
await page.waitForTimeout(400);
check(JSON.stringify(posts) === JSON.stringify([{ action: 'open', url: 'https://example.com/docs' }]), `${scheme}: the app window: the host is asked to open it in the default browser`, JSON.stringify(posts));
check((await page.evaluate(() => window.__opened)).length === 0 && await page.locator('.artifact-panel').count() === 0, `${scheme}: the app window: nothing else happens`);
check(page.problems.length === 0, `${scheme}: no page error`, page.problems.join('; '));
await page.context().close();
}
};
/* ------------------------------------------------------------------------------------------------------------------- #4802 */
cases.progress = async () => {
const t = await tuneHost();
await t.mode('ok');
const sessions = [chat('s1', 'Progress', [user('u1', 'Hi'), reply('a1', 'Hello there.')])];
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
const page = await open(scheme, w, h, sessions, { hash: '#/models', origin: t.origin });
const started = new Date(Date.now() - (3 * 60 + 12) * 1000).toISOString();
await page.route(/\/api\/native(\?.*)?$/, async route => {
const r = await route.fetch(); const j = await r.json();
j.jobs = [...(j.jobs ?? []), { id: 'job-x', kind: 'install', model: 'iq2_xs', state: 'running', startedAt: started, summary: 'Preparing', error: null, result: { install: { step: '6/7', label: 'Preparing the model data for the engine (one time)', parts: [{ label: 'Model files', done_gb: 0.6, total_gb: 0.6, state: 'done' }], download: { file: 'model files', done_gb: 0.6, total_gb: 0.6, remaining_gb: 0, percent: 99 } } }, progress: null }];
return route.fulfill({ response: r, json: j });
});
await page.reload();
const card = page.locator('.native-progress');
await card.waitFor({ timeout: 20000 });
await page.waitForTimeout(1500);
check(await card.locator('.spinner').count() === 1, `${tag}: a spinner shows that it is working`);
const alive = () => card.locator('.native-progress__alive').innerText();
const a1 = await alive();
check(/^Still working for 3m \d+s\. You don't need to do anything\.$/.test(a1), `${tag}: it says how long and that nothing is needed`, a1);
await page.waitForTimeout(2200);
const a2 = await alive();
check(a1 !== a2, `${tag}: the time keeps counting`, `${a1} -> ${a2}`);
check(/The buttons below wait until this finishes/.test(await page.locator('.native-wait-note').innerText()), `${tag}: a sentence says why the buttons are greyed out`);
const border = await card.evaluate(el => getComputedStyle(el).borderTopColor);
const other = await page.locator('.st-card:not(.native-progress)').first().evaluate(el => getComputedStyle(el).borderTopColor);
check(border !== other, `${tag}: the card has its own border colour`, `${border} vs ${other}`);
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
await card.scrollIntoViewIfNeeded();
await shot(page, `4802-${tag}`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
};
/* ------------------------------------------------------------------------------------------------------------------- #4798 */
cases.serving = async () => {
const sessions = [chat('s1', 'Serving', [user('u1', 'Hi'), reply('a1', 'Hello there.')])];
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
// Sharing off: one address, no extra line. Sharing on: the app's own address and the one for other devices, with one sentence.
for (const sharing of [false, true]) {
const page = await open(scheme, w, h, sessions, { hash: '#/serving', before: async p => {
if (!sharing) return;
await p.route(/\/api\/serving$/, async route => {
if (route.request().method() !== 'GET') return route.continue();
const r = await route.fetch(); const j = await r.json();
return route.fulfill({ response: r, json: { ...j, localUrl: 'http://127.0.0.1:8079/v1', baseUrl: 'http://100.76.61.73:8080/v1', sharing: { active: true, url: 'http://100.76.61.73:8080/v1' } } });
});
} });
await page.getByText('API base URL', { exact: false }).first().waitFor({ timeout: 15000 });
await page.waitForTimeout(800);
const text = await page.locator('.serving-overview').innerText();
if (sharing) {
check(/API base URL on this computer/.test(text) && text.includes('http://127.0.0.1:8079/v1'), `${tag}: sharing on: the app's own address is shown`);
check(/Address for other devices/.test(text) && text.includes('http://100.76.61.73:8080/v1'), `${tag}: sharing on: and the address for other devices`);
check(await page.locator('.serving-two-addresses').count() === 1 && /its own port/.test(await page.locator('.serving-two-addresses').innerText()), `${tag}: sharing on: one sentence says why they differ`);
const t = page.getByRole('button', { name: 'Copy local network URL' });
await t.click(); await page.waitForTimeout(250);
check((await page.evaluate(() => navigator.clipboard.readText())) === 'http://100.76.61.73:8080/v1', `${tag}: sharing on: the copy button gives the network address`);
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: sharing on: no sideways scroll`);
await page.locator('.serving-overview').scrollIntoViewIfNeeded();
await shot(page, `4798-${tag}-sharing`);
} else {
check(!/Address for other devices/.test(text) && await page.locator('.serving-two-addresses').count() === 0, `${tag}: sharing off: one address, no extra line`);
}
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
}
};
/* ------------------------------------------------------------------------------------------------------------------- #4722 */
cases.width = async () => {
const text = '用中文写一段九十字左右的段落,说明本地模型为什么适合处理私人文件,并且不需要把任何内容上传到网络上去,这样可以让人放心。\n\n- 第一条要点同样写得比较长,用来观察列表项在气泡里到底会排到多宽才换行,看看它是否与上面的段落一样。\n- 第二条要点也一样长,看看两条要点是不是排得整齐,不会比段落更宽也不会更窄。\n\nAnd an English paragraph that is long enough to wrap onto at least two lines so that the measure of the prose can be read off it by the test.\n\n1. A numbered point in English that is also long enough to wrap onto a second line in the same bubble as the paragraph above it.\n2. Another numbered point.\n\n| Name | Value |\n| --- | --- |\n| a long cell that is wide | another long cell that is wide too |\n\n```bash\nnpm install a-very-long-package-name another-very-long-package-name yet-another-long-package-name --save-dev --legacy-peer-deps\n```';
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
const page = await open(scheme, w, h, [chat('s1', 'Width', [user('u1', 'Write'), reply('a1', text)])]);
await page.locator('.st-bubble ul').first().waitFor({ timeout: 15000 });
const m = await page.evaluate(() => {
const b = document.querySelector('.st-msg--assistant .st-bubble'), r = el => Math.round(el.getBoundingClientRect().width);
return { bubble: r(b), p: r(b.querySelector('p')), ul: r(b.querySelector('ul')), ol: r(b.querySelector('ol')), table: r(b.querySelector('.md-table-wrap')), code: r(b.querySelector('.st-code')) };
});
const note = JSON.stringify(m);
if (w > 700) {
check(Math.abs(m.ul - m.p) <= 2 && Math.abs(m.ol - m.p) <= 2, `${tag}: bullets and numbers have the paragraph's width`, note);
check(m.table > m.p + 20 && m.code > m.p + 20, `${tag}: the table and the code block keep the full width`, note);
} else {
check(Math.abs(m.ul - m.p) <= 2 && m.p <= m.bubble, `${tag}: narrow window: everything fits the column`, note);
}
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
await shot(page, `4722-${tag}`);
await page.context().close();
}
};
/* ------------------------------------------------------------------------------------------------------------------- #3723 */
cases.firstrun = async () => {
const setupStep = dismissed => fetch(`${base}/api/setup/step`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ step: 1, dismissed, flowVersion: 2 }) });
await setupStep(false);
// A computer set to a language Strata does not have: English.
const page = await open('light', 1280, 860, null, { hash: '#/setup', locale: 'sv-SE', keepSetup: true });
await page.locator('.onboard-language').waitFor({ timeout: 20000 });
check(/English/.test(await page.locator('.onboard-language').innerText()), 'Swedish computer: English');
await page.context().close();
// A first start (nothing saved) on a computer set to Chinese opens in Chinese, with a language choice on the first screen.
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
const page = await open(scheme, w, h, null, { hash: '#/setup', locale: 'zh-CN', keepSetup: true });
await page.locator('.onboard-language').waitFor({ timeout: 20000 });
await page.waitForTimeout(500);
const pick = page.locator('.onboard-language [role=combobox], .onboard-language button').first();
const shown = await pick.innerText();
check(/中文/.test(shown), `${tag}: a computer set to Chinese starts in Chinese`, shown);
check(/[\u4e00-\u9fff]/.test(await page.locator('.guided-onboarding h1').first().innerText()), `${tag}: the first screen is in Chinese`);
const box = await page.locator('.onboard-language').boundingBox();
check(!!box && box.x >= 0 && box.x + box.width <= w && box.y >= 0, `${tag}: the choice is on screen`, JSON.stringify(box));
const brand = await page.locator('.guided-onboarding .onboard-header .brand').first().boundingBox();
check(!brand || box.x >= brand.x + brand.width - 1 || box.x + box.width <= brand.x + 1 || box.y >= brand.y + brand.height || box.y + box.height <= brand.y, `${tag}: the choice does not cover the logo`, JSON.stringify({ brand, box }));
await shot(page, `3723-${tag}-first-zh`);
// Choosing German changes the screen at once, and it is kept.
await pick.click();
await page.getByRole('option', { name: 'Deutsch' }).click();
await page.waitForTimeout(500);
check(/[A-Za-zäöü]/.test(await page.locator('.guided-onboarding h1').first().innerText()) && !/[\u4e00-\u9fff]/.test(await page.locator('.guided-onboarding h1').first().innerText()), `${tag}: choosing Deutsch changes the first screen`);
await shot(page, `3723-${tag}-first-de`);
await page.reload();
await page.locator('.onboard-language').waitFor({ timeout: 20000 });
check(/Deutsch/.test(await page.locator('.onboard-language').innerText()), `${tag}: the choice is kept after a restart, whatever the computer says`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
await setupStep(true);
};
cases.kv = async () => {
const t = await tuneHost();
await t.mode('ok');
const sessions = [chat('s1', 'KV', [user('u1', 'Hi'), reply('a1', 'Hello there.')])];
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
const page = await open(scheme, w, h, sessions, { hash: '#/models', origin: t.origin });
await page.route(/\/api\/native(\?.*)?$/, async route => {
const r = await route.fetch(); const j = await r.json();
j.jobs = [...(j.jobs ?? []), { id: 'job-f', kind: 'load', model: 'iq2_xs', state: 'error', startedAt: new Date().toISOString(), summary: 'Load failed', error: "server_exited (exit code 1): EngineDied: the engine exited before it was ready | the engine process ended: exit code 1: \nthe engine log's last lines:\n strata generate: expert cache auto: 1.92 GiB free, 2048 MiB reserved -> 0 slots\n strata generate: no VRAM is left for the expert cache: it needs at least 332 slots (737 MiB), about 1000 MiB more than this card has free.", result: null, progress: null }];
return route.fulfill({ response: r, json: j });
});
await page.reload();
const expert = page.getByRole('button', { name: 'Expert mode' });
await expert.waitFor({ timeout: 20000 });
await page.waitForTimeout(1500);
await expert.click();
const sel = page.getByRole('combobox', { name: 'Native KV format' });
if (!await sel.isVisible().catch(() => false)) { await page.waitForTimeout(800); if (!await sel.isVisible().catch(() => false)) await page.getByRole('button', { name: /expert mode/i }).first().click(); }
await sel.waitFor({ timeout: 10000 });
const hint = page.locator('.native-kv-hint');
check(/INT8 is the standard/.test(await hint.innerText()), `${tag}: Context memory: one line says what the chosen format is`);
await sel.click();
check(await page.getByRole('option').count() === 3, `${tag}: Context memory: three options`);
const titles = await page.locator('.select__option-label').evaluateAll(els => els.map(e => e.getAttribute('title')));
check(titles.length === 3 && titles.every(x => x && x.length > 20), `${tag}: Context memory: each option carries its line`, JSON.stringify(titles));
await page.getByRole('option', { name: 'Q4' }).click();
await page.waitForTimeout(300);
check(/Q4 uses about half the memory/.test(await hint.innerText()), `${tag}: Context memory: the line follows the choice`);
await hint.scrollIntoViewIfNeeded();
await shot(page, `3723-${tag}-kv`);
const notice = page.locator('.error-notice');
await notice.first().waitFor({ timeout: 10000 });
const said = await notice.first().innerText();
check(/did not fit in your graphics card.s memory/.test(said) && /Context memory to Q4/.test(said), `${tag}: a start that ran out of graphics memory says so in plain words`, said.replace(/\n+/g, ' | ').slice(0, 200));
await notice.first().scrollIntoViewIfNeeded();
await shot(page, `3723-${tag}-vram`);
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
};
/* ------------------------------------------------------------------------------------------------------------------- #4749 */
cases.helper = async () => {
const agents = [
{ id: 'child-1', name: 'Check the tests', description: 'All 12 tests pass.', status: 'completed', durationMs: 42000, tokens: 1800, updatedAt: Date.now() },
{ id: 'child-2', name: 'Write the notes', description: 'Could not write notes.txt', status: 'failed', durationMs: 9000, tokens: 400, updatedAt: Date.now() },
];
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
const sessions = [{ ...chat('s1', 'Helpers', [user('u1', 'Do two things'), reply('a1', 'Both helpers are on it.')]), agents }];
const posts = []; let refuse = false;
const page = await open(scheme, w, h, sessions, { before: async p => {
await p.route('**/api/chat/helper', async r => { posts.push(r.request().postDataJSON()); if (refuse) return r.fulfill({ status: 409, json: { error: 'This helper cannot take a message any more. Ask the assistant in the chat to start a new one.' } }); return r.fulfill({ json: { ok: true, messageId: 'm1' } }); });
} });
await page.getByRole('button', { name: /Subagents/ }).first().click();
const rows = page.locator('.subagent-row');
await rows.first().waitFor({ timeout: 15000 });
// The finished helper: queue.
await rows.nth(0).getByRole('button', { name: 'Message this helper' }).click();
const input = rows.nth(0).getByRole('textbox', { name: 'Message to Check the tests' });
await input.fill('use ISO dates in the file you just wrote');
await shot(page, `4749-${tag}-writing`);
await input.press('Enter');
await rows.nth(0).getByRole('status').waitFor({ timeout: 5000 });
check(JSON.stringify(posts[0]) === JSON.stringify({ sessionId: 's1', helperId: 'child-1', message: 'use ISO dates in the file you just wrote', delivery: 'queue' }), `${tag}: a finished helper gets the message as a new turn (queue)`, JSON.stringify(posts[0]));
check(/Sent to Check the tests\./.test(await rows.nth(0).innerText()) && await input.inputValue() === '', `${tag}: the row says it was sent and the box is empty again`);
// A second helper (a failed one is listed too).
await rows.nth(1).getByRole('button', { name: 'Message this helper' }).click();
await rows.nth(1).getByRole('textbox').fill('also add the date');
await rows.nth(1).getByRole('button', { name: 'Send' }).click();
await rows.nth(1).getByRole('status').waitFor({ timeout: 5000 });
check(posts[1]?.delivery === 'queue' && posts[1]?.helperId === 'child-2', `${tag}: the message goes to the helper that was chosen`, JSON.stringify(posts[1]));
// A refusal says so in plain words.
refuse = true;
await rows.nth(0).getByRole('textbox').fill('one more thing');
await rows.nth(0).getByRole('button', { name: 'Send' }).click();
await rows.nth(0).getByRole('alert').waitFor({ timeout: 5000 });
check(/cannot take a message any more/.test(await rows.nth(0).getByRole('alert').innerText()), `${tag}: a refused message says why in plain words`);
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
await shot(page, `4749-${tag}-sent`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
};
/* ------------------------------------------------------------------------------------------------------------------- #4783 */
cases.busy = async () => {
const fresh = (scheme, w, h, whileBusy) => open(scheme, w, h, [chat('s1', 'Busy', [])], { prefs: whileBusy ? { whileBusy } : {} });
const send = async (page, text) => { await page.locator('textarea').first().fill(text); await page.keyboard.press('Enter'); };
const lastReply = page => page.locator('.st-msg--assistant .st-bubble').last();
for (const scheme of ['light', 'dark']) {
const w = 1280, h = 860, tag = scheme;
// Wait (the default): Enter while it answers sends nothing, the message stays in the box, and a note says why.
let page = await fresh(scheme, w, h, null);
await send(page, 'SCRIPT:slow 80');
await page.locator('.st-msg--assistant').first().waitFor({ timeout: 15000 });
await page.locator('textarea').first().fill('my second message');
await page.keyboard.press('Enter');
await page.waitForTimeout(600);
check(await page.locator('textarea').first().inputValue() === 'my second message', `${tag}: Wait: the message stays in the box`);
check(await page.locator('.parked').count() === 0, `${tag}: Wait: nothing is parked`);
await page.getByText('The assistant is still answering').first().waitFor({ timeout: 4000 }).catch(() => {});
check(await page.getByText('The assistant is still answering').count() > 0, `${tag}: Wait: a note says the assistant is still answering`);
await page.context().close();
// Send after: it waits (shown), and goes when the answer is done, after the whole first answer.
page = await fresh(scheme, w, h, 'queue');
await send(page, 'SCRIPT:slow 80');
await page.locator('.st-msg--assistant').first().waitFor({ timeout: 15000 });
await page.locator('textarea').first().fill('SCRIPT:echo the second answer');
check(/Enter sends it when the answer is done/.test(await page.locator('.composer__busy-hint').innerText()), `${tag}: Send after: the hint says what Enter will do`);
await page.keyboard.press('Enter');
await page.locator('.parked').waitFor({ timeout: 3000 });
check(/Waiting to send/.test(await page.locator('.parked').innerText()) && /second answer/.test(await page.locator('.parked').innerText()), `${tag}: Send after: the waiting message is shown`);
if (scheme === 'light') await shot(page, `4783-${tag}-waiting`);
await page.locator('.st-msg--assistant').nth(1).waitFor({ timeout: 30000 });
await page.waitForFunction(() => document.querySelectorAll('.parked').length === 0, null, { timeout: 15000 });
const firstText = await page.locator('.st-msg--assistant .st-bubble').first().innerText();
check(/w79/.test(firstText), `${tag}: Send after: the first answer was written to its end`, firstText.slice(-30));
check(/the second answer/.test(await lastReply(page).innerText()), `${tag}: Send after: then the second message was answered`);
await page.context().close();
// Interrupt: the answer stops where it is, what is written stays, and the message goes at once.
page = await fresh(scheme, w, h, 'interject');
await send(page, 'SCRIPT:slow 600');
await page.locator('.st-msg--assistant').first().waitFor({ timeout: 15000 });
await page.waitForTimeout(800);
await page.locator('textarea').first().fill('SCRIPT:echo interrupted and answered');
check(/Enter stops the answer here and sends it now/.test(await page.locator('.composer__busy-hint').innerText()), `${tag}: Interrupt: the hint says what Enter will do`);
await page.keyboard.press('Enter');
const t0 = Date.now();
await page.locator('.st-msg--assistant').nth(1).waitFor({ timeout: 30000 });
check(Date.now() - t0 < 12000, `${tag}: Interrupt: the new message was answered long before the first answer would have ended`, `${Date.now() - t0} ms`);
const cut = await page.locator('.st-msg--assistant .st-bubble').first().innerText();
check(cut.includes('w0') && !cut.includes('w599'), `${tag}: Interrupt: the first answer stopped where it was and stays`, `${cut.length} characters`);
await page.waitForFunction(() => /interrupted and answered/.test([...document.querySelectorAll('.st-msg--assistant .st-bubble')].at(-1)?.innerText ?? ''), null, { timeout: 15000 }).catch(() => {});
check(/interrupted and answered/.test(await lastReply(page).innerText()), `${tag}: Interrupt: the second message was answered`);
if (scheme === 'light') await shot(page, `4783-${tag}-interrupted`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
};
/* ------------------------------------------------------------------------------------------------------------------- #4785 */
cases.quote = async () => {
const long = 'First point: local models keep your files on your own computer. Second point: nothing has to be uploaded to a service. Third point: you can work without a network connection.';
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
const page = await open(scheme, w, h, [chat('s1', 'Quote', [user('u1', 'Why run a model locally?'), reply('a1', long), user('u2', 'Thanks, one more question'), reply('a2', 'Ask away.')])]);
await page.locator('.st-msg--assistant').first().waitFor({ timeout: 15000 });
// A whole turn, from the message menu.
await page.locator('.st-msg--assistant').first().hover();
await page.locator('.st-msg--assistant').first().getByRole('button', { name: 'More response actions' }).click();
await page.getByRole('menuitem', { name: 'Quote' }).click();
await page.locator('.quote').first().waitFor({ timeout: 5000 });
check(/Quoting the assistant/.test(await page.locator('.quote').first().innerText()) && /First point/.test(await page.locator('.quote').first().innerText()), `${tag}: Quote in the message menu puts the turn above the box`);
// Just a passage: select the second sentence and quote it.
await page.evaluate(() => {
const p = document.querySelector('.st-msg--assistant .st-bubble p');
const node = p.firstChild; const at = node.textContent.indexOf('Second point'); const end = node.textContent.indexOf('Third point');
const r = document.createRange(); r.setStart(node, at); r.setEnd(node, end);
const s = window.getSelection(); s.removeAllRanges(); s.addRange(r);
});
await page.locator('.quote-selection').waitFor({ timeout: 5000 });
const sbox = await page.locator('.quote-selection').boundingBox();
check(!!sbox && sbox.x >= 0 && sbox.y >= 0 && sbox.x + sbox.width <= w, `${tag}: a Quote button appears by the selected words, on screen`, JSON.stringify(sbox));
await page.locator('.quote-selection').click();
check(await page.locator('.quote').count() === 2 && /^Second point: nothing has to be uploaded to a service\.$/.test((await page.locator('.quote .quote__text').nth(1).innerText()).trim()), `${tag}: the second quote is just the selected passage, listed after the first`);
check(await page.locator('.quote-selection').count() === 0, `${tag}: the button goes away`);
await shot(page, `4785-${tag}-quotes`);
// Remove one, quote it again, jump to the message.
await page.locator('.quote').first().getByRole('button', { name: 'Remove this quote' }).click();
check(await page.locator('.quote').count() === 1 && /Second point/.test(await page.locator('.quote').first().innerText()), `${tag}: one quote can be removed without touching the others`);
await page.locator('.quote__body').first().click();
await page.waitForTimeout(400);
// Send an answer: the model gets the fixed form, the transcript shows the quote apart from the reply.
await page.locator('textarea').first().fill('SCRIPT:echo thanks for the quote');
await page.keyboard.press('Enter');
await page.locator('.sent-quote').first().waitFor({ timeout: 15000 });
await page.waitForTimeout(300);
const sent = await page.locator('.st-msg--user').last().innerText();
check(/Second point: nothing has to be uploaded/.test(sent) && /SCRIPT:echo thanks for the quote/.test(sent) && !/Quoting:/.test(sent) && !/「/.test(sent), `${tag}: the sent message shows the quote apart from the reply, without the markup`, sent.replace(/\n+/g, ' | ').slice(0, 160));
const got = worker.requests.at(-1)?.raw ?? '';
check(got.includes('Quoting:\\n「Second point: nothing has to be uploaded to a service.」\\n→ Reply: SCRIPT:echo thanks for the quote'), `${tag}: the model receives the quote in its fixed form`, got.slice(-220));
check(await page.locator('.quote').count() === 0, `${tag}: the quotes are cleared after sending`);
await page.locator('.sent-quote').first().click();
check(await page.locator('.sent-quotes[data-open]').count() === 1, `${tag}: a click opens the rest of a sent quote`);
await shot(page, `4785-${tag}-sent`);
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
};
/* ------------------------------------------------------------------------------------------------------------------- #4244 */
cases.ask = async () => {
const t = await tuneHost();
await t.mode('ok');
const asked = () => fetch(`${t.origin}/__fixture/asked`).then(r => r.json());
await fetch(`${t.origin}/__fixture/ask`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ on: true }) });
for (const scheme of ['light', 'dark']) for (const [sizeName, [w, h]] of Object.entries(sizes)) {
const tag = `${scheme}-${sizeName}`;
const before = (await asked()).length;
const page = await open(scheme, w, h, [chat('s1', 'Ask', [])], { origin: t.origin });
await page.locator('textarea').first().fill('Build me a page');
await page.keyboard.press('Enter');
const card = page.locator('.askq');
await card.waitFor({ timeout: 15000 });
const text = await card.innerText();
check(/Which colour should the page be\?/.test(text) && /Which extras do you want\?/.test(text) && /Warm and strong/.test(text) && /It sets the whole theme/.test(text), `${tag}: the question, its detail and the option descriptions are shown`);
check(await page.locator('.askcard').count() === 1 && await page.locator('.approvaldock').count() === 1, `${tag}: it is one card in the dock above the box`);
const send = page.getByRole('button', { name: 'Send answer' });
check(await send.isDisabled(), `${tag}: Send answer waits until every question has an answer`);
await page.getByRole('radio', { name: /Red/ }).click();
await page.getByRole('radio', { name: /Blue/ }).click();
check(await page.getByRole('radio', { name: /Red/ }).getAttribute('aria-checked') === 'false' && await page.getByRole('radio', { name: /Blue/ }).getAttribute('aria-checked') === 'true', `${tag}: a single-select question takes one choice`);
await page.getByRole('checkbox', { name: 'Dark mode' }).click();
await page.getByRole('checkbox', { name: 'Animations' }).click();
check(await send.isEnabled(), `${tag}: Send answer works once every question has an answer`);
await page.waitForTimeout(300);
const sb = await send.boundingBox();
const top = await page.evaluate(({ x, y }) => { const e = document.elementFromPoint(x, y); return e?.closest('button')?.textContent ?? ''; }, { x: sb.x + sb.width / 2, y: sb.y + sb.height / 2 });
check(sb.y >= 0 && sb.y + sb.height <= h && /Send answer/.test(top), `${tag}: the answer buttons are in view and reachable without scrolling the card`, JSON.stringify(sb));
await shot(page, `4244-${tag}`);
check((await page.evaluate(() => document.documentElement.scrollWidth)) <= w, `${tag}: no sideways scroll`);
const box = await page.locator('.approvaldock').boundingBox();
check(!!box && box.y >= 0 && box.y + box.height <= h + 1, `${tag}: the dock with the card is inside the window`, JSON.stringify(box));
await send.click();
await page.waitForFunction(() => document.querySelectorAll('.askq').length === 0, null, { timeout: 8000 });
const got = (await asked()).slice(before);
check(JSON.stringify(got.at(-1)) === JSON.stringify({ sessionId: 's1', callId: 'call-ask-1', answers: [{ id: 'q1', selected: ['Blue'] }, { id: 'q2', selected: ['Dark mode', 'Animations'] }] }), `${tag}: the answer goes to the host as chosen`, JSON.stringify(got.at(-1)));
check(await page.locator('.askq').count() === 0, `${tag}: the card goes away, and the turn ends`);
// The person's own words, and a skip.
await page.locator('textarea').first().fill('Again');
await page.keyboard.press('Enter');
await card.waitFor({ timeout: 15000 });
await page.getByRole('textbox', { name: 'Type your own answer' }).first().fill('Green, like the logo');
await page.getByRole('checkbox', { name: 'Animations' }).click();
await send.click();
await page.waitForFunction(() => document.querySelectorAll('.askq').length === 0, null, { timeout: 8000 });
const own = (await asked()).slice(before).at(-1);
check(JSON.stringify(own?.answers) === JSON.stringify([{ id: 'q1', selected: [], custom: 'Green, like the logo' }, { id: 'q2', selected: ['Animations'] }]), `${tag}: the person's own words are sent as such`, JSON.stringify(own));
await page.locator('textarea').first().fill('Once more');
await page.keyboard.press('Enter');
await card.waitFor({ timeout: 15000 });
await page.getByRole('button', { name: 'Skip' }).click();
await page.waitForFunction(() => document.querySelectorAll('.askq').length === 0, null, { timeout: 8000 });
check((await asked()).slice(before).at(-1)?.skip === true, `${tag}: Skip is sent as a skip`);
check(page.problems.length === 0, `${tag}: no page error`, page.problems.join('; '));
await page.context().close();
}
// A /loop runs without anyone there: the question is skipped at once, no card, and the loop is not left waiting.
{
const before = (await asked()).length;
const page = await open('light', 1280, 860, [chat('s1', 'Loop', [])], { origin: t.origin });
await page.locator('textarea').first().fill('/loop 10m Build me a page stop after 1 answer');
await page.keyboard.press('Enter');
await page.waitForFunction(async () => (await (await fetch('/__fixture/asked')).json()).length > 0, null, { timeout: 15000 }).catch(() => {});
const got = (await asked()).slice(before).at(-1);
check(got?.skip === true && got?.callId === 'call-ask-1', 'a loop: the question is skipped at once', JSON.stringify(got));
check(await page.locator('.askq').count() === 0, 'a loop: no question card is shown');
await page.context().close();
}
await fetch(`${t.origin}/__fixture/ask`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ on: false }) });
};
/* ------------------------------------------------------------------------------------------------------------------- #3818 (a probe, no fix) */
cases.select = async () => {
const paras = Array.from({ length: 40 }, (_, i) => `Paragraph ${i + 1}. This is a sentence of a long answer that goes on for a while so that the reply overflows the window.`).join('\n\n');
const pick = async (page, fromText, toText) => {
const a = await page.getByText(fromText, { exact: false }).first().boundingBox(), b = await page.getByText(toText, { exact: false }).first().boundingBox();
await page.mouse.move(a.x + 2, a.y + a.height / 2);
await page.mouse.down();
await page.mouse.move(b.x + b.width - 4, b.y + b.height / 2, { steps: 12 });
await page.mouse.up();
return page.evaluate(() => window.getSelection().toString());
};
const page = await open('light', 1280, 860, [chat('s1', 'Select', [user('u1', 'Write a lot'), reply('a1', paras)])]);
await page.locator('.st-bubble p').first().waitFor({ timeout: 15000 });
await page.locator('.main__body.chat').evaluate(el => { el.scrollTop = el.scrollHeight; });
await page.waitForTimeout(400);
// After the reply has finished: a drag across several paragraphs.
const visible = await page.evaluate(() => [...document.querySelectorAll('.st-bubble p')].filter(p => { const r = p.getBoundingClientRect(); return r.top > 100 && r.bottom < innerHeight - 220; }).map(p => p.textContent.slice(0, 12)));
const first = visible[0], last = visible.at(-1);
const got = await pick(page, first, last);
check(got.includes(first) && got.includes(last) && got.split('\n').filter(Boolean).length >= visible.length, `finished reply: a drag across ${visible.length} paragraphs selects all of them`, `${got.length} characters`);
await page.context().close();
// While a reply streams: select a passage of the older reply, and see whether the selection and the view stay.
const page2 = await open('light', 1280, 860, [chat('s1', 'Select', [user('u1', 'Write a lot'), reply('a1', paras)])]);
await page2.locator('.st-bubble p').first().waitFor({ timeout: 15000 });
await page2.locator('textarea').first().fill('SCRIPT:slow 400');
await page2.keyboard.press('Enter');
await page2.waitForTimeout(1500);
const v2 = await page2.evaluate(() => [...document.querySelector('.st-msg--assistant').querySelectorAll('.st-bubble p')].filter(p => { const r = p.getBoundingClientRect(); return r.top > 100 && r.bottom < innerHeight - 220; }).map(p => p.textContent.slice(0, 12)));
if (v2.length >= 2) {
// The selection is made in code here (a drag moves with the layout and made this probe flaky): words of the older reply, as a drag would select them.
const sel0 = await page2.evaluate(([a, b]) => {
const ps = [...document.querySelector('.st-msg--assistant').querySelectorAll('.st-bubble p')], from = ps.find(p => p.textContent.startsWith(a)), to = ps.find(p => p.textContent.startsWith(b));
const r = document.createRange(); r.setStart(from.firstChild, 0); r.setEnd(to.lastChild, to.lastChild.textContent.length);
const s = window.getSelection(); s.removeAllRanges(); s.addRange(r); return s.toString();
}, [v2[0], v2.at(-1)]);
const top0 = await page2.locator('.main__body.chat').evaluate(el => el.scrollTop);
await page2.waitForTimeout(3000);
const sel1 = await page2.evaluate(() => window.getSelection().toString());
const top1 = await page2.locator('.main__body.chat').evaluate(el => el.scrollTop);
console.log(`INFO streaming: selection ${sel0.length} -> ${sel1.length} characters, scrollTop ${Math.round(top0)} -> ${Math.round(top1)}`);
console.log(`INFO before: ${JSON.stringify(sel0.slice(0, 40))} ... ${JSON.stringify(sel0.slice(-40))}
INFO after: ${JSON.stringify(sel1.slice(0, 40))} ... ${JSON.stringify(sel1.slice(-40))}`);
check(sel1 === sel0, 'streaming: a selection in the older reply stays as it was while the new reply is written (a selection inside the paragraph that is being written does not: React replaces its text node on every token)', `${sel0.length} -> ${sel1.length}`);
check(Math.abs(top1 - top0) < 40, 'streaming: the view is not pulled away from the selection', `${Math.round(top0)} -> ${Math.round(top1)}`);
} else console.log('INFO streaming: the older reply was not on screen, nothing to select');
await page2.context().close();
};
/* ------------------------------------------------------------------------------------------------------------------ run */
let ran = 0;
for (const [name, fn] of Object.entries(cases)) {
if (ONLY.length && !ONLY.includes(name)) continue;
console.log(`--- ${name}`);
ran += 1;
try { await fn(); } catch (e) {
check(false, `${name} threw`, String(e?.stack ?? e).split('\n').slice(0, 4).join(' | '));
const last = pages.at(-1);
if (last && !last.isClosed()) {
await last.screenshot({ path: join(SHOTS, `debug-${name}.png`) }).catch(() => {});
console.log('PAGE TEXT:', ((await last.evaluate(() => document.body.innerText).catch(() => '')) || '').slice(0, 600).replace(/\n+/g, ' | '));
console.log('PAGE ERRORS:', last.problems?.join('; '));
}
}
}
check(ran > 0, 'at least one case ran');
await browser.close();
await worker.close();
for (const proc of [child, tuneHostInfo?.proc]) if (proc) { if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(proc.pid), '/T', '/F'], { windowsHide: true }); else proc.kill('SIGTERM'); }
try { rmSync(home, { recursive: true, force: true, maxRetries: 3 }); } catch { /* best effort */ }
process.exit(failed ? 1 : 0);
+1 -1
View File
@@ -101,7 +101,7 @@ records the patch identity, the dependency version and the before/after SHA-256.
Source and already-installed Harness copies are not changed.
- A local route is reasoning-capable (`reasoning: true`) with `compat.thinkingFormat: 'chat-template'`: the request carries `chat_template_kwargs {enable_thinking, reasoning_effort}`, which the Strata server reads (a top-level `enable_thinking` is ignored by it, which is why pi-ai's `qwen` format left the model thinking). The chat's Reasoning menu travels as `thinking` in the stream body; the bridge sends it with `session/selectModel` (`reasoningEffort`) whenever the model or the level changed; a level a route does not have is dropped (never an error). API routes keep the provider default (the menu is disabled for them in the UI).
- `localOutputBudget(context)` is 30 percent of the window, at least 4096 and at most 32768 (9,830 tokens at 32K, 19,660 at 64K). The Harness's automatic compaction reads it as the room a reply reserves. pi-ai sends the window minus the prompt. App-loaded models get `fit_max_tokens: true` so the engine clamps instead of answering 400. Sampling is not set here: the Harness sends none, so the server's own defaults apply.
- `localOutputBudget(context)` is 30 percent of the window, at least 4096 and at most 32768 (9,830 tokens at 32K, 19,660 at 64K). The Harness's automatic compaction reads it as the room a reply reserves (the request header), and it is NOT the ceiling of a request: `plugins/strata-context.mjs` raises what the adapter sends to half the window, at most 64K tokens (`reply-cut.mjs` `replyCap`; pi-ai clamps it to the window minus the prompt), because a thinking model that writes a whole file in one call was cut inside the call at 19,660 and lost it (AGENT-LOST). A reply that is cut anyway is announced in the chat, the agent is asked to go on in smaller pieces (twice at most), and the file being written is streamed to the page as `agent-tool-draft` frames (name, size, lines, tail of the text). App-loaded models get `fit_max_tokens: true` so the engine clamps instead of answering 400. Sampling is not set here: the Harness sends none, so the server's own defaults apply.
## Tasks
+95 -5
View File
@@ -30,6 +30,11 @@ import { DeliverableTracker, ActivityGroups } from './deliverables.mjs';
import { SkillsUsed, skillInfoFrom } from './skills-used.mjs';
import { turnEngineSpeed } from './engine-speed.mjs';
import { TurnGuard, guardNote, GUARD_NOTES } from './turn-guard.mjs';
import { cutNotice, cutNudge } from './reply-cut.mjs';
/** How often one turn asks the agent to carry on after a reply the length limit cut (each time in smaller pieces). */
export const MAX_CUT_RETRIES = 2;
import { buildAnswer, parseQuestions } from './user-questions.mjs';
import { BASE_TOKENS, COUNT_ABOVE_CHARS, FIT_TEXT, countTokens, decideFit, fill, grouped } from './context-fit.mjs';
import { PlanStore, planPrompt, parsePlan, parsePoints, stepObjective, stepLine, firstSentence, stepSummary, planAsPrompt, editedSteps, planView, researchPrompt, researchWritePrompt, RESEARCH_NUDGE, parseSources } from './plan.mjs';
@@ -157,6 +162,8 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
const queues = new Map();
const queueView = chatId => (queues.get(chatId) ?? []).map(({ id, text, at, deliverAt }) => ({ id, text, at, deliverAt }));
const pending = new Map(); // `${sessionId}:${callId}` -> { eventId, sessionId, callId, at }
const questions = new Map(); // `${sessionId}:${callId}` -> the agent's own question waiting for the person: { eventId, sessionId, callId, items, at, clientId } (#4244)
const chatOfSession = sessionId => Object.keys(sessionMap).find(id => sessionMap[id]?.sessionId === sessionId) ?? null;
const idleWaiters = new Set();
const chat = id => { let c = chats.get(id); if (!c) { c = { id, turn: null, route: null, routeGeneration: -1 }; chats.set(id, c); } return c; };
@@ -275,10 +282,23 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
if (!r.callId) return;
pending.set(`${value.agentId}:${r.callId}`, { eventId: value.eventId, sessionId: value.agentId, callId: r.callId, tool: r.toolName, reason: r.reason ?? null, at: Date.now(), clientId: runtime.clientId });
} else if (value.type === 'waterfall' && value.event === 'user-questions/request') {
// The app has no surface for the agent's own questions yet. Declaring that nobody can answer makes
// the tool return an error the model reads and carries on from, instead of a turn that waits forever.
void runtime.gw.answerEvent({ clientId: runtime.clientId, eventId: value.eventId, outcome: 'next' }).catch(() => {});
// The agent of a chat that is running in front of the person asks the person: the question goes to the page (the question card above the message box) and the
// Harness waits for the answer. Anything else (a helper's question, a chat that is stopping, a request with nothing to show) is declared
// unanswerable, which makes the tool return an error the model reads and carries on from, instead of a turn that waits forever.
const r = value.request ?? {};
const chatId = chatOfSession(value.agentId);
const turn = chatId ? chats.get(chatId)?.turn : null;
const items = parseQuestions(r.questions);
// A goal or a plan step runs without anyone there: a question would stall it, so it is declined at once (the model reads "could not be answered" and goes on).
if (!turn || turn.finished || turn.stopping || turn.goalRun || turn.plan || !turn.announceQuestion || !items.length) {
void runtime.gw.answerEvent({ clientId: runtime.clientId, eventId: value.eventId, outcome: 'next' }).catch(() => {});
} else {
const callId = String(r.wait?.callId ?? value.eventId);
questions.set(`${value.agentId}:${callId}`, { eventId: value.eventId, sessionId: value.agentId, callId, items, at: Date.now(), clientId: runtime.clientId });
turn.announceQuestion({ type: 'agent-question', callId, questions: items });
}
} else if (value.type === 'cancel') {
for (const [k, v] of questions) if (v.eventId === value.eventId) { questions.delete(k); chats.get(chatOfSession(v.sessionId))?.turn?.announceQuestion?.({ type: 'agent-question-end', callId: v.callId }); }
for (const [k, v] of pending) if (v.eventId === value.eventId) pending.delete(k);
}
}
@@ -471,6 +491,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
async function finishTurn(failure) {
if (turn.finished) return;
turn.finished = true;
for (const [k, v] of questions) if (v.sessionId === turn.sessionId) questions.delete(k);
clearTimeout(turn.settleTimer);
clearInterval(turn.watchdog);
turn.follow?.close();
@@ -484,7 +505,15 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
try { extra.push(...settlePlan(failure)); } catch { /* a plan frame must never break the end of a turn */ }
// The turn guard: a turn the app stopped, or one that ended with no words, always ends with one plain message (a plan card carries it itself).
let noteReason = turn.guard ?? null;
if (!noteReason && guard && !failure && !turn.stopping && !turn.goalRun) {
// A reply the length limit cut ends with a sentence that says so (and what was lost), never with silence (AGENT-LOST).
const cutEnd = !failure && !turn.stopping && projector.ended?.kind === 'max-tokens';
if (cutEnd && !noteReason) {
const note = cutNotice({ ...projector.cut(), retrying: false });
extra.push({ type: 'delta', own: true, text: `
${note.text}`, key: note.key, values: note.values, noteReason: 'cut' });
}
if (!noteReason && !cutEnd && guard && !failure && !turn.stopping && !turn.goalRun) {
noteReason = guard.silent({ ended: projector.ended, wrote: projector.emittedAny === true, acted: projector.tools.size > 0, contextTokens: projector.contextTokens });
}
if (noteReason && !extra.some(f => f.type === 'plan' && f.event === 'proposed')) { const note = guardNote(noteReason); if (note) extra.push(note); }
@@ -601,6 +630,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
case 'turn/start':
turn.parentOpen = true;
if (turn.research) turn.research.waitingStart = false;
turn.cutWaiting = false;
clearTimeout(turn.settleTimer);
// After Stop, a helper's report would start a new turn that nobody asked for.
if (turn.stopping) void runtime.gw.rpc('session/cancel', { request: { sessionId } }).catch(() => {});
@@ -752,6 +782,24 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
const current = await goalGet();
if (turn.finished || turn.parentOpen) return;
if (current && current.phase === 'active' && current.activation === 'armed') { turn.settleTimer = after(700, armSettle); return; }
// A reply the length limit cut (AGENT-LOST): the agent lost what it was writing. Say so in the chat and ask it to carry on in smaller pieces, twice at most.
if (turn.cutWaiting) { turn.settleTimer = after(300, armSettle); return; }
if (projector.ended?.kind === 'max-tokens' && !turn.goalRun && !turn.stopping && (turn.cuts ?? 0) < MAX_CUT_RETRIES) {
const cut = projector.cut();
turn.cuts = (turn.cuts ?? 0) + 1;
const note = cutNotice({ ...cut, retrying: true });
emit({ type: 'delta', own: true, text: `
${note.text}
`, key: note.key, values: note.values, noteReason: 'cut' });
for (const f of projector.clearCut()) emit(f);
turn.cutWaiting = true;
try { await runtime.gw.rpc('session/prompt', { request: { requestId: `cut-${randomUUID()}`, sessionId, mode: 'queue', content: [{ type: 'text', text: cutNudge(cut) }] } }); }
catch { turn.cutWaiting = false; finishTurn(null); return; }
turn.settleTimer = after(500, armSettle);
return;
}
// Research: gather (search and read pages), then write the answer in the fixed shape. A step that did not open pages is sent back once.
if (turn.research && !turn.stopping) {
const r = turn.research;
@@ -794,6 +842,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
}
turn.announceGoal = announceGoal;
turn.announceQueue = frame => emit(frame);
turn.announceQuestion = frame => emit(frame);
const snapshotTimer = {};
await Promise.race([turn.snapshot.promise, new Promise((_, rej) => { snapshotTimer.t = after(20_000, () => rej(new Error('the session stream did not open'))); })])
@@ -1096,6 +1145,47 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
delete sessionMap[chatId]; persist();
}
// -- the agent's own questions ------------------------------------------------------------------------------
/**
* The person's answer to a question the agent asked (#4244), or a skip. The answer is checked against the questions that were asked, then sent
* to the Harness as the result of the waiting `user-questions/request`; a skip declares the question unanswerable, as before, and the model
* carries on.
*/
async function answerQuestion({ chatId, callId, answers, skip = false }) {
const sessionId = sessionMap[chatId]?.sessionId;
const key = `${sessionId}:${callId}`;
const entry = questions.get(key);
if (!entry || !rt?.hp.running) throw new AgentUnavailable('That question was already answered, or the assistant has gone on.', 'question-gone');
const outcome = skip ? 'next' : buildAnswer(entry.items, answers);
await rt.gw.answerEvent({ clientId: entry.clientId ?? rt.clientId, eventId: entry.eventId, outcome });
questions.delete(key);
return { ok: true, skipped: !!skip };
}
// -- a message for a helper ---------------------------------------------------------------------------------
/**
* The person's own words for one helper (#4749), through the Harness's `subagents/prompt`: it delivers one browser-authored message to a
* continuable child through its live parent. "steer" reaches a working helper at its next step; "queue" gives a helper that has finished
* (or is between steps) a new turn. The Harness refuses a helper that cannot take messages (a one-shot one) and a parent that is not live;
* either comes back as a plain sentence.
*/
async function messageHelper({ chatId, helperId, text, delivery = 'queue' }) {
const clean = String(text ?? '').trim().slice(0, 8000);
if (!clean) throw new AgentUnavailable('Type a few words for the helper.', 'helper-empty');
if (!/^[\w.:@-]{1,160}$/.test(String(helperId ?? ''))) throw new AgentUnavailable('That helper is not known.', 'helper-unknown');
const parent = sessionMap[chatId]?.sessionId;
if (!parent || !rt?.hp.running) throw new AgentUnavailable('The assistant is not running in this chat, so it cannot pass a message on. Send a message in the chat first.', 'helper-idle');
try {
const receipt = await rt.gw.rpc('subagents/prompt', { request: { requestId: `helper-${randomUUID()}`, parentSessionId: parent, childSessionId: String(helperId), mode: 'continuable', delivery: delivery === 'steer' ? 'steer' : 'queue', content: [{ type: 'text', text: clean }] } });
return { ok: true, messageId: receipt?.messageId ?? null, delivery: delivery === 'steer' ? 'steer' : 'queue' };
} catch (e) {
log(`agent · message for a helper refused: ${String(e?.message ?? e).slice(0, 200)}`);
throw new AgentUnavailable(/continuable|one-shot|not.*(found|live|open)|unavailable|closed|ended/i.test(String(e?.message ?? '')) ? 'This helper cannot take a message any more. Ask the assistant in the chat to start a new one.' : 'The message could not be delivered right now. Try again in a moment.', 'helper-refused');
}
}
// -- messages typed while a goal runs ----------------------------------------------------------------------
/** Queue a message for the running goal. It is delivered between steps, or handed back to the page when the goal ends or pauses. */
@@ -1333,7 +1423,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
/** Let go of a chat's Harness session mapping (a task starts every run in a session of its own). */
function forget(chatId) { if (chats.get(chatId)?.turn) return; delete sessionMap[chatId]; chats.delete(chatId); persist(); }
return { compactCommand, goalState, goalControl, harnessDir, status, runTurn, goalCommand, planCommand, researchCommand, llmTarget, setPlanBriefing, resetSession, reopenPlan, skipStep, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, pendingAll, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, workingFolder: chatId => sessionMap[chatId]?.cwd ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
return { compactCommand, messageHelper, answerQuestion, goalState, goalControl, harnessDir, status, runTurn, goalCommand, planCommand, researchCommand, llmTarget, setPlanBriefing, resetSession, reopenPlan, skipStep, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, pendingAll, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, workingFolder: chatId => sessionMap[chatId]?.cwd ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
}
/** A goal that stopped by itself, in words. */
+12
View File
@@ -160,3 +160,15 @@ export function isContextOverflow(error) {
const text = [error.message, error.cause?.message, error.failure?.message, error.failure?.code].filter(Boolean).join(' ');
return /request exceeds the context window|leaves no room to answer|context[_ ]length|context window|too many tokens|maximum context/i.test(text);
}
/** The Harness asks for "concise English engineering prose", so a Chinese or Russian chat came back summarised in English (#4750). One sentence is added to the
* summary request's last message: write it in the language the person used. Paths, commands and identifiers stay exact. Messages are not changed in place. */
export const SUMMARY_LANGUAGE_RULE = 'Write the summary in the language the person used in this conversation (English only when they wrote English). The instruction above is in English; that does not change the language of the summary. Keep file paths, commands, error strings, identifiers and numbers exactly as they appear.';
export function withSummaryLanguage(messages) {
if (!Array.isArray(messages) || !messages.length) return messages;
const last = messages[messages.length - 1];
if (!last || last.role !== 'user') return messages;
const content = typeof last.content === 'string' ? last.content + ' ' + SUMMARY_LANGUAGE_RULE
: Array.isArray(last.content) ? [...last.content, { type: 'text', text: SUMMARY_LANGUAGE_RULE }] : null;
return content === null ? messages : [...messages.slice(0, -1), { ...last, content }];
}
+35 -8
View File
@@ -11,6 +11,7 @@ import { toolUpdate, TokenCount, fmtTokens } from './status.mjs';
import { askFor } from './ask.mjs';
import { FIT_TEXT, fill } from './context-fit.mjs';
import { isContextOverflow } from './context-budget.mjs';
import { ToolDraft, cutCalls } from './reply-cut.mjs';
const SHELL = new Set(['pwsh', 'bash', 'job_kill', 'job_output', 'job_list']);
const WRITE = new Set(['write', 'edit']);
@@ -222,7 +223,8 @@ export class TurnProjector {
this.asked = new Map(); // approval id -> callId
this.onStatus = null; // (update) => void: what the assistant is doing now (status.mjs); set by the bridge
this.reasoning = new TokenCount();
this.composing = new Map(); // callId -> { name, head, bytes }: a tool call the model is still writing
this.composing = new Map(); // callId -> ToolDraft: a tool call the model is still writing (still here at a cut reply = it was lost)
this.lastOutput = 0; // tokens the LAST model request produced
this.running = []; // callIds of tools that are running, oldest first
}
@@ -289,19 +291,20 @@ export class TurnProjector {
out.push({ type: 'reasoning', text: c.text });
} else if (c.type === 'tool-call-delta' && c.id) {
// The model is writing a tool call. For a big file this is the long silent part: nothing else arrives until it is finished.
const call = this.composing.get(c.id) ?? { name: c.name ?? '', head: '', bytes: 0 };
const call = this.composing.get(c.id) ?? new ToolDraft(c.id, c.name ?? '');
if (c.name) call.name = c.name;
const piece = String(c.argumentsDelta ?? '');
// Writing a tool call (a whole file, say) is decoding too: its tokens are in the engine's output count, so its time belongs in the speed.
this.#produced(f.attemptId, piece, out);
call.bytes += piece.length;
if (call.head.length < 4000) call.head += piece;
call.add(piece);
this.composing.set(c.id, call);
if (call.name && !NO_CARD.has(call.name)) {
const raw = (/"(?:file_path|path|filePath)"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(call.head) ?? /"url"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(call.head))?.[1];
let target = raw; try { if (raw !== undefined) target = JSON.parse('"' + raw + '"'); } catch { /* a half-written escape: show it as it is */ }
const target = call.target;
const args = target ? { file_path: target, url: target } : {};
this.#status(toolUpdate('preparing', 'preparing-tool', call.name, args, { bytes: call.bytes }, { args: { tool: call.name, verb: toolVerb(call.name, 'running'), target: target ?? '', bytes: call.bytes } }));
// The live data path of a file being written (AGENT-LOST): the name, a growing size and the tail of the text, at most five times a second.
const now = Date.now();
if (now - call.at >= 200) { call.at = now; out.push(call.frame({ scope: toolScope(call.name), verb: toolVerb(call.name, 'running') })); }
}
} else if (c.type === 'usage' && c.usage) {
this.usageSeen = true;
@@ -310,6 +313,7 @@ export class TurnProjector {
const prompt = (Number(c.usage.inputTokens) || 0) + (Number(c.usage.cacheReadTokens) || 0) + (Number(c.usage.cacheWriteTokens) || 0);
this.tokensIn += prompt;
this.tokensOut += Number(c.usage.outputTokens) || 0;
this.lastOutput = Number(c.usage.outputTokens) || 0;
this.contextTokens = prompt + (Number(c.usage.outputTokens) || 0);
out.push({ type: 'context', contextTokens: this.contextTokens, contextMax: this.window || undefined });
}
@@ -333,7 +337,9 @@ export class TurnProjector {
const args = parseArguments(d.arguments);
const card = { ...presentTool({ callId: d.callId, name: d.name, args }), state: 'running' };
this.tools.set(d.callId, { card, calledAt: e.time });
const draft = this.composing.get(d.callId);
this.composing.delete(d.callId);
if (draft && draft.bytes > 0 && !NO_CARD.has(d.name)) out.push(draft.frame({ done: true }));
this.running.push(d.callId);
this.#status(toolUpdate('tool', 'tool', d.name, args, {}, { args: { tool: d.name, verb: card.verb, target: card.target } }));
out.push({ type: 'agent-tool', tool: card });
@@ -419,8 +425,29 @@ export class TurnProjector {
return this.#card(callId, { state: 'denied', result: outcome === 'rejected' ? 'Denied. Nothing ran.' : outcome === 'cancelled' ? 'Cancelled. Nothing ran.' : 'No one could answer this request, so nothing ran.' });
}
/** A reply the model could not finish (the turn ended on the length limit): what it was writing, so nothing is lost without a word. */
cut() {
return { tokens: this.lastOutput, calls: cutCalls(this.composing.values()) };
}
/** The agent was asked to carry on: the calls it lost are not pending any more. */
clearCut() { const frames = this.#dropDrafts(); this.ended = null; return frames; }
/** Frames that take the writing cards of calls that will never be whole off the page (the reply was cut, or the turn ended). */
#dropDrafts() {
const frames = [...this.composing.values()].filter(d => d.bytes > 0 && !NO_CARD.has(d.name)).map(d => d.frame({ done: true, lost: true }));
this.composing.clear();
return frames;
}
/** The terminal frames for a finished turn. */
finish({ model, contextMax, engine = null } = {}) {
finish(options = {}) {
const cutInfo = this.ended?.kind === 'max-tokens' ? this.cut() : null;
const dropped = this.#dropDrafts();
return [...dropped, ...this.#finished(options, cutInfo)];
}
#finished({ model, contextMax, engine = null } = {}, cutInfo = null) {
const reason = this.ended ?? { kind: 'interrupted' };
const durationMs = Date.now() - this.startedAt;
const usage = this.usageSeen
@@ -441,7 +468,7 @@ export class TurnProjector {
case 'completed':
return [{ type: 'done', ...base, ...usage, finishReason: 'stop' }];
case 'max-tokens':
return [{ type: 'done', ...base, ...usage, finishReason: 'length' }];
return [{ type: 'done', ...base, ...usage, finishReason: 'length', cut: cutInfo }];
case 'aborted':
return [{ type: 'done', ...base, ...usage, finishReason: 'aborted', cancelled: true }];
case 'error':
@@ -16,9 +16,13 @@
* request the Harness marks `purpose: 'compaction'` is touched in `llm.stream`. Nothing in the Harness is rewritten: a Harness update
* that renames either leaves compaction as it was, and the test that runs the real Harness fails.
*/
import { densityOf, estimateMessage, estimateTools, isContextOverflow, MAX_DENSITY, pressureNow, promptTokensOf, splitMessages, summaryBudget, usageOfEvent, WINDOW_SLACK } from '../context-budget.mjs';
import { replyCap } from '../reply-cut.mjs';
import { densityOf, estimateMessage, estimateTools, isContextOverflow, MAX_DENSITY, pressureNow, promptTokensOf, splitMessages, summaryBudget, usageOfEvent, WINDOW_SLACK, withSummaryLanguage } from '../context-budget.mjs';
export const name = 'strata-context';
/** The routes the host writes for models on this computer (profile.mjs): `strata-local`, `strata-local-<id>`, and their `~s<budget>` copies. */
const isLocalRoute = provider => typeof provider === 'string' && provider.startsWith('strata-local');
export const inject = ['tokenMeter', 'llm'];
/** Real prompt measurements per session: { real, estimated } for the last answer the model server reported. */
@@ -74,6 +78,47 @@ export function apply(ctx) {
};
}
// 3. A reply that has room to finish (AGENT-LOST, 11-01). The output budget in the profile is what the Harness's compaction sets aside for a
// reply (30 percent of the window; it reads it from the request header), and the same number was also the ceiling of every request: a thinking
// model that wrote a 15K-token file after 9K tokens of thinking was cut inside the tool call at 19,660, the unfinished call was dropped and the
// turn ended with no file and no word. The header (and so compaction) stays as it is; the request that goes to the model server may produce up
// to half the window, at most 64K tokens (replyCap), and pi-ai still clamps it to what the window has left after the prompt. The agent loop
// dispatches through the adapter's `streamWithSnapshot`, after the loop has checked the request against its header, so that is where the ceiling
// is raised, once per adapter, the first time a local route is prepared. A Harness that renames it leaves the ceiling as it was (the test that
// runs the real Harness fails).
if (llm && typeof llm.prepareCall === 'function' && !wrapped.has(llm.prepareCall)) {
const original = llm.prepareCall;
const raised = new WeakSet();
const prepare = async function prepareCall(config, signal, configure) {
try { raiseReplyCeiling(config?.provider, raised); } catch (e) { log('warn', `could not raise the reply ceiling: ${e.message}`); }
return original.call(this, config, signal, configure);
};
wrapped.add(prepare);
llm.prepareCall = prepare;
}
function raiseReplyCeiling(provider, raised) {
if (!isLocalRoute(provider)) return;
const adapter = llm.adapters?.get?.(provider)?.adapter;
if (!adapter || raised.has(adapter) || typeof adapter.streamWithSnapshot !== 'function' || typeof adapter.modelOf !== 'function') return;
raised.add(adapter);
const stream = adapter.streamWithSnapshot;
adapter.streamWithSnapshot = function streamWithSnapshot(options, snapshot) {
let next = options;
try {
if (isLocalRoute(options?.provider)) {
const model = this.modelOf(snapshot, options.provider, options.model);
const asked = options.maxTokens ?? model.maxTokens ?? 0;
// Only the agent's own reply budget is raised: a summary or a title asks for far less and keeps what it asked.
if (model.contextWindow > 0 && asked >= Math.floor(model.contextWindow * 0.25)) {
const cap = replyCap(model.contextWindow, asked);
if (cap > asked) next = { ...options, maxTokens: cap };
}
}
} catch { next = options; }
return stream.call(this, next, snapshot);
};
}
// The host asks, with /strata-context, how much of the window the chat holds right now: the same measured number compaction decides on
// (JSON in the command's text: { tokens, window }). Before a message goes out, and after /compact, so the person is told true figures.
ctx.inject(['commands'], (commandCtx) => {
@@ -108,6 +153,7 @@ export function apply(ctx) {
const refusedForSize = finish => finish?.kind === 'error' && isContextOverflow(finish.failure ?? finish);
async function* summaryStream(options, original) {
options = { ...options, messages: withSummaryLanguage(options.messages) }; // #4750: the summary follows the conversation's language
let window = 0;
try { window = (await llm.resolveModelInfo(options.provider, options.model, options.signal))?.context?.contextWindow ?? 0; } catch { window = 0; }
const messages = options.messages ?? [];
+23 -4
View File
@@ -11,8 +11,8 @@
* reminder tools would run behind its back.
* - `web_search` removed (tool-web with `search:false`): every search backend the Harness has needs a service key
* the app does not have, so the tool could only fail. `web_fetch` stays. (`searchProvider` can switch it back on.)
* - `tool-ask-user` removed: the app has no place to show the agent's own questions, so the tool could only fail;
* the agent asks in plain text instead.
* - `tool-ask-user` stays (it is the group "Questions for you" in Settings -> Tools): the app shows the agent's own questions in a card above
* the message box and sends the answer back (#4244). Switched off, the tool is not offered and the agent asks in plain text.
* - the `planning` group (Harness plan mode and `exit_plan_mode`) removed: the app's Plan mode is the permission
* preset (nothing can change); the Harness's plan mode needs an interactive review the app does not have.
* - `tool-bash` / `tool-pwsh`: exactly one, by operating system (the Harness chooses the same at load).
@@ -29,7 +29,7 @@ export const STANDARD_PLUGIN_IDS = [
'tool-ask-user', 'tool-todo', 'tool-web', 'present', 'tool-plugin-manager',
];
/** What we leave out of it. */
export const REMOVED_PLUGIN_IDS = ['tool-schedule', 'tool-ask-user', 'planning'];
export const REMOVED_PLUGIN_IDS = ['tool-schedule', 'planning'];
/** Plugins removed from inside the `delegation` group (the group stays: helper agents are a feature). */
export const REMOVED_DELEGATION_IDS = [];
@@ -48,6 +48,24 @@ export const AGENT_PROMPT = [
'Tokens prefixed with @ are files or folders the person pointed at. A failed or refused action changed nothing; say so, and do not claim work you did not do.',
].join('\n\n');
/**
* The one sentence that says which computer this is. The Harness's own runtime context is off (see `includeRuntimeContext` below), so without it
* a model on Windows spent its first steps on `ls`, `uname` and `wc` before trying PowerShell commands (and some ran out of steps first).
*/
export const OS_LINES = {
win32: 'This computer runs Windows and the shell tool is PowerShell: uname, wc and grep do not exist here; use Get-ChildItem, Get-Content, Select-String and Measure-Object. There is no need to check which system this is.',
darwin: 'This computer runs macOS and the shell tool is a Unix shell. There is no need to check which system this is.',
linux: 'This computer runs Linux and the shell tool is a Unix shell. There is no need to check which system this is.',
};
/** AGENT_PROMPT with the sentence for `platform` after its first line. */
export function agentPromptFor(platform = process.platform) {
const line = OS_LINES[platform];
if (!line) return AGENT_PROMPT;
const [first, ...rest] = AGENT_PROMPT.split('\n\n');
return [first, line, ...rest].join('\n\n');
}
/**
* @param {{platform?: string, search?: boolean}} [options]
* @returns {object} the `config` of the `preset-standard` row
@@ -55,7 +73,7 @@ export const AGENT_PROMPT = [
export function standardPreset({ platform = process.platform, search = false, off = new Set(), skillsDir = null, compaction = [] } = {}) {
const windows = platform === 'win32';
const plugins = [
{ id: 'persona', name: '@deepseek-ai/dsh-persona', config: { prefix: AGENT_PROMPT, complete: true, includeRuntimeContext: false } },
{ id: 'persona', name: '@deepseek-ai/dsh-persona', config: { prefix: agentPromptFor(platform), complete: true, includeRuntimeContext: false } },
{ id: 'agent-instructions', name: '@deepseek-ai/dsh-agent-instructions', config: { maxBytes: 65536 } },
{ id: 'time-context', name: '@deepseek-ai/dsh-time-context' },
windows
@@ -88,6 +106,7 @@ export function standardPreset({ platform = process.platform, search = false, of
{ id: 'tool-workflow', name: '@deepseek-ai/dsh-tool-workflow' },
],
},
{ id: 'tool-ask-user', name: '@deepseek-ai/dsh-tool-ask-user' },
{ id: 'tool-todo', name: '@deepseek-ai/dsh-tool-todo', config: { allowParallelInProgress: true } },
{ id: 'tool-web', name: '@deepseek-ai/dsh-tool-web', config: { fetch: !off.has('fetch'), search: search && !off.has('search'), searchTimeoutMs: 60000 } },
{ id: 'present', name: '@deepseek-ai/dsh-tool-present' },
+123
View File
@@ -0,0 +1,123 @@
/**
* A reply the model could not finish (AGENT-LOST, 11-01): pure helpers, no I/O.
*
* A model reply has a ceiling (the "output budget"). A thinking model that then writes a whole file in one tool call can spend all of it
* before the call is closed. The Harness drops a tool call that was not closed (its arguments are half a JSON text), the turn ends with
* the reason `max-tokens`, and until now the person saw nothing: no file, no card, no sentence. Here:
*
* - `replyCap(window)` how much one reply may produce (the request's ceiling), much larger than the budget the Harness reserves
* for compaction (profile.mjs `localOutputBudget`): the Harness still clamps it to what the window has left.
* - `ToolDraft` what a tool call that is being written looks like so far: its name, the file it is for, a size, a tail of the text.
* - `cutNotice(...)` the plain sentence for the chat, and `cutNudge(...)` the message that lets the agent carry on in smaller pieces.
*/
/** Half the window, at most 64K tokens, and never less than the budget compaction reserves. The Harness clamps it to the room left (window minus prompt). */
export function replyCap(window, reserved = 0) {
const w = Number.isFinite(window) && window > 0 ? Math.floor(window) : 32768;
return Math.max(Math.floor(reserved) || 0, Math.min(Math.floor(w / 2), 65536));
}
const ESCAPES = { n: '\n', t: '\t', r: '', '"': '"', '\\': '\\', '/': '/', b: '', f: '' };
/** A tail of a JSON string body, read back to text: `\n` becomes a newline, `\"` a quote. A half-written escape at the end is dropped. */
export function unescapeJsonTail(raw) {
let text = String(raw ?? '');
const cut = /\\(u[0-9a-fA-F]{0,3})?$/.exec(text);
if (cut) text = text.slice(0, cut.index);
return text.replace(/\\(u[0-9a-fA-F]{4}|[nrtbf"\\/])/g, (_, e) => (e[0] === 'u' ? String.fromCharCode(parseInt(e.slice(1), 16)) : ESCAPES[e] ?? e));
}
const TARGET = /"(?:file_path|path|filePath)"\s*:\s*"((?:[^"\\]|\\.)*)"/;
const URL_ARG = /"url"\s*:\s*"((?:[^"\\]|\\.)*)"/;
/** The long text argument of the common writing tools; the tail of the arguments is previewed after the first one of these. */
const BODY = /"(?:content|new_string|new_str|text|code|body|command|file_text)"\s*:\s*"/;
/** One tool call being written: the model's streamed arguments, kept small. */
export class ToolDraft {
constructor(id, name = '') {
this.id = id; this.name = name;
this.bytes = 0; this.lines = 0;
this.head = ''; // the first 4,000 characters: the body argument usually starts here
this.where = ''; // the file (or address) the call is for, once seen: a model may write it before or after the text
this.carry = ''; // the last 300 characters, so a name split between two pieces is still seen
this.tail = ''; // the last 2,400 characters: what is being written now
this.at = 0; // when a frame was last sent for it
}
add(piece) {
const text = String(piece ?? '');
if (!text) return;
this.bytes += text.length;
for (let i = text.indexOf('\\n'); i >= 0; i = text.indexOf('\\n', i + 2)) this.lines += 1;
if (this.head.length < 4000) this.head += text.slice(0, 4000 - this.head.length);
this.tail = (this.tail + text).slice(-2400);
if (!this.where) {
const seen = this.carry + text;
const raw = (TARGET.exec(seen) ?? URL_ARG.exec(seen))?.[1];
if (raw !== undefined) { try { this.where = JSON.parse(`"${raw}"`); } catch { this.where = unescapeJsonTail(raw); } }
this.carry = seen.slice(-300);
}
}
get target() { return this.where; }
/** The text of the body so far, only its last `max` characters; empty until the body argument has started. */
preview(max = 1600) {
const start = BODY.exec(this.head);
if (!start) return '';
const bodyAt = start.index + start[0].length; // where the body begins, counted in streamed characters
if (bodyAt >= this.bytes) return '';
const tailAt = this.bytes - this.tail.length; // where the kept tail begins
const body = unescapeJsonTail(this.tail.slice(Math.max(0, bodyAt - tailAt)));
return body.length > max ? body.slice(-max) : body;
}
/** The frame for the page. */
frame({ done = false, ...extra } = {}) {
return { type: 'agent-tool-draft', draft: { id: this.id, name: this.name, target: this.target, bytes: this.bytes, lines: this.lines, preview: done ? '' : this.preview(), ...extra, done } };
}
}
const kb = n => (n >= 10_000 ? `${Math.round(n / 1000)} KB` : `${(n / 1000).toFixed(1)} KB`);
/** Calls the model was still writing when its reply was cut: [{ name, target, bytes }]. */
export function cutCalls(drafts) {
return [...drafts].filter(d => d.bytes > 0).map(d => ({ name: d.name, target: d.target, bytes: d.bytes }));
}
const withTokens = tokens => (tokens > 0 ? ` (${Number(tokens).toLocaleString('en-US')} tokens)` : '');
/** The sentence for the person, with the catalog slots it needs: { text, key, values }. `again` is true when the agent was already asked to carry on. */
export function cutNotice({ calls = [], tokens = 0, again = false, retrying = false } = {}) {
const call = calls.find(c => c.target) ?? calls[0] ?? null;
const file = call?.target ? String(call.target).split(/[\\/]/).filter(Boolean).at(-1) : '';
const what = file || (/^(write|edit)$/.test(call?.name ?? '') ? 'a file' : call?.name);
if (call && retrying) {
const key = 'That reply reached its length limit{0} while writing {1} ({2}), so that write did not happen. I asked the assistant to look at what is on disk and write the rest in smaller parts.';
const values = [withTokens(tokens), what, kb(call.bytes)];
return { key, values, text: fill(key, values) };
}
if (call) {
const key = 'The reply reached its length limit{0} while writing {1} ({2}), so that write did not happen. Say “continue” and it will look at what is on disk and go on in smaller parts.';
const values = [withTokens(tokens), what, kb(call.bytes)];
return { key, values, text: fill(key, values) };
}
if (retrying) {
const key = 'That reply reached its length limit{0} and was cut off. I asked the assistant to carry on.';
return { key, values: [withTokens(tokens)], text: fill(key, [withTokens(tokens)]) };
}
const key = 'The reply reached its length limit{0} and was cut off. Say “continue” to go on.';
return { key, values: [withTokens(tokens)], text: fill(key, [withTokens(tokens)]) };
}
export function fill(template, values) { return String(template).replace(/\{(\d+)\}/g, (_, i) => String(values[Number(i)] ?? '')); }
/** The message sent to the assistant so it carries on. It is not shown as the person's message. */
export function cutNudge({ calls = [], tokens = 0 } = {}) {
const call = calls.find(c => c.target) ?? calls[0] ?? null;
if (!call) {
return 'Your last reply was cut off by the output limit before it was finished. Carry on from where it stopped without repeating what you already wrote, and keep your thinking short.';
}
const file = call.target || 'the file';
return `Your last reply was cut off by the output limit${tokens > 0 ? ` (${Number(tokens).toLocaleString('en-US')} tokens)` : ''} while you were writing ${file} in one tool call, so that call did not run and nothing was changed by it. First look at what is on disk now (list the folder, read the end of the file). Do not delete or start over a file that is already complete. If it is not complete, write what is missing in several smaller parts (about 150 lines or fewer each) with edits or appends, and read the end of the file back when you are done. Keep your thinking short.`;
}
+1
View File
@@ -16,6 +16,7 @@ export const TOOL_GROUPS = [
{ id: 'search', area: 'web', name: 'Search the web', about: 'Lets the assistant search the web when it needs fresh information.', asks: true, web: 'search' },
{ id: 'helpers', area: 'together', name: 'Helper assistants', about: 'Lets the assistant start helpers that work on parts of a task at the same time.', asks: false, plugins: ['delegation'] },
{ id: 'goals', area: 'together', name: 'Goals', about: 'Lets you give a goal with /goal. The assistant keeps working until it is done.', asks: false, plugins: ['command-goal', 'tool-goal'] },
{ id: 'questions', area: 'together', name: 'Questions for you', about: 'Lets the assistant stop and ask you a question, with answers to pick from, when it needs your decision.', asks: false, plugins: ['tool-ask-user'] },
{ id: 'todo', area: 'together', name: 'To-do list', about: 'Lets the assistant keep a checklist while it works on a big task.', asks: false, plugins: ['tool-todo'] },
{ id: 'skills', area: 'together', name: 'Skills', about: 'Lets the assistant use your saved skills when they fit the task.', asks: false, plugins: ['skill-filesystem', 'tool-skill'] },
{ id: 'present', area: 'together', name: 'Show results', about: 'Lets the assistant show a page or file in the preview next to the chat.', asks: false, plugins: ['present'] },
+39
View File
@@ -0,0 +1,39 @@
/**
* The agent's own questions (the Harness's `ask_user_question` tool, #4244). The Harness raises the `user-questions/request` event with
* `{ questions: [{ id, question, detail?, header?, options?: [{ label, description? }], multiSelect?, intent? }], wait?: { callId } }` and wants
* `{ answers: [{ id, selected: string[], custom? }] }` back (AskUserQuestionRequestEvent / AskUserQuestionAnswer of the pinned Harness).
* This file only reads the first into something the page can draw, and checks the second before it goes out.
*/
const text = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '');
/** The questions of a request, bounded and plain; an empty list when the request carries nothing that can be shown. */
export function parseQuestions(raw) {
const out = [];
for (const q of Array.isArray(raw) ? raw.slice(0, 6) : []) {
const id = text(q?.id, 120), question = text(q?.question, 800);
if (!id || !question) continue;
const options = (Array.isArray(q.options) ? q.options.slice(0, 12) : [])
.map(o => ({ label: text(o?.label, 200), description: text(o?.description, 400) || undefined })).filter(o => o.label);
out.push({ id, question, detail: text(q.detail, 800) || undefined, header: text(q.header, 80) || undefined, options, multiSelect: q.multiSelect === true });
}
return out;
}
/**
* The answer the person gave, checked against the questions that were asked: every question answered, only the offered labels (or the person's own
* words), one choice for a single-select question. Throws a plain Error with a code on the first thing that does not fit.
*/
export function buildAnswer(questions, given) {
const byId = new Map((Array.isArray(given) ? given : []).map(a => [String(a?.id ?? ''), a]));
const answers = [];
for (const q of questions) {
const a = byId.get(q.id);
const labels = new Set(q.options.map(o => o.label));
const selected = (Array.isArray(a?.selected) ? a.selected : []).map(s => String(s)).filter(s => labels.has(s));
const custom = text(a?.custom, 2000);
if (!selected.length && !custom) throw Object.assign(new Error('Answer every question, or skip them.'), { code: 'question-incomplete' });
if (!q.multiSelect && selected.length > 1) throw Object.assign(new Error('Choose one answer for each question.'), { code: 'question-invalid' });
answers.push({ id: q.id, selected: [...new Set(selected)], ...(custom ? { custom } : {}) });
}
return { answers };
}
+10
View File
@@ -7,6 +7,16 @@ const run=(command,args)=>promisify(execFile)(command,args,{env:externalEnvironm
const extensions=new Set(['.md','.txt','.pdf','.html','.htm','.png','.jpg','.jpeg','.webp','.gif','.svg','.csv','.ts','.tsx','.js','.jsx','.py','.json','.css','.rs','.go','.sh','.mp4','.webm']);
export async function openArtifact(body,{examplesDir,previewsDir,platform=process.platform,launch=run}) {
if(!['open','reveal'].includes(body.action))throw new Error('Choose Open or Show in file explorer.');
if(typeof body.url==='string') {
// #4721: a web address goes to the person's default browser (the app window drops window.open: WebView2 has no new-window handler). http and https only, no shell.
if(body.action!=='open')throw new Error('A web address can only be opened.');
let address;try{address=new URL(body.url);}catch{throw new Error('This is not a web address.');}
if(!['http:','https:'].includes(address.protocol)||body.url.length>4096)throw new Error('Only http and https addresses can be opened.');
if(platform==='darwin')await launch('open',[address.href]);
else if(platform==='win32')await launch('rundll32.exe',['url.dll,FileProtocolHandler',address.href]);
else await launch('xdg-open',[address.href]);
return {ok:true};
}
let file;
if(typeof body.path==='string') {
// Only public example assets can be addressed by path. Never accept host paths.
+46 -7
View File
@@ -121,10 +121,18 @@ export class ControlError extends Error {
}
}
/** Run one CLI command of Control. Resolves { code, json, text, stderr }; rejects only if it cannot start or times out. */
export function runCli(control, args, { env = process.env, timeoutMs = 60_000, spawnProcess = spawn, components, acquire = false, onLine = null, signal = null } = {}) {
/**
* Small Control programs that are a module of strata_control and not a `strata` command. They run on Control's interpreter with Control's folder first on the
* path (the folder is the working directory), so they work whatever the interpreter does with the script folder.
*/
const BOOT = {
pictures: 'import os,sys;sys.path.insert(0,os.getcwd());from strata_control import pictures;sys.exit(pictures.main(sys.argv[1:]))',
};
/** Run one CLI command of Control (or, with `boot`, one of its modules). Resolves { code, json, text, stderr }; rejects only if it cannot start or times out. */
export function runCli(control, args, { env = process.env, timeoutMs = 60_000, spawnProcess = spawn, components, acquire = false, onLine = null, signal = null, boot = null } = {}) {
return new Promise((resolveRun, reject) => {
const child = spawnProcess(control.python, [control.cli, ...args], {
const child = spawnProcess(control.python, boot ? ['-c', BOOT[boot], ...args] : [control.cli, ...args], {
cwd: control.dir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
env: controlEnvironment(control, { env, acquire, ...(components ? { components } : {}) }),
});
@@ -185,9 +193,9 @@ export function createControl({ control = findControl(), env = process.env, run
if (result.json?.ok) return result.json;
throw failure(result, 'The update check failed.');
},
async update({ feed = env.STRATA_UPDATE_FEED ?? null, engine = true, control: withControl = true, appVersion = env.STRATA_APP_VERSION ?? null, force = false, onEvent = null, signal = null } = {}) {
async update({ feed = env.STRATA_UPDATE_FEED ?? null, engine = true, control: withControl = true, appVersion = env.STRATA_APP_VERSION ?? null, force = false, stopModel = false, onEvent = null, signal = null } = {}) {
const args = ['update', '--output', 'jsonl', ...feedArgs(feed), ...(engine ? [] : ['--no-engine']), ...(withControl ? [] : ['--no-control']),
...(appVersion ? ['--app-version', appVersion] : []), ...(force ? ['--force'] : [])];
...(appVersion ? ['--app-version', appVersion] : []), ...(force ? ['--force'] : []), ...(stopModel ? ['--stop-model'] : [])];
const result = await call(args, { timeoutMs: 3 * 3600_000, acquire: true, onLine: onEvent ?? (() => {}), signal });
if (result.json?.ok) return result.json;
const failed = result.json?.results?.find?.(r => r.status === 'failed');
@@ -205,6 +213,24 @@ export function createControl({ control = findControl(), env = process.env, run
if (result.json?.ok) return result.json;
throw failure(result, 'The engine could not be rolled back.');
},
/** The engines installed on this computer, by family (`strata components engines`): the one list the Terminal prints too. Reads files only. */
async engines() {
const result = await call(['components', 'engines', '--output', 'json'], { timeoutMs: 60_000 });
if (result.json?.ok) return result.json;
throw failure(result, 'The installed engines could not be read.');
},
/** Switch to an installed engine (`strata components use`): nothing is downloaded. `root` is only for a DeepSeek engine that lives in more than one place. */
async useEngine({ family, folder }) {
const result = await call(['components', 'use', folder, '--family', family, '--output', 'json'], { timeoutMs: 600_000 });
if (result.json?.ok) return result.json;
throw failure(result, 'The engine could not be switched.');
},
/** Delete an installed engine (`strata components remove`); Control refuses the one in use. */
async removeEngine({ family, folder }) {
const result = await call(['components', 'remove', folder, '--family', family, '--output', 'json'], { timeoutMs: 600_000 });
if (result.json?.ok) return result.json;
throw failure(result, 'The engine could not be deleted.');
},
async engine({ backend = null } = {}) {
const result = await call(['components', 'engine', '--output', 'json', ...(backend ? ['--backend', backend] : [])], { timeoutMs: 30_000 });
if (result.json) return result.json; // exit 5 still carries the report and the notice
@@ -266,17 +292,30 @@ export function createControl({ control = findControl(), env = process.env, run
* (the table of strata_control/drafting.py); the app never reads it. Nothing is downloaded.
*/
async draftingPlan({ configPath = null, mode = null, family = null, dataDir = null } = {}) {
const result = await call(['drafting', 'plan', ...(configPath ? ['--config', configPath] : []), ...(mode ? ['--mode', mode] : []), ...(family ? ['--family', family] : []), ...(dataDir ? ['--data-dir', dataDir] : []), '--output', 'json'], { timeoutMs: 30_000 });
const result = await call(['drafting', 'plan', ...(configPath ? ['--config', configPath] : []), ...(mode ? ['--mode', mode] : []), ...(family ? ['--family', family] : []), ...(dataDir ? ['--data-dir', dataDir] : []), '--no-split', '--output', 'json'], { timeoutMs: 30_000 });
if (result.json?.ok) return result.json;
throw failure(result, 'The drafter could not be checked.');
},
/** Fetch and pack the drafter when the choice would use it and it is not here (`strata drafting fetch --if-needed`); `onEvent` gets {step, done, total}. */
async draftingFetch({ configPath, mode = null, onEvent = null, signal = null } = {}) {
const args = ['drafting', 'fetch', '--config', configPath, ...(mode ? ['--mode', mode] : []), '--if-needed', '--output', 'jsonl'];
const args = ['drafting', 'fetch', '--config', configPath, ...(mode ? ['--mode', mode] : []), '--no-split', '--if-needed', '--output', 'jsonl'];
const result = await call(args, { timeoutMs: 3 * 3600_000, acquire: true, onLine: onEvent ?? (() => {}), signal });
if (result.json?.ok) return result.json;
throw Object.assign(new ControlError(result.json?.error?.message ?? (result.stderr.split(/\r?\n/).filter(Boolean).pop() || 'The DFlash2 drafter could not be downloaded.'), { exitCode: result.code, output: result.json }), { code: 'drafting_failed' });
},
/**
* The picture files of an installed model that has none (strata_control/pictures.py): found or downloaded once (about 0.9 GB, shared), and the run
* config's `vision` section worked out for this engine. Nothing else of the model is touched and nothing is written to its run config: the caller does.
* Resolves { mmproj, vision, fetched, note, data_dir }; a failure is a ControlError whose `code` is offline | disk | failed | checksum | unsupported | no_reader.
* `onEvent` gets {step, done, total}. This is an explicit step that may reach the network (`acquire`), only for the Load the person pressed.
*/
async picturesFetch({ configPath, engineDir, vision = 'yes', dataDir = null, family = null, hardware = null, onEvent = null, signal = null } = {}) {
const args = ['fetch', '--config', configPath, '--engine-dir', engineDir, '--vision', vision, ...(dataDir ? ['--data-dir', dataDir] : []), ...(family ? ['--family', family] : []),
...(hardware?.vram_gb != null ? ['--vram-gb', String(hardware.vram_gb)] : []), ...(hardware?.make ? ['--make', String(hardware.make)] : []), ...(hardware?.arch != null ? ['--arch', String(hardware.arch)] : [])];
const result = await call(args, { timeoutMs: 3 * 3600_000, acquire: true, boot: 'pictures', onLine: onEvent ?? (() => {}), signal });
if (result.json?.ok) return result.json;
throw Object.assign(new ControlError(result.json?.error?.message ?? (result.stderr.split(/\r?\n/).filter(Boolean).pop() || 'The picture files could not be downloaded.'), { exitCode: result.code, output: result.json }), { code: result.json?.error?.code ?? 'failed' });
},
async saveProfile(name, configPath) {
const listed = await call(['profile', 'list', '--output', 'json'], { timeoutMs: 20_000 });
const known = (listed.json?.profiles ?? []).some(p => p.id === name);
+42
View File
@@ -0,0 +1,42 @@
import { plainEngineError } from './update-messages.mjs';
/**
* Settings > Engine: the engines installed on this computer, switched and deleted through Control (`strata components engines | use | remove`),
* the same code the Terminal runs. This module only asks, and turns Control's answer into plain words; it never touches an engine folder itself.
* Nothing here downloads: a switch uses what is installed.
*/
const FAMILIES = ['qwen', 'deepseek'];
/** { status, body } for GET /api/engines. Without Control (a source checkout) the list is not available and the page says so. */
export async function listEngines(control) {
if (typeof control?.engines !== 'function') return { status: 200, body: { available: false, families: [], total_bytes: 0 } };
try {
const listing = await control.engines();
return { status: 200, body: { available: true, families: listing.families ?? [], total_bytes: listing.total_bytes ?? 0, serving: listing.serving === true } };
} catch (error) {
return { status: 500, body: { error: "The installed engines couldn't be read.", details: String(error?.message ?? error) } };
}
}
/**
* { status, body } for POST /api/engines/use and /api/engines/remove. `modelsRunning()` says whether the app has a model loaded: a switch then
* answers at once with the one next step (stop it first); a delete of an engine nothing uses is allowed, Control refuses the one in use.
*/
export async function changeEngine(control, action, body, { modelsRunning = () => false } = {}) {
if (typeof control?.useEngine !== 'function') return { status: 501, body: { error: 'Engines can be switched and deleted in the installed Strata app.' } };
const family = body?.family, folder = body?.folder;
if (!FAMILIES.includes(family) || typeof folder !== 'string' || !/^[0-9A-Za-z][\w.+-]*$/.test(folder)) {
return { status: 400, body: { error: 'Choose an engine from the list.' } };
}
if (action === 'use' && modelsRunning()) {
return { status: 409, body: { error: 'A model is running, so nothing was changed. Unload the model in Models, then try again.' } };
}
try {
const result = action === 'use' ? await control.useEngine({ family, folder }) : await control.removeEngine({ family, folder });
return { status: 200, body: { ok: true, ...result } };
} catch (error) {
const plain = plainEngineError(error, action === 'use' ? 'switch' : 'delete');
return { status: plain.message.startsWith('A model is running') ? 409 : 400, body: { error: plain.message, details: plain.details } };
}
}
+6 -3
View File
@@ -43,12 +43,15 @@ export function createEngineStep({ control, signal = null, onChange = () => {} }
return { ...part };
}
const version = engine.offered?.version ?? null;
set({ state: 'downloading', version, name: `Engine ${version ?? ''}`.trim(), total_gb: round((engine.download_bytes ?? 0) / GB), message: 'Downloading the engine for this PC…' });
// An engine already works here: say what is about to change and that it can be undone (#4823), not only "downloading".
const before = engine.installed?.version ?? null;
const change = before && version && before !== version ? `Updating the engine from ${before} to ${version}. The engine you have now is kept, and Settings can go back to it.` : null;
set({ state: 'downloading', version, name: `Engine ${version ?? ''}`.trim(), total_gb: round((engine.download_bytes ?? 0) / GB), message: change ?? 'Downloading the engine for this PC…' });
const result = await control.update({
engine: true, control: false, signal,
onEvent: event => {
if (event.event === 'progress') {
set({ state: 'downloading', percent: event.percent ?? 0, done_gb: round((event.done ?? 0) / GB), total_gb: round((event.total ?? 0) / GB), message: `Downloading the engine for this PC: ${event.percent ?? 0}%` });
set({ state: 'downloading', percent: event.percent ?? 0, done_gb: round((event.done ?? 0) / GB), total_gb: round((event.total ?? 0) / GB), message: `${change ? `Updating the engine from ${before} to ${version}` : 'Downloading the engine for this PC'}: ${event.percent ?? 0}%` });
} else if (event.event === 'phase') {
const state = { verify: 'verifying', test: 'testing', switch: 'switching' }[event.phase];
if (state) set({ state, message: PHASE_TEXT[event.phase] ?? part.message });
@@ -58,7 +61,7 @@ export function createEngineStep({ control, signal = null, onChange = () => {} }
},
});
const done = (result.results ?? []).find(r => r.component === 'engine');
set({ state: 'done', percent: 100, done_gb: part.total_gb, version: done?.installed_after?.version ?? version, message: 'The engine for this PC is ready.' });
set({ state: 'done', percent: 100, done_gb: part.total_gb, version: done?.installed_after?.version ?? version, message: change ? `The engine was updated from ${before} to ${version}. Settings can go back to the one you had.` : 'The engine for this PC is ready.' });
} catch (error) {
const plain = plainUpdateError(error, 'engine');
note = `Your model is ready. The engine for this PC couldn't be downloaded. ${plain.next}`;
+9 -2
View File
@@ -25,7 +25,12 @@ export function windowsFolderScript(prompt) {
'$dialog.ShowNewFolderButton = $true',
// READY tells the host the window is about to open; a host that never sees it gives up and asks for the path in words (QA F06).
'[Console]::Out.WriteLine("READY"); [Console]::Out.Flush()',
'if ($dialog.ShowDialog($owner) -eq "OK") { [Console]::Out.Write($dialog.SelectedPath) }',
// The dialog starts at the Desktop folder. Where that folder does not exist (it was deleted, a OneDrive move left a dead path, the profile was
// redirected) ShowDialog throws "Unable to retrieve the root folder" and no folder could be chosen at all: start at This PC instead, and when even
// that fails say NODIALOG so the app asks for the path.
'$answer = $null',
'try { $answer = $dialog.ShowDialog($owner) } catch { try { $dialog.RootFolder = [System.Environment+SpecialFolder]::MyComputer; $answer = $dialog.ShowDialog($owner) } catch { [Console]::Out.WriteLine("NODIALOG"); [Console]::Out.Flush() } }',
'if ($answer -eq "OK") { [Console]::Out.Write($dialog.SelectedPath) }',
'$owner.Close()',
].join('; ');
}
@@ -88,11 +93,13 @@ export async function chooseFolder(prompt, options = {}) {
try {
const done = windowsDialog ? await windowsDialog(shell, windowsFolderArgs(prompt)) : await run(shell, windowsFolderArgs(prompt));
if (done.noDialog) return { unavailable: true, failed: true, reason: 'The folder window did not open. Type the folder path instead.' };
if (/^NODIALOG\s*$/m.test(String(done.stdout ?? ''))) return { unavailable: true, failed: true, reason: 'The folder window could not open. Type the folder path instead.' };
const answer = answerOf(done.stdout).trim();
return answer ? { path: cleanChosenPath(answer) } : { cancelled: true };
} catch (error) {
if (missing(error)) return { unavailable: true, reason: 'Windows PowerShell was not found.' };
throw new Error('The folder chooser could not open.');
// The window opened or tried to and ended with an error: the person is asked for the path instead of being left with an error and no way on.
return { unavailable: true, failed: true, reason: 'The folder window could not open. Type the folder path instead.', detail: `exit ${error?.code ?? '?'}` };
}
}
return chooseLinuxFolder(prompt, { run, portal });
+24 -1
View File
@@ -74,6 +74,7 @@ import { installBundledEngine, cleanupAfterInstall } from './bundled-engine.mjs'
import { systemLocale } from './region.mjs';
import { createShellNotifier, DEFAULT_PORT, parsePortNotice, readShellPrefs, writeShellPrefs } from './shell-prefs.mjs';
import { findControl, createControl, componentsDirectory } from './control-runtime.mjs';
import { listEngines, changeEngine } from './engine-library.mjs';
import { forbidden as acquisitionForbidden } from './acquisition.mjs';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { nativeGateway } from './native-gateway.mjs';
@@ -1010,6 +1011,7 @@ async function api(req, res, path, method, url) {
if(path==='/api/native/models-folder/leave'&&method==='POST')return json(res,200,await native.leaveInterruptedMove());
if(path==='/api/native/plan'&&method==='POST')return json(res,200,await native.plan(await readBody(req)));
if(path==='/api/native/operations'&&method==='POST'){if(storageChanging)return error(res,409,'Wait for the model folder check to finish.');const body=await readBody(req);return json(res,202,native.operation(body.kind,body.args));}
if(path==='/api/native/engine-restart/dismiss'&&method==='POST')return json(res,200,native.dismissRestart());
if(path==='/api/native/logs'&&method==='GET')return json(res,200,{logs:native.logs()});
if(path==='/api/native/logs/save'&&method==='POST'){
/* Diagnostics to a file: the engine messages the app kept, failed operations, and the tail of each model's server log under Control.
@@ -1354,6 +1356,15 @@ async function api(req, res, path, method, url) {
try { let at = componentsDirectory(); while (at && !existsSync(at) && dirname(at) !== at) at = dirname(at); const f = statfsSync(at); info.freeBytes = Number(f.bavail) * Number(f.bsize); } catch { /* unknown: no warning */ }
return json(res, 200, info);
}
if (path === '/api/engines' && method === 'GET') {
const { status, body } = await listEngines(control);
return json(res, status, body);
}
if ((path === '/api/engines/use' || path === '/api/engines/remove') && method === 'POST') {
const { status, body } = await changeEngine(control, path.endsWith('/use') ? 'use' : 'remove', await readBody(req), { modelsRunning: () => native.state().instances.length > 0 });
if (status === 200) Promise.resolve(native.refresh?.(true)).catch(() => { /* the next poll reads it */ });
return json(res, status, body);
}
if (path === '/api/engine/job' && method === 'GET') {
// The newest engine download / file install, read without waiting for the native snapshot to connect (the first-run card needs it before that).
const kinds = ['update-engine', 'install-engine-file'];
@@ -1914,6 +1925,18 @@ async function api(req, res, path, method, url) {
try { return json(res, 200, agent.queueMessage({ chatId: String(body.sessionId ?? ''), text: body.message })); }
catch (e) { return error(res, e.code === 'queue-idle' ? 409 : 400, e.message); }
}
// The person's answer to a question the agent asked, or a skip (#4244).
if (path === '/api/chat/answer' && method === 'POST') {
const body = await readBody(req);
try { return json(res, 200, await agent.answerQuestion({ chatId: String(body.sessionId ?? ''), callId: String(body.callId ?? ''), answers: body.answers, skip: body.skip === true })); }
catch (e) { return error(res, e.code === 'question-gone' ? 409 : 400, e.message); }
}
// A message for one helper (#4749).
if (path === '/api/chat/helper' && method === 'POST') {
const body = await readBody(req);
try { return json(res, 200, await agent.messageHelper({ chatId: String(body.sessionId ?? ''), helperId: body.helperId, text: body.message, delivery: body.delivery })); }
catch (e) { return error(res, e.code === 'helper-idle' || e.code === 'helper-refused' ? 409 : 400, e.message); }
}
if (path === '/api/chat/queue/cancel' && method === 'POST') {
const body = await readBody(req);
try { return json(res, 200, agent.cancelQueued({ chatId: String(body.sessionId ?? ''), id: String(body.id ?? '') })); }
@@ -2211,7 +2234,7 @@ async function staticFile(req, res, path) {
if (!existsSync(DIST)) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(
`<!doctype html><meta charset="utf-8"><title>Strata host</title>
`<!doctype html><meta charset="utf-8"><title>Strata</title>
<body style="font:16px/1.6 -apple-system,system-ui;background:#0e1113;color:#eef1f3;padding:48px">
<h1 style="margin:0 0 8px">Strata host is running</h1>
<p style="color:#8e979f">No built web app found at <code>${DIST}</code>.</p>
+1 -1
View File
@@ -4,7 +4,7 @@ import { createHash } from 'node:crypto';
import { basename, dirname, extname, isAbsolute, join } from 'node:path';
import { homedir } from 'node:os';
import { readGGUF } from './gguf.mjs';
const ARCHITECTURES=new Set(['qwen2','qwen2moe','qwen3','qwen3moe','qwen3next','qwen35','qwen35moe','llama','gemma','gemma2','gemma3','phi3']);
const ARCHITECTURES=new Set(['qwen2','qwen2moe','qwen3','qwen3moe','qwen3next','qwen35','qwen35moe','qwen4exp','llama','gemma','gemma2','gemma3','phi3']);
const QUANTS={0:'F32',1:'F16',2:'Q4_0',3:'Q4_1',7:'Q8_0',8:'Q5_0',9:'Q5_1',10:'Q2_K',11:'Q3_K_S',12:'Q3_K_M',13:'Q3_K_L',14:'Q4_K_S',15:'Q4_K_M',16:'Q5_K_S',17:'Q5_K_M',18:'Q6_K',19:'IQ2_XXS',20:'IQ2_XS',21:'Q2_K_S',22:'IQ3_XS',23:'IQ3_XXS',24:'IQ1_S',25:'IQ4_NL',26:'IQ3_S',27:'IQ3_M',28:'IQ2_S',29:'IQ2_M',30:'IQ4_XS',31:'IQ1_M',32:'BF16'};
const clean=value=>String(value??'').replace(/[\x00-\x1f\x7f]/g,' ').trim().slice(0,160);
function fullPath(input){if(typeof input!=='string'||!input.trim()||input.includes('\0'))throw new Error('Choose a model file or enter its full path.');const path=input.trim().replace(/^~(?=$|[\\/])/,homedir());if(!isAbsolute(path))throw new Error('Enter a full path.');try{return realpathSync(path);}catch{throw new Error('This file or folder is unavailable. Check its location and permissions.');}}
+5 -2
View File
@@ -4,7 +4,7 @@ import { detect, cachedHardware } from './hardware.mjs';
import { readGpuTelemetry, gpuMemoryTotals } from './device-probes.mjs';
import { createSystemSampler } from './system-telemetry.mjs';
import { sensorPace, WATCH_MS } from './sensor-pace.mjs';
import { readTemperatures } from './temperature-telemetry.mjs';
import { readTemperatures, stopTemperatures } from './temperature-telemetry.mjs';
import { addEnergy, energyWh } from './energy-meter.mjs';
import { state as servingState } from './serving.mjs';
import { originalDashboard } from './monitor-dashboard.mjs';
@@ -127,11 +127,14 @@ function tick() {
if(!sensorsPending&&Date.now()-sensorsAt>=pace.intervalMs){
sensorsPending=true;sensorsAt=Date.now();
const hardware=cachedHardware()??hwCache;
void Promise.all([readGpuTelemetry(hardware?.gpus??[],{transfer:pace.transfer}),sampleSystem(hardware?.processors??[]),(process.env.STRATA_REAL_ENGINE==='1'?readTemperatures():Promise.resolve(temperatures)).catch(()=>({cpuTempC:null,ssdTempC:null}))])
void Promise.all([readGpuTelemetry(hardware?.gpus??[],{transfer:pace.transfer}),sampleSystem(hardware?.processors??[]),(process.env.STRATA_REAL_ENGINE==='1'&&Date.now()<watchedUntil?readTemperatures():Promise.resolve(temperatures)).catch(()=>({cpuTempC:null,ssdTempC:null}))])
.then(([readings,system,temps])=>{state.hardware.gpus=readings;systemReading=system;temperatures=temps;})
.catch(()=>{state.hardware.gpus=[];systemReading=null;})
.finally(()=>{sensorsPending=false;});
}
/* #4661: the drive-temperature reader asks every physical drive for its temperature. Only while the Monitor is looked at, never from the first second of the app
(on some PCs the storage queries stall the whole system); it ends a couple of minutes after the Monitor was last seen. */
if(Date.now()>watchedUntil+120000)stopTemperatures();
addEnergy(hardwareSnapshot().powerW,Date.now());
const hw = state.hardware;
const busy = state.phase === 'generating' || state.phase === 'reading' || state.phase === 'thinking';
+188 -37
View File
@@ -7,12 +7,12 @@ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, realpa
import { join, resolve, dirname, basename, sep, relative, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer } from 'node:net';
import { NATIVE_FIELDS, APP_OWNED_FIELDS, withoutDraftLayer, validateNativeSettings, validateMcpServers, publicMcpServers, publicNativeEnv, restoreNativeEnv, speedSwitchRows } from './native-settings.mjs';
import { NATIVE_FIELDS, APP_OWNED_FIELDS, withoutDraftLayer, resolveDrafter, validateNativeSettings, validateMcpServers, publicMcpServers, publicNativeEnv, restoreNativeEnv, speedSwitchRows } from './native-settings.mjs';
import { resourceSettings, saferStart, reserveChoice } from './native-resources.mjs';
import { USER_KEY, capture as captureUserArgs, merge as mergeUserArgs, setFlag as setUserFlag, stamped as stampedUserArgs } from './native-user-args.mjs';
import { resolvePython, venvPython } from './python-resolver.mjs';
import os from 'node:os';
import { runtimeVision } from './native-vision.mjs';
import { markPictures, runtimeVision } from './native-vision.mjs';
import { dsEngineArgs, dsUserArgs, dsArgsNote, withoutVisionFlag } from './native-ds-args.mjs';
import { componentsDirectory, installerEnvironment, controlEnvironment, sourceServerEnvironment } from './control-runtime.mjs';
import { createEngineStep } from './engine-step.mjs';
@@ -32,7 +32,7 @@ const busy=requests=>requests.some(r=>['queued','generating'].includes(r.state))
function readJson(path,fallback={}){try{return JSON.parse(readFileSync(path,'utf8').replace(/^\uFEFF/,''));}catch{return fallback;}}
/** The plain words for "no usable engine": the model still downloads, but loading needs the engine (owner decision 2026-10-18). */
export const ENGINE_NOT_RECOGNISED='Strata didn’t recognise an engine for this computer. The model is downloaded, but it can’t run until the right engine is installed. Open Settings → Engine.';
export const ENGINE_NOT_RECOGNISED='Strata didn’t recognise an engine for this computer. The model is downloaded, but it can’t run until the right engine is installed. Open Settings → About.';
export function createNativeControl({directory,requests=()=>[],changed=()=>{},spawnProcess=spawn,fetcher=fetch,modelsDirectory,nativeDataDirectory=value=>value,beforeLoad=async()=>{},hardwareProbe=()=>[],control=null,ready=null,appUpdater=null,defaultRepo=process.env.STRATA_NATIVE_REPO??resolve(ROOT,'../../Strata')}={}){
mkdirSync(directory,{recursive:true});
const settingsPath=join(directory,'native-host.json');
@@ -63,17 +63,19 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
const updateLog=line=>{try{console.log(`update · ${line}`);}catch{/* no console */}};
const NO_INSTALL='Installing, updating, tuning with calibration or rolling back model files and engines happens from the Terminal (strata install), never while the app runs.';
let defaultId=null,timer=null,closing=false,polling=false;
/* Models the engine update stopped (with the person's yes): kept so the app can offer to start them again with the same settings. */
let stoppedForUpdate=null;
const emit=()=>{const value=state();writeFileSync(join(directory,'native-jobs.json'),JSON.stringify(value.jobs),{mode:0o600});changed(value);};
const append=(instanceId,line)=>{logs.push({at:new Date().toISOString(),instanceId,line:String(line).slice(0,2000)});if(logs.length>400)logs.shift();};
function diskSpace(path){try{const s=statfsSync(path);return {free:Number(s.bavail)*Number(s.bsize),total:Number(s.blocks)*Number(s.bsize)};}catch{return null;}}
/* Does the engine in this release serve DFlash2? Control knows (strata_control/drafting.py); asked once, false until it answers. The Models screen offers the choice only when true. */
let dflash2Served=null;
function probeDrafting(){if(!packaged||dflash2Served!==null||typeof control.draftingPlan!=='function')return;dflash2Served=false;control.draftingPlan({mode:'auto'}).then(p=>{dflash2Served=p.serves===true;emit();}).catch(()=>{});}
function state(){const folder=preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),space=folder?diskSpace(folder):null;return {...snapshot,downloadDirectory:folder,downloadFreeBytes:space?.free??null,downloadTotalBytes:space?.total??null,fields:nativeFields(),instances:[...instances.values()].map(({key,configPath,fingerprint,mcpServers,...i})=>structuredClone(i)),defaultId,jobs:[...jobs.values()].slice(-12).map(({process,abort,engineAbort,rawResult,...j})=>j),capabilities:{responses:sourceText().includes('/v1/responses'),updates:!!snapshot.available,rollback:previousEngine(),componentUpdates:packaged&&typeof control.update==='function',calibration:packaged?false:snapshot.hardware?.gpus?.some(g=>/nvidia/i.test(g.name??''))??false,dflash2:dflash2Served===true}};}
let dflash2Served=null,dflash2Support=null,dflash2Selq4=false,dflash2Split=false;
function probeDrafting(){if(!packaged||dflash2Served!==null||typeof control.draftingPlan!=='function')return;dflash2Served=false;control.draftingPlan({mode:'auto'}).then(p=>{dflash2Served=p.serves===true;dflash2Support=p.serves===true&&p.support&&p.support_line?{level:p.support,line:p.support_line}:null;dflash2Selq4=p.serves===true&&p.selq4===true;dflash2Split=p.split===true;emit();}).catch(()=>{});}
function state(){const folder=preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),space=folder?diskSpace(folder):null;return {...snapshot,downloadDirectory:folder,downloadFreeBytes:space?.free??null,downloadTotalBytes:space?.total??null,fields:nativeFields(),instances:[...instances.values()].map(({key,configPath,fingerprint,mcpServers,...i})=>structuredClone(i)),defaultId,stoppedForUpdate:stoppedForUpdate?{updated:stoppedForUpdate.updated,at:stoppedForUpdate.at,models:stoppedForUpdate.instances.map(i=>({id:i.id,label:i.label}))}:null,jobs:[...jobs.values()].slice(-12).map(({process,abort,engineAbort,rawResult,...j})=>j),capabilities:{responses:sourceText().includes('/v1/responses'),updates:!!snapshot.available,rollback:previousEngine(),componentUpdates:packaged&&typeof control.update==='function',calibration:packaged?false:snapshot.hardware?.gpus?.some(g=>/nvidia/i.test(g.name??''))??false,dflash2:dflash2Served===true,dflash2Support,dflash2Selq4,dflash2Split}};}
let sourceCache={path:'',time:0,text:''};
function sourceText(){try{const path=join(packaged?control.control.dir:preferences.repo,'serve/server.py'),time=statSync(path).mtimeMs;if(sourceCache.path===path&&sourceCache.time===time)return sourceCache.text;sourceCache={path,time,text:readFileSync(path,'utf8')};return sourceCache.text;}catch{return '';}}
function nativeFields(){const source=sourceText();return NATIVE_FIELDS.map(field=>({...field,supported:APP_OWNED_FIELDS.includes(field.id)||field.id.startsWith('vision_')||field.id.startsWith('mcp_')||field.id==='vram_reserve_mib'||source.includes(field.id)}));}
const modelArena=id=>({arenaGb:(snapshot.catalog?.families??[]).flatMap(f=>f.sizes??[]).find(s=>s.id===id)?.experts_gb});
const modelArena=id=>({arenaGb:(snapshot.catalog?.families??[]).flatMap(f=>f.sizes??[]).find(s=>s.id===id)?.experts_gb,engineVersion:snapshot.engine?.version??snapshot.installed?.engine?.version??null});
/* A start that ends with `server_exited` carries the engine's last log line with it; the app turns the code into words and
keeps this text under "Details" (src/lib/plain-errors.ts). */
function exitCodeText(code){const n=Number(code);if(!Number.isFinite(n)||n===0)return '';const u=n<0?n+2**32:n;return u>=0x80000000?`exit code 0x${u.toString(16).toUpperCase().padStart(8,'0')}`:`exit code ${u}`;}
@@ -88,7 +90,11 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
for(const name of names){const path=join(dir,name),config=readJson(path,null);if(!config||typeof config!=='object')continue;
const next={...config,...(config.cwd!==undefined?{cwd:fix(config.cwd)}:{}),...(Array.isArray(config.args)?{args:config.args.map(fix)}:{})};
if(JSON.stringify(next)!==JSON.stringify(config)){try{writeModelConfig(path,next);}catch{/* read-only folder: the load reports it */}}}}
function installedInfo(info){if(!info)return info;return {...info,models:(info.models??[]).map(entry=>{const cfg=/^strata-[a-z0-9_.-]+\.json$/i.test(entry.config??'')?readJson(entry.configPath??join(repo(),entry.config)):{};const at=cfg.args?.indexOf('--native')??-1,nativeFile=at>=0?cfg.args[at+1]:null;const dataFolder=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))==='models'?dirname(dirname(dirname(nativeFile))):info.data_folder;/* A model the person pointed at their own GGUF folder (not <data>/models/<tag>): remembered, so a later load runs setup with that folder again instead of downloading the files (#3199). */const ownGgufDir=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))!=='models'&&existsSync(nativeFile)?dirname(nativeFile):undefined;return {...entry,ownGgufDir,contextMax:configContextLimit(cfg)??undefined,diskGb:entry.diskGb??entry.disk_gb,diskBytes:entry.diskBytes??entry.disk_bytes,sharedBytes:entry.sharedBytes??entry.shared_bytes,removableGb:entry.removableGb??entry.removable_gb,dataFolder,visionPrepared:!!cfg.vision,/* The picture files are prepared when the run config has a vision section: the Models screen then loads straight away instead of running setup again (it used to read 'off' for every model, which sent every load of a picture-capable model through "Apply settings and load", and setup writes the arguments, the draft layer and the reader back each time). */images:cfg.vision?'yes':'off',draftLayer:cfg.mtp!==false,drafting:cfg.drafting??'auto',/* Picture reading is ON unless the person turned it off: no saved choice and not loaded is undefined, not false (owner rule: pictures on by default). */visionEnabled:cfg.ui_vision_enabled??(()=>{const running=[...instances.values()].find(i=>i.model===entry.model&&i.status==='ready');return running?!!running.vision:undefined;})(),visionDevice:cfg.ui_vision_device,resourceProfile:cfg.ui_resource_profile,lowRamMode:cfg.args?.includes('--resident-experts')?'resident':cfg.args?.includes('--mmap-experts')?'mmap':'auto'};})};}
/* DeepSeek reads pictures with the engine's own tower, which is part of the pack (pack_gguf writes the tower's geometry into ds4.txt when the picture file was there): a pack with it is "prepared", a text-only pack (made with --vision off, as the app did before 0.2.9) is not, and the Models screen then offers the one missing shared file. null for every other model. */
const dsPictures=cfg=>{if(cfg?.arch!=='deepseek41')return null;const a=Array.isArray(cfg.args)?cfg.args:[];const at=a.indexOf('--pack');if(at<0||typeof a[at+1]!=='string')return false;try{const t=readFileSync(join(a[at+1],'ds4.txt'),'utf8');return /image_token_id/.test(t)&&/vision[.]num_hidden_layers/.test(t);}catch{return false;}};
/* DeepSeek's picture switch is a pair, --vision on|off, in the saved arguments: set it to the person's choice at every load (on only where the pack carries the tower). */
const dsVisionPair=(a,value)=>{if(!value)return a;const at=a.indexOf('--vision');return at<0?[...a,'--vision',value]:a.map((x,i)=>i===at+1?value:x);};
function installedInfo(info){if(!info)return info;return {...info,models:(info.models??[]).map(entry=>{const cfg=/^strata-[a-z0-9_.-]+\.json$/i.test(entry.config??'')?readJson(entry.configPath??join(repo(),entry.config)):{};const at=cfg.args?.indexOf('--native')??-1,nativeFile=at>=0?cfg.args[at+1]:null;const dataFolder=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))==='models'?dirname(dirname(dirname(nativeFile))):info.data_folder;/* The folder this model's own run config keeps its prepared data in: the one that holds its packs/<tag> (or mtp/rt). It is the real data folder of the model, wherever the app's models folder is; a model in the person's own GGUF folder has its pack and draft layer here, not in the app's folder. A layout that only looks like <data>/models/<tag> says nothing: no pack, no folder. */const flagValue=flag=>{const i=cfg.args?.indexOf(flag)??-1;return i>=0&&typeof cfg.args[i+1]==='string'?cfg.args[i+1]:undefined;};const holder=(path,child,name)=>{if(!path||basename(dirname(path)).toLowerCase()!==child||(name&&basename(path).toLowerCase()!==name))return undefined;return dirname(dirname(path));};const runDataFolder=holder(flagValue('--pack'),'packs')??holder(flagValue('--mtp'),'mtp','rt');/* A model the person pointed at their own GGUF folder (not <data>/models/<tag>): remembered, so a later load runs setup with that folder again instead of downloading the files (#3199). */const ownGgufDir=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))!=='models'&&existsSync(nativeFile)?dirname(nativeFile):undefined;return {...entry,ownGgufDir,runDataFolder,contextMax:configContextLimit(cfg)??undefined,diskGb:entry.diskGb??entry.disk_gb,diskBytes:entry.diskBytes??entry.disk_bytes,sharedBytes:entry.sharedBytes??entry.shared_bytes,removableGb:entry.removableGb??entry.removable_gb,dataFolder,contextChosen:cfg.ui_context_chosen===true,visionPrepared:dsPictures(cfg)??(!!cfg.vision&&entry.picturesMissing!==true),/* The picture files are prepared when the run config has a vision section: the Models screen then loads straight away instead of running setup again (it used to read 'off' for every model, which sent every load of a picture-capable model through "Apply settings and load", and setup writes the arguments, the draft layer and the reader back each time). */images:(dsPictures(cfg)??(!!cfg.vision&&entry.picturesMissing!==true))?'yes':'off',draftLayer:cfg.mtp!==false,drafting:cfg.drafting??'auto',/* Picture reading is ON unless the person turned it off: no saved choice and not loaded is undefined, not false (owner rule: pictures on by default). */visionEnabled:cfg.ui_vision_enabled??(()=>{const running=[...instances.values()].find(i=>i.model===entry.model&&i.status==='ready');return running?!!running.vision:undefined;})(),visionDevice:cfg.ui_vision_device,resourceProfile:cfg.ui_resource_profile,lowRamMode:cfg.args?.includes('--resident-experts')?'resident':cfg.args?.includes('--mmap-experts')?'mmap':'auto'};})};}
function installRoot(){const found=[preferences.installDir].find(d=>d&&existsSync(d));if(found)return realpathSync(found);mkdirSync(defaultInstall(),{recursive:true});return realpathSync(defaultInstall());}
/* The settings file Strata Control and the installer read (control-runtime.mjs sets STRATA_SETTINGS_FILE to it): the app's data folder is written there. */
function controlSettingsFile(){return process.env.STRATA_SETTINGS_FILE||join(dirname(componentsDirectory()),'app-settings.json');}
@@ -122,10 +128,11 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
const engine=await control.engine();
const installer=await mcpCall('strata_status',{hardware:true}).catch(error=>({error:error.message}));
const disk=Object.fromEntries((installer.installed?.models??[]).map(m=>[m.model,{diskGb:m.disk_gb,diskBytes:m.disk_bytes,sharedBytes:m.shared_bytes,removableGb:m.removable_gb,partsMissing:m.parts_missing??0,missingFiles:m.missing_files}]));
const models=(list.models??[]).filter(m=>m.component_status!=='invalid'&&m.source_config).map(m=>({...(disk[m.id]??{}),model:m.id,config:basename(m.source_config),configPath:m.source_config,model_name:m.name,context:m.context_tokens??0,backend:m.backend,ready:m.component_status==='present'&&!(disk[m.id]?.partsMissing),missing:missingNames(m.missing,disk[m.id]?.missingFiles),images:'off',port:undefined,last_used:null}));
const onlyPictures=m=>m.component_status==='missing_or_invalid'&&Array.isArray(m.missing)&&m.missing.length>0&&m.missing.every(p=>p?.part==='vision/mmproj')&&Array.isArray(m.issue_codes)&&m.issue_codes.length===m.missing.length&&m.issue_codes.every(c=>/^COMPONENT_(MISSING|INVALID)$/.test(c));
const models=(list.models??[]).filter(m=>m.component_status!=='invalid'&&m.source_config).map(m=>({...(disk[m.id]??{}),model:m.id,config:basename(m.source_config),configPath:m.source_config,model_name:m.name,context:m.context_tokens??0,backend:m.backend,ready:(m.component_status==='present'||onlyPictures(m))&&!(disk[m.id]?.partsMissing),/* Only the picture file is gone (run config has the picture section, the file does not exist): the model is not "missing files". It loads, and the Load fetches the picture file again. */picturesMissing:m.component_status!=='present'&&onlyPictures(m),missing:missingNames(m.missing,disk[m.id]?.missingFiles),images:'off',port:undefined,last_used:null}));
const cores=os.cpus().length;
return {summary:engine.usable?`Strata Control is ready; engine ${engine.engine.version} (${engine.engine.platform}-${engine.engine.backend}).`:(engine.notice??'No engine for this PC was found.'),
installDir:list.install_dir,engine:{usable:engine.usable,notice:engine.notice,wrongMake:engine.wrongMake===true,version:engine.engine?.version??null,backend:engine.backend,componentsDir:engine.components_dir,previous:engine.previous??null,pinned:engine.pinned??null,updateNotice:engine.update_notice??null},
installDir:list.install_dir,engine:{usable:engine.usable,notice:engine.notice,wrongMake:engine.wrongMake===true,version:engine.engine?.version??null,backend:engine.backend,componentsDir:engine.components_dir,previous:engine.previous??null,pinned:engine.pinned??null,updateNotice:engine.update_notice??null,updateAvailable:engine.update_available??null},
installed:{strata_folder:list.install_dir,data_folder:null,engine:engine.engine?{version:engine.engine.version,backend:engine.engine.backend,folder:engine.engine.root}:{folder:null,version:null},models,usage:installer.installed?.usage??null},
hardware:installer.hardware?{...installer.hardware,cpu:{...(installer.hardware.cpu??{}),cores:installer.hardware.cpu?.cores??cores},gpus:installer.hardware.gpus??[]}:{cpu:{cores},ram_gb:Math.round(os.totalmem()/1024**3),gpus:[]},
recommendation:installer.recommendation??null,installerError:installer.error??null,install:installer.install??null,running:null};
@@ -141,7 +148,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
is refreshed in the background. Only the first read, and a forced one after an operation, waits. */
if(refreshing)return !force&&snapshot.available?state():force?refreshing.then(()=>refresh(true)):refreshing; /* a forced read that meets one already running (started before an engine update finished) waits for it and reads again, so it never settles on the old answer */if(!force&&Date.now()-lastRead<45000)return state();
probeDrafting();
refreshing=(async()=>{try{const [catalog,status]=packaged?await (async()=>{const s=await tool('strata_status',{hardware:true});const c=await tool('strata_models').catch(error=>({families:[],contexts:[],recommendation:{family:null,model:null},context_note:'',error:error.message}));return [c,s];})():await Promise.all([tool('strata_models'),tool('strata_status',{hardware:true})]);let visionReader=false;if(packaged){try{const e=await control.engine();const root=e?.engine?.root??(e?.engine?.exe?dirname(e.engine.exe):'');visionReader=!!root&&['strata-vision','strata-vision.exe'].some(n=>existsSync(join(root,n)));}catch{visionReader=false;}}const markImages=c=>c&&Array.isArray(c.families)?{...c,families:c.families.map(f=>({...f,imagesCapable:!!f.images,images:!!f.images&&visionReader,sizes:(f.sizes??[]).map(s=>({...s,imagesCapable:!!s.images,images:!!s.images&&visionReader}))}))}:c;snapshot={available:true,repo:packaged?status.installDir:repo(),version:packaged?(control.version??null):await execute('git',['describe','--tags','--always'],repo()).then(s=>s.trim()).catch(()=>null),summary:status.summary,catalog:markEngine(packaged?markImages(catalog):catalog,status.engine),engineInfo:{version:status.engine?.version??null,source:status.engine?.source??null},visionReader:packaged?visionReader:true,installed:installedInfo(status.installed),hardware:status.hardware,error:null,install:status.install,external:status.running??null,...(packaged?{packaged:true,installDir:status.installDir,engine:status.engine,installerError:status.installerError}:{})};lastRead=Date.now();if(status.install?.running&&![...jobs.values()].some(j=>j.state==='running'))begin('install',j=>watchInstall(j,status.install));}catch(error){snapshot={...snapshot,available:false,error:error.message};lastRead=Date.now();}emit();return state();})().finally(()=>{refreshing=null;});const run=refreshing;if(!force&&snapshot.available){run.catch(()=>{});return state();}return run;
refreshing=(async()=>{try{const [catalog,status]=packaged?await (async()=>{const s=await tool('strata_status',{hardware:true});const c=await tool('strata_models').catch(error=>({families:[],contexts:[],recommendation:{family:null,model:null},context_note:'',error:error.message}));return [c,s];})():await Promise.all([tool('strata_models'),tool('strata_status',{hardware:true})]);let visionReader=false;if(packaged){try{const e=await control.engine();const root=e?.engine?.root??(e?.engine?.exe?dirname(e.engine.exe):'');visionReader=!!root&&['strata-vision','strata-vision.exe'].some(n=>existsSync(join(root,n)));}catch{visionReader=snapshot.visionReader===true;}}const markImages=c=>markPictures(c,visionReader);snapshot={available:true,repo:packaged?status.installDir:repo(),version:packaged?(control.version??null):await execute('git',['describe','--tags','--always'],repo()).then(s=>s.trim()).catch(()=>null),summary:status.summary,catalog:markEngine(packaged?markImages(catalog):catalog,status.engine),engineInfo:{version:status.engine?.version??null,source:status.engine?.source??null},visionReader:packaged?visionReader:true,installed:installedInfo(status.installed),hardware:status.hardware,error:null,install:status.install,external:status.running??null,...(packaged?{packaged:true,installDir:status.installDir,engine:status.engine,installerError:status.installerError}:{})};lastRead=Date.now();if(status.install?.running&&![...jobs.values()].some(j=>j.state==='running'))begin('install',j=>watchInstall(j,status.install));}catch(error){snapshot={...snapshot,available:false,error:error.message};lastRead=Date.now();}emit();return state();})().finally(()=>{refreshing=null;});const run=refreshing;if(!force&&snapshot.available){run.catch(()=>{});return state();}return run;
}
function configure({repository}){if(packaged){if(instances.size||[...jobs.values()].some(j=>j.state==='running'))throw new Error('Unload models and finish operations before changing the installation.');const next=realpathSync(String(repository));if(!statSync(next).isDirectory())throw new Error('Choose the folder that holds your Strata model configurations.');preferences={...preferences,installDir:next};packagedInstall=null;disconnect();writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});lastRead=0;return refresh(true);}if(instances.size||[...jobs.values()].some(j=>j.state==='running'))throw new Error('Unload models and finish operations before changing the installation.');const next=realpathSync(String(repository));const old=preferences;preferences={...preferences,repo:next};try{repo();}catch(e){preferences=old;throw e;}disconnect();writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});lastRead=0;return refresh(true);}
function setDownloadDirectory(input,{insideJob=false}={}){if(!insideJob&&[...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before changing the folder.');if(typeof input!=='string'||!input.trim()||/[\r\n\0]/.test(input))throw new Error('Choose a model folder.');let path;try{path=realpathSync(input.trim());}catch{throw new Error('That folder does not exist. Check the path, or that the drive is connected.');}if(!statSync(path).isDirectory())throw new Error('Choose a folder, not a file.');/* A whole drive (D:\) is not filled with models: D:\Strata is made and used. */if(isDriveRoot(path)){const own=dataFolderFromPick(path).path;makeFolder(own);path=realpathSync(own);}try{accessSync(path,constants.R_OK|constants.W_OK);}catch{throw new Error('Strata cannot write in that folder. Choose a folder you can save files in.');}preferences={...preferences,downloadDirectory:path};/* the same choice for the installer and the Terminal: the Control settings file */if(packaged)rememberDataFolder(controlSettingsFile(),path,Date.now()/1000,true);writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});shareDataFolder();emit();return state();}
@@ -157,11 +164,11 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
function previousEngine(){if(packaged)return !!snapshot.engine?.previous;try{const data=snapshot.installed?.data_dir??snapshot.installed?.data_folder;return [join(preferences.repo,'engine/.previous'),data&&join(data,'engine/.previous')].filter(Boolean).some(existsSync)&&readFileSync(join(preferences.repo,'setup.py'),'utf8').includes('--rollback-engine');}catch{return false;}}
async function plan(args){await refresh();if(!snapshot.available)throw new Error(snapshot.error);const result=await tool('strata_install',installArgs(args,false));
/* A chosen drafter that is not on this computer yet is part of the download: its size is listed beside the model's (Control decides; nothing is fetched here). */
const choice=args.drafting;if(packaged&&typeof choice==='string'&&choice!=='auto'&&typeof control.draftingPlan==='function'){try{const d=await control.draftingPlan({mode:choice,family:args.family??null,dataDir:snapshot.installed?.data_folder??null});if(d.needed)return {...result,drafting:{needed:true,gb:d.gb,line:d.line}};}catch{/* the model plan still stands */}}
const choice=args.drafting??(dflash2Served===true?'auto':undefined);if(packaged&&typeof choice==='string'&&typeof control.draftingPlan==='function'){try{const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model),here=snapshot.installed?.models?.find(m=>m.model===size?.id);const d=await control.draftingPlan({mode:choice,family:args.family??null,dataDir:here?.runDataFolder??snapshot.installed?.data_folder??null});if(d.needed||d.mtp_needed)return {...result,drafting:{needed:true,drafter:d.needed?'dflash2':'mtp',gb:d.needed?d.gb:d.mtp_gb,line:d.line}};}catch{/* the model plan still stands */}}
return result;}
/* The folder the person picked in the first-run chooser can be the one that holds their GGUF files (or a folder inside it). It is theirs, not a Strata data folder: packs are never made in it, and a Strata folder around it (<Strata>/models/<tag>) is used instead (#3507, #3424). */
function ownFilesDataFolder(candidate,args){if(!candidate||args.data_dir!==undefined||!args.gguf_dir)return candidate;const same=(a,b)=>{const x=resolve(a),y=resolve(b);return process.platform==='win32'?x.toLowerCase()===y.toLowerCase():x===y;};const inside=(a,b)=>{const rel=relative(resolve(b),resolve(a));return rel===''||(!!rel&&!rel.startsWith('..')&&!isAbsolute(rel));};if(!same(candidate,args.gguf_dir)&&!inside(candidate,args.gguf_dir))return candidate;const models=dirname(resolve(args.gguf_dir)),top=dirname(models);return basename(models).toLowerCase()==='models'&&/strata/i.test(basename(top))?top:undefined;}
function installArgs(args,confirm){const allowed=['family','model','context','vision','kv','gpu','gpus','backend','low_ram','port','gguf_dir'];const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model);const existing=snapshot.installed?.models?.find(m=>m.model===size?.id);/* A model adopted from the person's own GGUF folder reports that folder's parent as its data folder: it is theirs, not Strata's, so a second preparation never uses it for the packs. */if(args.gguf_dir===undefined&&existing?.ownGgufDir)args={...args,gguf_dir:existing.ownGgufDir};const data=nativeDataDirectory(ownFilesDataFolder(args.data_dir??(args.gguf_dir?undefined:existing?.dataFolder)??preferences.downloadDirectory??modelsDirectory?.(),args));if(data&&packaged&&!args.data_dir)rememberDataFolder(controlSettingsFile(),data);return {...(data?{data_dir:data}:{}),...Object.fromEntries(allowed.filter(k=>args[k]!==undefined).map(k=>[k,args[k]])),/* One card is "gpu", never "gpus" (the installer wants two or more there: "Gpus='0' is not valid", #3203). */...(/^\d{1,2}$/.test(String(args.gpus??''))?{gpu:Number(args.gpus),gpus:undefined}:{}),/* Picture reading is only downloaded when it was asked for (Read pictures); the default stays the smaller download. */...(packaged&&args.vision===undefined?{vision:'no'}:{}),confirm};}
function installArgs(args,confirm){const allowed=['family','model','context','vision','kv','gpu','gpus','backend','low_ram','port','gguf_dir','drafter'];/* One drafter per run: when the choice means DFlash2 the installer does not fetch the MTP layer's ~5 GB (the drafter is fetched by the load); choosing the MTP later fetches the layer once. */if(args.drafter===undefined&&args.drafting!==undefined||args.drafter===undefined&&dflash2Served===true){const d=resolveDrafter(args.drafting,args.family,dflash2Served,dflash2Split);if(d==='dflash2')args={...args,drafter:'dflash2'};}const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model);const existing=snapshot.installed?.models?.find(m=>m.model===size?.id);/* A model adopted from the person's own GGUF folder reports that folder's parent as its data folder: it is theirs, not Strata's, so a second preparation never uses it for the packs. */if(args.gguf_dir===undefined&&existing?.ownGgufDir)args={...args,gguf_dir:existing.ownGgufDir};/* A model that is installed keeps working in the data folder its own run config names (where its pack and draft layer are), whether its files are under it or in the person's own folder: that is never changed or written to the settings from here. Only a folder taken from the app's models folder is the person's choice, and the one the installer is told about. */const own=args.data_dir===undefined?existing?.runDataFolder:undefined;const data=own??nativeDataDirectory(ownFilesDataFolder(args.data_dir??(args.gguf_dir?undefined:existing?.dataFolder)??preferences.downloadDirectory??modelsDirectory?.(),args));if(data&&packaged&&!args.data_dir&&!own)rememberDataFolder(controlSettingsFile(),data);return {...(data?{data_dir:data}:{}),...Object.fromEntries(allowed.filter(k=>args[k]!==undefined).map(k=>[k,args[k]])),/* One card is "gpu", never "gpus" (the installer wants two or more there: "Gpus='0' is not valid", #3203). */...(/^\d{1,2}$/.test(String(args.gpus??''))?{gpu:Number(args.gpus),gpus:undefined}:{}),/* Picture reading is only downloaded when it was asked for (Read pictures); the default stays the smaller download. */...(packaged&&args.vision===undefined&&args.family!=='deepseek41'?{vision:'no'}:{}),confirm};}
function begin(kind,run,metadata={}){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or cancel the current engine operation first.');const job={id:randomUUID(),kind,state:'running',startedAt:new Date().toISOString(),summary:'Starting…',result:null,error:null,...metadata};for(const [id,old] of jobs)if(jobs.size>=12&&old.state!=='running')jobs.delete(id);jobs.set(job.id,job);emit();void run(job).then(result=>{if(job.state==='running'){Object.assign(job,{state:'done',summary:result?.summary??'Complete',result,finishedAt:new Date().toISOString()});}}).catch(error=>{if(job.state==='running')Object.assign(job,{state:'error',error:error.message,summary:error.message,...(error.details?{details:error.details}:{}),finishedAt:new Date().toISOString()});}).finally(()=>{if(job.kind.startsWith('update')||job.kind==='rollback')disconnect();lastRead=0;emit();void refresh(true);});return state().jobs.find(j=>j.id===job.id);}
async function watchInstall(job,current){job.summary=current.step??'Resuming preparation status…';job.result={install:current};while(job.state==='running'){await new Promise(r=>setTimeout(r,1500));const result=await tool('strata_install');job.summary=result.summary;job.result=result;emit();if(!result.install?.running){if(result.install?.exit_code!==0)throw new Error(result.install?.error??'Preparation did not finish. Retry to reuse completed files.');return {summary:'Model preparation complete. Load it from Models.'};}}}
@@ -195,7 +202,9 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
const note=engineStep?.summaryNote();
const finalResult=engineStep?job.result:result;
if(note)return {...finalResult,summary:note};
if(args.loadAfter){const model=(result.install.family??args.family??'qwen')==='qwen'?String(args.model).toLowerCase():`${args.family}-${String(args.model).toLowerCase()}`;return loadEngine(job,{model,multiple:!!args.multiple,resourceProfile:args.resourceProfile,vision:args.loadVision,...(typeof args.drafting==='string'?{values:{drafting:args.drafting}}:{})});}
/* A context the person picked is kept with a marker (ui_context_chosen), so only an old saved 32K, which has no marker, is read as the old default. */
if(args.contextChosen===true){try{const id=(result.install.family??args.family??'qwen')==='qwen'?String(args.model).toLowerCase():`${args.family}-${String(args.model).toLowerCase()}`;rememberSetting(record(id).path,'ui_context_chosen',true);}catch{/* the install itself is done */}}
if(args.loadAfter){const model=(result.install.family??args.family??'qwen')==='qwen'?String(args.model).toLowerCase():`${args.family}-${String(args.model).toLowerCase()}`;return loadEngine(job,{model,multiple:!!args.multiple,resourceProfile:args.resourceProfile,vision:args.loadVision,...(args.visionUnavailable===true?{visionUnavailable:true}:{}),...(typeof args.drafting==='string'?{values:{drafting:args.drafting}}:{})});}
return finalResult;
}
}
@@ -267,18 +276,20 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
const sized=gb>40?Math.max(300,600+(gb-40)*8):300;
return Math.trunc(Math.max(300,Math.min(ceiling,Math.max(ready,sized))));
}
function buildConfig(args,config){
const vision=runtimeVision(config,args.vision);
const visionPreferences=args.vision!==undefined?{ui_vision_enabled:args.vision!=='no',ui_vision_device:args.vision==='no'?config.ui_vision_device??'yes':args.vision}:{};
function buildConfig(args,config,drafter=null){
/* `visionUnavailable`: this engine has no picture reader, so the run goes without pictures but nothing is saved: the model's own setting stays what the person had (it used to save "off", and the model stayed off after the engine got the reader). */
const noReader=args.visionUnavailable===true;
const vision=ownEngine(config)?undefined:runtimeVision(config,noReader?'no':args.vision); /* DeepSeek has its own picture tower: its picture setting is the --vision flag setup wrote */
const visionPreferences=args.vision!==undefined&&!noReader?{ui_vision_enabled:args.vision!=='no',ui_vision_device:args.vision==='no'?config.ui_vision_device??'yes':args.vision}:{};
/* The engine options the person set by hand (user_args, and what they wrote into "args" since the last write) go over everything the app generates, and an
explicit GPU-memory reserve (the Expert setting, user_args, a value they wrote) is never replaced by the safety default (#3215 #3270 #3216). */
const values0=args.values??{},cleared='vram_reserve_mib' in values0&&(values0.vram_reserve_mib===null||values0.vram_reserve_mib===''),base0=cleared?{...config,vram_reserve_mib:undefined,[USER_KEY]:setUserFlag(config[USER_KEY],'--vram-reserve-mib',null)}:config;
let mine=captureUserArgs(base0);const asked=cleared?null:reserveChoice({...base0,[USER_KEY]:mine},snapshot.hardware,values0).asked;
if(asked!==null)mine=setUserFlag(mine,'--vram-reserve-mib',asked);
const resources=args.resourceProfile&&!ownEngine(config)?resourceSettings({...base0,[USER_KEY]:mine,vision},snapshot.hardware,args.resourceProfile,modelArena(args.model),values0):null;
const cfg={...config,...visionPreferences,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),host:'127.0.0.1',open_browser:false,...validateNativeSettings(args.values??{}),vision};
const cfg={...config,...visionPreferences,...(args.contextChosen===true?{ui_context_chosen:true}:{}),...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),host:'127.0.0.1',open_browser:false,...validateNativeSettings(args.values??{}),vision};
/* The app's suggested cards only fill a gap: a model whose settings file already names its GPUs ("gpu": [1, 0], with "gpu_order": "as_given") keeps them (#3256). A card the person picked in the screen always wins. */
const namedInFile=(Array.isArray(config.gpu)?config.gpu.length>0:config.gpu!==undefined&&config.gpu!==null&&config.gpu!=='')||!!config.gpu_roles;
const namedInFile=((Array.isArray(config.gpu)?config.gpu.length>0:config.gpu!==undefined&&config.gpu!==null&&config.gpu!=='')||!!config.gpu_roles)&&!setupPickedOneCard(config,snapshot.hardware);
/* Roles chosen in the screen (Main / Helper / Off per card, gpu-roles.mjs): the card list is the roles' mirror - Helpers first, the Main card LAST, Off cards left out - with "gpu_roles" beside it. Every card Off is allowed: the server says plainly what the engine can do. */
if(args.gpuRoles!==undefined){const make=snapshot.hardware?.engine_vendor,cards=(snapshot.hardware?.gpus??[]).filter(g=>!make||!g.vendor||g.vendor===make),named=parseRoles(args.gpuRoles);if(!named)throw new Error('Choose Main, Helper or Off for each graphics card, with one Main.');const known=new Set(cards.map(g=>g.index));if(Object.keys(named).some(k=>!known.has(Number(k))))throw new Error('Choose detected graphics cards.');const roles=Object.fromEntries(cards.map(g=>[g.index,named[g.index]??'off'])),m=mirrorRoles(roles,cards);for(const k of ['gpu','gpu_order','gpu_none','gpu_roles','gpu_cards','hip_ordinal'])delete cfg[k];Object.assign(cfg,m);const count=Array.isArray(m.gpu)?m.gpu.length:m.gpu_none?0:1;cfg.layer_split=count>1?validateNativeSettings({layer_split:args.layerSplit??'auto'}).layer_split:'auto';if(cfg.layer_split!=='auto'&&cfg.layer_split.split(',').length!==count-1)throw new Error('Choose one layer split point between each pair of GPUs.');}
else if(args.gpus!==undefined&&!(args.gpusFromRecommendation&&namedInFile)){const detected=new Set((snapshot.hardware?.gpus??[]).map(g=>g.index));if(!Array.isArray(args.gpus)||!args.gpus.length||new Set(args.gpus).size!==args.gpus.length||args.gpus.some(g=>!Number.isInteger(g)||!detected.has(g)))throw new Error('Choose distinct detected GPUs. CPU-only text inference is not supported by this native adapter.');cfg.gpu=args.gpus.length===1?args.gpus[0]:args.gpus;for(const k of ['gpu_roles','gpu_cards','gpu_none'])delete cfg[k];delete cfg.hip_ordinal;cfg.layer_split=args.gpus.length>1?validateNativeSettings({layer_split:args.layerSplit??'auto'}).layer_split:'auto';if(cfg.layer_split!=='auto'&&cfg.layer_split.split(',').length!==args.gpus.length-1)throw new Error('Choose one layer split point between each pair of GPUs.');}
@@ -289,9 +300,10 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
if((cfg.mtp===false||cfg.drafting==='off')&&Array.isArray(cfg.args))cfg.args=withoutDraftLayer(cfg.args);
/* The Qwen engine's --vision stands alone, so it goes (with a picture word after it, if an older file has one). The DeepSeek engine's is a pair, `--vision on|off`, that setup wrote and the engine reads: it stays whole (taking out only the name left `off` behind: `--eos 1 off`, "unknown option off", #4358 #4359 #4505 #4507). */
if(!cfg.vision&&!ownEngine(config)&&Array.isArray(cfg.args))cfg.args=withoutVisionFlag(cfg.args);
if(ownEngine(config)&&Array.isArray(cfg.args)&&args.vision!==undefined)cfg.args=dsVisionPair(cfg.args,args.vision==='no'?'off':dsPictures(cfg)?'on':null);
for(const arg of cfg.args??[])if(arg.endsWith('.gguf')&&!existsSync(resolve(repo(),arg)))throw new Error('An engine argument names a missing GGUF. Restore the saved arguments or repair the files.');
/* The app's own choice of request slots (4, fewer where the card has no room) is part of the configuration, so a tune measures the engine a load will start: slots take VRAM from the expert cache. */
const parallelChoice=packaged&&!ownEngine(config)?chooseParallel({config:cfg,values:args.values??{},hardware:snapshot.hardware?.gpus?.length?snapshot.hardware:{gpus:hardwareProbe()??[]}}):null;
const parallelChoice=packaged&&!ownEngine(config)?chooseParallel({config:cfg,values:args.values??{},drafter,hardware:snapshot.hardware?.gpus?.length?snapshot.hardware:{gpus:hardwareProbe()??[]}}):null;
let parallelNote=null,dsNote='';
/* The agent asks for long answers (a thinking model can use most of the window): let the engine clamp the output to what is left of the context instead of refusing the request. */
if(packaged&&!ownEngine(config)&&cfg.fit_max_tokens===undefined)cfg.fit_max_tokens=true;
@@ -304,32 +316,115 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
else delete cfg.ds_experts;
return {cfg,resources,visionPreferences,parallelChoice,parallelNote,dsNote};
}
/* One drafter per run. Control (strata_control/drafting.py) says which one this load runs and whether its file is on this computer: the plan of the saved model file with the choice the Load carries. */
async function draftPlan(args,configFile){
if(!packaged||typeof control.draftingPlan!=='function')return null;
const asked=args.values?.drafting;
try{return await control.draftingPlan({configPath:configFile,...(asked===undefined?{}:{mode:asked===null?'auto':asked})});}catch{return null;}
}
/* The choice changed to the MTP and its layer is not here yet: it is fetched once, by the installer (finished steps are skipped), in this Load. Only the Models screen's own Load carries the choice; a restart or a tuning run never fetches. */
async function installMtpLayer(job,args,config){
const fam=snapshot.catalog?.families?.find(f=>f.sizes.some(s=>s.id===args.model)),size=fam?.sizes.find(s=>s.id===args.model);
if(!fam||!size)return;
job.summary='Downloading the MTP drafter…';emit();
/* the model's own files folder (the run config's --native): without it the installer, asked for this model by name, would fetch the whole model again next to the layer (an app install adopted from the person's own GGUF folder has its files elsewhere) */const native=(()=>{const a=(config.args??[]).map(String),i=a.indexOf('--native');return i>=0?(a[i+1]??''):'';})();try{await install(job,{family:fam.family,model:size.model,drafter:'mtp',vision:args.vision??(config.vision?'yes':'no'),...(native?{gguf_dir:dirname(native)}:{})});}
catch{if(job.state==='running'){job.drafterNote='The MTP drafter could not be downloaded.';emit();}}/* one short line, not the installer's own text; Drafting below says what runs instead */
}
/* The DFlash2 drafter, before a load that uses it: Control says whether the choice needs it (and the file is not here), downloads it pinned and checked, and packs it, with plain progress in the job.
Never stops the load: a drafter that cannot be had leaves the MTP running, and the job says so. The choice itself is remembered in the model's settings file. */
async function prepareDrafter(job,args,cfg,configFile){
Never stops the load: a drafter that cannot be had leaves the MTP running if its layer is here, else no drafter, and the job says so. The choice itself is remembered in the model's settings file. */
async function prepareDrafter(job,args,cfg,configFile,drafts){
const asked=args.values?.drafting;
if(asked!==undefined)rememberSetting(configFile,'drafting',asked===null||asked==='auto'?null:asked);
if(!packaged||typeof control.draftingFetch!=='function'||!cfg.drafting||cfg.drafting==='auto'||cfg.drafting==='mtp'||cfg.drafting==='off')return;
/* Only the Models screen's own Load passes the choice (its dialog has listed the download): a restart, a tuning run or any other load leaves `asked` undefined and never fetches; the model then starts with the drafter that is on this computer and says why. */
if(!packaged||typeof control.draftingFetch!=='function'||asked===undefined||drafts?.needed!==true)return;
const work=join(directory,'native-instances');mkdirSync(work,{recursive:true});const probe=join(work,'drafting-'+randomUUID()+'.json');
try{
writeFileSync(probe,JSON.stringify(cfg),{mode:0o600});
job.engineAbort??=new AbortController();
await control.draftingFetch({configPath:probe,mode:cfg.drafting,signal:job.engineAbort.signal,onEvent:event=>{if(event.event==='progress'&&job.state==='running'){job.summary=event.step;job.details=event.total>1&&event.done<event.total?`${(event.done/1e9).toFixed(2)} of ${(event.total/1e9).toFixed(2)} GB`:undefined;emit();}}});
await control.draftingFetch({configPath:probe,mode:cfg.drafting??'auto',signal:job.engineAbort.signal,onEvent:event=>{if(event.event==='progress'&&job.state==='running'){job.summary=event.step;job.details=event.total>1&&event.done<event.total?`${(event.done/1e9).toFixed(2)} of ${(event.total/1e9).toFixed(2)} GB`:undefined;emit();}}});
if(job.state==='running'){job.summary='Loading the model…';job.details=undefined;emit();}
}catch(error){if(job.state==='running'){job.drafterNote=error.message;job.summary='The DFlash2 drafter could not be downloaded, so the model starts with the MTP. Loading the model…';emit();}}
}catch(error){if(job.state==='running'){job.drafterNote=error.message;job.summary=drafts.mtp_here===false?'The DFlash2 drafter could not be downloaded, so the model starts without a drafter. Loading the model…':'The DFlash2 drafter could not be downloaded, so the model starts with the MTP. Loading the model…';emit();}}
finally{try{rmSync(probe,{force:true});}catch{/* best effort */}}
}
/* One key of a model's settings file, set or removed (the Drafting choice): written like the Settings screen writes it, to be there for the next load. */
function rememberSetting(configFile,key,value){try{const current=readJson(configFile);if(value===null?!(key in current):current[key]===value)return;const next={...current};if(value===null)delete next[key];else next[key]=value;const temp=configFile+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,configFile);}catch{/* the load goes on with the choice for this start */}}
/* Which drafter runs, when it is not the one that was asked for (the engine's own words, "Drafting: MTP (DFlash2 didn't fit)"): under Details of the load. */
async function noteDrafting(job,instance,key){let note='';try{const r=await fetcher(`http://127.0.0.1:${instance.port}/metrics`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(r.ok)note=String((await r.json())?.engine?.drafting_note??'');}catch{/* a convenience */}const lines=[job.drafterNote,note].filter(Boolean);if(lines.length)job.details=[job.details,...lines].filter(Boolean).join('\n');}
/* ---- The picture files of an installed model that has none (strata_control/pictures.py, run by Control) ----
Only the Load or the "Download picture-reading files" button the person pressed reaches the network, and only for the picture file (about 0.9 GB, one copy
shared by the models of a data folder). The model's own files, its pack, its draft layer, the data folder and the settings' data_dir are not touched; the one change
to the model's run config is the picture section. A restart, a tuning run or any other load that does not ask for pictures never downloads. */
const PICTURES_WORDS={download:'Downloading the picture files (0.9 GB, once)…',check:'Checking the picture files…',loading:'Loading the model…',
offline:'No internet connection: the model is loaded without picture reading. Load it again when you are online.',
disk:'There is not enough free disk space for the picture files, so the model is loaded without picture reading.',
failed:'The picture files could not be downloaded, so the model is loaded without picture reading. Try again later.',
gone:'The picture files are missing, so the model is loaded without picture reading.'};
const PLAIN_PICTURE_CODES=['offline','disk','failed','checksum','unsupported','no_reader'];
const askedPictures=value=>['yes','gpu','cpu'].includes(value);
const pictureFileGone=config=>!config.vision?.mmproj||!existsSync(resolve(repo(),String(config.vision.mmproj)));
/* The card the picture reader will be chosen for: the cards the model runs on, the one with the most memory (setup's rule is the card's memory: the CPU reader under 24 GB). */
function pictureCard(config){const make=snapshot.hardware?.engine_vendor,named=Array.isArray(config.gpu)?config.gpu:config.gpu===undefined||config.gpu===null?null:[config.gpu];const cards=(snapshot.hardware?.gpus??[]).filter(g=>g.usable!==false&&(!make||!g.vendor||g.vendor===make)&&(!named||named.includes(g.index)));const best=[...cards].sort((a,b)=>(Number(b.vram_gb)||0)-(Number(a.vram_gb)||0))[0];return best?{vram_gb:Number(best.vram_gb)||0,make:best.vendor??make??null,arch:best.arch??null}:null;}
async function ensurePictures(job,args,configFile,config,{strict=false,missing=false}={}){
const keep={config,args,note:null};
if(!packaged||ownEngine(config)||args.visionUnavailable===true)return keep;
const prepared=!!config.vision,asked=askedPictures(args.vision);
/* Control says the picture file is gone (missing) or, when pictures were asked for, this look at the disk says so; a load that asked for nothing never second-guesses Control. */
if(prepared&&!missing&&!(asked&&pictureFileGone(config)))return keep;
if(!asked){const none={...args,vision:'no',visionUnavailable:true};return prepared?{config,args:none,note:PICTURES_WORDS.gone}:keep;}
if(typeof control.picturesFetch!=='function')return keep;
job.abort=new AbortController();job.summary=PICTURES_WORDS.download;job.details=undefined;emit();
try{
const engine=await control.engine();const engineDir=engine?.engine?.root;
if(!engineDir)throw Object.assign(new Error('This engine has no picture reader.'),{code:'no_reader'});
const family=snapshot.catalog?.families?.find(f=>f.sizes?.some(s=>s.id===args.model))?.family??null;
const here=snapshot.installed?.models?.find(m=>m.model===args.model);
let fallbackData=null;try{fallbackData=here?.runDataFolder??here?.dataFolder??nativeDataDirectory(preferences.downloadDirectory??modelsDirectory?.())??null;}catch{fallbackData=here?.runDataFolder??here?.dataFolder??null;}
const got=await control.picturesFetch({configPath:configFile,engineDir,vision:args.vision,dataDir:fallbackData,family,hardware:pictureCard(config),signal:job.abort.signal,
onEvent:event=>{if(event?.event!=='progress'||job.state!=='running')return;const total=Number(event.total)||0,done=Number(event.done)||0;
if(/^Checking/i.test(String(event.step))){job.summary=PICTURES_WORDS.check;}
else{job.summary=PICTURES_WORDS.download;if(total>0)job.result={install:{download:{file:'picture reader',done_gb:done/1e9,total_gb:total/1e9,remaining_gb:Math.max(0,total-done)/1e9,percent:Math.min(99,Math.floor(100*done/total))}}};}
emit();}});
if(job.state!=='running')return keep;
const next={...config,vision:got.vision};
writeModelConfig(configFile,next);
if(got.note)append(args.model,`Picture files: ${got.note}`);
job.result=null;job.summary=PICTURES_WORDS.loading;job.details=undefined;emit();
return {config:next,args,note:null};
}catch(error){
job.result=null;
if(job.state!=='running')return keep;
const code=error?.code,details=error?.output?.error?.details;
append(args.model,`Picture files: ${error?.message??error}${details?` (${details})`:''}`);
if(strict)throw Object.assign(new Error(PLAIN_PICTURE_CODES.includes(code)?String(error.message):'The picture files could not be downloaded. Try again.'),{details:details?[details]:undefined});
job.summary=PICTURES_WORDS.loading;job.details=undefined;emit();
return {config,args:{...args,vision:'no',visionUnavailable:true},note:code==='offline'?PICTURES_WORDS.offline:code==='disk'?PICTURES_WORDS.disk:PICTURES_WORDS.failed};
}
}
/* "Download picture-reading files": the same step without a load. */
async function picturesOnly(job,args){
await refresh(true);if(job.state!=='running')return;
const {path:configFile}=recordFile(args.model);const config=checkedSettingsConfig(readFileSync(configFile,'utf8'));
if(!packaged)throw new Error('The picture files are downloaded by the installed app.');
if(ownEngine(config))throw new Error('This model reads pictures with its own files.');
const {entry}=recordFile(args.model);
if(config.vision&&!entry.picturesMissing&&!pictureFileGone(config))return {summary:'The picture files are ready.'};
await ensurePictures(job,{...args,vision:askedPictures(args.vision)?args.vision:'yes'},configFile,config,{strict:true,missing:entry.picturesMissing===true});
if(job.state!=='running')return;
await refresh(true);
return {summary:'The picture files are ready.'};
}
async function loadEngine(job,args){
await refresh(true);if(job.state!=='running')return;const {entry,path:configFile}=recordFile(args.model);const config=checkedSettingsConfig(readFileSync(configFile,'utf8'));if(packaged){const engine=await control.engine();if(!engine.usable)throw new Error(ENGINE_NOT_RECOGNISED+(engine.notice?` ${engine.notice}`:''));}if(!entry.ready){const error=missingFilesError(args.model,entry.missing);append(args.model,error.details[0]);throw error;}
await refresh(true);if(job.state!=='running')return;const {entry,path:configFile}=recordFile(args.model);let config=checkedSettingsConfig(readFileSync(configFile,'utf8'));if(packaged){const engine=await control.engine();if(!engine.usable)throw new Error(ENGINE_NOT_RECOGNISED+(engine.notice?` ${engine.notice}`:''));}if(!entry.ready){const error=missingFilesError(args.model,entry.missing);append(args.model,error.details[0]);throw error;}
if(externalRunning().length)throw new Error('A Strata server is already running outside this UI. Stop it before loading here, or connect its API in Settings → Models.');
if(args.multiple&&instances.size>=8)throw new Error('Unload an instance before adding another. Up to eight native instances can run here.');
const {cfg,resources,visionPreferences,parallelChoice,parallelNote:chosenNote,dsNote}=buildConfig(args,config);
/* Read pictures is on and the picture files are not here: the Load fetches those files (about 0.9 GB, once) and nothing else of the model, then goes on. No internet: the model loads without pictures and the page says so. */
let picturesNote=null;{const got=await ensurePictures(job,args,configFile,config,{missing:entry.picturesMissing===true});if(job.state!=='running')return;config=got.config;args=got.args;picturesNote=got.note;}
/* One drafter per run (strata_control/drafting.py): switching to the MTP on a model set up for DFlash2 fetches the layer once, here; DFlash2 is fetched below. */
let drafts=await draftPlan(args,configFile);
if(drafts?.mtp_needed===true&&(args.values?.drafting!==undefined||drafts.split===true&&(config.drafting==null||String(config.drafting).toLowerCase()==='auto'))){/* the Models screen's own Load carries the choice; a plain Auto load fetches the layer too when Control says the all-cards split engine will take the start (it runs the MTP and nothing else): the same as the Terminal */await installMtpLayer(job,args,config);if(job.state!=='running')return;await refresh(true);config=checkedSettingsConfig(readFileSync(recordFile(args.model).path,'utf8'));drafts=await draftPlan(args,configFile);}
const {cfg,resources,visionPreferences,parallelChoice,parallelNote:chosenNote,dsNote}=buildConfig(args,config,drafts?.choice==='dflash2'&&drafts.needed===true&&args.values?.drafting===undefined?'mtp':drafts?.choice??null);/* (a drafter that is not here and will not be fetched by this load: the MTP runs, and the request slots stay) */
let parallelNote=chosenNote;
await prepareDrafter(job,args,cfg,configFile);if(job.state!=='running')return;
await prepareDrafter(job,args,cfg,configFile,drafts);if(job.state!=='running')return;
const fingerprint=createHash('sha256').update(JSON.stringify({...cfg,api_key:undefined,port:undefined})).digest('hex');
const placement=Array.isArray(cfg.gpu)?cfg.gpu:cfg.gpu===undefined?[]:[cfg.gpu];const matching=[...instances.values()].find(i=>i.model===args.model&&JSON.stringify(i.gpus)===JSON.stringify(placement));
if(matching?.status==='ready'&&matching.fingerprint===fingerprint&&(args.multiple||instances.size===1))return {summary:`${matching.label} is already ready.`,instanceId:matching.id};
@@ -344,7 +439,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
if(!args.multiple){if(busy(requests().filter(r=>instances.has(r.instanceId))))throw new Error('Finish or cancel native requests before switching models.');for(const id of [...instances.keys()])await unload(id);}else if(matching)await unload(matching.id);
if(job.state!=='running')return;
writeFileSync(configPath,JSON.stringify(cfg,null,2),{mode:0o600});
const instance={id,apiModelId,fingerprint,context:entry.context??0,aliases:cfg.aliases??[],vision:!!cfg.vision,resourceProfile:cfg.ui_resource_profile??null,model:args.model,label:friendlyModelName(snapshot.catalog,args.model)??entry.model_name??args.model,port:cfg.port,gpus:Array.isArray(cfg.gpu)?cfg.gpu:cfg.gpu===undefined?[]:[cfg.gpu],status:'loading',phase:'loading',parallel:cfg.parallel??1,mcpServers:cfg.mcp_servers??cfg.mcpServers??{},key,configPath,loadedAt:new Date().toISOString(),metrics:null,error:null};
const instance={id,apiModelId,fingerprint,context:entry.context??0,aliases:cfg.aliases??[],vision:!!cfg.vision,resourceProfile:cfg.ui_resource_profile??null,model:args.model,label:friendlyModelName(snapshot.catalog,args.model)??entry.model_name??args.model,port:cfg.port,gpus:Array.isArray(cfg.gpu)?cfg.gpu:cfg.gpu===undefined?[]:[cfg.gpu],status:'loading',phase:'loading',parallel:cfg.parallel??1,mcpServers:cfg.mcp_servers??cfg.mcpServers??{},key,configPath,loadedAt:new Date().toISOString(),metrics:null,error:null,...(picturesNote?{picturesNote}:{})};
/* Another model on the same graphics card: the engine takes the card's free memory, so a second one usually finds none. Loading goes on (recommend, never force); if it fails, the reason is said. */
const sharedWith=instance.gpus.length?[...instances.values()].find(i=>i.id!==id&&i.status!=='error'&&i.model!==args.model&&i.gpus.some(g=>instance.gpus.includes(g))):null;
const sharedSentence=sharedWith?`GPU ${instance.gpus.find(g=>sharedWith.gpus.includes(g))+1} is already used by ${sharedWith.label}, so this model did not fit there. Load it on another GPU, or unload ${sharedWith.label} first.`:null;
@@ -391,7 +486,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
}
const deadline=Date.now()+300000;while(Date.now()<deadline&&job.state==='running'){
if(instance.status==='error')throw new Error(instance.error);
try{const response=await fetcher(`http://127.0.0.1:${instance.port}/health`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(job.state!=='running'){await unload(id);return;}if(response.ok){instance.status='ready';instance.phase='idle';await noteDrafting(job,instance,key);if(resources||args.vision!==undefined||JSON.stringify(cfg[USER_KEY]??null)!==JSON.stringify(config[USER_KEY]??null))writeModelConfig(record(args.model).path,{...config,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),...visionPreferences,...(cfg[USER_KEY]?{[USER_KEY]:cfg[USER_KEY]}:{})});emit();ensurePoll();instance.parallelNote=parallelNote;return {summary:retried?`${instance.label} is ready, after a second try: ${retried.words}`:`${instance.label} is ready.${parallelNote?' '+parallelNote:''}`,instanceId:id};}}catch{}
try{const response=await fetcher(`http://127.0.0.1:${instance.port}/health`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(job.state!=='running'){await unload(id);return;}if(response.ok){instance.status='ready';instance.phase='idle';await noteDrafting(job,instance,key);if(resources||args.vision!==undefined||args.contextChosen===true||JSON.stringify(cfg[USER_KEY]??null)!==JSON.stringify(config[USER_KEY]??null))writeModelConfig(record(args.model).path,{...config,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),...visionPreferences,...(args.contextChosen===true?{ui_context_chosen:true}:{}),...(cfg[USER_KEY]?{[USER_KEY]:cfg[USER_KEY]}:{})});emit();ensurePoll();instance.parallelNote=parallelNote;return {summary:retried?`${instance.label} is ready, after a second try: ${retried.words}`:`${instance.label} is ready.${parallelNote?' '+parallelNote:''}${picturesNote?' '+picturesNote:''}`,instanceId:id};}}catch{}
await new Promise(resolve=>setTimeout(resolve,1200));
}
if(job.state!=='running'){await unload(id);return;}
@@ -418,10 +513,10 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
shell's signed Tauri updater. Models are not part of any of it. */
async function componentProgram(job,args={}){
if(!COMPONENT_KINDS.includes(job.kind)||typeof control.update!=='function')throw new Error(NO_INSTALL);
if(instances.size)throw new Error('Unload native models before changing engine files.');
if(instances.size&&!(job.kind==='update-engine'&&args.stopModel===true))throw new Error('Unload native models before changing engine files.');
job.abort=new AbortController();
const subject=job.kind==='update-strata'?'Strata':'engine';
try{return await componentSteps(job,subject,args);}catch(error){updateLog(`${job.kind} failed: ${String(error?.message??error).slice(0,400)}`);throw error?.details!==undefined&&error.message?error:plainError(error,subject);}
try{return await componentSteps(job,subject,args);}catch(error){updateLog(`${job.kind} failed: ${String(error?.message??error).slice(0,400)}`);throw error?.details!==undefined&&error.message?error:plainError(error,subject,args?.firstRun===true?'Continue':undefined);}
}
async function componentSteps(job,subject,args={}){
const onEvent=event=>{
@@ -433,7 +528,13 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
updateLog(`${job.kind} started (app ${thisAppVersion()??'unknown'})`);
if(job.kind==='install-engine-file'){if(typeof args.path!=='string'||!args.path)throw new Error('Choose the engine file first.');let done;try{done=await control.installEngineFile(args.path,{onEvent,signal:job.abort.signal});}finally{try{const own=resolve(directory,'incoming-engine')+sep;if(resolve(args.path).startsWith(own))rmSync(args.path,{force:true});}catch{/* the copy in the host's own folder; a leftover is harmless */}}return {summary:updateSummary(done)==='Everything is already up to date.'?(done.results?.[0]?.note??'Nothing was changed.'):updateSummary(done),result:done};}
if(job.kind==='rollback'){const done=await control.rollbackEngine();return {summary:'The previous engine is back. Your models and settings are unchanged.',result:done};}
if(job.kind==='update-engine'){const done=await control.update({engine:true,control:false,onEvent,signal:job.abort.signal});return {summary:updateSummary(done),result:done};}
if(job.kind==='update-engine'){
/* A model is loaded and the person said yes to stopping it (the dialog names it): Control downloads while it serves, stops it cleanly, updates, and leaves it stopped.
Whatever the result, the models it stopped are kept so the app offers to start them again, on the new engine or, after a failed update, on the old one. */
const gate=args.stopModel===true&&instances.size?updateGate(job):null;
try{const done=await control.update({engine:true,control:false,stopModel:!!gate,onEvent:gate?e=>{onEvent(e);gate.event(e);}:onEvent,signal:job.abort.signal});gate?.finish(true);return {summary:updateSummary(done),result:done};}
catch(error){gate?.finish(false);if(gate?.refusal)throw Object.assign(new Error(gate.refusal),{details:`Running outside this app: ${gate.outside.join(', ')}`});throw error;}
}
const done=await control.update({engine:false,control:true,onEvent,signal:job.abort.signal});
updateLog(`Control step finished: ${updateSummary(done)}`);
const notes=[updateSummary(done)];
@@ -446,7 +547,47 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
}
return {summary:notes.filter(Boolean).join(' '),result:done};
}
function updateSummary(done){const r=(done.results??[]).filter(x=>x.status==='updated').map(x=>x.component==='engine'?'The engine is updated and ready.':'Strata is updated. It takes effect the next time you open the app.');return r.length?r.join(' '):'Everything is already up to date.';}
/* The engine update with a model loaded (strata update --stop-model --output jsonl). The `models` events say what Control is doing to the models; this turns them into the app's own
state: the model leaves the list when it is stopped, and a model the app did not start is never stopped (the update is ended before anything is downloaded). */
function updateGate(job){
const mine=new Map([...instances.values()].filter(i=>i.profile).map(i=>[i.profile,i])),stopped=[],gate={refusal:null,event(e){
if(e.event!=='models')return;
const models=Array.isArray(e.models)?e.models:[],label=m=>mine.get(m.profile)?.label??m.name??'The model';
if(e.phase==='consent'){
const other=models.filter(m=>!mine.has(m.profile));
if(other.length){gate.refusal='A model that was not started from this app is running, so nothing was changed. Stop it, then update the engine again.';gate.outside=other.map(label);job.abort.abort();return;}
job.step='getting-ready';job.models=models.map(label);job.summary=`Getting the new engine ready. ${job.models.join(' and ')} keeps running until it is ready.`;
}
else if(e.phase==='stopping'){for(const m of models){const i=mine.get(m.profile);if(i)i.status='unloading';}job.step='stopping';job.models=models.map(label);job.summary=`Stopping ${job.models.join(' and ')}. Answers in progress finish first.`;}
else if(e.phase==='stopped'){for(const m of models){const i=mine.get(m.profile);if(i&&instances.get(i.id)===i){instances.delete(i.id);stopped.push(i);if(defaultId===i.id)defaultId=instances.keys().next().value??null;}}job.step='stopped';job.models=models.map(label);job.summary=`${job.models.join(' and ')} stopped. Updating the engine…`;}
emit();
},finish(updated){stoppedForUpdate=stopped.length?{updated,at:new Date().toISOString(),instances:stopped}:null;emit();}};
return gate;
}
/* "Start again with the same settings": the stopped model's saved profile (the run file it was started with) is started again; the engine in use is whichever Control has primary now. */
async function restartStopped(job){
const offer=stoppedForUpdate;if(!offer?.instances?.length)throw new Error('There is no stopped model to start again.');
stoppedForUpdate=null;emit();
const labels=[];
for(const old of offer.instances){
const instance={...old,status:'loading',phase:'loading',error:null,metrics:null,loadedAt:new Date().toISOString()};
instances.set(instance.id,instance);if(!defaultId||!instances.has(defaultId))defaultId=instance.id;job.summary=`Starting ${instance.label}. Loading can take a few minutes.`;emit();
let wait=960;try{const {config,entry}=record(instance.model);wait=startWaitFor(config,entry,instance.model);}catch{/* the usual wait */}
try{
await control.start(instance.profile,{waitSeconds:wait});
const deadline=Date.now()+300000;let ready=false;
while(!ready&&Date.now()<deadline&&job.state==='running'){try{const response=await fetcher(`http://127.0.0.1:${instance.port}/health`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(2000)});ready=response.ok;}catch{/* not yet */}if(!ready)await new Promise(resolve=>setTimeout(resolve,1200));}
if(!ready)throw new Error('Loading took more than five minutes. Check the engine log and free memory.');
instance.status='ready';instance.phase='idle';ensurePoll();emit();labels.push(instance.label);
}catch(error){
await control.stop(instance.profile).catch(()=>{});
instance.status='error';instance.error=humanStartError(error);emit();throw new Error(`${instance.label} did not start again. ${instance.error}`);
}
}
return {summary:`${labels.join(' and ')} ${labels.length>1?'are':'is'} running again with the same settings.`};
}
function dismissRestart(){stoppedForUpdate=null;emit();return state();}
function updateSummary(done){const r=(done.results??[]).filter(x=>x.status==='updated').map(x=>x.component==='engine'||x.component==='dsv41'?'The engine is updated and ready.':'Strata is updated. It takes effect the next time you open the app.');return r.length?r.join(' '):'Everything is already up to date.';}
function execute(cmd,args,cwd){return new Promise((resolve,reject)=>{let output='';const process=spawnProcess(cmd,args,{cwd,stdio:['ignore','pipe','pipe']});process.stdout.on('data',c=>{output+=c;if(output.length>100000)process.kill();});process.stderr.on('data',c=>append('update',c.toString()));process.on('error',reject);process.on('exit',code=>code===0?resolve(output):reject(new Error('Git could not update this installation. Open diagnostics.')));});}
/* Every write of a model's run config keeps the person's options: those they wrote into "args" or "user_args" since the file was last written are read from the file itself, go over what the caller built, and "args_written" is refreshed. */
function writeModelConfig(path,next){const mine=mergeUserArgs(captureUserArgs(readJson(path)),next[USER_KEY]??[]);next=stampedUserArgs(next,mine);copyFileSync(path,path+'.bak');chmodSync(path+'.bak',0o600);const temp=path+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,path);}
@@ -468,7 +609,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
const next={...current.config,...values,ui_resource_profile:args.profile};
writeModelConfig(current.path,next);
job.summary='Applying your resource preference and loading the model…';emit();
const loaded=await loadEngine(job,{model:args.model,multiple:false,vision:current.config.vision?args.vision:'no'});
const loaded=await loadEngine(job,{model:args.model,multiple:false,vision:current.config.vision?args.vision:'no',...(args.visionUnavailable===true?{visionUnavailable:true}:{})});
if(job.state!=='running'||!loaded?.instanceId)return;
job.summary='Checking your model’s speed…';emit();
const measured=await benchmark(job,{instanceId:loaded.instanceId});
@@ -491,7 +632,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
const room=args.resourceProfile==='work';
return autotuner.start(job,job.kind==='recheck'?'recheck':'tune',{configPath:path,presetId:args.presetId,mode:args.mode,use:args.use,goal:room?'room':'speed',leaveRam:room?20:0,allowSmallDifferences:args.allowSmallDifferences===true,name:args.name},{onProgress:p=>{job.summary=p.text??job.summary;emit();}});
}
function operation(kind,args={}){if(!['install','load','benchmark','tune','autotune','recheck','calibrate','update-engine','update-strata','rollback','install-engine-file','remove'].includes(kind))throw new Error('Choose a supported native operation.');if(!snapshot.available&&!(packaged&&COMPONENT_KINDS.includes(kind)))throw new Error(snapshot.error??'Connect the native Strata installation first.');if(kind==='install'||kind==='load'){const refusal=engineRefusal(snapshot.catalog,args.model,snapshot.engineInfo);if(refusal)throw new Error(refusal);}return begin(kind,job=>kind==='install'?install(job,args):kind==='load'?loadEngine(job,args):kind==='benchmark'?benchmark(job,args):kind==='tune'?tune(job,args):kind==='autotune'||kind==='recheck'?autotune(job,args):kind==='remove'?removeModel(job,args):program(job,args),{guided:args.guided===true,model:args.model,...(args.family?{family:args.family}:{})});}
function operation(kind,args={}){if(!['install','load','pictures','benchmark','tune','autotune','recheck','calibrate','update-engine','update-strata','rollback','install-engine-file','remove','restart-models'].includes(kind))throw new Error('Choose a supported native operation.');if(kind==='restart-models'&&!packaged)throw new Error('Choose a supported native operation.');if(!snapshot.available&&!(packaged&&(COMPONENT_KINDS.includes(kind)||kind==='restart-models')))throw new Error(snapshot.error??'Connect the native Strata installation first.');if(kind==='install'||kind==='load'){const refusal=engineRefusal(snapshot.catalog,args.model,snapshot.engineInfo);if(refusal)throw new Error(refusal);}return begin(kind,job=>kind==='install'?install(job,args):kind==='load'?loadEngine(job,args):kind==='pictures'?picturesOnly(job,args):kind==='benchmark'?benchmark(job,args):kind==='tune'?tune(job,args):kind==='autotune'||kind==='recheck'?autotune(job,args):kind==='remove'?removeModel(job,args):kind==='restart-models'?restartStopped(job):program(job,args),{guided:args.guided===true,model:args.model,...(args.family?{family:args.family}:{})});}
/* ---- The models folder card (0.2.2): what a picked folder means, and the three choices. The copying is Control's (strata models move). ---- */
const currentModelsFolder=()=>preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder)??null;
const moveFile=()=>join(directory,'models-move.cancel');
@@ -591,5 +732,15 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
// Remove nonserializable process references from public jobs.
const publicState=()=>{const value=state();value.jobs=value.jobs.map(({process,engineAbort,rawResult,...job})=>job);return value;};
async function localPreview(path){if(!packaged)throw new Error('Checking local model files is part of the Strata app.');if(typeof path!=='string'||!path.trim()||[...path].some(c=>c.charCodeAt(0)<32))throw new Error('Choose a folder.');return control.importPlan(path.trim());}
return {state:publicState,tuneStatus,tuneEffective,tuneOff,tunePresets:{list:()=>autotuner.list(),action:(kind,id,extra)=>autotuner.action(kind,id,extra),undoFor:async args=>autotuner.undoFor(tuneConfig(args).path)},refresh,localPreview,configure,setDownloadDirectory,planModelsFolder,startModelsFolder,interruptedMove,finishInterruptedMove,leaveInterruptedMove,plan,settings,save,settingsFile,openSettingsFile,operation,cancel,unload,resolveInstance,poll,logs:()=>logs.slice(),benchmarkReport:()=>readJson(join(directory,'native-benchmark.json'),null),setDefault(id){defaultId=resolveInstance(id).id;emit();return publicState();},hasLive:()=>[...instances.values()].some(i=>i.status==='ready'),readyInstances:()=>[...instances.values()].filter(i=>i.status==='ready'),defaultInstanceId:()=>defaultId,close};
return {state:publicState,tuneStatus,tuneEffective,tuneOff,tunePresets:{list:()=>autotuner.list(),action:(kind,id,extra)=>autotuner.action(kind,id,extra),undoFor:async args=>autotuner.undoFor(tuneConfig(args).path)},refresh,localPreview,configure,setDownloadDirectory,planModelsFolder,startModelsFolder,interruptedMove,finishInterruptedMove,leaveInterruptedMove,plan,settings,save,settingsFile,openSettingsFile,operation,dismissRestart,cancel,unload,resolveInstance,poll,logs:()=>logs.slice(),benchmarkReport:()=>readJson(join(directory,'native-benchmark.json'),null),setDefault(id){defaultId=resolveInstance(id).id;emit();return publicState();},hasLive:()=>[...instances.values()].some(i=>i.status==='ready'),readyInstances:()=>[...instances.values()].filter(i=>i.status==='ready'),defaultInstanceId:()=>defaultId,close};
}
/* A DeepSeek config on AMD written by setup 0.2.8 or earlier names ONE card and marks it "asked", though setup picked it itself (strata_control/gpu_pick.py): it must not stop the app's recommended cards from applying. A card setup named for itself is the first of the usable cards, largest memory first; another card number was the person's. Roles and a "gpus_by" marker are always honoured. */
export function setupPickedOneCard(config,hardware){
if(!config||config.arch!=='deepseek41'||config.backend!=='hip'||config.gpus_by||config.gpu_roles||!config.gpus_asked)return false;
const one=Array.isArray(config.gpu)?(config.gpu.length===1?config.gpu[0]:null):config.gpu;if(!Number.isInteger(Number(one))||one===null||one==='')return false;
const cards=(hardware?.gpus??[]).filter(g=>(g.vendor==='amd')&&g.usable!==false),shares=g=>!!(g.uma||g.integrated||g.unified_gb),vram=g=>Number(g.vram_gb)||0;
const real=cards.some(g=>!shares(g)&&vram(g)>=4),keep=cards.filter(g=>!(shares(g)&&real)&&vram(g)>=8);
if(process.platform==='win32'||keep.length<2)return false;
const first=[...keep].sort((a,b)=>Math.round(vram(b))-Math.round(vram(a))||a.index-b.index)[0];
return Number(one)===first.index;
}
+3 -3
View File
@@ -2,16 +2,16 @@
import { once } from 'node:events';
export async function nativeGateway({req,res,path,body,control,serving,fetcher=fetch}){
let instance;try{instance=control.resolveInstance(body.model);}catch(e){res.writeHead(400,{'content-type':'application/json'});res.end(JSON.stringify({error:{message:e.message,type:'invalid_request_error',param:'model',code:null}}));return;}
const abort=new AbortController(),started=Date.now();res.on('close',()=>{if(!res.writableEnded)abort.abort();});
const abort=new AbortController(),started=Date.now();let sawDone=false;/* #4690: a client that hangs up after `data: [DONE]` (the OpenAI SDK does) has the whole answer: that is not a cancel. */res.on('close',()=>{if(!res.writableEnded&&!sawDone)abort.abort();});
const entry=serving.open({client:req.headers['user-agent']?.slice(0,100)??'API client',endpoint:path.slice(3),model:instance.apiModelId,instanceId:instance.id,gpuIds:instance.gpus.map(String),cancel:()=>abort.abort()});
let tokensIn=null,tokensOut=null,tps=null,promptTps=null;
const observe=value=>{const usage=value.usage??value.response?.usage;if(usage){tokensIn=usage.prompt_tokens??usage.input_tokens??tokensIn;tokensOut=usage.completion_tokens??usage.output_tokens??tokensOut;}if(value.timings){tps=value.timings.predicted_per_second??tps;promptTps=value.timings.prompt_per_second??promptTps;}};
const observe=value=>{/* Anthropic's stream carries the prompt count in message_start (message.usage) and only the output count in message_delta (usage): both are read (#4678). */for(const usage of [value.message?.usage,value.usage??value.response?.usage])if(usage){tokensIn=usage.prompt_tokens??usage.input_tokens??tokensIn;tokensOut=usage.completion_tokens??usage.output_tokens??tokensOut;}if(value.timings){tps=value.timings.predicted_per_second??tps;promptTps=value.timings.prompt_per_second??promptTps;}};
try{
const response=await fetcher(`http://127.0.0.1:${instance.port}${path}`,{method:'POST',headers:{'content-type':'application/json',authorization:`Bearer ${instance.key}`},signal:abort.signal,body:JSON.stringify({...body,model:instance.apiModelId,strata_mcp:false})});
if(!response.ok){const content=await response.text();serving.finish(entry.id,{durationMs:Date.now()-started,error:`Engine returned ${response.status}`});res.writeHead(response.status,{'content-type':response.headers.get('content-type')??'application/json'});res.end(content);return;}
serving.mark(entry.id,{state:'generating'});
res.writeHead(response.status,{'content-type':response.headers.get('content-type')??'application/json','cache-control':'no-cache','x-strata-request-id':entry.id,'x-accel-buffering':'no'});
if(body.stream===true){let buffer='';const decoder=new TextDecoder();for await(const chunk of response.body){buffer+=decoder.decode(chunk,{stream:true});let end;while((end=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,end);buffer=buffer.slice(end+1);if(line.startsWith('data:'))try{observe(JSON.parse(line.slice(5)));}catch{}}if(!res.write(chunk))await once(res,'drain',{signal:abort.signal});}}
if(body.stream===true){let buffer='';const decoder=new TextDecoder();for await(const chunk of response.body){buffer+=decoder.decode(chunk,{stream:true});let end;while((end=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,end);buffer=buffer.slice(end+1);if(line.startsWith('data:')){if(line.slice(5).trim()==='[DONE]')sawDone=true;else try{observe(JSON.parse(line.slice(5)));}catch{}}}if(!res.destroyed&&!res.write(chunk))await once(res,'drain',{signal:abort.signal});}}
else{const text=await response.text();try{observe(JSON.parse(text));}catch{}res.write(text);}
serving.mark(entry.id,{tokensIn,tokensOut,tps,promptTps,usageSource:tokensIn===null||tokensOut===null?'unavailable':'reported'});serving.finish(entry.id,{tokensOut,durationMs:Date.now()-started});res.end();
}catch(e){serving.finish(entry.id,{tokensOut,durationMs:Date.now()-started,error:abort.signal.aborted?'Request cancelled.':e.message});if(!res.headersSent){res.writeHead(502,{'content-type':'application/json'});res.end(JSON.stringify({error:{message:e.message,type:'server_error'}}));}else res.end();}
+12 -3
View File
@@ -19,6 +19,8 @@
*/
export const DEFAULT_SLOTS = 4;
/** What the person is told when DFlash2 is chosen and nobody picked a number of requests (strata_control/drafting.py ONE_REQUEST_NOTE). */
export const DFLASH2_NOTE = 'DFlash2 answers one request at a time, so Strata set this model to one request at a time.';
/** The most the engine's batch window holds without grouped MTP. */
export const MAX_SLOTS = 8;
/**
@@ -46,7 +48,11 @@ export const FIRST_CARD_OTHER_GB = 6.9;
* phaseA-tests/notes-021-settings.md). With "Draft layer (MTP)" off in Expert settings (`"mtp": false`) the engine does not load it, so
* the card has that much more cache and the slots may use it.
*/
export const MTP_VRAM_GB = 1.0;
export const MTP_VRAM_GB = 1.08;
/** Graphics memory of DFlash2 alone, with the MTP layer not loaded beside it (RESULTS-DF2MEM.md: 890 MiB; the MTP layer 1082 MiB). Fit, plan and slot estimates use the drafter that is chosen. */
export const DFLASH2_VRAM_GB = 0.89;
/** The same with the converted pack (the selector's codebooks as Q4_0 rows, 715 MiB; strata_control/drafting.py DRAFTER_MIB "dflash2_selq4"): `capabilities.dflash2Selq4` says it is the one in use. */
export const DFLASH2_SELQ4_VRAM_GB = 0.72;
export const NEXT_CARD_OTHER_GB = 2.0;
/**
* `--batch-mtp` lets each slot verify draft tokens. Where it pays and where it does not (all measured):
@@ -95,9 +101,10 @@ export function engineFacts(config = {}) {
* @param {object} [input.values] what the person set for this load (Expert mode's "Concurrent requests")
* @param {object} [input.hardware] the host's hardware read (`gpus[{index, vram_gb}]`)
* @param {number} [input.requested] the default to aim for
* @param {string} [input.drafter] the drafter this load runs (resolveDrafter): dflash2 answers one request at a time
* @returns {{slots:number, batchMtp:boolean, explicit:boolean, requested:number, note:string|null, slotGb:number}}
*/
export function chooseParallel({ config = {}, values = {}, hardware = null, requested = DEFAULT_SLOTS }) {
export function chooseParallel({ config = {}, values = {}, hardware = null, requested = DEFAULT_SLOTS, drafter = null }) {
const facts = engineFacts(config);
const slot = slotGb({ context: facts.context, kv: facts.kv, family: facts.family });
const chosen = values.parallel !== undefined && values.parallel !== null ? Number(values.parallel)
@@ -108,12 +115,14 @@ export function chooseParallel({ config = {}, values = {}, hardware = null, requ
const wantMtp = (values.batchMtp ?? config.batchMtp) === true && !facts.batchMtp && facts.mtp && facts.gpus.length === 1 && (slots ?? 0) > 1;
return { slots: slots ?? 0, batchMtp: wantMtp, explicit: true, requested, note: null, slotGb: slot };
}
// DFlash2 chosen on purpose: it answers one request at a time, and request slots would keep the MTP (the engine says "not --batch"). Nobody chose a number here, so one.
if ((drafter ?? ((values.drafting ?? config.drafting) === 'dflash2' ? 'dflash2' : null)) === 'dflash2') return { slots: 1, batchMtp: false, explicit: false, requested, note: DFLASH2_NOTE, slotGb: slot };
const cards = (hardware?.gpus ?? []).filter(g => facts.gpus.includes(g.index)).map(g => Number(g.vram_gb)).filter(n => Number.isFinite(n) && n > 0);
// Without a hardware read nothing can be sized: one at a time is the safe default (a number can still be chosen).
if (!cards.length) return { slots: PARALLEL_POLICY === 'fixed' ? requested : 1, batchMtp: false, explicit: false, requested, note: null, slotGb: slot };
const total = cards.reduce((a, b) => a + b, 0);
const oneCard = facts.gpus.length === 1 && cards.length === 1;
const soloCache = Math.max(0, total - (FIRST_CARD_OTHER_GB - (facts.mtp ? 0 : MTP_VRAM_GB)) - NEXT_CARD_OTHER_GB * (cards.length - 1));
const soloCache = Math.max(0, total - (FIRST_CARD_OTHER_GB - MTP_VRAM_GB + (facts.mtp ? MTP_VRAM_GB : 0)) - NEXT_CARD_OTHER_GB * (cards.length - 1));
let slots = requested;
if (PARALLEL_POLICY === 'fit') while (slots > 1 && slots * slot > CACHE_LOSS_SHARE * soloCache) slots -= 1;
const note = slots >= requested ? null
+41 -13
View File
@@ -1,4 +1,5 @@
import {USER_KEY,flagValue} from './native-user-args.mjs';
import {parseEngineVersion} from './engine-version.mjs';
/** The engine's own default reserve, and the one setup writes for pictures read on the GPU: neither is a choice of the person. */
const ENGINE_DEFAULT_RESERVE_MIB=700;
@@ -75,23 +76,50 @@ export function resourceSettings(config,hardware,profile,model={},values={}){
/* A reserve the person set is theirs, 0 included: no floor and no share of the card (#3215 #3270). Without one the card gets the safety default (defaultReserves). */
const reserve=asked!==null?asked:defaults[profile];
remove('--vram-reserve-mib');args.push('--vram-reserve-mib',String(reserve));
// RAM: "leave 20% for my work" keeps the model's RAM within 80% of the PC. A model whose experts do not fit there (the
// setup rule: experts plus 10 GB) keeps only that many GiB of them in RAM and reads the rest from the model files
// (--resident-budget-gib, which implies --mmap-experts). Full performance takes the budget out again.
// RAM: "leave 20% for my work". An engine that knows the setting (RAM_KEEP_MIN_ENGINE and newer) is asked to keep W GiB of the RAM free, W = ramKeepGib(RAM), as the env STRATA_RAM_KEEP_GIB: it decides from
// the RAM that is free at its start, not from the PC's total, using the full RAM copy of the experts (the arena) only if W GiB stay free after it and otherwise keeping the hottest experts in a RAM copy
// that leaves max(W, its own safety headroom) free. An env variable and not the flag --ram-keep-gib, so that an engine without the setting never meets an argument it does not know. The engine in use
// is the one the Engine tab selected (its version, model.engineVersion, or the folder named in the run config's exe); an older engine, or one whose version cannot be read, gets the rule of 0.2.9
// unchanged, which every engine understands: a RAM budget of 80% of the PC (--resident-budget-gib, which implies --mmap-experts) when the model's experts do not fit there (the setup rule: experts plus
// 10 GB), and a STRATA_RESIDENT_HEADROOM_GIB of 20% of the RAM. Full performance (and `strata serve`) pass nothing: the whole system, the arena whenever it fits; either rule is taken out again.
if(baseline.ramBudget)remove('--resident-budget-gib');
let env=null; // the engine's own "leave RAM free" setting travels in the run config's env
if(baseline.ramHeadroom&&config.env){env={...config.env};delete env.STRATA_RESIDENT_HEADROOM_GIB;}
const ram=Number(hardware?.ram_gb)||0,arena=Number(model?.arenaGb)||0;let ramBudget=false;
if(profile==='work'&&ram>0&&arena>0&&!['--resident-experts','--mmap-experts','--resident-budget-gib'].some(f=>args.includes(f))&&ram*.8<arena+10){
args.push('--resident-budget-gib',String(Math.max(8,Math.min(Math.floor(ram*.8)-10,Math.floor(arena/1.073741824)))));ramBudget=true;
let env=null;
const dropEnv=name=>{if(config.env&&name in config.env){env={...(env??config.env)};delete env[name];}};
if(baseline.ramHeadroom)dropEnv('STRATA_RESIDENT_HEADROOM_GIB');
if(baseline.ramKeep)dropEnv('STRATA_RAM_KEEP_GIB');
const ram=Number(hardware?.ram_gb)||0,arena=Number(model?.arenaGb)||0;let ramKeep=false,ramBudget=false,ramHeadroom=false;
// The person's own RAM choice wins: a RAM mode or --ram-keep-gib in their args, and STRATA_RAM_KEEP_GIB or STRATA_RESIDENT_HEADROOM_GIB of theirs in the env (the engine reads those first).
const mineEnv=Boolean((env??config.env)?.STRATA_RAM_KEEP_GIB);
const modes=['--resident-experts','--mmap-experts','--resident-budget-gib','--ram-keep-gib'];
if(profile==='work'&&ram>0&&arena>0&&!modes.some(f=>args.includes(f))){
if(engineKnowsRamKeep(engineVersionOfExe(config?.exe)??model?.engineVersion)){
if(!mineEnv){env={...(env??config.env??{}),STRATA_RAM_KEEP_GIB:String(ramKeepGib(ram))};ramKeep=true;}
}else if(ram*.8<arena+10){
args.push('--resident-budget-gib',String(Math.max(8,Math.min(Math.floor(ram*.8)-10,Math.floor(arena/1.073741824)))));ramBudget=true;
if(!(env??config.env)?.STRATA_RESIDENT_HEADROOM_GIB){env={...(env??config.env??{}),STRATA_RESIDENT_HEADROOM_GIB:String(Math.ceil(ram*.2))};ramHeadroom=true;}
}
}
// The budget caps what the engine keeps in RAM. "Leave 20%" is also asked of the engine itself: at its start it leaves 20% of the RAM
// available for other programs (it keeps fewer experts if the PC is busy), and it said nothing about this before.
let ramHeadroom=false;
if(ramBudget&&!(config.env?.STRATA_RESIDENT_HEADROOM_GIB)){env={...(env??config.env??{}),STRATA_RESIDENT_HEADROOM_GIB:String(Math.ceil(ram*.2))};ramHeadroom=true;}
return {args,vram_reserve_mib:reserve,...(asked!==null?{}:{ui_reserve_applied:reserve}),...(env?{env}:{}),ui_resource_baseline:{...baseline,ramBudget,ramHeadroom}};
return {args,vram_reserve_mib:reserve,...(asked!==null?{}:{ui_reserve_applied:reserve}),...(env?{env}:{}),ui_resource_baseline:{...baseline,ramBudget,ramHeadroom,ramKeep}};
}
/** The RAM, in GiB, "Leave 20% for my work" asks the engine to keep free: a fifth of the PC's RAM rounded up, never under 5 or over 8
* (5 at 24 GB, 7 at 32 GB, 8 at 64 GB and above). The engine adds it to the arena's need and, when the arena does not fit, leaves it free beside the RAM copy. It is sent as the env variable STRATA_RAM_KEEP_GIB. */
export const ramKeepGib=ram=>Math.min(8,Math.max(5,Math.ceil((Number(ram)||0)*.2)));
/** The first engine version that reads STRATA_RAM_KEEP_GIB / --ram-keep-gib (the setting was added in 0.1.56, the engine of the 0.2.9 update; the released 0.2.9 engine is 0.1.55). An engine older than this, or one whose version cannot be read, gets the
* rule of 0.2.9 instead. Lower it (or raise the engine's version) only together with the release that carries the setting. */
export const RAM_KEEP_MIN_ENGINE='0.1.56';
/** True when the engine with this version string knows the setting. Unknown or unreadable: false (the 0.2.9 rule works on every engine, an ignored env variable would keep no room). */
export function engineKnowsRamKeep(version){
const have=parseEngineVersion(version),need=parseEngineVersion(RAM_KEEP_MIN_ENGINE);
if(!have||!need)return false;
for(let i=0;i<4;i++){const a=have[i]??0,b=need[i]??0;if(a!==b)return a>b;}
return true;
}
/** The version in the folder a run config's exe names (.../engine/0.1.55-windows-x86_64-cuda-cuda13/strata.exe), or null (the exe "component:strata-engine" means the active engine, whose version the caller gives). */
export const engineVersionOfExe=exe=>{const m=/engine[\\/](\d+(?:\.\d+)+)-/.exec(String(exe??''));return m?m[1]:null;};
/* One automatic second try after a start that died. Returns null when there is nothing safer to try (the caller then reports the first
failure as it was) or {config, words, detail}: the same run config with ONE safer memory setting, a sentence for the user and the
technical line for Details. The caller tries once and never again, and does not save this config. */
+13 -3
View File
@@ -8,9 +8,9 @@ export const NATIVE_FIELDS=[
{id:'aliases',label:'API model aliases',group:'Serving',type:'array',hint:'Comma-separated names clients may use for this model.'},
{id:'idle_unload_s',label:'Unload after idle seconds',group:'Serving',type:'number',min:0,max:86400,hint:'Zero keeps the model loaded.'},
{id:'lazy_load',label:'Load on first request',group:'Serving',type:'boolean'},
{id:'mtp',label:'Draft layer (MTP)',group:'Serving',type:'boolean',hint:'The draft layer guesses several words ahead, which makes answers faster, and costs about 1 GB of graphics memory and some RAM. Turn it off if the model stops while it starts, or the card is small: the expert cache gets that memory back and answers are slower. Default is on.'},
{id:'drafting',label:'Drafting',group:'Serving',type:'enum',choices:['auto','mtp','dflash2','off'],hint:'Which drafter guesses words ahead to make answers faster. Auto (recommended) lets Strata pick from what it measured on cards like yours; today that is the MTP. DFlash2 is for Qwen3.8-Flash-Next only and downloads a 1.2 GB drafter once. If DFlash2 cannot run, the MTP is used and Details says why. Off turns the draft layer off.'},
{id:'batchMtp',label:'MTP per request',group:'Serving',type:'boolean',hint:'Each concurrent request uses the draft layer. Left alone, Strata turns it on for one graphics card with 24 GB or more and several requests at once, and off otherwise; choose on or off here to decide yourself.'},
{id:'mtp',notForModel:'deepseek',label:'Draft layer (MTP)',group:'Serving',type:'boolean',hint:'The draft layer guesses several words ahead, which makes answers faster, and costs about 1 GB of graphics memory and some RAM. Turn it off if the model stops while it starts, or the card is small: the expert cache gets that memory back and answers are slower. Default is on.'},
{id:'drafting',notForModel:'deepseek',label:'Drafting',group:'Serving',type:'enum',choices:['auto','mtp','dflash2','off'],hint:'Which drafter guesses words ahead to make answers faster, one at a time. Auto (recommended) is DFlash2 for Qwen3.8-Flash-Next where the engine has it, the MTP otherwise. DFlash2 downloads a 1.2 GB drafter once, the MTP its draft layer once. If DFlash2 cannot run (several requests at once), the MTP is used alone and Details says why. Off turns the drafter off.'},
{id:'batchMtp',notForModel:'deepseek',label:'MTP per request',group:'Serving',type:'boolean',hint:'Each concurrent request uses the draft layer. Left alone, Strata turns it on for one graphics card with 24 GB or more and several requests at once, and off otherwise; choose on or off here to decide yourself.'},
{id:'ds_experts',label:'Speed (DeepSeek)',group:'Serving',type:'enum',choices:['5','6'],forModel:'deepseek',hint:'Faster uses 5 of 6 experts per word: a small quality change, usually not noticeable. Full quality uses all 6. Left alone, the engine picks Faster on a PC whose graphics cards total 16 GB or less.'},
{id:'parallel',label:'Concurrent requests',group:'Serving',type:'number',min:1,max:8,hint:'More slots need more memory and can slow each response.'},
{id:'engine_silence_s',label:'Engine stall timeout (seconds)',group:'Recovery',type:'number',min:10,max:3600},
@@ -87,6 +87,16 @@ export function setSpeedSwitch(env,id,on){
export function speedSwitchRows(env,context){return SPEED_SWITCHES.filter(s=>speedSwitchApplies(s.id,context)).map(s=>({...s,on:speedSwitchIsOn(env,s.id)}));}
/** Settings the app itself applies when it starts a model (they are not read from the engine's source, so the engine's text never says it supports them). */
export const APP_OWNED_FIELDS=['mtp','batchMtp','drafting','ds_experts'];
/** The families that have a Drafting choice and the model names the DFlash2 drafter was made for (strata_control/drafting.py SUPPORTED_FAMILIES, _SUPPORTED_NAME): Qwen3.8-Flash-Next, Gyro and Unsloth's quantizations of the same weights; not Swift, Coder or NVFP4. */
export const DRAFTING_FAMILIES=['qwen','gyro','unsloth'];
const DFLASH2_MODEL=/^qwen3\.8-flash-next(-gyro-[a-z0-9._-]+|-unsloth-[a-z0-9._-]+|-(q|iq|tq)\d[a-z0-9_.]*)?$/i;
/** The drafter a choice means (one per run; strata_control/drafting.py choice_of): Auto is DFlash2 where the engine has it (`served`, Control's word) and the model is one it was made for, else the MTP; off and mtp are what they say. `model` is a run config's model name or a family. */
/** `split` (Control's plan: the all-cards split engine will take this start) makes Auto the MTP, the draft layer that engine runs; a DFlash2 the person chose by name stays. */
export function resolveDrafter(choice,model,served,split){
const c=String(choice??'auto').toLowerCase();if(c==='off'||c==='mtp')return c;
const name=String(model??'');if(!(served===true&&(DRAFTING_FAMILIES.includes(name.toLowerCase())||DFLASH2_MODEL.test(name))))return 'mtp';
return split===true&&c!=='dflash2'?'mtp':'dflash2';
}
/** The engine arguments without the draft layer: --mtp <folder or count>, --spec-min-p <p> (its confidence threshold) and --batch-mtp. --spec stays: prompt lookup still uses its windows. Same rule as strata_control/draft_layer.py. */
export function withoutDraftLayer(args){
const out=[];const list=(args??[]).map(String);
+14
View File
@@ -8,3 +8,17 @@ export function runtimeVision(config,requested){
if(mode==='cpu'&&config.vision.cpu_exe){const {cpu_exe,...rest}=config.vision;return {...rest,exe:cpu_exe,gpu:false};} // AMD: the CPU reader program, not the HIP one with the GPU hidden
return {...config.vision,...(mode==='cpu'?{gpu:false}:mode==='gpu'?{gpu:true}:{})};
}
/**
* The catalog with each picture-capable size marked as ready (`images`) or not (`imagesCapable` stays true either way). The Qwen engines
* read pictures with a separate program (strata-vision) that has to sit in the engine folder; DeepSeek-V4.1-Flash (`multi_file`, its own
* engine) carries its own picture tower, so it is ready whatever that folder holds (#4772: it was told to update an engine that was fine).
*/
export function markPictures(catalog,visionReader){
if(!catalog||!Array.isArray(catalog.families))return catalog;
const ready=(s)=>!!s.images&&(s.multi_file===true||visionReader===true);
return {...catalog,families:catalog.families.map(f=>{
const sizes=(f.sizes??[]).map(s=>({...s,imagesCapable:!!s.images,images:ready(s)}));
return {...f,imagesCapable:!!f.images,images:sizes.length?sizes.some(s=>s.images):!!f.images&&visionReader===true,sizes};
})};
}
+1 -1
View File
@@ -22,7 +22,7 @@ export const KNOWN_KEYS = new Set([
'elastic', 'elastic_idle_s', 'elastic_mib', 'min_free_vram_mib', 'vram_segment_mib', 'slot_save_path', 'telemetry_read_key', 'batch_telemetry',
'reasoning_effort', 'reasoning_close_retry', 'reasoning_loop_recovery', 'repeat_stop_tokens', 'tool_call_recovery', 'codex_compaction_cache',
'codex_thread_titles', 'expert_profile_save', 'expert_profile_save_every', 'images', 'vision', 'mmproj', 'draft_vocab', 'ui_resource_profile',
'ui_vision_enabled', 'component', 'native', 'uma', 'remote_expert_opt', 'diffusion_model', 'llm', 'vae', 'quant', 'type', 'url', 'timeout_s',
'ui_vision_enabled', 'ui_context_chosen', 'component', 'native', 'uma', 'remote_expert_opt', 'diffusion_model', 'llm', 'vae', 'quant', 'type', 'url', 'timeout_s',
]);
const BOOLS = new Set(['open_browser', 'prevent_sleep', 'elastic', 'env_to_user', 'gpus_asked', 'batch_telemetry', 'tool_call_recovery', 'reasoning_loop_recovery']);
+2
View File
@@ -39,6 +39,8 @@ export function state() {
revision,
running,
baseUrl: sharing.active ? sharing.url : baseUrl(),
// The address on this computer, the one the app's own port serves, kept apart from the sharing address (#4798).
localUrl: baseUrl(),
sharing,
tunnel,
port,
+1 -1
View File
@@ -218,7 +218,7 @@ export async function start({ modelId, modelPath, port = 8080, config, hardware,
const binary = await resolveBinary();
if (!binary) {
state.status = 'error';
state.error = 'No llama-server binary found. Download the engine from Settings → Engine.';
state.error = 'No llama-server binary found. Download the engine from Settings → About.';
throw new Error(state.error);
}
if (!existsSync(modelPath)) {
@@ -122,3 +122,8 @@ export async function readTemperatures({ platform = process.platform, now = Date
}
return { cpuTempC: null, ssdTempC: null };
}
/** Ends the Windows drive-temperature loop (the Monitor has not been open for a while); the next read starts it again. */
export function stopTemperatures() {
if (windowsReader) { windowsReader.stop(); windowsReader = null; }
}
+30 -5
View File
@@ -20,8 +20,9 @@ export function looksOffline(error) {
}
export const OFFLINE_MESSAGE = "No internet connection. Try again when you're online.";
/** { message, details, next } for an error thrown by Control's update calls (or the shell bridge). `subject` is "engine" or "Strata". */
export function plainUpdateError(error, subject = 'engine') {
/** { message, details, next } for an error thrown by Control's update calls (or the shell bridge). `subject` is "engine" or "Strata".
* `retry` names the button of a screen that has no Settings page yet (the first-run card's Continue): the next step then says that button. */
export function plainUpdateError(error, subject = 'engine', retry = undefined) {
const raw = String(error?.message ?? error ?? '');
const code = error?.code ?? '';
const exit = error?.exitCode ?? null;
@@ -43,9 +44,18 @@ export function plainUpdateError(error, subject = 'engine') {
} else if (/could not be read|interrupted|timed out|timeout|ECONN|ENOTFOUND|fetch failed|could not reach|server answered|not answer within/i.test(raw)) {
message = "Couldn't reach the update server.";
next = `Check your internet connection, then press ${button} in Settings.`;
} else if (/free space|not enough/i.test(raw)) {
} else if (/free space|not enough|no space left|disk is full/i.test(raw)) {
message = 'There is not enough free disk space for the update.';
next = `Free some disk space, then press ${button} in Settings.`;
} else if (/could not be moved into place|could not be unpacked|not accepted after the switch/i.test(raw)) {
// The files were downloaded and checked; Windows or a virus scanner would not let Strata put them in place (#4853). Not a download problem.
const long = /longer than Windows allows|path.*too long/i.test(raw);
message = long
? "The engine's folder is too deep for Windows, so it could not be put in place."
: 'The engine was downloaded and checked, but Windows or a security program would not let Strata put it in place.';
next = long
? `Open Details, then follow the folder advice there, and press ${button} in Settings.`
: `Wait a minute, then press ${button} in Settings. If it happens again, pause your virus scanner's live scanning for a few minutes while it runs.`;
} else if (/no engine for this PC|has no engine|no package/i.test(raw)) {
message = "There isn't an engine for this PC's graphics card yet.";
next = 'Check for updates again later.';
@@ -56,11 +66,26 @@ export function plainUpdateError(error, subject = 'engine') {
message = `The update didn't pass its safety check, so it was not installed. ${keep}`;
next = 'Try again later.';
}
if (retry) next = next.split(`${button} in Settings`).join(retry).replace(/in Settings/g, '').replace(/ {2,}/g, ' ').replace(/ ([.,])/g, '$1');
return { message: `${message} ${next}`.trim(), headline: message, next, details: raw || undefined };
}
/** Plain words for a switch or a delete in Settings > Engine: { message, details }. Control's own sentence for "the engine in use" is already plain and is kept. */
export function plainEngineError(error, action = 'switch') {
const raw = String(error?.message ?? error ?? '');
const exit = error?.exitCode ?? null;
let message = action === 'delete' ? "The engine wasn't deleted. Nothing else was changed." : "The engine wasn't switched. Nothing was changed.";
if (exit === 7 || /is serving|model is running/i.test(raw)) message = 'A model is running, so nothing was changed. Unload the model in Models, then try again.';
else if (/engine in use/i.test(raw)) message = 'This is the engine in use. Switch to another one first, then delete it.';
else if (/running program is using|something is using it/i.test(raw)) message = 'A program is still using this engine, so it was not deleted. Unload your models and try again.';
else if (/not installed|already been deleted/i.test(raw)) message = 'That engine is already gone. The list has been refreshed.';
else if (/another update is already running/i.test(raw)) message = 'An update is running. Wait for it to finish, then try again.';
else if (exit === 5 || /can no longer be used|does not fit|cannot run/i.test(raw)) message = "This engine can't run on this computer, so nothing was changed.";
return { message, details: raw || undefined };
}
/** An Error carrying the plain message and, behind it, the technical reason. */
export function plainError(error, subject) {
const { message, details } = plainUpdateError(error, subject);
export function plainError(error, subject, retry = undefined) {
const { message, details } = plainUpdateError(error, subject, retry);
return Object.assign(new Error(message), { details, code: error?.code });
}
+2 -1
View File
@@ -64,7 +64,8 @@ export async function checkComponentUpdates({ control, appUpdater = null, appVer
const installedEngine = engine.installed?.version ?? null;
const offered = engine.offered?.version ?? null;
const engineView = { installedVersion: installedEngine, latestVersion: offered ?? undefined, canInstall: engine.status === 'available' || engine.status === 'install' };
if (engine.status === 'available') Object.assign(engineView, { status: 'available', message: `A new engine is available (${mib(engine.download_bytes)}).` });
if (engine.status === 'available' && engine.installed_locally) Object.assign(engineView, { status: 'available', message: 'A newer engine is already on this computer. Update switches to it; nothing is downloaded.' });
else if (engine.status === 'available') Object.assign(engineView, { status: 'available', message: `A new engine is available (${mib(engine.download_bytes)}).` });
else if (engine.status === 'install') Object.assign(engineView, { status: 'available', message: `This PC needs an engine to run models. Press Update engine to download it (${mib(engine.download_bytes)}).` });
else if (engine.status === 'current' || engine.status === 'failed-before' || engine.status === 'skipped') Object.assign(engineView, { status: 'current', message: 'The engine is up to date.', details: engine.note });
else if (engine.status === 'wrong-make') Object.assign(engineView, { status: 'unavailable', message: engine.note ?? 'Nothing was changed: the update was for another make of graphics card.', details: (engine.reasons ?? []).join('\n') || undefined });
+7 -4
View File
@@ -9,7 +9,7 @@ import { Sidebar } from './Sidebar';
import { CommandPalette } from './CommandPalette';
import { ShortcutsDialog } from './Shortcuts';
import { FolderPathDialog } from '../components/FolderPathDialog';
import { handleAppShortcut, runCommand } from '../lib/commands';
import { MOD, handleAppShortcut, runCommand } from '../lib/commands';
import { useMedia } from '../lib/responsive';
import { Btn, Dialog, IconBtn, Toasts } from '../components/ui';
import { Brand } from '../components/Brand';
@@ -111,6 +111,9 @@ function ConnectionBar() {
* way out. Everything else on this screen is the conversation.
*/
function FocusBar() {
// The hints are chords, not decorations: on Windows the modifier is Ctrl and
// Alt is spelled out, because ⌘ and ⌥ are not on that keyboard.
const mac = MOD === '⌘';
const native = useNative(), nativeCurrent = native?.instances.find(i => i.id === native.defaultId);
const library = useStore((s) => s.library);
const monitor = useStore((s) => s.monitor);
@@ -139,11 +142,11 @@ function FocusBar() {
{/* A chord reads as one key: the two caps sit tight together, with
the space before the label doing the separating. */}
<span className="focusbar__chord">
<kbd>⌥↑</kbd>
<kbd>⌥↓</kbd>
<kbd>{mac ? '⌥↑' : 'Alt+↑'}</kbd>
<kbd>{mac ? '⌥↓' : 'Alt+↓'}</kbd>
</span>{tr("turns")}</span>
<span>
<kbd>⌘↓</kbd>{tr(" latest")}</span>
<kbd>{mac ? '⌘↓' : 'Ctrl+↓'}</kbd>{tr(" latest")}</span>
<span>
<kbd>{tr("Esc")}</kbd>{tr(" leave")}</span>
</span>
+6 -1
View File
@@ -136,7 +136,7 @@ export function CommandPalette({ open }: {
<input ref={inputRef} className="st-input st-input--bare" style={{ flex: 1 }} placeholder={tr("Search chats, screens or actions…")} aria-label={tr("Search commands")} aria-activedescendant={flat[cursor] ? `command-${flat[cursor].id}` : undefined} value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={onKeyDown}/>
<kbd className="kbd">{tr("esc")}</kbd>
</div>
<div style={{ overflowY: 'auto', padding: 8 }}>
<div className="palette__list">
{tr(flat.length === 0 ? (<div className="small muted" style={{ padding: 18 }}>{tr("Nothing matches “{0}”. Try > for commands, # for conversations, or @ for workspaces.", [query])}</div>) : (grouped.map(([group, items]) => (<div key={group} style={{ marginBottom: 6 }}>
<div className="navgroup__label" style={{ padding: '8px 12px 4px' }}>
{tr(group)}
@@ -167,6 +167,11 @@ export function CommandPalette({ open }: {
}))}
</div>))))}
</div>
<div className="palette__foot">
<span className="palette__hint"><kbd className="kbd">Enter</kbd>{tr("Open")}</span>
<span className="spacer"/>
<span className="palette__hint"><kbd className="kbd">esc</kbd>{tr("Close")}</span>
</div>
</div>
</>);
}
+12 -5
View File
@@ -10,7 +10,7 @@ import { FloatingMenu } from '../components/FloatingMenu';
import { QuitStrataDialog } from '../components/QuitStrata';
import { useAppMode } from '../lib/app-mode';
import { href, useLocation, type Route } from '../lib/router';
import { cx, relTime } from '../lib/format';
import { cx, relTime, relTimeShort } from '../lib/format';
import { useMedia } from '../lib/responsive';
import { recentChats, recentChatDescription } from '../lib/recent-chats';
import type { Session } from '../lib/types';
@@ -18,7 +18,7 @@ import { MOD, runCommand } from '../lib/commands';
import { bindingFor, bindingKeys, useBindings } from '../lib/shortcut-bindings';
import { Btn, Dialog, Icon, IconBtn, type IconName } from '../components/ui';
import { Brand } from '../components/Brand';
import { addWorkspace, deleteSession, go, isUnread, pickWorkspace, renameSession, removeWorkspace, renameWorkspace, selectSession, setActiveWorkspace, setNavOpen, togglePin, toggleRail, setTheme, useStore } from '../lib/store';
import { addWorkspace, deleteSession, go, isUnread, liveChatIds, pickWorkspace, renameSession, removeWorkspace, renameWorkspace, selectSession, setActiveWorkspace, setNavOpen, togglePin, toggleRail, setTheme, useStore } from '../lib/store';
export function Sidebar({ containerRef }: {
containerRef: RefObject<HTMLElement | null>;
}) {
@@ -37,6 +37,9 @@ export function Sidebar({ containerRef }: {
const workspaces = useStore(s => s.workspaces);
const activeWorkspace = useStore(s => s.activeWorkspace);
const activeSession = useStore(s => s.activeSession);
// The chats that have a turn running now (the board reads the same list). The count is what makes this redraw when one starts or ends.
const liveCount = useStore(s => s.liveChats);
const working = new Set(liveCount ? liveChatIds() : []);
const [query, setQuery] = useState('');
const [searchOpen, setSearchOpen] = useState(false);
const searchInput = useRef<HTMLInputElement>(null);
@@ -65,6 +68,10 @@ export function Sidebar({ containerRef }: {
const modelQuant = nativeSecond ?? current?.quant ?? tr('View model versions');
const phase = nativeCurrent ? (nativeCurrent.status === 'ready' ? nativeCurrent.phase : nativeCurrent.status) : monitor?.phase ?? 'idle';
const status = phase === 'unavailable' ? 'Status unavailable' : phase === 'error' ? 'Error' : phase === 'loading' ? 'Loading' : !current && !nativeCurrent ? 'No model loaded' : phase === 'reading' ? 'Reading prompt' : phase === 'thinking' ? 'Thinking' : phase === 'generating' ? 'Generating' : 'Ready';
// The model is busy with a chat that is not the one in front of the person: the chip says which one (a title, or how many).
const awayChats = [...working].filter(id => !(location.route === 'chat' && id === activeSession));
const awayTitle = awayChats.length === 1 ? sessions.find(s => s.id === awayChats[0]) : undefined;
const workingLine = awayChats.length === 1 && awayTitle ? tr("Working · {0}", [sessionTitle(awayTitle)]) : awayChats.length > 1 ? tr("{0} chats working", [awayChats.length]) : null;
const menuSession = sessions.find(s => s.id === menuId);
const q = query.trim().toLowerCase();
const matching = sessions.filter(s => (!s.archived || q || includeArchived) && (!q || `${sessionTitle(s)} ${s.messages.map(m => m.content).join(' ')}`.toLowerCase().includes(q))).sort((a, b) => Number(!!b.pinned) - Number(!!a.pinned) || b.updatedAt.localeCompare(a.updatedAt));
@@ -92,7 +99,7 @@ export function Sidebar({ containerRef }: {
<div className={cx('ws__row', activeWorkspace === w.id && 'is-active')}><button className="ws__name" aria-expanded={expanded} onClick={() => { setActiveWorkspace(w.id); setFolders(f => ({ ...f, [w.id]: !expanded })); }}><Icon name="chevron" size={14} className={cx('ws__chev', expanded && 'is-open')}/><Icon name="folder" size={17}/><span>{w.name}</span></button><IconBtn name="pencil" title={tr("Rename {0}", [w.name])} onClick={() => { setWsName(w.name); setNavOpen(false); setRenameWsId(w.id); }}/>{tr(w.id !== 'root' ? <IconBtn name="x" title={tr("Remove {0} from sidebar", [w.name])} onClick={() => { setNavOpen(false); setRemoveWorkspaceId(w.id); }}/> : null)}</div>
{tr(expanded ? <div className="navsessions">
{tr(shown.map(s => <div key={s.id}>
<div className="ws__session row"><a className="navsession" href={href('chat', s.id)} aria-current={activeSession === s.id && location.route === 'chat' ? 'page' : undefined} onClick={() => selectSession(s.id)} onContextMenu={e => { e.preventDefault(); openMenu(s.id, e.currentTarget); }} title={sessionTitle(s)}><span className="navsession__title">{sessionTitle(s)}</span>{tr(s.pinned ? <span aria-label={tr("Pinned")}><Icon name="pin" size={12}/></span> : <span className="navsession__time">{tr(relTime(s.updatedAt))}</span>)}{tr(isUnread(s, activeSession) ? <span className="navsession__unread" aria-label={tr("Unread")}/> : null)}{tr(s.archived ? <span className="xs muted">{tr("Archived")}</span> : null)}</a><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Actions for {0}", [sessionTitle(s)])} onClick={e => openMenu(s.id, e.currentTarget)}><Icon name="ellipsis" size={16}/></button></div>
<div className="ws__session row"><a className="navsession" href={href('chat', s.id)} data-working={working.has(s.id) || undefined} aria-current={activeSession === s.id && location.route === 'chat' ? 'page' : undefined} onClick={() => selectSession(s.id)} onContextMenu={e => { e.preventDefault(); openMenu(s.id, e.currentTarget); }} title={sessionTitle(s)}>{tr(working.has(s.id) ? <span className="navsession__working" role="img" aria-label={tr("Working")} title={tr("Working")}><svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><circle className="navsession__track" cx="8" cy="8" r="6"/><circle className="navsession__arc" cx="8" cy="8" r="6" pathLength="100"/></svg></span> : null)}<span className="navsession__title">{sessionTitle(s)}</span>{tr(s.pinned ? <span aria-label={tr("Pinned")}><Icon name="pin" size={12}/></span> : <span className="navsession__time">{relTimeShort(s.updatedAt)}</span>)}{tr(!working.has(s.id) && isUnread(s, activeSession) ? <span className="navsession__unread" aria-label={tr("Unread")}/> : null)}{tr(s.archived ? <span className="xs muted">{tr("Archived")}</span> : null)}</a><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Actions for {0}", [sessionTitle(s)])} onClick={e => openMenu(s.id, e.currentTarget)}><Icon name="ellipsis" size={16}/></button></div>
</div>))}
{tr(!shown.length ? <p className="small muted ws__empty">{tr(q ? 'No matching chats' : 'No chats yet')}</p> : null)}
{tr(!q && rows.length > shown.length ? <button className="ws-view small" onClick={() => { setNavOpen(false); setRecentOpen(true); }}>{tr("View all chats")}</button> : null)}
@@ -102,7 +109,7 @@ export function Sidebar({ containerRef }: {
</div> : null}
</div>
<div className="nav__foot">
<button className="enginechip" data-state={status === 'Ready' ? 'ready' : ['Reading prompt', 'Thinking', 'Generating', 'Loading'].includes(status) ? 'busy' : 'off'} onClick={() => go('models')} title={tr("{0} \u00B7 {1} \u00B7 {2}", [modelName, modelQuant, tr(status)])} aria-label={tr("{0} \u00B7 {1}", [modelName, modelQuant])}><span className="enginechip__family"><Icon name={modelFamilyIcon(current?.family)} size={20}/><span className="enginechip__dot"/></span><span className="enginechip__text nav__rail-hide"><span className="enginechip__title">{modelName}</span><span className="enginechip__sub">{modelQuant}</span></span></button>
<button className="enginechip" data-state={status === 'Ready' ? 'ready' : ['Reading prompt', 'Thinking', 'Generating', 'Loading'].includes(status) ? 'busy' : 'off'} onClick={() => go('models')} title={tr("{0} \u00B7 {1} \u00B7 {2}", [modelName, workingLine ?? modelQuant, tr(status)])} aria-label={tr("{0} \u00B7 {1}", [modelName, modelQuant])}><span className="enginechip__family"><Icon name={modelFamilyIcon(current?.family)} size={20}/><span className="enginechip__dot"/></span><span className="enginechip__text nav__rail-hide"><span className="enginechip__title">{modelName}</span><span className="enginechip__sub">{workingLine ?? modelQuant}</span></span></button>
<NavLink route="skills" label={tr("Skills")} icon="skills" current={location.route}/>
<NavLink route="monitor" label={tr("Monitor")} icon="activity" current={location.route}/>
<NavLink route="models" label={tr("Models")} icon="models" current={location.route === 'tuning' ? 'models' : location.route}/>
@@ -190,5 +197,5 @@ function MoreMenu({ current }: {
const [quitOpen, setQuitOpen] = useState(false);
const appMode = useAppMode();
const ref = useRef<HTMLButtonElement>(null);
return <><button ref={ref} className="navitem nav__more" aria-label={tr("More — Serving and Settings")} aria-haspopup="menu" aria-expanded={open} data-active={['serving', 'settings'].includes(current) || undefined} onClick={() => setOpen(v => !v)}><Icon name="ellipsis"/><span className="navitem__text">{tr("More")}</span></button>{tr(open ? <FloatingMenu anchor={ref.current} title={tr("More")} onClose={() => { setOpen(false); ref.current?.focus(); }}>{tr(([{ route: 'serving', label: 'Serving', icon: 'link' }, { route: 'settings', label: 'Settings', icon: 'settings' }] as const).map(d => <a key={d.route} role="menuitem" className="menu__item" href={href(d.route)} onClick={() => setOpen(false)}><Icon name={d.icon}/>{tr(d.label)}</a>))}{appMode.canQuit ? <button type="button" role="menuitem" className="menu__item" onClick={() => { setOpen(false); setNavOpen(false); setQuitOpen(true); }}><Icon name="x"/>{tr("Quit Strata")}</button> : null}</FloatingMenu> : null)}<QuitStrataDialog open={quitOpen} onClose={() => setQuitOpen(false)}/></>;
return <><button ref={ref} className="navitem nav__more" title={tr("More — Serving and Settings")} aria-label={tr("More — Serving and Settings")} aria-haspopup="menu" aria-expanded={open} data-active={['serving', 'settings'].includes(current) || undefined} aria-current={['serving', 'settings'].includes(current) ? 'page' : undefined} onClick={() => setOpen(v => !v)}><Icon name="ellipsis"/><span className="navitem__text">{tr("More")}</span></button>{tr(open ? <FloatingMenu anchor={ref.current} title={tr("More")} onClose={() => { setOpen(false); ref.current?.focus(); }}>{tr(([{ route: 'serving', label: 'Serving', icon: 'link' }, { route: 'settings', label: 'Settings', icon: 'settings' }] as const).map(d => <a key={d.route} role="menuitem" className="menu__item" href={href(d.route)} onClick={() => setOpen(false)}><Icon name={d.icon}/>{tr(d.label)}</a>))}{appMode.canQuit ? <button type="button" role="menuitem" className="menu__item" onClick={() => { setOpen(false); setNavOpen(false); setQuitOpen(true); }}><Icon name="x"/>{tr("Quit Strata")}</button> : null}</FloatingMenu> : null)}<QuitStrataDialog open={quitOpen} onClose={() => setQuitOpen(false)}/></>;
}
+63
View File
@@ -0,0 +1,63 @@
/** The card that asks the person to approve or deny one action of the assistant, and the dock that keeps it in view. */
import { useMemo } from 'react';
import { tr } from '../lib/i18n';
import { Btn, Icon } from './ui';
import { askQuestion, askSub, askIcon } from '../lib/ask';
import { resolveTool, useStore } from '../lib/store';
import { pendingApprovals } from '../lib/approvals';
import { QuestionCard } from './QuestionCard';
import type { AgentAsk } from '../lib/agent-ask';
import type { ToolCall } from '../lib/types';
const NO_ASKS: AgentAsk[] = [];
export const SCOPE: Record<ToolCall['scope'], {
label: string;
tone: 'plain' | 'info' | 'warn';
}> = {
read: { label: 'Reads', tone: 'plain' },
write: { label: 'Writes', tone: 'warn' },
shell: { label: 'Runs a command', tone: 'warn' },
connected: { label: 'Connected tool', tone: 'info' },
};
/** The one place the Approve and Deny buttons live. Chat shows it in the dock above the box, not in the middle of a long reply (#4725). */
export function ApprovalCard({ messageId, tool: t }: {
messageId: string;
tool: ToolCall;
}) {
const scope = SCOPE[t.scope];
if (t.ask)
return <section className="askcard" data-state={t.state} aria-label={tr(askQuestion(t.ask))}>
<div className="askcard__top"><span className="askcard__icon"><Icon name={askIcon(t.ask)} size={18}/></span>
<div className="askcard__text"><p className="askcard__q">{askQuestion(t.ask)}</p>{askSub(t.ask) ? <p className="askcard__why" title={askSub(t.ask)}>{askSub(t.ask)}</p> : null}</div>
<div className="askcard__acts"><Btn size="sm" onClick={() => resolveTool(messageId, t.id, false)}>{tr("Deny")}</Btn><Btn variant="primary" size="sm" onClick={() => resolveTool(messageId, t.id, true)}>{tr("Approve")}</Btn></div></div>
<details className="askcard__details"><summary><Icon name="chevron" size={11}/>{tr("Details")}</summary><pre>{t.preview}</pre></details>
</section>;
return <section className="toolcard toolcard--compact" data-state={t.state} aria-label={t.name}>
<details className="toolcard__disclosure">
<summary><Icon name={t.scope === 'shell' ? 'bolt' : 'tool'} size={17}/><span className="toolcard__title">{tr(t.verb ?? t.name)}</span><span className="toolcard__scope xs muted">{tr(scope.label)}</span><Icon name="chevron" className="toolcard__chevron" size={15}/></summary>
<div className="toolcard__body"><code className="toolcard__preview">{t.preview}</code><p className="small muted">{tr(t.effect)}</p>{t.result ? <p className="small">{t.result}</p> : null}</div>
</details>
{t.target ? <code className="toolcard__what" title={t.target}>{t.target}</code> : null}
<div className="toolcard__quick-actions"><span className="xs muted">{tr("Needs your approval")}</span><Btn size="sm" onClick={() => resolveTool(messageId, t.id, false)}>{tr("Deny")}</Btn><Btn variant="primary" size="sm" icon="check" onClick={() => resolveTool(messageId, t.id, true)}>{tr("Approve")}</Btn></div>
</section>;
}
/**
* Keeps an approval where the person is looking. A reply can run to many screens; the card used to sit above its text, so a
* person reading the end of it never saw that the assistant had stopped to ask. The dock sits directly above the message box and
* does not scroll with the conversation. Where the card was, the conversation says that it is waiting.
*/
export function ApprovalDock({ sessionId }: {
sessionId: string | undefined;
}) {
const session = useStore(s => s.sessions.find(x => x.id === sessionId));
const items = useMemo(() => pendingApprovals(session), [session]);
const asks = useStore(s => (sessionId ? s.agentAsks[sessionId] : undefined)) ?? NO_ASKS;
if (!items.length && !asks.length)
return null;
return <section className="approvaldock" role="group" aria-label={tr("Waiting for your answer")} data-count={items.length}>
{asks.map(a => <QuestionCard key={a.callId} sessionId={sessionId!} ask={a}/>)}
{items.map(({ messageId, tool }) => <ApprovalCard key={tool.id} messageId={messageId} tool={tool}/>)}
</section>;
}
+2 -1
View File
@@ -17,7 +17,8 @@ export function ArtifactCard({ artifact: a, onPreview }: {
setMenu(false);
setError('');
if (!canReveal && url && action === 'open') {
window.open(url, '_blank', 'noopener,noreferrer');
// The app window has no handler for a new window, so window.open does nothing there: the host opens the address in the default browser (#4721).
api.post('/api/artifacts/open', { action: 'open', url }).catch(() => { window.open(url, '_blank', 'noopener,noreferrer'); });
return;
}
setBusy(true);
+18 -4
View File
@@ -3,6 +3,7 @@ import { PreviewControls } from './PreviewControls';
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Dialog, Icon, IconBtn, Seg, type IconName } from './ui';
import { CopyBtn, CopyMenuItem } from './CopyBtn';
import { Markdown } from './Markdown';
import { SyntaxCode } from './SyntaxCode';
import { FloatingMenu } from './FloatingMenu';
@@ -61,15 +62,28 @@ export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab,
});
return () => abort.abort();
}, [current?.id, revision]);
// A closed panel leaves with a short slide instead of vanishing (a bare shell; the page itself is already gone).
const hadCurrent = useRef(false);
const [leaving, setLeaving] = useState(false);
useEffect(() => {
if (current) { hadCurrent.current = true; setLeaving(false); return; }
if (!hadCurrent.current || !inline) return;
hadCurrent.current = false;
if (matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.dataset.motion === 'reduced') return;
setLeaving(true);
// The leaving shell takes the 460 ms step of the motion scale (liquid.css); it is removed just after.
const timer = setTimeout(() => setLeaving(false), 520);
return () => clearTimeout(timer);
}, [!!current, inline]);
if (!current)
return null;
return leaving ? <aside className="artifact-panel" data-leaving="true" aria-hidden="true"/> : null;
const url = current.url ? safePreviewUrl(current.url) : null;
const viewControls = current.kind === 'markdown' ? <Seg ariaLabel={tr("Markdown view")} value={mode} options={[{ value: 'preview', label: 'Read' }, { value: 'source', label: 'Source' }]} onChange={setMode}/> : current.kind === 'image' ? <PreviewControls label={tr("Image")} zoom={imageSize} onZoom={setImageSize}/> : null;
const actions = <><div className="preview-file-actions__inline">{tr(current.kind === 'code' ? <IconBtn name="copy" title={tr("Copy code")} disabled={loading || !!error} onClick={() => void navigator.clipboard.writeText(text)}/> : null)}{tr(url ? <a className="st-btn st-btn--icon" href={url} target="_blank" rel="noreferrer" aria-label={tr("Open outside preview")} title={tr("Open outside preview")}><Icon name="link" size={17}/></a> : null)}<IconBtn name="refresh" title={tr("Reload preview")} onClick={() => setRevision(v => v + 1)}/></div><button ref={fileMenuRef} className="st-btn st-btn--icon preview-file-actions__more" aria-label={tr("Preview file actions")} title={tr("Preview file actions")} aria-haspopup="menu" aria-expanded={fileMenu} onClick={() => setFileMenu(v => !v)}><Icon name="ellipsis"/></button></>;
const actionMenu = fileMenu ? <FloatingMenu anchor={fileMenuRef.current} title={tr("Preview file actions")} onClose={() => setFileMenu(false)}>{tr(current.kind === 'code' ? <button role="menuitem" className="menu__item" disabled={loading || !!error} onClick={() => { void navigator.clipboard.writeText(text); setFileMenu(false); }}><Icon name="copy"/>{tr("Copy code")}</button> : null)}{tr(url ? <a role="menuitem" className="menu__item" href={url} target="_blank" rel="noreferrer" onClick={() => setFileMenu(false)}><Icon name="link"/>{tr("Open outside preview")}</a> : null)}<button role="menuitem" className="menu__item" onClick={() => { setRevision(v => v + 1); setFileMenu(false); }}><Icon name="refresh"/>{tr("Reload preview")}</button></FloatingMenu> : null;
const actions = <><div className="preview-file-actions__inline">{tr(current.kind === 'code' ? <CopyBtn text={text} label="Code" title="Copy code" disabled={loading || !!error}/> : null)}{tr(url ? <a className="st-btn st-btn--icon" href={url} target="_blank" rel="noreferrer" aria-label={tr("Open outside preview")} title={tr("Open outside preview")}><Icon name="link" size={17}/></a> : null)}<IconBtn name="refresh" title={tr("Reload preview")} onClick={() => setRevision(v => v + 1)}/></div><button ref={fileMenuRef} className="st-btn st-btn--icon preview-file-actions__more" aria-label={tr("Preview file actions")} title={tr("Preview file actions")} aria-haspopup="menu" aria-expanded={fileMenu} onClick={() => setFileMenu(v => !v)}><Icon name="ellipsis"/></button></>;
const actionMenu = fileMenu ? <FloatingMenu anchor={fileMenuRef.current} title={tr("Preview file actions")} onClose={() => setFileMenu(false)}>{tr(current.kind === 'code' ? <CopyMenuItem text={text} label="Code" disabled={loading || !!error} onClose={() => setFileMenu(false)}>Copy code</CopyMenuItem> : null)}{tr(url ? <a role="menuitem" className="menu__item" href={url} target="_blank" rel="noreferrer" onClick={() => setFileMenu(false)}><Icon name="link"/>{tr("Open outside preview")}</a> : null)}<button role="menuitem" className="menu__item" onClick={() => { setRevision(v => v + 1); setFileMenu(false); }}><Icon name="refresh"/>{tr("Reload preview")}</button></FloatingMenu> : null;
const content = <aside className="artifact-panel" aria-label={tr("{0} preview", [current.kind])}>
{tr(!inline ? <ArtifactTabs onCloseTab={onCloseTab} items={items} selected={selected} onSelect={onSelect}/> : null)}
<div id="artifact-content" role="tabpanel" aria-labelledby={`artifact-tab-${items.findIndex(a => a.id === selected)}`} className="artifact-content">
<div key={current.id} id="artifact-content" role="tabpanel" aria-labelledby={`artifact-tab-${items.findIndex(a => a.id === selected)}`} className="artifact-content">
{tr(current.kind === 'website' ? <><iframe key={`${current.id}-${revision}`} title={current.title} src={current.content ? undefined : url ?? undefined} srcDoc={current.content} sandbox="allow-scripts" referrerPolicy="no-referrer"/></> : current.kind === 'pdf' ? url ? <Suspense fallback={<p className="muted preview-placeholder">{tr("Opening PDF viewer\u2026")}</p>}><PdfPreview key={`${current.id}-${revision}`} url={url}/></Suspense> : <p>{tr("No PDF to display.")}</p> : current.kind === 'image' ? <div className="image-preview" data-size={imageSize === '1' ? 'actual' : 'fit'}>{tr(url ? <img key={`${current.id}-${revision}`} src={url} alt={current.title} onError={() => setError('Couldn’t load this image.')}/> : null)}{tr(error ? <p role="alert">{tr(error)}</p> : null)}</div> : loading ? <p className="muted preview-placeholder">{tr("Loading document\u2026")}</p> : error ? <p role="alert" className="inline-error preview-placeholder">{tr(error)}</p> : current.kind === 'code' ? <SyntaxCode text={text} filename={artifactFilename(current)} lineNumbers/> : current.kind === 'table' ? <TablePreview key={current.id} text={text}/> : mode === 'source' ? <SyntaxCode text={text} language="markdown" lineNumbers/> : <article className="document-markdown"><Markdown text={text}/></article>)}
</div>
{tr(viewControls ? <div className="artifact-view-controls">{tr(viewControls)}</div> : null)}
+1 -1
View File
@@ -20,7 +20,7 @@ function NativeChatModelPicker() {
const [open, setOpen] = useState(false), [error, setError] = useState('');
const ref = useRef<HTMLButtonElement>(null);
const current = native?.instances.find(i => i.id === native.defaultId);
return <><button className="chat-model-badge chat-model-picker" ref={ref} aria-label={tr("Choose model: {0}", [current?.label ?? 'Native model'])} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(v => !v)}><Icon name="models" size={16}/><span>{tr(current?.label ?? 'Native model')}</span><Icon name="chevron" size={12}/></button>{tr(open ? <FloatingMenu anchor={ref.current} title={tr("Chat model")} width={340} onClose={() => setOpen(false)}>{tr(native?.instances.filter(i => i.status !== 'error').map(i => <button className="menu__item" role="menuitemradio" aria-checked={current?.id === i.id} disabled={streaming || i.status !== 'ready'} key={i.id} onClick={() => void nativeAction(`instances/${i.id}/default`).then(() => setOpen(false)).catch(e => setError(e.message))}><Icon name="models"/><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.map(g => `GPU ${g + 1}`).join(" + ") || "Automatic GPU")}</small></span></button>))}{tr(providers?.connections.flatMap(c => c.models.map(m => <button className="menu__item" role="menuitem" disabled={streaming} key={`${c.id}-${m.id}`} onClick={() => { selectRemoteModel({ connectionId: c.id, modelId: m.id }); setOpen(false); }}><Icon name="link"/>{m.name} · {c.name}</button>)))}<button className="menu__item" role="menuitem" onClick={() => { setOpen(false); go('models'); }}>{tr("Manage models")}</button>{tr(error ? <p className="small inline-error">{tr(error)}</p> : null)}</FloatingMenu> : null)}</>;
return <><button className="chat-model-badge chat-model-picker" ref={ref} aria-label={tr("Choose model: {0}", [current?.label ?? 'Native model'])} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(v => !v)}><Icon name="models" size={16}/><span>{tr(current?.label ?? 'Native model')}</span><Icon name="chevron" size={12}/></button>{tr(open ? <FloatingMenu anchor={ref.current} title={tr("Chat model")} width={340} onClose={() => setOpen(false)}>{tr(native?.instances.filter(i => i.status !== 'error').map(i => <button className="menu__item" role="menuitemradio" aria-checked={current?.id === i.id} disabled={streaming || i.status !== 'ready'} key={i.id} onClick={() => void nativeAction(`instances/${i.id}/default`).then(() => setOpen(false)).catch(e => setError(e.message))}><Icon name="models"/><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.map(g => `GPU ${g + 1}`).join(" + ") || "Automatic GPU")}</small></span>{tr(current?.id === i.id ? <Icon name="check" size={16} className="menu__check"/> : null)}</button>))}{tr(providers?.connections.flatMap(c => c.models.map(m => <button className="menu__item" role="menuitem" disabled={streaming} key={`${c.id}-${m.id}`} onClick={() => { selectRemoteModel({ connectionId: c.id, modelId: m.id }); setOpen(false); }}><Icon name="link"/>{m.name} · {c.name}</button>)))}<button className="menu__item" role="menuitem" onClick={() => { setOpen(false); go('models'); }}>{tr("Manage models")}</button>{tr(error ? <p className="small inline-error">{tr(error)}</p> : null)}</FloatingMenu> : null)}</>;
}
function LegacyChatModelPicker() {
const native = useNative();
+1 -1
View File
@@ -39,7 +39,7 @@ export function ChatToolbar({ session, items, selected, onSelect, onClose, onClo
return <header className="chat-toolbar" style={previews && !expanded && previewWidth ? { gridTemplateColumns: `minmax(0,1fr) ${previewWidth}px` } : undefined} data-timeskip={selected?.startsWith('time-skip:') || undefined} data-preview={previews || undefined} data-expanded={expanded && previews || undefined}>
<div className="chat-toolbar__conversation"><h1 title={title}>{title}</h1><ChatModelPicker /><button ref={agentsRef} className="chat-agents" title={tr(count ? `${count} subagents running · View overview` : 'View subagent overview')} aria-label={tr(count ? `${count} subagents running — show overview` : 'Subagents — show overview')} aria-haspopup="dialog" aria-expanded={agents} data-working={count > 0 || undefined} onClick={() => setAgents(v => !v)}><Icon name="experts" size={17}/>{tr(count > 0 ? <span className="agent-live-dot"/> : null)}<span className="chat-agents__label">{tr("Subagents")}</span>{tr(session?.agents?.length ? <span className="chat-agents__count">{tr(count || session.agents.length)}</span> : null)}<Icon name="chevron" className={agents ? 'is-open' : ''} size={13}/></button><span className="spacer"/><button ref={menuRef} className="st-btn st-btn--icon" title={tr("Chat options")} aria-label={tr("Chat options")} aria-haspopup="menu" aria-expanded={menu} onClick={() => setMenu(v => !v)}><Icon name="ellipsis"/></button>{tr(!selected && (items.length || extraTabs.length) ? <IconBtn name="panel" title={tr("Open previews")} onClick={() => onSelect(extraTabs.at(-1)?.id ?? items[0].id)}/> : null)}</div>
{tr(previews ? <div className="chat-toolbar__previews"><ArtifactTabs extraTabs={extraTabs} onCloseTab={onCloseTab} items={items} selected={selected} onSelect={onSelect}/><div className="preview-window-actions"><div id="preview-file-actions" className="preview-file-actions"/><IconBtn name={expanded ? 'contract' : 'expand'} title={tr(expanded ? 'Exit full screen preview' : 'Full screen preview')} onClick={onExpand}/><IconBtn name="panel" title={tr("Collapse preview sidebar")} onClick={onClose}/></div></div> : null)}
{tr(agents ? <Popover anchor={agentsRef.current} title={tr("Subagents")} detail={session?.agents?.length ? <>{tr(count)}{tr(" running \u00B7 ")}{tr(session.agents.filter(a => a.status === 'completed').length)}{tr(" completed \u00B7 ")}{tr(session.agents.length)}{tr(" total")}</> : undefined} width={460} onClose={() => setAgents(false)}>{tr(session?.agents?.length ? <>{tr(session.agents.map(a => <SubagentRow key={a.id} agent={a} now={now}/>))}</> : <div className="subagents-empty"><Icon name="experts" size={24}/><strong>{tr("No subagents recorded")}</strong><p className="small muted">{tr("Subagents that run in this conversation appear here, with their progress, token usage and elapsed time. Completed runs stay visible.")}</p></div>)}</Popover> : null)}
{tr(agents ? <Popover anchor={agentsRef.current} title={tr(session?.agents?.length ? "Subagents" : "No subagents recorded")} detail={session?.agents?.length ? <>{tr(count)}{tr(" running \u00B7 ")}{tr(session.agents.filter(a => a.status === 'completed').length)}{tr(" completed \u00B7 ")}{tr(session.agents.length)}{tr(" total")}</> : undefined} width={460} onClose={() => setAgents(false)}>{tr(session?.agents?.length ? <>{tr(session.agents.map(a => <SubagentRow key={a.id} agent={a} now={now} sessionId={session && !session.showcase ? session.id : undefined}/>))}</> : null)}</Popover> : null)}
{tr(menu ? <FloatingMenu anchor={menuRef.current} title={tr("Chat options")} onClose={() => setMenu(false)}><button role="menuitem" className="menu__item" disabled={!!remote} title={tr(remote ? "API models use their provider’s response settings." : undefined)} onClick={() => { setMenu(false); setSettings(true); }}><Icon name="sliders"/>{tr("Response settings")}</button><button role="menuitem" className="menu__item" disabled={!session} onClick={() => { if (session)
downloadSessionLog(session); setMenu(false); }}><Icon name="download"/>{tr("Download session log")}</button></FloatingMenu> : null)}
<SamplingDrawer open={settings} onClose={() => setSettings(false)}/>
@@ -1,6 +1,7 @@
import { tr } from '../lib/i18n';
import { useEffect, useRef, useState } from 'react';
import { Btn, ErrorNotice, Dialog, Icon, IconBtn } from './ui';
import { Btn, ErrorNotice, Dialog, Icon } from './ui';
import { CopyBtn } from './CopyBtn';
import { api } from '../lib/client';
import { setCloudflare, useStore } from '../lib/store';
import type { TunnelState } from '../lib/types';
@@ -84,7 +85,7 @@ export function CloudflareDialog({ open, onClose, onCreateKey }: {
</>}>
<div className="tunnel-wizard">
{tr(!active ? <ol className="tunnel-progress" aria-label={tr("Connection progress")}>{tr(steps.map((label, i) => <li key={label} data-current={step === i || undefined} data-complete={step > i || undefined}><button disabled={i >= step || pending} aria-current={step === i ? 'step' : undefined} onClick={() => { setError(''); setStep(i); }}><span>{tr(step > i ? <Icon name="check" size={13}/> : i + 1)}</span><small>{tr(label)}</small></button></li>))}</ol> : null)}
{tr(active ? <section className="tunnel-stage tunnel-stage--status" key={tunnel?.status} aria-live="polite"><span className="tunnel-stage__symbol"><Icon name={tunnel?.status === 'connected' ? 'check' : 'clock'} size={26}/></span><h3 className="h3">{tr(tunnel?.status === 'connected' ? 'You’re connected' : tunnel?.status === 'stopping' ? 'Stopping the tunnel…' : 'Connecting to Cloudflare…')}</h3>{tr(tunnel?.url ? <><p className="small muted">{tr("Use this address and your Strata API key in your other app.")}</p><div className="install-command"><code>{tunnel.url}</code><IconBtn name="copy" title={tr("Copy tunnel URL")} onClick={() => void navigator.clipboard.writeText(tunnel.url!)}/></div><p className="xs muted">{tr(tunnel.mode === 'quick' ? 'Temporary address · turn off streaming in your client.' : `Your Cloudflare route must point to ${tunnel.originUrl}.`)}</p></> : <p className="small muted">{tr("This usually takes a few seconds. You can close this window while it connects.")}</p>)}</section> : <section className="tunnel-stage" key={step} aria-labelledby="tunnel-step-title">
{tr(active ? <section className="tunnel-stage tunnel-stage--status" key={tunnel?.status} aria-live="polite"><span className="tunnel-stage__symbol"><Icon name={tunnel?.status === 'connected' ? 'check' : 'clock'} size={26}/></span><h3 className="h3">{tr(tunnel?.status === 'connected' ? 'You’re connected' : tunnel?.status === 'stopping' ? 'Stopping the tunnel…' : 'Connecting to Cloudflare…')}</h3>{tr(tunnel?.url ? <><p className="small muted">{tr("Use this address and your Strata API key in your other app.")}</p><div className="install-command"><code>{tunnel.url}</code><CopyBtn text={tunnel.url!} label="Tunnel URL" title="Copy tunnel URL"/></div><p className="xs muted">{tr(tunnel.mode === 'quick' ? 'Temporary address · turn off streaming in your client.' : `Your Cloudflare route must point to ${tunnel.originUrl}.`)}</p></> : <p className="small muted">{tr("This usually takes a few seconds. You can close this window while it connects.")}</p>)}</section> : <section className="tunnel-stage" key={step} aria-labelledby="tunnel-step-title">
<h3 ref={heading} id="tunnel-step-title" className="h3" tabIndex={-1}>{tr(titles[step])}</h3>
{tr(step === 0 ? <><p className="small muted">{tr("Give your local API an HTTPS address that another app can reach.")}</p><div className="tunnel-choices" role="radiogroup" aria-label={tr("Cloudflare connection type")}>{tr([{ value: 'quick' as const, title: 'Free temporary URL', detail: 'No account or domain needed. Good for a quick connection.', icon: 'bolt' as const }, { value: 'domain' as const, title: 'My own domain', detail: 'A stable address on a domain you manage with Cloudflare.', icon: 'link' as const }].map(choice => <button key={choice.value} role="radio" aria-checked={mode === choice.value} tabIndex={mode === choice.value ? 0 : -1} onKeyDown={e => { if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
e.preventDefault();
@@ -93,7 +94,7 @@ export function CloudflareDialog({ open, onClose, onCreateKey }: {
} }} onClick={() => setMode(choice.value)}><Icon name={choice.icon} size={20}/><span><strong>{tr(choice.title)}</strong><small>{tr(choice.detail)}</small></span><span className="tunnel-choice-dot">{tr(mode === choice.value ? <Icon name="check" size={13}/> : null)}</span></button>))}</div></> : null)}
{tr(step === 1 ? <div className="tunnel-automatic" data-ready={installed || undefined} aria-live="polite" aria-busy={pending}><span className="tunnel-stage__symbol"><Icon name={installed ? 'check' : error ? 'warning' : 'refresh'} size={28}/></span><p className="small muted">{tr(installed ? 'Your Cloudflare connector is installed and ready.' : error ? 'Automatic setup needs another try.' : 'Strata downloads and sets up the Cloudflare connector for you.')}</p>{tr(installed ? <>{tr(tunnel?.connector ? <p className="xs muted">{tr("Using {0}", [tunnel.connector.summary])}</p> : null)}{tr(other ? <p className="xs muted" role="status">{tr(other.others.length === 1 && !other.own.length ? "cloudflared is already running for another tunnel (PID {0})." : other.own.length ? "Strata’s tunnel for this address is already running (PID {0})." : "cloudflared is already running for other tunnels (PIDs {0}).", [(other.own.length ? other.own : other.others).join(", ")])}</p> : null)}</> : error ? <Btn onClick={() => setRetry(v => v + 1)}>{tr("Retry setup")}</Btn> : <span className="small muted" role="status">{tr("Setting up\u2026")}</span>)}</div> : null)}
{tr(step === 2 ? <><p className="small muted">{tr(keyReady ? "Only apps with your Strata API key can use this connection." : "Without an API key, anyone who finds the link can use your model.")}</p>{tr(keyReady ? <div className="tunnel-check"><Icon name="shield"/><div><strong>{tr("API key ready")}</strong><span>{tr("Use your existing key in the app you connect.")}</span></div></div> : <div className="tunnel-key-step"><Icon name="shield" size={30}/><p className="small">{tr("Create a key to protect your API. You\u2019ll return here when it\u2019s ready. You can also continue without one, but anyone who finds the link can then use your model.")}</p><Btn onClick={onCreateKey}>{tr("Create an API key (recommended)")}</Btn></div>)}</> : null)}
{tr(mode === 'domain' && step === 3 ? <><p className="small muted">{tr("Create a tunnel in your ")}<a href="https://dash.cloudflare.com/" target="_blank" rel="noreferrer">{tr("Cloudflare dashboard \u2197")}</a>{tr(". Copy the token from its connector setup.")}</p><label className="input-field"><span>{tr("Tunnel token")}</span><input type="password" className="st-input" autoComplete="off" spellCheck={false} value={token} onChange={e => setToken(e.target.value)} placeholder={tr("Paste only the token after --token")}/></label><label className="input-field"><span>{tr("Your hostname")}</span><input className="st-input" autoComplete="off" spellCheck={false} value={hostname} onChange={e => setHostname(e.target.value)} placeholder={tr("ai.example.com")}/></label><div><p className="small muted">{tr("Add a published application route for this hostname with this service URL:")}</p><div className="install-command"><code>{tunnel?.originUrl ?? 'Loading…'}</code><IconBtn name="copy" title={tr("Copy tunnel service URL")} disabled={!tunnel?.originUrl} onClick={() => void navigator.clipboard.writeText(tunnel!.originUrl)}/></div></div><p className="xs muted">{tr("The token is used for this connection only and is not saved.")}</p></> : null)}
{tr(mode === 'domain' && step === 3 ? <><p className="small muted">{tr("Create a tunnel in your ")}<a href="https://dash.cloudflare.com/" target="_blank" rel="noreferrer">{tr("Cloudflare dashboard \u2197")}</a>{tr(". Copy the token from its connector setup.")}</p><label className="input-field"><span>{tr("Tunnel token")}</span><input type="password" className="st-input" autoComplete="off" spellCheck={false} value={token} onChange={e => setToken(e.target.value)} placeholder={tr("Paste only the token after --token")}/></label><label className="input-field"><span>{tr("Your hostname")}</span><input className="st-input" autoComplete="off" spellCheck={false} value={hostname} onChange={e => setHostname(e.target.value)} placeholder={tr("ai.example.com")}/></label><div><p className="small muted">{tr("Add a published application route for this hostname with this service URL:")}</p><div className="install-command"><code>{tunnel?.originUrl ?? 'Loading…'}</code><CopyBtn text={tunnel?.originUrl ?? ''} label="Tunnel service URL" title="Copy tunnel service URL" disabled={!tunnel?.originUrl}/></div></div><p className="xs muted">{tr("The token is used for this connection only and is not saved.")}</p></> : null)}
{tr(step === last ? <><p className="small muted">{tr(mode === 'quick' ? 'Cloudflare will create your address when you connect.' : 'Start the connector for the domain you configured.')}</p><dl className="tunnel-review"><div><dt>{tr("Address")}</dt><dd>{tr(mode === 'quick' ? 'Free temporary URL' : hostname)}</dd></div><div><dt>{tr("Connector")}</dt><dd><Icon name="check" size={14}/>{tr(tunnel?.connector?.version ? "Installed · cloudflared {0}" : "Installed", tunnel?.connector?.version ? [tunnel.connector.version] : undefined)}</dd></div><div><dt>{tr("Access")}</dt><dd><Icon name="shield" size={14}/>{tr(keyReady ? "API key required" : "No API key: anyone with the link can use it")}</dd></div></dl><p className="small muted">{tr(mode === 'quick' ? 'The address changes each time. Turn off streaming in your client; replies arrive all at once.' : 'Use this hostname and your Strata API key in your client. Streaming is supported.')}</p><p className="xs muted">{tr("Only the inference API is shared. Your computer and Strata must stay on.")}</p></> : null)}
</section>)}
{tr(error || tunnel?.error ? <ErrorNotice message={error || tunnel?.error || 'Connection failed'} next={tr("Check your internet connection, then retry.")}/> : null)}
@@ -0,0 +1,15 @@
import { tr } from '../lib/i18n';
import { cx } from '../lib/format';
import { Icon } from './ui';
import { useSelectedPreviewId } from '../lib/code-preview';
import type { PreviewArtifact } from '../lib/artifacts';
/** The preview control of a code block: an icon button like Copy, on its left. It shows both states: closed, and open (filled, "Close preview"). */
export function CodePreviewBtn({ artifact, onPreview, onClose }: { artifact: PreviewArtifact; onPreview: (a: PreviewArtifact) => void; onClose?: (id: string) => void }) {
const open = useSelectedPreviewId() === artifact.id;
const label = tr(open ? 'Close preview' : 'Open preview');
return <button type="button" className={cx('st-btn st-btn--icon', open && 'is-open')} data-open={open || undefined} aria-pressed={open} aria-label={label} title={label}
onClick={() => (open && onClose ? onClose(artifact.id) : onPreview(artifact))}>
<Icon name="panel" size={19}/>
</button>;
}
@@ -45,6 +45,25 @@ export function ComposerControls({ session, onPreview, onAttach, onError, onSlas
const remote = useStore(s => s.remoteModel), providers = useStore(s => s.providers);
const apiModel = providers?.connections.find(c => c.id === remote?.connectionId)?.models.find(m => m.id === remote?.modelId);
const [menu, setMenu] = useState(false), [preferences, setPreferences] = useState<'mode' | 'thinking' | null>(null), [reading, setReading] = useState(false);
// The mode menu prints 1–3 beside its rows. They were decoration: make those
// keys pick the mode while the menu is open, and leave modified keys to the app.
useEffect(() => {
if (preferences !== 'mode')
return;
const onKey = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey || event.altKey)
return;
const option = modes[Number(event.key) - 1];
if (!option)
return;
event.preventDefault();
setChatPreferences(session?.id, { mode: option.value });
setPreferences(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [preferences, session?.id]);
const [urlOpen, setUrlOpen] = useState(false), [url, setUrl] = useState(''), [error, setError] = useState('');
const files = useRef<HTMLInputElement>(null), folder = useRef<HTMLInputElement>(null), previewFile = useRef<HTMLInputElement>(null);
const trigger = useRef<HTMLButtonElement>(null), modeTrigger = useRef<HTMLButtonElement>(null), thinkingTrigger = useRef<HTMLButtonElement>(null);
+31
View File
@@ -0,0 +1,31 @@
/** The Copy controls of the app, with one shared behaviour: the control itself shows that the copy worked (#4748). */
import { createContext, useContext } from 'react';
import { tr } from '../lib/i18n';
import { cx } from '../lib/format';
import { COPIED_MENU_MS, useCopy } from '../lib/use-copy';
import { Icon } from './ui';
type Source = string | (() => string | Promise<string>);
/** A round icon button that copies `text`: the icon turns into a check mark for a moment, and a screen reader hears "Copied". */
export function CopyBtn({ text, label, title, disabled, size = 19 }: { text: Source; label: string; title: string; disabled?: boolean; size?: number }) {
const { copied, copy } = useCopy();
return <button type="button" className={cx('st-btn st-btn--icon', copied && 'is-copied')} data-copied={copied || undefined} title={tr(copied ? 'Copied' : title)} aria-label={tr(title)} disabled={disabled} onClick={() => void copy(text, label)}>
<Icon name={copied ? 'check' : 'copy'} size={size}/>
<span className="sr-only" role="status">{copied ? tr('Copied') : ''}</span>
</button>;
}
/** What closes the menu a menu item is in; Chat's message menu provides it. */
export const MenuCloseContext = createContext<() => void>(() => {});
/** A menu item that copies `text`: it says "Copied" for a moment, and then the menu closes. */
export function CopyMenuItem({ text, label, children, disabled, onClose }: { text: Source; label: string; children: string; disabled?: boolean; onClose?: () => void }) {
const { copied, copy } = useCopy(COPIED_MENU_MS);
const close = useContext(MenuCloseContext);
return <button role="menuitem" className="menu__item" data-copied={copied || undefined} disabled={disabled} onClick={e => {
// The menu would close on this click before the person sees that it worked.
e.stopPropagation();
void copy(text, label).then(ok => { setTimeout(onClose ?? close, ok ? COPIED_MENU_MS : 0); });
}}><Icon name={copied ? 'check' : 'copy'}/>{tr(copied ? 'Copied' : children)}</button>;
}
+71 -6
View File
@@ -1,10 +1,14 @@
import { useRef, useState } from 'react';
import { useEffect, useId, useRef, useState } from 'react';
import { tr } from '../lib/i18n';
import { fileAction, fileSize, fileTypeLabel, previewOf, rawUrlOf, type ActivityGroup, type Deliverable } from '../lib/deliverables';
import { deliveryFolder, dirOf, fileAction, fileSize, fileTypeLabel, previewOf, rawUrlOf, rootOf, type ActivityGroup, type Deliverable } from '../lib/deliverables';
import { useChatFolder } from '../lib/chat-folder';
import type { PreviewArtifact } from '../lib/artifacts';
import { draftSize, groupFromDraft } from '../lib/draft-view';
import type { ToolDraft } from '../lib/types';
import { pushToast } from '../lib/store';
import { plainFailureText } from '../lib/plain-errors';
import { FloatingMenu } from './FloatingMenu';
import { CopyMenuItem } from './CopyBtn';
import { Icon } from './ui';
const problem = (title: string, e: unknown) => pushToast('error', tr(title), plainFailureText(e instanceof Error ? e.message : String(e)).message);
@@ -36,7 +40,7 @@ export function FileCard({ file, sessionId, onPreview }: { file: Deliverable; se
{tr(menu ? <FloatingMenu anchor={more.current} title={tr("File actions")} width={220} onClose={() => setMenu(false)}>
<button role="menuitem" className="menu__item" onClick={() => { setMenu(false); void showOf(file, sessionId, onPreview, file.kind); }}><Icon name="panel"/>{tr("Open in sidebar")}</button>
<button role="menuitem" className="menu__item" onClick={() => void run(() => fileAction.open(sessionId, file.path, 'folder'), 'The folder was not opened')}><Icon name="folder"/>{tr("Show in folder")}</button>
<button role="menuitem" className="menu__item" onClick={() => void run(async () => { await navigator.clipboard.writeText(await fileAction.fullPath(sessionId, file.path)); pushToast('info', tr('Path copied')); }, 'The path was not copied')}><Icon name="copy"/>{tr("Copy path")}</button>
<CopyMenuItem text={() => fileAction.fullPath(sessionId, file.path)} label="Path" onClose={() => setMenu(false)}>Copy path</CopyMenuItem>
<button role="menuitem" className="menu__item" onClick={() => void run(async () => { await fileAction.attach(sessionId, file.path); pushToast('info', tr('Attached to your next message'), file.name); }, 'The file was not attached')}><Icon name="attach"/>{tr("Attach as context")}</button>
</FloatingMenu> : null)}
</div>;
@@ -56,6 +60,35 @@ export function EditedFiles({ files, sessionId, onPreview }: { files: Deliverabl
</div>;
}
/**
* A run that made many files (379 after one stopped agent run) as one compact block instead of a card per file: where the result was delivered, the folder the
* chat works in, and the count ("379 files") that opens the full list. The list is closed by default, scrolls inside the block, and every row is a button.
*/
export function FilesSummary({ files, answer, sessionId, onPreview }: { files: Deliverable[]; answer: string; sessionId: string; onPreview: (a: PreviewArtifact) => void }) {
const [open, setOpen] = useState(false), id = useId(), folder = useChatFolder(sessionId);
const delivery = deliveryFolder(files, answer);
const rootPath = folder?.path ?? rootOf(files[0]), rootName = folder?.name ?? (rootPath.split(/[\\/]/).filter(Boolean).at(-1) ?? '');
const added = files.reduce((n, f) => n + f.added, 0), removed = files.reduce((n, f) => n + f.removed, 0);
const openFolder = (path: string) => void fileAction.open(sessionId, path, 'app').catch(err => problem('The folder was not opened', err));
const places: { key: string; label: string; name: string; full: string; open: string }[] = [];
if (delivery) places.push({ key: 'delivered', label: 'Delivered to', name: delivery, full: rootPath ? `${rootPath}/${delivery}` : delivery, open: delivery });
if (rootPath) places.push({ key: 'working', label: delivery ? 'Working folder' : 'Delivered to', name: rootName, full: rootPath, open: rootPath });
return <section className="fsum" aria-label={tr("Files")}>
{places.length ? <ul className="fsum__places">{places.map(p => <li key={p.key}>
<Icon name="folder" size={16}/><span className="fsum__label">{tr(p.label)}</span><span className="fsum__name" title={p.full}>{p.name}</span>
<button type="button" className="fsum__open" aria-label={tr("Open {0}", [p.name])} onClick={() => openFolder(p.open)}>{tr("Open")}</button>
</li>)}</ul> : null}
<button type="button" className="fsum__toggle" aria-expanded={open} aria-controls={id} onClick={() => setOpen(v => !v)}>
<Icon name="chevron" size={14} className="fsum__chev"/><span>{tr('{0} files', [files.length.toLocaleString()])}</span>{added || removed ? <Plusminus added={added} removed={removed}/> : null}
</button>
{open ? <ul className="fsum__list" id={id}>{files.map(f => { const dir = dirOf(f.path); return <li key={f.path}>
<button type="button" className="fsum__file" title={f.path} onClick={() => void showOf(f, sessionId, onPreview, f.kind)}>
<span className="fsum__type">{fileTypeLabel(f)}</span><span className="fsum__fname">{f.name}</span>{dir ? <span className="fsum__dir">{dir}</span> : null}<span className="fsum__size">{fileSize(f.size)}</span>
</button>
</li>; })}</ul> : null}
</section>;
}
/**
* The saved skills an answer used, as one quiet line under the answer in the same style as the activity line ("Ran 2 commands, read a file"):
* "Used 2 skills: Weekly report, Brand voice"; opened, each skill with its one-line description.
@@ -79,13 +112,45 @@ export function ActivityLine({ group, sessionId, onPreview }: { group: ActivityG
const picture = group.files.find(f => f.kind === 'image');
// The host's sentence may end with the numbers ("… +225 −0"): they are drawn as figures instead.
const sentence = group.line.replace(/\s*\+\d+\s*[−-]\s*\d+\s*$/, '');
return <div className="act" data-open={open || undefined}>
// While the run is open this is the live step: the host's own words for it when it sends them, else the line it already had.
const live = !group.closed;
return <><div className="act" data-open={open || undefined} data-live={live || undefined}>
<button type="button" className="act__line" aria-expanded={open} disabled={!group.files.length} onClick={() => setOpen(v => !v)}>
<span>{sentence}</span>{group.added || group.removed ? <Plusminus added={group.added} removed={group.removed}/> : null}
<span>{live && group.live?.label ? group.live.label : sentence}</span>{live && group.live?.bytes ? <span className="act__meta">{draftSize(group.live.bytes)}{group.live.lines && group.live.lines > 1 ? <> {"\u00B7"} {tr("{0} lines", [group.live.lines])}</> : null}</span> : null}{group.added || group.removed ? <Plusminus added={group.added} removed={group.removed}/> : null}
{group.files.length ? <Icon name="chevron" size={14} className="act__chev"/> : null}
{!group.closed ? <span className="act__live" aria-hidden="true"/> : null}
</button>
{picture ? <img className="act__thumb" src={rawUrlOf(sessionId, picture.path)} alt="" loading="lazy" onClick={() => void showOf(picture, sessionId, onPreview)}/> : null}
{open ? <ul className="act__files">{group.files.map(f => <li key={f.path}><button type="button" onClick={() => void showOf(f, sessionId, onPreview, f.kind)}><Icon name="file" size={14}/>{f.name}{f.added || f.removed ? <Plusminus added={f.added} removed={f.removed}/> : null}</button></li>)}</ul> : null}
</div>;
</div>
{live && group.live?.output ? <p className="act__output" title={group.live.output}>{group.live.output}</p> : null}
{live && group.live?.tail !== undefined ? <div className="act__tail" aria-hidden="true">{group.live.tail.split('\n').filter(Boolean).map((line, i) => <span key={i}>{line}</span>)}</div> : null}
</>;
}
/** The value, at most every `ms` (a file arrives about five times a second; the eye needs two). A cleared value is shown at once. */
function useThrottled<T>(value: T, ms: number, cleared: (v: T) => boolean): T {
const [shown, setShown] = useState(value), last = useRef(0), timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
clearTimeout(timer.current);
if (cleared(value)) { setShown(value); return; }
const wait = Math.max(0, last.current + ms - Date.now());
timer.current = setTimeout(() => { last.current = Date.now(); setShown(value); }, wait);
return () => clearTimeout(timer.current);
}, [value, ms]);
return shown;
}
/**
* The files (or commands) the model is writing right now, from `message.drafts`: each is one open activity line with the file's name, its growing size
* and the last lines of its text. It draws nothing while there is nothing being written, and takes the place of the ordinary card when the call is whole.
*/
export function DraftLines({ drafts, sessionId, onPreview }: { drafts: ToolDraft[] | undefined; sessionId: string; onPreview: (a: PreviewArtifact) => void }) {
const shown = useThrottled(drafts, 350, v => !v?.length);
if (!drafts?.length || !shown?.length) return null;
return <>{shown.map(d => {
const file = d.target ? d.target.split(/[\\/]/).filter(Boolean).at(-1) : '';
const verb = tr(d.verb ?? (d.scope === 'shell' ? 'Running a command' : 'Writing a file'));
return <ActivityLine key={d.id} group={groupFromDraft(d, file ? `${verb} · ${file}` : verb)} sessionId={sessionId} onPreview={onPreview}/>;
})}</>;
}
+6 -3
View File
@@ -6,6 +6,7 @@ import { api } from '../lib/client';
import { useDownloadEta } from '../lib/download-eta';
import type { NativeJob } from '../lib/native';
import { megabytes } from '../lib/format';
import { FirstRunLanguage } from './FirstRunLanguage';
/** What the host says about this PC's engine (GET /api/engine/first-run). */
export interface FirstRunInfo {
@@ -87,7 +88,7 @@ export function EngineFirstRun({ onDone }: { onDone: () => void }) {
setProblem('');
try {
started.current = true;
setJob(await api.post<NativeJob>('/api/native/operations', { kind: 'update-engine', args: {} }));
setJob(await api.post<NativeJob>('/api/native/operations', { kind: 'update-engine', args: { firstRun: true } }));
}
catch (e) {
started.current = false;
@@ -165,9 +166,11 @@ export function EngineFirstRun({ onDone }: { onDone: () => void }) {
actions = <><Btn variant="primary" disabled={busy} onClick={look}>{tr("Try again")}</Btn>{pick("Choose a downloaded file…")}<button type="button" className="onboard-continue-anyway" onClick={onDone}>{tr("Continue without an engine")}</button></>;
}
body = <><div className="onboard-welcome-copy"><h1 tabIndex={-1} data-initial-focus>{tr("Welcome to Strata")}</h1>{main}</div>
{failed ? <div className="onboard-error" style={{ maxWidth: 560, margin: '0 auto' }}><p role="alert" className="small inline-error">{job?.error ?? job?.summary}</p><p className="small muted">{tr("The download stopped. Continue picks it up where it stopped.")}</p></div> : null}
{failed ? <div className="onboard-error" style={{ maxWidth: 560, margin: '0 auto' }}><p role="alert" className="small inline-error">{job?.error ?? job?.summary}</p>
{job?.details ? <details className="error-detail"><summary className="small">{tr('Details')}</summary><pre className="small muted">{job.details}</pre></details> : null}
<p className="small muted">{percent >= 100 ? tr("The file was downloaded. Continue tries the install again.") : tr("The download stopped. Continue picks it up where it stopped.")}</p></div> : null}
{problem ? <div className="onboard-error" style={{ maxWidth: 560, margin: '0 auto' }}><p role="alert" className="small inline-error">{problem}</p></div> : null}
<footer className="onboard-footer onboard-footer--welcome">{actions}</footer></>;
}
return <div className="setup guided-onboarding" data-step="engine">{hidden}<header className="onboard-header"><Brand variant="lockup" height={22}/></header>{body}</div>;
return <div className="setup guided-onboarding" data-step="engine">{hidden}<header className="onboard-header"><Brand variant="lockup" height={22}/><FirstRunLanguage/></header>{body}</div>;
}
@@ -0,0 +1,40 @@
import { useState } from 'react';
import { tr } from '../lib/i18n';
import { Btn, Icon } from './ui';
import { nativeAction, type NativeState } from '../lib/native';
/** Strata Control's last check found a newer engine than the one installed (the Qwen engine, the DeepSeek engine, or both): its numbers come from the
* check kept on this PC (`update_available` of `strata components engine`), so showing the card reads no network. After an app or Control update this is how a
* person with an older engine is told (#4848). */
export const engineUpdateOffered = (native: NativeState | null | undefined) => {
const offer = native?.engine?.updateAvailable;
return !!(offer && (offer.engine || offer.dsv41));
};
/** One card, plain words, one button. The update itself is the existing "Update engine" job (its progress shows where every job's does); models are not touched. */
export function EngineUpdateCard({ native, busy }: { native: NativeState | null | undefined; busy: boolean }) {
const [pending, setPending] = useState(false);
const [error, setError] = useState('');
if (!engineUpdateOffered(native)) return null;
const running = (native?.instances.length ?? 0) > 0;
async function update() {
setPending(true);
setError('');
try {
await nativeAction('operations', { kind: 'update-engine' });
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setPending(false);
}
}
return <div className="engine-warning engine-update" role="status">
<Icon name="info" size={18}/>
<div>
<p>{tr("A newer engine is available for this computer. Your models and settings stay as they are.")}</p>
{running ? <small>{tr("Unload your running models first.")}</small> : null}
{error ? <small className="inline-error" role="alert">{error}</small> : null}
</div>
<Btn size="sm" variant="primary" disabled={busy || pending || running} onClick={() => void update()}>{tr("Update engine")}</Btn>
</div>;
}
@@ -0,0 +1,129 @@
import { useEffect, useState } from 'react';
import { localeTag, tr } from '../lib/i18n';
import { Bar, Btn, Dialog } from './ui';
import { nativeAction, useNative, type NativeJob } from '../lib/native';
/** "A", "A and B", "A, B and C" in the language of the app. */
const listOf = (names: string[]) => {
try { return new Intl.ListFormat(localeTag(), { style: 'long', type: 'conjunction' }).format(names); } catch { return names.join(', '); }
};
/**
* Update engine with a model loaded (Settings > About). Strata Control does the work (`strata update --stop-model --output jsonl`, through the host); this is the
* conversation around it, in four plain steps:
* 1. Warn, naming the model, and ask. Nothing happens before the yes. With no model loaded the older dialog of Settings is used instead.
* 2. Progress in words: the new engine downloads while the model keeps working; then the model stops; then the engine is updated.
* 3. Offer to start the model again with the same settings. Never automatic.
* 4. A failed update leaves the old engine as it was and offers to start the model again on it.
* The dialog follows the host's state, so it also shows the offer when the update finished while Settings was closed.
*/
export function EngineUpdateDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const native = useNative();
const [jobId, setJobId] = useState('');
const [restartId, setRestartId] = useState('');
const [error, setError] = useState('');
const [hidden, setHidden] = useState(false);
const loaded = native?.instances ?? [];
const offer = native?.stoppedForUpdate ?? null;
const job: NativeJob | undefined = native?.jobs.find(j => j.id === jobId);
const restart: NativeJob | undefined = native?.jobs.find(j => j.id === restartId);
const offerKey = offer?.at ?? '';
useEffect(() => { setHidden(false); }, [offerKey]);
const restarting = restart?.state === 'running';
const updating = job?.state === 'running';
const names = listOf(offer?.models.map(m => m.label) ?? loaded.map(i => i.label));
const showOffer = !!offer && !hidden && !updating && !restarting;
const failedRestart = restart?.state === 'error' && !offer ? restart : undefined;
const ended = job?.state === 'done' || job?.state === 'error';
const visible = open || updating || restarting || showOffer || !!failedRestart || ended;
if (!visible) return null;
async function start() {
setError('');
try {
const started = await nativeAction<NativeJob>('operations', { kind: 'update-engine', args: { stopModel: true } });
setJobId(started.id);
onClose(); // the dialog now follows the job; the caller's "open" is spent
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}
async function startAgain() {
setError('');
try {
const started = await nativeAction<NativeJob>('operations', { kind: 'restart-models' });
setRestartId(started.id);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}
async function notNow() {
setHidden(true);
try { await nativeAction('engine-restart/dismiss'); } catch { /* the offer stays hidden here either way */ }
setJobId('');
onClose();
}
function close() {
if (showOffer) { void notNow(); return; }
if (failedRestart) setRestartId('');
onClose();
}
const percent = Number(job?.result?.install?.download?.percent);
const details = (j: NativeJob) => j.details ? <details className="native-settings-diagnostic"><summary className="small">{tr("Details")}</summary><p className="small muted">{j.details}</p></details> : null;
// 3 and 4: the model was stopped for the update.
if (showOffer && offer) {
const done = offer.updated;
return <Dialog open compact title={tr(done ? 'The engine is updated' : "The engine was not updated")} onClose={close}
actions={<><Btn onClick={() => void notNow()}>{tr("Not now")}</Btn><Btn variant="primary" onClick={() => void startAgain()}>{tr("Start again")}</Btn></>}>
<p>{done ? tr("The engine is updated. Start {0} again with the same settings?", [names]) : tr("The update didn't finish. Your old engine is unchanged and still works. Start {0} again on it?", [names])}</p>
{!done && job ? details(job) : null}
{error ? <p className="small inline-error" role="alert">{tr(error)}</p> : null}
</Dialog>;
}
// The model is starting again.
if (restarting) {
return <Dialog open compact title={tr("Starting {0} again", [names])} onClose={onClose}>
<p role="status">{tr("Starting {0}. Loading can take a few minutes.", [names])}</p>
<div className="engine-update-progress"><Bar value={0} indeterminate label={tr("Starting {0} again", [names])}/></div>
</Dialog>;
}
if (failedRestart) {
return <Dialog open compact title={tr("The model did not start again")} onClose={close} actions={<Btn variant="primary" onClick={close}>{tr("Close")}</Btn>}>
<p>{tr("You can load it again from Models.")}</p>
{failedRestart.error ? <p className="small muted">{failedRestart.error}</p> : null}
</Dialog>;
}
// 2: the update is running.
if (updating && job) {
const step = job.step, label = listOf(job.models ?? loaded.map(i => i.label));
return <Dialog open compact title={tr("Updating the engine")} onClose={onClose}>
<p role="status" aria-live="polite">{step === 'stopping' ? tr("Stopping {0}. Answers in progress finish first.", [label])
: step === 'stopped' ? tr("{0} is stopped. Updating the engine…", [label])
: step === 'getting-ready' ? tr("Getting the new engine ready. {0} keeps working until it is ready.", [label])
: tr(job.summary || 'Starting…')}</p>
<div className="engine-update-progress">{step === 'getting-ready' && Number.isFinite(percent) ? <Bar value={percent} label={tr("Downloading…")}/> : <Bar value={0} indeterminate label={tr("Updating the engine")}/>}</div>
</Dialog>;
}
// The update ended without stopping anything (declined, refused, or failed before the model was touched).
if (job?.state === 'error') {
return <Dialog open compact title={tr("The engine was not updated")} onClose={() => { setJobId(''); onClose(); }} actions={<Btn variant="primary" onClick={() => { setJobId(''); onClose(); }}>{tr("Close")}</Btn>}>
<p>{tr(job.error ?? job.summary)}</p>
{details(job)}
</Dialog>;
}
if (job?.state === 'done') {
return <Dialog open compact title={tr("The engine is updated")} onClose={() => { setJobId(''); onClose(); }} actions={<Btn variant="primary" onClick={() => { setJobId(''); onClose(); }}>{tr("Close")}</Btn>}>
<p>{tr(job.summary)}</p>
</Dialog>;
}
// 1: warn and ask.
return <Dialog open compact title={tr("Update engine?")} onClose={close}
actions={<><Btn onClick={close}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={!loaded.length} onClick={() => void start()}>{tr("Stop and update")}</Btn></>}>
<p>{tr("To update the engine, Strata has to stop the model that is loaded: {0}.", [names])}</p>
<p className="small muted">{tr("The new engine downloads first while the model keeps answering. Then the model stops (answers in progress finish first), the engine is updated, and you can start the model again with the same settings. Your models and settings are kept.")}</p>
{error ? <p className="small inline-error" role="alert">{tr(error)}</p> : null}
</Dialog>;
}
+1 -1
View File
@@ -9,5 +9,5 @@ export const engineMissing = (native: NativeState | null | undefined) => !!nativ
export function EngineWarning({ native }: { native: NativeState | null | undefined }) {
if (!engineMissing(native)) return null;
if (native?.engine?.wrongMake && native.engine.notice) return <div className="engine-warning" role="status"><Icon name="info" size={18}/><div><p>{native.engine.notice}</p></div></div>;
return <div className="engine-warning" role="status"><Icon name="info" size={18}/><div><p>{tr("Strata didn’t recognise an engine for this computer. The model will download, but it may not run until the right engine is installed (Settings → Engine).")}</p>{native?.engine?.notice ? <small className="muted">{native.engine.notice}</small> : null}</div></div>;
return <div className="engine-warning" role="status"><Icon name="info" size={18}/><div><p>{tr("Strata didn’t recognise an engine for this computer. The model will download, but it may not run until the right engine is installed (Settings → About).")}</p>{native?.engine?.notice ? <small className="muted">{native.engine.notice}</small> : null}</div></div>;
}
@@ -0,0 +1,10 @@
/** The language choice on the first screen, so a person who cannot read English can change it before anything is downloaded (#3723). */
import { tr } from '../lib/i18n';
import { LANGUAGES, type Language } from '../lib/i18n';
import { setLanguage, useStore } from '../lib/store';
import { Select } from './ui';
export function FirstRunLanguage() {
const language = useStore(s => s.language);
return <div className="onboard-language"><Select translateLabels={false} ariaLabel={tr("Language")} value={language} options={[...LANGUAGES]} onChange={value => setLanguage(value as Language)}/></div>;
}
+23 -10
View File
@@ -14,10 +14,14 @@ import type { LocalModelFolder, SetupState } from '../lib/types';
import { downloadCounter, modelFolderLabel, onboardingError } from '../lib/onboarding-download';
import { installProgress, installStage, stageText, useDownloadEta } from '../lib/download-eta';
import { VersionPicker } from './VersionPicker';
import { contextIsTight, defaultContext } from '../lib/default-context';
import { deepseekContexts } from '../lib/deepseek-context';
import { cacheGb } from '../lib/native-fit';
import { FirstRunLanguage } from './FirstRunLanguage';
import { DownloadParts } from './DownloadParts';
import { EngineWarning } from './EngineWarning';
import { OTHER_NOTE, PICK_NOTE, recommendedVersions } from '../lib/native-fit';
import { nativeCatalogModels, catalogVersionLabel } from '../lib/native-catalog';
import { nativeCatalogModels, catalogVersionLabel, startingChoice } from '../lib/native-catalog';
type Choices = NonNullable<NonNullable<SetupState['choices']>['onboarding']>;
function radioKeys(event: KeyboardEvent<HTMLDivElement>) { if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key))
return; event.preventDefault(); const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role=radio]')); const current = items.indexOf(event.target as HTMLButtonElement); const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : (current + (['ArrowLeft', 'ArrowUp'].includes(event.key) ? -1 : 1) + items.length) % items.length; items[next]?.focus(); items[next]?.click(); }
@@ -36,10 +40,17 @@ export function GuidedSetup() {
const families = nativeCatalogModels(native?.catalog?.families ?? []);
const saved = !setup?.complete && !setup?.choices?.onboarding?.manual ? setup?.choices?.onboarding : undefined;
const [step, setStep] = useState(saved?.manual || setup?.complete ? 1 : saved?.step ?? 1);
const [family, setFamily] = useState(saved?.family ?? native?.catalog?.recommendation?.family ?? native?.catalog?.families?.[0]?.family ?? '');
const initialFamily = families.find(f => f.family === family || f.sizes.some(size => size.sourceFamily === family));
const [size, setSize] = useState(saved?.size ?? initialFamily?.sizes.find(s => s.model === native?.catalog?.recommendation?.model)?.id ?? initialFamily?.sizes[0]?.id ?? '');
const [context, setContext] = useState(saved?.context ?? native?.catalog?.recommendation?.context ?? 32768);
/* Only what the person (or an earlier visit) chose is state. Until then the start is worked out from the catalog on every render: a model already
on this computer first, else the catalog's recommendation. The state used to be filled once at mount, before the catalog had arrived, and
so fell back to the first size in the list (Q2_0, a 66 GB download) while an installed model sat beside it. */
const [chosenFamily, setFamily] = useState(saved?.family ?? ''), [chosenSize, setSize] = useState(saved?.size ?? '');
const start = startingChoice(families, (native?.installed?.models ?? []).filter(m => m.ready).map(m => m.model), native?.catalog?.recommendation);
const family = chosenFamily || start?.family || native?.catalog?.recommendation?.family || families[0]?.family || '';
const size = chosenSize || start?.size || '';
/* 128K for every configuration unless the person picks another, on every card and for DeepSeek too (its pack's own limit allows it). The warning for a card
it leaves little room on is under the chooser; nothing is lowered. */
const topCardGb = Math.max(0, ...(native?.hardware?.gpus ?? []).filter(g => g.usable !== false).map(g => g.vram_gb));
const [pickedContext, setContext] = useState<number | undefined>(saved?.context), [contextTouched, setContextTouched] = useState(false);
const [vision, setVision] = useState(saved?.vision ?? true), [directory, setDirectory] = useState(saved?.directory ?? '');
const [profile, setProfile] = useState<'work' | 'performance'>(saved?.profile ?? 'work');
const [installJobId, setInstallJobId] = useState(saved?.installJobId ?? ''), [tuneJobId, setTuneJobId] = useState(saved?.tuneJobId ?? '');
@@ -49,6 +60,8 @@ export function GuidedSetup() {
const selectedFamily = families.find(f => f.family === family || f.sizes.some(size => size.sourceFamily === family)) ?? families[0];
const version = selectedFamily?.sizes.find(s => s.id === size) ?? selectedFamily?.sizes[0];
const installed = native?.installed?.models.find(m => m.model === version?.id);
const contextList = version?.multi_file ? deepseekContexts(installed?.contextMax) : native?.catalog?.contexts ?? [131072];
const context = pickedContext ?? defaultContext(contextList);
const installJob = native?.jobs.find(j => j.id === installJobId) ?? (step === 3 ? native?.jobs.find(j => j.kind === 'install' && j.state === 'running' && j.guided && j.model === version?.model) : undefined), tuneJob = native?.jobs.find(j => j.id === tuneJobId) ?? (step === 4 ? native?.jobs.find(j => j.kind === 'tune' && j.state === 'running' && j.guided && j.model === version?.id) : undefined);
const active = native?.jobs.find(j => j.state === 'running');
const ready = native?.instances.some(i => i.model === version?.id && i.status === 'ready');
@@ -141,8 +154,7 @@ export function GuidedSetup() {
setTuneJobId(tuneJob.id);
void persist(4, { tuneJobId: tuneJob.id }).catch(e => setError(e.message));
} }, [step, installJob?.id, tuneJob?.id]);
const fixedContext = version?.multi_file ? 8192 : null; // the one context DeepSeek's engine was checked at
const values = { guided: true, family: version?.sourceFamily, model: version?.model, context: fixedContext ?? context, vision: version?.images ? 'yes' : 'no', kv: 'int8', low_ram: 'auto', loadAfter: false, multiple: false, ...(directory ? { data_dir: directory } : {}) };
const values = { guided: true, family: version?.sourceFamily, model: version?.model, context, ...(contextTouched ? { contextChosen: true } : {}), vision: version?.images ? 'yes' : 'no', kv: 'int8', low_ram: 'auto', loadAfter: false, multiple: false, ...(directory ? { data_dir: directory } : {}) };
async function review() {
await perform(async () => {
setInstallJobId('');
@@ -201,7 +213,7 @@ export function GuidedSetup() {
const title = step === 1 ? 'State of the art. At home.' : step === 2 ? 'Select the model you want to run.' : step === 3 ? 'Downloading the model...' : tuneJob?.state === 'running' ? 'A little tuning. A better fit.' : 'How you would like to work with Strata?';
function changeDownloadFolder() { void perform(async () => { await advance(2); setFolderOpen(true); }); }
return <div className="setup guided-onboarding" data-step={step}>
<header className="onboard-header"><Brand variant="lockup" height={22}/>{tr(step !== 1 ? <button className="onboard-later" disabled={pending} onClick={() => void skip(false)}>{tr("Finish later")}<Icon name="chevron" size={13}/></button> : null)}</header>
<header className="onboard-header"><Brand variant="lockup" height={22}/>{tr(step === 1 ? <FirstRunLanguage/> : null)}{tr(step !== 1 ? <button className="onboard-later" disabled={pending} onClick={() => void skip(false)}>{tr("Finish later")}<Icon name="chevron" size={13}/></button> : null)}</header>
<div className="onboard-stepper" aria-label={tr("Setup progress")}>{tr(steps.map((label, i) => <div key={label} data-current={step === i + 1} data-done={step > i + 1} aria-current={step === i + 1 ? 'step' : undefined}><span>{tr(step > i + 1 ? <Icon name="check" size={12}/> : i + 1)}</span><small>{tr(label)}</small></div>))}</div>
{tr(step === 1 ? <>
<OnboardingArt />
@@ -211,7 +223,8 @@ export function GuidedSetup() {
<div className="onboard-stage-heading"><h1 ref={titleRef} tabIndex={-1} data-initial-focus>{tr(title)}</h1>{tr(step === 2 ? <p>{tr("You can always change your models later.")}</p> : step === 4 && tuneJob?.state === 'running' ? <p>{tr("We\u2019re measuring, applying your preference, and getting ready to chat.")}</p> : null)}</div>
{tr(step === 2 ? <div className="onboard-model-stage">
{tr(selectedFamily ? <>
<div className="onboard-model-top"><div className="onboard-model-picker"><span className="onboard-control-label">{tr("Model")}</span><div><span className="onboard-model-symbol"><Icon name="models" size={23}/></span><Select ariaLabel={tr("Setup model")} value={selectedFamily.family} options={families.map(f => ({ value: f.family, label: f.title }))} onChange={value => chooseNative(value, '')}/></div></div><div className="onboard-context"><span className="onboard-control-label">{tr("Conversation context")}</span><Select ariaLabel={tr("Setup conversation context")} value={String(version?.multi_file ? 8192 : context)} options={(version?.multi_file ? [8192] : native?.catalog?.contexts ?? [32768]).map(n => ({ value: String(n), label: `${n / 1024}K` }))} onChange={value => setContext(Number(value))}/></div></div>
<div className="onboard-model-top"><div className="onboard-model-picker"><span className="onboard-control-label">{tr("Model")}</span><div><span className="onboard-model-symbol"><Icon name="models" size={23}/></span><Select ariaLabel={tr("Setup model")} value={selectedFamily.family} options={families.map(f => ({ value: f.family, label: f.title }))} onChange={value => chooseNative(value, '')}/></div></div><div className="onboard-context"><span className="onboard-control-label">{tr("Conversation context")}</span><Select ariaLabel={tr("Setup conversation context")} value={String(context)} options={contextList.map(n => ({ value: String(n), label: `${n / 1024}K` }))} onChange={value => { setContextTouched(true); setContext(Number(value)); }}/></div></div>
{tr(contextIsTight(topCardGb, context) ? <p className="small muted native-spill-note"><Icon name="warning" size={16}/>{tr("This much context takes about {0} GB of graphics memory and leaves little room for the model on this card. Strata will try it; if it does not start, choose a shorter context.", [cacheGb(context).toFixed(1)])}</p> : null)}
<OnboardingMemory hardware={hardware} native={native?.hardware}/>{tr(!version || version.needsNewerEngine ? <p className="small muted">{tr("This model needs a newer engine. Update your engine or import a local GGUF for this model.")}</p> : null)}
<div className="onboard-version-heading"><strong>{tr("Choose a version")}</strong><span>{tr(version ? `${catalogVersionLabel(version)} selected` : '')}</span></div>
{tr(version?.multi_file ? <DeepSeekNote version={version} plan={plan}/> : null)}
@@ -228,7 +241,7 @@ export function GuidedSetup() {
</div> : <div className="onboard-tune-stage">
{tr(tuneJob?.state === 'running' ? <><OnboardingArt kind="tune"/><div className="onboard-tuning-status" aria-live="polite"><span className="onboard-pulse-dot"/><strong>{tr(tuneJob.summary)}</strong><small>{tr("Your choice: ")}{tr(profile === 'work' ? '20% headroom for other work' : 'Full performance')}</small><div className="onboard-progress" data-indeterminate><span /></div><button disabled={pending} onClick={() => void perform(async () => { await nativeAction(`operations/${tuneJob.id}/cancel`); })}>{tr("Stop tuning")}</button></div><OnboardingTour page={tour} onChange={setTour}/></> : <>
<OnboardingResources profile={profile}/>
<div className="onboard-resource-options" role="radiogroup" aria-label={tr("Computer resource preference")} onKeyDown={radioKeys}>{tr(([{ id: 'work', icon: 'computer', title: 'Leave 20% for my work', hint: 'Use Strata while browsing, writing or working.', badge: 'Everyday choice' }, { id: 'performance', icon: 'bolt', title: 'Use full performance', hint: 'Focus this computer on your AI.', badge: '100% priority' }] as const).map(option => <button key={option.id} role="radio" tabIndex={profile === option.id ? 0 : -1} aria-checked={profile === option.id} data-selected={profile === option.id} onClick={() => setProfile(option.id)}><span className="onboard-resource-option-top"><span className="onboard-resource-option-icon"><Icon name={option.icon} size={22}/></span><span className="onboard-version-radio">{tr(profile === option.id ? <Icon name="check" size={12}/> : null)}</span></span><strong>{tr(option.title)}</strong><p>{tr(option.hint)}</p><small>{tr(option.badge)}</small></button>))}</div>
<div className="onboard-resource-options" role="radiogroup" aria-label={tr("Computer resource preference")} onKeyDown={radioKeys}>{tr(([{ id: 'work', icon: 'computer', title: 'Leave room for my work', hint: 'Keeps 5-8 GB of memory free for other programs.', badge: 'Everyday choice' }, { id: 'performance', icon: 'bolt', title: 'Use full performance', hint: 'Focus this computer on your AI.', badge: '100% priority' }] as const).map(option => <button key={option.id} role="radio" tabIndex={profile === option.id ? 0 : -1} aria-checked={profile === option.id} data-selected={profile === option.id} onClick={() => setProfile(option.id)}><span className="onboard-resource-option-top"><span className="onboard-resource-option-icon"><Icon name={option.icon} size={22}/></span><span className="onboard-version-radio">{tr(profile === option.id ? <Icon name="check" size={12}/> : null)}</span></span><strong>{tr(option.title)}</strong><p>{tr(option.hint)}</p><small>{tr(option.badge)}</small></button>))}</div>
<details className="onboard-resource-honesty"><summary>{tr("How resource sharing works")}</summary><p>{tr("We reserve GPU memory and limit CPU expert workers to leave headroom for other apps. Actual usage varies with your model. Model RAM requirements and system safety reserves still apply.")}</p></details>
{tr(version?.images ? <label className="onboard-picture onboard-load-vision"><OnboardingCheckbox checked={vision} onChange={setVision}/><Icon name="image" size={20}/><span><strong>{tr("Read pictures when this model loads")}</strong><small>{tr("Understand photos and screenshots. Vision files are already downloaded.")}</small></span></label> : null)}
{tr(native?.capabilities.calibration ? <label className="onboard-calibration"><OnboardingCheckbox checked={calibrate} onChange={setCalibrate}/><Icon name="gauge" size={20}/><span><strong>{tr("Find the fastest settings for this PC")}</strong><small>{tr("One-time measurement \u00B7 about 5\u201310 minutes. Your computer will be busy.")}</small></span></label> : <p className="onboard-calibration-note">{tr("We\u2019ll apply your preference and run a short speed check.")}</p>)}
+12 -10
View File
@@ -1,8 +1,10 @@
import { tr } from '../lib/i18n';
import { copyText } from '../lib/clipboard';
import { CopyBtn } from './CopyBtn';
/** Safe, dependency-free rendering of the Markdown structures used in answers. */
import { Fragment, type ReactNode } from 'react';
import { Btn, IconBtn } from './ui';
import { CodePreviewBtn } from './CodePreviewBtn';
import { codePreview } from '../lib/code-preview';
import type { PreviewArtifact } from '../lib/artifacts';
import { SyntaxCode } from './SyntaxCode';
import { MathView } from './MathView';
import { displayBlock, hasMath, splitMath } from '../lib/math';
@@ -57,15 +59,13 @@ export function continuesParagraph(lines: string[], at: number): boolean {
return false;
return !displayBlock(lines, at);
}
/** Copy a code block. The toast store needs a browser, so it is loaded here, when someone copies: the renderer itself must render without one. */
async function copyCode(text: string) {
const { pushToast } = await import('../lib/store');
await copyText(text, 'Code', { toast: pushToast });
}
export function Markdown({ text, onOpenLink, onPreviewHtml, imageSession }: {
export function Markdown({ text, onOpenLink, previewId, onPreview, onClosePreview, imageSession }: {
text: string;
onOpenLink?: (url: string) => void;
onPreviewHtml?: (html: string) => void;
/** Where an HTML or Markdown code block can be shown in the sidebar: the message it belongs to, and the handlers that open and close a preview. */
previewId?: string;
onPreview?: (a: PreviewArtifact) => void;
onClosePreview?: (id: string) => void;
/** The chat whose folder a picture path in the answer is read from (the host serves only files inside it). */
imageSession?: string | null;
}) {
@@ -84,7 +84,9 @@ export function Markdown({ text, onOpenLink, onPreviewHtml, imageSession }: {
while (i < lines.length && !/^\s*```/.test(lines[i]))
body.push(lines[i++]);
i++;
nodes.push(<div className="st-code" key={key}><div className="st-code__head"><span>{language || tr('code')}</span>{tr(/^html$/i.test(language) && onPreviewHtml ? <Btn size="sm" onClick={() => onPreviewHtml(body.join('\n'))}>{tr("Open preview")}</Btn> : null)}<IconBtn name="copy" title={tr("Copy code")} onClick={() => void copyCode(body.join('\n'))}/></div><SyntaxCode text={body.join('\n')} language={language}/></div>);
const code = body.join('\n');
const preview = previewId && onPreview ? codePreview(previewId, language, code) : null;
nodes.push(<div className="st-code" key={key}><div className="st-code__head"><span>{language || tr('code')}</span><span className="st-code__actions">{tr(preview && onPreview ? <CodePreviewBtn artifact={preview} onPreview={onPreview} onClose={onClosePreview}/> : null)}<CopyBtn text={code} label="Code" title="Copy code"/></span></div><SyntaxCode text={code} language={language}/></div>);
continue;
}
const block = displayBlock(lines, i);
+143 -49
View File
@@ -3,8 +3,9 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { Screen } from './layout';
import { VersionPicker } from './VersionPicker';
import { FamilySwitch } from './FamilySwitch';
import { PcFitCard } from './PcFitCard';
import { fitOn, memoryNeeded } from '../lib/native-fit';
import { PcComputer } from './PcComputer';
import { cacheGb, fitOn, memoryNeeded, versionFit } from '../lib/native-fit';
import { contextIsTight, defaultContext, DEFAULT_CONTEXT, OLD_DEEPSEEK_CONTEXT, openingContext } from '../lib/default-context';
import { GpuRoles } from './GpuRoles';
import { fromLegacy, normalize as normalizeRoles, order as roleOrder, recommend as recommendRoles, rows as roleRows, sameRoles, vramOn, type RoleCard, type Roles } from '../lib/gpu-roles';
import { Btn, Dialog, ErrorNotice, Icon, Select } from './ui';
@@ -12,6 +13,8 @@ import { installProgress, installStage, stageText, useDownloadEta } from '../lib
import { useKept } from '../lib/kept-state';
import { DownloadParts } from './DownloadParts';
import { EngineWarning } from './EngineWarning';
import { EngineUpdateCard } from './EngineUpdateCard';
import { EngineUpdateDialog } from './EngineUpdateDialog';
import { sizeGb } from '../lib/format';
import { NativeDeleteDialog } from './NativeDelete';
import { plainFailureText } from '../lib/plain-errors';
@@ -20,7 +23,8 @@ import { go, setSettingsSection, selectRemoteModel, pushToast } from '../lib/sto
import { nativeAction, useNative, type NativeJob } from '../lib/native';
import { tuneEffective } from '../lib/native-tune';
import { tunedFields } from '../lib/tune-values';
import { draftingChoices as draftingOptions } from '../lib/drafting';
import { shownVision, steadyNoReader } from '../lib/pictures-choice';
import { draftingChoices as draftingOptions, resolveDrafter } from '../lib/drafting';
import { deepseekContexts, deepseekContextNote } from '../lib/deepseek-context';
import { NativeSettings } from './NativeSettings';
import { NativeSettingsFile } from './NativeSettingsFile';
@@ -30,7 +34,7 @@ import { useStore } from '../lib/store';
import { NativePresets } from './NativePresets';
import { NativeTune } from './NativeTune';
import { NativeTuneBadge } from './NativeTuneBadge';
import { nativeCatalogModels, catalogVersionLabel } from '../lib/native-catalog';
import { nativeCatalogModels, catalogVersionLabel, fetchesPictures } from '../lib/native-catalog';
export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine }: {
onOtherModels: () => void;
onImport?: () => void;
@@ -39,21 +43,35 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
}) {
const native = useNative(), imageLibrary = useStore(s => s.imageModels), hardware = useStore(s => s.hardware);
// The choices are kept while the page is closed (Models -> Monitor -> Models used to start them over and a person lost track of what was downloading).
const [family, setFamily] = useKept('models.family', ''), [size, setSize] = useKept('models.size', ''), [context, setContext] = useKept('models.context', 32768), [vision, setVision] = useKept('models.vision', 'yes');
const [family, setFamily] = useKept('models.family', ''), [size, setSize] = useKept('models.size', ''), [context, setContext] = useKept('models.context', DEFAULT_CONTEXT), [contextPicked, setContextPicked] = useKept('models.contextPicked', false), [vision, setVision] = useKept('models.vision', 'yes');
const manual = useStore(s => s.setup?.choices?.onboarding?.manual ?? false);
const [expert, setExpert] = useKept('models.expert', manual), [multiple, setMultiple] = useKept('models.multiple', false), [roles, setRoles] = useKept<Roles | null>('models.roles', null), [kv, setKv] = useKept('models.kv', 'int8'), [lowRam, setLowRam] = useKept('models.lowRam', 'auto'), [split, setSplit] = useKept('models.split', 'auto');
const [resourceProfile, setResourceProfile] = useKept<'work' | 'performance'>('models.profile', 'work');
const [drafting, setDrafting] = useState('auto');
const [pending, setPending] = useState(false), [error, setError] = useState(''), [plan, setPlan] = useState<Record<string, any> | null>(null), [loadedError, setLoadedError] = useState(''), [tuneOpen, setTuneOpen] = useState(false), [jobId, setJobId] = useKept('models.job', ''), [manage, setManage] = useState(false);
const [pending, setPending] = useState(false), [error, setError] = useState(''), [plan, setPlan] = useState<Record<string, any> | null>(null), [drafterGb, setDrafterGb] = useState<number | null>(null), [loadedError, setLoadedError] = useState(''), [tuneOpen, setTuneOpen] = useState(false), [jobId, setJobId] = useKept('models.job', ''), [manage, setManage] = useState(false);
const [toastedJob, setToastedJob] = useKept('models.toasted', ''), [appliedSig, setAppliedSig] = useKept('models.applied', '');
const presetPending = useRef(false);
const presetPending = useRef(false), visionPicks = useRef(new Map<string, string>()); // Read pictures per version: what the person set, or what the version opened with; later reports of the host never overwrite it
const [deleting, setDeleting] = useState<string | null>(null);
const families = nativeCatalogModels(native?.catalog?.families ?? []);
const expertSection = useRef<HTMLElement>(null);
useEffect(() => { if (expert)
expertSection.current?.scrollIntoView({ block: 'start', behavior: 'smooth' }); }, [expert]);
// The card is inserted above the fold and the browser's scroll anchoring
// immediately compensates by scrolling down, which cancelled the smooth
// scroll started in the same frame: the page stopped 121px in and clipped
// the cards above. Scroll after the layout has settled, and never animate
// when motion is reduced.
useEffect(() => {
if (!expert)
return;
const frame = requestAnimationFrame(() => expertSection.current?.scrollIntoView({
block: 'start',
behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
}));
return () => cancelAnimationFrame(frame);
}, [expert]);
const selectedFamily = families.find(f => f.family === family || f.sizes.some(size => size.sourceFamily === family)) ?? families[0], selectedSize = selectedFamily?.sizes.find(s => s.id === size) ?? selectedFamily?.sizes[0];
const installed = native?.installed?.models.find(m => m.model === selectedSize?.id);
// The sizes this model offers for the conversation context; it opens at 128K (or its largest size when that is smaller).
const contextList = selectedSize?.multi_file ? deepseekContexts(installed?.contextMax) : native?.catalog?.contexts ?? [8192, 32768, 65536, 131072, 262144];
// The model that is running is shown as running, with Unload: not offered for loading again.
const downloaded = native?.installed?.models ?? [], allSizes = families.flatMap(f => f.sizes);
// This computer for the fit figures: the biggest usable graphics card, or the ones chosen under Expert settings.
@@ -66,6 +84,8 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
const keptUse = keptModels.map(i => { const s = sizeOf(i.model); const card = pcGpus.find(g => g.index === i.gpus[0]); const f = s && pcRam ? fitOn(s, { ram_gb: pcRam, vram_gb: card?.vram_gb ?? pcVram }) : null; return { gpus: i.gpus, ram: f?.ramUseGb ?? 0, vram: f?.vramUseGb ?? 0 }; });
const keptRam = keptUse.reduce((n, u) => n + u.ram, 0);
const keptVramOn = (cards: number[]) => keptUse.filter(u => u.gpus.some(g => cards.includes(g))).reduce((n, u) => n + u.vram, 0);
// The models that are loaded now, with what each takes of graphics memory, RAM and SSD (the app's estimate from the same rule the fit uses: the engine does not report this split).
const loadedModels = (native?.instances ?? []).filter(i => i.status === 'ready' || i.status === 'loading').flatMap(i => { const s = sizeOf(i.model); if (!s || !pcRam) return []; const card = pcGpus.find(g => g.index === i.gpus[0]); return [{ id: i.model, label: `${i.label} · ${s.model}`, fit: fitOn(s, { ram_gb: pcRam, vram_gb: card?.vram_gb ?? pcVram }, true, { context: i.context ?? context, kv }) }]; });
// The versions that are really on this computer: a config whose files are all there (every part of a model in parts).
const readyIds = useMemo(() => new Set(downloaded.filter(m => m.ready).map(m => m.model)), [downloaded]);
// What a running job works on, by its name in the list: "Qwen3.8-Flash-Next · Gyro · Smallest".
@@ -90,7 +110,11 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
(j.model === selectedSize.model && j.family === selectedSize.sourceFamily));
const job = native?.jobs.find(j => j.id === jobId && selectedJob(j)) ??
native?.jobs.filter(j => ['load', 'install'].includes(j.kind) && selectedJob(j)).at(-1);
const noFit = selectedSize?.on_this_pc === 'does not fit';
// This model can read pictures but the installed engine has no picture reader: pictures run off for now, nothing is saved, and the row says nothing wordy.
// While a job runs the host is busy and its picture-reader probe can drop out for a moment: the checkbox then keeps what it showed, so the person's choice never flickers.
const noReaderNow = !!selectedSize?.imagesCapable && !selectedSize.images, noReaderSeen = useRef({ id: '', value: false });
noReaderSeen.current = steadyNoReader(noReaderNow, !!active, noReaderSeen.current, selectedSize?.id ?? '');
const noReader = noReaderSeen.current.value;
// Graphics cards (strata_control/gpu_fit.py: the one rule of the app, the Terminal and setup): the recommended option is pre-selected, the person may pick another
// A card a loaded model already uses (a second model on it usually does not fit): said on its row, and not the pre-selected one while another is free.
const usedBy = (cards: number[]) => (native?.instances ?? []).filter(i => i.status !== 'error' && i.model !== selectedSize?.id && i.gpus.some(g => cards.includes(g))).map(i => i.label);
@@ -105,18 +129,26 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
const cardsGpus: number[] = roleCards.length > 1 ? roleOrder(effectiveRoles, roleCards) : [];
const chosenCards = cardsGpus;
const fitHardware = { ram_gb: pcRam ? Math.max(0, pcRam - keptRam) : pcRam, vram_gb: Math.max(0, (roleCards.length > 1 ? vramOn(effectiveRoles, roleCards) : pcVram) - keptVramOn(chosenCards)) };
// How the chosen version sits on this computer (the same rule as the version list): the page says a word only for "SSD streaming" and "Doesn't fit".
const fitState = selectedSize ? versionFit(selectedSize, fitHardware, { context, kv }) : 'unknown';
// A layer split written for another number of cards no longer fits: back to automatic.
useEffect(() => { if (cardsGpus.length <= 1 && split !== 'auto') setSplit('auto'); }, [cardsGpus.length]);
// Drafting (which drafter guesses ahead): DFlash2 is for Qwen3.8-Flash-Next only (the qwen and gyro families), so no other model is offered it.
// Drafting (which drafter guesses ahead): a choice exists only for Qwen3.8-Flash-Next (the qwen and gyro families) on a computer whose engine can run DFlash2; every other model has no dropdown.
const draftingChoices = selectedSize ? draftingOptions(selectedSize.sourceFamily ?? selectedFamily?.family, native?.capabilities?.dflash2) : null;
// The drafter this run loads (one per run): the fit and the plan use its graphics memory (DFlash2 0.89 GB, MTP 1.08 GB).
const nowDrafter = draftingChoices ? resolveDrafter(drafting, selectedSize?.sourceFamily ?? selectedFamily?.family, native?.capabilities?.dflash2, native?.capabilities?.dflash2Split) : 'mtp';
// DFlash2 with the converted pack (the selector as Q4_0 rows) takes 175 MiB less graphics memory: the fit says the true number (Control's `selq4`, strata_control/drafting.py vram_gb).
const fitDrafter = nowDrafter === 'dflash2' && native?.capabilities?.dflash2Selq4 === true ? 'dflash2_selq4' : nowDrafter;
const draftingWords: Record<string, string> = { auto: 'Auto (recommended)', mtp: 'MTP', dflash2: 'DFlash2', off: 'Off' };
const values = useMemo(() => ({ family: selectedSize?.sourceFamily, model: selectedSize?.model, context, vision: selectedSize?.images ? vision : 'no', kv, low_ram: lowRam, resourceProfile, ...(cardsGpus.length > 1 ? { gpus: cardsGpus.join(',') } : cardsGpus.length === 1 ? { gpu: cardsGpus[0] } : {}), loadAfter: true, multiple: expert && multiple }), [selectedSize?.sourceFamily, selectedSize?.model, context, vision, kv, lowRam, resourceProfile, cardsGpus.join(','), expert, multiple]);
const downloadValues = { ...values, ...(draftingChoices ? { drafting } : {}), vision: selectedSize?.images && vision !== 'no' ? 'yes' : 'no', loadVision: selectedSize?.images ? vision : 'no' };
const downloadValues = { ...values, ...(draftingChoices ? { drafting } : {}), vision: selectedSize?.images && vision !== 'no' ? 'yes' : 'no', loadVision: selectedSize?.images ? vision : 'no', ...(noReader ? { visionUnavailable: true } : {}), ...(contextPicked ? { contextChosen: true } : {}) };
useEffect(() => { if (!native?.catalog || family)
return; const current = native.instances.find(i => i.id === native.defaultId); const rec = native.catalog.recommendation ?? { family: null, model: null }; const loadedFamily = families.find(f => f.sizes.some(s => s.id === current?.model)); const have = new Set((native.installed?.models ?? []).filter(m => m.ready).map(m => m.model)); const installedFamily = families.find(f => f.sizes.some(s => have.has(s.id))); const f = loadedFamily ?? installedFamily ?? families.find(f => f.family === rec.family || f.sizes.some(s => s.sourceFamily === rec.family)) ?? families[0]; if (f) {
setFamily(f.family);
setSize(f.sizes.find(s => s.id === current?.model)?.id ?? f.sizes.find(s => have.has(s.id))?.id ?? f.sizes.find(s => s.model === rec.model)?.id ?? f.sizes[0]?.id ?? '');
setContext(('context' in rec ? rec.context : undefined) ?? 32768);
const first = f.sizes.find(s => s.id === current?.model) ?? f.sizes.find(s => have.has(s.id)) ?? f.sizes.find(s => s.model === rec.model) ?? f.sizes[0];
setSize(first?.id ?? '');
// 128K for every configuration, DeepSeek included where its pack allows (the engine's own recommendation is not what the app opens with).
setContext(defaultContext(first?.multi_file ? deepseekContexts() : native.catalog.contexts));
} }, [native?.catalog]);
// The version that is selected: a model already on this PC shows the settings it was prepared with (so loading it never asks to download);
// a model that is not yet downloaded starts with Read pictures ON (owner rule: pictures on by default). This runs when the SELECTION or the
@@ -128,33 +160,45 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
}
if (!selectedSize)
return;
const sig = installed ? ['have', installed.model, installed.ready, installed.context, installed.kv, installed.images, installed.visionEnabled, installed.visionDevice, installed.lowRamMode, installed.drafting, installed.resourceProfile].join('|') : `new|${selectedSize.id}`;
const sig = installed ? ['have', installed.model, installed.ready, installed.context, installed.contextChosen, installed.kv, installed.lowRamMode, installed.drafting, installed.resourceProfile].join('|') : `new|${selectedSize.id}`;
if (sig === appliedSig)
return;
setAppliedSig(sig);
if (installed) {
// The context it was prepared with when someone chose it; a model that holds the old default (32K, DeepSeek's 8K) without the marker opens at the new default.
if (installed.context)
setContext(installed.context);
// Off only when the person turned it off (a saved choice); a model prepared without the picture files (images 'off') still starts with it ON.
setVision(installed.visionEnabled === false ? 'no' : installed.visionDevice ?? (installed.images && installed.images !== 'off' ? installed.images : 'yes'));
setContext(openingContext(installed.context, contextList, installed.contextChosen === true, selectedSize.multi_file ? [OLD_DEEPSEEK_CONTEXT] : undefined));
// Off only when the person turned it off (a saved choice on a model that has the picture files); a model prepared without them (images 'off', or an old "off" saved while the engine had no reader) starts with it ON.
// The first time this version is seen as installed it opens from its saved settings; from then on that value is the person's (a host report that changes while a job runs never changes it).
const known = visionPicks.current.get(selectedSize.id), value = shownVision(known, installed);
if (known === undefined)
visionPicks.current.set(selectedSize.id, value);
setVision(value);
setKv(installed.kv ?? 'int8');
setLowRam(installed.lowRamMode ?? 'auto');
setDrafting(installed.drafting ?? 'auto');
// A model never tuned has no saved choice: keep the page's own (Leave 20%) rather than flipping to Full performance.
// A model never tuned has no saved choice: keep the page's own (Leave room for my work) rather than flipping to Full performance.
if (installed.resourceProfile && !loadedSelected?.resourceProfile)
setResourceProfile(installed.resourceProfile);
}
else {
setVision('yes');
if (selectedSize.multi_file)
setContext(8192); // DeepSeek runs at the one context its engine was checked at (as in the guided setup)
setDrafting('auto'); // a model that is not installed has no saved choice: Auto (the page never carries the last model's Drafting over to another one)
setVision(shownVision(visionPicks.current.get(selectedSize.id), undefined));
if (!contextPicked) // a context the person picked stays; otherwise 128K (DeepSeek too, where its pack allows)
setContext(defaultContext(contextList));
}
}, [selectedSize?.id, installed?.model, installed?.ready, installed?.context, installed?.kv, installed?.images, installed?.visionEnabled, installed?.visionDevice, installed?.lowRamMode, installed?.resourceProfile]);
}, [selectedSize?.id, installed?.model, installed?.ready, installed?.context, installed?.contextChosen, installed?.kv, installed?.lowRamMode, installed?.resourceProfile]);
const pickVision = (next: string) => { if (selectedSize) visionPicks.current.set(selectedSize.id, next); setVision(next); };
useEffect(() => { if (jobId && job?.state === 'done' && job.id !== toastedJob && (job.kind === 'load' || job.kind === 'install')) {
setToastedJob(job.id);
selectRemoteModel(null);
pushToast('success', 'Model ready', 'Start a new chat when you’re ready.');
} }, [job?.state]);
// The picture files came without a load: a model that is loaded right now still runs without pictures until it is loaded again.
useEffect(() => { if (jobId && job?.state === 'done' && job.id !== toastedJob && job.kind === 'pictures') {
setToastedJob(job.id);
pushToast('success', 'Picture files ready', loadedSelected && !loadedSelected.vision ? 'Load the model again to read pictures.' : 'Picture reading is ready.');
} }, [job?.state]);
async function perform(action: () => Promise<unknown>) { setPending(true); setError(''); try {
await action();
}
@@ -164,13 +208,25 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
finally {
setPending(false);
} }
/* Load model: no pop-up. The press itself is the yes. The plan is read first (Control checks the free disk space); a disk that is too small is said
here, in the page, and nothing starts. Otherwise the download or the apply-and-load starts at once, with its size and notes in the page. */
// The model is on this computer and only its picture files are missing: the Load fetches those files itself (about 0.9 GB, once) and goes on; it does not run the installer again.
const picturesByLoad = fetchesPictures(selectedSize, native?.packaged);
async function load() { if (!selectedSize)
return; await perform(async () => { if (installed?.ready && context === installed.context && (!selectedSize?.images || vision === 'no' || installed.images && installed.images !== 'off') && (!installed.kv || kv === installed.kv || kv === tunedNow?.fields.kv) && (lowRam === (installed.lowRamMode ?? 'auto') || lowRam === tunedNow?.fields.lowRam)) {
const result = await nativeAction<NativeJob>('operations', { kind: 'load', args: { model: selectedSize.id, resourceProfile, vision: selectedSize?.images ? vision : 'no', multiple: expert && multiple, ...(draftingChoices ? { values: { drafting } } : {}), ...(roleCards.length > 1 ? (roles && customRoles ? { gpuRoles: effectiveRoles, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : cardsGpus.length ? { gpus: cardsGpus, gpusFromRecommendation: true, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : {}) : {}) } });
return; setPlan(null); setDrafterGb(null); await perform(async () => { if (installed?.ready && context === installed.context && (!selectedSize?.images || vision === 'no' || picturesByLoad || installed.images && installed.images !== 'off') && (!installed.kv || kv === installed.kv || kv === tunedNow?.fields.kv) && (lowRam === (installed.lowRamMode ?? 'auto') || lowRam === tunedNow?.fields.lowRam)) {
const result = await nativeAction<NativeJob>('operations', { kind: 'load', args: { model: selectedSize.id, resourceProfile, vision: selectedSize?.images ? vision : 'no', ...(noReader ? { visionUnavailable: true } : {}), ...(contextPicked && !installed.contextChosen ? { contextChosen: true } : {}), multiple: expert && multiple, ...(draftingChoices ? { values: { drafting } } : {}), ...(roleCards.length > 1 ? (roles && customRoles ? { gpuRoles: effectiveRoles, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : cardsGpus.length ? { gpus: cardsGpus, gpusFromRecommendation: true, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : {}) : {}) } });
setJobId(result.id);
}
else
setPlan(await api.post('/api/native/plan', downloadValues)); }); }
else {
const planned = await api.post<Record<string, any>>('/api/native/plan', downloadValues);
if (planned?.plan?.disk_short) {
setPlan(planned);
return;
}
setDrafterGb(planned?.drafting?.needed ? Number(planned.drafting.gb) || null : null);
const result = await nativeAction<NativeJob>('operations', { kind: 'install', args: downloadValues });
setJobId(result.id);
} }); }
/* Unload / Use in chat: a refusal (a chat is still using the model) is said here, beside the button, not far below the page. */
async function loadedAction(path: string) { setLoadedError(''); setPending(true); try {
await nativeAction(path);
@@ -181,7 +237,9 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
finally {
setPending(false);
} }
async function download() { await perform(async () => { const result = await nativeAction<NativeJob>('operations', { kind: 'install', args: downloadValues }); setJobId(result.id); setPlan(null); }); }
// The drafter note belongs to the download that is running; the not-enough-disk message belongs to the version it was said for.
useEffect(() => { if (!active) setDrafterGb(null); }, [!!active]);
useEffect(() => { setPlan(null); }, [selectedSize?.id]);
return <Screen plainHeader title={<span className="sr-only">{tr("Models")}</span>} titleDetail={<FamilySwitch families={families} selected={selectedFamily?.family} onPick={f => { setFamily(f.family); setSize((f.sizes.find(s => native?.installed?.models.some(m => m.model === s.id && m.ready)) ?? f.sizes[0])?.id ?? ''); setVision('yes'); setError(''); }}/>} actions={<Btn icon="sliders" aria-pressed={expert} aria-expanded={expert} aria-controls="native-expert-settings" onClick={() => { setExpert(v => !v); setMultiple(false); }}>{tr(expert ? 'Close expert mode' : 'Expert mode')}</Btn>}>
<div className="native-model-layout native-model-layout--fit">
<div className="stack">
@@ -189,37 +247,40 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
<section className="st-card native-choice">{tr(selectedFamily && (!selectedSize || selectedSize.needsNewerEngine) ? <><h2 className="h3">{tr("This model needs a newer engine.")}</h2><p className="small muted">{tr("Update your engine or import a local GGUF for this model.")}</p><Btn onClick={() => { setSettingsSection('Engine'); go('settings'); }}>{tr("Update engine")}</Btn><Btn variant="ghost" onClick={onImport ?? onOtherModels}>{tr("Use a local GGUF\u2026")}</Btn></> : null)}{tr(!selectedFamily ? <><h2 className="h3">{tr("Set up Strata")}</h2><p className="small muted">{tr("Connect your local installation to choose an optimized model.")}</p><Btn variant="primary" onClick={onConnectEngine ?? (() => { setSettingsSection('Engine'); go('settings'); })}>{tr("Connect engine")}</Btn>{tr(native?.error ? <p className="small inline-error">{tr(native.error)}</p> : null)}<Btn variant="ghost" onClick={onOtherModels}>{tr("Other local models")}</Btn></> : null)}
{tr(selectedSize ? <>
<div className="native-choice__head"><span className="native-choice__label">{tr("Version")}</span><span className="native-choice__tools"><NativePresets values={values} onApply={v => { const f = families.find(f => f.family === v.family || f.sizes.some(s => s.sourceFamily === v.family)); if (!f)
return; presetPending.current = true; setFamily(f.family); setSize(f.sizes.find(s => s.model === v.model && (s.sourceFamily === v.family || f.family === v.family))?.id ?? ''); setContext(v.context ?? 32768); setVision(v.vision ?? 'no'); setKv(v.kv ?? 'int8'); setLowRam(v.low_ram ?? 'auto'); }}/><Btn icon="models" onClick={() => setManage(true)}>{tr("Manage models")}</Btn></span></div>
<div className="native-version"><VersionPicker pick={native?.catalog?.recommendation} versions={allSizes} selectedId={selectedSize.id} hardware={fitHardware} downloaded={readyIds} onSelect={id => { const f = families.find(f => f.sizes.some(s => s.id === id)); if (f) { setFamily(f.family); setSize(id); setError(''); } }} onOwnGguf={() => (onImport ?? onOtherModels)()}/></div>
{tr(noFit ? <p className="small muted native-spill-note"><Icon name="disk" size={16}/>{tr("May run slower \u00B7 Model data can be read from SSD.")}</p> : null)}
return; presetPending.current = true; setFamily(f.family); setSize(f.sizes.find(s => s.model === v.model && (s.sourceFamily === v.family || f.family === v.family))?.id ?? ''); setContext(v.context ?? 32768); setVision(v.vision ?? 'no'); setKv(v.kv ?? 'int8'); setLowRam(v.low_ram ?? 'auto'); }}/><Btn icon="models" size="sm" variant="ghost" onClick={() => setManage(true)}>{tr("Manage models")}</Btn></span></div>
<div className="native-version"><VersionPicker pick={native?.catalog?.recommendation} versions={selectedFamily?.sizes ?? []} details={expert} selectedId={selectedSize.id} hardware={fitHardware} cache={{ context, kv, drafter: fitDrafter }} downloaded={readyIds} onSelect={id => { const f = families.find(f => f.sizes.some(s => s.id === id)); if (f) { setFamily(f.family); setSize(id); setError(''); } }} onOwnGguf={() => (onImport ?? onOtherModels)()}/></div>
{tr(fitState === 'ssd' ? <p className="small muted native-spill-note"><Icon name="disk" size={16}/>{tr("SSD streaming · will run slower")}</p> : fitState === 'no' ? <p className="small muted native-spill-note"><Icon name="warning" size={16}/>{tr("Doesn't fit")}</p> : null)}
{tr(selectedSize?.multi_file && deepseekContextNote(context) ? <p className="small muted native-spill-note"><Icon name="info" size={16}/>{tr(deepseekContextNote(context))}</p> : null)}
<div className="native-choice__row">
<div className="native-choice__col"><span className="native-choice__label">{tr("Conversation context")}</span><Select ariaLabel={tr("Conversation context")} value={String(context)} options={contextChoices(selectedSize?.multi_file ? deepseekContexts(installed?.contextMax) : native?.catalog?.contexts ?? [8192, 32768, 65536, 131072, 262144], context).map(n => ({ value: String(n), label: n % 1024 === 0 ? `${n / 1024}K` : `${n.toLocaleString()} tokens` }))} onChange={n => setContext(Number(n))}/></div>
<div className="native-choice__col"><span className="native-choice__label">{tr("Conversation context")}</span><Select ariaLabel={tr("Conversation context")} value={String(context)} options={contextChoices(contextList, context).map(n => ({ value: String(n), label: n % 1024 === 0 ? `${n / 1024}K` : `${n.toLocaleString()} tokens` }))} onChange={n => { setContextPicked(true); setContext(Number(n)); }}/></div>
{tr(draftingChoices ? <div className="native-choice__col"><span className="native-choice__label">{tr("Drafting")}</span><Select ariaLabel={tr("Drafting")} value={drafting} disabled={!!active} options={draftingChoices.map(v => ({ value: v, label: tr(draftingWords[v]) }))} onChange={v => setDrafting(String(v))}/></div> : null)}
<div className="native-choice__col"><span className="native-choice__label">{tr("Computer use")}</span><div className="native-resource-choice"><button aria-pressed={resourceProfile === 'work'} data-selected={resourceProfile === 'work'} onClick={() => setResourceProfile('work')}><Icon name="computer" size={20}/><span><strong>{tr("Leave 20% for my work")}</strong><small>{tr("Headroom for your other apps")}</small></span>{tr(resourceProfile === 'work' ? <Icon name="check" size={15}/> : null)}</button><button aria-pressed={resourceProfile === 'performance'} data-selected={resourceProfile === 'performance'} onClick={() => setResourceProfile('performance')}><Icon name="bolt" size={20}/><span><strong>{tr("Full performance")}</strong><small>{tr("Give Strata priority")}</small></span>{tr(resourceProfile === 'performance' ? <Icon name="check" size={15}/> : null)}</button></div></div>
<div className="native-choice__col"><span className="native-choice__label">{tr("Computer use")}</span><div className="native-resource-choice"><button aria-pressed={resourceProfile === 'work'} data-selected={resourceProfile === 'work'} onClick={() => setResourceProfile('work')}><Icon name="computer" size={20}/><span><strong>{tr("Leave room for my work")}</strong><small>{tr("Keeps 5-8 GB of memory free for other programs.")}</small></span>{tr(resourceProfile === 'work' ? <Icon name="check" size={15}/> : null)}</button><button aria-pressed={resourceProfile === 'performance'} data-selected={resourceProfile === 'performance'} onClick={() => setResourceProfile('performance')}><Icon name="bolt" size={20}/><span><strong>{tr("Full performance")}</strong><small>{tr("Give Strata priority")}</small></span>{tr(resourceProfile === 'performance' ? <Icon name="check" size={15}/> : null)}</button></div></div>
</div>
{tr(contextIsTight(fitHardware.vram_gb, context, kv) ? <p className="small muted native-spill-note"><Icon name="warning" size={16}/>{tr("This much context takes about {0} GB of graphics memory and leaves little room for the model on this card. Strata will try it; if it does not start, choose a shorter context.", [cacheGb(context, kv).toFixed(1)])}</p> : null)}
{tr(loadedSelected?.metrics?.engine?.drafting_note ? <p className="small muted native-spill-note">{tr(String(loadedSelected.metrics.engine.drafting_note))}</p> : null)}
{roleCards.length > 1 ? <GpuRoles cards={roleCards} roles={effectiveRoles} custom={customRoles} recommendedRoles={recommended.roles} vendor={engineVendor} disabled={!!active} onChange={changeRoles} onBack={() => setRoles(null)} memoryGb={selectedSize ? memoryNeeded(selectedSize) || null : null} ramGb={pcRam} spillGb={selectedSize ? fitOn(selectedSize, fitHardware).spillGb : 0} usedBy={i => usedBy([i])}/> : null}
{tr(roleCards.length > 1 && (native?.hardware?.gpus ?? []).length > roleCards.length ? <p className="small muted native-cards__note">{tr('Not used: {0} (another make of graphics card than this engine drives).', [(native?.hardware?.gpus ?? []).filter(g => !roleCards.some(r => r.index === g.index && r.vendor === g.vendor)).map(g => g.name).join(', ')])}</p> : null)}
{tr(selectedSize?.imagesCapable && !selectedSize.images ? <p className="small muted native-spill-note">{tr("This model can read pictures, but the engine installed here does not include the picture reader yet, so Read pictures is off. Update the engine in Settings → About to turn it on.")}</p> : null)}
{tr(selectedSize?.images ? <><div className="native-image-reading"><label><input type="checkbox" checked={vision !== 'no'} disabled={!selectedSize?.images || !!active} onChange={e => setVision(e.target.checked ? 'yes' : 'no')}/><span><strong>{tr("Read pictures ")}<small>{tr("On by default")}</small></strong><small>{tr("Understand photos, screenshots and charts. The picture files download automatically. Turn it off to save memory.")}</small></span></label>{tr(expert && vision !== 'no' ? <Select ariaLabel={tr("Image encoder device")} value={vision === 'yes' ? 'yes' : vision} options={[{ value: 'yes', label: 'Automatic' }, { value: 'gpu', label: 'GPU' }, { value: 'cpu', label: 'CPU' }]} onChange={setVision}/> : null)}</div>{tr(vision !== 'no' && installed?.ready && installed.visionPrepared === false ? <div className="native-vision-missing" role="status"><span>{tr("Picture reading needs a few more files that are not on this computer yet.")}</span><Btn size="sm" disabled={pending || !!active} onClick={() => void download()}>{tr("Download picture-reading files")}</Btn></div> : null)}</> : null)}
{tr(selectedSize?.images || noReader ? <><div className="native-image-reading"><label title={noReader ? tr("Needs an engine update") : undefined}><input type="checkbox" checked={!noReader && vision !== 'no'} disabled={noReader || !!active} onChange={e => pickVision(e.target.checked ? 'yes' : 'no')}/><span><strong>{tr("Read pictures")}</strong></span></label>{tr(expert && vision !== 'no' && selectedSize?.sourceFamily !== 'deepseek41' ? <Select ariaLabel={tr("Image encoder device")} value={vision === 'yes' ? 'yes' : vision} options={[{ value: 'yes', label: 'Automatic' }, { value: 'gpu', label: 'GPU' }, { value: 'cpu', label: 'CPU' }]} onChange={pickVision}/> : null)}</div>{tr(vision !== 'no' && !noReader && !active && loadedSelected?.picturesNote ? <p className="small muted native-vision-note" role="status">{tr(loadedSelected.picturesNote)}</p> : null)}</> : null)}
</> : null)}
<EngineWarning native={native}/>
<EngineUpdateCard native={native} busy={!!active}/>
<EngineUpdateDialog open={false} onClose={() => {}}/>
{tr(active ? <NativeProgress job={active} label={jobLabel(active)}/> : null)}
{tr(active && drafterGb ? <p className="small muted native-spill-note"><Icon name="info" size={16}/>{tr(nowDrafter === 'mtp' ? "Choosing MTP downloads its drafter: about {0} GB, once." : "Choosing DFlash2 downloads its drafter: about {0} GB, once.", [drafterGb])}</p> : null)}
{tr(job?.state === 'error' ? <ErrorNotice message={job.error ?? job.summary} action={tr("Retry")} onRetry={() => void load()}/> : null)}
{tr(plan?.plan?.disk_short ? <ErrorNotice message={plan.plan.disk_short}/> : null)}
{tr(error ? <ErrorNotice message={error}/> : null)}
{selectedSize && installed?.ready ? <NativeTuneBadge model={selectedSize.id} resourceProfile={resourceProfile} vision={selectedSize.images ? vision : 'no'}/> : null}
<div className="native-model-actions">{tr(loadedSelected ? <><Btn disabled={pending || !!active} onClick={() => void perform(() => nativeAction(`instances/${loadedSelected.id}/unload`))}>{tr("Unload")}</Btn>{tr(onReady ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <span className="native-ready">{tr("Ready")}</span>)}</> : onReady && native?.instances.some(i => i.status === 'ready') ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <><Btn icon="gauge" disabled={!installed?.ready || !native?.available || !!active || !selectedSize || selectedSize.needsNewerEngine} onClick={() => setTuneOpen(true)}>{tr("Tune model")}</Btn><Btn variant="primary" icon="models" disabled={pending || !native?.available || !!active || !selectedSize || !!selectedSize.needsNewerEngine} onClick={() => void load()}>{tr(pending ? 'Checking…' : installed?.ready ? 'Load model' : 'Download & load')}</Btn></>)}</div>
<div className="native-model-actions">{tr(!loadedSelected && !installed?.ready && selectedSize?.download_gb && !active ? <span className="native-download-size small muted">{tr("{0} download", [sizeGb(selectedSize.download_gb)])}</span> : null)}{tr(loadedSelected ? <><Btn disabled={pending || !!active} onClick={() => void perform(() => nativeAction(`instances/${loadedSelected.id}/unload`))}>{tr("Unload")}</Btn>{tr(onReady ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <span className="native-ready">{tr("Ready")}</span>)}</> : onReady && native?.instances.some(i => i.status === 'ready') ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <><Btn icon="gauge" disabled={!installed?.ready || !native?.available || !!active || !selectedSize || selectedSize.needsNewerEngine} onClick={() => setTuneOpen(true)}>{tr("Tune model")}</Btn><Btn variant="primary" icon="models" disabled={pending || !native?.available || !!active || !selectedSize || !!selectedSize.needsNewerEngine} onClick={() => void load()}>{tr(pending ? 'Checking…' : installed?.ready ? 'Load model' : 'Download & load')}</Btn></>)}</div>
</section>
{tr(expert && IMAGE_GENERATION_VISIBLE ? <><details className="native-generation"><summary>{tr("Image generation ")}<span className="muted">{tr("Optional")}</span></summary><RunImageModel library={imageLibrary} disabled={pending || !!active} onChange={() => { }}/></details></> : null)}
{tr(expert ? <section id="native-expert-settings" ref={expertSection} className="st-card native-execution"><h2 className="h4">{tr("Expert settings")}</h2>{tr(tunedNow ? <p className="small muted native-tuned-note">{tr("Showing the values of your tuning '{0}'.", [tunedNow.name])}</p> : null)}<label className="native-toggle"><span><strong>{tr("Run multiple models")}</strong><small>{tr("Loading another model keeps the others loaded.")}</small></span><button className="st-toggle" role="switch" aria-label={tr("Run multiple models")} aria-checked={multiple} onClick={() => setMultiple(v => !v)}/></label>{tr(cardsGpus.length > 1 ? <label className="input-field"><span>{tr("Layer split")}</span><input className="st-input" value={split} placeholder={tr("auto or 16,32")} onChange={e => setSplit(e.target.value)}/></label> : null)}<div className="native-field-grid"><label className="input-field"><span>{tr("Context memory")}</span><Select ariaLabel={tr("Native KV format")} value={kv} options={[{ value: 'int8', label: 'INT8' }, { value: 'q4_0', label: 'Q4' }, { value: 'k8v4', label: 'K8 / V4' }]} onChange={setKv}/></label><label className="input-field"><span>{tr("Low-RAM mode")}</span><Select ariaLabel={tr("Native low RAM mode")} value={lowRam} options={['auto', 'resident', 'mmap'].map(v => ({ value: v, label: v === 'auto' ? 'Automatic' : v === 'resident' ? 'Keep experts in RAM' : 'Read experts from disk' }))} onChange={setLowRam}/></label></div>{tr(installed ? <><NativeSettingsFile model={installed.model}/><NativeSettings model={installed.model}/></> : <p className="small muted">{tr("More engine parameters become available after preparing this version.")}</p>)}</section> : null)}
{tr(expert ? <section id="native-expert-settings" ref={expertSection} className="st-card native-execution"><h2 className="h4">{tr("Expert settings")}</h2>{tr(tunedNow ? <p className="small muted native-tuned-note">{tr("Showing the values of your tuning '{0}'.", [tunedNow.name])}</p> : null)}<label className="native-toggle"><span><strong>{tr("Run multiple models")}</strong><small>{tr("Loading another model keeps the others loaded.")}</small></span><button className="st-toggle" role="switch" aria-label={tr("Run multiple models")} aria-checked={multiple} onClick={() => setMultiple(v => !v)}/></label>{tr(cardsGpus.length > 1 ? <label className="input-field"><span>{tr("Layer split")}</span><input className="st-input" value={split} placeholder={tr("auto or 16,32")} onChange={e => setSplit(e.target.value)}/></label> : null)}<div className="native-field-grid"><label className="input-field"><span>{tr("Context memory")}</span><Select ariaLabel={tr("Native KV format")} value={kv} options={KV_CHOICES.map(c => ({ value: c.value, label: c.label, title: c.hint }))} onChange={setKv}/>{tr(KV_CHOICES.find(c => c.value === kv)?.hint ? <small className="muted native-kv-hint">{tr(KV_CHOICES.find(c => c.value === kv)!.hint)}</small> : null)}</label><label className="input-field"><span>{tr("Low-RAM mode")}</span><Select ariaLabel={tr("Native low RAM mode")} value={lowRam} options={['auto', 'resident', 'mmap'].map(v => ({ value: v, label: v === 'auto' ? 'Automatic' : v === 'resident' ? 'Keep experts in RAM' : 'Read experts from disk' }))} onChange={setLowRam}/></label></div>{tr(installed ? <><NativeSettingsFile model={installed.model}/><NativeSettings model={installed.model}/></> : <p className="small muted">{tr("More engine parameters become available after preparing this version.")}</p>)}</section> : null)}
</div>
<aside className="stack stack--lg"><PcFitCard gpuName={pcGpu?.name} ramGb={pcRam} vramGb={pcVram} diskGb={selectedSize?.multi_file ? selectedSize.disk_gb ?? selectedSize.download_gb : null} inUse={keptModels.length ? { ramGb: Math.round(keptRam), vramGb: Math.round(keptVramOn(chosenCards)) } : null} fit={selectedSize ? fitOn(selectedSize, fitHardware, true, { context, kv }) : null}/><section className="st-card"><h2 className="h4">{tr("Model files")}</h2><p className="small muted">{tr("Strata prepares all shards, the draft layer and shared files. Completed downloads are reused.")}</p><code className="native-path">{installed?.dataFolder ?? native?.downloadDirectory ?? native?.installed?.data_folder ?? 'Choose an installation'}</code><Btn size="sm" variant="ghost" onClick={() => { setSettingsSection('Storage'); go('settings'); }}>{tr("Storage settings")}</Btn></section><Btn size="sm" variant="ghost" onClick={onOtherModels}>{tr("Other local models\u2026")}</Btn></aside>
<aside className="stack stack--lg"><PcComputer gpuName={pcGpu?.name} ramGb={pcRam} vramGb={pcVram} vramAllGb={pcGpus.reduce((n, g) => n + g.vram_gb, 0) || undefined} loaded={loadedModels} selectedLoaded={!!selectedSize && loadedModels.some(m => m.id === selectedSize.id)} diskGb={selectedSize?.multi_file ? selectedSize.disk_gb ?? selectedSize.download_gb : null} inUse={keptModels.length ? { ramGb: Math.round(keptRam), vramGb: Math.round(keptVramOn(chosenCards)) } : null} fit={selectedSize ? fitOn(selectedSize, fitHardware, true, { context, kv }) : null}/><section className="st-card"><h2 className="h4">{tr("Model files")}</h2><code className="native-path">{installed?.dataFolder ?? native?.downloadDirectory ?? native?.installed?.data_folder ?? 'Choose an installation'}</code><div className="model-files__actions"><Btn size="sm" variant="ghost" onClick={() => { setSettingsSection('Storage'); go('settings'); }}>{tr("Storage settings")}</Btn><Btn size="sm" variant="ghost" onClick={onOtherModels} className="other-models">{tr("Other local models\u2026")}</Btn></div></section></aside>
</div>
<Dialog open={!!plan} title={tr(installed?.ready ? "Apply settings and load" : "Download and prepare")} compact onClose={() => !pending && setPlan(null)} actions={<><Btn disabled={pending} onClick={() => setPlan(null)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={pending || !!plan?.plan?.disk_short} onClick={() => void download()}>{tr(installed?.ready ? "Apply & load" : "Download & load")}</Btn></>}><p><strong>{tr(plan?.plan?.title ?? selectedFamily?.title)}</strong>{tr(installed?.ready ? null : <>{tr(" \u00B7 approximately ")}{tr(plan?.plan?.download_gb ?? selectedSize?.download_gb)}{tr(" GB")}</>)}</p>{tr(plan?.drafting?.needed ? <p className="small muted">{tr("Choosing DFlash2 also downloads its drafter: about {0} GB, once. If it does not fit in graphics memory, the model uses the MTP instead.", [plan.drafting.gb])}</p> : null)}{tr(installed?.ready ? <p className="small muted">{tr("The model files are already on this computer. Nothing will be downloaded; only the settings you chose are applied.")}</p> : null)}{tr(plan?.plan?.disk_short ? <ErrorNotice message={plan.plan.disk_short}/> : null)}{tr(installed?.ready ? <p className="small muted">{tr("Any currently loaded native models will be unloaded first.")}</p> : <><p className="small muted">{tr("Existing files are checked and reused. Any currently loaded native models will be unloaded before preparing files.")}</p><p className="small muted">{tr("Your computer may feel slower while weights load. You can stop the download and resume later.")}</p></>)}{tr(selectedSize?.images ? <p className="small muted">{tr("Vision encoder files are included. Picture reading loads only when enabled.")}</p> : null)}</Dialog>
<NativeDeleteDialog id={deleting} onClose={() => setDeleting(null)}/>
<Dialog open={manage} title={tr("Manage models")} onClose={() => setManage(false)} actions={<Btn onClick={() => setManage(false)}>{tr("Done")}</Btn>}><div className="native-manage">
<Dialog open={manage} compact title={tr("Manage models")} onClose={() => setManage(false)} actions={<Btn onClick={() => setManage(false)}>{tr("Done")}</Btn>}><div className="native-manage">
{tr(downloaded.some(m => m.ready || m.diskGb) ? <div className="native-manage-list" role="list">{tr(downloaded.filter(m => m.ready || m.diskGb).map(m => { const v = allSizes.find(x => x.id === m.model), loaded = native?.instances.some(i => i.model === m.model && i.status === 'ready'); return <div className="native-manage-row" role="listitem" key={m.model} data-selected={selectedSize?.id === m.model || undefined}>
<span className="native-manage-name"><strong>{tr(v ? `${catalogVersionLabel(v)}` : m.model_name ?? m.model)}</strong><small>{tr(v ? v.model : m.model)}{tr(m.diskGb ? ` · ${sizeGb(m.diskGb)}` : '')}</small></span>
<span className={loaded ? 'st-pill st-pill--accent' : 'st-pill'}>{tr(loaded ? 'Loaded' : 'Downloaded')}</span>
@@ -227,7 +288,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
</div>; }))}</div> : <p className="small muted native-manage-empty">{tr("No downloaded models")}</p>)}
<div className="native-manage-foot"><Btn variant="ghost" icon="folder" onClick={() => { setManage(false); (onImport ?? onOtherModels)(); }}>{tr("Use a local GGUF…")}</Btn></div>
</div></Dialog>
{selectedSize ? <NativeTune open={tuneOpen} onClose={() => setTuneOpen(false)} model={selectedSize.id} resourceProfile={resourceProfile} vision={selectedSize.images ? vision : 'no'}/> : null}
{selectedSize ? <NativeTune open={tuneOpen} onClose={() => setTuneOpen(false)} model={selectedSize.id} resourceProfile={resourceProfile} vision={selectedSize.images ? vision : 'no'} visionUnavailable={noReader}/> : null}
</Screen>;
}
/** setup's "5/7: downloading …" as a sentence a person can read. */
@@ -241,15 +302,48 @@ function installStepLabel(step?: string): string {
return step ? 'Getting things ready…' : 'Downloading and preparing model files…';
return n <= 2 ? 'Checking your computer…' : n <= 4 ? 'Getting things ready…' : n === 5 ? 'Downloading your model…' : n === 6 ? 'Preparing your model for Strata…' : 'Finishing up…';
}
/** The formats of the conversation cache and what each costs, in one line (the memory figures are from docs/DETAILS.md: Q4 half of 8-bit, K8/V4 23% less). */
const KV_CHOICES = [
{ value: 'int8', label: 'INT8', hint: 'INT8 is the standard: good quality and a medium amount of memory.' },
{ value: 'q4_0', label: 'Q4', hint: 'Q4 uses about half the memory of INT8, but is less precise on very long documents.' },
{ value: 'k8v4', label: 'K8 / V4', hint: 'K8 / V4 uses about a quarter less memory than INT8 and is more precise than Q4. Prompts are 2 to 5% slower.' },
];
/**
* What a running job is doing, and nothing else: one line with the step, one small line with the numbers when there are any, a thin bar, and Stop as a
* plain word. A soft ring breathes beside the step (transform and opacity only; still under reduced motion). The bar fills with the real percent, or
* glides while no percent is known.
*/
export function NativeProgress({ job, label }: {
job: NativeJob;
label?: string;
}) { const download = installProgress(job.result?.install); const detailsText = [job.details, job.result?.install?.engine?.details].filter(Boolean).join('\n'); const eta = useDownloadEta(job.kind === 'install' ? download : undefined, job.id); const [stopping, setStopping] = useState(false), [error, setError] = useState(''); async function stop() { setStopping(true); setError(''); try {
await nativeAction(`operations/${job.id}/cancel`);
}) {
const download = installProgress(job.result?.install), detailsText = [job.details, job.result?.install?.engine?.details].filter(Boolean).join('\n');
const eta = useDownloadEta(job.kind === 'install' ? download : undefined, job.id);
const [stopping, setStopping] = useState(false), [error, setError] = useState('');
async function stop() { setStopping(true); setError(''); try {
await nativeAction(`operations/${job.id}/cancel`);
}
catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
finally {
setStopping(false);
} }
const running = job.state === 'running', stoppable = ['install', 'load', 'pictures', 'autotune', 'recheck'].includes(job.kind);
const kindTitle = job.kind === 'install' ? (label ? tr('Preparing {0}', [label]) : 'Preparing your model') : job.kind === 'load' ? 'Loading model' : job.kind === 'pictures' ? 'Downloading the picture files' : job.kind === 'autotune' || job.kind === 'recheck' ? 'Tuning your model' : 'Engine operation';
const step = job.kind === 'install' ? (job.result?.install?.label ?? (download && /^5\//.test(job.result?.install?.step ?? '') ? stageText(installStage(job.result?.install)) : installStepLabel(job.result?.install?.step))) : job.summary;
const percent = download && Number.isFinite(download.percent) ? Math.max(0, Math.min(100, Number(download.percent))) : null;
const numbers = [percent !== null ? `${Math.round(percent)}%` : '', download && Number.isFinite(download.done_gb) && Number.isFinite(download.total_gb) ? `${download.done_gb!.toFixed(1)} / ${download.total_gb!.toFixed(1)} GB` : '', eta].filter(Boolean).join(' · ');
const words = tr(step || kindTitle);
return <section className="st-card native-progress" aria-live="polite" data-state={job.state}>
<div className="native-progress__row">
<span className="native-progress__ring" aria-hidden="true"/>
<div className="native-progress__text"><p className="native-progress__step">{words}</p>{numbers ? <p className="native-progress__numbers">{numbers}</p> : null}</div>
{stoppable && running ? <button type="button" className="native-progress__stop" disabled={stopping} onClick={() => void stop()}>{tr(stopping ? 'Stopping…' : 'Stop')}</button> : null}
</div>
{running ? <div className="native-progress__bar" role="progressbar" aria-label={words} aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent ?? undefined} data-indeterminate={percent === null || undefined}><i style={percent !== null ? { transform: `scaleX(${percent / 100})` } : undefined}/></div> : null}
<DownloadParts parts={job.result?.install?.parts}/>
{detailsText ? <details><summary className="small">{tr("Details")}</summary><pre className="small muted error-detail-text">{detailsText}</pre></details> : null}
{error ? <ErrorNotice message={error}/> : null}
</section>;
}
catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
finally {
setStopping(false);
} } return <section className="st-card native-progress" aria-live="polite"><div className="section-toolbar"><h2 className="h4">{tr(job.kind === 'install' ? (label ? tr('Preparing {0}', [label]) : 'Preparing your model') : job.kind === 'load' ? 'Loading model' : job.kind === 'autotune' || job.kind === 'recheck' ? 'Tuning your model' : 'Engine operation')}</h2>{tr(job.kind === 'install' || job.kind === 'load' || job.kind === 'autotune' || job.kind === 'recheck' ? <div className="onboard-progress-actions">{tr(eta ? <span className="small muted">{eta}</span> : null)}<Btn size="sm" disabled={stopping} onClick={() => void stop()}>{tr(stopping ? 'Stopping…' : 'Stop')}</Btn></div> : null)}</div><p className="small">{tr(job.kind === 'install' ? (job.result?.install?.label ?? (download && /^5\//.test(job.result?.install?.step ?? '') ? stageText(installStage(job.result?.install)) : installStepLabel(job.result?.install?.step))) : job.summary)}</p>{tr(download ? <><progress max={100} value={download.percent ?? 0}/><p className="xs muted">{tr(download.file === 'model files' ? 'Model files' : download.file === 'picture reader' ? 'Picture reader' : download.file === 'checking files' ? 'Checking files' : download.file === 'MTP draft layer' ? 'Draft layer' : download.file)} · {tr(download.percent ?? 0)}%{tr(Number.isFinite(download.done_gb) && Number.isFinite(download.total_gb) ? ` · ${download.done_gb!.toFixed(1)} / ${download.total_gb!.toFixed(1)} GB` : '')}</p></> : null)}<DownloadParts parts={job.result?.install?.parts}/>{tr(detailsText ? <details><summary className="small">{tr("Details")}</summary><pre className="small muted error-detail-text">{detailsText}</pre></details> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>; }
+4 -36
View File
@@ -2,11 +2,11 @@ import {localeTag} from '../lib/i18n';
import { tr } from '../lib/i18n';
import { useEffect, useState } from 'react';
import { api } from '../lib/client';
import { fetchUpdateCheck, publishUpdateCheck, useInstalledVersions, useLastUpdateCheck } from '../lib/update-check';
import { nativeAction, useNative, type NativeJob } from '../lib/native';
import { Btn, Dialog, ErrorNotice, Select } from './ui';
import { NativeProgress } from './NativeModels';
import { jobErrorText } from '../lib/native-job-diagnostics';
import { SettingsEngine } from './SettingsEngine';
export function NativeBenchmark({ open, onClose }: {
open: boolean;
onClose: () => void;
@@ -54,29 +54,8 @@ export function NativeOperations() {
at: string;
instanceId: string;
line: string;
}[]>([]), [showLogs, setShowLogs] = useState(false), [confirm, setConfirm] = useState(false);
// Opening this page shows the last check kept on this computer and makes no network call. The check runs when "Check for updates" is pressed.
const versions = useInstalledVersions();
const last = useLastUpdateCheck(versions);
const [checking, setChecking] = useState(false), [checkError, setCheckError] = useState('');
const update: 'checking' | 'available' | 'current' | 'unknown' = checking ? 'checking' : last?.engine.status === 'available' ? 'available' : last?.engine.status === 'current' ? 'current' : 'unknown';
}[]>([]), [showLogs, setShowLogs] = useState(false);
const active = native?.jobs.find(j => j.state === 'running');
async function checkForUpdates() {
setChecking(true);
setCheckError('');
try {
const data = await fetchUpdateCheck();
publishUpdateCheck(data, versions);
if (data.engine.status === 'error')
setCheckError(data.engine.message);
}
catch (e) {
setCheckError(e instanceof Error ? e.message : String(e));
}
finally {
setChecking(false);
}
}
useEffect(() => {
if (!showLogs)
return;
@@ -87,16 +66,6 @@ export function NativeOperations() {
const timer = setInterval(read, 2000);
return () => clearInterval(timer);
}, [showLogs]);
async function updateEngine() {
setError('');
try {
await nativeAction('operations', { kind: 'update-engine' });
setConfirm(false);
}
catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}
// The window cannot save a download, so the host writes the file to Downloads and says where.
async function saveLogs() {
setSaving(true);
@@ -114,13 +83,12 @@ export function NativeOperations() {
}
}
return <div className="stack stack--lg native-engine-settings">
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Update component")}</h2>{tr(update === 'available' ? <Btn size="sm" variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => setConfirm(true)}>{tr("Update available")}</Btn> : null)}{tr(update !== 'available' ? <Btn size="sm" disabled={update === 'checking'} onClick={() => void checkForUpdates()}>{tr(update === 'checking' ? 'Checking…' : 'Check for updates')}</Btn> : null)}</div><p className="small muted">{tr("Update the engine for this computer. Your models and settings are kept.")}</p>{tr(update === 'current' ? <p className="small muted">{tr("Up to date")}</p> : null)}{tr(checkError ? <p className="small inline-error" role="alert">{tr(checkError)}</p> : null)}{tr(update === 'available' && (native?.instances.length ?? 0) > 0 ? <p className="small muted">{tr("Open Models and unload the running native models first.")}</p> : null)}</section>
<SettingsEngine />
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Diagnostics")}</h2><Btn size="sm" onClick={() => setShowLogs(true)}>{tr("View logs")}</Btn></div><p className="small muted">{tr("Downloads, preparation and engine failures include their original messages here.")}</p>{tr(native?.jobs.filter(j => j.state === 'error').slice(-3).map(j => <ErrorNotice key={j.id} message={jobErrorText(j, native.jobs)} detail={j.details}/>))}</section>
<Dialog open={showLogs} wide title={tr("Engine logs")} onClose={() => setShowLogs(false)} actions={<><Btn icon="download" disabled={saving} onClick={() => void saveLogs()}>{tr(saving ? 'Working…' : 'Save logs')}</Btn><Btn variant="primary" onClick={() => setShowLogs(false)}>{tr("Close")}</Btn></>}>
<Dialog open={showLogs} wide compact title={tr("Engine logs")} onClose={() => setShowLogs(false)} actions={<><Btn icon="download" disabled={saving} onClick={() => void saveLogs()}>{tr(saving ? 'Working…' : 'Save logs')}</Btn><Btn variant="primary" onClick={() => setShowLogs(false)}>{tr("Close")}</Btn></>}>
<pre className="native-log" aria-label={tr("Engine log")}>{logs.length ? logs.map(l => `${l.at} · ${l.line}`).join(String.fromCharCode(10)) : tr('No engine messages yet.')}</pre>
{tr(saved ? <p className="small" role="status">{tr("Saved to {0}", [saved])}</p> : null)}
</Dialog>
{tr(active ? <NativeProgress job={active}/> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}
<Dialog open={confirm} compact title={tr("Update engine?")} onClose={() => setConfirm(false)} actions={<><Btn onClick={() => setConfirm(false)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => void updateEngine()}>{tr("Update")}</Btn></>}><p>{tr("The native installer updates the installation. Model files and settings are kept. Unload native models before updating.")}</p></Dialog>
</div>;
}
+1 -1
View File
@@ -29,6 +29,6 @@ export function NativePresets({ values, onApply }: {
setError('Browser storage is full. Free space before saving settings.');
return false;
} }
return <><button className="st-btn st-btn--secondary" ref={trigger} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(v => !v)}><Icon name="layers"/>{tr("Saved settings ")}<Icon name="chevron" size={12}/></button>{tr(open ? <FloatingMenu anchor={trigger.current} title={tr("Saved settings")} width={290} onClose={() => setOpen(false)}><p className="model-picker-heading">{tr("Saved settings")}</p>{tr(items.map(item => <button className="menu__item" role="menuitem" key={item.id} onClick={() => { onApply(item.values); setOpen(false); }}><Icon name="sliders"/><span>{item.name}<small>{tr(item.values.model)} · {tr((item.values.context ?? 32768) / 1024)}{tr("K")}</small></span></button>))}{tr(!items.length ? <p className="model-picker-empty">{tr("Save a setup to use it again.")}</p> : null)}<button className="menu__item" role="menuitem" onClick={() => { setOpen(false); setSave(true); setName(''); }}><Icon name="plus"/>{tr("Save current settings\u2026")}</button></FloatingMenu> : null)}<Dialog open={save} compact title={tr("Saved settings")} onClose={() => setSave(false)} actions={<><Btn onClick={() => setSave(false)}>{tr("Done")}</Btn><Btn variant="primary" disabled={!name.trim()} onClick={() => { if (persist([...items, { id: crypto.randomUUID(), name: name.trim(), values }]))
return <><button className="st-btn st-btn--ghost st-btn--sm" ref={trigger} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(v => !v)}><Icon name="layers"/>{tr("Saved settings ")}<Icon name="chevron" size={12}/></button>{tr(open ? <FloatingMenu anchor={trigger.current} title={tr("Saved settings")} width={290} onClose={() => setOpen(false)}><p className="model-picker-heading">{tr("Saved settings")}</p>{tr(items.map(item => <button className="menu__item" role="menuitem" key={item.id} onClick={() => { onApply(item.values); setOpen(false); }}><Icon name="sliders"/><span>{item.name}<small>{tr(item.values.model)} · {tr((item.values.context ?? 32768) / 1024)}{tr("K")}</small></span></button>))}{tr(!items.length ? <p className="model-picker-empty">{tr("Save a setup to use it again.")}</p> : null)}<button className="menu__item" role="menuitem" onClick={() => { setOpen(false); setSave(true); setName(''); }}><Icon name="plus"/>{tr("Save current settings\u2026")}</button></FloatingMenu> : null)}<Dialog open={save} compact title={tr("Saved settings")} onClose={() => setSave(false)} actions={<><Btn onClick={() => setSave(false)}>{tr("Done")}</Btn><Btn variant="primary" disabled={!name.trim()} onClick={() => { if (persist([...items, { id: crypto.randomUUID(), name: name.trim(), values }]))
setSave(false); }}>{tr("Save")}</Btn></>}><label className="input-field"><span>{tr("Name")}</span><input className="st-input" value={name} maxLength={80} onChange={e => setName(e.target.value)} placeholder={tr("Everyday")}/></label>{tr(items.map(item => <div className="row row--between" key={item.id}><span>{item.name}</span><Btn size="sm" variant="ghost" onClick={() => persist(items.filter(i => i.id !== item.id))}>{tr("Remove")}</Btn></div>))}{tr(error ? <p role="alert" className="small inline-error">{tr(error)}</p> : null)}</Dialog></>;
}
+12 -6
View File
@@ -7,6 +7,7 @@ import { api } from '../lib/client';
import { refreshNative, useNative, type NativeField } from '../lib/native';
import { Btn, ErrorNotice, Select } from './ui';
import { speedOptions } from '../lib/deepseek-speed';
import { draftingChoices } from '../lib/drafting';
import type { RunCard } from '../lib/native-fit';
import { NativeMcpEditor } from './NativeMcpEditor';
/** One of the speed features: what it is called, what it does, whether it is on by default, and the engine environment it writes outside its default (server/native-settings.mjs). */
@@ -114,15 +115,18 @@ export function NativeSettings({ model }: {
<summary>{tr('Native engine settings')}</summary>
{error ? <><ErrorNotice message={tr('Couldn’t load model settings.')} next={tr('Check that the engine is connected and this model is installed, then try again.')} onRetry={() => setRevision(value => value + 1)}/><details className="native-settings-diagnostic"><summary className="small">{tr('Details')}</summary><p className="small muted">{error}</p></details></> : <p className="small muted" role="status">{tr('Reading saved settings…')}</p>}
</details>;
const rows = (native?.fields ?? []).filter(f => (!f.forModel || (model.toLowerCase().includes(f.forModel) && (f.id !== 'ds_experts' || dsExperts))) && `${tr(f.label)} ${f.id} ${tr(f.group)}`.toLowerCase().includes(query.toLowerCase()));
const family = (native?.catalog?.families ?? []).find(f => f.sizes.some(s => s.id === model))?.family ?? null;
const draftingOffered = !!draftingChoices(family, native?.capabilities?.dflash2);
const rows = (native?.fields ?? []).filter(f => (!f.forModel || (model.toLowerCase().includes(f.forModel) && (f.id !== 'ds_experts' || dsExperts))) && (!f.notForModel || !model.toLowerCase().includes(f.notForModel)) && (f.id !== 'drafting' || draftingOffered) && `${tr(f.label)} ${f.id} ${tr(f.group)}`.toLowerCase().includes(query.toLowerCase()));
const switchRows = switches.filter(sw => `${tr(sw.label)} ${sw.id} ${tr('Speed')}`.toLowerCase().includes(query.toLowerCase()));
return <details className="native-settings"><summary>{tr("Native engine settings")}</summary>{tr(tuned?.changed?.length ? <div className="native-tuned"><p className="small"><strong>{tr("Your tuning '{0}' sets", [tuned.preset?.name ?? ''])}</strong></p><ul>{tuned.changed.map(c => { const d = describeChange(c); return <li key={c.flag}><span>{d.name}</span><b>{d.now || tr('off')}</b>{d.was !== null ? <small className="muted">{tr("was {0}", [d.was])}</small> : null}</li>; })}</ul></div> : null)}<p className="small muted">{tr(!original ? 'Reading saved settings… ' : '')}{tr("Settings for this model. Blank values use Strata\u2019s defaults.")}</p><div className="editor-tabs" role="tablist" aria-label={tr("Native settings editor")}>{tr(['fields', 'json', 'tools'].map(t => <button key={t} role="tab" aria-selected={tab === t} onClick={() => chooseTab(t)}>{tr(t === 'fields' ? 'Parameters' : t === 'json' ? 'JSON' : 'MCP tools')}</button>))}</div>
{tr(tab === 'fields' ? <><input className="st-input" type="search" aria-label={tr("Search native parameters")} placeholder={tr("Search parameters…")} value={query} onChange={e => setQuery(e.target.value)}/>{tr(['Responses', 'GPU & memory', 'Serving', 'Image reading', 'Tools', 'Recovery'].map(group => rows.some(f => f.group === group) ? <section className="native-settings-group" key={group}><h3 className="h4">{tr(group)}</h3>{tr(rows.filter(f => f.group === group).map(f => <label className="native-setting-row" key={f.id}><span><strong>{tr(f.label)}</strong>{tr(f.supported === false ? <small>{tr("Requires a newer Strata installation.")}</small> : f.hint ? <small>{tr(f.hint)}</small> : null)}</span><NativeFieldInput field={f} value={values[f.id]} onChange={v => change(f.id, v)}/></label>))}</section> : null))}{tr(switchRows.length ? <section className="native-settings-group"><h3 className="h4">{tr("Speed")}</h3>{tr(switchRows.map(sw => <label className="native-setting-row" key={sw.id}><span><strong>{tr(sw.label)}</strong><small>{tr(sw.hint)}</small></span><Select ariaLabel={tr(sw.label)} value={switchIsOn(sw, values.env) ? 'on' : 'off'} options={sw.default === 'on' ? [{ value: 'on', label: 'On (recommended)' }, { value: 'off', label: 'Off' }] : [{ value: 'off', label: 'Off (recommended)' }, { value: 'on', label: 'On' }]} onChange={v => changeSwitch(sw, v === 'on')}/></label>))}</section> : null)}<section className="native-settings-group"><h3 className="h4">{tr("Sampling")}</h3>{tr([['temperature', 'Temperature'], ['top_p', 'Top-p'], ['top_k', 'Top-k'], ['min_p', 'Min-p'], ['repetition_penalty', 'Repetition penalty'], ['presence_penalty', 'Presence penalty'], ['frequency_penalty', 'Frequency penalty'], ['penalty_last_n', 'Penalty window'], ['seed', 'Seed']].map(([id, label]) => <label className="native-setting-row" key={id}><span><strong>{tr(label)}</strong></span><DecimalInput ariaLabel={tr("Native {0}", [label])} value={values.sampling?.[id] ?? null} onChange={n => { const sampling = { ...values.sampling }; if (n === null) delete sampling[id]; else sampling[id] = n; change('sampling', sampling); }}/></label>))}</section></> : tab === 'json' ? <><p className="small muted">{tr("Engine arguments and STRATA environment flags are editable here. Program paths and network access remain managed by the host.")}</p><textarea className="st-input native-json" aria-label={tr("Native configuration JSON")} spellCheck={false} value={text} onChange={e => { setText(e.target.value); setNotice(''); }}/></> : <><p className="small muted">{tr("Add local programs or HTTP MCP servers. Connected tools can read or change the resources you give them access to. Enable tools in chat when you want the model to use them.")}</p><NativeMcpEditor text={mcp} onChange={value => { setMcp(value); setNotice(''); }}/><details><summary className="small">{tr("Configuration example")}</summary><pre className="native-example">{'{\n "files": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-filesystem", "/your/folder"],\n "disabled": false\n }\n}'}</pre><p className="xs muted">{tr("Saved credentials are masked. Keep the mask to preserve an existing value; type a new value to replace it.")}</p></details></>)}
{tr(tab === 'fields' ? <><input className="st-input" type="search" aria-label={tr("Search native parameters")} placeholder={tr("Search parameters…")} value={query} onChange={e => setQuery(e.target.value)}/>{tr(['Responses', 'GPU & memory', 'Serving', 'Image reading', 'Tools', 'Recovery'].map(group => rows.some(f => f.group === group) ? <section className="native-settings-group" key={group}><h3 className="h4">{tr(group)}</h3>{tr(rows.filter(f => f.group === group).map(f => <label className="native-setting-row" key={f.id}><span><strong>{tr(f.label)}</strong>{tr(f.supported === false ? <small>{tr("Requires a newer Strata installation.")}</small> : f.hint ? <small>{tr(f.hint)}</small> : null)}</span><NativeFieldInput field={f} model={model} value={values[f.id]} onChange={v => change(f.id, v)}/></label>))}</section> : null))}{tr(switchRows.length ? <section className="native-settings-group"><h3 className="h4">{tr("Speed")}</h3>{tr(switchRows.map(sw => <label className="native-setting-row" key={sw.id}><span><strong>{tr(sw.label)}</strong><small>{tr(sw.hint)}</small></span><Select ariaLabel={tr(sw.label)} value={switchIsOn(sw, values.env) ? 'on' : 'off'} options={sw.default === 'on' ? [{ value: 'on', label: 'On (recommended)' }, { value: 'off', label: 'Off' }] : [{ value: 'off', label: 'Off (recommended)' }, { value: 'on', label: 'On' }]} onChange={v => changeSwitch(sw, v === 'on')}/></label>))}</section> : null)}<section className="native-settings-group"><h3 className="h4">{tr("Sampling")}</h3>{tr([['temperature', 'Temperature'], ['top_p', 'Top-p'], ['top_k', 'Top-k'], ['min_p', 'Min-p'], ['repetition_penalty', 'Repetition penalty'], ['presence_penalty', 'Presence penalty'], ['frequency_penalty', 'Frequency penalty'], ['penalty_last_n', 'Penalty window'], ['seed', 'Seed']].map(([id, label]) => <label className="native-setting-row" key={id}><span><strong>{tr(label)}</strong></span><DecimalInput ariaLabel={tr("Native {0}", [label])} value={values.sampling?.[id] ?? null} onChange={n => { const sampling = { ...values.sampling }; if (n === null) delete sampling[id]; else sampling[id] = n; change('sampling', sampling); }}/></label>))}</section></> : tab === 'json' ? <><p className="small muted">{tr("Engine arguments and STRATA environment flags are editable here. Program paths and network access remain managed by the host.")}</p><textarea className="st-input native-json" aria-label={tr("Native configuration JSON")} spellCheck={false} value={text} onChange={e => { setText(e.target.value); setNotice(''); }}/></> : <><p className="small muted">{tr("Add local programs or HTTP MCP servers. Connected tools can read or change the resources you give them access to. Enable tools in chat when you want the model to use them.")}</p><NativeMcpEditor text={mcp} onChange={value => { setMcp(value); setNotice(''); }}/><details><summary className="small">{tr("Configuration example")}</summary><pre className="native-example">{'{\n "files": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-filesystem", "/your/folder"],\n "disabled": false\n }\n}'}</pre><p className="xs muted">{tr("Saved credentials are masked. Keep the mask to preserve an existing value; type a new value to replace it.")}</p></details></>)}
<div className="native-settings-actions"><Btn disabled={pending || !original || !dirty} variant="primary" onClick={() => void save()}>{tr(pending ? 'Saving…' : 'Save settings')}</Btn><Btn disabled={pending} onClick={() => { const saved = JSON.parse(original || '{"values":{},"mcp":{}}'); setValues(saved.values); setText(JSON.stringify(saved.values, null, 2)); setMcp(JSON.stringify(saved.mcp, null, 2)); setError(''); }}>{tr("Restore saved")}</Btn></div>{tr(notice ? <p role="status" className="small">{tr(notice)}</p> : null)}{tr(error ? <ErrorNotice message={error} onRetry={() => setRevision(value => value + 1)}/> : null)}
</details>;
}
function NativeFieldInput({ field, value, onChange }: {
function NativeFieldInput({ field, value, onChange, model }: {
field: NativeField;
model?: string;
value: any;
onChange: (v: any) => void;
}) {
@@ -131,7 +135,7 @@ function NativeFieldInput({ field, value, onChange }: {
if (field.type === 'boolean')
return <Select disabled={field.supported === false} ariaLabel={tr(field.label)} value={value === null || value === undefined ? '' : String(value)} options={[{ value: '', label: 'Default' }, { value: 'true', label: 'On' }, { value: 'false', label: 'Off' }]} onChange={v => onChange(v === '' ? null : v === 'true')}/>;
if (field.id === 'ds_experts')
return <DeepSeekSpeed field={field} value={value} onChange={onChange}/>;
return <DeepSeekSpeed field={field} model={model} value={value} onChange={onChange}/>;
if (field.type === 'enum')
return <Select disabled={field.supported === false} ariaLabel={tr(field.label)} value={value ?? ''} options={[{ value: '', label: 'Default' }, ...(field.choices ?? []).map(v => ({ value: v, label: v }))]} onChange={v => onChange(v || null)}/>;
if (field.type === 'number')
@@ -139,9 +143,11 @@ function NativeFieldInput({ field, value, onChange }: {
return <input disabled={field.supported === false} className="st-input" aria-label={tr(field.label)} placeholder={tr("Default")} value={Array.isArray(value) ? value.join(', ') : value ?? ''} onChange={e => onChange(e.target.value === '' ? null : field.type === 'array' ? e.target.value.split(',').map(s => s.trim()).filter(Boolean) : field.type === 'number' ? Number(e.target.value) : e.target.value)}/>;
}
/** DeepSeek's Speed choice. The engine decides the default itself (--experts auto); the labels name the same default (lib/deepseek-speed.ts). */
function DeepSeekSpeed({ field, value, onChange }: { field: NativeField; value: any; onChange: (v: any) => void }) {
function DeepSeekSpeed({ field, value, onChange, model }: { field: NativeField; model?: string; value: any; onChange: (v: any) => void }) {
const native = useNative();
const options = speedOptions(native?.hardware?.gpus as RunCard[] | undefined).map(o => ({ ...o, label: tr(o.label) }));
// the catalog row of this model says whether the engine gives 5 on RDNA4 cards here (one card, or several holding half of the experts)
const size = (native?.catalog?.families ?? []).flatMap(f => f.sizes ?? []).find(s => s.model.toLowerCase() === (model ?? '').toLowerCase());
const options = speedOptions(native?.hardware?.gpus as RunCard[] | undefined, size?.rdna4_faster).map(o => ({ ...o, label: tr(o.label) }));
return <Select disabled={field.supported === false} ariaLabel={tr(field.label)} value={value === null || value === undefined ? '' : String(value)}
options={options} onChange={v => onChange(v === '' ? null : v)}/>;
}
+15 -9
View File
@@ -2,10 +2,10 @@ import { tr } from '../lib/i18n';
import { useCallback, useEffect, useState } from 'react';
import { Btn, Dialog, Select } from './ui';
import { TuneRunView, TuneOutcomeCard } from './TuneRunView';
import { readOutcome } from '../lib/tune-outcome';
import { failureDetails, readOutcome } from '../lib/tune-outcome';
import { go } from '../lib/store';
import { nativeAction, useNative, type NativeJob } from '../lib/native';
import { answerNotice, tunePresetAction, tuneStatus, tuneUndo, type TuneProgress, type TunePreset, type TuneResult, type TuneStatus } from '../lib/native-tune';
import { TUNE_BLOCK_TEXT, answerNotice, tunePresetAction, tuneStartBlock, tuneStatus, tuneUndo, type TuneProgress, type TunePreset, type TuneResult, type TuneStatus } from '../lib/native-tune';
type Phase = 'idle' | 'running' | 'result';
@@ -14,12 +14,14 @@ type Phase = 'idle' | 'running' | 'result';
* with the same saved tunings); this dialog only starts it, shows its progress and result in plain words, and lists, switches,
* renames, deletes and undoes the saved tunings. It reuses the Auto-tune dialog's look.
*/
export function NativeTune({ open, onClose, model, resourceProfile, vision }: {
export function NativeTune({ open, onClose, model, resourceProfile, vision, visionUnavailable = false }: {
open: boolean;
onClose: () => void;
model: string;
resourceProfile: 'work' | 'performance';
vision: string;
/** The engine has no picture reader: the run goes without pictures and the model's own setting is left as it is. */
visionUnavailable?: boolean;
}) {
const native = useNative();
const [status, setStatus] = useState<TuneStatus | null>(null), [error, setError] = useState(''), [busy, setBusy] = useState(false);
@@ -40,7 +42,7 @@ export function NativeTune({ open, onClose, model, resourceProfile, vision }: {
async function start(kind: 'autotune' | 'recheck') {
setBusy(true); setError('');
try {
const started = await nativeAction<NativeJob>('operations', { kind, args: { model, resourceProfile, vision, mode, use, allowSmallDifferences: allowSmall, presetId: status?.active?.id } });
const started = await nativeAction<NativeJob>('operations', { kind, args: { model, resourceProfile, vision, ...(visionUnavailable ? { visionUnavailable: true } : {}), mode, use, allowSmallDifferences: allowSmall, presetId: status?.active?.id } });
setJobId(started.id);
} catch (e) { setError(e instanceof Error ? e.message : String(e)); }
finally { setBusy(false); }
@@ -57,6 +59,9 @@ export function NativeTune({ open, onClose, model, resourceProfile, vision }: {
const result = job?.state === 'done' ? (job.result as TuneResult | null) : null;
const ratio = progress && progress.runsPlanned ? Math.min(0.96, progress.runsDone / progress.runsPlanned) : 0;
const active = status?.active ?? null;
const block = tuneStartBlock({ busy, otherRunning: !!other, status, error });
// The one sentence beside a greyed-out Start tuning, in the footer so it is never below the fold of a scrolling body (the same line serves Tune again and Check again).
const why = block === 'other' || block === 'loading' || block === 'unreadable' ? <p className="small muted tune-why" role="status">{tr(TUNE_BLOCK_TEXT[block])}</p> : null;
const notice = (cls?: string, steady?: boolean) => cls ? <p className="small muted">{tr(answerNotice(cls, steady))}</p> : null;
let body;
@@ -67,10 +72,11 @@ export function NativeTune({ open, onClose, model, resourceProfile, vision }: {
} else if (phase === 'result') {
const outcome = readOutcome(result, job?.state);
const ok = outcome.tone === 'ok';
body = <div className="stack"><TuneOutcomeCard outcome={outcome} details={job?.state === 'error' ? (job.details || job.error) : null}/>{outcome.code === 'faster' ? notice(result?.answers?.class, result?.answers?.default_repeatable) : null}</div>;
body = <div className="stack"><TuneOutcomeCard outcome={outcome} details={job?.state === 'error' ? (job.details || job.error) : outcome.code === 'failed' || outcome.code === 'busy_pc' ? failureDetails(job?.result as TuneResult | null, native?.hardware) : null}/>{outcome.code === 'faster' ? notice(result?.answers?.class, result?.answers?.default_repeatable) : null}</div>;
const again = <Btn variant={ok ? undefined : 'primary'} disabled={busy || !!other} onClick={() => void start('autotune')}>{tr(outcome.code === 'faster' || outcome.code === 'already_fastest' || outcome.code === 'still_fast' ? 'Tune again' : 'Try again')}</Btn>;
actions = <>
{status?.can_undo && outcome.code === 'faster' ? <Btn disabled={busy} onClick={() => void act(async () => { await tuneUndo(model, resourceProfile, vision); setJobId(''); })}>{tr("Undo")}</Btn> : null}
{why}
<span className="tune-actions-gap"/>
{ok ? <>{again}<Btn variant="primary" onClick={() => { close(); go('chat'); }}>{tr(outcome.code === 'faster' ? 'Start chatting' : 'Close')}</Btn></> : <><Btn onClick={close}>{tr("Close")}</Btn>{again}</>}
</>;
@@ -78,13 +84,12 @@ export function NativeTune({ open, onClose, model, resourceProfile, vision }: {
const stale = active && status?.state && status.state !== 'fresh';
body = <div className="stack">
{error ? <p className="small inline-error" role="alert">{error}</p> : null}
{active ? <div><p className="strong">{tr("Saved tuning in use: {0}", [active.name])}</p>
{block === 'loading' ? <p className="small muted">{tr("Checking for saved tunings\u2026")}</p> : active ? <div><p className="strong">{tr("Saved tuning in use: {0}", [active.name])}</p>
{active.gain_pct ? <p className="small muted">{tr("+{0}% faster answers", [Math.round(active.gain_pct)])}</p> : null}
{notice(active.answers?.class, active.answers?.default_repeatable)}
{stale ? <p className="small">{status?.state === 'hard' ? tr("This PC changed since it was tuned, so these settings are not used until you tune again.") : tr("This PC or the engine changed since it was tuned. A short check takes about 2 minutes.")}</p> : null}</div>
: <p className="small muted">{tr("No saved tuning for these choices yet.")}</p>}
: status ? <p className="small muted">{tr("No saved tuning for these choices yet.")}</p> : null}
<p className="small muted">{tr("Strata tries different settings on this PC and keeps the ones that make answers faster. Your model is unloaded while this runs, and the computer will be busy.")}</p>
{other ? <p className="small inline-error" role="alert">{tr("Finish the current operation first.")}</p> : null}
<label className="input-field"><span>{tr("How long to spend")}</span><Select value={mode} ariaLabel={tr("How long to spend")} options={[{ value: 'quick', label: 'Quick (about 20 minutes)' }, { value: 'thorough', label: 'Thorough (up to 2 hours)' }]} onChange={v => setMode(v as 'quick' | 'thorough')}/></label>
<label className="input-field"><span>{tr("What you mostly do")}</span><Select value={use} ariaLabel={tr("What you mostly do")} options={[{ value: 'chat', label: 'Chat' }, { value: 'coding', label: 'Coding with long prompts' }]} onChange={v => setUse(v as 'chat' | 'coding')}/></label>
<label className="check-label"><input type="checkbox" checked={allowSmall} onChange={e => setAllowSmall(e.target.checked)}/>{tr("Allow settings that change answers very slightly (same quality)")}</label>
@@ -98,9 +103,10 @@ export function NativeTune({ open, onClose, model, resourceProfile, vision }: {
{renaming ? <div className="row"><input className="st-input" aria-label={tr("Name")} value={name} maxLength={80} onChange={e => setName(e.target.value)}/><Btn size="sm" variant="primary" disabled={!name.trim() || busy} onClick={() => void act(async () => { await tunePresetAction(renaming.id, 'rename', { name: name.trim() }); setRenaming(null); })}>{tr("Save")}</Btn><Btn size="sm" onClick={() => setRenaming(null)}>{tr("Cancel")}</Btn></div> : null}
</div>;
actions = <>
{why}
{status?.can_undo ? <Btn disabled={busy || !!other} onClick={() => void act(async () => { await tuneUndo(model, resourceProfile, vision); })}>{tr("Undo")}</Btn> : null}
{active && status?.state === 'soft' ? <Btn disabled={busy || !!other} onClick={() => void start('recheck')}>{tr("Check again")}</Btn> : null}
<Btn variant="primary" icon="gauge" disabled={busy || !!other || !status} onClick={() => void start('autotune')}>{tr(active ? "Tune again" : "Start tuning")}</Btn></>;
<Btn variant="primary" icon="gauge" disabled={block !== null} onClick={() => void start('autotune')}>{tr(active ? "Tune again" : "Start tuning")}</Btn></>;
}
return <Dialog open={open} wide={phase !== 'idle'} title={phase === 'result' && readOutcome(result, job?.state).code === 'faster' ? tr("Tuned and saved") : tr("Tune this model")} onClose={close} actions={actions}>{body}</Dialog>;
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect } from 'react';
import { api } from '../lib/client';
import { useStore } from '../lib/store';
import { PcFitCard, type PcLive } from './PcFitCard';
const tenth = (n: number | null | undefined) => typeof n === 'number' && Number.isFinite(n) ? Math.round(n * 10) / 10 : null;
/**
* "This computer" with the host's own readings: while the Models page is open the host reads the card every few seconds (the same ask the
* Monitor makes), and the free graphics memory and RAM come from those readings, not from a guess. It subscribes to the three numbers only,
* so the Models page itself does not redraw on every reading.
*/
export function PcComputer(props: Omit<React.ComponentProps<typeof PcFitCard>, 'live'> & { vramAllGb?: number }) {
const vramUsed = useStore(s => tenth(s.monitor?.hardware?.vramUsedGb));
const ramUsed = useStore(s => tenth(s.monitor?.hardware?.ramUsedGb));
const diskRead = useStore(s => tenth(s.monitor?.hardware?.diskReadGbs));
useEffect(() => {
const ask = () => { if (typeof document === 'undefined' || document.visibilityState !== 'hidden') void api.get('/api/monitor').catch(() => undefined); };
ask();
const timer = setInterval(ask, 10000);
return () => clearInterval(timer);
}, []);
const vramTotal = props.vramAllGb ?? props.vramGb;
const live: PcLive = {
vramTotalGb: vramTotal,
freeVramGb: vramUsed !== null && vramTotal > 0 ? Math.max(0, Math.round((vramTotal - vramUsed) * 10) / 10) : null,
freeRamGb: ramUsed !== null && props.ramGb ? Math.max(0, Math.round((props.ramGb - ramUsed) * 10) / 10) : null,
diskReadGbs: diskRead,
};
return <PcFitCard {...props} live={live}/>;
}
+30 -15
View File
@@ -2,28 +2,43 @@ import { tr } from '../lib/i18n';
import type { Fit } from '../lib/native-fit';
const gb = (n: number) => `${Number.isInteger(n) ? n : n.toFixed(1)} GB`;
const about = (n: number) => `${Math.round(n)} GB`;
function Meter({ label, total, use }: { label: string; total: number; use: number }) {
const pct = total > 0 ? Math.max(2, Math.min(100, (use / total) * 100)) : 0;
return <div className="pc-meter">
<div className="pc-meter__top"><span>{tr(label)}</span><b>{gb(use)}</b><span className="muted">{tr("of {0}", [gb(total)])}</span></div>
<div className="pc-meter__track" role="img" aria-label={tr("{0}: about {1} of {2} used by this model", [tr(label), gb(use), gb(total)])}><i style={{ width: `${pct}%` }}/></div>
/** What this computer has free right now (real readings of the host; null where one is not available). */
export interface PcLive { vramTotalGb?: number; freeVramGb: number | null; freeRamGb: number | null; diskReadGbs: number | null }
/** A model that is loaded: what it takes of graphics memory and RAM, and how much of it is read from the SSD (the app's estimate: the engine does not report this split). */
export interface PcLoaded { id: string; label: string; fit: Fit }
function FreeMeter({ label, total, free }: { label: string; total: number; free: number }) {
const used = total > 0 ? Math.max(0, Math.min(100, ((total - free) / total) * 100)) : 0;
return <div className="pc-meter" data-low={free / (total || 1) < 0.1 || undefined}>
<div className="pc-meter__top"><span>{tr(label)}</span><b>{tr("{0} free", [gb(free)])}</b><span className="muted">{tr("of {0}", [gb(total)])}</span></div>
<div className="pc-meter__track" role="img" aria-label={tr("{0}: {1} free of {2}", [tr(label), gb(free), gb(total)])}><i style={{ transform: `scaleX(${used / 100})` }}/></div>
</div>;
}
/**
* "This computer": the graphics memory and RAM, and what the chosen version would take of each. One plain line says whether it fits
* or how much would be read from the SSD (the n-gram table, which always sits on the SSD, is not counted).
* "This computer": how much graphics memory and RAM is free right now. While a model is loaded that is all the card shows: the hardware line, the model's
* name, "Loaded", what it takes and the SSD line are gone (owner, 11-01: the page already says what is loaded); only while the loaded model really reads
* from the SSD, how fast it reads right now. Before anything is loaded the card says what the chosen version would take. That a version does not fit in
* memory is said once, under the version picker ("SSD streaming · will run slower"), not again here. (The n-gram table, which always sits on the SSD, is not counted.)
*/
export function PcFitCard({ gpuName, ramGb, vramGb, fit, inUse = null, diskGb = null }: { gpuName?: string | null; ramGb: number | null; vramGb: number; fit: Fit | null; inUse?: { ramGb: number; vramGb: number } | null; diskGb?: number | null }) {
const freeVram = Math.max(0, vramGb - (inUse?.vramGb ?? 0)), freeRam = ramGb ? Math.max(0, ramGb - (inUse?.ramGb ?? 0)) : 0;
return <section className="st-card pc-card">
<div><h2 className="h4">{tr("This computer")}</h2>
<p className="small muted">{tr(gpuName ? tr("{0} · {1} graphics memory", [gpuName, gb(vramGb)]) : tr("No supported graphics card reported by this installation."))}{ramGb ? tr(" · {0} RAM", [gb(ramGb)]) : ''}</p></div>
{tr(fit?.known && vramGb > 0 ? <Meter label="Graphics memory" total={freeVram} use={fit.vramUseGb}/> : null)}
{tr(fit?.known && ramGb ? <Meter label="RAM" total={freeRam} use={fit.ramUseGb}/> : null)}
export function PcFitCard({ gpuName, ramGb, vramGb, fit, inUse = null, diskGb = null, live = null, loaded = [], selectedLoaded = false }: { gpuName?: string | null; ramGb: number | null; vramGb: number; fit: Fit | null; inUse?: { ramGb: number; vramGb: number } | null; diskGb?: number | null; live?: PcLive | null; loaded?: PcLoaded[]; selectedLoaded?: boolean }) {
const showFit = !!fit?.known && !selectedLoaded;
const isLoaded = loaded.length > 0;
const streamed = Math.max(0, ...loaded.map(m => (m.fit.known ? m.fit.spillGb : 0)));
return <section className="st-card pc-card" data-loaded={isLoaded ? true : undefined}>
{isLoaded ? <h2 className="h4">{tr("This computer")}</h2> : <div><h2 className="h4">{tr("This computer")}</h2>
<p className="small muted">{tr(gpuName ? tr("{0} · {1} graphics memory", [gpuName, gb(vramGb)]) : tr("No supported graphics card reported by this installation."))}{ramGb ? tr(" · {0} RAM", [gb(ramGb)]) : ''}</p></div>}
{tr(live && (live.freeVramGb !== null || live.freeRamGb !== null) ? <div className="pc-free">
<h3 className="pc-label">{tr("Free right now")}</h3>
{tr(live.freeVramGb !== null && vramGb > 0 ? <FreeMeter label="Graphics memory" total={live.vramTotalGb ?? vramGb} free={live.freeVramGb}/> : null)}
{tr(live.freeRamGb !== null && ramGb ? <FreeMeter label="RAM" total={ramGb} free={live.freeRamGb}/> : null)}
</div> : null)}
{tr(isLoaded && streamed >= 1 && live?.diskReadGbs && live.diskReadGbs >= 0.05 ? <p className="pc-note">{tr("Reading from your SSD now: {0}", [`${live.diskReadGbs.toFixed(1)} GB/s`])}</p> : null)}
{tr(inUse ? <p className="xs muted">{tr("Models that stay loaded use about {0} GB of RAM and {1} GB of this card’s memory.", [inUse.ramGb, inUse.vramGb])}</p> : null)}
{tr(showFit && !isLoaded && fit!.vramUseGb + fit!.ramUseGb > 0 ? <p className="pc-note">{tr("This version would take about {0} of graphics memory and {1} of RAM.", [about(fit!.vramUseGb), about(fit!.ramUseGb)])}</p> : null)}
{tr(showFit && diskGb && fit!.spillGb >= 1 ? <p className="pc-note">{tr("About {0} GB will be read from the SSD, so answers can be a little slower.", [Math.round(fit!.spillGb)])}</p> : null)}
{tr(diskGb ? <p className="pc-note">{tr("Needs about {0} GB of free disk space. What does not fit in memory is read from your SSD.", [Math.round(diskGb)])}</p> : null)}
{tr(fit?.known && (diskGb ? fit.spillGb >= 1 : true) ? <p className="pc-note">{tr(fit.spillGb >= 1 ? 'About {0} GB will be read from the SSD, so answers can be a little slower.' : 'Fits fully in memory.', [Math.round(fit.spillGb)])}</p> : null)}
</section>;
}
+3 -10
View File
@@ -1,17 +1,10 @@
import { tr } from '../lib/i18n';
const NOTES = [
'Strata sends no usage telemetry.',
'Conversations stay in browser storage.',
'When you choose an API model, that conversation and enabled skill instructions are sent to its provider.',
'The UI request log keeps client, timing and token metadata in memory.',
'Native API monitoring can retain request content in memory when enabled.',
'Connected tools access the resources you configure.',
];
/** What Strata collects and where things go: one compact paragraph (the six sentences read together, translated as one in every language). */
const NOTES = 'Strata sends no usage telemetry. Conversations stay in browser storage. When you choose an API model, that conversation and enabled skill instructions are sent to its provider. The UI request log keeps client, timing and token metadata in memory. Native API monitoring can retain request content in memory when enabled. Connected tools access the resources you configure.';
/** What Strata collects and where things go: plain lines of text, nothing else. */
export function PrivacyNotes() {
return <div className="privacy-notes" aria-label={tr("Collection and logs")}>
{NOTES.map(text => <p key={text}>{tr(text)}</p>)}
<p>{tr(NOTES)}</p>
</div>;
}
+47
View File
@@ -0,0 +1,47 @@
/** A question the agent asked the person, in the style of the approval card: options to pick, the person's own words, one answer or a skip (#4244). */
import { useState } from 'react';
import { tr } from '../lib/i18n';
import { Btn, Icon } from './ui';
import { answerAgentAsk } from '../lib/store';
import { plainFailureText } from '../lib/plain-errors';
import { answerComplete, toAnswers, type AgentAsk, type AgentQuestion } from '../lib/agent-ask';
type Draft = Record<string, { selected: string[]; custom: string }>;
export function QuestionCard({ sessionId, ask }: { sessionId: string; ask: AgentAsk }) {
const [draft, setDraft] = useState<Draft>({});
const [busy, setBusy] = useState(false), [problem, setProblem] = useState('');
const current = (q: AgentQuestion) => draft[q.id] ?? { selected: [], custom: '' };
const pick = (q: AgentQuestion, label: string) => setDraft(d => {
const cur = d[q.id] ?? { selected: [], custom: '' };
const selected = q.multiSelect ? (cur.selected.includes(label) ? cur.selected.filter(x => x !== label) : [...cur.selected, label]) : [label];
// One choice for a single-select question: picking an option replaces words typed instead.
return { ...d, [q.id]: { selected, custom: q.multiSelect ? cur.custom : '' } };
});
const type = (q: AgentQuestion, custom: string) => setDraft(d => {
const cur = d[q.id] ?? { selected: [], custom: '' };
return { ...d, [q.id]: { selected: q.multiSelect || !custom.trim() ? cur.selected : [], custom } };
});
async function send(skip: boolean) {
if (busy) return;
setBusy(true); setProblem('');
try { await answerAgentAsk(sessionId, ask.callId, skip ? null : toAnswers(ask.questions, draft)); }
catch (e) { setProblem(plainFailureText(e instanceof Error ? e.message : String(e)).message); setBusy(false); }
}
return <section className="askcard askq" role="group" aria-label={tr("The assistant has a question for you")}>
{ask.questions.map(q => {
const cur = current(q);
return <div className="askq__item" key={q.id}>
<div className="askcard__top"><span className="askcard__icon"><Icon name="chat" size={18}/></span>
<div className="askcard__text">{q.header ? <p className="xs muted">{q.header}</p> : null}<p className="askcard__q">{q.question}</p>{q.detail ? <p className="small muted askq__detail">{q.detail}</p> : null}</div></div>
{q.options.length ? <div className="askq__opts" role={q.multiSelect ? 'group' : 'radiogroup'} aria-label={q.question}>{q.options.map(o => {
const on = cur.selected.includes(o.label);
return <button type="button" key={o.label} role={q.multiSelect ? 'checkbox' : 'radio'} aria-checked={on} className="askq__opt" data-on={on || undefined} disabled={busy} onClick={() => pick(q, o.label)}><span>{o.label}</span>{o.description ? <small className="muted">{o.description}</small> : null}</button>;
})}</div> : null}
<input className="st-input askq__own" aria-label={tr("Type your own answer")} placeholder={tr("Type your own answer")} value={cur.custom} maxLength={2000} disabled={busy} onChange={e => type(q, e.target.value)}/>
</div>;
})}
{problem ? <p className="small inline-error askq__problem" role="alert">{problem}</p> : null}
<div className="askq__acts"><Btn size="sm" disabled={busy} onClick={() => void send(true)}>{tr("Skip")}</Btn><Btn variant="primary" size="sm" disabled={busy || !answerComplete(ask.questions, draft)} onClick={() => void send(false)}>{tr("Send answer")}</Btn></div>
</section>;
}
+101
View File
@@ -0,0 +1,101 @@
import { useState } from 'react';
import { localeTag, tr } from '../lib/i18n';
import { useNative } from '../lib/native';
import { changeInstalledEngine, engineMake, engineSize, modelsNeedingNewer, useEngineList, type InstalledEngine } from '../lib/engines';
import { ApiError } from '../lib/client';
import { Btn, Dialog, ErrorNotice, Icon } from './ui';
/** Why this computer cannot run an engine: Control gives a code and its values, the page words it (so every language has it). */
function unfitWords(engine: InstalledEngine): string {
const a = engine.why_args ?? [];
switch (engine.why_code) {
case 'os': return tr("This engine was made for a different operating system.");
case 'gpu_make': return tr("This engine was made for a different kind of graphics card than this computer has.");
case 'strata': return tr("This engine needs Strata {0} or newer.", [a[0] ?? '']);
case 'gpu_arch': return tr("This engine doesn't support this computer's graphics card.");
case 'driver': return tr("This engine needs NVIDIA driver {0} or newer. This computer has {1}.", [a[0] ?? '', a[1] ?? '']);
case 'files': return tr("Some of this engine's files are missing or changed, so it can't be started.");
default: return tr("This engine's description is missing or damaged, so it can't be started.");
}
}
/** Settings > Engine: the engines installed on this computer. Switch to any of them (the newest too) or delete one. Updating is in About. */
export function SettingsEngine() {
const native = useNative();
const { list, failed, reload } = useEngineList();
const [use, setUse] = useState<InstalledEngine | null>(null);
const [del, setDel] = useState<InstalledEngine | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState<{ message: string; detail?: string } | null>(null);
const [said, setSaid] = useState('');
const running = (native?.instances.length ?? 0) > 0;
const busy = !!native?.jobs.some(j => j.state === 'running') || pending;
const groups = list?.families ?? [];
const count = groups.reduce((sum, g) => sum + g.engines.length, 0);
async function run(action: 'use' | 'remove', engine: InstalledEngine) {
setPending(true);
setError(null);
setSaid('');
try {
await changeInstalledEngine(action, engine);
setSaid(action === 'use' ? tr("Engine {0} is now in use.", [engine.version]) : tr("Engine {0} was deleted.", [engine.version]));
} catch (e) {
const body = e instanceof ApiError ? e.body as { details?: string } | undefined : undefined;
setError({ message: e instanceof Error ? e.message : String(e), detail: body?.details });
} finally {
setUse(null);
setDel(null);
setPending(false);
void reload();
}
}
function row(engine: InstalledEngine) {
const facts = [engineMake(engine.backend), engineSize(engine.bytes, localeTag()), engine.newest ? tr("Newest") : ''].filter(Boolean).join(' · ');
return <div className="engine-row" key={`${engine.root ?? ''}${engine.folder}`} data-active={engine.active ? 'true' : undefined} data-unfit={engine.fits ? undefined : 'true'}>
<Icon name="engine" size={20}/>
<span className="engine-row__text">
<strong>{engine.version}</strong>
<small>{facts}</small>
{engine.fits ? null : <small className="engine-row__why">{unfitWords(engine)}</small>}
</span>
{engine.active ? <span className="engine-row__state">{tr("In use")}</span> : <>
{engine.can_use ? <Btn size="sm" disabled={busy} onClick={() => { setError(null); setSaid(''); setUse(engine); }}>{tr("Use this")}</Btn> : null}
<Btn size="sm" variant="ghost" disabled={busy || !engine.can_delete} title={engine.blocked_code === 'running' ? tr("A running program is using this engine. Unload your models first.") : undefined} onClick={() => { setError(null); setSaid(''); setDel(engine); }}>{tr("Delete")}</Btn>
</>}
</div>;
}
const needNewer = use ? modelsNeedingNewer(native, use) : [];
return <div className="stack stack--lg native-engine-settings">
<section className="st-card engine-list">
<div className="section-toolbar"><h2 className="h3">{tr("Installed engines")}</h2></div>
<p className="small muted">{tr("Switch to an older engine to test something, then switch back to the newest. Updating to the latest is in About.")}</p>
{list && !list.available ? <p className="small muted">{tr("Engines can be switched and deleted in the installed Strata app.")}</p> : null}
{failed ? <p className="small inline-error" role="alert">{tr("The installed engines couldn't be read. Try again in a moment.")}</p> : null}
{list?.available && count === 0 ? <p className="small muted">{tr("No engine is installed on this computer yet. Update in About downloads the right one.")}</p> : null}
{groups.map(group => <div key={group.family} className="engine-group">
{groups.length > 1 ? <h3 className="engine-group__title">{tr(group.title)}</h3> : null}
{group.engines.map(row)}
</div>)}
{count > 0 ? <p className="small muted engine-total">{tr("Engines take {0} on this computer.", [engineSize(list?.total_bytes ?? 0, localeTag())])}</p> : null}
{count > 0 ? <details className="native-settings-diagnostic engine-details"><summary className="small">{tr("Details")}</summary>{groups.flatMap(g => g.engines).map(e => <p key={`${e.root ?? ''}${e.folder}`} className="small muted">{e.version} · {e.path}{e.build_id ? ` · ${e.build_id}` : ''}</p>)}</details> : null}
<div role="status" aria-live="polite">{said ? <p className="small">{said}</p> : null}</div>
{error ? <ErrorNotice message={error.message} detail={error.detail}/> : null}
</section>
<Dialog open={!!use} compact title={tr("Use engine {0}?", [use?.version ?? ''])} onClose={() => { if (!pending) setUse(null); }}
actions={<><Btn disabled={pending} onClick={() => setUse(null)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={pending || running} onClick={() => use && void run('use', use)}>{pending ? tr("Working…") : tr("Use engine {0}", [use?.version ?? ''])}</Btn></>}>
<p>{tr("Models you start from now on run on engine {0}. Your models and settings stay as they are. You can switch back at any time.", [use?.version ?? ''])}</p>
{needNewer.length ? <p className="small muted">{tr("These models need a newer engine and may not start on it: {0}.", [needNewer.join(', ')])}</p> : null}
{running ? <p className="small muted" role="status">{tr("A model is running. Unload it in Models first, then switch.")}</p> : null}
</Dialog>
<Dialog open={!!del} compact title={tr("Delete engine {0}?", [del?.version ?? ''])} onClose={() => { if (!pending) setDel(null); }}
actions={<><Btn disabled={pending} onClick={() => setDel(null)}>{tr("Keep engine")}</Btn><Btn variant="danger" disabled={pending} onClick={() => del && void run('remove', del)}>{pending ? tr("Working…") : tr("Delete engine")}</Btn></>}>
<p>{tr("This frees {0}. Your models and settings are kept.", [engineSize(del?.bytes ?? 0, localeTag())])}</p>
<p className="small muted">{del?.newest ? tr("The newest engine can be downloaded again with Update in About.") : tr("Strata can only download the newest engine, so a deleted older engine can't be brought back.")}</p>
</Dialog>
</div>;
}
+9 -17
View File
@@ -1,11 +1,12 @@
import {localeTag} from '../lib/i18n';
import { tr } from '../lib/i18n';
import { useEffect, useRef, useState } from 'react';
import { Btn, Dialog, Icon, Select } from './ui';
import { Btn, Dialog, Icon } from './ui';
import { useStore } from '../lib/store';
import type { SoftwareUpdate, UpdateCheckResult } from '../lib/types';
import { nativeAction, useNative } from '../lib/native';
import { NativeProgress } from './NativeModels';
import { EngineUpdateDialog } from './EngineUpdateDialog';
import { jobErrorText } from '../lib/native-job-diagnostics';
import { appVersionLabel } from '../lib/app-version';
import { fetchUpdateCheck, publishUpdateCheck, useInstalledVersions, useLastUpdateCheck } from '../lib/update-check';
@@ -21,8 +22,6 @@ export function SettingsUpdates() {
const [confirm, setConfirm] = useState('');
const appVersion = appVersionLabel(useStore(s => s.setup?.app.version), native?.version);
const engineVersion = useStore(s => s.engine?.engine.version);
const [rollback, setRollback] = useState(false);
const [component, setComponent] = useState<'strata' | 'engine'>('strata');
const [checking, setChecking] = useState(false);
// Opening Settings shows the last check kept on this computer. The network is touched only when "Check for updates" is pressed.
const versions = useInstalledVersions();
@@ -31,15 +30,15 @@ export function SettingsUpdates() {
const [error, setError] = useState('');
const request = useRef<AbortController | null>(null);
useEffect(() => () => request.current?.abort(), []);
const installed = component === 'strata' ? appVersion : native?.available ? (native.installed?.engine?.version ?? 'Not installed') : shownEngineVersion(native, engineVersion);
const active = native?.jobs.find(j => j.state === 'running');
const result = checking || error ? null : (fresh ?? last);
// Update engine with a model loaded: its own dialog (warns naming the model, stops it with the person's yes, offers to start it again).
const withModel = (native?.instances.length ?? 0) > 0 && !!native?.capabilities.componentUpdates;
async function operate(kind: string) {
setError('');
try {
await nativeAction('operations', { kind });
setConfirm('');
setRollback(false);
}
catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -80,23 +79,16 @@ export function SettingsUpdates() {
<div className="settings-versions" aria-busy={checking}>
{tr(versionRow('Strata', appVersion, result?.strata))}
{tr(versionRow('Engine', native?.available ? (native.installed?.engine?.version ?? 'Not installed') : shownEngineVersion(native, engineVersion), result?.engine))}
<div className="settings-update-actions"><Btn size="sm" icon="refresh" disabled={checking} className={checking ? 'settings-update-checking' : undefined} onClick={() => void check()}>{tr(checking ? 'Checking…' : error || result?.strata.status === 'error' ? 'Try again' : result ? 'Check again' : 'Check for updates')}</Btn><Btn size="sm" variant="ghost" onClick={() => setRollback(true)}>{tr("Roll back\u2026")}</Btn></div>
<div className="settings-update-actions"><Btn size="sm" icon="refresh" disabled={checking} className={checking ? 'settings-update-checking' : undefined} onClick={() => void check()}>{tr(checking ? 'Checking…' : error || result?.strata.status === 'error' ? 'Try again' : result ? 'Check again' : 'Check for updates')}</Btn></div>
<div className="settings-update-inline" role="status" aria-live="polite">{tr(checking ? 'Checking for new versions…' : error ? <span className="inline-error">{tr(error)}</span> : result ? <><span className="sr-only">{tr(result.strata.message)} {tr(result.engine.message)} </span>{tr("Checked at ")}{tr(checkedLabel(result.checkedAt))}</> : null)}</div>
</div>
<Dialog open={rollback} compact title={tr("Roll back a version")} onClose={() => setRollback(false)} actions={<Btn onClick={() => setRollback(false)}>{tr("Done")}</Btn>}>
<div className="settings-update-dialog">
<label className="input-field"><span>{tr("Software")}</span><Select ariaLabel={tr("Software to manage")} value={component} options={[{ value: 'strata', label: 'Strata' }, { value: 'engine', label: 'Engine' }]} onChange={setComponent}/></label>
<div className="settings-update-installed"><span>{tr("Installed version")}</span><strong>{tr(installed ?? 'Unavailable')}</strong></div>
<div className="settings-update-status" role="status"><Icon name="info" size={18}/><div><strong>{tr(component === 'engine' && native?.capabilities.rollback ? 'Previous engine available' : 'No saved previous version')}</strong><p>{tr(component === 'engine' && native?.capabilities.rollback ? 'The native installer can restore its previous engine. Model files stay in place.' : 'Rollback requires a previous engine saved by a version of Strata that supports rollback.')}</p></div></div>
<Btn variant="primary" disabled={component !== 'engine' || !native?.capabilities.rollback || !!active || (native?.instances.length ?? 0) > 0} onClick={() => void operate('rollback')}>{tr("Restore previous engine")}</Btn>
</div>
</Dialog>
{tr(active ? <NativeProgress job={active}/> : null)}{tr(native?.jobs.filter(j => j.state === 'error' && ['update-strata', 'update-engine', 'rollback'].includes(j.kind)).slice(-1).map(j => <p role="alert" key={j.id} className="small inline-error">{tr(jobErrorText(j, native.jobs))}</p>))}
<Dialog open={!!confirm} compact title={tr(confirm === 'update-strata' ? 'Update Strata?' : 'Update engine?')} onClose={() => setConfirm('')} actions={<><Btn onClick={() => setConfirm('')}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => void operate(confirm)}>{tr("Update")}</Btn></>}><p>{tr("The native installer updates the installation. Model files and settings are kept. Unload native models before updating.")}</p>{tr((native?.instances.length ?? 0) > 0 ? <p className="small muted">{tr("Open Models and unload the running native models first.")}</p> : null)}</Dialog>
{tr(active ? <NativeProgress job={active}/> : null)}{tr(native?.jobs.filter(j => j.state === 'error' && ['update-strata', 'update-engine'].includes(j.kind)).slice(-1).map(j => <p role="alert" key={j.id} className="small inline-error">{tr(jobErrorText(j, native.jobs))}</p>))}
<EngineUpdateDialog open={confirm === 'update-engine' && withModel} onClose={() => setConfirm('')}/>
<Dialog open={!!confirm && !(confirm === 'update-engine' && withModel)} compact title={tr(confirm === 'update-strata' ? 'Update Strata?' : 'Update engine?')} onClose={() => setConfirm('')} actions={<><Btn onClick={() => setConfirm('')}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => void operate(confirm)}>{tr("Update")}</Btn></>}><p>{tr("The native installer updates the installation. Model files and settings are kept. Unload native models before updating.")}</p>{tr((native?.instances.length ?? 0) > 0 ? <p className="small muted">{tr("Open Models and unload the running native models first.")}</p> : null)}</Dialog>
</>;
}
/** "Strata 0.2.8 · Engine 0.1.54": the app's own version first, the engine's beside it (About). */
/** "Strata 0.2.10 · Engine 0.1.56": the app's own version first, the engine's beside it (About). */
export function AboutVersions() {
const native = useNative();
const app = appVersionLabel(useStore(s => s.setup?.app.version), native?.version);
+16 -2
View File
@@ -1,6 +1,8 @@
import { tr } from '../lib/i18n';
import { useRef, useState } from 'react';
import { Btn, Dialog, type IconName } from './ui';
import { api } from '../lib/client';
import { plainFailureText } from '../lib/plain-errors';
import { compactTokens, elapsed } from '../lib/chat-metrics';
import type { Session } from '../lib/types';
type Agent = NonNullable<Session['agents']>[number];
@@ -11,17 +13,29 @@ const states: Record<Agent['status'], {
working: { label: 'In progress', icon: 'clock' }, completed: { label: 'Completed', icon: 'check' },
failed: { label: 'Failed', icon: 'error' }, queued: { label: 'Queued', icon: 'clock' }, cancelled: { label: 'Cancelled', icon: 'x' },
};
export function SubagentRow({ agent: a, now = Date.now() }: {
export function SubagentRow({ agent: a, now = Date.now(), sessionId }: {
agent: Agent;
now?: number;
/** The chat the helper belongs to: with it the row can take a message for the helper (#4749). */
sessionId?: string;
}) {
const [log, setLog] = useState(false);
const [writing, setWriting] = useState(false), [draft, setDraft] = useState(''), [busy, setBusy] = useState(false), [sent, setSent] = useState(false), [problem, setProblem] = useState('');
// A working helper gets it at its next step (steer); one that has finished or waits gets a new turn with it (queue).
async function send() {
const message = draft.trim();
if (!message || !sessionId || busy) return;
setBusy(true); setProblem(''); setSent(false);
try { await api.post('/api/chat/helper', { sessionId, helperId: a.id, message, delivery: a.status === 'working' ? 'steer' : 'queue' }); setDraft(''); setSent(true); }
catch (e) { setProblem(plainFailureText(e instanceof Error ? e.message : String(e)).message); }
finally { setBusy(false); }
}
const observedAt = useRef(Date.now());
const duration = a.durationMs + (a.status === 'working' ? Math.max(0, now - (a.updatedAt ?? observedAt.current)) : 0);
const state = states[a.status] ?? states.queued;
return <div className="subagent-row" data-status={a.status}>
<span className="subagent-row__dot" aria-label={tr(state.label)}/>
<div className="subagent-row__copy"><div className="subagent-row__title"><strong title={a.name}>{a.name}</strong><span className="subagent-row__status">{tr(state.label)}</span></div><p title={a.description}>{a.description}</p>{tr(a.status === 'failed' ? <button className="text-action" onClick={() => setLog(true)}>{tr("View log")}</button> : null)}</div>
<div className="subagent-row__copy"><div className="subagent-row__title"><strong title={a.name}>{a.name}</strong><span className="subagent-row__status">{tr(state.label)}</span></div><p title={a.description}>{a.description}</p>{tr(a.status === 'failed' ? <button className="text-action" onClick={() => setLog(true)}>{tr("View log")}</button> : null)}{tr(sessionId && a.status !== 'queued' && a.status !== 'cancelled' ? <>{writing ? <form className="subagent-row__reply" onSubmit={e => { e.preventDefault(); void send(); }}><input className="st-input" value={draft} maxLength={8000} aria-label={tr("Message to {0}", [a.name])} placeholder={tr("Write to {0}\u2026", [a.name])} disabled={busy} onChange={e => { setDraft(e.target.value); setSent(false); }}/><Btn size="sm" variant="primary" type="submit" disabled={busy || !draft.trim()}>{tr("Send")}</Btn></form> : <button className="text-action" onClick={() => setWriting(true)}>{tr("Message this helper")}</button>}{tr(sent ? <p className="small subagent-row__note" role="status">{tr("Sent to {0}.", [a.name])}</p> : null)}{tr(problem ? <p className="small inline-error subagent-row__note" role="alert">{problem}</p> : null)}</> : null)}</div>
<div className="subagent-row__metrics"><span>{tr(compactTokens(a.tokens))}{tr(" tok")}</span><span>{tr(elapsed(duration))}</span></div>
<Dialog open={log} title={tr("{0} \u00B7 log", [a.name])} onClose={() => setLog(false)} actions={<Btn onClick={() => setLog(false)}>{tr("Done")}</Btn>}><p>{a.description}</p><p className="small muted">{tr("Status: ")}{tr(state.label)} · {tr(elapsed(a.durationMs))} · {tr(compactTokens(a.tokens))}{tr(" tokens")}</p></Dialog></div>;
}
Loaded 100 of 967 files, more files were not shown because too many files have changed in this diff. Show more