Compare commits

...
422 Commits
Author SHA1 Message Date
Niko1221 7ddc17a38f tests: the old-launcher test expects the 0.2.3.1 version line 2026-10-23 14:37:21 +02:00
Niko1221 7b3e2eaa0b Control 0.1.48.1 / Terminal 0.2.3.1: version bump for the Control-only hotfix of #3810 (app stays 0.2.3, engine packages stay 0.1.48); release notes 2026-10-23 14:10:33 +02:00
Niko1221 f5f205bee0 setup: the HIP ready-made-engine checks read gfx names with arch_names, not arch_numbers (an installed AMD engine was never recognised); tests for archs as a list and as a string 2026-10-23 14:07:48 +02:00
Niko1221 0edd96ccf6 setup: BUILD.json archs / vision_archs are read as a list or as the ';'-separated string the packaging writes (setup stopped on the released Windows CUDA package: int(';'), #3810) 2026-10-23 14:05:32 +02:00
Niko1221 5c8dd32252 serve: max_tokens -1 and 0 mean no cap again, as in 0.2.2 (clients send -1 for that); documented 2026-10-23 07:57:09 +02:00
Niko1221 5964d113f8 release notes 0.2.3: hotfix heading, status/logs names, launch not setup, drop the unreleased-pack limit 2026-10-23 06:37:32 +02:00
Niko1221 fea2d02aaa docs: Strata 0.2.3 release notes 2026-10-23 06:36:54 +02:00
Niko1221 81a7260194 launch config never keeps --mtp beside "mtp": false; one line says where the pack and draft layer are read from (B10, C08 note) 2026-10-23 03:28:36 +02:00
Niko1221 3b1ab2848d Save and launch saves the preset only after the restart question is answered Restart; the question names the running model and port 2026-10-23 03:27:48 +02:00
Niko1221 80a70b86de Folder step: models Strata already keeps stay where they are (the text said they were moved) 2026-10-23 03:24:46 +02:00
Niko1221 9222b6a1d3 a failed start ends its own runtime host, checks the port first and quotes only this attempt's logs (X01) 2026-10-23 03:23:58 +02:00
Niko1221 67a48427b8 Merge f-023-int into f-023-tui2 2026-10-23 00:27:00 +02:00
Niko1221 d64ae1b0b1 tests: the smoke test reads the engine's own words from .reason, the message is plain (X01) 2026-10-23 00:26:59 +02:00
Niko1221 27c0cabd81 Merge f-023-tui1: TUI stress fixes (second launch reuse-or-ask with --restart, context change on the same port, status/logs find the launch, key kept, retune stops its own model, folder checks, Ctrl+C, --model-path wins) 2026-10-23 00:13:45 +02:00
Niko1221 2f5f7c1135 Failed engine test start gives a plain cause and next step; rollback never says the same version twice; max_tokens below 1 is a 400 (TUI stress X01, #18, #15)
X01: TestStartFailed has one text builder (TestStartFailed.plain): what did not work in plain words, that nothing was installed and the current engine is unchanged, and one next step (`strata update --force`, or a plain retry when the card was busy). The engine's own words stay in .reason, the state file and the JSON. The app's message matcher also accepts the new wording. Hints that a caller passes, such as the one for a busy launch, are used as they are, so the launch-holds-the-card line of f-023-tui1 composes with it.
#18: `components rollback` when an earlier rollback is still in force says the engine is already the previous one and what to run, instead of "0.1.45.3 -> 0.1.45.3".
#15 (K05): max_tokens, max_completion_tokens and max_output_tokens below 1 are a 400 naming the field (they used to mean "no cap"); an unknown model id is still answered by the loaded model, now documented in DETAILS.md.
2026-10-23 00:06:13 +02:00
Niko1221 3bf804c9c8 tests follow the folder check and the compact save summary 2026-10-23 00:05:00 +02:00
Niko1221 0af0f9f125 Installer: an older feed shows its real reason; a folder with accented letters unpacks (TUI stress #11 A01b/A05, #12 A04)
#11: the refusal of a feed older than the one already accepted now says both sequence numbers, the folder that holds the newer one (.updates) and one next step (use the newest release folder, or delete state.json to take the older one). install.ps1 and install.sh print the whole check output, not its first 6 lines, and no longer blame a missing signature when the check gave a reason.
#12: tar.exe got the path through the ANSI/OEM code page and failed on accented letters. The installer now runs Windows' own bsdtar (System32 first, so a GNU tar from Git for Windows cannot be picked up for a zip) with ASCII relative arguments from inside the data folder and renames the result, so no non-ASCII path ever crosses that boundary. Tested on C:\...\<U-umlaut>n<i-umlaut> test with a stub release folder; the old script fails there with "could not chdir".
2026-10-22 23:54:57 +02:00
Niko1221 ea67f6999a A missing shard is named with its file name in the CLI, the TUI and the app's plain issues (TUI stress #9, C05, #3478)
plan_import now returns missing_shards (the file names of the split set that are not in the folder). problem_words and plain_issues say "<name>-00002-of-00002.gguf is missing" instead of "some parts of the model are missing"; more than two are counted. The three call sites (models import, strata launch --model-path, the Model screen's own-files field) pass the list.
2026-10-22 23:47:37 +02:00
Niko1221 eb818c50ed Download size counts the draft layer and the picture reader; --plan no longer calls an installed engine missing; --drafting off writes no --mtp (TUI stress #6, B03, B10/E04)
#6 (A07/B03): a model that is not installed now counts the draft layer (5.2 GB) and the picture reader (0.9 GB) in the one place (modelstate.assess), so the Folder step, the confirm and --plan agree (Gyro-S 58.5 + 5.2 + 0.9 = 64.6 GB). Gyro joins the families that get the helpers. --mtp off and --drafting off drop the draft layer from the count and from what setup is told. setup.py's disk check counts the draft layer it is about to fetch.
B03: --plan keeps "engine_missing" for no engine at all; an installed engine that fails the check says so in plain words and adds engine_usable.
B10/E04: with --drafting off (or an installed config that says off) the written run config carries "mtp": false and no --mtp / --spec-min-p arguments.
2026-10-22 23:44:27 +02:00
Niko1221 092a62daaa --model-path and --data-dir win over an installed copy with the same model and context (C08) 2026-10-22 23:37:20 +02:00
Niko1221 1ed28f8863 the Folder step checks the path before going on, the Save summary shows the storage path, a denied folder is told in plain words (B05, B12) 2026-10-22 23:36:16 +02:00
Niko1221 38c7f7017d ctrl+c quits the launch screens like ctrl+q; only a running download or tune asks first (B13) 2026-10-22 23:36:15 +02:00
Niko1221 c924d3c9d7 An old Terminal that cannot hand over to a new Control is told to run the install command once more (TUI stress #4, H03)
The Terminals before 0.2.1.3 (0.2.0-beta, 0.2.1, 0.2.1.1, 0.2.1.2) install a newer Control and report "updated", but their launcher has no hand-over, so the old Control keeps running. Their updater test-starts the staged Control with --version (STRATA_NO_ACQUIRE=1, no STRATA_HANDOVER, from the .s staging folder) and shows the first 120 characters of the answer when that fails. Control now answers that start with one plain sentence and exit 7, so the old updater keeps the old Control and prints the sentence instead of "updated". Every updater that can hand over sets STRATA_HANDOVER and is not touched. Release note added.
2026-10-22 23:33:24 +02:00
Niko1221 6f8800aefe Version bump for 0.2.3: app 0.2.3, engine and Control 0.1.48
Cargo.toml and Cargo.lock, desktop and host package.json and lock, PRODUCT_VERSION, CMake project and Metal STRATA_VERSION, build_hip_linux PKGVER default, About comments, tests that pin the number, Docker image tags and docs (0.2.3-<flavor>), download names in the READMEs, AMD_HIP.md and UPDATES.md, AGENTS.md, the bug report placeholder. The engine moves too: it shares the CMake number and its source changed since 0.2.2 (DFlash2, verify and prefill paths). A new test checks every place agrees and that a 0.2.2 install (Control 0.1.47) is offered Control 0.1.48.
2026-10-22 23:33:15 +02:00
Niko1221 814716336a a second launch reuses the same choices, asks or needs --restart for other choices, and frees the port first (G04/E01) 2026-10-22 23:31:30 +02:00
Niko1221 0530a1f7c2 retune stops its own server first; engine update gives one next step when the launch holds the card (E07/H04/H06) 2026-10-22 23:22:57 +02:00
Niko1221 f6db09bae2 launch keeps the API key it made until --new-key is given (E05/G01) 2026-10-22 23:21:37 +02:00
Niko1221 217ddda0eb status and logs find the running launch when no address is given (F02) 2026-10-22 23:20:36 +02:00
Niko1221 a14e85d8e6 tests: the pinned DFlash2 drafter address is allowed in strata_control (only strata drafting fetch reaches it, behind acquisition.require) 2026-10-22 21:19:41 +02:00
Niko1221 164dd3bbef Merge f-023-fix3: drafting network code loads only when used, rollback tests wait for the restored start, Arc B-series runs with pictures on 2026-10-22 19:58:52 +02:00
Niko1221 6577b69d5d Intel Arc B-series (Battlemage, e.g. Arc Pro B70) runs like any Arc: pictures on by default with the CPU reader, one plain line that the card is new and how to report a problem
Owner decision 2026-10-22. setup_intel no longer switches pictures off for a B-series card; it prints the line instead (known risk: the first-picture xe reset on the B70, 2026-10-19). strata launch gets the same line as a warn-level note (Computer.new_card_note) and its picture default for an Intel engine now follows the CPU-reader rule at any VRAM size (it used the GPU reader for a 32 GB B70). Gyro adoption on Arc was already allowed in this tree (adopt.FAMILIES). Tests that pinned the opt-in are updated; docs/INTEL.md, packaging/README.md and docs/GYRO.md say B-series is supported and untested by us.
2026-10-22 19:39:27 +02:00
Niko1221 dca4217501 test_runtime: the rollback tests wait 30 s for the restored launch (5 s was shorter than a Windows engine start); the two failures and the teardown error were a timeout, the rollback itself works
Same failure on v0.2.2 (011c9e600): the failed new start is refused for its port as intended, the old config is relaunched, but it was still loading when the 5 s startup wait ended, so rolled_back was False and the left-over process held the temp folder at teardown.
2026-10-22 19:35:29 +02:00
Niko1221 84b1e9815e strata drafting: load the network code only when the command runs, so a started engine's command line does not import it (437a60676 regressed the start-path rule) 2026-10-22 19:30:23 +02:00
Niko1221 c266cfb3cb Merge f-023-int into f-023-gui 2026-10-22 18:59:33 +02:00
Niko1221 a8301a2709 Merge f-023-metalfix2: no expert-cache loan in the Metal CLI prompt path either 2026-10-22 18:42:02 +02:00
Niko1221 669023fc08 Merge f-023-strixpp: Strix Halo gets its 8192 prompt chunk back on Windows, and the HIP engine finds its own tuning table 2026-10-22 18:36:58 +02:00
Niko1221 b440640929 metal: no expert-cache loan in the CLI prompt path either (the 4K+ prompt of a plain strata run aborted in ExpertCache::fill_slot_queued) 2026-10-22 18:31:04 +02:00
Niko1221 155995b65c Merge f-023-drafting: the app hides DFlash2 until the engine serves it 2026-10-22 18:07:21 +02:00
Niko1221 e890e30766 The app offers DFlash2 only once the engine serves it: the Drafting setting is Auto / MTP / Off until then (Control reports it) 2026-10-22 18:07:03 +02:00
Niko1221 7b04360f93 hip prefill: the package tuning folder lookup split the exe path on '/' only (a backslash escape lost in the string): split on both 2026-10-22 18:05:47 +02:00
Niko1221 e22a3c9ba9 Merge f-023-drafting: strata launch --drafting auto|mtp|dflash2|off and the app's Drafting setting; suggestion table (Auto = MTP until a card wins everywhere); pinned drafter download and numpy packer; gated until the engine serves DFlash2 2026-10-22 17:43:34 +02:00
Niko1221 b7196c23de Drafting in the app: "Drafting: Auto (recommended) / MTP / DFlash2 / Off" beside context in the model's settings
- The Models screen gets one more column in the context row, the existing Select, with the four choices; DFlash2 is offered for Qwen3.8-Flash-Next
  (the qwen and gyro families) only. The choice is saved with the model (a setting the app owns, like Draft layer) and shown again next time.
- Before a load the host asks Control (strata drafting plan / fetch --if-needed): when the choice would use the DFlash2 drafter and its file is not
  here, it is downloaded pinned and checked and packed, with plain progress in the load. A drafter that cannot be had never stops the load: the MTP
  starts and the job says so. The download plan dialog lists the drafter's size when it is chosen.
- What ran is told in the engine's words under Details and below the row: "Drafting: MTP (DFlash2 didn't fit)" and the other three reasons, all in
  the eight languages. Off is the draft layer's own switch.
- The app never reads the suggestion table: Control decides (strata_control/drafting.py), so the Terminal and the app cannot disagree.
2026-10-22 17:41:10 +02:00
Niko1221 fcb0c2d0ec Merge f-023-int into f-023-gui 2026-10-22 17:34:22 +02:00
Niko1221andmedking82 e17efa53ce Updates: an interruption that a later finished update settled says so in the same red alert (#3578)
Logic and tests ported from #3578 by medking82. The alert stays red and in place; only its words change for the restart interruption of an update that a later update of the same component finished. Sentence-case text in all eight languages; the PR's screenshots and validation files are left out.

Co-authored-by: medking82 <[email protected]>
2026-10-22 17:33:54 +02:00
Niko1221andmedking82 3f81245864 Reuse a retained legacy model library through a guarded native data root, without moving weights (#3362)
Ported from #3362 by medking82.

Co-authored-by: medking82 <[email protected]>
2026-10-22 17:33:01 +02:00
Niko1221andG0-0000 9471faa5f0 Web page: model settings rows no longer overflow their card on narrow windows (#3601)
The overflow was found and reported in #3601 by G0-0000. Its 75% column made the inputs narrower at every width, so this keeps the current layout when it fits: the label column may shrink, long names wrap, the input keeps 360 px.

Co-authored-by: G0-0000 <[email protected]>
2026-10-22 17:32:50 +02:00
Niko1221andjkir4n 3b51719abc Web page: keep long words and wide tables inside the viewport on narrow screens (#3594)
Ported from #3594 by jkir4n.

Co-authored-by: jkir4n <[email protected]>
2026-10-22 17:32:35 +02:00
Niko1221andengharat 2ba95cf61f Web page: use an edited API key at once, even when browser storage is blocked (#3435)
Ported from #3435 by engharat.

Co-authored-by: engharat <[email protected]>
2026-10-22 17:32:35 +02:00
Niko1221 437a60676f Drafting: strata launch --drafting auto|mtp|dflash2|off, one suggestion table, and the DFlash2 drafter fetched pinned and packed on the person's PC
- strata_control/drafting.py is the one place: the choice (saved in the model's run config as "drafting"), the table card class -> suggested
  drafter with the measured evidence (Auto picks DFlash2 only where it was faster on every tested prompt; today no class qualifies, so Auto is
  the MTP everywhere), the pinned drafter (incoai/Qwen3.8-Flash-Next-DFlash2 rev a22a1744, sizes and SHA-256 of the repository), what each choice
  puts on the engine arguments, and the plain "Drafting: MTP (DFlash2 didn't fit)" words read from the engine's own log.
- serve/server.py applies the choice at every start and reports "drafting" / "drafting_note" in /metrics.
- strata launch --drafting writes it to the launch config and presets, shows it in --plan, adds the drafter to the download plan when it would
  be used, fetches it before the start (a failure starts the MTP and says so), and prints the engine's fallback line.
- strata drafting plan|fetch (JSON/JSONL for the app). tools/dflash2_pack.py now reads safetensors with numpy alone (no torch on the person's
  PC), byte for byte the same file as before (checked against the earlier version), and ships in the Terminal bundle.
- DFlash2 is for Qwen3.8-Flash-Next (qwen, gyro) only. The engine in this release runs it only in its CLI run, not with --serve, so while
  drafting.ENGINE_SERVES_DFLASH2 is False the choice starts the MTP, says "DFlash2 isn't available in this mode yet" and downloads nothing.
2026-10-22 17:27:39 +02:00
Niko1221 f1b6a2144c Merge f-023-df2: DFlash2 drafter (opt-in --draft dflash2) merged onto the combined branch, builds on CUDA, HIP and Metal 2026-10-22 17:05:26 +02:00
Niko1221 c62d980112 hip prefill: with no STRATA_HIPBLASLT_TUNING in the environment the engine takes the table beside it (tuning/<arch>-hipblaslt-<version>.txt) itself, so a start that did not come through the launcher (hand-made config, CLI) does not read prompts at half speed on a Strix Halo; STRATA_HIPBLASLT_TABLE=off keeps plain hipBLAS 2026-10-22 17:04:58 +02:00
Niko1221 051db74cdf gfx1151 on Windows: a bare --prefill auto may reach 8192 again (the 6144 cap of #1630 stays for other cards); STRATA_GFX1151_CHUNK=0 keeps 6144 2026-10-22 16:55:16 +02:00
Niko1221 0c467e9d45 Merge f-023-agent: agent context pressure from measured tokens, summaries in pieces that fit, recovery from the context-window 400, preflight of oversized messages, quote-aware read-only shell detection, visible compaction, plain context errors, stale child marker after a force-kill
# Conflicts:
#	apps/host/src/screens/Chat.tsx
2026-10-22 16:52:15 +02:00
Niko1221 76ac5842ff Merge f-023-tune: Work profile CPU workers ceil(0.8 x physical cores), VRAM reserve capped at 1536 MiB on 16 GB cards (picture floor only for a GPU reader); #3493 conversation cache sized automatically after load; #3438 bench reports an engine that did not stop 2026-10-22 16:26:51 +02:00
Niko1221 5de8d26949 tests: setup no longer tips about the conversation cache (the engine sizes it, #3493) 2026-10-22 16:22:37 +02:00
Niko1221 e4b31d6f69 strata-context: drop the development log hook 2026-10-22 16:14:56 +02:00
Niko1221 b96f174775 Merge f-dflash2 (the DFlash2 drafter, opt-in) into f-023-int 2026-10-22 16:10:08 +02:00
Niko1221 f0010a607f serve: the conversation cache is sized from the free RAM when --conversation-cache-mib is not given (#3493): 40% of what is left above the parking floor, at most 8192, off under 1024, one log line; an explicit value (0 too) is kept; harness_reuse_probe.py; docs 2026-10-22 16:06:17 +02:00
Niko1221 ed0ed38e2d Merge f-023-tls: #3722 HTTPS uses the OS certificate store (truststore, certifi fallback) in setup, Control, serve and the fetch tools; the app host adds the system roots; a certificate failure says plainly what is intercepting and what to try 2026-10-22 15:43:33 +02:00
Niko1221 c4aec611b2 tls: downloads check the server against the operating system's certificates; a failed check gives a plain message (#3722)
Python's OpenSSL checks against certifi's roots and does not fetch a missing intermediate; Chrome and curl.exe use the Windows store
(schannel), which also holds the roots antivirus HTTPS scanning, a company proxy or a preview build adds. strata_control/tls.py
install() puts truststore (pinned in requirements.txt and the three runtime locks with hashes, CPython 3.12 in every archive) under
every HTTPS connection of setup, Control's transfer and component download, serve and the fetch tools, with certifi as the fallback.
A certificate failure is no longer the raw urlopen text and is not retried: it names the likely causes (antivirus web scanning, a
company or school proxy), says what to try, and never offers to skip the check.

The host: server/system-ca.mjs adds the system roots to Node's bundled list at start (Node 24.5+), and the agent process gets
NODE_USE_SYSTEM_CA=1.
2026-10-22 15:43:12 +02:00
Niko1221 3ae6b3041b A big message on a chat near the compaction threshold summarises first; the real-engine script can also run against a server that is already up 2026-10-22 15:40:55 +02:00
Niko1221 ebaeda85f1 Summarize and continue asks for the answer instead of sending the refused message a second time; agent README describes the context pieces 2026-10-22 15:37:35 +02:00
Niko1221 815256d89b Tests for the two ways to send a message that is too large (file in the chat folder, shortened preview) and the host route 2026-10-22 15:26:53 +02:00
Niko1221 b14862351b The chat checks a message against the model's window before sending it, handles /compact itself, and explains a full context in plain words
- /compact was typed into the chat and sent to the model as text (it answered "nothing to compact, this conversation is still short" at 90%
  full). The host now runs the Harness's /compact and says how full the chat was and is, measured.
- Before a message goes out the model server counts it (its own tokenizer). Fits: sent. Fits once the older messages are a summary: summarised
  first, with a line in the chat. Can never fit (a 103 KB paste is 70K tokens in a 64K window): not sent, and the card offers "Attach as a file"
  (saved in the chat folder, the assistant reads it in parts) or "Send a shortened preview".
- A refusal that remains after the Harness's own compact-and-retry is a plain sentence with the engine's text under Details, and the card
  offers "Summarize and continue" or "Start a new chat" instead of "Try sending the message again".
- Compaction shows: a status line while it runs and one plain line afterwards. The context meter follows the running turn (the host reports
  what the context holds after every model request) so it no longer reads 1% between two tool steps.
- A Harness error "lost host child is still present" reads as "the last Strata session is still closing down".
- All new sentences are in the catalog in the eight languages. The real-engine run is scripts/context-e2e.mjs.
2026-10-22 15:26:30 +02:00
Niko1221 f8f4e8a7df bench: a failed engine stop is loud and fails the run (#3438): stop_engine returns why the process may still be alive (taskkill result and exit, the 60 s and 30 s timeouts), the run prints an ERROR, writes ENGINE-NOT-STOPPED.txt, exits 6 and reports ok false 2026-10-22 15:20:59 +02:00
Niko1221 acf63bef73 app: the Work profile takes 80% of the physical cores rounded up (the engine's own count at most), keeps no more than 1536 MiB on a card of 16 GB or less, and a CPU picture reader no longer raises the reserve to 2048 (RTX 5070, Gyro-S 64K: 9 workers + 2048 33 tok/s, 5 + 1536 46) 2026-10-22 15:17:24 +02:00
Niko1221 098fd545b3 A marker left by a force-killed app no longer blocks the next load when its process number belongs to a later program
The child marker without a creation time could not tell the recorded engine from another Python or Strata program that had reused the
number, so every load said "lost host child is still present" until the marker was deleted by hand. The child is always started before
its marker is written, so a live process created after the marker's file time (with a two-second slack) is not the child. Unreadable
times still keep refusing; a recorded creation time still decides by itself. New procs.started_epoch reads a creation time on Windows,
Linux and macOS.
2026-10-22 15:02:49 +02:00
Niko1221 a7cd2de97a A look-only shell command with an & or a bracket inside quotes no longer asks for approval
The read-only check refused any command containing & ; ( ) { } before it understood quoting, so listing "C:\...\GUI Q&A" showed an
approval card. Quoted text is now set aside first (maskQuotes): an expansion inside double quotes ($ or a backtick), an unterminated
quote, a quote glued to other characters and a quoted text that starts with a dash are still not understood, so those and everything
outside quotes ask as before.
2026-10-22 14:46:52 +02:00
Niko1221 be58604387 Compaction reads the prompt size the model server measured, and a summary request always fits the window
The Harness prices a conversation at 4 characters a token; code, logs and JSON are about twice as dense. A new Harness plugin
(plugins/strata-context.mjs, arithmetic in context-budget.mjs) wraps the token meter so pressure is the last measured prompt plus what
came after, priced at the density that measurement showed. It also wraps the compaction summary request: when it would not fit the
window it is asked for in pieces (a message bigger than a piece is cut for the request, each piece builds on the summary before it, a piece
the server still refuses is halved). A chat the engine refused once (a 70K-token message in a 64K window) is no longer stuck: the next
message compacts and goes through. Tests run the real Harness against a scripted server that counts and refuses like the engine.
2026-10-22 14:40:20 +02:00
Niko1221 5701fc65ce Merge f-023-tools: tool-returned pictures with elastic vision, picture-reader start errors and per-variant packaging, Markdown images (safe sources), PR #3556 tool aliases, MCP wiring, command-tool cwd/shell/timeout, image-token limit, gate fails closed 2026-10-22 14:31:55 +02:00
Niko1221 0351ecb11d Two GTX 1080s are a working PC: an 8 GB card starts Off only beside a bigger card, and a PC whose only NVIDIA cards are Pascal uses them on the CUDA 12 engine (#3655)
recommend() (Terminal, app and shared vectors) now turns a card with 8 GB or less Off only when a bigger usable card is there or it has under 4 GB. The hardware report (tools/strata_mcp.py) applies setup's own opt-in rule for a PC with only Pascal/Volta NVIDIA cards and no usable AMD card, so those cards are no longer flagged 'older than the RTX 20 series' (engine_variant cuda12); the feed already picks the cuda12 package for sm_61 and refuses cuda13. Tests: shared gpu-roles vectors, strata_mcp hardware, feed selection for sm_61, and Tune keeping a two-card role map.
2026-10-22 13:47:41 +02:00
Niko1221 d9042a16bd Missing model files are named, with one next step (#3478)
The load used to stop with 'Some model files are missing' and nothing else, so Retry looked the same every time. Control's model list now carries the required parts it cannot find (part, file, status); the host joins them with the installer's missing GGUF files and shards, puts the names in the error (four shown, all in Details and in the load log), and the app shows 'These model files are missing: ...' with 'Press Download & load again to fetch them. Files that are already there are kept.' in all eight languages.
2026-10-22 13:29:59 +02:00
Niko1221 642f5120a7 Route change: the view transition's update callback no longer waits for requestAnimationFrame (#3673)
While the callback is pending the browser holds rendering, so the frame callback never ran and every click waited for the browser's own guard: about 4 s in Chromium (and so the WebView2 app window, reproduced in Edge 155: callback 4023 ms) and about 5 s in Firefox. The callback now resolves on the first DOM change after the hash flipped, or after 150 ms. Measured in Edge 155: callback 9-19 ms, full transition about 350 ms, with and without a 3.6 MB chat history.
2026-10-22 13:24:33 +02:00
Niko1221 98a139c3c7 Updates never cross GPU makes: Windows display adapters decide the backend, an installed engine of another make is not replaced (#3688)
On an AMD R9700 PC a leftover nvidia-smi made detection say cuda, so the update staged cuda13 and its test start failed. detect_backend (Control) and gpuBackendHere (app) now let the Windows display adapters decide first and ignore a tool for a make with no adapter. The update decision keeps the installed engine's make when the PC has a card of it, and otherwise stages nothing and says so in plain words (status wrong-make, shown by the app). update_engine refuses a package of another make before any download. --backend and STRATA_ENGINE_BACKEND are the explicit way to change make.
2026-10-22 13:21:36 +02:00
Niko1221 828a2559a9 vision: the image-token limit has a default and is enforced (I7, #3690)
The reader was clamped only when the config carried vision.max_tokens; an old, hand-edited or migrated config left it unclamped and
one picture became 93,700 image tokens. Vision.token_limit gives every reader a limit (GPU 1024, CPU 300, the numbers setup writes)
unless the config names one (a value the person set is kept, 0 still means none). strata-vision refuses a picture that comes back
over the limit plus an eighth with a plain ERR instead of passing 90 times the budget on, and prints a WARNING when its own warm-up
picture is over it. The server logs a picture that is more than 4 times the limit with the numbers to send with a report.
2026-10-22 13:10:12 +02:00
Niko1221 bf8285b8c9 Merge f-dflash2-check: the per-context cost table for the cost-aware MTP window (opt-in, STRATA_SPEC_COST), phase 0 tools and launch-script fixes 2026-10-22 13:09:05 +02:00
Niko1221 a71fd34758 agent: custom command tools run in the right shell and folder, with a time the tool can raise (T7)
shell: true meant cmd.exe on Windows (Get-Content failed), the host's own folder (python tools/foo.py found nothing) and a fixed two
minutes with silently cut output. custom-command-run.mjs: a Windows command that reads as PowerShell runs in pwsh or powershell.exe,
any other in cmd.exe as before, sh elsewhere (or the tool's named shell); the folder is the call's optional cwd, then the tool's cwd,
then the home folder, and a missing folder is a plain refusal; the time is the tool's timeoutSec (1 to 3600, default 120) and ends the
programs the command started; a cut output says so. Settings stores cwd, shell and timeoutSec for a command tool and an edit that
does not send them keeps them. No new field in the Settings screen: that needs a design decision.
2026-10-22 13:09:00 +02:00
Niko1221 7d55581527 agent: stop claiming a window opened; point the model at the present tool (T9, O6, #3576)
A launch command that printed nothing was reported as 'The program, folder or file was opened.', and the system prompt told the model
to say it is open. A browser given a file:// address also returns without an error, and the model then reported that it had previewed
the page. The card now says what is known (the command finished without an error, so the program was started; Strata cannot confirm
that a window or page opened). The prompt (and the plan view step) send local images, SVG, HTML and PDF to the present tool and
forbid claiming a window opened or a page rendered unless a tool confirmed it.
2026-10-22 13:07:18 +02:00
Niko1221 034f2fdebe agent: tool servers follow the default model, one naming rule, a dropped server is reported (T5, T6)
T5: the agent's tool servers came from local[0], the first model loaded, not the model the chat runs on. buildProfile takes
defaultLocalId (the native default instance, agent-bridge option defaultLocalId); the agent's default route follows it too.
T6: the editor and the save check accepted 64 characters while the profile dropped everything over 32 or with a dot, silently.
server/mcp-names.mjs holds the one rule (32, letters, digits, hyphen, underscore; src/lib/mcp-names.ts is its copy, a test
keeps them equal). A name saved before the rule stays saveable. mcpRows reports what it leaves out (name, no program or address,
a name that collapses to another's id); the agent status carries skippedServers and the chat shows one notice per reason before
the turn. The sentences are in all eight languages (src/locales/tools-<lang>.json).
2026-10-22 13:06:11 +02:00
Niko1221 c5b13accfd metal: serve_probe.py, the owner round 9 short/long prompt probe on the default config 2026-10-22 13:05:58 +02:00
Niko1221 4791d6b676 agent: the approval gate fails closed when it cannot read the permission mode (T4)
A session whose preset lookup threw ran the call unasked (return next()). It is treated as Manual now: a call that is not provably
read-only asks, a read goes through, and the reason is logged.
2026-10-22 13:03:11 +02:00
Niko1221andAmit Tsur 8bf178febf serve: tool-call aliases and wrapper boundaries (port of PR #3556), and an unparsed call is logged (T1)
Ported from #3556 (Amit Tsur): a declared namespaced tool written with its flat alias (mcp__server__tool for mcp__server.tool) keeps
its schema while streaming and is reported under the declared name; an ambiguous alias is no alias; a quoted incomplete wrapper
(`Format: <tool_call>... </function> and then I explain it.`) stays text; a following call opener separates concatenated calls;
streamed JSON is closed only once the wrapper or the next call confirms the boundary; a call cut by the output limit stays
incomplete. The Responses-only alias map is gone (the parser resolves aliases now).

Reconciled with the 0.2.2 envelope rule: with tools declared, a call for an undeclared name stays text on every route (the PR's
tests that expected a native call for an undeclared name now expect text, or a native call only where no tools were declared and
declared_only is off: the Responses/Codex compaction route keeps that). The aliases attribute replaces the envelope work's _alias
set, so the lenient opener (#3441) and the declared-only gate use the PR's unambiguous alias table.

New rail: a call that named a tool the request did not declare is counted (parser.unread) with the names the model wrote
(parser.unread_names), and the server's existing log line for unread calls now prints them.

Co-authored-by: Amit Tsur <[email protected]>
2026-10-22 13:01:04 +02:00
Niko1221 1fa22cd29a app: the chat renders Markdown images from data: URLs and the chat's own folder; remote pictures stay links (O3)
![alt](src) used to come out as a link with a stray '!', and a data: URI as literal text. Allowlist (src/lib/markdown-images.ts):
data: PNG/JPEG/WebP/GIF (never SVG), the host's /api/files/raw address, and a scheme-less path read through that address for the
active chat. http(s) pictures are not fetched: they show as a link with the alt text. file:, javascript:, blob:, share paths and
every other scheme show as text. One .md-image rule keeps the picture inside the bubble.
2026-10-22 12:58:22 +02:00
Niko1221 e2858b9855 packaging: the picture reader is built per CUDA variant and checked against the package's archs (I1, I2)
build_cuda_linux.sh builds strata-vision for the same architectures as the engine (cuda12 includes sm_60/61/70) and records
vision_archs in BUILD.json; the Dockerfile records it too. packaging/build_vision_windows.bat does the same for the Windows
cuda12 and cuda13 zips (they shared one reader built before Pascal support, #3669). package_engine.py refuses a CUDA package whose
reader's vision_archs do not cover its archs and a macOS package without strata-vision (#3697); --allow-stale-vision and
--no-picture-reader are the explicit exits; --stamp-vision records a hand-built reader. ENGINE-COMPONENT.md describes the release steps.
2026-10-22 12:56:30 +02:00
Niko1221 e6dbbc312a metal: no expert-cache loan for the prompt path (Prefill::relayout and the refill into given-back allocations), auto expert cache capped at 1500 slots; CI: Metal build job on a macOS runner 2026-10-22 12:55:09 +02:00
Niko1221 425bca7199 Agent watchdog: the engine's prompt-read progress counts as activity; local routes get a 60 min stream-idle timeout (#3623)
A local engine reading a long prompt sends the Harness nothing for minutes, so the 4 minute stall limit ended the turn mid-read. The host now hands the bridge the engine's /metrics progress (live.prompt_read, generated, slots); a moving mark keeps the turn alive for at most 30 minutes after the Harness last spoke. An engine that stops reporting still ends at the usual limit. API routes are unchanged.
2026-10-22 12:54:48 +02:00
Niko1221 1298a3971c Chat store: save the chat list at most once a second, serialise only changed chats, cap the saved copy (#3604, #3603)
Every streamed token stringified every chat and wrote the lot to localStorage, which grew the WebView2 renderer to gigabytes. Writes from the streaming path are now coalesced (1 s), flushed when a turn ends, is cancelled or fails, and on pagehide/hidden. Unchanged chats reuse their JSON by object identity; a field over 200k characters, a chat over 1.5M and a list over 3.5M are shortened in the saved copy only. The stored format is unchanged.
2026-10-22 12:52:53 +02:00
Niko1221 c40a03e6dc serve: a tool's picture with an elastic image encoder starts the encoder (I6); a failed encoder start carries its stderr tail, exit status and card hint (I3)
A picture a tool returned wrote to an elastic encoder that was not running (AttributeError, then a 400 that every later
turn repeated). _note_unreadable_tool_images now calls _vision_up and turns a start failure into the usual
[image omitted: why] note; Vision.encode raises a clear ValueError when no reader runs.

vision_start_error no longer returns a blank sentence: last stderr lines, the exit status in words, the seconds of a
timeout, and a kernel-image error (sm_60 on the Windows cuda12 reader, #3669) names the card/architecture and the CPU way out.
2026-10-22 12:52:37 +02:00
Niko1221 eebb870922 dflash2: reserve the drafter's head before the expert cache is sized, loud fallback lines naming the VRAM needed, Blackwell window cap 6 (draft_cap_test) 2026-10-22 12:44:45 +02:00
Niko1221 dd8201d20d metal: link stubs for hc_mean_streams and route_marginal_stats (STRATA_TAP_FILE / STRATA_ROUTE_STATS hooks of the shared Verifier; owner round 9 G6 link failure) 2026-10-22 12:44:40 +02:00
Niko1221 a4c503d7ce nv_defaults: the sm_86 speculative start-up adaptation is opt-in (STRATA_SPEC_ADAPT_SM86=1); owner round 9 measured 0.995 on the A4000s and a length-confounded 3060, so the earlier gain did not reproduce 2026-10-22 12:43:35 +02:00
Niko1221 23b3dfc2ca Merge f-0221-sycl: a Linux SYCL (Intel Arc) engine package recipe and feed entry, --version without GPU access, RUNPATH $ORIGIN; Arc PCs get an engine from the feed 2026-10-22 12:13:33 +02:00
Niko1221 8be3f916f2 Intel Arc engine package: packaging/build_sycl_linux.sh, tests, docs
No release feed ever carried a linux-x86_64-sycl engine package, because no recipe built one (the 0.1.45 package of stream split-s1 was made by
hand). build_sycl_linux.sh builds the JIT engine with oneAPI, bundles the oneAPI runtime and the Unified Runtime adapters with collect_libs.py,
sets RUNPATH $ORIGIN on every ELF file (Intel's libraries have none or a wrong one), packages it with archs intel-arc and checks the package
with --verify, binary_deps.py and an env -i strata --version. _drm_vendor_backend takes the sysfs root so the Arc detection can be tested with a
fake /sys/class/drm; tests/terminal/test_sycl_package.py covers the choice (an Arc PC with no oneAPI tools gets the package, nobody else does,
Windows gets a reason) and the recipe; the release-matrix test has the sycl package.
2026-10-22 12:12:30 +02:00
Niko1221 424b364a88 SYCL engine: strata --version answers before any GPU is touched; no SYCL device is built before main
The migrated generate.cpp had no --version (the CUDA and HIP engines answer it first thing), and three things in dpct's helper headers
built a SYCL device at program start: blas::descriptor's inline _saved_queue_ptr, the dpct::device_memory constructor (pointer table,
mem_mgr, dev_mgr) and its default-built sycl::queue member. On a PC whose user cannot open the GPU the engine therefore aborted with
'No device of requested type available' before it could print anything, which also failed the engine smoke test of strata update.
The headers are edited and sycl/tools/fixups.py reproduces each edit after a re-migration. A frozen first-token-logits run on an Arc A750
(4095-token prompt, IQ3_XXS) is byte-identical before and after.
2026-10-22 12:12:30 +02:00
Niko1221 0788a91c64 Merge f-0221-fix: #3683 test start behind a Windows venv launcher, #3670 certifi 2026.10.19, #3613/#3467 calibrate in the bundle, and the owner's round 8 Terminal fixes (real errors, cards and roles kept, --mtp off, every download planned, engine-version preflight) 2026-10-22 11:33:43 +02:00
Niko1221 ad98bf1d17 N6: Gyro on macOS needs engine 0.1.47 (the Metal TQK6/TQK7/TQ2_T kernels first shipped there) 2026-10-22 11:33:30 +02:00
Niko1221 c67180edf1 Owner round 8: show the real engine error, keep cards and roles, honour --mtp off, plan every download, check the engine version
F5: a start that did not reach ready names the stage, the cause line from the engine log, a next step (render group on Linux, smaller
context, strata update) and the log path; --no-tui and runs without a keyboard never ask 'Keep or move?' and print what was kept.
F3: a context or picture change forwards the installed cards and Main/Helper roles to setup.
N3: --mtp on|off reaches setup, and setup no longer fetches the draft layer for --mtp off.
N4/N2: --plan counts a picture reader unless one sits beside the files, names the --vision off and --mtp off switches, and counts
the ready-made pack (NVFP4) setup would fetch; setup's disk hint is --data-dir under launch.
N5/N6: STRATA_DATA_DIR/install is looked at first, --plan says when an earlier install is the one in use, and an installed engine
older than the model needs is updated or named.
N8: --plan warns when the file headers say another family or model than the one named.
N9: update --check names the package it selected.
N11: the Arc adopt path takes Gyro and the sentence has no double full stop.
2026-10-22 11:32:32 +02:00
Niko1221 a539e12c97 Merge f-strix-hcq8: Strix Halo 8-bit hyper-connection weights by default (gfx1151), sm_86 start-up adaptation default, gfx1151 expert layout mode 8, env-gated DFlash2/spec instrumentation and the opt-in cost-aware MTP window 2026-10-22 10:10:32 +02:00
Niko1221 2a22daa4f0 Fix #3613 #3467: the Terminal/Control bundle ships tools/calibrate.py; a setup crash now says what it was
setup.py imports tools/calibrate.py after a tune, but INSTALLER_TOOLS did not list it, so every launch after a tune died with
ModuleNotFoundError and only 'The model could not be installed'. The list now names it, and add_installer also reads the
tools/ modules setup.py imports so the list cannot go stale again (stage-control.py uses the same list).

A new test stages the bundle the way package_terminal.py does and imports setup.py and its tools with tools/ limited to the bundle.
strata launch keeps the last 400 lines of setup's output in launch/<name>.setup.log, shows the Python error line in plain words
when setup crashed without an [X] line, and names the file.
2026-10-22 10:08:50 +02:00
Niko1221 27a5707de5 Fix #3670: certifi 2026.10.19 (drops the emSign ECC Root CA - G3 root) in requirements.txt and the three runtime locks 2026-10-22 09:38:34 +02:00
Niko1221 72d0bf667e Fix #3683: the test start no longer waits out its 10 minutes when a Windows venv launcher sits above the server
A source checkout's .venv\Scripts\python.exe starts the real interpreter as its child, so the pid in the server's ready file
was one below the pid the runtime host started, the ready file was never matched, no probe was sent and the update ended
with startup_wait_expired. The host now accepts a ready pid within three parents of its child (Windows only).

The test start also shows progress (loading, loaded and waiting for one token) and a wait that runs out says what the engine was
doing, with the kept log, instead of blaming a busy graphics card because the log mentions VRAM.
2026-10-22 09:38:34 +02:00
Niko1221 e7ded666be docs: the Strix Halo 8-bit hyper-connection line goes into a new release-notes-0.2.3.md (0.2.2 is released) 2026-10-22 09:13:17 +02:00
Niko1221 3ae2123bce dflash2: --draft-conf (window cut by the selector's cumulative probability), drafter streams at high priority on CUDA 2026-10-22 08:51:54 +02:00
Niko1221 6d4874a491 hip: 8-bit hyper-connection weights (STRATA_HC_Q8) on by default on gfx1151, STRATA_HC_Q8=0 keeps the bf16 read; one start-up line; docs and 0.2.2 release note with the KL table and the CPU-vs-GPU rounding baseline 2026-10-22 08:16:47 +02:00
Niko1221 b1ebc3d7ca spec: the cost-aware window keeps one row-cost table per context length (< 6K, < 24K, longer), seedable per bucket 2026-10-22 07:24:46 +02:00
Niko1221 ff9ca03f3c dflash2: a verify window whose graph runs out of memory narrows the window instead of failing; --draft-cap, 5 by default on Strix Halo, a plain line on 12 GB NVIDIA 2026-10-22 06:24:00 +02:00
Niko1221 1ef68f83e7 docs: note the STRATA_HC_Q8 measurement on the Strix Halo
+8.4% on json_extract at fixed windows (53.55 -> 58.03 tok/s), 94 of 250
output tokens differ from the BF16 read, cold HC read 93.8 -> 20.3 us
(142 -> 352 GB/s).  Experimental, opt-in only, never a default.
2026-10-21 23:43:02 +02:00
Niko1221 aec327ee51 gr: fold the hyper-connection inject and gate/mean into their neighbours
gr_read launched six kernels per hyper-connection read: norm, down,
gate, mean, inject, write.  The inject projection is now the down grid's
last block (one warp per stream, the same lane stride and warp_sumf as
gr_inject_kernel), and gate + mean are one kernel (one block owns WARPS
columns for every stream, the per-stream gated values meet in shared and
are added in the old c order).  Both keep the old per-row arithmetic and
reduction order, so lo/lq, mixed and inject are bitwise unchanged; gated
is no longer written to global because nothing outside gr_read reads it.
Six launches per read become four.

This is a no-op for the default IQ3_S path on the Strix Halo: that engine
already takes the fused (staged) hyper-connection read, not gr_read.  The
change is kept for the unfused gr_read path (CUDA, other HIP
architectures and --no-fused-gr).

Verified on gfx1151: json_extract at fixed windows (--spec 4
--spec-min-p 0 --suffix-draft 0) gives an identical output line before
and after.
2026-10-21 23:42:55 +02:00
Niko1221 9fe2ad3f55 wip: gfx1151 takes mode 8 gate/up (LDS, fused SwiGLU + q8_1) and the CUDA-layout down for IQ4_NL 2026-10-21 19:55:22 +02:00
Niko1221 ba6966d83f verify: the DFlash2 tap kernel only where the DFlash2 sources are built (Metal and SYCL keep the per-row dump path) 2026-10-21 19:52:11 +02:00
Niko1221 4c953b424d wip (experiment): expert layouts compiled for every HIP arch 2026-10-21 19:46:43 +02:00
Niko1221 6f83fe2c42 dflash2: drop the cache-touch kernel (no gain), wait-commit measurement switch 2026-10-21 19:46:26 +02:00
Niko1221 2ccf57ec46 spec: faster start-up adaptation of the expert tier in speculative runs (swap every 2 verify rounds for the first 200, then the normal interval); default on sm_86 only, STRATA_SPEC_ADAPT_EVERY / STRATA_SPEC_ADAPT_WARM set it elsewhere, an explicit --adapt-every is kept 2026-10-21 19:45:52 +02:00
Niko1221 89799596a6 dflash2: fused conv+residual+norm+quant, q/k norm+rope, K/V ring write and attention-output quantization; stage skip switches and small benches 2026-10-21 16:34:27 +02:00
Niko1221 48ec4a59ac spec: cost-aware window: the first round of a width is replaced by the guess from the other widths, a running mean for the first samples 2026-10-21 16:10:56 +02:00
Niko1221 e0d79b02f9 spec: with STRATA_SPEC_COST a round far above the known cost of its width (the first use captures a graph) counts as a tenth of a sample 2026-10-21 16:04:10 +02:00
Niko1221 d45cd9d3cf spec: STRATA_SPEC_COST_TABLE seeds the row costs (a per-card table), the CLI prints the learned table at the end 2026-10-21 15:35:07 +02:00
Niko1221 5bc7f8cab3 dflash2: tiled Q8 GEMM, fused quantizers, head-major K/V ring with a prefetching attention, forked context update, PLE read-ahead, benches 2026-10-21 15:31:29 +02:00
Niko1221 b7d5ea2f82 native_expert_bench: BENCH_CAP sizes the launch for more possible groups than real ones (what the empty block rows of a verify window cost); bench only 2026-10-21 15:08:13 +02:00
Niko1221 d232721db6 spec: cost-aware chain rule keeps the best width seen and looks one or two drafts ahead (the row cost is not convex); unmeasured widths are probed a few times 2026-10-21 15:03:51 +02:00
Niko1221 5f49ec4599 spec: STRATA_SPEC_COST keeps an MTP draft only while the next row pays at the measured row cost (step-by-step chain rule in MtpDrafter, DraftPolicy::keep_draft) 2026-10-21 14:46:56 +02:00
Niko1221 6878105560 spec: STRATA_SPEC_COST, a cost-aware MTP window (DraftPolicy::best_rows: the window with the best expected tokens per ms from the chain's probabilities, a measured probability-to-acceptance table and the measured row costs); the trace now logs the probabilities the window was chosen from (A/B arm, off by default) 2026-10-21 14:07:25 +02:00
Niko1221 7f97c35aca DFlash2 drafter for Qwen3.8-Flash-Next: Q8_0 pack tool, GPU forward (conv, K/V ring, windowed attention, top-16 selector), permanent verify/prefill taps, --draft dflash2 --draft-model (opt-in, MTP stays the fallback) 2026-10-21 14:04:15 +02:00
Niko1221 651a56806a generate: STRATA_SPEC_TRACE, a per-round trace of the window rows, accepted drafts, round ms and MTP draft probabilities for offline window-length studies (bench only, off by default) 2026-10-21 13:46:06 +02:00
Niko1221 d276adf277 verify: STRATA_ROUTE_STATS (distinct and new-per-row routed experts per layer per window width) and a per-width GPU stage profile line for STRATA_SPEC_T_CYCLE runs (bench only, off by default) 2026-10-21 13:28:02 +02:00
Niko1221 d8f58af1b5 generate: STRATA_SPEC_T_CYCLE, a bench-only cycle of the verify window size with a per-width cost line (off by default) 2026-10-21 13:25:20 +02:00
Niko1221 dcba249d01 verify: STRATA_TAP_FILE, an env-gated dump of the hidden states at chosen layers per verified row (DFlash2 acceptance experiment; off by default, no new graph nodes without it) 2026-10-21 13:25:19 +02:00
Niko1221 5f426c38cb tools/dflash2: q8test, the drafter with Q8_0 matrices keeps its acceptance 2026-10-21 13:24:21 +02:00
Niko1221 128223891e tools/dflash2: the torch port of the DFlash2 drafter and the scripts of the acceptance experiment (research tooling) 2026-10-21 13:24:21 +02:00
Niko1221 17c9b92bc9 generate: STRATA_SPEC_T_CYCLE, a bench-only cycle of the verify window size with a per-width cost line (off by default) 2026-10-21 13:24:21 +02:00
Niko1221 d1dec64362 verify: STRATA_TAP_FILE, an env-gated dump of the hidden states at chosen layers per verified row (DFlash2 acceptance experiment; off by default, no new graph nodes without it) 2026-10-21 13:24:21 +02:00
Niko1221 011c9e600a README: RX 6000 cards get their own engine package in 0.2.2 (no installer of their own; built, not tested on a real card) 2026-10-21 11:07:39 +02:00
Niko1221 76f90e5843 Fix #3425: the memory tiers count RAM + VRAM less 5 GB that Windows and the system keep (setup.py and tools/strata_mcp.py, one constant each; the app and strata launch read the engine's pick; the Mac keeps its own macOS share) 2026-10-21 07:03:33 +02:00
Niko1221 328a3f5bd2 Gyro-M text: 16-21% slower than Q2_0 everywhere (setup's model list, strata_mcp, GYRO.md intro), as README and MODELS.md say 2026-10-21 05:04:54 +02:00
Niko1221 1df3a3108b Merge f-022-int (68864c564) into f-022-gyrocuda: release notes keep both sides 2026-10-21 05:03:36 +02:00
Niko1221 daa654bd35 Gyro docs: the 0.2.2 decode numbers on the pinned files (RTX 3060, 5 pairs, merged tree against f-022-int)
Gyro-S 54.3 -> 62.5 tok/s (+15.2%), Gyro-M 50.2 -> 54.8 (+9.1%), Q2_0 66.4 -> 67.1 (untouched); against Q2_0: Gyro-S 16% -> 7% slower, Gyro-M 23% -> 16%; prompts unchanged.
2026-10-21 05:03:12 +02:00
Niko1221 68864c564e Merge f-022-specfix: first-window HIP verify hang (PLE flag before the first launch of each window graph) + 1-token prompt starts the speculative loop 2026-10-21 03:48:34 +02:00
Niko1221 2c27afec08 docs/GYRO.md: Gyro-M was 24% slower than Q2_0 before 0.2.2 (the measured table says 24) 2026-10-21 02:57:31 +02:00
Niko1221 1c9bfd2a6e Version bump for 0.2.2 (proposed): app 0.2.2, engine and Control 0.1.47
Cargo.toml and Cargo.lock, desktop and host package.json, PRODUCT_VERSION, CMake project and Metal STRATA_VERSION, build_hip_linux PKGVER default, About comment, tests that pin the number, Docker image tags and docs, download names in the READMEs, AMD_HIP.md and UPDATES.md.
2026-10-21 02:57:22 +02:00
Niko1221 be4e24d421 Fix the gfx906 query-swizzle host guard test: the dispatch expression now spans two lines and reads kernel 2026-10-21 02:57:22 +02:00
Niko1221 3a7a6955cf docs: release notes 0.2.2 cover every merged stream (GPU roles, Gyro on Arc/Mac/NVIDIA/P100, models folder, NUMA, RX 6000, API key, own files, speed definition, fixes), what 0.2.1.1-0.2.1.4 already shipped, known limits 2026-10-21 02:26:58 +02:00
Niko1221 4f864bcd2d docs: translated READMEs give Gyro-M's decode gap to Q2_0 as 16-21% (RTX 3060 12 GB: 16%, R9700: 21%), like the English README 2026-10-21 02:22:24 +02:00
Niko1221 2c8eb88196 Merge f-022-gyrocuda: Gyro trellis kernels faster on NVIDIA (8-lane rows on every card above Pascal): Gyro-S +17%, Gyro-M +11% on an RTX 3060, identical answers 2026-10-21 02:20:43 +02:00
Niko1221 e0cfb80f53 Merge f-022-engfix: #3465 NaN retry reads the prompt batched, #3441/#3075 tool-call envelopes, #3503 cuda12 package keeps Pascal/Volta, #3480 queue priming respects GPU_MAX_HW_QUEUES 2026-10-21 01:48:02 +02:00
Niko1221 86d10ed8f9 verify (HIP): the first launch of each window graph reads the PLE rows and raises the PLE flag before cudaGraphLaunch, so the launch cannot wait for a GPU that waits for the flag (first-window hang on gfx1151; STRATA_VERIFY_EARLY_PLE_FLAG=0/1/2) 2026-10-21 01:38:57 +02:00
Niko1221 01f8c32559 Merge f-022-int into f-022-gyrocuda: Pascal path kept for cc 6.0, 8-lane trellis rows for every other CUDA card
iq_kernels.cu is f-022-int's (staged codebook, row chunks and staged activations on cc 6.0; STRATA_TQ_STAGE). On top of it, tq_sub8_on()
(CUDA, not cc 6.0, STRATA_TQ_STAGE / STRATA_TQ_SUB8 not 0) turns on the staged codebook and the staged 8-lane down rows for the other cards
and adds the 8-lane 160-dot gate/up rows (row_dot_160_sub8). RTX 3060, Gyro-M, 24 groups, 1 token: gate/up 142 us (the cc 6.0 path forced:
158, off: 225), down 77 (off: 184); the hashes of the outputs are equal in all three modes.
2026-10-21 01:16:12 +02:00
Niko1221 894f133b7d Fix #3465: after non-finite logits in a prompt window the retry reads the whole prompt batched
The reporter's prompts ended in non-finite logits in the window that reads the prompt's last few tokens (after the last turn boundary), every time and on every retry, and passed with --short-read 0. The cause on their machine (a Windows VM, sm_89, a Broadwell AVX2 CPU, IQ3_S) was not reproduced on a P100 with a Broadwell Xeon, an RTX 3060 or a Core Ultra.
The engine now reads the request that follows such a window with --short-read 0 for that one request, so the server's transparent retry succeeds instead of failing the same way. Nothing changes until a prompt window reports non-finite logits. STRATA_NAN_INJECT=1 exercises it.
2026-10-21 00:52:29 +02:00
Niko1221 8a4106a47e suffix drafter: randomized soundness test (small vocabularies, tiny tables, propose_after), clean under ASan/UBSan 2026-10-21 00:41:25 +02:00
Niko1221 4c3edf4765 generate: spec_loop_runs() names the speculative loop's start rule (+ test); STRATA_SPEC_WATCH=<s>, a stall watchdog with the stage names for the CLI's verify loop (stall report and thread dump, off by default) 2026-10-21 00:41:25 +02:00
Niko1221 f05725c332 Fix #3480: the HIP queue priming stands aside when GPU_MAX_HW_QUEUES is 1 or 2
With the runtime capped to two hardware queues the two primed queues are all of them, and the high-priority draft stream and the draft side stream share them: a 4K prompt on an RX 9060 XT read 681-753 tok/s primed and 1188-1238 unprimed. Primed only when the cap is 3 or more or unset; STRATA_HIP_QUEUE_PRIME=nn still forces it. HIP code only (gfx12, Linux).
2026-10-21 00:37:50 +02:00
Niko1221 8de1ec4165 Test: torch has one pin per Python range since #3474; compare the package set 2026-10-21 00:23:52 +02:00
Niko1221 3fbcea2f79 Merge f-022-issues2: #3488 #3502 #3490 #3507 #3424 #3501 #3504 #3425 #3469 #3437 #3510 2026-10-21 00:23:39 +02:00
Niko1221 dc16e3a7cb Fix #3510: on a card that shares the PC memory (Strix Halo) the Work profile no longer reserves a fifth of the shared pool as graphics memory 2026-10-21 00:23:09 +02:00
Niko1221 5cdb59cfa9 Fix #3469, #3437: UPDATE.bat and setup fetch the engine from the signed update feed when the release has no flat zip, and the minimum engine is 0.1.46 2026-10-21 00:21:24 +02:00
Niko1221 0cf83f610a Merge main (0.2.1.4 Terminal update: RX 9000 engine choice, download checks); engine choice keeps the RX 6000 tie-break 2026-10-21 00:20:01 +02:00
Niko1221 18b4affb87 Fix #3425: the recommendation says how much of the picked size would be read from the SSD when RAM + VRAM is only just the tier's floor 2026-10-21 00:16:23 +02:00
Niko1221 aae92ceb26 Gyro: decode speed on NVIDIA (docs, 0.2.2 notes), Gyro-M's slowdown against Q2_0 16-21%
GYRO.md gets the measured RTX 3060 numbers of the shared-memory codebook / 8-lane row kernels (Gyro-S 53.6 -> 62.8 tok/s, Gyro-M 50.6 -> 56.1, 5 interleaved pairs, identity gate identical), the release notes one line, and the places that said Gyro-M decodes about 20% slower than Q2_0 now say 16-21% (RTX 3060 12 GB after the kernels: 16%, R9700: 17-21%). The translated READMEs keep the old figure.
2026-10-21 00:14:08 +02:00
Niko1221 926e7a38b8 Merge f-022-uma-linux: Strix Halo + discrete card on Linux (no exit with PF_PAD, iGPU never last stage, UMA-aware expert cache, picture reader on a card with its own memory, nicer engine on the display chip); ports PR #3484 by John-Pi 2026-10-21 00:13:34 +02:00
Niko1221 366c8dbaae Fix #3503: the Linux cuda12 engine package carries sm_60/61/70; the source Dockerfile builds it
packaging/build_cuda_linux.sh is the recipe for the Linux cuda13 and cuda12 packages (cuda12: CUDA 12.9, 60;61;70;75;80;86;89, STRATA_EXPERIMENTAL_SM60=ON, driver 575.51). package_engine.py refuses a cuda12 package without sm_60, sm_61 or sm_70, so the 0.2.1 mistake (sm_75/86/89 only, a P100 container in a restart loop) cannot ship again.
The root Dockerfile takes the CUDA image from CUDA_IMAGE: the CUDA 12 case turns on the Pascal/Volta build when an architecture below 75 is listed, names the package cuda12 with the matching driver, and the terminal stage has git (package_terminal.py calls it).
2026-10-21 00:13:14 +02:00
Niko1221 70f4240728 Fix #3441, #3075: a drifted tool-call opener is read as the call; a call for a tool the request did not declare stays text
A <tool_call> whose opener drops the word function (<=NAME>, <function:NAME>, <NAME>) is read as <function=NAME> when NAME is a declared tool.
The answer follows the rule the reasoning already had (#804): a call counts only for a declared tool. Chat Completions and /v1/messages requests that declared no tools get the text back; the Responses route keeps its Codex compaction behaviour.
Blocks that open like a call but do not read as one are counted in the server window.
2026-10-21 00:08:55 +02:00
Niko1221 30a67051d0 setup: say when the BIOS gives a big carve-out to a Strix Halo this run does not use (the OS loses that RAM; the experts and the desktop need it) 2026-10-21 00:02:32 +02:00
Niko1221 b8b381a3b9 Fix #3504: a two-value option (control-vector-layer-range) is written and removed as two argv entries, and an empty Advanced value is no longer a bare flag 2026-10-21 00:02:01 +02:00
Niko1221 25d025fef8 docs: Strix Halo with a card of its own on Linux (RAM counted once, split order, picture reader, nice) 2026-10-21 00:00:22 +02:00
Niko1221 8895949ef7 gpu_fit: a chip that shares the system memory adds only its BIOS carve-out beside the RAM (a Strix Halo model was counted as VRAM and as RAM) 2026-10-21 00:00:22 +02:00
Niko1221 0da82e740c prefill gemm: skip the padded dequant on a device that has no padded product (a gfx12 stage of a gfx1151 + gfx1201 split)
After #3484 the process-wide STRATA_PF_PAD can be on for a gfx12 stage, which then dequantized every weight padded, found no product and dequantized it
again unpadded before the copy path. Ask strata_pf_padded_gemm_available() for the current device first; same products, same bits.
2026-10-21 00:00:22 +02:00
TheHubUser4860938 95cfbd4cc4 Fix #3501: in_trash splits backslashes, so a Windows Recycle Bin path is found on Linux too 2026-10-21 00:00:02 +02:00
Niko1221 c8b1436022 Fix #3507, #3424: first-run 'Use your own GGUF' goes to the local GGUF page instead of making the folder the data folder (which downloaded the files again); preparing own files never uses their folder as data_dir 2026-10-20 23:59:31 +02:00
Niko1221 9de04cd44e Control 0.1.46.3: version bump for the Terminal/Control-only update (engine packages unchanged) 2026-10-20 23:56:06 +02:00
Niko1221 31ca248fbe Progress words: a file being repaired after a wrong SHA-256 says so in plain words
Python only (strata_control/progress_words.py, test).
2026-10-20 23:55:59 +02:00
Niko1221 63214a5655 Downloads: every ranged answer is checked, a file with a wrong SHA-256 is repaired piece by piece instead of deleted
A 39.8 GB shard came out with the wrong SHA-256 on 0.2.1 and was deleted, 40 GB of download lost.

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

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

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

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

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

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

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

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

No files matched your search

+1 -1
View File
@@ -66,7 +66,7 @@ body:
attributes:
label: Strata version
description: "In the app: Settings > About. In the Terminal: `strata --version`. From source: the About tab of the web page, or `engine` in `GET /v1/status`, or the git commit."
placeholder: "Strata 0.2.1 · engine 0.1.46"
placeholder: "Strata 0.2.3 · engine 0.1.48"
validations:
required: true
- type: input
+1 -1
View File
@@ -13,7 +13,7 @@ on:
workflow_dispatch:
inputs:
release_tag:
description: 'Release tag whose engine packages go into the images (for example v0.2.1)'
description: 'Release tag whose engine packages go into the images (for example v0.2.3)'
required: true
type: string
flavors:
+51
View File
@@ -0,0 +1,51 @@
name: Metal build
# Compile and link the Metal engine on a macOS runner (no GPU needed: the kernels are compiled at run time). The shared host files
# (src/core/verify.cpp, src/prefill, src/program/generate.cpp) call kernel entry points that exist only for CUDA / HIP until Metal gets a
# stub or an implementation; a missing one is a link error here, not on a user's Mac (owner round 9, G6).
on:
pull_request:
paths:
- 'src/**'
- 'include/**'
- 'metal/**'
- 'third_party/**'
- 'CMakeLists.txt'
- '.github/workflows/metal-build.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
metal-link:
runs-on: macos-14
timeout-minutes: 45
env:
# setup.py's LLAMA_CPP_COMMIT and metal/setup_mac.py's metal-cpp pin
LLAMA_CPP_COMMIT: 3cf03257f219afbe7334045ff7c6a06ac68c627d
METAL_CPP_URL: https://developer.apple.com/metal/cpp/files/metal-cpp_26.zip
METAL_CPP_SHA256: 4df3c078b9aadcb516212e9cb03004cbc5ce9a3e9c068fa3144d021db585a3a4
steps:
- uses: actions/checkout@v4
- name: Tools
run: brew install ninja
- name: metal-cpp
run: |
curl -fsSL "$METAL_CPP_URL" -o metal-cpp.zip
echo "$METAL_CPP_SHA256 metal-cpp.zip" | shasum -a 256 -c -
unzip -q metal-cpp.zip -d _mcpp
- name: llama.cpp (ggml) at the pinned commit
run: |
mkdir llama.cpp && cd llama.cpp
git init -q
git remote add origin https://github.com/ggml-org/llama.cpp.git
git fetch -q --depth 1 origin "$LLAMA_CPP_COMMIT"
git checkout -q FETCH_HEAD
- name: Configure
run: |
cmake -S metal -B build-metal -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DMETAL_CPP_DIR="$PWD/_mcpp/metal-cpp" -DSTRATA_GGML_DIR="$PWD/llama.cpp"
- name: Build and link the engine
run: cmake --build build-metal --target strata
+2 -2
View File
@@ -2,7 +2,7 @@
Strata runs the Qwen3.8-Flash-Next mixture-of-experts model (and its Coder, Swift 1.5, Unsloth, Gyro and NVFP4
variants, and Qwen3.6-35B-A3B) on a normal PC: one NVIDIA or AMD graphics card plus system RAM on Windows or Linux,
or an Apple Silicon Mac. Version 0.2.1, engine 0.1.46. It has three parts:
or an Apple Silicon Mac. Version 0.2.3, engine 0.1.48. It has three parts:
- **The app** (`apps/desktop`, the Tauri shell; `apps/host`, the Node host and the web UI). It is for people who do
not want a terminal. The installer has the engine inside.
@@ -45,7 +45,7 @@ Follow **[docs/AI_SETUP.md](docs/AI_SETUP.md)**. For an agent that has a shell,
4. Connect the user's apps: `strata connect` lists them, `strata connect <app> --check` sends one prompt and one tool
call the way that app does.
Never expose the server beyond `127.0.0.1` without an API key. `--access lan` makes a key for you. The old route (clone
Keep the server on `127.0.0.1` unless the user asks for more. Beyond it an API key is recommended: `--access lan` makes one for you, and `--no-api-key` serves without one (a warning, never a refusal; on a public tunnel the warning is stronger). The old route (clone
the repository and run `START-HERE.bat --yes` or `./setup.sh --yes`) still works and is the fallback when the
Terminal cannot run. Strata's MCP server ([docs/MCP_SERVER.md](docs/MCP_SERVER.md)) offers the same steps as tools.
+1 -1
View File
@@ -11,7 +11,7 @@ if not exist ".venv\Scripts\python.exe" (
pause
exit /b 1
)
echo Quick speed check. For results other people can compare with (the standard benchmark, 20 to 30 minutes), run: .venv\Scripts\python.exe strata.py bench run
echo Quick speed check. For results other people can compare with (the standard benchmark, a few minutes), run: .venv\Scripts\python.exe strata.py bench run
echo.
".venv\Scripts\python.exe" tools\strata_bench.py %*
set "RC=%ERRORLEVEL%"
+60 -5
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.46.1 LANGUAGES CXX)
project(strata VERSION 0.1.48.1 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)
@@ -336,6 +336,8 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
add_library(strata_core STATIC src/core/device.cu src/core/pinned.cu src/platform/memory.cpp src/core/graph.cpp src/core/weights.cpp
src/core/layout.cpp)
target_include_directories(strata_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
# the DFlash2 drafter (src/core/dflash2.cpp, src/kernels/cuda/dflash2.cu) exists in the CUDA and HIP builds; the Metal and SYCL builds compile without it
target_compile_definitions(strata_core PUBLIC STRATA_HAVE_DFLASH2=1)
if(STRATA_ENABLE_CUDA AND STRATA_EXPERIMENTAL_SM60)
# the runtime device check (src/core/device.cu) admits the cards this build was made for (#236)
target_compile_definitions(strata_core PRIVATE STRATA_EXPERIMENTAL_SM60=1)
@@ -418,6 +420,7 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
src/kernels/cuda/dequant_bf16.cu
src/kernels/cuda/qsa_select.cu
src/kernels/cuda/verify_kernels.cu
src/kernels/cuda/dflash2.cu
src/kernels/cuda/iq_kernels.cu
src/kernels/ngram.cpp
src/ngram/ple_reader.cpp
@@ -579,7 +582,7 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
# about arithmetic, and this one knows the ORDER the arithmetic happens in.
add_library(strata_engine STATIC src/core/layer.cpp src/core/session.cpp src/core/expert_source.cpp src/core/remote_experts.cpp
src/core/remote_expert_opt.cu
src/core/expert_cache.cpp src/core/native_head.cpp src/core/native_dense.cpp src/core/verify.cpp src/core/mtp.cpp
src/core/expert_cache.cpp src/core/native_head.cpp src/core/native_dense.cpp src/core/verify.cpp src/core/mtp.cpp src/core/dflash2.cpp
src/core/conversation_snapshot.cpp src/core/conversation_state.cpp src/core/conversation_memory.cpp src/core/peer_experts.cpp
src/core/arch_defaults.cpp src/core/nv_defaults.cpp src/core/vmm.cpp src/core/conversation_file.cpp)
target_include_directories(strata_engine PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
@@ -732,10 +735,38 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(tune_rocblas PRIVATE hip::host roc::rocblas)
endif()
# DFlash2 drafter tools (not built by default: cmake --build <dir> --target dflash2_parity dflash2_gemm_bench dflash2_attn_bench): the drafter against
# the torch port on a synthetic case (needs the pack and a case file, tools/dflash2/mk_parity_case.py), and the timing of its Q8 GEMM and attention
add_executable(dflash2_parity EXCLUDE_FROM_ALL src/core/dflash2_parity.cpp)
target_include_directories(dflash2_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dflash2_parity PRIVATE strata_engine strata_kernels ${_strata_gpu_runtime_target})
add_executable(dflash2_gemm_bench EXCLUDE_FROM_ALL src/core/dflash2_gemm_bench.cpp)
target_include_directories(dflash2_gemm_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dflash2_gemm_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_executable(dflash2_launch_bench EXCLUDE_FROM_ALL src/core/dflash2_launch_bench.cu)
target_include_directories(dflash2_launch_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dflash2_launch_bench PRIVATE ${_strata_gpu_runtime_target})
add_executable(dflash2_bw_bench EXCLUDE_FROM_ALL src/core/dflash2_bw_bench.cu)
target_include_directories(dflash2_bw_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dflash2_bw_bench PRIVATE ${_strata_gpu_runtime_target})
add_executable(dflash2_small_bench EXCLUDE_FROM_ALL src/core/dflash2_small_bench.cpp)
target_include_directories(dflash2_small_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dflash2_small_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_executable(dflash2_attn_bench EXCLUDE_FROM_ALL src/core/dflash2_attn_bench.cpp)
target_include_directories(dflash2_attn_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dflash2_attn_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_executable(strata src/program/generate.cpp)
strata_utf8_manifest(strata)
target_include_directories(strata PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(strata PRIVATE strata_engine strata_prefill strata_spec ${_strata_gpu_runtime_target})
if(WIN32 AND STRATA_ENABLE_HIP)
# 0.2.1.2: the ROCm math libraries live in <package>\rocm\bin, which is on PATH only when the launcher starts the engine; load them on first
# use (main() adds that folder to the DLL search first) so `strata.exe --version` works from the package folder. amdhip64_7.dll and the
# C++ runtime sit beside the exe (tools/hip/package_windows.py) and stay normal imports.
target_link_libraries(strata PRIVATE delayimp)
target_link_options(strata PRIVATE "LINKER:/DELAYLOAD:hipblas.dll" "LINKER:/DELAYLOAD:libhipblaslt.dll" "LINKER:/DELAYLOAD:rocblas.dll")
endif()
# NEW-MODELS-PLAN.md T1: Qwen3.6-35B-A3B's reference path (one GPU, the model resident, plain kernels), checked
# against llama.cpp by tools/qwen35/; the engine itself does not run the family yet
@@ -985,6 +1016,12 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_include_directories(iq_multi_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(iq_multi_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME iq_multi_parity COMMAND iq_multi_parity)
# the Gyro trellis decode kernels (TQ2_T / TQK6 / TQK7) on synthetic blocks: decode-once vs per-column bitwise, output
# checksums to compare two builds byte for byte, --bench for timings (GPU, no model)
add_executable(tq_pascal_bench src/kernels/tq_pascal_bench.cpp)
target_include_directories(tq_pascal_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(tq_pascal_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME tq_pascal_bench COMMAND tq_pascal_bench)
set_tests_properties(iq_multi_parity PROPERTIES ENVIRONMENT "STRATA_KQ_SPLIT=1") # the Q4_K / Q5_K / Q8_0 Split path against the old kernels
# the same Split path on the other routes it can meet: the generic multi kernels (no V2K) and the fused gate/up + SwiGLU kernel, and the sub-16-row
# gate/up route switched off (a K-quant row has 160 dots, not the 80 the sub-16 rows assume: STRATA_KQ_SPLIT=1 must stay bitwise on all of them)
@@ -1068,7 +1105,6 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(verify_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME verify_parity COMMAND verify_parity --selftest)
endif()
add_executable(fused_gr_bench src/kernels/fused_gr_bench.cpp)
target_include_directories(fused_gr_bench PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(fused_gr_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
@@ -1400,6 +1436,9 @@ if(STRATA_BUILD_TESTS)
add_executable(arch_defaults_test src/core/arch_defaults_test.cpp src/core/arch_defaults.cpp)
target_include_directories(arch_defaults_test PRIVATE include)
add_test(NAME arch_defaults_test COMMAND arch_defaults_test)
add_executable(uma_cache_test src/core/uma_cache_test.cpp)
target_include_directories(uma_cache_test PRIVATE include)
add_test(NAME uma_cache_test COMMAND uma_cache_test)
add_executable(suffix_drafter_test src/spec/suffix_drafter_test.cpp)
target_link_libraries(suffix_drafter_test PRIVATE strata_spec)
add_test(NAME suffix_drafter_test COMMAND suffix_drafter_test)
@@ -1409,6 +1448,9 @@ if(STRATA_BUILD_TESTS)
add_executable(draft_policy_test src/spec/draft_policy_test.cpp)
target_link_libraries(draft_policy_test PRIVATE strata_spec)
add_test(NAME draft_policy_test COMMAND draft_policy_test)
add_executable(spec_start_test src/spec/spec_start_test.cpp)
target_include_directories(spec_start_test PRIVATE include)
add_test(NAME spec_start_test COMMAND spec_start_test)
endif()
if(STRATA_BUILD_TESTS OR STRATA_BUILD_CONVERSATION_TESTS)
add_executable(message_boundary_test src/program/message_boundary_test.cpp)
@@ -1434,6 +1476,10 @@ if(STRATA_BUILD_TESTS OR STRATA_BUILD_CONVERSATION_TESTS)
add_executable(spec_prob_test src/core/spec_prob_test.cpp)
target_include_directories(spec_prob_test PRIVATE include)
add_test(NAME spec_prob_test COMMAND spec_prob_test)
# the per-card window cap of --draft dflash2 (header-only, CPU)
add_executable(draft_cap_test src/core/draft_cap_test.cpp)
target_include_directories(draft_cap_test PRIVATE include)
add_test(NAME draft_cap_test COMMAND draft_cap_test)
# the f32 -> bf16 rounding against ggml's, NaN included, over every f32 bit pattern (header-only, CPU)
add_executable(bf16_bits_test src/kernels/bf16_bits_test.cpp)
target_include_directories(bf16_bits_test PRIVATE include)
@@ -2088,14 +2134,14 @@ if(STRATA_BUILD_TESTS)
add_executable(native_hadamard_test src/kernels/cpu/native_hadamard_test.cpp)
target_link_libraries(native_hadamard_test PRIVATE strata_kernels_cpu strata_artifact)
add_test(NAME native_hadamard_test COMMAND native_hadamard_test)
set_tests_properties(native_hadamard_test PROPERTIES TIMEOUT 300)
set_tests_properties(native_hadamard_test PROPERTIES TIMEOUT 300 SKIP_RETURN_CODE 77)
# the CPU trellis kernels (tq_cpu.hpp): AVX2 / AVX-512 vs the scalar kernel bit for bit, vs ggml-cpu's vec_dot
# and to_float, plain and with the Hadamard rotation; `tq_cpu_parity --bench` is the micro-benchmark
add_executable(tq_cpu_parity src/kernels/cpu/tq_cpu_parity.cpp)
target_link_libraries(tq_cpu_parity PRIVATE strata_kernels_cpu)
add_test(NAME tq_cpu_parity COMMAND tq_cpu_parity)
add_test(NAME tq_cpu_parity_avx2 COMMAND ${CMAKE_COMMAND} -E env STRATA_FORCE_AVX2=1 $<TARGET_FILE:tq_cpu_parity>)
set_tests_properties(tq_cpu_parity tq_cpu_parity_avx2 PROPERTIES TIMEOUT 300)
set_tests_properties(tq_cpu_parity tq_cpu_parity_avx2 PROPERTIES TIMEOUT 300 SKIP_RETURN_CODE 77)
endif()
add_executable(expert_parity src/kernels/cpu/expert_parity.cpp)
@@ -2108,10 +2154,19 @@ if(STRATA_BUILD_TESTS)
add_executable(pool_test src/kernels/cpu/pool_test.cpp)
target_link_libraries(pool_test PRIVATE strata_kernels_cpu)
add_test(NAME pool_test COMMAND pool_test --selftest WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
# where the workers are placed (one per physical core by default) and --pool-cores (real pools, no model)
add_executable(pool_cores_test src/kernels/cpu/pool_cores_test.cpp)
target_link_libraries(pool_cores_test PRIVATE strata_kernels_cpu)
add_test(NAME pool_cores_test COMMAND pool_cores_test)
# the default CPU expert worker count on mock CPUs (8c/16t, 16c/32t, 64c/128t, 2-socket 128c/256t, ...)
add_executable(pool_plan_test src/kernels/cpu/pool_plan_test.cpp)
target_include_directories(pool_plan_test PRIVATE include)
add_test(NAME pool_plan_test COMMAND pool_plan_test)
# 0.2.2: when the pool splits per NUMA node by itself (a fake sysfs node folder, mock cores; STRATA_NUMA_POOL=0, one node = unchanged)
add_executable(numa_pool_test src/kernels/cpu/numa_pool_test.cpp)
target_include_directories(numa_pool_test PRIVATE include)
target_compile_features(numa_pool_test PRIVATE cxx_std_17)
add_test(NAME numa_pool_test COMMAND numa_pool_test)
# #3204: when the HIP start-up warm-up of hipBLAS may run (pure policy, no GPU)
add_executable(warm_policy_test src/prefill/warm_policy_test.cpp)
target_include_directories(warm_policy_test PRIVATE include)
+10 -3
View File
@@ -5,6 +5,8 @@
#
# docker build -t strata:src . # RTX 20/30/40/50 (compiles for each, ~25 min)
# docker build -t strata:src --build-arg CUDA_ARCHITECTURES=86 . # one card only (faster); 86 = RTX 30, 89 = RTX 40, 120 = RTX 50
# docker build -t strata:src-cuda12 --build-arg CUDA_IMAGE=nvidia/cuda:12.9.1-devel-ubuntu24.04 \
# --build-arg CUDA_ARCHITECTURES="60;61;70;75;80;86;89" --build-arg BUILD_VISION=0 . # GTX 10 / P100 / V100 (driver 575+)
# docker run --rm --gpus all -p 127.0.0.1:8080:8080 --ulimit memlock=-1 --shm-size 2g --stop-timeout 120 \
# -v strata-data:/data -e MODEL=IQ3_XXS -e API_KEY=change-me-please-123 strata:src
#
@@ -35,7 +37,9 @@ RUN /venv/bin/python -c "import setup; print(setup.get_llama_cpp())"
RUN set -e; \
real=""; for a in $(echo "$CUDA_ARCHITECTURES" | tr ';,' ' '); do real="$real;${a}-real"; done; real=${real#;}; \
last=$(echo "$CUDA_ARCHITECTURES" | tr ';,' ' ' | awk '{print $NF}'); \
sm60=OFF; for a in $(echo "$CUDA_ARCHITECTURES" | tr ';,' ' '); do if [ "$a" -lt 75 ]; then sm60=ON; fi; done; \
/venv/bin/cmake -G Ninja -S . -B build -DCMAKE_BUILD_TYPE=Release -DSTRATA_ENABLE_CUDA=ON -DSTRATA_BUILD_TESTS=OFF \
-DSTRATA_EXPERIMENTAL_SM60=$sm60 \
-DSTRATA_NATIVE_EXPERTS=ON -DSTRATA_PORTABLE=ON -DSTRATA_Q6K_EXPERTS=ON -DSTRATA_MMQ_KQUANTS=ON \
"-DCMAKE_CUDA_ARCHITECTURES=${real};${last}-virtual" -DSTRATA_GGML_DIR=/src/third_party/llama.cpp; \
/venv/bin/cmake --build build --target strata strata_kq -j "$(nproc)"; \
@@ -50,19 +54,22 @@ RUN set -e; \
[ -f build/libstrata_kq.so ] && cp build/libstrata_kq.so /stage/ || true; \
[ -f build-vision/bin/strata-vision ] && cp build-vision/bin/strata-vision /stage/ || true; \
ver="${ENGINE_VERSION:-$(sed -n 's/^[[:space:]]*project([^)]*VERSION[[:space:]]*\([0-9][0-9.]*\).*/\1/p' CMakeLists.txt | head -1)}"; \
echo "{\"version\": \"$ver\", \"source\": \"docker build\", \"archs\": \"$CUDA_ARCHITECTURES\", \"cuda\": \"13.0\", \"portable\": true}" > /stage/BUILD.json; \
cudafull=$(nvcc --version | sed -n 's/.*release \([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -1); cudamajor=${cudafull%%.*}; \
if [ "$cudamajor" = 13 ]; then mindriver=580.65; else mindriver=575.51; fi; \
vision_archs=""; [ -f /stage/strata-vision ] && vision_archs=", \"vision_archs\": \"$CUDA_ARCHITECTURES\""; \
echo "{\"version\": \"$ver\", \"source\": \"docker build\", \"archs\": \"$CUDA_ARCHITECTURES\", \"cuda\": \"$cudafull\", \"portable\": true${vision_archs}}" > /stage/BUILD.json; \
python3 packaging/collect_libs.py --binary build/strata --search /usr/local/cuda/lib64 --out /stage --also 'libcublasLt.so*'; \
archs=""; for a in $(echo "$CUDA_ARCHITECTURES" | tr ';,' ' '); do archs="$archs --arch sm_$a"; done; \
last=$(echo "$CUDA_ARCHITECTURES" | tr ';,' ' ' | awk '{print $NF}'); \
python3 packaging/package_engine.py --src /stage --backend cuda --version "$ver" --out /engine $archs --arch "compute_$last" \
--min-driver nvidia=580.65 --variant cuda13 --platform linux-x86_64 --build-id "docker-source-${ver}-cuda13.0" --include SYSTEM-LIBS.txt --include 'libstrata_kq*' \
--min-driver nvidia=$mindriver --variant cuda$cudamajor --platform linux-x86_64 --build-id "docker-source-${ver}-cuda${cudafull}" --include SYSTEM-LIBS.txt --include 'libstrata_kq*' \
--include strata-vision; \
for d in /engine/*/; do python3 packaging/package_engine.py --verify "$d"; python3 packaging/binary_deps.py "$d"; done
# ---- 2. the Terminal (the release archive, built here)
FROM ${RUNTIME_BASE} AS terminal
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends python3 ca-certificates && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends python3 ca-certificates git && rm -rf /var/lib/apt/lists/*
COPY . /src/
RUN python3 /src/packaging/package_terminal.py --out /out --cache /cache \
&& mkdir -p /opt/strata \
+14 -14
View File
@@ -33,7 +33,7 @@ Bilder und arbeitet mit deinen Apps und Coding-Agents zusammen. Deine Chats und
- **Kostenlos.** MIT-Lizenz, kein Konto, keine Cloud.
- **Privat.** Keine Telemetrie. Was Strata herunterlädt, wann es das Internet nutzt und wie es Downloads prüft, steht in [docs/SAFETY.md](docs/SAFETY.md).
Version 0.2.1 · Engine 0.1.46
Version 0.2.3 · Engine 0.1.48
## Was du brauchst
@@ -73,16 +73,16 @@ unsicher, welche du brauchst? Lies den Absatz unter der Tabelle.
| System | Deine Grafikkarte | Datei zum Herunterladen |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.1-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda13.exe) |
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.1-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda12.exe) |
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.1-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip.exe) |
| | AMD Radeon RX 7000 Serie oder Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.1-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.1-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.1-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 Serie | [`Strata-0.2.1-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 oder neuer) | eingebaut | [`Strata-0.2.1-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon RX 7000 Serie oder Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 Serie | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 oder neuer) | eingebaut | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**Wenn du unsicher bist:** Bei NVIDIA nimmst du `cuda13`, wenn dein Treiber Version 580 oder neuer ist, sonst `cuda12`.
`cuda12` ist für ältere Karten und ältere Treiber. Die Dateien `hip` und `amd` sind für AMD-Karten. Für andere AMD-Karten
@@ -162,9 +162,9 @@ Größen, mehr oder weniger stark komprimiert. Kleinere Größen sind schneller.
| Größe | Benötigter Speicher (RAM + VRAM) | Download | Was es ist |
| --- | ---: | ---: | --- |
| **Gyro-S** | 27,6 GB | 58,5 GB | Für PCs, auf denen nichts anderes läuft. Die Qualität ist geringer als bei IQ2_XS. |
| **Gyro-M** | 35 GB | 92,0 GB | Ungefähr die Qualität von IQ3_XXS bei etwa 20 % weniger GPU-Speicher. Es schreibt Antworten auf einer 12-GB-Karte und auf einer R9700 17-21 % langsamer als Q2_0. |
| **Gyro-M** | 35 GB | 92,0 GB | Ungefähr die Qualität von IQ3_XXS bei etwa 20 % weniger GPU-Speicher. Es schreibt Antworten langsamer als Q2_0: 16 % auf einer RTX 3060 mit 12 GB, 21 % auf einer R9700. |
Gyro läuft auf NVIDIA- und AMD-Karten (bei AMD experimentell), nicht auf Intel Arc und nicht auf dem Mac. Details und Messwerte:
Gyro läuft auf NVIDIA- und AMD-Karten (bei AMD experimentell) auf Intel-Arc-Karten unter Linux (getestet auf einer Arc A750) und ab 0.2.2 auf Macs mit Apple Silicon (Gyro-S auf einem Mac mini mit 32 GB: 9 Tokens/s, die Experten werden von der SSD gelesen). Details und Messwerte:
[docs/GYRO.md](docs/GYRO.md). Starte es mit `strata launch --model gyro-s`.
### Die anderen Größen
@@ -172,7 +172,7 @@ Gyro läuft auf NVIDIA- und AMD-Karten (bei AMD experimentell), nicht auf Intel
| Dein Speicher (RAM + VRAM) | Nimm | Hinweise |
| --- | --- | --- |
| **32 GB** | **Gyro-S**, oder den **[Coder](docs/MODELS.md#coder)** für Code | Der Coder braucht 32 GB RAM und behält die Hälfte der Experten, deshalb ist er außerhalb von Code schwächer |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), oder **Gyro-M** (35 GB) | Swift denkt kürzer nach, bevor es antwortet. Gyro-M kommt der Qualität von IQ3_XXS nahe, schreibt Antworten aber 17-21 % langsamer als Q2_0 |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), oder **Gyro-M** (35 GB) | Swift denkt kürzer nach, bevor es antwortet. Gyro-M kommt der Qualität von IQ3_XXS nahe, schreibt Antworten aber 16-21 % langsamer als Q2_0 |
| **48 GB** | **IQ3_XXS** (47,0 GB) | Langsamer, besser |
| **64 GB** | **IQ3_S** (54,8 GB), das Original oder Swift 1.5 | Am langsamsten, am besten. Es erreicht in den veröffentlichten Tests das volle Modell |
| **96 GB oder mehr** | **UD-IQ4_XS** von Unsloth (etwa 72 GB RAM) bis **Q8_0** (etwa 136 GB), 4 bis 8 Bit | Näher am vollen Modell. Bei weniger RAM liest Strata einen Teil von der SSD, das ist viel langsamer |
+14 -14
View File
@@ -33,7 +33,7 @@ con tus aplicaciones y tus agentes de programación. Tus chats y tus archivos se
- **Gratis.** Licencia MIT, sin cuenta, sin nube.
- **Privado.** Sin telemetría. Lo que Strata descarga, cuándo usa internet y cómo comprueba las descargas está en [docs/SAFETY.md](docs/SAFETY.md).
Versión 0.2.1 · motor 0.1.46
Versión 0.2.3 · motor 0.1.48
## Lo que necesitas
@@ -73,16 +73,16 @@ sabes cuál elegir? Mira el párrafo que hay debajo de la tabla.
| Sistema | Tu tarjeta gráfica | Archivo que descargar |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.1-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda13.exe) |
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.1-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda12.exe) |
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.1-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip.exe) |
| | AMD Radeon serie RX 7000 o Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.1-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.1-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.1-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx9070.AppImage) |
| | AMD Radeon serie RX 7000 | [`Strata-0.2.1-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 o posterior) | integrada | [`Strata-0.2.1-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon serie RX 7000 o Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon serie RX 7000 | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 o posterior) | integrada | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**Si no estás seguro:** con NVIDIA, elige `cuda13` si la versión de tu controlador es la 580 o posterior, y `cuda12` en caso
contrario. `cuda12` es para tarjetas y controladores más antiguos. Los archivos `hip` y `amd` son para tarjetas AMD. Las
@@ -162,9 +162,9 @@ comprimido. Los tamaños pequeños son más rápidos. Los grandes son algo más
| Tamaño | Memoria que necesita (RAM + VRAM) | Descarga | Qué es |
| --- | ---: | ---: | --- |
| **Gyro-S** | 27,6 GB | 58,5 GB | Para PC donde no cabe nada más. La calidad es menor que la de IQ2_XS. |
| **Gyro-M** | 35 GB | 92,0 GB | Calidad parecida a IQ3_XXS con un 20 % menos de memoria de GPU. Escribe las respuestas un 17-21 % más despacio que Q2_0 en una tarjeta de 12 GB y en una R9700. |
| **Gyro-M** | 35 GB | 92,0 GB | Calidad parecida a IQ3_XXS con un 20 % menos de memoria de GPU. Escribe las respuestas un 16 % más despacio que Q2_0 en una RTX 3060 de 12 GB y un 21 % más despacio en una R9700. |
Gyro funciona en tarjetas NVIDIA y AMD (en AMD es experimental), no en Intel Arc ni en el Mac. Detalles y mediciones:
Gyro funciona en tarjetas NVIDIA y AMD (en AMD es experimental) en tarjetas Intel Arc con Linux (probado en una Arc A750) y, desde 0.2.2, en Mac con Apple Silicon (Gyro-S en un Mac mini de 32 GB: 9 tokens/s, con los expertos leídos del SSD). Detalles y mediciones:
[docs/GYRO.md](docs/GYRO.md). Arráncalo con `strata launch --model gyro-s`.
### Los demás tamaños
@@ -172,7 +172,7 @@ Gyro funciona en tarjetas NVIDIA y AMD (en AMD es experimental), no en Intel Arc
| Tu memoria (RAM + VRAM) | Elige | Notas |
| --- | --- | --- |
| **32 GB** | **Gyro-S**, o el **[Coder](docs/MODELS.md#coder)** para código | El Coder necesita 32 GB de RAM y conserva la mitad de los expertos, así que es más flojo fuera del código |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), o **Gyro-M** (35 GB) | Swift piensa menos tiempo antes de responder. Gyro-M se acerca a la calidad de IQ3_XXS, pero escribe las respuestas un 17-21 % más despacio que Q2_0 |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), o **Gyro-M** (35 GB) | Swift piensa menos tiempo antes de responder. Gyro-M se acerca a la calidad de IQ3_XXS, pero escribe las respuestas un 16-21 % más despacio que Q2_0 |
| **48 GB** | **IQ3_XXS** (47,0 GB) | Más lento, mejor |
| **64 GB** | **IQ3_S** (54,8 GB), el original o Swift 1.5 | El más lento, el mejor. Iguala al modelo completo en las pruebas publicadas |
| **96 GB o más** | **UD-IQ4_XS** de Unsloth (unos 72 GB de RAM) hasta **Q8_0** (unos 136 GB), de 4 a 8 bits | Más cerca del modelo completo. Con menos RAM, Strata lee una parte desde el SSD, que es mucho más lento |
+14 -14
View File
@@ -33,7 +33,7 @@ fonctionne avec vos applications et vos agents de code. Vos conversations et vos
- **Gratuit.** Licence MIT, pas de compte, pas de cloud.
- **Privé.** Aucune télémétrie. Ce que Strata télécharge, quand il utilise internet et comment il vérifie les téléchargements est expliqué dans [docs/SAFETY.md](docs/SAFETY.md).
Version 0.2.1 · moteur 0.1.46
Version 0.2.3 · moteur 0.1.48
## Ce qu'il vous faut
@@ -73,16 +73,16 @@ Vous ne savez pas lequel prendre ? Lisez le paragraphe sous le tableau.
| Système | Votre carte graphique | Fichier à télécharger |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.1-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda13.exe) |
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.1-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda12.exe) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.1-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip.exe) |
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.1-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.1-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.1-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx9070.AppImage) |
| | AMD Radeon série RX 7000 | [`Strata-0.2.1-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 ou plus récent) | intégrée | [`Strata-0.2.1-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon série RX 7000 | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 ou plus récent) | intégrée | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**Si vous hésitez :** pour NVIDIA, prenez `cuda13` si la version de votre pilote est 580 ou plus récente, sinon `cuda12`.
`cuda12` convient aux cartes plus anciennes et aux pilotes plus anciens. Les fichiers `hip` et `amd` sont pour les cartes
@@ -163,9 +163,9 @@ plus ou moins compressé. Les petites tailles sont plus rapides. Les grandes son
| Taille | Mémoire nécessaire (RAM + VRAM) | Téléchargement | Ce que c'est |
| --- | ---: | ---: | --- |
| **Gyro-S** | 27,6 Go | 58,5 Go | Pour les PC où rien d'autre ne tient. La qualité est inférieure à celle d'IQ2_XS. |
| **Gyro-M** | 35 Go | 92,0 Go | À peu près la qualité d'IQ3_XXS avec environ 20 % de mémoire GPU en moins. Il écrit les réponses 17 à 21 % plus lentement que Q2_0 sur une carte de 12 Go et sur une R9700. |
| **Gyro-M** | 35 Go | 92,0 Go | À peu près la qualité d'IQ3_XXS avec environ 20 % de mémoire GPU en moins. Il écrit les réponses 16 % plus lentement que Q2_0 sur une RTX 3060 de 12 Go et 21 % plus lentement sur une R9700. |
Gyro fonctionne sur les cartes NVIDIA et AMD (expérimental sur AMD), pas sur Intel Arc ni sur Mac. Détails et mesures :
Gyro fonctionne sur les cartes NVIDIA et AMD (expérimental sur AMD) sur les cartes Intel Arc sous Linux (testé sur une Arc A750) et, depuis la 0.2.2, sur les Mac Apple Silicon (Gyro-S sur un Mac mini de 32 Go : 9 tokens/s, experts lus sur le SSD). Détails et mesures :
[docs/GYRO.md](docs/GYRO.md). Lancez-le avec `strata launch --model gyro-s`.
### Les autres tailles
@@ -173,7 +173,7 @@ Gyro fonctionne sur les cartes NVIDIA et AMD (expérimental sur AMD), pas sur In
| Votre mémoire (RAM + VRAM) | Prenez | Remarques |
| --- | --- | --- |
| **32 Go** | **Gyro-S**, ou le **[Coder](docs/MODELS.md#coder)** pour le code | Le Coder demande 32 Go de RAM et garde la moitié des experts, il est donc moins bon hors du code |
| **40 Go** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 Go), ou **Gyro-M** (35 Go) | Swift réfléchit moins longtemps avant de répondre. Gyro-M approche la qualité d'IQ3_XXS, mais il écrit les réponses 17 à 21 % plus lentement que Q2_0 |
| **40 Go** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 Go), ou **Gyro-M** (35 Go) | Swift réfléchit moins longtemps avant de répondre. Gyro-M approche la qualité d'IQ3_XXS, mais il écrit les réponses 16 à 21 % plus lentement que Q2_0 |
| **48 Go** | **IQ3_XXS** (47,0 Go) | Plus lent, meilleur |
| **64 Go** | **IQ3_S** (54,8 Go), l'original ou Swift 1.5 | Le plus lent, le meilleur. Il égale le modèle complet dans les tests publiés |
| **96 Go ou plus** | **UD-IQ4_XS** d'Unsloth (environ 72 Go de RAM) jusqu'à **Q8_0** (environ 136 Go), 4 à 8 bits | Plus proche du modèle complet. Avec moins de RAM, Strata lit une partie depuis le SSD, ce qui est beaucoup plus lent |
+14 -14
View File
@@ -33,7 +33,7 @@ Strata は **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next
- **無料です。** MIT ライセンス。アカウントもクラウドも不要です。
- **プライバシーを守ります。** テレメトリはありません。Strata が何をダウンロードするか、いつインターネットを使うか、ダウンロードをどう検証するかは [docs/SAFETY.md](docs/SAFETY.md) に書いてあります。
バージョン 0.2.1 · エンジン 0.1.46
バージョン 0.2.3 · エンジン 0.1.48
## 必要な環境
@@ -72,16 +72,16 @@ Strata は **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next
| OS | グラフィックスカード | ダウンロードするファイル |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.1-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda13.exe) |
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.1-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda12.exe) |
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.1-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip.exe) |
| | AMD Radeon RX 7000 シリーズまたは Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.1-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda13.AppImage) |
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.1-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.1-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 シリーズ | [`Strata-0.2.1-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-strixhalo.AppImage) |
| **Mac**(Apple Silicon、macOS 15 以降) | 内蔵 | [`Strata-0.2.1-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon RX 7000 シリーズまたは Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 シリーズ | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac**(Apple Silicon、macOS 15 以降) | 内蔵 | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**迷ったときは:** NVIDIA の場合、ドライバーがバージョン 580 以降なら `cuda13`、それより古ければ `cuda12` を選びます。`cuda12` は古いカードと古いドライバー向けです。
`hip` と `amd` のファイルは AMD のカード用です。それ以外の AMD カード(RX 6000)には、まだ用意されたアプリがありません。
@@ -156,9 +156,9 @@ AI ツールは [MCP サーバー](docs/MCP_SERVER.md)を通じて、Strata の
| サイズ | 必要なメモリ(RAM + VRAM) | ダウンロード | 内容 |
| --- | ---: | ---: | --- |
| **Gyro-S** | 27.6 GB | 58.5 GB | ほかのサイズが入らないPC向け。品質は IQ2_XS より下がります。 |
| **Gyro-M** | 35 GB | 92.0 GB | 品質は IQ3_XXS 程度で、GPUメモリの使用量は約20%少なくなります。12 GBのカードと R9700 では、回答の書き出しが Q2_0 より17〜21%遅くなります。 |
| **Gyro-M** | 35 GB | 92.0 GB | 品質は IQ3_XXS 程度で、GPUメモリの使用量は約20%少なくなります。回答の書き出しは Q2_0 より、12 GBの RTX 3060 で16%、R9700 で21%遅くなります。 |
Gyro は NVIDIA と AMD のカードで動きます(AMD は実験的)。Intel Arc と Mac では動きません。詳細と測定結果は
Gyro は NVIDIA と AMD のカード(AMD は実験的)、および Linux 上の Intel Arc カード(Arc A750 で確認)で動きます。0.2.2 からは Apple Silicon の Mac でも動きます(32 GB の Mac mini で Gyro-S は 9 トークン/秒、エキスパートは SSD から読み込み)。詳細と測定結果は
[docs/GYRO.md](docs/GYRO.md) にあります。`strata launch --model gyro-s` で起動します。
### ほかのサイズ
@@ -166,7 +166,7 @@ Gyro は NVIDIA と AMD のカードで動きます(AMD は実験的)。Inte
| メモリ(RAM + VRAM) | 選ぶモデル | 備考 |
| --- | --- | --- |
| **32 GB** | **Gyro-S**、またはコード向けの **[Coder](docs/MODELS.md#coder)** | Coder はRAM 32 GBが必要で、エキスパートを半分だけ保持するため、コード以外は弱くなります |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS(39.2 GB)、または **Gyro-M**(35 GB) | Swift は答える前に考える時間が短くなります。Gyro-M は品質が IQ3_XXS に近いものの、回答の書き出しは Q2_0 より17〜21%遅くなります |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS(39.2 GB)、または **Gyro-M**(35 GB) | Swift は答える前に考える時間が短くなります。Gyro-M は品質が IQ3_XXS に近いものの、回答の書き出しは Q2_0 より16〜21%遅くなります |
| **48 GB** | **IQ3_XXS**(47.0 GB) | 遅くなりますが、品質は上がります |
| **64 GB** | **IQ3_S**(54.8 GB)、オリジナルまたは Swift 1.5 | いちばん遅く、いちばん高品質です。公開されているテストでは完全版と同等です |
| **96 GB 以上** | Unsloth の **UD-IQ4_XS**(RAM 約72 GB)から **Q8_0**(約136 GB)まで、4〜8ビット | 完全版に近づきます。RAM が足りないと、Strata は一部を SSD から読むため、ずっと遅くなります |
+20 -17
View File
@@ -33,7 +33,7 @@ agents. Your chats and files stay on your PC.
- **Free.** MIT license, no account, no cloud.
- **Private.** No telemetry. What Strata downloads, when it uses the internet and how it checks downloads is in [docs/SAFETY.md](docs/SAFETY.md).
Version 0.2.1 · engine 0.1.46
Version 0.2.3 · engine 0.1.48
## What you need
@@ -73,20 +73,21 @@ line under the table.
| System | Your graphics card | File to download |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.1-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda13.exe) |
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.1-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda12.exe) |
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.1-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip.exe) |
| | AMD Radeon RX 7000 series or Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.1-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.1-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.1-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 series | [`Strata-0.2.1-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 or newer) | built in | [`Strata-0.2.1-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon RX 7000 series or Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 series | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 or newer) | built in | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**If you are unsure:** for NVIDIA, take `cuda13` if your driver is version 580 or newer, otherwise `cuda12`. `cuda12` is
for older cards and older drivers. The `hip` and `amd` files are for AMD cards. Other AMD cards (RX 6000) have no
ready-made app yet: see [AMD cards](docs/AMD_HIP.md).
for older cards and older drivers. The `hip` and `amd` files are for AMD cards. RX 6000 cards have no installer of
their own: install `hip-rdna3` (Windows) or `amd-rx7900` (Linux), and on a PC with an RX 6000 card it fetches the RX 6000 engine by itself
(also `strata update`). That engine is built but not yet tested on a real RX 6000 card: see [AMD cards](docs/AMD_HIP.md).
Then:
@@ -157,17 +158,19 @@ Smaller sizes are faster. Larger sizes are a bit smarter.
| Size | Memory it needs (RAM + VRAM) | Download | What it is |
| --- | ---: | ---: | --- |
| **Gyro-S** | 27.6 GB | 58.5 GB | For PCs that fit nothing else. Quality is lower than IQ2_XS. |
| **Gyro-M** | 35 GB | 92.0 GB | About IQ3_XXS quality with about 20% less GPU memory. It writes answers 17-21% slower than Q2_0 on a 12 GB card and on an R9700. |
| **Gyro-M** | 35 GB | 92.0 GB | About IQ3_XXS quality with about 20% less GPU memory. It writes answers 16% (12 GB RTX 3060) to 21% (R9700) slower than Q2_0. |
Gyro runs on NVIDIA and AMD cards (AMD is experimental), not on Intel Arc or the Mac. Details and measurements:
Gyro runs on NVIDIA and AMD cards (AMD is experimental) on Intel Arc cards on Linux (tested on an Arc A750) and, from 0.2.2, on Apple Silicon Macs (Gyro-S on a 32 GB Mac mini: 9 tok/s, streaming from the SSD; [docs/MAC.md](docs/MAC.md#gyro-s-and-gyro-m-agentionais-trellis-experts)). Details and measurements:
[docs/GYRO.md](docs/GYRO.md). Start it with `strata launch --model gyro-s`.
### The other sizes
The tiers count your RAM plus your VRAM, less 5 GB that Windows and the system keep for themselves. Every other size stays a choice.
| Your memory (RAM + VRAM) | Take | Notes |
| --- | --- | --- |
| **32 GB** | **Gyro-S**, or the **[Coder](docs/MODELS.md#coder)** for code | The Coder needs 32 GB of RAM and keeps half of the experts, so it is weaker outside code |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39.2 GB), or **Gyro-M** (35 GB) | Swift thinks for a shorter time before it answers. Gyro-M is close to IQ3_XXS in quality, but it writes answers 17-21% slower than Q2_0 |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39.2 GB), or **Gyro-M** (35 GB) | Swift thinks for a shorter time before it answers. Gyro-M is close to IQ3_XXS in quality, but it writes answers 16-21% slower than Q2_0 |
| **48 GB** | **IQ3_XXS** (47.0 GB) | Slower, better |
| **64 GB** | **IQ3_S** (54.8 GB), the original or Swift 1.5 | The slowest, the best: it matches the full model on the published tests |
| **96 GB or more** | Unsloth's **UD-IQ4_XS** (about 72 GB of RAM) to **Q8_0** (about 136 GB), 4 to 8 bit | Closer to the full model. With less RAM, Strata reads part of them from the SSD, which is much slower |
@@ -246,7 +249,7 @@ strata doctor # check the installed engine file
`strata --help` lists everything. Starting and serving never download anything. Only the install, `strata launch` (when
you choose a model) and `strata update` do. To reach Strata from your phone or another PC, use
`strata launch --access lan`. It makes an API key and will not start on a network without one. No browser opens unless
`strata launch --access lan`. It makes an API key (recommended; add `--no-api-key` to go without, with a warning). No browser opens unless
you add `--open`. Guide: [docs/TERMINAL_LAUNCH.md](docs/TERMINAL_LAUNCH.md). Whole client: [docs/TERMINAL.md](docs/TERMINAL.md).
### With AI agents
+14 -14
View File
@@ -33,7 +33,7 @@ agentes de programação. Suas conversas e arquivos ficam no seu PC.
- **Gratuito.** Licença MIT, sem conta, sem nuvem.
- **Privado.** Sem telemetria. O que o Strata baixa, quando usa a internet e como confere os downloads está em [docs/SAFETY.md](docs/SAFETY.md).
Versão 0.2.1 · mecanismo 0.1.46
Versão 0.2.3 · mecanismo 0.1.48
## O que você precisa
@@ -73,16 +73,16 @@ logo abaixo da tabela.
| Sistema | Sua placa de vídeo | Arquivo para baixar |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.1-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda13.exe) |
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.1-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda12.exe) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.1-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip.exe) |
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.1-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.1-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.1-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx9070.AppImage) |
| | AMD Radeon série RX 7000 | [`Strata-0.2.1-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 ou mais novo) | integrada | [`Strata-0.2.1-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon série RX 7000 | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac** (Apple Silicon, macOS 15 ou mais novo) | integrada | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**Se você estiver em dúvida:** para NVIDIA, use `cuda13` se o seu driver for a versão 580 ou mais nova, e `cuda12` nos outros casos. O `cuda12`
serve para placas e drivers mais antigos. Os arquivos `hip` e `amd` são para placas AMD. Outras placas AMD (RX 6000) ainda não têm app pronto: veja [Placas AMD](docs/AMD_HIP.md).
@@ -156,9 +156,9 @@ O [Gyro](https://huggingface.co/agentionai/Qwen3.8-Flash-Next-Gyro-GGUF) (da age
| Tamanho | Memória necessária (RAM + VRAM) | Download | O que é |
| --- | ---: | ---: | --- |
| **Gyro-S** | 27,6 GB | 58,5 GB | Para PCs onde nenhum outro tamanho cabe. A qualidade é menor que a do IQ2_XS. |
| **Gyro-M** | 35 GB | 92,0 GB | Qualidade próxima à do IQ3_XXS, com cerca de 20% menos memória da GPU. Ele escreve as respostas 17 a 21% mais devagar que o Q2_0 numa placa de 12 GB e numa R9700. |
| **Gyro-M** | 35 GB | 92,0 GB | Qualidade próxima à do IQ3_XXS, com cerca de 20% menos memória da GPU. Ele escreve as respostas 16% mais devagar que o Q2_0 numa RTX 3060 de 12 GB e 21% mais devagar numa R9700. |
O Gyro roda em placas NVIDIA e AMD (AMD é experimental), e não roda em Intel Arc nem no Mac. Detalhes e medições:
O Gyro roda em placas NVIDIA e AMD (AMD é experimental) em placas Intel Arc no Linux (testado numa Arc A750) e, desde a 0.2.2, em Macs com Apple Silicon (Gyro-S num Mac mini de 32 GB: 9 tokens/s, com os especialistas lidos do SSD). Detalhes e medições:
[docs/GYRO.md](docs/GYRO.md). Inicie com `strata launch --model gyro-s`.
### Os outros tamanhos
@@ -166,7 +166,7 @@ O Gyro roda em placas NVIDIA e AMD (AMD é experimental), e não roda em Intel A
| Sua memória (RAM + VRAM) | Escolha | Observações |
| --- | --- | --- |
| **32 GB** | **Gyro-S**, ou o **[Coder](docs/MODELS.md#coder)** para código | O Coder precisa de 32 GB de RAM e mantém metade dos experts, então é mais fraco fora de código |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), ou **Gyro-M** (35 GB) | O Swift pensa por menos tempo antes de responder. O Gyro-M chega perto do IQ3_XXS em qualidade, mas escreve as respostas 17 a 21% mais devagar que o Q2_0 |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), ou **Gyro-M** (35 GB) | O Swift pensa por menos tempo antes de responder. O Gyro-M chega perto do IQ3_XXS em qualidade, mas escreve as respostas 16 a 21% mais devagar que o Q2_0 |
| **48 GB** | **IQ3_XXS** (47,0 GB) | Mais lento, melhor |
| **64 GB** | **IQ3_S** (54,8 GB), o original ou o Swift 1.5 | O mais lento, o melhor: empata com o modelo completo nos testes publicados |
| **96 GB ou mais** | **UD-IQ4_XS** da Unsloth (cerca de 72 GB de RAM) até **Q8_0** (cerca de 136 GB), de 4 a 8 bits | Mais perto do modelo completo. Com menos RAM, o Strata lê parte deles do SSD, o que é bem mais lento |
+14 -14
View File
@@ -33,7 +33,7 @@ Strata 运行 **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-N
- **免费。** MIT 许可证,不用注册账号,不走云端。
- **隐私。** 没有遥测。Strata 下载什么、什么时候联网、如何校验下载内容,都写在 [docs/SAFETY.md](docs/SAFETY.md)。
版本 0.2.1 · 引擎 0.1.46
版本 0.2.3 · 引擎 0.1.48
## 硬件要求
@@ -72,16 +72,16 @@ Strata 运行 **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-N
| 系统 | 你的显卡 | 要下载的文件 |
| --- | --- | --- |
| **Windows 10 / 11** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.1-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda13.exe) |
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.1-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-cuda12.exe) |
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.1-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip.exe) |
| | AMD Radeon RX 7000 系列或 Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.1-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda13.AppImage) |
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.1-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.1-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 系列 | [`Strata-0.2.1-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.1-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-linux-amd-strixhalo.AppImage) |
| **Mac**(Apple 芯片,macOS 15 及以上) | 内置 | [`Strata-0.2.1-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.1-mac.dmg) |
| **Windows 10 / 11** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.3-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda13.exe) |
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.3-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-cuda12.exe) |
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.3-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip.exe) |
| | AMD Radeon RX 7000 系列或 Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-windows-hip-rdna3.exe) |
| **Linux** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.3-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda13.AppImage) |
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.3-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-nvidia-cuda12.AppImage) |
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.3-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx9070.AppImage) |
| | AMD Radeon RX 7000 系列 | [`Strata-0.2.3-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-rx7900.AppImage) |
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.3-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-linux-amd-strixhalo.AppImage) |
| **Mac**(Apple 芯片,macOS 15 及以上) | 内置 | [`Strata-0.2.3-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.3-mac.dmg) |
**不确定时:** NVIDIA 显卡的驱动版本在 580 及以上就选 `cuda13`,否则选 `cuda12`。`cuda12` 适用于较旧的显卡和驱动。
`hip` 和 `amd` 的文件是给 AMD 显卡用的。其他 AMD 显卡(RX 6000)目前还没有现成的应用,
@@ -155,9 +155,9 @@ Set up Strata on this PC for me: https://github.com/Niko1221/Strata - follow doc
| 尺寸 | 所需内存(RAM + VRAM) | 下载大小 | 说明 |
| --- | ---: | ---: | --- |
| **Gyro-S** | 27.6 GB | 58.5 GB | 给其他尺寸都放不下的电脑。质量低于 IQ2_XS。 |
| **Gyro-M** | 35 GB | 92.0 GB | 质量约相当于 IQ3_XXS,显存占用少约 20%。在 12 GB 显卡和 R9700 上,输出回答比 Q2_0 慢 17-21%。 |
| **Gyro-M** | 35 GB | 92.0 GB | 质量约相当于 IQ3_XXS,显存占用少约 20%。输出回答比 Q2_0 慢,12 GB 的 RTX 3060 上慢 16%,R9700 上慢 21%。 |
Gyro 可在 NVIDIA 和 AMD 显卡上运行(AMD 为实验性支持),不支持 Intel Arc 和 Mac。详情和测试数据:
Gyro 可在 NVIDIA 和 AMD 显卡(AMD 为实验性支持)以及 Linux 上的 Intel Arc 显卡(已在 Arc A750 上测试)上运行,自 0.2.2 起也可在 Apple Silicon 的 Mac 上运行(32 GB 的 Mac mini 上 Gyro-S 为 9 token/秒,专家权重从 SSD 读取)。详情和测试数据:
[docs/GYRO.md](docs/GYRO.md)。用 `strata launch --model gyro-s` 启动。
### 其他尺寸
@@ -165,7 +165,7 @@ Gyro 可在 NVIDIA 和 AMD 显卡上运行(AMD 为实验性支持),不支
| 你的内存(RAM + VRAM) | 选择 | 备注 |
| --- | --- | --- |
| **32 GB** | **Gyro-S**,写代码可选 **[Coder](docs/MODELS.md#coder)** | Coder 需要 32 GB 内存,只保留一半的专家,所以代码以外的能力较弱 |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS(39.2 GB),或 **Gyro-M**(35 GB) | Swift 回答前思考的时间更短。Gyro-M 的质量接近 IQ3_XXS,但输出回答比 Q2_0 慢 17-21% |
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS(39.2 GB),或 **Gyro-M**(35 GB) | Swift 回答前思考的时间更短。Gyro-M 的质量接近 IQ3_XXS,但输出回答比 Q2_0 慢 16-21% |
| **48 GB** | **IQ3_XXS**(47.0 GB) | 更慢,更好 |
| **64 GB** | **IQ3_S**(54.8 GB),原版或 Swift 1.5 | 最慢,最好:在公开测试中与完整模型持平 |
| **96 GB 及以上** | Unsloth 的 **UD-IQ4_XS**(约 72 GB 内存)到 **Q8_0**(约 136 GB),4 到 8 位 | 更接近完整模型。内存不足时,Strata 会从固态硬盘读取一部分,速度会慢很多 |
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "strata-desktop",
"productName": "Strata",
"version": "0.2.1",
"version": "0.2.3",
"private": true,
"description": "Strata Desktop - local AI, tuned to one model.",
"scripts": {
+1 -1
View File
@@ -3524,7 +3524,7 @@ checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
[[package]]
name = "strata-desktop"
version = "0.2.1"
version = "0.2.3"
dependencies = [
"gtk",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "strata-desktop"
version = "0.2.1"
version = "0.2.3"
description = "Strata Desktop — local AI, tuned to one model."
edition = "2021"
rust-version = "1.77"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "strata",
"version": "0.2.1",
"version": "0.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "strata",
"version": "0.2.1",
"version": "0.2.3",
"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.1",
"version": "0.2.3",
"type": "module",
"description": "Strata \u2014 local AI, tuned to one model. Web shell + engine host.",
"scripts": {
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env node
/**
* The context fixes against a REAL engine (not the scripted worker): Gyro-S at 64K on this PC, the agent bridge and the real Harness.
*
* gpu + heavy locks held first (python phaseA-tests\lock.py acquire gpu "023-agent: context e2e", then heavy); one engine; nothing of the
* installed app is used or touched: its model files are read, its Harness is copied (STRATA_HARNESS_DIR), data goes to a scratch folder.
*
* node scripts/context-e2e.mjs (needs STRATA_HARNESS_DIR; see the E2E_* names below)
*
* It starts serve/server.py as ITS OWN child (foreground: this script waits for it and stops it by PID at the end or on a memory alarm),
* runs four scenarios and prints one table. Exit code 0 when every expectation held.
*
* A a message that can never fit (about 60K tokens in a 64K window) is refused in seconds, before the engine reads it
* B a chat of dense log messages grows past the window several times over: automatic compaction runs, nothing is refused
* C /compact at a high fill: summarises, says the figures; and a second /compact is answered in plain words
* D the needle: a fact from the first message is asked about at the end (reported, not asserted: it is the summary's quality)
*/
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createAgentBridge } from '../server/agent/agent-bridge.mjs';
import { countTokens } from '../server/agent/context-fit.mjs';
const REPO = join(import.meta.dirname, '..', '..', '..');
const env = (name, fallback) => process.env[name] ?? fallback;
const DOCS = env('E2E_DOCS', 'C:\\Users\\AI-Server\\Documents\\Strata');
const ENGINE = env('E2E_ENGINE', 'C:\\Users\\AI-Server\\Desktop\\Strata\\GitHub\\wt-023-int\\build-cuda\\strata.exe');
const PROFILE = env('E2E_EXPERT_PROFILE', 'C:\\Users\\AI-Server\\AppData\\Local\\Programs\\Strata\\control\\data\\expert-profile.bin');
const PYTHON = env('E2E_PYTHON', 'python');
const PORT = Number(env('E2E_PORT', '18590'));
const MIN_FREE_GB = Number(env('E2E_MIN_FREE_GB', '7'));
const GGUF = join(DOCS, 'models', 'gyro-Gyro-S', 'Qwen3.8-Flash-Next-Gyro-S-TQ1_0.gguf');
const CUDA_BIN = env('E2E_CUDA_BIN', 'C:\\Program Files\\NVIDIA GPU Computing Toolkit\\CUDA\\v13.0\\bin');
const WINDOW = 65536;
/** A model server that is already running (the scripted worker, to try this script without a GPU): nothing is started or stopped. */
const EXISTING = process.env.E2E_BASE_URL ?? '';
const sleep = ms => new Promise(r => setTimeout(r, ms));
const freeGb = () => {
const out = spawnSync('powershell', ['-NoProfile', '-Command', '[math]::Round((Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory/1MB,1)'], { encoding: 'utf8' }).stdout.trim();
return Number(out);
};
const rows = [];
const note = (id, ok, detail) => { rows.push({ id, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${id} ${detail}`); };
if (!process.env.STRATA_HARNESS_DIR) { console.error('STRATA_HARNESS_DIR is not set'); process.exit(2); }
if (!EXISTING) for (const f of [ENGINE, GGUF]) if (!existsSync(f)) { console.error(`missing: ${f}`); process.exit(2); }
if (!EXISTING && freeGb() < 20) { console.error(`only ${freeGb()} GB of RAM are free; wait for the other streams (lock.py status) and run again`); process.exit(2); }
const scratch = mkdtempSync(join(tmpdir(), 'strata-ctx-e2e-'));
const configFile = join(scratch, 'server.json');
const logFile = join(scratch, 'server.log');
const readyFile = join(scratch, 'ready.json');
writeFileSync(configFile, JSON.stringify({
exe: ENGINE,
args: ['--pack', join(DOCS, 'packs', 'gyro-gyro-s'), '--native', GGUF, '--ple-gguf', GGUF, ...(existsSync(PROFILE) ? ['--expert-profile', PROFILE] : []),
'--prefill', 'auto', '--spec-min-p', '0.5', '--mtp', join(DOCS, 'mtp', 'rt'), '--max-context', String(WINDOW), '--kv', 'int8', '--pool-workers', '5',
'--expert-cache', 'auto', '--kv-resident', '32768', '--spec', '4', '--vram-reserve-mib', '1536'],
cwd: REPO, tokenizer: join(DOCS, 'packs', 'gyro-gyro-s', 'tokenizer'), model_name: 'qwen3.8-flash-next-gyro-gyro-s', log: join(scratch, 'engine.log'), lib_dirs: [], port: PORT, gpu: 0,
}, null, 2));
let server = null, bridge = null, alarm = null;
const stopServer = () => {
if (server?.pid) spawnSync('taskkill', ['/T', '/F', '/PID', String(server.pid)], { stdio: 'ignore' });
server = null;
};
const finish = async (code) => {
clearInterval(alarm);
try { await bridge?.close(); } catch { /* going away */ }
stopServer();
await sleep(1500);
console.log(`\nscratch folder: ${scratch}`);
process.exit(code);
};
process.on('SIGINT', () => finish(130));
if (!EXISTING) server = spawn(PYTHON, ['serve/server.py', '--engine', 'strata', '--config', configFile, '--port', String(PORT), '--no-api-key', '--ready-file', readyFile], {
cwd: REPO, env: { ...process.env, PATH: `${CUDA_BIN};${process.env.PATH}`, STRATA_DATA_DIR: join(scratch, 'data') }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
});
const sink = [];
if (server) { server.stdout.on('data', d => sink.push(String(d))); server.stderr.on('data', d => sink.push(String(d))); server.on('exit', code => { sink.push(`[server exited ${code}]`); server = null; }); }
// Memory alarm: this run must never be what freezes the PC. Under the floor the engine is stopped (by PID) and the run ends.
if (!EXISTING) alarm = setInterval(() => { const free = freeGb(); if (free < MIN_FREE_GB) { console.error(`ALARM: ${free} GB of RAM free, stopping the engine`); finish(3); } }, 3000);
alarm?.unref?.();
const base = EXISTING || `http://127.0.0.1:${PORT}`;
let ready = false;
for (let i = 0; i < 120 && !ready; i++) {
if (!EXISTING && !server) break;
try { ready = (await fetch(`${base}/health`, { signal: AbortSignal.timeout(2000) })).ok; } catch { /* still loading */ }
if (!ready) await sleep(2000);
}
writeFileSync(logFile, sink.join(''));
if (!ready) { console.error('the engine did not become ready:\n' + sink.join('').slice(-1500)); await finish(4); }
const model = (await (await fetch(`${base}/v1/models`)).json()).data[0].id;
console.log(`engine ready: ${model}, free RAM ${freeGb()} GB`);
// ---------------------------------------------------------------------------------------------------------------- the pieces
const line = i => `2026-10-22 13:${String(Math.floor(i / 60) % 60).padStart(2, '0')}:${String(i % 60).padStart(2, '0')} INFO svc-${i % 17} req=${(i * 2654435761 >>> 0).toString(16)} status=${200 + (i % 7) * 3} ms=${(i * 37) % 900} path=/api/v1/items/${(i * 7919) % 100000}`;
const logText = (chars, from = 0, needle = '') => {
const out = [];
let n = 0, i = from;
while (n < chars) { const l = line(i++); out.push(l); n += l.length + 1; }
if (needle) out.splice(Math.floor(out.length / 2), 0, needle);
return out.join('\n');
};
const count = text => countTokens({ baseUrl: `${base}/v1`, model, text });
const turnsOf = async (message) => {
const frames = [];
const t0 = Date.now();
await bridge.runTurn({ chatId: 'e2e', message, workspacePath: scratch, mode: 'bypass', thinking: 'Off', onFrame: f => frames.push(f) });
return { frames, seconds: (Date.now() - t0) / 1000, last: frames.at(-1) };
};
const textOf = frames => frames.filter(f => f.type === 'delta' && !f.noteReason).map(f => f.text).join('').trim();
const noteOf = frames => frames.find(f => f.type === 'delta' && f.noteReason === 'compacted')?.text?.trim() ?? null;
bridge = createAgentBridge({
dataDir: join(scratch, 'agent'), env: process.env, startTimeoutMs: 120_000, log: l => console.log(' bridge:', l),
localInstances: () => [{ id: 'e2e', label: 'Gyro-S', baseUrl: `${base}/v1`, modelId: model, context: WINDOW, slots: 1, key: '' }], connections: () => [],
});
mkdirSync(join(scratch, 'agent'), { recursive: true });
// ---------------------------------------------------------------------------------------------------------------- A
{
// Grow a log until the engine itself counts about 60K tokens.
let chars = 150_000, tokens = await count(logText(chars));
while (tokens && tokens < 58_000 && chars < 400_000) { chars = Math.round(chars * (60_000 / tokens)); tokens = await count(logText(chars)); }
const t = await turnsOf(logText(chars) + '\n\nWhat is the most common status?');
const refused = t.last.type === 'error' && t.last.code === 'CONTEXT_MESSAGE_TOO_LARGE';
note('A too large is refused before it is read', refused && t.seconds < 15, `${chars} characters = ${tokens} tokens by the engine; answered in ${t.seconds.toFixed(1)} s; frame ${t.last.type}/${t.last.code ?? '-'}: ${String(t.last.message ?? '').slice(0, 110)}`);
}
// ---------------------------------------------------------------------------------------------------------------- B
const needle = 'NOTE FROM OPS: the vault code is COBALT-27. Do not lose it.';
const timeline = [];
let notes = 0, refusedAny = false, peak = 0;
{
for (let k = 0; k < 5; k++) {
const body = logText(34_000, k * 1000, k === 0 ? needle : '');
const t = await turnsOf(`${body}\n\nReply with the single word OK.`);
const note = noteOf(t.frames);
if (note) notes++;
if (t.last.type === 'error') refusedAny = true;
const used = t.last.contextTokens ?? 0;
peak = Math.max(peak, ...t.frames.filter(f => f.type === 'context').map(f => f.contextTokens), used);
timeline.push(`msg${k + 1}: ${t.last.type}${t.last.code ? '/' + t.last.code : ''} ${t.seconds.toFixed(0)} s context ${used} (${Math.round(100 * used / WINDOW)}%)${note ? ' [' + note.slice(0, 80) + ']' : ''}`);
console.log(' ' + timeline.at(-1));
if (t.last.type === 'error') break;
}
note('B five dense messages (about 14K tokens each) in a 64K window: no error', !refusedAny, `peak context ${peak} of ${WINDOW}; ${notes} compaction note(s) in the chat`);
note('B compaction ran and was announced', notes >= 1, timeline.join(' | '));
}
// ---------------------------------------------------------------------------------------------------------------- C
{
const run = async (line) => { const frames = []; await bridge.compactCommand({ chatId: 'e2e', message: line, workspacePath: scratch, mode: 'bypass', choice: null, onFrame: f => frames.push(f) }); return frames; };
await turnsOf(`${logText(30_000, 90_000)}\n\nReply with the single word OK.`);
const t0 = Date.now();
const first = await run('/compact');
const said = first.find(f => f.type === 'delta');
const ok = said?.key === 'Older messages were summarized to make room. The chat was {0}% full and is now {1}% full.' && said.values[0] > said.values[1];
note('C /compact summarises and states the figures', ok, `${((Date.now() - t0) / 1000).toFixed(0)} s: ${said?.text}`);
const second = await run('/compact');
const again = second.find(f => f.type === 'delta')?.text ?? '';
note('C a second /compact is answered in plain words', /summarized|nothing|could not/i.test(again) && !/\{|400|invalid_request/.test(again), again);
}
// ---------------------------------------------------------------------------------------------------------------- D
{
const t = await turnsOf('What was the vault code in the very first message? Answer with the code only.');
const answer = textOf(t.frames);
console.log(`D needle after compaction: "${answer.slice(0, 80)}" (${t.last.type})`);
rows.push({ id: 'D the needle', ok: /COBALT-27/.test(answer), detail: `reported only: answer "${answer.slice(0, 80)}"` });
}
console.log('\n' + rows.map(r => `${r.ok ? 'PASS' : r.id.startsWith('D') ? 'INFO' : 'FAIL'} ${r.id}${r.id.startsWith('D') ? ` ${r.detail}` : ''}`).join('\n'));
const failed = rows.filter(r => !r.ok && !r.id.startsWith('D')).length;
writeFileSync(join(scratch, 'result.json'), JSON.stringify(rows, null, 2));
await finish(failed ? 1 : 0);
+12
View File
@@ -14,6 +14,7 @@ and talks to its Gateway. Nothing here downloads or builds anything.
| `children.mjs` | pure projection of one helper agent's events into its row and its approval card |
| `preset.mjs` | the agent preset (which tools the chat has) |
| `plugins/strata-gate.mjs` | the Manual-mode approval gate (a Harness plugin, loaded by path) |
| `plugins/strata-context.mjs`, `context-budget.mjs`, `context-fit.mjs` | measured context pressure, summaries that fit the window, the check before a message goes out |
| `agent-bridge.mjs` | the chat-to-session map, turns, approvals, Stop, status |
## Frames sent on `/api/chat/stream` for an agent turn
@@ -132,3 +133,14 @@ A turn that keeps failing the same way is stopped by the app, never left to the
- **Compaction:** after a guard stop (and after a turn the model server refused because the context was full) the bridge asks the Harness for `/compact` on that session, so the next message has room. Best effort and logged.
- **Never silent:** a turn that ends (`completed` or `max-tokens`) with no answer text gets "I ran out of room in this chat..." when the last request used 85% of the window or more, otherwise "I stopped without writing an answer..." (not when tool cards were shown, not after Stop, not after an error, which says why by itself). Goal runs have their own ending lines and are not guarded.
- Tests: `tests/agent-turn-guard.test.mjs` (counting, always; end to end with the real Harness, the scripted model's `searchloop` and `bloat` scripts and `searchMock: 'limited'` on the bridge, which runs the real search code on a pretend web that refuses, no network).
## Context: measured pressure, summaries that fit, the check before a message goes out (2026-10-22)
Found by the owner's hands-on test of the installed 0.2.1 (Gyro-S, 64K): a 118 KB log paste was 66,500 tokens to the engine and about 29,500 to the Harness (4 characters a token; logs and code are about 2); the engine refused it with a 400, the refusal stayed in the history, and then neither the next message nor `/compact` could go through, because the summary request carried the same history and was refused for its size too. `/compact` itself was never a command of the app: it went to the model as a question and the model answered "nothing to compact".
- **`plugins/strata-context.mjs`** (inserted by the profile, like the gate) wraps `tokenMeter.measure` and `llm.stream` in place, once each. Pressure = the last prompt size the model server reported for an answer, plus what changed since priced at the density that measurement showed (`context-budget.mjs`: `pressureNow`, `densityOf`); never lower than the Harness's own price. Node prices are scaled the same way, so the retained tail is in real tokens. A summary request (`purpose: 'compaction'`) that would not fit the window is asked for in pieces: a message bigger than a piece is cut for the request only, each piece builds on the summary before it, a piece the server refuses is halved and tried again. `/strata-context` (a Harness command, like `/strata-gate`) answers `{tokens, window}` for a session so the host can tell the person true figures.
- **`context-fit.mjs`**: before a message is sent the model server counts it (`POST /v1/messages/count_tokens`, its own tokenizer). `decideFit`: send; summarise first and then send (the message fits an otherwise empty chat); or never fits. The last one is not sent: the error frame has `code: 'CONTEXT_MESSAGE_TOO_LARGE'` and the page offers "Attach as a file" (`oversize.mjs`, `/api/chat/oversize`: the text is saved in the chat's folder, the assistant reads it in parts) or "Send a shortened preview". An engine that cannot count lets the message through; the Harness's own compact-and-retry is the net.
- **`/compact`** is handled by the host (`compactCommand`): the Harness's `/compact`, then a plain line with the measured figures ("The chat was 90% full and is now 38% full"), or "nothing older to summarize" when the newest message is what fills the chat.
- **Visible:** `compaction/start` shows "Summarizing earlier messages..." in the status line; `compaction/end` adds one plain line to the reply. The projector also sends a `context` frame after every model request, so the meter follows the running turn.
- **A refusal that is left** (`CONTEXT_WINDOW_EXCEEDED`) is a plain sentence with the engine's text under Details and the buttons "Summarize and continue" and "Start a new chat". "Try sending the message again" is for other errors only.
- Tests: `tests/agent-context.test.mjs` (arithmetic; the real Harness against the scripted worker with `window`, `charsPerToken` and `noCount`), `tests/oversize.test.mjs`, `tests/context-ui.test.mjs`. The run against a real engine is `scripts/context-e2e.mjs`.
+148 -9
View File
@@ -22,13 +22,15 @@ import { randomUUID } from 'node:crypto';
import { GatewayClient } from './gateway-client.mjs';
import { HarnessProcess, resolveHarness, redact, stopStaleAgent } from './harness-runtime.mjs';
import { StatusReporter } from './status.mjs';
import { buildProfile, routeFor, MODE_PRESETS, modeOf, effortOf } from './profile.mjs';
import { buildProfile, routeFor, MODE_PRESETS, modeOf, effortOf, localOutputBudget, compactionHeadroom } from './profile.mjs';
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 { SkillsUsed, skillInfoFrom } from './skills-used.mjs';
import { turnEngineSpeed } from './engine-speed.mjs';
import { TurnGuard, guardNote, GUARD_NOTES } from './turn-guard.mjs';
import { BASE_TOKENS, COUNT_ABOVE_CHARS, FIT_TEXT, countTokens, decideFit, fill, grouped } from './context-fit.mjs';
import { PlanStore, planPrompt, parsePlan, parsePoints, stepObjective, stepLine, firstSentence, stepSummary, planAsPrompt, editedSteps, planView, researchPrompt, researchWritePrompt, RESEARCH_NUDGE, parseSources } from './plan.mjs';
/**
@@ -110,11 +112,12 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve
* @param {string|null} [options.componentsDir] where installed components live
* @param {() => Array} options.localInstances ready local instances (see profile.mjs)
* @param {() => Array} options.connections saved API connections WITH their keys (host-internal)
* @param {() => (string|null)} [options.defaultLocalId] the local instance the chat runs on: its tool servers are the agent's (T5)
* @param {NodeJS.ProcessEnv} [options.env]
* @param {string} [options.nodePath] the Node runtime that runs the Harness
* @param {(line:string)=>void} [options.log]
*/
export function createAgentBridge({ dataDir, componentsDir = null, bundledDir = null, gate = true, localInstances, connections, tools = () => undefined, skillsDir = () => null, onGoal = () => {}, onPlan = () => {}, onQueue = () => {}, proxyBase = null, searchMock = null, expertSampling = () => null, stallMs = 4 * 60_000, stallToolMs = 20 * 60_000, env = process.env, nodePath = process.execPath, log = () => {}, startTimeoutMs = 120_000, sessionReadyMs = 60_000, sessionRetryMs = 1_500 }) {
export function createAgentBridge({ dataDir, componentsDir = null, bundledDir = null, gate = true, localInstances, connections, tools = () => undefined, defaultLocalId = () => null, skillsDir = () => null, onGoal = () => {}, onPlan = () => {}, onQueue = () => {}, proxyBase = null, searchMock = null, expertSampling = () => null, engineProgress = () => null, engineGraceMs = 30 * 60_000, stallMs = 4 * 60_000, stallToolMs = 20 * 60_000, env = process.env, nodePath = process.execPath, log = () => {}, startTimeoutMs = 120_000, sessionReadyMs = 60_000, sessionRetryMs = 1_500 }) {
const root = join(dataDir, 'agent');
const home = join(root, 'home');
const sessionsFile = join(root, 'sessions.json');
@@ -170,7 +173,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
/** The agent's profile now, plus where its shaped routes really lead (read by the forwarding endpoint). */
let shapeTargets = {};
function buildProfileNow() {
const profile = buildProfile({ local: localInstances(), connections: connections(), gate, tools: tools(), skillsDir: skillsDir(), proxyBase, searchMock });
const profile = buildProfile({ local: localInstances(), connections: connections(), gate, tools: tools(), skillsDir: skillsDir(), proxyBase, searchMock, defaultLocalId: defaultLocalId() });
shapeTargets = profile.shapeTargets ?? {};
return profile;
}
@@ -192,6 +195,8 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
harness: { version: h.version },
runtime: rt?.hp.running ? { pid: rt.hp.pid, generation: rt.generation } : null,
routes: profile.routes.map(({ route, kind, modelId, name, contextWindow }) => ({ route, kind, modelId, name, contextWindow })),
// tool servers that could not be wired (a name the rule does not allow, no program or address): the chat says so
skippedServers: profile.mcpSkipped ?? [],
activeTurns: activeTurns(),
pendingApprovals: pending.size,
};
@@ -228,6 +233,13 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
return next;
}
/** The local engine's own speed for this turn's requests (null when it cannot be read: the stream's estimate stays). */
async function engineSpeedOf(route, projector) {
const inst = localInstances().find(i => i.id === route.instanceId);
if (!inst) return null;
return turnEngineSpeed({ baseUrl: inst.baseUrl, key: inst.key, since: projector.startedAt, attempts: projector.streams.size, timeoutMs: 800 });
}
/** Make sure a runtime exists that knows the route this turn needs. */
async function ensureRuntime(choice, signal) {
for (;;) {
@@ -398,7 +410,27 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
const appliedMode = planTask ? 'plan' : modeOf(mode);
await applyMode(runtime, sessionId, appliedMode);
const projector = new TurnProjector({ policy: MODE_PRESETS[appliedMode].approval });
// Does the message fit the model's window? Asked before anything is sent, with the model server's own count (context-fit.mjs). A message
// that fits once the older messages are a summary is sent after a summary; one that can never fit is not sent, and the person is told
// plainly and offered the two ways out. Whatever cannot be counted goes through: the Harness recovers from a refusal by itself.
if (!goal && route.kind === 'local') {
const fit = await fitCheck({ runtime, route, sessionId, fresh, history, message }).catch(() => null);
if (fit?.action === 'too-large') {
reporter.clear();
const values = [grouped(fit.tokens), grouped(fit.window)];
const text = fill(FIT_TEXT.tooLarge, values);
emit({ type: 'error', message: text, key: FIT_TEXT.tooLarge, values, code: 'CONTEXT_MESSAGE_TOO_LARGE', tokens: fit.tokens, window: fit.window });
return { error: text };
}
if (fit?.action === 'summarize') {
reporter.set({ phase: 'compacting', code: 'compacting', text: FIT_TEXT.summarizing, key: FIT_TEXT.summarizing, values: [] });
const done = await compactChat(runtime, sessionId);
reporter.clear();
if (done.ok) emit(summarizedNote(done.before, done.after, fit.window));
}
}
const projector = new TurnProjector({ policy: MODE_PRESETS[appliedMode].approval, window: route.contextWindow });
if (!goal) guard = new TurnGuard({ plan: !!planTask, contextMax: route.kind === 'local' ? route.contextWindow : 0 }); // a saved connection's window is often only assumed: never judged by a guess
projector.onStatus = update => { if (!turn?.parentOpen && turn?.children.size && [...turn.children.values()].some(h => !h.done) && update.phase === 'waiting') return; reporter.set(update); };
const turn = {
@@ -407,7 +439,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
abortFromRuntime: reason => finishTurn(reason), finish: failure => finishTurn(failure),
// Helper agents (subagents) the Harness started for this chat, and the state of the run around them.
children: new Map(), subagentCalls: new Map(), parentOpen: false, stopping: false, settleTimer: null, mode: appliedMode,
plan: null, advancing: false, goalRun: false, lastActivity: Date.now(), watchdog: null,
plan: null, advancing: false, goalRun: false, lastActivity: Date.now(), heard: Date.now(), engineMark: null, watchdog: null,
research: researchTopic ? { phase: 'gather', nudged: false, waitingStart: false } : null,
};
c.turn = turn;
@@ -420,6 +452,13 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
if (turn.finished || turn.stopping) return;
const toolRunning = [...projector.tools.values()].some(x => x.card?.state === 'running' || x.card?.state === 'proposed') || pending.size > 0 && pendingFor(chatId).length > 0;
const limit = toolRunning ? stallToolMs : stallMs;
// A local engine reading a long prompt sends the Harness nothing for minutes (#3623). Its /metrics progress counts as activity,
// for at most engineGraceMs after the Harness last spoke: progress for another chat cannot keep a lost turn alive forever.
if (turn.route?.kind === 'local' && Date.now() - turn.heard < engineGraceMs) {
let mark = null;
try { mark = engineProgress(turn.route.instanceId); } catch { /* no progress known: the Harness alone decides */ }
if (mark != null && mark !== turn.engineMark) { turn.engineMark = mark; turn.lastActivity = Date.now(); return; }
}
if (Date.now() - turn.lastActivity < limit) return;
turn.stalledFor = Math.round((Date.now() - turn.lastActivity) / 1000);
turn.stopping = true;
@@ -429,7 +468,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
turn.finish(`The assistant stopped answering for ${mins > 1 ? mins + ' minutes' : 'a minute'}, so I ended this turn. Anything it already finished is kept. Send your message again to go on.`);
}, Math.min(15_000, Math.max(200, Math.floor(stallMs / 4))));
turn.watchdog.unref?.();
function finishTurn(failure) {
async function finishTurn(failure) {
if (turn.finished) return;
turn.finished = true;
clearTimeout(turn.settleTimer);
@@ -450,7 +489,9 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
}
if (noteReason && !extra.some(f => f.type === 'plan' && f.event === 'proposed')) { const note = guardNote(noteReason); if (note) extra.push(note); }
for (const f of extra) emit(f);
const frames = failure ? [{ type: 'error', message: plainAgentFailure(failure), details: failure, code: 'AGENT_STOPPED' }] : projector.finish({ model: route.name, contextMax: route.contextWindow });
// The answer speed of a turn on this computer is the engine's own (tokens it produced over its decode time, as the Monitor shows it).
const engine = !failure && route.kind === 'local' ? await engineSpeedOf(route, projector) : null;
const frames = failure ? [{ type: 'error', message: plainAgentFailure(failure), details: failure, code: 'AGENT_STOPPED' }] : projector.finish({ model: route.name, contextMax: route.contextWindow, engine });
for (const f of frames.slice(0, -1)) emit(f);
// Messages typed while the goal ran and not delivered between steps go back to the page now, to be sent as ordinary messages.
if (turn.goalRun && attached) {
@@ -491,6 +532,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
function handle(item) {
if (turn.finished) return;
turn.lastActivity = Date.now();
turn.heard = turn.lastActivity;
if (item.type === 'event') {
const e = item.event;
if (e.seq <= turn.lastSeq) return;
@@ -528,7 +570,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
emitRow(child, true);
const fold = item => {
if (turn.finished) return;
turn.lastActivity = Date.now();
turn.lastActivity = turn.heard = Date.now();
// A helper that finishes before its throttled "working" row went out would show queued -> completed and never "working": send the held row first.
if (item.type === 'event' && item.event.type === 'turn/end' && child.rowPending) emitRow(child, true);
const { frames, row } = cp.consume(item);
@@ -941,6 +983,16 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
// -- /plan -----------------------------------------------------------------------------------------------
/** A short answer in the chat that the page translates (the sentence is in the interface catalog, "values" fill its slots). */
function sayKey(options, template, values = []) {
const { onFrame } = options;
onFrame({ type: 'queued', requestId: 'compact-' + randomUUID() });
onFrame({ type: 'start', model: '', contextMax: 0, demo: false, remoteModel: false, agent: true, mode: modeOf(options.mode), skills: [] });
onFrame({ type: 'delta', text: fill(template, values), key: template, values });
onFrame({ type: 'done', finishReason: 'stop', tokensIn: null, tokensOut: null, usageSource: 'unavailable', durationMs: 0 });
return { compact: true };
}
/** A short answer in the chat (no model): the same frames a turn sends. */
function sayOnce(options, reply) {
const { onFrame } = options;
@@ -1140,6 +1192,93 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
* Stop one chat's turn. Resolves only once the turn has ended (or the wait times out), because the
* Harness acknowledges a cancel as admitted, not as done.
*/
/** What the chat holds now and the model's window, as the context plugin measures them (null when it cannot be read). */
async function contextOf(runtime, sessionId) {
try {
const r = await runtime.gw.rpc('commands/execute', { agentId: sessionId, line: '/strata-context', submittedAttachments: [] });
if (r?.result?.kind !== 'success') return null;
const v = JSON.parse(String(r.result.text));
return Number.isFinite(v.tokens) ? { tokens: Math.round(v.tokens), window: Number(v.window) > 0 ? Math.round(v.window) : 0 } : null;
} catch { return null; }
}
/** The decision of context-fit.mjs for the message about to be sent, or null when the model server cannot count it. */
async function fitCheck({ runtime, route, sessionId, fresh, history, message }) {
const inst = localInstances().find(i => i.id === route.instanceId);
if (!inst || !(route.contextWindow > 0)) return null;
const carried = fresh && history.length ? withEarlierConversation(history, message) : message;
if (carried.length < COUNT_ABOVE_CHARS) return null;
const count = text => countTokens({ baseUrl: inst.baseUrl, key: inst.key, model: inst.modelId, text });
let tokens = await count(carried);
if (tokens == null) return null;
const now = fresh ? null : await contextOf(runtime, sessionId);
const window = now?.window || route.contextWindow;
const pressure = now?.tokens ?? 0;
// Where the Harness starts summarising by itself on this window (profile.mjs: the reply keeps its room, then the headroom).
const reply = localOutputBudget(window);
const threshold = Math.floor(Math.min(window * 0.8, window - reply - compactionHeadroom(window, reply)));
let verdict = decideFit({ window, pressure, messageTokens: tokens, threshold });
// The earlier conversation carried into a fresh session is what pushed it over: send the message alone instead.
if (verdict.action === 'too-large' && carried !== message) {
const alone = await count(message);
if (alone != null) { tokens = alone; verdict = decideFit({ window, pressure, messageTokens: alone, threshold }); }
}
return { ...verdict, tokens, window, pressure };
}
/**
* Summarise the older part of a chat with the Harness's /compact and say what happened. Never throws.
* ok:true carries the figures before and after; ok:false says why (nothing to summarise, busy, failed).
*/
async function compactChat(runtime, sessionId) {
const before = await contextOf(runtime, sessionId);
let result;
try { result = (await runtime.gw.rpc('commands/execute', { agentId: sessionId, line: '/compact', submittedAttachments: [] }))?.result; }
catch (e) { return { ok: false, reason: /not.found|no such/i.test(String(e.message)) ? 'nothing' : 'failed', before, text: String(e.message ?? e) }; }
const text = String(result?.text ?? '');
log('agent · compact: ' + (result?.kind ?? 'none') + ' ' + text.slice(0, 140));
if (result?.kind === 'success' && /^Compacted/i.test(text)) return { ok: true, before, after: await contextOf(runtime, sessionId) };
if (result?.kind === 'success') return { ok: false, reason: 'nothing', before, text };
if (/active compaction|not idle/i.test(text)) return { ok: false, reason: 'busy', before, text };
// The Harness cannot make a summary smaller than what is left when the chat holds little beyond its first prompt: that is nothing to summarise, not a failure.
const little = before && before.window > 0 && before.tokens - BASE_TOKENS < before.window * 0.25;
return { ok: false, reason: little && /useful summary|not smaller/i.test(text) ? 'nothing' : 'failed', before, text };
}
/** The line in the chat that explains why the context meter fell: how full it was and how full it is. */
function summarizedNote(before, after, window) {
const w = after?.window || before?.window || window || 0;
const pct = v => Math.max(0, Math.min(100, Math.round(100 * v.tokens / w)));
const known = w > 0 && before && after;
const template = known ? FIT_TEXT.summarized : FIT_TEXT.summarizedShort;
const values = known ? [pct(before), pct(after)] : [];
return { type: 'delta', own: true, text: '\n\n' + fill(template, values), key: template, values, noteReason: 'compacted' };
}
/** "/compact": summarise the older messages of this chat now, and say in plain words what happened. */
async function compactCommand(options) {
const { chatId, choice, signal } = options;
const c = chat(chatId);
const say = (template, values = []) => sayKey(options, template, values);
if (c.turn || c.starting) return say(FIT_TEXT.compactBusy);
const known = sessionMap[chatId]?.sessionId;
if (!known) return say(FIT_TEXT.noChatYet);
let runtime;
try { ({ rt: runtime } = await ensureRuntime(choice, signal)); } catch { return say(FIT_TEXT.compactFailed); }
const done = await compactChat(runtime, known);
if (done.ok) {
const note = summarizedNote(done.before, done.after, 0);
return say(note.key, note.values);
}
if (done.reason === 'busy') return say(FIT_TEXT.compactBusy);
if (done.reason === 'nothing') {
const w = done.before?.window || 0;
const pct = w > 0 && done.before ? Math.round(100 * done.before.tokens / w) : 0;
return say(pct >= 60 ? FIT_TEXT.nothingFull : FIT_TEXT.nothingToSummarize, pct >= 60 ? [pct] : []);
}
return say(FIT_TEXT.compactFailed);
}
/** Summarise the older part of a session with the Harness's own /compact. Best effort: the next message compacts again by itself when it must. */
async function compactSession(runtime, sessionId) {
try {
@@ -1194,7 +1333,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
/** Let go of a chat's Harness session mapping (a task starts every run in a session of its own). */
function forget(chatId) { if (chats.get(chatId)?.turn) return; delete sessionMap[chatId]; chats.delete(chatId); persist(); }
return { goalState, goalControl, harnessDir, status, runTurn, goalCommand, planCommand, researchCommand, llmTarget, setPlanBriefing, resetSession, reopenPlan, skipStep, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, 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, goalState, goalControl, harnessDir, status, runTurn, goalCommand, planCommand, researchCommand, llmTarget, setPlanBriefing, resetSession, reopenPlan, skipStep, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, pendingAll, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, workingFolder: chatId => sessionMap[chatId]?.cwd ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
}
/** A goal that stopped by itself, in words. */
+162
View File
@@ -0,0 +1,162 @@
/**
* Token arithmetic for the agent's context, with no I/O (the Harness plugin `plugins/strata-context.mjs` and the host both use it).
*
* Why it exists. The Harness prices a conversation with a fixed 4 characters per token. Code, logs and JSON are about twice as dense
* (2.09 characters per token in the 2026-10-22 hands-on test), so its pressure read 31,794 tokens while the engine had been sent 59,048,
* and compaction waited until the window was already past full. The engine reports the real prompt size with every answer; this module
* turns that report into the number compaction decides on, and splits a summarising request that would not fit the window.
*/
/** The Harness's own price of a text: characters over this, plus a fixed cost per block and per message. */
export const CHARS_PER_TOKEN = 4;
const BLOCK_OVERHEAD = 4;
const MESSAGE_OVERHEAD = 4;
/** The most a real token can be worth in the Harness's units (one character per token is the worst a tokenizer does). */
export const MAX_DENSITY = 6;
/** Room kept free in a window for the engine's own framing and the chat template (the engine keeps 64 tokens of slack itself). */
export const WINDOW_SLACK = 1024;
/** The Harness's price (4 characters a token) of one message, the same arithmetic as its token meter. */
export function estimateMessage(message) {
const content = Array.isArray(message?.content) ? message.content : [];
if (message?.role === 'system') {
if (!content.length) return 0;
let chars = 0;
for (const block of content) chars += block?.type === 'text' ? String(block.text ?? '').length : JSON.stringify(block ?? {}).length;
return Math.ceil(chars / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD;
}
let tokens = MESSAGE_OVERHEAD;
for (const block of content) {
if (block?.type === 'text' || block?.type === 'reasoning') tokens += Math.ceil(String(block.text ?? '').length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
else if (block?.type === 'tool-call') tokens += Math.ceil(String(block.name ?? '').length / CHARS_PER_TOKEN) + Math.ceil(String(block.arguments ?? '').length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
else tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block ?? {}).length / CHARS_PER_TOKEN);
}
return tokens;
}
/** The Harness's price of the tool list of a request header. */
export function estimateTools(header) {
const tools = header?.tools;
if (!Array.isArray(tools) || !tools.length) return 0;
return Math.ceil(JSON.stringify(tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
}
/**
* How many real tokens one of the Harness's tokens is worth, from one measured request: the engine said the prompt was `real` tokens,
* the Harness priced the same prompt at `estimated`. Never below 1 (a conversation of plain prose is priced about right, and a lower
* number would only delay compaction) and never above MAX_DENSITY (a wild sample must not make every step compact).
*/
export function densityOf(real, estimated) {
if (!(real > 0) || !(estimated > 0)) return 1;
return Math.min(MAX_DENSITY, Math.max(1, real / estimated));
}
/**
* The prompt size compaction decides on. `sample` is the last measured request: `real` tokens for a prompt the Harness priced at
* `estimated`. `now` is the Harness's price of the conversation now. What was added or removed since the sample is scaled by the sample's
* density; the measured part is exact. Without a sample, or when the measurement is lower than the Harness's own price, the Harness's
* price stands (it is never an underestimate that makes this smaller).
*/
export function pressureNow({ sample = null, now }) {
if (!sample || !(sample.real > 0) || !(sample.estimated > 0)) return Math.max(0, now);
const density = densityOf(sample.real, sample.estimated);
return Math.max(Math.round(now), Math.round(sample.real + density * (now - sample.estimated)));
}
/** The usage a finished model request reported, as the prompt side only: input plus the cache's reads and writes (output is not in the prompt). */
export function promptTokensOf(usage) {
if (!usage || typeof usage !== 'object') return 0;
const n = value => (Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0);
return n(usage.inputTokens) + n(usage.cacheReadTokens) + n(usage.cacheWriteTokens);
}
/** The usage of a settled assistant event (a `usage` on the message, or the last usage chunk of its stream). */
export function usageOfEvent(event) {
const data = event?.data;
if (!data) return null;
if (data.usage && typeof data.usage === 'object') return data.usage;
const stream = Array.isArray(data.stream) ? data.stream : [];
for (let i = stream.length - 1; i >= 0; i--) if (stream[i]?.type === 'chunk' && stream[i].chunk?.type === 'usage') return stream[i].chunk.usage ?? null;
return null;
}
/**
* A summarising request is: the conversation's system prompt and tools, the messages to condense, then the instruction. This is how many
* real tokens the messages may take in a window so that all of it, the summary and a safety margin fit.
*/
export function summaryBudget({ window, summaryTokens, toolsTokens = 0, systemTokens = 0, instructionTokens = 900, slack = WINDOW_SLACK }) {
return Math.floor(window - summaryTokens - toolsTokens - systemTokens - instructionTokens - slack);
}
/** Text blocks of a message, longest first: the ones worth shortening when a single message is bigger than the budget. */
function textBlocks(message) {
return (Array.isArray(message?.content) ? message.content : []).filter(b => (b?.type === 'text' || b?.type === 'reasoning') && typeof b.text === 'string');
}
/**
* One message cut down to `maxTokens` (in the Harness's price times `density`): the start and the end of its longest text are kept and
* the middle is replaced by a line saying how much was left out. Only the copy sent to the summariser is cut; the conversation keeps
* the whole message until the summary replaces it.
*/
export function clipMessage(message, maxTokens, density = 1) {
if (estimateMessage(message) * density <= maxTokens) return message;
const blocks = textBlocks(message);
if (!blocks.length) return message;
const others = estimateMessage({ ...message, content: (message.content ?? []).filter(b => !blocks.includes(b)) }) * density;
const room = Math.max(256, maxTokens - others);
const share = room / blocks.reduce((n, b) => n + b.text.length, 0) / density * CHARS_PER_TOKEN; // characters kept per character
const content = (message.content ?? []).map((block) => {
if (!blocks.includes(block)) return block;
const keep = Math.max(200, Math.floor(block.text.length * Math.min(1, share) - 120));
if (block.text.length <= keep) return block;
const head = Math.ceil(keep * 0.6), tail = keep - head;
const left = block.text.length - head - tail;
return { ...block, text: `${block.text.slice(0, head)}\n\n[… ${left.toLocaleString('en-US')} characters from the middle are left out of this summary request …]\n\n${block.text.slice(block.text.length - tail)}` };
});
return { ...message, content };
}
/** Whether cutting the list in front of index `i` would separate a tool call from its result. */
function balancedBefore(messages, i) {
let open = 0;
for (let k = 0; k < i; k++) {
const m = messages[k];
if (m?.role === 'assistant') open += (Array.isArray(m.content) ? m.content : []).filter(b => b?.type === 'tool-call').length;
else if (m?.role === 'tool') open -= 1;
}
return open <= 0;
}
/**
* Split messages into runs that each fit `budget` real tokens (price `density`), cutting between a tool call's result and the next
* message when it can. A message bigger than the budget by itself is clipped first. Order is kept.
*/
export function splitMessages(messages, budget, density = 1) {
const room = Math.max(512, budget);
const sized = messages.map(m => clipMessage(m, Math.floor(room * 0.8), density));
const cost = sized.map(m => estimateMessage(m) * density);
const runs = [];
let start = 0, used = 0;
for (let i = 0; i < sized.length; i++) {
if (i > start && used + cost[i] > room) {
// Cut as late as possible, but never between a tool call and its result when a clean place exists inside the run.
let cut = i;
while (cut > start + 1 && !balancedBefore(sized, cut)) cut--;
if (!balancedBefore(sized, cut)) cut = i;
runs.push(sized.slice(start, cut));
start = cut;
used = cost.slice(start, i).reduce((n, x) => n + x, 0);
}
used += cost[i];
}
if (start < sized.length) runs.push(sized.slice(start));
return runs;
}
/** Whether a failure is the model server saying the prompt does not fit its window. */
export function isContextOverflow(error) {
if (!error) return false;
if (error.code === 'CONTEXT_WINDOW_EXCEEDED') return true;
const text = [error.message, error.cause?.message, error.failure?.message, error.failure?.code].filter(Boolean).join(' ');
return /request exceeds the context window|leaves no room to answer|context[_ ]length|context window|too many tokens|maximum context/i.test(text);
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Does a message fit the model's window? Asked before it is sent, with the model server's own count, so a message the server would refuse
* is never read for half a minute first (2026-10-22: a 103 KB paste was 70,141 tokens in a 65,536-token window, and the 400 came back after
* the whole prompt had been read). No I/O except the one count request; everything else is arithmetic the tests call directly.
*
* Three answers: send it; summarise the chat first and then send it (the message fits once the older messages are a summary); or it can
* never fit, and the person is offered the two ways out that do (the text as a file the assistant reads in parts, or a shortened preview).
*/
/** What a new chat already holds before the person's first word: the system prompt and the tool list. Measured 10,510 by the Harness's price. */
export const BASE_TOKENS = 12000;
/** The least room a reply needs after the prompt: an eighth of the window, at most this. */
export const REPLY_FLOOR = 4096;
/** A message shorter than this many characters cannot matter to a window of 8K or more, so it is not even counted. */
export const COUNT_ABOVE_CHARS = 3000;
export const replyRoom = window => Math.max(512, Math.min(REPLY_FLOOR, Math.floor(window / 8)));
/**
* @param {{window:number, pressure?:number, base?:number, messageTokens:number, threshold?:number}} input
* `pressure` is what the chat holds now (measured), `base` what an empty chat holds, `messageTokens` the new message, `threshold` where the
* Harness starts summarising by itself. The Harness looks at that only before its NEXT step, so a big message that arrives now would otherwise
* be sent on top of a chat already near the threshold (2026-10-22: 54K of 64K held by the time it was looked at): summarise first.
* @returns {{action:'send'|'summarize'|'too-large', room:number, needs:number}}
*/
export function decideFit({ window, pressure = 0, base: given = BASE_TOKENS, messageTokens, threshold = 0 }) {
const reply = replyRoom(window);
const base = Math.min(given, Math.floor(window * 0.6)); // on a small window the first prompt cannot be what the window is made of
// A chat holds at least what an empty one does (the system prompt and the tools), so a new chat is not counted as empty.
const needs = Math.max(pressure, base) + messageTokens + reply;
if (!(window > 0) || !(messageTokens >= 0)) return { action: 'send', room: Infinity, needs: 0 };
if (needs <= window) {
const worthIt = pressure > base + window * 0.1; // older messages worth summarising: more than a tenth of the window beyond the first prompt
if (threshold > 0 && worthIt && pressure + messageTokens > threshold) return { action: 'summarize', room: window - needs, needs, why: 'threshold' };
return { action: 'send', room: window - needs, needs };
}
// Not as it stands. Does the message fit in an otherwise empty chat (only the system prompt and the tools before it)?
if (base + messageTokens + reply <= window) return { action: 'summarize', room: window - (base + messageTokens + reply), needs };
return { action: 'too-large', room: window - (base + messageTokens + reply), needs };
}
/**
* The model server's count of a text (its tokenizer and chat template), through its Anthropic-style count endpoint.
* Returns the number, or null when it cannot be had (an old engine, a server that is busy, a timeout): then nothing is blocked.
*/
export async function countTokens({ baseUrl, key = '', model = '', text, fetchImpl = fetch, timeoutMs = 8000 }) {
try {
const root = String(baseUrl ?? '').replace(/\/v1\/?$/, '').replace(/\/$/, '');
const res = await fetchImpl(`${root}/v1/messages/count_tokens`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}`, 'x-api-key': key } : {}) },
body: JSON.stringify({ model, messages: [{ role: 'user', content: String(text) }] }),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) return null;
const n = Number((await res.json())?.input_tokens);
return Number.isFinite(n) && n >= 0 ? Math.round(n) : null;
} catch { return null; }
}
/** The plain sentences (also in the interface catalog in all eight languages). `{0}` slots are filled by the page. */
export const FIT_TEXT = Object.freeze({
tooLarge: 'This message is about {0} tokens long, and this model can read about {1} at a time. It is too large to send as it is.',
tooLargeNext: 'Attach it as a file for the assistant to read in parts, or send a shortened preview.',
full: 'This conversation no longer fits the model’s window, and summarizing the older messages did not make enough room.',
fullNext: 'Summarize it now and continue, or start a new chat.',
summarizing: 'Summarizing earlier messages…',
summarized: 'Older messages were summarized to make room. The chat was {0}% full and is now {1}% full.',
summarizedWas: 'Older messages were summarized to make room. The chat was {0}% full.',
summarizedShort: 'Older messages were summarized to make room.',
nothingFull: 'This chat is {0}% full, but there is nothing older to summarize: the newest message is what fills it. Start a new chat, or send a shorter message.',
nothingToSummarize: 'There is nothing older to summarize yet. Everything in this chat is still needed by the assistant.',
noChatYet: 'There is nothing to summarize yet. This chat has not started a conversation with the assistant.',
compactFailed: 'The older messages could not be summarized right now. Try again in a moment, or start a new chat.',
compactBusy: 'This chat is still working. Stop it or wait for it to finish, then try again.',
});
export const fill = (template, values = []) => String(template).replace(/\{(\d+)\}/g, (_, i) => String(values[Number(i)] ?? ''));
/** A number with thousands separators, the way the page writes it. */
export const grouped = n => Math.round(n).toLocaleString('en-US');
/**
* The two ways to send a message that is too large.
* file the whole text goes in a file in the chat's folder; the message names it and shows how it starts
* preview the start and the end of the text, cut to what the window can hold, with the number of characters left out
* @param {{text:string, mode:'file'|'preview', tokens:number, window:number, pressure?:number, base?:number, path?:string}} input
* @returns {{message:string, kept:number}}
*/
export function oversizedReplacement({ text, mode, tokens, window, pressure = 0, base = BASE_TOKENS, path = '' }) {
const t = String(text);
const charsPerToken = tokens > 0 ? Math.max(0.8, t.length / tokens) : 3;
if (mode === 'file') {
const start = t.slice(0, 1200).trimEnd();
return { kept: start.length, message: `My message was too long to send in the chat, so I saved it as a file: ${path}\n\nIt starts like this:\n\n${start}\n\n[the rest is in the file]\n\nPlease read the file in parts (it is long) and then do what my message asks.` };
}
const room = Math.max(1000, Math.floor((window - Math.max(pressure, base) - replyRoom(window)) * 0.7));
const keep = Math.max(500, Math.floor(room * charsPerToken * 0.9));
const head = Math.ceil(keep * 0.7), tail = keep - head;
const left = Math.max(0, t.length - head - tail);
if (!left) return { kept: t.length, message: t };
return { kept: head + tail, message: `${t.slice(0, head)}\n\n[… ${grouped(left)} characters from the middle are left out because the message was too long to send …]\n\n${t.slice(t.length - tail)}` };
}
@@ -0,0 +1,103 @@
/**
* Runs the command of a command tool the person wrote (Settings -> Tools). Split from custom-tools-mcp.mjs so it can be tested.
*
* T7 (tools and images review, 2026-10-22). It used to run every command with `shell: true` (cmd.exe on Windows, so `Get-Content x` failed with
* "not recognized"), in whatever folder the host happened to run in (so `python tools/foo.py` could not find its file), and ended it after two
* minutes with 20,000 characters of output, silently cut.
*
* shell 'auto' (default): PowerShell for a Windows command that reads as PowerShell (Get-Content, Start-Process, $env:X, $(...)), cmd.exe for
* any other Windows command (so every command that worked keeps working), /bin/sh elsewhere. 'cmd', 'powershell', 'bash' and 'sh' name one.
* cwd the folder to run in: the tool's own, or the one the assistant names in the call (it knows the chat's folder); else the person's home.
* 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 { existsSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
export const DEFAULT_TIMEOUT_SEC = 120;
export const MAX_TIMEOUT_SEC = 3600;
export const OUT_MAX = 20000;
export const SHELLS = ['auto', 'cmd', 'powershell', 'bash', 'sh'];
/** A Windows command that is PowerShell and would fail in cmd.exe. */
const POWERSHELL_LOOKS = /(^|[\s;|(&])[A-Z][A-Za-z]+-[A-Z][A-Za-z]+\b|\$\w|\$\(|\$env:|@\(|\|\s*(?:%|\?)\s*\{/;
export const looksLikePowerShell = command => POWERSHELL_LOOKS.test(String(command ?? ''));
let powershellFound;
/** pwsh when it is installed (PowerShell 7), else the Windows PowerShell every Windows has. */
function powershellExe(platform) {
if (powershellFound) return powershellFound;
if (platform !== 'win32') return (powershellFound = 'pwsh');
try { if (spawnSync('where', ['pwsh'], { windowsHide: true, timeout: 5000 }).status === 0) return (powershellFound = 'pwsh'); } catch { /* none */ }
return (powershellFound = 'powershell.exe');
}
/**
* -> { file, args, shell } for spawn: which program runs `command`.
* @param {string} command
* @param {{shell?: string, platform?: string}} [options]
*/
export function planShell(command, { shell = 'auto', platform = process.platform } = {}) {
const windows = platform === 'win32';
let choice = SHELLS.includes(shell) ? shell : 'auto';
if (choice === 'auto') choice = windows ? (looksLikePowerShell(command) ? 'powershell' : 'cmd') : 'sh';
if (choice === 'powershell') return { file: powershellExe(platform), args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command], shell: false, name: 'PowerShell' };
if (choice === 'bash') return { file: 'bash', args: ['-c', command], shell: false, name: 'bash' };
if (choice === 'sh') return windows ? { file: 'sh', args: ['-c', command], shell: false, name: 'sh' } : { file: command, args: [], shell: true, name: 'sh' };
return { file: command, args: [], shell: true, name: windows ? 'cmd.exe' : 'sh' }; // cmd: spawn's shell:true is cmd.exe on Windows
}
/** The folder a command runs in: -> { cwd } or { error } with a plain sentence. */
export function resolveCwd(...candidates) {
for (const candidate of candidates) {
const text = typeof candidate === 'string' ? candidate.trim() : '';
if (!text) continue;
const expanded = text.replace(/^~(?=$|[\\/])/, homedir());
try {
if (!existsSync(expanded) || !statSync(expanded).isDirectory()) return { error: `The folder “${text}” does not exist, so the command was not run.` };
} catch { return { error: `The folder “${text}” could not be opened, so the command was not run.` }; }
return { cwd: expanded };
}
return { cwd: homedir() };
}
export const clampTimeoutSec = value => {
const n = Number(value);
return Number.isFinite(n) && n >= 1 ? Math.min(Math.floor(n), MAX_TIMEOUT_SEC) : DEFAULT_TIMEOUT_SEC;
};
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 */ }
}
/**
* @param {{command: string, input?: string, cwd?: string, callCwd?: string, shell?: string, timeoutSec?: number, platform?: string}} job
* @returns {Promise<{ok: boolean, text: string}>}
*/
export function runCommand({ command, input = '', cwd, callCwd, shell = 'auto', timeoutSec, platform = process.platform }) {
return new Promise(resolve => {
const where = resolveCwd(callCwd, cwd);
if (where.error) return resolve({ ok: false, text: where.error });
const plan = planShell(command, { shell, platform });
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'] });
} 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; };
child.stdout.on('data', take); child.stderr.on('data', take);
const timer = setTimeout(() => { timedOut = true; endTree(child); }, limit * 1000);
child.on('error', e => { clearTimeout(timer); resolve({ ok: false, text: `The command could not start in ${plan.name}: ${e.message}` }); });
child.on('close', code => {
clearTimeout(timer);
if (text.length > OUT_MAX) { cut = true; text = text.slice(0, OUT_MAX); }
const out = text.trim() + (cut ? `\n\n(The output was cut after ${OUT_MAX.toLocaleString('en-US')} characters.)` : '');
if (timedOut) return resolve({ ok: false, text: `The command was stopped after ${limit} seconds.${out ? '\n' + out : ''}` });
resolve({ ok: code === 0, text: out || (code === 0 ? 'The command finished without any error. It printed nothing.' : `The command failed (exit code ${code}) and printed nothing.`) });
});
});
}
+9 -23
View File
@@ -8,49 +8,35 @@
* It speaks the Model Context Protocol over stdio (newline-delimited JSON-RPC), the transport the Harness's MCP client uses for a program
* it starts. The file is re-read on every request, so a change in Settings is seen without restarting anything. The assistant's text for
* a command tool reaches the command in the STRATA_INPUT environment variable (never spliced into the command line, so it cannot add
* commands of its own). A command gets 2 minutes and 20,000 characters of output.
* commands of its own). A command runs in the shell that suits it (PowerShell for a Windows command that reads as PowerShell, cmd.exe for
* the rest, sh elsewhere), in its own folder or the one the assistant names, gets 2 minutes unless its tool allows more (up to an hour) and
* 20,000 characters of output (custom-command-run.mjs).
*/
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { createInterface } from 'node:readline';
import { runCommand } from './custom-command-run.mjs';
const [mode, file] = process.argv.slice(2);
if (!['instructions', 'commands'].includes(mode) || !file) { console.error('usage: custom-tools-mcp.mjs instructions|commands <tools.json>'); process.exit(2); }
const load = () => { try { return JSON.parse(readFileSync(file, 'utf8'))[mode] ?? {}; } catch { return {}; } };
const OUT_MAX = 20000;
const TIME_MS = 120000;
const schema = mode === 'commands'
? { type: 'object', properties: { input: { type: 'string', description: 'Text to give the command (optional).' } }, additionalProperties: false }
? { type: 'object', properties: {
input: { type: 'string', description: 'Text to give the command (optional).' },
cwd: { type: 'string', description: 'The folder to run the command in (optional). Give the working folder of this chat when the command uses relative paths.' },
}, additionalProperties: false }
: { type: 'object', properties: {}, additionalProperties: false };
function listTools() {
return Object.entries(load()).map(([name, t]) => ({ name, description: String(t.description ?? ''), inputSchema: schema, annotations: mode === 'instructions' ? { readOnlyHint: true } : {} }));
}
function runCommand(command, input) {
return new Promise(resolve => {
const child = spawn(command, { shell: true, windowsHide: true, env: { ...process.env, STRATA_INPUT: String(input ?? '') }, stdio: ['ignore', 'pipe', 'pipe'] });
let text = '';
const take = chunk => { if (text.length < OUT_MAX) text += chunk.toString(); };
child.stdout.on('data', take); child.stderr.on('data', take);
const timer = setTimeout(() => { try { child.kill(); } catch { /* gone */ } }, TIME_MS);
child.on('error', e => { clearTimeout(timer); resolve({ ok: false, text: `The command could not start: ${e.message}` }); });
child.on('close', (code, signal) => {
clearTimeout(timer);
const out = text.slice(0, OUT_MAX).trim();
if (signal) resolve({ ok: false, text: `The command was stopped after ${TIME_MS / 1000} seconds.${out ? '\n' + out : ''}` });
else resolve({ ok: code === 0, text: out || (code === 0 ? 'The command finished without any error. It printed nothing.' : `The command failed (exit code ${code}) and printed nothing.`) });
});
});
}
async function call(name, args) {
const t = load()[name];
if (!t) return { isError: true, content: [{ type: 'text', text: 'That tool is no longer available.' }] };
if (mode === 'instructions') return { content: [{ type: 'text', text: String(t.text ?? '') }] };
const r = await runCommand(String(t.command ?? ''), args?.input);
const r = await runCommand({ command: String(t.command ?? ''), input: args?.input, callCwd: args?.cwd, cwd: t.cwd, shell: t.shell, timeoutSec: t.timeoutSec });
return { isError: !r.ok, content: [{ type: 'text', text: r.text }] };
}
@@ -0,0 +1,11 @@
/**
* How far a local engine has got, as a short text that changes whenever it makes progress. The agent's stall watchdog
* compares it with the last one it saw: while a long prompt is read the Harness hears nothing for minutes, but the
* engine's /metrics (polled every 2 s) shows `live.prompt_read` moving (#3623). Null when the engine is idle or says nothing.
*/
export function engineProgressMark(instance) {
const live = instance?.metrics?.live;
if (!live || live.state === 'idle' || live.state === 'unloaded') return null;
const slots = Array.isArray(live.slots) ? live.slots.map(s => `${s?.state}:${s?.prompt_read ?? ''}:${s?.generated ?? ''}`).join(',') : '';
return `${live.state}|${live.prompt_read ?? ''}|${live.generated ?? ''}|${slots}`;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* The engine's own answer speed for one assistant turn: every token its requests produced (thinking, answer and tool calls; with
* MTP drafting only the tokens it kept) over the time they spent decoding. These are the numbers the Monitor and `/metrics` show,
* so the chat footer reads the same. Nothing here is a guess from the stream: it returns null when the engine does not say.
*/
/** Sum the engine's finished requests that began at or after `since` (ms). `attempts` is how many model requests the turn made:
* when the engine lists more (another chat ran at the same time), only the newest `attempts` are the turn's. */
export function sumEngineRequests(requests, { since, attempts = Infinity } = {}) {
if (!Array.isArray(requests)) return null;
const mine = requests
.filter(r => Number.isFinite(r?.time) && r.time * 1000 >= since - 1500)
.sort((a, b) => a.time - b.time)
.slice(-Math.max(1, attempts));
let tokens = 0, decodeMs = 0, promptRead = 0, promptMs = 0;
for (const r of mine) {
const made = Number.isFinite(r.engine_generated) ? r.engine_generated : r.output_tokens;
if (!(made > 0) || !(r.decode_ms > 0)) continue;
tokens += made; decodeMs += r.decode_ms;
const read = Number.isFinite(r.prompt_read) ? r.prompt_read : (r.prompt_tokens ?? 0) - (r.reused ?? 0);
if (read > 0 && r.prompt_ms > 0) { promptRead += read; promptMs += r.prompt_ms; }
}
if (!(tokens > 0 && decodeMs > 0)) return null;
return {
tokens, decodeMs, tps: Number((tokens / (decodeMs / 1000)).toFixed(1)),
promptTps: promptRead > 0 && promptMs > 0 ? Number((promptRead / (promptMs / 1000)).toFixed(1)) : null,
};
}
/** Ask a local engine for its request history. Any failure is null: the caller keeps the stream's own estimate. */
export async function turnEngineSpeed({ baseUrl, key, since, attempts, fetcher = fetch, timeoutMs = 1500 }) {
try {
const origin = new URL(baseUrl).origin;
const res = await fetcher(`${origin}/metrics?requests=all`, {
signal: AbortSignal.timeout(timeoutMs),
headers: key ? { authorization: `Bearer ${key}` } : undefined,
});
if (!res.ok) return null;
return sumEngineRequests((await res.json())?.requests, { since, attempts });
} catch { return null; }
}
+46 -9
View File
@@ -9,6 +9,8 @@
import { toolUpdate, TokenCount, fmtTokens } from './status.mjs';
import { askFor } from './ask.mjs';
import { FIT_TEXT, fill } from './context-fit.mjs';
import { isContextOverflow } from './context-budget.mjs';
const SHELL = new Set(['pwsh', 'bash', 'job_kill', 'job_output', 'job_list']);
const WRITE = new Set(['write', 'edit']);
@@ -157,6 +159,9 @@ export function withPlain(raw) {
return plain ? `${plain}\n\nDetails: ${raw.replace(/^Error:\s*/, '')}` : raw;
}
/** What a launch that printed nothing proves, and no more. */
export const LAUNCHED_TEXT = 'The command finished without an error, so the program was started. Strata cannot confirm that a window or page actually opened.';
/** A command whose whole point is to open something: it has no output to show when it worked. */
const LAUNCH = /(^|[\s;|&(])(start-process|start|invoke-item|ii|explorer(?:\.exe)?|xdg-open|open|notepad(?:\.exe)?|code)(?=[\s;|&)]|$)/i;
@@ -172,9 +177,10 @@ export function outcomeText(tool, text, args = {}) {
text = text.replace(/\r?\n?\[stderr\]\r?\n(?:Cannot create type\. Only core types are supported in this language mode\.[\s\S]*?CannotCreateTypeConstrainedLanguage\s*)+$/, '');
if (!text.trim()) text = '(no output)';
}
// Starting a program or opening a folder prints nothing when it worked. The model used to read "finished, nothing to show" as "cannot tell
// whether it opened" and told the person so; a launch that returned without an error did start, and the result says that plainly.
if ((tool === 'pwsh' || tool === 'bash') && /^\(no output\)$/.test(text.trim()) && LAUNCH.test(String(args?.command ?? ''))) return 'Started. The program, folder or file was opened.';
// Starting a program or opening a folder prints nothing when it worked, and also when it did not (a browser given a file:// address, a
// program that closed at once). T9 / #3576: the card used to say "was opened", and the model repeated it ("I previewed it in a browser to
// confirm it renders"). It says what is known: the command returned without an error.
if ((tool === 'pwsh' || tool === 'bash') && /^\(no output\)$/.test(text.trim()) && LAUNCH.test(String(args?.command ?? ''))) return LAUNCHED_TEXT;
if ((tool === 'pwsh' || tool === 'bash') && /^\(no output\)$/.test(text.trim())) return 'The command finished without any error. It printed nothing, so there is nothing more to show.';
return text;
}
@@ -196,8 +202,10 @@ export class TurnProjector {
* no question ever reaches the person: the card goes straight from running to denied, without a moment where it
* offers Approve and Deny buttons that would do nothing.
*/
constructor({ startedAt = Date.now(), policy = 'ask' } = {}) {
constructor({ startedAt = Date.now(), policy = 'ask', window = 0 } = {}) {
this.startedAt = startedAt;
this.window = Number(window) || 0; // the model's window, for the figures in the line that says the chat was summarised
this.summarizing = false;
this.policy = policy;
this.byPolicy = new Set(); // callIds the approval policy rejected without asking
this.tools = new Map(); // callId -> { card, calledAt }
@@ -303,6 +311,7 @@ export class TurnProjector {
this.tokensIn += prompt;
this.tokensOut += Number(c.usage.outputTokens) || 0;
this.contextTokens = prompt + (Number(c.usage.outputTokens) || 0);
out.push({ type: 'context', contextTokens: this.contextTokens, contextMax: this.window || undefined });
}
}
return out;
@@ -371,6 +380,22 @@ export class TurnProjector {
if (frame) out.push(frame);
break;
}
case 'compaction/start':
// The Harness is summarising the older messages to make room: say so while it does, and again (once) when it is done.
this.summarizing = true;
this.#status({ phase: 'compacting', code: 'compacting', text: FIT_TEXT.summarizing, key: FIT_TEXT.summarizing, values: [] });
break;
case 'compaction/end':
if (!this.summarizing) break;
this.summarizing = false;
this.#afterTool();
if (!d.error) {
const before = this.window > 0 && this.contextTokens ? Math.min(100, Math.round(100 * this.contextTokens / this.window)) : null;
const text = before === null ? FIT_TEXT.summarizedShort : FIT_TEXT.summarizedWas;
const values = before === null ? [] : [before];
out.push({ type: 'delta', own: true, text: '\n\n' + fill(text, values), key: text, values, noteReason: 'compacted' });
}
break;
case 'turn/start':
// The parent woke up again (a helper finished and reported back): the message goes on, as a new paragraph.
this.ended = null;
@@ -395,7 +420,7 @@ export class TurnProjector {
}
/** The terminal frames for a finished turn. */
finish({ model, contextMax } = {}) {
finish({ model, contextMax, engine = null } = {}) {
const reason = this.ended ?? { kind: 'interrupted' };
const durationMs = Date.now() - this.startedAt;
const usage = this.usageSeen
@@ -403,8 +428,15 @@ export class TurnProjector {
: { tokensIn: null, tokensOut: null, usageSource: 'unavailable' };
const decoded = this.decode();
const generated = this.usageSeen && this.tokensOut > 0 ? this.tokensOut : decoded.tokens;
const tps = decoded.seconds >= 0.3 && generated > 0 ? Number((generated / decoded.seconds).toFixed(1)) : null;
const base = { durationMs, ttftMs: this.firstTokenAt ? this.firstTokenAt - this.startedAt : null, model, contextMax, tps, contextTokens: this.contextTokens };
// Answer speed, one definition everywhere: all the tokens the model produced over its decode time. The engine's own figures (the ones the
// Monitor shows) when the bridge could read them; otherwise the stream's own span. A very short reply has almost no span, so its decode time
// is then the time from its first chunk to the end of the turn (only when no tool ran, so no tool time is in it): the number is still shown.
let tps = null;
if (engine?.tps > 0) tps = engine.tps;
else if (generated > 0 && decoded.seconds >= 0.3) tps = Number((generated / decoded.seconds).toFixed(1));
else if (generated > 0 && this.tools.size === 0 && this.firstTokenAt && Date.now() - this.firstTokenAt >= 50) tps = Number((generated / ((Date.now() - this.firstTokenAt) / 1000)).toFixed(1));
const promptTps = engine?.promptTps > 0 ? engine.promptTps : null;
const base = { durationMs, ttftMs: this.firstTokenAt ? this.firstTokenAt - this.startedAt : null, model, contextMax, tps, ...(promptTps ? { promptTps } : {}), contextTokens: this.contextTokens };
switch (reason.kind) {
case 'completed':
return [{ type: 'done', ...base, ...usage, finishReason: 'stop' }];
@@ -413,7 +445,7 @@ export class TurnProjector {
case 'aborted':
return [{ type: 'done', ...base, ...usage, finishReason: 'aborted', cancelled: true }];
case 'error':
return [{ type: 'error', message: friendlyFailure(reason.error), code: reason.error?.code ?? 'UNKNOWN' }];
return [{ type: 'error', message: friendlyFailure(reason.error), code: isContextFailure(reason.error) ? 'CONTEXT_WINDOW_EXCEEDED' : reason.error?.code ?? 'UNKNOWN', ...(isContextFailure(reason.error) ? { key: FIT_TEXT.full, details: String(reason.error?.message ?? '') } : {}) }];
case 'blocked':
return [{ type: 'error', message: 'The agent stopped because this turn was blocked by policy.', code: 'BLOCKED' }];
default:
@@ -422,10 +454,15 @@ export class TurnProjector {
}
}
/** Whether a failure is the model server saying the conversation does not fit its window (its code, or the engine's own words). */
export function isContextFailure(failure) {
return isContextOverflow(failure) || /context window|too long for|exceeds the context/i.test(String(failure?.message ?? ''));
}
/** A model-route failure in words a user can act on. */
export function friendlyFailure(failure) {
const message = String(failure?.message ?? 'The model request failed.');
if (/context window|too long|exceeds/i.test(message)) return `The conversation no longer fits the model's context window. ${clip(message, 200)}`;
if (isContextFailure(failure)) return FIT_TEXT.full;
if (/ECONNREFUSED|fetch failed|connect/i.test(message)) return `The model is not reachable. ${clip(message, 200)}`;
if (/401|403|credential|api key/i.test(message)) return 'The model rejected the credentials. Check the connection in Settings.';
return clip(message, 400);
+1 -1
View File
@@ -33,7 +33,7 @@ export function agentEnvironment(base = process.env, extra = {}) {
}
// DSH_PERMISSION_MODE is the composition default for a session no chat mode has touched (a child agent, a
// seeded session): the strictest sandbox, which matches the Manual preset, never the Harness's wider default.
return { ...out, DSH_TELEMETRY_DISABLED: '1', DSH_PERMISSION_MODE: 'read-only', ...extra };
return { ...out, DSH_TELEMETRY_DISABLED: '1', DSH_PERMISSION_MODE: 'read-only', NODE_USE_SYSTEM_CA: '1', ...extra };
}
/**
+1 -1
View File
@@ -117,7 +117,7 @@ export function stepObjective(plan, index) {
`You are on step ${index + 1} of ${n}: ${s.text}${s.detail ? ` (${s.detail})` : ''}.`,
done ? `Already done: ${done}.` : '',
plan.briefing ? String(plan.briefing) : '',
isViewStep(s.text) ? 'This step is about seeing the result in a browser. Never wait for a program to close. If a person is at this computer, start the browser so the command returns at once (Windows: Start-Process; macOS: open; Linux: xdg-open) and say it is open. If there is no screen, check the page without a visible window: use the headless browser tool if you have one, otherwise read the file and its script for mistakes, and say in one plain sentence what you could and could not check. Then finish the step.' : '',
isViewStep(s.text) ? 'This step is about seeing the result. To show a local image, SVG, HTML page or PDF, use the present tool (a browser cannot open file:// addresses). Never wait for a program to close. If a person is at this computer and you start a browser, start it so the command returns at once (Windows: Start-Process; macOS: open; Linux: xdg-open) and say only that you started it, not that the page rendered. If there is no screen, check the page without a visible window: use the headless browser tool if you have one, otherwise read the file and its script for mistakes, and say in one plain sentence what you could and could not check. Then finish the step.' : '',
'Do only this step, check that it worked, and then finish the goal.',
index + 1 < n ? 'The next steps are handled separately: do not start them.' : '',
].filter(Boolean).join(' ');
@@ -0,0 +1,159 @@
/**
* Strata's context plugin for the Harness (plain ESM, loaded by absolute path from the profile, like strata-gate and strata-models).
*
* What it fixes (2026-10-22 hands-on test, Gyro-S at 64K):
* 1. Compaction decided from the Harness's own price of the conversation, 4 characters a token. Code, logs and JSON are about twice as
* dense, so a chat the engine had read as 59,048 tokens was priced at 31,794 and compaction started far too late. Here the token meter
* is calibrated by what the model server reports for each answer (its real prompt size): the measured size is exact, and what was
* added since is priced with the density that measurement showed. Automatic compaction and the manual /compact both measure through
* the token meter, so both read the same number as the context meter in the app.
* 2. The summarising request itself could be bigger than the window ("/compact" sent a 66,507-token prompt to a 65,536 window). The
* summary is now asked for in pieces that fit: a message bigger than a piece is cut for the request (the conversation keeps it until
* the summary replaces it), the pieces are summarised one after the other, each building on the summary before it, and a piece the
* server still refuses is halved and tried again.
*
* How. It wraps `tokenMeter.measure` and `llm.stream` in place, once each (the same way strata-models wraps the subagent tools). Only a
* request the Harness marks `purpose: 'compaction'` is touched in `llm.stream`. Nothing in the Harness is rewritten: a Harness update
* that renames either leaves compaction as it was, and the test that runs the real Harness fails.
*/
import { densityOf, estimateMessage, estimateTools, isContextOverflow, MAX_DENSITY, pressureNow, promptTokensOf, splitMessages, summaryBudget, usageOfEvent, WINDOW_SLACK } from '../context-budget.mjs';
export const name = 'strata-context';
export const inject = ['tokenMeter', 'llm'];
/** Real prompt measurements per session: { real, estimated } for the last answer the model server reported. */
const samples = new WeakMap();
const bySessionId = new Map();
const wrapped = new WeakSet();
/** Densities that made a summary request fail on this server, per session id (the next request starts from a more careful one). */
const careful = new Map();
export function apply(ctx) {
const meter = ctx.tokenMeter;
const llm = ctx.llm;
const log = (level, text) => { try { ctx.logger?.[level]?.(`strata-context: ${text}`); } catch { /* logging never blocks */ } };
// 1. The measured size of the prompt, from the model server's own report for each answer.
if (meter && typeof meter.measure === 'function' && !wrapped.has(meter)) {
wrapped.add(meter);
const original = meter.measure;
meter.measure = function measure(session, requestHeader) {
const m = original.call(this, session, requestHeader);
const sample = samples.get(session);
if (!sample) return m;
const now = m.surfaceTokens + estimateTools(requestHeader ?? session.requestHeader?.());
const total = Math.max(m.totalTokens, pressureNow({ sample, now }));
if (total <= m.totalTokens) return m;
const density = densityOf(sample.real, sample.estimated);
return { ...m, totalTokens: total, nodes: m.nodes.map(n => ({ ...n, tokens: Math.ceil(n.tokens * density) })) };
};
ctx.on('session/event', (session, event) => {
if (event.type !== 'assistant/message') return;
const real = promptTokensOf(usageOfEvent(event));
if (!(real > 0)) return;
try {
// The request that was just answered saw the conversation without this answer: its price is the surface before the answer's node.
const m = original.call(meter, session);
const heuristic = m.nodes.reduce((n, x) => n + (x.seq === event.seq ? 0 : x.heuristicTokens), 0);
const estimated = heuristic + estimateTools(session.requestHeader?.());
if (!(estimated > 0)) return;
const sample = { real, estimated };
samples.set(session, sample);
if (session.id) bySessionId.set(String(session.id), sample);
} catch (e) { log('warn', `could not record a measurement: ${e.message}`); }
});
}
// 2. A summary request that fits the window.
if (llm && typeof llm.stream === 'function' && !wrapped.has(llm)) {
wrapped.add(llm);
const original = llm.stream;
llm.stream = function stream(options) {
if (options?.purpose !== 'compaction') return original.call(this, options);
return summaryStream.call(this, options, original);
};
}
// The host asks, with /strata-context, how much of the window the chat holds right now: the same measured number compaction decides on
// (JSON in the command's text: { tokens, window }). Before a message goes out, and after /compact, so the person is told true figures.
ctx.inject(['commands'], (commandCtx) => {
commandCtx.commands.register({
definitionId: 'strata-context',
name: 'strata-context',
description: 'Reports how many tokens the conversation holds now and the size of the model window',
handler: async (invocation) => {
try {
const session = invocation.agent.session;
const tokens = meter.measure(session).totalTokens;
const config = session.requestHeader?.()?.config;
let window = 0;
if (config) window = (await llm.resolveModelInfo(config.provider, config.model))?.context?.contextWindow ?? 0;
return { kind: 'success', text: JSON.stringify({ tokens, window }) };
} catch (e) { return { kind: 'error', text: `strata-context: ${e.message}` }; }
},
});
});
/** A finished stream: its chunks, the text it wrote and why it ended. */
async function drain(iterable) {
let text = '', finish = null;
const chunks = [];
for await (const chunk of iterable) {
chunks.push(chunk);
if (chunk.type === 'text-delta') text += chunk.text ?? '';
else if (chunk.type === 'finish') finish = chunk.reason;
}
return { text, finish, chunks };
}
const refusedForSize = finish => finish?.kind === 'error' && isContextOverflow(finish.failure ?? finish);
async function* summaryStream(options, original) {
let window = 0;
try { window = (await llm.resolveModelInfo(options.provider, options.model, options.signal))?.context?.contextWindow ?? 0; } catch { window = 0; }
const messages = options.messages ?? [];
const key = String(options.sessionId ?? '');
const summaryTokens = options.maxTokens ?? 4096;
if (!(window > 0) || messages.length < 2) { yield* original.call(this, options); return; }
const own = bySessionId.get(key);
let density = Math.max(careful.get(key) ?? 1, own ? densityOf(own.real, own.estimated) : 1);
const price = list => list.reduce((n, m) => n + estimateMessage(m), 0) * density;
const limit = window - summaryTokens - WINDOW_SLACK;
// The whole request fits by this price: ask as the Harness would (the model server's cache of the conversation is reused).
if (price(messages) + estimateTools({ tools: options.tools }) * density <= limit) {
const first = await drain(original.call(this, options));
if (!refusedForSize(first.finish)) { for (const chunk of first.chunks) yield chunk; return; }
density = Math.min(MAX_DENSITY, density * 1.6);
careful.set(key, density);
log('warn', 'the summary request did not fit the window; asking in pieces');
}
// In pieces. The instruction is the last message; the system prompt, when there is one, the first.
const instruction = messages[messages.length - 1];
const system = messages[0]?.role === 'system' ? messages[0] : null;
const body = messages.slice(system ? 1 : 0, -1);
let tools = options.tools;
if (tools && estimateTools({ tools }) * density > window / 3) tools = undefined; // a long tool list only describes tools: the summary does not need it
const fixed = (system ? estimateMessage(system) : 0) * density + estimateTools({ tools }) * density + estimateMessage(instruction) * density;
let budget = summaryBudget({ window, summaryTokens: summaryTokens * 2, systemTokens: fixed, instructionTokens: 0 });
for (let attempt = 0; ; attempt++) {
const runs = splitMessages(body, Math.max(1024, budget), density);
log('info', `summarising ${body.length} messages in ${runs.length} piece(s) for a ${window}-token window (density ${density.toFixed(2)}, about ${Math.round(budget)} tokens a piece)`);
let failed = null, summary = '';
for (let i = 0; i < runs.length && !failed; i++) {
options.signal?.throwIfAborted?.();
const carried = summary ? [{ role: 'user', content: [{ type: 'text', text: `<compacted-summary>\n${summary}\n</compacted-summary>` }] }] : [];
const { tools: _drop, ...rest } = options;
const piece = { ...rest, ...(tools ? { tools } : {}), messages: [...(system ? [system] : []), ...carried, ...runs[i], instruction] };
const { text, finish, chunks } = await drain(original.call(this, piece));
if (refusedForSize(finish)) { failed = finish; break; }
// The last piece's answer is the summary the Harness reads; a piece that ended in another error ends the request the same way.
if (i === runs.length - 1 || finish?.kind === 'error' || finish?.kind === 'aborted') { for (const chunk of chunks) yield chunk; return; }
summary = text;
}
if (!failed || attempt >= 3) throw Object.assign(new Error(failed?.failure?.message ?? 'the summary request does not fit the window'), { code: 'CONTEXT_WINDOW_EXCEEDED' });
density = Math.min(MAX_DENSITY, density * 1.6);
careful.set(key, density);
budget = Math.max(1024, budget / 2);
log('warn', `a piece did not fit; asking again with pieces of about ${Math.round(budget)} tokens`);
}
}
}
+38 -3
View File
@@ -68,8 +68,37 @@ const SAFE_VERSION = /^(node|python|python3|py|npm|pnpm|git|dotnet|cargo|rustc|g
/** Anything that can chain, redirect, expand or start something else is not read-only. */
const UNSAFE_SYNTAX = /[;&<>`${}()]|\r|\n|--%|\bout-file\b|\bset-content\b|\badd-content\b|\binvoke-|\biex\b|\bstart-|\bnew-|\bremove-|\bcopy-|\bmove-|\brename-|\bclear-|\bset-|\bpush-|\bexpand-|\bsave-|\binstall-|\bcurl\b|\bwget\b|\bsudo\b|\bsh\b|\bbash\b|\bpwsh\b|\bpowershell\b|\bcmd\b|-exec\b|-delete\b|-execdir\b/i;
/**
* The command with every quoted text replaced by a plain stand-in, or null when it cannot be read safely. What is inside quotes is data
* (a path like "C:\Users\x\Q&A", a pattern like 'a|b'), so an & ; | ( ) < > or { in it must not decide anything. Two things still can:
* inside double quotes a $ or a backtick expands (a variable, a sub-command), and an unterminated quote means the text was not understood.
* Either one gives null, and the command asks as before.
* @param {string} text
* @returns {string|null}
*/
export function maskQuotes(text) {
let out = '';
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (c !== '"' && c !== "'") { out += c; continue; }
let end = text.indexOf(c, i + 1);
// PowerShell writes a quote inside a quoted text by doubling it ('it''s'): the text goes on past the pair.
while (end >= 0 && text[end + 1] === c) end = text.indexOf(c, end + 2);
if (end < 0) return null;
if (c === '"' && /[$`]/.test(text.slice(i + 1, end))) return null;
// A quoted text is a whole word. One glued to other characters (--out"put"=f, "a"b) or one that starts with a dash could hide an option from the
// word checks below (git diff "--output=f" writes a file), so those are not understood either.
const before = text[i - 1], after = text[end + 1];
if ((before !== undefined && !/[\s|]/.test(before)) || (after !== undefined && !/[\s|]/.test(after)) || text[i + 1] === '-') return null;
out += '"_"';
i = end;
}
return out;
}
/**
* Whether a shell command only reads. Conservative by design: one unrecognised word and the answer is no.
* Quoted text is data and is looked at after it has been set aside (maskQuotes): Get-ChildItem "C:\Users\x\Q&A" only lists.
* @param {string} command
* @returns {boolean}
*/
@@ -77,8 +106,9 @@ export function isReadOnlyShell(command) {
const text = String(command ?? '').trim();
if (!text || text.length > 600) return false;
if (SAFE_VERSION.test(text)) return true;
if (UNSAFE_SYNTAX.test(text)) return false;
const segments = text.split('|').map(s => s.trim());
const masked = maskQuotes(text);
if (masked === null || UNSAFE_SYNTAX.test(masked)) return false;
const segments = masked.split('|').map(s => s.trim());
return segments.every((segment) => {
if (!segment) return false;
const words = segment.split(/\s+/);
@@ -219,8 +249,13 @@ export function apply(ctx, config = {}) {
ctx.on('tools/pre-execute', async (exec, next) => {
const agent = exec.agent;
if (!agent || exec.name === 'run_code') return next();
// Fail closed (T4): a session whose preset cannot be read (a reset in progress, a helper whose chat is gone) is treated as Manual, so
// anything this file cannot prove is read-only still asks. It used to run unasked.
let preset;
try { preset = effectivePreset(agent.session); } catch { return next(); }
try { preset = effectivePreset(agent.session); } catch (e) {
preset = MANUAL_PRESET;
try { console.warn(`[strata-gate] could not read the permission mode for this call (${e?.message ?? e}); treating it as Manual`); } catch { /* logging must never decide a permission */ }
}
// A helper that was set to ask while the chat was in Manual must stop asking the moment the chat leaves Manual,
// so a mode change applies to the very next tool call of a running goal and its helpers.
if (preset !== MANUAL_PRESET && agent.session.header?.origin === 'subagent' && ctx.approval.overrideOf(agent.session) === 'ask') {
+2 -2
View File
@@ -42,8 +42,8 @@ export const AGENT_PROMPT = [
'You are a helpful assistant that can also work on this computer through your tools. You are running as {{model}}.',
'Answer questions directly and briefly; use a tool only when the task needs one. The person reads your replies in a chat: use plain words, not tool names or technical jargon.',
'Look before you change: read a file before you edit or overwrite it, and prefer a small edit to a rewrite. Search with the glob and grep tools and read files with the read tool; use the shell for running programs.',
'When you start a program or open a folder or file for the person, the command returning without an error means it worked: say plainly that it is open. Do not say you could not confirm it. If a command fails, say what went wrong in one sentence and what you will try next.',
'To open a page or a file for the person (a browser, an app, a folder), start it so the command returns at once and never wait for the program to close: on Windows use Start-Process, on macOS open, on Linux xdg-open. Then say plainly that it is open.',
'A command that starts a program, folder or page only tells you that it started. Never say a window opened, a page rendered or that you checked how it looks unless a tool confirmed it. If a command fails, say what went wrong in one sentence and what you will try next.',
'To show the person an image, SVG, HTML page or PDF from their folders, use the present tool: the app shows it next to the chat. A browser cannot open file:// addresses. If you do start a program, start it so the command returns at once and never wait for it to close: Start-Process on Windows, open on macOS, xdg-open on Linux.',
'When the person asks you to ask a subagent, a helper or another model ("ask a subagent", "ask model X"), use the subagent tool and name the model in its model field. Never look for a model with shell commands, and never wait for a helper with sleep: end your turn; its answer arrives as a new message.',
'Tokens prefixed with @ are files or folders the person pointed at. A failed or refused action changed nothing; say so, and do not claim work you did not do.',
].join('\n\n');
+42 -6
View File
@@ -20,6 +20,7 @@
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { standardPreset } from './preset.mjs';
import { validMcpName } from '../mcp-names.mjs';
import { BUDGETS, isPrivateHost } from './llm-shape.mjs';
import { DDG_PROVIDER_ID } from '../web-search-ddg.mjs';
@@ -55,6 +56,8 @@ export const modeOf = value => (Object.hasOwn(MODE_PRESETS, value) ? value : DEF
/** The approval gate plugin (plain ESM, loaded by absolute path). */
export const GATE_PLUGIN = fileURLToPath(new URL('./plugins/strata-gate.mjs', import.meta.url));
/** The app's web search (DuckDuckGo, or the person's own Exa / Perplexity key), registered with the Harness's web seam. */
/** Compaction that measures the real prompt and summarises in pieces that fit the window (plugins/strata-context.mjs). */
export const CONTEXT_PLUGIN = fileURLToPath(new URL('./plugins/strata-context.mjs', import.meta.url));
export const MODELS_PLUGIN = fileURLToPath(new URL('./plugins/strata-models.mjs', import.meta.url));
export const SEARCH_PLUGIN = fileURLToPath(new URL('./plugins/strata-search.mjs', import.meta.url));
/** The app's own tool server for the tools a person writes (instructions, commands). */
@@ -71,11 +74,17 @@ const NO_TOOLS_SETTINGS = Object.freeze({ off: new Set(), search: { provider: 'b
* `mcp__<server>__<tool>`. A command or URL goes into the patch as text; a secret (an env value or a header) never does: the row
* carries `!!js process.env.<NAME>` and the value travels in the agent process's environment (returned in `env`).
*/
export function mcpRows(servers, env) {
export function mcpRows(servers, env, skipped = []) {
const rows = [];
const ids = new Set();
let index = 0;
for (const [name, server] of Object.entries(servers ?? {})) {
if (!server || server.disabled === true || !/^[A-Za-z0-9_-]{1,32}$/.test(name)) continue;
if (!server || server.disabled === true) continue; // switched off on purpose: not an error
// A server that cannot be wired is REPORTED (T6): the model never sees its tools, and a silent drop looked like "the tool does not work".
if (!validMcpName(name)) { skipped.push({ name, why: 'name' }); continue; }
if (!server.command && !server.url) { skipped.push({ name, why: 'address' }); continue; }
if (ids.has(`mcp-${slug(name)}`)) { skipped.push({ name, why: 'duplicate' }); continue; }
ids.add(`mcp-${slug(name)}`);
const token = (kind, key, value) => {
const variable = `STRATA_MCP_${index}_${kind}_${slug(key).replace(/-/g, '_').toUpperCase()}`;
env[variable] = String(value);
@@ -91,6 +100,24 @@ export function mcpRows(servers, env) {
return rows;
}
/**
* What the chat says when a tool server was left out (one notice per reason). The sentences are interface copy: they are in every language's
* catalog (src/locales/tools-<lang>.json), and the {0} is the list of names exactly as the person wrote them.
*/
const SKIPPED_TEXT = {
name: 'These tool servers were left out because their names are not allowed: {0}. A name can have up to 32 letters, numbers, hyphens or underscores.',
address: 'These tool servers were left out because they have no program or web address: {0}.',
duplicate: 'These tool servers were left out because another one has almost the same name: {0}.',
};
export function mcpSkippedNotices(skipped) {
const out = [];
for (const why of Object.keys(SKIPPED_TEXT)) {
const names = (skipped ?? []).filter(s => s.why === why).map(s => String(s.name).slice(0, 40));
if (names.length) out.push({ code: `mcp-skipped-${why}-${names.join(',')}`.slice(0, 160), message: SKIPPED_TEXT[why].replace('{0}', names.join(', ')) });
}
return out;
}
const slug = value => String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'x';
/** Identity of one route's wire configuration, secret included by hash only. */
@@ -166,7 +193,7 @@ export function outputBudget(context, maxOutput = null) {
/** What a local model, or an OpenAI-style server on this network, takes for the thinking level (see the comment where a local route is built). */
const THINKING_COMPAT = { supportsStore: false, thinkingFormat: 'chat-template', chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' }, reasoning_effort: { $var: 'thinking.effort', omitWhenOff: true } } };
export function buildProfile({ local = [], connections = [], gate = true, tools = NO_TOOLS_SETTINGS, skillsDir = null, proxyBase = null, searchMock = null }) {
export function buildProfile({ local = [], connections = [], gate = true, tools = NO_TOOLS_SETTINGS, skillsDir = null, proxyBase = null, searchMock = null, defaultLocalId = null }) {
const providers = {};
const env = {};
const routes = [];
@@ -202,6 +229,9 @@ export function buildProfile({ local = [], connections = [], gate = true, tools
api: 'openai-completions',
baseURL: inst.baseUrl,
apiKeyEnv: keyEnv,
// A long prompt read sends only SSE comments, which pi-ai's OpenAI client drops, so its 300 s stream-idle timer would cut the
// request five minutes in (#3623). 60 minutes is a backstop: serve ends a hung engine and the bridge's watchdog ends a lost turn.
streamIdleTimeoutMs: 60 * 60_000,
// The Strata server reads the thinking level from `chat_template_kwargs` (enable_thinking, reasoning_effort) or from a top-level
// reasoning_effort; a top-level `enable_thinking` (pi-ai's `qwen` format) is ignored, so the model went on thinking with the menu on Off.
compat: THINKING_COMPAT,
@@ -257,9 +287,13 @@ export function buildProfile({ local = [], connections = [], gate = true, tools
for (const m of conn.models) routes.push({ route, fp, kind: 'api', connectionId: conn.id, connectionName: conn.name, modelId: m.id, name: m.name || m.id, contextWindow: m.contextMax ?? ASSUMED_API_CONTEXT, ...(shaped ? { shaped } : {}) });
});
const defaultRoute = routes.find(r => r.kind === 'local') ?? null;
// The model the chat really runs on is the default instance, not whichever one was loaded first (T5: with two models loaded, the tool servers of the
// first one were wired to a chat on the second).
const defaultInstance = local.find(i => i.id === defaultLocalId) ?? local[0] ?? null;
const defaultRoute = routes.find(r => r.kind === 'local' && r.instanceId === defaultInstance?.id) ?? routes.find(r => r.kind === 'local') ?? null;
// One Harness, one set of tool servers: the default local model's.
const mcp = mcpRows({ ...(local[0]?.mcpServers ?? {}), ...(tools.servers ?? {}) }, mcpEnv);
const mcpSkipped = [];
const mcp = mcpRows({ ...(defaultInstance?.mcpServers ?? {}), ...(tools.servers ?? {}) }, mcpEnv, mcpSkipped);
Object.assign(env, mcpEnv);
// The person's own instruction and command tools come from the app's tool server; a command tool asks in Manual like any connected tool.
const own = (kind, on) => on && tools.toolFile ? [{ id: `mcp-strata-${kind}`, name: '@deepseek-ai/dsh-mcp-client', config: { serverName: `strata-${kind}`, transport: 'stdio', command: process.execPath, args: [CUSTOM_TOOLS_SCRIPT, kind, tools.toolFile] } }] : [];
@@ -286,6 +320,8 @@ export function buildProfile({ local = [], connections = [], gate = true, tools
// Manual asks before a change runs, so the person sees one Approve/Deny card and nothing has happened yet.
// Its web question names the search service the person chose (a keyed one only when its key is saved), and the person's switch skips the question.
...(gate ? [{ insert: [{ id: 'strata-gate', name: GATE_PLUGIN, config: { web: { provider: SEARCH_NAMES[keyed ?? 'builtin'], skipAsk: tools.webWithoutAsking === true } } }] }] : []),
// Compaction decides from what the model server measured, and its summary requests always fit the window (plugins/strata-context.mjs).
{ insert: [{ id: 'strata-context', name: CONTEXT_PLUGIN, config: {} }] },
// Helper agents on a model the person names: every model the app can reach is allowed, and the plugin makes the choice forgiving (plugins/strata-models.mjs).
...(subagentRoutes.length ? [{ id: 'subagent-model-selection-settings', config: { enabled: true, allowedModels: subagentRoutes.map(r => ({ provider: r.provider, model: r.model })) } }] : []),
...(subagentRoutes.length ? [{ insert: [{ id: 'strata-models', name: MODELS_PLUGIN, config: { routes: subagentRoutes } }] }] : []),
@@ -309,7 +345,7 @@ export function buildProfile({ local = [], connections = [], gate = true, tools
// What the agent is offered (tool groups, web search, tool servers, the person's own tools) decides whether a running agent must be
// restarted for the next message; the routes are compared separately (a changed context size alone does not restart it).
const toolsSignature = createHash('sha256').update(JSON.stringify([mcp, ownRows, webRows, entries.find(e => e.id === 'preset-standard'), env.STRATA_SEARCH_KEY ?? '', mcpEnv])).digest('hex').slice(0, 16);
return { patch, env, routes, defaultRoute, signature, toolsSignature, shapeTargets };
return { patch, env, routes, defaultRoute, signature, toolsSignature, shapeTargets, mcpSkipped };
}
/** Find the route for a chat's model choice. */
+14 -2
View File
@@ -13,6 +13,7 @@
import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { GROUP_IDS, TOOL_GROUPS } from './tools-catalog.mjs';
import { SHELLS, clampTimeoutSec } from './custom-command-run.mjs';
export const SEARCH_PROVIDERS = ['builtin', 'exa', 'perplexity', 'deepseek'];
const KINDS = ['instructions', 'command', 'server'];
@@ -38,6 +39,8 @@ function normalize(raw) {
id: String(t.id), name: String(t.name), about: clean(t.about, 300), kind: t.kind, enabled: t.enabled !== false,
instructions: clean(t.instructions, 20000), command: clean(t.command, 2000), address: clean(t.address, 2000),
args: (Array.isArray(t.args) ? t.args : []).slice(0, 50).map(a => clean(a, 1000)),
// a command tool's folder, shell and time allowed (custom-command-run.mjs); '' / 'auto' / 120 when not set
cwd: clean(t.cwd, 1000).trim(), shell: SHELLS.includes(t.shell) ? t.shell : 'auto', timeoutSec: clampTimeoutSec(t.timeoutSec),
})),
};
}
@@ -49,7 +52,7 @@ function validateCustom(body, existing) {
if (!about) throw new Error('Say what the tool does, in a sentence. The assistant reads this to decide when to use it.');
const kind = body?.kind;
if (!KINDS.includes(kind)) throw new Error('Choose how the tool works.');
const out = { name, about, kind, instructions: '', command: '', address: '', args: [] };
const out = { name, about, kind, instructions: '', command: '', address: '', args: [], cwd: '', shell: 'auto', timeoutSec: clampTimeoutSec(undefined) };
if (kind === 'instructions') {
out.instructions = clean(body.instructions, 20000).trim();
if (!out.instructions) throw new Error('Write the instructions the assistant should follow when it uses this tool.');
@@ -57,6 +60,14 @@ function validateCustom(body, existing) {
out.command = clean(body.command, 2000).trim();
if (!out.command) throw new Error('Enter the command to run.');
if (/[\r\n]/.test(out.command)) throw new Error('Put the command on one line.');
// The edit form of Settings does not send these three: an edit keeps what the tool had.
const before = existing.find(t => t.id === body.id && t.kind === 'command');
out.cwd = body.cwd !== undefined ? clean(body.cwd, 1000).trim() : before?.cwd ?? '';
if (/[\r\n]/.test(out.cwd)) throw new Error('Put the folder on one line.');
if (body.shell !== undefined && !SHELLS.includes(body.shell)) throw new Error('Choose how the command runs: automatic, cmd, PowerShell, bash or sh.');
out.shell = body.shell !== undefined ? body.shell : before?.shell ?? 'auto';
if (body.timeoutSec !== undefined && !(Number(body.timeoutSec) >= 1 && Number(body.timeoutSec) <= 3600)) throw new Error('Give the time allowed in seconds, from 1 to 3600.');
out.timeoutSec = body.timeoutSec !== undefined ? clampTimeoutSec(body.timeoutSec) : before?.timeoutSec ?? clampTimeoutSec(undefined);
} else {
out.address = clean(body.address, 2000).trim();
if (!out.address) throw new Error('Enter the tool server’s web address, or the program that starts it.');
@@ -91,7 +102,7 @@ export function createAgentTools({ read, write, dataDir, browser = () => ({ foun
for (const t of s.custom.filter(t => t.enabled)) {
const key = t.id;
if (t.kind === 'instructions') instructions[key] = { description: t.about, text: t.instructions };
else if (t.kind === 'command') commands[key] = { description: t.about, command: t.command };
else if (t.kind === 'command') commands[key] = { description: t.about, command: t.command, cwd: t.cwd, shell: t.shell, timeoutSec: t.timeoutSec };
}
mkdirSync(join(dataDir, 'agent'), { recursive: true });
const tmp = `${filePath}.tmp`;
@@ -115,6 +126,7 @@ export function createAgentTools({ read, write, dataDir, browser = () => ({ foun
id: t.id, name: t.name, about: t.about, kind: t.kind, enabled: t.enabled,
summary: t.kind === 'instructions' ? t.instructions.slice(0, 80) : t.kind === 'command' ? t.command : t.address,
instructions: t.instructions, command: t.command, address: t.address, args: t.args,
...(t.kind === 'command' ? { cwd: t.cwd, shell: t.shell, timeoutSec: t.timeoutSec } : {}),
})),
};
}
+17 -1
View File
@@ -67,13 +67,29 @@ export function nvidiaDriverVersion() {
/** Where the builds are published; the wrong-make sentence points here. */
export const RELEASES_URL = 'https://github.com/Niko1221/Strata/releases';
/** The PCI vendor ids (`10de`, `1002`, `8086`) of the Windows display adapters; empty off Windows or when the registry cannot be read. */
export function windowsDisplayVendors({ platform = process.platform, run = execFileSync } = {}) {
const found = new Set();
if (platform !== 'win32') return found;
try {
const out = run('reg', ['query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}', '/s', '/v', 'MatchingDeviceId'], { encoding: 'utf8', timeout: 8000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
for (const match of String(out).matchAll(/VEN_([0-9A-F]{4})/gi)) found.add(match[1].toLowerCase());
} catch { /* no registry access: the tools decide */ }
return found;
}
/**
* The engine family this PC's graphics card needs ('cuda', 'hip', 'sycl'), or null when it cannot be told. The same order as Control
* (NVIDIA first, then AMD, then Intel); STRATA_ENGINE_BACKEND wins. Reads only the driver and (Linux) the PCI vendor of the display adapters.
*/
export function gpuBackendHere(env = process.env) {
export function gpuBackendHere(env = process.env, { vendors = windowsDisplayVendors() } = {}) {
const forced = String(env.STRATA_ENGINE_BACKEND ?? '').trim().toLowerCase();
if (forced) return ['cuda', 'hip', 'sycl'].includes(forced) ? forced : null;
// Windows: the display adapters are the machine's own statement of which cards it has; a leftover nvidia-smi from a card that was
// taken out must not outvote them (#3688). Same order as Control: NVIDIA, then AMD, then Intel.
if (vendors.size) {
for (const [vendor, backend] of [['10de', 'cuda'], ['1002', 'hip'], ['8086', 'sycl']]) if (vendors.has(vendor)) return backend;
}
if (nvidiaDriverVersion()) return 'cuda';
try {
const base = '/sys/class/drm';
+3 -2
View File
@@ -3,6 +3,8 @@ import { spawn } from 'node:child_process';
import { probe, which } from './util.mjs';
import { classifyRunning, describeConnector, otherTunnelMessage, ownTunnelMessage } from './cloudflared-find.mjs';
/** A tunnel without a key is allowed (owner decision, 0.2.2); because the link is public the warning is stronger than the local one. */
export const NO_KEY_TUNNEL_WARNING = 'No API key is set, so anyone who finds this link can use your model from the internet. Create an API key to protect it.';
export function tunnelHostname(value) {
const hostname = String(value ?? '').trim().toLowerCase();
if (hostname.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(hostname)) throw new Error('Enter a hostname such as ai.example.com, without https:// or a path.');
@@ -13,7 +15,7 @@ export function createCloudflare({ origin, originUrl, hasKey, changed = () => {}
let value = { status: 'idle', mode: null, url: null, error: null };
const state = () => {
const info = binary ? connectorInfo() : null;
return { ...value, installed: !!binary, originUrl, connector: info ? { path: info.path, version: info.version, source: info.source, label: info.label, summary: describeConnector(info) } : null, external: other?.running ? externalState() : null };
return { ...value, warning: value.status === 'connected' && !hasKey() ? NO_KEY_TUNNEL_WARNING : null, installed: !!binary, originUrl, connector: info ? { path: info.path, version: info.version, source: info.source, label: info.label, summary: describeConnector(info) } : null, external: other?.running ? externalState() : null };
};
const update = patch => { value = { ...value, ...patch }; changed(state()); };
function externalState(token = '') {
@@ -52,7 +54,6 @@ export function createCloudflare({ origin, originUrl, hasKey, changed = () => {}
}
async function start(body) {
if (busy || ['starting', 'connected', 'stopping'].includes(value.status)) throw new Error('Stop the current tunnel before starting another.');
if (!hasKey()) throw new Error('Create a Strata API key before connecting Cloudflare.');
if (!['quick', 'domain'].includes(body.mode)) throw new Error('Choose a temporary URL or your own domain.');
const hostname = body.mode === 'domain' ? tunnelHostname(body.hostname) : null;
const token = body.mode === 'domain' ? String(body.token ?? '').trim() : '';
+53
View File
@@ -207,6 +207,59 @@ export function createControl({ control = findControl(), env = process.env, run
if (result.json?.plan && Array.isArray(result.json.plan.issues)) return result.json.plan;
throw failure(result, 'The files could not be checked.');
},
/** What picking `to` as the models folder means (sizes, free space, which choices are possible, why not): nothing is changed. */
async planModelsMove(from, to) {
const result = await call(['models', 'move', to, ...(from ? ['--from', from] : []), '--plan', '--output', 'json'], { timeoutMs: 120_000 });
if (result.json?.plan) return result.json.plan;
throw failure(result, 'The folder could not be checked.');
},
/**
* Move the model files to `to` (copied, checked, then the old copies removed). `onEvent` gets the progress lines; a file at `cancelFile` stops it
* safely. Resolves with the summary (done / stopped, what moved, what is left); a failure is a ControlError whose `output` carries the details.
*/
async moveModels(from, to, { installDir = null, cancelFile = null, onEvent = null } = {}) {
const args = ['models', 'move', to, ...(from ? ['--from', from] : []), ...(installDir ? ['--install-dir', installDir] : []), ...(cancelFile ? ['--cancel-file', cancelFile] : []), '--output', 'jsonl'];
const result = await call(args, { timeoutMs: 24 * 3600_000, onLine: onEvent ?? (() => {}) });
if (result.json?.ok && result.json.event === 'result') return result.json;
const error = result.json?.error;
throw Object.assign(new ControlError(error?.message ?? (result.stderr.split(/\r?\n/).filter(Boolean).pop() || 'The models could not be moved.'), { exitCode: result.code, output: result.json }), { code: error?.code ?? 'move_failed', details: error?.details ?? '' });
},
/** A move of the models folder that the last run left unfinished (`strata models move --status`): null, or {state, from, to, bytes}. */
async interruptedModelsMove(dataFolder) {
const result = await call(['models', 'move', '--status', ...(dataFolder ? ['--from', dataFolder] : []), '--output', 'json'], { timeoutMs: 30_000 });
if (result.json?.ok) return result.json.interrupted ?? null;
throw failure(result, 'The models folder could not be checked.');
},
/** "Finish it": the same move again, from where it stopped. Same events and result as moveModels. */
async resumeModelsMove(dataFolder, { installDir = null, cancelFile = null, onEvent = null } = {}) {
const args = ['models', 'move', '--resume', ...(dataFolder ? ['--from', dataFolder] : []), ...(installDir ? ['--install-dir', installDir] : []), ...(cancelFile ? ['--cancel-file', cancelFile] : []), '--output', 'jsonl'];
const result = await call(args, { timeoutMs: 24 * 3600_000, onLine: onEvent ?? (() => {}) });
if (result.json?.ok && result.json.event === 'result') return result.json;
const error = result.json?.error;
throw Object.assign(new ControlError(error?.message ?? (result.stderr.split(/\r?\n/).filter(Boolean).pop() || 'The models could not be moved.'), { exitCode: result.code, output: result.json }), { code: error?.code ?? 'move_failed', details: error?.details ?? '' });
},
/** "Leave it": keep the old folder, remove the partial copies in the new one. */
async leaveModelsMove(dataFolder, { installDir = null } = {}) {
const result = await call(['models', 'move', '--leave', ...(dataFolder ? ['--from', dataFolder] : []), ...(installDir ? ['--install-dir', installDir] : []), '--output', 'json'], { timeoutMs: 24 * 3600_000 });
if (result.json?.ok) return result.json;
throw Object.assign(failure(result, 'The partial copies could not be removed.'), { code: result.json?.error?.code ?? 'leave_failed', details: result.json?.error?.details ?? '' });
},
/**
* 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.
*/
async draftingPlan({ configPath = null, mode = null, family = null, dataDir = null } = {}) {
const result = await call(['drafting', 'plan', ...(configPath ? ['--config', configPath] : []), ...(mode ? ['--mode', mode] : []), ...(family ? ['--family', family] : []), ...(dataDir ? ['--data-dir', dataDir] : []), '--output', 'json'], { timeoutMs: 30_000 });
if (result.json?.ok) return result.json;
throw failure(result, 'The drafter could not be checked.');
},
/** Fetch and pack the drafter when the choice would use it and it is not here (`strata drafting fetch --if-needed`); `onEvent` gets {step, done, total}. */
async draftingFetch({ configPath, mode = null, onEvent = null, signal = null } = {}) {
const args = ['drafting', 'fetch', '--config', configPath, ...(mode ? ['--mode', mode] : []), '--if-needed', '--output', 'jsonl'];
const 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' });
},
async saveProfile(name, configPath) {
const listed = await call(['profile', 'list', '--output', 'json'], { timeoutMs: 20_000 });
const known = (listed.json?.profiles ?? []).some(p => p.id === name);
+9 -6
View File
@@ -182,13 +182,16 @@ export function chooserUnavailable(picked) {
return { message: picked.reason, code: 'folder-chooser-unavailable', details: picked.detail };
}
/** A typed path stands in for the dialog: it must be an absolute directory that exists. */
export async function typedFolder(value, { isAbsolute }) {
const text = typeof value === 'string' ? value.trim() : '';
/** A typed path stands in for the dialog: it must be an absolute directory that exists. `fileType` (a RegExp on the name) also lets a
* file of that kind through (the model page takes a folder or one .gguf file). Quotes around the path (Windows "Copy as path") go. */
export async function typedFolder(value, { isAbsolute, fileType = null }) {
let text = typeof value === 'string' ? value.trim() : '';
if (text.length > 1 && /^(["'])[\s\S]*\1$/.test(text)) text = text.slice(1, -1).trim();
if (!text || text.includes('\0')) return null;
if (!isAbsolute(text)) throw new Error('Enter the full path of the folder.');
if (!isAbsolute(text)) throw new Error(fileType ? 'Enter the full path of the folder or the file.' : 'Enter the full path of the folder.');
let info;
try { info = await stat(text); } catch { throw new Error('That folder does not exist.'); }
if (!info.isDirectory()) throw new Error('Choose a folder, not a file.');
try { info = await stat(text); } catch { throw new Error(fileType ? 'That folder or file does not exist.' : 'That folder does not exist.'); }
if (info.isFile() && fileType?.test(text)) return text;
if (!info.isDirectory()) throw new Error(fileType ? 'Choose a folder, or a .gguf file.' : 'Choose a folder, not a file.');
return cleanChosenPath(text);
}
+52
View File
@@ -0,0 +1,52 @@
/**
* The host's copy of the roles rules it needs to WRITE a model's settings (src/lib/gpu-roles.ts has the screen's, strata_control/gpu_roles.py the
* Terminal's and the server's; tests/data/gpu_roles_vectors.json is run against all three).
* Roles are { [card number]: 'main' | 'helper' | 'off' }. The engine takes the cards in stage order: Helpers, then the Main card LAST.
*/
export const ROLES = ['main', 'helper', 'off'];
const mem = c => Number(c.vram_gb) || 0;
const speed = c => (typeof c.speed === 'number' && c.speed > 0 ? c.speed : 0);
const nums = roles => Object.keys(roles).map(Number).sort((a, b) => a - b);
const onList = roles => nums(roles).filter(i => roles[i] !== 'off');
const shortName = c => { let n = String(c.name || `GPU ${c.index + 1}`); for (const j of ['NVIDIA GeForce ', 'NVIDIA ', 'GeForce ', 'AMD Radeon ', 'AMD ', 'Intel(R) ', 'Intel ']) if (n.startsWith(j)) { n = n.slice(j.length); break; } return n.trim(); };
export function parseSaved(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const out = {};
for (const [k, v] of Object.entries(value)) {
if (!/^\d+$/.test(k.trim()) || !ROLES.includes(String(v).toLowerCase())) return null;
out[Number(k)] = String(v).toLowerCase();
}
if (!Object.keys(out).length || Object.values(out).filter(r => r === 'main').length > 1) return null;
return out;
}
export function normalize(roles, cards) {
const cs = new Map(cards.map(c => [c.index, c]));
const out = {};
for (const i of nums(roles)) if (cs.has(i)) out[i] = ROLES.includes(roles[i]) ? roles[i] : 'off';
const list = onList(out), mains = list.filter(i => out[i] === 'main');
if (list.length && !mains.length) {
const best = [...list].sort((a, b) => speed(cs.get(b)) - speed(cs.get(a)) || Math.round(mem(cs.get(b))) - Math.round(mem(cs.get(a))) || a - b)[0];
out[best] = 'main';
}
for (const extra of mains.slice(1)) out[extra] = 'helper';
if (list.length === 1) out[list[0]] = 'main';
return out;
}
export function order(roles, cards) {
const cs = new Map(cards.map(c => [c.index, c]));
const r = normalize(roles, cards);
const helpers = nums(r).filter(i => r[i] === 'helper').sort((a, b) => speed(cs.get(a)) - speed(cs.get(b)) || a - b);
return [...helpers, ...nums(r).filter(i => r[i] === 'main')];
}
/** "gpu_roles" plus the legacy mirror ("gpu" Main last, "gpu_order": "as_given"); nothing on: "gpu": [] and "gpu_none": true. */
export function mirror(roles, cards) {
const r = normalize(roles, cards), lst = order(r, cards);
const out = { gpu_roles: Object.fromEntries(nums(r).map(i => [String(i), r[i]])), gpu: [], gpu_cards: {} };
if (lst.length) { out.gpu = lst.length === 1 ? lst[0] : lst; out.gpu_order = 'as_given'; } else out.gpu_none = true;
for (const c of cards) if (c.index in r) out.gpu_cards[String(c.index)] = [shortName(c), c.bus ?? ''].filter(Boolean).join('|');
return out;
}
+48 -5
View File
@@ -1,4 +1,6 @@
#!/usr/bin/env node
import { engineProgressMark } from './agent/engine-progress.mjs';
import './system-ca.mjs'; // #3722: the operating system's certificates, before any HTTPS request
import { createCloudflare } from './cloudflare.mjs';
import { createConnectorInstaller } from './cloudflared-install.mjs';
import { findRunningConnector } from './cloudflared-find.mjs';
@@ -15,6 +17,7 @@ import { materializeSkills, parseSkillFile, skillSlug, writeCreatedSkill } from
import { systemFonts } from './fonts.mjs';
import { tuningDir } from './platform-paths.mjs';
import { createAgentBridge, plainTurnFailure } from './agent/agent-bridge.mjs';
import { mcpSkippedNotices } from './agent/profile.mjs';
import { createAgentTools } from './agent/tools-settings.mjs';
import { withPlainStatus } from './agent/status.mjs';
import { findBrowser, findPlaywrightMcp } from './agent/browser-probe.mjs';
@@ -55,6 +58,7 @@ import { RequestQueue } from './request-queue.mjs';
import { selectSkills, skillInstructions } from './skill-selection.mjs';
import { createModelProviders } from './model-providers.mjs';
import { createForkTimeskip } from './timeskip-fork.mjs';
import { prepareOversized } from './oversize.mjs';
import { createChatFolders, chatRootOf, openFile, resolveChatFile, previewFile, rawFile, createAttachments, withAttachments } from './chat-files.mjs';
import { createLlmProxy, samplingFromExpert } from './agent/llm-shape.mjs';
import { checkUpdates, checkComponentUpdates } from './updates.mjs';
@@ -72,6 +76,7 @@ import { randomBytes, timingSafeEqual } from 'node:crypto';
import { nativeGateway } from './native-gateway.mjs';
import { proxyCompletion } from './inference.mjs';
import { apiPath } from './api-paths.mjs';
import { findFreePort, listenProblem } from './ports.mjs';
import { validateChatImages } from './chat-images.mjs';
import { createTimeSkip, measureFolder, timeskipRevisionMessage, imageTypeOf } from './timeskip.mjs';
@@ -173,12 +178,15 @@ const agent = createAgentBridge({
// The person's Response settings (thinking budget, Expert sampling) reach the agent's model through this host's own forwarding endpoint.
proxyBase: `http://127.0.0.1:${PORT}/api/internal/agent-llm`,
expertSampling: () => samplingFromExpert(store.get().expert),
// How far a local engine has got (the 2 s /metrics poll), for the agent's stall watchdog: it moves while a long prompt is read.
engineProgress: instanceId => engineProgressMark(native.readyInstances().find(i => i.id === instanceId)),
onPlan: (chatId, event, plan) => hub.send('plan', { chatId, event, plan }),
onQueue: (chatId, queue) => hub.send('queue', { chatId, queue }),
dataDir: store.DATA_DIR,
componentsDir: componentsDirectory(),
// The installer ships the Harness next to the host (<install>/host/server -> <install>/harness).
bundledDir: resolve(HERE, '..', '..', 'harness'),
defaultLocalId: () => native.defaultInstanceId(),
localInstances: () => native.readyInstances().map(i => ({
id: i.id, label: i.label, baseUrl: `http://127.0.0.1:${i.port}/v1`, modelId: i.apiModelId,
context: i.metrics?.engine?.max_context ?? i.context ?? undefined, key: i.key, vision: i.vision,
@@ -269,7 +277,7 @@ function setupState() {
flowVersion: 2,
completedAt: s.setup.completedAt,
history: s.setup.history ?? [],
app: { version: appVersion(), modelsDir: store.MODELS_DIR, dataDir: store.DATA_DIR },
app: { version: appVersion(), modelsDir: store.MODELS_DIR, dataDir: store.DATA_DIR, modelsDirNotice: store.MODELS_DIR_NOTICE },
};
}
@@ -600,8 +608,11 @@ async function runAgentTurn({ res, controller, chatId, body, message, workspaceI
const goalLine = /^\/goal(?:\s+([\s\S]*))?$/i.exec(message.trim());
const planLine = /^\/plan(?:\s+([\s\S]*))?$/i.exec(message.trim());
const researchLine = /^\/research(?:\s+([\s\S]*))?$/i.exec(message.trim());
// "/compact" summarises the older messages of this chat now (it used to go to the model as text, which answered it as a question).
const compactLine = /^\/compact\s*$/i.test(message.trim());
const turnOptions = { chatId, message, workspacePath: cwd, choice, images, mode: typeof body.mode === 'string' ? body.mode : null, thinking: typeof body.thinking === 'string' ? body.thinking : null, thinkingBudget: Number.isFinite(body.thinkingBudget) ? body.thinkingBudget : 0, history: Array.isArray(body.history) ? body.history : [], onFrame: write, signal: controller.signal };
const result = typeof body.planId === 'string' && body.message === '/plan start' ? await agent.planGoal({ ...turnOptions, planId: body.planId })
: compactLine ? await agent.compactCommand(turnOptions)
: researchLine ? await agent.researchCommand({ ...turnOptions, line: researchLine[1] ?? '' })
: planLine ? await agent.planCommand({ ...turnOptions, line: planLine[1] ?? '' })
: goalLine ? await agent.goalCommand({ ...turnOptions, line: goalLine[1] ?? '' }) : await agent.runTurn(turnOptions);
@@ -709,6 +720,7 @@ async function runTurn({ message, history = [], onFrame, signal, client, workspa
durationMs: Date.now() - started,
ttftMs: firstTokenMs ?? undefined,
cachedTokens: result.cachedTokens,
tps: result.tps,
usageSource: result.usageSource,
});
if(models.REAL){monitor.setRecordedContext({ ...result, requestId: entry.id, sessionId });monitor.idle();}
@@ -908,8 +920,10 @@ const tasks = createTaskService({
const taskTimer = setInterval(() => tasks.tick(), 15000);
taskTimer.unref();
const localShare = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, port: Number(process.env.STRATA_SHARE_PORT ?? PORT + 1), changed: () => serving.setSharing(localShare.state()) });
const tunnelPort = Number(process.env.STRATA_TUNNEL_PORT ?? PORT + 2);
// The share and tunnel listeners take a free port (the next ones above the host's own) instead of a fixed PORT+1 / PORT+2 that Docker, WSL or Hyper-V may hold or reserve.
const sharePort = await findFreePort(Number(process.env.STRATA_SHARE_PORT ?? PORT + 1), '0.0.0.0', { skip: [PORT] });
const localShare = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, port: sharePort, changed: () => serving.setSharing(localShare.state()) });
const tunnelPort = await findFreePort(Number(process.env.STRATA_TUNNEL_PORT ?? PORT + 2), '127.0.0.1', { skip: [PORT, sharePort] });
const tunnelOrigin = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, host: '127.0.0.1', port: tunnelPort });
const connector = createConnectorInstaller({ directory: join(store.HOME, 'engine', 'cloudflare'), componentDirectory: join(componentsDirectory(), 'cloudflared') });
const cloudflare = createCloudflare({ origin: tunnelOrigin, originUrl: `http://127.0.0.1:${tunnelPort}`, hasKey: () => !!store.get().serving?.apiKey, changed: serving.setTunnel, locate: connector.locate, prepareConnector: connector.ensure, connectorInfo: connector.connector, findRunning: findRunningConnector });
@@ -962,6 +976,17 @@ async function api(req, res, path, method, url) {
if(path==='/api/native'&&method==='GET')return json(res,200,await native.refresh(url.searchParams.get('refresh')==='1'));
if(path==='/api/native'&&method==='PATCH')return json(res,200,await native.configure(await readBody(req)));
if(path==='/api/native/directory'&&method==='POST')return json(res,200,native.setDownloadDirectory((await readBody(req)).directory));
/* Settings > Storage > Models folder: pick (or type) a folder, say what it means, then start the choice. Copying is Control's (strata models move). */
if(path==='/api/native/models-folder/plan'&&method==='POST'){
const typed=await typedFolder((await readBody(req)).path,{isAbsolute});
const picked=typed?{path:typed}:await chooseFolder('Choose a folder for your models');
if(picked.unavailable){const u=chooserUnavailable(picked);return error(res,501,u.message,{code:u.code});}
if(picked.cancelled)return json(res,200,{cancelled:true});
return json(res,200,{plan:await native.planModelsFolder(picked.path)});}
if(path==='/api/native/models-folder/start'&&method==='POST'){const body=await readBody(req);const result=await native.startModelsFolder({folder:body.path,choice:body.choice,stopRunning:body.stopRunning===true});return json(res,result.id?202:200,result);}
if(path==='/api/native/models-folder/interrupted'&&method==='GET')return json(res,200,{interrupted:await native.interruptedMove()});
if(path==='/api/native/models-folder/resume'&&method==='POST'){const body=await readBody(req);return json(res,202,await native.finishInterruptedMove({stopRunning:body.stopRunning===true}));}
if(path==='/api/native/models-folder/leave'&&method==='POST')return json(res,200,await native.leaveInterruptedMove());
if(path==='/api/native/plan'&&method==='POST')return json(res,200,await native.plan(await readBody(req)));
if(path==='/api/native/operations'&&method==='POST'){if(storageChanging)return error(res,409,'Wait for the model folder check to finish.');const body=await readBody(req);return json(res,202,native.operation(body.kind,body.args));}
if(path==='/api/native/logs'&&method==='GET')return json(res,200,{logs:native.logs()});
@@ -979,7 +1004,7 @@ async function api(req, res, path, method, url) {
return json(res,200,{path:file});}
if(path==='/api/native/local/preview'&&method==='POST'){
/* "Use a local GGUF": pick (or type) the folder, and Control says what is in it. Nothing is copied or changed. */
const typed=await typedFolder((await readBody(req)).path,{isAbsolute});
const typed=await typedFolder((await readBody(req)).path,{isAbsolute,fileType:/\.gguf$/i});
const picked=typed?{path:typed}:await chooseFolder('Choose the folder that holds your GGUF files');
if(picked.unavailable){const u=chooserUnavailable(picked);return error(res,501,u.message,{code:u.code});};
if(picked.cancelled)return json(res,200,{cancelled:true});
@@ -1006,7 +1031,7 @@ async function api(req, res, path, method, url) {
if(nativeSettingsFile&&method==='POST')return json(res,200,await native.openSettingsFile(nativeSettingsFile[1]));
// Credentials are handled only on the host. Responses and events expose a key-set flag.
return error(res,404,'Unknown native control action.');}catch(e){return error(res,/Finish|Unload|running|current operation/.test(e.message)?409:400,e.message);}}
return error(res,404,'Unknown native control action.');}catch(e){return error(res,/Finish|Unload|running|current operation/.test(e.message)?409:400,e.message,{...(e.code&&typeof e.code==='string'?{code:e.code}:{}),...(e.details?{details:String(e.details)}:{})});}}
if (path === '/api/model-providers' && method === 'GET') return json(res, 200, withPictureAnswers(modelProviders.list()));
if (path === '/api/model-providers/discover' && method === 'POST') {
@@ -1702,6 +1727,16 @@ async function api(req, res, path, method, url) {
try { const f = rawFile(chatRoot(url.searchParams.get('sessionId') ?? ''), url.searchParams.get('path')); res.writeHead(200, { 'content-type': f.mime, 'content-length': f.bytes.length, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); res.end(f.bytes); return undefined; }
catch (e) { return error(res, e.code === 'outside' ? 403 : 400, e.message); }
}
/* A message too large for the model's window, made small enough to send: its text as a file in the chat's folder, or a shortened preview (oversize.mjs). */
if (path === '/api/chat/oversize' && method === 'POST') {
const body = await readBody(req, 12 * 1024 * 1024);
const text = String(body.text ?? '');
if (!text.trim()) return error(res, 400, 'text is required');
let instance = null;
try { instance = native.resolveInstance(body.nativeModelId ?? native.state().defaultId); } catch { /* no local model: the length alone decides */ }
try { return json(res, 200, await prepareOversized({ mode: body.mode === 'file' ? 'file' : 'preview', text, root: chatRoot(String(body.sessionId ?? '')), instance })); }
catch (e) { return error(res, 400, 'The message could not be prepared. Try a shorter message.'); }
}
/* "Attach as context": the file is named in the next message of the chat, so the assistant reads it again. */
if (path === '/api/chat/attachments' && method === 'GET') return json(res, 200, { files: chatAttachments.list(url.searchParams.get('sessionId') ?? '') });
if (path === '/api/chat/attachments' && method === 'POST') {
@@ -1919,6 +1954,8 @@ async function api(req, res, path, method, url) {
if (chatId && !agentSkip) {
const capability = agent.status();
if (capability.state !== 'unavailable') {
const skipped = mcpSkippedNotices(capability.skippedServers);
for (const notice of skipped) res.write(`${JSON.stringify({ type: 'notice', ...notice })}\n`);
await runAgentTurn({ res, controller, chatId, body, message, workspaceId, images });
res.end();
return undefined;
@@ -2161,6 +2198,12 @@ async function staticFile(req, res, path) {
/* ------------------------------------------------------------------ start --- */
server.on('error', error => {
if (server.listening) { console.error(`[strata] ${error.message}`); return; }
// a taken or Windows-reserved port: one plain sentence instead of a stack trace
console.error(`[strata] ${listenProblem(error, PORT, HOST)}`);
process.exit(1);
});
server.listen(PORT, HOST, () => {
if (process.send) process.send({ type: 'strata-ready', port: PORT });
// Engines a lost owner left behind (an earlier crash, an older version): ended after a PID + start time + command-line check.
+5 -3
View File
@@ -2,14 +2,16 @@
import http from 'node:http';
import os from 'node:os';
import { apiPath } from './api-paths.mjs';
import { listenProblem } from './ports.mjs';
/** Sharing without a key is allowed (owner decision, 0.2.2): the person is told once, in plain words, and the app keeps offering to make a key. */
export const NO_KEY_WARNING = 'No API key is set, so any device on this network can use your model. Create an API key to protect it.';
export function createLocalShare({ handle, hasKey, changed = () => {}, host = '0.0.0.0', port = 8792 }) {
let listener = null, active = false;
const address = () => Object.values(os.networkInterfaces()).flat().find(n => n?.family === 'IPv4' && !n.internal)?.address ?? '127.0.0.1';
const state = () => ({ active, url: active ? `http://${host === '127.0.0.1' ? host : address()}:${listener.address().port}/v1` : null });
const state = () => ({ active, url: active ? `http://${host === '127.0.0.1' ? host : address()}:${listener.address().port}/v1` : null, warning: active && !hasKey() ? NO_KEY_WARNING : null });
async function start() {
if (active) return state();
if (listener) throw new Error('Sharing is already starting.');
if (!hasKey()) throw new Error('Set an API key before sharing with other devices.');
listener = http.createServer(async (req, res) => {
const path = apiPath(new URL(req.url ?? '/', 'http://localhost').pathname);
if (!['/v1/models','/v1/chat/completions','/v1/messages'].includes(path)) { res.writeHead(404); res.end('Not found'); return; }
@@ -17,7 +19,7 @@ export function createLocalShare({ handle, hasKey, changed = () => {}, host = '0
catch { if (!res.headersSent) res.writeHead(500); res.end('Request failed'); }
});
try { await new Promise((resolve,reject) => { listener.once('error',reject); listener.listen(port,host,resolve); }); }
catch(e) { listener.close(); listener=null; throw e; }
catch(e) { listener.close(); listener=null; throw new Error(listenProblem(e, port, host)); }
active=true; changed(); return state();
}
async function stop() {
+10
View File
@@ -0,0 +1,10 @@
// The one rule for the name of a tool server (MCP), shared by the Settings editor (src/lib/mcp-names.ts keeps a copy; tests/mcp-rows.test.mjs
// checks they agree), the save check (native-settings.mjs) and the agent profile (agent/profile.mjs).
//
// The model sees every tool as `mcp__<server>__<tool>`, and an OpenAI-style API takes at most 64 characters for that name, so the server's
// part is limited to 32 (T6, tools and images review 2026-10-22). Before, the editor and the save check accepted 64 and the profile quietly
// dropped everything over 32 (or with a dot): the model never saw the tool and nothing said why.
export const MCP_NAME_MAX = 32;
export const MCP_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/;
export const validMcpName = name => typeof name === 'string' && MCP_NAME_RE.test(name);
export const MCP_NAME_HELP = 'Use a simple server name of up to 32 characters: letters, numbers, hyphens or underscores.';
+30
View File
@@ -0,0 +1,30 @@
/**
* Which files of a model are missing, in words a person can act on (#3478). Control's model list says which required parts of the run
* config are absent (`missing`: part + file name), and the installer's status says which GGUF files and shards it could not find
* (`missing_files`, full paths). Together they give the names; the app shows them instead of only "some files are missing".
*/
import { basename } from 'node:path';
const PART_WORDS = { engine: 'the engine program', 'tokenizer/vocab.json': 'the tokenizer', 'tokenizer/merges.txt': 'the tokenizer', 'tokenizer/token_type.json': 'the tokenizer' };
/** Short readable names, no duplicates, most useful first. */
export function missingNames(controlParts, installerFiles) {
const names = [];
const add = text => { const t = String(text ?? '').trim(); if (t && !names.includes(t)) names.push(t); };
for (const file of Array.isArray(installerFiles) ? installerFiles : []) add(basename(String(file).split(String.fromCharCode(92)).join('/')));
for (const item of Array.isArray(controlParts) ? controlParts : []) {
add(PART_WORDS[item?.part] ?? item?.file ?? item?.part);
}
return names;
}
/** The error a load or a tune raises for a model that is not whole. Names are in the sentence (at most four) and all of them in `details`. */
export function missingFilesError(model, names) {
const list = names ?? [];
const shown = list.slice(0, 4).join(', ') + (list.length > 4 ? ` and ${list.length - 4} more` : '');
const error = new Error(list.length
? `Some model files are missing: ${shown}. Use Download / repair to restore them.`
: 'Some model files are missing. Use Download / repair to restore them.');
error.details = [list.length ? `Missing for ${model}: ${list.join(', ')}` : `Missing for ${model}: Strata could not tell which files`];
return error;
}
+112
View File
@@ -0,0 +1,112 @@
/**
* Where the person's models go: what a picked folder means (0.2.2).
*
* - A drive on its own (D:\ or /) is never filled with model files: it becomes D:\Strata (the data folder) and D:\Strata\Models (the
* model library), which the person is told.
* - The data folder is the person's own choice, so it is written into the settings file Strata Control reads (`data_dir`). Control's
* installer accepts a data folder that is that setting, whatever the folder is called; for a folder it has not been told about it wants a
* name starting with "Strata" (a rule against an AI tool filling an arbitrary folder, tools/strata_mcp.py check_data_dir).
* - The link that makes `<data>/models` and `<data>/Models` one folder is only needed where the file system tells them apart; where it cannot be
* made (exFAT, FAT, a network share) the models simply go in `<data>/models`.
*/
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join, parse, resolve } from 'node:path';
export const isDriveRoot = path => { const p = resolve(path); return p === parse(p).root; };
/** The data folder for a picked folder: a drive root gets a `Strata` folder of its own. `{ path, note }`. */
export function dataFolderFromPick(picked) {
const path = resolve(picked);
if (!isDriveRoot(path)) return { path, note: null };
const target = join(path, 'Strata');
return { path: target, note: `${path} is a whole drive, so the models go in ${target}.` };
}
/** The model library folder for a picked folder: a drive root gets `Strata/Models`. `{ path, note }`. */
export function libraryFolderFromPick(picked) {
const path = resolve(picked);
if (!isDriveRoot(path)) return { path, note: null };
const target = join(path, 'Strata', 'Models');
return { path: target, note: `${path} is a whole drive, so the models go in ${target}.` };
}
/** Make a folder the person asked for; a refusal of the disk is said in words. */
export function makeFolder(path) {
try { mkdirSync(path, { recursive: true }); }
catch (error) { throw new Error(`Strata could not create ${path} (${error.code === 'EACCES' || error.code === 'EPERM' ? 'access was denied' : error.code === 'ENOSPC' ? 'the drive is full' : error.message}). Choose another folder.`); }
return path;
}
/** `models` beside `Models`: a link where the two are different names. A disk that cannot hold the link gets a plain `models` folder instead. */
export function alignModels(data, library, { link = symlinkSync } = {}) {
const models = join(data, 'models');
if (existsSync(models)) return { data, linked: false };
try { link(process.platform === 'win32' ? library : 'Models', models, process.platform === 'win32' ? 'junction' : 'dir'); return { data, linked: true }; }
catch { /* exFAT, FAT, NFS, a share: no links there */ }
try { mkdirSync(models, { recursive: true }); } catch { /* the install says what it cannot write */ }
return { data, linked: false };
}
function readSettings(file) {
try { const parsed = JSON.parse(readFileSync(file, 'utf8').replace(/^/, '')); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; } catch { return {}; }
}
/** The data folder a settings file names and when it was last changed (`data_dir_at`, seconds; the file's own time for a file that has none). */
export function readDataFolder(file) {
if (!file) return null;
const current = readSettings(file);
if (typeof current.data_dir !== 'string' || !current.data_dir) return null;
let at = Number(current.data_dir_at);
if (!Number.isFinite(at) || at <= 0) { try { at = statSync(file).mtimeMs / 1000; } catch { at = 0; } }
return { folder: current.data_dir, at };
}
/** The settings file the Terminal (command-line Strata) keeps: the same place setup.py and strata_control/discovery.py use. */
export function terminalSettingsFile(env = process.env, platform = process.platform) {
if (env.STRATA_TERMINAL_SETTINGS_FILE) return env.STRATA_TERMINAL_SETTINGS_FILE;
if (platform === 'win32') return join(env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'Strata', 'settings.json');
return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'strata', 'settings.json');
}
/** Write the person's data folder into a settings file (merged, atomically) with the time of the change; returns whether it changed. Never
* throws: the folder still works for the app, only the Terminal and the installer's own check lose the shared setting. */
export function rememberDataFolder(file, folder, at = Date.now() / 1000, touch = false) {
if (!file || !folder) return false;
try {
const current = readSettings(file);
if (current.data_dir === folder && !touch) return false;
mkdirSync(dirname(file), { recursive: true });
const temp = `${file}.${process.pid}.tmp`;
writeFileSync(temp, JSON.stringify({ ...current, data_dir: folder, data_dir_at: at }, null, 1) + '\n', { mode: 0o600 });
renameSync(temp, file);
return true;
} catch { return false; }
}
const same = (a, b) => resolve(a).toLowerCase() === resolve(b).toLowerCase();
/**
* One models folder for the app and the Terminal. The app keeps its choice in its own settings file (`appFile`), the Terminal in `terminalFile`;
* whichever was changed last wins and the other file is brought up to date. Nothing is moved. A folder that is gone (a disk that is not
* connected) is never adopted. `app` is the app's own answer when its file names none (`{ folder, at }`).
* Returns `{ folder, from }`: the shared folder (null when neither names one) and `from` = 'terminal' when the app has to take over the
* Terminal's folder, 'app' when the Terminal's file was updated, null when nothing changed.
*/
export function syncDataFolder({ appFile, terminalFile, app = null, exists = path => existsSync(path) }) {
const mine = readDataFolder(appFile) ?? app;
const theirs = readDataFolder(terminalFile);
const theirsUsable = theirs && exists(theirs.folder);
if (!theirsUsable) {
if (mine && theirs && !same(mine.folder, theirs.folder)) { rememberDataFolder(terminalFile, mine.folder, mine.at); return { folder: mine.folder, from: 'app' }; }
return { folder: mine?.folder ?? null, from: null };
}
if (!mine || (!same(mine.folder, theirs.folder) && theirs.at > mine.at)) {
rememberDataFolder(appFile, theirs.folder, theirs.at);
return { folder: theirs.folder, from: 'terminal' };
}
if (!same(mine.folder, theirs.folder)) { rememberDataFolder(terminalFile, mine.folder, mine.at); return { folder: mine.folder, from: 'app' }; }
return { folder: mine.folder, from: null };
}
+11 -1
View File
@@ -30,6 +30,16 @@ export const PROVIDER_CATALOG = [
const PROTOCOLS = ['openai-chat', 'openai-responses', 'anthropic'];
const fail = message => { throw new Error(message); };
const text = (value, max = 160) => typeof value === 'string' ? value.trim().slice(0, max) : '';
/** The answer speed of a model API's reply: the tokens it reports over the time from its first streamed token to the end, never over the whole
* wait (that would put the time to the first token, often seconds of prompt reading, into the speed). null when it reported no tokens;
* with no usable first-token time (or under a quarter of a second) the whole time is the best there is. */
export function answerSpeed(tokensOut, durationMs, firstTokenMs) {
if (!Number.isFinite(tokensOut) || tokensOut <= 0) return null;
const decodeMs = Number.isFinite(firstTokenMs) ? durationMs - firstTokenMs : NaN;
const ms = decodeMs >= 250 ? decodeMs : durationMs;
return Number((tokensOut / Math.max(0.001, ms / 1000)).toFixed(1));
}
export function normalizeBaseUrl(value) {
let url; try { url = new URL(text(value, 2048)); } catch { fail('Enter a full http:// or https:// API address.'); }
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) fail('Use an HTTP or HTTPS base URL without credentials, a query, or a fragment.');
@@ -207,7 +217,7 @@ export function createModelProviders({ directory, changed = () => {}, fetchImpl
if (!textLength) fail('The model returned no text. Choose a model that supports text chat.');
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
const durationMs = Date.now() - started;
const result = { tokensIn, tokensOut, usageSource: tokensIn !== null && tokensOut !== null ? 'reported' : 'unavailable', durationMs, ttftMs: firstToken, tps: tokensOut !== null ? tokensOut / Math.max(0.001, durationMs / 1000) : null };
const result = { tokensIn, tokensOut, usageSource: tokensIn !== null && tokensOut !== null ? 'reported' : 'unavailable', durationMs, ttftMs: firstToken, tps: answerSpeed(tokensOut, durationMs, firstToken) };
onFrame({ type: 'done', ...result }); return result;
}
/** Connections WITH their keys, for the host's own agent bridge only; never serialised to a client. */
+5 -3
View File
@@ -347,12 +347,14 @@ export function idle(label = 'Waiting for a request') {
hubRef?.send('monitor', snapshot());
}
export function record({ tokensIn, tokensOut, durationMs = 0, ttftMs = null, cachedTokens = null }) {
export function record({ tokensIn, tokensOut, durationMs = 0, ttftMs = null, cachedTokens = null, tps: answerTps = null }) {
if (Number.isFinite(tokensIn)) state.totals.tokensIn += tokensIn;
if (Number.isFinite(tokensOut)) state.totals.tokensOut += tokensOut;
state.totals.requests += 1;
if (Number.isFinite(tokensOut) && durationMs > 0) {
const tps = tokensOut / (durationMs / 1000);
// The average is of answer speeds (tokens over decode time). A request with no decode figure is left out: dividing by the whole request would
// put the prompt reading into it and read lower than the engine and the chat do.
if (Number.isFinite(answerTps) && answerTps > 0) {
const tps = answerTps;
const n = ++state.totals.usageRequests;
state.totals.avgTps = round((state.totals.avgTps * (n - 1) + tps) / n, 1);
}
+150 -17
View File
@@ -1,9 +1,10 @@
/** Native Strata control plane. All user actions are explicit; opening the UI only reads state. */
import {missingFilesError,missingNames} from './missing-files.mjs';
import { spawn } from 'node:child_process';
import {chooseParallel,applyParallel,withoutParallel,FALLBACK_NOTE} from './native-parallel.mjs';
import { randomBytes, randomUUID, createHash } from 'node:crypto';
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, realpathSync, copyFileSync, renameSync, unlinkSync, chmodSync, statSync, accessSync, constants } from 'node:fs';
import { join, resolve, dirname, basename } from 'node:path';
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, realpathSync, copyFileSync, renameSync, rmSync, statfsSync, unlinkSync, chmodSync, statSync, accessSync, constants } from 'node:fs';
import { join, resolve, dirname, basename, sep, relative, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer } from 'node:net';
import { NATIVE_FIELDS, APP_OWNED_FIELDS, withoutDraftLayer, validateNativeSettings, validateMcpServers, publicMcpServers, publicNativeEnv, restoreNativeEnv } from './native-settings.mjs';
@@ -14,12 +15,14 @@ import os from 'node:os';
import { runtimeVision } from './native-vision.mjs';
import { componentsDirectory, installerEnvironment, controlEnvironment } from './control-runtime.mjs';
import { createEngineStep } from './engine-step.mjs';
import { dataFolderFromPick, isDriveRoot, makeFolder, rememberDataFolder, syncDataFolder, terminalSettingsFile } from './model-folder.mjs';
import { createAutoTune, writeTuneConfig } from './native-autotune.mjs';
import { plainError, PHASE_TEXT } from './update-messages.mjs';
import { checkRunConfig, describeIssues } from './run-config-check.mjs';
import { openTextFile } from './open-text-file.mjs';
import { markEngine, engineRefusal } from './engine-version.mjs';
import { friendlyModelName } from './model-names.mjs';
import { parseSaved as parseRoles, mirror as mirrorRoles } from './gpu-roles.mjs';
const ROOT=resolve(dirname(fileURLToPath(import.meta.url)),'..');
const safeId=value=>typeof value==='string'&&/^[a-z0-9_-]{1,80}$/i.test(value);
@@ -51,7 +54,11 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
let defaultId=null,timer=null,closing=false,polling=false;
const emit=()=>{const value=state();writeFileSync(join(directory,'native-jobs.json'),JSON.stringify(value.jobs),{mode:0o600});changed(value);};
const append=(instanceId,line)=>{logs.push({at:new Date().toISOString(),instanceId,line:String(line).slice(0,2000)});if(logs.length>400)logs.shift();};
function state(){return {...snapshot,downloadDirectory:preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),fields:nativeFields(),instances:[...instances.values()].map(({key,configPath,fingerprint,mcpServers,...i})=>structuredClone(i)),defaultId,jobs:[...jobs.values()].slice(-12).map(({process,abort,engineAbort,rawResult,...j})=>j),capabilities:{responses:sourceText().includes('/v1/responses'),updates:!!snapshot.available,rollback:previousEngine(),componentUpdates:packaged&&typeof control.update==='function',calibration:packaged?false:snapshot.hardware?.gpus?.some(g=>/nvidia/i.test(g.name??''))??false}};}
function diskSpace(path){try{const s=statfsSync(path);return {free:Number(s.bavail)*Number(s.bsize),total:Number(s.blocks)*Number(s.bsize)};}catch{return null;}}
/* Does the engine in this release serve DFlash2? Control knows (strata_control/drafting.py); asked once, false until it answers. The Models screen offers the choice only when true. */
let dflash2Served=null;
function probeDrafting(){if(!packaged||dflash2Served!==null||typeof control.draftingPlan!=='function')return;dflash2Served=false;control.draftingPlan({mode:'auto'}).then(p=>{dflash2Served=p.serves===true;emit();}).catch(()=>{});}
function state(){const folder=preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),space=folder?diskSpace(folder):null;return {...snapshot,downloadDirectory:folder,downloadFreeBytes:space?.free??null,downloadTotalBytes:space?.total??null,fields:nativeFields(),instances:[...instances.values()].map(({key,configPath,fingerprint,mcpServers,...i})=>structuredClone(i)),defaultId,jobs:[...jobs.values()].slice(-12).map(({process,abort,engineAbort,rawResult,...j})=>j),capabilities:{responses:sourceText().includes('/v1/responses'),updates:!!snapshot.available,rollback:previousEngine(),componentUpdates:packaged&&typeof control.update==='function',calibration:packaged?false:snapshot.hardware?.gpus?.some(g=>/nvidia/i.test(g.name??''))??false,dflash2:dflash2Served===true}};}
let 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)}));}
@@ -70,8 +77,12 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
for(const name of names){const path=join(dir,name),config=readJson(path,null);if(!config||typeof config!=='object')continue;
const next={...config,...(config.cwd!==undefined?{cwd:fix(config.cwd)}:{}),...(Array.isArray(config.args)?{args:config.args.map(fix)}:{})};
if(JSON.stringify(next)!==JSON.stringify(config)){try{writeModelConfig(path,next);}catch{/* read-only folder: the load reports it */}}}}
function installedInfo(info){if(!info)return info;return {...info,models:(info.models??[]).map(entry=>{const cfg=/^strata-[a-z0-9_-]+\.json$/i.test(entry.config??'')?readJson(entry.configPath??join(repo(),entry.config)):{};const at=cfg.args?.indexOf('--native')??-1,nativeFile=at>=0?cfg.args[at+1]:null;const dataFolder=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))==='models'?dirname(dirname(dirname(nativeFile))):info.data_folder;/* A model the person pointed at their own GGUF folder (not <data>/models/<tag>): remembered, so a later load runs setup with that folder again instead of downloading the files (#3199). */const ownGgufDir=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))!=='models'&&existsSync(nativeFile)?dirname(nativeFile):undefined;return {...entry,ownGgufDir,diskGb:entry.diskGb??entry.disk_gb,diskBytes:entry.diskBytes??entry.disk_bytes,sharedBytes:entry.sharedBytes??entry.shared_bytes,removableGb:entry.removableGb??entry.removable_gb,dataFolder,visionPrepared:!!cfg.vision,/* The picture files are prepared when the run config has a vision section: the Models screen then loads straight away instead of running setup again (it used to read 'off' for every model, which sent every load of a picture-capable model through "Apply settings and load", and setup writes the arguments, the draft layer and the reader back each time). */images:cfg.vision?'yes':'off',draftLayer:cfg.mtp!==false,/* Picture reading is ON unless the person turned it off: no saved choice and not loaded is undefined, not false (owner rule: pictures on by default). */visionEnabled:cfg.ui_vision_enabled??(()=>{const running=[...instances.values()].find(i=>i.model===entry.model&&i.status==='ready');return running?!!running.vision:undefined;})(),visionDevice:cfg.ui_vision_device,resourceProfile:cfg.ui_resource_profile,lowRamMode:cfg.args?.includes('--resident-experts')?'resident':cfg.args?.includes('--mmap-experts')?'mmap':'auto'};})};}
function installedInfo(info){if(!info)return info;return {...info,models:(info.models??[]).map(entry=>{const cfg=/^strata-[a-z0-9_-]+\.json$/i.test(entry.config??'')?readJson(entry.configPath??join(repo(),entry.config)):{};const at=cfg.args?.indexOf('--native')??-1,nativeFile=at>=0?cfg.args[at+1]:null;const dataFolder=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))==='models'?dirname(dirname(dirname(nativeFile))):info.data_folder;/* A model the person pointed at their own GGUF folder (not <data>/models/<tag>): remembered, so a later load runs setup with that folder again instead of downloading the files (#3199). */const ownGgufDir=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))!=='models'&&existsSync(nativeFile)?dirname(nativeFile):undefined;return {...entry,ownGgufDir,diskGb:entry.diskGb??entry.disk_gb,diskBytes:entry.diskBytes??entry.disk_bytes,sharedBytes:entry.sharedBytes??entry.shared_bytes,removableGb:entry.removableGb??entry.removable_gb,dataFolder,visionPrepared:!!cfg.vision,/* The picture files are prepared when the run config has a vision section: the Models screen then loads straight away instead of running setup again (it used to read 'off' for every model, which sent every load of a picture-capable model through "Apply settings and load", and setup writes the arguments, the draft layer and the reader back each time). */images:cfg.vision?'yes':'off',draftLayer:cfg.mtp!==false,drafting:cfg.drafting??'auto',/* Picture reading is ON unless the person turned it off: no saved choice and not loaded is undefined, not false (owner rule: pictures on by default). */visionEnabled:cfg.ui_vision_enabled??(()=>{const running=[...instances.values()].find(i=>i.model===entry.model&&i.status==='ready');return running?!!running.vision:undefined;})(),visionDevice:cfg.ui_vision_device,resourceProfile:cfg.ui_resource_profile,lowRamMode:cfg.args?.includes('--resident-experts')?'resident':cfg.args?.includes('--mmap-experts')?'mmap':'auto'};})};}
function installRoot(){const found=[preferences.installDir].find(d=>d&&existsSync(d));if(found)return realpathSync(found);mkdirSync(defaultInstall(),{recursive:true});return realpathSync(defaultInstall());}
/* The settings file Strata Control and the installer read (control-runtime.mjs sets STRATA_SETTINGS_FILE to it): the app's data folder is written there. */
function controlSettingsFile(){return process.env.STRATA_SETTINGS_FILE||join(dirname(componentsDirectory()),'app-settings.json');}
/* One models folder for the app and the Terminal: the one changed last wins and the other settings file is updated (nothing is moved). */
function shareDataFolder(){if(!packaged)return;try{let at=0;if(preferences.downloadDirectory)try{at=statSync(settingsPath).mtimeMs/1000;}catch{/* no file yet */}const found=syncDataFolder({appFile:controlSettingsFile(),terminalFile:terminalSettingsFile(),app:preferences.downloadDirectory?{folder:preferences.downloadDirectory,at}:null});if(found.from==='terminal'&&found.folder&&found.folder!==preferences.downloadDirectory){preferences={...preferences,downloadDirectory:found.folder};writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});}}catch{/* the shared setting is a convenience: never stop the app */}}
function repo(){if(packaged)return installRoot();const path=realpathSync(preferences.repo);if(!existsSync(join(path,'setup.py'))||!existsSync(join(path,'tools/strata_mcp.py'))||!existsSync(join(path,'serve/server.py')))throw new Error('Choose the Strata repository folder containing setup.py and tools/strata_mcp.py.');return path;}
function python(){return resolvePython({repo:preferences.repo});}
function send(method,params,timeout=30000){return new Promise((resolve,reject)=>{const id=++seq;const alarm=setTimeout(()=>{pending.delete(id);reject(new Error('The native engine did not respond in time. Check engine logs.'));},timeout);pending.set(id,{resolve,reject,alarm});child.stdin.write(JSON.stringify({jsonrpc:'2.0',id,method,params})+'\n');});}
@@ -99,8 +110,8 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
packagedInstall=list.install_dir;
const engine=await control.engine();
const installer=await mcpCall('strata_status',{hardware:true}).catch(error=>({error:error.message}));
const disk=Object.fromEntries((installer.installed?.models??[]).map(m=>[m.model,{diskGb:m.disk_gb,diskBytes:m.disk_bytes,sharedBytes:m.shared_bytes,removableGb:m.removable_gb,partsMissing:m.parts_missing??0}]));
const models=(list.models??[]).filter(m=>m.component_status!=='invalid'&&m.source_config).map(m=>({...(disk[m.id]??{}),model:m.id,config:basename(m.source_config),configPath:m.source_config,model_name:m.name,context:m.context_tokens??0,backend:m.backend,ready:m.component_status==='present'&&!(disk[m.id]?.partsMissing),images:'off',port:undefined,last_used:null}));
const disk=Object.fromEntries((installer.installed?.models??[]).map(m=>[m.model,{diskGb:m.disk_gb,diskBytes:m.disk_bytes,sharedBytes:m.shared_bytes,removableGb:m.removable_gb,partsMissing:m.parts_missing??0,missingFiles:m.missing_files}]));
const models=(list.models??[]).filter(m=>m.component_status!=='invalid'&&m.source_config).map(m=>({...(disk[m.id]??{}),model:m.id,config:basename(m.source_config),configPath:m.source_config,model_name:m.name,context:m.context_tokens??0,backend:m.backend,ready:m.component_status==='present'&&!(disk[m.id]?.partsMissing),missing:missingNames(m.missing,disk[m.id]?.missingFiles),images:'off',port:undefined,last_used:null}));
const cores=os.cpus().length;
return {summary:engine.usable?`Strata Control is ready; engine ${engine.engine.version} (${engine.engine.platform}-${engine.engine.backend}).`:(engine.notice??'No engine for this PC was found.'),
installDir:list.install_dir,engine:{usable:engine.usable,notice:engine.notice,wrongMake:engine.wrongMake===true,version:engine.engine?.version??null,backend:engine.backend,componentsDir:engine.components_dir,previous:engine.previous??null,pinned:engine.pinned??null,updateNotice:engine.update_notice??null},
@@ -118,10 +129,11 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
/* A read that is not forced never makes the window wait for the (slow) status of Control: it gets the last state at once, and a stale one
is refreshed in the background. Only the first read, and a forced one after an operation, waits. */
if(refreshing)return !force&&snapshot.available?state():refreshing;if(!force&&Date.now()-lastRead<45000)return state();
probeDrafting();
refreshing=(async()=>{try{const [catalog,status]=packaged?await (async()=>{const s=await tool('strata_status',{hardware:true});const c=await tool('strata_models').catch(error=>({families:[],contexts:[],recommendation:{family:null,model:null},context_note:'',error:error.message}));return [c,s];})():await Promise.all([tool('strata_models'),tool('strata_status',{hardware:true})]);let visionReader=false;if(packaged){try{const e=await control.engine();const root=e?.engine?.root??(e?.engine?.exe?dirname(e.engine.exe):'');visionReader=!!root&&['strata-vision','strata-vision.exe'].some(n=>existsSync(join(root,n)));}catch{visionReader=false;}}const markImages=c=>c&&Array.isArray(c.families)?{...c,families:c.families.map(f=>({...f,imagesCapable:!!f.images,images:!!f.images&&visionReader,sizes:(f.sizes??[]).map(s=>({...s,imagesCapable:!!s.images,images:!!s.images&&visionReader}))}))}:c;snapshot={available:true,repo:packaged?status.installDir:repo(),version:packaged?(control.version??null):await execute('git',['describe','--tags','--always'],repo()).then(s=>s.trim()).catch(()=>null),summary:status.summary,catalog:markEngine(packaged?markImages(catalog):catalog,status.engine),engineInfo:{version:status.engine?.version??null,source:status.engine?.source??null},visionReader:packaged?visionReader:true,installed:installedInfo(status.installed),hardware:status.hardware,error:null,install:status.install,external:status.running??null,...(packaged?{packaged:true,installDir:status.installDir,engine:status.engine,installerError:status.installerError}:{})};lastRead=Date.now();if(status.install?.running&&![...jobs.values()].some(j=>j.state==='running'))begin('install',j=>watchInstall(j,status.install));}catch(error){snapshot={...snapshot,available:false,error:error.message};lastRead=Date.now();}emit();return state();})().finally(()=>{refreshing=null;});const run=refreshing;if(!force&&snapshot.available){run.catch(()=>{});return state();}return run;
}
function configure({repository}){if(packaged){if(instances.size||[...jobs.values()].some(j=>j.state==='running'))throw new Error('Unload models and finish operations before changing the installation.');const next=realpathSync(String(repository));if(!statSync(next).isDirectory())throw new Error('Choose the folder that holds your Strata model configurations.');preferences={...preferences,installDir:next};packagedInstall=null;disconnect();writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});lastRead=0;return refresh(true);}if(instances.size||[...jobs.values()].some(j=>j.state==='running'))throw new Error('Unload models and finish operations before changing the installation.');const next=realpathSync(String(repository));const old=preferences;preferences={...preferences,repo:next};try{repo();}catch(e){preferences=old;throw e;}disconnect();writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});lastRead=0;return refresh(true);}
function setDownloadDirectory(input){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before changing the folder.');if(typeof input!=='string'||!input.trim()||/[\r\n\0]/.test(input))throw new Error('Choose a model folder.');const path=realpathSync(input.trim());if(!statSync(path).isDirectory())throw new Error('Choose a folder, not a file.');accessSync(path,constants.R_OK|constants.W_OK);preferences={...preferences,downloadDirectory:path};writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});emit();return state();}
function setDownloadDirectory(input,{insideJob=false}={}){if(!insideJob&&[...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before changing the folder.');if(typeof input!=='string'||!input.trim()||/[\r\n\0]/.test(input))throw new Error('Choose a model folder.');let path;try{path=realpathSync(input.trim());}catch{throw new Error('That folder does not exist. Check the path, or that the drive is connected.');}if(!statSync(path).isDirectory())throw new Error('Choose a folder, not a file.');/* A whole drive (D:\) is not filled with models: D:\Strata is made and used. */if(isDriveRoot(path)){const own=dataFolderFromPick(path).path;makeFolder(own);path=realpathSync(own);}try{accessSync(path,constants.R_OK|constants.W_OK);}catch{throw new Error('Strata cannot write in that folder. Choose a folder you can save files in.');}preferences={...preferences,downloadDirectory:path};/* the same choice for the installer and the Terminal: the Control settings file */if(packaged)rememberDataFolder(controlSettingsFile(),path,Date.now()/1000,true);writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});shareDataFolder();emit();return state();}
function recordFile(model){if(!safeId(model))throw new Error('Choose an installed model.');const entry=snapshot.installed?.models?.find(i=>i.model===model);if(!entry)throw new Error('This model is not installed. Download and prepare it first.');const path=realpathSync(entry.configPath??join(repo(),entry.config));if(dirname(path)!==repo()||!/^strata-[a-z0-9_-]+\.json$/i.test(basename(path)))throw new Error('The model configuration is outside the Strata installation.');return {entry,path};}
function record(model){const {entry,path}=recordFile(model);return {entry,path,config:readJson(path)};}
/* The text file a power user edits by hand: where it is and what is wrong in it (run-config-check.mjs). */
@@ -132,8 +144,13 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
function settings(model){const {config}=record(model);const values=Object.fromEntries(NATIVE_FIELDS.map(f=>[f.id,config[f.id]??null]));if(typeof values.aliases==='string')values.aliases=values.aliases.split(',').map(v=>v.trim()).filter(Boolean);values.sampling=config.sampling??{};values.args=config.args??[];values.env=publicNativeEnv(config.env??{});return {model,values,mcp:publicMcpServers(config.mcp_servers??config.mcpServers??{}),restartNeeded:true};}
function save(model,{values,mcp}){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before saving native settings.');const {path,config}=record(model);if(busy(requests().filter(r=>instances.get(r.instanceId)?.model===model)))throw new Error('Finish this model’s requests before saving its settings.');const patch=validateNativeSettings(values??{}),next={...config};for(const field of nativeFields())if(field.supported===false&&patch[field.id]!=null&&JSON.stringify(patch[field.id])!==JSON.stringify(config[field.id]))throw new Error(`${field.label} needs a newer native Strata installation. Update it in Settings → About.`);for(const [key,value] of Object.entries(patch)){if(key==='env')next.env=restoreNativeEnv(value,config.env??{});else if(value===null)delete next[key];else next[key]=value;}if('vram_reserve_mib' in patch){const base=captureUserArgs(next);const mine=setUserFlag(base,'--vram-reserve-mib',patch.vram_reserve_mib);if(mine.length)next[USER_KEY]=mine;else delete next[USER_KEY];next.args=mine.length&&patch.vram_reserve_mib!==null?mergeUserArgs(next.args??[],mine):setUserFlag(next.args??[],'--vram-reserve-mib',null);delete next.ui_reserve_applied;if(Array.isArray(next.args_written))next.args_written=[...next.args];}if(mcp!==undefined){next.mcp_servers=validateMcpServers(mcp,config.mcp_servers??config.mcpServers??{});delete next.mcpServers;}copyFileSync(path,path+'.bak');chmodSync(path+'.bak',0o600);const temp=path+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,path);return settings(model);}
function previousEngine(){if(packaged)return !!snapshot.engine?.previous;try{const data=snapshot.installed?.data_dir??snapshot.installed?.data_folder;return [join(preferences.repo,'engine/.previous'),data&&join(data,'engine/.previous')].filter(Boolean).some(existsSync)&&readFileSync(join(preferences.repo,'setup.py'),'utf8').includes('--rollback-engine');}catch{return false;}}
async function plan(args){await refresh();if(!snapshot.available)throw new Error(snapshot.error);return tool('strata_install',installArgs(args,false));}
function installArgs(args,confirm){const allowed=['family','model','context','vision','kv','gpu','gpus','backend','low_ram','port','gguf_dir'];const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model);const existing=snapshot.installed?.models?.find(m=>m.model===size?.id);/* A model adopted from the person's own GGUF folder reports that folder's parent as its data folder: it is theirs, not Strata's, so a second preparation never uses it for the packs. */if(args.gguf_dir===undefined&&existing?.ownGgufDir)args={...args,gguf_dir:existing.ownGgufDir};const data=nativeDataDirectory(args.data_dir??(args.gguf_dir?undefined:existing?.dataFolder)??preferences.downloadDirectory??modelsDirectory?.());return {...(data?{data_dir:data}:{}),...Object.fromEntries(allowed.filter(k=>args[k]!==undefined).map(k=>[k,args[k]])),/* One card is "gpu", never "gpus" (the installer wants two or more there: "Gpus='0' is not valid", #3203). */...(/^\d{1,2}$/.test(String(args.gpus??''))?{gpu:Number(args.gpus),gpus:undefined}:{}),/* Picture reading is only downloaded when it was asked for (Read pictures); the default stays the smaller download. */...(packaged&&args.vision===undefined?{vision:'no'}:{}),confirm};}
async function plan(args){await refresh();if(!snapshot.available)throw new Error(snapshot.error);const result=await tool('strata_install',installArgs(args,false));
/* A chosen drafter that is not on this computer yet is part of the download: its size is listed beside the model's (Control decides; nothing is fetched here). */
const choice=args.drafting;if(packaged&&typeof choice==='string'&&choice!=='auto'&&typeof control.draftingPlan==='function'){try{const d=await control.draftingPlan({mode:choice,family:args.family??null,dataDir:snapshot.installed?.data_folder??null});if(d.needed)return {...result,drafting:{needed:true,gb:d.gb,line:d.line}};}catch{/* the model plan still stands */}}
return result;}
/* The folder the person picked in the first-run chooser can be the one that holds their GGUF files (or a folder inside it). It is theirs, not a Strata data folder: packs are never made in it, and a Strata folder around it (<Strata>/models/<tag>) is used instead (#3507, #3424). */
function ownFilesDataFolder(candidate,args){if(!candidate||args.data_dir!==undefined||!args.gguf_dir)return candidate;const same=(a,b)=>{const x=resolve(a),y=resolve(b);return process.platform==='win32'?x.toLowerCase()===y.toLowerCase():x===y;};const inside=(a,b)=>{const rel=relative(resolve(b),resolve(a));return rel===''||(!!rel&&!rel.startsWith('..')&&!isAbsolute(rel));};if(!same(candidate,args.gguf_dir)&&!inside(candidate,args.gguf_dir))return candidate;const models=dirname(resolve(args.gguf_dir)),top=dirname(models);return basename(models).toLowerCase()==='models'&&/strata/i.test(basename(top))?top:undefined;}
function installArgs(args,confirm){const allowed=['family','model','context','vision','kv','gpu','gpus','backend','low_ram','port','gguf_dir'];const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model);const existing=snapshot.installed?.models?.find(m=>m.model===size?.id);/* A model adopted from the person's own GGUF folder reports that folder's parent as its data folder: it is theirs, not Strata's, so a second preparation never uses it for the packs. */if(args.gguf_dir===undefined&&existing?.ownGgufDir)args={...args,gguf_dir:existing.ownGgufDir};const data=nativeDataDirectory(ownFilesDataFolder(args.data_dir??(args.gguf_dir?undefined:existing?.dataFolder)??preferences.downloadDirectory??modelsDirectory?.(),args));if(data&&packaged&&!args.data_dir)rememberDataFolder(controlSettingsFile(),data);return {...(data?{data_dir:data}:{}),...Object.fromEntries(allowed.filter(k=>args[k]!==undefined).map(k=>[k,args[k]])),/* One card is "gpu", never "gpus" (the installer wants two or more there: "Gpus='0' is not valid", #3203). */...(/^\d{1,2}$/.test(String(args.gpus??''))?{gpu:Number(args.gpus),gpus:undefined}:{}),/* Picture reading is only downloaded when it was asked for (Read pictures); the default stays the smaller download. */...(packaged&&args.vision===undefined?{vision:'no'}:{}),confirm};}
function begin(kind,run,metadata={}){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or cancel the current engine operation first.');const job={id:randomUUID(),kind,state:'running',startedAt:new Date().toISOString(),summary:'Starting…',result:null,error:null,...metadata};for(const [id,old] of jobs)if(jobs.size>=12&&old.state!=='running')jobs.delete(id);jobs.set(job.id,job);emit();void run(job).then(result=>{if(job.state==='running'){Object.assign(job,{state:'done',summary:result?.summary??'Complete',result,finishedAt:new Date().toISOString()});}}).catch(error=>{if(job.state==='running')Object.assign(job,{state:'error',error:error.message,summary:error.message,...(error.details?{details:error.details}:{}),finishedAt:new Date().toISOString()});}).finally(()=>{if(job.kind.startsWith('update')||job.kind==='rollback')disconnect();lastRead=0;emit();void refresh(true);});return state().jobs.find(j=>j.id===job.id);}
async function watchInstall(job,current){job.summary=current.step??'Resuming preparation status…';job.result={install:current};while(job.state==='running'){await new Promise(r=>setTimeout(r,1500));const result=await tool('strata_install');job.summary=result.summary;job.result=result;emit();if(!result.install?.running){if(result.install?.exit_code!==0)throw new Error(result.install?.error??'Preparation did not finish. Retry to reuse completed files.');return {summary:'Model preparation complete. Load it from Models.'};}}}
@@ -167,7 +184,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
const note=engineStep?.summaryNote();
const finalResult=engineStep?job.result:result;
if(note)return {...finalResult,summary:note};
if(args.loadAfter){const model=(result.install.family??args.family??'qwen')==='qwen'?String(args.model).toLowerCase():`${args.family}-${String(args.model).toLowerCase()}`;return loadEngine(job,{model,multiple:!!args.multiple,resourceProfile:args.resourceProfile,vision:args.loadVision});}
if(args.loadAfter){const model=(result.install.family??args.family??'qwen')==='qwen'?String(args.model).toLowerCase():`${args.family}-${String(args.model).toLowerCase()}`;return loadEngine(job,{model,multiple:!!args.multiple,resourceProfile:args.resourceProfile,vision:args.loadVision,...(typeof args.drafting==='string'?{values:{drafting:args.drafting}}:{})});}
return finalResult;
}
}
@@ -201,13 +218,15 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
const resources=args.resourceProfile?resourceSettings({...base0,[USER_KEY]:mine,vision},snapshot.hardware,args.resourceProfile,modelArena(args.model),values0):null;
const cfg={...config,...visionPreferences,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),host:'127.0.0.1',open_browser:false,...validateNativeSettings(args.values??{}),vision};
/* The app's suggested cards only fill a gap: a model whose settings file already names its GPUs ("gpu": [1, 0], with "gpu_order": "as_given") keeps them (#3256). A card the person picked in the screen always wins. */
const namedInFile=Array.isArray(config.gpu)?config.gpu.length>0:config.gpu!==undefined&&config.gpu!==null&&config.gpu!=='';
if(args.gpus!==undefined&&!(args.gpusFromRecommendation&&namedInFile)){const detected=new Set((snapshot.hardware?.gpus??[]).map(g=>g.index));if(!Array.isArray(args.gpus)||!args.gpus.length||new Set(args.gpus).size!==args.gpus.length||args.gpus.some(g=>!Number.isInteger(g)||!detected.has(g)))throw new Error('Choose distinct detected GPUs. CPU-only text inference is not supported by this native adapter.');cfg.gpu=args.gpus.length===1?args.gpus[0]:args.gpus;cfg.layer_split=args.gpus.length>1?validateNativeSettings({layer_split:args.layerSplit??'auto'}).layer_split:'auto';if(cfg.layer_split!=='auto'&&cfg.layer_split.split(',').length!==args.gpus.length-1)throw new Error('Choose one layer split point between each pair of GPUs.');}
const namedInFile=(Array.isArray(config.gpu)?config.gpu.length>0:config.gpu!==undefined&&config.gpu!==null&&config.gpu!=='')||!!config.gpu_roles;
/* Roles chosen in the screen (Main / Helper / Off per card, gpu-roles.mjs): the card list is the roles' mirror - Helpers first, the Main card LAST, Off cards left out - with "gpu_roles" beside it. Every card Off is allowed: the server says plainly what the engine can do. */
if(args.gpuRoles!==undefined){const make=snapshot.hardware?.engine_vendor,cards=(snapshot.hardware?.gpus??[]).filter(g=>!make||!g.vendor||g.vendor===make),named=parseRoles(args.gpuRoles);if(!named)throw new Error('Choose Main, Helper or Off for each graphics card, with one Main.');const known=new Set(cards.map(g=>g.index));if(Object.keys(named).some(k=>!known.has(Number(k))))throw new Error('Choose detected graphics cards.');const roles=Object.fromEntries(cards.map(g=>[g.index,named[g.index]??'off'])),m=mirrorRoles(roles,cards);for(const k of ['gpu','gpu_order','gpu_none','gpu_roles','gpu_cards','hip_ordinal'])delete cfg[k];Object.assign(cfg,m);const count=Array.isArray(m.gpu)?m.gpu.length:m.gpu_none?0:1;cfg.layer_split=count>1?validateNativeSettings({layer_split:args.layerSplit??'auto'}).layer_split:'auto';if(cfg.layer_split!=='auto'&&cfg.layer_split.split(',').length!==count-1)throw new Error('Choose one layer split point between each pair of GPUs.');}
else if(args.gpus!==undefined&&!(args.gpusFromRecommendation&&namedInFile)){const detected=new Set((snapshot.hardware?.gpus??[]).map(g=>g.index));if(!Array.isArray(args.gpus)||!args.gpus.length||new Set(args.gpus).size!==args.gpus.length||args.gpus.some(g=>!Number.isInteger(g)||!detected.has(g)))throw new Error('Choose distinct detected GPUs. CPU-only text inference is not supported by this native adapter.');cfg.gpu=args.gpus.length===1?args.gpus[0]:args.gpus;for(const k of ['gpu_roles','gpu_cards','gpu_none'])delete cfg[k];delete cfg.hip_ordinal;cfg.layer_split=args.gpus.length>1?validateNativeSettings({layer_split:args.layerSplit??'auto'}).layer_split:'auto';if(cfg.layer_split!=='auto'&&cfg.layer_split.split(',').length!==args.gpus.length-1)throw new Error('Choose one layer split point between each pair of GPUs.');}
if(cfg.vram_reserve_mib!==undefined&&cfg.vram_reserve_mib!==null){cfg.args=[...(cfg.args??[])];const at=cfg.args.indexOf('--vram-reserve-mib');if(at>=0)cfg.args.splice(at,2);cfg.args.push('--vram-reserve-mib',String(cfg.vram_reserve_mib));}
if(mine.length){cfg.args=mergeUserArgs(cfg.args??[],mine);cfg[USER_KEY]=mine;}else delete cfg[USER_KEY];
if(cfg.vision){if(cfg.vision_tokens!=null)cfg.vision={...cfg.vision,max_tokens:cfg.vision_tokens};if(cfg.vision_min_tokens!=null)cfg.vision={...cfg.vision,min_tokens:cfg.vision_min_tokens};}cfg.mcp={...(cfg.mcp??{}),...(cfg.mcp_timeout_s!=null?{timeout_s:cfg.mcp_timeout_s}:{}),...(cfg.mcp_max_rounds!=null?{max_rounds:cfg.mcp_max_rounds}:{}),...(cfg.mcp_max_result_chars!=null?{max_result_chars:cfg.mcp_max_result_chars}:{})};
/* The model's own choices win over what setup wrote into the arguments: "Draft layer (MTP)" off leaves --mtp out of every start, and a picture reading that is off leaves --vision out (setup writes both again whenever it runs; the saved settings are applied here, on every load). */
if(cfg.mtp===false&&Array.isArray(cfg.args))cfg.args=withoutDraftLayer(cfg.args);
if((cfg.mtp===false||cfg.drafting==='off')&&Array.isArray(cfg.args))cfg.args=withoutDraftLayer(cfg.args);
if(!cfg.vision&&Array.isArray(cfg.args))cfg.args=cfg.args.filter(a=>a!=='--vision');
for(const arg of cfg.args??[])if(arg.endsWith('.gguf')&&!existsSync(resolve(repo(),arg)))throw new Error('An engine argument names a missing GGUF. Restore the saved arguments or repair the files.');
/* The app's own choice of request slots (4, fewer where the card has no room) is part of the configuration, so a tune measures the engine a load will start: slots take VRAM from the expert cache. */
@@ -218,12 +237,32 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
if(parallelChoice){const applied=applyParallel(cfg,parallelChoice);for(const k of Object.keys(cfg))if(!(k in applied.cfg))delete cfg[k];Object.assign(cfg,applied.cfg);parallelNote=applied.note;}
return {cfg,resources,visionPreferences,parallelChoice,parallelNote};
}
/* The DFlash2 drafter, before a load that uses it: Control says whether the choice needs it (and the file is not here), downloads it pinned and checked, and packs it, with plain progress in the job.
Never stops the load: a drafter that cannot be had leaves the MTP running, and the job says so. The choice itself is remembered in the model's settings file. */
async function prepareDrafter(job,args,cfg,configFile){
const asked=args.values?.drafting;
if(asked!==undefined)rememberSetting(configFile,'drafting',asked===null||asked==='auto'?null:asked);
if(!packaged||typeof control.draftingFetch!=='function'||!cfg.drafting||cfg.drafting==='auto'||cfg.drafting==='mtp'||cfg.drafting==='off')return;
const work=join(directory,'native-instances');mkdirSync(work,{recursive:true});const probe=join(work,'drafting-'+randomUUID()+'.json');
try{
writeFileSync(probe,JSON.stringify(cfg),{mode:0o600});
job.engineAbort??=new AbortController();
await control.draftingFetch({configPath:probe,mode:cfg.drafting,signal:job.engineAbort.signal,onEvent:event=>{if(event.event==='progress'&&job.state==='running'){job.summary=event.step;job.details=event.total>1&&event.done<event.total?`${(event.done/1e9).toFixed(2)} of ${(event.total/1e9).toFixed(2)} GB`:undefined;emit();}}});
if(job.state==='running'){job.summary='Loading the model…';job.details=undefined;emit();}
}catch(error){if(job.state==='running'){job.drafterNote=error.message;job.summary='The DFlash2 drafter could not be downloaded, so the model starts with the MTP. Loading the model…';emit();}}
finally{try{rmSync(probe,{force:true});}catch{/* best effort */}}
}
/* One key of a model's settings file, set or removed (the Drafting choice): written like the Settings screen writes it, to be there for the next load. */
function rememberSetting(configFile,key,value){try{const current=readJson(configFile);if(value===null?!(key in current):current[key]===value)return;const next={...current};if(value===null)delete next[key];else next[key]=value;const temp=configFile+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,configFile);}catch{/* the load goes on with the choice for this start */}}
/* Which drafter runs, when it is not the one that was asked for (the engine's own words, "Drafting: MTP (DFlash2 didn't fit)"): under Details of the load. */
async function noteDrafting(job,instance,key){let note='';try{const r=await fetcher(`http://127.0.0.1:${instance.port}/metrics`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(r.ok)note=String((await r.json())?.engine?.drafting_note??'');}catch{/* a convenience */}const lines=[job.drafterNote,note].filter(Boolean);if(lines.length)job.details=[job.details,...lines].filter(Boolean).join('\n');}
async function loadEngine(job,args){
await refresh(true);if(job.state!=='running')return;const {config,entry,path:configFile}=record(args.model);assertSettingsFile(configFile);if(packaged){const engine=await control.engine();if(!engine.usable)throw new Error(ENGINE_NOT_RECOGNISED+(engine.notice?` ${engine.notice}`:''));}if(!entry.ready)throw new Error('Some model files are missing. Use Download / repair to restore them.');
await refresh(true);if(job.state!=='running')return;const {config,entry,path:configFile}=record(args.model);assertSettingsFile(configFile);if(packaged){const engine=await control.engine();if(!engine.usable)throw new Error(ENGINE_NOT_RECOGNISED+(engine.notice?` ${engine.notice}`:''));}if(!entry.ready){const error=missingFilesError(args.model,entry.missing);append(args.model,error.details[0]);throw error;}
if(externalRunning().length)throw new Error('A Strata server is already running outside this UI. Stop it before loading here, or connect its API in Settings → Models.');
if(args.multiple&&instances.size>=8)throw new Error('Unload an instance before adding another. Up to eight native instances can run here.');
const {cfg,resources,visionPreferences,parallelChoice,parallelNote:chosenNote}=buildConfig(args,config);
let parallelNote=chosenNote;
await prepareDrafter(job,args,cfg,configFile);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};
@@ -278,7 +317,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
}
const deadline=Date.now()+300000;while(Date.now()<deadline&&job.state==='running'){
if(instance.status==='error')throw new Error(instance.error);
try{const response=await fetcher(`http://127.0.0.1:${instance.port}/health`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(job.state!=='running'){await unload(id);return;}if(response.ok){instance.status='ready';instance.phase='idle';if(resources||args.vision!==undefined||JSON.stringify(cfg[USER_KEY]??null)!==JSON.stringify(config[USER_KEY]??null))writeModelConfig(record(args.model).path,{...config,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),...visionPreferences,...(cfg[USER_KEY]?{[USER_KEY]:cfg[USER_KEY]}:{})});emit();ensurePoll();instance.parallelNote=parallelNote;return {summary:retried?`${instance.label} is ready, after a second try: ${retried.words}`:`${instance.label} is ready.${parallelNote?' '+parallelNote:''}`,instanceId:id};}}catch{}
try{const response=await fetcher(`http://127.0.0.1:${instance.port}/health`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(job.state!=='running'){await unload(id);return;}if(response.ok){instance.status='ready';instance.phase='idle';await noteDrafting(job,instance,key);if(resources||args.vision!==undefined||JSON.stringify(cfg[USER_KEY]??null)!==JSON.stringify(config[USER_KEY]??null))writeModelConfig(record(args.model).path,{...config,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),...visionPreferences,...(cfg[USER_KEY]?{[USER_KEY]:cfg[USER_KEY]}:{})});emit();ensurePoll();instance.parallelNote=parallelNote;return {summary:retried?`${instance.label} is ready, after a second try: ${retried.words}`:`${instance.label} is ready.${parallelNote?' '+parallelNote:''}`,instanceId:id};}}catch{}
await new Promise(resolve=>setTimeout(resolve,1200));
}
if(job.state!=='running'){await unload(id);return;}
@@ -372,10 +411,104 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
return autotuner.start(job,job.kind==='recheck'?'recheck':'tune',{configPath:path,presetId:args.presetId,mode:args.mode,use:args.use,goal:room?'room':'speed',leaveRam:room?20:0,allowSmallDifferences:args.allowSmallDifferences===true,name:args.name},{onProgress:p=>{job.summary=p.text??job.summary;emit();}});
}
function operation(kind,args={}){if(!['install','load','benchmark','tune','autotune','recheck','calibrate','update-engine','update-strata','rollback','remove'].includes(kind))throw new Error('Choose a supported native operation.');if(!snapshot.available)throw new Error(snapshot.error??'Connect the native Strata installation first.');if(kind==='install'||kind==='load'){const refusal=engineRefusal(snapshot.catalog,args.model,snapshot.engineInfo);if(refusal)throw new Error(refusal);}return begin(kind,job=>kind==='install'?install(job,args):kind==='load'?loadEngine(job,args):kind==='benchmark'?benchmark(job,args):kind==='tune'?tune(job,args):kind==='autotune'||kind==='recheck'?autotune(job,args):kind==='remove'?removeModel(job,args):program(job,args),{guided:args.guided===true,model:args.model,...(args.family?{family:args.family}:{})});}
async function cancel(id){const job=jobs.get(id);if(!job||job.state!=='running')throw new Error('This operation is no longer running.');if(job.kind==='install'){job.engineAbort?.abort();await tool('strata_install',{cancel:true});}else if(job.abort)job.abort.abort();else if(job.process){if(!['tune','calibrate','autotune','recheck'].includes(job.kind))throw new Error('File updates cannot be cancelled safely while files are being replaced.');job.state='cancelled';await stopProcess(job.process);}job.state='cancelled';job.summary=job.kind==='install'?'Stopped. Completed downloads are kept.':'Stopped.';emit();return state();}
/* ---- The models folder card (0.2.2): what a picked folder means, and the three choices. The copying is Control's (strata models move). ---- */
const currentModelsFolder=()=>preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder)??null;
const moveFile=()=>join(directory,'models-move.cancel');
const folderWords=error=>({message:error.message,details:error.details||''});
/** A picked folder as the data folder: a whole drive (D:\) becomes D:\Strata, and the person is told (the models then live in D:\Strata\Models). */
function pickedModelsFolder(input){
if(typeof input!=='string'||!input.trim()||/[\r\n\0]/.test(input))throw new Error('Choose a models folder.');
const picked=resolve(input.trim()),target=dataFolderFromPick(picked);
return {picked,folder:target.path,wholeDrive:!!target.note,shown:target.note?join(target.path,'Models'):target.path};
}
async function planModelsFolder(input){
if(!packaged)throw new Error('Changing the models folder is part of the Strata app.');
const now=currentModelsFolder();if(!now)throw new Error('Strata is still reading your models folder. Try again in a moment.');
const pick=pickedModelsFolder(input),plan=await control.planModelsMove(now,pick.folder);
return {...plan,...pick,current:now,running:instances.size,busy:[...jobs.values()].some(j=>j.state==='running')};
}
/* "Start empty": the old models are no longer listed. Their run configs are moved (not deleted) into old-models beside the others; the model files are not touched. */
function setAsideConfigs(old){
const folder=repo(),kept=join(folder,'old-models'),aside=[];
const under=value=>typeof value==='string'&&[ 'models','packs','mtp' ].some(item=>{const base=join(old,item).toLowerCase();const v=value.toLowerCase();return v===base||v.startsWith(base+sep)||v.startsWith(base+'/');});
const scan=value=>Array.isArray(value)?value.some(scan):value&&typeof value==='object'?Object.values(value).some(scan):under(value);
for(const name of readdirSync(folder)){
if(!/^strata-[a-z0-9_-]+\.json$/i.test(name)||name.endsWith('.shared-settings.json'))continue;
if(!scan(readJson(join(folder,name))))continue;
mkdirSync(kept,{recursive:true});renameSync(join(folder,name),join(kept,name));aside.push(name);
}
return aside;
}
async function startModelsFolder({folder:input,choice,stopRunning=false}){
if(!packaged)throw new Error('Changing the models folder is part of the Strata app.');
if(!['move','keep','empty'].includes(choice))throw new Error('Choose what to do with your models.');
if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or stop the download to change the folder.');
const plan=await planModelsFolder(input);
if(plan.issue)throw Object.assign(new Error(plan.issue.message),{details:plan.issue.details,code:plan.issue.code});
if(!plan.choices[choice].possible)throw Object.assign(new Error(plan.choices[choice].reason||'That choice is not possible here.'),{code:'not_possible'});
if(instances.size){
if(!stopRunning)throw Object.assign(new Error('A model is running. Stop it first, then change the folder.'),{code:'running'});
for(const id of [...instances.keys()])await unload(id);
}
const old=plan.current,target=plan.folder;
if(choice!=='move'){
makeFolder(target);
const aside=choice==='empty'?setAsideConfigs(old):[];
setDownloadDirectory(target);lastRead=0;await refresh(true);
return {done:true,choice,folder:target,oldFolder:old,setAside:aside};
}
return runModelsMove(old,target,(from,to,options)=>control.moveModels(from,to,options));
}
/* The move as a job (the progress dialog reads it). `run` is moveModels for a new move, resumeModelsMove for "Finish it". */
function runModelsMove(old,target,run){
rmSync(moveFile(),{force:true});
return begin('models-move',async job=>{
job.cancelFile=moveFile();job.summary='Starting…';
try{
const result=await run(old,target,{installDir:repo(),cancelFile:moveFile(),onEvent:event=>{
if(event.event!=='progress')return;
job.progress={done:event.done,total:event.total,file:event.file,group:event.group,index:event.index,count:event.count,secondsLeft:event.seconds_left||0,sameDrive:!!event.same_drive};
job.summary=`${Math.round(event.done/1e8)/10} of ${Math.round(event.total/1e8)/10} GB`;emit();}});
const finished={done:!!result.done,stopped:!!result.stopped,moved:result.moved??[],remaining:result.remaining??[],bytes:result.bytes_moved??0,total:result.total_bytes??0,from:old,to:target,files:result.files_moved??0,sameDrive:!!result.same_drive};
/* Only a whole move changes the folder the app uses: a stopped move leaves it where it was, and every model works where it is. */
if(result.done){setDownloadDirectory(target,{insideJob:true});finished.folder=target;}
job.progress=null;
return {summary:result.done?'Your models are moved.':'Moving stopped. Nothing was lost.',...finished};
}catch(error){
const left=error.output;
job.result={failed:true,code:error.code,moved:left?.moved??[],remaining:left?.remaining??[],bytes:left?.bytes_moved??0,total:left?.total_bytes??0,from:old,to:target};
job.progress=null;
throw error;
}finally{rmSync(moveFile(),{force:true});}
},{family:undefined});
}
/* "A move was interrupted. Finish it or leave it": the next start finds the unfinished move (Control keeps a note in the models folder in use). */
async function interruptedMove(){
if(!packaged)return null;
const now=currentModelsFolder();if(!now)return null;
if([...jobs.values()].some(j=>j.kind==='models-move'&&j.state==='running'))return null;
try{return await control.interruptedModelsMove(now);}catch{return null;}
}
async function finishInterruptedMove({stopRunning=false}={}){
const st=await interruptedMove();if(!st)throw new Error('No move was interrupted.');
if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or stop the download to change the folder.');
if(instances.size){
if(!stopRunning)throw Object.assign(new Error('A model is running. Stop it first, then change the folder.'),{code:'running'});
for(const id of [...instances.keys()])await unload(id);
}
return runModelsMove(st.from,st.to,(from,to,options)=>control.resumeModelsMove(from,options));
}
async function leaveInterruptedMove(){
const st=await interruptedMove();if(!st)throw new Error('No move was interrupted.');
if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or stop the download to change the folder.');
const result=await control.leaveModelsMove(st.from,{installDir:repo()});
lastRead=0;await refresh(true);
return {done:true,folder:st.from,partial:st.to,removed:result.removed??0,restored:result.restored??0};
}
async function cancel(id){const job=jobs.get(id);if(!job||job.state!=='running')throw new Error('This operation is no longer running.');if(job.kind==='install'){job.engineAbort?.abort();await tool('strata_install',{cancel:true});}else if(job.kind==='models-move'){/* a safe stop: the copy in flight is dropped, files already moved stay */try{writeFileSync(job.cancelFile,'stop');}catch{/* the run ends anyway */}job.summary='Stopping…';emit();return state();}else if(job.abort)job.abort.abort();else if(job.process){if(!['tune','calibrate','autotune','recheck'].includes(job.kind))throw new Error('File updates cannot be cancelled safely while files are being replaced.');job.state='cancelled';await stopProcess(job.process);}job.state='cancelled';job.summary=job.kind==='install'?'Stopped. Completed downloads are kept.':'Stopped.';emit();return state();}
async function close(){closing=true;clearInterval(timer);const installing=packaged&&child&&[...jobs.values()].some(j=>j.kind==='install'&&j.state==='running');if(installing)await send('tools/call',{name:'strata_install',arguments:{cancel:true}},15000).catch(()=>{});const operations=[];for(const job of jobs.values()){job.abort?.abort();if(job.process)operations.push(stopProcess(job.process));if(job.state==='running')job.state='cancelled';}await Promise.all(operations);await Promise.all([...instances.keys()].map(id=>unload(id,true).catch(()=>{})));disconnect();}
// Remove nonserializable process references from public jobs.
const publicState=()=>{const value=state();value.jobs=value.jobs.map(({process,engineAbort,rawResult,...job})=>job);return value;};
async function localPreview(path){if(!packaged)throw new Error('Checking local model files is part of the Strata app.');if(typeof path!=='string'||!path.trim()||[...path].some(c=>c.charCodeAt(0)<32))throw new Error('Choose a folder.');return control.importPlan(path.trim());}
return {state:publicState,tuneStatus,tuneEffective,tuneOff,tunePresets:{list:()=>autotuner.list(),action:(kind,id,extra)=>autotuner.action(kind,id,extra),undoFor:async args=>autotuner.undoFor(tuneConfig(args).path)},refresh,localPreview,configure,setDownloadDirectory,plan,settings,save,settingsFile,openSettingsFile,operation,cancel,unload,resolveInstance,poll,logs:()=>logs.slice(),benchmarkReport:()=>readJson(join(directory,'native-benchmark.json'),null),setDefault(id){defaultId=resolveInstance(id).id;emit();return publicState();},hasLive:()=>[...instances.values()].some(i=>i.status==='ready'),readyInstances:()=>[...instances.values()].filter(i=>i.status==='ready'),close};
return {state:publicState,tuneStatus,tuneEffective,tuneOff,tunePresets:{list:()=>autotuner.list(),action:(kind,id,extra)=>autotuner.action(kind,id,extra),undoFor:async args=>autotuner.undoFor(tuneConfig(args).path)},refresh,localPreview,configure,setDownloadDirectory,planModelsFolder,startModelsFolder,interruptedMove,finishInterruptedMove,leaveInterruptedMove,plan,settings,save,settingsFile,openSettingsFile,operation,cancel,unload,resolveInstance,poll,logs:()=>logs.slice(),benchmarkReport:()=>readJson(join(directory,'native-benchmark.json'),null),setDefault(id){defaultId=resolveInstance(id).id;emit();return publicState();},hasLive:()=>[...instances.values()].some(i=>i.status==='ready'),readyInstances:()=>[...instances.values()].filter(i=>i.status==='ready'),defaultInstanceId:()=>defaultId,close};
}
+12 -4
View File
@@ -28,9 +28,16 @@ export function defaultReserves(config,hardware,baselineReserve=0){
const placement=Array.isArray(config.gpu)?config.gpu:[config.gpu??0];
const placed=(hardware?.gpus??[]).filter(g=>placement.includes(g.index)),gpuMemory=placed.map(g=>g.vram_gb).filter(n=>Number.isFinite(n)&&n>0);
const headless=placed.length>0&&placed.every(computeOnly); // no display on the card (the desktop is on an integrated GPU): no desktop to keep room for
const floor=headless?(config.vision?ENGINE_DEFAULT_RESERVE_MIB:COMPUTE_ONLY_RESERVE_MIB):gpuMemory.length&&Math.min(...gpuMemory)>16?1024:1536;
const safety=headless?floor:Math.max(config.vision?2048:floor,baselineReserve);
return {work:gpuMemory.length&&!headless?Math.max(safety,Math.floor(Math.min(...gpuMemory)*1024*.2)):safety,performance:safety};
/* Only a picture reader that runs on the GPU needs VRAM of its own: the CPU reader (vision.gpu false) took the same 2048 floor and cost a 12 GB card a quarter of its expert cache (0.2.3 tuning, notes-023-tune.md). */
const pictures=config.vision?(typeof config.vision==='object'?Boolean(config.vision.gpu):true):false;
const floor=headless?(pictures?ENGINE_DEFAULT_RESERVE_MIB:COMPUTE_ONLY_RESERVE_MIB):gpuMemory.length&&Math.min(...gpuMemory)>16?1024:1536;
const safety=headless?floor:Math.max(pictures?2048:floor,baselineReserve);
/* A card that shares the PC's memory (Strix Halo, other APUs) has no memory of its own to keep a fifth of: that fifth is RAM, and "Leave 20% for my work" already keeps it
* through the RAM budget. A fifth of the shared pool here held back 16 GB of expert cache for nothing (#3510); the display floor stays. */
const shared=placed.length>0&&placed.some(g=>g.uma===true||g.unified===true);
/* "work" keeps a fifth of the card, but on a card of 16 GB or less no more than 1536 MiB: a fifth of 12 GB (2457) cost 20% of the speed against 1536, which already leaves 1.1 GiB of VRAM free after the load (RTX 5070, Gyro-S 64K, 5 pairs; notes-023-tune.md). */
const fifth=gpuMemory.length?Math.floor(Math.min(...gpuMemory)*1024*.2):0,cap=gpuMemory.length&&Math.min(...gpuMemory)<=16?1536:Infinity;
return {work:gpuMemory.length&&!headless&&!shared?Math.max(safety,Math.min(fifth,cap)):safety,performance:safety};
}
/** The reserve the person asked for (null: nothing) and the default for each profile. A 0.2.0-beta file has the key and the same number in "args" with no note that the
@@ -60,7 +67,8 @@ export function resourceSettings(config,hardware,profile,model={},values={}){
const cores=Number(hardware?.cpu?.cores)||0;
if(profile==='work'&&cores>1){
const at=args.indexOf('--pool-workers'),previous=at>=0?Number(args[at+1]):0;
const workers=Math.max(1,Math.floor(cores*.8));remove('--pool-workers');
/* 80% of the physical cores rounded UP, never more than the cores less the host thread (the engine's own count): floor(0.8 x 6) = 4 ran 8-10% slower than the engine's 5 on a 6-core Ryzen (notes-023-tune.md); 12 logical threads gave 9 in 0.2.1. */
const workers=Math.max(1,Math.min(cores-1,Math.ceil(cores*.8)));remove('--pool-workers');
args.push('--pool-workers',String(previous>0?Math.min(previous,workers):workers));
}
// Preserve calibrated worker counts and the installation's safety/vision reserve.
+6 -2
View File
@@ -1,3 +1,4 @@
import { validMcpName, MCP_NAME_HELP } from './mcp-names.mjs';
/** Editable native keys. Unknown config keys and program paths stay intact. */
export const NATIVE_FIELDS=[
{id:'reasoning_budget_tokens',label:'Thinking token budget',group:'Responses',type:'number',min:0,max:1048576,hint:'Leave blank for the model default.'},
@@ -8,6 +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',label:'Draft layer (MTP)',group:'Serving',type:'boolean',hint:'The draft layer guesses several words ahead, which makes answers faster, and costs about 1 GB of graphics memory and some RAM. Turn it off if the model stops while it starts, or the card is small: the expert cache gets that memory back and answers are slower. Default is on.'},
{id:'drafting',label:'Drafting',group:'Serving',type:'enum',choices:['auto','mtp','dflash2','off'],hint:'Which drafter guesses words ahead to make answers faster. Auto (recommended) lets Strata pick from what it measured on cards like yours; today that is the MTP. DFlash2 is for Qwen3.8-Flash-Next only and downloads a 1.2 GB drafter once. If DFlash2 cannot run, the MTP is used and Details says why. Off turns the draft layer off.'},
{id:'batchMtp',label:'MTP per request',group:'Serving',type:'boolean',hint:'Each concurrent request uses the draft layer. Left alone, Strata turns it on for one graphics card with 24 GB or more and several requests at once, and off otherwise; choose on or off here to decide yourself.'},
{id:'parallel',label:'Concurrent requests',group:'Serving',type:'number',min:1,max:8,hint:'More slots need more memory and can slow each response.'},
{id:'engine_silence_s',label:'Engine stall timeout (seconds)',group:'Recovery',type:'number',min:10,max:3600},
@@ -24,7 +26,7 @@ export const NATIVE_FIELDS=[
{id:'mcp_max_result_chars',label:'Maximum tool result characters',group:'Tools',type:'number',min:100,max:1000000},
];
/** Settings the app itself applies when it starts a model (they are not read from the engine's source, so the engine's text never says it supports them). */
export const APP_OWNED_FIELDS=['mtp','batchMtp'];
export const APP_OWNED_FIELDS=['mtp','batchMtp','drafting'];
/** The engine arguments without the draft layer: --mtp <folder or count>, --spec-min-p <p> (its confidence threshold) and --batch-mtp. --spec stays: prompt lookup still uses its windows. Same rule as strata_control/draft_layer.py. */
export function withoutDraftLayer(args){
const out=[];const list=(args??[]).map(String);
@@ -56,7 +58,9 @@ export function publicMcpServers(servers){return Object.fromEntries(Object.entri
export function validateMcpServers(servers,previous={}){
if(!servers||typeof servers!=='object'||Array.isArray(servers)||Object.keys(servers).length>30)throw new Error('MCP servers must be an object with at most 30 entries.');
const out={};for(const [name,s] of Object.entries(servers)){
if(!/^[a-z0-9_-]{1,64}$/i.test(name)||!s||typeof s!=='object'||Array.isArray(s))throw new Error('Give each tool server a simple, unique name.');
if(!s||typeof s!=='object'||Array.isArray(s))throw new Error('Give each tool server a simple, unique name.');
// a name that was saved before the 32-character rule stays saveable (unrelated settings must not be blocked); the agent reports it as left out
if(!validMcpName(name)&&!Object.hasOwn(previous??{},name))throw new Error(MCP_NAME_HELP);
if(!!s.command===!!s.url)throw new Error(`${name}: choose a command or HTTP URL.`);
for(const key of Object.keys(s))if(!['command','args','env','cwd','url','headers','disabled'].includes(key))throw new Error(`${name}: unsupported property ${key}.`);
if(s.command&&(typeof s.command!=='string'||s.command.length>1000||/[\n\r\0]/.test(s.command)))throw new Error(`${name}: choose a valid program.`);
+30
View File
@@ -0,0 +1,30 @@
/**
* A message too large for the model's window, made small enough to send (agent/context-fit.mjs decides that it is too large).
* The person picks how, on the error card:
* file the whole text is saved in the chat's folder and the message names the file; the assistant reads it in parts
* preview the start and the end of the text, cut to what the window can hold
* Nothing is sent from here: the page sends the returned message as the person's next message.
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { countTokens, oversizedReplacement } from './agent/context-fit.mjs';
const stamp = d => `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}-${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}${String(d.getSeconds()).padStart(2, '0')}`;
/**
* @param {{mode:'file'|'preview', text:string, root:string|null, instance?:{port:number, key?:string, apiModelId?:string, context?:number, metrics?:any}|null, now?:Date, fetchImpl?:typeof fetch}} input
* @returns {Promise<{message:string, mode:string, tokens:number|null, kept:number, path?:string}>}
*/
export async function prepareOversized({ mode, text, root, instance = null, now = new Date(), fetchImpl = fetch }) {
const window = instance?.metrics?.engine?.max_context ?? instance?.context ?? 32768;
const tokens = instance ? await countTokens({ baseUrl: `http://127.0.0.1:${instance.port}/v1`, key: instance.key ?? '', model: instance.apiModelId ?? '', text, fetchImpl }) : null;
const counted = tokens ?? Math.ceil(text.length / 3); // not countable: three characters a token is on the safe side for code and logs
if (mode === 'file') {
if (!root) throw new Error('This chat has no folder yet. Choose one first.');
mkdirSync(root, { recursive: true });
const path = join(root, `pasted-message-${stamp(now)}.txt`);
writeFileSync(path, text, { encoding: 'utf8', flag: 'wx' });
return { mode, tokens, path, ...oversizedReplacement({ text, mode, tokens: counted, window, path }) };
}
return { mode: 'preview', tokens, ...oversizedReplacement({ text, mode: 'preview', tokens: counted, window }) };
}
+30
View File
@@ -0,0 +1,30 @@
/** Ports the host listens on: a free one for the helpers, and a plain sentence when the main one cannot be taken. */
import net from 'node:net';
/** Why a listen failed, in words a person can act on (Windows keeps whole ranges for Hyper-V, WSL and Docker). */
export function listenProblem(error, port, host = '127.0.0.1') {
const code = error?.code;
if (code === 'EADDRINUSE') return `Port ${port} is already in use. Strata may already be running; close it or the other program, or start Strata with another port (STRATA_PORT).`;
if (code === 'EACCES') return `Windows or a firewall does not let Strata use port ${port}. Hyper-V, WSL and Docker keep ranges of ports for themselves (see "netsh int ipv4 show excludedportrange protocol=tcp"). Start Strata with another port (STRATA_PORT), for example 8801.`;
if (code === 'EADDRNOTAVAIL') return `${host} is not an address of this computer, so Strata cannot listen on it. Check STRATA_BIND.`;
return `Strata cannot listen on ${host}:${port} (${error?.message ?? code}).`;
}
function tryListen(port, host) {
return new Promise(resolve => {
const probe = net.createServer();
probe.once('error', () => resolve(null));
probe.listen(port, host, () => { const got = probe.address().port; probe.close(() => resolve(got)); });
});
}
/** `preferred` when it can be taken, else the next free port above it (up to `tries` of them), else any free port. Never throws. */
export async function findFreePort(preferred, host = '127.0.0.1', { skip = [], tries = 20 } = {}) {
for (let offset = 0; offset < tries; offset++) {
const port = preferred + offset;
if (port > 65535 || skip.includes(port)) continue;
const got = await tryListen(port, host);
if (got) return got;
}
return (await tryListen(0, host)) ?? preferred;
}
+3 -2
View File
@@ -70,7 +70,7 @@ export function patch(body) {
const s = getStore();
const serving = { ...(s.serving ?? {}) };
if (typeof body.otherDevices === 'boolean' && body.otherDevices !== !isLocal()) throw new Error('Change the host listen address and restart to update network access.');
if (body.apiKey === null && (!isLocal() || sharing.active || tunnelActive())) throw new Error('Stop local sharing and Cloudflare before removing the API key.');
// The key is optional (owner decision, 0.2.2): removing it while sharing or the tunnel runs is allowed; the app warns in plain words (local-share.mjs, cloudflare.mjs).
if (body.apiKey === null) serving.apiKey = null;
else if (typeof body.apiKey === 'string' && body.apiKey.trim()) serving.apiKey = body.apiKey.trim();
else if (body.generateKey) serving.apiKey = `sk-strata-${randomBytes(12).toString('hex')}`;
@@ -149,7 +149,8 @@ export function cancel(id) {
export function rateSample() {
const done = requests.filter((r) => r.state === 'done' && r.tokensOut > 0);
if (!done.length) return 0;
const tps = done.map((r) => r.tokensOut / Math.max(0.001, r.durationMs / 1000));
const tps = done.filter((r) => r.tps > 0).map((r) => r.tps); // answer speeds (decode time), not tokens over the whole request
if (!tps.length) return 0;
return round(tps.reduce((a, b) => a + b, 0) / tps.length, 1);
}
+45 -18
View File
@@ -1,6 +1,7 @@
import { migrateSetup } from './setup.mjs';
/** Tiny persisted state for the host: one JSON file under STRATA_HOME. */
import { accessSync, constants, mkdirSync, readFileSync, writeFileSync, existsSync, realpathSync, readdirSync, statSync, statfsSync, symlinkSync } from 'node:fs';
import { alignModels, isDriveRoot, libraryFolderFromPick, makeFolder } from './model-folder.mjs';
import { dirname, isAbsolute, join, parse, relative, resolve } from 'node:path';
import { homedir } from 'node:os';
import { defaultModelDirectory } from './model-directory-default.mjs';
@@ -58,29 +59,53 @@ function read() {
let state = read();
const DEFAULT_MODELS_DIRECTORY=defaultModelDirectory();
function initialModelsDirectory() {
if(state.modelsDir)return state.modelsDir;
const legacy=realpathSync(join(HOME,'models'));
// Keep an existing library in place. A new install uses Documents/Strata/Models.
/**
* The model folder for a new install: the saved one, an existing library inside STRATA_HOME, else Documents/Strata/Models. A Documents folder
* that cannot be made (Controlled folder access, a disconnected share, an offline OneDrive known folder) must not stop the host from starting:
* the models go to STRATA_HOME/models instead, and `notice` says so in plain words.
*/
export function pickModelsDirectory({ saved, home, preferred, fixedByEnv = false }) {
if (saved) return { directory: saved, notice: '' };
const fallback = join(home, 'models');
let legacy = fallback;
try { legacy = realpathSync(fallback); } catch { /* the folder is made at start; keep the plain path */ }
function hasWeights(folder,depth=0){try{return readdirSync(folder,{withFileTypes:true}).some(entry=>entry.isFile()&&/\.gguf(?:\.part|\.partial)?$/i.test(entry.name)||entry.isDirectory()&&depth<4&&hasWeights(join(folder,entry.name),depth+1));}catch{return false;}}
if(!process.env.STRATA_MODELS_DIR&&hasWeights(legacy))return legacy;
const directory=DEFAULT_MODELS_DIRECTORY;
mkdirSync(directory,{recursive:true});
return realpathSync(directory);
// Keep an existing library in place. A new install uses Documents/Strata/Models.
if (!fixedByEnv && hasWeights(legacy)) return { directory: legacy, notice: '' };
try {
mkdirSync(preferred, { recursive: true });
accessSync(preferred, constants.R_OK | constants.W_OK | constants.X_OK);
return { directory: realpathSync(preferred), notice: '' };
} catch (error) {
return { directory: legacy, notice: `Strata could not use ${preferred} for models (${error.code ?? error.message}), so models go to ${legacy}. You can pick another folder in Settings.` };
}
}
export let MODELS_DIR = initialModelsDirectory();
const MODELS_PICK = pickModelsDirectory({ saved: state.modelsDir, home: HOME, preferred: DEFAULT_MODELS_DIRECTORY, fixedByEnv: !!process.env.STRATA_MODELS_DIR });
/** Plain words for the person when the usual model folder could not be used (empty otherwise). */
export const MODELS_DIR_NOTICE = MODELS_PICK.notice;
if (MODELS_DIR_NOTICE) console.warn(`[strata] ${MODELS_DIR_NOTICE}`);
export let MODELS_DIR = MODELS_PICK.directory;
/** Native setup writes weights to data_dir/models. Keep its data root one level up. */
export function nativeDataDirectory(input) {
if(!input||process.env.STRATA_MODELS_DIR||!existsSync(DEFAULT_MODELS_DIRECTORY))return input;
const root=realpathSync(DEFAULT_MODELS_DIRECTORY);
if(resolve(input)!==resolve(DEFAULT_MODELS_DIRECTORY)&&realpathSync(input)!==root)return input;
const data=dirname(root),nativeModels=join(data,'models');
// On Windows/macOS case-insensitive volumes, Models already resolves as models.
// On Linux, a relative alias keeps the native layout inside the requested folder.
if(!existsSync(nativeModels))symlinkSync(process.platform==='win32'?root:'Models',nativeModels,process.platform==='win32'?'junction':'dir');
const a=statSync(nativeModels),b=statSync(root);
if(realpathSync(nativeModels)!==root&&!(a.ino&&a.ino===b.ino&&a.dev===b.dev))throw new Error('The default model folder conflicts with another library. Choose a different folder.');
if(!input||!existsSync(input))return input;
if(MODELS_DIR_NOTICE)return input;
const root=realpathSync(input),legacy=realpathSync(join(HOME,'models'));
// Retained legacy libraries are model folders too, not native data roots.
// A named data root satisfies the installer's guard; its alias leaves weights in place.
if(root===legacy){
const data=join(HOME,'Strata-data'),nativeModels=join(data,'models');
mkdirSync(data,{recursive:true});
// Unlike a fresh default, a retained library must not fall back to an empty folder if linking fails.
if(!existsSync(nativeModels))symlinkSync(process.platform==='win32'?root:relative(data,root),nativeModels,process.platform==='win32'?'junction':'dir');
const a=statSync(nativeModels),b=statSync(root);
if(realpathSync(nativeModels)!==root&&!(a.ino&&a.ino===b.ino&&a.dev===b.dev))throw new Error('The model folder conflicts with another library. Choose a different folder.');
return data;
}
if(process.env.STRATA_MODELS_DIR||!existsSync(DEFAULT_MODELS_DIRECTORY)||root!==realpathSync(DEFAULT_MODELS_DIRECTORY))return input;
const data=dirname(root);
// `models` and `Models` are one folder on Windows and macOS; where they are not (Linux) a link makes them one, and where no link can be made (exFAT, a share) models go in `models`.
alignModels(data,root);
return data;
}
@@ -95,6 +120,8 @@ export function validateModelsDirectory(input) {
if (!statSync(directory).isDirectory()) throw new Error();
accessSync(directory, constants.R_OK | constants.W_OK | constants.X_OK);
} catch { throw new Error('This folder is unavailable or not writable. Check the path and folder permissions.'); }
// A whole drive (D:\) is not filled with models: D:\Strata\Models is made and used.
if (isDriveRoot(directory)) directory = realpathSync(makeFolder(libraryFolderFromPick(directory).path));
const data = realpathSync(DATA_DIR);
const contains = (parent, child) => { const r = relative(parent, child); return !r || !r.startsWith('..') && !isAbsolute(r); };
if (directory === parse(directory).root || contains(directory, data) || contains(data, directory)) throw new Error('Choose a separate folder for models, outside Strata’s app data.');
+24
View File
@@ -0,0 +1,24 @@
// The host's own HTTPS downloads (engine releases from GitHub, web search, a cloud model's API) check the server against the operating
// system's certificates as well as Node's bundled list (#3722). Node carries its own list of roots; Chrome and curl.exe use the Windows
// store, which also holds the roots an antivirus's HTTPS scanning, a company proxy or an OS preview build adds. Nothing is turned off:
// the system roots are ADDED to the bundled ones, and a connection that still fails keeps failing.
//
// Node 24.5 and newer: tls.getCACertificates('system') and tls.setDefaultCACertificates. An older Node (the app bundles 24.x) does nothing here;
// the agent process gets NODE_USE_SYSTEM_CA=1 (harness-runtime.mjs) for the same reason.
import tlsModule from 'node:tls';
/** @returns {'system'|'bundled'} what the process trusts after the call */
export function useSystemCertificates(tls = tlsModule, env = process.env) {
if (env.STRATA_NO_SYSTEM_CA === '1') return 'bundled'; // a way back for a PC where reading the store misbehaves; not a way to skip checks
try {
if (typeof tls.getCACertificates !== 'function' || typeof tls.setDefaultCACertificates !== 'function') return 'bundled';
const system = tls.getCACertificates('system');
if (!Array.isArray(system) || !system.length) return 'bundled';
tls.setDefaultCACertificates([...new Set([...tls.getCACertificates('bundled'), ...system])]);
return 'system';
} catch {
return 'bundled';
}
}
export const trust = useSystemCertificates();
+1 -1
View File
@@ -35,7 +35,7 @@ export function plainUpdateError(error, subject = 'engine') {
} else if (/another update is already running/i.test(raw)) {
message = 'An update is already running.';
next = `Wait a moment, then press ${button} in Settings.`;
} else if (/test start|did not answer a one-token|did not become ready/i.test(raw)) {
} else if (/test start|did not start correctly|did not answer a one-token|did not become ready/i.test(raw)) {
message = `The new ${subject} didn't start correctly, so nothing was changed. ${keep}`;
} else if (looksOffline(error) || looksOffline(raw)) {
message = 'No internet connection.';
+1
View File
@@ -67,6 +67,7 @@ export async function checkComponentUpdates({ control, appUpdater = null, appVer
if (engine.status === 'available') Object.assign(engineView, { status: 'available', message: `A new engine is available (${mib(engine.download_bytes)}).` });
else if (engine.status === 'install') Object.assign(engineView, { status: 'available', message: `This PC needs an engine to run models. Press Update engine to download it (${mib(engine.download_bytes)}).` });
else if (engine.status === 'current' || engine.status === 'failed-before' || engine.status === 'skipped') Object.assign(engineView, { status: 'current', message: 'The engine is up to date.', details: engine.note });
else if (engine.status === 'wrong-make') Object.assign(engineView, { status: 'unavailable', message: engine.note ?? 'Nothing was changed: the update was for another make of graphics card.', details: (engine.reasons ?? []).join('\n') || undefined });
else if (engine.status === 'not-in-feed') Object.assign(engineView, { status: 'current', message: 'No engine update is listed right now.' });
else Object.assign(engineView, { status: 'unavailable', message: "There isn't an engine for this PC's graphics card yet.", details: (engine.reasons ?? []).join('\n') || undefined });
const control_ = report.control ?? {};
@@ -19,7 +19,7 @@ export function CloudflareDialog({ open, onClose, onCreateKey }: {
const steps = mode === 'quick' ? ['Address', 'Connector', 'API key', 'Connect'] : ['Address', 'Connector', 'API key', 'Domain', 'Connect'];
const last = steps.length - 1;
const installed = !!tunnel?.installed, keyReady = !!serving?.access.apiKeySet, other = tunnel?.external ?? null;
const canContinue = step === 1 ? installed : step === 2 ? keyReady : step === 3 && mode === 'domain' ? !!hostname.trim() && !!token.trim() : true;
const canContinue = step === 1 ? installed : step === 2 ? true : step === 3 && mode === 'domain' ? !!hostname.trim() && !!token.trim() : true;
async function inspect() { setPending(true); setError(''); try {
setDetected(await api.get<TunnelState>('/api/serving/tunnel'));
}
@@ -80,7 +80,7 @@ export function CloudflareDialog({ open, onClose, onCreateKey }: {
} }
const titles = ['Choose your address', installed ? 'Connector ready' : 'Preparing your connector', 'Protect your connection', ...(mode === 'domain' ? ['Set up your domain'] : []), 'Ready to connect'];
return <Dialog open={open} title={tr("Connect with Cloudflare")} onClose={dismiss} actions={<>
{tr(active ? <><Btn onClick={dismiss}>{tr("Done")}</Btn><Btn disabled={pending || tunnel?.status === 'stopping'} onClick={() => void stop()}>{tr(tunnel?.status === 'stopping' ? 'Stopping…' : 'Stop tunnel')}</Btn></> : <><Btn disabled={pending} onClick={back}>{tr(step ? 'Back' : 'Cancel')}</Btn>{tr(step === last ? <Btn variant="primary" disabled={pending || !installed || !keyReady || (mode === 'domain' && (!hostname.trim() || !token.trim()))} onClick={() => void connect()}>{tr(pending ? 'Connecting…' : (error || tunnel?.error) ? 'Try again' : mode === 'quick' ? 'Create temporary URL' : 'Connect domain')}</Btn> : <Btn variant="primary" disabled={pending || !canContinue} onClick={next}>{tr("Continue")}<Icon name="chevron" className="wizard-next" size={14}/></Btn>)}</>)}
{tr(active ? <><Btn onClick={dismiss}>{tr("Done")}</Btn><Btn disabled={pending || tunnel?.status === 'stopping'} onClick={() => void stop()}>{tr(tunnel?.status === 'stopping' ? 'Stopping…' : 'Stop tunnel')}</Btn></> : <><Btn disabled={pending} onClick={back}>{tr(step ? 'Back' : 'Cancel')}</Btn>{tr(step === last ? <Btn variant="primary" disabled={pending || !installed || (mode === 'domain' && (!hostname.trim() || !token.trim()))} onClick={() => void connect()}>{tr(pending ? 'Connecting…' : (error || tunnel?.error) ? 'Try again' : mode === 'quick' ? 'Create temporary URL' : 'Connect domain')}</Btn> : <Btn variant="primary" disabled={pending || !canContinue} onClick={next}>{tr("Continue")}<Icon name="chevron" className="wizard-next" size={14}/></Btn>)}</>)}
</>}>
<div className="tunnel-wizard">
{tr(!active ? <ol className="tunnel-progress" aria-label={tr("Connection progress")}>{tr(steps.map((label, i) => <li key={label} data-current={step === i || undefined} data-complete={step > i || undefined}><button disabled={i >= step || pending} aria-current={step === i ? 'step' : undefined} onClick={() => { setError(''); setStep(i); }}><span>{tr(step > i ? <Icon name="check" size={13}/> : i + 1)}</span><small>{tr(label)}</small></button></li>))}</ol> : null)}
@@ -92,9 +92,9 @@ export function CloudflareDialog({ open, onClose, onCreateKey }: {
(e.currentTarget.parentElement?.children[mode === 'quick' ? 1 : 0] as HTMLElement)?.focus();
} }} onClick={() => setMode(choice.value)}><Icon name={choice.icon} size={20}/><span><strong>{tr(choice.title)}</strong><small>{tr(choice.detail)}</small></span><span className="tunnel-choice-dot">{tr(mode === choice.value ? <Icon name="check" size={13}/> : null)}</span></button>))}</div></> : null)}
{tr(step === 1 ? <div className="tunnel-automatic" data-ready={installed || undefined} aria-live="polite" aria-busy={pending}><span className="tunnel-stage__symbol"><Icon name={installed ? 'check' : error ? 'warning' : 'refresh'} size={28}/></span><p className="small muted">{tr(installed ? 'Your Cloudflare connector is installed and ready.' : error ? 'Automatic setup needs another try.' : 'Strata downloads and sets up the Cloudflare connector for you.')}</p>{tr(installed ? <>{tr(tunnel?.connector ? <p className="xs muted">{tr("Using {0}", [tunnel.connector.summary])}</p> : null)}{tr(other ? <p className="xs muted" role="status">{tr(other.others.length === 1 && !other.own.length ? "cloudflared is already running for another tunnel (PID {0})." : other.own.length ? "Strata’s tunnel for this address is already running (PID {0})." : "cloudflared is already running for other tunnels (PIDs {0}).", [(other.own.length ? other.own : other.others).join(", ")])}</p> : null)}</> : error ? <Btn onClick={() => setRetry(v => v + 1)}>{tr("Retry setup")}</Btn> : <span className="small muted" role="status">{tr("Setting up\u2026")}</span>)}</div> : null)}
{tr(step === 2 ? <><p className="small muted">{tr("Only apps with your Strata API key can use this connection.")}</p>{tr(keyReady ? <div className="tunnel-check"><Icon name="shield"/><div><strong>{tr("API key ready")}</strong><span>{tr("Use your existing key in the app you connect.")}</span></div></div> : <div className="tunnel-key-step"><Icon name="shield" size={30}/><p className="small">{tr("Create a key to protect your API. You\u2019ll return here when it\u2019s ready.")}</p><Btn onClick={onCreateKey}>{tr("Create an API key")}</Btn></div>)}</> : null)}
{tr(step === 2 ? <><p className="small muted">{tr(keyReady ? "Only apps with your Strata API key can use this connection." : "Without an API key, anyone who finds the link can use your model.")}</p>{tr(keyReady ? <div className="tunnel-check"><Icon name="shield"/><div><strong>{tr("API key ready")}</strong><span>{tr("Use your existing key in the app you connect.")}</span></div></div> : <div className="tunnel-key-step"><Icon name="shield" size={30}/><p className="small">{tr("Create a key to protect your API. You\u2019ll return here when it\u2019s ready. You can also continue without one, but anyone who finds the link can then use your model.")}</p><Btn onClick={onCreateKey}>{tr("Create an API key (recommended)")}</Btn></div>)}</> : null)}
{tr(mode === 'domain' && step === 3 ? <><p className="small muted">{tr("Create a tunnel in your ")}<a href="https://dash.cloudflare.com/" target="_blank" rel="noreferrer">{tr("Cloudflare dashboard \u2197")}</a>{tr(". Copy the token from its connector setup.")}</p><label className="input-field"><span>{tr("Tunnel token")}</span><input type="password" className="st-input" autoComplete="off" spellCheck={false} value={token} onChange={e => setToken(e.target.value)} placeholder={tr("Paste only the token after --token")}/></label><label className="input-field"><span>{tr("Your hostname")}</span><input className="st-input" autoComplete="off" spellCheck={false} value={hostname} onChange={e => setHostname(e.target.value)} placeholder={tr("ai.example.com")}/></label><div><p className="small muted">{tr("Add a published application route for this hostname with this service URL:")}</p><div className="install-command"><code>{tunnel?.originUrl ?? 'Loading…'}</code><IconBtn name="copy" title={tr("Copy tunnel service URL")} disabled={!tunnel?.originUrl} onClick={() => void navigator.clipboard.writeText(tunnel!.originUrl)}/></div></div><p className="xs muted">{tr("The token is used for this connection only and is not saved.")}</p></> : null)}
{tr(step === last ? <><p className="small muted">{tr(mode === 'quick' ? 'Cloudflare will create your address when you connect.' : 'Start the connector for the domain you configured.')}</p><dl className="tunnel-review"><div><dt>{tr("Address")}</dt><dd>{tr(mode === 'quick' ? 'Free temporary URL' : hostname)}</dd></div><div><dt>{tr("Connector")}</dt><dd><Icon name="check" size={14}/>{tr(tunnel?.connector?.version ? "Installed · cloudflared {0}" : "Installed", tunnel?.connector?.version ? [tunnel.connector.version] : undefined)}</dd></div><div><dt>{tr("Access")}</dt><dd><Icon name="shield" size={14}/>{tr("API key required")}</dd></div></dl><p className="small muted">{tr(mode === 'quick' ? 'The address changes each time. Turn off streaming in your client; replies arrive all at once.' : 'Use this hostname and your Strata API key in your client. Streaming is supported.')}</p><p className="xs muted">{tr("Only the inference API is shared. Your computer and Strata must stay on.")}</p></> : null)}
{tr(step === last ? <><p className="small muted">{tr(mode === 'quick' ? 'Cloudflare will create your address when you connect.' : 'Start the connector for the domain you configured.')}</p><dl className="tunnel-review"><div><dt>{tr("Address")}</dt><dd>{tr(mode === 'quick' ? 'Free temporary URL' : hostname)}</dd></div><div><dt>{tr("Connector")}</dt><dd><Icon name="check" size={14}/>{tr(tunnel?.connector?.version ? "Installed · cloudflared {0}" : "Installed", tunnel?.connector?.version ? [tunnel.connector.version] : undefined)}</dd></div><div><dt>{tr("Access")}</dt><dd><Icon name="shield" size={14}/>{tr(keyReady ? "API key required" : "No API key: anyone with the link can use it")}</dd></div></dl><p className="small muted">{tr(mode === 'quick' ? 'The address changes each time. Turn off streaming in your client; replies arrive all at once.' : 'Use this hostname and your Strata API key in your client. Streaming is supported.')}</p><p className="xs muted">{tr("Only the inference API is shared. Your computer and Strata must stay on.")}</p></> : null)}
</section>)}
{tr(error || tunnel?.error ? <ErrorNotice message={error || tunnel?.error || 'Connection failed'} next={tr("Check your internet connection, then retry.")}/> : null)}
</div>
+136
View File
@@ -0,0 +1,136 @@
import { tr } from '../lib/i18n';
import { useId, useState } from 'react';
import { Btn, Icon } from './ui';
import {
CPU_ONLY_LINE, NO_CARD_LINE, OFF_AT_OR_BELOW_GB, gpuLabel, noteText, order, recommend, setRole, shortName, vramOn, warnings,
type Role, type RoleCard, type Roles, type RoleWarning,
} from '../lib/gpu-roles';
const ROLE_WORD: Record<Role, string> = { main: 'Main', helper: 'Helper', off: 'Off' };
const HINT: Record<Role, string> = { main: 'Does the main work', helper: 'Holds part of the model', off: 'Not used' };
/** The one plain line above the cards, built from translated pieces. */
export function recommendationLine(cards: RoleCard[], vendor?: string | null): string {
const rec = recommend(cards, vendor);
if (rec.main === null) return tr('No graphics card that Strata can use was found.');
const byi = new Map(cards.map(c => [c.index, c]));
const m = byi.get(rec.main)!;
if (cards.length === 1) return tr('Strata will use your {0}.', [shortName(m)]);
const why = { same: 'the cards are alike', memory: 'it has more memory', faster: 'it is faster', only: 'it is the only card', none: '' }[rec.why];
let s = tr('{0} is Main: {1}.', [shortName(m), tr(why)]);
const helpers = Object.keys(rec.roles).map(Number).filter(i => rec.roles[i] === 'helper').map(i => shortName(byi.get(i)!));
if (helpers.length) s += ' ' + tr(helpers.length > 1 ? '{0} help.' : '{0} helps.', [helpers.join(', ')]);
for (const i of Object.keys(rec.roles).map(Number)) if (rec.roles[i] === 'off') {
const code = rec.reasons[i], c = byi.get(i)!;
s += ' ' + tr('{0} starts Off: {1}', [shortName(c), code === 'cannot_run' ? tr('Strata cannot run it.') : code === 'igpu' ? tr('built-in graphics.') : tr('{0} GB or less.', [OFF_AT_OR_BELOW_GB])]);
}
return s;
}
function warningText(w: RoleWarning, cards: RoleCard[]): string {
const c = w.card !== null ? cards.find(x => x.index === w.card) : undefined;
if (w.code === 'cannot_run') return tr('{0} cannot run Strata.', [c ? shortName(c) : '']);
if (w.code === 'igpu_main') return tr('{0} is built-in graphics; it is slow as Main.', [c ? shortName(c) : '']);
if (w.code === 'cpu_only') return tr(w.text === CPU_ONLY_LINE ? CPU_ONLY_LINE : NO_CARD_LINE);
return tr('Not enough memory for this setup: about {0} GB is read from the drive.', [w.text.match(/about (\d+) GB/)?.[1] ?? '']);
}
export function noteLine(code: string | null, cards: RoleCard[]): string {
if (!code) return '';
const kind = code.split(':')[0], c = cards.find(x => x.index === Number(code.split(':')[1]));
if (kind === 'old_main_helper') return tr('{0} is now a Helper.', [c ? shortName(c) : '']);
if (kind === 'promoted') return tr('{0} is now Main.', [c ? shortName(c) : '']);
if (kind === 'only_main') return tr('The only card that is on has to be Main.');
return noteText(code, cards);
}
/**
* Main / Helper / Off for every graphics card (the mockup GpuRoles-mockup.html). One click changes a role; "Back to recommended" restores the
* recommendation. Warnings are short and never block Load. `roles` are the roles in force (the recommendation until the person changes one).
*/
export function GpuRoles({ cards, roles, custom, recommendedRoles, vendor, onChange, onBack, memoryGb, ramGb, spillGb = 0, engineRunsCpuOnly = false, disabled = false, usedBy }: {
cards: RoleCard[];
roles: Roles;
/** The person changed a role: "Your choice", and "Back to recommended" is offered. */
custom: boolean;
/** The roles Strata recommends (the tags and "Back to recommended" use them); the pure recommendation when not given. */
recommendedRoles?: Roles;
vendor?: string | null;
onChange: (next: Roles) => void;
onBack: () => void;
memoryGb?: number | null;
ramGb?: number | null;
/** About how many GB of the model are read from the drive with these cards (0 when everything fits or it is unknown). */
spillGb?: number;
engineRunsCpuOnly?: boolean;
disabled?: boolean;
/** Names of the loaded models that already use a card ("In use by ..."). */
usedBy?: (card: number) => string[];
}) {
const titleId = useId();
const [note, setNote] = useState('');
const rec = { ...recommend(cards, vendor), ...(recommendedRoles ? { roles: recommendedRoles } : {}) };
const single = cards.length === 1;
const warns = warnings(roles, cards, { spillGb, engineRunsCpuOnly });
const cardWarn = (i: number) => warns.filter(w => w.card === i);
const globalWarn = warns.filter(w => w.card === null);
const lst = order(roles, cards);
const change = (idx: number, role: Role) => {
const out = setRole(roles, cards, idx, role);
setNote(noteLine(out.note, cards));
onChange(out.roles);
};
const back = () => { setNote(''); onBack(); };
const fix = (w: RoleWarning) => w.fix && change(w.fix.card, w.fix.role);
const fixLabel = (w: RoleWarning) => {
if (!w.fix) return '';
const target = cards.find(c => c.index === w.fix!.card);
return w.fix.role === 'off' ? tr('Turn it off') : tr('Make {0} Main', [target ? shortName(target) : '']);
};
const have = vramOn(roles, cards) + Math.max(0, (ramGb ?? 0) - 10);
return <div className="gpuroles" aria-labelledby={titleId} data-custom={custom}>
<div className="gpuroles__head">
<span className="native-choice__label" id={titleId}>{tr('Graphics cards')}</span>
{custom ? <button type="button" className="gpuroles__link" disabled={disabled} onClick={back}>{tr('Back to recommended')}</button> : null}
</div>
<div className="gpuroles__reco" data-custom={custom}>
<Icon name="bolt" size={18}/>
<p><strong>{tr(custom ? 'Your choice.' : 'Recommended.')}</strong> {custom ? tr('You changed the roles. Strata will use exactly these.') : recommendationLine(cards, vendor)}</p>
</div>
<div className="gpuroles__rows" role="list">
{cards.map(c => {
const role = roles[c.index] ?? 'off', isRec = rec.roles[c.index] === role, w = cardWarn(c.index), other = usedBy?.(c.index) ?? [];
const shared = !!(c.integrated || c.uma || c.unified_gb);
const options: Role[] = single ? ['main', 'off'] : ['main', 'helper', 'off'];
return <div className="gpuroles__row" role="listitem" data-role={role} key={c.index}>
<div className="gpuroles__top">
<span className="gpuroles__ico"><Icon name="gpu" size={20}/></span>
<span className="gpuroles__name">
<strong>{gpuLabel(c)} · {c.name}{isRec ? <span className="gpuroles__tag">{tr('Recommended')}</span> : <span className="gpuroles__tag gpuroles__tag--neutral">{tr('Recommended: {0}', [tr(ROLE_WORD[rec.roles[c.index] ?? 'off'])])}</span>}</strong>
<small>{shared ? tr("Uses your computer's memory (built-in graphics)") : tr('{0} GB memory', [Math.round(c.vram_gb)])} · {tr(HINT[role])}</small>
</span>
<span className="gpuroles__roles" role="radiogroup" aria-label={tr('Role of {0}', [gpuLabel(c)])}>
{options.map(r => <button type="button" role="radio" key={r} data-r={r} aria-checked={role === r} disabled={disabled} onClick={() => change(c.index, r)}>{tr(ROLE_WORD[r])}</button>)}
</span>
</div>
{other.length ? <div className="gpuroles__note gpuroles__note--info"><Icon name="info" size={16}/><p>{tr('In use by {0}', [other.join(', ')])}</p></div> : null}
{w.map(x => <div className="gpuroles__note gpuroles__note--warn" key={x.code}><Icon name="warning" size={16}/><p>{warningText(x, cards)}</p>{x.fix ? <Btn size="sm" disabled={disabled} onClick={() => fix(x)}>{fixLabel(x)}</Btn> : null}</div>)}
</div>;
})}
</div>
<div className="gpuroles__toast" role="status" aria-live="polite">{note ? <div className="gpuroles__note gpuroles__note--info"><Icon name="info" size={16}/><p>{note}</p></div> : null}</div>
{globalWarn.map(x => <div className="gpuroles__note gpuroles__note--warn" key={x.code}><Icon name="warning" size={16}/><p>{warningText(x, cards)}</p>{x.code === 'cpu_only' && custom ? <Btn size="sm" disabled={disabled} onClick={back}>{tr('Back to recommended')}</Btn> : null}</div>)}
<details className="gpuroles__details">
<summary>{tr('Details')}</summary>
<div className="gpuroles__body">
<dl>
<dt>{tr('Main')}</dt><dd>{tr('The card that does the final steps of every answer: it writes the next words, runs the draft-ahead step and checks it, and keeps your conversation. Pick your fastest card with plenty of memory. There is only ever one.')}</dd>
<dt>{tr('Helper')}</dt><dd>{tr('Holds a share of the model and its fast memory, so more of it fits on graphics cards instead of being read from your drive. Prompts are read about 20% faster.')}</dd>
<dt>{tr('Off')}</dt><dd>{tr('Strata does not touch this card. Good for a card you use for something else, such as a game or your screen.')}</dd>
</dl>
{memoryGb ? <div className="gpuroles__fit" data-state={lst.length ? (spillGb >= 1 ? 'bad' : 'ok') : 'bad'}><span className="gpuroles__dot" aria-hidden="true"/><span>{lst.length ? (spillGb >= 1 ? tr('The model needs about {0} GB. These cards and your memory give about {1} GB, so about {2} GB would be read from your drive.', [Math.round(memoryGb), Math.round(have), Math.round(spillGb)]) : tr('The model needs about {0} GB. These cards and your memory give about {1} GB. It fits.', [Math.round(memoryGb), Math.round(have)])) : tr('Only your processor and memory are used.')}</span></div> : null}
<div className="gpuroles__expert xs muted">{lst.length ? tr('For experts: the engine starts on cards {0} (Main last).', [lst.join(',')]) : null} {custom ? '' : tr('Not saved while you keep the recommendation: Strata picks again if you add or remove a card.')}</div>
</div>
</details>
</div>;
}
+17 -4
View File
@@ -78,6 +78,19 @@ export function GuidedSetup() {
throw e;
} });
}
/* Files the person already has are checked and adopted by the "Use a local GGUF" page, not made the data folder (that downloaded them again, #3507). */
async function ownModel(path?: string) {
await perform(async () => { leaving.current = true; if (saveTimer.current)
clearTimeout(saveTimer.current); try {
await persist(step, { manual: true }, true);
openSetup(false);
navigate('models', 'local', path ? { path } : undefined);
}
catch (e) {
leaving.current = false;
throw e;
} });
}
async function finish() {
if (leaving.current)
return;
@@ -192,9 +205,9 @@ export function GuidedSetup() {
<OnboardingMemory hardware={hardware} native={native?.hardware}/>{tr(!version || version.needsNewerEngine ? <p className="small muted">{tr("This model needs a newer engine. Update your engine or import a local GGUF for this model.")}</p> : null)}
<div className="onboard-version-heading"><strong>{tr("Choose a version")}</strong><span>{tr(version ? `${catalogVersionLabel(version)} selected` : '')}</span></div>
<div className="onboard-versions" role="radiogroup" aria-label={tr("Model version")} onKeyDown={radioKeys}>{tr(cards.map(v => versionCard(v)))}</div>
{tr(others.length ? <div className="onboard-more-versions"><VersionPicker versions={others} selectedId={version?.id ?? ''} hardware={hardware ? { ram_gb: native?.hardware?.ram_gb ?? hardware.memory.totalGb, vram_gb: Math.max(0, ...(native?.hardware?.gpus ?? []).filter(g => g.usable !== false).map(g => g.vram_gb)) } : null} downloaded={readyIds} showRecommended={false} moreLabel="More versions" onSelect={id => { setSize(id); setPlan(null); }} onOwnGguf={() => setFolderOpen(true)}/></div> : null)}
{tr(others.length ? <div className="onboard-more-versions"><VersionPicker versions={others} selectedId={version?.id ?? ''} hardware={hardware ? { ram_gb: native?.hardware?.ram_gb ?? hardware.memory.totalGb, vram_gb: Math.max(0, ...(native?.hardware?.gpus ?? []).filter(g => g.usable !== false).map(g => g.vram_gb)) } : null} downloaded={readyIds} showRecommended={false} moreLabel="More versions" onSelect={id => { setSize(id); setPlan(null); }} onOwnGguf={() => void ownModel()}/></div> : null)}
<div className="onboard-folder"><Icon name="folder" size={20}/><div><strong>{tr(directory ? 'Your model folder' : 'Already downloaded the model?')}</strong><small>{tr(directory || "Choose the folder and we'll start your models with Strata.")}</small></div><Btn size="sm" onClick={() => setFolderOpen(v => !v)}>{tr(folderOpen ? 'Close' : directory ? 'Change' : 'Choose folder')}</Btn></div>
{tr(folderOpen ? <OnboardingFolder directory={folder} onChoose={path => void perform(async () => { await nativeAction('directory', { directory: path }); setDirectory(path); setFolderOpen(false); setPlan(null); })}/> : null)}
{tr(folderOpen ? <OnboardingFolder directory={folder} onChoose={(path, ggufFiles) => ggufFiles > 0 && !/strata/i.test(path.split(/[\\/]/).filter(Boolean).pop() ?? '') ? void ownModel(path) : void perform(async () => { await nativeAction('directory', { directory: path }); setDirectory(path); setFolderOpen(false); setPlan(null); })}/> : null)}
</> : <div className="onboard-engine-connect"><Icon name="models" size={24}/><strong>{tr("Connect your Strata installation")}</strong><Btn onClick={() => void skip(false, 'Engine')}>{tr("Connect engine")}</Btn></div>)}
</div> : step === 3 ? <div className="onboard-prepare-stage">
<section className="onboard-download"><EngineWarning native={native}/><div className="onboard-download-identity"><Icon name="models" size={19}/><strong>{tr(selectedFamily?.title)}</strong><span>{tr(version ? catalogVersionLabel(version) : '')}</span><small>{tr(version?.model)}</small></div><OnboardingArt kind="download" progress={progress?.percent ?? 0} counter={counter}/><div className="onboard-download-info">
@@ -228,7 +241,7 @@ function OnboardingError({ message, onFolder }: {
}
function OnboardingFolder({ directory, onChoose }: {
directory: string;
onChoose: (path: string) => void;
onChoose: (path: string, ggufFiles: number) => void;
}) {
const [path, setPath] = useState(directory), [folder, setFolder] = useState<LocalModelFolder | null>(null), [pending, setPending] = useState(false), [error, setError] = useState('');
async function browse(input: string, initial = false) { setPending(true); setError(''); try {
@@ -252,5 +265,5 @@ function OnboardingFolder({ directory, onChoose }: {
setPending(false);
} }
useEffect(() => { void browse(directory, true); }, []);
return <section className="onboard-folder-browser" aria-label={tr("Choose your model folder")}><form onSubmit={e => { e.preventDefault(); void browse(path); }}><input className="st-input" aria-label={tr("Existing model folder")} value={path} onChange={e => { setPath(e.target.value); setFolder(null); }} placeholder={tr("Full folder path")} spellCheck={false}/><Btn type="submit" size="sm" disabled={pending || !path.trim()}>{tr(pending ? 'Checking…' : 'Open')}</Btn></form><p>{tr("Choose the Strata data folder that holds your model downloads. Matching files are reused; missing files are prepared automatically.")}</p><div className="onboard-folder-shortcuts">{folder?.shortcuts?.map(item => <Btn key={item.path} size="sm" disabled={pending} onClick={() => void browse(item.path)}>{tr(item.name)}</Btn>)}</div><div className="onboard-folder-files">{tr(folder?.parent ? <button onClick={() => void browse(folder.parent!)}><Icon name="folder" size={16}/>{tr(" Up a folder")}</button> : null)}{folder?.entries.filter(e => e.kind === 'folder').slice(0, 30).map(e => <button key={e.path} onClick={() => void browse(e.path)}><Icon name="folder" size={16}/>{tr(e.name)}<Icon name="chevron" size={12}/></button>)}{tr(folder ? <small>{tr(folder.entries.filter(e => e.kind === 'model').length)}{tr(" GGUF files in this folder. Nested downloads are checked by the installer.")}</small> : null)}</div>{tr(folder ? <Btn variant="primary" size="sm" disabled={pending} onClick={() => onChoose(folder.directory)}>{tr("Use this folder")}</Btn> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>;
return <section className="onboard-folder-browser" aria-label={tr("Choose your model folder")}><form onSubmit={e => { e.preventDefault(); void browse(path); }}><input className="st-input" aria-label={tr("Existing model folder")} value={path} onChange={e => { setPath(e.target.value); setFolder(null); }} placeholder={tr("Full folder path")} spellCheck={false}/><Btn type="submit" size="sm" disabled={pending || !path.trim()}>{tr(pending ? 'Checking…' : 'Open')}</Btn></form><p>{tr("Choose the Strata data folder that holds your model downloads. Matching files are reused; missing files are prepared automatically.")}</p><div className="onboard-folder-shortcuts">{folder?.shortcuts?.map(item => <Btn key={item.path} size="sm" disabled={pending} onClick={() => void browse(item.path)}>{tr(item.name)}</Btn>)}</div><div className="onboard-folder-files">{tr(folder?.parent ? <button onClick={() => void browse(folder.parent!)}><Icon name="folder" size={16}/>{tr(" Up a folder")}</button> : null)}{folder?.entries.filter(e => e.kind === 'folder').slice(0, 30).map(e => <button key={e.path} onClick={() => void browse(e.path)}><Icon name="folder" size={16}/>{tr(e.name)}<Icon name="chevron" size={12}/></button>)}{tr(folder ? <small>{tr(folder.entries.filter(e => e.kind === 'model').length)}{tr(" GGUF files in this folder. Nested downloads are checked by the installer.")}</small> : null)}</div>{tr(folder ? <Btn variant="primary" size="sm" disabled={pending} onClick={() => onChoose(folder.directory, folder.entries.filter(e => e.kind === 'model').length)}>{tr("Use this folder")}</Btn> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>;
}
+25 -10
View File
@@ -5,8 +5,21 @@ import { Btn, IconBtn } from './ui';
import { SyntaxCode } from './SyntaxCode';
import { MathView } from './MathView';
import { displayBlock, hasMath, splitMath } from '../lib/math';
function inlineMarks(text: string, onOpenLink?: (url: string) => void): ReactNode {
return text.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]]+\]\([^\s)]+\))/g).map((part, i) => {
import { imagePlan } from '../lib/markdown-images';
function inlineMarks(text: string, onOpenLink?: (url: string) => void, imageSession?: string | null): ReactNode {
return text.split(/(!\[[^\]]*\]\([^\s)]+\)|`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]]+\]\([^\s)]+\))/g).map((part, i) => {
const image = part.match(/^!\[([^\]]*)\]\(([^\s)]+)\)$/);
if (image) {
const plan = imagePlan(image[2], imageSession);
if (plan.kind === 'img')
return <img key={i} src={plan.src} alt={image[1]} className="md-image" loading="lazy" referrerPolicy="no-referrer"/>;
if (plan.kind === 'link')
return <a key={i} href={plan.href} target="_blank" rel="noreferrer" onClick={e => { if (onOpenLink) {
e.preventDefault();
onOpenLink(plan.href);
} }}>{image[1] || plan.href}</a>;
return <Fragment key={i}>{image[1] || part}</Fragment>;
}
if (part.startsWith('`') && part.endsWith('`'))
return <code key={i} className="md-code">{part.slice(1, -1)}</code>;
if (part.startsWith('**') && part.endsWith('**'))
@@ -23,21 +36,23 @@ function inlineMarks(text: string, onOpenLink?: (url: string) => void): ReactNod
});
}
/** Text with its inline marks and, outside code spans, its math (#3268). Math is cut out first: a * or _ inside a formula is not emphasis. */
function inline(text: string, onOpenLink?: (url: string) => void): ReactNode {
function inline(text: string, onOpenLink?: (url: string) => void, imageSession?: string | null): ReactNode {
if (!hasMath(text))
return inlineMarks(text, onOpenLink);
return inlineMarks(text, onOpenLink, imageSession);
return text.split(/(`[^`]+`)/g).map((chunk, c) => {
if (chunk.length > 1 && chunk.startsWith('`') && chunk.endsWith('`'))
return <Fragment key={c}>{inlineMarks(chunk, onOpenLink)}</Fragment>;
return <Fragment key={c}>{inlineMarks(chunk, onOpenLink, imageSession)}</Fragment>;
return <Fragment key={c}>{splitMath(chunk).map((part, p) => part.kind === 'math'
? <MathView key={p} tex={part.tex} display={part.display}/>
: <Fragment key={p}>{inlineMarks(part.text, onOpenLink)}</Fragment>)}</Fragment>;
: <Fragment key={p}>{inlineMarks(part.text, onOpenLink, imageSession)}</Fragment>)}</Fragment>;
});
}
export function Markdown({ text, onOpenLink, onPreviewHtml }: {
export function Markdown({ text, onOpenLink, onPreviewHtml, imageSession }: {
text: string;
onOpenLink?: (url: string) => void;
onPreviewHtml?: (html: string) => void;
/** The chat whose folder a picture path in the answer is read from (the host serves only files inside it). */
imageSession?: string | null;
}) {
const nodes: ReactNode[] = [];
const lines = text.split('\n');
@@ -66,7 +81,7 @@ export function Markdown({ text, onOpenLink, onPreviewHtml }: {
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
const Tag = heading[1].length <= 2 ? 'h2' : heading[1].length === 3 ? 'h3' : 'h4';
nodes.push(<Tag key={i}>{tr(inline(heading[2], onOpenLink))}</Tag>);
nodes.push(<Tag key={i}>{tr(inline(heading[2], onOpenLink, imageSession))}</Tag>);
i++;
continue;
}
@@ -90,7 +105,7 @@ export function Markdown({ text, onOpenLink, onPreviewHtml }: {
i += 2;
while (i < lines.length && lines[i].includes('|'))
rows.push(cells(lines[i++]));
nodes.push(<div className="md-table-wrap" key={key}><table className="md-table"><thead><tr>{tr(header.map((c, j) => <th key={j}>{tr(inline(c, onOpenLink))}</th>))}</tr></thead><tbody>{tr(rows.map((r, j) => <tr key={j}>{tr(r.map((c, k) => <td key={k}>{tr(inline(c, onOpenLink))}</td>))}</tr>))}</tbody></table></div>);
nodes.push(<div className="md-table-wrap" key={key}><table className="md-table"><thead><tr>{tr(header.map((c, j) => <th key={j}>{tr(inline(c, onOpenLink, imageSession))}</th>))}</tr></thead><tbody>{tr(rows.map((r, j) => <tr key={j}>{tr(r.map((c, k) => <td key={k}>{tr(inline(c, onOpenLink, imageSession))}</td>))}</tr>))}</tbody></table></div>);
continue;
}
if (/^>\s?/.test(line)) {
@@ -98,7 +113,7 @@ export function Markdown({ text, onOpenLink, onPreviewHtml }: {
i++;
continue;
}
nodes.push(<p key={i}>{tr(inline(line, onOpenLink))}</p>);
nodes.push(<p key={i}>{tr(inline(line, onOpenLink, imageSession))}</p>);
i++;
}
return <>{tr(nodes)}</>;
@@ -0,0 +1,273 @@
import { tr, localeTag } from '../lib/i18n';
import { useEffect, useMemo, useState } from 'react';
import { Bar, Btn, Dialog, Icon } from './ui';
import { api, ApiError } from '../lib/client';
import { nativeAction, refreshNative, useNative, type NativeJob, type NativeState } from '../lib/native';
import { nativeShortName } from '../lib/native-names';
import { sizeGb } from '../lib/format';
type Choice = 'move' | 'keep' | 'empty';
interface Plan {
ok: boolean;
issue: { code: string; message: string; details: string } | null;
old: string; new: string; same_drive: boolean;
free_bytes: number; models_bytes: number; needed_bytes: number;
models: { group: string; name: string; bytes: number }[];
choices: Record<Choice, { possible: boolean; reason: string }>;
folder: string; picked: string; wholeDrive: boolean; shown: string; current: string; running: number; busy: boolean;
}
interface Outcome { done?: boolean; stopped?: boolean; failed?: boolean; code?: string; moved?: string[]; remaining?: string[]; bytes?: number; total?: number; from?: string; to?: string; sameDrive?: boolean }
const gb = (bytes: number) => sizeGb(bytes / 1e9);
const driveOf = (path: string) => /^[A-Za-z]:/.test(path) ? path.slice(0, 2) : path;
const list = (items: string[]) => { try { return new Intl.ListFormat(localeTag(), { type: 'conjunction' }).format(items); } catch { return items.join(', '); } };
/** The model a folder name belongs to, in the words the Storage list uses ("Gyro-S"); the draft layer and loose files have no name of their own. */
function modelName(group: string, native: NativeState | null) {
const hit = native?.installed?.models.find(m => m.model.toLowerCase() === group.toLowerCase());
return hit ? nativeShortName(hit.model) : null;
}
function timeLeft(seconds: number) {
if (seconds <= 0) return null;
if (seconds < 60) return tr('Less than a minute left');
const minutes = Math.ceil(seconds / 60);
if (minutes <= 1) return tr('About a minute left');
if (minutes < 120) return tr('About {0} minutes left', [minutes]);
return tr('About {0} hours left', [Math.ceil(minutes / 60)]);
}
function Failure({ message, next, details }: { message: string; next?: string; details?: string }) {
return <div className="error-notice" role="alert"><Icon name="error" size={18}/><div><strong>{tr(message)}</strong>{next ? <p>{tr(next)}</p> : null}{details ? <details className="error-detail"><summary className="small">{tr('Details')}</summary><pre className="small muted">{details}</pre></details> : null}</div></div>;
}
/** Settings > Storage > Models folder: the card, the folder dialog with its three choices, the progress of a move and what came of it. */
export function ModelsFolderCard({ blocked }: { blocked: boolean }) {
const native = useNative();
const [plan, setPlan] = useState<Plan | null>(null);
const [stage, setStage] = useState<'issue' | 'drive' | 'choose' | null>(null);
const [choice, setChoice] = useState<Choice>('move');
const [pending, setPending] = useState(false);
const [error, setError] = useState<{ message: string; details?: string } | null>(null);
const [typing, setTyping] = useState(false), [typed, setTyped] = useState('');
const [jobId, setJobId] = useState<string | null>(null);
const [outcome, setOutcome] = useState<{ job: NativeJob } | null>(null);
const [notice, setNotice] = useState('');
const [stopping, setStopping] = useState(false);
const [unfinished, setUnfinished] = useState<{ from: string; to: string } | null>(null);
const [resumed, setResumed] = useState<{ from: string; to: string } | null>(null);
const folder = native?.downloadDirectory ?? '';
const free = native?.downloadFreeBytes ?? null;
const usage = native?.installed?.usage?.total_bytes ?? 0;
const moving = native?.jobs.find(j => j.kind === 'models-move' && j.state === 'running') ?? null;
const finished = jobId ? native?.jobs.find(j => j.id === jobId && j.kind === 'models-move' && j.state !== 'running') : undefined;
useEffect(() => { if (finished && !outcome) { setOutcome({ job: finished }); setStopping(false); void refreshNative(true); } }, [finished, outcome]);
const total = native?.downloadTotalBytes ?? null;
const used = free !== null && total ? Math.min(100, Math.max(1, ((total - free) / total) * 100)) : 0;
useEffect(() => { if (moving && !jobId) setJobId(moving.id); }, [moving, jobId]);
/* A move that a crash or a stop left half done: found again at the next start, with its two choices. */
useEffect(() => {
if (moving || outcome) return;
let live = true;
api.get<{ interrupted: { from: string; to: string } | null }>('/api/native/models-folder/interrupted')
.then(r => { if (live) setUnfinished(r.interrupted); }).catch(() => { if (live) setUnfinished(null); });
return () => { live = false; };
}, [moving, outcome, folder]);
async function finishMove() {
if (!unfinished) return;
setPending(true);
setError(null);
try {
const result = await api.post<NativeJob>('/api/native/models-folder/resume', { stopRunning: true });
setResumed(unfinished);
setUnfinished(null);
if (result.id) { setJobId(result.id); setOutcome(null); await refreshNative(true); }
}
catch (e) { setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details }); }
finally { setPending(false); }
}
async function leaveMove() {
if (!unfinished) return;
setPending(true);
setError(null);
try {
await api.post('/api/native/models-folder/leave', {});
setNotice(tr('The partial copies were removed. Your models are in {0}.', [unfinished.from]));
setUnfinished(null);
await refreshNative(true);
}
catch (e) { setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details }); }
finally { setPending(false); }
}
function leave() { setPlan(null); setStage(null); setError(null); setTyping(false); }
async function pick(path = '') {
setPending(true);
setError(null);
setNotice('');
try {
const result = await api.post<{ cancelled?: boolean; plan?: Plan }>('/api/native/models-folder/plan', { path });
if (result.cancelled || !result.plan) return;
const next = result.plan;
setTyping(false);
setPlan(next);
if (next.issue?.code === 'same') { setPlan(null); return; }
setChoice(next.choices.move.possible ? 'move' : 'keep');
setStage(next.issue ? 'issue' : next.wholeDrive ? 'drive' : 'choose');
}
catch (e) {
if (e instanceof ApiError && e.status === 501) { setTyping(true); setError({ message: e.message }); }
else setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details });
}
finally { setPending(false); }
}
async function start() {
if (!plan) return;
setPending(true);
setError(null);
try {
const result = await api.post<NativeJob & { done?: boolean; setAside?: string[] }>('/api/native/models-folder/start', { path: plan.picked, choice, stopRunning: true });
if (result.id) { setJobId(result.id); setOutcome(null); setStage(null); await refreshNative(true); setPlan(prev => prev); return; }
setNotice(choice === 'keep' ? tr('Strata now also uses {0}. New downloads go there, and your current models stay where they are.', [plan.shown])
: tr('Strata now uses {0}. Your old model files are still in {1}; Strata does not delete them.', [plan.shown, plan.current]));
await refreshNative(true);
leave();
}
catch (e) { setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details }); }
finally { setPending(false); }
}
async function stop() {
if (!moving) return;
setStopping(true);
try { await nativeAction(`operations/${moving.id}/cancel`); }
catch (e) { setError({ message: (e as Error).message }); }
}
function closeResult() { setOutcome(null); setJobId(null); setResumed(null); leave(); setNotice(''); }
const progress = moving?.progress as { done: number; total: number; file: string; group: string; index: number; count: number; secondsLeft: number; sameDrive: boolean } | null | undefined;
const percent = progress && progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0;
const planNames = useMemo(() => (plan?.models ?? []).map(m => ({ ...m, label: modelName(m.group, native) })).filter(m => m.label), [plan, native]);
const savedPlan = useMemo(() => plan, [plan]);
return <>
<section className="storage-directory" aria-labelledby="models-folder-title">
<div className="storage-directory__heading"><span><Icon name="folder" size={18}/><strong id="models-folder-title">{tr('Models folder')}</strong></span><Btn size="sm" disabled={!folder || blocked || pending || !!moving} onClick={() => void pick()}>{tr(pending && !plan ? 'Waiting for the folder…' : 'Change folder')}</Btn></div>
<p className="small muted">{tr('Strata saves the big model files here.')}</p>
<code className="storage-directory__path">{folder || tr('Reading model directory…')}</code>
{free !== null ? <div className="models-free"><Bar value={used} label={tr('Disk use')}/><div className="models-free__row small"><span><strong>{tr('{0} free', [gb(free)])}</strong> {tr('on this drive')}</span><span className="muted">{tr('Your models use {0}', [gb(usage)])}</span></div></div> : null}
{blocked && !moving ? <p className="small muted">{tr('Finish or stop the download to change the folder.')}</p> : null}
{typing ? <form className="storage-directory__edit" onSubmit={e => { e.preventDefault(); if (typed.trim()) void pick(typed.trim()); }}>
<label htmlFor="models-folder-path" className="small">{tr('Directory path')}</label>
<input id="models-folder-path" className="st-input" value={typed} onChange={e => setTyped(e.target.value)} placeholder={tr('Full path to an existing folder')} spellCheck={false} autoFocus/>
<div className="row"><Btn variant="primary" type="submit" disabled={pending || !typed.trim()}>{tr('Use this directory')}</Btn></div>
</form> : null}
{unfinished && !moving ? <div className="models-notice models-notice--info" role="alert"><Icon name="info" size={18}/><div>
<strong>{tr('A move was interrupted. Finish it or leave it.')}</strong><br/>
{tr('Strata was moving your models from {0} to {1} when it stopped. Nothing is lost.', [unfinished.from, unfinished.to])}
<p className="small muted">{tr('Finishing puts all your models in {1}. Leaving it keeps them in {0} and removes the partial copies from {1}.', [unfinished.from, unfinished.to])}</p>
<div className="row"><Btn variant="primary" size="sm" disabled={pending || blocked} onClick={() => void finishMove()}>{tr('Finish the move')}</Btn><Btn size="sm" disabled={pending || blocked} onClick={() => void leaveMove()}>{tr('Leave it')}</Btn></div>
</div></div> : null}
{notice ? <p className="small storage-directory__notice" role="status"><Icon name="check" size={15}/>{notice}</p> : null}
{error && !stage ? <Failure message={error.message} details={error.details}/> : null}
</section>
<Dialog className="models-dialog" open={stage === 'issue' && !!plan?.issue} compact title={tr(plan?.issue?.code === 'not_found' ? "That folder isn't there" : "That folder can't be used")} onClose={leave}
actions={<>{plan?.issue?.code === 'not_found' ? null : <Btn onClick={leave}>{tr('Cancel')}</Btn>}<Btn variant="primary" onClick={() => { leave(); void pick(); }}>{tr('Choose another folder')}</Btn></>}>
{plan?.issue ? <Failure message={plan.issue.message} details={plan.issue.details}
next={plan.issue.code === 'not_writable' ? 'Choose a folder you own, for example one in Documents or on another drive.' : plan.issue.code === 'not_found' ? 'Check that the drive is plugged in, then pick the folder again. Your models were not touched.' : undefined}/> : null}
</Dialog>
<Dialog className="models-dialog" open={stage === 'drive' && !!plan} compact title={tr('Use this folder for your models?')} onClose={leave}
actions={<><Btn onClick={leave}>{tr('Cancel')}</Btn><Btn variant="primary" onClick={() => setStage('choose')}>{tr('Continue')}</Btn></>}>
{plan ? <>
<div className="models-picked"><span className="small muted">{tr('New folder')}</span><code className="storage-directory__path">{plan.shown}</code><span className="small muted">{tr('{0} free on this drive', [gb(plan.free_bytes)])}</span></div>
<div className="models-notice models-notice--info"><Icon name="info" size={18}/><div>{tr('You picked the whole drive {0}. Strata keeps its files in a folder of their own, so your models go in {1}.', [plan.picked, plan.shown])}</div></div>
<p className="small muted">{tr('The three choices come next.')}</p>
</> : null}
</Dialog>
<Dialog className="models-dialog" open={stage === 'choose' && !!plan} title={tr('Use this folder for your models?')} onClose={() => { if (!pending) leave(); }}
actions={plan ? <>{plan.choices.move.possible ? <Btn disabled={pending} onClick={leave}>{tr('Cancel')}</Btn> : <Btn disabled={pending} onClick={() => { leave(); void pick(); }}>{tr('Choose another folder')}</Btn>}<Btn variant="primary" disabled={pending} onClick={() => void start()}>{tr(pending ? 'Working…' : choice === 'move' ? 'Move my models' : choice === 'keep' ? 'Use both folders' : 'Start empty')}</Btn></> : null}>
{plan ? <ChooseBody plan={plan} choice={choice} setChoice={setChoice} names={planNames} error={error}/> : null}
</Dialog>
<Dialog className="models-dialog" open={!!moving && !outcome} compact noClose title={tr('Moving your models')} onClose={() => undefined}
actions={<Btn disabled={stopping} onClick={() => void stop()}>{tr(stopping ? 'Stopping…' : 'Stop moving')}</Btn>}>
<div className="models-free"><Bar value={percent} label={tr('Progress')}/><div className="models-free__row small"><span><strong>{tr('{0} of {1} GB', [progress ? (progress.done / 1e9 >= 10 ? Math.round(progress.done / 1e9) : (progress.done / 1e9).toFixed(1)) : 0, Math.round((progress?.total ?? 0) / 1e9)])}</strong></span><span className="muted">{progress && !progress.sameDrive ? timeLeft(progress.secondsLeft) : null}</span></div></div>
<p className="small">{tr('Keep Strata open. You can keep using other apps. Your models cannot be loaded until the move is done.')}</p>
{progress ? <details className="models-details"><summary>{tr('Details')}</summary><ul>
<li>{tr('Now copying: {0}, file {1} of {2}', [modelName(progress.group, native) ?? progress.group, progress.index, progress.count])}</li>
<li>{tr('From {0} to {1}', [savedPlan?.current ?? resumed?.from ?? '', savedPlan?.folder ?? resumed?.to ?? ''])}</li></ul></details> : null}
</Dialog>
<ResultDialog outcome={outcome?.job ?? null} plan={savedPlan} native={native} onClose={closeResult} onAgain={() => { closeResult(); void pick(); }}/>
</>;
}
function ChooseBody({ plan, choice, setChoice, names, error }: { plan: Plan; choice: Choice; setChoice: (c: Choice) => void; names: (Plan['models'][number] & { label: string | null })[]; error: { message: string; details?: string } | null }) {
const none = plan.models_bytes === 0;
const short = !none && !plan.choices.move.possible;
const lacking = Math.max(0, plan.needed_bytes - plan.free_bytes);
const options: { id: Choice; title: string; text: string; need: string }[] = [
{ id: 'move', title: 'Move my models there', text: none ? 'You have no models to move.' : plan.same_drive ? 'Strata moves your {0} of models to the new folder. This is the same drive, so it takes a moment.' : 'Strata copies your {0} of models to the new folder, checks them, then removes the old copies.',
need: none ? '' : plan.same_drive ? 'Needs no extra space.' : 'Needs {0} free in the new folder. You have {1}.' },
{ id: 'keep', title: 'Keep them where they are and also use the new folder', text: 'Nothing is copied. Your current models keep working, and new downloads go to the new folder.', need: short ? 'Needs no space now. New downloads must fit in {1}.' : 'Needs no space now.' },
{ id: 'empty', title: 'Start empty', text: 'Strata uses the new folder from now on and does not list your current models. Your old files are not deleted.', need: 'Needs no space now. Each model you download later needs its own space.' },
];
const values = [gb(plan.models_bytes), gb(plan.free_bytes)];
const running = plan.running > 0;
return <>
<div className="models-picked"><span className="small muted">{tr('New folder')}</span><code className="storage-directory__path">{plan.shown}</code><span className="small muted">{tr('{0} free on this drive', [gb(plan.free_bytes)])}</span></div>
{short ? <div className="models-notice models-notice--warn" role="alert"><Icon name="warning" size={18}/><div><strong>{tr('Your models need {0}, this drive has {1} free.', [gb(plan.needed_bytes), gb(plan.free_bytes)])}</strong><br/>{tr('Free up about {0} there, pick another folder, or choose one of the other two options.', [gb(lacking)])}</div></div> : null}
<fieldset className="models-choices" aria-label={tr('What should happen to your models')}>
{options.map(o => { const possible = plan.choices[o.id].possible; return <label className="models-choice" key={o.id} data-disabled={!possible || undefined}>
<input type="radio" name="models-folder-choice" checked={choice === o.id} disabled={!possible} onChange={() => setChoice(o.id)}/>
<span><strong>{tr(o.title)}</strong>{o.id === 'move' && possible && !none ? <span className="models-choice__tag">{tr('Recommended')}</span> : null}<br/><span className="small">{tr(o.text, o.id === 'move' ? [gb(plan.models_bytes)] : undefined)}</span></span>
{o.need ? <span className="models-choice__need">{tr(o.need, o.id === 'move' ? [gb(plan.needed_bytes), gb(plan.free_bytes)] : [values[0], values[1]])}</span> : null}
</label>; })}
</fieldset>
{running ? <p className="small muted">{tr('A running model is stopped first; load it again when the move is done.')}</p> : null}
{error ? <Failure message={error.message} details={error.details}/> : null}
<details className="models-details"><summary>{tr('Details')}</summary><ul>
{names.length ? <li>{tr('{0} would move.', [list(names.map(m => `${m.label}, ${gb(m.bytes)}`))])}</li> : null}
{short ? <li>{tr('Free space is measured on the drive that holds {0}.', [plan.shown])}</li> : <>
<li>{tr('Old folder: {0}', [plan.current])}</li>
<li>{tr('Strata copies one file at a time and checks its size before it removes the old one.')}</li>
<li>{tr('You can stop at any time; files already moved stay usable.')}</li></>}
</ul></details>
</>;
}
function ResultDialog({ outcome, plan, native, onClose, onAgain }: { outcome: NativeJob | null; plan: Plan | null; native: NativeState | null; onClose: () => void; onAgain: () => void }) {
if (!outcome) return null;
const result = (outcome.result ?? {}) as Outcome;
const sized = (groups: string[]) => list(groups.map(g => { const m = plan?.models.find(x => x.group === g); const name = modelName(g, native); return name ? (m ? `${name} (${gb(m.bytes)})` : name) : null; }).filter((x): x is string => !!x));
if (outcome.state === 'error') {
const full = result.code === 'disk_full';
const where = driveOf(result.to ?? '');
const some = (result.moved?.length ?? 0) > 0;
return <Dialog open compact title={tr(full ? 'The drive ran out of space' : 'The move stopped')} onClose={onClose}
actions={<><Btn onClick={onClose}>{tr('Close')}</Btn><Btn variant="primary" onClick={onAgain}>{tr('Choose another folder')}</Btn></>}>
<div className="error-notice" role="alert"><Icon name="error" size={18}/><div>
<strong>{full ? tr('{0} filled up after {1}, so the move stopped.', [where, gb(result.bytes ?? 0)]) : tr(outcome.error ?? 'The models could not be moved.')}</strong>
<p>{full ? (some ? tr('Nothing was lost. Models already moved are in the new folder, the rest are still in {0}, and the half-copied file was removed. Free up space on {1} or choose another folder, then try again.', [result.from ?? '', where])
: tr('Nothing was lost. All your models are still in {0}, and the half-copied file was removed. Free up space on {1} or choose another folder, then try again.', [result.from ?? '', where]))
: tr('Nothing was lost. Your models that were not moved are still in {0}. Choose another folder to try again.', [result.from ?? ''])}</p>
{outcome.details ? <details className="error-detail"><summary className="small">{tr('Details')}</summary><pre className="small muted">{outcome.details}</pre></details> : null}
</div></div>
</Dialog>;
}
if (result.stopped) {
const moved = sized(result.moved ?? []), left = sized(result.remaining ?? []);
return <Dialog open compact title={tr('Moving stopped')} onClose={onClose} actions={<Btn variant="primary" onClick={onClose}>{tr('Close')}</Btn>}>
<div className="models-notice models-notice--info"><Icon name="info" size={18}/><div><strong>{tr('Nothing was lost.')}</strong><br/>
{moved ? tr('{0} is in the new folder.', [moved]) + ' ' : ''}{left ? tr('{0} is still in the old folder.', [left]) + ' ' : ''}{moved && left ? tr('Both work.') : ''}</div></div>
<p className="small muted">{tr('Models in both folders are listed as normal. You can finish moving later with Change folder.')}</p>
</Dialog>;
}
return <Dialog open compact title={tr('Your models are moved')} onClose={onClose} actions={<Btn variant="primary" onClick={onClose}>{tr('Done')}</Btn>}>
<div className="models-ok"><span className="models-ok__mark"><Icon name="check" size={20}/></span><div><strong>{(() => { const [before, after] = tr('{0} now live in {1}', [gb(result.total ?? 0), '@@']).split('@@'); return <>{before}<code className="models-path">{plan?.shown ?? result.to ?? ''}</code>{after}</>; })()}</strong>
{result.sameDrive ? null : <><br/><span className="small muted">{tr('The old copies were removed and {0} is free again on {1}.', [gb(result.total ?? 0), driveOf(result.from ?? '')])}</span></>}</div></div>
</Dialog>;
}
+18 -8
View File
@@ -1,25 +1,28 @@
import { tr } from '../lib/i18n';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Screen } from './layout';
import { Btn, ErrorNotice, Icon } from './ui';
import { api } from '../lib/client';
import { parseLocation } from '../lib/router';
import { nativeAction, useNative, type NativeJob } from '../lib/native';
import { nativeCatalogModels, catalogVersionLabel } from '../lib/native-catalog';
import { sizeGb } from '../lib/format';
import { NativeProgress } from './NativeModels';
interface Preview { path: string; preview: { file_count: number; total_bytes: number; file_set_status: string; issues: string[]; messages?: string[]; projectors?: { name: string; status: string; pinned: boolean; plain: string }[]; reads_pictures?: boolean; candidate_selection: { family: string; model: string } | null } }
interface Preview { path: string; preview: { model_dir?: string; plain_issues?: string[]; already_set_up?: { data_dir?: string | null; pack?: string | null } | null; file_count: number; total_bytes: number; file_set_status: string; issues: string[]; messages?: string[]; projectors?: { name: string; status: string; pinned: boolean; plain: string }[]; reads_pictures?: boolean; candidate_selection: { family: string; model: string } | null } }
/** The words for why a folder of GGUF files cannot be used: a sentence and what to do, the checker's own codes under Details. */
function problem(issues: string[]): { message: string; next: string } {
const has = (re: RegExp) => issues.some(i => re.test(i));
if (has(/incomplete_or_invalid_shard_set|multiple_model_groups/))
return { message: 'Some parts of this model are missing, or the folder holds more than one model.', next: 'Put every part of one model in one folder, then check again.' };
if (has(/multiple_model_groups/))
return { message: 'This folder holds more than one model.', next: 'Type the full path of the .gguf file of the one you want, or choose the folder of that model.' };
if (has(/incomplete_or_invalid_shard_set/))
return { message: 'Some parts of this model are missing.', next: 'Put every part of one model (all the -0000N-of-0000M files) in one folder, then check again.' };
if (has(/unsupported_architecture|unsupported_or_missing_architecture/))
return { message: 'This model type isn’t supported by the Strata engine.', next: 'Add it as a Custom model API instead: start it with llama-server, then open Settings > Models > Add model provider > Custom model API.' };
if (has(/unrecognized_or_unsupported_model_variant|expert_quantization|tokenizer/))
return { message: 'Strata can’t run these files yet.', next: 'Strata runs the Qwen3.8-Flash-Next models. Download one from Models, or choose a folder with its files.' };
return { message: 'These files could not be checked.', next: 'Check that the folder holds complete GGUF files, then try again.' };
return { message: 'These files could not be checked.', next: 'Check that the folder holds complete GGUF files, then try again. The file named under Details says what is wrong with it.' };
}
/** "Use a local GGUF": choose the folder of a model you already have, Strata checks it, and prepares and loads it without copying
@@ -47,13 +50,18 @@ export function NativeLocalModel({ onBack }: { onBack: () => void }) {
catch (e) { setError(e instanceof Error ? e.message : String(e)); }
finally { setPending(false); }
}
/* The first-run page hands over the folder the person chose (#3507): it is checked at once, as if they had pressed Check files. */
useEffect(() => { const path = parseLocation(window.location.hash).query.get('path'); if (path) { setTyped(path); void check(path); } }, []);
/* Files that sit in a Strata data folder (<data>/models/<tag>) with their prepared model data beside them: that folder is used as the data
* folder, so the prepared data is not made a second time. A folder the installer would not accept as a data folder is not passed. */
const ownDataFolder = (() => { const d = checked?.preview.already_set_up?.pack ? checked.preview.already_set_up.data_dir : null; return d && /strata/i.test(d.split(/[\\/]/).filter(Boolean).pop() ?? '') ? d : null; })();
async function prepare() {
if (!usable || !selection || !checked)
return;
setPending(true);
setError('');
try {
const result = await nativeAction<NativeJob>('operations', { kind: 'install', args: { family: selection.family, model: selection.model, gguf_dir: checked.path, context: native?.catalog?.recommendation?.context ?? 32768, vision: checked.preview.projectors?.some(p => p.status === 'ok' && p.pinned) && native?.visionReader ? 'yes' : 'no', kv: 'int8', loadAfter: true } });
const result = await nativeAction<NativeJob>('operations', { kind: 'install', args: { family: selection.family, model: selection.model, gguf_dir: checked.preview.model_dir ?? checked.path, ...(ownDataFolder ? { data_dir: ownDataFolder } : {}), context: native?.catalog?.recommendation?.context ?? 32768, vision: checked.preview.projectors?.some(p => p.status === 'ok' && p.pinned) && native?.visionReader ? 'yes' : 'no', kv: 'int8', loadAfter: true } });
setJobId(result.id);
}
catch (e) { setError(e instanceof Error ? e.message : String(e)); }
@@ -64,8 +72,8 @@ export function NativeLocalModel({ onBack }: { onBack: () => void }) {
<div className="native-model-layout">
<div className="stack stack--lg">
<section className="st-card native-choice"><h2 className="h3">{tr("Use a local GGUF…")}</h2>
<p className="small muted">{tr("Already have a supported model on this computer? Choose the folder that holds its files. Strata checks them, prepares the model and loads it. Nothing is copied or downloaded.")}</p>
<label className="input-field"><span>{tr("Folder with the GGUF files")}</span><input className="st-input" value={typed} placeholder={tr("Full path to the folder")} spellCheck={false} onChange={e => setTyped(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && typed.trim()) void check(typed); }}/></label>
<p className="small muted">{tr("Already have a supported model on this computer? Choose the folder that holds its files, or one of its .gguf files. Strata checks them, prepares the model and loads it. Nothing is copied or downloaded.")}</p>
<label className="input-field"><span>{tr("Folder with the GGUF files, or one .gguf file")}</span><input className="st-input" value={typed} placeholder={tr("Full path to the folder or the file")} spellCheck={false} onChange={e => setTyped(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && typed.trim()) void check(typed); }}/></label>
<div className="native-settings-actions"><Btn icon="folder" disabled={pending || !!active} onClick={() => void check('')}>{tr("Choose folder")}</Btn><Btn disabled={pending || !typed.trim() || !!active} onClick={() => void check(typed)}>{tr(pending && !jobId ? 'Checking…' : 'Check files')}</Btn></div>
</section>
{tr(checked ? <section className="st-card native-choice">{tr(usable ? <>
@@ -77,6 +85,8 @@ export function NativeLocalModel({ onBack }: { onBack: () => void }) {
</> : <>
<h2 className="h3">{tr(found!.message)}</h2>
<p className="small muted">{tr(found!.next)}</p>
{tr(checked.preview.plain_issues?.map(m => <p className="small muted" key={m}>{tr(m)}</p>))}
{tr(checked.preview.messages?.map(m => <p className="small muted" key={m}>{tr(m)}</p>))}
{tr(checked.preview.issues.length ? <details><summary className="small">{tr("Details")}</summary><pre className="small muted error-detail-text">{checked.preview.issues.join(String.fromCharCode(10))}</pre></details> : null)}
</>)}</section> : null)}
{tr(active ? <NativeProgress job={active}/> : job?.state === 'error' ? <ErrorNotice message={job.error ?? job.summary} detail={job.details}/> : job?.state === 'done' ? <p className="small" role="status"><Icon name="check" size={15}/> {tr(job.summary)}</p> : null)}
+3 -2
View File
@@ -1,6 +1,7 @@
import { tr } from '../lib/i18n';
import { useState } from 'react';
import { Btn, Dialog, ErrorNotice, IconBtn, Select } from './ui';
import { MCP_NAME_HELP, validMcpName } from '../lib/mcp-names';
export function NativeMcpEditor({ text, onChange }: {
text: string;
onChange: (text: string) => void;
@@ -19,8 +20,8 @@ export function NativeMcpEditor({ text, onChange }: {
function open(id: string) { const server = servers[id] ?? {}; setEditing(id); setName(id); setTransport(server.url ? 'url' : 'command'); setAddress(server.url ?? server.command ?? ''); setArgs((server.args ?? []).join('\n')); setAdvanced(JSON.stringify(Object.fromEntries(Object.entries(server).filter(([k]) => !['url', 'command', 'args', 'disabled'].includes(k))), null, 2)); setError(''); }
function save() {
try {
if (!/^[a-z0-9_-]{1,64}$/i.test(name))
throw new Error('Use a simple server name: letters, numbers, hyphens or underscores.');
if (!validMcpName(name))
throw new Error(MCP_NAME_HELP);
if (!address.trim())
throw new Error('Enter the server URL or program.');
if (name !== editing && servers[name])
+38 -27
View File
@@ -4,7 +4,9 @@ import { Screen } from './layout';
import { VersionPicker } from './VersionPicker';
import { FamilySwitch } from './FamilySwitch';
import { PcFitCard } from './PcFitCard';
import { fitOn, runCards } from '../lib/native-fit';
import { fitOn, memoryNeeded } from '../lib/native-fit';
import { GpuRoles } from './GpuRoles';
import { fromLegacy, normalize as normalizeRoles, order as roleOrder, recommend as recommendRoles, rows as roleRows, sameRoles, vramOn, type RoleCard, type Roles } from '../lib/gpu-roles';
import { Btn, Dialog, ErrorNotice, Icon, Select } from './ui';
import { installProgress, installStage, stageText, useDownloadEta } from '../lib/download-eta';
import { useKept } from '../lib/kept-state';
@@ -18,6 +20,7 @@ import { go, setSettingsSection, selectRemoteModel, pushToast } from '../lib/sto
import { nativeAction, useNative, type NativeJob } from '../lib/native';
import { tuneEffective } from '../lib/native-tune';
import { tunedFields } from '../lib/tune-values';
import { draftingChoices as draftingOptions } from '../lib/drafting';
import { NativeSettings } from './NativeSettings';
import { NativeSettingsFile } from './NativeSettingsFile';
import { RunImageModel } from './RunImageModel';
@@ -37,9 +40,9 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
// The choices are kept while the page is closed (Models -> Monitor -> Models used to start them over and a person lost track of what was downloading).
const [family, setFamily] = useKept('models.family', ''), [size, setSize] = useKept('models.size', ''), [context, setContext] = useKept('models.context', 32768), [vision, setVision] = useKept('models.vision', 'yes');
const manual = useStore(s => s.setup?.choices?.onboarding?.manual ?? false);
const [expert, setExpert] = useKept('models.expert', manual), [multiple, setMultiple] = useKept('models.multiple', false), [gpus, setGpus] = useKept<number[]>('models.gpus', []), [kv, setKv] = useKept('models.kv', 'int8'), [lowRam, setLowRam] = useKept('models.lowRam', 'auto'), [split, setSplit] = useKept('models.split', 'auto'), [deviceChoice, setDeviceChoice] = useKept('models.device', 'auto');
const [cardsKey, setCardsKey] = useKept<string | null>('models.cards', null);
const [expert, setExpert] = useKept('models.expert', manual), [multiple, setMultiple] = useKept('models.multiple', false), [roles, setRoles] = useKept<Roles | null>('models.roles', null), [kv, setKv] = useKept('models.kv', 'int8'), [lowRam, setLowRam] = useKept('models.lowRam', 'auto'), [split, setSplit] = useKept('models.split', 'auto');
const [resourceProfile, setResourceProfile] = useKept<'work' | 'performance'>('models.profile', 'work');
const [drafting, setDrafting] = useState('auto');
const [pending, setPending] = useState(false), [error, setError] = useState(''), [plan, setPlan] = useState<Record<string, any> | null>(null), [loadedError, setLoadedError] = useState(''), [tuneOpen, setTuneOpen] = useState(false), [jobId, setJobId] = useKept('models.job', ''), [manage, setManage] = useState(false);
const [toastedJob, setToastedJob] = useKept('models.toasted', ''), [appliedSig, setAppliedSig] = useKept('models.applied', '');
const presetPending = useRef(false);
@@ -53,7 +56,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
// The model that is running is shown as running, with Unload: not offered for loading again.
const downloaded = native?.installed?.models ?? [], allSizes = families.flatMap(f => f.sizes);
// This computer for the fit figures: the biggest usable graphics card, or the ones chosen under Expert settings.
const runGpus = runCards(native?.hardware?.gpus, native?.hardware?.engine_vendor);
const engineVendor = native?.hardware?.engine_vendor ?? null, roleCards: RoleCard[] = roleRows(native?.hardware?.gpus as RoleCard[] | undefined, engineVendor);
const pcGpus = (native?.hardware?.gpus ?? []).filter(g => g.usable !== false), pcGpu = [...pcGpus].sort((x, y) => y.vram_gb - x.vram_gb)[0] ?? null;
const pcRam = native?.hardware?.ram_gb || hardware?.memory.totalGb || null, pcVram = pcGpu?.vram_gb ?? 0;
// What the models that stay loaded (Run multiple models) already take: the fit figures are for what is left.
@@ -85,20 +88,24 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
// Graphics cards (strata_control/gpu_fit.py: the one rule of the app, the Terminal and setup): the recommended option is pre-selected, the person may pick another
// A card a loaded model already uses (a second model on it usually does not fit): said on its row, and not the pre-selected one while another is free.
const usedBy = (cards: number[]) => (native?.instances ?? []).filter(i => i.status !== 'error' && i.model !== selectedSize?.id && i.gpus.some(g => cards.includes(g))).map(i => i.label);
const cardOptions = selectedSize?.gpu_options && selectedSize.gpu_options.options.length >= 2 ? selectedSize.gpu_options : null;
const loadedCards = loadedSelected ? cardOptions?.options.find(o => o.cards.length === loadedSelected.gpus.length && o.cards.every(c => loadedSelected.gpus.includes(c))) : undefined;
const activeCards = cardOptions ? (cardOptions.options.find(o => o.key === cardsKey) ?? loadedCards ?? (multiple ? cardOptions.options.find(o => o.recommended && !usedBy(o.cards).length) ?? cardOptions.options.find(o => !usedBy(o.cards).length) : undefined) ?? cardOptions.options.find(o => o.recommended) ?? cardOptions.options[0]) : null;
const cardsGpus: number[] = activeCards?.cards ?? [];
const chosenCards = expert && gpus.length ? gpus : cardsGpus;
const fitHardware = { ram_gb: pcRam ? Math.max(0, pcRam - keptRam) : pcRam, vram_gb: Math.max(0, pcVram - keptVramOn(chosenCards)) };
// "(card 1)" in a card name is the number the screens call GPU 2 (Monitor, the model picker, the loaded list): one numbering everywhere.
const gpuNumbers = (text: string) => text.replace(/\(card (\d+)\)/g, (_m, n) => `(GPU ${Number(n) + 1})`);
// The fit sentence for a card choice, less what the kept models take of RAM and of that card's memory.
const lessKept = (words: string, cards: number[]) => { const gone = Math.round(keptRam + keptVramOn(cards)); return gone > 0 ? words.replace(/of the (\d+) GB available/, (_m, n) => `of the ${Math.max(0, Number(n) - gone)} GB available`) : words; };
const shownSize = useRef(selectedSize?.id);
useEffect(() => { if (shownSize.current !== selectedSize?.id) { shownSize.current = selectedSize?.id; setCardsKey(null); } }, [selectedSize?.id]);
const values = useMemo(() => ({ family: selectedSize?.sourceFamily, model: selectedSize?.model, context, vision: selectedSize?.images ? vision : 'no', kv, low_ram: lowRam, resourceProfile, ...(expert && gpus.length === 1 ? { gpu: gpus[0] } : expert && gpus.length ? { gpus: gpus.join(',') } : cardsGpus.length > 1 ? { gpus: cardsGpus.join(',') } : cardsGpus.length === 1 ? { gpu: cardsGpus[0] } : {}), loadAfter: true, multiple: expert && multiple }), [selectedSize?.sourceFamily, selectedSize?.model, context, vision, kv, lowRam, resourceProfile, gpus.join(','), cardsGpus.join(','), expert, multiple]);
const downloadValues = { ...values, vision: selectedSize?.images && vision !== 'no' ? 'yes' : 'no', loadVision: selectedSize?.images ? vision : 'no' };
// Main / Helper / Off per card (src/lib/gpu-roles.ts, strata_control/gpu_roles.py): the recommendation until the person changes a role; a loaded model shows what it was started with.
// With Run multiple models, a card another loaded model already uses starts Off for this one (while a free card exists).
const busy = (i: number) => expert && multiple && usedBy([i]).length > 0, freeCards = roleCards.filter(c => !busy(c.index));
const recommended = freeCards.length && freeCards.length < roleCards.length ? { ...recommendRoles(freeCards, engineVendor), roles: { ...Object.fromEntries(roleCards.map(c => [c.index, 'off'])), ...recommendRoles(freeCards, engineVendor).roles } as Roles } : recommendRoles(roleCards, engineVendor);
const loadedRoles = loadedSelected && roleCards.length > 1 ? fromLegacy(loadedSelected.gpus, roleCards, 'as_given') : null;
const effectiveRoles: Roles = roleCards.length > 1 ? (roles ? normalizeRoles(roles, roleCards) : loadedRoles ?? recommended.roles) : recommended.roles;
const customRoles = !sameRoles(effectiveRoles, recommended.roles);
const changeRoles = (next: Roles) => setRoles(sameRoles(next, recommended.roles) ? null : next);
const cardsGpus: number[] = roleCards.length > 1 ? roleOrder(effectiveRoles, roleCards) : [];
const chosenCards = cardsGpus;
const fitHardware = { ram_gb: pcRam ? Math.max(0, pcRam - keptRam) : pcRam, vram_gb: Math.max(0, (roleCards.length > 1 ? vramOn(effectiveRoles, roleCards) : pcVram) - keptVramOn(chosenCards)) };
// A layer split written for another number of cards no longer fits: back to automatic.
useEffect(() => { if (cardsGpus.length <= 1 && split !== 'auto') setSplit('auto'); }, [cardsGpus.length]);
// Drafting (which drafter guesses ahead): DFlash2 is for Qwen3.8-Flash-Next only (the qwen and gyro families), so no other model is offered it.
const draftingChoices = selectedSize ? draftingOptions(selectedSize.sourceFamily ?? selectedFamily?.family, native?.capabilities?.dflash2) : null;
const draftingWords: Record<string, string> = { auto: 'Auto (recommended)', mtp: 'MTP', dflash2: 'DFlash2', off: 'Off' };
const values = useMemo(() => ({ family: selectedSize?.sourceFamily, model: selectedSize?.model, context, vision: selectedSize?.images ? vision : 'no', kv, low_ram: lowRam, resourceProfile, ...(cardsGpus.length > 1 ? { gpus: cardsGpus.join(',') } : cardsGpus.length === 1 ? { gpu: cardsGpus[0] } : {}), loadAfter: true, multiple: expert && multiple }), [selectedSize?.sourceFamily, selectedSize?.model, context, vision, kv, lowRam, resourceProfile, cardsGpus.join(','), expert, multiple]);
const downloadValues = { ...values, ...(draftingChoices ? { drafting } : {}), vision: selectedSize?.images && vision !== 'no' ? 'yes' : 'no', loadVision: selectedSize?.images ? vision : 'no' };
useEffect(() => { if (!native?.catalog || family)
return; const current = native.instances.find(i => i.id === native.defaultId); const rec = native.catalog.recommendation ?? { family: null, model: null }; const loadedFamily = families.find(f => f.sizes.some(s => s.id === current?.model)); const have = new Set((native.installed?.models ?? []).filter(m => m.ready).map(m => m.model)); const installedFamily = families.find(f => f.sizes.some(s => have.has(s.id))); const f = loadedFamily ?? installedFamily ?? families.find(f => f.family === rec.family || f.sizes.some(s => s.sourceFamily === rec.family)) ?? families[0]; if (f) {
setFamily(f.family);
@@ -115,7 +122,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
}
if (!selectedSize)
return;
const sig = installed ? ['have', installed.model, installed.ready, installed.context, installed.kv, installed.images, installed.visionEnabled, installed.visionDevice, installed.lowRamMode, installed.resourceProfile].join('|') : `new|${selectedSize.id}`;
const sig = installed ? ['have', installed.model, installed.ready, installed.context, installed.kv, installed.images, installed.visionEnabled, installed.visionDevice, installed.lowRamMode, installed.drafting, installed.resourceProfile].join('|') : `new|${selectedSize.id}`;
if (sig === appliedSig)
return;
setAppliedSig(sig);
@@ -126,6 +133,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
setVision(installed.visionEnabled === false ? 'no' : installed.visionDevice ?? (installed.images && installed.images !== 'off' ? installed.images : 'yes'));
setKv(installed.kv ?? 'int8');
setLowRam(installed.lowRamMode ?? 'auto');
setDrafting(installed.drafting ?? 'auto');
// A model never tuned has no saved choice: keep the page's own (Leave 20%) rather than flipping to Full performance.
if (installed.resourceProfile && !loadedSelected?.resourceProfile)
setResourceProfile(installed.resourceProfile);
@@ -149,7 +157,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
} }
async function load() { if (!selectedSize)
return; await perform(async () => { if (installed?.ready && context === installed.context && (!selectedSize?.images || vision === 'no' || installed.images && installed.images !== 'off') && (!installed.kv || kv === installed.kv || kv === tunedNow?.fields.kv) && (lowRam === (installed.lowRamMode ?? 'auto') || lowRam === tunedNow?.fields.lowRam)) {
const result = await nativeAction<NativeJob>('operations', { kind: 'load', args: { model: selectedSize.id, resourceProfile, vision: selectedSize?.images ? vision : 'no', multiple: expert && multiple, ...(expert && gpus.length ? { gpus, layerSplit: split } : cardsGpus.length ? { gpus: cardsGpus, gpusFromRecommendation: !cardsKey } : {}), values: {} } });
const result = await nativeAction<NativeJob>('operations', { kind: 'load', args: { model: selectedSize.id, resourceProfile, vision: selectedSize?.images ? vision : 'no', multiple: expert && multiple, ...(draftingChoices ? { values: { drafting } } : {}), ...(roleCards.length > 1 ? (roles && customRoles ? { gpuRoles: effectiveRoles, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : cardsGpus.length ? { gpus: cardsGpus, gpusFromRecommendation: true, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : {}) : {}) } });
setJobId(result.id);
}
else
@@ -165,10 +173,10 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
setPending(false);
} }
async function download() { await perform(async () => { const result = await nativeAction<NativeJob>('operations', { kind: 'install', args: downloadValues }); setJobId(result.id); setPlan(null); }); }
return <Screen plainHeader title={<span className="sr-only">{tr("Models")}</span>} titleDetail={<FamilySwitch families={families} selected={selectedFamily?.family} onPick={f => { setFamily(f.family); setSize((f.sizes.find(s => native?.installed?.models.some(m => m.model === s.id && m.ready)) ?? f.sizes[0])?.id ?? ''); setVision('yes'); setError(''); }}/>} actions={<Btn icon="sliders" aria-pressed={expert} aria-expanded={expert} aria-controls="native-expert-settings" onClick={() => { setExpert(v => !v); setMultiple(false); setGpus([]); setDeviceChoice('auto'); }}>{tr(expert ? 'Close expert mode' : 'Expert mode')}</Btn>}>
return <Screen plainHeader title={<span className="sr-only">{tr("Models")}</span>} titleDetail={<FamilySwitch families={families} selected={selectedFamily?.family} onPick={f => { setFamily(f.family); setSize((f.sizes.find(s => native?.installed?.models.some(m => m.model === s.id && m.ready)) ?? f.sizes[0])?.id ?? ''); setVision('yes'); setError(''); }}/>} actions={<Btn icon="sliders" aria-pressed={expert} aria-expanded={expert} aria-controls="native-expert-settings" onClick={() => { setExpert(v => !v); setMultiple(false); }}>{tr(expert ? 'Close expert mode' : 'Expert mode')}</Btn>}>
<div className="native-model-layout native-model-layout--fit">
<div className="stack">
{tr(native?.instances.length && expert ? <section className="st-card native-running"><h2 className="h4">{tr(native.instances.length === 1 ? 'Loaded model' : 'Loaded models')}</h2>{tr(native.instances.map(i => <div key={i.id}><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.length ? i.gpus.map(g => `GPU ${g + 1}`).join(' + ') : 'Automatic GPU')} · {tr(i.status === 'ready' ? i.phase : i.status)}{tr(native.defaultId === i.id ? ' · Chat default' : '')}</small>{tr(i.error ? <small className="inline-error">{tr(plainFailureText(i.error).message)}</small> : null)}</span>{tr(expert && multiple && native.defaultId !== i.id ? <Btn size="sm" variant="ghost" onClick={() => void loadedAction(`instances/${i.id}/default`)}>{tr("Use in chat")}</Btn> : null)}<Btn size="sm" disabled={pending || !!active} onClick={() => void loadedAction(`instances/${i.id}/unload`)}>{tr("Unload")}</Btn></div>))}{tr(loadedError ? <ErrorNotice message={loadedError} next={tr("Stop or answer the chat that is still using this model, then try again.")}/> : null)}</section> : null)}
{tr(native?.instances.length && expert ? <section className="st-card native-running"><h2 className="h4">{tr(native.instances.length === 1 ? 'Loaded model' : 'Loaded models')}</h2>{tr(native.instances.map(i => <div key={i.id}><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.length ? i.gpus.map((g, n) => `GPU ${g + 1}${i.gpus.length > 1 && n === i.gpus.length - 1 ? ' (Main)' : ''}`).join(' + ') : 'Automatic GPU')} · {tr(i.status === 'ready' ? i.phase : i.status)}{tr(native.defaultId === i.id ? ' · Chat default' : '')}</small>{tr(i.error ? <small className="inline-error">{tr(plainFailureText(i.error).message)}</small> : null)}</span>{tr(expert && multiple && native.defaultId !== i.id ? <Btn size="sm" variant="ghost" onClick={() => void loadedAction(`instances/${i.id}/default`)}>{tr("Use in chat")}</Btn> : null)}<Btn size="sm" disabled={pending || !!active} onClick={() => void loadedAction(`instances/${i.id}/unload`)}>{tr("Unload")}</Btn></div>))}{tr(loadedError ? <ErrorNotice message={loadedError} next={tr("Stop or answer the chat that is still using this model, then try again.")}/> : null)}</section> : null)}
<section className="st-card native-choice">{tr(selectedFamily && (!selectedSize || selectedSize.needsNewerEngine) ? <><h2 className="h3">{tr("This model needs a newer engine.")}</h2><p className="small muted">{tr("Update your engine or import a local GGUF for this model.")}</p><Btn onClick={() => { setSettingsSection('Engine'); go('settings'); }}>{tr("Update engine")}</Btn><Btn variant="ghost" onClick={onImport ?? onOtherModels}>{tr("Use a local GGUF\u2026")}</Btn></> : null)}{tr(!selectedFamily ? <><h2 className="h3">{tr("Set up Strata")}</h2><p className="small muted">{tr("Connect your local installation to choose an optimized model.")}</p><Btn variant="primary" onClick={onConnectEngine ?? (() => { setSettingsSection('Engine'); go('settings'); })}>{tr("Connect engine")}</Btn>{tr(native?.error ? <p className="small inline-error">{tr(native.error)}</p> : null)}<Btn variant="ghost" onClick={onOtherModels}>{tr("Other local models")}</Btn></> : null)}
{tr(selectedSize ? <>
<div className="native-choice__head"><span className="native-choice__label">{tr("Version")}</span><span className="native-choice__tools"><NativePresets values={values} onApply={v => { const f = families.find(f => f.family === v.family || f.sizes.some(s => s.sourceFamily === v.family)); if (!f)
@@ -177,9 +185,12 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
{tr(noFit ? <p className="small muted native-spill-note"><Icon name="disk" size={16}/>{tr("May run slower \u00B7 Model data can be read from SSD.")}</p> : null)}
<div className="native-choice__row">
<div className="native-choice__col"><span className="native-choice__label">{tr("Conversation context")}</span><Select ariaLabel={tr("Conversation context")} value={String(context)} options={contextChoices(native?.catalog?.contexts ?? [8192, 32768, 65536, 131072, 262144], context).map(n => ({ value: String(n), label: n % 1024 === 0 ? `${n / 1024}K` : `${n.toLocaleString()} tokens` }))} onChange={n => setContext(Number(n))}/></div>
{tr(draftingChoices ? <div className="native-choice__col"><span className="native-choice__label">{tr("Drafting")}</span><Select ariaLabel={tr("Drafting")} value={drafting} disabled={!!active} options={draftingChoices.map(v => ({ value: v, label: tr(draftingWords[v]) }))} onChange={v => setDrafting(String(v))}/></div> : null)}
<div className="native-choice__col"><span className="native-choice__label">{tr("Computer use")}</span><div className="native-resource-choice"><button aria-pressed={resourceProfile === 'work'} data-selected={resourceProfile === 'work'} onClick={() => setResourceProfile('work')}><Icon name="computer" size={20}/><span><strong>{tr("Leave 20% for my work")}</strong><small>{tr("Headroom for your other apps")}</small></span>{tr(resourceProfile === 'work' ? <Icon name="check" size={15}/> : null)}</button><button aria-pressed={resourceProfile === 'performance'} data-selected={resourceProfile === 'performance'} onClick={() => setResourceProfile('performance')}><Icon name="bolt" size={20}/><span><strong>{tr("Full performance")}</strong><small>{tr("Give Strata priority")}</small></span>{tr(resourceProfile === 'performance' ? <Icon name="check" size={15}/> : null)}</button></div></div>
</div>
{tr(cardOptions ? <div className="native-cards"><span className="native-choice__label">{tr("Graphics cards")}</span><div className="native-cards__list" role="radiogroup" aria-label={tr("Graphics cards")}>{cardOptions.options.map(o => { const on = activeCards?.key === o.key; return <button key={o.key} type="button" role="radio" aria-checked={on} data-selected={on} disabled={!!active} className="native-cards__opt" onClick={() => setCardsKey(o.key)}><span className="native-cards__dot" aria-hidden="true"/><span><strong>{gpuNumbers(o.label)}</strong><small data-warn={o.verdict === 'spills' || o.verdict === 'tight' || usedBy(o.cards).length > 0}>{[lessKept(o.words, o.cards), ...o.notes, ...(usedBy(o.cards).length ? [tr('Already used by {0}', [usedBy(o.cards).join(', ')])] : [])].join('. ')}.</small></span></button>; })}{cardOptions.ignored.length ? <p className="small muted native-cards__note">{`Not used: ${cardOptions.ignored.map(g => `${g.name ?? 'a card'} (${g.why_ignored})`).join('; ')}.`}</p> : null}</div></div> : null)}
{tr(loadedSelected?.metrics?.engine?.drafting_note ? <p className="small muted native-spill-note">{tr(String(loadedSelected.metrics.engine.drafting_note))}</p> : null)}
{roleCards.length > 1 ? <GpuRoles cards={roleCards} roles={effectiveRoles} custom={customRoles} recommendedRoles={recommended.roles} vendor={engineVendor} disabled={!!active} onChange={changeRoles} onBack={() => setRoles(null)} memoryGb={selectedSize ? memoryNeeded(selectedSize) || null : null} ramGb={pcRam} spillGb={selectedSize ? fitOn(selectedSize, fitHardware).spillGb : 0} usedBy={i => usedBy([i])}/> : null}
{tr(roleCards.length > 1 && (native?.hardware?.gpus ?? []).length > roleCards.length ? <p className="small muted native-cards__note">{tr('Not used: {0} (another make of graphics card than this engine drives).', [(native?.hardware?.gpus ?? []).filter(g => !roleCards.some(r => r.index === g.index && r.vendor === g.vendor)).map(g => g.name).join(', ')])}</p> : null)}
{tr(selectedSize?.imagesCapable && !selectedSize.images ? <p className="small muted native-spill-note">{tr("This model can read pictures, but the engine installed here does not include the picture reader yet, so Read pictures is off. Update the engine in Settings → About to turn it on.")}</p> : null)}
{tr(selectedSize?.images ? <><div className="native-image-reading"><label><input type="checkbox" checked={vision !== 'no'} disabled={!selectedSize?.images || !!active} onChange={e => setVision(e.target.checked ? 'yes' : 'no')}/><span><strong>{tr("Read pictures ")}<small>{tr("On by default")}</small></strong><small>{tr("Understand photos, screenshots and charts. The picture files download automatically. Turn it off to save memory.")}</small></span></label>{tr(expert && vision !== 'no' ? <Select ariaLabel={tr("Image encoder device")} value={vision === 'yes' ? 'yes' : vision} options={[{ value: 'yes', label: 'Automatic' }, { value: 'gpu', label: 'GPU' }, { value: 'cpu', label: 'CPU' }]} onChange={setVision}/> : null)}</div>{tr(vision !== 'no' && installed?.ready && installed.visionPrepared === false ? <div className="native-vision-missing" role="status"><span>{tr("Picture reading needs a few more files that are not on this computer yet.")}</span><Btn size="sm" disabled={pending || !!active} onClick={() => void download()}>{tr("Download picture-reading files")}</Btn></div> : null)}</> : null)}
</> : null)}
@@ -188,15 +199,15 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
{tr(job?.state === 'error' ? <ErrorNotice message={job.error ?? job.summary} action={tr("Retry")} onRetry={() => void load()}/> : null)}
{tr(error ? <ErrorNotice message={error}/> : null)}
{selectedSize && installed?.ready ? <NativeTuneBadge model={selectedSize.id} resourceProfile={resourceProfile} vision={selectedSize.images ? vision : 'no'}/> : null}
<div className="native-model-actions">{tr(loadedSelected ? <><Btn disabled={pending || !!active} onClick={() => void perform(() => nativeAction(`instances/${loadedSelected.id}/unload`))}>{tr("Unload")}</Btn>{tr(onReady ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <span className="native-ready">{tr("Ready")}</span>)}</> : onReady && native?.instances.some(i => i.status === 'ready') ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <><Btn icon="gauge" disabled={!installed?.ready || !native?.available || !!active || !selectedSize || selectedSize.needsNewerEngine} onClick={() => setTuneOpen(true)}>{tr("Tune model")}</Btn><Btn variant="primary" icon="models" disabled={pending || !native?.available || !!active || !selectedSize || !!selectedSize.needsNewerEngine || expert && deviceChoice === 'split' && gpus.length < 2} onClick={() => void load()}>{tr(pending ? 'Checking…' : installed?.ready ? 'Load model' : 'Download & load')}</Btn></>)}</div>
<div className="native-model-actions">{tr(loadedSelected ? <><Btn disabled={pending || !!active} onClick={() => void perform(() => nativeAction(`instances/${loadedSelected.id}/unload`))}>{tr("Unload")}</Btn>{tr(onReady ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <span className="native-ready">{tr("Ready")}</span>)}</> : onReady && native?.instances.some(i => i.status === 'ready') ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <><Btn icon="gauge" disabled={!installed?.ready || !native?.available || !!active || !selectedSize || selectedSize.needsNewerEngine} onClick={() => setTuneOpen(true)}>{tr("Tune model")}</Btn><Btn variant="primary" icon="models" disabled={pending || !native?.available || !!active || !selectedSize || !!selectedSize.needsNewerEngine} onClick={() => void load()}>{tr(pending ? 'Checking…' : installed?.ready ? 'Load model' : 'Download & load')}</Btn></>)}</div>
</section>
{tr(expert && IMAGE_GENERATION_VISIBLE ? <><details className="native-generation"><summary>{tr("Image generation ")}<span className="muted">{tr("Optional")}</span></summary><RunImageModel library={imageLibrary} disabled={pending || !!active} onChange={() => { }}/></details></> : null)}
{tr(expert ? <section id="native-expert-settings" ref={expertSection} className="st-card native-execution"><h2 className="h4">{tr("Expert settings")}</h2>{tr(tunedNow ? <p className="small muted native-tuned-note">{tr("Showing the values of your tuning '{0}'.", [tunedNow.name])}</p> : null)}<label className="native-toggle"><span><strong>{tr("Run multiple models")}</strong><small>{tr("Loading another model keeps the others loaded.")}</small></span><button className="st-toggle" role="switch" aria-label={tr("Run multiple models")} aria-checked={multiple} onClick={() => setMultiple(v => !v)}/></label><h3 className="small strong">{tr("Run on")}</h3><p className="small muted">{tr("Automatic uses the installation\u2019s GPU placement. Choose a GPU, or split this model across several GPUs.")}</p><Select ariaLabel={tr("Run on")} value={deviceChoice} options={[{ value: 'auto', label: 'Automatic' }, ...runGpus.map(g => ({ value: String(g.index), label: `GPU ${g.index + 1} · ${g.name}` })), ...(runGpus.length > 1 ? [{ value: 'split', label: 'Split across GPUs…' }] : [])]} onChange={value => { setDeviceChoice(value); setGpus(value === 'auto' ? [] : value === 'split' ? runGpus.map(g => g.index) : [Number(value)]); }}/>{tr(deviceChoice === 'split' ? <div className="native-gpu-options">{tr(runGpus.map(g => <label key={g.index}><input type="checkbox" checked={gpus.includes(g.index)} onChange={e => setGpus(old => e.target.checked ? [...old, g.index] : old.filter(i => i !== g.index))}/><span>{tr("GPU ")}{tr(g.index + 1)} · {tr(g.name)}<small>{tr(g.vram_gb)}{tr(" GB VRAM")}</small></span></label>))}</div> : null)}{tr(gpus.length > 1 ? <label className="input-field"><span>{tr("Layer split")}</span><input className="st-input" value={split} placeholder={tr("auto or 16,32")} onChange={e => setSplit(e.target.value)}/></label> : null)}<div className="native-field-grid"><label className="input-field"><span>{tr("Context memory")}</span><Select ariaLabel={tr("Native KV format")} value={kv} options={[{ value: 'int8', label: 'INT8' }, { value: 'q4_0', label: 'Q4' }, { value: 'k8v4', label: 'K8 / V4' }]} onChange={setKv}/></label><label className="input-field"><span>{tr("Low-RAM mode")}</span><Select ariaLabel={tr("Native low RAM mode")} value={lowRam} options={['auto', 'resident', 'mmap'].map(v => ({ value: v, label: v === 'auto' ? 'Automatic' : v === 'resident' ? 'Keep experts in RAM' : 'Read experts from disk' }))} onChange={setLowRam}/></label></div>{tr(installed ? <><NativeSettingsFile model={installed.model}/><NativeSettings model={installed.model}/></> : <p className="small muted">{tr("More engine parameters become available after preparing this version.")}</p>)}</section> : null)}
{tr(expert ? <section id="native-expert-settings" ref={expertSection} className="st-card native-execution"><h2 className="h4">{tr("Expert settings")}</h2>{tr(tunedNow ? <p className="small muted native-tuned-note">{tr("Showing the values of your tuning '{0}'.", [tunedNow.name])}</p> : null)}<label className="native-toggle"><span><strong>{tr("Run multiple models")}</strong><small>{tr("Loading another model keeps the others loaded.")}</small></span><button className="st-toggle" role="switch" aria-label={tr("Run multiple models")} aria-checked={multiple} onClick={() => setMultiple(v => !v)}/></label>{tr(cardsGpus.length > 1 ? <label className="input-field"><span>{tr("Layer split")}</span><input className="st-input" value={split} placeholder={tr("auto or 16,32")} onChange={e => setSplit(e.target.value)}/></label> : null)}<div className="native-field-grid"><label className="input-field"><span>{tr("Context memory")}</span><Select ariaLabel={tr("Native KV format")} value={kv} options={[{ value: 'int8', label: 'INT8' }, { value: 'q4_0', label: 'Q4' }, { value: 'k8v4', label: 'K8 / V4' }]} onChange={setKv}/></label><label className="input-field"><span>{tr("Low-RAM mode")}</span><Select ariaLabel={tr("Native low RAM mode")} value={lowRam} options={['auto', 'resident', 'mmap'].map(v => ({ value: v, label: v === 'auto' ? 'Automatic' : v === 'resident' ? 'Keep experts in RAM' : 'Read experts from disk' }))} onChange={setLowRam}/></label></div>{tr(installed ? <><NativeSettingsFile model={installed.model}/><NativeSettings model={installed.model}/></> : <p className="small muted">{tr("More engine parameters become available after preparing this version.")}</p>)}</section> : null)}
</div>
<aside className="stack stack--lg"><PcFitCard gpuName={pcGpu?.name} ramGb={pcRam} vramGb={pcVram} inUse={keptModels.length ? { ramGb: Math.round(keptRam), vramGb: Math.round(keptVramOn(chosenCards)) } : null} fit={selectedSize ? fitOn(selectedSize, fitHardware) : null}/><section className="st-card"><h2 className="h4">{tr("Model files")}</h2><p className="small muted">{tr("Strata prepares all shards, the draft layer and shared files. Completed downloads are reused.")}</p><code className="native-path">{installed?.dataFolder ?? native?.downloadDirectory ?? native?.installed?.data_folder ?? 'Choose an installation'}</code><Btn size="sm" variant="ghost" onClick={() => { setSettingsSection('Storage'); go('settings'); }}>{tr("Storage settings")}</Btn></section><Btn size="sm" variant="ghost" onClick={onOtherModels}>{tr("Other local models\u2026")}</Btn></aside>
</div>
<Dialog open={!!plan} title={tr(installed?.ready ? "Apply settings and load" : "Download and prepare")} compact onClose={() => !pending && setPlan(null)} actions={<><Btn disabled={pending} onClick={() => setPlan(null)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={pending || !!plan?.plan?.disk_short} onClick={() => void download()}>{tr(installed?.ready ? "Apply & load" : "Download & load")}</Btn></>}><p><strong>{tr(plan?.plan?.title ?? selectedFamily?.title)}</strong>{tr(installed?.ready ? null : <>{tr(" \u00B7 approximately ")}{tr(plan?.plan?.download_gb ?? selectedSize?.download_gb)}{tr(" GB")}</>)}</p>{tr(installed?.ready ? <p className="small muted">{tr("The model files are already on this computer. Nothing will be downloaded; only the settings you chose are applied.")}</p> : null)}{tr(plan?.plan?.disk_short ? <ErrorNotice message={plan.plan.disk_short}/> : null)}{tr(installed?.ready ? <p className="small muted">{tr("Any currently loaded native models will be unloaded first.")}</p> : <><p className="small muted">{tr("Existing files are checked and reused. Any currently loaded native models will be unloaded before preparing files.")}</p><p className="small muted">{tr("Your computer may feel slower while weights load. You can stop the download and resume later.")}</p></>)}{tr(selectedSize?.images ? <p className="small muted">{tr("Vision encoder files are included. Picture reading loads only when enabled.")}</p> : null)}</Dialog>
<Dialog open={!!plan} title={tr(installed?.ready ? "Apply settings and load" : "Download and prepare")} compact onClose={() => !pending && setPlan(null)} actions={<><Btn disabled={pending} onClick={() => setPlan(null)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={pending || !!plan?.plan?.disk_short} onClick={() => void download()}>{tr(installed?.ready ? "Apply & load" : "Download & load")}</Btn></>}><p><strong>{tr(plan?.plan?.title ?? selectedFamily?.title)}</strong>{tr(installed?.ready ? null : <>{tr(" \u00B7 approximately ")}{tr(plan?.plan?.download_gb ?? selectedSize?.download_gb)}{tr(" GB")}</>)}</p>{tr(plan?.drafting?.needed ? <p className="small muted">{tr("Choosing DFlash2 also downloads its drafter: about {0} GB, once. If it does not fit in graphics memory, the model uses the MTP instead.", [plan.drafting.gb])}</p> : null)}{tr(installed?.ready ? <p className="small muted">{tr("The model files are already on this computer. Nothing will be downloaded; only the settings you chose are applied.")}</p> : null)}{tr(plan?.plan?.disk_short ? <ErrorNotice message={plan.plan.disk_short}/> : null)}{tr(installed?.ready ? <p className="small muted">{tr("Any currently loaded native models will be unloaded first.")}</p> : <><p className="small muted">{tr("Existing files are checked and reused. Any currently loaded native models will be unloaded before preparing files.")}</p><p className="small muted">{tr("Your computer may feel slower while weights load. You can stop the download and resume later.")}</p></>)}{tr(selectedSize?.images ? <p className="small muted">{tr("Vision encoder files are included. Picture reading loads only when enabled.")}</p> : null)}</Dialog>
<NativeDeleteDialog id={deleting} onClose={() => setDeleting(null)}/>
<Dialog open={manage} title={tr("Manage models")} onClose={() => setManage(false)} actions={<Btn onClick={() => setManage(false)}>{tr("Done")}</Btn>}><div className="native-manage">
{tr(downloaded.length ? <div className="native-manage-list" role="list">{tr(downloaded.map(m => { const v = allSizes.find(x => x.id === m.model), loaded = native?.instances.some(i => i.model === m.model && i.status === 'ready'); return <div className="native-manage-row" role="listitem" key={m.model} data-selected={selectedSize?.id === m.model || undefined}>
@@ -231,4 +242,4 @@ catch (e) {
}
finally {
setStopping(false);
} } return <section className="st-card native-progress" aria-live="polite"><div className="section-toolbar"><h2 className="h4">{tr(job.kind === 'install' ? (label ? tr('Preparing {0}', [label]) : 'Preparing your model') : job.kind === 'load' ? 'Loading model' : job.kind === 'autotune' || job.kind === 'recheck' ? 'Tuning your model' : 'Engine operation')}</h2>{tr(job.kind === 'install' || job.kind === 'load' || job.kind === 'autotune' || job.kind === 'recheck' ? <div className="onboard-progress-actions">{tr(eta ? <span className="small muted">{eta}</span> : null)}<Btn size="sm" disabled={stopping} onClick={() => void stop()}>{tr(stopping ? 'Stopping…' : 'Stop')}</Btn></div> : null)}</div><p className="small">{tr(job.kind === 'install' ? (download && /^5\//.test(job.result?.install?.step ?? '') ? stageText(installStage(job.result?.install)) : installStepLabel(job.result?.install?.step)) : job.summary)}</p>{tr(download ? <><progress max={100} value={download.percent ?? 0}/><p className="xs muted">{tr(download.file === 'model files' ? 'Model files' : download.file === 'picture reader' ? 'Picture reader' : download.file)} · {tr(download.percent ?? 0)}%{tr(Number.isFinite(download.done_gb) && Number.isFinite(download.total_gb) ? ` · ${download.done_gb!.toFixed(1)} / ${download.total_gb!.toFixed(1)} GB` : '')}</p></> : null)}<DownloadParts parts={job.result?.install?.parts}/>{tr(detailsText ? <details><summary className="small">{tr("Details")}</summary><pre className="small muted error-detail-text">{detailsText}</pre></details> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>; }
} } return <section className="st-card native-progress" aria-live="polite"><div className="section-toolbar"><h2 className="h4">{tr(job.kind === 'install' ? (label ? tr('Preparing {0}', [label]) : 'Preparing your model') : job.kind === 'load' ? 'Loading model' : job.kind === 'autotune' || job.kind === 'recheck' ? 'Tuning your model' : 'Engine operation')}</h2>{tr(job.kind === 'install' || job.kind === 'load' || job.kind === 'autotune' || job.kind === 'recheck' ? <div className="onboard-progress-actions">{tr(eta ? <span className="small muted">{eta}</span> : null)}<Btn size="sm" disabled={stopping} onClick={() => void stop()}>{tr(stopping ? 'Stopping…' : 'Stop')}</Btn></div> : null)}</div><p className="small">{tr(job.kind === 'install' ? (job.result?.install?.label ?? (download && /^5\//.test(job.result?.install?.step ?? '') ? stageText(installStage(job.result?.install)) : installStepLabel(job.result?.install?.step))) : job.summary)}</p>{tr(download ? <><progress max={100} value={download.percent ?? 0}/><p className="xs muted">{tr(download.file === 'model files' ? 'Model files' : download.file === 'picture reader' ? 'Picture reader' : download.file === 'checking files' ? 'Checking files' : download.file === 'MTP draft layer' ? 'Draft layer' : download.file)} · {tr(download.percent ?? 0)}%{tr(Number.isFinite(download.done_gb) && Number.isFinite(download.total_gb) ? ` · ${download.done_gb!.toFixed(1)} / ${download.total_gb!.toFixed(1)} GB` : '')}</p></> : null)}<DownloadParts parts={job.result?.install?.parts}/>{tr(detailsText ? <details><summary className="small">{tr("Details")}</summary><pre className="small muted error-detail-text">{detailsText}</pre></details> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>; }
@@ -6,6 +6,7 @@ import { fetchUpdateCheck, publishUpdateCheck, useInstalledVersions, useLastUpda
import { nativeAction, useNative, type NativeJob } from '../lib/native';
import { Btn, Dialog, ErrorNotice, Select } from './ui';
import { NativeProgress } from './NativeModels';
import { jobErrorText } from '../lib/native-job-diagnostics';
export function NativeBenchmark({ open, onClose }: {
open: boolean;
onClose: () => void;
@@ -114,7 +115,7 @@ export function NativeOperations() {
}
return <div className="stack stack--lg native-engine-settings">
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Update component")}</h2>{tr(update === 'available' ? <Btn size="sm" variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => setConfirm(true)}>{tr("Update available")}</Btn> : null)}{tr(update !== 'available' ? <Btn size="sm" disabled={update === 'checking'} onClick={() => void checkForUpdates()}>{tr(update === 'checking' ? 'Checking…' : 'Check for updates')}</Btn> : null)}</div><p className="small muted">{tr("Update the engine for this computer. Your models and settings are kept.")}</p>{tr(update === 'current' ? <p className="small muted">{tr("Up to date")}</p> : null)}{tr(checkError ? <p className="small inline-error" role="alert">{tr(checkError)}</p> : null)}{tr(update === 'available' && (native?.instances.length ?? 0) > 0 ? <p className="small muted">{tr("Open Models and unload the running native models first.")}</p> : null)}</section>
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Diagnostics")}</h2><Btn size="sm" onClick={() => setShowLogs(true)}>{tr("View logs")}</Btn></div><p className="small muted">{tr("Downloads, preparation and engine failures include their original messages here.")}</p>{tr(native?.jobs.filter(j => j.state === 'error').slice(-3).map(j => <ErrorNotice key={j.id} message={j.error ?? j.summary} detail={j.details}/>))}</section>
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Diagnostics")}</h2><Btn size="sm" onClick={() => setShowLogs(true)}>{tr("View logs")}</Btn></div><p className="small muted">{tr("Downloads, preparation and engine failures include their original messages here.")}</p>{tr(native?.jobs.filter(j => j.state === 'error').slice(-3).map(j => <ErrorNotice key={j.id} message={jobErrorText(j, native.jobs)} detail={j.details}/>))}</section>
<Dialog open={showLogs} wide title={tr("Engine logs")} onClose={() => setShowLogs(false)} actions={<><Btn icon="download" disabled={saving} onClick={() => void saveLogs()}>{tr(saving ? 'Working…' : 'Save logs')}</Btn><Btn variant="primary" onClick={() => setShowLogs(false)}>{tr("Close")}</Btn></>}>
<pre className="native-log" aria-label={tr("Engine log")}>{logs.length ? logs.map(l => `${l.at} · ${l.line}`).join(String.fromCharCode(10)) : tr('No engine messages yet.')}</pre>
{tr(saved ? <p className="small" role="status">{tr("Saved to {0}", [saved])}</p> : null)}
@@ -6,6 +6,7 @@ import { pickerGroups, pickerVersionState } from '../lib/model-picker';
import { sizeGb } from '../lib/format';
import { useNative } from '../lib/native';
import { NativeDeleteDialog } from './NativeDelete';
import { ModelsFolderCard } from './ModelsFolderDialog';
import { nativeShortName } from '../lib/native-names';
export function SettingsStorage() {
const state = useStore(s => s);
@@ -77,7 +78,9 @@ export function SettingsStorage() {
setPending(false);
}
}
const downloading = !!native?.jobs.some(j => j.state === 'running' && j.kind !== 'models-move') || !!library?.downloads.active || !!state.imageModels?.variants.some(v => v.state === 'downloading');
return <>
{native?.available && native.downloadDirectory ? <ModelsFolderCard blocked={downloading}/> : null}
<section className="storage-directory" aria-labelledby="storage-directory-title">
<div className="storage-directory__heading"><span><Icon name="folder" size={18}/><strong id="storage-directory-title">{tr("Model directory")}</strong></span><Btn size="sm" disabled={!currentDirectory || directoryPending || pending} onClick={() => { setDirectory(currentDirectory); setEditing(!editing); setDirectoryError(''); setNotice(''); }}>{tr(editing ? 'Cancel' : 'Change directory')}</Btn></div>
<p className="small muted">{tr("Your models are saved here. Use an existing folder to reuse supported downloads.")}</p>
@@ -95,6 +98,7 @@ export function SettingsStorage() {
<div className="row"><Btn variant="primary" type="submit" disabled={directoryPending || directoryBlocked || !directory.trim()}>{tr(directoryPending ? 'Checking files…' : changing && loadedCount > 0 ? 'Unload and change directory' : changing ? 'Use this directory' : 'Check folder')}</Btn></div>
</form> : <Btn size="sm" icon="refresh" disabled={directoryPending || directoryBlocked || !currentDirectory} onClick={() => void saveDirectory(currentDirectory)}>{tr(directoryPending ? 'Checking files…' : 'Check folder')}</Btn>)}
{tr(directoryBlocked && !directoryPending ? <p className="small muted">{tr(!lifecycle.allowed ? lifecycle.reason : 'Finish or cancel downloads and loading to change the directory.')}</p> : null)}
{tr(state.setup?.app.modelsDirNotice && !notice ? <p className="small storage-directory__notice" role="status"><Icon name="info" size={15}/>{tr(state.setup.app.modelsDirNotice)}</p> : null)}
{tr(notice ? <p className="small storage-directory__notice" role="status"><Icon name="check" size={15}/>{tr(notice)}</p> : null)}
{tr(directoryError ? <ErrorNotice message={directoryError}/> : null)}
</section>
+3 -2
View File
@@ -6,6 +6,7 @@ import { useStore } from '../lib/store';
import type { SoftwareUpdate, UpdateCheckResult } from '../lib/types';
import { nativeAction, useNative } from '../lib/native';
import { NativeProgress } from './NativeModels';
import { jobErrorText } from '../lib/native-job-diagnostics';
import { appVersionLabel } from '../lib/app-version';
import { fetchUpdateCheck, publishUpdateCheck, useInstalledVersions, useLastUpdateCheck } from '../lib/update-check';
/** A check from an earlier day shows its date too, because the result is kept between visits. */
@@ -89,12 +90,12 @@ export function SettingsUpdates() {
<Btn variant="primary" disabled={component !== 'engine' || !native?.capabilities.rollback || !!active || (native?.instances.length ?? 0) > 0} onClick={() => void operate('rollback')}>{tr("Restore previous engine")}</Btn>
</div>
</Dialog>
{tr(active ? <NativeProgress job={active}/> : null)}{tr(native?.jobs.filter(j => j.state === 'error' && ['update-strata', 'update-engine', 'rollback'].includes(j.kind)).slice(-1).map(j => <p role="alert" key={j.id} className="small inline-error">{tr(j.error ?? j.summary)}</p>))}
{tr(active ? <NativeProgress job={active}/> : null)}{tr(native?.jobs.filter(j => j.state === 'error' && ['update-strata', 'update-engine', 'rollback'].includes(j.kind)).slice(-1).map(j => <p role="alert" key={j.id} className="small inline-error">{tr(jobErrorText(j, native.jobs))}</p>))}
<Dialog open={!!confirm} compact title={tr(confirm === 'update-strata' ? 'Update Strata?' : 'Update engine?')} onClose={() => setConfirm('')} actions={<><Btn onClick={() => setConfirm('')}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => void operate(confirm)}>{tr("Update")}</Btn></>}><p>{tr("The native installer updates the installation. Model files and settings are kept. Unload native models before updating.")}</p>{tr((native?.instances.length ?? 0) > 0 ? <p className="small muted">{tr("Open Models and unload the running native models first.")}</p> : null)}</Dialog>
</>;
}
/** "Strata 0.2.1 · Engine 0.1.46": the app's own version first, the engine's beside it (About). */
/** "Strata 0.2.3 · Engine 0.1.48": 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);
@@ -50,7 +50,7 @@ export function ChatTelemetry({ session }: {
const approx = m.estimated ? tr('≈ ') : tr('');
const dur = (n: number) => elapsed(n);
return <div className="telemetry xs muted" aria-label={tr("Chat statistics")}>
<Chip icon="gauge" label={tr("{0}{1} tok/s{2}", [approx, speed === null ? tr('—') : Math.round(speed), m.streaming ? tr('') : tr(' · last reply')])} live={around(m.streaming ? "{0}{1} tok/s" : "{0}{1} tok/s · last reply", approx, <Num value={speed} format={int}/>)} title={tr("Session statistics")}>{tr(m.estimated ? <p className="xs muted">{tr("Live estimate from response length and elapsed time. Final usage comes from the engine.")}</p> : null)}<Row label={tr("Thinking time")} value={<Num value={m.thinkMs} format={dur}/>}/><Row label={tr("Writing time")} value={<Num value={m.writeMs} format={dur}/>}/><Row label={tr("Tool time")} value={<Num value={m.toolMs} format={dur}/>}/><Row label={tr("Average time to first token")} value={m.ttft === null ? tr('Not reported') : <Num value={m.ttft} format={dur}/>}/><Row label={tr("Latest response speed")} value={speed === null ? tr('Not reported') : <>{<Num value={speed} format={int}/>}{tr(' tokens/s')}</>}/><Row label={tr("Turns / tool calls")} value={<><Num value={m.turns} format={int}/> / <Num value={m.steps} format={int}/></>}/></Chip>
<Chip icon="gauge" label={tr("{0}{1} tok/s{2}", [approx, speed === null ? tr('—') : Math.round(speed), m.streaming ? tr('') : tr(' · last reply')])} live={around(m.streaming ? "{0}{1} tok/s" : "{0}{1} tok/s · last reply", approx, <Num value={speed} format={int}/>)} title={tr("Session statistics")}>{tr(m.estimated ? <p className="xs muted">{tr("Live estimate from response length and elapsed time. Final usage comes from the engine.")}</p> : null)}<Row label={tr("Thinking time")} value={<Num value={m.thinkMs} format={dur}/>}/><Row label={tr("Writing time")} value={<Num value={m.writeMs} format={dur}/>}/><Row label={tr("Tool time")} value={<Num value={m.toolMs} format={dur}/>}/><Row label={tr("Average time to first token")} value={m.ttft === null ? tr('Not reported') : <Num value={m.ttft} format={dur}/>}/><Row label={tr("Latest response speed")} value={speed === null ? tr('Not reported') : <>{<Num value={speed} format={int}/>}{tr(' tokens/s')}</>}/><Row label={tr("Prompt reading")} value={m.promptSpeed === null ? tr('Not reported') : <>{<Num value={m.promptSpeed} format={int}/>}{tr(' tokens/s')}</>}/><Row label={tr("Turns / tool calls")} value={<><Num value={m.turns} format={int}/> / <Num value={m.steps} format={int}/></>}/></Chip>
<Chip icon="memory" label={tr("{0}{1} total tokens", [approx, compactTokens(m.total)])} live={around("{0}{1} total tokens", approx, <Num value={m.total} format={compactTokens}/>)} title={tr("Token usage")}><Row label={tr("Total in this chat")} value={<Num value={m.total} format={compactTokens}/>}/><Row label={tr("Input tokens")} value={<Num value={m.input} format={compactTokens}/>}/><Row label={tr("Output tokens")} value={<Num value={m.output} format={compactTokens}/>}/></Chip>
<Chip visual={<ContextRing percent={m.pct}/>} label={tr(m.pct === null ? '—' : `${Math.round(m.pct)}% of context`)} live={m.pct === null ? undefined : <><Num value={m.pct} format={int}/>{tr('% of context')}</>} title={tr("Context length")}><Row label={tr("Context used")} value={m.pct === null ? tr('Not reported') : <><Num value={m.pct} format={int}/>%</>}/><div className="context-meter" role="meter" aria-label={tr("Context window used")} aria-valuemin={0} aria-valuemax={100} aria-valuenow={m.pct ?? undefined}><span style={{ width: `${m.pct ?? 0}%` }}/></div><Row label={tr("Current context")} value={<Num value={m.used} format={compactTokens}/>}/><Row label={tr("Context limit")} value={<Num value={m.max} format={compactTokens}/>}/><Row label={tr("Space remaining")} value={m.used !== null && m.max !== null ? <Num value={Math.max(0, m.max - m.used)} format={compactTokens}/> : '—'}/></Chip>
</div>;
+8 -4
View File
@@ -384,7 +384,7 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
</>);
}
/** One modal surface for confirmations and narrow-screen inspectors. */
export function Dialog({ open, title, onClose, children, actions, headerActions, compact = false, sheet = false, wide = false, hideTitle = false, className }: {
export function Dialog({ open, title, onClose, children, actions, headerActions, compact = false, sheet = false, wide = false, hideTitle = false, className, noClose = false }: {
open: boolean;
title: ReactNode;
onClose: () => void;
@@ -396,6 +396,8 @@ export function Dialog({ open, title, onClose, children, actions, headerActions,
wide?: boolean;
hideTitle?: boolean;
className?: string;
/** No close button: the person must use the dialog's own buttons (a move that is running). */
noClose?: boolean;
}) {
const ref = useRef<HTMLElement>(null);
const id = useId();
@@ -418,7 +420,7 @@ export function Dialog({ open, title, onClose, children, actions, headerActions,
return createPortal(<div className="dialog-layer">
<div className="dialog-scrim" onClick={onClose} aria-hidden="true"/>
<section ref={ref} className={cx(`${sheet ? 'dialog-surface dialog-surface--sheet' : 'dialog-surface'}${wide ? ' dialog-surface--wide' : ''}${compact ? ' dialog-surface--compact' : ''}`, className)} role="dialog" aria-modal="true" aria-labelledby={id} tabIndex={-1}>
<header className="dialog-head"><h2 className={hideTitle ? 'sr-only' : 'h3'} id={id}>{tr(title)}</h2><div className="dialog-head__actions">{tr(headerActions)}<IconBtn name="x" title={typeof title === 'string' ? tr("Close {0}", [title]) : tr("Close")} onClick={onClose}/></div></header>
<header className="dialog-head"><h2 className={hideTitle ? 'sr-only' : 'h3'} id={id}>{tr(title)}</h2><div className="dialog-head__actions">{tr(headerActions)}{noClose ? null : <IconBtn name="x" title={typeof title === 'string' ? tr("Close {0}", [title]) : tr("Close")} onClick={onClose}/>}</div></header>
<div className="dialog-body">{tr(children)}</div>
{tr(actions ? <footer className="dialog-actions">{tr(actions)}</footer> : null)}
</section>
@@ -516,18 +518,20 @@ export function MetricGrid({ children, min = 200 }: {
</div>);
}
/** Shared, announced error with a cause and a concrete recovery action. */
export function ErrorNotice({ message, next, action, onRetry, detail }: {
export function ErrorNotice({ message, next, action, onRetry, detail, secondary }: {
message: string;
next?: string;
action?: string;
onRetry?: () => void;
detail?: string;
/** A second way out, beside the first (a context error offers two). */
secondary?: { label: string; onClick: () => void };
}) {
// A known failure is shown as a sentence and a next step; the original text stays under Details for experts.
const plain = plainFailure(message);
const known = plain.kind !== 'plain';
const nextStep = known ? (plain.next ? tr(plain.next) : next) : next;
const raw = detail ?? (known ? plain.detail : undefined);
return <div className="error-notice" role="alert"><Icon name="error" size={18}/><div><strong>{tr(known ? plain.message : userError(message))}</strong>{tr(nextStep ? <p>{tr(nextStep)}</p> : null)}{tr(raw ? <details className="error-detail"><summary className="small">{tr("Details")}</summary><pre className="small muted">{raw}</pre></details> : null)}</div>{tr(onRetry ? <Btn size="sm" onClick={onRetry}>{tr(action ?? 'Try again')}</Btn> : null)}</div>;
return <div className="error-notice" role="alert"><Icon name="error" size={18}/><div><strong>{tr(known ? plain.message : userError(message))}</strong>{tr(nextStep ? <p>{tr(nextStep)}</p> : null)}{tr(raw ? <details className="error-detail"><summary className="small">{tr("Details")}</summary><pre className="small muted">{raw}</pre></details> : null)}</div>{tr(onRetry ? (secondary ? <div className="row"><Btn size="sm" onClick={onRetry}>{tr(action ?? 'Try again')}</Btn><Btn size="sm" variant="ghost" onClick={secondary.onClick}>{tr(secondary.label)}</Btn></div> : <Btn size="sm" onClick={onRetry}>{tr(action ?? 'Try again')}</Btn>) : null)}</div>;
}
export function PreviewBadge() { const demo = useStore(s => s.library?.mode === 'simulated'); return demo ? <span className="preview-badge">{tr("Preview build")}</span> : null; }
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* The version the person sees as "Strata". It is the DESKTOP APP's version (0.2.1), not that of Strata Control, the tools the app runs
* (0.1.45, the version of the Terminal). Showing Control's number as "Strata" made a tester report "Strata 0.1.45" for the 0.2.1 app.
* The version the person sees as "Strata". It is the DESKTOP APP's version (0.2.3), not that of Strata Control, the tools the app runs
* (0.1.45, the version of the Terminal). Showing Control's number as "Strata" made a tester report "Strata 0.1.45" for the 0.2.3 app.
* Without a real app version (a source checkout reports 'dev') the other number is the best there is.
*/
export function appVersionLabel(app: string | null | undefined, control?: string | null): string | undefined {
+9 -3
View File
@@ -13,15 +13,21 @@ export function chatMetrics(session:Session|null, monitor:MonitorState|null, con
const finished=latest?(latest.contextUsed??latest.inputTokens!+(latest.tokens??0)):null;
const liveUsed=active&&Number.isFinite(monitor!.context.usedTokens)&&monitor!.context.usedTokens>0?monitor!.context.usedTokens:null;
const live=replies.some(m=>m.streaming)||(active&&['reading','thinking','generating'].includes(monitor!.phase));
const used=live?(liveUsed??finished):(finished??liveUsed);
// While a turn runs, its own figure comes first: the host reports what the context holds after each model request of the turn. The engine's live
// figure belongs to the request in flight (a small one between two tools read as 1%), and `finished` to an earlier reply.
const turnUsed=replies.find(m=>m.streaming&&Number.isFinite(m.contextUsed)&&m.contextUsed!>0)?.contextUsed??null;
const used=live?(turnUsed??liveUsed??finished):(finished??liveUsed);
const max=remote?(replies.at(-1)?.contextMax||null):configuredContext??(active?monitor!.context.maxTokens:latest?.contextMax??null);
const streaming=replies.find(m=>m.streaming);
const speed=streaming?.tps??[...replies].reverse().find(m=>m.tps!==undefined)?.tps??null;
// Answer speed: everything the model generated over its decode time. While a reply streams it is that reply's; after it, the LAST reply's own
// (never an older reply's number under the words "last reply").
const lastReply=streaming??replies.at(-1);
const speed=lastReply?.tps??null,promptSpeed=lastReply?.promptTps??null;
const ttfts=replies.flatMap(m=>m.ttftMs===undefined?[]:[m.ttftMs]);
const hasUsage=session?.statsAvailable===true;
const llmTotal=replies.reduce((n,m)=>n+(m.durationMs??(m.streaming?Math.max(0,now-(Date.parse(m.at)||now)):0)),0),thinkTotal=replies.reduce((n,m)=>n+(m.reasoningMs??(m.streaming&&m.reasoningStartedAt?Math.max(0,now-m.reasoningStartedAt):0)),0);
return {streaming:!!streaming,writeMs:Math.max(0,llmTotal-thinkTotal),estimated:streaming?.usageSource==='estimated',hasReplies:replies.length>0,example:!!session?.showcase||replies.some(m=>m.usageSource==='demo'),
speed:active&&monitor?.phase==='generating'?monitor.outputTps:speed,
speed:active&&['generating','thinking'].includes(monitor?.phase??'')&&monitor!.outputTps>0?monitor!.outputTps:speed,promptSpeed,
total:session?.statsAvailable===false?null:hasUsage?session.tokensIn+session.tokensOut+(streaming?.tokens??0)+(streaming?.inputTokens??0):replies.every(m=>m.inputTokens!==undefined&&m.tokens!==undefined)?replies.reduce((n,m)=>n+m.inputTokens!+m.tokens!,0):null,
input:hasUsage?session.tokensIn:null,output:hasUsage?session.tokensOut:null,
llmMs:replies.reduce((n,m)=>n+(m.durationMs??(m.streaming?Math.max(0,now-(Date.parse(m.at)||now)):0)),0),
+32
View File
@@ -0,0 +1,32 @@
import { tr } from './i18n';
import { sendOversized, startNewChat, summarizeAndContinue } from './store';
/**
* What the error card of a chat offers when the model's window is the problem. A conversation that no longer fits can be summarised and
* carried on, or left for a new chat; a single message that is too large can go as a file the assistant reads in parts, or as a shortened
* preview. "Try the message again" would only fail the same way, so the card never says it for these.
*/
export function contextRecovery(code: string | undefined): {
next: string;
action?: string;
onRetry?: () => void;
secondary?: { label: string; onClick: () => void };
} {
if (code === 'CONTEXT_MESSAGE_TOO_LARGE') {
return {
next: tr('Attach it as a file for the assistant to read in parts, or send a shortened preview.'),
action: 'Attach as a file',
onRetry: () => void sendOversized('file'),
secondary: { label: 'Send a shortened preview', onClick: () => void sendOversized('preview') },
};
}
if (code === 'CONTEXT_WINDOW_EXCEEDED') {
return {
next: tr('Summarize it now and continue, or start a new chat.'),
action: 'Summarize and continue',
onRetry: () => void summarizeAndContinue(),
secondary: { label: 'Start a new chat', onClick: () => startNewChat() },
};
}
return { next: tr('Try sending the message again.') };
}
+10
View File
@@ -0,0 +1,10 @@
/**
* The Drafting choices the Models screen offers for a model. DFlash2 is for Qwen3.8-Flash-Next only (the qwen and gyro families) AND only while
* the engine serves it: strata_control/drafting.py ENGINE_SERVES_DFLASH2 (Control reports it as `capabilities.dflash2`). Until then the setting
* is Auto / MTP / Off; DFlash2 appears by itself when the engine can run it. The Terminal's --drafting flag has it either way, gated with a note.
*/
export const DRAFTING_FAMILIES = ['qwen', 'gyro'];
export function draftingChoices(family: string | null | undefined, dflash2Served: boolean | null | undefined): string[] {
return DRAFTING_FAMILIES.includes(String(family)) && dflash2Served === true ? ['auto', 'mtp', 'dflash2', 'off'] : ['auto', 'mtp', 'off'];
}
+277
View File
@@ -0,0 +1,277 @@
/**
* Main / Helper / Off for every graphics card. The same rules as strata_control/gpu_roles.py (the Terminal, setup and the server); both run
* tests/data/gpu_roles_vectors.json.
*
* - Main: the card that runs the LAST stage of the layer split (the output head, the draft layer and the check of its guesses). Exactly one.
* - Helper: holds a share of the model, so more of it fits on graphics cards instead of being read from the drive.
* - Off: Strata does not touch it.
*
* The engine takes the cards in stage order (`gpu: [helper, ..., main]`, `gpu_order: "as_given"`), so Main is the LAST one.
*/
export type Role = 'main' | 'helper' | 'off';
export const ROLES: Role[] = ['main', 'helper', 'off'];
export type Roles = Record<number, Role>;
export interface RoleCard {
index: number;
name: string;
vram_gb: number;
/** SMs x clock (or the AMD equivalent): only the order between cards matters. */
speed?: number;
usable?: boolean;
problem?: string | null;
vendor?: string;
uma?: boolean;
integrated?: boolean;
unified_gb?: number;
/** PCI bus id when known: used to notice a renumbering. */
bus?: string;
}
export const OFF_AT_OR_BELOW_GB = 8;
const SAME_SPEED = 1.05;
const MEMORY_GUARD_SPEED = 0.5;
const MIN_CARD_GB = 4;
const SPLIT_CARD_OVERHEAD_GB = 2;
export const CPU_ONLY_LINE = 'Performance is degraded on CPU only.';
export const NO_CARD_LINE = 'This engine needs a graphics card to start. Turn one on.';
const mem = (c: RoleCard) => Number(c.vram_gb) || 0;
const speed = (c: RoleCard) => (typeof c.speed === 'number' && c.speed > 0 ? c.speed : 0);
const shares = (c: RoleCard) => !!(c.integrated || c.uma || c.unified_gb || c.vendor === 'apple');
const canRun = (c: RoleCard) => c.usable !== false;
const rnd = (n: number) => Math.round(n);
export function shortName(c: RoleCard): string {
let n = String(c.name || `GPU ${c.index + 1}`);
for (const junk of ['NVIDIA GeForce ', 'NVIDIA ', 'GeForce ', 'AMD Radeon ', 'AMD ', 'Intel(R) ', 'Intel ']) if (n.startsWith(junk)) { n = n.slice(junk.length); break; }
return n.trim();
}
/** The number the screens show: GPU 1 is card 0. */
export const gpuLabel = (c: RoleCard) => `GPU ${c.index + 1}`;
/** The cards shown as rows: every card the engine's make can name, in card order. */
export function rows<T extends RoleCard>(cards: T[] | null | undefined, vendor?: string | null): T[] {
const list = cards ?? [];
const make = vendor || pickVendor(list);
const mine = make ? list.filter(c => !c.vendor || c.vendor === make) : list;
return [...mine].sort((a, b) => a.index - b.index);
}
function pickVendor(cards: RoleCard[]): string | null {
const makes = new Set(cards.filter(c => canRun(c) && c.vendor).map(c => c.vendor as string));
if (makes.size <= 1) return [...makes][0] ?? null;
return makes.has('nvidia') ? 'nvidia' : cards[0]?.vendor ?? null;
}
function eligible(cs: RoleCard[]): RoleCard[] {
const real = cs.filter(c => canRun(c) && !shares(c) && mem(c) >= MIN_CARD_GB);
return real.length ? real : cs.filter(canRun);
}
export type MainWhy = 'only' | 'faster' | 'same' | 'memory' | 'none';
export function pickMain(cs: RoleCard[]): { main: RoleCard | null; why: MainWhy } {
const pool = eligible(cs);
if (!pool.length) return { main: null, why: 'none' };
if (pool.length === 1) return { main: pool[0], why: 'only' };
const top = Math.max(...pool.map(speed));
const group = top > 0 ? pool.filter(c => speed(c) * SAME_SPEED >= top) : [...pool];
const byMem = (a: RoleCard, b: RoleCard) => rnd(mem(b)) - rnd(mem(a)) || a.index - b.index;
let main = [...group].sort(byMem)[0];
let why: MainWhy = group.length === 1 ? 'faster' : 'same';
if (group.length > 1 && new Set(group.map(c => `${c.name}|${rnd(mem(c))}`)).size === 1) main = [...group].sort((a, b) => b.index - a.index)[0]; // identical cards: the HIGHEST number is Main
if (top <= 0 && new Set(pool.map(c => rnd(mem(c)))).size > 1) why = 'memory';
const bigger = pool.filter(c => rnd(mem(c)) > rnd(mem(main)) && (top <= 0 || speed(c) >= MEMORY_GUARD_SPEED * top));
if (bigger.length) { main = [...bigger].sort(byMem)[0]; why = 'memory'; }
return { main, why };
}
export type OffWhy = 'cannot_run' | 'igpu' | 'small' | 'helper';
export interface Recommendation { roles: Roles; main: number | null; why: MainWhy; reasons: Record<number, MainWhy | OffWhy> }
/** The best card is Main; every other card starts as Helper, EXCEPT a card with 8 GB or less when a bigger card is there (or it has under 4 GB), built-in graphics beside a real card, and a card the engine cannot run: those start Off. */
export function recommend(cards: RoleCard[] | null | undefined, vendor?: string | null): Recommendation {
const cs = rows(cards, vendor);
const { main, why } = pickMain(cs);
const roles: Roles = {}, reasons: Recommendation['reasons'] = {};
// An 8 GB card is a poor Helper beside a bigger card, but a perfectly good one when 8 GB is all there is: two GTX 1080s are a working
// 16 GB pool (#3655). A card with 8 GB or less starts Off only when a bigger usable card is there, or when it is too small to help.
const hasBigger = cs.some(c => canRun(c) && !shares(c) && rnd(mem(c)) > OFF_AT_OR_BELOW_GB);
for (const c of cs) {
const i = c.index;
if (main && c === main) { roles[i] = 'main'; reasons[i] = why; }
else if (!canRun(c)) { roles[i] = 'off'; reasons[i] = 'cannot_run'; }
else if (shares(c) && main) { roles[i] = 'off'; reasons[i] = 'igpu'; }
else if (rnd(mem(c)) <= OFF_AT_OR_BELOW_GB && (hasBigger || mem(c) < MIN_CARD_GB)) { roles[i] = 'off'; reasons[i] = 'small'; }
else { roles[i] = 'helper'; reasons[i] = 'helper'; }
}
return { roles, main: main ? main.index : null, why, reasons };
}
const on = (roles: Roles) => Object.keys(roles).map(Number).filter(i => roles[i] !== 'off');
const byNumber = (roles: Roles) => Object.keys(roles).map(Number).sort((a, b) => a - b);
/** Exactly one Main among the cards that are on; a lone card that is on is Main. */
export function normalize(roles: Roles, cards: RoleCard[]): Roles {
const cs = new Map(cards.map(c => [c.index, c]));
const out: Roles = {};
for (const i of byNumber(roles)) if (cs.has(i)) out[i] = ROLES.includes(roles[i]) ? roles[i] : 'off';
const onList = on(out), mains = onList.filter(i => out[i] === 'main');
if (onList.length && !mains.length) {
const best = [...onList].sort((a, b) => speed(cs.get(b)!) - speed(cs.get(a)!) || rnd(mem(cs.get(b)!)) - rnd(mem(cs.get(a)!)) || a - b)[0];
out[best] = 'main';
}
for (const extra of mains.slice(1)) out[extra] = 'helper';
if (onList.length === 1) out[onList[0]] = 'main';
return out;
}
export interface RoleChange { roles: Roles; note: string | null }
/** One click: the new roles and a code for the quiet line (old_main_helper:<i>, promoted:<i>, only_main). */
export function setRole(roles: Roles, cards: RoleCard[], idx: number, role: Role): RoleChange {
const cs = new Map(cards.map(c => [c.index, c]));
const out: Roles = { ...roles };
let note: string | null = null;
if (!cs.has(idx) || !ROLES.includes(role)) return { roles: out, note };
const ids = byNumber(out);
const fastest = (list: number[]) => [...list].sort((a, b) => speed(cs.get(b)!) - speed(cs.get(a)!) || a - b);
if (role === 'main') {
const old = ids.find(i => i !== idx && out[i] === 'main');
out[idx] = 'main';
if (old !== undefined) { out[old] = 'helper'; note = `old_main_helper:${old}`; }
} else if (role === 'helper') {
if (out[idx] === 'main') {
const others = fastest(ids.filter(i => i !== idx && out[i] === 'helper'));
if (!others.length) return { roles: out, note: 'only_main' };
out[idx] = 'helper'; out[others[0]] = 'main'; note = `promoted:${others[0]}`;
} else out[idx] = 'helper';
} else {
const wasMain = out[idx] === 'main';
out[idx] = 'off';
if (wasMain) {
const next = fastest(ids.filter(i => out[i] === 'helper'));
if (next.length) { out[next[0]] = 'main'; note = `promoted:${next[0]}`; }
}
}
const onList = on(out);
if (onList.length === 1) out[onList[0]] = 'main';
else if (onList.length && !onList.some(i => out[i] === 'main')) out[onList[0]] = 'main';
return { roles: out, note };
}
/** The engine's card list: Helpers (slowest first, ties by number), then the Main card LAST; Off cards left out. */
export function order(roles: Roles, cards: RoleCard[]): number[] {
const cs = new Map(cards.map(c => [c.index, c]));
const r = normalize(roles, cards);
const helpers = byNumber(r).filter(i => r[i] === 'helper').sort((a, b) => speed(cs.get(a)!) - speed(cs.get(b)!) || a - b);
return [...helpers, ...byNumber(r).filter(i => r[i] === 'main')];
}
export interface RoleMirror { gpu_roles: Record<string, Role>; gpu: number | number[]; gpu_order?: 'as_given'; gpu_none?: true; gpu_cards: Record<string, string> }
/** What a writer stores: "gpu_roles" plus the legacy mirror older engines and the Monitor read. Nothing on: "gpu": [] and "gpu_none": true. */
export function mirror(roles: Roles, cards: RoleCard[]): RoleMirror {
const r = normalize(roles, cards), lst = order(r, cards);
const out: RoleMirror = { gpu_roles: Object.fromEntries(byNumber(r).map(i => [String(i), r[i]])), gpu: [], gpu_cards: {} };
if (lst.length) { out.gpu = lst.length === 1 ? lst[0] : lst; out.gpu_order = 'as_given'; } else out.gpu_none = true;
for (const c of cards) if (c.index in r) out.gpu_cards[String(c.index)] = [shortName(c), c.bus ?? ''].filter(Boolean).join('|');
return out;
}
/** Roles of an old "gpu" setting, for display: the LAST card of the engine's order is Main, earlier ones Helpers, unlisted cards Off. */
export function fromLegacy(gpu: unknown, cards: RoleCard[], gpuOrder?: string | null): Roles {
const cs = new Map(cards.map(c => [c.index, c]));
let items: number[] = Array.isArray(gpu) ? gpu.map(Number) : gpu === null || gpu === undefined || gpu === '' ? [] : String(gpu).split(',').filter(x => x.trim() !== '').map(Number);
items = items.filter(i => cs.has(i));
if (!items.length) return { ...recommend(cards).roles };
if (items.length > 1 && gpuOrder !== 'as_given') {
const sp = items.map(i => speed(cs.get(i)!));
if (sp.every(x => x > 0) && Math.max(...sp) >= SAME_SPEED * Math.min(...sp)) items = [...items].sort((a, b) => speed(cs.get(a)!) - speed(cs.get(b)!));
}
const roles: Roles = {};
for (const c of cards) roles[c.index] = 'off';
for (const i of items.slice(0, -1)) roles[i] = 'helper';
roles[items[items.length - 1]] = 'main';
return roles;
}
export const sameRoles = (a: Roles, b: Roles) => { const ka = byNumber(a), kb = byNumber(b); return ka.length === kb.length && ka.every((k, n) => k === kb[n] && a[k] === b[k]); };
export interface RoleWarning { code: 'igpu_main' | 'cannot_run' | 'low_memory' | 'cpu_only'; card: number | null; text: string; fix: { card: number; role: Role } | null }
/** One short line each, only what a person could not know. Never blocking. */
export function warnings(roles: Roles, cards: RoleCard[], opts: { spillGb?: number; engineRunsCpuOnly?: boolean } = {}): RoleWarning[] {
const cs = new Map(cards.map(c => [c.index, c]));
const out: RoleWarning[] = [];
for (const i of byNumber(roles)) {
const c = cs.get(i), role = roles[i];
if (!c || role === 'off') continue;
if (!canRun(c)) out.push({ code: 'cannot_run', card: i, text: `${shortName(c)} cannot run Strata.`, fix: { card: i, role: 'off' } });
else if (role === 'main' && shares(c) && cards.some(x => !shares(x) && canRun(x))) {
const real = cards.filter(x => !shares(x) && canRun(x)).sort((a, b) => speed(b) - speed(a) || mem(b) - mem(a) || a.index - b.index)[0];
out.push({ code: 'igpu_main', card: i, text: `${shortName(c)} is built-in graphics; it is slow as Main.`, fix: { card: real.index, role: 'main' } });
}
}
if (!on(roles).length) out.push({ code: 'cpu_only', card: null, text: opts.engineRunsCpuOnly ? CPU_ONLY_LINE : NO_CARD_LINE, fix: null });
else if ((opts.spillGb ?? 0) >= 1) out.push({ code: 'low_memory', card: null, text: `Not enough memory for this setup: about ${Math.round(opts.spillGb!)} GB is read from the drive.`, fix: null });
return out;
}
/** The graphics memory the cards that are on give for the model (an extra card costs its own dense layers); 0 when all are off. */
export function vramOn(roles: Roles, cards: RoleCard[]): number {
const cs = new Map(cards.map(c => [c.index, c]));
const onCards = on(roles).map(i => cs.get(i)).filter((c): c is RoleCard => !!c && !shares(c));
return Math.max(0, onCards.reduce((n, c) => n + mem(c), 0) - SPLIT_CARD_OVERHEAD_GB * Math.max(0, onCards.length - 1));
}
export function noteText(code: string | null, cards: RoleCard[]): string {
if (!code) return '';
const [kind, arg] = code.split(':');
const c = arg !== undefined ? cards.find(x => x.index === Number(arg)) : undefined;
const name = c ? shortName(c) : 'That card';
if (kind === 'old_main_helper') return `${name} is now a Helper.`;
if (kind === 'promoted') return `${name} is now Main.`;
if (kind === 'only_main') return 'The only card that is on has to be Main.';
return '';
}
/** The one plain line above the cards. */
export function sentence(cards: RoleCard[] | null | undefined, vendor?: string | null): string {
const cs = rows(cards, vendor), rec = recommend(cards, vendor);
if (rec.main === null) return 'No graphics card that Strata can use was found.';
const byi = new Map(cs.map(c => [c.index, c]));
const m = byi.get(rec.main)!;
if (cs.length === 1) return `Strata will use your ${shortName(m)}.`;
const why = { same: 'the cards are alike', memory: 'it has more memory', faster: 'it is faster', only: 'it is the only card', none: '' }[rec.why];
let s = `${shortName(m)} is Main: ${why}.`;
const helpers = byNumber(rec.roles).filter(i => rec.roles[i] === 'helper').map(i => shortName(byi.get(i)!));
if (helpers.length) s += ` ${helpers.join(' and ')} ${helpers.length > 1 ? 'help' : 'helps'}.`;
for (const i of byNumber(rec.roles)) if (rec.roles[i] === 'off') {
const code = rec.reasons[i], c = byi.get(i)!;
s += ` ${shortName(c)} starts Off: ${code === 'cannot_run' ? 'Strata cannot run it.' : code === 'igpu' ? 'built-in graphics.' : `${OFF_AT_OR_BELOW_GB} GB or less.`}`;
}
return s;
}
/** `Main: GPU 2 RTX 4070 Ti. Helper: GPU 1 RTX 3060.` */
export function describe(roles: Roles, cards: RoleCard[]): string {
const cs = new Map(cards.map(c => [c.index, c]));
const parts: string[] = [];
for (const [role, word] of [['main', 'Main'], ['helper', 'Helper'], ['off', 'Off']] as const) {
const names = byNumber(roles).filter(i => roles[i] === role).map(i => (cs.has(i) ? `GPU ${i + 1} ${shortName(cs.get(i)!)}` : `GPU ${i + 1}`));
if (names.length) parts.push(`${word}: ${names.join(', ')}.`);
}
return parts.join(' ');
}
/** A saved "gpu_roles" object -> roles; null when it is not one. */
export function parseSaved(value: unknown): Roles | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const out: Roles = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (!/^\d+$/.test(k.trim()) || !ROLES.includes(String(v).toLowerCase() as Role)) return null;
out[Number(k)] = String(v).toLowerCase() as Role;
}
if (!Object.keys(out).length || Object.values(out).filter(r => r === 'main').length > 1) return null;
return out;
}
+11 -2
View File
@@ -7,6 +7,14 @@ import tune_es from '../locales/tune-es.json';
import tune_ru from '../locales/tune-ru.json';
import tune_pt from '../locales/tune-pt.json';
import tune_ko from '../locales/tune-ko.json';
import tools_en from '../locales/tools-en.json';
import tools_zh from '../locales/tools-zh.json';
import tools_ja from '../locales/tools-ja.json';
import tools_de from '../locales/tools-de.json';
import tools_es from '../locales/tools-es.json';
import tools_ru from '../locales/tools-ru.json';
import tools_pt from '../locales/tools-pt.json';
import tools_ko from '../locales/tools-ko.json';
import base_zh from '../locales/zh.json';
import base_ja from '../locales/ja.json';
import base_de from '../locales/de.json';
@@ -18,8 +26,9 @@ import base_ko from '../locales/ko.json';
export const LANGUAGES=[{value:'en',label:'English'},{value:'zh',label:'中文(简体)'},{value:'ja',label:'日本語'},{value:'de',label:'Deutsch'},{value:'es',label:'Español'},{value:'ru',label:'Русский'},{value:'pt',label:'Português'},{value:'ko',label:'한국어'}] as const;
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. */
const english:Record<string,string>={...baseEnglish,...tune_en};
const [zh,ja,de,es,ru,pt,ko]=[{...base_zh,...tune_zh},{...base_ja,...tune_ja},{...base_de,...tune_de},{...base_es,...tune_es},{...base_ru,...tune_ru},{...base_pt,...tune_pt},{...base_ko,...tune_ko}] as Record<string,string>[];
/* The tool-server, picture and agent-result strings of the tools and images fixes live in tools-<lang>.json for the same reason. */
const english:Record<string,string>={...baseEnglish,...tune_en,...tools_en};
const [zh,ja,de,es,ru,pt,ko]=[{...base_zh,...tune_zh,...tools_zh},{...base_ja,...tune_ja,...tools_ja},{...base_de,...tune_de,...tools_de},{...base_es,...tune_es,...tools_es},{...base_ru,...tune_ru,...tools_ru},{...base_pt,...tune_pt,...tools_pt},{...base_ko,...tune_ko,...tools_ko}] as Record<string,string>[];
const catalogs:Record<Language,Record<string,string>>={en:english,zh,ja,de,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);
+32
View File
@@ -0,0 +1,32 @@
import { rawUrlOf } from './deliverables';
/**
* Which picture addresses a model's Markdown may put in an <img> (tools and images review O3, 2026-10-22).
*
* An answer can carry `![chart](...)`. It is shown only when the picture is already on this computer or inside the message:
* - a data: address of a PNG, JPEG, WebP or GIF (never SVG: an SVG can carry script and styles);
* - the host's own /api/files/raw address (a file inside the chat's folder, served by the host and by nobody else);
* - a path without a scheme, which is read as a file of the chat's folder through that same address.
* Nothing is fetched from the internet by default (an address in the answer would tell its owner that the answer was read, and
* which IP read it): an http(s) picture stays a link the person can open. file:, javascript:, blob: and every other scheme show as text.
*/
export type ImagePlan =
| { kind: 'img'; src: string }
| { kind: 'link'; href: string }
| { kind: 'text' };
const DATA_IMAGE = /^data:image\/(?:png|jpe?g|webp|gif);base64,[A-Za-z0-9+/]+={0,2}$/i;
const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
export function imagePlan(src: string, sessionId?: string | null): ImagePlan {
const value = src.trim();
if (!value) return { kind: 'text' };
if (DATA_IMAGE.test(value)) return { kind: 'img', src: value };
if (/^https?:\/\//i.test(value)) return { kind: 'link', href: value };
if (value.startsWith('/api/files/raw?')) return { kind: 'img', src: value };
// a drive letter (C:\x.png, C:/x.png) is a path, not a scheme
if (SCHEME.test(value) && !/^[a-z]:[\\/]/i.test(value)) return { kind: 'text' };
if (value.startsWith('//') || value.startsWith('\\\\')) return { kind: 'text' }; // another computer's share
if (!sessionId) return { kind: 'text' };
return { kind: 'img', src: rawUrlOf(sessionId, value) };
}
+5
View File
@@ -0,0 +1,5 @@
/** The one rule for the name of a tool server. A copy of server/mcp-names.mjs (tests/mcp-rows.test.mjs checks that they agree). */
export const MCP_NAME_MAX = 32;
export const MCP_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/;
export const validMcpName = (name: string) => MCP_NAME_RE.test(name);
export const MCP_NAME_HELP = 'Use a simple server name of up to 32 characters: letters, numbers, hyphens or underscores.';
@@ -0,0 +1,22 @@
import type { NativeJob } from './native';
/** What the host says about a job a restart cut short. */
export const INTERRUPTED = 'The host restarted before completion. Check diagnostics, then retry; completed files are kept.';
const updateKinds = new Set(['update-strata', 'update-engine']);
/**
* A later finished update of the same component settles the restart interruption of an earlier one (ported from #3578 by medking82).
* The records are never changed: a running retry, another component or an ordinary failure stays a plain failure.
*/
export function recoveredInterruptions(jobs: readonly NativeJob[]): Set<string> {
const time = (job: NativeJob) => Date.parse(job.startedAt);
return new Set(jobs.filter(job => job.state === 'error' && updateKinds.has(job.kind) && job.error === INTERRUPTED && Number.isFinite(time(job))
&& jobs.some(later => later.state === 'done' && later.kind === job.kind && Number.isFinite(time(later)) && time(later) > time(job))).map(job => job.id));
}
/** The text of a failed job's alert. The alert stays red; an interruption a later update has settled says so in plain words. */
export function jobErrorText(job: NativeJob, jobs: readonly NativeJob[]): string {
return recoveredInterruptions(jobs).has(job.id)
? 'This update was cut short when the app restarted. A later update finished, so nothing needs doing.'
: job.error ?? job.summary;
}
+1 -1
View File
@@ -12,7 +12,7 @@ export interface NativeState {
/** 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};
visionReader?: boolean;available:boolean;repo:string;downloadDirectory?:string;version?:string|null;summary:string;error:string|null;catalog:{families:NativeFamily[];contexts:number[];recommendation:{family:string|null;model:string|null;context?:number;why?:string};context_note:string}|null;installed:{models:{model:string;config:string;model_name:string;ready:boolean;/** The picture-reading files are on this computer (notes-split-agent8.md, item 8). */visionPrepared?:boolean;context?:number;kv?:string;images?:string;visionEnabled?:boolean;visionDevice?:string;lowRamMode?:string;resourceProfile?:'work'|'performance';dataFolder?:string;diskGb?:number;diskBytes?:number;/** Files this model shares with another (the picture reader, the draft layer): counted once, in `usage`. */sharedBytes?:number;/** What Delete frees: files in Strata's own folders that no other model uses. */removableGb?:number;missing_files?:string[]}[];/** What all models take on disk, from their real files, shared files counted once. */usage?:{total_bytes:number;total_gb:number;shared_bytes:number;shared_gb:number;models:Record<string,{bytes:number;gb:number}>}|null;data_folder:string;engine:{version:string|null}}|null;hardware:{ram_gb:number;engine_vendor?:string;gpus:{index:number;name:string;vram_gb:number;usable:boolean;vendor?:string;uma?:boolean;integrated?:boolean;unified_gb?:number;problem?:string|null}[]}|null;fields:NativeField[];instances:NativeInstance[];defaultId:string|null;jobs:NativeJob[];capabilities:{responses?:boolean;updates:boolean;rollback:boolean;calibration:boolean;componentUpdates?:boolean}}
visionReader?: boolean;available:boolean;repo:string;downloadDirectory?:string;/** Free bytes on the drive that holds the models folder. */downloadFreeBytes?:number|null;downloadTotalBytes?:number|null;version?:string|null;summary:string;error:string|null;catalog:{families:NativeFamily[];contexts:number[];recommendation:{family:string|null;model:string|null;context?:number;why?:string};context_note:string}|null;installed:{models:{model:string;config:string;model_name:string;ready:boolean;/** The picture-reading files are on this computer (notes-split-agent8.md, item 8). */visionPrepared?:boolean;context?:number;kv?:string;images?:string;visionEnabled?:boolean;visionDevice?:string;lowRamMode?:string;resourceProfile?:'work'|'performance';/** The Drafting choice saved with the model: auto | mtp | dflash2 | off. */drafting?:string;dataFolder?:string;diskGb?:number;diskBytes?:number;/** Files this model shares with another (the picture reader, the draft layer): counted once, in `usage`. */sharedBytes?:number;/** What Delete frees: files in Strata's own folders that no other model uses. */removableGb?:number;missing_files?:string[]}[];/** What all models take on disk, from their real files, shared files counted once. */usage?:{total_bytes:number;total_gb:number;shared_bytes:number;shared_gb:number;models:Record<string,{bytes:number;gb:number}>}|null;data_folder:string;engine:{version:string|null}}|null;hardware:{ram_gb:number;engine_vendor?:string;gpus:{index:number;name:string;vram_gb:number;usable:boolean;/** SMs x clock: only the order between cards matters (strata_control/gpu_roles.py). */speed?:number;vendor?:string;uma?:boolean;integrated?:boolean;unified_gb?:number;problem?:string|null}[]}|null;fields:NativeField[];instances:NativeInstance[];defaultId:string|null;jobs:NativeJob[];capabilities:{/** The engine serves DFlash2 (Control says): the Drafting choice then offers it. */dflash2?:boolean;responses?:boolean;updates:boolean;rollback:boolean;calibration:boolean;componentUpdates?:boolean}}
let state:NativeState|null=null;
const listeners=new Set<()=>void>();
function put(value:NativeState){if(state&&JSON.stringify(state)===JSON.stringify(value))return;state=value;listeners.forEach(fn=>fn());}
+5 -2
View File
@@ -8,13 +8,15 @@ import { tr } from './i18n';
export interface PlainFailure { message: string; next?: string; detail?: string; kind: string }
interface Rule { kind: string; match: RegExp; message: string; next?: string; detail?: (m: RegExpMatchArray, raw: string) => string | undefined }
interface Rule { kind: string; match: RegExp; message: string | ((m: RegExpMatchArray) => string); next?: string; detail?: (m: RegExpMatchArray, raw: string) => string | undefined }
const MEMORY = 'The model stopped while starting, most likely because the PC ran low on memory.';
const MEMORY_NEXT = 'Close other programs and try again, or choose “Leave 20% for my work”. In Expert mode you can also turn on the low-RAM option, so part of the model is read from the SSD.';
/** Order matters: the first rule that matches wins. */
const RULES: Rule[] = [
// The host names the files it could not find (#3478). The sentence with the names is a template in every language: tr() fills it.
{ kind: 'model-files-missing', match: /Some model files are missing: (.+?)\. Use Download/i, message: m => `These model files are missing: ${m[1]}.`, next: '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.' },
{ kind: 'tunnel-own-running', match: /Strata’s tunnel for this address is already running \(PID/i, message: 'Strata’s tunnel for this address is already running.', next: 'Stop it first, or use it as it is, then connect again here.' },
{ kind: 'gpu-shared', match: /GPU \d+ is already used by .*, so this model did not fit there/i, message: 'Another model on the same graphics card left no room for this one.', next: 'Load it on a different GPU, or unload the other model first.' },
{ kind: 'gpu-no-code', match: /none of this computer.s graphics cards has code in this Strata engine|not an architecture this Strata engine was compiled for/i, message: 'This Strata engine has no code for the graphics card it tried to use.', next: 'Pick another card under Run on, or open Details to see which cards were found and what the engine is built for.' },
@@ -34,6 +36,7 @@ const RULES: Rule[] = [
{ kind: 'blocked', match: /0xC0000022|3221225506|\bEACCES\b|\bEPERM\b|operation not permitted/i, message: 'Windows or your security software stopped the engine from starting.', next: 'Allow Strata in your security software, then try again.' },
{ kind: 'file-missing', match: /\bENOENT\b|no such file or directory|cannot find the (?:file|path)|0x80070002|exit code 127|file not found/i, message: 'Strata could not find a file it needs to start the engine.', next: 'Reinstall Strata or press Repair.' },
{ kind: 'memory', match: /out of memory|memory guard|\boom\b|\bkilled\b|exit code 137|cannot allocate memory|not enough memory|bad_alloc|pinned-memory-exhausted|mapped staging|cudaErrorMemoryAllocation|insufficient memory/i, message: MEMORY, next: MEMORY_NEXT },
{ kind: 'previous-session', match: /lost host child is still present|retry after it exits/i, message: 'The last Strata session is still closing down.', next: 'Wait a moment and try again. If it keeps happening, restart the computer.' },
{ kind: 'start-failed', match: /server_exited|engine stopped|exited before|died before/i, message: 'The model stopped while starting.', next: 'Try again. If it keeps happening, open Details and share them when you ask for help.' },
{ kind: 'slow-load', match: /loading took more than/i, message: 'Loading is taking too long.', next: 'The PC may be short on memory. Close other programs and try again.' },
{ kind: 'engine-missing', match: /no engine for this pc|engine component|never downloads an engine|engine .*not installed|no strata engine/i, message: 'Strata’s engine for this computer isn’t installed yet.', next: 'It is added when you download a model. Download your model again to add it.' },
@@ -50,7 +53,7 @@ export function plainFailure(raw: string | null | undefined): PlainFailure {
for (const rule of RULES) {
const m = text.match(rule.match);
if (!m) continue;
return { kind: rule.kind, message: rule.message, next: rule.next, detail: rule.detail ? rule.detail(m, text) : text };
return { kind: rule.kind, message: typeof rule.message === 'function' ? rule.message(m) : rule.message, next: rule.next, detail: rule.detail ? rule.detail(m, text) : text };
}
return { kind: 'plain', message: text.replace(/^./, c => c.toUpperCase()) };
}
+30 -2
View File
@@ -74,7 +74,7 @@ function normalise(loc: Location): void {
* Navigate, morphing between screens when the browser can.
*
* A view transition needs the DOM update to happen inside its callback, and
* React commits asynchronously — so we wait two frames before resolving. It is
* React commits asynchronously — so we wait for the commit (never for a frame: see afterRouteUpdate). It is
* skipped while a response is streaming (the transcript is being appended to on
* every token, and snapshotting it mid-stream would fight the SSE updates) and
* when the user has asked for reduced motion.
@@ -100,6 +100,34 @@ export function isWebKitGtk(ua: string = navigator.userAgent): boolean {
return /AppleWebKit/.test(ua) && /Linux|X11/.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|Android/.test(ua);
}
/**
* Resolve once the new route has been drawn into the page, or after a short deadline, whichever comes first.
*
* The update callback of a view transition must not wait for requestAnimationFrame: while the callback is pending the browser holds
* rendering (Chromium and Firefox both do), so a frame callback never runs, and every click waits for the browser's own guard,
* about 4 s in Chromium and the WebView2 window, about 5 s in Firefox (#3673). A DOM change after the hash flipped means React has
* committed the new screen; the deadline covers a route that changes nothing visible.
*/
export function afterRouteUpdate(done: () => void, timeoutMs = 150): void {
let settled = false;
let observer: MutationObserver | undefined;
const finish = () => {
if (settled) return;
settled = true;
observer?.disconnect();
done();
};
try {
if (typeof MutationObserver === 'function' && typeof document !== 'undefined' && document.body) {
observer = new MutationObserver(() => setTimeout(finish, 0));
observer.observe(document.body, { childList: true, subtree: true });
}
} catch {
/* the deadline alone is enough */
}
setTimeout(finish, timeoutMs);
}
function navigateHash(next: string, morph = true) {
if (window.location.hash === next) return;
@@ -121,7 +149,7 @@ function navigateHash(next: string, morph = true) {
() =>
new Promise<void>((resolve) => {
window.location.hash = next;
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
afterRouteUpdate(resolve);
}),
);
// A transition is *expected* to be skipped when one is already running (rapid
+110
View File
@@ -0,0 +1,110 @@
import type { Session } from './types';
/**
* Saving the chat list without hurting the page (#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:
*
* 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.
*/
/** 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;
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 JSON text kept for one chat: whole when it fits, otherwise clipped and then shortened from the oldest message. */
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 text;
}
export interface SessionWriterOptions {
/** Where the list is saved (the storage write). Receives the finished JSON text. */
write: (json: string) => void;
delayMs?: number;
maxTotalChars?: number;
/** Counts a serialisation of one chat; for tests. */
onSerialize?: (session: Session) => void;
}
export function createSessionWriter(options: SessionWriterOptions) {
const delayMs = options.delayMs ?? 1_000;
const maxTotal = options.maxTotalChars ?? MAX_TOTAL_CHARS;
let timer: ReturnType<typeof setTimeout> | null = null;
let pending: Session[] | null = null;
// Chat object -> its JSON. WeakMap: a replaced chat is forgotten with its object.
const cache = new WeakMap<Session, string>();
const textOf = (s: Session): string => {
let text = cache.get(s);
if (text === undefined) {
options.onSerialize?.(s);
text = serializeSession(s);
cache.set(s, text);
}
return text;
};
const flush = () => {
if (timer !== null) { clearTimeout(timer); timer = null; }
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;
}
options.write(`[${parts.map(p => p.text).join(',')}]`);
};
const schedule = (sessions: Session[]) => {
pending = sessions;
if (timer === null) timer = setTimeout(flush, delayMs);
};
return { schedule, flush, hasPending: () => pending !== null };
}
+92 -9
View File
@@ -19,6 +19,7 @@ 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 { setTranslationLanguage, tr, validLanguage, type Language } from './i18n';
/**
* Strata app store.
@@ -1008,12 +1009,33 @@ export async function scopeSkill(id: string, workspaceId: string | null) {
/* ---------------------------------------------------------------- sessions */
function persistSessions(sessions: Session[]) {
try {
localStorage.setItem(SESSION_KEY, JSON.stringify(sessions));
} catch {
/* quota — sessions are a cache, not the source of truth */
}
const sessionWriter = createSessionWriter({
write: (json) => {
try {
localStorage.setItem(SESSION_KEY, json);
} catch {
/* quota — sessions are a cache, not the source of truth */
}
},
});
/**
* Save the chat list. A one-off change (new, rename, delete, pin) is written 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();
}
/** Write the newest chat list now: a turn ended, or the page is going away. */
export function flushSessions() {
sessionWriter.flush();
}
if (typeof window !== 'undefined' && typeof document !== 'undefined' && window.addEventListener) {
window.addEventListener('pagehide', flushSessions);
document.addEventListener?.('visibilitychange', () => { if (document.visibilityState === 'hidden') flushSessions(); });
}
export function newSession(): Session {
@@ -1120,7 +1142,7 @@ export function renameSession(id: string, title: string) {
function patchSession(id: string, fn: (s: Session) => Session) {
const sessions = state.sessions.map((s) => (s.id === id ? fn(s) : s));
persistSessions(sessions);
persistSessions(sessions, true);
set({ sessions });
}
@@ -1366,6 +1388,55 @@ export async function retryLast(messageId?: string) {
}
}
/** The open chat's last question and the answer that failed under it (null when the chat does not end that way). */
function failedExchange(): { prompt: string; sessionId: string } | null {
const sessionId = state.activeSession;
const chat = state.sessions.find(s => s.id === sessionId);
if (!sessionId || !chat || state.streaming) return null;
const reply = chat.messages[chat.messages.length - 1], ask = chat.messages[chat.messages.length - 2];
return reply?.role === 'assistant' && reply.error && ask?.role === 'user' ? { prompt: ask.content, sessionId } : null;
}
/** Takes the failed question and its error out of the transcript, so asking again does not show the question twice. */
function dropFailedExchange(sessionId: string) {
patchSession(sessionId, s => ({ ...s, messages: s.messages.slice(0, -2) }));
}
/**
* The error card of a conversation that no longer fits the model's window: summarise the older messages (the same as typing /compact), then
* ask the assistant to answer. The refused question is already in the assistant's conversation (it is the newest message, and a summary keeps
* the newest message as it is), so it is not sent a second time: a large one twice would not fit either.
*/
export async function summarizeAndContinue() {
if (!failedExchange()) return;
await sendMessage('/compact');
await sendMessage('Please answer my last message now.');
}
/**
* The error card of a message that is too large to send: the host makes it small enough (its text as a file in the chat's folder that the
* assistant reads in parts, or a shortened preview), and that is sent in its place.
*/
export async function sendOversized(mode: 'file' | 'preview') {
const failed = failedExchange();
if (!failed) return;
let reply: { message?: string };
try {
reply = await api.post<{ message?: string }>('/api/chat/oversize', { sessionId: failed.sessionId, mode, text: failed.prompt, nativeModelId: nativeState()?.instances.length ? nativeState()?.defaultId : undefined });
} catch {
pushToast('error', 'The message could not be prepared. Try a shorter message.');
return;
}
if (!reply.message) return;
dropFailedExchange(failed.sessionId);
await sendMessage(reply.message);
}
/** A new, empty chat, opened. */
export function startNewChat() {
const session = newSession();
go('chat', session.id);
}
/**
* Forks a conversation at a message, leaving the original untouched.
*
@@ -1731,10 +1802,17 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
...m,
tokens: typeof frame.tokensOut === 'number' ? frame.tokensOut : m.tokens,
tps: typeof frame.tps === 'number' ? frame.tps : m.tps,
promptTps: typeof frame.promptTps === 'number' ? frame.promptTps : m.promptTps,
}));
} else if (type === 'context') {
// What the context holds after each model request of an agent turn: the meter follows the turn instead of waiting for its end.
const used = Number(frame.contextTokens), max = Number(frame.contextMax);
if (Number.isFinite(used) && used > 0) patchAssistant((m) => ({ ...m, contextUsed: used, ...(Number.isFinite(max) && max > 0 ? { contextMax: max } : {}) }));
} else if (type === 'error') {
clearStatus(sessionId);
patchAssistant((m) => ({ ...m, error: plainFailure(String(frame.message ?? 'Engine error')).message, streaming: false }));
// A sentence the host wrote for the person (it carries its catalog key) is shown in their language as it is; anything else is made plain here.
const said = typeof frame.key === 'string' ? tr(frame.key, Array.isArray(frame.values) ? frame.values : undefined) : plainFailure(String(frame.message ?? 'Engine error')).message;
patchAssistant((m) => ({ ...m, error: said, streaming: false, ...(typeof frame.code === 'string' ? { errorCode: frame.code } : {}), ...(typeof frame.details === 'string' && frame.details ? { errorDetail: frame.details } : {}) }));
} else if (type === 'done') {
clearStatus(sessionId);
patchSession(sessionId, (sess) => ({
@@ -1752,8 +1830,11 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
inputTokens: typeof frame.tokensIn === 'number' ? frame.tokensIn : undefined,
contextUsed: typeof frame.contextTokens === 'number' ? frame.contextTokens : undefined,
usageSource: frame.usageSource === 'reported' || frame.usageSource === 'demo' ? frame.usageSource : 'unavailable',
tps: typeof frame.tps === 'number' ? frame.tps : undefined,
tps: typeof frame.tps === 'number' ? frame.tps : m.tps,
promptTps: typeof frame.promptTps === 'number' ? frame.promptTps : m.promptTps,
}));
// The final text is in `state`: write it once now instead of waiting for the coalescing timer.
flushSessions();
}
},
turnAbort.signal,
@@ -1770,6 +1851,8 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
patchAssistant((m) => ({ ...m, streaming: false }));
}
} finally {
// A cancelled or failed turn is still worth keeping.
flushSessions();
clearStatus(sessionId);
markTurn(sessionId, null);
const next = releasedMessages.get(sessionId)?.shift();
+8 -3
View File
@@ -223,11 +223,11 @@ export interface ServeRequest {
finishReason?: string | null;
}
export interface TunnelState { status: 'idle' | 'starting' | 'connected' | 'stopping' | 'error'; mode: 'quick' | 'domain' | null; url: string | null; error: string | null; installed: boolean; originUrl: string; connector?: { path: string; version: string | null; source: string; label: string; summary: string } | null; external?: { kind: 'service' | 'process'; others: number[]; own: number[]; message: string } | null; }
export interface TunnelState { status: 'idle' | 'starting' | 'connected' | 'stopping' | 'error'; mode: 'quick' | 'domain' | null; url: string | null; error: string | null; warning?: string | null; installed: boolean; originUrl: string; connector?: { path: string; version: string | null; source: string; label: string; summary: string } | null; external?: { kind: 'service' | 'process'; others: number[]; own: number[]; message: string } | null; }
export interface ServingState {
tunnel?: TunnelState;
sharing?: { active: boolean; url: string | null };
sharing?: { active: boolean; url: string | null; warning?: string | null };
revision?: number;
running: boolean;
baseUrl: string;
@@ -355,8 +355,13 @@ export interface ChatMessage {
reasoningOpen?: boolean;
tokens?: number;
tps?: number;
promptTps?: number;
durationMs?: number;
error?: string;
/** What went wrong, as a code the page acts on (a context error offers its own recovery buttons). */
errorCode?: string;
/** The engine's own text of the error, kept behind Details. */
errorDetail?: string;
tools?: ToolCall[];
}
@@ -494,7 +499,7 @@ export interface SetupState {
step: number;
completedAt?: string;
history: { step: number; at: string }[];
app: { version: string; modelsDir: string; dataDir: string };
app: { version: string; modelsDir: string; dataDir: string; modelsDirNotice?: string };
}
export interface EngineEvent {
+6 -4
View File
@@ -7,8 +7,8 @@ import type { UpdateCheckResult } from './types';
*/
const KEY = 'strata.update-check.v1';
interface Stored { result: UpdateCheckResult; appVersion: string | null; engineVersion: string | null }
export interface InstalledVersions { appVersion?: string | null; engineVersion?: string | null }
interface Stored { result: UpdateCheckResult; appVersion: string | null; engineVersion: string | null; controlVersion?: string | null }
export interface InstalledVersions { appVersion?: string | null; engineVersion?: string | null; controlVersion?: string | null }
const same = (a: string | null | undefined, b: string | null | undefined) => (a ?? null) === (b ?? null);
@@ -16,13 +16,15 @@ export function readUpdateCache(now: InstalledVersions): UpdateCheckResult | nul
try {
const stored = JSON.parse(localStorage.getItem(KEY) ?? 'null') as Stored | null;
if (!stored?.result?.strata?.status || !stored.result.engine?.status || !stored.result.checkedAt) return null;
if (!same(stored.appVersion, now.appVersion) || !same(stored.engineVersion, now.engineVersion)) return null;
// Still loading (no app version yet): show nothing rather than a result that may belong to the version before an update.
if (!now.appVersion) return null;
if (!same(stored.appVersion, now.appVersion) || !same(stored.engineVersion, now.engineVersion) || !same(stored.controlVersion, now.controlVersion)) return null;
return stored.result;
} catch { return null; }
}
export function saveUpdateCache(result: UpdateCheckResult, now: InstalledVersions) {
try { localStorage.setItem(KEY, JSON.stringify({ result, appVersion: now.appVersion ?? null, engineVersion: now.engineVersion ?? null } satisfies Stored)); } catch { /* Not remembering a result is harmless. */ }
try { localStorage.setItem(KEY, JSON.stringify({ result, appVersion: now.appVersion ?? null, engineVersion: now.engineVersion ?? null, controlVersion: now.controlVersion ?? null } satisfies Stored)); } catch { /* Not remembering a result is harmless. */ }
}
export function clearUpdateCache() {
+1 -1
View File
@@ -10,7 +10,7 @@ export function useInstalledVersions(): InstalledVersions {
const native = useNative();
const engineVersion = useStore(s => s.engine?.engine.version);
const appVersion = appVersionLabel(useStore(s => s.setup?.app.version), native?.version);
return { appVersion: appVersion ?? null, engineVersion: (native?.available ? native.installed?.engine?.version : engineVersion) ?? null };
return { appVersion: appVersion ?? null, engineVersion: (native?.available ? native.installed?.engine?.version : engineVersion) ?? null, controlVersion: native?.version ?? null };
}
/** The one place the app asks the host to check for updates. Called only from a button press. */
+181 -1
View File
@@ -3346,5 +3346,185 @@
"s8257": "alle {0} Min.",
"s8258": "Diese Strata-Engine hat keinen Code für die Grafikkarte, die sie verwenden wollte.",
"s8259": "Wähle unter „Ausführen auf“ eine andere Karte, oder öffne die Details: Dort steht, welche Karten gefunden wurden und wofür die Engine gebaut ist.",
"s8260": "Dieser Schalter bewirkt in der App nichts. Webseiten beliebiger Seiten können die unter „API-Server“ gezeigte API schon aufrufen. Um nur bestimmte Seiten zuzulassen oder den eigenen Server der Engine (Port 8080) zu nutzen, tragen Sie \"cors_origins\": [\"https://ihre.seite\"] in die Konfigurationsdatei des Modells ein."
"s8260": "Dieser Schalter bewirkt in der App nichts. Webseiten beliebiger Seiten können die unter „API-Server“ gezeigte API schon aufrufen. Um nur bestimmte Seiten zuzulassen oder den eigenen Server der Engine (Port 8080) zu nutzen, tragen Sie \"cors_origins\": [\"https://ihre.seite\"] in die Konfigurationsdatei des Modells ein.",
"s8301": "API-Schlüssel erstellen (empfohlen)",
"s8302": "Ohne API-Schlüssel kann jedes Gerät in diesem Netzwerk Ihr Modell nutzen.",
"s8303": "Es wird nur die Inferenz-API freigegeben. Clients brauchen Ihren API-Schlüssel, sofern einer gesetzt ist. Nutzen Sie ein vertrauenswürdiges lokales Netzwerk; diese Verbindung verwendet HTTP.",
"s8304": "Erstellen Sie einen Schlüssel, um Ihre API zu schützen. Danach kehren Sie hierher zurück. Sie können auch ohne Schlüssel fortfahren, dann kann aber jeder, der den Link findet, Ihr Modell nutzen.",
"s8305": "Sie haben schon ein unterstütztes Modell auf diesem Computer? Wählen Sie den Ordner mit den Dateien oder eine der .gguf-Dateien. Strata prüft sie, bereitet das Modell vor und lädt es. Es wird nichts kopiert oder heruntergeladen.",
"s8306": "Ordner mit den GGUF-Dateien oder eine .gguf-Datei",
"s8307": "Vollständiger Pfad zum Ordner oder zur Datei",
"s8308": "Ohne API-Schlüssel kann jeder, der diese Adresse erreicht, Ihr Modell nutzen.",
"s8309": "Strata konnte {0} nicht für Modelle verwenden ({1}); die Modelle werden daher in {2} abgelegt. Sie können in den Einstellungen einen anderen Ordner wählen.",
"s8310": "Port {0} wird bereits verwendet. Strata läuft vielleicht schon; schließen Sie es oder das andere Programm, oder starten Sie Strata mit einem anderen Port (STRATA_PORT).",
"s8311": "Windows oder eine Firewall erlaubt Strata nicht, Port {0} zu verwenden. Hyper-V, WSL und Docker reservieren Portbereiche für sich (siehe \"netsh int ipv4 show excludedportrange protocol=tcp\"). Starten Sie Strata mit einem anderen Port (STRATA_PORT), zum Beispiel 8801.",
"s8312": "{0} ist keine Adresse dieses Computers, daher kann Strata dort nicht lauschen. Prüfen Sie STRATA_BIND.",
"s8313": "Strata kann auf {0}:{1} nicht lauschen ({2}).",
"s8339": "Modellordner",
"s8340": "Strata speichert die großen Modelldateien hier.",
"s8341": "Ordner ändern",
"s8342": "Warte auf den Ordner …",
"s8343": "Speicherplatz belegt",
"s8344": "{0} frei",
"s8345": "auf diesem Laufwerk",
"s8346": "Ihre Modelle belegen {0}",
"s8347": "Beenden oder stoppen Sie den Download, um den Ordner zu ändern.",
"s8348": "Diesen Ordner gibt es nicht",
"s8349": "Dieser Ordner kann nicht verwendet werden",
"s8350": "Strata kann in {0} keine Dateien speichern.",
"s8351": "Strata kann {0} nicht finden.",
"s8352": "Wählen Sie einen Ordner außerhalb des aktuellen.",
"s8353": "Wählen Sie einen Ordner, der Ihnen gehört, zum Beispiel in Dokumente oder auf einem anderen Laufwerk.",
"s8354": "Prüfen Sie, ob das Laufwerk angeschlossen ist, und wählen Sie den Ordner erneut. Ihre Modelle wurden nicht verändert.",
"s8355": "Diesen Ordner für Ihre Modelle verwenden?",
"s8356": "Neuer Ordner",
"s8357": "{0} frei auf diesem Laufwerk",
"s8358": "Sie haben das ganze Laufwerk {0} gewählt. Strata legt seine Dateien in einem eigenen Ordner ab, Ihre Modelle liegen daher in {1}.",
"s8359": "Als Nächstes folgen die drei Möglichkeiten.",
"s8360": "Meine Modelle verschieben",
"s8361": "Beide Ordner verwenden",
"s8362": "Leer beginnen",
"s8363": "Ihre Modelle werden verschoben",
"s8364": "Verschieben stoppen",
"s8365": "{0} von {1} GB",
"s8366": "Weniger als eine Minute übrig",
"s8367": "Etwa eine Minute übrig",
"s8368": "Etwa {0} Minuten übrig",
"s8369": "Etwa {0} Stunden übrig",
"s8370": "Lassen Sie Strata geöffnet. Andere Apps können Sie weiter benutzen. Ihre Modelle lassen sich erst laden, wenn das Verschieben fertig ist.",
"s8371": "Kopiert gerade: {0}, Datei {1} von {2}",
"s8372": "Von {0} nach {1}",
"s8373": "Meine Modelle dorthin verschieben",
"s8374": "Sie haben keine Modelle zum Verschieben.",
"s8375": "Strata verschiebt Ihre {0} Modelle in den neuen Ordner. Es ist dasselbe Laufwerk, das geht also schnell.",
"s8376": "Strata kopiert Ihre {0} Modelle in den neuen Ordner, prüft sie und entfernt dann die alten Kopien.",
"s8377": "Braucht keinen zusätzlichen Platz.",
"s8378": "Benötigt {0} freien Platz im neuen Ordner. Verfügbar: {1}.",
"s8379": "Am bisherigen Ort lassen und zusätzlich den neuen Ordner verwenden",
"s8380": "Es wird nichts kopiert. Ihre aktuellen Modelle funktionieren weiter, neue Downloads landen im neuen Ordner.",
"s8381": "Braucht jetzt keinen Platz. Neue Downloads müssen in {1} passen.",
"s8382": "Braucht jetzt keinen Platz.",
"s8383": "Strata verwendet ab jetzt den neuen Ordner und zeigt Ihre aktuellen Modelle nicht mehr an. Ihre alten Dateien werden nicht gelöscht.",
"s8384": "Braucht jetzt keinen Platz. Jedes später geladene Modell braucht eigenen Platz.",
"s8385": "Ihre Modelle brauchen {0}, auf diesem Laufwerk sind {1} frei.",
"s8386": "Geben Sie dort etwa {0} frei, wählen Sie einen anderen Ordner oder nehmen Sie eine der anderen beiden Möglichkeiten.",
"s8387": "Was soll mit Ihren Modellen geschehen",
"s8388": "Ein laufendes Modell wird zuerst gestoppt; laden Sie es nach dem Verschieben erneut.",
"s8389": "{0} würden verschoben.",
"s8390": "Der freie Platz wird auf dem Laufwerk gemessen, auf dem {0} liegt.",
"s8391": "Alter Ordner: {0}",
"s8392": "Strata kopiert eine Datei nach der anderen und prüft ihre Größe, bevor die alte entfernt wird.",
"s8393": "Sie können jederzeit stoppen; bereits verschobene Dateien bleiben nutzbar.",
"s8394": "Auf dem Laufwerk ist der Platz ausgegangen",
"s8395": "Das Verschieben wurde gestoppt",
"s8396": "{0} war nach {1} voll, deshalb wurde das Verschieben gestoppt.",
"s8397": "Die Modelle konnten nicht verschoben werden.",
"s8398": "Es ging nichts verloren. Bereits verschobene Modelle liegen im neuen Ordner, die übrigen noch in {0}, und die halb kopierte Datei wurde entfernt. Schaffen Sie Platz auf {1} oder wählen Sie einen anderen Ordner und versuchen Sie es erneut.",
"s8399": "Es ging nichts verloren. Alle Ihre Modelle liegen noch in {0}, und die halb kopierte Datei wurde entfernt. Schaffen Sie Platz auf {1} oder wählen Sie einen anderen Ordner und versuchen Sie es erneut.",
"s8400": "Es ging nichts verloren. Die nicht verschobenen Modelle liegen noch in {0}. Wählen Sie einen anderen Ordner, um es erneut zu versuchen.",
"s8401": "Verschieben gestoppt",
"s8402": "Es ging nichts verloren.",
"s8403": "{0} liegt im neuen Ordner.",
"s8404": "{0} liegt noch im alten Ordner.",
"s8405": "Beide funktionieren.",
"s8406": "Modelle in beiden Ordnern werden wie gewohnt angezeigt. Das Verschieben können Sie später mit „Ordner ändern“ beenden.",
"s8407": "Ihre Modelle wurden verschoben",
"s8408": "{0} liegen jetzt in {1}",
"s8409": "Die alten Kopien wurden entfernt, auf {1} sind wieder {0} frei.",
"s8410": "Strata verwendet jetzt auch {0}. Neue Downloads landen dort, Ihre aktuellen Modelle bleiben, wo sie sind.",
"s8411": "Strata verwendet jetzt {0}. Ihre alten Modelldateien liegen noch in {1}; Strata löscht sie nicht.",
"s8412": "Das Ändern des Modellordners ist Teil der Strata-App.",
"s8413": "Ein Modell läuft. Stoppen Sie es zuerst und ändern Sie dann den Ordner.",
"s8414": "Wählen Sie einen Modellordner.",
"s8415": "Wählen Sie, was mit Ihren Modellen geschehen soll.",
"s8416": "Diese Möglichkeit gibt es hier nicht.",
"s8417": "Ein Verschieben wurde unterbrochen. Beenden Sie es oder lassen Sie es sein.",
"s8418": "Strata hat Ihre Modelle von {0} nach {1} verschoben, als es angehalten wurde. Es ist nichts verloren.",
"s8419": "Verschieben beenden",
"s8420": "Belassen",
"s8421": "Beenden legt alle Ihre Modelle in {1} ab. Belassen lässt sie in {0} und entfernt die unvollständigen Kopien aus {1}.",
"s8422": "Die unvollständigen Kopien wurden entfernt. Ihre Modelle liegen in {0}.",
"s8601": "Grafikkarten",
"s8602": "Haupt",
"s8603": "Helfer",
"s8604": "Ihre Wahl.",
"s8605": "Empfohlen.",
"s8606": "Sie haben die Rollen geändert. Strata verwendet genau diese.",
"s8607": "Empfohlen: {0}",
"s8608": "Rolle von {0}",
"s8609": "{0} GB Speicher",
"s8610": "Nutzt den Arbeitsspeicher Ihres Computers (integrierte Grafik)",
"s8611": "Macht die Hauptarbeit",
"s8612": "Hält einen Teil des Modells",
"s8613": "Nicht genutzt",
"s8614": "Wird von {0} verwendet",
"s8615": "Ausschalten",
"s8616": "{0} zur Hauptkarte machen",
"s8617": "{0} kann Strata nicht ausführen.",
"s8618": "{0} ist integrierte Grafik; als Hauptkarte ist sie langsam.",
"s8619": "Nur mit der CPU ist die Leistung eingeschränkt.",
"s8620": "Diese Engine braucht zum Start eine Grafikkarte. Schalten Sie eine ein.",
"s8621": "Nicht genug Speicher für diese Einstellung: etwa {0} GB werden vom Laufwerk gelesen.",
"s8622": "{0} ist jetzt ein Helfer.",
"s8623": "{0} ist jetzt die Hauptkarte.",
"s8624": "Die einzige eingeschaltete Karte muss die Hauptkarte sein.",
"s8625": "Es wurde keine Grafikkarte gefunden, die Strata nutzen kann.",
"s8626": "Strata verwendet Ihre {0}.",
"s8627": "{0} ist die Hauptkarte: {1}.",
"s8628": "die Karten sind gleich",
"s8629": "sie hat mehr Speicher",
"s8630": "sie ist schneller",
"s8631": "sie ist die einzige Karte",
"s8632": "{0} helfen.",
"s8633": "{0} hilft.",
"s8634": "{0} startet ausgeschaltet: {1}",
"s8635": "Strata kann sie nicht ausführen.",
"s8636": "integrierte Grafik.",
"s8637": "{0} GB oder weniger.",
"s8638": "Die Karte, die die letzten Schritte jeder Antwort übernimmt: Sie schreibt die nächsten Wörter, führt den Vorausschau-Schritt aus, prüft ihn und hält Ihr Gespräch. Wählen Sie Ihre schnellste Karte mit viel Speicher. Es gibt immer nur eine.",
"s8639": "Hält einen Teil des Modells und seines schnellen Speichers, sodass mehr davon auf den Grafikkarten Platz findet statt vom Laufwerk gelesen zu werden. Prompts werden etwa 20 % schneller gelesen.",
"s8640": "Strata rührt diese Karte nicht an. Gut für eine Karte, die Sie anderweitig nutzen, etwa für ein Spiel oder Ihren Bildschirm.",
"s8641": "Das Modell braucht etwa {0} GB. Diese Karten und Ihr Arbeitsspeicher bieten etwa {1} GB. Es passt.",
"s8642": "Das Modell braucht etwa {0} GB. Diese Karten und Ihr Arbeitsspeicher bieten etwa {1} GB, daher würden etwa {2} GB vom Laufwerk gelesen.",
"s8643": "Nur Ihr Prozessor und Arbeitsspeicher werden verwendet.",
"s8644": "Für Experten: Die Engine startet auf den Karten {0} (Hauptkarte zuletzt).",
"s8645": "Wird nicht gespeichert, solange Sie die Empfehlung behalten: Strata wählt neu, wenn Sie eine Karte hinzufügen oder entfernen.",
"s8646": "Nicht genutzt: {0} (anderer Grafikkarten-Hersteller als der, den diese Engine ansteuert).",
"s9001": "Diese Nachricht ist etwa {0} Tokens lang, und dieses Modell kann etwa {1} auf einmal lesen. Sie ist zu groß, um so gesendet zu werden.",
"s9002": "Hängen Sie sie als Datei an, die der Assistent in Teilen liest, oder senden Sie eine gekürzte Vorschau.",
"s9003": "Diese Unterhaltung passt nicht mehr in das Fenster des Modells, und das Zusammenfassen der älteren Nachrichten hat nicht genug Platz geschaffen.",
"s9004": "Fassen Sie sie jetzt zusammen und machen Sie weiter, oder beginnen Sie einen neuen Chat.",
"s9005": "Frühere Nachrichten werden zusammengefasst …",
"s9006": "Ältere Nachrichten wurden zusammengefasst, um Platz zu schaffen. Der Chat war zu {0} % voll und ist jetzt zu {1} % voll.",
"s9007": "Ältere Nachrichten wurden zusammengefasst, um Platz zu schaffen. Der Chat war zu {0} % voll.",
"s9008": "Ältere Nachrichten wurden zusammengefasst, um Platz zu schaffen.",
"s9009": "Dieser Chat ist zu {0} % voll, aber es gibt nichts Älteres zusammenzufassen: Die neueste Nachricht füllt ihn. Beginnen Sie einen neuen Chat oder senden Sie eine kürzere Nachricht.",
"s9010": "Es gibt noch nichts Älteres zusammenzufassen. Alles in diesem Chat braucht der Assistent noch.",
"s9011": "Es gibt noch nichts zusammenzufassen. In diesem Chat hat noch keine Unterhaltung mit dem Assistenten begonnen.",
"s9012": "Die älteren Nachrichten konnten gerade nicht zusammengefasst werden. Versuchen Sie es gleich noch einmal oder beginnen Sie einen neuen Chat.",
"s9013": "In diesem Chat läuft noch etwas. Halten Sie ihn an oder warten Sie, bis er fertig ist, und versuchen Sie es dann erneut.",
"s9014": "Als Datei anhängen",
"s9015": "Gekürzte Vorschau senden",
"s9016": "Zusammenfassen und weitermachen",
"s9017": "Neuen Chat beginnen",
"s9018": "Die Nachricht konnte nicht vorbereitet werden. Versuchen Sie es mit einer kürzeren Nachricht.",
"s9019": "Die letzte Strata-Sitzung wird noch beendet.",
"s9020": "Warten Sie einen Moment und versuchen Sie es erneut. Wenn es wieder passiert, starten Sie den Computer neu.",
"s9021": "Vorausraten",
"s9022": "Automatisch (empfohlen)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "Vorausraten: MTP (DFlash2 hat nicht hineingepasst)",
"s9026": "Vorausraten: MTP (DFlash2 ist in diesem Modus nicht verfügbar)",
"s9027": "Vorausraten: MTP (DFlash2 gibt es nur für Qwen3.8-Flash-Next)",
"s9028": "Vorausraten: MTP (der DFlash2-Entwurfsbaustein ist noch nicht heruntergeladen)",
"s9029": "Wenn Sie DFlash2 wählen, wird auch sein Entwurfsbaustein heruntergeladen: etwa {0} GB, einmalig. Passt er nicht in den Grafikspeicher, nutzt das Modell stattdessen MTP.",
"s9030": "DFlash2-Entwurfsbaustein wird heruntergeladen …",
"s9031": "DFlash2-Entwurfsbaustein wird für die Engine vorbereitet …",
"s9032": "Der DFlash2-Entwurfsbaustein ist bereit.",
"s9033": "Der DFlash2-Entwurfsbaustein konnte nicht heruntergeladen werden, deshalb startet das Modell mit MTP. Das Modell wird geladen …",
"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."
}
+181 -1
View File
@@ -3346,5 +3346,185 @@
"s8257": "every {0} min",
"s8258": "This Strata engine has no code for the graphics card it tried to use.",
"s8259": "Pick another card under Run on, or open Details to see which cards were found and what the engine is built for.",
"s8260": "This switch does nothing in the app. Web pages from any site can already call the API shown under Serving. To allow only some sites, or to use the engine’s own server (port 8080), add \"cors_origins\": [\"https://your.site\"] to the model’s config file."
"s8260": "This switch does nothing in the app. Web pages from any site can already call the API shown under Serving. To allow only some sites, or to use the engine’s own server (port 8080), add \"cors_origins\": [\"https://your.site\"] to the model’s config file.",
"s8301": "Create an API key (recommended)",
"s8302": "Without an API key, any device on this network can use your model.",
"s8303": "Only the inference API is shared. Clients need your API key when one is set. Use a trusted local network; this connection uses HTTP.",
"s8304": "Create a key to protect your API. You’ll return here when it’s ready. You can also continue without one, but anyone who finds the link can then use your model.",
"s8305": "Already have a supported model on this computer? Choose the folder that holds its files, or one of its .gguf files. Strata checks them, prepares the model and loads it. Nothing is copied or downloaded.",
"s8306": "Folder with the GGUF files, or one .gguf file",
"s8307": "Full path to the folder or the file",
"s8308": "Without an API key, anyone who can reach this address can use your model.",
"s8309": "Strata could not use {0} for models ({1}), so models go to {2}. You can pick another folder in Settings.",
"s8310": "Port {0} is already in use. Strata may already be running; close it or the other program, or start Strata with another port (STRATA_PORT).",
"s8311": "Windows or a firewall does not let Strata use port {0}. 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.",
"s8312": "{0} is not an address of this computer, so Strata cannot listen on it. Check STRATA_BIND.",
"s8313": "Strata cannot listen on {0}:{1} ({2}).",
"s8339": "Models folder",
"s8340": "Strata saves the big model files here.",
"s8341": "Change folder",
"s8342": "Waiting for the folder…",
"s8343": "Disk use",
"s8344": "{0} free",
"s8345": "on this drive",
"s8346": "Your models use {0}",
"s8347": "Finish or stop the download to change the folder.",
"s8348": "That folder isn't there",
"s8349": "That folder can't be used",
"s8350": "Strata can't save files in {0}.",
"s8351": "Strata can't find {0}.",
"s8352": "Pick a folder outside the current one.",
"s8353": "Choose a folder you own, for example one in Documents or on another drive.",
"s8354": "Check that the drive is plugged in, then pick the folder again. Your models were not touched.",
"s8355": "Use this folder for your models?",
"s8356": "New folder",
"s8357": "{0} free on this drive",
"s8358": "You picked the whole drive {0}. Strata keeps its files in a folder of their own, so your models go in {1}.",
"s8359": "The three choices come next.",
"s8360": "Move my models",
"s8361": "Use both folders",
"s8362": "Start empty",
"s8363": "Moving your models",
"s8364": "Stop moving",
"s8365": "{0} of {1} GB",
"s8366": "Less than a minute left",
"s8367": "About a minute left",
"s8368": "About {0} minutes left",
"s8369": "About {0} hours left",
"s8370": "Keep Strata open. You can keep using other apps. Your models cannot be loaded until the move is done.",
"s8371": "Now copying: {0}, file {1} of {2}",
"s8372": "From {0} to {1}",
"s8373": "Move my models there",
"s8374": "You have no models to move.",
"s8375": "Strata moves your {0} of models to the new folder. This is the same drive, so it takes a moment.",
"s8376": "Strata copies your {0} of models to the new folder, checks them, then removes the old copies.",
"s8377": "Needs no extra space.",
"s8378": "Needs {0} free in the new folder. You have {1}.",
"s8379": "Keep them where they are and also use the new folder",
"s8380": "Nothing is copied. Your current models keep working, and new downloads go to the new folder.",
"s8381": "Needs no space now. New downloads must fit in {1}.",
"s8382": "Needs no space now.",
"s8383": "Strata uses the new folder from now on and does not list your current models. Your old files are not deleted.",
"s8384": "Needs no space now. Each model you download later needs its own space.",
"s8385": "Your models need {0}, this drive has {1} free.",
"s8386": "Free up about {0} there, pick another folder, or choose one of the other two options.",
"s8387": "What should happen to your models",
"s8388": "A running model is stopped first; load it again when the move is done.",
"s8389": "{0} would move.",
"s8390": "Free space is measured on the drive that holds {0}.",
"s8391": "Old folder: {0}",
"s8392": "Strata copies one file at a time and checks its size before it removes the old one.",
"s8393": "You can stop at any time; files already moved stay usable.",
"s8394": "The drive ran out of space",
"s8395": "The move stopped",
"s8396": "{0} filled up after {1}, so the move stopped.",
"s8397": "The models could not be moved.",
"s8398": "Nothing was lost. Models already moved are in the new folder, the rest are still in {0}, and the half-copied file was removed. Free up space on {1} or choose another folder, then try again.",
"s8399": "Nothing was lost. All your models are still in {0}, and the half-copied file was removed. Free up space on {1} or choose another folder, then try again.",
"s8400": "Nothing was lost. Your models that were not moved are still in {0}. Choose another folder to try again.",
"s8401": "Moving stopped",
"s8402": "Nothing was lost.",
"s8403": "{0} is in the new folder.",
"s8404": "{0} is still in the old folder.",
"s8405": "Both work.",
"s8406": "Models in both folders are listed as normal. You can finish moving later with Change folder.",
"s8407": "Your models are moved",
"s8408": "{0} now live in {1}",
"s8409": "The old copies were removed and {0} is free again on {1}.",
"s8410": "Strata now also uses {0}. New downloads go there, and your current models stay where they are.",
"s8411": "Strata now uses {0}. Your old model files are still in {1}; Strata does not delete them.",
"s8412": "Changing the models folder is part of the Strata app.",
"s8413": "A model is running. Stop it first, then change the folder.",
"s8414": "Choose a models folder.",
"s8415": "Choose what to do with your models.",
"s8416": "That choice is not possible here.",
"s8417": "A move was interrupted. Finish it or leave it.",
"s8418": "Strata was moving your models from {0} to {1} when it stopped. Nothing is lost.",
"s8419": "Finish the move",
"s8420": "Leave it",
"s8421": "Finishing puts all your models in {1}. Leaving it keeps them in {0} and removes the partial copies from {1}.",
"s8422": "The partial copies were removed. Your models are in {0}.",
"s8601": "Graphics cards",
"s8602": "Main",
"s8603": "Helper",
"s8604": "Your choice.",
"s8605": "Recommended.",
"s8606": "You changed the roles. Strata will use exactly these.",
"s8607": "Recommended: {0}",
"s8608": "Role of {0}",
"s8609": "{0} GB memory",
"s8610": "Uses your computer's memory (built-in graphics)",
"s8611": "Does the main work",
"s8612": "Holds part of the model",
"s8613": "Not used",
"s8614": "In use by {0}",
"s8615": "Turn it off",
"s8616": "Make {0} Main",
"s8617": "{0} cannot run Strata.",
"s8618": "{0} is built-in graphics; it is slow as Main.",
"s8619": "Performance is degraded on CPU only.",
"s8620": "This engine needs a graphics card to start. Turn one on.",
"s8621": "Not enough memory for this setup: about {0} GB is read from the drive.",
"s8622": "{0} is now a Helper.",
"s8623": "{0} is now Main.",
"s8624": "The only card that is on has to be Main.",
"s8625": "No graphics card that Strata can use was found.",
"s8626": "Strata will use your {0}.",
"s8627": "{0} is Main: {1}.",
"s8628": "the cards are alike",
"s8629": "it has more memory",
"s8630": "it is faster",
"s8631": "it is the only card",
"s8632": "{0} help.",
"s8633": "{0} helps.",
"s8634": "{0} starts Off: {1}",
"s8635": "Strata cannot run it.",
"s8636": "built-in graphics.",
"s8637": "{0} GB or less.",
"s8638": "The card that does the final steps of every answer: it writes the next words, runs the draft-ahead step and checks it, and keeps your conversation. Pick your fastest card with plenty of memory. There is only ever one.",
"s8639": "Holds a share of the model and its fast memory, so more of it fits on graphics cards instead of being read from your drive. Prompts are read about 20% faster.",
"s8640": "Strata does not touch this card. Good for a card you use for something else, such as a game or your screen.",
"s8641": "The model needs about {0} GB. These cards and your memory give about {1} GB. It fits.",
"s8642": "The model needs about {0} GB. These cards and your memory give about {1} GB, so about {2} GB would be read from your drive.",
"s8643": "Only your processor and memory are used.",
"s8644": "For experts: the engine starts on cards {0} (Main last).",
"s8645": "Not saved while you keep the recommendation: Strata picks again if you add or remove a card.",
"s8646": "Not used: {0} (another make of graphics card than this engine drives).",
"s9001": "This message is about {0} tokens long, and this model can read about {1} at a time. It is too large to send as it is.",
"s9002": "Attach it as a file for the assistant to read in parts, or send a shortened preview.",
"s9003": "This conversation no longer fits the model’s window, and summarizing the older messages did not make enough room.",
"s9004": "Summarize it now and continue, or start a new chat.",
"s9005": "Summarizing earlier messages…",
"s9006": "Older messages were summarized to make room. The chat was {0}% full and is now {1}% full.",
"s9007": "Older messages were summarized to make room. The chat was {0}% full.",
"s9008": "Older messages were summarized to make room.",
"s9009": "This chat is {0}% full, but there is nothing older to summarize: the newest message is what fills it. Start a new chat, or send a shorter message.",
"s9010": "There is nothing older to summarize yet. Everything in this chat is still needed by the assistant.",
"s9011": "There is nothing to summarize yet. This chat has not started a conversation with the assistant.",
"s9012": "The older messages could not be summarized right now. Try again in a moment, or start a new chat.",
"s9013": "This chat is still working. Stop it or wait for it to finish, then try again.",
"s9014": "Attach as a file",
"s9015": "Send a shortened preview",
"s9016": "Summarize and continue",
"s9017": "Start a new chat",
"s9018": "The message could not be prepared. Try a shorter message.",
"s9019": "The last Strata session is still closing down.",
"s9020": "Wait a moment and try again. If it keeps happening, restart the computer.",
"s9021": "Drafting",
"s9022": "Auto (recommended)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "Drafting: MTP (DFlash2 didn't fit)",
"s9026": "Drafting: MTP (DFlash2 isn't available in this mode)",
"s9027": "Drafting: MTP (DFlash2 is for Qwen3.8-Flash-Next only)",
"s9028": "Drafting: MTP (the DFlash2 drafter isn't downloaded yet)",
"s9029": "Choosing DFlash2 also downloads its drafter: about {0} GB, once. If it does not fit in graphics memory, the model uses the MTP instead.",
"s9030": "Downloading the DFlash2 drafter…",
"s9031": "Packing the DFlash2 drafter for the engine…",
"s9032": "The DFlash2 drafter is ready.",
"s9033": "The DFlash2 drafter could not be downloaded, so the model starts with the MTP. Loading the model…",
"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."
}
+181 -1
View File
@@ -3346,5 +3346,185 @@
"s8257": "cada {0} min",
"s8258": "Este motor de Strata no tiene código para la tarjeta gráfica que intentó usar.",
"s8259": "Elige otra tarjeta en «Ejecutar en», o abre los detalles para ver qué tarjetas se encontraron y para cuáles está compilado el motor.",
"s8260": "Este interruptor no hace nada en la app. Las páginas web de cualquier sitio ya pueden llamar a la API que se muestra en Servicio. Para permitir solo algunos sitios, o para usar el servidor propio del motor (puerto 8080), añada \"cors_origins\": [\"https://su.sitio\"] al archivo de configuración del modelo."
"s8260": "Este interruptor no hace nada en la app. Las páginas web de cualquier sitio ya pueden llamar a la API que se muestra en Servicio. Para permitir solo algunos sitios, o para usar el servidor propio del motor (puerto 8080), añada \"cors_origins\": [\"https://su.sitio\"] al archivo de configuración del modelo.",
"s8301": "Crear una clave de API (recomendado)",
"s8302": "Sin una clave de API, cualquier dispositivo de esta red puede usar tu modelo.",
"s8303": "Solo se comparte la API de inferencia. Los clientes necesitan tu clave de API si hay una. Usa una red local de confianza; esta conexión usa HTTP.",
"s8304": "Crea una clave para proteger tu API. Volverás aquí cuando esté lista. También puedes continuar sin ella, pero cualquiera que encuentre el enlace podrá usar tu modelo.",
"s8305": "¿Ya tienes un modelo compatible en este equipo? Elige la carpeta con sus archivos o uno de sus archivos .gguf. Strata los comprueba, prepara el modelo y lo carga. No se copia ni se descarga nada.",
"s8306": "Carpeta con los archivos GGUF, o un archivo .gguf",
"s8307": "Ruta completa de la carpeta o del archivo",
"s8308": "Sin una clave de API, cualquiera que pueda llegar a esta dirección podrá usar tu modelo.",
"s8309": "Strata no pudo usar {0} para los modelos ({1}), así que los modelos van a {2}. Puedes elegir otra carpeta en Ajustes.",
"s8310": "El puerto {0} ya está en uso. Puede que Strata ya se esté ejecutando; ciérralo o cierra el otro programa, o inicia Strata con otro puerto (STRATA_PORT).",
"s8311": "Windows o un cortafuegos no deja que Strata use el puerto {0}. Hyper-V, WSL y Docker reservan rangos de puertos para sí mismos (consulta \"netsh int ipv4 show excludedportrange protocol=tcp\"). Inicia Strata con otro puerto (STRATA_PORT), por ejemplo 8801.",
"s8312": "{0} no es una dirección de este equipo, así que Strata no puede escuchar en ella. Revisa STRATA_BIND.",
"s8313": "Strata no puede escuchar en {0}:{1} ({2}).",
"s8339": "Carpeta de modelos",
"s8340": "Strata guarda aquí los archivos grandes de los modelos.",
"s8341": "Cambiar carpeta",
"s8342": "Esperando la carpeta…",
"s8343": "Uso del disco",
"s8344": "{0} libres",
"s8345": "en esta unidad",
"s8346": "Tus modelos ocupan {0}",
"s8347": "Termina o detén la descarga para cambiar la carpeta.",
"s8348": "Esa carpeta no existe",
"s8349": "No se puede usar esa carpeta",
"s8350": "Strata no puede guardar archivos en {0}.",
"s8351": "Strata no encuentra {0}.",
"s8352": "Elige una carpeta fuera de la actual.",
"s8353": "Elige una carpeta tuya, por ejemplo una en Documentos o en otra unidad.",
"s8354": "Comprueba que la unidad esté conectada y vuelve a elegir la carpeta. Tus modelos no se han tocado.",
"s8355": "¿Usar esta carpeta para tus modelos?",
"s8356": "Carpeta nueva",
"s8357": "{0} libres en esta unidad",
"s8358": "Has elegido toda la unidad {0}. Strata guarda sus archivos en una carpeta propia, así que tus modelos irán en {1}.",
"s8359": "A continuación verás las tres opciones.",
"s8360": "Mover mis modelos",
"s8361": "Usar ambas carpetas",
"s8362": "Empezar vacío",
"s8363": "Moviendo tus modelos",
"s8364": "Detener el traslado",
"s8365": "{0} de {1} GB",
"s8366": "Queda menos de un minuto",
"s8367": "Queda aproximadamente un minuto",
"s8368": "Quedan aproximadamente {0} minutos",
"s8369": "Quedan aproximadamente {0} horas",
"s8370": "Mantén Strata abierto. Puedes seguir usando otras aplicaciones. Tus modelos no se podrán cargar hasta que termine el traslado.",
"s8371": "Copiando ahora: {0}, archivo {1} de {2}",
"s8372": "De {0} a {1}",
"s8373": "Mover mis modelos allí",
"s8374": "No tienes modelos que mover.",
"s8375": "Strata mueve tus {0} de modelos a la carpeta nueva. Es la misma unidad, así que tarda un instante.",
"s8376": "Strata copia tus {0} de modelos a la carpeta nueva, los comprueba y después elimina las copias antiguas.",
"s8377": "No necesita espacio adicional.",
"s8378": "Necesita {0} libres en la carpeta nueva. Tienes {1}.",
"s8379": "Dejarlos donde están y usar también la carpeta nueva",
"s8380": "No se copia nada. Tus modelos actuales siguen funcionando y las descargas nuevas van a la carpeta nueva.",
"s8381": "No necesita espacio ahora. Las descargas nuevas deben caber en {1}.",
"s8382": "No necesita espacio ahora.",
"s8383": "Strata usará la carpeta nueva a partir de ahora y no mostrará tus modelos actuales. Tus archivos antiguos no se borran.",
"s8384": "No necesita espacio ahora. Cada modelo que descargues después necesitará su propio espacio.",
"s8385": "Tus modelos necesitan {0} y esta unidad tiene {1} libres.",
"s8386": "Libera unos {0} allí, elige otra carpeta o escoge una de las otras dos opciones.",
"s8387": "Qué debe pasar con tus modelos",
"s8388": "Un modelo en ejecución se detiene antes; cárgalo de nuevo cuando termine el traslado.",
"s8389": "Se moverían {0}.",
"s8390": "El espacio libre se mide en la unidad que contiene {0}.",
"s8391": "Carpeta anterior: {0}",
"s8392": "Strata copia un archivo cada vez y comprueba su tamaño antes de eliminar el antiguo.",
"s8393": "Puedes detenerlo en cualquier momento; los archivos ya movidos siguen siendo utilizables.",
"s8394": "La unidad se quedó sin espacio",
"s8395": "El traslado se detuvo",
"s8396": "{0} se llenó tras {1}, así que el traslado se detuvo.",
"s8397": "No se pudieron mover los modelos.",
"s8398": "No se perdió nada. Los modelos ya movidos están en la carpeta nueva, el resto sigue en {0} y se eliminó el archivo copiado a medias. Libera espacio en {1} o elige otra carpeta y vuelve a intentarlo.",
"s8399": "No se perdió nada. Todos tus modelos siguen en {0} y se eliminó el archivo copiado a medias. Libera espacio en {1} o elige otra carpeta y vuelve a intentarlo.",
"s8400": "No se perdió nada. Los modelos que no se movieron siguen en {0}. Elige otra carpeta para volver a intentarlo.",
"s8401": "Traslado detenido",
"s8402": "No se perdió nada.",
"s8403": "{0} está en la carpeta nueva.",
"s8404": "{0} sigue en la carpeta anterior.",
"s8405": "Ambos funcionan.",
"s8406": "Los modelos de ambas carpetas aparecen como siempre. Puedes terminar el traslado más tarde con «Cambiar carpeta».",
"s8407": "Tus modelos se han movido",
"s8408": "{0} están ahora en {1}",
"s8409": "Se eliminaron las copias antiguas y vuelven a estar libres {0} en {1}.",
"s8410": "Strata ahora también usa {0}. Las descargas nuevas van allí y tus modelos actuales se quedan donde están.",
"s8411": "Strata ahora usa {0}. Tus archivos de modelos antiguos siguen en {1}; Strata no los borra.",
"s8412": "Cambiar la carpeta de modelos forma parte de la aplicación Strata.",
"s8413": "Hay un modelo en ejecución. Deténlo primero y después cambia la carpeta.",
"s8414": "Elige una carpeta de modelos.",
"s8415": "Elige qué hacer con tus modelos.",
"s8416": "Esa opción no es posible aquí.",
"s8417": "Se interrumpió un traslado. Termínelo o déjelo como estaba.",
"s8418": "Strata estaba trasladando sus modelos de {0} a {1} cuando se detuvo. No se ha perdido nada.",
"s8419": "Terminar el traslado",
"s8420": "Dejarlo",
"s8421": "Terminar pone todos sus modelos en {1}. Dejarlo los mantiene en {0} y elimina las copias parciales de {1}.",
"s8422": "Se eliminaron las copias parciales. Sus modelos están en {0}.",
"s8601": "Tarjetas gráficas",
"s8602": "Principal",
"s8603": "Ayudante",
"s8604": "Tu elección.",
"s8605": "Recomendado.",
"s8606": "Cambiaste los roles. Strata usará exactamente estos.",
"s8607": "Recomendado: {0}",
"s8608": "Rol de {0}",
"s8609": "{0} GB de memoria",
"s8610": "Usa la memoria de tu equipo (gráficos integrados)",
"s8611": "Hace el trabajo principal",
"s8612": "Guarda parte del modelo",
"s8613": "No se usa",
"s8614": "En uso por {0}",
"s8615": "Apagarla",
"s8616": "Hacer principal a {0}",
"s8617": "{0} no puede ejecutar Strata.",
"s8618": "{0} son gráficos integrados; como principal es lenta.",
"s8619": "El rendimiento se reduce al usar solo la CPU.",
"s8620": "Este motor necesita una tarjeta gráfica para arrancar. Activa una.",
"s8621": "Memoria insuficiente para esta configuración: unos {0} GB se leen del disco.",
"s8622": "{0} ahora es ayudante.",
"s8623": "{0} ahora es la principal.",
"s8624": "La única tarjeta activada tiene que ser la principal.",
"s8625": "No se encontró ninguna tarjeta gráfica que Strata pueda usar.",
"s8626": "Strata usará tu {0}.",
"s8627": "{0} es la principal: {1}.",
"s8628": "las tarjetas son iguales",
"s8629": "tiene más memoria",
"s8630": "es más rápida",
"s8631": "es la única tarjeta",
"s8632": "{0} ayudan.",
"s8633": "{0} ayuda.",
"s8634": "{0} empieza apagada: {1}",
"s8635": "Strata no puede ejecutarla.",
"s8636": "gráficos integrados.",
"s8637": "{0} GB o menos.",
"s8638": "La tarjeta que hace los pasos finales de cada respuesta: escribe las siguientes palabras, ejecuta el paso de adelanto y lo comprueba, y guarda tu conversación. Elige tu tarjeta más rápida con mucha memoria. Solo hay una.",
"s8639": "Guarda una parte del modelo y de su memoria rápida, así cabe más en las tarjetas gráficas en lugar de leerse del disco. Los prompts se leen un 20 % más rápido.",
"s8640": "Strata no toca esta tarjeta. Útil para una tarjeta que usas para otra cosa, como un juego o tu pantalla.",
"s8641": "El modelo necesita unos {0} GB. Estas tarjetas y tu memoria suman unos {1} GB. Cabe.",
"s8642": "El modelo necesita unos {0} GB. Estas tarjetas y tu memoria suman unos {1} GB, así que unos {2} GB se leerían del disco.",
"s8643": "Solo se usan tu procesador y tu memoria.",
"s8644": "Para expertos: el motor arranca en las tarjetas {0} (la principal al final).",
"s8645": "No se guarda mientras mantengas la recomendación: Strata elige de nuevo si añades o quitas una tarjeta.",
"s8646": "No se usa: {0} (otra marca de tarjeta gráfica distinta de la que maneja este motor).",
"s9001": "Este mensaje tiene unos {0} tokens y este modelo puede leer unos {1} a la vez. Es demasiado grande para enviarlo tal cual.",
"s9002": "Adjúntalo como archivo para que el asistente lo lea por partes, o envía una vista previa acortada.",
"s9003": "Esta conversación ya no cabe en la ventana del modelo, y resumir los mensajes antiguos no dejó espacio suficiente.",
"s9004": "Resúmela ahora y sigue, o empieza un chat nuevo.",
"s9005": "Resumiendo mensajes anteriores…",
"s9006": "Se resumieron los mensajes antiguos para hacer sitio. El chat estaba al {0} % y ahora está al {1} %.",
"s9007": "Se resumieron los mensajes antiguos para hacer sitio. El chat estaba al {0} %.",
"s9008": "Se resumieron los mensajes antiguos para hacer sitio.",
"s9009": "Este chat está al {0} %, pero no hay nada antiguo que resumir: el mensaje más reciente es lo que lo llena. Empieza un chat nuevo o envía un mensaje más corto.",
"s9010": "Todavía no hay nada antiguo que resumir. El asistente aún necesita todo lo que hay en este chat.",
"s9011": "Todavía no hay nada que resumir. Este chat aún no ha empezado una conversación con el asistente.",
"s9012": "No se pudieron resumir los mensajes antiguos ahora mismo. Inténtalo de nuevo en un momento o empieza un chat nuevo.",
"s9013": "Este chat sigue trabajando. Deténlo o espera a que termine y vuelve a intentarlo.",
"s9014": "Adjuntar como archivo",
"s9015": "Enviar una vista previa acortada",
"s9016": "Resumir y continuar",
"s9017": "Empezar un chat nuevo",
"s9018": "No se pudo preparar el mensaje. Prueba con un mensaje más corto.",
"s9019": "La última sesión de Strata todavía se está cerrando.",
"s9020": "Espera un momento y vuelve a intentarlo. Si sigue pasando, reinicia el ordenador.",
"s9021": "Predicción",
"s9022": "Automático (recomendado)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "Predicción: MTP (DFlash2 no cabía)",
"s9026": "Predicción: MTP (DFlash2 no está disponible en este modo)",
"s9027": "Predicción: MTP (DFlash2 es solo para Qwen3.8-Flash-Next)",
"s9028": "Predicción: MTP (el predictor de DFlash2 aún no está descargado)",
"s9029": "Elegir DFlash2 también descarga su predictor: unos {0} GB, una sola vez. Si no cabe en la memoria gráfica, el modelo usa MTP.",
"s9030": "Descargando el predictor de DFlash2…",
"s9031": "Preparando el predictor de DFlash2 para el motor…",
"s9032": "El predictor de DFlash2 está listo.",
"s9033": "No se pudo descargar el predictor de DFlash2, así que el modelo arranca con MTP. Cargando el modelo…",
"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."
}
+181 -1
View File
@@ -3346,5 +3346,185 @@
"s8257": "{0}分ごと",
"s8258": "この Strata エンジンには、使おうとしたグラフィックスカード用のコードが含まれていません。",
"s8259": "「実行先」で別のカードを選ぶか、「詳細」を開いて、見つかったカードとエンジンの対応状況を確認してください。",
"s8260": "このスイッチはアプリでは何もしません。どのサイトのWebページも、「配信」に表示されるAPIをすでに呼び出せます。一部のサイトだけを許可したい場合や、エンジン自身のサーバー(ポート8080)を使う場合は、モデルの設定ファイルに \"cors_origins\": [\"https://your.site\"] を追加してください。"
"s8260": "このスイッチはアプリでは何もしません。どのサイトのWebページも、「配信」に表示されるAPIをすでに呼び出せます。一部のサイトだけを許可したい場合や、エンジン自身のサーバー(ポート8080)を使う場合は、モデルの設定ファイルに \"cors_origins\": [\"https://your.site\"] を追加してください。",
"s8301": "APIキーを作成する(推奨)",
"s8302": "APIキーがないと、このネットワーク上のどの機器でもモデルを使えます。",
"s8303": "共有されるのは推論APIだけです。キーを設定している場合、クライアントにはAPIキーが必要です。信頼できるローカルネットワークで使ってください。この接続はHTTPを使います。",
"s8304": "APIを守るキーを作成します。準備ができたらここに戻ります。キーなしで続けることもできますが、その場合はリンクを見つけた人なら誰でもモデルを使えます。",
"s8305": "対応モデルがすでにこのコンピューターにありますか?ファイルがあるフォルダー、または.ggufファイルを1つ選んでください。Strataがファイルを確認し、モデルを準備して読み込みます。コピーやダウンロードは行いません。",
"s8306": "GGUFファイルのフォルダー、または.ggufファイル1つ",
"s8307": "フォルダーまたはファイルのフルパス",
"s8308": "APIキーがないと、このアドレスに接続できる人なら誰でもモデルを使えます。",
"s8309": "Strataは{0}をモデルの保存先として使えなかったため({1})、モデルは{2}に保存されます。別のフォルダーは設定で選べます。",
"s8310": "ポート{0}はすでに使われています。Strataがすでに起動しているかもしれません。それか別のプログラムを閉じるか、別のポート(STRATA_PORT)でStrataを起動してください。",
"s8311": "Windowsまたはファイアウォールが、Strataによるポート{0}の使用を許可していません。Hyper-V、WSL、Dockerは一部のポート範囲を自分用に確保します(\"netsh int ipv4 show excludedportrange protocol=tcp\"で確認できます)。別のポート(STRATA_PORT)、たとえば8801でStrataを起動してください。",
"s8312": "{0}はこのコンピューターのアドレスではないため、Strataはそこで待ち受けできません。STRATA_BINDを確認してください。",
"s8313": "Strataは{0}:{1}で待ち受けできません({2})。",
"s8339": "モデルフォルダ",
"s8340": "Strata は大きなモデルファイルをここに保存します。",
"s8341": "フォルダを変更",
"s8342": "フォルダを待っています…",
"s8343": "ディスクの使用量",
"s8344": "空き {0}",
"s8345": "(このドライブ)",
"s8346": "モデルの使用量: {0}",
"s8347": "フォルダを変更するには、ダウンロードを完了するか停止してください。",
"s8348": "そのフォルダは見つかりません",
"s8349": "そのフォルダは使えません",
"s8350": "Strata は {0} にファイルを保存できません。",
"s8351": "Strata は {0} を見つけられません。",
"s8352": "現在のフォルダの外にあるフォルダを選んでください。",
"s8353": "ご自分のフォルダ(ドキュメント内や別のドライブ上など)を選んでください。",
"s8354": "ドライブが接続されているか確認してから、もう一度フォルダを選んでください。モデルには手を加えていません。",
"s8355": "このフォルダをモデル用に使いますか?",
"s8356": "新しいフォルダ",
"s8357": "このドライブの空き: {0}",
"s8358": "ドライブ全体 {0} を選びました。Strata は専用のフォルダにファイルを置くため、モデルは {1} に保存されます。",
"s8359": "次に 3 つの選択肢が表示されます。",
"s8360": "モデルを移動する",
"s8361": "両方のフォルダを使う",
"s8362": "空の状態で始める",
"s8363": "モデルを移動しています",
"s8364": "移動を停止",
"s8365": "{0} / {1} GB",
"s8366": "残り 1 分未満",
"s8367": "残り約 1 分",
"s8368": "残り約 {0} 分",
"s8369": "残り約 {0} 時間",
"s8370": "Strata を開いたままにしてください。ほかのアプリは使い続けられます。移動が終わるまでモデルは読み込めません。",
"s8371": "コピー中: {0}、ファイル {1} / {2}",
"s8372": "{0} から {1} へ",
"s8373": "モデルをそこへ移動する",
"s8374": "移動するモデルがありません。",
"s8375": "Strata はモデル {0} を新しいフォルダへ移動します。同じドライブなので、すぐに終わります。",
"s8376": "Strata はモデル {0} を新しいフォルダへコピーし、確認してから古いコピーを削除します。",
"s8377": "追加の空き容量は不要です。",
"s8378": "新しいフォルダに {0} の空きが必要です。現在の空き: {1}。",
"s8379": "今の場所に残し、新しいフォルダも使う",
"s8380": "コピーは行いません。今あるモデルはそのまま使え、新しいダウンロードは新しいフォルダに保存されます。",
"s8381": "今は空き容量は不要です。新しいダウンロードは {1} に収まる必要があります。",
"s8382": "今は空き容量は不要です。",
"s8383": "今後は新しいフォルダを使い、今のモデルは一覧に表示しません。古いファイルは削除されません。",
"s8384": "今は空き容量は不要です。あとでダウンロードするモデルごとに、その分の空きが必要です。",
"s8385": "モデルには {0} が必要ですが、このドライブの空きは {1} です。",
"s8386": "そこで約 {0} を空けるか、別のフォルダを選ぶか、ほかの 2 つの選択肢のどちらかを選んでください。",
"s8387": "モデルをどう扱うか",
"s8388": "実行中のモデルは先に停止されます。移動が終わったらもう一度読み込んでください。",
"s8389": "移動対象: {0}。",
"s8390": "空き容量は {0} があるドライブで測定しています。",
"s8391": "元のフォルダ: {0}",
"s8392": "Strata は 1 ファイルずつコピーし、サイズを確認してから古いファイルを削除します。",
"s8393": "いつでも停止できます。移動済みのファイルはそのまま使えます。",
"s8394": "ドライブの空き容量が足りなくなりました",
"s8395": "移動が停止しました",
"s8396": "{1} を移動したところで {0} がいっぱいになったため、移動を停止しました。",
"s8397": "モデルを移動できませんでした。",
"s8398": "失われたものはありません。移動済みのモデルは新しいフォルダにあり、残りは {0} にあります。途中までコピーされたファイルは削除しました。{1} の空きを増やすか別のフォルダを選んで、もう一度お試しください。",
"s8399": "失われたものはありません。モデルはすべて {0} に残っており、途中までコピーされたファイルは削除しました。{1} の空きを増やすか別のフォルダを選んで、もう一度お試しください。",
"s8400": "失われたものはありません。移動されなかったモデルは {0} に残っています。別のフォルダを選んで、もう一度お試しください。",
"s8401": "移動を停止しました",
"s8402": "失われたものはありません。",
"s8403": "{0} は新しいフォルダにあります。",
"s8404": "{0} は元のフォルダに残っています。",
"s8405": "どちらも使えます。",
"s8406": "どちらのフォルダのモデルも通常どおり一覧に表示されます。移動の続きは、あとで「フォルダを変更」から行えます。",
"s8407": "モデルを移動しました",
"s8408": "{0} が {1} に移動しました",
"s8409": "古いコピーを削除し、{1} の空きが {0} 増えました。",
"s8410": "Strata は {0} も使うようになりました。新しいダウンロードはそこに保存され、今のモデルはそのままの場所に残ります。",
"s8411": "Strata は {0} を使うようになりました。古いモデルファイルは {1} に残っており、Strata が削除することはありません。",
"s8412": "モデルフォルダの変更は Strata アプリの機能です。",
"s8413": "モデルが実行中です。先に停止してから、フォルダを変更してください。",
"s8414": "モデルフォルダを選んでください。",
"s8415": "モデルをどうするか選んでください。",
"s8416": "この選択肢はここでは使えません。",
"s8417": "移動が中断されました。完了するか、中止してください。",
"s8418": "Strata がモデルを {0} から {1} へ移動している途中で止まりました。失われたものはありません。",
"s8419": "移動を完了する",
"s8420": "中止する",
"s8421": "完了すると、すべてのモデルが {1} に入ります。中止すると、モデルは {0} に残り、{1} の不完全なコピーが削除されます。",
"s8422": "不完全なコピーを削除しました。モデルは {0} にあります。",
"s8601": "グラフィックカード",
"s8602": "メイン",
"s8603": "ヘルパー",
"s8604": "あなたの選択です。",
"s8605": "おすすめです。",
"s8606": "役割を変更しました。Strata はこのとおりに使います。",
"s8607": "おすすめ:{0}",
"s8608": "{0} の役割",
"s8609": "メモリ {0} GB",
"s8610": "コンピューター本体のメモリを使います(内蔵グラフィックス)",
"s8611": "主な処理を担当",
"s8612": "モデルの一部を保持",
"s8613": "使用しません",
"s8614": "{0} が使用中",
"s8615": "オフにする",
"s8616": "{0} をメインにする",
"s8617": "{0} では Strata を実行できません。",
"s8618": "{0} は内蔵グラフィックスで、メインにすると遅くなります。",
"s8619": "CPU のみの場合、性能が低下します。",
"s8620": "このエンジンの起動にはグラフィックカードが必要です。1 枚オンにしてください。",
"s8621": "この設定ではメモリが足りません。約 {0} GB をドライブから読み込みます。",
"s8622": "{0} はヘルパーになりました。",
"s8623": "{0} がメインになりました。",
"s8624": "オンになっている唯一のカードはメインでなければなりません。",
"s8625": "Strata が使えるグラフィックカードが見つかりません。",
"s8626": "Strata は {0} を使います。",
"s8627": "{0} がメインです:{1}。",
"s8628": "カードが同じため",
"s8629": "メモリが多いため",
"s8630": "より高速なため",
"s8631": "唯一のカードのため",
"s8632": "{0} がサポートします。",
"s8633": "{0} がサポートします。",
"s8634": "{0} は最初オフです:{1}",
"s8635": "Strata では実行できません。",
"s8636": "内蔵グラフィックスです。",
"s8637": "{0} GB 以下です。",
"s8638": "回答の最後の工程を担当するカードです。次の単語を書き、先読みの工程を実行して確認し、会話を保持します。メモリに余裕のある一番速いカードを選んでください。常に 1 枚だけです。",
"s8639": "モデルの一部と高速メモリを保持し、ドライブから読み込む代わりにより多くをグラフィックカード上に置けます。プロンプトの読み込みが約 20% 速くなります。",
"s8640": "Strata はこのカードに触れません。ゲームや画面表示など、別の用途に使うカードに向いています。",
"s8641": "モデルには約 {0} GB が必要です。これらのカードとメモリで約 {1} GB あり、収まります。",
"s8642": "モデルには約 {0} GB が必要です。これらのカードとメモリは約 {1} GB のため、約 {2} GB がドライブから読み込まれます。",
"s8643": "プロセッサとメモリのみを使います。",
"s8644": "上級者向け:エンジンはカード {0} で起動します(メインが最後)。",
"s8645": "おすすめのままの間は保存されません。カードを追加・取り外すと Strata が選び直します。",
"s8646": "未使用:{0}(このエンジンが対応するのとは別メーカーのカードです)。",
"s9001": "このメッセージは約{0}トークンで、このモデルは一度に約{1}トークンまで読めます。このまま送るには大きすぎます。",
"s9002": "ファイルとして添付してアシスタントに分けて読んでもらうか、短くしたプレビューを送ってください。",
"s9003": "この会話はモデルのウィンドウに収まらなくなり、古いメッセージを要約しても十分な空きができませんでした。",
"s9004": "今すぐ要約して続けるか、新しいチャットを始めてください。",
"s9005": "これまでのメッセージを要約しています…",
"s9006": "空きを作るために古いメッセージを要約しました。チャットは{0}%埋まっていましたが、現在は{1}%です。",
"s9007": "空きを作るために古いメッセージを要約しました。チャットは{0}%埋まっていました。",
"s9008": "空きを作るために古いメッセージを要約しました。",
"s9009": "このチャットは{0}%埋まっていますが、要約できる古いメッセージはありません。最新のメッセージで埋まっています。新しいチャットを始めるか、もっと短いメッセージを送ってください。",
"s9010": "まだ要約できる古いメッセージはありません。このチャットの内容はどれもアシスタントがまだ必要としています。",
"s9011": "まだ要約するものがありません。このチャットではアシスタントとの会話が始まっていません。",
"s9012": "今は古いメッセージを要約できませんでした。しばらくしてからもう一度試すか、新しいチャットを始めてください。",
"s9013": "このチャットはまだ作業中です。停止するか、終わるのを待ってからもう一度試してください。",
"s9014": "ファイルとして添付",
"s9015": "短くしたプレビューを送る",
"s9016": "要約して続ける",
"s9017": "新しいチャットを始める",
"s9018": "メッセージを準備できませんでした。もっと短いメッセージを試してください。",
"s9019": "前回の Strata セッションがまだ終了処理中です。",
"s9020": "しばらく待ってからもう一度試してください。繰り返し起こる場合はコンピューターを再起動してください。",
"s9021": "先読み",
"s9022": "自動(おすすめ)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "先読み:MTP(DFlash2 は収まりませんでした)",
"s9026": "先読み:MTP(このモードでは DFlash2 を使えません)",
"s9027": "先読み:MTP(DFlash2 は Qwen3.8-Flash-Next 専用です)",
"s9028": "先読み:MTP(DFlash2 のドラフトモデルはまだダウンロードされていません)",
"s9029": "DFlash2 を選ぶと、そのドラフトモデルも一度だけダウンロードします(約 {0} GB)。グラフィックメモリに収まらない場合は、代わりに MTP を使います。",
"s9030": "DFlash2 のドラフトモデルをダウンロードしています…",
"s9031": "DFlash2 のドラフトモデルをエンジン用に準備しています…",
"s9032": "DFlash2 のドラフトモデルの準備ができました。",
"s9033": "DFlash2 のドラフトモデルをダウンロードできなかったため、MTP で起動します。モデルを読み込んでいます…",
"s9034": "モデルを読み込んでいます…",
"s9035": "次のモデルファイルが見つかりません: {0}。",
"s9036": "もう一度「ダウンロードして読み込む」を押すと取得します。すでにあるファイルはそのまま残ります。同じファイルがまた表示される場合は、詳細を開き、助けを求めるときに共有してください。",
"s9037": "このアップデートは、アプリの再起動で中断されました。その後のアップデートが完了しているため、対応は不要です。"
}
+181 -1
View File
@@ -3346,5 +3346,185 @@
"s8257": "{0}분마다",
"s8258": "이 Strata 엔진에는 사용하려던 그래픽 카드용 코드가 없습니다.",
"s8259": "“실행 장치”에서 다른 카드를 고르거나, “세부 정보”에서 찾은 카드와 엔진이 지원하는 카드를 확인하세요.",
"s8260": "이 스위치는 앱에서는 아무 일도 하지 않습니다. 어느 사이트의 웹 페이지든 ‘API 서비스’에 표시된 API를 이미 호출할 수 있습니다. 일부 사이트만 허용하거나 엔진의 자체 서버(포트 8080)를 쓰려면 모델의 설정 파일에 \"cors_origins\": [\"https://your.site\"] 를 추가하세요."
"s8260": "이 스위치는 앱에서는 아무 일도 하지 않습니다. 어느 사이트의 웹 페이지든 ‘API 서비스’에 표시된 API를 이미 호출할 수 있습니다. 일부 사이트만 허용하거나 엔진의 자체 서버(포트 8080)를 쓰려면 모델의 설정 파일에 \"cors_origins\": [\"https://your.site\"] 를 추가하세요.",
"s8301": "API 키 만들기(권장)",
"s8302": "API 키가 없으면 이 네트워크의 모든 기기가 모델을 사용할 수 있습니다.",
"s8303": "추론 API만 공유됩니다. 키를 설정한 경우 클라이언트에 API 키가 필요합니다. 신뢰할 수 있는 로컬 네트워크에서 사용하세요. 이 연결은 HTTP를 사용합니다.",
"s8304": "API를 보호할 키를 만드세요. 준비되면 이 화면으로 돌아옵니다. 키 없이 계속할 수도 있지만, 그러면 링크를 찾은 누구나 모델을 사용할 수 있습니다.",
"s8305": "이 컴퓨터에 이미 지원되는 모델이 있나요? 파일이 있는 폴더나 .gguf 파일 하나를 선택하세요. Strata가 파일을 확인하고 모델을 준비해 불러옵니다. 복사하거나 다운로드하지 않습니다.",
"s8306": "GGUF 파일이 있는 폴더 또는 .gguf 파일 하나",
"s8307": "폴더 또는 파일의 전체 경로",
"s8308": "API 키가 없으면 이 주소에 접속할 수 있는 누구나 모델을 사용할 수 있습니다.",
"s8309": "Strata가 {0}을(를) 모델 폴더로 사용할 수 없어({1}) 모델을 {2}에 저장합니다. 설정에서 다른 폴더를 고를 수 있습니다.",
"s8310": "포트 {0}을(를) 이미 사용 중입니다. Strata가 이미 실행 중일 수 있습니다. 해당 프로그램을 닫거나 다른 포트(STRATA_PORT)로 Strata를 시작하세요.",
"s8311": "Windows 또는 방화벽이 Strata의 포트 {0} 사용을 허용하지 않습니다. Hyper-V, WSL, Docker는 일부 포트 범위를 자체적으로 예약합니다(\"netsh int ipv4 show excludedportrange protocol=tcp\" 참고). 다른 포트(STRATA_PORT), 예를 들어 8801로 Strata를 시작하세요.",
"s8312": "{0}은(는) 이 컴퓨터의 주소가 아니어서 Strata가 해당 주소에서 연결을 받을 수 없습니다. STRATA_BIND를 확인하세요.",
"s8313": "Strata가 {0}:{1}에서 연결을 받을 수 없습니다({2}).",
"s8339": "모델 폴더",
"s8340": "Strata는 큰 모델 파일을 여기에 저장합니다.",
"s8341": "폴더 변경",
"s8342": "폴더를 기다리는 중…",
"s8343": "디스크 사용량",
"s8344": "{0} 여유",
"s8345": "(이 드라이브)",
"s8346": "모델이 차지하는 공간: {0}",
"s8347": "폴더를 바꾸려면 다운로드를 끝내거나 중지하세요.",
"s8348": "해당 폴더가 없습니다",
"s8349": "해당 폴더는 사용할 수 없습니다",
"s8350": "Strata가 {0}에 파일을 저장할 수 없습니다.",
"s8351": "Strata가 {0}을(를) 찾을 수 없습니다.",
"s8352": "현재 폴더 밖에 있는 폴더를 선택하세요.",
"s8353": "내 소유의 폴더(예: 문서 폴더나 다른 드라이브)를 선택하세요.",
"s8354": "드라이브가 연결되어 있는지 확인한 뒤 폴더를 다시 선택하세요. 모델은 그대로입니다.",
"s8355": "이 폴더를 모델용으로 사용할까요?",
"s8356": "새 폴더",
"s8357": "이 드라이브의 여유 공간: {0}",
"s8358": "드라이브 전체 {0}을(를) 선택했습니다. Strata는 자체 폴더에 파일을 보관하므로 모델은 {1}에 저장됩니다.",
"s8359": "다음으로 세 가지 선택지가 나옵니다.",
"s8360": "내 모델 이동",
"s8361": "두 폴더 모두 사용",
"s8362": "빈 상태로 시작",
"s8363": "모델을 이동하는 중",
"s8364": "이동 중지",
"s8365": "{0} / {1} GB",
"s8366": "1분 미만 남음",
"s8367": "약 1분 남음",
"s8368": "약 {0}분 남음",
"s8369": "약 {0}시간 남음",
"s8370": "Strata를 열어 두세요. 다른 앱은 계속 사용할 수 있습니다. 이동이 끝날 때까지 모델을 불러올 수 없습니다.",
"s8371": "복사 중: {0}, 파일 {1}/{2}",
"s8372": "{0}에서 {1}(으)로",
"s8373": "내 모델을 그쪽으로 이동",
"s8374": "이동할 모델이 없습니다.",
"s8375": "Strata가 모델 {0}을(를) 새 폴더로 옮깁니다. 같은 드라이브라 금방 끝납니다.",
"s8376": "Strata가 모델 {0}을(를) 새 폴더로 복사하고 확인한 뒤 기존 사본을 삭제합니다.",
"s8377": "추가 공간이 필요 없습니다.",
"s8378": "새 폴더에 {0}의 여유 공간이 필요합니다. 현재 {1}.",
"s8379": "지금 위치에 두고 새 폴더도 함께 사용",
"s8380": "아무것도 복사하지 않습니다. 현재 모델은 그대로 동작하고, 새로 받는 모델은 새 폴더에 저장됩니다.",
"s8381": "지금은 공간이 필요 없습니다. 새 다운로드는 {1} 안에 들어가야 합니다.",
"s8382": "지금은 공간이 필요 없습니다.",
"s8383": "앞으로 새 폴더를 사용하며 현재 모델은 목록에 표시하지 않습니다. 기존 파일은 삭제되지 않습니다.",
"s8384": "지금은 공간이 필요 없습니다. 나중에 받는 모델마다 각자의 공간이 필요합니다.",
"s8385": "모델에 {0}이(가) 필요한데 이 드라이브의 여유 공간은 {1}입니다.",
"s8386": "그곳에서 약 {0}을(를) 비우거나, 다른 폴더를 선택하거나, 나머지 두 가지 중 하나를 고르세요.",
"s8387": "모델을 어떻게 할까요",
"s8388": "실행 중인 모델은 먼저 중지됩니다. 이동이 끝나면 다시 불러오세요.",
"s8389": "{0}이(가) 이동됩니다.",
"s8390": "여유 공간은 {0}이(가) 있는 드라이브를 기준으로 측정합니다.",
"s8391": "기존 폴더: {0}",
"s8392": "Strata는 파일을 하나씩 복사하고, 크기를 확인한 뒤에 기존 파일을 삭제합니다.",
"s8393": "언제든 중지할 수 있으며, 이미 이동한 파일은 계속 사용할 수 있습니다.",
"s8394": "드라이브의 공간이 부족합니다",
"s8395": "이동이 중지되었습니다",
"s8396": "{1}을(를) 옮긴 뒤 {0}이(가) 가득 차서 이동을 중지했습니다.",
"s8397": "모델을 이동하지 못했습니다.",
"s8398": "잃어버린 것은 없습니다. 이미 이동한 모델은 새 폴더에 있고 나머지는 {0}에 그대로 있으며, 반쯤 복사된 파일은 삭제했습니다. {1}의 공간을 확보하거나 다른 폴더를 선택한 뒤 다시 시도하세요.",
"s8399": "잃어버린 것은 없습니다. 모든 모델이 {0}에 그대로 있으며, 반쯤 복사된 파일은 삭제했습니다. {1}의 공간을 확보하거나 다른 폴더를 선택한 뒤 다시 시도하세요.",
"s8400": "잃어버린 것은 없습니다. 이동하지 못한 모델은 {0}에 그대로 있습니다. 다른 폴더를 선택해 다시 시도하세요.",
"s8401": "이동 중지됨",
"s8402": "잃어버린 것은 없습니다.",
"s8403": "{0}은(는) 새 폴더에 있습니다.",
"s8404": "{0}은(는) 아직 기존 폴더에 있습니다.",
"s8405": "둘 다 사용할 수 있습니다.",
"s8406": "두 폴더의 모델 모두 평소처럼 목록에 표시됩니다. 나머지 이동은 나중에 '폴더 변경'으로 마칠 수 있습니다.",
"s8407": "모델 이동이 끝났습니다",
"s8408": "{0}이(가) 이제 {1}에 있습니다",
"s8409": "기존 사본을 삭제했으며 {1}에서 {0}이(가) 다시 확보되었습니다.",
"s8410": "이제 Strata가 {0}도 사용합니다. 새 다운로드는 그곳에 저장되고 현재 모델은 그대로 있습니다.",
"s8411": "이제 Strata가 {0}을(를) 사용합니다. 기존 모델 파일은 {1}에 그대로 있으며 Strata가 삭제하지 않습니다.",
"s8412": "모델 폴더 변경은 Strata 앱의 기능입니다.",
"s8413": "모델이 실행 중입니다. 먼저 중지한 뒤 폴더를 변경하세요.",
"s8414": "모델 폴더를 선택하세요.",
"s8415": "모델을 어떻게 할지 선택하세요.",
"s8416": "여기서는 해당 선택지를 사용할 수 없습니다.",
"s8417": "이동이 중단되었습니다. 마무리하거나 그대로 두세요.",
"s8418": "Strata가 모델을 {0}에서 {1}(으)로 옮기던 중 멈췄습니다. 잃어버린 것은 없습니다.",
"s8419": "이동 마무리",
"s8420": "그대로 두기",
"s8421": "마무리하면 모든 모델이 {1}에 들어갑니다. 그대로 두면 모델은 {0}에 남고 {1}의 불완전한 복사본이 삭제됩니다.",
"s8422": "불완전한 복사본을 삭제했습니다. 모델은 {0}에 있습니다.",
"s8601": "그래픽 카드",
"s8602": "메인",
"s8603": "헬퍼",
"s8604": "직접 고른 설정입니다.",
"s8605": "추천 설정입니다.",
"s8606": "역할을 변경했습니다. Strata는 이 설정을 그대로 사용합니다.",
"s8607": "추천: {0}",
"s8608": "{0}의 역할",
"s8609": "메모리 {0} GB",
"s8610": "컴퓨터 본체 메모리를 사용합니다 (내장 그래픽)",
"s8611": "주요 작업을 처리합니다",
"s8612": "모델의 일부를 담당합니다",
"s8613": "사용 안 함",
"s8614": "{0}에서 사용 중",
"s8615": "끄기",
"s8616": "{0}을(를) 메인으로 지정",
"s8617": "{0}에서는 Strata를 실행할 수 없습니다.",
"s8618": "{0}은(는) 내장 그래픽이라 메인으로 쓰면 느립니다.",
"s8619": "CPU만 사용하면 성능이 떨어집니다.",
"s8620": "이 엔진은 시작하려면 그래픽 카드가 필요합니다. 하나를 켜세요.",
"s8621": "이 설정에는 메모리가 부족합니다. 약 {0} GB를 드라이브에서 읽습니다.",
"s8622": "{0}이(가) 이제 헬퍼입니다.",
"s8623": "{0}이(가) 이제 메인입니다.",
"s8624": "켜져 있는 유일한 카드는 메인이어야 합니다.",
"s8625": "Strata가 사용할 수 있는 그래픽 카드를 찾지 못했습니다.",
"s8626": "Strata가 {0}을(를) 사용합니다.",
"s8627": "{0}이(가) 메인입니다: {1}.",
"s8628": "카드가 같습니다",
"s8629": "메모리가 더 많습니다",
"s8630": "더 빠릅니다",
"s8631": "유일한 카드입니다",
"s8632": "{0}이(가) 돕습니다.",
"s8633": "{0}이(가) 돕습니다.",
"s8634": "{0}은(는) 기본적으로 꺼집니다: {1}",
"s8635": "Strata에서 실행할 수 없습니다.",
"s8636": "내장 그래픽입니다.",
"s8637": "{0} GB 이하입니다.",
"s8638": "모든 답변의 마지막 단계를 맡는 카드입니다. 다음 단어를 쓰고, 미리 계산하는 단계를 실행해 확인하며, 대화를 보관합니다. 메모리가 넉넉한 가장 빠른 카드를 고르세요. 항상 하나뿐입니다.",
"s8639": "모델의 일부와 빠른 메모리를 담당해, 더 많은 부분이 드라이브가 아니라 그래픽 카드에 올라갑니다. 프롬프트를 약 20% 더 빠르게 읽습니다.",
"s8640": "Strata가 이 카드를 건드리지 않습니다. 게임이나 화면 출력 등 다른 용도로 쓰는 카드에 알맞습니다.",
"s8641": "모델에는 약 {0} GB가 필요합니다. 이 카드들과 메모리는 약 {1} GB를 제공하므로 충분합니다.",
"s8642": "모델에는 약 {0} GB가 필요합니다. 이 카드들과 메모리는 약 {1} GB를 제공하므로 약 {2} GB를 드라이브에서 읽게 됩니다.",
"s8643": "프로세서와 메모리만 사용합니다.",
"s8644": "전문가용: 엔진은 카드 {0}에서 시작합니다 (메인이 마지막).",
"s8645": "추천 설정을 유지하는 동안은 저장되지 않습니다. 카드를 추가하거나 빼면 Strata가 다시 고릅니다.",
"s8646": "사용 안 함: {0} (이 엔진이 다루는 것과 다른 제조사의 그래픽 카드입니다).",
"s9001": "이 메시지는 약 {0}토큰이고, 이 모델은 한 번에 약 {1}토큰까지 읽을 수 있습니다. 그대로 보내기에는 너무 큽니다.",
"s9002": "파일로 첨부해 어시스턴트가 나눠 읽게 하거나, 줄인 미리보기를 보내세요.",
"s9003": "이 대화는 더 이상 모델의 창에 맞지 않으며, 오래된 메시지를 요약해도 충분한 공간이 나오지 않았습니다.",
"s9004": "지금 요약하고 계속하거나, 새 채팅을 시작하세요.",
"s9005": "이전 메시지를 요약하는 중…",
"s9006": "공간을 만들기 위해 오래된 메시지를 요약했습니다. 채팅이 {0}% 찼던 것이 지금은 {1}%입니다.",
"s9007": "공간을 만들기 위해 오래된 메시지를 요약했습니다. 채팅이 {0}% 찼습니다.",
"s9008": "공간을 만들기 위해 오래된 메시지를 요약했습니다.",
"s9009": "이 채팅은 {0}% 찼지만 요약할 오래된 메시지가 없습니다. 가장 최근 메시지가 공간을 차지하고 있습니다. 새 채팅을 시작하거나 더 짧은 메시지를 보내세요.",
"s9010": "아직 요약할 오래된 메시지가 없습니다. 이 채팅의 내용은 어시스턴트가 아직 모두 필요로 합니다.",
"s9011": "아직 요약할 내용이 없습니다. 이 채팅은 아직 어시스턴트와 대화를 시작하지 않았습니다.",
"s9012": "지금은 오래된 메시지를 요약할 수 없었습니다. 잠시 후 다시 시도하거나 새 채팅을 시작하세요.",
"s9013": "이 채팅은 아직 작업 중입니다. 중지하거나 끝날 때까지 기다린 뒤 다시 시도하세요.",
"s9014": "파일로 첨부",
"s9015": "줄인 미리보기 보내기",
"s9016": "요약하고 계속하기",
"s9017": "새 채팅 시작",
"s9018": "메시지를 준비하지 못했습니다. 더 짧은 메시지를 시도해 보세요.",
"s9019": "지난 Strata 세션이 아직 종료되는 중입니다.",
"s9020": "잠시 기다렸다가 다시 시도하세요. 계속 반복되면 컴퓨터를 다시 시작하세요.",
"s9021": "초안 예측",
"s9022": "자동(권장)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "초안 예측: MTP(DFlash2가 들어가지 않았습니다)",
"s9026": "초안 예측: MTP(이 모드에서는 DFlash2를 쓸 수 없습니다)",
"s9027": "초안 예측: MTP(DFlash2는 Qwen3.8-Flash-Next 전용입니다)",
"s9028": "초안 예측: MTP(DFlash2 초안 모델이 아직 내려받아지지 않았습니다)",
"s9029": "DFlash2를 고르면 초안 모델도 한 번 내려받습니다(약 {0}GB). 그래픽 메모리에 들어가지 않으면 모델은 대신 MTP를 씁니다.",
"s9030": "DFlash2 초안 모델을 내려받는 중…",
"s9031": "엔진용 DFlash2 초안 모델을 준비하는 중…",
"s9032": "DFlash2 초안 모델이 준비되었습니다.",
"s9033": "DFlash2 초안 모델을 내려받지 못해 모델을 MTP로 시작합니다. 모델을 불러오는 중…",
"s9034": "모델을 불러오는 중…",
"s9035": "다음 모델 파일이 없습니다: {0}.",
"s9036": "'다운로드 및 불러오기'를 다시 누르면 가져옵니다. 이미 있는 파일은 그대로 둡니다. 같은 파일이 다시 표시되면 자세히 보기를 열어 도움을 요청할 때 함께 알려 주세요.",
"s9037": "이 업데이트는 앱이 다시 시작되면서 중단되었습니다. 이후 업데이트가 완료되었으므로 할 일은 없습니다."
}
+181 -1
View File
@@ -3346,5 +3346,185 @@
"s8257": "a cada {0} min",
"s8258": "Este motor do Strata não tem código para a placa de vídeo que tentou usar.",
"s8259": "Escolha outra placa em «Executar em» ou abra os detalhes para ver quais placas foram encontradas e para quais o motor foi compilado.",
"s8260": "Este botão não faz nada no app. Páginas web de qualquer site já podem chamar a API mostrada em API. Para permitir só alguns sites, ou para usar o servidor próprio do motor (porta 8080), adicione \"cors_origins\": [\"https://seu.site\"] ao arquivo de configuração do modelo."
"s8260": "Este botão não faz nada no app. Páginas web de qualquer site já podem chamar a API mostrada em API. Para permitir só alguns sites, ou para usar o servidor próprio do motor (porta 8080), adicione \"cors_origins\": [\"https://seu.site\"] ao arquivo de configuração do modelo.",
"s8301": "Criar uma chave de API (recomendado)",
"s8302": "Sem uma chave de API, qualquer dispositivo desta rede pode usar o seu modelo.",
"s8303": "Apenas a API de inferência é compartilhada. Os clientes precisam da sua chave de API quando houver uma. Use uma rede local confiável; esta conexão usa HTTP.",
"s8304": "Crie uma chave para proteger a sua API. Você voltará aqui quando ela estiver pronta. Também pode continuar sem ela, mas qualquer pessoa que encontrar o link poderá usar o seu modelo.",
"s8305": "Já tem um modelo compatível neste computador? Escolha a pasta com os arquivos ou um dos arquivos .gguf. O Strata verifica os arquivos, prepara o modelo e o carrega. Nada é copiado nem baixado.",
"s8306": "Pasta com os arquivos GGUF ou um arquivo .gguf",
"s8307": "Caminho completo da pasta ou do arquivo",
"s8308": "Sem uma chave de API, qualquer pessoa que consiga acessar este endereço pode usar o seu modelo.",
"s8309": "O Strata não conseguiu usar {0} para os modelos ({1}), então os modelos vão para {2}. Você pode escolher outra pasta em Configurações.",
"s8310": "A porta {0} já está em uso. O Strata pode já estar em execução; feche-o ou o outro programa, ou inicie o Strata com outra porta (STRATA_PORT).",
"s8311": "O Windows ou um firewall não deixa o Strata usar a porta {0}. Hyper-V, WSL e Docker reservam faixas de portas para si (veja \"netsh int ipv4 show excludedportrange protocol=tcp\"). Inicie o Strata com outra porta (STRATA_PORT), por exemplo 8801.",
"s8312": "{0} não é um endereço deste computador, então o Strata não pode escutar nele. Verifique STRATA_BIND.",
"s8313": "O Strata não consegue escutar em {0}:{1} ({2}).",
"s8339": "Pasta de modelos",
"s8340": "O Strata guarda aqui os arquivos grandes dos modelos.",
"s8341": "Alterar pasta",
"s8342": "Aguardando a pasta…",
"s8343": "Uso do disco",
"s8344": "{0} livres",
"s8345": "nesta unidade",
"s8346": "Seus modelos ocupam {0}",
"s8347": "Conclua ou pare o download para alterar a pasta.",
"s8348": "Essa pasta não existe",
"s8349": "Não é possível usar essa pasta",
"s8350": "O Strata não consegue salvar arquivos em {0}.",
"s8351": "O Strata não encontra {0}.",
"s8352": "Escolha uma pasta fora da atual.",
"s8353": "Escolha uma pasta sua, por exemplo em Documentos ou em outra unidade.",
"s8354": "Verifique se a unidade está conectada e escolha a pasta de novo. Seus modelos não foram alterados.",
"s8355": "Usar esta pasta para seus modelos?",
"s8356": "Pasta nova",
"s8357": "{0} livres nesta unidade",
"s8358": "Você escolheu a unidade inteira {0}. O Strata guarda seus arquivos em uma pasta própria, então seus modelos ficam em {1}.",
"s8359": "Em seguida, você verá as três opções.",
"s8360": "Mover meus modelos",
"s8361": "Usar as duas pastas",
"s8362": "Começar vazio",
"s8363": "Movendo seus modelos",
"s8364": "Parar a movimentação",
"s8365": "{0} de {1} GB",
"s8366": "Menos de um minuto restante",
"s8367": "Cerca de um minuto restante",
"s8368": "Cerca de {0} minutos restantes",
"s8369": "Cerca de {0} horas restantes",
"s8370": "Mantenha o Strata aberto. Você pode continuar usando outros aplicativos. Seus modelos só poderão ser carregados quando a movimentação terminar.",
"s8371": "Copiando agora: {0}, arquivo {1} de {2}",
"s8372": "De {0} para {1}",
"s8373": "Mover meus modelos para lá",
"s8374": "Você não tem modelos para mover.",
"s8375": "O Strata move seus {0} de modelos para a pasta nova. É a mesma unidade, então leva só um instante.",
"s8376": "O Strata copia seus {0} de modelos para a pasta nova, confere os arquivos e depois remove as cópias antigas.",
"s8377": "Não precisa de espaço extra.",
"s8378": "Precisa de {0} livres na pasta nova. Você tem {1}.",
"s8379": "Mantê-los onde estão e usar também a pasta nova",
"s8380": "Nada é copiado. Seus modelos atuais continuam funcionando e os novos downloads vão para a pasta nova.",
"s8381": "Não precisa de espaço agora. Os novos downloads precisam caber em {1}.",
"s8382": "Não precisa de espaço agora.",
"s8383": "O Strata passa a usar a pasta nova e não lista seus modelos atuais. Seus arquivos antigos não são apagados.",
"s8384": "Não precisa de espaço agora. Cada modelo baixado depois precisa do próprio espaço.",
"s8385": "Seus modelos precisam de {0}, e esta unidade tem {1} livres.",
"s8386": "Libere cerca de {0} lá, escolha outra pasta ou use uma das outras duas opções.",
"s8387": "O que deve acontecer com seus modelos",
"s8388": "Um modelo em execução é parado antes; carregue-o de novo quando a movimentação terminar.",
"s8389": "{0} seriam movidos.",
"s8390": "O espaço livre é medido na unidade que contém {0}.",
"s8391": "Pasta antiga: {0}",
"s8392": "O Strata copia um arquivo por vez e confere o tamanho antes de remover o antigo.",
"s8393": "Você pode parar a qualquer momento; os arquivos já movidos continuam utilizáveis.",
"s8394": "A unidade ficou sem espaço",
"s8395": "A movimentação parou",
"s8396": "{0} ficou cheia após {1}, então a movimentação parou.",
"s8397": "Não foi possível mover os modelos.",
"s8398": "Nada foi perdido. Os modelos já movidos estão na pasta nova, o restante continua em {0} e o arquivo copiado pela metade foi removido. Libere espaço em {1} ou escolha outra pasta e tente de novo.",
"s8399": "Nada foi perdido. Todos os seus modelos continuam em {0} e o arquivo copiado pela metade foi removido. Libere espaço em {1} ou escolha outra pasta e tente de novo.",
"s8400": "Nada foi perdido. Os modelos que não foram movidos continuam em {0}. Escolha outra pasta para tentar de novo.",
"s8401": "Movimentação interrompida",
"s8402": "Nada foi perdido.",
"s8403": "{0} está na pasta nova.",
"s8404": "{0} ainda está na pasta antiga.",
"s8405": "Ambos funcionam.",
"s8406": "Os modelos das duas pastas aparecem normalmente. Você pode concluir a movimentação depois, em “Alterar pasta”.",
"s8407": "Seus modelos foram movidos",
"s8408": "{0} agora estão em {1}",
"s8409": "As cópias antigas foram removidas e {0} voltaram a ficar livres em {1}.",
"s8410": "O Strata agora também usa {0}. Os novos downloads vão para lá, e seus modelos atuais ficam onde estão.",
"s8411": "O Strata agora usa {0}. Seus arquivos de modelo antigos continuam em {1}; o Strata não os apaga.",
"s8412": "Alterar a pasta de modelos faz parte do aplicativo Strata.",
"s8413": "Há um modelo em execução. Pare-o primeiro e depois altere a pasta.",
"s8414": "Escolha uma pasta de modelos.",
"s8415": "Escolha o que fazer com seus modelos.",
"s8416": "Essa opção não é possível aqui.",
"s8417": "Uma mudança foi interrompida. Termine-a ou deixe como estava.",
"s8418": "O Strata estava mudando seus modelos de {0} para {1} quando parou. Nada foi perdido.",
"s8419": "Terminar a mudança",
"s8420": "Deixar como está",
"s8421": "Terminar coloca todos os seus modelos em {1}. Deixar como está mantém os modelos em {0} e remove as cópias parciais de {1}.",
"s8422": "As cópias parciais foram removidas. Seus modelos estão em {0}.",
"s8601": "Placas de vídeo",
"s8602": "Principal",
"s8603": "Auxiliar",
"s8604": "Sua escolha.",
"s8605": "Recomendado.",
"s8606": "Você mudou as funções. O Strata usará exatamente estas.",
"s8607": "Recomendado: {0}",
"s8608": "Função de {0}",
"s8609": "{0} GB de memória",
"s8610": "Usa a memória do seu computador (gráficos integrados)",
"s8611": "Faz o trabalho principal",
"s8612": "Guarda parte do modelo",
"s8613": "Não usada",
"s8614": "Em uso por {0}",
"s8615": "Desligar",
"s8616": "Tornar {0} a principal",
"s8617": "{0} não consegue executar o Strata.",
"s8618": "{0} é um gráfico integrado; como principal é lenta.",
"s8619": "O desempenho fica reduzido usando apenas a CPU.",
"s8620": "Este motor precisa de uma placa de vídeo para iniciar. Ative uma.",
"s8621": "Memória insuficiente para esta configuração: cerca de {0} GB são lidos do disco.",
"s8622": "{0} agora é auxiliar.",
"s8623": "{0} agora é a principal.",
"s8624": "A única placa ligada tem de ser a principal.",
"s8625": "Nenhuma placa de vídeo que o Strata possa usar foi encontrada.",
"s8626": "O Strata usará a sua {0}.",
"s8627": "{0} é a principal: {1}.",
"s8628": "as placas são iguais",
"s8629": "tem mais memória",
"s8630": "é mais rápida",
"s8631": "é a única placa",
"s8632": "{0} ajudam.",
"s8633": "{0} ajuda.",
"s8634": "{0} começa desligada: {1}",
"s8635": "O Strata não consegue executá-la.",
"s8636": "gráficos integrados.",
"s8637": "{0} GB ou menos.",
"s8638": "A placa que faz os passos finais de cada resposta: escreve as próximas palavras, executa o passo de antecipação e o verifica, e guarda a sua conversa. Escolha a placa mais rápida com bastante memória. Há sempre só uma.",
"s8639": "Guarda uma parte do modelo e da sua memória rápida, para que mais caiba nas placas de vídeo em vez de ser lido do disco. Os prompts são lidos cerca de 20% mais rápido.",
"s8640": "O Strata não mexe nesta placa. Bom para uma placa que você usa para outra coisa, como um jogo ou a tela.",
"s8641": "O modelo precisa de cerca de {0} GB. Estas placas e a sua memória dão cerca de {1} GB. Cabe.",
"s8642": "O modelo precisa de cerca de {0} GB. Estas placas e a sua memória dão cerca de {1} GB, por isso cerca de {2} GB seriam lidos do disco.",
"s8643": "Apenas o processador e a memória são usados.",
"s8644": "Para especialistas: o motor inicia nas placas {0} (a principal por último).",
"s8645": "Não é guardado enquanto mantiver a recomendação: o Strata escolhe de novo se adicionar ou remover uma placa.",
"s8646": "Não usada: {0} (outra marca de placa de vídeo diferente da que este motor controla).",
"s9001": "Esta mensagem tem cerca de {0} tokens e este modelo consegue ler cerca de {1} de cada vez. É grande demais para ser enviada assim.",
"s9002": "Anexe-a como arquivo para o assistente ler por partes, ou envie uma prévia reduzida.",
"s9003": "Esta conversa já não cabe na janela do modelo, e resumir as mensagens mais antigas não liberou espaço suficiente.",
"s9004": "Resuma agora e continue, ou comece um novo chat.",
"s9005": "Resumindo as mensagens anteriores…",
"s9006": "As mensagens antigas foram resumidas para liberar espaço. O chat estava {0}% cheio e agora está {1}% cheio.",
"s9007": "As mensagens antigas foram resumidas para liberar espaço. O chat estava {0}% cheio.",
"s9008": "As mensagens antigas foram resumidas para liberar espaço.",
"s9009": "Este chat está {0}% cheio, mas não há nada antigo para resumir: a mensagem mais recente é o que o enche. Comece um novo chat ou envie uma mensagem mais curta.",
"s9010": "Ainda não há nada antigo para resumir. O assistente ainda precisa de tudo o que está neste chat.",
"s9011": "Ainda não há nada para resumir. Este chat ainda não iniciou uma conversa com o assistente.",
"s9012": "Não foi possível resumir as mensagens antigas agora. Tente novamente em instantes ou comece um novo chat.",
"s9013": "Este chat ainda está trabalhando. Pare-o ou espere terminar e tente novamente.",
"s9014": "Anexar como arquivo",
"s9015": "Enviar uma prévia reduzida",
"s9016": "Resumir e continuar",
"s9017": "Começar um novo chat",
"s9018": "Não foi possível preparar a mensagem. Tente uma mensagem mais curta.",
"s9019": "A última sessão do Strata ainda está sendo encerrada.",
"s9020": "Aguarde um momento e tente novamente. Se continuar acontecendo, reinicie o computador.",
"s9021": "Previsão",
"s9022": "Automático (recomendado)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "Previsão: MTP (o DFlash2 não coube)",
"s9026": "Previsão: MTP (o DFlash2 não está disponível neste modo)",
"s9027": "Previsão: MTP (o DFlash2 é só para o Qwen3.8-Flash-Next)",
"s9028": "Previsão: MTP (o preditor do DFlash2 ainda não foi baixado)",
"s9029": "Escolher o DFlash2 também baixa o preditor dele: cerca de {0} GB, uma vez. Se não couber na memória gráfica, o modelo usa o MTP.",
"s9030": "Baixando o preditor do DFlash2…",
"s9031": "Preparando o preditor do DFlash2 para o motor…",
"s9032": "O preditor do DFlash2 está pronto.",
"s9033": "Não foi possível baixar o preditor do DFlash2, então o modelo inicia com o MTP. Carregando o modelo…",
"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."
}
+181 -1
View File
@@ -3346,5 +3346,185 @@
"s8257": "каждые {0} мин",
"s8258": "В этом движке Strata нет кода для видеокарты, которую он попытался использовать.",
"s8259": "Выберите другую карту в разделе «Запускать на» или откройте «Подробности»: там указано, какие карты найдены и для каких собран движок.",
"s8260": "Этот переключатель в приложении ничего не делает. Веб-страницы любого сайта уже могут обращаться к API, который показан в «API». Чтобы разрешить только некоторые сайты или использовать собственный сервер движка (порт 8080), добавьте \"cors_origins\": [\"https://your.site\"] в файл настроек модели."
"s8260": "Этот переключатель в приложении ничего не делает. Веб-страницы любого сайта уже могут обращаться к API, который показан в «API». Чтобы разрешить только некоторые сайты или использовать собственный сервер движка (порт 8080), добавьте \"cors_origins\": [\"https://your.site\"] в файл настроек модели.",
"s8301": "Создать ключ API (рекомендуется)",
"s8302": "Без ключа API любое устройство в этой сети сможет пользоваться вашей моделью.",
"s8303": "Доступен только API инференса. Если ключ задан, клиентам нужен ваш ключ API. Используйте доверенную локальную сеть: это соединение работает по HTTP.",
"s8304": "Создайте ключ, чтобы защитить API. Когда он будет готов, вы вернётесь сюда. Можно продолжить и без ключа, но тогда любой, кто найдёт ссылку, сможет пользоваться вашей моделью.",
"s8305": "Уже есть поддерживаемая модель на этом компьютере? Выберите папку с её файлами или один из файлов .gguf. Strata проверит их, подготовит модель и загрузит её. Ничего не копируется и не скачивается.",
"s8306": "Папка с файлами GGUF или один файл .gguf",
"s8307": "Полный путь к папке или файлу",
"s8308": "Без ключа API любой, кто может обратиться к этому адресу, сможет пользоваться вашей моделью.",
"s8309": "Strata не смогла использовать {0} для моделей ({1}), поэтому модели сохраняются в {2}. Другую папку можно выбрать в настройках.",
"s8310": "Порт {0} уже занят. Возможно, Strata уже запущена; закройте её или другую программу либо запустите Strata с другим портом (STRATA_PORT).",
"s8311": "Windows или брандмауэр не разрешает Strata использовать порт {0}. Hyper-V, WSL и Docker резервируют для себя диапазоны портов (см. \"netsh int ipv4 show excludedportrange protocol=tcp\"). Запустите Strata с другим портом (STRATA_PORT), например 8801.",
"s8312": "{0} не является адресом этого компьютера, поэтому Strata не может его прослушивать. Проверьте STRATA_BIND.",
"s8313": "Strata не может прослушивать {0}:{1} ({2}).",
"s8339": "Папка моделей",
"s8340": "Strata хранит здесь большие файлы моделей.",
"s8341": "Сменить папку",
"s8342": "Ждём выбор папки…",
"s8343": "Занято на диске",
"s8344": "свободно {0}",
"s8345": "на этом диске",
"s8346": "Ваши модели занимают {0}",
"s8347": "Чтобы сменить папку, дождитесь конца загрузки или остановите её.",
"s8348": "Такой папки нет",
"s8349": "Эту папку использовать нельзя",
"s8350": "Strata не может сохранять файлы в {0}.",
"s8351": "Strata не находит {0}.",
"s8352": "Выберите папку вне текущей.",
"s8353": "Выберите свою папку, например в «Документах» или на другом диске.",
"s8354": "Проверьте, что диск подключён, и выберите папку снова. Ваши модели не затронуты.",
"s8355": "Использовать эту папку для моделей?",
"s8356": "Новая папка",
"s8357": "Свободно на этом диске: {0}",
"s8358": "Вы выбрали весь диск {0}. Strata хранит свои файлы в отдельной папке, поэтому модели будут в {1}.",
"s8359": "Дальше будут три варианта.",
"s8360": "Переместить мои модели",
"s8361": "Использовать обе папки",
"s8362": "Начать с пустой",
"s8363": "Перемещение моделей",
"s8364": "Остановить перемещение",
"s8365": "{0} из {1} ГБ",
"s8366": "Осталось меньше минуты",
"s8367": "Осталось около минуты",
"s8368": "Осталось около {0} мин.",
"s8369": "Осталось около {0} ч.",
"s8370": "Не закрывайте Strata. Другими приложениями можно пользоваться. Пока перемещение не закончится, модели загрузить нельзя.",
"s8371": "Сейчас копируется: {0}, файл {1} из {2}",
"s8372": "Из {0} в {1}",
"s8373": "Переместить мои модели туда",
"s8374": "Нет моделей для перемещения.",
"s8375": "Strata переместит ваши модели ({0}) в новую папку. Это тот же диск, поэтому это займёт мгновение.",
"s8376": "Strata скопирует ваши модели ({0}) в новую папку, проверит их и удалит старые копии.",
"s8377": "Дополнительное место не нужно.",
"s8378": "В новой папке нужно {0} свободного места. Сейчас доступно {1}.",
"s8379": "Оставить на месте и дополнительно использовать новую папку",
"s8380": "Ничего не копируется. Текущие модели продолжают работать, а новые загрузки идут в новую папку.",
"s8381": "Сейчас место не нужно. Новые загрузки должны поместиться в {1}.",
"s8382": "Сейчас место не нужно.",
"s8383": "Отныне Strata использует новую папку и не показывает текущие модели. Старые файлы не удаляются.",
"s8384": "Сейчас место не нужно. Каждой модели, которую вы загрузите позже, понадобится своё место.",
"s8385": "Вашим моделям нужно {0}, а на этом диске свободно {1}.",
"s8386": "Освободите там около {0}, выберите другую папку или один из двух других вариантов.",
"s8387": "Что сделать с вашими моделями",
"s8388": "Работающая модель сначала останавливается; загрузите её снова после перемещения.",
"s8389": "Будет перемещено: {0}.",
"s8390": "Свободное место измеряется на диске, где находится {0}.",
"s8391": "Старая папка: {0}",
"s8392": "Strata копирует файлы по одному и проверяет размер, прежде чем удалить старый.",
"s8393": "Остановить можно в любой момент; уже перемещённые файлы остаются пригодными.",
"s8394": "На диске закончилось место",
"s8395": "Перемещение остановлено",
"s8396": "Диск {0} заполнился после {1}, поэтому перемещение остановлено.",
"s8397": "Не удалось переместить модели.",
"s8398": "Ничего не потеряно. Уже перемещённые модели в новой папке, остальные по-прежнему в {0}, а наполовину скопированный файл удалён. Освободите место на {1} или выберите другую папку и повторите попытку.",
"s8399": "Ничего не потеряно. Все ваши модели по-прежнему в {0}, а наполовину скопированный файл удалён. Освободите место на {1} или выберите другую папку и повторите попытку.",
"s8400": "Ничего не потеряно. Неперемещённые модели по-прежнему в {0}. Выберите другую папку и повторите попытку.",
"s8401": "Перемещение остановлено",
"s8402": "Ничего не потеряно.",
"s8403": "{0}: в новой папке.",
"s8404": "{0}: всё ещё в старой папке.",
"s8405": "Работают оба.",
"s8406": "Модели из обеих папок отображаются как обычно. Закончить перемещение можно позже через «Сменить папку».",
"s8407": "Модели перемещены",
"s8408": "{0} теперь находятся в {1}",
"s8409": "Старые копии удалены, на {1} снова свободно {0}.",
"s8410": "Теперь Strata использует и {0}. Новые загрузки идут туда, а текущие модели остаются на месте.",
"s8411": "Теперь Strata использует {0}. Старые файлы моделей остаются в {1}; Strata их не удаляет.",
"s8412": "Смена папки моделей доступна в приложении Strata.",
"s8413": "Модель запущена. Сначала остановите её, затем смените папку.",
"s8414": "Выберите папку моделей.",
"s8415": "Выберите, что сделать с моделями.",
"s8416": "Этот вариант здесь невозможен.",
"s8417": "Перенос был прерван. Завершите его или оставьте как есть.",
"s8418": "Strata переносил ваши модели из {0} в {1}, когда остановился. Ничего не потеряно.",
"s8419": "Завершить перенос",
"s8420": "Оставить как есть",
"s8421": "Если завершить, все модели окажутся в {1}. Если оставить, модели останутся в {0}, а неполные копии в {1} будут удалены.",
"s8422": "Неполные копии удалены. Ваши модели находятся в {0}.",
"s8601": "Видеокарты",
"s8602": "Основная",
"s8603": "Помощник",
"s8604": "Ваш выбор.",
"s8605": "Рекомендуется.",
"s8606": "Вы изменили роли. Strata будет использовать именно их.",
"s8607": "Рекомендуется: {0}",
"s8608": "Роль {0}",
"s8609": "{0} ГБ памяти",
"s8610": "Использует память компьютера (встроенная графика)",
"s8611": "Выполняет основную работу",
"s8612": "Хранит часть модели",
"s8613": "Не используется",
"s8614": "Используется: {0}",
"s8615": "Выключить",
"s8616": "Сделать {0} основной",
"s8617": "{0} не может запускать Strata.",
"s8618": "{0} — встроенная графика; в роли основной она медленная.",
"s8619": "Только на процессоре производительность снижена.",
"s8620": "Для запуска этому движку нужна видеокарта. Включите одну.",
"s8621": "Для этой настройки не хватает памяти: около {0} ГБ читается с диска.",
"s8622": "{0} теперь помощник.",
"s8623": "{0} теперь основная.",
"s8624": "Единственная включённая карта должна быть основной.",
"s8625": "Не найдено видеокарт, которые может использовать Strata.",
"s8626": "Strata будет использовать ваш {0}.",
"s8627": "{0} — основная: {1}.",
"s8628": "карты одинаковые",
"s8629": "у неё больше памяти",
"s8630": "она быстрее",
"s8631": "это единственная карта",
"s8632": "{0} помогают.",
"s8633": "{0} помогает.",
"s8634": "{0} по умолчанию выключена: {1}",
"s8635": "Strata не может её запустить.",
"s8636": "встроенная графика.",
"s8637": "{0} ГБ или меньше.",
"s8638": "Карта, выполняющая последние шаги каждого ответа: пишет следующие слова, запускает шаг опережающего черновика и проверяет его, а также хранит вашу беседу. Выберите самую быструю карту с большим объёмом памяти. Основная карта всегда одна.",
"s8639": "Хранит часть модели и её быстрой памяти, поэтому больше помещается на видеокартах и меньше читается с диска. Запросы читаются примерно на 20 % быстрее.",
"s8640": "Strata не трогает эту карту. Подходит для карты, занятой другим, например игрой или выводом на экран.",
"s8641": "Модели нужно около {0} ГБ. Эти карты и ваша память дают около {1} ГБ. Помещается.",
"s8642": "Модели нужно около {0} ГБ. Эти карты и ваша память дают около {1} ГБ, поэтому около {2} ГБ придётся читать с диска.",
"s8643": "Используются только процессор и память.",
"s8644": "Для опытных: движок запускается на картах {0} (основная — последняя).",
"s8645": "Не сохраняется, пока вы оставляете рекомендацию: Strata выберет заново, если вы добавите или уберёте карту.",
"s8646": "Не используется: {0} (другой производитель видеокарт, чем тот, с которым работает этот движок).",
"s9001": "Это сообщение состоит примерно из {0} токенов, а эта модель за раз читает примерно {1}. Оно слишком большое, чтобы отправить его как есть.",
"s9002": "Прикрепите его как файл, чтобы ассистент прочитал его по частям, или отправьте сокращённый предпросмотр.",
"s9003": "Этот разговор больше не помещается в окно модели, а краткое изложение старых сообщений не освободило достаточно места.",
"s9004": "Сжать его сейчас и продолжить или начать новый чат.",
"s9005": "Кратко пересказываю предыдущие сообщения…",
"s9006": "Старые сообщения были кратко пересказаны, чтобы освободить место. Чат был заполнен на {0}%, а теперь на {1}%.",
"s9007": "Старые сообщения были кратко пересказаны, чтобы освободить место. Чат был заполнен на {0}%.",
"s9008": "Старые сообщения были кратко пересказаны, чтобы освободить место.",
"s9009": "Этот чат заполнен на {0}%, но пересказывать старые сообщения нечего: место занимает самое новое сообщение. Начните новый чат или отправьте сообщение покороче.",
"s9010": "Пока нечего пересказывать. Всё, что есть в этом чате, ассистенту ещё нужно.",
"s9011": "Пока нечего пересказывать. В этом чате ещё не начался разговор с ассистентом.",
"s9012": "Старые сообщения сейчас не удалось кратко пересказать. Попробуйте ещё раз через минуту или начните новый чат.",
"s9013": "Этот чат ещё работает. Остановите его или дождитесь окончания и попробуйте снова.",
"s9014": "Прикрепить как файл",
"s9015": "Отправить сокращённый предпросмотр",
"s9016": "Сжать и продолжить",
"s9017": "Начать новый чат",
"s9018": "Не удалось подготовить сообщение. Попробуйте сообщение покороче.",
"s9019": "Прошлый сеанс Strata ещё завершается.",
"s9020": "Подождите немного и попробуйте снова. Если это повторяется, перезагрузите компьютер.",
"s9021": "Предсказание",
"s9022": "Авто (рекомендуется)",
"s9023": "MTP",
"s9024": "DFlash2",
"s9025": "Предсказание: MTP (DFlash2 не поместился)",
"s9026": "Предсказание: MTP (DFlash2 недоступен в этом режиме)",
"s9027": "Предсказание: MTP (DFlash2 только для Qwen3.8-Flash-Next)",
"s9028": "Предсказание: MTP (черновая модель DFlash2 ещё не скачана)",
"s9029": "Если выбрать DFlash2, будет также скачана его черновая модель: около {0} ГБ, один раз. Если она не поместится в видеопамять, модель будет работать с MTP.",
"s9030": "Скачивается черновая модель DFlash2…",
"s9031": "Черновая модель DFlash2 готовится для движка…",
"s9032": "Черновая модель DFlash2 готова.",
"s9033": "Не удалось скачать черновую модель DFlash2, поэтому модель запускается с MTP. Загружается модель…",
"s9034": "Загружается модель…",
"s9035": "Не хватает этих файлов модели: {0}.",
"s9036": "Нажмите «Скачать и загрузить» ещё раз, чтобы получить их. Уже имеющиеся файлы сохраняются. Если те же файлы снова появятся в списке, откройте «Подробности» и приложите их, когда будете просить о помощи.",
"s9037": "Это обновление прервалось при перезапуске приложения. Более позднее обновление завершилось, так что делать ничего не нужно."
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "Verwende einen einfachen Servernamen mit höchstens 32 Zeichen: Buchstaben, Zahlen, Bindestriche oder Unterstriche.",
"tl02": "Diese Tool-Server wurden weggelassen, weil ihre Namen nicht erlaubt sind: {0}. Ein Name darf bis zu 32 Buchstaben, Zahlen, Bindestriche oder Unterstriche enthalten.",
"tl03": "Diese Tool-Server wurden weggelassen, weil sie kein Programm und keine Webadresse haben: {0}.",
"tl04": "Diese Tool-Server wurden weggelassen, weil ein anderer fast den gleichen Namen hat: {0}."
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "Use a simple server name of up to 32 characters: letters, numbers, hyphens or underscores.",
"tl02": "These tool servers were left out because their names are not allowed: {0}. A name can have up to 32 letters, numbers, hyphens or underscores.",
"tl03": "These tool servers were left out because they have no program or web address: {0}.",
"tl04": "These tool servers were left out because another one has almost the same name: {0}."
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "Usa un nombre de servidor sencillo de hasta 32 caracteres: letras, números, guiones o guiones bajos.",
"tl02": "Estos servidores de herramientas se han omitido porque sus nombres no están permitidos: {0}. Un nombre puede tener hasta 32 letras, números, guiones o guiones bajos.",
"tl03": "Estos servidores de herramientas se han omitido porque no tienen programa ni dirección web: {0}.",
"tl04": "Estos servidores de herramientas se han omitido porque otro tiene casi el mismo nombre: {0}."
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "サーバー名は32文字以内で、英字、数字、ハイフン、アンダースコアを使ってください。",
"tl02": "名前が使えないため、次のツールサーバーは除外されました: {0}。名前には、英字、数字、ハイフン、アンダースコアを32文字まで使えます。",
"tl03": "プログラムまたはWebアドレスがないため、次のツールサーバーは除外されました: {0}。",
"tl04": "ほぼ同じ名前の別のサーバーがあるため、次のツールサーバーは除外されました: {0}。"
}
+6
View File
@@ -0,0 +1,6 @@
{
"tl01": "32자 이내의 간단한 서버 이름을 사용하세요. 영문자, 숫자, 하이픈, 밑줄을 쓸 수 있습니다.",
"tl02": "이름을 사용할 수 없어 다음 도구 서버를 제외했습니다: {0}. 이름은 영문자, 숫자, 하이픈, 밑줄로 최대 32자까지 지을 수 있습니다.",
"tl03": "프로그램이나 웹 주소가 없어 다음 도구 서버를 제외했습니다: {0}.",
"tl04": "이름이 거의 같은 다른 서버가 있어 다음 도구 서버를 제외했습니다: {0}."
}
Loaded 100 of 564 files, more files were not shown because too many files have changed in this diff. Show more