Compare commits

...
303 Commits
Author SHA1 Message Date
Niko1221 146df3c468 setup.py run(): the app's installer also runs the bundled Mac preparation tools in metal/tools (metal_repack.py, stream_experts.py); a fresh Mac Terminal or app install of UD-Q4_K_XL or UD-IQ4_XS-v31 stopped with "only runs its bundled preparation tools" since af1ffd913. Test: they run, and -m, another interpreter and other folders are still refused 2026-11-07 01:29:36 +01:00
Niko1221 9780a8c27d Merge main (54828c7ef): four sponsors in Loyal supporters, all 8 READMEs 2026-11-06 21:53:35 +01:00
Niko1221 311926416a Merge f-0212-tools (ea765a571): tool-call fixes in serve/ and the native gateway, with their tests and docs 2026-11-06 21:53:34 +01:00
Niko1221 0fe4ae5090 qsa_select tp_select: no __match_any_sync below sm_70 (a plain add per thread there; integer atomics, the same sums): the CUDA 12 release engine (sm_60..89) builds again 2026-11-06 21:39:07 +01:00
Niko1221 ea765a5717 App gateway: an unknown model name on /v1/messages uses the one loaded model (Claude Desktop sends claude-5-5-sonnet); chat and Responses stay exact, and with several models loaded the 400 stays (#5312) 2026-11-06 19:55:58 +01:00
Niko1221 c49f198c77 Test: the Anthropic route serves the loaded model whatever name the request carries (#5312, claude-5-5-sonnet) 2026-11-06 19:51:51 +01:00
Niko1221 b1541ab245 Parser: the prefixed-name and misspelt-opener checks look only at the end of the buffer (the new text and 256 characters before it), so a long call is not rescanned with every token (#5409, #5276) 2026-11-06 19:48:21 +01:00
Niko1221 8a42a9457d moe_iq4_w4a8: the device side compiles for sm_60..75 targets (a trap there, as moe_fused_iq.cu does; arch_ok() already declines those cards, so no behaviour changes): the CUDA 12 and CUDA 13 release engines build again 2026-11-06 19:42:58 +01:00
Niko1221 05506f9532 Tool names: a namespaced MCP tool written by its last segment (get_asset_info for mcp__assets__get_asset_info) is that tool when exactly one declared tool ends that way (#5281) 2026-11-06 19:40:40 +01:00
Gabriel A. Devenyi 95381770f8 Anthropic tool results follow tool_use_id: results sent in the order the tools finished are put in call order before the template pairs them by position (PR #4832) 2026-11-06 19:36:59 +01:00
Gabriel A. Devenyi d7e555bdbb tool_choice "required" with several tools: write only "<tool_call>\n", because from a written "<function=" the model went on with names no tool has and the reply came back as text (PR #4828) 2026-11-06 19:36:43 +01:00
Niko1221 75acfca321 Config undeclared_tool_calls (env STRATA_UNDECLARED_TOOL_CALLS): "call" returns a well-formed call of a tool the request did not declare as a tool call; the default "text" is as before (#4622) 2026-11-06 19:36:04 +01:00
Niko1221 63fd759227 Tool calls: a misspelt opener (<toolcall>, <tool-call>) followed by <function=NAME> of a declared tool opens the call, where a <tool_call> would; prose and code stay text (#5276) 2026-11-06 19:34:47 +01:00
Niko1221 54828c7efb README: Loyal supporters adds themactep, macintog, bisonbet and vizv (public GitHub sponsors) in all 8 languages 2026-11-06 19:33:46 +01:00
Niko1221 68ab15ddc1 Tool names: <function=tools NAME> (a space for the dot) is the declared tool NAME, and a bare <function=tools> is the one declared tool that takes the call's parameters (with tool_call_recovery); anything else stays text (#5409) 2026-11-06 19:32:59 +01:00
Niko1221 7be42963e6 Version 0.2.12 / engine 0.1.58: Terminal and app 0.2.12, Control and every engine package 0.1.58 (the 0.2.11 bump list, plus the v0.1.58 line in the setup.py version history) 2026-11-06 19:21:19 +01:00
Niko1221 5b1728358b test_download_integrity: a server that keeps serving the wrong file ends with the file kept as .bad (the dlsha behaviour: the new mismatch line, the file not deleted) 2026-11-06 17:43:19 +01:00
Niko1221 706efc5b0f prefill_fused_iq_test: skip the Q5_0 down pairs where the native kernels do not cover them (CUDA has none; the gfx12 / gfx1151 builds run them); --only=NAME still insists 2026-11-06 16:29:26 +01:00
Niko1221 0a0e4463ea file tier: the re-check after the RAM copy counts only the page cache the group can really have (on by default; STRATA_FILE_TIER_HONEST=0 turns it off)
I/O path only: the same bytes are read, only HOW the experts outside the RAM copy are read (unbuffered or through the file cache) is chosen differently.

file_tier_unbuffered took its `avail` from cgroup_available_bytes, which credits the group's whole `file` counter as reclaimable. On a RAM-budget
config that credit includes pages the engine holds itself: P2003 (RTX 3060, 46 GB, 36 GiB group), UD-IQ4_XS-v31, 22 GiB RAM copy: the re-check read 18.7 GiB
available when the group had 11 GiB left (limit 36, current 24.8). With held = 0.52 >= 0.5 the timed drive probe was skipped and the file tier switched to the file
cache (direct reads 7.9 GB/s vs 2.5 GB/s cached): prompts 2.5-3x slower (pp2k 85 vs 273 tok/s, pp8k 195 vs 580), decode 10-14% slower. The one start that read
12.0 GiB kept the fast mode, which is why the first start of a session was sometimes fast.

Now the smaller of that figure and the conversation cache's sampler (only the clean inactive file cache is credited, the group's own limit, no operator
cap) decides. The --memory-limit-mib cap is not applied: it bounds the engine's own allocations, not the page cache (counting it sent IQ3_S unbuffered and cut its decode
2-3x). A flip now prints once: "strata: expert reads now go through the file cache (X GiB of RAM free for them, Y GiB limit)"; the technical line is behind STRATA_TRACE.
STRATA_UNBUFFERED_LOAD still forces either mode.

Measured on P2003 (build wip11, n >= 3 starts per arm, interleaved fresh engines, I/O-quiet before each start, memory watch on, floor 14336 MiB):
 v31 unforced: main flipped in 5 of 6 starts, this change 0 of 9 (6 + 3): pp2k 85-94 -> 263-273 tok/s, pp8k 187-198 -> 580-590, code_py decode +14 to +20%;
 v31 DFlash2 vs MTP in the kept state (3 rounds): DFlash2 +2 to +14% per kind (json_extract -3.5%);
 IQ3_S: the file cache stays (9 of 9 starts), decode level with main (code_py 17.0 vs 15.6 tok/s, json_extract 14.2 vs 13.4, page cache evicted before every start);
 low RAM (6-7 GiB ballast, budget 16 GiB): no regression, MemAvailable minimum 14911 MiB;
 memory minimum with the unbuffered state 15.6-15.8 GB (floor 14.3 GB), no arm stopped.
Not P1.8: main and the P1.8 build flip alike.

(cherry picked from commit be3831e9d944fb44ac36d176f4764e30df0f9f6b)
2026-11-06 15:51:21 +01:00
Niko1221 e77bd43bc9 prefill: a Q5_0 down (UD-IQ4_XS-v31: 33 of 48 layers) on the fused int8 kernel on gfx12, opt-in STRATA_PF_FUSED_Q50=1
native_w11_kernel takes a Q5_0 down block (d, qh, qs[16]; 22 bytes): the 5-bit codes are rebuilt with the qh bits and kept as the signed int8 q - 16 (no minimum
term), the scale is d.  d_covered_on() says yes for T_Q5_0 only on gfx12 with the switch; the layer otherwise leaves the fused path for the gather + FP16
dequantize + hipBLASLt route as before.  hip_prefill_fused_iq gets the pairs IQ3_XXS / Q5_0 and IQ4_XS / Q5_0 (reference-checked: the build has no Q5_0 MMQ).

R9700 (p620, v31, DFlash2, same executable with and without the switch, 3 rounds x 2 reps): prompt 2K +44%, 8K +40%, 32K +13%; decode unchanged.
First-token KL against the FP16 route (self-variation 0): 2K mean 0.016, max 0.068, top-1 24/24.  Off by default in this commit.

(cherry picked from commit 97c3033f566d27ea1d741d7b893ac13e0f4e8526)
2026-11-06 15:48:47 +01:00
Niko1221 37b5cf0db2 gfx12: the start-up line for the length-gated chunked GDN scan, and STRATA_GFX12_DEFAULTS=0 turns the gate off
"strata: RDNA4 (gfx12): prompt chunks of 30,000+ tokens use the chunked GDN scan (STRATA_GDN_CHUNKED=0 turns it off)" is printed once at start with the
other gfx12 default lines (owner's wording, 11-06): only on gfx12, only while STRATA_GDN_CHUNKED is unset and STRATA_GFX12_DEFAULTS is not 0.
The gate follows the gfx12 defaults' one off switch, so the line is true whenever it is printed.

(cherry picked from commit b2d1680a864195383697559edb9b6cf8180b9a19)
2026-11-06 15:47:44 +01:00
Niko1221 c0e7a6f4f8 gfx12: the chunked GDN scan is the default for prompt chunks of 30,000 tokens or more
STRATA_GDN_CHUNKED unset on a gfx12 card (RDNA4): the FP16 matrix-core chunked recurrence runs for prompt chunks of 30,000 tokens or more
(--prefill auto takes chunks up to 32,768 there, so a 32K prompt is one chunk of about 31,996 rows).  Shorter chunks keep the kernels they
ran, so every prompt up to 20K is bit for bit what it was.  STRATA_GDN_CHUNKED=0 turns it off, =1 is the old opt-in (every chunk of 64
tokens or more), STRATA_GDN_CHUNKED_MIN=N moves the line.  Owner OK 11-06.

R9700 (p620, IQ3_S, DFlash2, interleaved pairs against the same tree without the change, 5 rounds x 2 reps): prompt 32K +6.1% (5/5),
64K +6.7% (5/5).  First-token KL against the unchanged build (self-variation 0.00000): all 24 prompts of 2K, 8K, 20K and the 8K agent turn
identical; 32K 0.00005, 32K+ 0.00004, 64K 0.00086; top-1 equal on all 30 prompts.  No log line yet (wording is with the coordinator).

(cherry picked from commit 7b579367aaa56978232cb60ef405987a199d736f)
2026-11-06 15:47:44 +01:00
Niko1221 645b9177e3 setup: wording for the kept .bad file (NEEDS OWNER APPROVAL): the mismatch line says the file was kept, and says when the server sends the very same bytes; one line when the kept file is removed for room
(cherry picked from commit ae3c3bad63c23b2b51c72f232832d2db195ef72c)
2026-11-06 15:47:44 +01:00
Niko1221 071e4dc51e setup: a downloaded file with the wrong SHA-256 is kept as <name>.bad instead of deleted; the next run hashes it again and takes it back if it matches, a good new download or too little room removes it (visible text unchanged in this commit)
(cherry picked from commit e547295924d5cb560febafca6d1cbadeed33d2b9)
2026-11-06 15:47:44 +01:00
bulhenvaster bca7931f3d peer tier: STRATA_ADAPT_OVERLAP=1 with --peer-device (the peer's copies land while the next window runs)
In peer mode the adaptive overlap was refused, so every decode window first waited for the previous round's copies. With the overlap the peer
tier issues its copies in layer order, signals each layer's landing the way the primary does (a stream-ordered D2H copy of the layer number into
a pinned int, or host functions), its residency table takes the swaps at the window's start (flip_pending), and a layer waits for a tier's copies
only when that tier swapped in it (AdaptGate: peer_landed, peer_has, per-tier last). The wait before a window clears the peer's marks with the
primary's. Without the variable nothing changes.

Ported from #2720 (bulhenvaster), rebased by hand onto the current gate (the watchdog label stays). Not ported: STRATA_PEER_LAUNCH_IN_POOL
(the author measured no gain on his build), the new decode timing line and the documentation paragraph.

(cherry picked from commit e78b64a7348d685dfd11799fdb395661f2120436)
2026-11-06 15:47:43 +01:00
bulhenvaster 2b9f05093d prefill: STRATA_PF_PEER_CPU=1 (opt-in) runs the CPU share beside a peer GPU
With --peer-device the prompt path never gave the CPU pool a share (cpu_maybe required !m.pp), so a short read at long context streamed every
expert neither card holds over the primary's link while the CPU sat idle. With the switch the share runs beside the peer: it takes only experts
neither card computes, its rows go between the primary's and the peer's block (the peer's stays the tail its combine reads from back_at), and the
staged chunks go up to cpu_share_max()'s 3072 tokens. Without the switch nothing changes.

Ported from #2721 (bulhenvaster), rebased by hand onto the current prompt path (the default-armed share's limit and the routed-stream gate).

(cherry picked from commit c8b6c43c1f2653636d15fe14abf53112357570aa)
2026-11-06 15:47:43 +01:00
sergqwer 24c6c2f682 Admit the PCIe share's fetched misses to the GPU cache (opt-in STRATA_ADAPT_FETCH=1|2)
Decode reads missed expert blobs from host RAM three ways; the PCIe share's
blobs cross into VRAM anyway and were dropped after the window. With the
switch set, the share takes each layer's most-routed misses and a fetched
expert routed enough more than the layer's least-routed resident expert that
the window does not route is copied straight into that expert's slot
(fetch_blobs gets an optional dst2 target per blob). =2 leaves the tier's own
swaps out of a layer where an admission ran. STRATA_ADAPT_FETCH_VERIFY=1 reads
every admitted slot back. Off, with a log line saying why, where it cannot run.
Unset changes nothing.

Ported from PR 2964 (sergqwer) onto f-0216-core; the strata generate verify
loop was merged by hand (main's speculative-q lines kept) and the DETAILS.md
note became a row of the opt-in switch table.

l620 (RTX PRO 5000, IQ3_S, 3,896-slot cache, --pcie-frac 0.25): decode =2 +10.2%,
=1 +5.0% (4 rounds); token forcing over 14,167 tokens: mismatch 2.73% vs 2.53% /
2.53% for two unset arms (+0.19 pt, 90% CI -0.02..+0.42), PASS.

(cherry picked from commit b2bfaef906acee4f86bdf9f052aee6a4d295fb4e)
2026-11-06 15:47:43 +01:00
bitaccesscomau 386f9959f1 Exact two-pass QSA prompt selection (opt-in STRATA_SELECT_TWO_PASS=1)
Prompt-path block scores in single-pass TF32 with a per-block error bound,
3xTF32 rescoring only near each query's top-k cut-off, so qsa_block_topk
returns the same cells in the same order, ties included. Off unless the
variable is set; says once where it cannot run (HIP, Metal, SYCL, layer-major
path). Adds qsa_two_pass_test, qsa_two_pass_status_test and the GPU parity
test qsa_two_pass_parity.

Ported from PR 4119 (bitaccesscomau); the log line of the per-prompt counters
is shortened without dropping a field.

l620 (sm_120, IQ3_S), greedy ids identical off vs on in every pair:
prompt 128K +1.65% (n=12), 256K +4.10% (n=8), 32K -0.14% (noise).

(cherry picked from commit bbf7c55f1351d913233250fe7869642f7c2b98a1)
2026-11-06 15:47:43 +01:00
Niko1221 f281e3fe26 force_check.py: absolute path for the profile copy
(cherry picked from commit 1fb1e134809f6015c72802f19a99d70a1b1636db)
2026-11-06 15:47:43 +01:00
Niko1221 f46e88d610 force_check.py: cfg profile_src gives each engine start a fresh copy of the expert profile
(cherry picked from commit e47164c8e9d4d0ae0826d10177cc4d74369ec0bf)
2026-11-06 15:47:43 +01:00
Niko1221 d486fbe7d6 serve: STRATA_FOLLOW / STRATA_FOLLOW_LOGPOS token forcing through the real decode/verify windows (test and measurement only, off by default); force_check.py score --path decode
(cherry picked from commit af68878f0cc4c5aa4618da8f96b10f9262230721)
2026-11-06 15:47:43 +01:00
Niko1221 65909b2d0f verify: STRATA_VERIFY_PROFILE and STRATA_ROUTE_STATS reports cover windows up to 16 rows (measurement only; default behaviour unchanged); tools/force_check.py: the token-forcing check (scorer driver + the paper's two rules)
(cherry picked from commit b3b8c1d5a088c998947895a3a4007138b085da66)
2026-11-06 15:47:43 +01:00
Niko1221 cae562fa01 verify: STRATA_FORCE_FAULT=<m>:<bias> test knob for the token-forcing scorer (adds a bias to every m-th token's logit in the STRATA_LOGPOS rows; off by default, silent)
(cherry picked from commit dcb0483f06810f397cfdce9c1db976d8e67e2a24)
2026-11-06 15:47:43 +01:00
Niko1221 63f063852f prefill: UD-IQ4_XS-v31's Q5_0 down layers (33 of 48, with IQ3_XXS or IQ4_XS gate/up) take the fused F16 matrix-core kernel on gfx1151 under Windows instead of leaving the fused path for gather + FP16 dequantize + hipBLASLt. The Q5_0 block decode (qh bits spread, int8 codes q - 16, scale d) is the RDNA4 patch's; native_f16_kernel and the FP16 down rows are the K-quant experts' route. On by default there; STRATA_PF_FUSED_Q50=0 or STRATA_GFX1151_DEFAULTS=0 turns it off (the layers keep the earlier route), =1 turns it on for any gfx1151 build; it needs the F16 prompt set (STRATA_PF_F16). Other GPUs are untouched. Parity test pairs IQ3_XXS / Q5_0 and IQ4_XS / Q5_0 against the FP32 dequant reference, with 256/64, 33/16, 1/16, 130/32 and 517/32 token/expert cases: rel RMS 2.7e-3 against the int8-rounded model, 1.4-1.5e-2 against FP32 (the IQ pairs' usual level)
(cherry picked from commit 12f9547785781f77da41d6d08c2f541077de2df0)
2026-11-06 15:47:42 +01:00
Niko1221 cc3f9142e3 Auto on the 12 GB / streaming CUDA class: IQ3_S starts the MTP, UD-IQ4_XS-v31 starts DFlash2 with the tuned pack at the class's own 0.8
drafting.CLASS_DEFAULTS: one removable entry per set, read from the signals the confidence already reads (a CUDA card whose engine slots are under the last-but-one CONF_STEPS step).
IQ3_S: on P2003 the file cache makes the MTP faster than DFlash2 by 12-44% (RTX2 t4b). UD-IQ4_XS-v31: DFlash2 beats the MTP by about 6.5% (t8, 3 starts; DF-VAL forced-fast tuned 0.8 x1.037).
The v31 entry DEPENDS ON RTX2's FILE-TIER FIX (unbuffered reads) BEING IN THE SAME RELEASE; without it remove that entry.
Anything chosen by name wins. Resident CUDA, Strix, gfx1201, Arc and Metal entries and the conf tables are unchanged. The sm_86 support line reads the set: UD-IQ4_XS-v31 gets the existing
'faster on some kinds of text and slower on others' line, every other model keeps 'slower'.

(cherry picked from commit 42412d42ff835d6a66a78fbf6d119911908eda1b)
2026-11-06 15:47:42 +01:00
Niko1221 f138653938 DFlash2 on Strix Halo: the tuned pack for UD-IQ4_XS-v31 too, at Strix's current confidence
One more SET_DEFAULTS line for the measured cell (DF-VAL, setup's config, budget 38, 3 rounds: tuned 0.35 against stock 0.35 x1.046, 3 of 3 rounds, every kind 3 of 3).

(cherry picked from commit c2b801157e71a503da859b66baff12fff28cd299)
2026-11-06 15:47:42 +01:00
Niko1221 ce6bbd7d54 DFlash2 on Intel Arc: the tuned bf16-selector pack for SYCL + IQ3_XXS at Arc's own confidence 0.6
The SYCL 0.2.11 engine reads only bf16 selector codebooks, so Arc gets its own tuned file (qwen3.8-flash-next-dflash2.strata1.q4_0-bf16sel.pack, 522,458,112 bytes, own SHA-256 pin) through
SetDefault.pack. Only IQ3_XXS, the set measured on the A750 (tuned 0.6 x1.054, 3 of 3 rounds); other Arc sets keep the stock pack. SYCL never gets the selq4 file, CUDA / HIP / Metal never the
bf16sel one. NOTICE names the second file and no longer overclaims about the text.

(cherry picked from commit 6111c583b70e3c88a3d54039e13d76b19f15742d)
2026-11-06 15:47:42 +01:00
Niko1221 aba4ddaca7 Auto on HIP gfx1201 starts the MTP until the DFlash2 prompt stall is fixed (one entry: drafting.AUTO_MTP)
11 of 50 engines on 0.2.11 never got past the prompt on this card class. Only Auto changes: DFlash2 chosen by name, a saved choice and the person's own options still win; gfx1151, other AMD, CUDA, Metal and SYCL and the confidence tables are unchanged. The support line under the Drafting dropdown says it in plain words (app catalog entry in all nine languages).

(cherry picked from commit 8b4398743aa67368d72277310bbe8093bbe4923c)
2026-11-06 15:47:42 +01:00
Niko1221 31928fbe34 DFlash2: Strata's tuned drafter pack on CUDA (every expert resident) and Strix Halo, at their current confidence
SET_DEFAULTS entries carry a card condition (tuned_applies: the CONF_STEPS first-step slots or the Spark's shared memory for CUDA, gfx1151 for HIP); no entry names a conf, so
conf_for / CONF_STEPS / BACKEND_CONF run as before and only the pack changes. Enabled only for the cells measured at the shipped conf (CUDA-resident IQ3_S and UD-IQ4_XS-v31, Strix IQ3_S, UD-Q4_K_XL, UD-IQ4_XS).
12 GB and 24 GB cards, several cards, gfx1201, other AMD and SYCL never fetch or use it. Fetch keeps tuned, then stock, then MTP; silent offline; feed backends are asked per start's backend.

(cherry picked from commit f096dcd750b5928ace6f9eca1e2936b9e35578d4)
2026-11-06 15:47:42 +01:00
Niko1221 10831eceb2 Metal build: stubs for the f-0216-cpu 512-bit entry points (q2_rows_512_ok, iq512f_supported, iq512_fused_gu_rows, iq512_reference_gu_rows, q50_512_down_rows) 2026-11-06 09:57:08 +01:00
Niko1221 7604725595 arm64 portable build: stubs for cpu_vnni_ok, q2_rows_512_ok and iq512_reference_gu_rows (the Spark link failed on them) 2026-11-06 09:51:02 +01:00
Niko1221 2a8217c0ce generate: the CPU expert kernel line reads 'CPU expert kernels: <X>' (owner's short wording) 2026-11-06 09:44:37 +01:00
Niko1221 a7cc8faa5d split: one constant for the expert window floor, and the IQ3_S pool test
EXPERT_WINDOW_SLOTS (2 * MAXT * TOPK) is the slots a size pool must hold for one window's picks to have a place; ExpertCache's check, the residency
planner's floor and the arena's keep all read it. expert_residency_test now also covers the GSQ-RCO IQ3_S set's real pools (7, two of them a single
layer), a sweep of RAM targets against a copy of the old global ranking as the negative control, a refusal beyond what the floors allow, and that
the card's sliced 16-byte-padded strides partition the layers like the whole-expert bytes do. Class E (placement only).
2026-11-06 09:36:37 +01:00
MIDHAT NASHAR 20885f7af0 split: retain a window in every expert pool
Mixed-quant models can fail to load in the split engine after cold-expert offload: the global residency ranking can leave one size pool below the
2 * MAXT * TOPK slots Up::expert_cache() needs even while other pools can still give up experts. Group experts by both the gate/up and the
down byte strides, keep the cold-first ranking but skip eviction from a pool that has reached the window floor, count only the extra evictions
that really happen, and say so before the model is uploaded when the request cannot be placed.

Ported from #5158 (midhatn) unchanged; the tests and the engine check follow in the next commit.
2026-11-06 09:36:37 +01:00
Niko1221andhavok2-htwo abf2953be0 native_dense: STRATA_HC_Q8 copies only the stage's own Q8_0 hyper-connection rows on a trimmed layer split (P1.2)
With --trim-stage-weights every later stage's HC_Q8 loop over the GGUF copied the rows of all 48 layers and read its own. The loop now skips a
tensor outside the stage's layer range, the filter the upload loop above it and the bf16-pack path below it already use. The Q8_0 rows copied
and the bytes not copied are recorded in the run manifest (STRATA_MANIFEST). STRATA_HC_Q8_ALL_STAGES=1 copies every layer as before.
No value changes: a stage never reads another stage's layers; without a trim the range is unset and nothing is skipped. Class E.

Co-authored-by: havok2-htwo <[email protected]>
2026-11-06 09:36:37 +01:00
Niko1221 78c5a834e3 Merge f-0216-dfship into f-0216-all 2026-11-06 09:36:34 +01:00
Niko1221 51548e5539 Merge f-0216-strix2 into f-0216-all 2026-11-06 09:36:34 +01:00
Niko1221 7597e383cf Merge f-0216-rtx into f-0216-all 2026-11-06 09:36:30 +01:00
Niko1221 f2eedfe9f2 Merge f-0216-cpu into f-0216-all 2026-11-06 09:36:30 +01:00
Niko1221 bd0e878f42 Merge f-0216-core into f-0216-all 2026-11-06 09:36:29 +01:00
Niko1221 4084979c95 prefill: STRATA_PF_ALLOC_TRACE=1 prints the free-memory figure around Prefill::init's allocations against what the code asked for (stager ring, routing tables, PLE rows, GEMM scratch, the chunk's buffers, library warm-up). Off by default, no behaviour change 2026-11-06 08:56:14 +01:00
Niko1221 530a29ea2d serve: on an integrated GPU with a page-locked expert complement (Strix Halo), the free-VRAM startup line and the request-start warning no longer advise a larger --vram-reserve-mib (it cannot raise that figure: the driver counts pinned host memory and the prompt path's buffers about twice, so the cache giving up experts to the complement only lowers it). New startup line: "N MiB of graphics memory reported free. On this PC the graphics chip shares the system memory, so this figure reads low, and a larger --vram-reserve-mib won't raise it. If decode drops to a few tok/s, lower --max-context." New request-start note, printed once: "Only N MiB of graphics memory reported free at the start of this request. On this PC the figure reads low because the graphics chip shares the system memory. If decode drops to a few tok/s, lower --max-context." Every other GPU keeps the earlier text. Wording only: greedy ids identical to engI on agent8k, agent, pp4k and code_py (UD-Q4_K_XL, setup config) 2026-11-06 08:56:13 +01:00
Niko1221 3e8d3b2940 Mac: DFlash2 is the default drafter for UD-Q4_K_XL and UD-IQ4_XS-v31, with Strata's tuned drafter at --draft-conf 0.7
SET_DEFAULTS is the one per-(backend, set) table (Metal only; IQ3_S stays on the MTP). Setup, strata launch and strata drafting fetch fetch the tuned pack through the signed feed
(pinned size and SHA-256, resumable, silent offline, the standard drafter and then the MTP as fallbacks). Apache-2.0 licence and the modified-by-Strata notice ship in
data/dflash2-tuned and are copied beside the file. The Metal 'not tested' line is replaced for these sets.
2026-11-06 07:52:31 +01:00
Niko1221 039cecc5a5 feed: a drafters member for DFlash2 drafter files Strata hosts (pack-drafter, build-feed --drafters, make_local_feed --drafter) 2026-11-06 07:52:31 +01:00
Niko1221 d735d66a4d docs: STRATA_IQ4_W4A8 on sm_120 and its exit counters; STRATA_FILE_TIER_IDLE and the idle policy of the file tier 2026-11-06 01:49:09 +01:00
cctan 7bf5ec4ebf prefill: opt-in int8 kernel for the IQ4_XS / IQ4_NL expert products (STRATA_IQ4_W4A8), MMQ's bits
Ported from #4871 (cctan): src/prefill/moe_iq4_w4a8.cu, tried first by mmq::Context::run; unset, nothing changes.
Changes on top of the PR: the "would MMQ split this launch along K" test is now mmq::launch_splits_k, which reads the
device's real MMQ configs (tile width, stream_k flag, tile efficiency) instead of an Ampere table, so the kernel declines
exactly the launches whose sums MMQ adds in another order, on any architecture; sm_120 joins sm_80..sm_88 (bit-for-bit
against MMQ on an RTX PRO 5000 and an RTX 3060); the kernel counts what it ran and why it declined and prints that at exit;
prefill_iq4_w4a8_test takes --tokens N (tails), --wide (scale extremes) and --bench (MMQ against the kernel per product set).

Class E: 0 differing output words in every case (tokens 97 / 2049 / 4096 / 8191 / 15360 / 32768, uniform and log-normal routing,
group buffers and pointer table). Kernel time against MMQ on the down products (IQ4_NL): +9..+70% on sm_86, +13..+50% on sm_120.
2026-11-06 01:49:09 +01:00
Niko1221 178d1c74b5 merge f-0215-mac-pp (host-row pipeline, qsa_merge_h, ragged expert tiles) into f-0215-mac-int 2026-11-06 01:25:09 +01:00
Niko1221 eecd7f61d7 merge f-0215-mac-out (step profile, opt-in screens, Metal DFlash2 conf 0.5, calibrated gate) into f-0215-mac-int 2026-11-06 01:25:09 +01:00
Niko1221 86c3367a89 native_expert.cpp: the fused-rows type list terminator is '\0' (a NUL byte had slipped into the character constant; MSVC rejects it) 2026-11-06 01:05:40 +01:00
Niko1221 04b73d061d kq_window_bench: --dump-trace writes the synthetic windows in the route-dump text form 2026-11-06 01:01:14 +01:00
Niko1221 1daec2a5b8 check_isa_guard: iq512_reference_gu_rows and iq512x_rows are listed (main already reports them as unlisted; both are reached only behind cpu_avx512_ok / cpu_vnni_ok) 2026-11-06 00:51:10 +01:00
Niko1221 b94ab5f4c6 kq_window_bench: the CPU pool's part of a decode window replayed without a GPU (per-layer format pairs from the pack, expert and row counts calibrated on the engine's logged means, cold DRAM) 2026-11-06 00:50:40 +01:00
Niko1221 445cdf4540 Run manifest: say what the unset STRATA_ROUTE_TAIL_SKIP means (the engine default applies, tails are skipped only where the configuration allows) 2026-11-06 00:47:28 +01:00
Niko1221 8463d15c84 STRATA_ROUTE_DUMP: terminator fix 2026-11-06 00:42:08 +01:00
Niko1221 1c768f9d12 STRATA_ROUTE_DUMP: newline fix 2026-11-06 00:41:18 +01:00
Niko1221 b0b0348c2b kernels: opt-in fused gate/up rows in ggml order for 2+ tokens (STRATA_IQ512_FUSED_MAX; N, after PR 5243's row_gu_dot); STRATA_ROUTE_DUMP routing trace for offline placement replays 2026-11-06 00:39:11 +01:00
Niko1221 7e3fae57ca PR 5120 tier below VBMI is opt-in (STRATA_VNNI_TIER=1): its 512-bit rows round differently from the AVX-2 rows such a CPU runs today (SDE Cascade Lake: all multi-token gate/up lines and the Q2_0 rows differ), so by default every CPU keeps its output 2026-11-06 00:22:37 +01:00
Niko1221 32944ce1ee cpu_dispatch_dump: the Q2_0 rows (q2_rows_any) next to ggml's, so the substitute unpack can be compared with the multishift 2026-11-06 00:22:30 +01:00
Michel Pollet f000d4a455 cpu/experts: the 512-bit VNNI rows on a CPU without AVX-512-VBMI (PR 5120)
cpu_vnni_ok() = AVX-512 F + DQ + BW + VL + VNNI + OS state, cpu_vbmi_ok() = F + VL + VBMI; the Q2_0 row dot and the q8 quantizer take the 512-bit form
on VNNI without VBMI (the one VBMI op, vpmultishiftqb, has a bit-for-bit substitute unpack), and the i-quant / Q5_0 rows of iq_avx512.cpp, whose object holds
no VBMI instruction, gate on cpu_vnni_ok() instead of cpu_avx512_ok().  Adjustments over the PR: DQ is asked for (the file is compiled with it), the i-quant rows
gate on VNNI (the rows use vpdpwssd/vpdpbusd), and STRATA_NO_VBMI=1 runs the substitute unpack on a CPU that has VBMI (a test knob).
2026-11-06 00:22:30 +01:00
Niko1221 6368dc68ab cpu: drop the IQ4_NL 512-bit down rows (5-11% slower than iq4nl256_down_rows at 2-4 tokens on Zen 4); Q5_0 down keeps its 512-bit rows; vpdpwssd in the i-quant rows' integer sums (same integers, one op less) 2026-11-06 00:22:29 +01:00
Niko1221andbsorensen110 55dc17697c cpu: IQ4_XS gate/up and Q5_0 / IQ4_NL down rows in 512-bit lanes on AVX-512 CPUs, bit-identical to the code they replace
IQ4_XS gate/up: ggml-cpu's dot order (the integer lanes of its AVX2 dot, f16 scale times y.d, eight float lanes, its hsum) for every token count;
before, an AVX-512 CPU ran ggml's one-token dot per token.  Q5_0 down: ggml's dot order, weights decoded once per block pair for all tokens.
IQ4_NL down: iq4nl256_down_rows' order, 512-bit.  Each is checked once against the code it replaces; STRATA_NO_IQ4XS512=1 / STRATA_NO_QD512=1 switch
them off.  The IQ4_XS nibble-plane decode follows the layout of PR 5243.

Co-authored-by: bsorensen110 <[email protected]>
2026-11-06 00:22:29 +01:00
Niko1221 241b2f203c cpu_dispatch_dump: a hash per (pair, token count, row range) of the pool's native expert calls, to compare a change switched off and on 2026-11-06 00:22:29 +01:00
Niko1221 044105d8de kq_cpu_bench: --part gu|down times one projection alone, --hot K walks K distinct experts (cache-resident case) next to the DRAM walk 2026-11-06 00:22:29 +01:00
Phlimosx 994029b02f generate: the file tier's Linux defaults and the routing-aware prefetch stay off while every expert is in the GPU caches
Ported from #4972 (Phlimosx), extended: the residency is checked again at every request's start, so a
smaller cache (VRAM resize) or an eviction turns io prefetch, its decode gate and the look-ahead back on,
and a regrown cache turns them off again. Only what the defaults switched on is touched: an explicit
STRATA_IO_PREFETCH=1 keeps io prefetch and the look-ahead, STRATA_LOOKAHEAD=1 keeps the look-ahead,
STRATA_FILE_TIER_IDLE=0 turns the policy off. set_io_prefetch clears the gate flag with the prefetch.

Class E: the answers are identical (45 of 45 decode answers on l620, --mmap-experts, 24576 of 24576 resident).
2026-11-06 00:22:11 +01:00
Niko1221 89f4fa18ee Run manifest (lite): STRATA_MANIFEST=<file> writes the selected fast paths, fallback reasons, tensor formats, engine choice and budgets as JSON (P0.1)
A diagnostic for testers and for the performance work, off by default. With STRATA_MANIFEST unset every call returns at once
and nothing is printed or written. With it set, the engine writes <file> (whole file, replaced by rename) when it is ready and
again after a request if something new was recorded; the launcher (strata_control/split_engine.py) writes the all-cards split
decision to <file>.launch.json. Sorted keys, no timestamps, process ids or addresses, so two matched runs give the same text.

Content:
- engine: version, engine kind (single GPU or layer split), stages, KV format, drafter, spec window, pipeline windows, PLE I/O.
- budgets: context, KV resident cells, expert slots and cache MiB (all tiers and primary), VRAM reserve, prefill chunk cap and
  stages, pool workers, memory limit, resident budget, batch slots, conversation cache.
- paths: one entry per decision with selected/declined and the reason in plain words: prompt experts per format class (MMQ,
  FP16 WMMA tiles, native fused kernels, FP16 GEMM, with the gate/up and down formats and the layer count), MMQ direct and group
  sort, HC_Q8, the output head (VRAM or pinned host, repacked or not), route tail skip, DMA batch mode.
- tensors: dense and head formats as held (count, bytes), the packed and HC_Q8 copies made at load.
- launch: split-engine mode, engine chosen, reasons, card architecture and count, routed-expert types, and the AMD auto rule
  condition by condition (architecture, card count, expert types), so four gfx1201 with the IQ3_S set is recognised and two or
  three cards are not promoted by the four-card rule.

The only text it prints is "strata: STRATA_MANIFEST: cannot write <file>" on stderr, once, when the variable is set and the file
cannot be written.

Tests: tests/core/run_manifest_test.cpp (off is free and writes nothing, matched runs in any recording order give the same text,
replace-on-rerecord, escaping, dirty tracking, unwritable target), tests/split/test_split_engine.py::LaunchManifest.
2026-11-06 00:21:53 +01:00
Niko1221andAlanJager e2d3159996 Build the flag-order test on HIP too: hip_flag_order_test is the same source through the compat header (P1.1)
Co-authored-by: AlanJager <[email protected]>
2026-11-06 00:20:28 +01:00
Niko1221andmartifaj 1ed923f59e PLE reader: issue() folds a small batch in a stack buffer instead of a hash table and a job vector (P1.6)
Class E: the queued jobs (page offsets, read lengths, Use order inside a job) and the dedup_rows count are the same as
before; the bytes delivered are the same.

PleReader::issue() built a std::unordered_map by page and a std::vector<Job> on the heap on every call, then sorted the
jobs by offset. A decode step asks for 1..16 rows (the prefetch for PLE_N_HEADS = 16), so those allocations and the
hashing were most of the work. A batch of up to 64 rows (PR #4910) now collects its misses into a fixed stack array,
sorts them by (page offset, request order), and merges equal pages in one pass straight into the queue. Nothing is shared
between calls, so concurrent issuers (they hold the reader's mutex when threaded) have no scratch to race on.

Batches above 64 rows (a prompt chunk: thousands of rows) keep the previous hash path. The PR also sorted every miss in
that case with a reusable vector; measured, that is slower there (see below), so it was not taken. A batch of more than
2^32 rows is refused like a failed reader, as the 32-bit order/ticket counters cannot hold it (the old code could not
queue it either).

Measured with tests/ngram/ple_issue_bench.cpp (this thread's heap allocations and thread CPU time inside issue(), threaded
reader, 3000 calls per cell, 2 rounds that agree within 3%; Ryzen 5 5600X, g++ -O2, synthetic 300k-row table, rows are a
4000-row window / uniform / skewed):
  rows   allocations/issue before -> after    CPU us/issue before -> after
     1        5.1 -> 2.1                          1.69 -> 1.54
     4       13.4 -> 5.4                          2.00 -> 1.82
    16       40.3 -> 17.4                         3.44 -> 2.53
    64      125.2 -> 51.8                         8.07 -> 5.01
  above 64: unchanged (the PR's sort-everything variant took 159 vs 66 us at 3072 rows and 2.9 vs 0.55 ms at 49152).
The decode impact is not measured here (see the round's results file).

Tests: ple_reader_test --selftest now also checks, for batch sizes 0, 1, 2, 15, 16, 63, 64, 65, 66, 127..129, 300, 1000,
4096 (random, clustered, one row n times, descending, out-of-range mixed in, page-straddling rows twice), that the bytes
are right, requests grew by the rows asked for and dedup_rows by (rows read - distinct pages). With the existing
selftest and the multi-client stress test (rows across pages, injected delays and read errors, late writers, failed
reader): pass plain, under ASan+UBSan, and the stress test under TSan.

Co-authored-by: martifaj <[email protected]>
2026-11-06 00:12:28 +01:00
Niko1221andcloud4t0r aeaca205eb Mixed-HIP pipelined prompt read: stage 0 is served to its end before the drafter's blocking prefill on its card (P1.9, part 2)
Correctness (class E): the same windows are served in the same order, only a little earlier; the tokens do not change.

With --pipeline-windows 2 on a two-stage layer split the drafter sits on the first stage's card (mtp_first).
read_windows_pl called mtp.prefill(...) right after a stage-1 window finished, while stage 0 still had a window in
flight on that same card. MtpDrafter::prefill blocks the host until its stream is idle (on HIP it synchronizes once
per group whatever its `sync` argument says: prefill_per_group_sync), a stage-0 window only advances when this same
thread serves its doorbells, so the host sat in hipStreamSynchronize and the window it waited for could never
finish: the first request of a fresh engine read its prompt, produced 0 tokens, and the watchdog ended the engine
60 s later (gfx1201 + gfx1030, issue #5147). Neither 20 s guard in Verifier::service / done can fire, because the
loop never runs again.

serve_stage0_to_end() serves stage 0's windows to their end before that prefill, when the drafter shares stage 0's
card. The HIP per-group synchronization (which exists because the unsynchronized E-4 queue hangs on gfx1201 /
ROCm 7.2.4) is left as it is: the caller is fixed, not the mitigation. Pipeline windows 0 and 1 take no new path
(mtp_first is false there). The overlap lost around each drafter prefill has not been measured.

Not testable on the boxes of this round: it needs a mixed pair of HIP cards (gfx1201 + gfx1030) and a two-stage split;
p620 has one R9700. Compiles on CUDA (z440, sm_86).

Co-authored-by: cloud4t0r <[email protected]>
2026-11-05 23:57:51 +01:00
Niko1221andquantforge-br f225844d19 Prompt MMQ row tables get the stream-k fixup's over-read margin, sized from the backend's tile bound (P1.9, part 1)
Correctness (class E, not a speedup): the product values do not change; the rows past T*K are never routed to.

llama.cpp's mul_mat_q_stream_k_fixup loads ids_dst[col_low + jt*J + j] for a whole J-column tile before it compares
the tile with the expert's rows (the main kernel skips an empty tile first; the fixup only skips its writes), and its
tiles run to ceil(max_rows / J) * J for every expert of the launch. A table of exactly T*K entries is read past its
end by up to roundup(max_rows, J), and the main kernel reads up to J - 1 past its last row. Strata's `ids_identity`
was an exact-sized allocation: when it ended a 2 MiB page the over-read faulted on the next (Xid 31 FAULT_PDE,
"prefill copy_i32: an illegal memory access" on a long prompt on a CUDA layer split; issue #1468 and others).

PR #5108 added T + 256 rows by hand. Here the margin is derived: J is bounded by ggml's mul_mat_q_switch_J
(J = 8 .. 128 in steps of 8), the tile loops of moe_mmq.cu now use that one constant (kMmqTileColsMax /
kMmqTileColsStep), and the table is T*K + roundup(T, J) + J rows (an expert takes at most one row per token), with
checked 64-bit arithmetic: a size that does not fit fails the carve instead of wrapping to a small table. The same
size is used by carve, bytes_needed_impl and the three places that write the identity (helper rebind, normal prompt,
layer-major), so the estimate and the carve stay identical. The peer prefill's row table, an exact cudaMalloc used by
the same MMQ launches, gets the same margin.

Tests: tests/prefill/mmq_ids_margin_test.cpp enumerates every index the fixup and the main kernel can read for 2000
random layouts (late experts, empty tiles, every J), checks the exact-sized table of before is overrun by the late
expert layout, and the overflow refusals. Builds on CUDA sm_86 (z440).

Co-authored-by: quantforge-br <[email protected]>
2026-11-05 23:46:57 +01:00
40849df0a8 Transfer completion: flag B stays behind queued copies, and the adaptive-copy range covers the whole round (P1.1)
Correctness (class E): kept even without a speedup.

Flag B (PR #5096): the graph waits for "flag B >= layer + 1" and flag B is one counter that only rises. A non-empty
plan raises it from a host function on the copy stream, after its uploads. An empty plan (n <= 0) raised it on the
spot, even when the copy stream still held an earlier layer's, or the other token group's, uploads: the counter
jumped past a layer whose bytes had not landed. Verifier::raise_flag_b_ordered() now raises it through the copy
stream whenever copies were queued in this window, and directly only when none were. The same ordering is used at
the four places that publish an empty plan when the pool did not (run, run_slot_rows, batch_serve, service), which
had the same hazard. A failed host-function launch used to be ignored (flag B never rose and the window hung); it
now stops the engine like a failed upload does, after releasing the GPU waits.

Adaptive-copy bookkeeping (PR #4844): a round planned on the pipelined loop whose loop drained before step 3 is
marked resident on the serial rule, which used the lo/hi/bi of an earlier round, so only that range of swaps was
marked and the others were lost (the slot held the new expert, the table still named the old one). The pipelined plan
now names the whole round. The range arithmetic moved into AdaptiveCopyRange (adaptive_range.hpp) so a host test can
walk the rounds.

Tests: tests/core/flag_order_test.cu (delayed host-function copy standing in for a slow DMA, a consumer kernel that
reads staging once flag B allows it, a negative control that shows the old direct raise releases it early, flag only
rises, nothing queued, graph replay twice, every device; passes on both A4000s of z440), tests/program/
adaptive_range_test.cpp (serial batches for 0, 1, many swaps and several caps, the stale-range loss, empty round).

Co-authored-by: AlanJager <[email protected]>
Co-authored-by: uvlad23 <[email protected]>
2026-11-05 23:39:06 +01:00
a5b820a415 Request accounting and prompt timing marks: yielded prompt legs summed once, admission time without a double count, unknown stays unknown (P0.4)
Reporting only (class E): no engine speedup, no change to what the engine computes.

serve/server.py
- A prompt read that gives way (BYIELD) and resumes keeps the request's external prefix boundary, and the prompt
  time and tokens of every leg are summed exactly once (prefill_leg_stats; PR #5318).
- The admission's DONE times one token and BDONE counts that token but times only the slot: the slot time now
  includes the admission time, and the output token is not counted twice. A figure the engine never reported
  (a close at the admission token, in the slot, a carry back to the solo path) stays unknown (None) instead of
  turning into 0 or into the API's output count (PR #5307).
- The two patches overlap in the request finalization; ended_on_yield is taken before the unknown-figures step so
  the identity check still sees the yield's own record.

PfTimer::mark(): a mark in the phase already running is not recorded (PR #4006). The totals are the same, a
fraction of the event records; under WDDM every record is a submission of its own and the launch gaps they made
were charged to "dequant". The rule is a pure function in prefill/timer_marks.hpp with a host test.

Tests: serve/test_yield_accounting.py (new: two yields, solo and admission yields, cancel after one and two
yields), serve/test_parallel.py BatchLegAccounting (EOS and length finishes, zero slot time, close at the
admission token and in the slot, slot to solo carry), serve/test_request_accounting.py (unknown stays unknown,
explicit zero), tests/prefill/timer_marks_test.cpp (same totals with 18 of 23 records skipped).

Co-authored-by: YusenZhang0601 <[email protected]>
Co-authored-by: shoucandanghehe <[email protected]>
Co-authored-by: Vop3p <[email protected]>
2026-11-05 23:31:14 +01:00
Niko1221 b97433df05 spec: STRATA_DF2_GATE=1 (opt-in): DFlash2's verify window from a per-request calibrated survival (position x confidence bucket, prior C^1.3) against the cost of one more row, instead of a fixed --draft-conf; window_rule_test covers it 2026-11-05 23:18:51 +01:00
Niko1221 64cc20a8e2 Merge f-0215-fit (setup wording) into f-0215-int 2026-11-05 22:47:02 +01:00
Niko1221 1d342f0836 setup: on a unified-memory GPU whose RAM budget leaves no room for a numeric expert cache, say where the cache is sized from instead of warning that it may take the OS's room: "The expert cache is sized from the graphics memory (<N> GB carve-out + shared), not from the RAM." N is the carve-out setup detected (no number when none was). The old warning and its --resident-budget-gib tip are gone; the test checks the new line with 2 GB and 48 GB carve-outs 2026-11-05 22:44:44 +01:00
Niko1221 0e1d440713 Merge f-0215-pk into f-0215-int 2026-11-05 22:36:02 +01:00
Niko1221 19136808f9 Merge f-0215-fit into f-0215-int 2026-11-05 22:35:59 +01:00
Niko1221 2c0ad33f71 Metal prompt: ragged expert tiles on by default for the types that have kernels (STRATA_PROMPT_V6=0 off); drop the unused Mac PLE threading and the tall-tile probes 2026-11-05 21:59:37 +01:00
Niko1221 3cc5714f37 Strix Halo (Windows gfx1151): STRATA_SMALL_UNR=3 and STRATA_GR_Q8_PFS=1 (load-ordering variants of the decode kernels, same arithmetic) join the default table. UD-Q4_K_XL, DFlash2 serve, 4 rounds x 2 passes x 4 prompts interleaved: window time -0.25% (25 of 32 pairs faster, same window and token count in every pair); first-token logits and 220-token outputs identical on 3 prompts for UD-Q4_K_XL and UD-IQ4_XS. Linux gfx1151 and STRATA_DEFAULTS=026 keep their tables; arch_defaults_test updated and passing 2026-11-05 21:19:24 +01:00
Niko1221 687a7d79d7 Metal prompt: host rows beside the GPU (embedding + PLE rows of the next chunk on a thread, parallel PLE gather on Macs), qsa_merge_h (merge writes the fp16 operand), ragged expert tiles (mm_gu6/mm_down6/mm_scan6, STRATA_PROMPT_V6), STRATA_PROMPT_KPROF per-dispatch profile, matmul2d probes 2026-11-05 20:36:54 +01:00
Niko1221 6e31946cbe Strix Halo (gfx1151, Windows): the F16 prompt expert kernels write the down rows as FP16 and a combine kernel reads them (STRATA_PF_DM16, on by default there, =0 turns it off; from the Strix dm16 work patch, extended to the F16 K-quant and IQ kernels). 8K prompt, same engine, interleaved pairs: IQ3_S +2.2% (4 of 4), UD-IQ4_XS +2.6% (3 of 3), UD-Q4_K_XL +1.2% (3 of 4); first-token KL 1e-4 at 8K, 0.02-0.04 on agent transcripts 2026-11-05 19:59:50 +01:00
Niko1221 66b346c29f drafting: Metal gets its own DFlash2 confidence (0.5, BACKEND_CONF) instead of the CUDA slot steps, which wrote 0.8 for the Mac's --expert-cache 64; M5 Max UD-Q4_K_XL +14.3% over MTP spec 2 on six kinds 2026-11-05 19:54:31 +01:00
Niko1221 830732a33d metal: step profile scales every pass by the cool whole-window time; opt-in screens STRATA_METAL_HCLD=1 (HC loads in flight: -10.9%, off), STRATA_METAL_WPF_MIB (weight prefetch beside gr_up), STRATA_METAL_MOE_GU / MOE_DN (expert tile override) 2026-11-05 19:54:31 +01:00
Niko1221 e431a4a8e2 Strix Halo (Windows gfx1151): the V2K grouped decode experts for Q4_K / Q5_K gate/up and Q5_1 / Q8_0 down are on by default (STRATA_EXPERT_V2K=0 turns them off). UD-Q4_K_XL, DFlash2 serve, 16 request pairs: window time -4.0% (16 of 16 faster, mean x1.040), same window and token count in every pair; first-token logits and 220-token outputs identical on 3 prompts (the kernels are bitwise the old ones) 2026-11-05 19:27:35 +01:00
Niko1221 4dbaf703fd Strix Halo (gfx1151, Windows): the K-quant prompt experts of UD-Q4_K_XL (Q4_K / Q5_K gate/up, Q5_1 / Q8_0 down) take the fused F16 matrix-core kernels by default (STRATA_PF_FUSED_KQ=0 or STRATA_GFX1151_DEFAULTS=0 keeps the earlier route). Setup config, n=1: 8K prompt 885 -> 1,387 tok/s, 4K 611 -> 1,316; first-token KL against the earlier route 1e-5 .. 6e-2 on prompts of 1K+ tokens (information), same greedy ids 2026-11-05 18:54:42 +01:00
Niko1221 c4fa1d9e1e metal: STRATA_METAL_STEPPROF=T, the in-situ step profile by part (prefix runs at every barrier group, weight bytes and GB/s per part, distinct experts per layer); run_graph can run the first N dispatches 2026-11-05 18:41:45 +01:00
Niko1221 9f7d977d22 README: a Loyal supporters section under the Sponsor and coffee buttons in all 8 languages, with the public GitHub sponsors as linked profile pictures (first: ryan4yin) and a one-line template for the next one 2026-11-05 14:54:54 +01:00
Niko1221 5d37280db1 Docker: build.sh copies metal/tools/metal_repack.py and stream_experts.py into the build context (the Terminal bundle carries them since the Mac Unsloth sets; the v0.2.11 image build stopped at the missing metal_repack.py); the context test checks every INSTALLER_METAL_TOOLS name 2026-11-05 14:02:27 +01:00
Niko1221 dc9dfcfd29 GitHub Sponsors: FUNDING.yml names github: Niko1221 (the repository's Sponsor button), and every README gets a Sponsor badge beside the coffee badge, a Sponsor button in Support Strata and a support line that names both 2026-11-05 14:02:27 +01:00
Niko1221 c3b3f50355 Merge f-0214-int d7f5c6dd1 (Strix first-launch plan fix: expert cache auto on UMA, Python only) into f-0211-rc 2026-11-05 12:54:06 +01:00
Niko1221 d7f5c6dd11 Merge f-0214-strixplan 0c0b7b875 (Strix plan fix) into f-0214-int 2026-11-05 12:53:44 +01:00
Niko1221 0c0b7b8751 setup: uma_holds_experts is never true on a Mac (the signed Mac plans stay exactly as they were); test 2026-11-05 12:29:14 +01:00
Niko1221 86ffdbeb17 setup: on a unified-memory GPU a v3.1 Unsloth set whose experts fit the shared pool gets expert cache auto and nothing pinned (UD-IQ2_XS's first launch failed with the fixed cache and the pinned part on a 64 GB Strix Halo); the other sets keep their plans; tests 2026-11-05 12:25:53 +01:00
Niko1221 aac067021c Merge f-0214-int e2b53784 (METAL test-only change: metal/tests/moe_mm_parity.cpp) into f-0211-rc 2026-11-05 11:25:35 +01:00
Niko1221 e2b5378403 Merge f-0214-metal ba5bef2d6 (parity test: IQ4_XS gate/up runs v2) into f-0214-int 2026-11-05 09:42:34 +01:00
Niko1221 ba5bef2d61 moe_mm_parity: IQ4_XS gate/up runs the v2 path (v1 is not an engine path and has no IQ4_XS kernel) 2026-11-05 09:41:13 +01:00
Niko1221 3e0210663b Merge f-0214-int 437668151 (round 0214: the new Unsloth Qwen quants, the Q5_0 dot fix, the Mac app branch) into f-0211-rc 2026-11-05 09:39:50 +01:00
Niko1221 437668151d Merge f-0214-macapp 408a5ca37 (Mac app offers Unsloth sets) into f-0214-int 2026-11-05 09:37:11 +01:00
Niko1221 0bdcef4682 Merge f-0214-engamd aa678f10d (Q5_0 expert-dot fix) into f-0214-int 2026-11-05 09:35:26 +01:00
Niko1221 408a5ca37b tests: the Mac model list includes Unsloth's sets 2026-11-05 09:33:32 +01:00
Niko1221 10df7ace1d tests: the Mac app's running-engine hint 2026-11-05 09:25:00 +01:00
Niko1221 d6eaab500f Mac app: the running-engine hint says to unload the model in Strata (approved wording); test 2026-11-05 09:24:49 +01:00
Niko1221 aa678f10d0 Q5_0 expert dot: the -16 offset takes the sum of the quantized activations (CUDA/HIP and SYCL), like Q5_1 and Q4_0
vec_dot_q5_0_q8_1 used the q8_1 block's ds.y (the sum of the original activations). With the SwiGLU outputs (not zero-mean) the whole-expert error with Q5_0 down was 2.1e-2 .. 3.1e-2 against 1.2e-2 for every other down type. Now 1.2e-2, and the GPU agrees with the CPU path. The Q5_0 parity limit is tightened to 1.8e-2.
2026-11-05 09:07:47 +01:00
Niko1221 af1ffd913a Mac app: Unsloth's sets are offered and installed as the Terminal's setup_mac does (metal_repack to one GGUF, --ple-gguf the table's shard, disk counts the repacked file); the repack tools ship in the installer bundle; tests 2026-11-05 09:05:01 +01:00
Niko1221 edb7c4819b native_grouped_parity: pin the CUDA layout (STRATA_EXP_MODE=0) on gfx1151, where mode 8 and the strided call sum in a different order 2026-11-05 08:54:01 +01:00
Niko1221 74a4441354 Merge f-0214-engamd into f-0214-int 2026-11-05 08:53:25 +01:00
Niko1221 bee9168114 Merge f-0214-engnv into f-0214-int 2026-11-05 08:53:25 +01:00
Niko1221 f699fc0a7d Merge f-0214-metal into f-0214-int 2026-11-05 08:53:24 +01:00
Niko1221 96d48dff45 Merge f-0214-catalog into f-0214-int 2026-11-05 08:53:24 +01:00
Niko1221 99cbdd5592 tests: the Terminal model list needs a taller screen for the seven new rows; adopt's unsupported-family example is nvfp4 2026-11-05 08:45:15 +01:00
Niko1221 7faeb8c264 Parity tests: v1-vs-new grouped diagnosis (differing words listed), v1 against the float reference in native_expert_parity, Q5_0 gets 32-value scale blocks in the grouped test's random weights 2026-11-05 08:41:41 +01:00
Niko1221 39ea3e5216 launch: Unsloth's sets are written for an Intel Arc card (adopt.FAMILIES), without --ple-gguf; test 2026-11-05 08:30:25 +01:00
Niko1221 60ab1e52f3 catalog: the MCP tool description says Unsloth's 1- to 8-bit files 2026-11-05 08:11:01 +01:00
Niko1221 bfbe7fab2b MMQ parity screen: skip the trellis types when the ggml has no host traits for them (it read past the type table on Windows) 2026-11-05 08:06:52 +01:00
Niko1221 85e7c83d96 catalog: the Unsloth family credit line reads 1- to 8-bit 2026-11-05 08:05:38 +01:00
Niko1221 199fbc7a45 catalog: approved wording (about lines, SUPPORTED_GGUFS, UD-IQ1_S reason, Unsloth family line); METAL_Q5_0_DOWN on (f-0214-metal cbef1a9ff) so the Mac lists all seven sets 2026-11-05 08:04:15 +01:00
Niko1221 9ed3c77899 catalog: Unsloth v3.1 Qwen3.8-Flash-Next sets (UD-IQ1_M, UD-Q2_0, UD-IQ2_XS, UD-Q2_K_XL, UD-IQ3_XXS, UD-Q3_K_XL, UD-IQ4_XS-v31) at d9620119 with a per-model revision; UD-Q4_K_XL's four-shard layout accepted from local files; manual-load refusal lifted (UD-IQ1_S stays refused); mac_fit switch for the Q5_0 down sets; header-only fixtures and tests (about lines pending wording) 2026-11-05 07:23:41 +01:00
Niko1221 c1d5e4d5e5 Tests for the new Unsloth Qwen3.8-Flash-Next expert and dense type combinations (HIP, CUDA, SYCL)
native_expert_parity: --bench decode micro-number, +0/-0 is no fp16 difference.
Registers every gate/up x down pair of the new sets (CUDA and HIP, SYCL), a dense type test
(native_dense_types_parity), Q5_0 down in native_grouped_parity, new-set types in the MMQ screen,
and a --synthetic mode in the SYCL native_expert_parity.
2026-11-05 07:22:34 +01:00
Niko1221 14a4636b83 native_dense_parity: Q4_0/Q5_0 decode tolerance 4e-2 (offset term uses the unrounded activation sum, as llama.cpp CUDA) 2026-11-05 07:19:16 +01:00
Niko1221 5d56ff8c06 Parity tests for the Unsloth v3.1 Qwen3.8-Flash-Next quant mixes (CUDA)
- native_expert_parity: ctest entries for every gate/up x down pair the new sets use
  (IQ1_M, IQ2_XXS, IQ2_XS, IQ2_S, IQ3_XXS, IQ4_XS gate/up; Q2_0, IQ4_NL, Q5_0, Q8_0 down)
  at hidden 2560 / FF 640.
- native_dense_parity: dense Q2_0..Q6_K, IQ4_NL, IQ4_XS decode (MMVQ, 1-8 columns) and the
  prompt dequantizers (f32, f16, bf16) against ggml.
- prefill_mmq_v31_test: the new expert formats through MMQ (Q5_0 only in a
  STRATA_ORCA_Q4KS_MMQ build), plus a --bench of Q5_0 MMQ against dequantize + cuBLAS.
2026-11-05 07:18:29 +01:00
Niko1221 cbef1a9ff6 Metal: Q5_0 down experts, decode (moe.metal, moeu) and prompt (moe_mm v1/v2/v3), host tiles, honest gu_qk/d_qk; parity tests take Q5_0 2026-11-05 07:14:30 +01:00
Niko1221 2b8179f3e6 Merge f-0213-int ce4197ce8 (AppImage ELF check: an ELF test instead of 'every ET_DYN is a static PIE', #4337) into f-0211-rc 2026-11-05 05:29:02 +01:00
Niko1221 ce4197ce81 appimage_elf: a shared library is not a static PIE; the check tells them apart without name rules (#4337 checker)
The checker treated every ET_DYN without PT_INTERP as a static executable, so the RUNPATH $ORIGIN that Pillow, numpy and sharp libraries carry was
reported as 'static PIE with a damaged dynamic section' (35 files in 0.2.11, 18 Pillow libraries in the published 0.2.10 Control; false positives).
A static PIE is now an ET_DYN with no interpreter, a non-zero entry point and DF_1_PIE in DT_FLAGS_1 and no DT_SONAME; a library (no entry point, or a
SONAME and no PIE flag) is never checked. A candidate whose dynamic section cannot be read is reported as damaged, because the overwrite that crashes
rg also erases the flags, so the test for 'executable' uses the entry point, which the damage does not touch. Tests (tools/test_appimage_elf.py, 16, run
on p1006): shared libraries with and without a SONAME, entry point or RUNPATH pass; a static PIE passes; a static PIE with a 0x5A .dynamic or an added
RUNPATH fails; a real gcc shared library with RUNPATH $ORIGIN passes; a real gcc static PIE passes and its overwritten copy fails. Build tool only.
2026-11-05 05:24:48 +01:00
Niko1221 3fda37bd8c Merge f-0213-int 3c4afa8d7 (Models page: no Connect engine / Download & load flash after a start) into f-0211-rc 2026-11-04 23:37:34 +01:00
Niko1221 3c4afa8d78 QAGUI #2 (source): /api/native says checking until Control has answered; a failed start-time read is retried in 3 s, not held 45 s; page shows Checking, not Connect engine 2026-11-04 23:26:47 +01:00
Niko1221 975eaab2bb Version 0.2.11 / engine 0.1.57: Terminal and app 0.2.11, Control and every engine package 0.1.57 (the 0.2.10 bump list: strata_cli, apps/host, apps/desktop, Cargo, CMake, Metal, the build scripts, the version tests, AGENTS.md, the bug-report template) 2026-11-04 23:23:33 +01:00
Niko1221 0fa4288d30 markdown: keep the list line as it was written (a source guard in ui-do.test.mjs reads it) 2026-11-04 22:34:01 +01:00
Niko1221 90c77357fb markdown: a file with Windows line ends no longer runs the page out of memory (follow-up to 68bead108)
The sidebar now shows a file's current content, so a CRLF file reaches the Markdown renderer. Every line then ends in a carriage return, which '.'
does not match: a list line passed the list-start test but not the item pattern, the list loop took no line, and the outer loop never advanced - an
endless loop pushing nodes (150 MB to 4.4 GB in 20 s on a 60 byte file; QAGUI final pass, the agent's Add-Content writes CRLF). 0.2.10 never showed
the changed text so it never met it; the bug is older and any CRLF Markdown list opened in the preview would hit it.
Line ends (CRLF, CR, LF) are read as one in the renderer and in the preview slicer, and the list loop always moves on (a line the item pattern rejects is
shown as text). Test: tests/markdown-crlf.test.mjs renders in a child process with a 200 MB cap and a 20 s limit; on 68bead108 all 4 cases abort out of
memory, with the fix all pass. Edge 156, seeded live turns (stream, finish with the card, file rewritten with CRLF, cards opened 6 times): heap stays
near 19 MB with the fix. No visible text.
2026-11-04 22:10:41 +01:00
Niko1221 1814da7c44 Merge f-0213-overlay (aba888972) into f-0213-int 2026-11-04 21:49:55 +01:00
Niko1221 64b48c0d00 Merge f-0213-qafix into f-0213-int 2026-11-04 21:49:19 +01:00
Niko1221 5d363f11ea Merge f-0213-qatui into f-0213-int 2026-11-04 21:49:19 +01:00
Niko1221 aba888972a select: the list is placed by the shared overlay function, as tall as it needs and no taller than the room above or below its button
The Select popup (every dropdown in Settings, Models and the tune dialogs) was capped at 320 px whatever the window and opened upward through a
transform, so in a short window or at a large Interface size it scrolled although the window had room, or started above the top of the window
(Edge 156 matrix: Select popups 4 of 72 and 3 of 72 cases right on 548fdf061, 69 of 72 and 61 of 72 after; the rest are harness not-open rows).
It now uses lib/overlay-place.ts like the menus, measures its real height once it is on the screen, and writes maxHeight. The Mode menu and the
sidebar More menu already passed on 548fdf061 (77 of 77, 84 of 84; 43 of 77 and 49 of 84 on v0.2.10). No visible text.
2026-11-04 21:33:24 +01:00
Niko1221 a87c399c91 F12: French s9038 uses the typographic apostrophe (french-catalog test) 2026-11-04 20:35:53 +01:00
Niko1221 e10be676b1 F13: the ? key list wraps at the keys on narrow terminals (80x24 and 60x16) so every key shows 2026-11-04 20:31:12 +01:00
Niko1221 0269e2c42a F13: the monitor's ? key list stays up on every view instead of vanishing at the next poll 2026-11-04 20:26:01 +01:00
Niko1221 6f5cf9f6b7 nvidia-smi is asked once per field and 30 s: one launch --plan on Windows with an engine installed started it 19 times 2026-11-04 20:24:56 +01:00
Niko1221 be2d275361 QAGUI #2: Models page waits for Control's installed list before offering Download & load 2026-11-04 20:24:31 +01:00
Niko1221 c7272c70db QAGUI #1: start-up reads have a time limit and bounded retries; optional reads no longer hold back live 2026-11-04 20:23:27 +01:00
Niko1221 8768e95f12 F12: port-denied text names the right cause on Linux and macOS (QATUI F12)
Windows keeps the Hyper-V/firewall text. serve listen_problem and the host
ports.mjs pick by platform; new locale key s9038 in 9 languages.
2026-11-04 20:21:11 +01:00
Niko1221 548fdf061e Merge f-0213-life (3e009d2b7, process-tree test hardening) into f-0213-int 2026-11-04 19:15:09 +01:00
Niko1221 3e009d2b75 tests: process-tree waits on the real conditions (a process that is running, not a zombie; the child's own ready line; a marker file) with long limits instead of fixed delays 2026-11-04 18:59:35 +01:00
Niko1221 e82c706981 Merge f-0213-preview (68bead108) into f-0213-int 2026-11-04 18:46:17 +01:00
Niko1221 68bead1082 preview: the sidebar shows the file's current content after it changed on disk (follow-up to #5180)
The preview kept a file's first content: openPreview dropped the freshly read item when its id was already a tab, the panel's loading effect had no
content version, and the effect that re-adds a message's artifacts put the stored first copy back. All three change together, through pure rules in
lib/preview-merge.ts: opening a changed file replaces its tab in place; a message's artifact only adds a missing tab or replaces an older, different
copy (each copy carries the time it was read or made) and never overwrites a newer one; both return the same array when nothing changed, so no update
and no loop follows. The panel reloads when (id, content version) changes: a hash of the text, or the host's mtime and size for pictures and PDFs
(/api/files/preview now sends mtime). The reading view is reset by the file, not by its content. Tests: stale case, 2000 alternations without a
change, one load per real change. Real Edge 156 with the real panel: the old rules keep Version One, the new ones show Version Two through 300
sync/render alternations with 0 Markdown renders. No visible text.
2026-11-04 18:45:06 +01:00
Niko1221 85269ca155 Merge f-0213-guidata (final 1d5096beb) into f-0213-int 2026-11-04 18:43:27 +01:00
Niko1221 1d5096beba Privacy note: conversations stay on this computer, in Strata's data folder (owner wording; all nine languages) 2026-11-04 18:33:30 +01:00
Niko1221 e647e132d5 locale-gaps: the notice catalog (sav-en) is a known catalog (#5192) 2026-11-04 17:59:26 +01:00
Niko1221 8ad605789b agent turn: a reloaded page takes a running turn over again; chats resume after they are read from the host
A reload does not stop an agent turn, but the page that came back showed "Network error. Try sending the message again."
with no Stop button, and a new message was refused with "This chat is still working". The host now answers GET /api/chat/active
(the chats with a turn running) and POST /api/chat/attach: a running turn sends one catch-up (the start, the text and thinking
so far as a resync frame, every tool card as it is now with the approvals waiting for a yes, the open questions) and then its
live frames; a turn that ended while the page was away is shown whole once, named by the request id of the cut-off reply.
At start the page asks for the running turns, takes each over in the reply it already has (running, Stop button, the
failure it wrote is cleared), and asks once about cut-off replies of the other recent chats; nothing to take over leaves the
reply as it was. Existing states and texts only.

The things that used to run at bootOnce from the chats in memory (follow time-skip runs, end loops the last window left running,
mark tool cards of vanished turns) now run once the chats have been read from the host (resumeSessions), because the chats are
no longer in the page at that moment. The save retry waits a little longer each time (up to a minute) and never keeps a closing
page alive. Also: tests for a second window's changes (applyChatChange) in the parallel-chats test.
2026-11-04 17:48:03 +01:00
Niko1221 792b626980 Merge f-0213-secure (fix 0855796e6) into f-0213-int
Conflicts in strata_cli/bench_facts.py (the redactor), three hunks: kept every side. The patterns stay QATUI's (the key and the names are matched as whole tokens, a hyphen next to the key does not hide it, the names are not touched inside a label the function wrote), each entry carries SECURE's third field (is a login or host name) for redact_document, and the host names come from socket.gethostname() and sysinfo.node() (LIFE), both marked as names.
2026-11-04 17:40:48 +01:00
Niko1221 cd66956ca6 Merge f-0213-tuicfg (fix 71395ec9c) into f-0213-int 2026-11-04 17:38:00 +01:00
Niko1221 16904848bc setup: the low-RAM mode's copy of the experts is checked for room where it is written (the data folder's packs folder), and the refusal names that folder in the approved words instead of the model folder; test 2026-11-04 17:35:55 +01:00
Niko1221 0855796e68 bench: results.json redacts the machine's login and host name only in the text people and servers wrote, not in the report's own words (#4484)
On a PC whose login is an ordinary word ("user", "test", "admin") the whole-document pass turned status reasons and field names into <user>, and the report failed its own validation. Paths, addresses and the key are still replaced everywhere; the name rules apply to hardware, model and configuration text, error messages, engine options and request checks.
2026-11-04 17:35:01 +01:00
Niko1221 71395ec9c7 setup: the pinned-shard mark helper needs no 'hf' key (the NVFP4 families have none) (#4088)
main() and mac_control_setup() read fam['hf'] to build the pin lookup URL; the NVFP4 families download a file list and have no such key, so every NVFP4 install raised KeyError.
The helper takes None and then keeps the old mark. Found by the INT3 merge run (tools/test_setup_nvfp4.py, tools/test_setup_dl_fix.py).
2026-11-04 17:33:39 +01:00
Niko1221 9cb1dc6454 App Host: the start-up probe for the share port listens on the loopback address, not on 0.0.0.0 (it made Windows Defender Firewall ask 'allow Node.js on these networks?' on the first start of every new install path); test records every listen() of a started host 2026-11-04 17:33:27 +01:00
Niko1221 b247f4ecda Merge f-0213-qatui (final d33d45ade) into f-0213-int 2026-11-04 17:33:14 +01:00
Niko1221 d33d45ade2 update --check on a source checkout prints the tree's own version for Control instead of '?' (display only; decide_control is unchanged) 2026-11-04 17:20:23 +01:00
Niko1221 cabb271145 launch: 'A new API key was made' is said only once the run config holding the key has been written (never in the plan or before a refusal) 2026-11-04 17:19:20 +01:00
Niko1221 094bb3f0a3 chats are files in the profile's data folder, not browser storage: the 5 MB limit is gone (#5192)
The app host saves each chat as its own file (data/chats/<id>.json, an index, a version and a revision in
every file), written to a temporary file, flushed and renamed over the chat, so a crash or a full disk in
the middle keeps the older version whole. The folder and files are owner-only. The page talks to the host
through /api/chats (the page must name itself with x-strata-client; other sites are refused), sends only the
chats that changed, never cuts anything, and keeps a chat the host cannot write whole in memory, retries it,
and says so with the notice and the "Copy unsaved chats" button in all nine languages. A save from an older
copy (a second window) keeps the version it displaces in chats/conflicts. The browser's two older layouts
(the 0.2.10 list and the per-chat entries) are handed to the host once at start and left untouched as a
fallback for one release; a last-moment copy covers a page that is closed before the host confirmed a save.
Other windows are told by an event and take over what changed.
2026-11-04 17:15:42 +01:00
Niko1221 0481347ec0 bench redaction: a key next to a hyphen is replaced (it leaked in results.json), and redacting twice gives what once gives (no <<user>>) 2026-11-04 16:04:18 +01:00
Niko1221 b75e8a975d launch: a profile or run config of the launch that cannot be read stops it before anything is written, so the API key is not replaced (F1, found on l620); owner-approved wording 2026-11-04 16:04:18 +01:00
Niko1221 2204582932 settings file check: args_written, user_args and gpus_by, which setup itself writes, are known keys (a freshly set-up model showed 'args_written is not a setting Strata reads' in the settings panel); test reads SETUP_KEYS from setup.py 2026-11-04 16:00:01 +01:00
Niko1221 22a3d70688 Merge f-0213-guidata (4c4596746) into f-0213-int 2026-11-04 15:59:53 +01:00
Niko1221 d8b6ce356c Windows: the display-adapter CIM query is asked once per process and 2 minutes instead of 18 times in one launch --plan (a stuck WMI cost 18 x 15 s, #4975) 2026-11-04 14:45:52 +01:00
Niko1221 d73ce8608e bench run --profile: SIGTERM ends the run like Ctrl+C, so the server and engine it started are stopped; an interrupt during the model load stops them too (found on l620 and m1001) 2026-11-04 14:45:52 +01:00
Niko1221 127d829018 Merge f-0213-life (final 74f390538) into f-0213-int 2026-11-04 13:43:15 +01:00
Niko1221 4c45967460 chat titles are cut at a character through shortTitle, not in the middle of an emoji (#4533) 2026-11-04 13:34:07 +01:00
Niko1221 74f390538c serve: the error of a request that ended without an answer says the next request restarts the engine only where the engine was really ended (#3961) 2026-11-04 13:34:06 +01:00
Niko1221 50c2db6905 merge f-0213-guidata-shell (#5180 #5079 #5071 #4653 #4533 #4337) 2026-11-04 13:33:50 +01:00
Niko1221 a8767488ce serve: a request that ended without its end event ends the engine only if it is still the process that request ran on and nobody has the turn, so the next request is never killed with it (#3961) 2026-11-04 13:18:36 +01:00
Niko1221 f4d8688e8c merge f-0213-guidata-avail (#4397 #4535 #4221 #4878 #3750 tests) 2026-11-04 12:27:00 +01:00
Niko1221 4d482e627c Merge f-0213-own (final daf4ac087) into f-0213-int
Conflict in serve/server.py main(): LIFECYCLE wraps _main in the shared activity lease (#4463), OWN wraps it in the decoupled console (#4479 / #2481). Kept both: the lease is taken first, and _main runs inside decoupled_console() unless STRATA_BLOCKING_CONSOLE=1; the lease is closed in the same finally.
2026-11-04 12:26:56 +01:00
Niko1221 66331f9bc0 Merge f-0213-life (final 576d06553) into f-0213-int
Conflict in strata_cli/bench_facts.py (the redaction helper): kept both sides' intent. The host name comes from sysinfo.node() (LIFE) and the key is redacted with usable_key(key), word=True, exact=True (SECURE).
2026-11-04 12:24:01 +01:00
Niko1221 daf4ac087a serve --batch: the drain and quarantine limits count silence, never progress (#4451 follow-up)
DRAIN_TIMEOUT_S (300 s) and QUARANTINE_S (600 s) apply only after a request has left and the server has sent STOP / BSTOP:
_drain_control (the finally of generate_batched), _quarantine_control and the BDONE wait of _release_slot_when_done. They
never cover a generation a client is still reading. But they were counted from the start of the drain, so an engine that
was told to stop and kept producing lines (an older engine runs on to max_new; a long prompt read ends at its next chunk)
could be ended after 900 s, with every other request in it. Now every line the engine writes starts the limit again: only
an engine that says nothing at all for 300 s + 600 s after the stop is ended.

Tests (fake engine, limits scaled to 0.3 s + 0.4 s): an engine that keeps talking for ~1.8 s after the stop is not ended and
the request in another slot is untouched (fails with the previous commit's absolute limits); a silent one is ended; the
drain-timeout leak test now really closes the stream mid-read (the old version was ended by a BYIELD before the drain);
long replies, far over the limits, complete. Also: a client that is idle while the engine thinks is not cut by the 60 s
write limit (0.3 s between tokens, 0.1 s limit, the whole reply arrives).
2026-11-04 12:18:12 +01:00
Niko1221 576d06553c launch: _host_platform imports sysinfo (the earlier edit left the name undefined, #4975) 2026-11-04 12:10:31 +01:00
Niko1221 2bfd151578 tests: the adopted-load test lets Control's first answer be 'starting' and the next 'failed' (#4293) 2026-11-04 11:59:36 +01:00
Niko1221 049ae1c678 Merge f-0213-tuicfg (final c6dda74bf) into f-0213-int 2026-11-04 11:45:45 +01:00
Niko1221 39851463bb serve --batch: vram() and session_file() take the control lines like a request (#4451, same class)
On a batch engine the control lines carry one exchange at a time and every request takes `ctl` before it sends or reads
there. VRAM and SAVE / RESTORE read the same lines for their answer without it (POST /v1/vram took it itself, the image
encoder's cache resize, the load-time reserve and the session handler did not): run beside a request they took its
T / PP / DONE lines, and the request lost its reply (or session_file ended the engine as out of step).

StrataEngine.vram / session_file now run under _exclusive_control(): ctl on a batch engine (a thread that already holds it,
as Service.vram does, is marked and does not wait for itself), nothing to take for one request at a time. Waiting longer
than CONTROL_WAIT_S raises the existing "a request is still running; try again when it has finished". No output changes.

Test: the fake engine answers VRAM / SAVE / RESTORE; a request streams while the call is made. On the base the request
ends with 212 of 421 tokens; with the fix it is complete and the call is answered.
2026-11-04 11:43:02 +01:00
Niko1221 3c95402c4f tools: stream_ownership_probe.py - interleaved streaming clients with canaries and random disconnects against a running server (#4451, #2892)
Each client puts its own random canary in its prompt and asks the model to repeat it; some leave before the first byte,
during the prompt read or after a few chunks. Every text a client receives (content and reasoning) is searched for the
canaries issued in the run: a canary that is not the client's own is a cross-delivery (exit 1). Used for the real-engine
A/B of the round 0213 ownership fixes.
2026-11-04 11:35:16 +01:00
Niko1221 b776e1cd02 serve: console output goes through a bounded queue, so a blocked console cannot hold a request (#4479, #2481)
print() in the server blocks when the console stops taking output (a Windows console in selection mode, a paused
terminal, a launcher that stopped reading the pipe), in whatever thread called it - including those that own the
engine's turn, a cancel or an admission. While serve main() runs, stdout and stderr (when they are the process's own)
are replaced by serve/nonblocking_out.NonBlockingOut: write() queues and returns, one daemon thread writes to the real
stream, the oldest text is dropped (and counted) when the queue is over 1 MiB / 4000 chunks. Restored and drained at the
end of main(). Protocol output (engine pipe, HTTP) is not touched. STRATA_BLOCKING_CONSOLE=1 turns it off.
No visible text is added; a drop is counted, not announced.

Tests: a plain print to an unread pipe blocks (control); the wrapper never blocks, keeps the newest lines in order, and
drains. Approach credit: the proposal in #4479 (one process-owned output consumer).
2026-11-04 11:26:31 +01:00
Niko1221 c08fd2ecc8 serve: a streaming client that stops reading no longer holds the engine's turn (#4271, part 1)
The thread that writes the SSE stream to a client also holds the engine's turn (the one-request path). A client that
stops reading without closing (a hung process, a dropped network) filled the socket buffers and the write blocked until
the OS gave the connection up: the engine idle, every other request waiting. A write that makes no progress for
STREAM_WRITE_TIMEOUT_S (60 s; STRATA_STREAM_WRITE_TIMEOUT_S, 0 = off) now ends as a disconnect: the engine is
stopped and the turn given back. No text changes.

Test: a client that never reads a 400k-token stream; on the base the turn is still held after 20 s, with the fix it is
given back at once. Part 2 of #4271 (arrival order of the one-request path: the turn is a plain Lock) is not done here.
2026-11-04 11:26:30 +01:00
Niko1221 97da46cc1c Merge f-0213-secure (f374b5029) into f-0213-int 2026-11-04 11:21:31 +01:00
Niko1221 f1fd753de8 serve: a request waiting for the control lines does not miss a wake-up (#4738)
_take_control read its turn, gave up the lock and then waited: a notification sent in between was lost and the waiter
slept up to 0.5 s although it could go. It now reads the turn (and the short-first arbitration) again under the lock
before waiting, and a waiter that leaves (cancelled, closed) notifies the others.

Approach credit: PR #4738. Tests are new (a hooked Condition forces the schedule; on the base the waiter waits).
2026-11-04 11:16:42 +01:00
Niko1221 b3c6338689 serve: a refused batch admission is an error row, not a completed request; engine output with a non-UTF-8 byte is read (#5044)
- GpuBusy before any segment and with zero tokens: the history row says "error" with no prompt tokens and takes no part
  in the totals, latencies or last timings (#3040); a continuation after native work keeps the old accounting;
- the engine's and the image encoder's stdout are decoded with errors="replace": on Windows a byte such as 0x8e ended
  the reader thread (UnicodeDecodeError) and the server lost the engine's output. Protocol lines stay ASCII.

Not taken from PR #5044: the q36 engine OOM messages (engine C++, and the wording goes to the coordinator first).
Approach credit: PR #5044 (Python parts). Tests: test_parallel (refused admission accounting), test_serve_robustness
(a 0x8e byte before READY and in a reply).
2026-11-04 11:16:42 +01:00
Niko1221 0d922698f2 serve: a failed engine start no longer crashes when another thread closed the process (#4792)
StrataEngine.__init__ cleaned up a start that ended before READY through self.proc; restart()'s close() on another
thread had set it to None (AttributeError: NoneType has no attribute stdin, a recoverable engine abort became a 400).
The cleanup now uses the process object of THIS start, captured once right after it was spawned.

Approach credit: PR #4792 (same idea; the test is new: the closing thread is forced inside proc.wait()).
2026-11-04 11:16:42 +01:00
Niko1221 3f6960f1fc serve: ConvCacheLog.poll runs under a lock (#3443)
Polls come from several request and monitor threads. A second poll moved `pos` between the first one's size check and
its read: lines were read twice (parks counted double) and a read length could go negative (ValueError). Found by
HWSTALL while triaging #3443.

serve/test_serve_robustness.py: the overlap is forced with a hooked getsize; on the base every parked line is counted
twice (8 != 4).
2026-11-04 11:16:41 +01:00
Niko1221 65cff54c0d serve --batch: every frame and the end of an exchange belong to one request on one engine process (#4451)
A --batch request read its slot's lines from `self.slot_q[slot]`, the control lines from `self.lines`, and sent its
BSTOP / BYIELD / STOP with the slot number, all looked up when used. After the engine was restarted the same names meant
the NEW process: a request of the old one read (and could stop) the new one's requests. And a request that left before
the engine had ended its exchange (a drain that timed out after 300 s) gave the slot and the control lines back, so the
engine's late T / PP / DONE / BADM / BT frames reached the next request as its own.

- the request notes the queues of the engine process it was admitted on and reads only those; a send meant for that
  process (BSTOP, BYIELD, STOP) is not sent to a later one (EngineDied instead); slot state is only given back on the
  same process;
- an exchange that is not drained in DRAIN_TIMEOUT_S is quarantined: the control lines and the admission's slot stay with
  it, in the background, until the engine gives DONE / BADM (then a continued admission is BSTOPped and freed at its
  BDONE); if the engine never does within QUARANTINE_S it is ended, as a silent engine is (#481). A slot's BDONE wait that
  times out ends the engine too, instead of freeing a slot whose lines may still come;
- the pump drops BT / BDONE frames for a slot no request has claimed (counted in stale_frames);
- _ctl_mode, _ctl_result, _yielded, _last_done, _ctl_erred are per request thread on a batch engine;
- serve/test_stream_ownership.py (BatchOwnership): interleaved clients with canaries, a cancel in the middle of the prompt
  read followed by reuse, a drain that times out (DEAFSTOP), the engine ending during a drain and restarting. On the base
  the restart case fails: requests of the old process stay attached to the new process's slots.

The other cases of the register (mid-prefill cancel, drain timeout, slot not handed on) pass on the base with the fake
engine; they are kept as regression tests. The real-engine check follows.
2026-11-04 11:16:19 +01:00
Niko1221 66385404ff replicas: a request owns its replica's pipe until its native exchange is over; DONE figures are the caller's (#2892)
ReplicaEngine.generate released the per-replica run lock in its finally without closing the child generator. The
child's own STOP and drain-to-DONE ran later, when the wrapper frame was collected, after the next request had sent
its GEN: the two read each other's tokens and DONE (an empty reply, or another request's text), and a cleanup error
was lost as an ignored generator exception. The unbatched engine's shared `last` was also read by Service after the
lock was free, so a reply could carry the next request's figures.

- close the child before the lock is given back; a close that fails ends the replica (heal() starts it again) and the
  error still reaches Service (EngineSilent handling is unchanged);
- copy the new DONE figures into a per-caller object while the replica is owned; with no new DONE the caller's earlier
  object stays, so an old result is never recorded as new;
- serve/test_stream_ownership.py: canary replies from a fake engine, a hooked run lock that forces the legal schedule
  (the successor takes the replica right after release), 30 forced hand-offs and 60 unforced back-to-back requests.
  On the base: empty reply for the successor, the predecessor's canary in its text, 30 of 30 forced hand-offs crossed,
  figures of the other request.

Approach credit: PR #2886 (scheme A: explicit child close, caller-local DONE snapshot). Written in this tree.
2026-11-04 11:15:36 +01:00
Niko1221 632b611df9 packaging: check the static PIEs of an AppImage (the bundled ripgrep) and gate the repack on them (#4337)
linuxdeploy rewrites the RPATH of every ELF in the AppDir; for a static PIE (the bundled rg) that leaves .dynamic overwritten and the program does not
start (shown on a real gcc static PIE in tools/test_appimage_elf.py). repack-appimage.sh already put the staged bytes back, but --check covered only
engine-bundle and node, nothing ran the rg, and nothing stopped a pack with a damaged one. appimage_elf.py scans for a damaged .dynamic (garbage tags,
RUNPATH on a static PIE, filled relocation table, filler runs) and compares to the staged files; --check now covers control, harness, node and engine-bundle,
scans, and runs the bundled rg; the pack path runs the same check before it squashes. Whether the released AppImages were repacked is outside this repo.
2026-11-04 11:11:56 +01:00
Niko1221 f8f97d9f36 titles: a chat title is cut at a character, not in the middle of an emoji (#4533, helper only)
shortTitle() cuts at a grapheme (Intl.Segmenter) or a code point, so a title never ends in half an emoji, a lone surrogate or a ZWJ. lib/store.ts still
cuts with prompt.slice(0, 47) in three places (not edited here: the GUIDATA owner's file); they should call shortTitle. Tests cover ordinary emoji,
ZWJ families, flags, skin tones, keycaps, combining marks, Arabic, Hebrew, CJK, and a JSON save and restore.
2026-11-04 11:11:56 +01:00
Niko1221 5af35d050e overlays: menus and popovers are placed in screen pixels and written in the page's pixels, so the sidebar More menu opens at any Interface size (#4653)
Interface size scales the page with CSS zoom on the root. In WebView2 (Edge 156) getBoundingClientRect and innerWidth are screen pixels, but left/top/
width/max-height written on an element are zoomed pixels, so a menu placed from its anchor landed (scale - 1) too far right and down: at 125% on a
1080p window the More menu was entirely off the screen (14 of 18 size and scale cases broken, measured in Edge 156). lib/overlay-place.ts places in
screen pixels (below, else above, else the roomier side shortened, kept inside the window) and divides by a scale measured from the page at the end.
Used by FloatingMenu (More, chat actions, preview file actions), Popover, the Select popup and the two tooltips. No layout or text change at 100%.
2026-11-04 11:11:55 +01:00
Niko1221 c6dda74bf2 tune: when candidates were skipped for memory the result says so instead of 'already the fastest' (#5122)
'N settings could not be tried: not enough free memory' is the result message (and the Terminal's tuning line) only when some candidates were skipped for memory
in a result that otherwise keeps the current settings; the structured outcome is unchanged and values.not_tried carries the count. Owner decision 11-04.
2026-11-04 11:06:16 +01:00
Niko1221 64abc62393 linux window: keep the COLRv1 colour emoji font away from WebKitGTK, which aborts drawing it (#4533, idea of #4485)
The window process (only it) is pointed at a fontconfig file that includes the system configuration, rejects *COLRv1* font files inside
<selectfont> and prefers the outline Noto Emoji. Written once under the data folder, only when a COLRv1 font is installed; FONTCONFIG_FILE already
set, STRATA_FONT_CONFIG=off or =<file> leave it alone. fontguard.rs is plain std with unit tests that run on every platform. The three variables
join the crash report. Also: the desktoplog crash-file test asserted the literal 0.2.0 and failed on any other version; it now follows the
crate version. Linux-only wiring (run_window_process) is not compiled on this PC; no WebKitGTK here to show the crash or its absence.
2026-11-04 11:02:10 +01:00
Niko1221 25fe8bca8e monitor: the Windows drive-temperature reader asks NVMe drives only, starts only for the Monitor screen's own ask and not in the first minute, leaves a marker across a crash and gives up a stuck first pass (#4661) 2026-11-04 11:00:21 +01:00
Niko1221 d0f86f8359 tuning: a memory wait that found nothing freed is not repeated for every later candidate (#4895) 2026-11-04 11:00:21 +01:00
Niko1221 c30aa64754 serve and update: a server started by running serve.py holds the activity lease so strata update sees it, the update defaults to the checkout's engine store, the server's own picture encoder is released when its engine died, and a stream that ends without its end event is an error that restarts the engine (#4463 #4352 #3961) 2026-11-04 11:00:21 +01:00
Niko1221 8431dde5d4 bench run --profile: the engine tree it starts is put in a job object and ended through it, so a refused taskkill no longer leaves it running (#4260) 2026-11-04 11:00:20 +01:00
Niko1221 efaa100b37 host: Stop and a timed-out command end the whole process group on Linux and macOS, as taskkill /T does on Windows (#4529 #4570) 2026-11-04 11:00:20 +01:00
Niko1221 0b5096edd4 app: a start Control stopped waiting for is kept and watched while Control says it is loading, and a ready instance whose engine Control says is gone is marked stopped (#5177 #5059 #4293 #4973) 2026-11-04 11:00:20 +01:00
Niko1221 b526f98104 tests: 256K context + Q4 KV on a 12 GiB / 16 GB card keeps a 1536 MiB reserve and a positive expert cache; the reporter's 3256 file and log (#3750) 2026-11-04 10:56:29 +01:00
Niko1221 fce58233fb App Host: a quiet chat stream carries a beat frame every 15 s so the desktop web view does not drop it; the timer ends with the response; a response that already began ends in the outer catch (#4878) 2026-11-04 10:56:28 +01:00
Niko1221 acbaa4391b connect: the output cap grows with the window, and a rewrite keeps a cap the person set (#5006; #4528 checked, no change)
limit.output, CLAUDE_CODE_MAX_OUTPUT_TOKENS and maxTokens are max(4096, context/8): 16384 at 128K, so OpenCode's compaction summary has room (the tested 32K server keeps
4096). The input limit and compaction point stay as tested. Rewriting a config keeps an output cap other than the 4096 older versions wrote. #4528: the documented
provider block is still accepted by OpenCode's schema (deprecated in favour of 'providers'); no change. Idea as in PR #4230, written here.
2026-11-04 10:56:16 +01:00
Niko1221 5899c5bbfd ui-flows: the saved-chat check reads the per-chat entries (#5192), fixes a syntax slip 2026-11-04 10:55:07 +01:00
Niko1221 380212689a launch: --set mmap-experts takes a word for strata-dsv41 and stays a bare switch for Qwen (#5027)
The engine's flag schema picks which options are valueless: on DeepSeek --mmap-experts on|off|auto keeps its word through derive_config, the one argument
precedence (arg_layers) and a second launch. Approach as in PR #5028, written here.
2026-11-04 10:54:12 +01:00
Niko1221 c3109534d0 setup: a shard with a pinned SHA-256 is hashed before it is marked finished (#4088)
The pre-pass that gave a hand-copied full-length shard the 'whole' mark also marked a shard left by a run that stopped between the rename and the hash check,
so download() skipped it and the pinned hash was never looked at. A file the pin table names is left unmarked now, and download() hashes and repairs it;
--gguf-dir files and files without a pin keep the mark. Both setup flows call one helper. Approach as in PR #4318.
2026-11-04 10:54:12 +01:00
Niko1221 3434ed0e3b composer: the Enter that commits an IME conversion no longer sends, in the event order of each engine (#5071)
WKWebView sends compositionend before the committing keydown, with isComposing false and keyCode 229; WebView2 sends the keydown first. The
composer asks a small guard (lib/ime-enter.ts): a key that says it is composing or has keyCode 229 belongs to the IME, and the first Enter in the
few milliseconds after a compositionend is the commit; the flag is used up by the next key and expires, so the next Enter sends exactly once.
The guard runs before the command menu too. Replay tests for WebView2, WKWebView, WebKitGTK, Latin input, Shift+Enter, a cancelled conversion
and shortcuts. No visible text.
2026-11-04 10:53:24 +01:00
Niko1221 474fe147de desktop: a renderer that dies is recovered, not left on the browser error page (#5079)
Windows: the shell subscribes to WebView2 ProcessFailed; macOS: to the web content process termination. The page's own renderer dying is
written to the run log (kind, reason, exit code, the address it showed) and the page is loaded again after a short wait: the same screen at
/?recovery=1 first, home at /?recovery=2 second, and nothing after two recoveries in two minutes (the window is left as it is). The decisions
are in src/recovery.rs (unit tested). The page reads ?recovery=n before it renders (lib/recovery.ts), removes it from the address, and starts
with the sidebar previews closed until the person opens one or another chat. The chats are untouched. No visible text.
2026-11-04 10:53:20 +01:00
Niko1221 fe4a03e2f5 tuning: the worker ignores STRATA_API_KEY, restart candidates are judged against the base's own free memory, an engine abort shows its own error line and is an OOM (#5056, #5122, #5078)
#5056 the worker is started without STRATA_API_KEY, so it answers to the key the tuner made for it. #5122 a candidate that adds no memory needs what the base run
started with (min of the old peak-RSS rule and base free memory plus the candidate's delta). #5078 a 5xx from the worker adds the engine log's last error lines;
an allocation failure is an OOM, other aborts fail that candidate alone.
2026-11-04 10:44:36 +01:00
Niko1221 a09ab1c781 runtime owner: answers status while it plans and launches, gives the first token its own budget, leaves when its server is gone, follows a process it cannot read only after repeated looks, and is recoverable before its first launch (#5177 #5059 #4293 #4574 #4893 #4250 #4973 #4824) 2026-11-04 10:40:01 +01:00
Niko1221 bae1e1d4d3 serve: a branching follow-up goes to the slot of its chat (#2518)
pick_slot also recognises an earlier message boundary of a slot's held stream (the last question replaced), so the request no longer overwrites another chat's idle slot. A boundary every held conversation shares (one client's system prompt) names no owner; busy slots are never picked. Tests: branch affinity, busy owner, shared root, interleaved branches (retained prefix 96000 -> 115200 of 115200 tokens).
2026-11-04 10:38:48 +01:00
Niko1221 ac944fc1f8 chats sharing a folder: a turn reports only the files its own tools wrote (#5008)
Two chats in one workspace folder: the end-of-turn sweep of chat A listed the files chat B had just written as
chat A's deliverables, and the other way round. A registry shared by the turns of the host (FolderClaims) now
records which chat's file tool wrote which file; the sweep skips files another chat claimed, and for turns that
overlapped in the same folder it also leaves out files nobody claimed (a script's output cannot be told apart),
rather than give them to the wrong chat. A turn that ran alone behaves as before. Chat records, tool cards and
approvals were already keyed by chat id and session id; a store-level test with two interleaved live chats and
the same call id in both pins that down. The per-folder bucket, the shared model cache and the approval-policy
notice described in the issue are Harness and engine behaviour, not changed here.
2026-11-04 10:38:45 +01:00
Niko1221 faf3db296e Web page: /?q= fills in the question and focuses the box; nothing is sent until the person presses Send (#4221) 2026-11-04 10:38:41 +01:00
Niko1221 16ff27af85 App Host: an empty or malformed Host header (or a request target that is not a path) gets a 400 and the host keeps running (#4397); the shared listener compares the API key in constant time over SHA-256 digests, as do the other two header secrets (#4535) 2026-11-04 10:38:41 +01:00
Niko1221 5bf9626fed SYCL server: the engine_args wrapper takes the base signature (exe, announce) and forwards it (#5086)
Every SYCL start died with TypeError: unexpected keyword 'announce' because install_two_card_spec wrapped engine_args(cfg) while serve/server.py calls it with exe
and, for the memory budget check, announce=False. The quiet call prints no line. Tests run the real base function under the wrapper.
2026-11-04 10:38:22 +01:00
Niko1221 f374b5029d quant_formats --hf: the header takes the file's size from the tree listing, so gguf_reader's bounds checks apply (#5075)
Approach from PR #5075. --hf failed on every repository with AttributeError: 'Header' object has no attribute '_size'; a cut or huge-length header is now a ValueError that names the file.
2026-11-04 10:38:09 +01:00
Niko1221 3bbf0f399e tool parser: streamed arguments keep a file that lists the closing tags (#5040, #4701)
Approach from PR #5040: _close_verdict holds while </tool_call> is still arriving, and the streaming scan of a body whose end is known runs as final. #4701 (final call) and #4702 (longer code fence) are already fixed on the base; the corpus gets the four 4701 specimens (PR #4762 and #5040 fixtures) and runs them whole, in pieces and cut at every byte, with streamed arguments on and off.
2026-11-04 10:37:34 +01:00
Niko1221 06eb02b1da engine choice: an integrated GPU no longer outvotes a discrete card; unlisted Arc models and card numbers above 9 are still seen (#4971, #5111)
_drm_vendor_backend skips a make whose every card reports a small own memory (the 512 MiB carve-out of an APU) while another make has a card;
intel_arc_cards reads each entry on its own, takes card10 and up, and counts an unlisted Battlemage/Alchemist id as an Arc of the family's smallest memory.
The launcher's merge of the Arc beside other makes was already in place. Backend half as in PR #5184, written here.
2026-11-04 10:37:15 +01:00
Niko1221 c7596ce873 preview: bound the Markdown preview so an unchanged file costs nothing while tokens stream (#5180)
The sidebar parsed and rebuilt the whole file on every render of the chat. It now renders through PreviewMarkdown: memoised by the text, cut into slices at block boundaries (each memoised, keyed by place and content), two slices at first and two more per waiting step (one timer, cancelled by a newer text), a block above 40000 characters and a file above 200000 shown as plain text. No visible text, no layout change.
2026-11-04 10:36:19 +01:00
Niko1221 ad142b468c launch: one precedence for engine options; an explicit --set draft-conf and calibrated values survive (#5178, #5132, #4985)
Explicit launch values, then persisted overrides (user_args and hand edits to the launch config), then calibrated values (recorded by
engine calibrate --apply-result with the model and context they were measured for), then Auto. derive_config builds the argv once through
strata_control/arg_layers.py; explicit values go to user_args so the DFlash2 policy keeps them, and are not remembered when left out next time.
2026-11-04 10:36:06 +01:00
Niko1221 23dda3099f launch --stop: a failed stop exits nonzero and no longer ends with 'Nothing is running' (#4398)
Failed stops are counted apart from stopped ones; the empty-list line is for zero targets only. No wording changes: the existing lines are reused.
Approach as in PR #4568.
2026-11-04 10:36:05 +01:00
Niko1221 2d5319cef1 launch: a damaged profile no longer lends its neighbour's config or mints a new API key (#4395, #4396)
running_launches binds prof before the try so a failed load uses this row's defaults; kept_key skips list rows that carry only an error
and keeps looking for a valid key. Approach as in PR #4562 and #4563; tests use real temporary profiles in both orders.
2026-11-04 10:36:05 +01:00
Niko1221 ff36fa9282 images: the prompt reaches sd-cli in an owner-only file (--prompt-file), not as a process argument (#4202)
Approach from PR #4204. sd-cli at the pinned stable-diffusion.cpp commit reads --prompt-file as raw bytes (checked in examples/common/common.cpp at 2988060). The job folder is the 0700 mkdtemp one; the file is 0600, created exclusively and removed with the job's other files. The stand-in sd-cli reads the file; a canary test checks the recorded argument list.
2026-11-04 10:36:00 +01:00
Niko1221 4be6a003aa bench: results.json gets the same redaction as the other report files; a credential of "none" is no key; the key is replaced as a whole word in its own case (#4484, #4483)
Approach from PRs #4500 (one redact_object call before validation) and #4599 (none is no key, whole word, own case). tools/strata_bench.py gets the same none rule and key matching.
The 8-character floor of #4599 is not taken: a short key is still removed where it stands alone.
2026-11-04 10:34:11 +01:00
Niko1221 8181522f15 Pillow 13.0.0 on Python 3.11+ (GHSA-78g4-xqwv-q3g8, OpenJPEG heap overflow) and a decoder format list (#4220)
requirements.txt and the four hash-locked terminal locks (windows, linux x86_64, linux aarch64, macOS arm64) move to 13.0.0; Python 3.10 stays on
12.3.0. Image.open() in the vision path, the EXIF probe and the DeepSeek picture reader now only opens JPEG, PNG, GIF, BMP, WebP, TIFF and AVIF, so a
JPEG 2000 file never reaches OpenJPEG on any Pillow version. Approach from PR #4220 (version split by Python range).
2026-11-04 10:34:11 +01:00
Niko1221 a837fb450b chats: save every chat whole, one entry per chat, and report a full storage instead of trimming (#5192)
The saved chat list cut long fields, dropped the oldest messages of a long chat and left old chats out of the
list when it passed 3.5M characters, and every restart trimmed the chats again (12 messages of 140,000
characters came back as 6). Now each chat has its own storage entry holding the chat exactly as it is, the index
only keeps the order, and a chat the browser refuses keeps its last good copy, stays whole in memory, is tried
again at every save and is reported (state.sessionSave, unsavedSessionsJson). The old single-entry list is moved
over chat by chat and a chat leaves it only after its own entry was written. Approach from PR #5192 (never remove
chats) kept; the field clipping it still did is gone.
2026-11-04 10:31:29 +01:00
Niko1221 888c5eca84 tests: the GB10 doctor row pins sys.platform 2026-11-04 10:26:52 +01:00
Niko1221 eb4f41fb9a Control: the OS and architecture are read without WMI on Windows, so a stuck winmgmt cannot hang models list or a load (#4975, #5185 as reference) 2026-11-04 10:26:40 +01:00
Niko1221 a3a10b90af owner decisions: plan cards keep the screens' count and add "(--gpu N)"; refusal says "free"; app tells the plan which engine it will unload
- gpu_roles.describe(card_numbers=True) prints "GPU <n+1> (--gpu <n>)"; the two test_launch.py assertions are back on the old digits plus the hint. The Terminal has no translation catalog for this line.
- split_engine: the refusal sentence ends "has 7 GiB free)" when the free memory was read (the size, as before, when it was not).
- the desktop app asked `drafting plan` / `drafting fetch` before it unloads the running model, so its own engine counted as busy memory on a model switch and the plan could pick DFlash2 and fetch the 1.2 GB drafter. New flag --engines-unloading (hidden from --help), sent by native-control.mjs for a load that unloads the running instances (not for a model added beside them); same effect as Runner.card_probe. Logic only, no text.
- tests: python (drafting plan with and without the flag), node split-app.test.mjs (flag on the argv, a 2-card model switch, a model added beside).
2026-11-04 09:32:34 +01:00
Niko1221 0e76645a73 tests: the plan's cards line in test_launch.py counts from card 0 (the numbers --gpu takes) 2026-11-04 07:26:12 +01:00
Niko1221 ac1dfef883 launcher: draft layer also found in --data-dir, split engine counts free VRAM, plan card numbers are the --gpu numbers
- discovery.prepared_beside(gguf, data_dirs): after the models-folder walk it also looks in --data-dir and the remembered data folder for mtp/rt, the pack and the picture reader (setup writes them there); plan, download_plan and the own-files preview pass them. A model folder outside the data folder no longer asks for the 5.2 GB layer a second time.
- split_engine: Card.free_mib from the same nvidia-smi call (memory.free; AMD: the render node's vram_used), fit = min(size, free) - 700 MiB; busy cards get the layer split with DFlash2 and no draft-layer part. Runner.card_probe leaves free memory out while a server of this launch (same profile or port) is about to be stopped. Free cards decide as before.
- plan: the cards line prints the numbers --gpu takes and leaves out cards CUDA_VISIBLE_DEVICES (AMD: ROCR/HIP_VISIBLE_DEVICES) hides; the split probe respects the same variables when no card is named. Digits only.
- tests/terminal/test_fix3_0212.py (41 tests); launch_support.Home clears the visibility variables.
2026-11-04 07:11:31 +01:00
Niko1221 1fef27e836 setup: no "--prefill auto:32768" tip where a bare auto already takes 32768 (one NVIDIA card of compute capability 8.0+, no STRATA_NV_CHUNK=0 / STRATA_DEFAULTS=026, no layer split); docs: DETAILS.md describes the NVIDIA ceiling
Logic only, no text changed: bench_tips takes the GPU and leaves the tip out for those configs; older cards, AMD, a layer split and no GPU information keep it. The explicit auto:32768 warning is unchanged. test_setup_bench_tips covers it (10 passed on l620).
2026-11-03 20:37:45 +01:00
Niko1221 aa12025515 NVIDIA: a bare --prefill auto takes a 32768-token chunk ceiling on cards of compute capability 8.0 and up (owner 11-03, after the full suite passes)
The gfx12 rule in generate.cpp now serves both: nv_chunk.hpp (STRATA_NV_CHUNK=0 or STRATA_DEFAULTS=026 keep 8192, =16 keeps 16384, =1 skips the RAM guard), the fit scan still picks a smaller chunk when the cache cannot lend the buffers, the #2090 RAM guard steps 32768 -> 16384 -> 8192, and a layer split takes 8192 back (STRATA_SPLIT_CHUNK16K=1 keeps the ceiling). An explicit --prefill N, auto:N or STRATA_PREFILL_AUTO_MAX is never touched. Volta / Turing / Pascal keep 8192.
A6000 UD-Q4_K_XL, 13.1K of 24.6K experts in VRAM, MMQ_KQ on: prompt 20K +43.4%, 32K +22.5%, 64K +21.3%, 8K 0.0%; cards that hold every expert: flat.
nv_defaults_test covers the ceiling, the switches, the card rule and the step-down.
2026-11-03 20:37:28 +01:00
Niko1221 dbdd81a675 Merge f-0212-strix (final 8b98b6f1c) into f-0212-int 2026-11-03 19:14:15 +01:00
Niko1221 8b98b6f1ca Strix Halo: an oversized prompt chunk is refused at the start instead of killing the engine after the prefill
An integrated GPU cannot report how much room is left (the pool reads 0 MiB free on a start that works). With a chunk above the 16384 default that the person or the environment names (STRATA_PREFILL_AUTO_MAX=32768, --prefill auto:32768) and a UD-Q4_K_XL cache that fills the pool, the driver could not create a stream (PAL failed to submit CMD) and the first launch after the prompt buffers died with HIP 719. Once the prompt path's own buffers are in place the engine now tries one stream, fill and wait; if that fails it prints one line naming the chunk, the cache and the 16384 alternative and ends with exit code 2, before any prompt is read (HIP builds, integrated GPU, own buffers, chunk above 16384 only; the 16384 default, CUDA and Metal are untouched; STRATA_PF_OWN_PROBE_OFF=1 skips it). The failed context cannot be reused, so the engine cannot step down in the same process.

The #1630 warning about a named chunk of 8192 or more no longer prints on gfx1151 (it said a bare --prefill auto stops at 6144, stale since 0.2.8); it is printed once the device is known. STRATA_TRACE marks around the prompt path and the buffer size of the chunk.

setup: the bench tip for --prefill auto:32768 is not said on an APU (apu_hides_chunk_tip). The tuner's prefill_32k arm needs a discrete GPU. The app's saferStart does not retry a start that ended with the chunk refusal (the same chunk would fail again, and a memory setting is not the cause). Tests for all three.
2026-11-03 19:11:00 +01:00
Niko1221 a1c7f05059 serve: an explicit --draft dflash2 with --batch N turns the batch slots' DFlash2 drafts on for one card (STRATA_BATCH_DF2=0 still turns them off; --draft auto and MTP unchanged) 2026-11-03 17:54:13 +01:00
Niko1221 081edf2d9b Merge f-0212-df2choice (final 181b668e5) into f-0212-int 2026-11-03 17:54:12 +01:00
Niko1221 181b668e5a app: a named DFlash2 with a number of requests passes Expert mode's batch-mtp wish on as in 0.2.10, so the Metal fallback to the MTP keeps its --batch-mtp
The server drops --batch-mtp when DFlash2 runs and keeps it when the MTP runs; a test pins the Metal fallback arguments equal to the MTP's.
2026-11-03 17:52:57 +01:00
Niko1221 ae5e73de20 drafting: a DFlash2 the person chose by name keeps the number of requests they chose (DFlash2 with N requests); Auto and the MTP unchanged
- drafting.blocked_by(named=True): only --pipeline-windows still blocks; Metal keeps the old rule (no per-request drafters there)
- apply/launcher/plan pass the choice; the all-cards split engine runs --draft dflash2 --draft-model PACK only for DFlash2 by name AND --split-engine on
- the split engine's own DFlash2 start line counts as "DFlash2 on"; a split run config with --draft dflash2 is left as it is
- app Drafting hint and Terminal --drafting help say it (owner-approved wording, English only)
- tests: terminal drafting/single/split-parallel, split engine, serve parallel, node parallel-default
2026-11-03 16:28:14 +01:00
Niko1221 938b76eb3e gfx12 prompt staging ring (a1e6ef324): only on a gfx12 card. The branch was keyed on the matrix-core attention being active, which the Strix Halo (gfx1151) also has on by default on Windows, so it took the 256 / 192 slot ring that was measured on an R9700 only. gfx1151 keeps the 96-slot ring of 0.2.10; gfx12 is unchanged. 2026-11-03 16:13:46 +01:00
Niko1221 75bbf42912 docs: the split engine's opt-in DFlash2 draft source (engine flag only; Auto keeps the MTP draft layer) 2026-11-03 14:55:40 +01:00
Niko1221 c65b9f8be8 Merge branch 'f-0212-int' into f-0212-splitdf2 2026-11-03 13:57:10 +01:00
Niko1221 0748e92701 split: DFlash2 rounds follow the draft layer's length rule, lanes with no planned drafts bank their taps (FNX_DF2_DEFER, FNX_DF2_REFRESH), the batch planner prices a drafter round per lane that drafts (RowOffer::first_ms), FNX_DF2_TRACE, FNX_SPECULATE=0 reference 2026-11-03 13:57:09 +01:00
Niko1221 f6cea71a78 Merge f-0212-tailskip (final) into f-0212-int 2026-11-03 12:56:48 +01:00
Niko1221 2d8ead05b8 Merge f-0212-parallel (final) into f-0212-int 2026-11-03 12:56:48 +01:00
Niko1221 212d15ba88 Merge f-0212-nvsingle (final 52ac4e733) into f-0212-int 2026-11-03 12:22:49 +01:00
Niko1221 52ac4e7337 NVIDIA: STRATA_MMQ_KQ=1 is on by default for compute capability 8.0 and up (owner yes 11-03): K-quant experts (UD-Q4/Q5/Q6_K_XL) read the prompt on the MMQ kernels
kNvKqDefaultOn is true; STRATA_MMQ_KQ=0, STRATA_NV_KQ_DEFAULT=0 and STRATA_DEFAULTS=026 turn it off, a STRATA_MMQ_KQ the user sets is kept, Volta / Turing / Pascal keep the FP16 prompt path. A pack without K-quant experts never reads the variable.
Start check (RTX PRO 6000, UD-Q4_K_XL, no STRATA variable set): the start prints the K-quant MMQ line and loads libstrata_kq; 4K prompt GPU timeline 1,047 ms (dequant 7.3%) against 1,500 ms (dequant 25.1%) with STRATA_MMQ_KQ=0, prompt 2,854 vs 2,233 tok/s (one request each).
2026-11-03 12:20:34 +01:00
Niko1221 c850b979b5 split: DFlash2 as the draft source of the all-cards split engine (--draft dflash2 --draft-model PACK, FNX_DRAFT=dflash2). One drafter a lane on card 0 through df2_bridge (src/core/dflash2.cpp with bind() compiled out); the window graphs tap layers 5/15/25/35/45 on card 0 (df2_tap_gr), the prompt path taps each card's own rows into a host ring per lane; Session and BatchDecoder take the drafts after the commit. Greedy 6.4K and 27K answers identical to no drafter and to the draft layer (UD-Q4, 2 x A6000) 2026-11-03 12:16:09 +01:00
Niko1221 95cb87d427 Merge f-0212-multispill (final 31a735153) into f-0212-int 2026-11-03 12:07:25 +01:00
Niko1221 c2de8af1c4 setup: the route-tail skip stays on for Unsloth files on Linux gfx12 (R9700, RX 9000)
Owner 11-03 ("3 Yes"). Since 0.1.44 setup wrote STRATA_ROUTE_TAIL_SKIP=0 into the run config of every Unsloth file whose experts are all in RAM. On Linux gfx1200 / gfx1201 it no longer
does: the engine's own default (rank 7, applied only while the variable is unset, so =0 still turns it off) runs. UD-Q4_K_XL on one R9700: decode +6.2% (2 rounds x 8 classes, interleaved) and a
median +15.6% over the 61 suite tasks; the suite scores 56/61 against 55, 57 and 56 for the default arms; start line "STRATA_ROUTE_TAIL_SKIP=7 is ON (default)". Teacher-forced decode-window
KL against no skip 0.0773 (noise floor 0.00000, top-1 91.1%; agent segment 0.20) on a static, low-hit-rate cache, an upper bound for real use. Windows and other cards keep the 0.
unsloth_default_env takes the card's arch (default "": the old behaviour); test_tail_skip_stays_on_for_linux_gfx12 pins the rule.
2026-11-03 11:57:52 +01:00
Niko1221 dfc675848f Merge f-0212-amdsplit (final 9149835c6) into f-0212-int 2026-11-03 11:55:52 +01:00
Niko1221 31a7351534 Layer split: price DFlash2's head, logits and staging into the last stage's room 2026-11-03 11:51:52 +01:00
Niko1221 ee0b367fb7 Layer split walk: early trim for every card, stage late-allocation reserve, fewer-cards step, min card 3 GiB; docs 2026-11-03 11:48:08 +01:00
Niko1221 c2335eebba Merge f-0211-nvdual (final f95411b84) into f-0211-int 2026-11-03 11:41:43 +01:00
Niko1221 9149835c6a Auto starts the split engine on 4 x R9700 for the IQ3_S set (and the other i-quant / Q2_0 expert types). Owner's yes 11-03. With the shared expert fused into the routed launches the split beats the layer split on IQ3_S: 1 user 186.8 against 104.2 tok/s, 8 users 364.6 against 320.7 through launch; 61-task suite 57/61 against 54/61; first-token KL vs the layer split 0.041. 2 and 3 cards unchanged; --split-engine off keeps the layer split 2026-11-03 11:39:16 +01:00
Niko1221 62853e78de FP16 weight cache on by default when the engine runs batch slots on Linux CUDA (3 GiB kept free); gemm_w16_parity test
gemm_dequant_side_parity failed with the cache because its synthetic scratch (10.5 M elements) is smaller than four of its weights, so uncached they take the sliced path and cached one full-N GEMM. The engine's scratch is 32 M elements and every dense projection fits it. gemm_w16_parity runs the engine's scratch, shapes and types, cache off against on, bitwise. STRATA_PF_W16_CACHE_MIB=0 turns it off.
2026-11-03 11:29:44 +01:00
Niko1221 f95411b843 split: the int8 prompt attention is on for the IQ3_S-type files and off for the others (UD-Q4_K_XL: 57/61 with it off, 55/61 with it on, agent-A8 and A10), like GDN_TC; the variable overrides 2026-11-03 11:22:25 +01:00
Niko1221 e2f7c47447 NVIDIA: STRATA_MMQ_KQ=1 as a candidate default for K-quant experts (UD-Q4/Q5/Q6_K_XL prompt on the MMQ kernels), behind STRATA_NV_KQ_DEFAULT=1 until the owner says yes
UD-Q4_K_XL's Q4_K gate/up and Q5_1 down experts read the prompt through the FP16 dequant path plus cuBLAS on CUDA (STRATA_PREFILL_TIMING, 8K prompt on an A6000: dequant 21%, gemm gate/up 24%, gemm down 13% of the GPU timeline).
With STRATA_MMQ_KQ=1 (libstrata_kq): RTX PRO 6000 (sm_120) prompt 4K +37.7%, 8K +25.8%, 20K +34.3%, 32K +32.6% (n 4 per cell, 2 rounds, all won); A6000 (sm_86) 4K +1.7%, 8K +9.0%, 20K +8.1%, 32K +11.2%. First-token KL against the FP16 path over 24 prompts of 2K: 0.0243 (PRO 6000) and 0.0351 (A6000), top-1 23/24 and 22/24.
nv_kq_default_env / apply_nv_kq_default: compute capability 8.0 and up (lowest card decides), off switches STRATA_MMQ_KQ=0, STRATA_NV_KQ_DEFAULT=0, STRATA_DEFAULTS=026; kNvKqDefaultOn stays false, so nothing changes by default.
2026-11-03 10:18:17 +01:00
Niko1221 e318910206 FP16 weight cache: STRATA_PF_W16_CACHE_MIB=auto sizes it by the card (3 GiB kept free); tester switch, the default stays off 2026-11-03 10:16:40 +01:00
Niko1221 fc5dac23c5 Layer split spill walk: stage-weight trim for short cards, drafter drop and head on host per card, KV window and fewer-cards steps; launcher retry without a drafter and the engine's own reason as the last line 2026-11-03 10:02:18 +01:00
Niko1221 3671e77acd split: the shared expert rides in the routed gate|up launch for every expert type, not only Q4_K (FNX_SH_FUSE=0 turns it off). The IQ and the other layers ran it as three launches on a second stream with an event fork and join, 48 layers a window; k_moe_gu_sd carries its swiglu and down as extra rows of blocks like k_moe_gu_q4k does, with the same lanes a row and summation order. Greedy answers (short, 6.5K, 26K) identical for IQ3_S and UD-Q4; IQ3_S 4 x R9700 1 user 115 -> 203 tok/s, 8 users 211 -> 351; UD-Q4 179 -> 186 and 367 -> 385 (n=1 pair) 2026-11-03 09:57:55 +01:00
Niko1221 22f4dbe0e1 split: the NVIDIA prompt path takes 8192 rows x 4 chunks only if the free VRAM after the weights holds it beside the K/V pool (UD-Q4_K_XL on 2 x 48 GB did not start); else 8192 x 2, 4096 x 4 or 4096 x 2 2026-11-03 09:46:00 +01:00
Niko1221 55a2bcd474 Merge f-0211-nvdual into f-0211-int
Conflict in src/split/src/prefill.cu (pf_ring and the ring wiring): kept both sides. The HIP all-to-all small-chunk path stays, guarded by !own_out (the own-rows output is the NVIDIA hyper-connection split's), and the ring runs with nvdual's own_out argument; in the next-card wiring the HIP a2a box/flag copies and the hyper-connection split's input pointers are both kept.
2026-11-03 08:46:45 +01:00
Niko1221 07e1508893 Merge f-0211-hybrid into f-0211-int 2026-11-03 08:46:30 +01:00
Niko1221 2613b4de5a Merge f-0211-r9700one into f-0211-int 2026-11-03 08:46:29 +01:00
Niko1221 66cfc4bcdb Merge f-0211-r9700split into f-0211-int 2026-11-03 08:46:29 +01:00
Niko1221 d2dc05557e split: gdn_tc_set_default and its flag live in namespace fnx (link) 2026-11-03 08:41:39 +01:00
Niko1221 0ffbf096f7 split: g_gdn_tc_default is defined where the scan reads it (kernels part 4), before its use 2026-11-03 08:37:41 +01:00
Niko1221 fcd1493c1a split: gdn_tc_set_default is called from the Engine constructor (engine.cu includes kernels.hpp), so the HIP build compiles; GDN_TC stays NVIDIA-only 2026-11-03 08:34:32 +01:00
Niko1221 c2d44e93ac gfx12: K-quant experts read the prompt on MMQ by default; bare --prefill auto takes 32768-token chunks
Owner 11-03 ("do all 5"), two prompt defaults for Linux gfx12 (R9700), both changing rounding slightly and both with their off switch.

STRATA_MMQ_KQ=1 joins the gfx12 value-changing defaults (gfx12_extra.hpp, applied by apply_arch_defaults, so a raw engine gets it too; Linux only like the
other approved gfx12 prompt defaults; STRATA_MMQ_KQ=0, STRATA_GFX12_DEFAULTS=0 or STRATA_DEFAULTS=026 turn it off, a value the user sets wins). The experts of
Unsloth's UD-Q4/Q5/Q6_K_XL files (Q4_K, Q5_K, Q5_1, Q6_K) then run their prompt products on llama.cpp's MMQ kernels instead of being dequantized to FP16 and
multiplied by hipBLASLt (UD-Q4_K_XL ran 25K dequant launches and ~50K small Tensile GEMMs per 8K prompt). R9700, same job, interleaved: prompt 4K +10.3%,
8K +14.3%, 32K +16.8%; first-token KL against the FP16 path 0.031 over 24 prompts of ~2K (agent turns 0.065), top-1 23/24. A pack without K-quant experts never
reads the variable. The fused variant (STRATA_PF_FUSED_KQ) stays a flag.

The gfx12 ceiling of a bare --prefill auto goes from 16384 to 32768 (gfx12_chunk.hpp, pure functions with tests). A 20K prompt is one chunk instead of
16384 + a 3.6K tail, and every chunk of 1,024 tokens or more streams all the experts the card does not hold. R9700, IQ3_S, 3 rounds interleaved: 20K +12.1%,
64K +2.6%, 32K +1.3%, 8K +0.9%; UD-Q4_K_XL 20K +26%; first-token KL against 16384 at most 5e-5. The lent slots are taken only for what a prompt needs and are
refilled after it; the RAM guard (#2090) now steps 32768 -> 16384 -> 8192 until the extra loan fits. STRATA_GFX12_CHUNK=16 keeps 16384, =0 keeps 8192.

gfx12_extra_test covers both (gfx only, Linux, off switches, user value wins, the ceiling and its step-down); docs/DETAILS.md lists them.
2026-11-03 08:32:12 +01:00
Niko1221 d00d937b89 split: Pf::down_lt is declared outside the NVIDIA-only cublasLt block, so the hyper-connection checks in prefill.cu compile on HIP (HIP never sets it) 2026-11-03 08:30:58 +01:00
Niko1221 213f1a3905 Engine: the auto CPU/PCIe miss share follows the CPU pool by default on a discrete card: ceiling 0.95, factor k falling from 1.85 at 30 GB/s of expert bytes to 0.8 at 12, and a CUDA layer split refines its stage shares (STRATA_PCIE_AUTO_SPLIT=0, STRATA_PCIE_K_RULE=0 and STRATA_PCIE_SHARE_MAX=0.60 restore 0.2.10; integrated GPUs, HIP layer splits and STRATA_DEFAULTS=026 keep it). Pure rule in pcie_share_rule.hpp with a test. 2026-11-03 08:27:33 +01:00
Niko1221 6983b09bba split (owner 11-03, do all 5): FNX_PF_ROWS 8192, FNX_PF_HCSPLIT mode 2 and FNX_PFX_ATTN_I8 on by default on NVIDIA; FNX_PFX_GDN_TC off for the IQ3_S-type files (decided in the engine from the file's routed-expert types), on for the others; passes limited to 8192 rows while other requests decode; tests 2026-11-03 08:26:30 +01:00
Niko1221 38c08443e0 split: a short last chunk is a pass of its own only after at least 3 whole chunks (with fewer the pass is unsplit anyway and the chunks hide each other's sums) 2026-11-03 06:33:56 +01:00
Niko1221 37e4fcf9ac split: the hyper-connection split needs at least 3 chunks in a pass (2 or 1 chunk: slower than the plain read) 2026-11-03 06:17:40 +01:00
Niko1221 cde3399b31 split: with the hyper-connection split a short last chunk is a pass of its own (prompts under 16K were not split at all: the tail kept the pass unsplit) 2026-11-03 05:37:45 +01:00
Niko1221 a1e6ef3246 gfx12 prompt staging ring: 256 / 192 slots below 12K-token chunks instead of the fixed 96
The Linux gfx12 matrix-core prompt path hard-set the streamed-expert ring to 96 slots (measured before the fused IQ3_S kernels). A layer's streamed
experts want a deeper ring at small and middle chunks. R9700 (gfx1201), IQ3_S, 3 rounds x 2 reps interleaved, prompt tok/s against ring 96: ring 256
4K +12.5%, 8K +2.4%, 2K +1.4%, 32K -0.4%; ring 192 4K +11.8%, 8K +4.4%, 32K -1.3%. New default build against the old one: 2K +2.3%, 4K +16.1%,
8K +2.7% (3/3 rounds each), 20K and 32K +0.1%, decode unchanged. UD-Q4_K_XL (MMQ): ring 192 4K +6.9%, 8K +9.5%, 20K +4.0%.
The ring is staging depth only: first-token full-vocabulary logits are bit-identical (md5) for ring 96 / 192 / 384 on 6 prompts of 8K to 64K; greedy
128-token hashes with static residency are identical on UD-Q4 (12/12) and, on IQ3_S, the few divergent requests occur in ring-96 engines as often
as in ring-192 engines (the known HIP timing divergence of an engine's first requests).
Chunks of 12,288 tokens and more keep 96. The ring is capped at ~800 MiB of blobs. STRATA_PREFILL_RING=N and STRATA_GFX12_RING=0 restore the old value.
Only the Linux gfx12 branch (matrix-core prompt attention active) changes; gfx1151 and Windows keep their rule.
2026-11-03 05:09:40 +01:00
Niko1221 e364620c56 Engine: two tester switches for the auto CPU/PCIe miss share: STRATA_PCIE_SHARE_MAX (ceiling, default 0.60) and STRATA_PCIE_K_RULE=1 (pool factor k falls from 1.85 at 30 GB/s of expert bytes to 0.8 at 12). Defaults unchanged. 2026-11-03 04:46:13 +01:00
Niko1221 400e26e3a8 split: fix the two log lines of the hcsplit decision 2026-11-03 04:20:27 +01:00
Niko1221 0ed3a193ac tests: split hcsplit pictures 2026-11-03 04:10:15 +01:00
Niko1221 9f891886d6 split: the hyper-connection split (exact mode) is the default on two NVIDIA cards when the calibration finds the algorithm; 4 chunks a pass unless other requests decode; tests 2026-11-03 04:09:53 +01:00
Niko1221 73dabf548d 0.2.10.1 App: Auto uses the all-cards split engine wherever Control says the Terminal does
Host: draftingPlan/draftingFetch no longer pass --no-split (only when told noSplit, after the draft layer for the split could not be downloaded); the start asks Control
(splitEngine) to turn the run config into the split engine's before the profile is saved, the second try after a failed start is never split, the load waits at least 10 minutes
for a split start, and the Details line says what runs (or that the server started the layer split). A failed MTP layer fetch ends in one plain note and the 0.2.10 layer split
plan. Screen: the split word counts with two or more cards; the download note and the Drafting hint say Auto is the split with the MTP draft layer (text only, two new strings in nine languages).
Tests: tests/split-app.test.mjs (fake Control, 13 tests); drafting.test.mjs and ui-r3.test.mjs updated.
2026-11-03 04:06:55 +01:00
Niko1221 3f15274e8f 0.2.10.1 Control: the app starts the all-cards split engine like the Terminal
`strata split-engine apply --config IN --out OUT` turns the run config the app built into the split engine's (same steps as strata launch: rule, strata-split-N beside the
engine, draft layer on disk, lanes 1..8 from "parallel"); nothing is written on a no. Shared helpers in split_engine.py (exe_finder, draft_layer_present, apply_run_config,
split_plan_line), used by the launcher too. `strata drafting plan` asks the rule with the choice in hand (a saved DFlash2 under an Auto on the screen is Auto), says split for
an explicit MTP as the Terminal does, and uses the Terminal's plan line; expected() refuses a conversation memory other than int8 as the start does. --no-split stays.
Tests: AppPlanEqualsTerminalPlan, AppStartsTheSplit replace AppDoesNotSplit; docs/SPLIT_ENGINE.md describes the app.
2026-11-03 04:06:55 +01:00
Niko1221 c984ab42a0 split: FNX_PF_HCSPLIT=1 splits only passes of whole 4096-row chunks and uses the calibrated algorithm only at 4096 and 2048 rows (exact mode); =2 any chunk of 512 rows or more 2026-11-03 03:52:10 +01:00
Niko1221 e8570ac616 split: FNX_PF_HCSPLIT runs the hc down GEMM through cublasLt with an algorithm calibrated at start to give the same rows at 2048 and 4096 rows (and cublasGemmEx's at 4096) 2026-11-03 03:42:59 +01:00
Niko1221 8088d056f0 split HIP: chunks of up to 256 rows are summed all-to-all instead of round the ring (FNX_PF_A2A_ROWS, with FNX_PF_PEER). Each card stores its f32 addends into the other cards' mailboxes, raises a flag per peer, and sums the chunk itself in the ring's own order and roundings: bit-identical to the ring, one cross-card latency instead of three hops and the gather. 29-row prompt pass 212 -> 139 ms, 6-row pass 206 -> 145 ms (RCCL: 156 / 149); greedy answers (short, 6.5K, 26K) identical to the ring and to RCCL 2026-11-03 03:21:46 +01:00
Niko1221 deebb73a65 split HIP: the prompt chunk sums through the peers' device memory (FNX_PF_PEER, default on when the peer push is on and RCCL is not in use). Receive slots, gather rows and flags live in fine-grained device memory of the receiving card; the sender's kernels store into it. Same sum kernels as the host exchange: greedy answers at 6.5K / 26K / 103K tokens identical to the host sums, 103K prompt 33.4 s -> 30.4 s 2026-11-03 02:52:12 +01:00
Niko1221 750cbf0dbd split HIP: i-quant decode dots (IQ3_S, IQ3_XXS, IQ2_S) with a cheaper sign expansion, the same integers as ggml's (0 of 25M random dots differ, tools/iqdot_test.cu; IQ3_S greedy 26K-token answer identical to the base build) 2026-11-03 02:52:12 +01:00
Niko1221 697fec9d06 split: FNX_PF_HCSPLIT_FULL=2 debug 2026-11-03 02:19:42 +01:00
Niko1221 8969119a42 split: FNX_ONEHOP=2 (screen): the one-hop window sum on two cards 2026-11-03 02:15:50 +01:00
Niko1221 a2aa1f3122 split: FNX_PF_HCSPLIT_FULL debug switch 2026-11-03 02:11:05 +01:00
Niko1221 d716ccfca6 split: FNX_PF_HCSPLIT swap on its own stream, FNX_PF_HCSPLIT_F16 (wip) 2026-11-03 02:05:20 +01:00
Niko1221 838b2e0de6 split: FNX_PFX_ATTN_I8 default back to off (agent-A10 failed 3 of 3 suite runs with it on); FNX_PFX_GDN_TC stays on; FNX_PF_HCSPLIT (screen, wip) 2026-11-03 02:00:38 +01:00
Niko1221 8fbf6b1902 Split engine on AMD: the default lane count is 8 (was 6). A plain launch on 4 R9700 ran 8 users in two waves (255 tok/s aggregate); with 8 lanes they run in one (350). The K/V pool and buffers of 8 lanes at 128K context leave 7.2 GiB free on each card. NVIDIA keeps 6; a number the person chose stays. 2026-11-03 01:35:59 +01:00
Niko1221 1bf5e13e7f split: the INT8 prompt attention (FNX_PFX_ATTN_I8) and the tensor-core GDN scan (FNX_PFX_GDN_TC) are on by default on NVIDIA sm_80+ (the owner's yes, 0.2.10.1); =0 turns each off; HIP keeps the old kernels 2026-11-03 01:16:47 +01:00
Niko1221 082af9c19a Split: tensor-core chunk scan for the prompt GDN behind FNX_PFX_GDN_TC (default off)
Strata's gdn_tc.cuh scan (mma.m16n8k16 f16 hi/lo split, state in registers, 2048-token super-blocks, prep kernel per value head) ported
into the split engine's prompt GDN (gdn_tc_prompt.cuh, part 4, CUDA sm_80+; HIP keeps the sub-chunk kernels). FNX_PFX_GDN_TC=1 runs it in
place of k_gdn_ch_prep / k_gdn_ch_rec when the card has the scratch for it. split_gdn_tc_test checks outputs and end state against the
sub-chunk kernels on random inputs (one card slice, C 33..32768) and times both. FNX_GDN_TIME=1 prints the prompt GDN's GPU time per card.
2026-11-03 01:16:47 +01:00
Niko1221 4a49d25975 split: FNX_PFX_ATTN_I8=1 (screen): the prompt attention on INT8 tensor cores, Strata's prompt_attn_i8v3 adapted to the split's K/V pages (scale per 32 values), card partials and cell lists (off by default) 2026-11-03 01:16:47 +01:00
433 changed files with 32924 additions and 2030 deletions

No files matched your search

+1
View File
@@ -1 +1,2 @@
github: Niko1221
buy_me_a_coffee: strataengine
+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.10 · engine 0.1.56"
placeholder: "Strata 0.2.12 · engine 0.1.58"
validations:
required: true
- type: input
+1 -1
View File
@@ -1,6 +1,6 @@
# AGENTS.md
Version 0.2.10, engine 0.1.56. The public model families are DeepSeekV4.1-Flash-1017,
Version 0.2.12, engine 0.1.58. 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
+118 -2
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.56 LANGUAGES CXX)
project(strata VERSION 0.1.58 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)
@@ -388,6 +388,11 @@ if(STRATA_BUILD_TESTS)
target_include_directories(stage_reserve_test PRIVATE include)
add_test(NAME stage_reserve_test COMMAND stage_reserve_test)
# the layer split's spill walk (header-only arithmetic: who is short, which step comes next)
add_executable(split_walk_test tests/program/split_walk_test.cpp)
target_include_directories(split_walk_test PRIVATE include)
add_test(NAME split_walk_test COMMAND split_walk_test)
# the prompt planner's host-memory model (#2824): header-only, no GPU
add_executable(prefill_plan_test tests/program/prefill_plan_test.cpp)
target_include_directories(prefill_plan_test PRIVATE include)
@@ -398,6 +403,17 @@ if(STRATA_BUILD_TESTS)
target_include_directories(share_rules_test PRIVATE include)
add_test(NAME share_rules_test COMMAND share_rules_test)
# STRATA_SELECT_TWO_PASS: the bound between the single-pass and 3xTF32 QSA block scores and the cut-off rule that
# keeps the selection (header-only, no GPU)
add_executable(qsa_two_pass_test tests/prefill/qsa_two_pass_test.cpp)
target_include_directories(qsa_two_pass_test PRIVATE include)
add_test(NAME qsa_two_pass_test COMMAND qsa_two_pass_test)
# STRATA_SELECT_TWO_PASS: the request's parse and the line it prints once where the scorer cannot run (no GPU)
add_executable(qsa_two_pass_status_test tests/prefill/qsa_two_pass_status_test.cpp)
target_include_directories(qsa_two_pass_status_test PRIVATE include)
add_test(NAME qsa_two_pass_status_test COMMAND qsa_two_pass_status_test
"${CMAKE_CURRENT_BINARY_DIR}/qsa_two_pass_status_test.log")
# 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)
@@ -405,6 +421,26 @@ if(STRATA_BUILD_TESTS)
target_link_libraries(routed_stream_test PRIVATE Threads::Threads)
add_test(NAME routed_stream_test COMMAND routed_stream_test)
# PR #4006: STRATA_PREFILL_TIMING does not record a mark in the phase already running (header-only, no GPU)
add_executable(timer_marks_test tests/prefill/timer_marks_test.cpp)
target_include_directories(timer_marks_test PRIVATE include)
add_test(NAME timer_marks_test COMMAND timer_marks_test)
# PR #5108: the MMQ row table's over-read margin follows the backend tile bound; every fixup read stays inside (header-only, no GPU)
add_executable(mmq_ids_margin_test tests/prefill/mmq_ids_margin_test.cpp)
target_include_directories(mmq_ids_margin_test PRIVATE include)
add_test(NAME mmq_ids_margin_test COMMAND mmq_ids_margin_test)
# PR #4844: the adaptive tier's copy-in range across serial, pipelined and drained rounds (header-only, no GPU)
add_executable(adaptive_range_test tests/program/adaptive_range_test.cpp)
target_include_directories(adaptive_range_test PRIVATE include)
add_test(NAME adaptive_range_test COMMAND adaptive_range_test)
# P0.1 (lite): STRATA_MANIFEST, the machine-readable record of the selected paths, tensor formats, engine and budgets (header-only, no GPU)
add_executable(run_manifest_test tests/core/run_manifest_test.cpp)
target_include_directories(run_manifest_test PRIVATE include)
add_test(NAME run_manifest_test COMMAND run_manifest_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)
@@ -609,6 +645,13 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(qsa_scores_parity PRIVATE strata_kernels CUDA::cudart)
add_test(NAME qsa_scores_parity COMMAND qsa_scores_parity)
set_tests_properties(qsa_scores_parity PROPERTIES SKIP_RETURN_CODE 77 ENVIRONMENT "STRATA_SCORES_THREAD=1")
# STRATA_SELECT_TWO_PASS: the two-pass prompt scores' selection against the tensor-core scorer's, ids and the
# scored-again blocks bitwise (sm_80+, synthetic, no model)
add_executable(qsa_two_pass_parity src/kernels/qsa_two_pass_parity.cpp)
target_include_directories(qsa_two_pass_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(qsa_two_pass_parity PRIVATE strata_kernels CUDA::cudart)
add_test(NAME qsa_two_pass_parity COMMAND qsa_two_pass_parity)
set_tests_properties(qsa_two_pass_parity PROPERTIES SKIP_RETURN_CODE 77 ENVIRONMENT "STRATA_SELECT_TWO_PASS=1")
endif()
# QSAROWS (docs/QSAROWS.md): what the T rows of a verification window cost in a QSA layer's decode kernels at a long
# context (block scores, top-k, attention over each row's selection, indexer append), row by row; GPU, synthetic, no
@@ -1164,6 +1207,14 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(hip_gdn_rec_head PRIVATE strata_prefill strata_hip_runtime)
add_test(NAME hip_gdn_rec_head COMMAND hip_gdn_rec_head)
list(APPEND _strata_hip_tests hip_gdn_rec_head)
# PR #5096: flag B stays behind queued copies (the same test as the CUDA build's flag_order_test; the HIP compat header maps the runtime calls)
set_source_files_properties(tests/core/flag_order_test.cu PROPERTIES LANGUAGE HIP)
add_executable(hip_flag_order_test tests/core/flag_order_test.cu)
target_include_directories(hip_flag_order_test PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/include")
target_link_libraries(hip_flag_order_test PRIVATE strata_hip_runtime Threads::Threads)
add_test(NAME hip_flag_order_test COMMAND hip_flag_order_test)
set_tests_properties(hip_flag_order_test PROPERTIES TIMEOUT 60)
list(APPEND _strata_hip_tests hip_flag_order_test)
add_executable(hip_gdn_chunked tests/hip/gdn_chunked.cpp)
target_link_libraries(hip_gdn_chunked PRIVATE strata_prefill strata_hip_runtime)
add_test(NAME hip_gdn_chunked COMMAND hip_gdn_chunked)
@@ -1367,6 +1418,12 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(dma_batch_parity PRIVATE ${_strata_gpu_runtime_target})
add_test(NAME dma_batch_parity COMMAND dma_batch_parity)
set_tests_properties(dma_batch_parity PROPERTIES TIMEOUT 120)
# PR #5096: flag B stays behind queued copies (delayed host-function copy, a consumer kernel, graph replay, every device)
add_executable(flag_order_test tests/core/flag_order_test.cu)
target_include_directories(flag_order_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_link_libraries(flag_order_test PRIVATE ${_strata_gpu_runtime_target} Threads::Threads)
add_test(NAME flag_order_test COMMAND flag_order_test)
set_tests_properties(flag_order_test PROPERTIES TIMEOUT 60)
if(WIN32)
add_test(NAME dma_batch_registered_parity COMMAND dma_batch_parity --registered)
set_tests_properties(dma_batch_registered_parity PROPERTIES TIMEOUT 120)
@@ -1404,6 +1461,10 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(gemm_dequant_side_parity PRIVATE strata_prefill )
add_test(NAME gemm_dequant_side_parity COMMAND gemm_dequant_side_parity)
set_tests_properties(gemm_dequant_side_parity PROPERTIES SKIP_RETURN_CODE 77)
add_executable(gemm_w16_parity src/prefill/gemm_w16_parity.cu)
target_link_libraries(gemm_w16_parity PRIVATE strata_prefill )
add_test(NAME gemm_w16_parity COMMAND gemm_w16_parity)
set_tests_properties(gemm_w16_parity PROPERTIES SKIP_RETURN_CODE 77)
# Pascal, opt-in STRATA_DENSE_F32=1: the dense projections' FP16 GEMMs as cublasSgemm on FP32 copies, bitwise vs
# the cuBLAS FP16 call over a sweep of chunk sizes (GPU, synthetic, no model; --full sweeps every size, --bench
# adds timings). Not a ctest: whether the two agree is a property of the cuBLAS build, which this checks.
@@ -1866,6 +1927,9 @@ if(STRATA_BUILD_TESTS)
add_executable(arch_defaults_test src/core/arch_defaults_test.cpp src/core/arch_defaults.cpp)
target_include_directories(arch_defaults_test PRIVATE include)
add_test(NAME arch_defaults_test COMMAND arch_defaults_test)
add_executable(pcie_share_rule_test src/core/pcie_share_rule_test.cpp)
target_include_directories(pcie_share_rule_test PRIVATE include)
add_test(NAME pcie_share_rule_test COMMAND pcie_share_rule_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)
@@ -2222,6 +2286,12 @@ if(STRATA_NATIVE_EXPERTS)
# MODEL-UPDATE: the pool's expert rows per format on T threads against the RAM bandwidth (a benchmark, no test)
add_executable(kq_cpu_bench src/kernels/cpu/kq_cpu_bench.cpp)
target_link_libraries(kq_cpu_bench PRIVATE strata_kernels_cpu)
# round 0216: a hash per (pair, token count, row range) of the pool's native expert calls, to compare a change switched off and on
add_executable(cpu_dispatch_dump src/kernels/cpu/cpu_dispatch_dump.cpp)
target_link_libraries(cpu_dispatch_dump PRIVATE strata_kernels_cpu)
# round 0216: the CPU pool's part of a decode window replayed without a GPU (per-layer format pairs, calibrated expert and row counts, cold DRAM)
add_executable(kq_window_bench src/kernels/cpu/kq_window_bench.cpp)
target_link_libraries(kq_window_bench PRIVATE strata_kernels_cpu)
if(STRATA_BUILD_TESTS)
add_test(NAME iq_avx2_parity COMMAND iq_avx2_parity)
add_test(NAME iq_avx2_parity_iq3s_mt1 COMMAND ${CMAKE_COMMAND} -E env STRATA_IQ3S_MT1=1 $<TARGET_FILE:iq_avx2_parity>)
@@ -2297,7 +2367,7 @@ if(STRATA_NATIVE_EXPERTS)
endif()
# #136: the Q2_0 pack's prompt experts on Strata's own int8 kernels (moe_fused.cu, opt-in STRATA_PF_FUSED=1; CUDA
# only: the HIP branch below keeps MMQ, and an sm_75 device or an sm_75-only build reports them unavailable)
add_library(strata_mmq STATIC src/prefill/moe_mmq.cu src/prefill/moe_fused.cu src/prefill/moe_fused_iq.cu
add_library(strata_mmq STATIC src/prefill/moe_mmq.cu src/prefill/moe_iq4_w4a8.cu src/prefill/moe_fused.cu src/prefill/moe_fused_iq.cu
src/prefill/mmq_nvfp4_w4a8.cu
src/prefill/ggml_cuda_host.cu ${_strata_quantize_cu})
if(STRATA_EXPERIMENTAL_SM60 AND NOT STRATA_HIP_GFX906)
@@ -2479,6 +2549,11 @@ if(STRATA_NATIVE_EXPERTS)
set_tests_properties(prefill_fused_iq_test_ws PROPERTIES ENVIRONMENT "STRATA_PF_FUSED_WS=1" SKIP_RETURN_CODE 77 TIMEOUT 600)
# the MoE input quantized per token and scattered to its rows (mmq::quantize_scatter) against the per-row
# gather: the same q8_1 bytes for every MMQ layout (--bench: both at 2K, 8K and 32K tokens)
# the Unsloth v3.1 sets' expert formats through MMQ (Q5_0 down only in a STRATA_ORCA_Q4KS_MMQ build), and --bench
add_executable(prefill_mmq_v31_test tests/cuda/prefill_mmq_v31_test.cpp)
target_link_libraries(prefill_mmq_v31_test PRIVATE strata_mmq strata_kernels ggml-base ${_strata_gpu_blas_target} ${_strata_gpu_runtime_target})
add_test(NAME prefill_mmq_v31_test COMMAND prefill_mmq_v31_test)
set_tests_properties(prefill_mmq_v31_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 300)
add_executable(prefill_mmq_scatter_test tests/cuda/prefill_mmq_scatter_test.cpp)
target_link_libraries(prefill_mmq_scatter_test PRIVATE strata_mmq ggml-base ${_strata_gpu_runtime_target})
add_test(NAME prefill_mmq_scatter_test COMMAND prefill_mmq_scatter_test)
@@ -2497,6 +2572,14 @@ if(STRATA_NATIVE_EXPERTS)
set_tests_properties(prefill_ws_parity_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 600)
endif()
endif()
if(STRATA_BUILD_TESTS AND NOT STRATA_HIP_GFX906)
# STRATA_IQ4_W4A8: the IQ4 W4A8 expert kernel against MMQ, bit for bit, on the group buffers and (with
# STRATA_MMQ_TAB) the pointer table (exit 77 without a card or off sm_80..sm_88; gfx906: a stub, no test)
add_executable(prefill_iq4_w4a8_test tests/cuda/prefill_iq4_w4a8_test.cu)
target_link_libraries(prefill_iq4_w4a8_test PRIVATE strata_mmq ggml-base ${_strata_gpu_runtime_target})
add_test(NAME prefill_iq4_w4a8_test COMMAND prefill_iq4_w4a8_test)
set_tests_properties(prefill_iq4_w4a8_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 300)
endif()
if(STRATA_BUILD_TESTS AND _strata_mmq_tab_on)
# STRATA_MMQ_DIRECT: the pointer-table MMQ bit for bit against the gathered MMQ (exit 77 below sm_80)
add_executable(prefill_mmq_tab_test tests/cuda/prefill_mmq_tab_test.cu)
@@ -2684,6 +2767,21 @@ if(STRATA_NATIVE_EXPERTS)
set_tests_properties(native_expert_parity_${_name} PROPERTIES TIMEOUT 120)
endforeach()
add_test(NAME native_expert_parity_q5_1_min COMMAND native_expert_parity --q5_1-min)
# Unsloth v3.1 Qwen3.8-Flash-Next sets (UD-IQ1_M .. UD-IQ4_XS, round 0214): every gate/up x down pair their layers use,
# at the real shapes (hidden 2560, FF 640): gate/up IQ2_XXS IQ2_XS IQ2_S IQ3_XXS IQ4_XS (+ IQ1_M, the GSQ-RCO packs'),
# down Q2_0 IQ4_NL Q5_0 Q8_0. Q5_0 down (UD-IQ4_XS 33 layers, UD-Q3_K_XL 20) had no test on this model before.
foreach(_gu iq1_m iq2_xxs iq2_xs iq2_s iq3_xxs iq4_xs)
foreach(_dn q2_0 iq4_nl q5_0 q8_0)
add_test(NAME native_expert_parity_v31_${_gu}_${_dn} COMMAND native_expert_parity --synthetic ${_gu}/${_dn})
set_tests_properties(native_expert_parity_v31_${_gu}_${_dn} PROPERTIES TIMEOUT 180)
endforeach()
endforeach()
# the dense projections (Q5_0, Q6_K, Q5_K, Q4_K, Q8_0, IQ4_XS, IQ4_NL ...): decode MMVQ and the prompt's dequantizers
add_executable(native_dense_parity src/kernels/native_dense_parity.cpp)
target_include_directories(native_dense_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(native_dense_parity PRIVATE strata_kernels ggml-base ${_strata_gpu_runtime_target})
add_test(NAME native_dense_parity COMMAND native_dense_parity)
set_tests_properties(native_dense_parity PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 300)
# APR: the trellis expert types and the Hadamard activation rotation, when ggml comes from the agention fork
# (the only tree with these types; marked by its ggml-cuda/tq.cuh)
if(STRATA_GGML_DIR AND EXISTS "${STRATA_GGML_DIR}/ggml/src/ggml-cuda/tq.cuh")
@@ -2702,6 +2800,24 @@ if(STRATA_NATIVE_EXPERTS)
add_test(NAME native_expert_parity_bf16_embd COMMAND native_expert_parity --bf16-embd)
endif()
endif()
# Round 0214 (the new Unsloth Qwen3.8-Flash-Next quants UD-IQ1_M .. UD-IQ4_XS), CUDA and HIP: every expert type pair the
# sets use at 2560/640 (GPU decode kernels, the CPU pool and the prompt path's dequantizers against ggml), and the dense
# tensor types (Q5_0, Q6_K, the K-quants, IQ4_NL, IQ4_XS, Q2_0) through native_mmvq.
if(STRATA_BUILD_TESTS AND (STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP))
set(_strata_new_gu iq1_m iq2_xxs iq2_xs iq2_s q2_0 iq3_xxs iq4_xs)
set(_strata_new_dn q2_0 iq4_nl q5_0 q8_0)
foreach(_g ${_strata_new_gu})
foreach(_d ${_strata_new_dn})
add_test(NAME native_expert_parity_new_${_g}_${_d} COMMAND native_expert_parity --synthetic ${_g}/${_d})
set_tests_properties(native_expert_parity_new_${_g}_${_d} PROPERTIES TIMEOUT 300)
endforeach()
endforeach()
add_executable(native_dense_types_parity src/kernels/native_dense_types_parity.cpp)
target_include_directories(native_dense_types_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(native_dense_types_parity PRIVATE strata_kernels strata_kernels_cpu ${_strata_gpu_runtime_target})
add_test(NAME native_dense_types_parity COMMAND native_dense_types_parity)
set_tests_properties(native_dense_types_parity PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 300)
endif()
# #296's real-weight checks (OrcaRouter Q4_K_S files; docs/ORCA_Q4_K_S.md) - test programs, not part of a release
if(STRATA_ENABLE_CUDA AND STRATA_BUILD_TESTS)
add_executable(ple_q5_parity src/kernels/ple_q5_parity.cpp)
+15 -1
View File
@@ -17,6 +17,7 @@
<p align="center">
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Strata herunterladen"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="MIT-Lizenz"></a>
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="Strata über GitHub Sponsors unterstützen"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Strata auf Buy Me a Coffee unterstützen"></a>
</p>
@@ -175,10 +176,23 @@ Führe **`strata connect`** aus, um die Einrichtung für dein Tool zu sehen. [Ve
## Strata unterstützen
**Hilf mit, lokale KI für alle offen zu halten.** Ein Kaffee hilft, Entwicklung und Tests auf mehr Hardware zu finanzieren.
**Hilf mit, lokale KI für alle offen zu halten.** Ein GitHub-Sponsoring oder ein Kaffee hilft, Entwicklung und Tests auf mehr Hardware zu finanzieren.
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors: Strata unterstützen" height="48"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee: Strata unterstützen" height="48"></a>
### Treue Unterstützer
Danke an alle, die Strata unterstützen.
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
<!-- supporters:end -->
[MIT-Lizenz](LICENSE) · [Danksagung und Modelllizenzen](docs/HOW_IT_WORKS.md#credits) · [Datenschutz](docs/SAFETY.md)
<a id="option-c-let-your-ai-set-it-up"></a>
+15 -1
View File
@@ -17,6 +17,7 @@
<p align="center">
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Descargar Strata"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="Licencia MIT"></a>
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="Patrocina Strata en GitHub Sponsors"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Apoya Strata en Buy Me a Coffee"></a>
</p>
@@ -175,10 +176,23 @@ Ejecuta **`strata connect`** para ver las instrucciones de tu herramienta. [Guí
## Apoya Strata
**Ayuda a mantener la IA local abierta a todos.** Un café ayuda a financiar el desarrollo y las pruebas en más hardware.
**Ayuda a mantener la IA local abierta a todos.** Un patrocinio en GitHub o un café ayuda a financiar el desarrollo y las pruebas en más hardware.
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors: patrocina Strata" height="48"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee: apoya Strata" height="48"></a>
### Apoyos fieles
Gracias a todas las personas que apoyan Strata.
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
<!-- supporters:end -->
[Licencia MIT](LICENSE) · [Créditos y licencias de los modelos](docs/HOW_IT_WORKS.md#credits) · [Privacidad](docs/SAFETY.md)
<a id="option-c-let-your-ai-set-it-up"></a>
+15 -1
View File
@@ -17,6 +17,7 @@
<p align="center">
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Télécharger Strata"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="Licence MIT"></a>
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="Soutenir Strata sur GitHub Sponsors"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Soutenir Strata sur Buy Me a Coffee"></a>
</p>
@@ -175,10 +176,23 @@ Lancez **`strata connect`** pour voir la configuration de votre outil. [Guide de
## Soutenir Strata
**Aidez à garder l'IA locale ouverte à tous.** Un café aide à financer le développement et les tests sur davantage de matériel.
**Aidez à garder l'IA locale ouverte à tous.** Un parrainage sur GitHub ou un café aide à financer le développement et les tests sur davantage de matériel.
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors : soutenir Strata" height="48"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee : soutenir Strata" height="48"></a>
### Fidèles soutiens
Merci à toutes les personnes qui soutiennent Strata.
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
<!-- supporters:end -->
[Licence MIT](LICENSE) · [Crédits et licences des modèles](docs/HOW_IT_WORKS.md#credits) · [Confidentialité](docs/SAFETY.md)
<a id="option-c-let-your-ai-set-it-up"></a>
+15 -1
View File
@@ -17,6 +17,7 @@
<p align="center">
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Strata をダウンロード"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="MIT ライセンス"></a>
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="GitHub Sponsors で Strata を支援する"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Buy Me a Coffee で Strata を支援する"></a>
</p>
@@ -175,10 +176,23 @@ strata launch
## Strata を支援する
**ローカル AI をみんなに開かれたものに。** コーヒー1杯が、開発と、より多くのハードウェアでのテストの支えになります。
**ローカル AI をみんなに開かれたものに。** GitHub でのスポンサーやコーヒー1杯が、開発と、より多くのハードウェアでのテストの支えになります。
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors: Strata を支援する" height="48"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee: Strata を支援する" height="48"></a>
### いつも支えてくださる方々
Strata を支えてくださるすべての方に感謝します。
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
<!-- supporters:end -->
[MIT ライセンス](LICENSE) · [クレジットとモデルのライセンス](docs/HOW_IT_WORKS.md#credits) · [プライバシー](docs/SAFETY.md)
<a id="option-c-let-your-ai-set-it-up"></a>
+15 -1
View File
@@ -17,6 +17,7 @@
<p align="center">
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Download Strata"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="MIT license"></a>
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="Sponsor Strata on GitHub Sponsors"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Support Strata on Buy Me a Coffee"></a>
</p>
@@ -181,10 +182,23 @@ Run **`strata connect`** to see setup instructions for your tool. [Connection gu
## Support Strata
**Help keep local AI open to everyone.** A coffee helps fund development and testing on more hardware.
**Help keep local AI open to everyone.** A GitHub sponsorship or a coffee helps fund development and testing on more hardware.
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors: sponsor Strata" height="48"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee: support Strata" height="48"></a>
### Loyal supporters
Thank you to everyone who supports Strata.
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
<!-- supporters:end -->
[MIT license](LICENSE) · [Credits and model licenses](docs/HOW_IT_WORKS.md#credits) · [Privacy](docs/SAFETY.md)
<a id="option-c-let-your-ai-set-it-up"></a>
+15 -1
View File
@@ -17,6 +17,7 @@
<p align="center">
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Pobierz Stratę"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="Licencja MIT"></a>
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="Wesprzyj Stratę na GitHub Sponsors"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Wesprzyj Stratę na Buy Me a Coffee"></a>
</p>
@@ -179,10 +180,23 @@ Uruchom **`strata connect`**, aby zobaczyć instrukcję konfiguracji dla swojego
## Wesprzyj Stratę
**Pomóż zachować lokalną sztuczną inteligencję otwartą dla wszystkich.** Kawa pomaga finansować rozwój i testy na większej liczbie sprzętu.
**Pomóż zachować lokalną sztuczną inteligencję otwartą dla wszystkich.** Sponsoring na GitHubie lub kawa pomaga finansować rozwój i testy na większej liczbie sprzętu.
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors: wesprzyj Stratę" height="48"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee: wesprzyj Stratę" height="48"></a>
### Wierni wspierający
Dziękujemy wszystkim, którzy wspierają Stratę.
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
<!-- supporters:end -->
[Licencja MIT](LICENSE) · [Podziękowania i licencje modeli](docs/HOW_IT_WORKS.md#credits) · [Prywatność](docs/SAFETY.md)
<a id="option-c-let-your-ai-set-it-up"></a>
+15 -1
View File
@@ -17,6 +17,7 @@
<p align="center">
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="Baixar o Strata"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="Licença MIT"></a>
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="Apoie o Strata no GitHub Sponsors"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="Apoie o Strata no Buy Me a Coffee"></a>
</p>
@@ -175,10 +176,23 @@ Execute **`strata connect`** para ver as instruções da sua ferramenta. [Guia d
## Apoie o Strata
**Ajude a manter a IA local aberta para todos.** Um café ajuda a financiar o desenvolvimento e os testes em mais hardware.
**Ajude a manter a IA local aberta para todos.** Um patrocínio no GitHub ou um café ajuda a financiar o desenvolvimento e os testes em mais hardware.
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors: apoie o Strata" height="48"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee: apoie o Strata" height="48"></a>
### Apoiadores fiéis
Obrigado a todas as pessoas que apoiam o Strata.
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
<!-- supporters:end -->
[Licença MIT](LICENSE) · [Créditos e licenças dos modelos](docs/HOW_IT_WORKS.md#credits) · [Privacidade](docs/SAFETY.md)
<a id="option-c-let-your-ai-set-it-up"></a>
+15 -1
View File
@@ -17,6 +17,7 @@
<p align="center">
<a href="https://github.com/Niko1221/Strata/releases/latest"><img src="https://img.shields.io/github/v/release/Niko1221/Strata?label=download&color=0ab887" alt="下载 Strata"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Niko1221/Strata" alt="MIT 许可证"></a>
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor-EA4AAA?logo=githubsponsors&logoColor=fff" alt="在 GitHub Sponsors 上支持 Strata"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buymeacoffee&logoColor=000" alt="在 Buy Me a Coffee 上支持 Strata"></a>
</p>
@@ -175,10 +176,23 @@ strata launch
## 支持 Strata
**帮助本地 AI 保持开放。** 一杯咖啡能帮助我们在更多硬件上开发和测试。
**帮助本地 AI 保持开放。** GitHub 赞助或一杯咖啡,都能帮助我们在更多硬件上开发和测试。
<a href="https://github.com/sponsors/Niko1221"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="GitHub Sponsors:支持 Strata" height="48"></a>
<a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee:支持 Strata" height="48"></a>
### 忠实支持者
感谢每一位支持 Strata 的人。
<!-- supporters:start (one line per public supporter, newest last; copy a line and change the GitHub name in all four places) -->
<a href="https://github.com/ryan4yin"><img src="https://github.com/ryan4yin.png?size=96" width="48" height="48" alt="ryan4yin" title="ryan4yin"></a>
<a href="https://github.com/themactep"><img src="https://github.com/themactep.png?size=96" width="48" height="48" alt="themactep" title="themactep"></a>
<a href="https://github.com/macintog"><img src="https://github.com/macintog.png?size=96" width="48" height="48" alt="macintog" title="macintog"></a>
<a href="https://github.com/bisonbet"><img src="https://github.com/bisonbet.png?size=96" width="48" height="48" alt="bisonbet" title="bisonbet"></a>
<a href="https://github.com/vizv"><img src="https://github.com/vizv.png?size=96" width="48" height="48" alt="vizv" title="vizv"></a>
<!-- supporters:end -->
[MIT 许可证](LICENSE) · [致谢和模型许可证](docs/HOW_IT_WORKS.md#credits) · [隐私](docs/SAFETY.md)
<a id="option-c-let-your-ai-set-it-up"></a>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "strata-desktop",
"productName": "Strata",
"version": "0.2.10",
"version": "0.2.12",
"private": true,
"description": "Strata Desktop - local AI, tuned to one model.",
"scripts": {
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""Is every static-PIE program inside an AppImage still the program it was staged as? (#4337)
Why: the bundled ripgrep (`harness/node_modules/@vscode/ripgrep-linux-x64/bin/rg`) is a statically linked PIE. Such a program relocates itself at
start-up by reading its own `.dynamic` section through a fixed offset in its code. linuxdeploy, which Tauri runs over the AppDir, treats every ELF file
as a library to fix up and rewrites its RPATH; for a static PIE that moves `.dynamic` to a new segment and leaves the old place filled with a filler
byte, so the start-up code reads garbage. Reporters on #4337 see the same crash offset in every run (`rg + 0x3ffcf1`) and filler bytes (0x5A) where
`.rela.dyn` and `.dynamic` were. A static PIE whose `.dynamic` is filled does not start: tools/test_appimage_elf.py shows that on a real one.
apps/desktop/scripts/repack-appimage.sh puts the staged bytes back; this file is the check that says whether it did.
appimage_elf.py scan <folder> every static PIE under <folder> must have a sound .dynamic (exit 1 if not)
appimage_elf.py compare <staged-folder> <folder> the same files, byte for byte, plus what differs in their ELF headers
What is checked: only static PIE programs. A shared library (.so, Python's and Node's native modules, Pillow, numpy, sharp) is not one, however it is
named: it has no entry point, or a DT_SONAME and no DF_1_PIE flag, and its RPATH/RUNPATH is normal. A static PIE is an ET_DYN with no interpreter, an entry
point, and DF_1_PIE in DT_FLAGS_1; one whose .dynamic cannot be read at all (overwritten) is reported as damaged, since the overwrite takes the flags too.
Standard library only; reads ELF64 little endian (x86-64 and arm64), anything else is ignored.
"""
import os
import struct
import sys
ET_DYN = 3
PT_LOAD, PT_DYNAMIC, PT_INTERP = 1, 2, 3
DT_NULL, DT_NEEDED, DT_RELA, DT_RELASZ, DT_RELAENT, DT_SONAME, DT_RPATH, DT_RUNPATH = 0, 1, 7, 8, 9, 14, 15, 29
DT_FLAGS_1, DF_1_PIE = 0x6FFFFFFB, 0x08000000
FILLERS = (0x5A, 0x58) # 'Z' (what the reports found) and 'X' (what patchelf documents)
RUN = 128 # that many equal filler bytes in a row is not code or data
def _known_tag(tag):
"""The dynamic tags the ELF specification and the GNU and processor ranges define: anything else is garbage."""
return 0 <= tag <= 0x22 or 0x6000000D <= tag <= 0x6FFFFFFF or 0x70000000 <= tag <= 0x7FFFFFFF
def read_elf(data):
"""The facts about an ELF64 little-endian file, or None when it is not one."""
if len(data) < 64 or data[:4] != b"\x7fELF" or data[4] != 2 or data[5] != 1:
return None
e_type = struct.unpack_from("<H", data, 16)[0]
e_entry = struct.unpack_from("<Q", data, 0x18)[0]
phoff = struct.unpack_from("<Q", data, 0x20)[0]
phentsize, phnum = struct.unpack_from("<HH", data, 0x36)
if phentsize < 56 or phoff + phentsize * phnum > len(data):
return {"type": e_type, "entry": e_entry, "phdrs": [], "broken": "the program header table is outside the file"}
phdrs = []
for i in range(phnum):
p_type, flags, off, vaddr, _paddr, filesz, memsz, align = struct.unpack_from("<IIQQQQQQ", data, phoff + i * phentsize)
phdrs.append({"type": p_type, "flags": flags, "off": off, "vaddr": vaddr, "filesz": filesz, "memsz": memsz, "align": align})
return {"type": e_type, "entry": e_entry, "phdrs": phdrs, "broken": None}
def is_pie_candidate(info):
"""An ET_DYN with no interpreter, a dynamic segment and an entry point: what a static PIE looks like from outside the dynamic section.
A shared library normally has no entry point (e_entry 0) and is not a candidate; the few that do (the C library itself, the dynamic loader)
have a readable dynamic section and are told apart by it (static_pie_kind). The test uses nothing the damage we look for can erase."""
return bool(info) and info["type"] == ET_DYN and not info["broken"] and info.get("entry", 0) != 0 and not any(p["type"] == PT_INTERP for p in info["phdrs"]) and any(p["type"] == PT_DYNAMIC for p in info["phdrs"])
def static_pie_kind(data, info):
"""'static-pie' for a sound static PIE (DT_FLAGS_1 has DF_1_PIE, no DT_SONAME), 'damaged' for a candidate whose dynamic section cannot be read
(a static PIE with an overwritten .dynamic loses its flags with it, so unreadable is treated as damaged), None for everything else:
shared libraries, which carry a DT_SONAME and no DF_1_PIE, and programs that are not static PIEs."""
if not is_pie_candidate(info):
return None
entries, why = dynamic_entries(data, info)
if why or any(not _known_tag(t) for t, _ in entries):
return "damaged"
tags = {t: v for t, v in entries}
if DT_SONAME in tags:
return None
return "static-pie" if tags.get(DT_FLAGS_1, 0) & DF_1_PIE else None
def is_static_pie(info):
"""Kept for callers: a candidate. check_static_pie decides with static_pie_kind."""
return is_pie_candidate(info)
def _mapped(info, vaddr, size):
"""The file offset of [vaddr, vaddr+size) when one PT_LOAD maps all of it from the file, else None."""
for p in info["phdrs"]:
if p["type"] == PT_LOAD and p["vaddr"] <= vaddr and vaddr + size <= p["vaddr"] + p["filesz"]:
return p["off"] + (vaddr - p["vaddr"])
return None
def dynamic_entries(data, info):
"""(tag, value) pairs of the PT_DYNAMIC segment up to DT_NULL, and the problem if it does not end properly."""
seg = next(p for p in info["phdrs"] if p["type"] == PT_DYNAMIC)
if seg["off"] + seg["filesz"] > len(data):
return [], "the dynamic segment is outside the file"
out = []
for at in range(seg["off"], seg["off"] + seg["filesz"] - 15, 16):
tag, val = struct.unpack_from("<qQ", data, at)
if tag == DT_NULL:
return out, None
out.append((tag, val))
return out, "the dynamic segment does not end with DT_NULL"
def _longest_filler(data, start, end):
best = run = 0
prev = None
for b in data[start:end]:
run = run + 1 if b == prev and b in FILLERS else (1 if b in FILLERS else 0)
prev = b
best = max(best, run)
return best
def check_static_pie(data):
"""The problems of one file (a list of sentences); empty for a sound static PIE and for anything that is not one."""
info = read_elf(data)
if static_pie_kind(data, info) is None:
return []
problems = []
seg = next(p for p in info["phdrs"] if p["type"] == PT_DYNAMIC)
if _mapped(info, seg["vaddr"], max(seg["filesz"], 1)) is None:
problems.append("the dynamic segment is not inside any loaded segment")
entries, why = dynamic_entries(data, info)
if why:
problems.append(why)
bad = [t for t, _ in entries if not _known_tag(t)]
if bad:
problems.append(f"{len(bad)} dynamic entries have tags that do not exist (first {bad[0]:#x}): the section is overwritten")
if any(t in (DT_RPATH, DT_RUNPATH) for t, _ in entries):
problems.append("a static PIE has an RPATH or RUNPATH: something rewrote it after it was linked")
values = dict(entries)
if DT_RELA in values:
size = values.get(DT_RELASZ, 0)
at = _mapped(info, values[DT_RELA], max(size, 1))
if size == 0 or at is None:
problems.append("the relocation table named by DT_RELA is empty or outside the loaded segments")
elif _longest_filler(data, at, at + size) >= min(RUN, size):
problems.append("the relocation table is filled with one byte")
for p in info["phdrs"]:
if p["type"] == PT_LOAD and _longest_filler(data, p["off"], p["off"] + p["filesz"]) >= RUN:
problems.append(f"{RUN} or more filler bytes (0x5A or 0x58) in a row inside a loaded segment")
break
return problems
def elf_differences(staged, packaged):
"""What differs between two builds of one program in the parts that matter for start-up: for the report."""
a, b = read_elf(staged), read_elf(packaged)
if not a or not b:
return [] if staged == packaged else ["one of the two is not an ELF64 file"]
out = []
ta, tb = [p["type"] for p in a["phdrs"]], [p["type"] for p in b["phdrs"]]
if ta != tb:
out.append(f"program headers {len(ta)} -> {len(tb)} ({ta.count(PT_LOAD)} -> {tb.count(PT_LOAD)} loaded segments)")
if is_pie_candidate(a) and is_pie_candidate(b):
da, _ = dynamic_entries(staged, a)
db, _ = dynamic_entries(packaged, b)
if da != db:
out.append(f"dynamic section: {len(da)} -> {len(db)} entries" + ("; an RPATH/RUNPATH was added" if any(t in (DT_RPATH, DT_RUNPATH) for t, _ in db) and not any(t in (DT_RPATH, DT_RUNPATH) for t, _ in da) else ""))
return out
def _files(folder):
for base, dirs, names in os.walk(folder):
dirs.sort()
for name in sorted(names):
path = os.path.join(base, name)
if os.path.islink(path) or not os.path.isfile(path):
continue
yield path
def scan(folder):
"""{relative path: problems} for every static PIE under `folder` that has any."""
found = {}
for path in _files(folder):
try:
with open(path, "rb") as f:
head = f.read(4)
if head != b"\x7fELF":
continue
data = head + f.read()
except OSError:
continue
problems = check_static_pie(data)
if problems:
found[os.path.relpath(path, folder)] = problems
return found
def compare(staged_folder, folder):
"""{relative path: sentences} for every staged ELF file that is missing or differs in `folder`."""
found = {}
for path in _files(staged_folder):
rel = os.path.relpath(path, staged_folder)
with open(path, "rb") as f:
want = f.read()
if want[:4] != b"\x7fELF":
continue
other = os.path.join(folder, rel)
if not os.path.isfile(other):
found[rel] = ["missing"]
continue
with open(other, "rb") as f:
have = f.read()
if have != want:
found[rel] = ["bytes differ"] + elf_differences(want, have)
return found
def main(argv):
if len(argv) >= 3 and argv[1] == "scan":
bad = scan(argv[2])
for rel, problems in bad.items():
print(f"BROKEN {rel}: " + "; ".join(problems))
print(f"{len(bad)} static PIE file(s) with a damaged dynamic section")
return 1 if bad else 0
if len(argv) >= 4 and argv[1] == "compare":
bad = compare(argv[2], argv[3])
for rel, why in bad.items():
print(f"DIFFERS {rel}: " + "; ".join(why))
print(f"{len(bad)} ELF file(s) differ from what was staged")
return 1 if bad else 0
print(__doc__)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv))
+29 -2
View File
@@ -9,7 +9,9 @@
# installer's own integrity promise ("what we staged is what runs") is broken. This script unpacks the AppImage, copies the staged
# folders (control, harness, node, engine-bundle) over the AppDir byte for byte, and packs it again with the same runtime.
#
# --check does not write anything: it lists every engine-bundle file whose bytes differ from the manifest in the AppImage.
# --check does not write anything: it lists every file of control, harness, node and engine-bundle whose bytes differ from what was staged, then
# looks at every static PIE in the AppImage (appimage_elf.py: the bundled ripgrep is one; linuxdeploy's RPATH rewrite leaves such a program with a
# filled .dynamic and it crashes at start, #4337) and runs the bundled `rg` once. A single "0 file(s) differ" and "ok" is what a release needs.
set -euo pipefail
IN="$(realpath "${1:?in.AppImage}")"; OUT="${2:-}"
@@ -33,13 +35,36 @@ LIB="$(find "$WORK/AppDir/usr/lib" -maxdepth 2 -type d -name engine-bundle -prin
[ -n "$LIB" ] || LIB="$(find "$WORK/AppDir/usr/lib" -maxdepth 2 -type d -name control -printf '%h\n' | head -1)"
[ -n "$LIB" ] || { echo "no Strata resource folder in the AppImage" >&2; exit 1; }
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Every static PIE under the AppDir must still have a sound .dynamic, and the bundled ripgrep must start (x86-64 Linux only: it is run).
elf_and_rg_check() {
local bad=0
if command -v python3 >/dev/null 2>&1; then
python3 "$HERE/appimage_elf.py" scan "$LIB" || bad=1
else
echo "python3 is not here: the static PIE check was skipped" >&2
fi
local rg
rg="$(find "$LIB/harness" -path '*ripgrep-linux-x64/bin/rg' -type f 2>/dev/null | head -1)"
if [ -n "$rg" ] && [ "$(uname -s)" = Linux ] && [ "$(uname -m)" = x86_64 ]; then
local t; t="$(mktemp -d -t strata-rg.XXXXXX)"
echo 'needle in a haystack' > "$t/h.txt"
if "$rg" --version >/dev/null 2>&1 && [ "$("$rg" --no-config -c needle "$t/h.txt" 2>/dev/null)" = 1 ]; then echo "bundled rg runs: ok"
else echo "the bundled rg does not run: $rg" >&2; bad=1; fi
rm -rf "$t"
fi
return $bad
}
if [ "$CHECK" = 1 ]; then
bad=0
while IFS= read -r f; do
rel="${f#$RES/}"
if ! cmp -s "$f" "$LIB/$rel"; then echo "differs: $rel"; bad=$((bad + 1)); fi
done < <(find "$RES/engine-bundle" "$RES/node" -type f ! -name .gitignore)
done < <(for d in control harness node engine-bundle; do [ -d "$RES/$d" ] && find "$RES/$d" -type f ! -name .gitignore; done)
echo "$bad file(s) differ from what was staged"
elf_and_rg_check || bad=$((bad + 1))
[ "$bad" = 0 ]
exit
fi
@@ -49,6 +74,8 @@ for d in control harness node engine-bundle; do
[ -d "$RES/$d" ] || continue
rsync -aL --checksum --delete --exclude .gitignore "$RES/$d/" "$LIB/$d/"
done
# What was put back must be sound before it is packed: a damaged static PIE (the bundled ripgrep) stops the pack here, not on a person's computer.
elf_and_rg_check || { echo "the restored AppDir failed the static PIE / ripgrep check; not packing" >&2; exit 1; }
# The launcher records the environment the AppImage was started from, before its own hooks add the AppImage's libraries and GTK/GIO
# settings (LD_LIBRARY_PATH, GTK_*, XDG_DATA_DIRS ...). The host gives it back to every program that is not Strata's own (zenity,
# kdialog, xdg-open, the assistant's shell): apps/host/server/external-env.mjs. Without it a system program loads the AppImage's libssl
+4 -4
View File
@@ -616,10 +616,10 @@ await scenario('stop', { installed: true, env: { FAKE_TOKEN_DELAY: '0.1', FAKE_R
await scenario('latest', {}, async ({ base, browser, shot }) => {
const page = await newPage(browser, base);
const now = Date.now();
await page.evaluate(now => {
await page.evaluate(async now => {
const messages = [];
for (let i = 0; i < 40; i++) { messages.push({ id: 'u' + i, role: 'user', content: 'question ' + i, at: now }, { id: 'a' + i, role: 'assistant', content: 'answer number ' + i + ' with some text to fill the line and more of it.', at: now, tokens: 20, inputTokens: 5 }); }
localStorage.setItem('strata.sessions.v1', JSON.stringify([{ id: 's_latest', title: 'long', updatedAt: now, createdAt: now, messages }]));
await fetch('/api/chats/s_latest', { method: 'PUT', headers: { 'x-strata-client': 'ui' }, body: JSON.stringify({ id: 's_latest', title: 'long', updatedAt: now, createdAt: now, messages }) });
localStorage.setItem('strata.active-session.v1', 's_latest');
}, now);
await page.reload({ waitUntil: 'domcontentloaded' });
@@ -690,12 +690,12 @@ if (only.has('restart')) {
await run(async (context, { base }) => {
const page = context.pages()[0] ?? await context.newPage();
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => { localStorage.setItem('strata.prefs.v2', JSON.stringify({ theme: 'dark', contentFontSize: 19, language: 'de' })); localStorage.setItem('strata.sessions.v1', JSON.stringify([{ id: 'chat-1', title: 'My chat' }])); });
await page.evaluate(() => { localStorage.setItem('strata.prefs.v2', JSON.stringify({ theme: 'dark', contentFontSize: 19, language: 'de' })); return fetch('/api/chats/chat-1', { method: 'PUT', headers: { 'x-strata-client': 'ui' }, body: JSON.stringify({ id: 'chat-1', title: 'My chat', messages: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }) }); });
});
await run(async (context, { base }) => {
const page = context.pages()[0] ?? await context.newPage();
await page.goto(base, { waitUntil: 'domcontentloaded' });
const saved = await page.evaluate(() => ({ prefs: JSON.parse(localStorage.getItem('strata.prefs.v2') || '{}'), chats: localStorage.getItem('strata.sessions.v1'), theme: document.documentElement.dataset.theme }));
const saved = await page.evaluate(async () => ({ prefs: JSON.parse(localStorage.getItem('strata.prefs.v2') || '{}'), chats: JSON.stringify(await (await fetch('/api/chats', { headers: { 'x-strata-client': 'ui' } })).json()), theme: document.documentElement.dataset.theme }));
check(saved.prefs.contentFontSize === 19 && saved.prefs.language === 'de', 'text size and language survive a restart on the same port', JSON.stringify(saved.prefs));
check(saved.theme === 'dark', 'the theme survives too', saved.theme);
check(/My chat/.test(saved.chats ?? ''), 'and the chat list', saved.chats);
+3 -1
View File
@@ -3625,7 +3625,7 @@ checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
[[package]]
name = "strata-desktop"
version = "0.2.10"
version = "0.2.12"
dependencies = [
"gtk",
"serde",
@@ -3637,6 +3637,8 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"webkit2gtk",
"webview2-com",
"windows-core",
]
[[package]]
+6 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "strata-desktop"
version = "0.2.10"
version = "0.2.12"
description = "Strata Desktop — local AI, tuned to one model."
edition = "2021"
rust-version = "1.77"
@@ -11,6 +11,11 @@ license = "MIT"
gtk = "0.18"
webkit2gtk = "2.0"
# Windows: the shell listens for the page's renderer failing (WebView2 ProcessFailed, see hook_renderer_failures in main.rs); these are the versions wry itself uses.
[target.'cfg(windows)'.dependencies]
webview2-com = "0.39"
windows-core = "0.62"
[build-dependencies]
tauri-build = { version = "2", features = [] }
+2 -2
View File
@@ -188,7 +188,7 @@ fn graphics_environment() -> Vec<String> {
const NAMES: &[&str] = &[
"XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "DESKTOP_SESSION", "DISPLAY", "WAYLAND_DISPLAY", "GDK_BACKEND", "GTK_THEME",
"WEBKIT_DISABLE_DMABUF_RENDERER", "WEBKIT_DISABLE_COMPOSITING_MODE", "WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "LIBGL_ALWAYS_SOFTWARE",
"GALLIUM_DRIVER", "__GLX_VENDOR_LIBRARY_NAME", "__NV_PRIME_RENDER_OFFLOAD", "MESA_LOADER_DRIVER_OVERRIDE", "APPIMAGE", "APPDIR",
"FONTCONFIG_FILE", "FONTCONFIG_PATH", "STRATA_FONT_CONFIG", "GALLIUM_DRIVER", "__GLX_VENDOR_LIBRARY_NAME", "__NV_PRIME_RENDER_OFFLOAD", "MESA_LOADER_DRIVER_OVERRIDE", "APPIMAGE", "APPDIR",
];
NAMES.iter().filter_map(|n| std::env::var(n).ok().map(|v| format!(" {n}={v}"))).collect()
}
@@ -584,7 +584,7 @@ mod tests {
let path = write_startup_failure(&dir, "the app stopped unexpectedly: boom (at main.rs:1, thread main)", Some(Path::new("/x/desktop-1.log"))).unwrap();
assert!(path.file_name().unwrap().to_string_lossy().starts_with("desktop-crash-"));
let text = std::fs::read_to_string(&path).unwrap();
for needle in ["boom", "main.rs:1", "/x/desktop-1.log", "0.2.0", "System:", "Meaning:"] {
for needle in ["boom", "main.rs:1", "/x/desktop-1.log", env!("CARGO_PKG_VERSION"), "System:", "Meaning:"] {
assert!(text.contains(needle), "{needle}");
}
let _ = std::fs::remove_dir_all(&dir);
+291
View File
@@ -0,0 +1,291 @@
//! Linux: keep the colour emoji font that the AppImage's WebKitGTK cannot draw away from the app window (#4533).
//!
//! The WebKitGTK inside the AppImage paints a COLRv1 colour font through Skia, and its `colrv1_configure_skpaint` reads past the end of a
//! gradient's colour-stop list. The library is built with libstdc++ range checks on, so the read becomes `abort()` and `WebKitWebProcess`
//! dies: the window goes grey the first time a chat title or a message draws an emoji (Fedora 44 and newer Ubuntu ship
//! `Noto-COLRv1.ttf` as the default colour emoji font; the reporters on #4533 name the same assertion and the same workaround).
//!
//! fontconfig reads one file when `FONTCONFIG_FILE` is set, and that file replaces the normal configuration, so the file written here
//! includes the system configuration first and only then rejects the COLRv1 faces. Emoji are then drawn from whatever else the system
//! has (the outline "Noto Emoji" is preferred); nothing else about the fonts changes.
//!
//! Only the window process is told (main.rs run_window_process, before WebKit starts its own processes). The shell owns the host, the
//! engine and the agent, and they keep the fonts the person chose. A machine with no COLRv1 font is not touched at all.
//!
//! This file is plain std so its decisions are unit-tested on every platform; only the window process on Linux uses it.
//! The idea is that of community PR #4485; the code is written for this tree.
use std::path::{Path, PathBuf};
/// File name patterns of the COLRv1 faces (fontconfig globs, case sensitive, so both spellings). Bitmap (`CBDT`) and outline faces are not
/// listed: they draw fine, and hiding them would leave an empty box where an emoji should be.
pub const COLRV1_GLOBS: &[&str] = &["*COLRv1*", "*colrv1*", "*COLRV1*"];
/// The outline family preferred in place of the colour one.
const OUTLINE_EMOJI: &str = "Noto Emoji";
/// fontconfig's own configuration. `FONTCONFIG_FILE` replaces it, so the written file pulls it back in.
pub const SYSTEM_CONFIG: &str = "/etc/fonts/fonts.conf";
/// `STRATA_FONT_CONFIG=off` turns this off; `STRATA_FONT_CONFIG=/path/to/fonts.conf` uses the person's own file.
const OFF: &str = "off";
/// The name of the file written under the data folder.
const FILE_NAME: &str = "fonts-no-colrv1.conf";
/// Folders fontconfig scans by default, where a colour emoji font is found: the system ones, the person's own, and the XDG data folders.
pub fn font_dirs(env: &dyn Fn(&str) -> Option<String>) -> Vec<PathBuf> {
let mut dirs = vec![PathBuf::from("/usr/share/fonts"), PathBuf::from("/usr/local/share/fonts")];
let home = env("HOME").filter(|h| !h.is_empty()).map(PathBuf::from);
match env("XDG_DATA_HOME").filter(|d| !d.is_empty()) {
Some(data) => dirs.push(PathBuf::from(data).join("fonts")),
None => {
if let Some(h) = &home {
dirs.push(h.join(".local/share/fonts"));
}
}
}
if let Some(h) = &home {
dirs.push(h.join(".fonts"));
}
if let Some(list) = env("XDG_DATA_DIRS") {
for d in list.split(':').filter(|d| !d.is_empty()) {
dirs.push(PathBuf::from(d).join("fonts"));
}
}
dirs.dedup();
dirs
}
/// A COLRv1 font file installed under `dirs`, if any. The scan is shallow and stops at the first one found, so a window that starts on a
/// machine without such a font costs a few directory reads and nothing else.
pub fn colrv1_font_present(dirs: &[PathBuf]) -> Option<PathBuf> {
fn scan(dir: &Path, depth: u8, found: &mut Option<PathBuf>) {
if found.is_some() || depth == 0 {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan(&path, depth - 1, found);
}
else if path.file_name().map(|n| n.to_string_lossy().to_ascii_lowercase().contains("colrv1")).unwrap_or(false) {
*found = Some(path);
return;
}
if found.is_some() {
return;
}
}
}
let mut found = None;
for dir in dirs {
scan(dir, 5, &mut found);
if found.is_some() {
break;
}
}
found
}
/// The configuration file's text: the system's own rules, then the rejection of the COLRv1 faces, then the outline family preferred.
pub fn config_text(system_config: &str) -> String {
let mut lines: Vec<String> = vec![
"<?xml version=\"1.0\"?>".into(),
"<!DOCTYPE fontconfig SYSTEM \"urn:fontconfig:fonts.dtd\">".into(),
"<!-- Written by Strata for its own window only. The WebKitGTK inside the AppImage aborts while it draws a COLRv1 colour font,".into(),
" so those faces are hidden from the window; every other font is the system's own setup, included first.".into(),
" Off: STRATA_FONT_CONFIG=off. Your own file: set FONTCONFIG_FILE yourself. -->".into(),
"<fontconfig>".into(),
format!(" <include ignore_missing=\"yes\">{system_config}</include>"),
" <selectfont>".into(),
];
// fontconfig rejects a font only inside <selectfont>
for glob in COLRV1_GLOBS {
lines.push(format!(" <rejectfont><glob>{glob}</glob></rejectfont>"));
}
lines.push(" </selectfont>".into());
lines.push(" <alias binding=\"strong\">".into());
lines.push(" <family>Noto Color Emoji</family>".into());
lines.push(format!(" <prefer><family>{OUTLINE_EMOJI}</family></prefer>"));
lines.push(" </alias>".into());
lines.push("</fontconfig>".into());
lines.join("\n") + "\n"
}
/// Write the file under `dir` and return its path, or None when it cannot be written or there is no system configuration to include
/// (then the window would be left without any font). Rewritten only when the contents differ, so a start-up touches the disk once, and a
/// file left by an older Strata is put right. Written through a temporary file and moved, so a second instance starting at the same moment
/// never hands fontconfig half a file.
pub fn ensure_config(dir: &Path, system_config: &Path) -> Option<PathBuf> {
if !system_config.exists() {
return None;
}
std::fs::create_dir_all(dir).ok()?;
let path = dir.join(FILE_NAME);
let want = config_text(&system_config.to_string_lossy());
if matches!(std::fs::read_to_string(&path), Ok(have) if have == want) {
return Some(path);
}
let tmp = dir.join(format!("{FILE_NAME}.{}.tmp", std::process::id()));
std::fs::write(&tmp, want.as_bytes()).ok()?;
if std::fs::rename(&tmp, &path).is_err() {
let _ = std::fs::remove_file(&tmp);
// another writer got there first: take its file only if it is the one wanted
return (std::fs::read_to_string(&path).ok()? == want).then_some(path);
}
Some(path)
}
/// The file the window process should hand to fontconfig, or None to leave the environment exactly as it is. In this order, so a person's
/// own choice always wins and a machine without the font is untouched:
/// 1. `FONTCONFIG_FILE` already set: theirs.
/// 2. `STRATA_FONT_CONFIG=off`: nothing.
/// 3. `STRATA_FONT_CONFIG=<path>`: that file, when it exists.
/// 4. no COLRv1 font installed: nothing.
pub fn config_for(env: &dyn Fn(&str) -> Option<String>, dir: &Path, dirs: &[PathBuf], system_config: &Path) -> Option<PathBuf> {
if env("FONTCONFIG_FILE").is_some() || env("STRATA_FONT_CONFIG").as_deref() == Some(OFF) {
return None;
}
if let Some(custom) = env("STRATA_FONT_CONFIG") {
let p = PathBuf::from(custom);
return p.is_file().then_some(p);
}
colrv1_font_present(dirs)?;
ensure_config(dir, system_config)
}
#[cfg(test)]
mod tests {
use super::*;
fn env_from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let owned: Vec<(String, String)> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
move |k: &str| owned.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone())
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("strata-fontguard-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn the_file_includes_the_system_config_first_and_rejects_only_colrv1_faces_inside_selectfont() {
let text = config_text("/etc/fonts/fonts.conf");
assert!(text.starts_with("<?xml"));
let include = text.find("/etc/fonts/fonts.conf").expect("the system's fonts are kept");
let select = text.find("<selectfont>").expect("a rejection needs <selectfont>");
let select_end = text.find("</selectfont>").expect("closed");
assert!(include < select && select < select_end);
assert_eq!(text.matches("<rejectfont>").count(), COLRV1_GLOBS.len());
for glob in COLRV1_GLOBS {
let at = text.find(&format!("<glob>{glob}</glob>")).unwrap_or_else(|| panic!("{glob} is rejected"));
assert!(at > select && at < select_end, "{glob} is inside <selectfont>");
}
// the bitmap and the outline faces stay
assert!(!text.contains("NotoColorEmoji.ttf") && !text.contains("NotoEmoji-Regular.ttf"));
assert!(text.contains("<prefer><family>Noto Emoji</family></prefer>"));
assert!(!text.contains('{') && !text.contains('}'), "no placeholder left: {text}");
// well formed enough: every opened tag is closed
for tag in ["fontconfig", "selectfont", "alias"] {
assert_eq!(text.matches(&format!("<{tag}")).count(), text.matches(&format!("</{tag}>")).count(), "{tag}");
}
}
#[test]
fn the_globs_match_the_real_file_names_and_not_the_faces_that_draw_fine() {
// fontconfig's glob is `*` anywhere; this is the same rule on the file names that matter
let hit = |name: &str| COLRV1_GLOBS.iter().any(|g| { let core = g.trim_matches('*'); name.contains(core) });
for bad in ["/usr/share/fonts/google-noto-color-emoji-fonts/Noto-COLRv1.ttf", "/usr/share/fonts/truetype/noto/NotoColorEmoji-COLRv1.ttf",
"/home/me/.fonts/Twemoji-colrv1.ttf", "/usr/share/fonts/NotoSansCOLRv1-Regular.ttf"] {
assert!(hit(bad), "{bad}");
}
for good in ["/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf", "/usr/share/fonts/truetype/noto/NotoEmoji-Regular.ttf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf"] {
assert!(!hit(good), "{good}");
}
}
#[test]
fn a_persons_own_fontconfig_or_an_opt_out_leaves_the_environment_alone() {
let dir = scratch("own");
let system = dir.join("system.conf");
std::fs::write(&system, b"<fontconfig/>").unwrap();
let fonts = dir.join("fonts");
std::fs::create_dir_all(&fonts).unwrap();
std::fs::write(fonts.join("Noto-COLRv1.ttf"), b"x").unwrap();
let dirs = vec![fonts];
assert_eq!(config_for(&env_from(&[("FONTCONFIG_FILE", "/home/me/fonts.conf")]), &dir, &dirs, &system), None);
assert_eq!(config_for(&env_from(&[("STRATA_FONT_CONFIG", "off")]), &dir, &dirs, &system), None);
let theirs = dir.join("theirs.conf");
std::fs::write(&theirs, b"<fontconfig/>").unwrap();
assert_eq!(config_for(&env_from(&[("STRATA_FONT_CONFIG", theirs.to_str().unwrap())]), &dir, &dirs, &system), Some(theirs));
assert_eq!(config_for(&env_from(&[("STRATA_FONT_CONFIG", "/no/such/fonts.conf")]), &dir, &dirs, &system), None);
// no setting at all: the font is there, so the file is written
assert!(config_for(&env_from(&[]), &dir.join("out"), &dirs, &system).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_machine_without_a_colrv1_font_is_not_touched() {
let dir = scratch("none");
let system = dir.join("system.conf");
std::fs::write(&system, b"<fontconfig/>").unwrap();
let fonts = dir.join("fonts");
std::fs::create_dir_all(fonts.join("noto")).unwrap();
std::fs::write(fonts.join("noto/NotoColorEmoji.ttf"), b"bitmap").unwrap();
std::fs::write(fonts.join("noto/NotoEmoji-Regular.ttf"), b"outline").unwrap();
std::fs::write(fonts.join("noto/NotoSansArabic-Regular.ttf"), b"arabic").unwrap();
assert_eq!(colrv1_font_present(&[fonts.clone()]), None);
assert_eq!(config_for(&env_from(&[]), &dir.join("out"), &[fonts.clone()], &system), None);
assert!(!dir.join("out").exists(), "nothing is written");
std::fs::write(fonts.join("noto/Noto-COLRv1.ttf"), b"colour").unwrap();
assert!(colrv1_font_present(&[fonts]).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_scan_is_shallow_and_a_missing_folder_is_fine() {
let dir = scratch("deep");
let mut deep = dir.clone();
for i in 0..9 { deep = deep.join(format!("d{i}")); }
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(deep.join("Noto-COLRv1.ttf"), b"x").unwrap();
assert_eq!(colrv1_font_present(&[dir.clone(), dir.join("missing")]), None, "nine levels down is not a font folder");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_folders_follow_the_xdg_variables() {
let none = font_dirs(&env_from(&[("HOME", "/home/me")]));
assert!(none.contains(&PathBuf::from("/usr/share/fonts")));
assert!(none.contains(&PathBuf::from("/home/me/.local/share/fonts")));
assert!(none.contains(&PathBuf::from("/home/me/.fonts")));
let set = font_dirs(&env_from(&[("HOME", "/home/me"), ("XDG_DATA_HOME", "/data/me"), ("XDG_DATA_DIRS", "/usr/local/share:/var/lib/flatpak/exports/share")]));
assert!(set.contains(&PathBuf::from("/data/me/fonts")));
assert!(!set.contains(&PathBuf::from("/home/me/.local/share/fonts")));
assert!(set.contains(&PathBuf::from("/var/lib/flatpak/exports/share/fonts")));
}
#[test]
fn the_file_is_written_once_an_old_one_is_put_right_and_nothing_is_left_behind() {
let dir = scratch("write");
let system = dir.join("system.conf");
std::fs::write(&system, b"<fontconfig/>").unwrap();
let out = dir.join("out");
let first = ensure_config(&out, &system).expect("written");
assert_eq!(ensure_config(&out, &system).unwrap(), first, "the same path every start");
std::fs::write(&first, "<fontconfig/>").unwrap();
assert_eq!(ensure_config(&out, &system), Some(first.clone()));
assert_eq!(std::fs::read_to_string(&first).unwrap(), config_text(&system.to_string_lossy()));
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 1, "one file, no temporary");
// no system configuration to include: nothing is written (the window would have no font at all)
assert_eq!(ensure_config(&dir.join("out2"), &dir.join("absent.conf")), None);
assert!(!dir.join("out2").exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+97 -1
View File
@@ -39,7 +39,11 @@ use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
mod updater;
mod desktoplog;
mod recovery;
mod shell;
// Plain std, built on every platform so its decisions are tested everywhere; only the Linux window process uses it.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
mod fontguard;
#[cfg(target_os = "linux")]
mod supervisor;
@@ -879,7 +883,19 @@ fn main() {
/// Windows and macOS: one process owns the host and the window.
#[cfg(not(target_os = "linux"))]
fn run_embedded() {
tauri::Builder::default()
let builder = tauri::Builder::default();
// macOS: WKWebView ends the page's web content process under memory pressure and leaves a blank window; the page is loaded again (#5079).
#[cfg(target_os = "macos")]
let builder = builder.on_web_content_process_terminate(|webview| {
let port = webview.app_handle().state::<HostPort>().0.lock().ok().and_then(|p| *p);
let Some(port) = port else { return };
let failed = webview.url().ok().map(|u| u.to_string());
let target = webview.clone();
on_renderer_failure(format!("http://127.0.0.1:{port}"), recovery::Failure::RendererExited, "web content process terminated".to_string(), failed, move |url| {
if let Ok(url) = url.parse::<tauri::Url>() { let _ = target.navigate(url); }
});
});
builder
// First, as the plugin requires: a second launch ends here after telling the running app to show itself.
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
@@ -987,9 +1003,80 @@ fn open_window(app: &tauri::App, port: u16, shell_token: &str) -> Result<(), Box
}
#[cfg(target_os = "linux")]
hook_linux_drops(&window, port, shell_token);
#[cfg(windows)]
hook_renderer_failures(&window, port);
Ok(())
}
/// Milliseconds since the first failure report or window (a clock that only goes forward, for the recovery limit).
#[cfg(not(target_os = "linux"))]
fn uptime_ms() -> u64 {
static START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
START.get_or_init(Instant::now).elapsed().as_millis() as u64
}
/// The page's renderer is gone (#5079). Writes it to the run log, and, when the window needs the page again, loads it again through
/// `navigate` after a short wait: the same screen with `?recovery=1` the first time (the page then starts with the sidebar previews
/// closed once), the home screen the second time, and nothing after two in two minutes. The chats are the host's and are not touched.
#[cfg(not(target_os = "linux"))]
fn on_renderer_failure(origin: String, failure: recovery::Failure, detail: String, failed: Option<String>, navigate: impl FnOnce(String) + Send + 'static) {
static GUARD: Mutex<recovery::Guard> = Mutex::new(recovery::Guard::new());
let say = |text: &str| { if let Some(log) = desktoplog::run_log() { log.line("shell", text); } };
say(&format!("the page's {} ({detail}); it was showing {}", failure.name(), failed.as_deref().unwrap_or("an unknown address")));
let plan = match GUARD.lock() {
Ok(mut guard) => recovery::plan(&mut guard, uptime_ms(), &origin, failure, failed.as_deref()),
Err(_) => recovery::Plan::Stop,
};
match plan {
recovery::Plan::Ignore => {}
recovery::Plan::Stop => say("the page's renderer failed again within two minutes of two recoveries; the window is left as it is (Reload in the page loads it again)"),
recovery::Plan::Reload { attempt, url } => {
say(&format!("loading the page again (recovery {attempt}): {url}"));
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(recovery::RELOAD_DELAY_MS));
navigate(url);
});
}
}
}
/// Windows: WebView2 tells the shell when a process of the page fails; the page's renderer dying is recovered by on_renderer_failure.
#[cfg(windows)]
fn hook_renderer_failures(window: &tauri::WebviewWindow, port: u16) {
use webview2_com::Microsoft::Web::WebView2::Win32::{ICoreWebView2ProcessFailedEventArgs2, COREWEBVIEW2_PROCESS_FAILED_KIND};
use webview2_com::{take_pwstr, ProcessFailedEventHandler};
use windows_core::{Interface, PWSTR};
let target = window.clone();
let origin = format!("http://127.0.0.1:{port}");
let _ = window.with_webview(move |platform| unsafe {
let Ok(core) = platform.controller().CoreWebView2() else { return };
let handler = ProcessFailedEventHandler::create(Box::new(move |sender, args| {
let Some(args) = args else { return Ok(()) };
let mut kind = COREWEBVIEW2_PROCESS_FAILED_KIND::default();
args.ProcessFailedKind(&mut kind)?;
let mut detail = format!("kind {}", kind.0);
if let Ok(more) = args.cast::<ICoreWebView2ProcessFailedEventArgs2>() {
let mut reason = Default::default();
let mut exit_code = 0i32;
if more.Reason(&mut reason).is_ok() { detail = format!("{detail}, reason {}", recovery::reason_name(reason.0)); }
if more.ExitCode(&mut exit_code).is_ok() { detail = format!("{detail}, exit code {exit_code:#x}"); }
}
let failed = sender.and_then(|view| {
let mut source = PWSTR::null();
view.Source(&mut source).ok()?;
Some(take_pwstr(source))
});
let window = target.clone();
on_renderer_failure(origin.clone(), recovery::Failure::from_webview2(kind.0), detail, failed, move |url| {
if let Ok(url) = url.parse::<tauri::Url>() { let _ = window.navigate(url); }
});
Ok(())
}));
let mut token = 0i64;
let _ = core.add_ProcessFailed(&handler, &mut token);
});
}
/// A file:// address from a drag, as a path (percent-escapes decoded); anything else (http, mailto, text) is not a dropped file.
#[cfg_attr(windows, allow(dead_code))]
fn file_uri_to_path(uri: &str) -> Option<String> {
@@ -1143,6 +1230,15 @@ fn run_window_process() -> ! {
eprintln!("[strata] STRATA_WINDOW_TEST_CRASH is set: the window process aborts on purpose.");
std::process::abort();
}
// WebKitGTK aborts while it draws a COLRv1 colour emoji font (fontguard.rs, #4533): this process is told to leave that font out. Only this
// process, and before WebKit starts its own: the host, the engine and the agent keep the fonts the person chose.
{
let env = |k: &str| std::env::var(k).ok();
let dirs = fontguard::font_dirs(&env);
if let Some(conf) = fontguard::config_for(&env, &desktoplog::data_dir().join("fonts"), &dirs, Path::new(fontguard::SYSTEM_CONFIG)) {
std::env::set_var("FONTCONFIG_FILE", conf);
}
}
// The shell is this process's parent: when it is gone (killed, crashed, logged off) the window goes too.
let parent = std::os::unix::process::parent_id();
std::thread::spawn(move || loop {
+260
View File
@@ -0,0 +1,260 @@
//! A page whose renderer dies is brought back, not left on the browser's error page (#5079).
//!
//! WebView2 (Windows) reports a dead renderer through its ProcessFailed event, and WKWebView (macOS) through
//! `webViewWebContentProcessDidTerminate`; until now the shell listened to neither, so the window kept the browser's own "This page has a
//! problem" page, nothing reached the log, and the person's Reload brought back the same chat that had just crashed. Linux does not need it:
//! there the window is its own process and the shell moves to browser mode when it dies (supervisor.rs).
//!
//! This file is only decisions, so it builds and tests everywhere; the platform hooks are in main.rs.
//! - which failures are worth a reload (the page's own renderer; not a GPU process that restarts by itself, not a frame of the page);
//! - how many automatic recoveries are allowed in a window of time (a renderer that dies again at once is not reloaded for ever);
//! - where the reload goes: the same screen first, with the sidebar previews closed once; the home screen the second time.
//! The page reads `?recovery=<n>` in apps/host/src/lib/recovery.ts. The chats are the host's: nothing here touches them.
/// Automatic recoveries allowed inside `WINDOW_MS`; the next failure leaves the window as it is for the person to reload.
pub const MAX_AUTOMATIC: usize = 2;
/// The window of time the limit counts over.
pub const WINDOW_MS: u64 = 120_000;
/// Let the browser start a new renderer before the page is asked for again.
pub const RELOAD_DELAY_MS: u64 = 400;
/// WebView2's COREWEBVIEW2_PROCESS_FAILED_KIND values, by number.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Failure {
BrowserExited,
/// The renderer of the page itself.
RendererExited,
Unresponsive,
/// The renderer of a frame inside the page (a website preview); the page itself is alive.
FrameRendererExited,
Gpu,
Other(i32),
}
impl Failure {
pub fn from_webview2(kind: i32) -> Failure {
match kind {
0 => Failure::BrowserExited,
1 => Failure::RendererExited,
2 => Failure::Unresponsive,
3 => Failure::FrameRendererExited,
6 => Failure::Gpu,
other => Failure::Other(other),
}
}
/// Only the page's own renderer dying leaves a window that has to be loaded again. A GPU process is restarted by the browser, a frame
/// shows its own sad face, an unresponsive page may only be busy, and a dead browser process cannot navigate at all.
pub fn needs_reload(self) -> bool {
self == Failure::RendererExited
}
pub fn name(self) -> String {
match self {
Failure::BrowserExited => "browser process exited".into(),
Failure::RendererExited => "renderer exited".into(),
Failure::Unresponsive => "renderer unresponsive".into(),
Failure::FrameRendererExited => "frame renderer exited".into(),
Failure::Gpu => "gpu process exited".into(),
Failure::Other(n) => format!("process failure kind {n}"),
}
}
}
/// WebView2's COREWEBVIEW2_PROCESS_FAILED_REASON, for the log.
pub fn reason_name(reason: i32) -> &'static str {
match reason {
0 => "unexpected",
1 => "unresponsive",
2 => "terminated",
3 => "crashed",
4 => "launch failed",
5 => "out of memory",
6 => "profile deleted",
_ => "unknown",
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
/// Load the page again; `attempt` is 1 for the first recovery of the window, 2 for the second.
Reload { attempt: usize },
/// Too many in a short time: leave the window alone.
Stop,
}
/// Counts recoveries over a sliding window of time. `now_ms` is any clock that only goes forward (the caller's uptime).
#[derive(Default, Debug)]
pub struct Guard {
times: Vec<u64>,
}
impl Guard {
pub const fn new() -> Guard {
Guard { times: Vec::new() }
}
pub fn on_failure(&mut self, now_ms: u64) -> Action {
self.times.retain(|t| now_ms.saturating_sub(*t) < WINDOW_MS);
if self.times.len() >= MAX_AUTOMATIC {
return Action::Stop;
}
self.times.push(now_ms);
Action::Reload { attempt: self.times.len() }
}
}
/// The address a recovery loads. The first goes back to the screen that died (its `#/...` part) with `?recovery=1`, which makes the page
/// start with the sidebar previews closed once; the second goes to the home screen, in case the screen itself is what crashes.
/// `failed` is the address the window was showing; it is used only when it is on `origin`.
pub fn recovery_url(origin: &str, attempt: usize, failed: Option<&str>) -> String {
let origin = origin.trim_end_matches('/');
let base = format!("{origin}/?recovery={}", attempt.clamp(1, 9));
if attempt > 1 {
return base;
}
let Some(failed) = failed else { return base };
// on this origin: after it comes the end, a path, a query or a fragment (not another digit of a longer port)
let rest = match failed.strip_prefix(origin) {
Some(rest) if rest.is_empty() || rest.starts_with(['/', '?', '#']) => rest,
_ => return base,
};
match rest.find('#') {
Some(at) => {
let hash = &rest[at..];
// only what a page address holds: no spaces, quotes, angle brackets or control characters
let safe = hash.len() < 2048 && hash.starts_with("#/") && hash.chars().all(|c| c.is_ascii_graphic() && !matches!(c, '"' | '<' | '>' | '\\' | '`' | '{' | '}' | '|' | '^'));
if safe { format!("{base}{hash}") } else { base }
}
None => base,
}
}
/// What the shell does about one failure report.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Plan {
/// Not a failure that leaves the window needing a reload: only logged.
Ignore,
/// A reload is due, to `url`.
Reload { attempt: usize, url: String },
/// Too many recent recoveries: the window is left as it is.
Stop,
}
/// One failure report in, the decision out. The platform hooks (main.rs) only collect the facts and carry the decision out.
pub fn plan(guard: &mut Guard, now_ms: u64, origin: &str, failure: Failure, failed: Option<&str>) -> Plan {
if !failure.needs_reload() {
return Plan::Ignore;
}
match guard.on_failure(now_ms) {
Action::Stop => Plan::Stop,
Action::Reload { attempt } => Plan::Reload { attempt, url: recovery_url(origin, attempt, failed) },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_pages_own_renderer_is_reloaded() {
assert!(Failure::from_webview2(1).needs_reload());
for kind in [0, 2, 3, 4, 5, 6, 7, 8, 9, 42] {
assert!(!Failure::from_webview2(kind).needs_reload(), "kind {kind}");
}
assert_eq!(Failure::from_webview2(6), Failure::Gpu);
assert_eq!(Failure::from_webview2(1).name(), "renderer exited");
assert_eq!(Failure::from_webview2(42).name(), "process failure kind 42");
}
#[test]
fn reasons_are_named_for_the_log() {
assert_eq!(reason_name(5), "out of memory");
assert_eq!(reason_name(3), "crashed");
assert_eq!(reason_name(99), "unknown");
}
#[test]
fn two_recoveries_in_two_minutes_then_the_window_is_left_alone() {
let mut g = Guard::new();
assert_eq!(g.on_failure(1_000), Action::Reload { attempt: 1 });
assert_eq!(g.on_failure(5_000), Action::Reload { attempt: 2 });
assert_eq!(g.on_failure(6_000), Action::Stop);
assert_eq!(g.on_failure(100_000), Action::Stop);
// the first has left the window: one more is allowed, and it is the second of the window
assert_eq!(g.on_failure(1_000 + WINDOW_MS), Action::Reload { attempt: 2 });
assert_eq!(g.on_failure(1_000 + WINDOW_MS + 1), Action::Stop);
}
#[test]
fn a_renderer_that_dies_later_starts_over_at_the_first_recovery() {
let mut g = Guard::new();
assert_eq!(g.on_failure(0), Action::Reload { attempt: 1 });
assert_eq!(g.on_failure(10), Action::Reload { attempt: 2 });
assert_eq!(g.on_failure(WINDOW_MS + 100), Action::Reload { attempt: 1 });
}
#[test]
fn a_stopped_loop_stays_stopped_until_the_window_has_passed() {
let mut g = Guard::new();
let mut reloads = 0;
// a renderer that dies every second for ten minutes: never more than two reloads in any two-minute window
let mut loaded = Vec::new();
for second in 0..600u64 {
if let Action::Reload { .. } = g.on_failure(second * 1_000) {
reloads += 1;
loaded.push(second);
}
}
assert!(reloads <= 2 * (600 / (WINDOW_MS / 1_000) as usize + 1));
for w in loaded.windows(3) {
assert!(w[2] - w[0] >= WINDOW_MS / 1_000, "three reloads inside one window: {w:?}");
}
}
#[test]
fn the_first_recovery_keeps_the_screen_and_the_second_goes_home() {
let o = "http://127.0.0.1:47831";
assert_eq!(recovery_url(o, 1, Some("http://127.0.0.1:47831/#/chat/s_abc")), "http://127.0.0.1:47831/?recovery=1#/chat/s_abc");
assert_eq!(recovery_url(o, 1, Some("http://127.0.0.1:47831/?recovery=1#/chat/s_abc")), "http://127.0.0.1:47831/?recovery=1#/chat/s_abc");
assert_eq!(recovery_url(o, 2, Some("http://127.0.0.1:47831/#/chat/s_abc")), "http://127.0.0.1:47831/?recovery=2");
assert_eq!(recovery_url(o, 1, None), "http://127.0.0.1:47831/?recovery=1");
assert_eq!(recovery_url("http://127.0.0.1:47831/", 1, None), "http://127.0.0.1:47831/?recovery=1");
}
#[test]
fn an_address_that_is_not_ours_or_not_plain_is_not_carried_over() {
let o = "http://127.0.0.1:47831";
for failed in [
"https://example.com/#/chat/x",
"http://127.0.0.1:47832/#/chat/x",
"http://127.0.0.1:47831/#/chat/x y",
"http://127.0.0.1:47831/#/chat/\"onload=",
"http://127.0.0.1:47831/#/chat/<script>",
"http://127.0.0.1:47831/#nothing",
"http://127.0.0.1:47831/#/chat/\u{e9}",
"chrome-error://chromewebdata/",
] {
assert_eq!(recovery_url(o, 1, Some(failed)), "http://127.0.0.1:47831/?recovery=1", "{failed}");
}
let long = format!("http://127.0.0.1:47831/#/{}", "a".repeat(3000));
assert_eq!(recovery_url(o, 1, Some(&long)), "http://127.0.0.1:47831/?recovery=1");
// a look-alike host is not the origin
assert_eq!(recovery_url(o, 1, Some("http://127.0.0.1:478310/#/chat/x")), "http://127.0.0.1:47831/?recovery=1");
}
#[test]
fn a_failure_report_becomes_one_decision() {
let o = "http://127.0.0.1:47831";
let mut g = Guard::new();
let chat = Some("http://127.0.0.1:47831/#/chat/s_1");
// a GPU process or a frame going down never costs the person their page, and does not use up the allowance
assert_eq!(plan(&mut g, 0, o, Failure::Gpu, chat), Plan::Ignore);
assert_eq!(plan(&mut g, 1, o, Failure::FrameRendererExited, chat), Plan::Ignore);
assert_eq!(plan(&mut g, 2, o, Failure::Unresponsive, chat), Plan::Ignore);
assert_eq!(plan(&mut g, 3, o, Failure::BrowserExited, chat), Plan::Ignore);
assert_eq!(plan(&mut g, 1_000, o, Failure::RendererExited, chat), Plan::Reload { attempt: 1, url: "http://127.0.0.1:47831/?recovery=1#/chat/s_1".into() });
assert_eq!(plan(&mut g, 4_000, o, Failure::RendererExited, chat), Plan::Reload { attempt: 2, url: "http://127.0.0.1:47831/?recovery=2".into() });
assert_eq!(plan(&mut g, 7_000, o, Failure::RendererExited, chat), Plan::Stop);
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "strata",
"version": "0.2.10",
"version": "0.2.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "strata",
"version": "0.2.10",
"version": "0.2.12",
"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.10",
"version": "0.2.12",
"type": "module",
"description": "Strata \u2014 local AI, tuned to one model. Web shell + engine host.",
"scripts": {
+1 -1
View File
@@ -24,7 +24,7 @@ async function metrics(tag) {
await cdp.send('HeapProfiler.collectGarbage').catch(() => {});
const m = Object.fromEntries((await cdp.send('Performance.getMetrics')).metrics.map(x => [x.name, x.value]));
const dom = await cdp.send('Memory.getDOMCounters').catch(() => ({}));
const sess = await page.evaluate(() => { try { return (localStorage.getItem('strata.sessions.v1') ?? '').length; } catch { return -1; } });
const sess = await page.evaluate(async () => { try { const h = { 'x-strata-client': 'ui' }; const l = await (await fetch('/api/chats', { headers: h })).json(); return l.chats.reduce((n, c) => n + (c.bytes ?? 0), 0); } catch { return -1; } });
console.log(`${tag.padEnd(22)} heap=${(m.JSHeapUsedSize / 1048576).toFixed(0)}MB total=${(m.JSHeapTotalSize / 1048576).toFixed(0)}MB nodes=${dom.nodes ?? m.Nodes} listeners=${dom.jsEventListeners ?? '-'} docs=${m.Documents} ls=${(sess / 1e6).toFixed(2)}M`);
return m.JSHeapUsedSize;
}
+8 -5
View File
@@ -1235,14 +1235,17 @@ if (flow === 'unread') {
await page.waitForTimeout(600);
// make the older conversation look updated since it was last read
await page.evaluate(() => {
const raw = JSON.parse(localStorage.getItem('strata.sessions.v1') ?? '[]');
await page.evaluate(async () => {
// The chats are files on the host (owner decision 11-04): read them and save the older one again, as the page would.
const h = { 'x-strata-client': 'ui' };
const list = await (await fetch('/api/chats', { headers: h })).json();
const raw = await Promise.all(list.chats.map(async (c) => (await (await fetch('/api/chats/' + encodeURIComponent(c.id), { headers: h })).json()).session));
const older = raw.filter((s) => s.messages.length > 0).at(-1);
if (older) {
older.updatedAt = new Date(Date.now() + 120000).toISOString();
older.lastReadAt = new Date(Date.now() - 120000).toISOString();
await fetch('/api/chats/' + encodeURIComponent(older.id), { method: 'PUT', headers: h, body: JSON.stringify(older) });
}
localStorage.setItem('strata.sessions.v1', JSON.stringify(raw));
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1800);
@@ -1352,7 +1355,7 @@ if (flow === 'branch') {
// remember the original so "untouched" means unchanged by the branch, not a
// hardcoded length
const before = await page.evaluate(() =>
JSON.parse(localStorage.getItem('strata.sessions.v1') ?? '[]')
(async () => { const h = { 'x-strata-client': 'ui' }; const l = await (await fetch('/api/chats', { headers: h })).json(); return Promise.all(l.chats.map(async (c) => (await (await fetch('/api/chats/' + encodeURIComponent(c.id), { headers: h })).json()).session)); })()
.map((s) => ({ title: s.title, roles: s.messages.map((m) => m.role) })));
const originalBefore = before.find((s) => !s.title.includes('branch'));
@@ -1370,7 +1373,7 @@ if (flow === 'branch') {
console.log('from user :', JSON.stringify(await view()), '<- branch must show only the user turn, and the url must follow');
const sessions = await page.evaluate(() =>
JSON.parse(localStorage.getItem('strata.sessions.v1') ?? '[]')
(async () => { const h = { 'x-strata-client': 'ui' }; const l = await (await fetch('/api/chats', { headers: h })).json(); return Promise.all(l.chats.map(async (c) => (await (await fetch('/api/chats/' + encodeURIComponent(c.id), { headers: h })).json()).session)); })()
.map((s) => ({ title: s.title, roles: s.messages.map((m) => m.role) })));
console.log('stored :', JSON.stringify(sessions));
const originalAfter = sessions.find((s) => !s.title.includes('branch'));
@@ -0,0 +1,63 @@
"""Writes src/locales/sav-<lang>.json: the notice for chats the host could not write (owner wording 11-04, #5192).
Run from apps/host: python scripts/make-chatsave-locales.py
Every entry names all nine languages; the keys are sav01.. in the order of S. sav-en.json is the English source. Placeholders ({0}) must be kept in every
language. A separate file keeps this work from colliding with the other catalogs (the same reason upd-, df2-, pick- and the others exist)."""
import json
import re
from pathlib import Path
LANGS = ["en", "zh", "ja", "de", "es", "ru", "pt", "ko", "fr"]
S = []
def add(en, zh, ja, de, es, ru, pt, ko, fr):
S.append(dict(en=en, zh=zh, ja=ja, de=de, es=es, ru=ru, pt=pt, ko=ko, fr=fr))
add("Some chats could not be saved",
"部分聊天无法保存",
"一部のチャットを保存できませんでした",
"Einige Chats konnten nicht gespeichert werden",
"No se pudieron guardar algunos chats",
"Не удалось сохранить некоторые чаты",
"Alguns chats não puderam ser guardados",
"일부 채팅을 저장하지 못했습니다",
"Certaines conversations n'ont pas pu être enregistrées")
add("Strata's chat storage is full, so the newest changes to {0} chat(s) are only in this window. Delete old chats to make room; they then save by themselves.",
"Strata 的聊天存储空间已满,因此 {0} 个聊天的最新更改目前只保存在此窗口中。请删除旧聊天以腾出空间,之后它们会自动保存。",
"Strata のチャット保存領域がいっぱいのため、{0} 件のチャットの最新の変更はこのウィンドウにしかありません。古いチャットを削除して空きを作ると、自動的に保存されます。",
"Der Chat-Speicher von Strata ist voll, daher gibt es die neuesten Änderungen an {0} Chat(s) nur in diesem Fenster. Löschen Sie alte Chats, um Platz zu schaffen; dann werden sie automatisch gespeichert.",
"El almacenamiento de chats de Strata está lleno, así que los cambios más recientes de {0} chat(s) solo están en esta ventana. Elimina chats antiguos para liberar espacio; después se guardarán solos.",
"Хранилище чатов Strata заполнено, поэтому последние изменения в чатах ({0}) есть только в этом окне. Удалите старые чаты, чтобы освободить место; после этого они сохранятся сами.",
"O armazenamento de chats do Strata está cheio, por isso as alterações mais recentes de {0} chat(s) só existem nesta janela. Apague chats antigos para libertar espaço; depois eles guardam-se sozinhos.",
"Strata의 채팅 저장 공간이 가득 차서 {0}개 채팅의 최신 변경 내용이 이 창에만 있습니다. 오래된 채팅을 삭제해 공간을 확보하세요. 그러면 자동으로 저장됩니다.",
"L'espace de stockage des conversations de Strata est plein : les dernières modifications de {0} conversation(s) ne sont que dans cette fenêtre. Supprimez d'anciennes conversations pour faire de la place ; elles s'enregistreront alors d'elles-mêmes.")
add("Copy unsaved chats",
"复制未保存的聊天",
"未保存のチャットをコピー",
"Nicht gespeicherte Chats kopieren",
"Copiar chats sin guardar",
"Скопировать несохранённые чаты",
"Copiar chats não guardados",
"저장되지 않은 채팅 복사",
"Copier les conversations non enregistrées")
here = Path(__file__).resolve().parent.parent / "src" / "locales"
NBSP = " "
def french(text):
text = re.sub(r"(?<=[A-Za-zÀ-ſ])'(?=[A-Za-zÀ-ſ{])", "’", text)
text = re.sub(r"(?<=\S) ([:;?!])", NBSP + r"\1", text)
return text
for lang in LANGS:
data = {}
for i, entry in enumerate(S):
text = entry[lang]
assert sorted(re.findall(r"\{\d+\}", entry["en"])) == sorted(re.findall(r"\{\d+\}", text)), (lang, entry["en"], text)
data[f"sav{i + 1:02d}"] = french(text) if lang == "fr" else text
with (here / f"sav-{lang}.json").open("w", encoding="utf-8", newline="\n") as handle:
handle.write(json.dumps(data, ensure_ascii=False, indent=1) + "\n")
print("written", len(S), "strings in", len(LANGS), "languages")
+45
View File
@@ -175,6 +175,51 @@ add("Drafting: MTP (DFlash2 didn't start)",
"Previsão: MTP (o DFlash2 não iniciou)",
"초안 예측: MTP(DFlash2가 시작되지 않았습니다)",
"Anticipation : MTP (DFlash2 n'a pas démarré)")
add("Auto uses the all-cards split engine with the MTP draft layer: about {0} GB to download, once.",
"自动模式使用全部显卡的拆分引擎和 MTP 草稿层:约 {0} GB,只下载一次。",
"自動では、全カードのスプリットエンジンと MTP ドラフト層を使います。約 {0} GB を一度だけダウンロードします。",
"Automatisch nutzt die Alle-Karten-Split-Engine mit der MTP-Entwurfsschicht: etwa {0} GB, einmalig herunterzuladen.",
"Automático usa el motor de reparto entre todas las tarjetas con la capa de borrador MTP: unos {0} GB, una sola vez.",
"Авто использует движок с разделением по всем картам и черновой слой MTP: около {0} ГБ, скачивается один раз.",
"O automático usa o motor de divisão entre todas as placas com a camada de rascunho MTP: cerca de {0} GB, uma vez.",
"자동은 모든 카드를 쓰는 분할 엔진과 MTP 초안 층을 사용합니다(약 {0}GB, 한 번만 내려받음).",
"Auto utilise le moteur réparti sur toutes les cartes avec la couche de brouillon MTP : environ {0} Go à télécharger, une seule fois.")
add("The all-cards split engine could not start, so the normal layer split runs.",
"全显卡拆分引擎未能启动,因此改用常规的分层拆分。",
"全カードのスプリットエンジンを起動できなかったため、通常のレイヤー分割で動作しています。",
"Die Alle-Karten-Split-Engine konnte nicht starten, deshalb läuft die normale Schichtaufteilung.",
"El motor de reparto entre todas las tarjetas no pudo arrancar, así que se usa el reparto normal por capas.",
"Движок с разделением по всем картам не запустился, поэтому работает обычное послойное разделение.",
"O motor de divisão entre todas as placas não conseguiu iniciar, então a divisão normal por camadas está em uso.",
"모든 카드를 쓰는 분할 엔진을 시작하지 못해 일반 레이어 분할로 실행합니다.",
"Le moteur réparti sur toutes les cartes n'a pas pu démarrer, la répartition normale par couches est donc utilisée.")
add("Drafting: off (the cards were short of memory)",
"预测草稿:关闭(显卡内存不足)",
"先読み:オフ(グラフィックスカードのメモリが不足していました)",
"Vorausraten: aus (den Karten fehlte Speicher)",
"Predicción: desactivada (a las tarjetas les faltaba memoria)",
"Предсказание: выключено (на картах не хватило памяти)",
"Previsão: desligada (faltou memória nas placas)",
"초안 예측: 꺼짐(카드 메모리가 부족했습니다)",
"Anticipation : désactivée (les cartes manquaient de mémoire)")
add("On your Mac, DFlash2 was faster than MTP on this model in our tests.",
"在您的 Mac 上,我们的测试中 DFlash2 在此模型上比 MTP 更快。",
"お使いの Mac では、私たちのテストでこのモデルにおいて DFlash2 は MTP より速くなりました。",
"Auf Ihrem Mac war DFlash2 bei diesem Modell in unseren Tests schneller als MTP.",
"En su Mac, en nuestras pruebas DFlash2 fue más rápido que MTP con este modelo.",
"На вашем Mac в наших тестах DFlash2 с этой моделью был быстрее MTP.",
"No seu Mac, nos nossos testes o DFlash2 foi mais rápido que o MTP com este modelo.",
"고객님의 Mac에서 저희 테스트 결과 이 모델에서는 DFlash2가 MTP보다 빨랐습니다.",
"Sur votre Mac, DFlash2 a été plus rapide que MTP avec ce modèle lors de nos tests.")
add("DFlash2 is off by default on this graphics card for now; choose it in Drafting to use it.",
"目前在这张显卡上 DFlash2 默认关闭;如需使用,请在“预测草稿”中选择它。",
"現在、このグラフィックスカードでは DFlash2 は既定でオフです。使うには「先読み」で選んでください。",
"DFlash2 ist auf dieser Grafikkarte vorerst standardmäßig aus; wählen Sie es unter „Vorausraten“ aus, um es zu nutzen.",
"DFlash2 está desactivado de forma predeterminada en esta tarjeta gráfica por ahora; elíjalo en Predicción para usarlo.",
"На этой видеокарте DFlash2 пока выключен по умолчанию; чтобы использовать его, выберите его в разделе «Предсказание».",
"O DFlash2 está desligado por predefinição nesta placa de vídeo por agora; escolha-o em Previsão para o usar.",
"현재 이 그래픽 카드에서는 DFlash2가 기본적으로 꺼져 있습니다. 사용하려면 ‘초안 예측’에서 선택하세요.",
"DFlash2 est désactivé par défaut sur cette carte graphique pour le moment ; choisissez-le dans Anticipation pour l'utiliser.")
here = Path(__file__).resolve().parent.parent / "src" / "locales"
NBSP = " "
+2 -1
View File
@@ -62,7 +62,8 @@ export async function open(scheme, width, height, sessions, { hash = '#/chat', p
try {
if (sessionStorage.getItem('ui-do-seeded')) return;
sessionStorage.setItem('ui-do-seeded', '1');
if (sessions) localStorage.setItem('strata.sessions.v1', JSON.stringify(sessions));
// The chats are files on the host: put the seed there (a synchronous request, so it is in place before the page starts).
for (const chat of sessions ?? []) { const request = new XMLHttpRequest(); request.open('PUT', '/api/chats/' + encodeURIComponent(chat.id), false); request.setRequestHeader('x-strata-client', 'ui'); request.send(JSON.stringify({ messages: [], ...chat })); }
if (Object.keys(prefs).length) localStorage.setItem('strata.prefs.v1', JSON.stringify(prefs));
} catch { /* storage refused */ }
}, { sessions, prefs });
+56 -6
View File
@@ -26,7 +26,7 @@ import { buildProfile, routeFor, MODE_PRESETS, modeOf, effortOf, localOutputBudg
import { TurnProjector, parseArguments as parseArgs } from './events.mjs';
import { ChildProjector } from './children.mjs';
import { nearestBudget } from './llm-shape.mjs';
import { DeliverableTracker, ActivityGroups } from './deliverables.mjs';
import { DeliverableTracker, ActivityGroups, FolderClaims } from './deliverables.mjs';
import { SkillsUsed, skillInfoFrom } from './skills-used.mjs';
import { turnEngineSpeed } from './engine-speed.mjs';
import { TurnGuard, guardNote, GUARD_NOTES } from './turn-guard.mjs';
@@ -127,6 +127,8 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
const home = join(root, 'home');
const sessionsFile = join(root, 'sessions.json');
mkdirSync(root, { recursive: true });
/** Which chat's file tools wrote which file, for chats that share a folder (#5008). */
const folderClaims = new FolderClaims();
/**
* Where the turn's file tracker must never look (deliverables.mjs): the app, the engine and its components, Strata's own data, AppData
@@ -367,24 +369,27 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
let hideDeltas = !!researchTopic;
signal?.addEventListener('abort', () => { send = () => {}; attached = false; }, { once: true });
// What the assistant itself wrote in this run (not our own step lines): a plan turn reads its plan from it, a step reads its summary.
const written = { all: '', step: '' };
const written = { all: '', step: '', reasoning: '' };
// The frame that closed the turn, kept with the turn's text for a page that comes back after it ended (attach).
let terminal = null;
// The turn guard (turn-guard.mjs): counts failing searches, searches, plan rewrites and a nearly full context. Set up once the route is known.
let guard = null;
const raw = frame => { try { send(frame); } catch { send = () => {}; attached = false; } };
const raw = frame => { if (frame.type === 'done' || frame.type === 'error') terminal = frame; try { send(frame); } catch { send = () => {}; attached = false; } };
// What the turn made (files) and compact activity lines (notes-split-agent8.md): derived from the cards the turn already sends.
const tracker = new DeliverableTracker(workspacePath, trackerFolders());
const tracker = new DeliverableTracker(workspacePath, { ...trackerFolders(), claims: folderClaims, owner: chatId });
let toolsRan = false; // a turn that ran no tool wrote nothing, so there is nothing to look for at its end
try { tracker.snapshot(); } catch { /* a folder that cannot be read has no deliverables */ }
const groups = new ActivityGroups({ emit: raw });
const skills = new SkillsUsed({ emit: raw, info: skillInfoFrom(skillsDir()) });
const emit = frame => {
if (frame.type === 'reasoning' && frame.text && written.reasoning.length < 2_000_000) written.reasoning += frame.text;
if (frame.type === 'delta' && !frame.own) { written.all += frame.text; written.step += frame.text; if (guard) trip(guard.text(frame.text)); if (hideDeltas) return; }
if (frame.own) { const { own, ...rest } = frame; frame = rest; }
if (frame.type === 'delta' && frame.text) groups.text();
if (frame.type === 'agent-tool' && frame.tool) {
if (['done', 'error'].includes(frame.tool.state) && !['read', 'read_image', 'glob', 'grep', 'web_fetch', 'web_search'].includes(frame.tool.name)) toolsRan = true;
let made = null;
if (frame.tool.state === 'done' && (frame.tool.name === 'write' || frame.tool.name === 'edit')) { try { made = tracker.check(frame.tool.args?.file_path ?? frame.tool.args?.path); } catch { made = null; } }
if (frame.tool.state === 'done' && (frame.tool.name === 'write' || frame.tool.name === 'edit')) { try { made = tracker.check(frame.tool.args?.file_path ?? frame.tool.args?.path, { explicit: true }); } catch { made = null; } }
raw(frame);
if (made) raw(made);
if (guard) trip(guard.card(frame.tool));
@@ -463,6 +468,20 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
research: researchTopic ? { phase: 'gather', nudged: false, waitingStart: false } : null,
};
c.turn = turn;
if (workspacePath) folderClaims.begin(workspacePath, chatId);
/**
* A page that comes back (a reload, a new window) takes the running turn over again. Everything that was said while no page was there is
* sent as one catch-up: the start, the text and thinking so far (a `resync`), every tool card as it is now (the ones waiting for a yes
* included) and the questions waiting for an answer. After that the turn's frames flow to the new page as they did to the old one.
*/
turn.reattach = ({ onFrame, signal: again }) => {
send = onFrame; attached = true;
again?.addEventListener('abort', () => { send = () => {}; attached = false; }, { once: true });
raw({ type: 'start', model: route.name, contextMax: route.contextWindow, demo: false, remoteModel: route.kind === 'api', agent: true, mode: appliedMode, sessionId, requestId, skills: [], resumed: true, ...(planTask ? { planning: true } : {}) });
raw({ type: 'resync', content: hideDeltas ? '' : written.all, reasoning: written.reasoning });
for (const t of projector.tools.values()) if (t.card) raw({ type: 'agent-tool', tool: t.card });
for (const q of questions.values()) if (q.sessionId === sessionId) raw({ type: 'agent-question', callId: q.callId, questions: q.items });
};
/**
* A turn must always end. If the Harness sends nothing at all for a while (a tool that never returns, a model server that went
* quiet, a lost event) the turn is stopped and ended with a plain message instead of showing "thinking" forever. A running tool gets
@@ -500,6 +519,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
reporter.clear();
const extra = [];
try { if (toolsRan) for (const d of tracker.sweep()) { emit(d); groups.addFile(d); } } catch { /* never blocks the end of a turn */ }
if (workspacePath) folderClaims.end(workspacePath, chatId);
groups.end();
if (researchTopic && !failure && !turn.stopping) { const sources = parseSources(written.all); if (sources.length) extra.push({ type: 'research', topic: researchTopic, sources }); }
try { extra.push(...settlePlan(failure)); } catch { /* a plan frame must never break the end of a turn */ }
@@ -529,6 +549,8 @@ ${note.text}`, key: note.key, values: note.values, noteReason: 'cut' });
}
for (const f of frames.slice(-1)) emit(f);
c.turn = null;
// What a page that was away needs to show the end of this turn (attach, below).
c.last = { requestId, sessionId, mode: appliedMode, model: route.name, contextMax: route.contextWindow, remoteModel: route.kind === 'api', content: hideDeltas ? '' : written.all.slice(0, 4_000_000), reasoning: written.reasoning, cards: [...projector.tools.values()].filter(t => t.card).map(t => t.card), terminal };
turn.ended.resolve({ reason: projector.ended, failure: failure ?? null });
notifyIdle();
// A turn that filled the context, or that the guard stopped, leaves a long history behind: summarise the older part now so the next message has room.
@@ -1402,6 +1424,34 @@ ${note.text}
return { stopped: true, ended: result.reason?.kind ?? 'unknown', ms: Date.now() - t0 };
}
/** The turn running for a chat now, for a page that has just loaded; null when none. */
function turnOf(chatId) {
const t = chats.get(chatId)?.turn;
if (!t || t.finished) return null;
return { chatId, sessionId: t.sessionId, requestId: t.requestId, mode: t.mode, stopping: !!t.stopping, pending: pendingFor(chatId) };
}
/** Every chat with a turn running. */
function runningTurns() { return [...chats.keys()].map(turnOf).filter(Boolean); }
/**
* Take a turn over from a new page. A running turn: the catch-up, then the live frames until it ends or the page leaves again. A turn that
* ended while no page was there (`requestId` names it): its text, tool cards and closing frame once. null when there is nothing to take over.
*/
function attach({ chatId, requestId = null, onFrame, signal }) {
const c = chats.get(chatId);
const turn = c?.turn;
if (turn && !turn.finished && typeof turn.reattach === 'function') {
if (requestId && turn.requestId !== requestId) return null;
turn.reattach({ onFrame, signal });
return { ended: turn.ended.promise.then(() => undefined), live: true };
}
const last = c?.last;
if (!last || !requestId || last.requestId !== requestId) return null;
onFrame({ type: 'start', model: last.model, contextMax: last.contextMax, demo: false, remoteModel: last.remoteModel, agent: true, mode: last.mode, sessionId: last.sessionId, requestId: last.requestId, skills: [], resumed: true });
onFrame({ type: 'resync', content: last.content, reasoning: last.reasoning });
for (const card of last.cards) onFrame({ type: 'agent-tool', tool: card });
if (last.terminal) onFrame(last.terminal);
return { ended: Promise.resolve(), live: false };
}
function pendingFor(chatId) {
const sessionId = sessionMap[chatId]?.sessionId;
const helpers = chats.get(chatId)?.turn?.children;
@@ -1423,7 +1473,7 @@ ${note.text}
/** Let go of a chat's Harness session mapping (a task starts every run in a session of its own). */
function forget(chatId) { if (chats.get(chatId)?.turn) return; delete sessionMap[chatId]; chats.delete(chatId); persist(); }
return { compactCommand, messageHelper, answerQuestion, goalState, goalControl, harnessDir, status, runTurn, goalCommand, planCommand, researchCommand, llmTarget, setPlanBriefing, resetSession, reopenPlan, skipStep, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, pendingAll, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, workingFolder: chatId => sessionMap[chatId]?.cwd ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
return { compactCommand, messageHelper, answerQuestion, goalState, goalControl, harnessDir, status, turnOf, runningTurns, attach, runTurn, goalCommand, planCommand, researchCommand, llmTarget, setPlanBriefing, resetSession, reopenPlan, skipStep, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, pendingAll, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, workingFolder: chatId => sessionMap[chatId]?.cwd ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
}
/** A goal that stopped by itself, in words. */
@@ -11,6 +11,7 @@
* timeoutSec the time allowed, 1 to 3600 (default 120). A command that runs over is ended together with the programs it started.
*/
import { spawn, spawnSync } from 'node:child_process';
import { endTree as endTreeNow, ownGroup } from '../process-tree.mjs';
import { existsSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
@@ -67,10 +68,7 @@ export const clampTimeoutSec = value => {
};
function endTree(child) {
try {
if (process.platform === 'win32' && child.pid) spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 10000 });
else child.kill('SIGKILL');
} catch { /* gone */ }
endTreeNow(child, 'SIGKILL'); // the command's whole process group on Linux and macOS (it is started as its own group below), taskkill /T on Windows
}
/**
@@ -85,7 +83,7 @@ export function runCommand({ command, input = '', cwd, callCwd, shell = 'auto',
const limit = clampTimeoutSec(timeoutSec);
let child;
try {
child = spawn(plan.file, plan.args, { shell: plan.shell, windowsHide: true, cwd: where.cwd, env: { ...process.env, STRATA_INPUT: String(input ?? '') }, stdio: ['ignore', 'pipe', 'pipe'] });
child = spawn(plan.file, plan.args, { shell: plan.shell, windowsHide: true, detached: ownGroup(), cwd: where.cwd, env: { ...process.env, STRATA_INPUT: String(input ?? '') }, stdio: ['ignore', 'pipe', 'pipe'] });
} catch (e) { return resolve({ ok: false, text: `The command could not start in ${plan.name}: ${e.message}` }); }
let text = '', cut = false, timedOut = false;
const take = chunk => { if (text.length < OUT_MAX + 1) text += chunk.toString(); else cut = true; };
+51 -3
View File
@@ -49,6 +49,44 @@ export function isWithin(parent, child) {
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
}
/**
* Who made which file when several chats work in one folder (#5008). A folder chosen for two chats is shared on purpose, but a
* chat's list of "files this turn made" is private to that turn: a file another chat wrote must not be shown as this chat's work.
* One registry is shared by the turns of a host. A turn `begin`s on its folder, `claim`s the files its own file tools wrote, and
* `end`s. Turns that were running at the same time in the same folder are `overlapped`: for them the end-of-turn sweep reports only
* files nobody else claimed AND that no overlapping turn could have written; since a script's output cannot be told apart, it is
* left out for an overlapped turn rather than given to the wrong chat.
*/
export class FolderClaims {
#active = new Map(); // folder key -> Set(owner)
#overlap = new Map(); // folder key + owner -> true
#claims = new Map(); // folder key -> Map(rel path -> owner)
#key(root) { return same(resolve(String(root))); }
begin(root, owner) {
const k = this.#key(root);
const set = this.#active.get(k) ?? new Set();
const others = [...set].filter(o => o !== owner);
if (others.length) { for (const o of [...others, owner]) this.#overlap.set(`${k}|${o}`, true); }
set.add(owner); this.#active.set(k, set);
}
end(root, owner) {
const k = this.#key(root);
const set = this.#active.get(k);
if (!set) return;
set.delete(owner); this.#overlap.delete(`${k}|${owner}`);
if (!set.size) { this.#active.delete(k); this.#claims.delete(k); }
}
claim(root, rel, owner) {
const k = this.#key(root);
if (!this.#active.has(k)) return;
const m = this.#claims.get(k) ?? new Map();
m.set(rel, owner); this.#claims.set(k, m);
}
ownerOf(root, rel) { return this.#claims.get(this.#key(root))?.get(rel) ?? null; }
/** True when another turn was running in this folder at any time during this owner's turn. */
overlapped(root, owner) { return this.#overlap.get(`${this.#key(root)}|${owner}`) === true; }
}
export class DeliverableTracker {
/**
* `exclude`: folders that are never searched when they lie inside the chat's folder (the app, the engine and its components, AppData,
@@ -56,8 +94,9 @@ export class DeliverableTracker {
* tracked at all. The turn only reports files the turn itself wrote: an explicit file-tool call, or (for scripts) a file whose modified
* or creation time is after the turn started.
*/
constructor(root, { now = Date.now, exclude = [], forbid = [] } = {}) {
constructor(root, { now = Date.now, exclude = [], forbid = [], claims = null, owner = null } = {}) {
this.root = root ? resolve(root) : null;
this.claims = claims; this.owner = owner;
this.now = now;
if (this.root && forbid.filter(Boolean).some(p => isWithin(p, this.root))) this.root = null;
// only the folders that lie inside the chat's folder matter for the search
@@ -104,13 +143,15 @@ export class DeliverableTracker {
}
/** The deliverable for one path now, compared with the snapshot (and the last report), or null when nothing changed. */
check(rawPath) {
check(rawPath, { explicit = false } = {}) {
if (!this.root) return null;
const full = resolve(this.root, String(rawPath ?? ''));
const rel = relative(this.root, full).split(sep).join('/');
if (!rel || rel.startsWith('..') || isAbsolute(rel) || rel.split('/').some(seg => SKIP_DIR.test(seg)) || this.#excluded(dirname(full))) return null;
let st; try { st = statSync(full); } catch { return null; }
if (!st.isFile()) return null;
// A file this chat's own file tool wrote is this chat's, whoever else works in the folder (#5008).
if (explicit && this.claims && this.owner) this.claims.claim(this.root, rel, this.owner);
const old = this.before.get(rel);
if (old && old.size === st.size && old.mtimeMs === st.mtimeMs) return null;
const type = fileType(rel);
@@ -139,7 +180,14 @@ export class DeliverableTracker {
// modified during the turn; or created during it (a copy that kept its old modified time), which is only certain when the first look saw the whole folder
const fresh = st.mtimeMs >= since || (!this.snapshotTruncated && (st.birthtimeMs || 0) >= since);
if (!fresh) return;
const d = this.check(relative(this.root, full)); if (d) out.push(d);
const rel = relative(this.root, full).split(sep).join('/');
if (this.claims && this.owner) {
// Another chat's file tool wrote it: not ours. Written by a script while another chat was working in the same folder: nobody can say whose it is.
const by = this.claims.ownerOf(this.root, rel);
if (by && by !== this.owner) return;
if (!by && this.claims.overlapped(this.root, this.owner)) return;
}
const d = this.check(rel); if (d) out.push(d);
});
return out;
}
+1 -1
View File
@@ -153,7 +153,7 @@ export function parseCsv(text, delimiter = ',', maxRows = 500) {
export function previewFile(root, rawPath, sessionId) {
const file = resolveChatFile(root, rawPath);
const st = statSync(file); const kind = fileType(file);
const base = { kind, name: basename(file), path: file, size: st.size, url: pathToFileURL(file).href };
const base = { kind, name: basename(file), path: file, size: st.size, mtime: Math.round(st.mtimeMs), url: pathToFileURL(file).href };
const raw = `/api/files/raw?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(file)}`;
if (kind === 'image' || kind === 'pdf') return { ...base, rawUrl: raw, mime: mimeOf(file) };
if (kind === 'other') return { ...base, note: 'This kind of file has no preview. Open it in its own app.' };
+186
View File
@@ -0,0 +1,186 @@
/**
* The chats, saved by the host as files (owner decision 11-04: the browser's 5 MB storage limit is gone).
*
* <data folder>/chats/index.json { v: 1, chats: [{ id, rev, createdAt, updatedAt, bytes, title }] }
* <data folder>/chats/<id>.json { "v": 1, "rev": N, "savedAt": ISO, "session": { ...the chat exactly as the page sent it... } }
* <data folder>/chats/conflicts/ the version a later save displaced when two windows saved the same chat from an older copy
*
* - One file per chat. A save writes a temporary file next to it, flushes it and renames it over the chat: a crash or a full disk
* in the middle leaves the older file whole. Temporary files left by a crash are removed at the next start.
* - `rev` counts the saves of a chat. A save that names a `baseRev` older than the stored one (a second window saved first) is still
* written, but the version it displaces is kept in conflicts/ first, so nothing is lost.
* - The index is a list for fast start. If it is missing or does not match the files it is rebuilt from the files; a file that cannot
* be read is reported and left exactly as it is.
* - Nothing is cut: the chat is stored byte for byte as received (a text splice, no re-serialising).
* - Owner-only: the folder is created private (POSIX 0700 and files 0600; on Windows the folder gets a protected ACL that its files
* inherit), the same level as the rest of the profile.
*/
import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from 'node:fs';
import { open, readFile, rename, unlink, mkdir, copyFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
export const CHAT_FORMAT = 1;
const ID = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
const RESERVED = /^(con|prn|aux|nul|com\d|lpt\d|index|import|conflicts)(\.|$)/i;
const KEEP_CONFLICTS = 5;
export class ChatStoreError extends Error {
constructor(message, status, code) { super(message); this.status = status; this.code = code; }
}
/** The disk operations, replaceable in tests to make a disk full, a permission failure or a crash in the middle of a write. */
export const realIo = {
async writeFile(path, text) {
const handle = await open(path, 'w', 0o600);
try { await handle.writeFile(text, 'utf8'); await handle.sync(); } finally { await handle.close(); }
},
rename, unlink, copyFile, readFile, readdir, mkdir,
};
export const validChatId = id => typeof id === 'string' && ID.test(id) && !RESERVED.test(id);
/** Map a failed disk operation to what the page is told: a full disk and a refused write are different words for the same "not saved". */
export function storeFailure(error) {
if (error instanceof ChatStoreError) return error;
const code = error?.code;
if (code === 'ENOSPC' || code === 'EDQUOT' || code === 'EFBIG') return new ChatStoreError('The disk is full.', 507, 'disk-full');
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') return new ChatStoreError('Strata may not write its chat folder.', 403, 'permission');
return new ChatStoreError(error?.message ?? 'The chat could not be written.', 500, code ?? 'write-failed');
}
export function createChatStore({ dir, io = realIo, privateDir = () => {}, now = () => new Date() }) {
const root = join(dir, 'chats');
const conflictsDir = join(root, 'conflicts');
mkdirSync(root, { recursive: true, mode: 0o700 });
try { privateDir(root); } catch { /* the folder is inside the profile; the tighter ACL is best effort */ }
let tmpCount = 0;
let index = new Map(); // id -> entry
const damaged = new Set();
const fileOf = id => join(root, `${id}.json`);
const bytesOf = text => Buffer.byteLength(text, 'utf8');
const entryOf = (id, rev, session, bytes) => ({ id, rev, createdAt: session?.createdAt ?? null, updatedAt: session?.updatedAt ?? null, bytes, title: typeof session?.title === 'string' ? session.title.slice(0, 200) : '' });
// A chain, so saves of one host never overlap: the file, the index and the conflicts folder change one save at a time.
let chain = Promise.resolve();
const queue = work => { const run = chain.then(work, work); chain = run.catch(() => {}); return run; };
function loadSync() {
// Temporary files from a write that never finished.
let names = [];
try { names = readdirSync(root); } catch { /* empty */ }
for (const name of names) if (/\.tmp-\d+-\d+$/.test(name)) { try { unlinkSync(join(root, name)); } catch { /* left */ } }
const files = names.filter(n => n.endsWith('.json') && n !== 'index.json' && !/\.tmp-/.test(n)).map(n => n.slice(0, -5)).filter(validChatId);
let saved = null;
try { const parsed = JSON.parse(readFileSync(join(root, 'index.json'), 'utf8')); if (parsed?.v === CHAT_FORMAT && Array.isArray(parsed.chats)) saved = parsed.chats; } catch { /* rebuilt below */ }
const map = new Map();
if (saved) for (const e of saved) if (e && validChatId(e.id) && files.includes(e.id)) map.set(e.id, e);
// Files the index does not know (a crash before the index was written, an index lost): read from the files.
for (const id of files) {
if (map.has(id)) continue;
try {
const parsed = JSON.parse(readFileSync(fileOf(id), 'utf8'));
if (parsed?.v !== CHAT_FORMAT || !parsed.session || parsed.session.id !== id) throw new Error('not a chat file');
map.set(id, entryOf(id, Number(parsed.rev) || 1, parsed.session, statSync(fileOf(id)).size));
} catch { damaged.add(id); }
}
index = map;
}
loadSync();
async function writeAtomic(path, text) {
const temp = `${path}.tmp-${process.pid}-${++tmpCount}`;
try {
await io.writeFile(temp, text);
for (let attempt = 0; ; attempt++) {
try { await io.rename(temp, path); break; }
catch (e) { if (attempt >= 5 || !['EPERM', 'EBUSY', 'EACCES'].includes(e?.code)) throw e; await new Promise(r => setTimeout(r, 40 * (attempt + 1))); } // an antivirus scan holds the file for a moment on Windows
}
} catch (e) {
try { await io.unlink(temp); } catch { /* nothing to remove */ }
throw e;
}
}
async function writeIndex() {
const list = [...index.values()];
try { await writeAtomic(join(root, 'index.json'), JSON.stringify({ v: CHAT_FORMAT, savedAt: now().toISOString(), chats: list })); }
catch { /* only a list: it is rebuilt from the files when it does not match */ }
}
async function keepDisplaced(id, rev) {
try {
await io.mkdir(conflictsDir, { recursive: true });
await io.copyFile(fileOf(id), join(conflictsDir, `${id}.rev${rev}.json`));
const mine = (await io.readdir(conflictsDir)).filter(n => n.startsWith(`${id}.rev`)).sort((a, b) => Number(a.match(/rev(\d+)/)?.[1]) - Number(b.match(/rev(\d+)/)?.[1]));
for (const old of mine.slice(0, Math.max(0, mine.length - KEEP_CONFLICTS))) { try { await io.unlink(join(conflictsDir, old)); } catch { /* left */ } }
} catch (e) {
throw storeFailure(e); // not saved rather than saved over the only copy of the other window's work
}
}
return {
root,
/** The chats, newest first. */
list() {
const chats = [...index.values()].sort((a, b) => String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')) || a.id.localeCompare(b.id));
return { v: CHAT_FORMAT, chats, damaged: [...damaged] };
},
has: id => index.has(id),
rev: id => index.get(id)?.rev ?? 0,
/** The stored file text of a chat ({"v","rev","savedAt","session"}), or null. */
async read(id) {
if (!validChatId(id) || !index.has(id)) return null;
try { return await io.readFile(fileOf(id), 'utf8'); }
catch (e) { if (e?.code === 'ENOENT') { index.delete(id); return null; } throw storeFailure(e); }
},
/**
* Save one chat. `sessionText` is the chat's JSON as the page wrote it. Resolves { id, rev, bytes, conflict }.
* Rejects with a ChatStoreError (status 400 for a chat that is not a chat, 507 disk full, 403 refused, 500 other): nothing was changed.
*/
save(id, sessionText, { baseRev = null } = {}) {
if (!validChatId(id)) return Promise.reject(new ChatStoreError('That is not a chat id.', 400, 'bad-id'));
let session;
try { session = JSON.parse(sessionText); } catch { return Promise.reject(new ChatStoreError('The chat must be JSON.', 400, 'bad-json')); }
if (!session || typeof session !== 'object' || Array.isArray(session) || session.id !== id) return Promise.reject(new ChatStoreError('The chat does not match its id.', 400, 'bad-chat'));
const body = sessionText.trim();
return queue(async () => {
const current = index.get(id);
const rev = (current?.rev ?? 0) + 1;
let conflict = false;
if (current && baseRev !== null && Number(baseRev) !== current.rev) { conflict = true; await keepDisplaced(id, current.rev); }
const text = `{"v":${CHAT_FORMAT},"rev":${rev},"savedAt":${JSON.stringify(now().toISOString())},"session":${body}}`;
try { await writeAtomic(fileOf(id), text); } catch (e) { throw storeFailure(e); }
const bytes = bytesOf(text);
index.set(id, entryOf(id, rev, session, bytes));
damaged.delete(id);
await writeIndex();
return { id, rev, bytes, conflict };
});
},
/** Delete a chat (the person deleted it). A chat that is not there is already gone. */
remove(id) {
if (!validChatId(id)) return Promise.reject(new ChatStoreError('That is not a chat id.', 400, 'bad-id'));
return queue(async () => {
try { await io.unlink(fileOf(id)); } catch (e) { if (e?.code !== 'ENOENT') throw storeFailure(e); }
const had = index.delete(id);
await writeIndex();
return { id, removed: had };
});
},
/** Chats from the browser's old storage: every one the host does not have yet is saved; the ones it has are left alone. */
async importMany(sessions) {
const imported = [], skipped = [], failed = [];
for (const session of Array.isArray(sessions) ? sessions : []) {
const id = session?.id;
if (!validChatId(id)) { failed.push({ id: String(id ?? ''), error: 'not a chat id' }); continue; }
if (index.has(id)) { skipped.push(id); continue; }
try { await this.save(id, JSON.stringify(session)); imported.push(id); }
catch (e) { failed.push({ id, error: e.message, code: e.code }); if (e.code === 'disk-full') break; }
}
return { imported, skipped, failed };
},
/** Wait until every save queued so far is on disk (tests, shutdown). */
idle: () => chain,
};
}
+17 -6
View File
@@ -288,21 +288,32 @@ export function createControl({ control = findControl(), env = process.env, run
throw Object.assign(failure(result, 'The partial copies could not be removed.'), { code: result.json?.error?.code ?? 'leave_failed', details: result.json?.error?.details ?? '' });
},
/**
* The DFlash2 drafter for a model's run config and the drafting choice (`strata drafting plan`): { needed, gb, line, note, drafter }. Control decides
* (the table of strata_control/drafting.py); the app never reads it. Nothing is downloaded.
* The drafter for a model's run config and the drafting choice (`strata drafting plan`): { needed, gb, line, note, drafter, split, mtp_needed, mtp_gb }. Control decides
* (the table of strata_control/drafting.py and the all-cards split engine's rule, strata_control/split_engine.py: the same answer as the Terminal's); the app never reads
* it. Nothing is downloaded. `noSplit` asks as if the split engine will not take the start (only after the draft layer for the split could not be downloaded).
*/
async draftingPlan({ configPath = null, mode = null, family = null, dataDir = null } = {}) {
const result = await call(['drafting', 'plan', ...(configPath ? ['--config', configPath] : []), ...(mode ? ['--mode', mode] : []), ...(family ? ['--family', family] : []), ...(dataDir ? ['--data-dir', dataDir] : []), '--no-split', '--output', 'json'], { timeoutMs: 30_000 });
async draftingPlan({ configPath = null, mode = null, family = null, dataDir = null, noSplit = false, unloading = false } = {}) {
const result = await call(['drafting', 'plan', ...(configPath ? ['--config', configPath] : []), ...(mode ? ['--mode', mode] : []), ...(family ? ['--family', family] : []), ...(dataDir ? ['--data-dir', dataDir] : []), ...(noSplit ? ['--no-split'] : []), ...(unloading ? ['--engines-unloading'] : []), '--output', 'json'], { timeoutMs: 30_000 });
if (result.json?.ok) return result.json;
throw failure(result, 'The drafter could not be checked.');
},
/** Fetch and pack the drafter when the choice would use it and it is not here (`strata drafting fetch --if-needed`); `onEvent` gets {step, done, total}. */
async draftingFetch({ configPath, mode = null, onEvent = null, signal = null } = {}) {
const args = ['drafting', 'fetch', '--config', configPath, ...(mode ? ['--mode', mode] : []), '--no-split', '--if-needed', '--output', 'jsonl'];
async draftingFetch({ configPath, mode = null, noSplit = false, unloading = false, onEvent = null, signal = null } = {}) {
const args = ['drafting', 'fetch', '--config', configPath, ...(mode ? ['--mode', mode] : []), ...(noSplit ? ['--no-split'] : []), ...(unloading ? ['--engines-unloading'] : []), '--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 all-cards split engine for the run config the app is about to start (`strata split-engine apply`, the step `strata launch` does itself): Control checks the rule, the
* engine package and the draft layer on this computer and writes the split engine's run config to `outPath` when the answer is yes. Resolves { split, line, warn, reasons, cards, written };
* `configPath` is never changed, nothing is downloaded.
*/
async splitEngine({ configPath, outPath } = {}) {
const result = await call(['split-engine', 'apply', '--config', configPath, '--out', outPath, '--output', 'json'], { timeoutMs: 60_000 });
if (result.json?.ok) return result.json;
throw failure(result, 'The all-cards split engine could not be checked.');
},
/**
* The picture files of an installed model that has none (strata_control/pictures.py): found or downloaded once (about 0.9 GB, shared), and the run
* config's `vision` section worked out for this engine. Nothing else of the model is touched and nothing is written to its run config: the caller does.
+80 -9
View File
@@ -46,12 +46,15 @@ import * as engine from './engine.mjs';
import * as monitor from './monitor.mjs';
import * as serving from './serving.mjs';
import * as store from './store.mjs';
import { createChatStore, storeFailure } from './chat-store.mjs';
import { makePrivateDir } from './private-file.mjs';
import * as supervisor from './supervisor.mjs';
import * as engineInstall from './engine-install.mjs';
import { detectBackend, currentBackend, streamChat, inferenceLine } from './inference.mjs';
import { CONTEXT_TOKENS, VARIANTS, localModels, variant } from './catalog.mjs';
import { browseLocalModels, inspectLocalModel } from './local-models.mjs';
import { EventHub, error, json, readBody, readRaw, uid } from './util.mjs';
import { keepStreamAlive } from './stream-beat.mjs';
import { EventHub, badRequest, error, json, readBody, readRaw, requestUrl, sameSecret, uid } from './util.mjs';
import { createUploads } from './chat-uploads.mjs';
import { createNativeDrops } from './native-drop.mjs';
import { createVisionProbe, effectiveVision, readsPicturesByDefault } from './vision-probe.mjs';
@@ -118,6 +121,8 @@ const WORKSPACE_NAME = process.env.STRATA_WORKSPACE ? basename(WORKSPACE) : 'Str
const insideApp = path => { const rel = relative(ROOT, resolve(path)); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); };
const hub = new EventHub();
/** The chats, as files in the profile's data folder (chat-store.mjs). */
const chatStore = createChatStore({ dir: store.DATA_DIR, privateDir: makePrivateDir });
const modelProviders = createModelProviders({ directory: store.DATA_DIR, knownThinks: (connection, modelId) => visionProbe.knownThinks(connection, modelId), changed: value => hub.send('providers', withPictureAnswers(value)) });
const imageModels = createImageModels({ dataDir: store.DATA_DIR, modelsDir: () => store.MODELS_DIR, real: models.REAL, changed: value => hub.send('image-models', value) });
/* The installed app ships Strata Control (python + strata_control) next to the host; a source checkout has none and keeps its older paths. */
@@ -943,7 +948,9 @@ const taskTimer = setInterval(() => tasks.tick(), 15000);
taskTimer.unref();
// The share and tunnel listeners take a free port (the next ones above the host's own) instead of a fixed PORT+1 / PORT+2 that Docker, WSL or Hyper-V may hold or reserve.
const sharePort = await findFreePort(Number(process.env.STRATA_SHARE_PORT ?? PORT + 1), '0.0.0.0', { skip: [PORT] });
// The probe listens on the loopback address only: a probe on 0.0.0.0 made Windows Defender Firewall ask "Allow Node.js to communicate on these networks?" on the
// first start of every new install path (QA 0213). Reserved or taken ports are caught on any address; the share listener itself binds 0.0.0.0 only when the person turns sharing on.
const sharePort = await findFreePort(Number(process.env.STRATA_SHARE_PORT ?? PORT + 1), '127.0.0.1', { skip: [PORT] });
const localShare = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, port: sharePort, changed: () => serving.setSharing(localShare.state()) });
const tunnelPort = await findFreePort(Number(process.env.STRATA_TUNNEL_PORT ?? PORT + 2), '127.0.0.1', { skip: [PORT, sharePort] });
const tunnelOrigin = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, host: '127.0.0.1', port: tunnelPort });
@@ -954,7 +961,8 @@ await cloudflare.inspect();
/* --------------------------------------------------------------- routing --- */
const server = http.createServer(async (req, res) => {
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
const url = requestUrl(req);
if (!url) return badRequest(res); // #4397: a bad Host or request target is a 400, never an unhandled rejection
const path = url.pathname;
const method = req.method ?? 'GET';
@@ -985,12 +993,55 @@ const server = http.createServer(async (req, res) => {
return await staticFile(req, res, path);
} catch (err) {
console.error('[strata]', err);
if (res.headersSent) { res.end(); return; } // a stream already began: it ends here (a second writeHead would throw out of this handler and end the process)
error(res, 500, err.message ?? 'Internal error');
}
});
async function api(req, res, path, method, url) {
if(path==='/api/fonts'&&method==='GET')return json(res,200,await systemFonts());
// --- chats: one file each in the data folder -----------------------------
// Not held back by a model folder move: saving a chat has nothing to do with the models. A page must name itself (x-strata-client), which
// a web page from another site cannot do without the browser asking the host first.
const chatsRoute = /^\/api\/chats(?:\/([^/]+))?$/.exec(path);
if (chatsRoute) {
if (!req.headers['x-strata-client']) return error(res, 403, 'Not allowed.');
let id = null;
try { id = chatsRoute[1] ? decodeURIComponent(chatsRoute[1]) : null; } catch { return error(res, 400, 'That is not a chat id.', { code: 'bad-id' }); }
const from = typeof req.headers['x-strata-window'] === 'string' ? req.headers['x-strata-window'].slice(0, 64) : null;
try {
if (!id && method === 'GET') return json(res, 200, chatStore.list());
if (id === 'import' && method === 'POST') {
const body = JSON.parse((await readRaw(req, 512 * 1024 * 1024)).toString('utf8') || '{}');
const result = await chatStore.importMany(body.sessions);
for (const imported of result.imported) hub.send('chats', { id: imported, rev: chatStore.rev(imported), from });
return json(res, 200, result);
}
if (id && method === 'GET') {
const text = await chatStore.read(id);
if (text === null) return error(res, 404, 'No such chat.', { code: 'not-found' });
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text), 'cache-control': 'no-store' });
res.end(text);
return undefined;
}
if (id && method === 'PUT') {
const text = (await readRaw(req, 256 * 1024 * 1024)).toString('utf8');
const base = req.headers['x-strata-base-rev'];
const saved = await chatStore.save(id, text, { baseRev: typeof base === 'string' && /^\d+$/.test(base) ? Number(base) : null });
hub.send('chats', { id, rev: saved.rev, from });
return json(res, 200, saved);
}
if (id && method === 'DELETE') {
const removed = await chatStore.remove(id);
hub.send('chats', { id, deleted: true, from });
return json(res, 200, removed);
}
} catch (e) {
const failure = e instanceof SyntaxError ? { status: 400, message: 'The request must be JSON.', code: 'bad-json' } : storeFailure(e);
return error(res, failure.status, failure.message, { code: failure.code });
}
return error(res, 405, 'Method not allowed.');
}
if (storageChanging && !['GET', 'OPTIONS'].includes(method)) return error(res, 409, 'Wait for the model folder check to finish.');
// --- events -------------------------------------------------------------
if (path === '/api/events') return hub.add(res);
@@ -1195,9 +1246,7 @@ async function api(req, res, path, method, url) {
}
// One revision attempt for a job this host started: the app's assistant works inside the candidate folder.
if (path === '/api/internal/timeskip-revise' && method === 'POST') {
const given = Buffer.from(String(req.headers['x-strata-internal'] ?? ''));
const want = Buffer.from(INTERNAL_SECRET);
if (given.length !== want.length || !timingSafeEqual(given, want)) return error(res, 403, 'Not allowed.');
if (!sameSecret(req.headers['x-strata-internal'], INTERNAL_SECRET)) return error(res, 403, 'Not allowed.');
const body = await readBody(req);
const cwd = String(body.cwd ?? '');
// Only the job's own candidate folders may be worked in.
@@ -1307,8 +1356,7 @@ async function api(req, res, path, method, url) {
// The shell (and nothing else) holds the secret that lets it end the host or tell it which window is in use.
const fromShell = () => {
const token = process.env.STRATA_SHUTDOWN_TOKEN ?? '';
const given = String(req.headers['x-strata-shutdown'] ?? '');
return token.length >= 32 && given.length === token.length && timingSafeEqual(Buffer.from(given), Buffer.from(token));
return token.length >= 32 && sameSecret(req.headers['x-strata-shutdown'], token);
};
// Which window the person is using: the page shows "Quit Strata" in browser mode only.
if (path === '/api/app/mode' && method === 'GET') return json(res, 200, appUi.state());
@@ -1614,7 +1662,7 @@ async function api(req, res, path, method, url) {
if (cancelMatch && method === 'POST') return json(res, 200, serving.cancel(cancelMatch[1]));
// --- monitor ------------------------------------------------------------
if (path === '/api/monitor') { monitor.watch(); return json(res, 200, monitor.snapshot()); }
if (path === '/api/monitor') { monitor.watch(); if (url.searchParams.get('drives') === '1') monitor.watchDrives(); return json(res, 200, monitor.snapshot()); }
// --- skills / workspace -------------------------------------------------
if (path === '/api/skills' && method === 'GET') {
@@ -1989,6 +2037,28 @@ async function api(req, res, path, method, url) {
if (typeof b.sessionId !== 'string' || !['manual', 'plan', 'bypass'].includes(b.mode)) return error(res, 400, 'sessionId and mode (manual, plan or bypass) are required');
try { return json(res, 200, await agent.setMode(b.sessionId, b.mode)); } catch (e) { return error(res, e.code === 'mode-unavailable' ? 409 : 502, e.message); }
}
/* A page that has just loaded asks which chats have a turn running (a reload does not stop a turn), and takes one over again: the catch-up
of what was said while no page was there, then the live frames, as on /api/chat/stream. A turn that ended while the page was away
(the page names it by requestId) is sent once, whole. 404 when there is nothing to take over. */
if (path === '/api/chat/active' && method === 'GET') return json(res, 200, { turns: agent.runningTurns() });
if (path === '/api/chat/attach' && method === 'POST') {
const b = await readBody(req);
if (typeof b.sessionId !== 'string' || !b.sessionId) return error(res, 400, 'sessionId is required');
const controller = new AbortController();
res.on('close', () => { if (!res.writableEnded) controller.abort(); });
let started = false;
const write = frame => {
if (res.writableEnded || res.destroyed) return;
if (!started) { started = true; res.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8', 'cache-control': 'no-cache, no-transform', connection: 'keep-alive' }); keepStreamAlive(res); }
res.write(`${JSON.stringify(frame)}
`);
};
const attached = agent.attach({ chatId: b.sessionId, requestId: typeof b.requestId === 'string' ? b.requestId : null, onFrame: write, signal: controller.signal });
if (!attached) return error(res, 404, 'There is no turn to take over.', { code: 'no-turn' });
await Promise.race([attached.ended, new Promise(r => controller.signal.addEventListener('abort', r, { once: true }))]);
if (!res.writableEnded) res.end();
return undefined;
}
/* Stop one chat's turn. Resolves once the turn has really ended, not when the cancel was admitted. */
if (path === '/api/chat/stop' && method === 'POST') {
const b = await readBody(req);
@@ -2018,6 +2088,7 @@ async function api(req, res, path, method, url) {
'cache-control': 'no-cache, no-transform',
connection: 'keep-alive',
});
keepStreamAlive(res); // #4878: a quiet turn stays open; the timer ends with the response
const controller = new AbortController();
res.on('close', () => { if (!res.writableEnded) controller.abort(); });
+4 -1
View File
@@ -3,6 +3,7 @@ import http from 'node:http';
import os from 'node:os';
import { apiPath } from './api-paths.mjs';
import { listenProblem } from './ports.mjs';
import { badRequest, requestUrl } from './util.mjs';
/** Sharing without a key is allowed (owner decision, 0.2.2): the person is told once, in plain words, and the app keeps offering to make a key. */
export const NO_KEY_WARNING = 'No API key is set, so any device on this network can use your model. Create an API key to protect it.';
export function createLocalShare({ handle, hasKey, changed = () => {}, host = '0.0.0.0', port = 8792 }) {
@@ -13,7 +14,9 @@ export function createLocalShare({ handle, hasKey, changed = () => {}, host = '0
if (active) return state();
if (listener) throw new Error('Sharing is already starting.');
listener = http.createServer(async (req, res) => {
const path = apiPath(new URL(req.url ?? '/', 'http://localhost').pathname);
const url = requestUrl({ url: req.url, headers: {} }); // the Host header is not read here; a request target that is not a path is a 400 (#4397)
if (!url) return badRequest(res);
const path = apiPath(url.pathname);
const method = req.method ?? 'GET';
// #3208: with an API key set, a browser page of any site can call the shared API - the key decides, not the
// origin. The browser's preflight (OPTIONS) never carries the key (the spec says so), so it is answered here,
+8 -3
View File
@@ -62,7 +62,12 @@ let ticker = null;
let memoryWarningShown = false;
let sensorsPending = false, sensorsAt = 0;
/* The Monitor screen asks (GET /api/monitor) every few seconds while it is open; a model that is loaded counts as live (#4066). */
let watchedUntil = 0, liveProbe = () => false;
let watchedUntil = 0, drivesUntil = 0, liveProbe = () => false;
const hostStartedAt = Date.now();
/** The drive-temperature reader is not started in the first minute of the host: the app's own start-up traffic is not a person looking at the Monitor (#4661). */
export const DRIVES_AFTER_MS = 60000;
/** Only the Monitor screen's own ask (GET /api/monitor?drives=1) wants drive temperatures; the start-up fetch and the Models page do not show them. */
export function watchDrives() { drivesUntil = Date.now() + WATCH_MS; }
export function watch() {
const wasIdle = Date.now() >= watchedUntil;
watchedUntil = Date.now() + WATCH_MS;
@@ -127,14 +132,14 @@ function tick() {
if(!sensorsPending&&Date.now()-sensorsAt>=pace.intervalMs){
sensorsPending=true;sensorsAt=Date.now();
const hardware=cachedHardware()??hwCache;
void Promise.all([readGpuTelemetry(hardware?.gpus??[],{transfer:pace.transfer}),sampleSystem(hardware?.processors??[]),(process.env.STRATA_REAL_ENGINE==='1'&&Date.now()<watchedUntil?readTemperatures():Promise.resolve(temperatures)).catch(()=>({cpuTempC:null,ssdTempC:null}))])
void Promise.all([readGpuTelemetry(hardware?.gpus??[],{transfer:pace.transfer}),sampleSystem(hardware?.processors??[]),(process.env.STRATA_REAL_ENGINE==='1'&&Date.now()<drivesUntil&&Date.now()-hostStartedAt>DRIVES_AFTER_MS?readTemperatures():Promise.resolve(temperatures)).catch(()=>({cpuTempC:null,ssdTempC:null}))])
.then(([readings,system,temps])=>{state.hardware.gpus=readings;systemReading=system;temperatures=temps;})
.catch(()=>{state.hardware.gpus=[];systemReading=null;})
.finally(()=>{sensorsPending=false;});
}
/* #4661: the drive-temperature reader asks every physical drive for its temperature. Only while the Monitor is looked at, never from the first second of the app
(on some PCs the storage queries stall the whole system); it ends a couple of minutes after the Monitor was last seen. */
if(Date.now()>watchedUntil+120000)stopTemperatures();
if(Date.now()>drivesUntil+120000)stopTemperatures();
addEnergy(hardwareSnapshot().powerW,Date.now());
const hw = state.hardware;
const busy = state.phase === 'generating' || state.phase === 'reading' || state.phase === 'thinking';
+54 -19
View File
@@ -27,6 +27,8 @@ import { parseSaved as parseRoles, mirror as mirrorRoles } from './gpu-roles.mjs
import { configContextLimit } from './dsv41-context.mjs';
const ROOT=resolve(dirname(fileURLToPath(import.meta.url)),'..');
/* What the load says when the server had to start the normal layer split because the all-cards split engine would not come up (the engine's own reason stays in its log). */
const SPLIT_FELL_BACK='The all-cards split engine could not start, so the normal layer split runs.';
const safeId=value=>typeof value==='string'&&/^[a-z0-9](?:[a-z0-9_-]|\.(?!\.)){0,79}$/i.test(value); /* a dot inside the name: deepseek-v4.1-flash-ist-das-2.00bpw */
const busy=requests=>requests.some(r=>['queued','generating'].includes(r.state));
function readJson(path,fallback={}){try{return JSON.parse(readFileSync(path,'utf8').replace(/^\uFEFF/,''));}catch{return fallback;}}
@@ -38,6 +40,8 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
const settingsPath=join(directory,'native-host.json');
let preferences=readJson(settingsPath,{repo:defaultRepo});
let child=null,seq=0,buffer='',pending=new Map(),definitions=[],refreshing=null,lastRead=0,connecting=null;
/* QAGUI 0213: until Control has answered once, the state says it is still being asked (`checking`), not that nothing is installed. A failed read during Control's start is read again after a few seconds instead of standing for 45 s; after READ_TRIES failures the answer is final and the error shows. */
let everRead=false,failedReads=0;const READ_TRIES=4,RETRY_MS=3000;
let snapshot={available:false,repo:preferences.repo,summary:'Connect a local Strata installation.',catalog:null,installed:null,hardware:null,error:null};
const instances=new Map(),processes=new Map(),jobs=new Map(),logs=[];
const savedJobs=readJson(join(directory,'native-jobs.json'),[]);/* An app update ends the app on purpose: the installer replaces it and starts it again. The job that asked for it was saved as running together with the app version it started from (appUpdateFrom); a start that finds a different version knows the update finished, instead of calling it an interrupted operation. */
@@ -71,7 +75,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
/* Does the engine in this release serve DFlash2? Control knows (strata_control/drafting.py); asked once, false until it answers. The Models screen offers the choice only when true. */
let dflash2Served=null,dflash2Support=null,dflash2Selq4=false,dflash2Split=false;
function probeDrafting(){if(!packaged||dflash2Served!==null||typeof control.draftingPlan!=='function')return;dflash2Served=false;control.draftingPlan({mode:'auto'}).then(p=>{dflash2Served=p.serves===true;dflash2Support=p.serves===true&&p.support&&p.support_line?{level:p.support,line:p.support_line}:null;dflash2Selq4=p.serves===true&&p.selq4===true;dflash2Split=p.split===true;emit();}).catch(()=>{});}
function state(){const folder=preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),space=folder?diskSpace(folder):null;return {...snapshot,downloadDirectory:folder,downloadFreeBytes:space?.free??null,downloadTotalBytes:space?.total??null,fields:nativeFields(),instances:[...instances.values()].map(({key,configPath,fingerprint,mcpServers,...i})=>structuredClone(i)),defaultId,stoppedForUpdate:stoppedForUpdate?{updated:stoppedForUpdate.updated,at:stoppedForUpdate.at,models:stoppedForUpdate.instances.map(i=>({id:i.id,label:i.label}))}:null,jobs:[...jobs.values()].slice(-12).map(({process,abort,engineAbort,rawResult,...j})=>j),capabilities:{responses:sourceText().includes('/v1/responses'),updates:!!snapshot.available,rollback:previousEngine(),componentUpdates:packaged&&typeof control.update==='function',calibration:packaged?false:snapshot.hardware?.gpus?.some(g=>/nvidia/i.test(g.name??''))??false,dflash2:dflash2Served===true,dflash2Support,dflash2Selq4,dflash2Split}};}
function state(){const checking=!everRead&&failedReads<READ_TRIES;const folder=preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),space=folder?diskSpace(folder):null;return {...snapshot,checking,downloadDirectory:folder,downloadFreeBytes:space?.free??null,downloadTotalBytes:space?.total??null,fields:nativeFields(),instances:[...instances.values()].map(({key,configPath,fingerprint,mcpServers,...i})=>structuredClone(i)),defaultId,stoppedForUpdate:stoppedForUpdate?{updated:stoppedForUpdate.updated,at:stoppedForUpdate.at,models:stoppedForUpdate.instances.map(i=>({id:i.id,label:i.label}))}:null,jobs:[...jobs.values()].slice(-12).map(({process,abort,engineAbort,rawResult,...j})=>j),capabilities:{responses:sourceText().includes('/v1/responses'),updates:!!snapshot.available,rollback:previousEngine(),componentUpdates:packaged&&typeof control.update==='function',calibration:packaged?false:snapshot.hardware?.gpus?.some(g=>/nvidia/i.test(g.name??''))??false,dflash2:dflash2Served===true,dflash2Support,dflash2Selq4,dflash2Split}};}
let sourceCache={path:'',time:0,text:''};
function sourceText(){try{const path=join(packaged?control.control.dir:preferences.repo,'serve/server.py'),time=statSync(path).mtimeMs;if(sourceCache.path===path&&sourceCache.time===time)return sourceCache.text;sourceCache={path,time,text:readFileSync(path,'utf8')};return sourceCache.text;}catch{return '';}}
function nativeFields(){const source=sourceText();return NATIVE_FIELDS.map(field=>({...field,supported:APP_OWNED_FIELDS.includes(field.id)||field.id.startsWith('vision_')||field.id.startsWith('mcp_')||field.id==='vram_reserve_mib'||source.includes(field.id)}));}
@@ -148,7 +152,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
is refreshed in the background. Only the first read, and a forced one after an operation, waits. */
if(refreshing)return !force&&snapshot.available?state():force?refreshing.then(()=>refresh(true)):refreshing; /* a forced read that meets one already running (started before an engine update finished) waits for it and reads again, so it never settles on the old answer */if(!force&&Date.now()-lastRead<45000)return state();
probeDrafting();
refreshing=(async()=>{try{const [catalog,status]=packaged?await (async()=>{const s=await tool('strata_status',{hardware:true});const c=await tool('strata_models').catch(error=>({families:[],contexts:[],recommendation:{family:null,model:null},context_note:'',error:error.message}));return [c,s];})():await Promise.all([tool('strata_models'),tool('strata_status',{hardware:true})]);let visionReader=false;if(packaged){try{const e=await control.engine();const root=e?.engine?.root??(e?.engine?.exe?dirname(e.engine.exe):'');visionReader=!!root&&['strata-vision','strata-vision.exe'].some(n=>existsSync(join(root,n)));}catch{visionReader=snapshot.visionReader===true;}}const markImages=c=>markPictures(c,visionReader);snapshot={available:true,repo:packaged?status.installDir:repo(),version:packaged?(control.version??null):await execute('git',['describe','--tags','--always'],repo()).then(s=>s.trim()).catch(()=>null),summary:status.summary,catalog:markEngine(packaged?markImages(catalog):catalog,status.engine),engineInfo:{version:status.engine?.version??null,source:status.engine?.source??null},visionReader:packaged?visionReader:true,installed:installedInfo(status.installed),hardware:status.hardware,error:null,install:status.install,external:status.running??null,...(packaged?{packaged:true,installDir:status.installDir,engine:status.engine,installerError:status.installerError}:{})};lastRead=Date.now();if(status.install?.running&&![...jobs.values()].some(j=>j.state==='running'))begin('install',j=>watchInstall(j,status.install));}catch(error){snapshot={...snapshot,available:false,error:error.message};lastRead=Date.now();}emit();return state();})().finally(()=>{refreshing=null;});const run=refreshing;if(!force&&snapshot.available){run.catch(()=>{});return state();}return run;
refreshing=(async()=>{try{const [catalog,status]=packaged?await (async()=>{const s=await tool('strata_status',{hardware:true});const c=await tool('strata_models').catch(error=>({families:[],contexts:[],recommendation:{family:null,model:null},context_note:'',error:error.message}));return [c,s];})():await Promise.all([tool('strata_models'),tool('strata_status',{hardware:true})]);let visionReader=false;if(packaged){try{const e=await control.engine();const root=e?.engine?.root??(e?.engine?.exe?dirname(e.engine.exe):'');visionReader=!!root&&['strata-vision','strata-vision.exe'].some(n=>existsSync(join(root,n)));}catch{visionReader=snapshot.visionReader===true;}}const markImages=c=>markPictures(c,visionReader);snapshot={available:true,repo:packaged?status.installDir:repo(),version:packaged?(control.version??null):await execute('git',['describe','--tags','--always'],repo()).then(s=>s.trim()).catch(()=>null),summary:status.summary,catalog:markEngine(packaged?markImages(catalog):catalog,status.engine),engineInfo:{version:status.engine?.version??null,source:status.engine?.source??null},visionReader:packaged?visionReader:true,installed:installedInfo(status.installed),hardware:status.hardware,error:null,install:status.install,external:status.running??null,...(packaged?{packaged:true,installDir:status.installDir,engine:status.engine,installerError:status.installerError}:{})};lastRead=Date.now();everRead=true;failedReads=0;if(status.install?.running&&![...jobs.values()].some(j=>j.state==='running'))begin('install',j=>watchInstall(j,status.install));}catch(error){failedReads++;snapshot={...snapshot,available:false,error:error.message};lastRead=!everRead&&failedReads<READ_TRIES?Date.now()-45000+RETRY_MS:Date.now();}emit();return state();})().finally(()=>{refreshing=null;});const run=refreshing;if(!force&&snapshot.available){run.catch(()=>{});return state();}return run;
}
function configure({repository}){if(packaged){if(instances.size||[...jobs.values()].some(j=>j.state==='running'))throw new Error('Unload models and finish operations before changing the installation.');const next=realpathSync(String(repository));if(!statSync(next).isDirectory())throw new Error('Choose the folder that holds your Strata model configurations.');preferences={...preferences,installDir:next};packagedInstall=null;disconnect();writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});lastRead=0;return refresh(true);}if(instances.size||[...jobs.values()].some(j=>j.state==='running'))throw new Error('Unload models and finish operations before changing the installation.');const next=realpathSync(String(repository));const old=preferences;preferences={...preferences,repo:next};try{repo();}catch(e){preferences=old;throw e;}disconnect();writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});lastRead=0;return refresh(true);}
function setDownloadDirectory(input,{insideJob=false}={}){if(!insideJob&&[...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before changing the folder.');if(typeof input!=='string'||!input.trim()||/[\r\n\0]/.test(input))throw new Error('Choose a model folder.');let path;try{path=realpathSync(input.trim());}catch{throw new Error('That folder does not exist. Check the path, or that the drive is connected.');}if(!statSync(path).isDirectory())throw new Error('Choose a folder, not a file.');/* A whole drive (D:\) is not filled with models: D:\Strata is made and used. */if(isDriveRoot(path)){const own=dataFolderFromPick(path).path;makeFolder(own);path=realpathSync(own);}try{accessSync(path,constants.R_OK|constants.W_OK);}catch{throw new Error('Strata cannot write in that folder. Choose a folder you can save files in.');}preferences={...preferences,downloadDirectory:path};/* the same choice for the installer and the Terminal: the Control settings file */if(packaged)rememberDataFolder(controlSettingsFile(),path,Date.now()/1000,true);writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});shareDataFolder();emit();return state();}
@@ -164,7 +168,7 @@ 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??(dflash2Served===true?'auto':undefined);if(packaged&&typeof choice==='string'&&typeof control.draftingPlan==='function'){try{const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model),here=snapshot.installed?.models?.find(m=>m.model===size?.id);const d=await control.draftingPlan({mode:choice,family:args.family??null,dataDir:here?.runDataFolder??snapshot.installed?.data_folder??null});if(d.needed||d.mtp_needed)return {...result,drafting:{needed:true,drafter:d.needed?'dflash2':'mtp',gb:d.needed?d.gb:d.mtp_gb,line:d.line}};}catch{/* the model plan still stands */}}
const choice=args.drafting??(dflash2Served===true?'auto':undefined);if(packaged&&typeof choice==='string'&&typeof control.draftingPlan==='function'){try{const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model),here=snapshot.installed?.models?.find(m=>m.model===size?.id);const d=await control.draftingPlan({mode:choice,family:args.family??null,dataDir:here?.runDataFolder??snapshot.installed?.data_folder??null,...(unloadsRunning(args)?{unloading:true}:{})});if(d.needed||d.mtp_needed)return {...result,drafting:{needed:true,drafter:d.needed?'dflash2':'mtp',gb:d.needed?d.gb:d.mtp_gb,line:d.line}};}catch{/* the model plan still stands */}}
return result;}
/* The folder the person picked in the first-run chooser can be the one that holds their GGUF files (or a folder inside it). It is theirs, not a Strata data folder: packs are never made in it, and a Strata folder around it (<Strata>/models/<tag>) is used instead (#3507, #3424). */
function ownFilesDataFolder(candidate,args){if(!candidate||args.data_dir!==undefined||!args.gguf_dir)return candidate;const same=(a,b)=>{const x=resolve(a),y=resolve(b);return process.platform==='win32'?x.toLowerCase()===y.toLowerCase():x===y;};const inside=(a,b)=>{const rel=relative(resolve(b),resolve(a));return rel===''||(!!rel&&!rel.startsWith('..')&&!isAbsolute(rel));};if(!same(candidate,args.gguf_dir)&&!inside(candidate,args.gguf_dir))return candidate;const models=dirname(resolve(args.gguf_dir)),top=dirname(models);return basename(models).toLowerCase()==='models'&&/strata/i.test(basename(top))?top:undefined;}
@@ -317,22 +321,28 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
return {cfg,resources,visionPreferences,parallelChoice,parallelNote,dsNote};
}
/* One drafter per run. Control (strata_control/drafting.py) says which one this load runs and whether its file is on this computer: the plan of the saved model file with the choice the Load carries. */
async function draftPlan(args,configFile){
/* The engines this Load is about to unload (the load below unloads every instance unless it adds one beside them): their graphics memory is given back before the new engine starts, so Control must not count it as busy memory when it asks whether the all-cards split engine fits (`--engines-unloading`; the Terminal does the same, Runner.card_probe). A model added beside the others (`multiple`) keeps their memory busy. */
function unloadsRunning(args){return instances.size>0&&args?.multiple!==true;}
async function draftPlan(args,configFile,{noSplit=false}={}){
if(!packaged||typeof control.draftingPlan!=='function')return null;
const asked=args.values?.drafting;
try{return await control.draftingPlan({configPath:configFile,...(asked===undefined?{}:{mode:asked===null?'auto':asked})});}catch{return null;}
/* The plan is for the cards this Load uses: the screen's own card choice goes over the ones the saved settings name (the all-cards split engine needs two or more). */
let planFile=configFile,temporary=null;
if(Array.isArray(args.gpus)&&args.gpus.length&&args.gpus.every(Number.isInteger)){try{const work=join(directory,'native-instances');mkdirSync(work,{recursive:true});temporary=join(work,'drafting-plan-'+randomUUID()+'.json');writeFileSync(temporary,JSON.stringify({...readJson(configFile),gpu:args.gpus.length===1?args.gpus[0]:args.gpus}),{mode:0o600});planFile=temporary;}catch{temporary=null;planFile=configFile;}}
try{return await control.draftingPlan({configPath:planFile,...(asked===undefined?{}:{mode:asked===null?'auto':asked}),...(noSplit?{noSplit:true}:{}),...(unloadsRunning(args)?{unloading:true}:{})});}catch{return null;}
finally{if(temporary){try{rmSync(temporary,{force:true});}catch{/* best effort */}}}
}
/* The choice changed to the MTP and its layer is not here yet: it is fetched once, by the installer (finished steps are skipped), in this Load. Only the Models screen's own Load carries the choice; a restart or a tuning run never fetches. */
async function installMtpLayer(job,args,config){
const fam=snapshot.catalog?.families?.find(f=>f.sizes.some(s=>s.id===args.model)),size=fam?.sizes.find(s=>s.id===args.model);
if(!fam||!size)return;
job.summary='Downloading the MTP drafter…';emit();
/* the model's own files folder (the run config's --native): without it the installer, asked for this model by name, would fetch the whole model again next to the layer (an app install adopted from the person's own GGUF folder has its files elsewhere) */const native=(()=>{const a=(config.args??[]).map(String),i=a.indexOf('--native');return i>=0?(a[i+1]??''):'';})();try{await install(job,{family:fam.family,model:size.model,drafter:'mtp',vision:args.vision??(config.vision?'yes':'no'),...(native?{gguf_dir:dirname(native)}:{})});}
catch{if(job.state==='running'){job.drafterNote='The MTP drafter could not be downloaded.';emit();}}/* one short line, not the installer's own text; Drafting below says what runs instead */
/* the model's own files folder (the run config's --native): without it the installer, asked for this model by name, would fetch the whole model again next to the layer (an app install adopted from the person's own GGUF folder has its files elsewhere) */const native=(()=>{const a=(config.args??[]).map(String),i=a.indexOf('--native');return i>=0?(a[i+1]??''):'';})();try{await install(job,{family:fam.family,model:size.model,drafter:'mtp',vision:args.vision??(config.vision?'yes':'no'),...(native?{gguf_dir:dirname(native)}:{})});return true;}
catch{if(job.state==='running'){job.drafterNote='The MTP drafter could not be downloaded.';emit();}return false;}/* one short line, not the installer's own text; Drafting below says what runs instead */
}
/* The DFlash2 drafter, before a load that uses it: Control says whether the choice needs it (and the file is not here), downloads it pinned and checked, and packs it, with plain progress in the job.
Never stops the load: a drafter that cannot be had leaves the MTP running if its layer is here, else no drafter, and the job says so. The choice itself is remembered in the model's settings file. */
async function prepareDrafter(job,args,cfg,configFile,drafts){
async function prepareDrafter(job,args,cfg,configFile,drafts,{noSplit=false}={}){
const asked=args.values?.drafting;
if(asked!==undefined)rememberSetting(configFile,'drafting',asked===null||asked==='auto'?null:asked);
/* Only the Models screen's own Load passes the choice (its dialog has listed the download): a restart, a tuning run or any other load leaves `asked` undefined and never fetches; the model then starts with the drafter that is on this computer and says why. */
@@ -341,7 +351,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
try{
writeFileSync(probe,JSON.stringify(cfg),{mode:0o600});
job.engineAbort??=new AbortController();
await control.draftingFetch({configPath:probe,mode:cfg.drafting??'auto',signal:job.engineAbort.signal,onEvent:event=>{if(event.event==='progress'&&job.state==='running'){job.summary=event.step;job.details=event.total>1&&event.done<event.total?`${(event.done/1e9).toFixed(2)} of ${(event.total/1e9).toFixed(2)} GB`:undefined;emit();}}});
await control.draftingFetch({configPath:probe,mode:cfg.drafting??'auto',...(noSplit?{noSplit:true}:{}),...(unloadsRunning(args)?{unloading:true}:{}),signal:job.engineAbort.signal,onEvent:event=>{if(event.event==='progress'&&job.state==='running'){job.summary=event.step;job.details=event.total>1&&event.done<event.total?`${(event.done/1e9).toFixed(2)} of ${(event.total/1e9).toFixed(2)} GB`:undefined;emit();}}});
if(job.state==='running'){job.summary='Loading the model…';job.details=undefined;emit();}
}catch(error){if(job.state==='running'){job.drafterNote=error.message;job.summary=drafts.mtp_here===false?'The DFlash2 drafter could not be downloaded, so the model starts without a drafter. Loading the model…':'The DFlash2 drafter could not be downloaded, so the model starts with the MTP. Loading the model…';emit();}}
finally{try{rmSync(probe,{force:true});}catch{/* best effort */}}
@@ -349,7 +359,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
/* One key of a model's settings file, set or removed (the Drafting choice): written like the Settings screen writes it, to be there for the next load. */
function rememberSetting(configFile,key,value){try{const current=readJson(configFile);if(value===null?!(key in current):current[key]===value)return;const next={...current};if(value===null)delete next[key];else next[key]=value;const temp=configFile+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,configFile);}catch{/* the load goes on with the choice for this start */}}
/* Which drafter runs, when it is not the one that was asked for (the engine's own words, "Drafting: MTP (DFlash2 didn't fit)"): under Details of the load. */
async function noteDrafting(job,instance,key){let note='';try{const r=await fetcher(`http://127.0.0.1:${instance.port}/metrics`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(r.ok)note=String((await r.json())?.engine?.drafting_note??'');}catch{/* a convenience */}const lines=[job.drafterNote,note].filter(Boolean);if(lines.length)job.details=[job.details,...lines].filter(Boolean).join('\n');}
async function noteDrafting(job,instance,key){let note='',fellBack=false;try{const r=await fetcher(`http://127.0.0.1:${instance.port}/metrics`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(r.ok){const engine=(await r.json())?.engine;note=String(engine?.drafting_note??'');fellBack=engine?.split_fallback===true;}}catch{/* a convenience */}/* the all-cards split engine: the line of what runs (the server starts the normal layer split by itself when the split engine would not come up) */const lines=[job.drafterNote,fellBack?SPLIT_FELL_BACK:instance.splitNote,note].filter(Boolean);if(lines.length)job.details=[job.details,...lines].filter(Boolean).join('\n');}
/* ---- The picture files of an installed model that has none (strata_control/pictures.py, run by Control) ----
Only the Load or the "Download picture-reading files" button the person pressed reaches the network, and only for the picture file (about 0.9 GB, one copy
shared by the models of a data folder). The model's own files, its pack, its draft layer, the data folder and the settings' data_dir are not touched; the one change
@@ -420,11 +430,13 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
/* Read pictures is on and the picture files are not here: the Load fetches those files (about 0.9 GB, once) and nothing else of the model, then goes on. No internet: the model loads without pictures and the page says so. */
let picturesNote=null;{const got=await ensurePictures(job,args,configFile,config,{missing:entry.picturesMissing===true});if(job.state!=='running')return;config=got.config;args=got.args;picturesNote=got.note;}
/* One drafter per run (strata_control/drafting.py): switching to the MTP on a model set up for DFlash2 fetches the layer once, here; DFlash2 is fetched below. */
let drafts=await draftPlan(args,configFile);
if(drafts?.mtp_needed===true&&(args.values?.drafting!==undefined||drafts.split===true&&(config.drafting==null||String(config.drafting).toLowerCase()==='auto'))){/* the Models screen's own Load carries the choice; a plain Auto load fetches the layer too when Control says the all-cards split engine will take the start (it runs the MTP and nothing else): the same as the Terminal */await installMtpLayer(job,args,config);if(job.state!=='running')return;await refresh(true);config=checkedSettingsConfig(readFileSync(recordFile(args.model).path,'utf8'));drafts=await draftPlan(args,configFile);}
let drafts=await draftPlan(args,configFile),noSplit=false;
if(drafts?.mtp_needed===true&&(args.values?.drafting!==undefined||drafts.split===true&&(config.drafting==null||String(config.drafting).toLowerCase()==='auto'))){/* the Models screen's own Load carries the choice; a plain Auto load fetches the layer too when Control says the all-cards split engine will take the start (it runs the MTP and nothing else): the same as the Terminal */await installMtpLayer(job,args,config);if(job.state!=='running')return;await refresh(true);config=checkedSettingsConfig(readFileSync(recordFile(args.model).path,'utf8'));drafts=await draftPlan(args,configFile);
/* The draft layer for the split could not be had (no internet, disk): the load goes on as the 0.2.10 app did, the normal layer split with the plan DFlash2's again (the Models screen's own Load fetches that drafter below). One plain line says so. */
if(drafts?.split===true&&drafts.mtp_needed===true){noSplit=true;job.drafterNote=job.drafterNote??'The MTP drafter could not be downloaded.';job.summary='Loading the model…';emit();drafts=await draftPlan(args,configFile,{noSplit:true});}}
const {cfg,resources,visionPreferences,parallelChoice,parallelNote:chosenNote,dsNote}=buildConfig(args,config,drafts?.choice==='dflash2'&&drafts.needed===true&&args.values?.drafting===undefined?'mtp':drafts?.choice??null);/* (a drafter that is not here and will not be fetched by this load: the MTP runs, and the request slots stay) */
let parallelNote=chosenNote;
await prepareDrafter(job,args,cfg,configFile,drafts);if(job.state!=='running')return;
await prepareDrafter(job,args,cfg,configFile,drafts,{noSplit});if(job.state!=='running')return;
const fingerprint=createHash('sha256').update(JSON.stringify({...cfg,api_key:undefined,port:undefined})).digest('hex');
const placement=Array.isArray(cfg.gpu)?cfg.gpu:cfg.gpu===undefined?[]:[cfg.gpu];const matching=[...instances.values()].find(i=>i.model===args.model&&JSON.stringify(i.gpus)===JSON.stringify(placement));
if(matching?.status==='ready'&&matching.fingerprint===fingerprint&&(args.multiple||instances.size===1))return {summary:`${matching.label} is already ready.`,instanceId:matching.id};
@@ -443,25 +455,43 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
/* Another model on the same graphics card: the engine takes the card's free memory, so a second one usually finds none. Loading goes on (recommend, never force); if it fails, the reason is said. */
const sharedWith=instance.gpus.length?[...instances.values()].find(i=>i.id!==id&&i.status!=='error'&&i.model!==args.model&&i.gpus.some(g=>instance.gpus.includes(g))):null;
const sharedSentence=sharedWith?`GPU ${instance.gpus.find(g=>sharedWith.gpus.includes(g))+1} is already used by ${sharedWith.label}, so this model did not fit there. Load it on another GPU, or unload ${sharedWith.label} first.`:null;
let proc=null,started=null,retried=null,startWait=null;
let proc=null,started=null,retried=null,startWait=null,adopted=false;
if(packaged){
/* How long this start waits, and the server's own engine wait kept above it. The server ends the engine when its engine wait runs
out (900 s, or the config's engine_ready_s), so a start that waits longer than that can never win: as in `strata launch`, the
server gets a wait above the start's. It is only ever raised, never lowered, so a config that already names a longer one
(setup's 3600 for DeepSeek V4.1-Flash) is left alone. */
startWait=startWaitFor(config,entry,args.model);
/* The split engine reads every expert into the cards before it answers, and a start that falls back to the layer split begins again: the 10 minutes the Terminal gives a launch (never above the ceiling). */
if(!noSplit&&drafts?.split===true)startWait=Math.min(startWaitCeiling(),Math.max(startWait,600));
if(startWait+60>Math.max(900,Number(cfg.engine_ready_s)||0))cfg.engine_ready_s=startWait+60;
/* The engine starts under Control's owner runtime, not as our child: a saved profile per slot, then `runtime start`. */
const used=new Set([...instances.values()].map(i=>i.profile));let slot=1;while(used.has('app-'+slot))slot++;instance.profile='app-'+slot;
instances.set(id,instance);if(!defaultId||!args.multiple||wasDefault)defaultId=id;emit();
if(dsNote)append(id,dsNote);
const startWith=value=>{writeFileSync(configPath,JSON.stringify(value,null,2),{mode:0o600});return control.stop(instance.profile).catch(()=>{}).then(()=>control.saveProfile(instance.profile,configPath)).then(()=>control.start(instance.profile,{waitSeconds:startWait}));};
/* The all-cards split engine: where the plan says it takes this start, Control turns the run config into the split engine's (the step `strata launch` does itself: the rule, the engine package and the draft layer are checked on this computer) and says so in one line. Any "no" leaves the layer split config as it is. The second try after a failed start is never split. */
const splitStart=async value=>{
if(noSplit||drafts?.split!==true||typeof control.splitEngine!=='function')return value;
const input=join(work,id+'.layer.json'),output=join(work,id+'.split.json');
try{
writeFileSync(input,JSON.stringify(value,null,2),{mode:0o600});rmSync(output,{force:true});
const answer=await control.splitEngine({configPath:input,outPath:output});
if(answer.line){instance.splitNote=answer.line;append(id,answer.line);}
if(answer.split===true&&answer.written)return JSON.parse(readFileSync(output,'utf8'));
}catch{/* the layer split config stands */}
finally{for(const f of [input,output]){try{rmSync(f,{force:true});}catch{/* best effort */}}}
return value;
};
const startWith=async(value,split=true)=>{const run=split?await splitStart(value):value;writeFileSync(configPath,JSON.stringify(run,null,2),{mode:0o600});return control.stop(instance.profile).catch(()=>{}).then(()=>control.saveProfile(instance.profile,configPath)).then(()=>control.start(instance.profile,{waitSeconds:startWait}));};
/* A Control whose wait ran out has NOT stopped the engine: `runtime start` answers "startup_wait_expired" (exit 8), or "runtime operation wait expired" when the owner was too busy to answer in time, while the engine is still loading (#5177 #4293). That is not a failure: Control is asked, and a start it still reports as starting (or ready) is kept and watched below instead of being stopped and marked failed. */
const stillLoading=async e=>{if(!/startup_wait_expired|runtime operation wait expired/i.test(String(e?.message??'')+JSON.stringify(e?.output??'')))return false;if(!instance.profile||typeof control.status!=='function'||job.state!=='running')return false;try{const st=await control.status(instance.profile);return st?.state==='starting'||st?.state==='ready';}catch{return false;}};
started=startWith(cfg);
started.then(()=>append(id,'Strata Control reports the engine is ready.'),error=>{append(id,error.message);});
try{await started;}catch(firstError){let error=firstError;
/* A settings, profile or run file an elevated run made can be closed to this user: repair the access in Strata's own folders and start again (this is not the memory try below). */
const isAccess=e=>/PermissionError|access is denied|EACCES/i.test(String(e?.message??'')+JSON.stringify(e?.output??''));
if(isAccess(error)&&typeof control.repairAccess==='function'&&job.state==='running'){await control.repairAccess();append(id,'Strata repaired access to its own files; starting again.');try{await startWith(cfg);started=Promise.resolve();error=null;}catch(again){error=again;}}
if(error&&await stillLoading(error)){adopted=true;error=null;}
if(error&&sharedSentence&&!isAccess(error)){instance.status='error';instance.error=sharedSentence;emit();throw new Error(sharedSentence);}
if(error){const said=humanStartError(error);
if(isAccess(error)){instance.status='error';instance.error=said;emit();throw new Error(said);} // access errors are not memory stops: no safer retry
@@ -476,16 +506,19 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
const words=safer?safer.words:FALLBACK_NOTE,detail=[safer?.detail,dropParallel?'one request at a time':null].filter(Boolean).join('; ');
retried={words,detail};if(dropParallel){instance.parallel=1;if(!safer)parallelNote=FALLBACK_NOTE;}
append(id,`The first start stopped (${said}). Starting once more: ${detail}`);job.summary=words;job.details=[said,`Second try: ${detail}`];instance.notice=words;emit();
try{await startWith(retryConfig);append(id,'Strata Control reports the engine is ready (second try).');}
catch(second){const said2=humanStartError(second);instance.status='error';instance.error=said2;/* the first stop and the second try stay in job.details; the pop-up shows the last stop once */emit();throw new Error(instance.error);}}}
delete instance.splitNote;
try{await startWith(retryConfig,false);append(id,'Strata Control reports the engine is ready (second try).');}
catch(second){if(await stillLoading(second))adopted=true;else{const said2=humanStartError(second);instance.status='error';instance.error=said2;/* the first stop and the second try stay in job.details; the pop-up shows the last stop once */emit();throw new Error(instance.error);}}}}
} else {
proc=spawnProcess(py,[join(repo(),'serve/server.py'),'--engine','strata','--config',configPath,'--port',String(cfg.port),'--host','127.0.0.1'],{cwd:repo(),detached:process.platform!=='win32',stdio:['ignore','pipe','pipe'],env:sourceServerEnvironment()});
instances.set(id,instance);processes.set(id,proc);if(!defaultId||!args.multiple||wasDefault)defaultId=id;
for(const stream of [proc.stdout,proc.stderr])stream.on('data',chunk=>{const lines=chunk.toString().split('\n').filter(Boolean);lines.forEach(line=>append(id,line.replaceAll(key,'[redacted]')));job.summary=lines.at(-1)??job.summary;emit();});
proc.on('error',error=>{instance.status='error';instance.error=error.message;emit();});proc.on('exit',code=>{if(instances.has(id)&&instance.status!=='unloading'){instance.status='error';instance.error=`Engine stopped${code===null?'':` (exit ${code})`}. Open diagnostics or reload.`;emit();}});emit();
}
const deadline=Date.now()+300000;while(Date.now()<deadline&&job.state==='running'){
/* A start Control stopped waiting for is watched for as long as the start asked for (the server ends the engine itself when its own wait runs out, a minute after this one), and Control is asked now and then whether the engine is still there. */
const deadline=Date.now()+(adopted?Math.max(300,startWait??300)*1000:300000);let lastAsked=0;while(Date.now()<deadline&&job.state==='running'){
if(instance.status==='error')throw new Error(instance.error);
if(adopted&&instance.profile&&Date.now()-lastAsked>10000){lastAsked=Date.now();const st=await control.status(instance.profile).catch(()=>null);if(st&&['failed','stopped','stale'].includes(st.state)){instance.status='error';instance.error=humanStartError({message:st.error||'server_exited',output:st});emit();throw new Error(instance.error);}}
try{const response=await fetcher(`http://127.0.0.1:${instance.port}/health`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(job.state!=='running'){await unload(id);return;}if(response.ok){instance.status='ready';instance.phase='idle';await noteDrafting(job,instance,key);if(resources||args.vision!==undefined||args.contextChosen===true||JSON.stringify(cfg[USER_KEY]??null)!==JSON.stringify(config[USER_KEY]??null))writeModelConfig(record(args.model).path,{...config,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),...visionPreferences,...(args.contextChosen===true?{ui_context_chosen:true}:{}),...(cfg[USER_KEY]?{[USER_KEY]:cfg[USER_KEY]}:{})});emit();ensurePoll();instance.parallelNote=parallelNote;return {summary:retried?`${instance.label} is ready, after a second try: ${retried.words}`:`${instance.label} is ready.${parallelNote?' '+parallelNote:''}${picturesNote?' '+picturesNote:''}`,instanceId:id};}}catch{}
await new Promise(resolve=>setTimeout(resolve,1200));
}
@@ -496,7 +529,9 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
let pollCount=0;
/* The engine's metrics carry every finished request with `?requests=all`: a long session makes that a large JSON that the host parses and
pushes to the window every 2 s. The last dozen is what the app shows live; the full list is refreshed every tenth poll. */
async function poll(){if(polling||closing)return;polling=true;const full=pollCount++%10===0;try{await Promise.all([...instances.values()].map(async instance=>{if(instance.status!=='ready')return;try{const response=await fetcher(`http://127.0.0.1:${instance.port}/metrics${full?'?requests=all':''}`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(1500)});if(!response.ok)throw new Error('Metrics unavailable');const fresh=await response.json();const older=instance.metrics?.requests,recent=fresh.requests;instance.metrics=full||!Array.isArray(older)||!Array.isArray(recent)||!recent.length?fresh:{...fresh,requests:[...recent,...older.filter(r=>Number(r.time)<Math.min(...recent.map(x=>Number(x.time))))]};const tools=await fetcher(`http://127.0.0.1:${instance.port}/mcp`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(1500)});if(tools.ok)instance.metrics.mcp=await tools.json();instance.phase=instance.metrics.live?.state??'idle';}catch{instance.metrics=null;instance.phase='unavailable';}}));emit();}finally{polling=false;}}
async function poll(){if(polling||closing)return;polling=true;const full=pollCount++%10===0;try{await Promise.all([...instances.values()].map(async instance=>{if(instance.status!=='ready')return;try{const response=await fetcher(`http://127.0.0.1:${instance.port}/metrics${full?'?requests=all':''}`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(1500)});if(!response.ok)throw new Error('Metrics unavailable');const fresh=await response.json();const older=instance.metrics?.requests,recent=fresh.requests;instance.metrics=full||!Array.isArray(older)||!Array.isArray(recent)||!recent.length?fresh:{...fresh,requests:[...recent,...older.filter(r=>Number(r.time)<Math.min(...recent.map(x=>Number(x.time))))]};const tools=await fetcher(`http://127.0.0.1:${instance.port}/mcp`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(1500)});if(tools.ok)instance.metrics.mcp=await tools.json();instance.phase=instance.metrics.live?.state??'idle';instance.misses=0;}catch{instance.metrics=null;instance.phase='unavailable';instance.misses=(instance.misses??0)+1;await reconcileLost(instance);}}));emit();}finally{polling=false;}}
/* The engine of a ready instance stopped answering. After a few looks in a row Control is asked: an owner that says the engine is stopped, failed or lost ends the "ready" the app was still showing (the model looked loaded for good while every request failed, #4973); an engine that is only busy stays as it is. */
async function reconcileLost(instance){if(instance.misses<3||instance.status!=='ready'||!instance.profile||typeof control.status!=='function')return;let st=null;try{st=await control.status(instance.profile);}catch{return;}if(instance.status==='ready'&&['stopped','failed','stale'].includes(st?.state)){instance.status='error';instance.error='Engine stopped. Open diagnostics or reload.';}}
function ensurePoll(){if(!timer){timer=setInterval(()=>void poll(),2000);timer.unref?.();}void poll();}
async function benchmark(job,args){const instance=resolveInstance(args.instanceId);job.instanceId=instance.id;if(busy(requests().filter(r=>r.instanceId===instance.id)))throw new Error('Wait for this model to finish before benchmarking.');const started=Date.now();job.abort=new AbortController();const response=await fetcher(`http://127.0.0.1:${instance.port}/v1/chat/completions`,{method:'POST',headers:{'content-type':'application/json',authorization:`Bearer ${instance.key}`},signal:AbortSignal.any([job.abort.signal,AbortSignal.timeout(180000)]),body:JSON.stringify({model:instance.apiModelId,messages:[{role:'user',content:'Explain how a rainbow forms in clear, simple language.'}],temperature:0,reasoning_effort:'none',max_tokens:200,stream:false})});const body=await response.json();if(!response.ok)throw new Error(body.error?.message??'Benchmark failed.');if(job.state!=='running')return;const result={summary:'Measured benchmark complete.',at:new Date().toISOString(),model:instance.apiModelId,durationMs:Date.now()-started,tokensIn:body.usage?.prompt_tokens??null,tokensOut:body.usage?.completion_tokens??null,outputTps:body.timings?.predicted_per_second??null,promptTps:body.timings?.prompt_per_second??null,source:'engine',settings:'Greedy · thinking off · up to 200 output tokens'};writeFileSync(join(directory,'native-benchmark.json'),JSON.stringify(result,null,2),{mode:0o600});return result;}
function resolveInstance(id){const aliases=[...instances.values()].filter(i=>i.aliases?.includes(id));if(aliases.length>1&&!instances.has(id)&&![...instances.values()].some(i=>i.apiModelId===id))throw new Error('This alias matches more than one instance. Choose its exact model ID.');const i=instances.get(id)??[...instances.values()].find(i=>i.apiModelId===id)??aliases[0]??(!id&&instances.size===1?[...instances.values()][0]:undefined);if(!i)throw new Error('Choose a loaded model ID.');if(i.status!=='ready')throw new Error('This model is not ready.');return i;}
+1 -1
View File
@@ -1,7 +1,7 @@
/** Preserve native protocol events and tool payloads; the gateway owns identity and cancellation. */
import { once } from 'node:events';
export async function nativeGateway({req,res,path,body,control,serving,fetcher=fetch}){
let instance;try{instance=control.resolveInstance(body.model);}catch(e){res.writeHead(400,{'content-type':'application/json'});res.end(JSON.stringify({error:{message:e.message,type:'invalid_request_error',param:'model',code:null}}));return;}
let instance;try{instance=control.resolveInstance(body.model);}catch(e){/* #5312: an Anthropic client sends a model name of its own (Claude Desktop: claude-5-5-sonnet) and cannot change it: with exactly one model loaded the route answers with it */if(path==='/v1/messages'&&body.model){try{instance=control.resolveInstance(undefined);}catch{}}if(!instance){res.writeHead(400,{'content-type':'application/json'});res.end(JSON.stringify({error:{message:e.message,type:'invalid_request_error',param:'model',code:null}}));return;}}
const abort=new AbortController(),started=Date.now();let sawDone=false;/* #4690: a client that hangs up after `data: [DONE]` (the OpenAI SDK does) has the whole answer: that is not a cancel. */res.on('close',()=>{if(!res.writableEnded&&!sawDone)abort.abort();});
const entry=serving.open({client:req.headers['user-agent']?.slice(0,100)??'API client',endpoint:path.slice(3),model:instance.apiModelId,instanceId:instance.id,gpuIds:instance.gpus.map(String),cancel:()=>abort.abort()});
let tokensIn=null,tokensOut=null,tps=null,promptTps=null;
+6 -2
View File
@@ -101,7 +101,7 @@ export function engineFacts(config = {}) {
* @param {object} [input.values] what the person set for this load (Expert mode's "Concurrent requests")
* @param {object} [input.hardware] the host's hardware read (`gpus[{index, vram_gb}]`)
* @param {number} [input.requested] the default to aim for
* @param {string} [input.drafter] the drafter this load runs (resolveDrafter): dflash2 answers one request at a time
* @param {string} [input.drafter] the drafter this load runs (resolveDrafter): with dflash2 the app fills in one request at a time unless the person chose a number
* @returns {{slots:number, batchMtp:boolean, explicit:boolean, requested:number, note:string|null, slotGb:number}}
*/
export function chooseParallel({ config = {}, values = {}, hardware = null, requested = DEFAULT_SLOTS, drafter = null }) {
@@ -112,10 +112,14 @@ export function chooseParallel({ config = {}, values = {}, hardware = null, requ
// A number someone chose (including 1) is the person's decision, and so is an explicit --batch in the engine arguments.
if (facts.explicitBatch || (chosen !== null && Number.isFinite(chosen))) {
const slots = facts.explicitBatch ? null : Math.max(1, Math.min(1024, Math.trunc(chosen)));
// DFlash2 chosen by name with a number of requests (owner, 2026-11-03: the person's own choice wins) runs DFlash2 with that many requests, a drafter per request
// (strata_control/drafting.py blocked_by `named`). Expert mode's batch-mtp wish is still added here as in 0.2.10: the server knows the backend and the engine, takes
// --batch-mtp out when DFlash2 runs, and keeps it when the MTP runs instead (Metal, which has no per-request drafters).
const wantMtp = (values.batchMtp ?? config.batchMtp) === true && !facts.batchMtp && facts.mtp && facts.gpus.length === 1 && (slots ?? 0) > 1;
return { slots: slots ?? 0, batchMtp: wantMtp, explicit: true, requested, note: null, slotGb: slot };
}
// DFlash2 chosen on purpose: it answers one request at a time, and request slots would keep the MTP (the engine says "not --batch"). Nobody chose a number here, so one.
// DFlash2 (named, or Auto where the engine has it) and nobody chose a number of requests: one at a time, the app does not fill in slots beside it. (A number the person chose is kept
// above: DFlash2 named runs it with that many requests; Auto keeps the MTP for several.)
if ((drafter ?? ((values.drafting ?? config.drafting) === 'dflash2' ? 'dflash2' : null)) === 'dflash2') return { slots: 1, batchMtp: false, explicit: false, requested, note: DFLASH2_NOTE, slotGb: slot };
const cards = (hardware?.gpus ?? []).filter(g => facts.gpus.includes(g.index)).map(g => Number(g.vram_gb)).filter(n => Number.isFinite(n) && n > 0);
// Without a hardware read nothing can be sized: one at a time is the safe default (a number can still be chosen).
+2
View File
@@ -125,6 +125,8 @@ export const engineVersionOfExe=exe=>{const m=/engine[\\/](\d+(?:\.\d+)+)-/.exec
technical line for Details. The caller tries once and never again, and does not save this config. */
export function saferStart(config,hardware,model,failure){
const text=String(failure??'');
// The engine refused an oversized prompt chunk at the start (an APU whose pool the expert cache fills; the person or the environment named the chunk): the same chunk would fail again, and a memory setting is not the cause.
if(/prompt chunk does not fit/i.test(text))return null;
// A start that died for a reason that is not memory (a missing or bad DLL, a crash, a blocked file) is not helped by a memory setting, and must not be called a memory failure.
if(/0xC0000135|0xC000007B|0xC0000142|0xC000001D|0xC0000005|0xC0000409|0xC0000022|error while loading shared libraries|cannot open shared object file|\bENOENT\b|exit code 127|exit code 139|illegal instruction/i.test(text))return null;
const ranOut=/out of memory|memory guard|\boom\b|\bkilled\b|exit code 137|cannot allocate|not enough memory|bad_alloc|insufficient memory/i.test(text),first=ranOut?'The first start ran out of memory':'The first start did not finish';
+1 -1
View File
@@ -9,7 +9,7 @@ export const NATIVE_FIELDS=[
{id:'idle_unload_s',label:'Unload after idle seconds',group:'Serving',type:'number',min:0,max:86400,hint:'Zero keeps the model loaded.'},
{id:'lazy_load',label:'Load on first request',group:'Serving',type:'boolean'},
{id:'mtp',notForModel:'deepseek',label:'Draft layer (MTP)',group:'Serving',type:'boolean',hint:'The draft layer guesses several words ahead, which makes answers faster, and costs about 1 GB of graphics memory and some RAM. Turn it off if the model stops while it starts, or the card is small: the expert cache gets that memory back and answers are slower. Default is on.'},
{id:'drafting',notForModel:'deepseek',label:'Drafting',group:'Serving',type:'enum',choices:['auto','mtp','dflash2','off'],hint:'Which drafter guesses words ahead to make answers faster, one at a time. Auto (recommended) is DFlash2 for Qwen3.8-Flash-Next where the engine has it, the MTP otherwise. DFlash2 downloads a 1.2 GB drafter once, the MTP its draft layer once. If DFlash2 cannot run (several requests at once), the MTP is used alone and Details says why. Off turns the drafter off.'},
{id:'drafting',notForModel:'deepseek',label:'Drafting',group:'Serving',type:'enum',choices:['auto','mtp','dflash2','off'],hint:'Which drafter guesses words ahead to make answers faster, one at a time. Auto (recommended) is DFlash2 for Qwen3.8-Flash-Next where the engine has it, the MTP otherwise; on two or more identical graphics cards that the all-cards split engine takes, it is that engine with the MTP draft layer. DFlash2 downloads a 1.2 GB drafter once, the MTP its draft layer once. With Auto, several requests at once use the MTP. If you choose DFlash2 yourself, it runs with the number of requests you set. If DFlash2 cannot run, the MTP is used alone and Details says why. Off turns the drafter off.'},
{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.'},
+2 -1
View File
@@ -2,9 +2,10 @@
import net from 'node:net';
/** Why a listen failed, in words a person can act on (Windows keeps whole ranges for Hyper-V, WSL and Docker). */
export function listenProblem(error, port, host = '127.0.0.1') {
export function listenProblem(error, port, host = '127.0.0.1', platform = process.platform) {
const code = error?.code;
if (code === 'EADDRINUSE') return `Port ${port} is already in use. Strata may already be running; close it or the other program, or start Strata with another port (STRATA_PORT).`;
if (code === 'EACCES' && platform !== 'win32') return `This system does not let Strata use port ${port}. On Linux and macOS, ports below 1024 need administrator rights. Start Strata with another port (STRATA_PORT), for example 8801.`;
if (code === 'EACCES') return `Windows or a firewall does not let Strata use port ${port}. Hyper-V, WSL and Docker keep ranges of ports for themselves (see "netsh int ipv4 show excludedportrange protocol=tcp"). Start Strata with another port (STRATA_PORT), for example 8801.`;
if (code === 'EADDRNOTAVAIL') return `${host} is not an address of this computer, so Strata cannot listen on it. Check STRATA_BIND.`;
return `Strata cannot listen on ${host}:${port} (${error?.message ?? code}).`;
+10
View File
@@ -24,3 +24,13 @@ export function makePrivate(path, { platform = process.platform } = {}) {
// The owner too, so a file an elevated run made is not left owned by Administrators (needs the right to; best effort).
try { execFileSync(system32('icacls.exe'), [path, '/setowner', `*${currentUserSid()}`], { stdio: 'ignore', timeout: 15000, windowsHide: true }); } catch { /* the explicit entry above already opens it to the user */ }
}
/**
* Owner-only folder. POSIX: mode 0700. Windows: a protected ACL naming only the current user that the files made inside inherit,
* so a file written later (a chat saved every second) is private without a tool run per write.
*/
export function makePrivateDir(path, { platform = process.platform } = {}) {
if (platform !== 'win32') { chmodSync(path, 0o700); return; }
execFileSync(system32('icacls.exe'), [path, '/grant:r', `*${currentUserSid()}:(OI)(CI)F`, '/inheritance:r'],
{ stdio: 'ignore', timeout: 15000, windowsHide: true });
}
+42
View File
@@ -0,0 +1,42 @@
// End a program this host started together with everything that program started (#4529 #4570).
//
// Windows: `taskkill /T` walks the tree. Linux and macOS have no such call, and signalling the child alone leaves its own children running (they are
// re-parented to init). A child started with `detached: ownGroup()` leads a process group of its own, so a signal to the negative pid reaches the
// whole group and nothing else: the group is the ownership record, no other process (a same-named one included) is ever signalled by name.
import { spawnSync } from 'node:child_process';
/** The spawn option that makes a child lead its own process group on Linux and macOS (Windows needs none: taskkill /T). */
export const ownGroup = () => process.platform !== 'win32';
/** Whether the group the child leads still has a member. */
export function groupAlive(child) {
if (process.platform === 'win32' || !child?.pid) return false;
try { process.kill(-child.pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
}
/**
* Signal the child's whole tree once. A child that does not lead its own group (it was not started with ownGroup()) is signalled alone, which is what
* every caller did before.
*/
export function endTree(child, signal = 'SIGTERM') {
if (!child) return;
try {
if (process.platform === 'win32') {
if (child.pid) spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore', timeout: 10000 });
return;
}
if (child.pid) {
try { process.kill(-child.pid, signal); return; } catch { /* no such group: it did not lead one, or it is gone */ }
}
child.kill(signal);
} catch { /* already gone */ }
}
/** SIGTERM to the tree, then SIGKILL to whatever of its group is still there after `graceMs`. Returns the timer so a caller may clear it. */
export function endTreeThenKill(child, graceMs = 2000) {
endTree(child, 'SIGTERM');
if (process.platform === 'win32' || !child?.pid) return null;
const timer = setTimeout(() => { if (groupAlive(child)) endTree(child, 'SIGKILL'); }, graceMs);
timer.unref?.();
return timer;
}
+1 -1
View File
@@ -15,7 +15,7 @@ import { NATIVE_FIELDS } from './native-settings.mjs';
/** Every top-level key a run config may carry: what setup writes, what serve/server.py reads, the app's own fields. */
export const KNOWN_KEYS = new Set([
...NATIVE_FIELDS.map(f => f.id),
'exe', 'args', 'cwd', 'tokenizer', 'model_name', 'model', 'arch', 'log', 'lib_dirs', 'port', 'host', 'api_key', 'cuda', 'backend', 'gpu', 'gpus_asked',
'exe', 'args', 'args_written', 'user_args', 'gpus_by', 'cwd', 'tokenizer', 'model_name', 'model', 'arch', 'log', 'lib_dirs', 'port', 'host', 'api_key', 'cuda', 'backend', 'gpu', 'gpus_asked',
'gpu_policy', 'hip_ordinal', 'env', 'env_to_user', 'sampling', 'mcp_servers', 'mcpServers', 'before_load', 'before_load_ignore_failure',
'before_load_strict', 'before_load_timeout_s', 'cors_origins', 'trusted_origins', 'allowed_hosts', 'open_browser', 'prevent_sleep',
'max_queue', 'queue_timeout', 'max_tokens', 'min_tokens', 'answer_room', 'threads', 'replicas', 'replica_cpus', 'replica_load_tokens',
+8 -5
View File
@@ -2,7 +2,7 @@
import { randomBytes } from 'node:crypto';
import os from 'node:os';
import { get as getStore, update } from './store.mjs';
import { nowIso, round, uid } from './util.mjs';
import { nowIso, round, sameSecret, uid } from './util.mjs';
const requests = [];
const MAX_REQUESTS = 60;
@@ -85,10 +85,13 @@ export function patch(body) {
export function authorized(req) {
const key = getStore().serving?.apiKey;
if (!key) return true;
const header = req.headers.authorization ?? '';
const bearer = header.startsWith('Bearer ') ? header.slice(7) : '';
const alt = req.headers['x-api-key'];
return bearer === key || alt === key;
const header = req.headers?.authorization;
const bearer = typeof header === 'string' && header.startsWith('Bearer ') ? header.slice(7) : '';
const alt = req.headers?.['x-api-key'];
// #4535: constant time over fixed-length digests, and both headers are always compared so the time does not show which one was sent.
const viaBearer = sameSecret(bearer, key);
const viaHeader = sameSecret(alt, key);
return viaBearer || viaHeader;
}
export function open({ client, endpoint, tokensIn = null, model, instanceId, gpuIds, cancel }) {
+36
View File
@@ -0,0 +1,36 @@
/**
* A quiet NDJSON answer stays open (#4878). A chat turn can say nothing for minutes (the model reads a long prompt, a tool
* runs, an approval waits), and the desktop's web view gives up on a request that has been silent for 60 seconds ("Load failed")
* while the turn goes on in the background. A beat frame, the same `{"type":"beat"}` the Time-skip stream uses and the chat
* consumer ignores, goes out every 15 seconds. It changes nothing else: a client that goes away still cancels (or detaches
* from) its turn through the response's own close, exactly as before, and the timer ends on finish, on close and on stop().
*/
export const BEAT_MS = 15_000;
const BEAT_LINE = `${JSON.stringify({ type: 'beat' })}\n`;
/** The interval (a tester may shorten it: STRATA_STREAM_BEAT_MS); anything that is not a sensible number is the default. */
export function beatInterval(env = process.env) {
const n = Number(env.STRATA_STREAM_BEAT_MS);
return Number.isFinite(n) && n >= 20 && n <= 60_000 ? n : BEAT_MS;
}
/** Start beating on `res`. Returns stop(), which is safe to call more than once. */
export function keepStreamAlive(res, { ms = beatInterval() } = {}) {
let timer = setInterval(() => {
if (res.writableEnded || res.destroyed) { stop(); return; }
try { res.write(BEAT_LINE); } catch { stop(); }
}, ms);
timer.unref?.();
function stop() {
if (timer === null) return;
clearInterval(timer);
timer = null;
res.off?.('finish', stop);
res.off?.('close', stop);
res.off?.('error', stop);
}
res.once('finish', stop);
res.once('close', stop);
res.once('error', stop);
return stop;
}
+46 -5
View File
@@ -10,8 +10,11 @@
* macOS: none.
*/
import { readFile, readdir } from 'node:fs/promises';
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { dirname, join } from 'node:path';
import { createPowerShellLoop, endWithHost } from './windows-poll.mjs';
import { stateRoot } from './platform-paths.mjs';
const CPU_SENSORS = new Set(['coretemp', 'k10temp', 'zenpower', 'cpu_thermal', 'cpu-thermal']);
const DISK_SENSORS = new Set(['nvme', 'drivetemp']);
@@ -82,28 +85,66 @@ while($true){
if($c.Count -gt 0){ $max=($c | Measure-Object Temperature -Maximum).Maximum }
else {
if(-not $compiled){ Add-Type -TypeDefinition $cs; $compiled=$true }
foreach($n in 0..15){ $v=[StrataDiskTemp]::Read($n); if($v -gt $max){ $max=$v } }
# only drives Windows names as NVMe, one at a time: a RAID, SATA, USB or unknown drive is never asked (a driver stack that stalls on this query
# froze whole PCs, #4661)
foreach($d in @(Get-PhysicalDisk | Where-Object { $_.BusType -eq 'NVMe' })){ $v=[StrataDiskTemp]::Read([int]$d.DeviceId); if($v -gt $max){ $max=$v } }
}
if($max -gt 0){ [Console]::Out.WriteLine('ssd '+$max) } else { [Console]::Out.WriteLine('ssd -') }
[Console]::Out.Flush()
Start-Sleep -Seconds 30
}`;
export function createWindowsSsdReader({ spawnFn = spawn, maxStarts = 2, now = Date.now, intervalSec = 30 } = {}) {
let latest = null;
/** How long the first pass of the reader may take before it is given up for good (a healthy pass takes a second or two). */
export const FIRST_PASS_MS = 45_000;
/** The marker file the reader leaves before it asks the drives and removes after its first answer. */
export const sentinelPath = (env = process.env, platform = process.platform) => join(stateRoot(env, platform), 'drive-temperature.probe');
/**
* `sentinel`: { exists(), write(), remove() }. The reader leaves the marker before it first asks a drive and removes it when the first answer has come. A host
* that is still there with a marker at its next start died, or the PC did, in the middle of the question: the reader stays off from then on (the line is left
* out of the Monitor; deleting the file brings it back). It never asks again after a first pass that took longer than FIRST_PASS_MS either.
*/
export function createWindowsSsdReader({ spawnFn = spawn, maxStarts = 2, now = Date.now, intervalSec = 30, sentinel = null, firstPassMs = FIRST_PASS_MS } = {}) {
let latest = null, off = false, startedAt = null;
const loop = createPowerShellLoop({
script: WINDOWS_SCRIPT, spawnFn, maxStarts,
onLine: line => {
const parsed = parseSsdLine(line);
if (parsed === undefined) return false;
latest = { value: parsed, at: now() };
if (sentinel) { try { sentinel.remove(); } catch { /* best effort */ } }
return true;
},
});
return {
read() { loop.start(); return latest && now() - latest.at < 4 * intervalSec * 1000 ? latest.value : null; },
read() {
if (off) return null;
if (startedAt === null) {
if (sentinel) {
try { if (sentinel.exists()) { off = true; return null; } sentinel.write(); } catch { off = true; return null; } // cannot leave the marker: do not ask
}
startedAt = now();
} else if (latest === null && now() - startedAt > firstPassMs) {
off = true; // the first pass is stuck: ended, and the marker stays
loop.stop();
return null;
}
loop.start();
return latest && now() - latest.at < 4 * intervalSec * 1000 ? latest.value : null;
},
stop() { loop.stop(); },
get starts() { return loop.starts; },
get off() { return off; },
};
}
/** The real marker file under Strata's state folder. */
export function fileSentinel(path = sentinelPath()) {
return {
exists: () => existsSync(path),
write: () => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, String(Date.now())); },
remove: () => rmSync(path, { force: true }),
};
}
@@ -117,7 +158,7 @@ export async function readTemperatures({ platform = process.platform, now = Date
return linuxCache.value;
}
if (platform === 'win32') {
windowsReader ??= endWithHost(createWindowsSsdReader());
windowsReader ??= endWithHost(createWindowsSsdReader({ sentinel: fileSentinel() }));
return { cpuTempC: null, ssdTempC: windowsReader.read() };
}
return { cpuTempC: null, ssdTempC: null };
+4 -6
View File
@@ -17,7 +17,8 @@
* A run is a job, not a request: it outlives the HTTP call that started it. The endpoint returns the
* run id immediately and the client follows it with `/api/timeskip/status`.
*/
import { spawn, spawnSync } from 'node:child_process';
import { spawn } from 'node:child_process';
import { endTreeThenKill, ownGroup } from './process-tree.mjs';
import { existsSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
import { isAbsolute, join, relative, resolve } from 'node:path';
import { timeSkipDir } from './platform-paths.mjs';
@@ -146,10 +147,7 @@ export function timeskipRevisionMessage(request) {
/** End a process and everything it started (the job, the Python it runs, the revision command and its Node). */
function killTree(child) {
try {
if (process.platform === 'win32' && child.pid) spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
else child.kill('SIGTERM');
} catch { /* already gone */ }
endTreeThenKill(child); // Windows: taskkill /T. Linux and macOS: the job leads its own process group (startDetached), so the group is ended (#4529 #4570)
}
/** How big a folder is, stopping early once it is clearly too big to copy for a trial. */
@@ -452,7 +450,7 @@ export function createTimeSkip({ repoRoot, python, pythonArgs = [], timeoutMs =
'--attempts', String(Math.max(0, Math.min(10, Number(attempts) || 0))), '--output', 'json'];
if (reviseCommand) args.push('--revise-command', reviseCommand);
const child = spawn(python, [...pythonArgs, cli, ...args], {
cwd: repoRoot, windowsHide: true, env: { ...env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8', ...extraEnv, STRATA_TIMESKIP_DIR: root }, stdio: ['ignore', 'pipe', 'pipe'],
cwd: repoRoot, windowsHide: true, detached: ownGroup(), env: { ...env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8', ...extraEnv, STRATA_TIMESKIP_DIR: root }, stdio: ['ignore', 'pipe', 'pipe'],
});
let out = '', err = '', closed = null;
child.stdout.on('data', b => { out += b.toString(); });
+41 -1
View File
@@ -1,5 +1,5 @@
/** Shared helpers for the Strata host. */
import { randomUUID } from 'node:crypto';
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { whichCommand } from './platform-paths.mjs';
@@ -24,6 +24,46 @@ export function error(res, status, message, extra = {}) {
json(res, status, { error: message, ...extra });
}
/**
* Two secrets compared in constant time (#4535). Both sides are hashed first, so the comparison always runs over two
* 32-byte digests: neither the content nor the length of the given value can end it early. Anything that is not a
* non-empty string never matches (an empty secret is "not set", not "equal to an empty header").
*/
export function sameSecret(given, wanted) {
const ok = typeof given === 'string' && typeof wanted === 'string' && given.length > 0 && wanted.length > 0;
const digest = value => createHash('sha256').update(typeof value === 'string' ? value : '', 'utf8').digest();
return timingSafeEqual(digest(given), digest(wanted)) && ok;
}
/**
* The request's URL, or null when the request cannot be read as one (#4397). The Host header is the caller's: an empty
* value, one with a space, a path, credentials or an unclosed bracket used to make `new URL` throw before the routing
* try/catch and end the whole process. A missing Host (HTTP/1.0) is read as localhost; a good one keeps its port and IPv6 form.
*/
export function requestUrl(req) {
const host = req.headers?.host;
try {
let authority = 'localhost';
if (host !== undefined) {
if (typeof host !== 'string' || !host.trim() || /[\s/?#@\\]/.test(host)) return null;
const base = new URL(`http://${host}`);
if (!base.host || base.username || base.password || base.search || base.hash || base.pathname !== '/') return null;
authority = host;
}
return new URL(req.url ?? '/', `http://${authority}`);
} catch {
return null;
}
}
/** The answer to a request that could not be read as a URL: bounded, and the connection closes. */
export function badRequest(res) {
if (res.headersSent) return res.end();
const payload = '{"error":"bad_host"}';
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(payload), 'cache-control': 'no-store', connection: 'close' });
res.end(payload);
}
export async function readBody(req, limit = 4 * 1024 * 1024) {
const chunks = [];
let size = 0;
+4 -2
View File
@@ -7,6 +7,7 @@ import { TaskTree } from './TaskTree';
import { useEffect, useId, useLayoutEffect, useRef, useState, type RefObject } from 'react';
import { createPortal } from 'react-dom';
import { FloatingMenu } from '../components/FloatingMenu';
import { measureScale } from '../lib/overlay-place';
import { QuitStrataDialog } from '../components/QuitStrata';
import { useAppMode } from '../lib/app-mode';
import { href, useLocation, type Route } from '../lib/router';
@@ -162,8 +163,9 @@ function RecentChatLink({ session, active, slot }: {
const rect = anchor.current?.getBoundingClientRect();
if (!rect)
return;
const height = tooltip.current?.getBoundingClientRect().height ?? 80;
setPosition({ left: rect.right + 12, top: Math.max(12, Math.min(rect.top, window.innerHeight - height - 12)) });
const scale = measureScale();
const height = tooltip.current?.getBoundingClientRect().height ?? 80 * scale;
setPosition({ left: (rect.right + 12) / scale, top: Math.max(12, Math.min(rect.top, window.innerHeight - height - 12)) / scale });
};
place();
window.addEventListener('resize', place);
+12 -6
View File
@@ -1,15 +1,16 @@
import { tr } from '../lib/i18n';
import { PreviewControls } from './PreviewControls';
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Dialog, Icon, IconBtn, Seg, type IconName } from './ui';
import { CopyBtn, CopyMenuItem } from './CopyBtn';
import { Markdown } from './Markdown';
import { PreviewMarkdown } from './PreviewMarkdown';
import { SyntaxCode } from './SyntaxCode';
import { FloatingMenu } from './FloatingMenu';
import { TablePreview } from './TablePreview';
import { artifactFilename, artifactIcon, safePreviewUrl, type PreviewArtifact } from '../lib/artifacts';
import { scrollBehavior } from '../lib/responsive';
import { previewVersion } from '../lib/preview-merge';
const PdfPreview = lazy(() => import('./PdfPreview'));
export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab, inline }: {
items: PreviewArtifact[];
@@ -20,17 +21,22 @@ export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab,
inline: boolean;
}) {
const current = items.find(a => a.id === selected);
// What the text loading depends on: the file's identity and its content version, not the object (the chat hands over new objects often).
const version = useMemo(() => current ? previewVersion(current) : '', [current]);
const [text, setText] = useState(''), [error, setError] = useState(''), [mode, setMode] = useState<'preview' | 'source'>('preview'), [revision, setRevision] = useState(0), [loading, setLoading] = useState(false), [imageSize, setImageSize] = useState('fit');
const [toolbarHost, setToolbarHost] = useState<HTMLElement | null>(null);
const [fileMenu, setFileMenu] = useState(false);
const fileMenuRef = useRef<HTMLButtonElement>(null);
useLayoutEffect(() => { setToolbarHost(inline && current ? document.getElementById('preview-file-actions') : null); }, [inline, current?.id]);
// A different file starts in the reading view; the same file with new content keeps the person's view.
useEffect(() => {
setError('');
setMode('preview');
setLoading(false);
setImageSize('fit');
setFileMenu(false);
}, [current?.id]);
useEffect(() => {
setError('');
setLoading(false);
if (!current || !['markdown', 'code', 'table'].includes(current.kind))
return;
if (current.content !== undefined) {
@@ -61,7 +67,7 @@ export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab,
setLoading(false);
});
return () => abort.abort();
}, [current?.id, revision]);
}, [current?.id, version, revision]);
// A closed panel leaves with a short slide instead of vanishing (a bare shell; the page itself is already gone).
const hadCurrent = useRef(false);
const [leaving, setLeaving] = useState(false);
@@ -84,7 +90,7 @@ export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab,
const content = <aside className="artifact-panel" aria-label={tr("{0} preview", [current.kind])}>
{tr(!inline ? <ArtifactTabs onCloseTab={onCloseTab} items={items} selected={selected} onSelect={onSelect}/> : null)}
<div key={current.id} id="artifact-content" role="tabpanel" aria-labelledby={`artifact-tab-${items.findIndex(a => a.id === selected)}`} className="artifact-content">
{tr(current.kind === 'website' ? <><iframe key={`${current.id}-${revision}`} title={current.title} src={current.content ? undefined : url ?? undefined} srcDoc={current.content} sandbox="allow-scripts" referrerPolicy="no-referrer"/></> : current.kind === 'pdf' ? url ? <Suspense fallback={<p className="muted preview-placeholder">{tr("Opening PDF viewer\u2026")}</p>}><PdfPreview key={`${current.id}-${revision}`} url={url}/></Suspense> : <p>{tr("No PDF to display.")}</p> : current.kind === 'image' ? <div className="image-preview" data-size={imageSize === '1' ? 'actual' : 'fit'}>{tr(url ? <img key={`${current.id}-${revision}`} src={url} alt={current.title} onError={() => setError('Couldn’t load this image.')}/> : null)}{tr(error ? <p role="alert">{tr(error)}</p> : null)}</div> : loading ? <p className="muted preview-placeholder">{tr("Loading document\u2026")}</p> : error ? <p role="alert" className="inline-error preview-placeholder">{tr(error)}</p> : current.kind === 'code' ? <SyntaxCode text={text} filename={artifactFilename(current)} lineNumbers/> : current.kind === 'table' ? <TablePreview key={current.id} text={text}/> : mode === 'source' ? <SyntaxCode text={text} language="markdown" lineNumbers/> : <article className="document-markdown"><Markdown text={text}/></article>)}
{tr(current.kind === 'website' ? <><iframe key={`${current.id}-${revision}-${version}`} title={current.title} src={current.content ? undefined : url ?? undefined} srcDoc={current.content} sandbox="allow-scripts" referrerPolicy="no-referrer"/></> : current.kind === 'pdf' ? url ? <Suspense fallback={<p className="muted preview-placeholder">{tr("Opening PDF viewer\u2026")}</p>}><PdfPreview key={`${current.id}-${revision}-${version}`} url={url}/></Suspense> : <p>{tr("No PDF to display.")}</p> : current.kind === 'image' ? <div className="image-preview" data-size={imageSize === '1' ? 'actual' : 'fit'}>{tr(url ? <img key={`${current.id}-${revision}-${version}`} src={url} alt={current.title} onError={() => setError('Couldn’t load this image.')}/> : null)}{tr(error ? <p role="alert">{tr(error)}</p> : null)}</div> : loading ? <p className="muted preview-placeholder">{tr("Loading document\u2026")}</p> : error ? <p role="alert" className="inline-error preview-placeholder">{tr(error)}</p> : current.kind === 'code' ? <SyntaxCode text={text} filename={artifactFilename(current)} lineNumbers/> : current.kind === 'table' ? <TablePreview key={current.id} text={text}/> : mode === 'source' ? <SyntaxCode text={text} language="markdown" lineNumbers/> : <article className="document-markdown"><PreviewMarkdown text={text}/></article>)}
</div>
{tr(viewControls ? <div className="artifact-view-controls">{tr(viewControls)}</div> : null)}
</aside>;
+16 -9
View File
@@ -1,6 +1,7 @@
import { tr } from '../lib/i18n';
import { useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { measureScale, placeAnchored } from '../lib/overlay-place';
export function FloatingMenu({ anchor, title, onClose, children, width = 232 }: {
anchor: HTMLElement | null;
title: string;
@@ -9,22 +10,28 @@ export function FloatingMenu({ anchor, title, onClose, children, width = 232 }:
width?: number;
}) {
const ref = useRef<HTMLDivElement>(null);
const [placed, setPlaced] = useState({ left: 12, top: 12 });
const [placed, setPlaced] = useState<{ left: number; top: number; maxWidth?: number; maxHeight?: number }>({ left: 12, top: 12 });
useLayoutEffect(() => {
const rect = anchor?.getBoundingClientRect();
const height = ref.current?.getBoundingClientRect().height ?? 200;
const left = Math.max(12, Math.min(rect?.left ?? 12, window.innerWidth - Math.min(width, window.innerWidth - 24) - 12));
const below = (rect?.bottom ?? 12) + 8; // a little air between the button and its panel
const top = below + height + 12 <= window.innerHeight ? below : Math.max(12, (rect?.top ?? window.innerHeight) - height - 6);
setPlaced({ left, top });
// Placed in visual pixels and written in the page's own pixels (lib/overlay-place.ts): the page may be scaled by "Interface size" (#4653).
const place = () => {
const el = ref.current;
const scale = measureScale();
const rect = anchor?.getBoundingClientRect();
const natural = el ? Math.max(el.getBoundingClientRect().height, el.scrollHeight * scale) : 200 * scale;
const home = { left: 12, top: 12, right: 12, bottom: 12 };
const p = placeAnchored({ anchor: rect ?? home, size: { width: width * scale, height: natural }, viewport: { width: window.innerWidth, height: window.innerHeight }, scale });
setPlaced({ left: p.left, top: p.top, maxWidth: p.maxWidth, maxHeight: p.maxHeight });
};
place();
ref.current?.querySelector<HTMLElement>('[role="menuitem"]:not(:disabled), [role="menuitemradio"]:not(:disabled)')?.focus();
const outside = (e: PointerEvent) => { if (!ref.current?.contains(e.target as Node) && !anchor?.contains(e.target as Node))
onClose(); };
document.addEventListener('pointerdown', outside);
return () => { document.removeEventListener('pointerdown', outside); if (anchor?.isConnected)
window.addEventListener('resize', place);
return () => { document.removeEventListener('pointerdown', outside); window.removeEventListener('resize', place); if (anchor?.isConnected)
anchor.focus({ preventScroll: true }); };
}, [anchor, width]);
return createPortal(<div className="menu" role="menu" aria-label={tr(title)} ref={ref} style={{ ...placed, zIndex: anchor?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)', width, maxWidth: 'calc(100dvw - 24px)', maxHeight: `calc(100dvh - ${placed.top + 12}px)`, overflowY: 'auto' }} onKeyDown={e => {
return createPortal(<div className="menu" role="menu" aria-label={tr(title)} ref={ref} style={{ ...placed, zIndex: anchor?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)', width, maxWidth: placed.maxWidth ?? 'calc(100dvw - 24px)', maxHeight: placed.maxHeight ?? 'calc(100dvh - 24px)', overflowY: 'auto' }} onKeyDown={e => {
const nodes = Array.from(ref.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled), [role="menuitemradio"]:not(:disabled)') ?? []);
const at = nodes.indexOf(document.activeElement as HTMLElement);
if (['Escape', 'Tab'].includes(e.key)) {
+8 -1
View File
@@ -70,7 +70,9 @@ export function Markdown({ text, onOpenLink, previewId, onPreview, onClosePrevie
imageSession?: string | null;
}) {
const nodes: ReactNode[] = [];
const lines = text.split('\n');
// A file written on Windows has \r\n: the \r stays at the end of every line, where `.` does not match it, so a list line was never taken and the
// list loop below never moved on (an endless loop that filled the page's memory, found in the sidebar preview). Line ends are read as one.
const lines = text.split(/\r\n|\r|\n/);
for (let i = 0; i < lines.length;) {
const line = lines[i];
if (!line.trim()) {
@@ -111,6 +113,11 @@ export function Markdown({ text, onOpenLink, previewId, onPreview, onClosePrevie
items.push(<li key={i}>{tr(inline(lines[i].replace(re, '$1'), onOpenLink))}</li>);
i++;
}
if (i === key) { // never stand still: a line the list start accepted but the item pattern did not is shown as text and passed
nodes.push(<p key={key}>{line}</p>);
i++;
continue;
}
nodes.push(ordered ? <ol key={key}>{tr(items)}</ol> : <ul key={key}>{tr(items)}</ul>);
continue;
}
+8 -6
View File
@@ -20,7 +20,7 @@ import { NativeDeleteDialog } from './NativeDelete';
import { plainFailureText } from '../lib/plain-errors';
import { api } from '../lib/client';
import { go, setSettingsSection, selectRemoteModel, pushToast } from '../lib/store';
import { nativeAction, useNative, type NativeJob } from '../lib/native';
import { installedListPending, nativeAction, useNative, type NativeJob } from '../lib/native';
import { tuneEffective } from '../lib/native-tune';
import { tunedFields } from '../lib/tune-values';
import { shownVision, steadyNoReader } from '../lib/pictures-choice';
@@ -69,7 +69,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
return () => cancelAnimationFrame(frame);
}, [expert]);
const selectedFamily = families.find(f => f.family === family || f.sizes.some(size => size.sourceFamily === family)) ?? families[0], selectedSize = selectedFamily?.sizes.find(s => s.id === size) ?? selectedFamily?.sizes[0];
const installed = native?.installed?.models.find(m => m.model === selectedSize?.id);
const installed = native?.installed?.models.find(m => m.model === selectedSize?.id), listPending = installedListPending(native);
// The sizes this model offers for the conversation context; it opens at 128K (or its largest size when that is smaller).
const contextList = selectedSize?.multi_file ? deepseekContexts(installed?.contextMax) : native?.catalog?.contexts ?? [8192, 32768, 65536, 131072, 262144];
// The model that is running is shown as running, with Unload: not offered for loading again.
@@ -135,8 +135,10 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
useEffect(() => { if (cardsGpus.length <= 1 && split !== 'auto') setSplit('auto'); }, [cardsGpus.length]);
// Drafting (which drafter guesses ahead): a choice exists only for Qwen3.8-Flash-Next (the qwen and gyro families) on a computer whose engine can run DFlash2; every other model has no dropdown.
const draftingChoices = selectedSize ? draftingOptions(selectedSize.sourceFamily ?? selectedFamily?.family, native?.capabilities?.dflash2) : null;
// Control's plan says the all-cards split engine takes an Auto start on this computer (it runs the MTP draft layer); it needs two or more cards, so one card chosen in the roles is not it.
const splitCards = native?.capabilities?.dflash2Split === true && cardsGpus.length !== 1;
// The drafter this run loads (one per run): the fit and the plan use its graphics memory (DFlash2 0.89 GB, MTP 1.08 GB).
const nowDrafter = draftingChoices ? resolveDrafter(drafting, selectedSize?.sourceFamily ?? selectedFamily?.family, native?.capabilities?.dflash2, native?.capabilities?.dflash2Split) : 'mtp';
const nowDrafter = draftingChoices ? resolveDrafter(drafting, selectedSize?.sourceFamily ?? selectedFamily?.family, native?.capabilities?.dflash2, splitCards) : 'mtp';
// DFlash2 with the converted pack (the selector as Q4_0 rows) takes 175 MiB less graphics memory: the fit says the true number (Control's `selq4`, strata_control/drafting.py vram_gb).
const fitDrafter = nowDrafter === 'dflash2' && native?.capabilities?.dflash2Selq4 === true ? 'dflash2_selq4' : nowDrafter;
const draftingWords: Record<string, string> = { auto: 'Auto (recommended)', mtp: 'MTP', dflash2: 'DFlash2', off: 'Off' };
@@ -244,7 +246,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
<div className="native-model-layout native-model-layout--fit">
<div className="stack">
{tr(native?.instances.length && expert ? <section className="st-card native-running"><h2 className="h4">{tr(native.instances.length === 1 ? 'Loaded model' : 'Loaded models')}</h2>{tr(native.instances.map(i => <div key={i.id}><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.length ? i.gpus.map((g, n) => `GPU ${g + 1}${i.gpus.length > 1 && n === i.gpus.length - 1 ? ' (Main)' : ''}`).join(' + ') : 'Automatic GPU')} · {tr(i.status === 'ready' ? i.phase : i.status)}{tr(native.defaultId === i.id ? ' · Chat default' : '')}</small>{tr(i.error ? <small className="inline-error">{tr(plainFailureText(i.error).message)}</small> : null)}</span>{tr(expert && multiple && native.defaultId !== i.id ? <Btn size="sm" variant="ghost" onClick={() => void loadedAction(`instances/${i.id}/default`)}>{tr("Use in chat")}</Btn> : null)}<Btn size="sm" disabled={pending || !!active} onClick={() => void loadedAction(`instances/${i.id}/unload`)}>{tr("Unload")}</Btn></div>))}{tr(loadedError ? <ErrorNotice message={loadedError} next={tr("Stop or answer the chat that is still using this model, then try again.")}/> : null)}</section> : null)}
<section className="st-card native-choice">{tr(selectedFamily && (!selectedSize || selectedSize.needsNewerEngine) ? <><h2 className="h3">{tr("This model needs a newer engine.")}</h2><p className="small muted">{tr("Update your engine or import a local GGUF for this model.")}</p><Btn onClick={() => { setSettingsSection('Engine'); go('settings'); }}>{tr("Update engine")}</Btn><Btn variant="ghost" onClick={onImport ?? onOtherModels}>{tr("Use a local GGUF\u2026")}</Btn></> : null)}{tr(!selectedFamily ? <><h2 className="h3">{tr("Set up Strata")}</h2><p className="small muted">{tr("Connect your local installation to choose an optimized model.")}</p><Btn variant="primary" onClick={onConnectEngine ?? (() => { setSettingsSection('Engine'); go('settings'); })}>{tr("Connect engine")}</Btn>{tr(native?.error ? <p className="small inline-error">{tr(native.error)}</p> : null)}<Btn variant="ghost" onClick={onOtherModels}>{tr("Other local models")}</Btn></> : null)}
<section className="st-card native-choice">{tr(selectedFamily && (!selectedSize || selectedSize.needsNewerEngine) ? <><h2 className="h3">{tr("This model needs a newer engine.")}</h2><p className="small muted">{tr("Update your engine or import a local GGUF for this model.")}</p><Btn onClick={() => { setSettingsSection('Engine'); go('settings'); }}>{tr("Update engine")}</Btn><Btn variant="ghost" onClick={onImport ?? onOtherModels}>{tr("Use a local GGUF\u2026")}</Btn></> : null)}{tr(!selectedFamily && !listPending ? <><h2 className="h3">{tr("Set up Strata")}</h2><p className="small muted">{tr("Connect your local installation to choose an optimized model.")}</p><Btn variant="primary" onClick={onConnectEngine ?? (() => { setSettingsSection('Engine'); go('settings'); })}>{tr("Connect engine")}</Btn>{tr(native?.error ? <p className="small inline-error">{tr(native.error)}</p> : null)}<Btn variant="ghost" onClick={onOtherModels}>{tr("Other local models")}</Btn></> : null)}
{tr(selectedSize ? <>
<div className="native-choice__head"><span className="native-choice__label">{tr("Version")}</span><span className="native-choice__tools"><NativePresets values={values} onApply={v => { const f = families.find(f => f.family === v.family || f.sizes.some(s => s.sourceFamily === v.family)); if (!f)
return; presetPending.current = true; setFamily(f.family); setSize(f.sizes.find(s => s.model === v.model && (s.sourceFamily === v.family || f.family === v.family))?.id ?? ''); setContext(v.context ?? 32768); setVision(v.vision ?? 'no'); setKv(v.kv ?? 'int8'); setLowRam(v.low_ram ?? 'auto'); }}/><Btn icon="models" size="sm" variant="ghost" onClick={() => setManage(true)}>{tr("Manage models")}</Btn></span></div>
@@ -266,12 +268,12 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
<EngineUpdateCard native={native} busy={!!active}/>
<EngineUpdateDialog open={false} onClose={() => {}}/>
{tr(active ? <NativeProgress job={active} label={jobLabel(active)}/> : null)}
{tr(active && drafterGb ? <p className="small muted native-spill-note"><Icon name="info" size={16}/>{tr(nowDrafter === 'mtp' ? "Choosing MTP downloads its drafter: about {0} GB, once." : "Choosing DFlash2 downloads its drafter: about {0} GB, once.", [drafterGb])}</p> : null)}
{tr(active && drafterGb ? <p className="small muted native-spill-note"><Icon name="info" size={16}/>{tr(nowDrafter === 'mtp' ? (splitCards && drafting === 'auto' ? "Auto uses the all-cards split engine with the MTP draft layer: about {0} GB to download, once." : "Choosing MTP downloads its drafter: about {0} GB, once.") : "Choosing DFlash2 downloads its drafter: about {0} GB, once.", [drafterGb])}</p> : null)}
{tr(job?.state === 'error' ? <ErrorNotice message={job.error ?? job.summary} action={tr("Retry")} onRetry={() => void load()}/> : null)}
{tr(plan?.plan?.disk_short ? <ErrorNotice message={plan.plan.disk_short}/> : null)}
{tr(error ? <ErrorNotice message={error}/> : null)}
{selectedSize && installed?.ready ? <NativeTuneBadge model={selectedSize.id} resourceProfile={resourceProfile} vision={selectedSize.images ? vision : 'no'}/> : null}
<div className="native-model-actions">{tr(!loadedSelected && !installed?.ready && selectedSize?.download_gb && !active ? <span className="native-download-size small muted">{tr("{0} download", [sizeGb(selectedSize.download_gb)])}</span> : null)}{tr(loadedSelected ? <><Btn disabled={pending || !!active} onClick={() => void perform(() => nativeAction(`instances/${loadedSelected.id}/unload`))}>{tr("Unload")}</Btn>{tr(onReady ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <span className="native-ready">{tr("Ready")}</span>)}</> : onReady && native?.instances.some(i => i.status === 'ready') ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <><Btn icon="gauge" disabled={!installed?.ready || !native?.available || !!active || !selectedSize || selectedSize.needsNewerEngine} onClick={() => setTuneOpen(true)}>{tr("Tune model")}</Btn><Btn variant="primary" icon="models" disabled={pending || !native?.available || !!active || !selectedSize || !!selectedSize.needsNewerEngine} onClick={() => void load()}>{tr(pending ? 'Checking…' : installed?.ready ? 'Load model' : 'Download & load')}</Btn></>)}</div>
<div className="native-model-actions">{tr(!loadedSelected && !listPending && !installed?.ready && selectedSize?.download_gb && !active ? <span className="native-download-size small muted">{tr("{0} download", [sizeGb(selectedSize.download_gb)])}</span> : null)}{tr(loadedSelected ? <><Btn disabled={pending || !!active} onClick={() => void perform(() => nativeAction(`instances/${loadedSelected.id}/unload`))}>{tr("Unload")}</Btn>{tr(onReady ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <span className="native-ready">{tr("Ready")}</span>)}</> : onReady && native?.instances.some(i => i.status === 'ready') ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <><Btn icon="gauge" disabled={listPending || !installed?.ready || !native?.available || !!active || !selectedSize || selectedSize.needsNewerEngine} onClick={() => setTuneOpen(true)}>{tr("Tune model")}</Btn><Btn variant="primary" icon="models" disabled={pending || listPending || !native?.available || !!active || !selectedSize || !!selectedSize.needsNewerEngine} onClick={() => void load()}>{tr(pending || listPending ? 'Checking…' : installed?.ready ? 'Load model' : 'Download & load')}</Btn></>)}</div>
</section>
{tr(expert && IMAGE_GENERATION_VISIBLE ? <><details className="native-generation"><summary>{tr("Image generation ")}<span className="muted">{tr("Optional")}</span></summary><RunImageModel library={imageLibrary} disabled={pending || !!active} onChange={() => { }}/></details></> : null)}
{tr(expert ? <section id="native-expert-settings" ref={expertSection} className="st-card native-execution"><h2 className="h4">{tr("Expert settings")}</h2>{tr(tunedNow ? <p className="small muted native-tuned-note">{tr("Showing the values of your tuning '{0}'.", [tunedNow.name])}</p> : null)}<label className="native-toggle"><span><strong>{tr("Run multiple models")}</strong><small>{tr("Loading another model keeps the others loaded.")}</small></span><button className="st-toggle" role="switch" aria-label={tr("Run multiple models")} aria-checked={multiple} onClick={() => setMultiple(v => !v)}/></label>{tr(cardsGpus.length > 1 ? <label className="input-field"><span>{tr("Layer split")}</span><input className="st-input" value={split} placeholder={tr("auto or 16,32")} onChange={e => setSplit(e.target.value)}/></label> : null)}<div className="native-field-grid"><label className="input-field"><span>{tr("Context memory")}</span><Select ariaLabel={tr("Native KV format")} value={kv} options={KV_CHOICES.map(c => ({ value: c.value, label: c.label, title: c.hint }))} onChange={setKv}/>{tr(KV_CHOICES.find(c => c.value === kv)?.hint ? <small className="muted native-kv-hint">{tr(KV_CHOICES.find(c => c.value === kv)!.hint)}</small> : null)}</label><label className="input-field"><span>{tr("Low-RAM mode")}</span><Select ariaLabel={tr("Native low RAM mode")} value={lowRam} options={['auto', 'resident', 'mmap'].map(v => ({ value: v, label: v === 'auto' ? 'Automatic' : v === 'resident' ? 'Keep experts in RAM' : 'Read experts from disk' }))} onChange={setLowRam}/></label></div>{tr(installed ? <><NativeSettingsFile model={installed.model}/><NativeSettings model={installed.model}/></> : <p className="small muted">{tr("More engine parameters become available after preparing this version.")}</p>)}</section> : null)}
+5 -2
View File
@@ -2,6 +2,7 @@ import { tr } from '../lib/i18n';
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { IconBtn } from './ui';
import { measureScale } from '../lib/overlay-place';
/** Anchored, non-modal surface. Focus and dismissal stay with its own trigger. */
export function Popover({ anchor, title, detail, onClose, children, above = false, width = 360 }: {
anchor: HTMLElement | null;
@@ -15,8 +16,10 @@ export function Popover({ anchor, title, detail, onClose, children, above = fals
}) {
const ref = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ left: 12, top: 12 });
const [maxWidth, setMaxWidth] = useState<number | string>('calc(100vw - 24px)');
useLayoutEffect(() => {
const place = () => { const r = anchor?.getBoundingClientRect(); const h = ref.current?.offsetHeight ?? 200; const w = Math.min(width, window.innerWidth - 24); const y = above ? (r?.top ?? h) - h - 10 : (r?.bottom ?? 12) + 8; setPosition({ left: Math.max(12, Math.min(r?.left ?? 12, window.innerWidth - w - 12)), top: Math.max(12, Math.min(y, window.innerHeight - h - 12)) }); };
// The numbers from the anchor are visual pixels and the numbers written on the panel are the page's own: they differ when the page is scaled (#4653).
const place = () => { const scale = measureScale(); const r = anchor?.getBoundingClientRect(); const h = (ref.current?.offsetHeight ?? 200) * scale; const w = Math.min(width * scale, window.innerWidth - 24); const y = above ? (r?.top ?? h) - h - 10 : (r?.bottom ?? 12) + 8; setPosition({ left: Math.max(12, Math.min(r?.left ?? 12, window.innerWidth - w - 12)) / scale, top: Math.max(12, Math.min(y, window.innerHeight - h - 12)) / scale }); setMaxWidth((window.innerWidth - 24) / scale); };
place();
ref.current?.focus({ preventScroll: true });
const outside = (e: PointerEvent) => {
@@ -31,7 +34,7 @@ export function Popover({ anchor, title, detail, onClose, children, above = fals
return () => { resize.disconnect(); document.removeEventListener('pointerdown', outside); window.removeEventListener('resize', place); };
}, [anchor, width, above]);
function close() { onClose(); anchor?.focus({ preventScroll: true }); }
return createPortal(<div ref={ref} role="dialog" aria-label={tr(title)} tabIndex={-1} className="telepop anchored-popover" style={{ ...position, zIndex: anchor?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)', width, maxWidth: 'calc(100vw - 24px)' }} onKeyDown={e => {
return createPortal(<div ref={ref} role="dialog" aria-label={tr(title)} tabIndex={-1} className="telepop anchored-popover" style={{ ...position, zIndex: anchor?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)', width, maxWidth }} onKeyDown={e => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
@@ -0,0 +1,36 @@
/**
* The Markdown preview of the sidebar, bounded (#5180). Same elements as <Markdown>, but:
* - memoised by the text, so a chat that renders for every group of streamed tokens does not rebuild an unchanged file;
* - drawn in slices (lib/preview-render.ts) that are memoised one by one, so a file changed in one place re-renders that slice;
* - only a few slices at a time, the rest added by one waiting step that a newer text or leaving the view cancels;
* - a block too large to parse, and a file above the limit, shown as plain text.
*/
import { memo, useEffect, useMemo, useState } from 'react';
import { Markdown } from './Markdown';
import { SyntaxCode } from './SyntaxCode';
import { previewSlices, previewTier, slicesAfter, slicesAtStart, textRevision } from '../lib/preview-render';
const Slice = memo(function Slice({ text, plain }: { text: string; plain: boolean }) {
return plain ? <SyntaxCode text={text}/> : <Markdown text={text}/>;
});
export const PreviewMarkdown = memo(function PreviewMarkdown({ text }: { text: string }) {
if (previewTier(text) === 'plain')
return <SyntaxCode text={text}/>;
return <Sliced text={text}/>;
});
function Sliced({ text }: { text: string }) {
const slices = useMemo(() => previewSlices(text), [text]);
const revision = useMemo(() => textRevision(text), [text]);
const [shown, setShown] = useState(() => slicesAtStart(0, slices.length));
// one step waiting at most; a new revision or leaving the view clears it, so work for an old text never runs
useEffect(() => {
if (shown >= slices.length)
return;
const timer = setTimeout(() => setShown(n => slicesAfter(n, slices.length)), 24);
return () => clearTimeout(timer);
}, [revision, shown, slices.length]);
const count = Math.min(Math.max(shown, slicesAtStart(0, slices.length)), slices.length);
return <>{slices.slice(0, count).map(s => <Slice key={s.key} text={s.text} plain={s.plain}/>)}</>;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { tr } from '../lib/i18n';
/** What Strata collects and where things go: one compact paragraph (the six sentences read together, translated as one in every language). */
const NOTES = 'Strata sends no usage telemetry. Conversations stay in browser storage. When you choose an API model, that conversation and enabled skill instructions are sent to its provider. The UI request log keeps client, timing and token metadata in memory. Native API monitoring can retain request content in memory when enabled. Connected tools access the resources you configure.';
const NOTES = "Strata sends no usage telemetry. Conversations stay on this computer, in Strata's data folder. When you choose an API model, that conversation and enabled skill instructions are sent to its provider. The UI request log keeps client, timing and token metadata in memory. Native API monitoring can retain request content in memory when enabled. Connected tools access the resources you configure.";
export function PrivacyNotes() {
return <div className="privacy-notes" aria-label={tr("Collection and logs")}>
+1 -1
View File
@@ -88,7 +88,7 @@ export function SettingsUpdates() {
</>;
}
/** "Strata 0.2.10 · Engine 0.1.56": the app's own version first, the engine's beside it (About). */
/** "Strata 0.2.12 · Engine 0.1.58": the app's own version first, the engine's beside it (About). */
export function AboutVersions() {
const native = useNative();
const app = appVersionLabel(useStore(s => s.setup?.app.version), native?.version);
@@ -1,6 +1,7 @@
import { tr } from '../lib/i18n';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { measureScale } from '../lib/overlay-place';
export function VersionMemoryTooltip({ id, anchor, text, onClose, onEnter, onLeave }: {
id: string;
anchor: HTMLElement;
@@ -12,11 +13,12 @@ export function VersionMemoryTooltip({ id, anchor, text, onClose, onEnter, onLea
const ref = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ left: 12, top: 12 });
useLayoutEffect(() => {
const rect = anchor.getBoundingClientRect(), width = Math.min(260, window.innerWidth - 24);
const height = ref.current?.getBoundingClientRect().height ?? 80;
const scale = measureScale();
const rect = anchor.getBoundingClientRect(), width = Math.min(260 * scale, window.innerWidth - 24);
const height = ref.current?.getBoundingClientRect().height ?? 80 * scale;
const left = Math.max(12, Math.min(rect.right - width, window.innerWidth - width - 12));
const top = rect.bottom + height + 20 <= window.innerHeight ? rect.bottom + 8 : Math.max(12, rect.top - height - 8);
setPosition({ left, top });
setPosition({ left: left / scale, top: top / scale });
}, [anchor, text]);
useEffect(() => {
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') {
+37 -22
View File
@@ -7,12 +7,13 @@ import { tr } from '../lib/i18n';
* the same system. Anything added here is a documented extension that only uses
* kit tokens — see src/styles/app.css.
*/
import { createContext, useContext, useCallback, useEffect, useId, useRef, useState, type AriaAttributes, type CSSProperties, type ReactNode } from 'react';
import { createContext, useContext, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type AriaAttributes, type CSSProperties, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { cx, userError } from '../lib/format';
import { plainFailure } from '../lib/plain-errors';
import { dismissActionError, dismissToast, holdToast, releaseToast, useStore } from '../lib/store';
import { isTopSurface, useSurfaceFocus } from '../lib/focus';
import { measureScale, placeAnchored } from '../lib/overlay-place';
/* -------------------------------------------------------------------- icon */
export type IconName = 'pause' | 'play' | 'activity' | 'plus' | 'shield' | 'computer'
// Authored for the shell: see docs/design-system-notes.md.
@@ -248,7 +249,7 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
left: number;
top: number;
width: number;
up: boolean;
maxHeight: number;
} | null>(null);
const [cursor, setCursor] = useState(0);
const [typed, setTyped] = useState('');
@@ -260,34 +261,47 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
if (refocus)
triggerRef.current?.focus();
}, []);
const openMenu = useCallback(() => {
/* Where the list goes, in screen pixels, written in the page's own pixels (lib/overlay-place.ts; the page may be scaled by "Interface size", #4653):
below its button when the whole list fits there, else above, else the side with more room; as tall as the list needs, and no taller than
the room (it then scrolls, using all of it). It used to be capped at 320 px whatever the window, and opened upward by a transform that
sent it off the top of a short window. `measured` is the rendered popup, when there is one. */
const placeList = useCallback((measured: HTMLElement | null) => {
const el = triggerRef.current;
if (!el)
return;
return null;
const scale = measureScale();
const r = el.getBoundingClientRect();
const height = Math.min(320, options.length * (menuSize === 'comfortable' ? 52 : 38) + 12);
const below = window.innerHeight - r.bottom;
// Flip above the trigger when there is more room there than below.
const up = below < height + 12 && r.top > below;
const width = Math.min(menuWidth ?? Math.max(r.width, menuMinWidth ?? (menuSize === 'comfortable' ? 340 : 180)), window.innerWidth - 24);
setPlaced({ left: Math.max(12, Math.min(r.left, window.innerWidth - width - 12)), top: up ? r.top - 6 : r.bottom + 6, width, up });
const width = Math.min((menuWidth ?? Math.max(r.width / scale, menuMinWidth ?? (menuSize === 'comfortable' ? 340 : 180))) * scale, window.innerWidth - 24);
const natural = measured
? measured.scrollHeight * scale + (measured.getBoundingClientRect().height - measured.clientHeight * scale)
: (options.length * (menuSize === 'comfortable' ? 52 : 38) + 12) * scale;
const p = placeAnchored({ anchor: r, size: { width, height: natural }, viewport: { width: window.innerWidth, height: window.innerHeight }, scale, gapBelow: 6, gapAbove: 6 });
return { left: p.left, top: p.top, width: width / scale, maxHeight: p.maxHeight };
}, [options.length, menuSize, menuMinWidth, menuWidth]);
const openMenu = useCallback(() => {
const next = placeList(null);
if (!next)
return;
setPlaced(next);
setCursor(Math.max(0, options.findIndex((o) => o.value === value)));
setOpen(true);
}, [options, value, menuSize, menuMinWidth, menuWidth]);
}, [options, value, placeList]);
// Once the list is on the screen its real height is known: place it again with that.
useLayoutEffect(() => {
if (!open)
return;
const next = placeList(popupRef.current);
if (next)
setPlaced(next);
}, [open, placeList]);
// The popup is fixed-positioned, so it has to follow its trigger.
useEffect(() => {
if (!open)
return undefined;
const reposition = () => {
const el = triggerRef.current;
if (!el)
return;
const r = el.getBoundingClientRect();
const height = popupRef.current?.offsetHeight ?? Math.min(320, options.length * (menuSize === 'comfortable' ? 52 : 38) + 12);
const below = window.innerHeight - r.bottom;
const up = below < height + 12 && r.top > below;
const width = Math.min(menuWidth ?? Math.max(r.width, menuMinWidth ?? (menuSize === 'comfortable' ? 340 : 180)), window.innerWidth - 24);
setPlaced({ left: Math.max(12, Math.min(r.left, window.innerWidth - width - 12)), top: up ? r.top - 6 : r.bottom + 6, width, up });
const next = placeList(popupRef.current);
if (next)
setPlaced(next);
};
const onDocDown = (e: PointerEvent) => {
if (!triggerRef.current?.contains(e.target as Node) && !popupRef.current?.contains(e.target as Node))
@@ -301,7 +315,7 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
window.removeEventListener('resize', reposition);
document.removeEventListener('pointerdown', onDocDown, true);
};
}, [open, options.length, close, menuSize, menuMinWidth, menuWidth]);
}, [open, close, placeList]);
useEffect(() => {
if (open)
document.getElementById(`${listId}-${cursor}`)?.scrollIntoView({ block: 'nearest' });
@@ -368,7 +382,7 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
<Icon name="chevron" size={16}/>
</button>
{tr(open && placed
? createPortal(<div ref={popupRef} id={listId} className={cx('select__pop', menuSize === 'comfortable' && 'select__pop--comfortable', singleLine && 'select__pop--single-line', menuClassName)} role="listbox" aria-label={tr(ariaLabel)} data-up={placed.up || undefined} style={{ left: placed.left, top: placed.top, width: placed.width, zIndex: triggerRef.current?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)' }}>
? createPortal(<div ref={popupRef} id={listId} className={cx('select__pop', menuSize === 'comfortable' && 'select__pop--comfortable', singleLine && 'select__pop--single-line', menuClassName)} role="listbox" aria-label={tr(ariaLabel)} style={{ left: placed.left, top: placed.top, width: placed.width, maxHeight: placed.maxHeight, zIndex: triggerRef.current?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)' }}>
{tr(options.map((o, i) => (<div key={o.value} id={`${listId}-${i}`} role="option" aria-selected={o.value === value} aria-disabled={o.disabled || undefined} tabIndex={-1} className="select__opt" data-active={i === cursor || undefined} onMouseEnter={() => setCursor(i)} onMouseDown={(e) => e.preventDefault()} onClick={() => {
if (o.disabled)
return;
@@ -469,6 +483,7 @@ export function Toasts() {
<div style={{ flex: 1, minWidth: 0 }}>
<div className="st-toast__title">{tr(t.title)}</div>
{tr(t.body ? <div className="st-toast__body">{tr(t.body)}</div> : null)}
{tr(t.action ? <button type="button" className="st-btn st-btn--sm" style={{ marginTop: 8 }} onClick={t.action.run}>{tr(t.action.label)}</button> : null)}
</div>
<button type="button" className="st-btn st-btn--icon st-btn--sm" onClick={() => dismissToast(t.id)} aria-label={tr("Dismiss")}>
<Icon name="chevron" size={15} style={{ transform: 'rotate(90deg)' }}/>
+5 -1
View File
@@ -1,4 +1,8 @@
export interface PreviewArtifact { id: string; kind: 'website' | 'markdown' | 'pdf' | 'image' | 'code' | 'table'; title: string; url?: string; content?: string; demo?: boolean; }
export interface PreviewArtifact { id: string; kind: 'website' | 'markdown' | 'pdf' | 'image' | 'code' | 'table'; title: string; url?: string; content?: string; demo?: boolean;
/** The host's version of the file this was read from (modified time and size), for a preview with an address instead of text. */
version?: string;
/** When this copy was read or made (ms): a message's copy never replaces a later one (lib/preview-merge.ts). */
at?: number; }
export const PREVIEW_EXAMPLES: PreviewArtifact[] = [
{ id:'sample-website', kind:'website', title:'Fieldnotes — website', url:'/examples/fieldnotes.html', demo:true },
{ id:'sample-markdown', kind:'markdown', title:'project-brief.md', url:'/examples/project-brief.md', demo:true },
+169
View File
@@ -0,0 +1,169 @@
import type { Session } from './types';
import {
HOST_FLAG_KEY, PENDING_KEY, TransportError, readBrowserSessions,
type ChatTransport, type HostChatEntry, type StorageLike,
} from './session-persist';
/**
* The page's side of the chat files on the host (server/chat-store.mjs): the HTTP transport, the first read at start (with the move of
* the browser's old chats to the host), the last-moment copy for a page that goes away, and the merge of what another window saved.
*/
const BASE = () => (globalThis as { __STRATA_HOST__?: string }).__STRATA_HOST__ ?? '';
/** Names this window to the host, so the event of its own save is not mistaken for another window's. */
export const WINDOW_ID = `w_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36).slice(-4)}`;
export function createHttpTransport(fetchImpl: typeof fetch = (...args) => fetch(...args)): ChatTransport {
const headers = (extra: Record<string, string> = {}) => ({ 'x-strata-client': 'ui', 'x-strata-window': WINDOW_ID, ...extra });
async function call(method: string, path: string, init: { body?: string; headers?: Record<string, string> } = {}): Promise<Response> {
let res: Response;
try { res = await fetchImpl(`${BASE()}${path}`, { method, headers: headers(init.headers), body: init.body }); }
catch (e) { throw new TransportError(e instanceof Error ? e.message : 'The host did not answer.', 0, 'offline', true); }
if (res.ok) return res;
let code: string | undefined, message = `Request failed (${res.status})`;
try { const data = await res.json() as { error?: string; code?: string }; code = data.code; if (data.error) message = data.error; } catch { /* no body */ }
throw new TransportError(message, res.status, code);
}
return {
async list() { return await (await call('GET', '/api/chats')).json() as { chats: HostChatEntry[]; damaged?: string[] }; },
async get(id) {
try {
const data = await (await call('GET', `/api/chats/${encodeURIComponent(id)}`)).json() as { rev: number; session: Session };
return { rev: Number(data.rev) || 0, session: data.session };
} catch (e) { if (isTransportError(e) && (e as TransportError).status === 404) return null; throw e; }
},
async put(id, text, baseRev) {
const res = await call('PUT', `/api/chats/${encodeURIComponent(id)}`, { body: text, headers: { 'content-type': 'application/json', ...(baseRev !== null ? { 'x-strata-base-rev': String(baseRev) } : {}) } });
const data = await res.json() as { rev: number; conflict?: boolean };
return { rev: Number(data.rev) || 0, conflict: data.conflict === true };
},
async remove(id) { await call('DELETE', `/api/chats/${encodeURIComponent(id)}`); },
async importMany(sessions) {
const res = await call('POST', '/api/chats/import', { body: JSON.stringify({ sessions }), headers: { 'content-type': 'application/json' } });
return await res.json() as { imported: string[]; skipped: string[]; failed: { id: string; error: string; code?: string }[] };
},
};
}
export interface HydrateResult {
/** The chats to show: the host's, newest first, with this page's own newer ones kept. */
sessions: Session[];
/** [id, rev] of every chat read from the host. */
revs: Map<string, number>;
/** The host could not be asked: `sessions` is what the page had, nothing was moved. */
offline: boolean;
/** Chats moved from the browser's storage to the host this time. */
moved: number;
/** Chats the host refused during the move (disk full / permission) or could not be read; they stay in the browser. */
failed: { id: string; error: string; code?: string }[];
/** Chats that came back from the last-moment copy because the host's version was older. */
recovered: string[];
}
/** By name, so it holds across two copies of this module (a bundle per test file). */
const isTransportError = (e: unknown): boolean => e instanceof TransportError || (e as { name?: string } | null)?.name === 'TransportError';
const newer = (a?: string | null, b?: string | null) => String(a ?? '') > String(b ?? '');
/** Read a batch of chats from the host, a few at a time. */
async function readAll(transport: ChatTransport, entries: HostChatEntry[], parallel = 4): Promise<Map<string, { rev: number; session: Session }>> {
const out = new Map<string, { rev: number; session: Session }>();
let next = 0;
const worker = async () => {
for (;;) {
const entry = entries[next++];
if (!entry) return;
const got = await transport.get(entry.id);
if (got) out.set(entry.id, got);
}
};
await Promise.all(Array.from({ length: Math.min(parallel, entries.length) }, worker));
return out;
}
/**
* The first read of the host's chats at start. Before the host has ever been given the browser's chats (no flag), they are handed over
* (the host takes the ones it does not have, in one request) and the flag is set; the browser's copy is left untouched. When the host
* cannot be asked the page keeps what it has and the writer stays off.
*/
export async function hydrateChats(opts: {
transport: ChatTransport;
browser: StorageLike;
/** The chats in memory now (the browser's copy at start, or what this window already holds). */
current: Session[];
/** A chat that must not be replaced from the host (a turn is running in it). */
isLive?: (id: string) => boolean;
/** Whether a chat in memory has changes the host does not have yet. */
isDirty?: (session: Session) => boolean;
}): Promise<HydrateResult> {
const { transport, browser } = opts;
const isLive = opts.isLive ?? (() => false);
const isDirty = opts.isDirty ?? (() => false);
let moved = 0;
const failed: HydrateResult['failed'] = [];
try {
let flagged = false;
try { flagged = browser.getItem(HOST_FLAG_KEY) === '1'; } catch { flagged = false; }
let list = await transport.list();
if (!flagged) {
const old = readBrowserSessions(browser).sessions;
if (old.length) {
const result = await transport.importMany(old);
moved = result.imported.length;
failed.push(...result.failed);
list = await transport.list();
}
// Set only when nothing was refused: a disk-full move is tried again at the next start.
if (!failed.length) { try { browser.setItem(HOST_FLAG_KEY, '1'); } catch { /* the flag is a courtesy; the host skips what it has */ } }
}
const fromHost = await readAll(transport, list.chats);
const revs = new Map([...fromHost].map(([id, v]) => [id, v.rev]));
const mine = new Map(opts.current.map(s => [s.id, s]));
const merged: Session[] = [];
for (const entry of list.chats) {
const remote = fromHost.get(entry.id);
const local = mine.get(entry.id);
if (!remote) { if (local) merged.push(local); continue; }
merged.push(local && (isLive(entry.id) || isDirty(local) || newer(local.updatedAt, remote.session.updatedAt)) ? local : remote.session);
mine.delete(entry.id);
}
// Chats only this page has (made while the host was being read): kept, and saved when the writer starts.
const hostIds = new Set(list.chats.map(c => c.id));
const onlyHere = opts.current.filter(s => !hostIds.has(s.id));
const sessions = [...onlyHere, ...merged];
const recovered = takeLastMomentCopy(browser, sessions, revs);
return { sessions: recovered.sessions, revs, offline: false, moved, failed, recovered: recovered.ids };
} catch (e) {
if (isTransportError(e)) return { sessions: opts.current, revs: new Map(), offline: true, moved, failed, recovered: [] };
throw e;
}
}
/* ---------------------------------------------------------- the last-moment copy */
/** The page is going away with chats the host has not confirmed: keep them (best effort, may not fit) for the next start. */
export function keepLastMomentCopy(storage: StorageLike, sessions: Session[]): boolean {
try {
if (!sessions.length) { storage.removeItem(PENDING_KEY); return true; }
storage.setItem(PENDING_KEY, JSON.stringify(sessions));
return true;
} catch { return false; }
}
/** At start: chats from the last-moment copy that are newer than the host's (or the host lacks) replace the host's version in memory, to be saved. */
export function takeLastMomentCopy(storage: StorageLike, sessions: Session[], revs: Map<string, number>): { sessions: Session[]; ids: string[] } {
let kept: Session[] = [];
try { const raw = storage.getItem(PENDING_KEY); if (raw) { const parsed = JSON.parse(raw) as unknown; if (Array.isArray(parsed)) kept = parsed.filter((s): s is Session => !!s && typeof (s as Session).id === 'string'); } } catch { kept = []; }
if (!kept.length) return { sessions, ids: [] };
const byId = new Map(sessions.map(s => [s.id, s]));
const ids: string[] = [];
for (const copy of kept) {
const have = byId.get(copy.id);
if (!have || newer(copy.updatedAt, have.updatedAt)) { byId.set(copy.id, copy); ids.push(copy.id); }
}
try { storage.removeItem(PENDING_KEY); } catch { /* removed at the next start */ }
if (!ids.length) return { sessions, ids };
const order = sessions.map(s => s.id);
const result = order.map(id => byId.get(id)!).concat([...byId.keys()].filter(id => !order.includes(id)).map(id => byId.get(id)!));
void revs;
return { sessions: result, ids };
}
+4 -3
View File
@@ -14,9 +14,10 @@ export class ApiError extends Error {
}
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
async function request<T>(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method,
signal,
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
@@ -31,7 +32,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
get: <T>(path: string, signal?: AbortSignal) => request<T>('GET', path, undefined, signal),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
@@ -47,7 +48,7 @@ export interface StreamEvent {
export function onEvent(handler: (e: StreamEvent) => void, onStatus?: (s: 'live' | 'offline') => void) {
let source: EventSource;
let seenAt = Date.now();
const names = ['engine', 'monitor', 'models', 'native', 'image-models', 'runtime', 'providers', 'serving', 'download', 'log', 'setup', 'skills', 'task', 'goal', 'timeskip', 'plan', 'queue'];
const names = ['engine', 'monitor', 'models', 'native', 'image-models', 'runtime', 'providers', 'serving', 'download', 'log', 'setup', 'skills', 'task', 'goal', 'timeskip', 'plan', 'queue', 'chats'];
const connect = () => {
source?.close();
seenAt = Date.now();
+8 -8
View File
@@ -145,18 +145,18 @@ export function fileSize(bytes: number): string {
const csvText = (rows: string[][]) => rows.map(r => r.map(c => (/[",\n]/.test(c) ? `"${c.replace(/"/g, '""')}"` : c)).join(',')).join('\n');
interface PreviewAnswer { kind: DeliverableKind; name: string; path: string; size: number; url?: string; text?: string; rows?: string[][]; rawUrl?: string; note?: string }
interface PreviewAnswer { kind: DeliverableKind; name: string; path: string; size: number; mtime?: number; url?: string; text?: string; rows?: string[][]; rawUrl?: string; note?: string }
/** The sidebar preview of a file: the host reads it (only inside the chat's folder) and the page draws it as a website, markdown, table, picture, PDF or code. */
export async function previewOf(sessionId: string, file: { path: string; name: string }): Promise<PreviewArtifact> {
const a = await api.get<PreviewAnswer>(`/api/files/preview?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(file.path)}`);
const id = `file:${a.path}`, title = a.name || file.name;
if (a.kind === 'image' && a.rawUrl) return { id, kind: 'image', title, url: a.rawUrl };
if (a.kind === 'pdf' && a.rawUrl) return { id, kind: 'pdf', title, url: a.rawUrl };
if (a.kind === 'html' && a.text !== undefined) return { id, kind: 'website', title, content: a.text };
if (a.kind === 'markdown' && a.text !== undefined) return { id, kind: 'markdown', title, content: a.text };
if (a.kind === 'csv' && a.rows) return { id, kind: 'table', title, content: csvText(a.rows) };
if (a.text !== undefined) return { id, kind: 'code', title, content: a.text };
const id = `file:${a.path}`, title = a.name || file.name, at = Date.now(), version = a.mtime !== undefined ? `${a.mtime}:${a.size}` : undefined;
if (a.kind === 'image' && a.rawUrl) return { id, kind: 'image', title, url: a.rawUrl, version, at };
if (a.kind === 'pdf' && a.rawUrl) return { id, kind: 'pdf', title, url: a.rawUrl, version, at };
if (a.kind === 'html' && a.text !== undefined) return { id, kind: 'website', title, content: a.text, at };
if (a.kind === 'markdown' && a.text !== undefined) return { id, kind: 'markdown', title, content: a.text, at };
if (a.kind === 'csv' && a.rows) return { id, kind: 'table', title, content: csvText(a.rows), at };
if (a.text !== undefined) return { id, kind: 'code', title, content: a.text, at };
throw new Error(a.note || 'This file has no preview. Open it in its own app.');
}
+12 -2
View File
@@ -115,6 +115,16 @@ import gui_ru from '../locales/gui-ru.json';
import gui_pt from '../locales/gui-pt.json';
import gui_ko from '../locales/gui-ko.json';
import gui_fr from '../locales/gui-fr.json';
/* The notice for chats the host could not write (sav-<lang>.json, scripts/make-chatsave-locales.py). */
import sav_en from '../locales/sav-en.json';
import sav_zh from '../locales/sav-zh.json';
import sav_ja from '../locales/sav-ja.json';
import sav_de from '../locales/sav-de.json';
import sav_es from '../locales/sav-es.json';
import sav_ru from '../locales/sav-ru.json';
import sav_pt from '../locales/sav-pt.json';
import sav_ko from '../locales/sav-ko.json';
import sav_fr from '../locales/sav-fr.json';
import base_zh from '../locales/zh.json';
import base_ja from '../locales/ja.json';
import base_de from '../locales/de.json';
@@ -129,8 +139,8 @@ export type Language=typeof LANGUAGES[number]['value'];
/* The Tune model dialog's strings live in tune-<lang>.json (apps/host/scripts/make-tune-locales.py) so other interface work does not collide with them. */
/* The tool-server, picture and agent-result strings of the tools and images fixes live in tools-<lang>.json for the same reason. */
/* The strings that had no key in the files above (#3479, #3825) and the 0.2.4.3 fixes live in fix-<lang>.json (scripts/make-fix-locales.py). */
const english:Record<string,string>={...baseEnglish,...tune_en,...tools_en,...fix_en,...gh_en,...ui_en,...engine_en,...pick_en,...df2_en,...upd_en,...r3_en,...pic_en,...gui_en};
const [zh,ja,de,es,ru,pt,ko,fr]=[{...base_zh,...tune_zh,...tools_zh,...fix_zh,...gh_zh,...ui_zh,...engine_zh,...pick_zh,...df2_zh,...upd_zh,...r3_zh,...pic_zh,...gui_zh},{...base_ja,...tune_ja,...tools_ja,...fix_ja,...gh_ja,...ui_ja,...engine_ja,...pick_ja,...df2_ja,...upd_ja,...r3_ja,...pic_ja,...gui_ja},{...base_de,...tune_de,...tools_de,...fix_de,...gh_de,...ui_de,...engine_de,...pick_de,...df2_de,...upd_de,...r3_de,...pic_de,...gui_de},{...base_es,...tune_es,...tools_es,...fix_es,...gh_es,...ui_es,...engine_es,...pick_es,...df2_es,...upd_es,...r3_es,...pic_es,...gui_es},{...base_ru,...tune_ru,...tools_ru,...fix_ru,...gh_ru,...ui_ru,...engine_ru,...pick_ru,...df2_ru,...upd_ru,...r3_ru,...pic_ru,...gui_ru},{...base_pt,...tune_pt,...tools_pt,...fix_pt,...gh_pt,...ui_pt,...engine_pt,...pick_pt,...df2_pt,...upd_pt,...r3_pt,...pic_pt,...gui_pt},{...base_ko,...tune_ko,...tools_ko,...fix_ko,...gh_ko,...ui_ko,...engine_ko,...pick_ko,...df2_ko,...upd_ko,...r3_ko,...pic_ko,...gui_ko},{...base_fr,...tune_fr,...tools_fr,...fix_fr,...gh_fr,...ui_fr,...engine_fr,...pick_fr,...df2_fr,...upd_fr,...r3_fr,...pic_fr,...gui_fr}] as Record<string,string>[];
const english:Record<string,string>={...baseEnglish,...tune_en,...tools_en,...fix_en,...gh_en,...ui_en,...engine_en,...pick_en,...df2_en,...upd_en,...r3_en,...pic_en,...gui_en,...sav_en};
const [zh,ja,de,es,ru,pt,ko,fr]=[{...base_zh,...tune_zh,...tools_zh,...fix_zh,...gh_zh,...ui_zh,...engine_zh,...pick_zh,...df2_zh,...upd_zh,...r3_zh,...pic_zh,...gui_zh,...sav_zh},{...base_ja,...tune_ja,...tools_ja,...fix_ja,...gh_ja,...ui_ja,...engine_ja,...pick_ja,...df2_ja,...upd_ja,...r3_ja,...pic_ja,...gui_ja,...sav_ja},{...base_de,...tune_de,...tools_de,...fix_de,...gh_de,...ui_de,...engine_de,...pick_de,...df2_de,...upd_de,...r3_de,...pic_de,...gui_de,...sav_de},{...base_es,...tune_es,...tools_es,...fix_es,...gh_es,...ui_es,...engine_es,...pick_es,...df2_es,...upd_es,...r3_es,...pic_es,...gui_es,...sav_es},{...base_ru,...tune_ru,...tools_ru,...fix_ru,...gh_ru,...ui_ru,...engine_ru,...pick_ru,...df2_ru,...upd_ru,...r3_ru,...pic_ru,...gui_ru,...sav_ru},{...base_pt,...tune_pt,...tools_pt,...fix_pt,...gh_pt,...ui_pt,...engine_pt,...pick_pt,...df2_pt,...upd_pt,...r3_pt,...pic_pt,...gui_pt,...sav_pt},{...base_ko,...tune_ko,...tools_ko,...fix_ko,...gh_ko,...ui_ko,...engine_ko,...pick_ko,...df2_ko,...upd_ko,...r3_ko,...pic_ko,...gui_ko,...sav_ko},{...base_fr,...tune_fr,...tools_fr,...fix_fr,...gh_fr,...ui_fr,...engine_fr,...pick_fr,...df2_fr,...upd_fr,...r3_fr,...pic_fr,...gui_fr,...sav_fr}] as Record<string,string>[];
const catalogs:Record<Language,Record<string,string>>={en:english,zh,ja,de,fr,es,ru,pt,ko};
const entities:Record<string,string>={amp:'&',lt:'<',gt:'>',quot:'"',apos:"'",nbsp:'\u00a0'};
const decode=(text:string)=>text.replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi,(whole,key:string)=>key[0]==='#'?String.fromCodePoint(key[1].toLowerCase()==='x'?parseInt(key.slice(2),16):parseInt(key.slice(1),10)):entities[key]??whole);
+54
View File
@@ -0,0 +1,54 @@
/**
* The Enter that commits an IME conversion must not also send the message (#5071, Japanese input on macOS).
*
* The browsers disagree on the order of the events when Enter commits a conversion:
* Chrome, Edge (WebView2), Firefox: keydown(Enter, keyCode 229, isComposing true) -> compositionend
* Safari (WKWebView): compositionend -> keydown(Enter, keyCode 229, isComposing FALSE)
* WebKitGTK (IBus), as reported: compositionend -> keydown(Enter, keyCode 13, isComposing false)
* `isComposing` alone only covers the first. The rule here covers all three:
* 1. a key event that says it is composing (isComposing) or that is the IME's own (keyCode 229) belongs to the IME;
* 2. the first Enter right after a compositionend, in the same tick (a few milliseconds), is the one that committed;
* 3. every other key is the person's. A second Enter after the conversion is committed sends, exactly once.
* The flag of rule 2 is armed by compositionend, used up by the next key whatever it is, and expires on its own, so it can never leave
* the composer unable to send. Times are the events' own `timeStamp`s (one clock).
*/
/** How long after a compositionend an Enter still counts as the one that committed. The three engines deliver it in the same tick. */
export const IME_COMMIT_WINDOW_MS = 50;
export interface ImeKey {
key: string;
isComposing?: boolean;
keyCode?: number;
}
export interface ImeGuard {
compositionStart(): void;
compositionEnd(now: number): void;
/** True when this keydown belongs to the IME: the composer must neither send nor run a menu or shortcut action for it. */
keyDown(event: ImeKey, now: number): boolean;
/** The textarea lost the focus: a conversion that was cut short never sends a compositionend. */
reset(): void;
}
export function createImeGuard(windowMs = IME_COMMIT_WINDOW_MS): ImeGuard {
let armedAt: number | null = null;
return {
compositionStart() {
armedAt = null;
},
compositionEnd(now) {
armedAt = now;
},
keyDown(event, now) {
const armed = armedAt;
armedAt = null; // the next key of any kind uses the flag up
if (event.isComposing || event.keyCode === 229)
return true;
return armed !== null && event.key === 'Enter' && now - armed >= 0 && now - armed <= windowMs;
},
reset() {
armedAt = null;
},
};
}
+4
View File
@@ -9,6 +9,8 @@ export interface NativeField {id:string;label:string;group:string;/** Not shown
export interface NativeJob {id:string;kind:string;guided?:boolean;model?:string;family?:string;state:'running'|'done'|'error'|'cancelled';startedAt:string;summary:string;error:string|null;details?:string;result:Record<string,any>|null;progress?:Record<string,any>|null;/** An engine update beside a loaded model: which step it is on and the models it concerns (the app words these itself). */step?:'getting-ready'|'stopping'|'stopped';models?:string[]}
export interface NativeInstance {id:string;apiModelId:string;model:string;label:string;aliases?:string[];vision?:boolean;resourceProfile?:'work'|'performance'|null;context?:number;port:number;gpus:number[];status:'loading'|'ready'|'unloading'|'error';phase:string;loadedAt:string;error:string|null;/** One short line when the model was loaded without picture reading although it was on (no internet for the picture files, no room on the disk). */picturesNote?:string;metrics:Record<string,any>|null}
export interface NativeState {
/** The host has not had an answer from Control yet (it is still starting): not "nothing installed". */
checking?: boolean;
/** The picture reader (strata-vision) is installed with the engine, so models marked for pictures can read them here. */
/** Strata Control's view of the engine for this computer (the app): usable false = none found or recognised; `notice` says why in plain words. */
engine?:{usable:boolean;notice?:string|null;wrongMake?:boolean;version?:string|null;/** What the last update check found newer (kept on this PC, read without the network): the Qwen engine, the DeepSeek engine, the app. */updateAvailable?:{engine?:string|null;dsv41?:string|null;app?:string|null}|null};
@@ -65,6 +67,8 @@ export function useNative(){
return value;
}
export const nativeState=()=>state;
/** QAGUI 0213: the engine answered but Control's list of installed models has not arrived yet (the first seconds after a start): a downloaded model must not be offered as a download. */
export const installedListPending=(value:Pick<NativeState,'available'|'installed'|'error'|'checking'>|null|undefined):boolean=>!!value&&(value.checking===true||value.available===true&&!value.installed&&!value.error);
/** Connection-list projection only; memory reservations are not inferred. */
export function nativeRouting(native:NativeState|null,hardware:Hardware|null):RuntimeModels|null{
if(!native?.instances.length)return null;
+120
View File
@@ -0,0 +1,120 @@
/**
* Where an anchored panel (a menu, a popover) goes, in a page that may be scaled (#4653).
*
* "Interface size" in Settings scales the whole page with the CSS `zoom` property on the root. In Chromium (WebView2) and the engines
* that follow the standard, `getBoundingClientRect()` and `window.innerWidth` then report what is on the screen (visual pixels), but the
* `left`, `top`, `width` and `max-height` written on an element are in the element's own zoomed pixels: 100 written is 125 on the screen
* at 125%. A menu placed from the anchor's rectangle and written back unchanged therefore lands (scale - 1) of its distance too far
* right and down, which at the bottom-left of the window (the sidebar's "More" item) is off the screen: the click "does nothing".
*
* Everything here works in visual pixels and divides by the scale only at the end, when the numbers become CSS values.
* `measureScale()` reads the scale from the page itself (a probe element), so it is right whichever way an engine counts.
*/
export interface Box { left: number; top: number; right: number; bottom: number }
export interface Size { width: number; height: number }
export interface Placement {
/** CSS values for `position: fixed` (the element's own pixels). */
left: number;
top: number;
maxWidth: number;
maxHeight: number;
/** True when the panel sits above its anchor. */
above: boolean;
/** Where the panel is on the screen (visual pixels): for checks. */
visual: Box;
}
/** The least room (screen pixels) on one side of the anchor that is worth shortening the panel to. */
const MIN_ROOM = 120;
const finite = (n: number, fallback: number) => (Number.isFinite(n) ? n : fallback);
/** A scale that makes sense: engines and tests may hand over 0, NaN or nonsense. */
export function sane(scale: number): number {
return Number.isFinite(scale) && scale >= 0.25 && scale <= 4 ? scale : 1;
}
export interface PlaceOptions {
anchor: Box;
/** The panel's size on the screen when it has all the room it wants. */
size: Size;
viewport: Size;
/** Visual pixels per CSS pixel of the panel (1.25 at "Interface size" 125%). */
scale: number;
/** Air between the screen edge and the panel. */
margin?: number;
/** Air between the anchor and the panel when it opens below / above. */
gapBelow?: number;
gapAbove?: number;
/** Open to the right of the anchor, top aligned (a tooltip beside a sidebar row), instead of below / above. */
side?: boolean;
}
/**
* Below the anchor when it fits, otherwise above it, otherwise on the side with more room, shortened to fit (the panel scrolls).
* Left aligned with the anchor and kept inside the screen. Works for any viewport and scale.
*/
export function placeAnchored(o: PlaceOptions): Placement {
const scale = sane(o.scale);
const m = o.margin ?? 12;
const vw = Math.max(0, finite(o.viewport.width, 0)), vh = Math.max(0, finite(o.viewport.height, 0));
const maxW = Math.max(0, vw - 2 * m);
const width = Math.min(Math.max(0, finite(o.size.width, 0)), maxW);
let left: number, top: number, above = false, maxH: number;
if (o.side) {
left = Math.min(o.anchor.right + (o.gapBelow ?? 12), Math.max(m, vw - width - m));
maxH = Math.max(0, vh - 2 * m);
const height = Math.min(Math.max(0, finite(o.size.height, 0)), maxH);
top = Math.max(m, Math.min(o.anchor.top, vh - height - m));
}
else {
left = Math.max(m, Math.min(o.anchor.left, vw - width - m));
const gapBelow = o.gapBelow ?? 8, gapAbove = o.gapAbove ?? 6;
const height = Math.max(0, finite(o.size.height, 0));
const roomBelow = vh - (o.anchor.bottom + gapBelow) - m;
const roomAbove = o.anchor.top - gapAbove - m;
above = height > roomBelow && roomAbove > roomBelow;
const room = Math.max(0, above ? roomAbove : roomBelow);
if (room < Math.min(height, MIN_ROOM)) {
// a short window: neither side has room worth using, so the panel covers part of its anchor rather than shrinking to nothing
above = false;
maxH = Math.max(0, vh - 2 * m);
const shown = Math.min(height, maxH);
top = Math.max(m, Math.min(o.anchor.top, vh - shown - m));
}
else {
maxH = room;
const shown = Math.min(height, room);
top = above ? o.anchor.top - gapAbove - shown : o.anchor.bottom + gapBelow;
}
}
const heightShown = Math.min(Math.max(0, finite(o.size.height, 0)), maxH);
return {
left: left / scale,
top: top / scale,
maxWidth: maxW / scale,
maxHeight: maxH / scale,
above,
visual: { left, top, right: left + width, bottom: top + heightShown },
};
}
/**
* Visual pixels per CSS pixel where `host` lives, found by measuring: a probe 100 CSS pixels wide is put in, read and taken out.
* 1 when there is no zoom, 1.25 at 125%, and whatever the engine really does if it counts differently.
*/
export function measureScale(host: HTMLElement = document.body): number {
try {
const probe = document.createElement('div');
probe.style.cssText = 'position:fixed;left:0;top:0;width:100px;height:1px;visibility:hidden;pointer-events:none;';
host.appendChild(probe);
const scale = probe.getBoundingClientRect().width / 100;
probe.remove();
return sane(scale);
}
catch {
return 1;
}
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Which copy of a file the sidebar shows (#5180 follow-up: the preview kept a file's first content after the file changed).
*
* A preview tab can come from two places: the person (or a finished run) opening a file, which reads it from the host now, and the
* message the file was made in, which carries the content it had then. They share one id (`file:<path>`). The rules, as pure functions:
* - opening a file replaces the tab in place when the content differs, and keeps the very same array when it does not;
* - a message's artifact only adds a tab that is missing, or replaces one that is OLDER than the message and different; it never
* overwrites a copy that was read later;
* - both return the array they were given when nothing changed, so no state update, no render and no effect follows: there is no loop to
* start however often they are called (the same artifacts are handed over again whenever a message arrives).
* The panel reloads when `previewVersion` changes, and only then.
*/
import type { PreviewArtifact } from './artifacts';
import { textRevision } from './preview-render';
/** What identifies the content of a preview: the host's version for a file (modified time and size), else a hash of the text, else the address. */
export function previewVersion(a: Pick<PreviewArtifact, 'version' | 'content' | 'url'>): string {
if (a.content !== undefined)
return 'c' + textRevision(a.content);
return a.version ? 'v' + a.version : 'u' + (a.url ?? '');
}
const sameContent = (a: PreviewArtifact, b: PreviewArtifact) => a.kind === b.kind && a.title === b.title && previewVersion(a) === previewVersion(b);
/** A file was opened (read from the host just now): it takes the place of its tab when it differs. */
export function openItem(old: PreviewArtifact[], item: PreviewArtifact): PreviewArtifact[] {
const at = old.findIndex(a => a.id === item.id);
if (at < 0)
return [...old, item];
if (sameContent(old[at], item))
return old;
const next = old.slice();
next[at] = item;
return next;
}
/**
* The artifacts the messages carry, merged into the tabs. `items` are in message order, each stamped with the time of its message (`at`);
* `closed` are the ids the person closed.
*/
export function syncArtifacts(old: PreviewArtifact[], items: PreviewArtifact[], closed: readonly string[] | undefined = []): PreviewArtifact[] {
let next = old;
const edit = () => { if (next === old) next = old.slice(); };
for (const item of items) {
const at = next.findIndex(a => a.id === item.id);
if (closed?.includes(item.id)) {
if (at >= 0) { edit(); next.splice(at, 1); }
continue;
}
if (at < 0) {
edit();
next.push(item);
continue;
}
const have = next[at];
// only a message newer than the copy on show, with different content, replaces it
if (item.at !== undefined && have.at !== undefined && item.at > have.at && !sameContent(have, item)) {
edit();
next[at] = item;
}
}
return next;
}
/** The message artifacts of a session, in order and de-duplicated by id (the last of an id wins), each stamped with its message's time. */
export function messageArtifacts(messages: ReadonlyArray<{ at?: string; artifacts?: PreviewArtifact[] }> | undefined): PreviewArtifact[] {
const byId = new Map<string, PreviewArtifact>();
for (const m of messages ?? []) {
const at = m.at ? Date.parse(m.at) : NaN;
for (const a of m.artifacts ?? [])
byId.set(a.id, Number.isFinite(at) && a.at === undefined ? { ...a, at } : a);
}
return [...byId.values()];
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Work limits for the Markdown preview in the sidebar (#5180).
*
* The preview used to parse and rebuild the whole file on every render of the chat, which is once per group of streamed tokens, and a
* 60 KB file with many tables and code blocks is tens of thousands of React elements each time. The pieces here are pure text work:
* - a revision key of the text, so an unchanged file is the same key however often the chat renders;
* - the file cut into slices at block boundaries, each with its own key, so a change in one place re-renders one slice;
* - limits: how many slices are drawn at once, when a block is shown as plain text, and when the whole file is.
* The components that use them are components/PreviewMarkdown.tsx and components/ArtifactPanel.tsx.
*/
/** Slices of about this many characters; a slice is the unit that is rendered, kept and skipped. */
export const PREVIEW_SLICE_CHARS = 6000;
/** Slices drawn at once when a file opens; the rest follow a few at a time, so the first paint is bounded however long the file is. */
export const PREVIEW_FIRST_SLICES = 2;
/** Slices added by each step after that. At most one step is waiting at any time. */
export const PREVIEW_STEP_SLICES = 2;
/** One block (a table or a fenced code block that cannot be cut) above this is shown as plain text, not parsed. */
export const PREVIEW_BLOCK_LIMIT = 40_000;
/** A file above this is shown as plain text (the Source view); the Read view would be thousands of elements per render. */
export const PREVIEW_RENDER_LIMIT = 200_000;
/** 32-bit FNV-1a over the UTF-16 units, in base 36; with the length it is the revision key of a text. Not a security hash. */
export function textRevision(text: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return `${text.length.toString(36)}-${h.toString(36)}`;
}
export interface PreviewSlice {
/** Stable for the same text at the same place: a slice that did not change keeps its key and is not rendered again. */
key: string;
text: string;
/** Too large to parse: shown as plain text. */
plain: boolean;
}
/** The longest run of lines a `$$` or `\[` block may span; lib/math.ts displayBlock reads at most this many. */
const MATH_LINES = 60;
/**
* Cut Markdown into slices of about `target` characters, only at a blank line that is outside a fenced code block and outside a
* display-math block. Blank lines already end a paragraph, a list and a table in components/Markdown.tsx, so the slices drawn one
* after the other are the same elements as the whole text drawn once.
*/
export function splitPreview(text: string, target = PREVIEW_SLICE_CHARS): PreviewSlice[] {
const lines = text.split(/\r\n|\r|\n/);
const slices: PreviewSlice[] = [];
let start = 0, size = 0, fenced = false, mathEnd = -1;
const emit = (end: number) => {
const body = lines.slice(start, end).join('\n');
if (body.trim()) {
const plain = body.length > PREVIEW_BLOCK_LIMIT;
slices.push({ key: `${slices.length}:${textRevision(body)}`, text: body, plain });
}
start = end;
size = 0;
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
size += line.length + 1;
if (/^\s*```/.test(line)) {
fenced = !fenced;
continue;
}
if (fenced)
continue;
const t = line.trim();
if (i > mathEnd) {
const opens = t.startsWith('$$') ? '$$' : t.startsWith('\\[') ? '\\[' : '';
if (opens && !t.slice(2).includes(opens === '$$' ? '$$' : '\\]'))
mathEnd = i + MATH_LINES;
}
else if (t.includes('$$') || t.includes('\\]'))
mathEnd = -1;
// a blank line outside code and math, once the slice is big enough, ends it (the blank line goes with the slice before)
if (!t && i > mathEnd && size >= target && i + 1 < lines.length)
emit(i + 1);
}
emit(lines.length);
return slices;
}
/** How a file is shown: drawn as Markdown in slices, or as plain text when it is above the limit. */
export function previewTier(text: string): 'markdown' | 'plain' {
return text.length > PREVIEW_RENDER_LIMIT ? 'plain' : 'markdown';
}
/**
* How many slices are drawn after a step. `shown` is the count now; the new text may have fewer or more slices than before. A change
* in the file never takes back slices already on screen (the page would jump), it only adds what is still missing.
*/
export function slicesAfter(shown: number, total: number, step = PREVIEW_STEP_SLICES): number {
return Math.min(total, Math.max(shown, 0) + step);
}
/** The count to draw before any step has run for this text: the ones already shown, or the first few. */
export function slicesAtStart(shown: number, total: number): number {
return Math.min(total, Math.max(shown, PREVIEW_FIRST_SLICES));
}
/**
* Parsing cache: the slices of the last few texts, found by revision. A chat that renders again with the same file asks for the same
* text, and the answer is the same array (same identity), so nothing downstream sees a change. `stats.cuts` counts the real work.
*/
const cache = new Map<string, { text: string; slices: PreviewSlice[] }>();
export const previewStats = { cuts: 0 };
export function previewSlices(text: string): PreviewSlice[] {
const key = textRevision(text);
const known = cache.get(key);
if (known && known.text === text) {
cache.delete(key);
cache.set(key, known);
return known.slices;
}
previewStats.cuts++;
const slices = splitPreview(text);
cache.set(key, { text, slices });
if (cache.size > 4)
cache.delete(cache.keys().next().value as string);
return slices;
}
+65
View File
@@ -0,0 +1,65 @@
/**
* After a crash of the page's renderer (#5079). The desktop shell loads the page again at `/?recovery=<n>`; the page then starts with the
* sidebar previews closed, once, so the file that may have caused the crash is not opened again by itself. The chats are the host's and
* are all still there; only what the sidebar showed is deferred, and only until the person opens a preview themselves.
*/
/** 1..9 from a `recovery=<n>` query, 0 when there is none or it is not a number. */
export function parseRecovery(search: string): number {
const value = new URLSearchParams(search).get('recovery');
if (value === null || !/^[1-9]$/.test(value))
return 0;
return Number(value);
}
/** The query without the recovery parameter (and with its `?` only when something is left). */
export function withoutRecovery(search: string): string {
const params = new URLSearchParams(search);
params.delete('recovery');
const rest = params.toString();
return rest ? `?${rest}` : '';
}
let deferred = false;
let deferredSession: string | null = null;
/**
* Read the shell's recovery mark from the address, remove it (a later reload of the page is an ordinary start), and defer the previews.
* Called once, before the first render. Returns the recovery number, 0 for an ordinary start.
*/
export function startRecovery(): number {
let level = 0;
try {
level = parseRecovery(window.location.search);
if (level) {
const { pathname, search, hash } = window.location;
window.history.replaceState(window.history.state, '', pathname + withoutRecovery(search) + hash);
}
}
catch { /* no address to read: an ordinary start */ }
deferred = level > 0;
deferredSession = null;
return level;
}
/**
* True while the sidebar previews stay closed after a recovery: for the chat that was open when the renderer died, until the person
* opens a preview themselves or goes to another chat. The first chat asked about is that chat.
*/
export function previewsDeferred(sessionId?: string): boolean {
if (!deferred)
return false;
if (sessionId === undefined)
return true;
deferredSession ??= sessionId;
if (deferredSession !== sessionId) {
deferred = false;
return false;
}
return true;
}
/** The person opened a preview themselves: from now on previews open as usual. */
export function endPreviewDeferral(): void {
deferred = false;
}
+240 -63
View File
@@ -1,71 +1,198 @@
import type { Session } from './types';
/**
* Saving the chat list without hurting the page (#3604, #3603).
* Saving the chats: by the app host, as files (owner decision 11-04; #5192, #3604, #3603).
*
* Every streamed token used to stringify every chat and write the lot to localStorage. On a long history that is
* megabytes of string work per token, and the WebView2 renderer grew to gigabytes before it died (the window goes
* white). Three rules fix it, and the stored format is unchanged, so older builds and the flow scripts still read it:
* The chats used to live in the browser's localStorage, which holds about 5 MB for a whole origin. To fit, the writer first cut
* long fields and dropped old messages and chats (#5192), and after that fix a full storage meant chats that could not be saved
* at all. Now the host keeps one file per chat in the profile's data folder (server/chat-store.mjs) and the page only sends the
* chats that changed. The write side keeps the rules of the renderer-memory fix (#3604, #3603):
*
* 1. Coalesce: the newest state wins and at most one write happens per `delayMs`. The caller flushes when a turn ends
* or the page goes away.
* 2. Only the chats that changed are serialised again. A chat is replaced by a new object when it changes (the store
* never mutates), so the previous JSON of every other chat is reused by object identity.
* 3. The stored copy is capped: a huge tool result or an old chat that no longer fits is left out of the saved copy,
* never out of memory. The saved list is a cache, not the source of truth.
* 1. Coalesce: the newest state wins and at most one save happens per `delayMs`. The caller flushes when a turn ends or the
* page goes away.
* 2. Only the chats that changed are serialised again (a chat is replaced by a new object when it changes, the store never
* mutates), so the previous JSON of every other chat is reused by object identity, and only those are sent.
*
* And, new with the files:
* - A chat is sent exactly as it is (JSON.stringify of the chat): no clipping, no dropped messages or chats.
* - A save the host refuses (disk full, no permission) or cannot be asked (the host is not answering) leaves the chat whole in
* memory and marked unsaved; it is tried again at the next change and on a timer, and the failure is reported through
* `onResult` so the caller can say so and offer the unsaved chats (`unsaved()`, `exportSessions`).
* - A chat removed from the list is removed on the host. Only chats this writer has seen are ever removed.
* - The writer does not save before `enable()` (the first read of the host's chats has to land first, so an older copy in the
* page never replaces a newer file).
*
* The browser's old storage is read once for the move to files (`readBrowserSessions`) and is never written or emptied again:
* it stays as a fallback for one release.
*/
/** Longest text kept for one field in the saved copy. */
export const MAX_FIELD_CHARS = 200_000;
/** Longest saved copy of one chat (JSON characters). A longer chat is shortened from its oldest messages. */
export const MAX_SESSION_CHARS = 1_500_000;
/** Longest saved chat list (JSON characters). localStorage allows about 5M; keep a margin for everything else. */
export const MAX_TOTAL_CHARS = 3_500_000;
/** The old whole-list entry of the released versions (0.2.10 and before). */
export const LEGACY_SESSIONS_KEY = 'strata.sessions.v1';
/** The order of the chats in the short-lived per-chat browser layout (this branch before the files). */
export const SESSION_INDEX_KEY = 'strata.sessions.v2';
/** One chat per entry in that layout: this prefix plus the chat id. */
export const SESSION_KEY_PREFIX = 'strata.session.v2.';
/** Set when the browser's chats were handed to the host. After it, the host's files are the only truth. */
export const HOST_FLAG_KEY = 'strata.chats.host.v1';
/** Chats that were not yet on the host when the page went away (a last-moment copy, taken back at the next start). */
export const PENDING_KEY = 'strata.chats.pending.v1';
type Json = unknown;
function clipFields(value: Json, depth = 0): Json {
if (typeof value === 'string') return value.length > MAX_FIELD_CHARS ? `${value.slice(0, MAX_FIELD_CHARS)}\n[shortened in the saved copy]` : value;
if (depth > 6 || value === null || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map(v => clipFields(v, depth + 1));
const out: Record<string, Json> = {};
for (const [k, v] of Object.entries(value as Record<string, Json>)) out[k] = clipFields(v, depth + 1);
return out;
/** The part of `Storage` that is read from. */
export interface StorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
key(index: number): string | null;
readonly length: number;
}
/** The JSON text kept for one chat: whole when it fits, otherwise clipped and then shortened from the oldest message. */
/** The JSON text sent for one chat: the chat exactly as it is. */
export function serializeSession(session: Session): string {
const whole = JSON.stringify(session);
if (whole.length <= MAX_SESSION_CHARS) return whole;
let copy = clipFields(session) as Session;
let text = JSON.stringify(copy);
if (text.length <= MAX_SESSION_CHARS) return text;
let messages = copy.messages ?? [];
// Drop the oldest messages in halves until it fits; always keep the latest two.
while (text.length > MAX_SESSION_CHARS && messages.length > 2) {
messages = messages.slice(Math.max(1, Math.floor(messages.length / 2)));
copy = { ...copy, messages };
text = JSON.stringify(copy);
return JSON.stringify(session);
}
/** The chats as one JSON text, for keeping a copy by hand when they cannot be saved. */
export function exportSessions(sessions: Session[]): string {
return JSON.stringify(sessions);
}
/* ------------------------------------------------------------- the browser's old storage (read only) */
function readIndex(storage: StorageLike): string[] {
try {
const raw = storage.getItem(SESSION_INDEX_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as { ids?: unknown };
return Array.isArray(parsed.ids) ? parsed.ids.filter((id): id is string => typeof id === 'string') : [];
} catch {
return [];
}
return text;
}
/**
* Every chat in the browser's storage, from both layouts: one entry per chat (strata.session.v2.<id>) and the old whole list
* (strata.sessions.v1). Where a chat is in both, the per-chat entry wins. Nothing is written, moved or removed.
*/
export function readBrowserSessions(storage: StorageLike): { sessions: Session[]; damaged: string[] } {
const damaged: string[] = [];
const found = new Map<string, Session>();
try {
const raw = storage.getItem(LEGACY_SESSIONS_KEY);
if (raw) {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
parsed.forEach((entry, n) => {
if (!entry || typeof entry !== 'object') return;
const s = entry as Session;
const id = typeof s.id === 'string' && s.id ? s.id : `legacy_${n}`;
found.set(id, id === s.id ? s : { ...s, id });
});
} else damaged.push(LEGACY_SESSIONS_KEY);
}
} catch { damaged.push(LEGACY_SESSIONS_KEY); }
try {
const keys: string[] = [];
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i);
if (key && key.startsWith(SESSION_KEY_PREFIX)) keys.push(key);
}
for (const key of keys) {
try {
const parsed = JSON.parse(storage.getItem(key) ?? 'null') as Session | null;
if (parsed && typeof parsed === 'object' && typeof parsed.id === 'string') found.set(parsed.id, parsed);
else damaged.push(key);
} catch { damaged.push(key); }
}
} catch { /* storage not readable: nothing to load */ }
const rank = new Map(readIndex(storage).map((id, i) => [id, i]));
const sessions = [...found.values()].sort((a, b) => {
const ra = rank.get(a.id), rb = rank.get(b.id);
if (ra !== undefined && rb !== undefined) return ra - rb;
if (ra !== undefined) return -1;
if (rb !== undefined) return 1;
return String(b.createdAt ?? '') < String(a.createdAt ?? '') ? -1 : 1;
});
return { sessions, damaged };
}
/* ------------------------------------------------------------- the host */
export class TransportError extends Error {
constructor(message: string, readonly status: number, readonly code: string | undefined, readonly network = false) { super(message); this.name = 'TransportError'; }
}
export interface HostChatEntry { id: string; rev: number; createdAt?: string | null; updatedAt?: string | null }
/** What the page asks of the host about chats (chat-sync.ts makes the real one; a test passes a fake). */
export interface ChatTransport {
list(): Promise<{ chats: HostChatEntry[]; damaged?: string[] }>;
get(id: string): Promise<{ rev: number; session: Session } | null>;
put(id: string, text: string, baseRev: number | null): Promise<{ rev: number; conflict: boolean }>;
remove(id: string): Promise<void>;
importMany(sessions: Session[]): Promise<{ imported: string[]; skipped: string[]; failed: { id: string; error: string; code?: string }[] }>;
}
export type FailureReason = 'disk-full' | 'permission' | 'offline' | 'other';
export interface SaveFailure {
id: string;
reason: FailureReason;
message: string;
}
export interface SaveResult {
ok: boolean;
failed: SaveFailure[];
/** How many chats were saved by this pass. */
written: number;
deleted: number;
}
export function failureReason(error: unknown): FailureReason {
const e = error as Partial<TransportError> | null;
if (e && e.name === 'TransportError') {
if (e.network) return 'offline';
if (e.code === 'disk-full' || e.status === 507) return 'disk-full';
if (e.code === 'permission' || e.status === 403) return 'permission';
}
return 'other';
}
export interface SessionWriterOptions {
/** Where the list is saved (the storage write). Receives the finished JSON text. */
write: (json: string) => void;
transport: ChatTransport;
delayMs?: number;
maxTotalChars?: number;
/** After a pass with failures the unsaved chats are tried again after this long. */
retryMs?: number;
/** Counts a serialisation of one chat; for tests. */
onSerialize?: (session: Session) => void;
/** Called after every pass, with what was saved and which chats the host did not take. */
onResult?: (result: SaveResult) => void;
/** The chats the host already has: [id, rev]. Only these (and chats this writer saved) are ever removed from it. */
known?: Iterable<[string, number]>;
/** Start saving at once. The page leaves it off until the host's chats have been read. */
enabled?: boolean;
}
export function createSessionWriter(options: SessionWriterOptions) {
const transport = options.transport;
const delayMs = options.delayMs ?? 1_000;
const maxTotal = options.maxTotalChars ?? MAX_TOTAL_CHARS;
const retryMs = options.retryMs ?? 8_000;
let enabled = options.enabled ?? true;
let timer: ReturnType<typeof setTimeout> | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let pending: Session[] | null = null;
let lastSessions: Session[] = [];
let running: Promise<SaveResult> | null = null;
let rerun = false;
let lastResult: SaveResult = { ok: true, failed: [], written: 0, deleted: 0 };
let failures = 0;
// Chat object -> its JSON. WeakMap: a replaced chat is forgotten with its object.
const cache = new WeakMap<Session, string>();
// The JSON of each chat as the host last confirmed it (or as it was read from the host).
const sent = new Map<string, string>();
const revs = new Map<string, number>(options.known ?? []);
const known = new Set<string>(revs.keys());
const failedIds = new Set<string>();
const textOf = (s: Session): string => {
let text = cache.get(s);
@@ -77,34 +204,84 @@ export function createSessionWriter(options: SessionWriterOptions) {
return text;
};
const flush = () => {
if (timer !== null) { clearTimeout(timer); timer = null; }
async function pass(): Promise<SaveResult> {
const sessions = pending;
pending = null;
if (!sessions) return;
const parts = sessions.map(s => ({ s, text: textOf(s) }));
let total = parts.reduce((n, p) => n + p.text.length + 1, 2);
if (total > maxTotal) {
// Leave the oldest unpinned chats out of the saved copy until the rest fits. The newest chat is always kept.
const order = parts
.map((p, i) => ({ i, p }))
.sort((a, b) => Number(!!a.p.s.pinned) - Number(!!b.p.s.pinned) || (a.p.s.updatedAt < b.p.s.updatedAt ? -1 : 1));
const drop = new Set<number>();
for (const { i, p } of order) {
if (total <= maxTotal || drop.size >= parts.length - 1) break;
drop.add(i);
total -= p.text.length + 1;
}
options.write(`[${parts.filter((_, i) => !drop.has(i)).map(p => p.text).join(',')}]`);
return;
if (!sessions) return lastResult;
lastSessions = sessions;
const ids = new Set(sessions.map(s => s.id));
const failed: SaveFailure[] = [];
let written = 0, deleted = 0;
// Removed chats first.
for (const id of [...known]) {
if (ids.has(id)) continue;
try { await transport.remove(id); known.delete(id); sent.delete(id); revs.delete(id); failedIds.delete(id); deleted++; }
catch (error) { failed.push({ id, reason: failureReason(error), message: error instanceof Error ? error.message : String(error) }); }
}
options.write(`[${parts.map(p => p.text).join(',')}]`);
for (const s of sessions) {
const text = textOf(s);
if (sent.get(s.id) === text) { failedIds.delete(s.id); continue; }
try {
const saved = await transport.put(s.id, text, revs.get(s.id) ?? null);
revs.set(s.id, saved.rev); known.add(s.id); sent.set(s.id, text); failedIds.delete(s.id);
written++;
} catch (error) {
// The host keeps the last good file of this chat; the chat stays whole in memory and is tried again.
failedIds.add(s.id);
failed.push({ id: s.id, reason: failureReason(error), message: error instanceof Error ? error.message : String(error) });
}
}
lastResult = { ok: failedIds.size === 0 && failed.length === 0, failed, written, deleted };
if (retryTimer !== null) { clearTimeout(retryTimer); retryTimer = null; }
if (!lastResult.ok) {
// Tried again, a little later each time (up to a minute); the timer never keeps a closing page or process alive.
failures = Math.min(failures + 1, 6);
retryTimer = setTimeout(() => { retryTimer = null; if (pending === null) pending = lastSessions; void flush(); }, Math.min(retryMs * 2 ** (failures - 1), 60_000));
(retryTimer as unknown as { unref?: () => void }).unref?.();
} else failures = 0;
options.onResult?.(lastResult);
return lastResult;
}
const flush = (): Promise<SaveResult> => {
if (timer !== null) { clearTimeout(timer); timer = null; }
if (!enabled) return Promise.resolve(lastResult);
// Nothing new, but a chat the host refused earlier: a flush (a turn ended, the page is going away) tries it again.
if (pending === null && failedIds.size) pending = lastSessions;
if (running) { rerun = true; return running; }
running = (async () => {
let result = lastResult;
do { rerun = false; result = await pass(); } while (rerun || pending !== null);
return result;
})().finally(() => { running = null; });
return running;
};
const schedule = (sessions: Session[]) => {
pending = sessions;
if (timer === null) timer = setTimeout(flush, delayMs);
if (enabled && timer === null) timer = setTimeout(() => { timer = null; void flush(); }, delayMs);
};
return { schedule, flush, hasPending: () => pending !== null };
return {
schedule,
flush,
hasPending: () => pending !== null || running !== null,
/** Start saving (the host's chats have been read). Whatever was scheduled meanwhile is saved now. */
enable() { enabled = true; if (pending !== null) void flush(); },
/** The chats whose newest state is not on the host. */
unsaved: (): Session[] => lastSessions.filter(s => failedIds.has(s.id)),
/** A chat that was read from the host (or arrived from another window): its JSON and revision are what the host has. */
adopt(session: Session, rev: number | null) { known.add(session.id); if (rev === null) revs.delete(session.id); else revs.set(session.id, rev); sent.set(session.id, serializeSession(session)); failedIds.delete(session.id); },
/** A chat the host no longer has (removed from another window). */
forget(id: string) { known.delete(id); revs.delete(id); sent.delete(id); failedIds.delete(id); },
rev: (id: string) => revs.get(id) ?? 0,
/** True when the page holds a newer state of this chat than the host has (scheduled, in flight or refused). */
dirty(session: Session) { return sent.get(session.id) !== textOf(session); },
/** The chats not confirmed by the host right now: what to keep if the page goes away this instant. */
notOnHost(): Session[] {
const list = pending ?? lastSessions;
return list.filter(s => sent.get(s.id) !== textOf(s));
},
};
}
+278 -68
View File
@@ -1,4 +1,5 @@
import { workspaceIdForSession } from './session-workspace';
import { shortTitle } from './title-text';
import { DEFAULT_CONTENT_FS, DEFAULT_UI_SCALE, SIZE_DEFAULTS_VERSION, savedSizes } from './default-sizes';
import { detectLanguage } from './language-detect';
import { validWhileBusy, type WhileBusy } from './while-busy';
@@ -26,7 +27,9 @@ import { noModelNotice } from './no-model';
import { taskHandoverMessage, taskHandoverTitle, chatHandoverMessage, chatHandoverTitle } from './task-handover';
import type { Task } from './tasks';
import { postWithFolderFallback } from './folder-pick';
import { createSessionWriter } from './session-persist';
import { createSessionWriter, exportSessions, readBrowserSessions, HOST_FLAG_KEY, PENDING_KEY, type SaveResult, type StorageLike } from './session-persist';
import { createHttpTransport, hydrateChats, keepLastMomentCopy, WINDOW_ID } from './chat-sync';
import { copyText } from './clipboard';
import { setTranslationLanguage, tr, validLanguage, type Language } from './i18n';
/**
* Strata app store.
@@ -36,7 +39,7 @@ import { setTranslationLanguage, tr, validLanguage, type Language } from './i18n
* arrive on the event stream. No state library, no dependencies.
*/
import { useSyncExternalStore } from 'react';
import { api, onEvent, streamPost, type StreamEvent } from './client';
import { api, ApiError, onEvent, streamPost, type StreamEvent } from './client';
import type {
Workspace,
ChatMessage,
@@ -67,6 +70,8 @@ export interface Toast {
title: string;
body?: string;
leaving?: boolean;
/** A button under the text. A toast with one stays until it is dismissed. */
action?: { label: string; run: () => void };
}
export interface AppState {
@@ -141,9 +146,12 @@ export interface AppState {
* with should not. */
actionError: { message: string; label: string; retry: () => void } | null;
busy: Record<string, boolean>;
/** Set while the host cannot write some chats (disk full, no permission); null when every chat is saved (#5192).
* The chats stay whole in memory and are tried again at every save; `unsavedSessionsJson()` gives them as text.
* A host that does not answer is not this: those saves are simply retried. */
sessionSave: { failedIds: string[]; reason: 'disk-full' | 'permission' | 'other' } | null;
}
const SESSION_KEY = 'strata.sessions.v1';
const PREFS_KEY = 'strata.prefs.v2';
/** What the model has been allowed to read, kept across restarts. */
const TOUCHED_KEY = 'strata.touched.v1';
@@ -202,21 +210,37 @@ function uid(prefix = 'id'): string {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36).slice(-4)}`;
}
/** localStorage, looked up at each call (it may not exist when this file is loaded outside a browser). */
const browserStorage: StorageLike = {
getItem: key => localStorage.getItem(key),
setItem: (key, value) => localStorage.setItem(key, value),
removeItem: key => localStorage.removeItem(key),
key: index => localStorage.key(index),
get length() { return localStorage.length; },
};
/** A chat as the page works with it: a reply that was streaming when the page went away is marked interrupted, not left running. */
function fixSession(session: Session): Session {
return {
...session,
...(session.showcase && !session.showcaseAgentStates ? {agents:SHOWCASE_AGENTS,showcaseAgentStates:true}:{}),
...(!session.showcase && session.agents ? { agents: settleAgents(session.agents) } : {}),
messages: (session.messages ?? []).map(message => message.streaming ? {
...message,
streaming: false,
error: message.error ?? 'Response interrupted. Ask again to retry; the partial answer is kept.',
} : message),
};
}
/**
* The chats shown before the host has answered. Once the browser's chats were handed to the host (the flag) the host's files are the only
* truth and the page starts empty until it has read them (the boot screen is up until then); before that, and when the host cannot be
* asked, the browser's old copy is shown. It is only ever read.
*/
function loadSessions(): Session[] {
try {
const raw = localStorage.getItem(SESSION_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as Session[];
return Array.isArray(parsed) ? parsed.map(session => ({
...session,
...(session.showcase && !session.showcaseAgentStates ? {agents:SHOWCASE_AGENTS,showcaseAgentStates:true}:{}),
...(!session.showcase && session.agents ? { agents: settleAgents(session.agents) } : {}),
messages: session.messages.map(message => message.streaming ? {
...message,
streaming: false,
error: message.error ?? 'Response interrupted. Ask again to retry; the partial answer is kept.',
} : message),
})) : [];
if (browserStorage.getItem(HOST_FLAG_KEY) === '1') return [];
return readBrowserSessions(browserStorage).sessions.map(fixSession);
} catch {
return [];
}
@@ -308,6 +332,7 @@ let state: AppState = {
activeWorkspace: null,
setupOpen: false,
sessions: loadSessions(),
sessionSave: null,
activeSession: null,
streaming: false,
liveChats: 0,
@@ -417,11 +442,11 @@ export function useStore<T>(selector: (s: AppState) => T): T {
const toastTimers = new Map<string, ReturnType<typeof setTimeout>>();
const TOAST_MS = 5200;
export function pushToast(tone: Toast['tone'], title: string, body?: string) {
export function pushToast(tone: Toast['tone'], title: string, body?: string, action?: Toast['action']): string {
const id = uid('toast');
// Cap the stack: past three, the oldest collapses rather than pushing the
// rest off-screen. A toast nobody can read is not a notification.
const next = [...state.toasts, { id, tone, title, body }];
const next = [...state.toasts, { id, tone, title, body, ...(action ? { action } : {}) }];
const overflow = next.length > 3 ? next.slice(0, next.length - 3) : [];
for (const gone of overflow) {
const t = toastTimers.get(gone.id);
@@ -430,7 +455,8 @@ export function pushToast(tone: Toast['tone'], title: string, body?: string) {
}
const kept = overflow.length ? next.slice(-3) : next;
set({ toasts: kept });
toastTimers.set(id, setTimeout(() => dismissToast(id), TOAST_MS));
if (!action) toastTimers.set(id, setTimeout(() => dismissToast(id), TOAST_MS));
return id;
}
/**
@@ -838,6 +864,10 @@ function applyEvent(e: StreamEvent) {
else if (t.status === 'done') { pushToast('success', 'Task finished', String(t.title ?? '')); workFinished(state.doneSound, Infinity); }
break;
}
case 'chats':
// Another window saved or removed a chat (this window's own saves carry its name and are ignored).
if (typeof data.id === 'string' && data.from !== WINDOW_ID) void applyChatChange(data.id, typeof data.rev === 'number' ? data.rev : null, data.deleted === true);
break;
case 'agent':
if (typeof data.sessionId === 'string') patchSession(data.sessionId, s => ({ ...s, agents: updateAgent(s.agents ?? [], data.agent) }));
break;
@@ -846,40 +876,73 @@ function applyEvent(e: StreamEvent) {
}
}
/** QAGUI 0213: how long one start-up GET may take, how often it is tried, and the pause between tries (a test may shorten them). */
export const bootstrapTiming = { timeoutMs: 8000, tries: 3, backoffMs: 400 };
/** One GET with a time limit, tried again after a timeout, a lost connection or a host error. A refusal (4xx) is final. */
export async function getWithRetry<T>(path: string): Promise<T> {
const { timeoutMs, tries, backoffMs } = bootstrapTiming;
let last: unknown;
for (let attempt = 0; attempt < tries; attempt++) {
if (attempt) await new Promise(r => setTimeout(r, backoffMs * 2 ** (attempt - 1)));
const abort = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
api.get<T>(path, abort.signal),
new Promise<never>((_, reject) => { timer = setTimeout(() => { abort.abort(); reject(new Error(`No answer from ${path}`)); }, timeoutMs); }),
]);
} catch (err) {
last = err;
if (err instanceof ApiError && err.status >= 400 && err.status < 500) throw err;
} finally { clearTimeout(timer); }
}
throw last;
}
let closeEvents: (() => void) | null = null;
export async function bootstrap() {
applyPrefs();
try {
const [setup, hardware, library, engine, serving, monitor, skills, providers, imageModels, runtime] = await Promise.all([
api.get<SetupState>('/api/setup'),
api.get<Hardware>('/api/hardware'),
api.get<ModelLibrary>('/api/models'),
api.get<EngineConfig>('/api/engine'),
api.get<ServingState>('/api/serving'),
api.get<MonitorState>('/api/monitor'),
api.get<Skill[]>('/api/skills'),
api.get<ModelProviders>('/api/model-providers'),
api.get<ImageModelLibrary>('/api/image-models'),
api.get<RuntimeModels>('/api/runtime'),
let chatsTimer: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
loadChatsFromHost(),
new Promise<never>((_, reject) => { chatsTimer = setTimeout(() => reject(new Error('The chats did not load in time')), bootstrapTiming.timeoutMs); }),
]).catch(() => { sessionWriter.enable(); }).finally(() => clearTimeout(chatsTimer));
// Only what the first screen cannot do without holds up 'live'; the rest is read at the same time and fills in when it arrives.
const soft = <T,>(path: string) => getWithRetry<T>(path).catch(() => null);
const late = {
hardware: soft<Hardware>('/api/hardware'),
monitor: soft<MonitorState>('/api/monitor'),
skills: soft<Skill[]>('/api/skills'),
providers: soft<ModelProviders>('/api/model-providers'),
imageModels: soft<ImageModelLibrary>('/api/image-models'),
runtime: soft<RuntimeModels>('/api/runtime'),
};
const [setup, library, engine, serving, workspaceState] = await Promise.all([
getWithRetry<SetupState>('/api/setup'),
getWithRetry<ModelLibrary>('/api/models'),
getWithRetry<EngineConfig>('/api/engine'),
getWithRetry<ServingState>('/api/serving'),
getWithRetry<{ workspaces: Workspace[] }>('/api/workspaces'),
]);
const workspaceState = await api.get<{ workspaces: Workspace[] }>('/api/workspaces');
set({
ready: true,
setup,
hardware,
library: libraryResponse(library),
imageModels,
runtime,
engine,
serving: newestServingState(state.serving, serving),
monitor,
skills,
workspacePath: workspaceState.workspaces[0]?.path ?? '',
workspaces: workspaceState.workspaces,
activeWorkspace: workspaceState.workspaces.some(w => w.id === state.activeWorkspace) ? state.activeWorkspace : workspaceIdForSession(workspaceState.workspaces, state.sessions.find(s => s.id === state.activeSession)) ?? workspaceState.workspaces[0]?.id ?? null,
connection: 'live',
});
applyProviders(providers);
void late.hardware.then(hardware => { if (hardware) set({ hardware }); });
void late.monitor.then(monitor => { if (monitor) set({ monitor }); });
void late.skills.then(skills => { if (skills) set({ skills }); });
void late.providers.then(providers => { if (providers) applyProviders(providers); });
void late.imageModels.then(imageModels => { if (imageModels) set({ imageModels }); });
void late.runtime.then(runtime => { if (runtime) set({ runtime }); });
if (!shellAnnounced) { shellAnnounced = true; void announcePortNotice(); rememberShellLanguage(state.language); }
closeEvents?.();
let reconnect = false;
@@ -1165,33 +1228,125 @@ export async function scopeSkill(id: string, workspaceId: string | null) {
/* ---------------------------------------------------------------- sessions */
const chatTransport = createHttpTransport();
let saveToast: string | null = null;
const sessionWriter = createSessionWriter({
write: (json) => {
try {
localStorage.setItem(SESSION_KEY, json);
} catch {
/* quota — sessions are a cache, not the source of truth */
transport: chatTransport,
enabled: false, // on once the host's chats have been read (loadChatsFromHost), so an older copy here never replaces a newer file
// Nothing is trimmed to fit (#5192). A chat the host cannot write keeps its last file and stays whole here; it is reported and
// tried again. A host that does not answer is retried quietly: that is the app being down, not a full disk.
onResult: (result: SaveResult) => {
if (result.ok) {
try { browserStorage.removeItem(PENDING_KEY); } catch { /* removed at the next start */ }
if (state.sessionSave) set({ sessionSave: null });
if (saveToast) { dismissToast(saveToast); saveToast = null; }
return;
}
const real = result.failed.filter(f => f.reason !== 'offline');
if (!real.length) return;
const failedIds = sessionWriter.unsaved().map(s => s.id);
const reason = real[0].reason === 'offline' ? 'other' : real[0].reason;
const was = state.sessionSave;
if (!was || was.reason !== reason || was.failedIds.join() !== failedIds.join()) set({ sessionSave: { failedIds, reason } });
if (!was && !saveToast) {
saveToast = pushToast('error', 'Some chats could not be saved', tr("Strata's chat storage is full, so the newest changes to {0} chat(s) are only in this window. Delete old chats to make room; they then save by themselves.", [failedIds.length]), {
label: 'Copy unsaved chats',
run: () => { const text = unsavedSessionsJson(); if (text) void copyText(text, '', { toast: (tone, title, body) => { pushToast(tone, title, body || undefined); } }); },
});
}
},
});
/** The chats the host could not write, as one JSON text (to keep a copy by hand), or null when all are saved. */
export function unsavedSessionsJson(): string | null {
const unsaved = sessionWriter.unsaved();
return unsaved.length ? exportSessions(unsaved) : null;
}
/**
* Save the chat list. A one-off change (new, rename, delete, pin) is written now; `soon` is for the streaming
* Save the chat list. A one-off change (new, rename, delete, pin) is sent now; `soon` is for the streaming
* path, where a write per token grew the renderer to gigabytes (#3604, #3603): those are coalesced to one a second.
*/
function persistSessions(sessions: Session[], soon = false) {
sessionWriter.schedule(sessions);
if (!soon) sessionWriter.flush();
if (!soon) void sessionWriter.flush();
}
/** Write the newest chat list now: a turn ended, or the page is going away. */
export function flushSessions() {
sessionWriter.flush();
/** Send the newest chat list now: a turn ended, or the page is going away. */
export function flushSessions(): Promise<SaveResult> {
return sessionWriter.flush();
}
/** The page is going away: send what can still be sent, and keep a last-moment copy of what the host has not confirmed (taken back at the next start). */
function leavingPage() {
void sessionWriter.flush();
const unconfirmed = sessionWriter.notOnHost();
if (unconfirmed.length) keepLastMomentCopy(browserStorage, unconfirmed);
}
if (typeof window !== 'undefined' && typeof document !== 'undefined' && window.addEventListener) {
window.addEventListener('pagehide', flushSessions);
document.addEventListener?.('visibilitychange', () => { if (document.visibilityState === 'hidden') flushSessions(); });
window.addEventListener('pagehide', leavingPage);
document.addEventListener?.('visibilitychange', () => { if (document.visibilityState === 'hidden') leavingPage(); });
}
let chatsRead = false;
/**
* The first read of the chats at start, and again after the host came back: the host's files become the chats shown (the browser's
* old chats are handed over first, once), then saving starts. A host that does not answer leaves the page as it is.
*/
export async function loadChatsFromHost() {
if (chatsRead) { await syncChatsFromHost(); return; }
const result = await hydrateChats({
transport: chatTransport, browser: browserStorage, current: state.sessions,
isLive: id => liveTurns.has(id),
});
if (result.offline) {
// Show the browser's old copy so the person can still read; saves are retried until the host answers.
if (!state.sessions.length) { try { set({ sessions: readBrowserSessions(browserStorage).sessions.map(fixSession) }); } catch { /* none */ } }
for (const s of state.sessions) sessionWriter.adopt(s, null);
sessionWriter.enable();
return;
}
chatsRead = true;
const sessions = result.sessions.map(fixSession);
for (const s of sessions) { const rev = result.revs.get(s.id); if (rev !== undefined && !result.recovered.includes(s.id)) sessionWriter.adopt(s, rev); }
set({ sessions });
// Whatever the host does not have yet (a chat it refused during the move, a last-moment copy) is sent now; a refusal raises the notice.
sessionWriter.schedule(sessions);
sessionWriter.enable();
void resumeSessions();
}
/** One chat that another window saved or removed: taken over unless this window is working in it. */
export async function applyChatChange(id: string, rev: number | null, deleted: boolean) {
const here = state.sessions.find(s => s.id === id);
if (deleted) {
if (here && !liveTurns.has(id) && !sessionWriter.dirty(here)) {
sessionWriter.forget(id);
set({ sessions: state.sessions.filter(s => s.id !== id), ...(state.activeSession === id ? { activeSession: null } : {}) });
}
return;
}
if (rev !== null && sessionWriter.rev(id) >= rev) return;
if (here && (liveTurns.has(id) || sessionWriter.dirty(here))) return; // this window's own work wins; its next save keeps the displaced version on the host
const got = await chatTransport.get(id).catch(() => null);
if (!got) return;
const fresh = state.sessions.find(s => s.id === id);
if (fresh && (liveTurns.has(id) || sessionWriter.dirty(fresh))) return;
sessionWriter.adopt(got.session, got.rev);
const next = fresh ? state.sessions.map(s => (s.id === id ? got.session : s)) : [...state.sessions, got.session].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
set({ sessions: next });
}
/** After the host was away: take over what other windows saved or removed meanwhile. */
async function syncChatsFromHost() {
const list = await chatTransport.list().catch(() => null);
if (!list) return;
const onHost = new Set(list.chats.map(c => c.id));
for (const entry of list.chats) await applyChatChange(entry.id, entry.rev, false);
for (const s of state.sessions) if (!onHost.has(s.id) && sessionWriter.rev(s.id) > 0) await applyChatChange(s.id, null, true);
sessionWriter.enable();
}
export function newSession(): Session {
@@ -1397,7 +1552,7 @@ async function startTimeSkipReal(prompt: string) {
const at = new Date().toISOString();
patchSession(sessionId, s => ({ ...s,
defaultTitle: false,
title: s.messages.length ? s.title : prompt.length > 48 ? prompt.slice(0, 47).trimEnd() + '…' : prompt, updatedAt: at,
title: s.messages.length ? s.title : shortTitle(prompt), updatedAt: at,
// The comparison sidebar opens only when there is an update to compare (when the job ends with changed files), never while it works.
timeSkips: [...(s.timeSkips ?? []), run],
messages: [...s.messages,
@@ -1490,7 +1645,7 @@ function startTimeSkipDemo(prompt: string) {
const at = new Date().toISOString();
patchSession(sessionId, s => ({ ...s,
defaultTitle: false,
title: s.messages.length ? s.title : prompt.length>48?prompt.slice(0,47).trimEnd()+'…':prompt, updatedAt: at,
title: s.messages.length ? s.title : shortTitle(prompt), updatedAt: at,
timeSkips: [...(s.timeSkips ?? []), run], previewOpen: timeSkipTabId(run.id),
messages: [...s.messages,
{ id: uid('m'), role: 'user', at, content: `/time-skip ${prompt}`, timeSkipId: run.id },
@@ -1695,7 +1850,7 @@ const lastAnswer = (sessionId: string) => [...(state.sessions.find(s => s.id ===
const loopScheduler = new LoopScheduler({
async send(sessionId, { text, shown }) {
const before = lastAnswer(sessionId)?.id;
await sendMessage(text, { fromLoop: true, shown, title: shown.length > 48 ? shown.slice(0, 47).trimEnd() + '…' : shown });
await sendMessage(text, { fromLoop: true, shown, title: shortTitle(shown) });
const answer = lastAnswer(sessionId);
if (!answer || answer.id === before || answer.streaming) return { answered: false, text: '', failed: false };
return { answered: true, text: answer.content ?? '', failed: !!answer.error };
@@ -1779,8 +1934,10 @@ export async function approvePlan(sessionId: string, planId: string) {
/** "Start as goal" (and Resume on a paused plan): the host streams the run like any chat turn. */
export async function startPlanGoal(planId: string) { await sendMessage('', { planId }); }
export async function sendMessage(text: string, options: { fromLoop?: boolean; goalAction?: 'resume'; planId?: string; shown?: string; title?: string; markdown?: boolean } = {}) {
if (state.streaming) {
export async function sendMessage(text: string, options: { fromLoop?: boolean; goalAction?: 'resume'; planId?: string; shown?: string; title?: string; markdown?: boolean; reattach?: string; requestId?: string } = {}) {
// `reattach`: a turn the host is still running for this chat (or finished while the page was away) is taken over again after a reload, not started.
const reattach = options.reattach;
if (state.streaming && !reattach) {
// Typed while the assistant works: behind a running goal it waits in the queue; otherwise there is nothing to do but wait.
const waiting = state.activeSession, line = text.trim();
if (waiting && line && !options.planId && !options.goalAction) {
@@ -1791,9 +1948,9 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
}
const resume = options.goalAction === 'resume';
const planStart = options.planId;
const loopCommand = options.fromLoop || resume || planStart ? null : parseLoop(text.trim());
const loopCommand = options.fromLoop || resume || planStart || reattach ? null : parseLoop(text.trim());
if (loopCommand) { await runLoopCommand(loopCommand); return; }
const demoPrompt = resume || planStart ? null : timeSkipPrompt(text.trim());
const demoPrompt = resume || planStart || reattach ? null : timeSkipPrompt(text.trim());
if (demoPrompt !== null) {
if (demoPrompt && (state.remoteModel || nativeState()?.instances.some(i => i.status === 'ready'))) void startTimeSkipReal(demoPrompt); else if (state.connection === 'live' && demoPrompt) pushToast('info', 'Load a model first', 'Time-skip works on a loaded model. Choose one on the Models tab, then try again.');
else if (state.connection === 'connecting' && demoPrompt) pushToast('info', 'Still starting up', 'Time-skip needs the app to finish starting. Try again in a moment.');
@@ -1801,23 +1958,26 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
return;
}
// Whatever sent this (the box, a loop, a plan): with no model to answer, say so in plain words and start nothing.
if (state.connection === 'live') {
if (state.connection === 'live' && !reattach) {
// The first read of the engine's state has not come back yet: do not say "no model" about something not known yet, and do not send.
if (!nativeState() && !state.remoteModel && !state.library?.loaded) { pushToast('info', 'Still starting up', 'Strata is still checking your models. Try again in a moment.'); return; }
const why = noModelNotice(nativeState(), { remote: !!state.remoteModel, libraryLoaded: !!state.library?.loaded });
if (why) { pushToast('info', why.title, why.message); return; }
}
const pictureCheck=pictureStatus(nativeState(),state.remoteModel,state.providers);
if(pendingChatPhotos(state.activeSession).length&&!pictureCheck.ok){pushToast('error','This model cannot read pictures',tr(pictureCheck.message));return;}
const pictures=takeChatPhotos(state.activeSession);
if(!reattach&&pendingChatPhotos(state.activeSession).length&&!pictureCheck.ok){pushToast('error','This model cannot read pictures',tr(pictureCheck.message));return;}
const pictures=reattach?[]:takeChatPhotos(state.activeSession);
const body=text.trim()||(pictures.length?'Describe this picture.':'');
if(!body&&!resume&&!planStart)return;
if(!body&&!resume&&!planStart&&!reattach)return;
let sessionId = state.activeSession;
let sessionId = reattach ?? state.activeSession;
if (!sessionId) sessionId = newSession().id;
const userMsg: ChatMessage = { id: uid('m'), role: 'user', content: options.shown ?? body, at: new Date().toISOString(),...(pictures.length?{photos:pictures}:{}),...(options.markdown?{markdown:true}:{}) };
let assistantId = uid('m');
// A taken-over turn goes on in the reply the page already has (the last one, if it is the assistant's).
const lastReply = reattach ? [...(state.sessions.find(s => s.id === sessionId)?.messages ?? [])].reverse()[0] : undefined;
const resumedReply = lastReply?.role === 'assistant' ? lastReply : undefined;
let assistantId = resumedReply?.id ?? uid('m');
const assistantMsg: ChatMessage = {
id: assistantId,
role: 'assistant',
@@ -1845,10 +2005,15 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
// Bounded so a long conversation cannot send an unbounded body.
.slice(-40);
patchSession(sessionId, (s) => ({
patchSession(sessionId, (s) => reattach ? ({
...s,
messages: resumedReply
? s.messages.map(m => m.id === resumedReply.id ? { ...m, streaming: true } : m)
: [...s.messages, assistantMsg],
}) : ({
...s,
defaultTitle: false,
title: s.messages.length === 0 && !resume && !planStart ? (options.title ?? (body.length>48?body.slice(0,47).trimEnd()+'…':body)) : s.title,
title: s.messages.length === 0 && !resume && !planStart ? (options.title ?? (shortTitle(body))) : s.title,
updatedAt: new Date().toISOString(),
messages: [...s.messages, ...(resume || planStart ? [] : [userMsg]), assistantMsg],
}));
@@ -1887,8 +2052,8 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
};
try {
await streamPost(
planStart ? '/api/plan/start-goal' : '/api/chat/stream',
{
reattach ? '/api/chat/attach' : planStart ? '/api/plan/start-goal' : '/api/chat/stream',
reattach ? { sessionId, ...(options.requestId ? { requestId: options.requestId } : {}) } : {
sessionId,
...(planStart ? { planId: planStart } : {}),
...(resume ? { goalAction: 'resume' } : planStart ? {} : { message: options.shown ? body : body }),
@@ -1914,10 +2079,21 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
if (type === 'delta' || type === 'reasoning') { queueText(type, String(frame.text ?? '')); return; }
flushWaiting();
if (type === 'start' && frame.agent === true) { const turn = liveTurns.get(sessionId); if (turn) turn.agent = true; }
// The host has a turn to take over: the failure the page wrote when it lost the stream no longer applies.
if (type === 'start' && frame.resumed === true) patchAssistant(m => ({ ...m, error: undefined, errorCode: undefined }));
if (type === 'queued') {
patchAssistant(m => ({ ...m, requestId: String(frame.requestId) }));
} else if (type === 'start') {
patchAssistant((m) => ({ ...m, demo: frame.demo === true, remoteModel: frame.remoteModel === true, requestId: typeof frame.requestId === 'string' ? frame.requestId : m.requestId, model: String(frame.model), contextMax: Number(frame.contextMax), skillsUsed: Array.isArray(frame.skills) ? frame.skills.map(String) : [] }));
} else if (type === 'resync') {
// A taken-over turn: what was said while no page was here. The reply continues from what the page had; where the two disagree the host's text stands.
const said = String(frame.content ?? ''), thought = String(frame.reasoning ?? '');
patchAssistant(m => {
let next = m;
if (said) next = said.startsWith(next.content) ? { ...next, content: tidyAnswer(said), ...(next.blocks && said.length > next.content.length ? { blocks: appendText(next.blocks, said.slice(next.content.length)) } : {}) } : { ...next, content: tidyAnswer(said), blocks: undefined };
if (thought && thought.length >= (next.reasoning ?? '').length) next = { ...next, reasoning: thought, reasoningStartedAt: next.reasoningStartedAt ?? Date.now() };
return next;
});
} else if (type === 'status') {
applyStatusFrame(sessionId, frame as Record<string, unknown>);
// Manual mode waits for the person: a notification when they have opted in and Strata is in the background (#3477), once per question.
@@ -2042,7 +2218,10 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
);
} catch (err) {
flushWaiting();
if ((err as Error).name !== 'AbortError') {
if (reattach && (err as { status?: number } | null)?.status === 404) {
// Nothing to take over (the turn ended and the host no longer holds it): the reply stays as the page had it.
patchAssistant((m) => ({ ...m, streaming: false }));
} else if ((err as Error).name !== 'AbortError') {
patchAssistant((m) => ({
...m,
streaming: false,
@@ -2136,6 +2315,18 @@ export function go(route: Route, param?: string) {
export function bootOnce() {
if ((globalThis as { __strataBooted?: boolean }).__strataBooted) return;
(globalThis as { __strataBooted?: boolean }).__strataBooted = true;
void bootstrap();
}
let sessionsResumed = false;
/**
* Once, after the chats were first read from the host: what a chat can still have going on. Time-skip runs are followed again, a loop the last
* window left "running" has ended, a turn the host is still running is taken over (or one that ended while the page was away is shown whole),
* and tool cards the browser remembers as waiting for a turn that no longer exists are marked interrupted.
*/
async function resumeSessions() {
if (sessionsResumed) return;
sessionsResumed = true;
for (const session of state.sessions) for (const run of session.timeSkips ?? []) { scheduleTimeSkip(session.id, run); if (run.job && !run.completedAt) { markTurn(session.id, { abort: new AbortController(), agent: false }); void followTimeSkip(session.id, run.id); } }
// A loop lives in the window: one the last window left "running" has ended, and the board says so.
if (state.sessions.some(s => s.loop?.state === 'running')) {
@@ -2143,10 +2334,28 @@ export function bootOnce() {
persistSessions(sessions);
set({ sessions });
}
void bootstrap();
await takeOverTurns();
void reconcileAgentTools();
}
/**
* A reload does not stop an agent turn: the host keeps it going. The page asks which chats have a turn running and takes each one over again
* (the catch-up, then the rest as it is written, the Stop button and the approvals waiting). A chat whose last reply was cut off by the page
* going away is asked about too: if its turn ended meanwhile, the host still holds the end of it and shows it whole; if not, nothing changes.
*/
async function takeOverTurns() {
let running: { chatId: string }[] = [];
try { running = (await api.get<{ turns: { chatId: string }[] }>('/api/chat/active')).turns; } catch { /* an older host, or none reachable: nothing to take over */ }
const live = new Set(running.map(t => t.chatId));
for (const id of live) if (state.sessions.some(s => s.id === id) && !liveTurns.has(id)) void sendMessage('', { reattach: id });
// Cut-off replies of chats with no turn running: the most recent few, asked once each.
const cut = state.sessions.filter(s => !live.has(s.id) && !liveTurns.has(s.id)).slice().sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)).slice(0, 12).flatMap(s => {
const last = s.messages[s.messages.length - 1];
return last?.role === 'assistant' && last.requestId && last.usageSource === undefined && !last.local ? [{ id: s.id, requestId: last.requestId }] : [];
});
await Promise.allSettled(cut.map(c => sendMessage('', { reattach: c.id, requestId: c.requestId })));
}
/**
* After a reload, a tool card the browser remembers as waiting or running may belong to a turn that no
* longer exists. Cards whose approval the host still holds stay answerable; the rest are marked as
@@ -2155,6 +2364,7 @@ export function bootOnce() {
async function reconcileAgentTools() {
const open = (t: ToolCall) => t.live && (t.state === 'proposed' || t.state === 'running');
for (const session of state.sessions) {
if (liveTurns.has(session.id)) continue; // a turn taken over again reports its own cards
if (!session.messages.some(m => m.tools?.some(open))) continue;
let waiting = new Set<string>();
try {
+34
View File
@@ -0,0 +1,34 @@
/**
* A chat title cut from the first prompt without cutting a character in half (#4533).
*
* `prompt.slice(0, 47)` counts UTF-16 units, so an emoji (two units), a flag (four), a family or a skin-tone sequence (up to eleven) that
* straddles the cut leaves half a character: a lone surrogate that draws as a replacement box, or a ZWJ with nothing after it. The title is
* stored and restored as it is, so the broken tail comes back on every start and goes through the font fallback that WebKitGTK crashes in.
* This cuts at a user-perceived character (grapheme) when the engine has Intl.Segmenter, and at a code point when it has not.
*
* The three `prompt.length > 48 ? prompt.slice(0, 47).trimEnd() + '…' : prompt` places in lib/store.ts should call `shortTitle(prompt)`.
*/
const WIDTH = 48;
export function shortTitle(text: string, width = WIDTH): string {
if (text.length <= width)
return text;
const room = width - 1; // the ellipsis takes one unit
let end = 0;
const Segmenter = (Intl as unknown as { Segmenter?: new (locale?: string, options?: { granularity: string }) => { segment(input: string): Iterable<{ segment: string; index: number }> } }).Segmenter;
if (Segmenter) {
for (const part of new Segmenter(undefined, { granularity: 'grapheme' }).segment(text)) {
if (part.index + part.segment.length > room)
break;
end = part.index + part.segment.length;
}
}
else {
for (const ch of text) { // iterates by code point
if (end + ch.length > room)
break;
end += ch.length;
}
}
return text.slice(0, end).trimEnd() + '…';
}
+4 -3
View File
@@ -1779,7 +1779,7 @@
"s1761": "Strata ist nicht mit seiner Engine verbunden.",
"s1762": "Strata-optimierte Modelle",
"s1763": "Strata bereitet alle Teildateien, die Entwurfsschicht und gemeinsame Dateien vor. Abgeschlossene Downloads werden wiederverwendet.",
"s1764": "Strata sendet keine Nutzungstelemetrie. Gespräche bleiben im Browserspeicher. Wenn du ein API-Modell auswählst, werden das Gespräch und die Anweisungen aktivierter Skills an dessen Anbieter gesendet. Das UI-Anfrageprotokoll hält Client-, Zeit- und Token-Metadaten im Arbeitsspeicher. Die native API-Überwachung kann bei Aktivierung Anfrageinhalte im Arbeitsspeicher behalten. Verbundene Tools greifen auf die von dir eingerichteten Ressourcen zu.",
"s1764": "Strata sendet keine Nutzungstelemetrie. Gespräche bleiben auf diesem Computer, im Datenordner von Strata. Wenn du ein API-Modell auswählst, werden das Gespräch und die Anweisungen aktivierter Skills an dessen Anbieter gesendet. Das UI-Anfrageprotokoll hält Client-, Zeit- und Token-Metadaten im Arbeitsspeicher. Die native API-Überwachung kann bei Aktivierung Anfrageinhalte im Arbeitsspeicher behalten. Verbundene Tools greifen auf die von dir eingerichteten Ressourcen zu.",
"s1765": "Strata verwendet diese Dateien an ihrem Speicherort. Nach dem Hinzufügen kannst du das Modell laden.",
"s1766": "Strata — Chat öffnen",
"s1767": "Stratas Budget",
@@ -3056,7 +3056,7 @@
"s3042": "Protokolle anzeigen",
"s3043": "Engine-Protokolle",
"s3044": "Strata sendet keine Nutzungstelemetrie.",
"s3045": "Unterhaltungen bleiben im Browser-Speicher.",
"s3045": "Unterhaltungen bleiben auf diesem Computer, im Datenordner von Strata.",
"s3046": "Wenn Sie ein API-Modell wählen, werden diese Unterhaltung und die aktivierten Skill-Anweisungen an den Anbieter gesendet.",
"s3047": "Das UI-Anfrageprotokoll behält Client-, Zeit- und Token-Metadaten nur im Arbeitsspeicher.",
"s3048": "Die native API-Überwachung kann bei Aktivierung Anfrageinhalte im Arbeitsspeicher behalten.",
@@ -3526,5 +3526,6 @@
"s9034": "Das Modell wird geladen …",
"s9035": "Diese Modelldateien fehlen: {0}.",
"s9036": "Drücken Sie noch einmal auf „Herunterladen und laden“, um sie zu holen. Vorhandene Dateien bleiben erhalten. Werden dieselben Dateien wieder aufgelistet, öffnen Sie die Details und teilen Sie sie mit, wenn Sie um Hilfe bitten.",
"s9037": "Dieses Update wurde beim Neustart der App unterbrochen. Ein späteres Update ist abgeschlossen, Sie müssen nichts tun."
"s9037": "Dieses Update wurde beim Neustart der App unterbrochen. Ein späteres Update ist abgeschlossen, Sie müssen nichts tun.",
"s9038": "Dieses System erlaubt Strata nicht, Port {0} zu verwenden. Unter Linux und macOS brauchen Ports unter 1024 Administratorrechte. Starten Sie Strata mit einem anderen Port (STRATA_PORT), zum Beispiel 8801."
}
+6 -1
View File
@@ -15,5 +15,10 @@
"df14": "Der DFlash2-Entwurfsbaustein konnte nicht heruntergeladen werden, deshalb startet das Modell ohne Entwurfsbaustein. Das Modell wird geladen …",
"df15": "MTP-Entwurfsbaustein wird heruntergeladen …",
"df16": "Der MTP-Entwurfsbaustein konnte nicht heruntergeladen werden.",
"df17": "Vorausraten: MTP (DFlash2 ist nicht gestartet)"
"df17": "Vorausraten: MTP (DFlash2 ist nicht gestartet)",
"df18": "Automatisch nutzt die Alle-Karten-Split-Engine mit der MTP-Entwurfsschicht: etwa {0} GB, einmalig herunterzuladen.",
"df19": "Die Alle-Karten-Split-Engine konnte nicht starten, deshalb läuft die normale Schichtaufteilung.",
"df20": "Vorausraten: aus (den Karten fehlte Speicher)",
"df21": "Auf Ihrem Mac war DFlash2 bei diesem Modell in unseren Tests schneller als MTP.",
"df22": "DFlash2 ist auf dieser Grafikkarte vorerst standardmäßig aus; wählen Sie es unter „Vorausraten“ aus, um es zu nutzen."
}
+6 -1
View File
@@ -15,5 +15,10 @@
"df14": "The DFlash2 drafter could not be downloaded, so the model starts without a drafter. Loading the model…",
"df15": "Downloading the MTP drafter…",
"df16": "The MTP drafter could not be downloaded.",
"df17": "Drafting: MTP (DFlash2 didn't start)"
"df17": "Drafting: MTP (DFlash2 didn't start)",
"df18": "Auto uses the all-cards split engine with the MTP draft layer: about {0} GB to download, once.",
"df19": "The all-cards split engine could not start, so the normal layer split runs.",
"df20": "Drafting: off (the cards were short of memory)",
"df21": "On your Mac, DFlash2 was faster than MTP on this model in our tests.",
"df22": "DFlash2 is off by default on this graphics card for now; choose it in Drafting to use it."
}
+6 -1
View File
@@ -15,5 +15,10 @@
"df14": "No se pudo descargar el predictor de DFlash2, así que el modelo arranca sin predictor. Cargando el modelo…",
"df15": "Descargando el predictor de MTP…",
"df16": "No se pudo descargar el predictor de MTP.",
"df17": "Predicción: MTP (DFlash2 no arrancó)"
"df17": "Predicción: MTP (DFlash2 no arrancó)",
"df18": "Automático usa el motor de reparto entre todas las tarjetas con la capa de borrador MTP: unos {0} GB, una sola vez.",
"df19": "El motor de reparto entre todas las tarjetas no pudo arrancar, así que se usa el reparto normal por capas.",
"df20": "Predicción: desactivada (a las tarjetas les faltaba memoria)",
"df21": "En su Mac, en nuestras pruebas DFlash2 fue más rápido que MTP con este modelo.",
"df22": "DFlash2 está desactivado de forma predeterminada en esta tarjeta gráfica por ahora; elíjalo en Predicción para usarlo."
}
+6 -1
View File
@@ -15,5 +15,10 @@
"df14": "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…",
"df15": "Téléchargement du module d’anticipation MTP…",
"df16": "Le module d’anticipation MTP n’a pas pu être téléchargé.",
"df17": "Anticipation : MTP (DFlash2 n’a pas démarré)"
"df17": "Anticipation : MTP (DFlash2 n’a pas démarré)",
"df18": "Auto utilise le moteur réparti sur toutes les cartes avec la couche de brouillon MTP : environ {0} Go à télécharger, une seule fois.",
"df19": "Le moteur réparti sur toutes les cartes n’a pas pu démarrer, la répartition normale par couches est donc utilisée.",
"df20": "Anticipation : désactivée (les cartes manquaient de mémoire)",
"df21": "Sur votre Mac, DFlash2 a été plus rapide que MTP avec ce modèle lors de nos tests.",
"df22": "DFlash2 est désactivé par défaut sur cette carte graphique pour le moment ; choisissez-le dans Anticipation pour l’utiliser."
}
+6 -1
View File
@@ -15,5 +15,10 @@
"df14": "DFlash2 のドラフトモデルをダウンロードできなかったため、ドラフトモデルなしで起動します。モデルを読み込んでいます…",
"df15": "MTP のドラフトモデルをダウンロードしています…",
"df16": "MTP のドラフトモデルをダウンロードできませんでした。",
"df17": "先読み:MTP(DFlash2 は起動しませんでした)"
"df17": "先読み:MTP(DFlash2 は起動しませんでした)",
"df18": "自動では、全カードのスプリットエンジンと MTP ドラフト層を使います。約 {0} GB を一度だけダウンロードします。",
"df19": "全カードのスプリットエンジンを起動できなかったため、通常のレイヤー分割で動作しています。",
"df20": "先読み:オフ(グラフィックスカードのメモリが不足していました)",
"df21": "お使いの Mac では、私たちのテストでこのモデルにおいて DFlash2 は MTP より速くなりました。",
"df22": "現在、このグラフィックスカードでは DFlash2 は既定でオフです。使うには「先読み」で選んでください。"
}
+6 -1
View File
@@ -15,5 +15,10 @@
"df14": "DFlash2 초안 모델을 내려받지 못해 초안 모델 없이 시작합니다. 모델을 불러오는 중…",
"df15": "MTP 초안 모델을 내려받는 중…",
"df16": "MTP 초안 모델을 내려받지 못했습니다.",
"df17": "초안 예측: MTP(DFlash2가 시작되지 않았습니다)"
"df17": "초안 예측: MTP(DFlash2가 시작되지 않았습니다)",
"df18": "자동은 모든 카드를 쓰는 분할 엔진과 MTP 초안 층을 사용합니다(약 {0}GB, 한 번만 내려받음).",
"df19": "모든 카드를 쓰는 분할 엔진을 시작하지 못해 일반 레이어 분할로 실행합니다.",
"df20": "초안 예측: 꺼짐(카드 메모리가 부족했습니다)",
"df21": "고객님의 Mac에서 저희 테스트 결과 이 모델에서는 DFlash2가 MTP보다 빨랐습니다.",
"df22": "현재 이 그래픽 카드에서는 DFlash2가 기본적으로 꺼져 있습니다. 사용하려면 ‘초안 예측’에서 선택하세요."
}
+6 -1
View File
@@ -15,5 +15,10 @@
"df14": "Não foi possível baixar o preditor do DFlash2, então o modelo inicia sem preditor. Carregando o modelo…",
"df15": "Baixando o preditor do MTP…",
"df16": "Não foi possível baixar o preditor do MTP.",
"df17": "Previsão: MTP (o DFlash2 não iniciou)"
"df17": "Previsão: MTP (o DFlash2 não iniciou)",
"df18": "O automático usa o motor de divisão entre todas as placas com a camada de rascunho MTP: cerca de {0} GB, uma vez.",
"df19": "O motor de divisão entre todas as placas não conseguiu iniciar, então a divisão normal por camadas está em uso.",
"df20": "Previsão: desligada (faltou memória nas placas)",
"df21": "No seu Mac, nos nossos testes o DFlash2 foi mais rápido que o MTP com este modelo.",
"df22": "O DFlash2 está desligado por predefinição nesta placa de vídeo por agora; escolha-o em Previsão para o usar."
}
+6 -1
View File
@@ -15,5 +15,10 @@
"df14": "Не удалось скачать черновую модель DFlash2, поэтому модель запускается без черновой модели. Загружается модель…",
"df15": "Скачивается черновая модель MTP…",
"df16": "Не удалось скачать черновую модель MTP.",
"df17": "Предсказание: MTP (DFlash2 не запустился)"
"df17": "Предсказание: MTP (DFlash2 не запустился)",
"df18": "Авто использует движок с разделением по всем картам и черновой слой MTP: около {0} ГБ, скачивается один раз.",
"df19": "Движок с разделением по всем картам не запустился, поэтому работает обычное послойное разделение.",
"df20": "Предсказание: выключено (на картах не хватило памяти)",
"df21": "На вашем Mac в наших тестах DFlash2 с этой моделью был быстрее MTP.",
"df22": "На этой видеокарте DFlash2 пока выключен по умолчанию; чтобы использовать его, выберите его в разделе «Предсказание»."
}
+6 -1
View File
@@ -15,5 +15,10 @@
"df14": "无法下载 DFlash2 草稿模型,因此模型将不使用草稿模型启动。正在加载模型…",
"df15": "正在下载 MTP 草稿模型…",
"df16": "无法下载 MTP 草稿模型。",
"df17": "预测草稿:MTP(DFlash2 未能启动)"
"df17": "预测草稿:MTP(DFlash2 未能启动)",
"df18": "自动模式使用全部显卡的拆分引擎和 MTP 草稿层:约 {0} GB,只下载一次。",
"df19": "全显卡拆分引擎未能启动,因此改用常规的分层拆分。",
"df20": "预测草稿:关闭(显卡内存不足)",
"df21": "在您的 Mac 上,我们的测试中 DFlash2 在此模型上比 MTP 更快。",
"df22": "目前在这张显卡上 DFlash2 默认关闭;如需使用,请在“预测草稿”中选择它。"
}
+4 -3
View File
@@ -1779,7 +1779,7 @@
"s1761": "Strata isn’t connected to its engine.",
"s1762": "Strata optimized models",
"s1763": "Strata prepares all shards, the draft layer and shared files. Completed downloads are reused.",
"s1764": "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.",
"s1764": "Strata sends no usage telemetry. Conversations stay on this computer, in Strata's data folder. When you choose an API model, that conversation and enabled skill instructions are sent to its provider. The UI request log keeps client, timing and token metadata in memory. Native API monitoring can retain request content in memory when enabled. Connected tools access the resources you configure.",
"s1765": "Strata uses these files in place. You can load the model after adding it.",
"s1766": "Strata — open chat",
"s1767": "Strata’s budget",
@@ -3056,7 +3056,7 @@
"s3042": "View logs",
"s3043": "Engine logs",
"s3044": "Strata sends no usage telemetry.",
"s3045": "Conversations stay in browser storage.",
"s3045": "Conversations stay on this computer, in Strata's data folder.",
"s3046": "When you choose an API model, that conversation and enabled skill instructions are sent to its provider.",
"s3047": "The UI request log keeps client, timing and token metadata in memory.",
"s3048": "Native API monitoring can retain request content in memory when enabled.",
@@ -3526,5 +3526,6 @@
"s9034": "Loading the model…",
"s9035": "These model files are missing: {0}.",
"s9036": "Press Download & load again to fetch them. Files that are already there are kept. If the same files are listed again, open Details and share them when you ask for help.",
"s9037": "This update was cut short when the app restarted. A later update finished, so nothing needs doing."
"s9037": "This update was cut short when the app restarted. A later update finished, so nothing needs doing.",
"s9038": "This system does not let Strata use port {0}. On Linux and macOS, ports below 1024 need administrator rights. Start Strata with another port (STRATA_PORT), for example 8801."
}
+4 -3
View File
@@ -1779,7 +1779,7 @@
"s1761": "Strata no está conectado a su motor.",
"s1762": "Modelos optimizados de Strata",
"s1763": "Strata prepara todos los fragmentos, la capa de borrador y los archivos compartidos. Se reutilizan las descargas completadas.",
"s1764": "Strata no envía telemetría de uso. Las conversaciones permanecen en el almacenamiento del navegador. Cuando eliges un modelo de API, esa conversación y las instrucciones de las habilidades activadas se envían a su proveedor. El registro de solicitudes de la interfaz mantiene en memoria metadatos de clientes, tiempos y tokens. La supervisión de la API nativa puede conservar el contenido de las solicitudes en memoria cuando está activada. Las herramientas conectadas acceden a los recursos que configures.",
"s1764": "Strata no envía telemetría de uso. Las conversaciones permanecen en este equipo, en la carpeta de datos de Strata. Cuando eliges un modelo de API, esa conversación y las instrucciones de las habilidades activadas se envían a su proveedor. El registro de solicitudes de la interfaz mantiene en memoria metadatos de clientes, tiempos y tokens. La supervisión de la API nativa puede conservar el contenido de las solicitudes en memoria cuando está activada. Las herramientas conectadas acceden a los recursos que configures.",
"s1765": "Strata usa estos archivos en su ubicación actual. Puedes cargar el modelo después de añadirlo.",
"s1766": "Strata — abrir chat",
"s1767": "Recursos disponibles para Strata",
@@ -3056,7 +3056,7 @@
"s3042": "Ver registros",
"s3043": "Registros del motor",
"s3044": "Strata no envía telemetría de uso.",
"s3045": "Las conversaciones se quedan en el almacenamiento del navegador.",
"s3045": "Las conversaciones se quedan en este equipo, en la carpeta de datos de Strata.",
"s3046": "Si eliges un modelo de API, esa conversación y las instrucciones de las habilidades activadas se envían a su proveedor.",
"s3047": "El registro de solicitudes de la interfaz guarda en memoria metadatos de cliente, tiempo y tokens.",
"s3048": "Si se activa, la supervisión de la API nativa puede conservar en memoria el contenido de las solicitudes.",
@@ -3526,5 +3526,6 @@
"s9034": "Cargando el modelo…",
"s9035": "Faltan estos archivos del modelo: {0}.",
"s9036": "Pulse de nuevo «Descargar y cargar» para obtenerlos. Los archivos que ya están se conservan. Si vuelven a aparecer los mismos archivos, abra los detalles y compártalos cuando pida ayuda.",
"s9037": "Esta actualización se interrumpió cuando la aplicación se reinició. Una actualización posterior terminó, así que no hay nada que hacer."
"s9037": "Esta actualización se interrumpió cuando la aplicación se reinició. Una actualización posterior terminó, así que no hay nada que hacer.",
"s9038": "Este sistema no deja que Strata use el puerto {0}. En Linux y macOS, los puertos por debajo de 1024 necesitan derechos de administrador. Inicia Strata con otro puerto (STRATA_PORT), por ejemplo 8801."
}
+4 -3
View File
@@ -1779,7 +1779,7 @@
"s1761": "Strata n’est pas connecté à son moteur.",
"s1762": "Modèles optimisés par Strata",
"s1763": "Strata prépare tous les fragments, la couche de brouillon et les fichiers partagés. Les téléchargements terminés sont réutilisés.",
"s1764": "Strata n’envoie aucune télémétrie d’utilisation. Les conversations restent dans le stockage du navigateur. Quand vous choisissez un modèle API, cette conversation et les instructions des compétences activées sont envoyées à son fournisseur. Le journal des requêtes de l’interface garde en mémoire le client, les durées et les métadonnées de tokens. La surveillance de l’API native peut conserver le contenu des requêtes en mémoire lorsqu’elle est activée. Les outils connectés accèdent aux ressources que vous configurez.",
"s1764": "Strata n’envoie aucune télémétrie d’utilisation. Les conversations restent sur cet ordinateur, dans le dossier de données de Strata. Quand vous choisissez un modèle API, cette conversation et les instructions des compétences activées sont envoyées à son fournisseur. Le journal des requêtes de l’interface garde en mémoire le client, les durées et les métadonnées de tokens. La surveillance de l’API native peut conserver le contenu des requêtes en mémoire lorsqu’elle est activée. Les outils connectés accèdent aux ressources que vous configurez.",
"s1765": "Strata utilise ces fichiers sur place. Vous pourrez charger le modèle après l’avoir ajouté.",
"s1766": "Strata — ouvrir la conversation",
"s1767": "Budget de Strata",
@@ -3056,7 +3056,7 @@
"s3042": "Voir les journaux",
"s3043": "Journaux du moteur",
"s3044": "Strata n’envoie aucune télémétrie d’utilisation.",
"s3045": "Les conversations restent dans le stockage du navigateur.",
"s3045": "Les conversations restent sur cet ordinateur, dans le dossier de données de Strata.",
"s3046": "Quand vous choisissez un modèle API, cette conversation et les instructions des compétences activées sont envoyées à son fournisseur.",
"s3047": "Le journal des requêtes de l’interface garde en mémoire le client, les durées et les métadonnées de tokens.",
"s3048": "La surveillance de l’API native peut conserver le contenu des requêtes en mémoire lorsqu’elle est activée.",
@@ -3526,5 +3526,6 @@
"s9034": "Chargement du modèle…",
"s9035": "Ces fichiers du modèle sont manquants : {0}.",
"s9036": "Appuyez à nouveau sur Télécharger et charger pour les récupérer. Les fichiers déjà présents sont conservés. Si les mêmes fichiers sont listés à nouveau, ouvrez Détails et partagez-les quand vous demandez de l’aide.",
"s9037": "Cette mise à jour a été interrompue au redémarrage de l’application. Une mise à jour plus récente s’est terminée, il n’y a donc rien à faire."
"s9037": "Cette mise à jour a été interrompue au redémarrage de l’application. Une mise à jour plus récente s’est terminée, il n’y a donc rien à faire.",
"s9038": "Ce système ne laisse pas Strata utiliser le port {0}. Sous Linux et macOS, les ports inférieurs à 1024 nécessitent des droits d’administrateur. Lancez Strata avec un autre port (STRATA_PORT), par exemple 8801."
}
+4 -3
View File
@@ -1779,7 +1779,7 @@
"s1761": "Strataがエンジンに接続されていません。",
"s1762": "Strataの最適化モデル",
"s1763": "Strataがすべてのシャード、ドラフトレイヤー、共有ファイルを準備します。ダウンロード済みのものは再利用します。",
"s1764": "Strataは使用状況のテレメトリーを送信しません。会話はブラウザーのストレージに保存します。APIモデルを選ぶと、会話と有効なスキルの指示をプロバイダーに送信します。UIのリクエストログは、クライアント、時間、トークンのメタデータをメモリーに保持します。ネイティブAPIの監視を有効にすると、リクエストの内容をメモリーに保持する場合があります。接続ツールは設定したリソースにアクセスします。",
"s1764": "Strataは使用状況のテレメトリーを送信しません。会話はこのコンピューター上のStrataのデータフォルダーに保存します。APIモデルを選ぶと、会話と有効なスキルの指示をプロバイダーに送信します。UIのリクエストログは、クライアント、時間、トークンのメタデータをメモリーに保持します。ネイティブAPIの監視を有効にすると、リクエストの内容をメモリーに保持する場合があります。接続ツールは設定したリソースにアクセスします。",
"s1765": "Strataは現在の場所のファイルを使います。追加した後、モデルを読み込めます。",
"s1766": "Strata — チャットを開く",
"s1767": "Strataの容量枠",
@@ -3056,7 +3056,7 @@
"s3042": "ログを見る",
"s3043": "エンジンのログ",
"s3044": "Strata は利用状況のテレメトリーを送信しません。",
"s3045": "会話はブラウザーのストレージに保存されます。",
"s3045": "会話はこのコンピューター上のStrataのデータフォルダーに保存されます。",
"s3046": "API モデルを選ぶと、その会話と有効なスキルの指示がプロバイダーに送信されます。",
"s3047": "UI のリクエストログは、クライアント・時間・トークンのメタデータをメモリ上にのみ保持します。",
"s3048": "有効にすると、ネイティブ API の監視はリクエスト内容をメモリ上に保持することがあります。",
@@ -3526,5 +3526,6 @@
"s9034": "モデルを読み込んでいます…",
"s9035": "次のモデルファイルが見つかりません: {0}。",
"s9036": "もう一度「ダウンロードして読み込む」を押すと取得します。すでにあるファイルはそのまま残ります。同じファイルがまた表示される場合は、詳細を開き、助けを求めるときに共有してください。",
"s9037": "このアップデートは、アプリの再起動で中断されました。その後のアップデートが完了しているため、対応は不要です。"
"s9037": "このアップデートは、アプリの再起動で中断されました。その後のアップデートが完了しているため、対応は不要です。",
"s9038": "このシステムでは、Strataによるポート{0}の使用が許可されていません。LinuxとmacOSでは、1024未満のポートには管理者権限が必要です。別のポート(STRATA_PORT)、たとえば8801でStrataを起動してください。"
}
+4 -3
View File
@@ -1779,7 +1779,7 @@
"s1761": "Strata가 엔진에 연결되어 있지 않습니다.",
"s1762": "Strata 최적화 모델",
"s1763": "Strata가 모든 분할 파일, 초안 레이어, 공유 파일을 준비합니다. 완료된 다운로드는 재사용합니다.",
"s1764": "Strata는 사용 원격 측정 데이터를 전송하지 않습니다. 대화는 브라우저 저장 공간에 유지됩니다. API 모델을 선택하면 해당 대화와 활성화된 스킬 지침이 제공업체에 전송됩니다. UI 요청 로그는 클라이언트, 시간, 토큰 메타데이터를 메모리에 보관합니다. 네이티브 API 모니터링을 활성화하면 요청 내용을 메모리에 보관할 수 있습니다. 연결된 도구는 구성한 리소스에 접근합니다.",
"s1764": "Strata는 사용 원격 측정 데이터를 전송하지 않습니다. 대화는 이 컴퓨터의 Strata 데이터 폴더에 유지됩니다. API 모델을 선택하면 해당 대화와 활성화된 스킬 지침이 제공업체에 전송됩니다. UI 요청 로그는 클라이언트, 시간, 토큰 메타데이터를 메모리에 보관합니다. 네이티브 API 모니터링을 활성화하면 요청 내용을 메모리에 보관할 수 있습니다. 연결된 도구는 구성한 리소스에 접근합니다.",
"s1765": "Strata는 이 파일을 현재 위치에서 사용합니다. 추가한 뒤 모델을 불러올 수 있습니다.",
"s1766": "Strata — 채팅 열기",
"s1767": "Strata 할당량",
@@ -3056,7 +3056,7 @@
"s3042": "로그 보기",
"s3043": "엔진 로그",
"s3044": "Strata는 사용 현황 텔레메트리를 전송하지 않습니다.",
"s3045": "대화는 브라우저 저장소에 남습니다.",
"s3045": "대화는 이 컴퓨터의 Strata 데이터 폴더에 남습니다.",
"s3046": "API 모델을 선택하면 해당 대화와 활성화된 스킬 지침이 제공업체로 전송됩니다.",
"s3047": "UI 요청 로그는 클라이언트, 시간, 토큰 메타데이터를 메모리에만 보관합니다.",
"s3048": "활성화하면 네이티브 API 모니터링이 요청 내용을 메모리에 보관할 수 있습니다.",
@@ -3526,5 +3526,6 @@
"s9034": "모델을 불러오는 중…",
"s9035": "다음 모델 파일이 없습니다: {0}.",
"s9036": "'다운로드 및 불러오기'를 다시 누르면 가져옵니다. 이미 있는 파일은 그대로 둡니다. 같은 파일이 다시 표시되면 자세히 보기를 열어 도움을 요청할 때 함께 알려 주세요.",
"s9037": "이 업데이트는 앱이 다시 시작되면서 중단되었습니다. 이후 업데이트가 완료되었으므로 할 일은 없습니다."
"s9037": "이 업데이트는 앱이 다시 시작되면서 중단되었습니다. 이후 업데이트가 완료되었으므로 할 일은 없습니다.",
"s9038": "이 시스템은 Strata의 포트 {0} 사용을 허용하지 않습니다. Linux와 macOS에서는 1024 미만의 포트에 관리자 권한이 필요합니다. 다른 포트(STRATA_PORT), 예를 들어 8801로 Strata를 시작하세요."
}
+4 -3
View File
@@ -1779,7 +1779,7 @@
"s1761": "O Strata não está conectado ao mecanismo.",
"s1762": "Modelos otimizados do Strata",
"s1763": "O Strata prepara todas as partes, a camada de rascunho e os arquivos compartilhados. Downloads concluídos são reutilizados.",
"s1764": "O Strata não envia telemetria de uso. As conversas ficam no armazenamento do navegador. Quando você escolhe um modelo de API, a conversa e as instruções das habilidades ativadas são enviadas ao provedor. O log de solicitações da interface mantém metadados de cliente, tempo e tokens na memória. O monitoramento da API nativa pode reter o conteúdo das solicitações na memória quando ativado. Ferramentas conectadas acessam os recursos que você configura.",
"s1764": "O Strata não envia telemetria de uso. As conversas ficam neste computador, na pasta de dados do Strata. Quando você escolhe um modelo de API, a conversa e as instruções das habilidades ativadas são enviadas ao provedor. O log de solicitações da interface mantém metadados de cliente, tempo e tokens na memória. O monitoramento da API nativa pode reter o conteúdo das solicitações na memória quando ativado. Ferramentas conectadas acessam os recursos que você configura.",
"s1765": "O Strata usa estes arquivos onde estão. Você pode carregar o modelo depois de adicioná-lo.",
"s1766": "Strata — abrir chat",
"s1767": "Limite de recursos do Strata",
@@ -3056,7 +3056,7 @@
"s3042": "Ver registros",
"s3043": "Registros do mecanismo",
"s3044": "O Strata não envia telemetria de uso.",
"s3045": "As conversas ficam no armazenamento do navegador.",
"s3045": "As conversas ficam neste computador, na pasta de dados do Strata.",
"s3046": "Ao escolher um modelo de API, essa conversa e as instruções das habilidades ativadas são enviadas ao provedor.",
"s3047": "O registro de solicitações da interface guarda na memória metadados de cliente, tempo e tokens.",
"s3048": "Quando ativado, o monitoramento da API nativa pode manter o conteúdo das solicitações na memória.",
@@ -3526,5 +3526,6 @@
"s9034": "Carregando o modelo…",
"s9035": "Estes arquivos do modelo estão faltando: {0}.",
"s9036": "Pressione Baixar e carregar de novo para buscá-los. Os arquivos que já estão aí são mantidos. Se os mesmos arquivos aparecerem de novo, abra os Detalhes e compartilhe-os ao pedir ajuda.",
"s9037": "Esta atualização foi interrompida quando o aplicativo reiniciou. Uma atualização posterior terminou, então não há nada a fazer."
"s9037": "Esta atualização foi interrompida quando o aplicativo reiniciou. Uma atualização posterior terminou, então não há nada a fazer.",
"s9038": "Este sistema não deixa o Strata usar a porta {0}. No Linux e no macOS, as portas abaixo de 1024 precisam de direitos de administrador. Inicie o Strata com outra porta (STRATA_PORT), por exemplo 8801."
}
+4 -3
View File
@@ -1779,7 +1779,7 @@
"s1761": "Strata не подключена к движку.",
"s1762": "Оптимизированные модели Strata",
"s1763": "Strata готовит все части, черновой слой и общие файлы. Уже скачанные файлы используются повторно.",
"s1764": "Strata не отправляет телеметрию использования. Диалоги хранятся в браузере. При выборе модели API диалог и инструкции включённых навыков отправляются её провайдеру. Журнал запросов интерфейса хранит в памяти метаданные клиентов, времени и токенов. Мониторинг нативного API при включении может хранить содержимое запросов в памяти. Подключённые инструменты обращаются к настроенным вами ресурсам.",
"s1764": "Strata не отправляет телеметрию использования. Диалоги хранятся на этом компьютере, в папке данных Strata. При выборе модели API диалог и инструкции включённых навыков отправляются её провайдеру. Журнал запросов интерфейса хранит в памяти метаданные клиентов, времени и токенов. Мониторинг нативного API при включении может хранить содержимое запросов в памяти. Подключённые инструменты обращаются к настроенным вами ресурсам.",
"s1765": "Strata использует эти файлы на месте. После добавления можно загрузить модель.",
"s1766": "Strata — открыть чат",
"s1767": "Бюджет Strata",
@@ -3056,7 +3056,7 @@
"s3042": "Показать журналы",
"s3043": "Журналы движка",
"s3044": "Strata не отправляет телеметрию использования.",
"s3045": "Беседы хранятся в хранилище браузера.",
"s3045": "Беседы хранятся на этом компьютере, в папке данных Strata.",
"s3046": "Если вы выбираете API-модель, эта беседа и инструкции включённых навыков отправляются её провайдеру.",
"s3047": "Журнал запросов интерфейса хранит в памяти данные о клиенте, времени и токенах.",
"s3048": "Если включён мониторинг нативного API, содержимое запросов может храниться в памяти.",
@@ -3526,5 +3526,6 @@
"s9034": "Загружается модель…",
"s9035": "Не хватает этих файлов модели: {0}.",
"s9036": "Нажмите «Скачать и загрузить» ещё раз, чтобы получить их. Уже имеющиеся файлы сохраняются. Если те же файлы снова появятся в списке, откройте «Подробности» и приложите их, когда будете просить о помощи.",
"s9037": "Это обновление прервалось при перезапуске приложения. Более позднее обновление завершилось, так что делать ничего не нужно."
"s9037": "Это обновление прервалось при перезапуске приложения. Более позднее обновление завершилось, так что делать ничего не нужно.",
"s9038": "Эта система не разрешает Strata использовать порт {0}. В Linux и macOS порты ниже 1024 требуют прав администратора. Запустите Strata с другим портом (STRATA_PORT), например 8801."
}
+5
View File
@@ -0,0 +1,5 @@
{
"sav01": "Einige Chats konnten nicht gespeichert werden",
"sav02": "Der Chat-Speicher von Strata ist voll, daher gibt es die neuesten Änderungen an {0} Chat(s) nur in diesem Fenster. Löschen Sie alte Chats, um Platz zu schaffen; dann werden sie automatisch gespeichert.",
"sav03": "Nicht gespeicherte Chats kopieren"
}
+5
View File
@@ -0,0 +1,5 @@
{
"sav01": "Some chats could not be saved",
"sav02": "Strata's chat storage is full, so the newest changes to {0} chat(s) are only in this window. Delete old chats to make room; they then save by themselves.",
"sav03": "Copy unsaved chats"
}
+5
View File
@@ -0,0 +1,5 @@
{
"sav01": "No se pudieron guardar algunos chats",
"sav02": "El almacenamiento de chats de Strata está lleno, así que los cambios más recientes de {0} chat(s) solo están en esta ventana. Elimina chats antiguos para liberar espacio; después se guardarán solos.",
"sav03": "Copiar chats sin guardar"
}
+5
View File
@@ -0,0 +1,5 @@
{
"sav01": "Certaines conversations n’ont pas pu être enregistrées",
"sav02": "L’espace de stockage des conversations de Strata est plein : les dernières modifications de {0} conversation(s) ne sont que dans cette fenêtre. Supprimez d’anciennes conversations pour faire de la place ; elles s’enregistreront alors d’elles-mêmes.",
"sav03": "Copier les conversations non enregistrées"
}
+5
View File
@@ -0,0 +1,5 @@
{
"sav01": "一部のチャットを保存できませんでした",
"sav02": "Strata のチャット保存領域がいっぱいのため、{0} 件のチャットの最新の変更はこのウィンドウにしかありません。古いチャットを削除して空きを作ると、自動的に保存されます。",
"sav03": "未保存のチャットをコピー"
}
+5
View File
@@ -0,0 +1,5 @@
{
"sav01": "일부 채팅을 저장하지 못했습니다",
"sav02": "Strata의 채팅 저장 공간이 가득 차서 {0}개 채팅의 최신 변경 내용이 이 창에만 있습니다. 오래된 채팅을 삭제해 공간을 확보하세요. 그러면 자동으로 저장됩니다.",
"sav03": "저장되지 않은 채팅 복사"
}
Loaded 100 of 433 files, more files were not shown because too many files have changed in this diff. Show more