mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-10 17:33:53 -10:00
Compare commits
404
Commits
v0.2.0-beta
...
v0.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
011c9e600a | ||
|
|
76f90e5843 | ||
|
|
328a3f5bd2 | ||
|
|
1df3a3108b | ||
|
|
daa654bd35 | ||
|
|
68864c564e | ||
|
|
2c27afec08 | ||
|
|
1c9bfd2a6e | ||
|
|
be4e24d421 | ||
|
|
3a7a6955cf | ||
|
|
4f864bcd2d | ||
|
|
2c8eb88196 | ||
|
|
e0cfb80f53 | ||
|
|
86d10ed8f9 | ||
|
|
01f8c32559 | ||
|
|
894f133b7d | ||
|
|
8a4106a47e | ||
|
|
4c3edf4765 | ||
|
|
f05725c332 | ||
|
|
8de1ec4165 | ||
|
|
3fbcea2f79 | ||
|
|
dc16e3a7cb | ||
|
|
5cdb59cfa9 | ||
|
|
0cf83f610a | ||
|
|
18b4affb87 | ||
|
|
aae92ceb26 | ||
|
|
926e7a38b8 | ||
|
|
366c8dbaae | ||
|
|
70f4240728 | ||
|
|
30a67051d0 | ||
|
|
b8b381a3b9 | ||
|
|
25d025fef8 | ||
|
|
8895949ef7 | ||
|
|
0da82e740c | ||
|
|
95cfbd4cc4 | ||
|
|
c8b1436022 | ||
|
|
9de04cd44e | ||
|
|
31ca248fbe | ||
|
|
63214a5655 | ||
|
|
86620837b2 | ||
|
|
75d0d0b966 | ||
|
|
6515aebac2 | ||
|
|
f801953992 | ||
|
|
53752401f2 | ||
|
|
ab61f892d8 | ||
|
|
034422c4d3 | ||
|
|
aece46db89 | ||
|
|
1e7bcb8b6e | ||
|
|
c5eb2dd923 | ||
|
|
88942998c5 | ||
|
|
0255581677 | ||
|
|
9b5b758358 | ||
|
|
29c811217f | ||
|
|
5f81c5dc47 | ||
|
|
cbf7f1d9f9 | ||
|
|
4e83ef04d5 | ||
|
|
34d7b2a3ba | ||
|
|
31fa54fb68 | ||
|
|
bc3f293861 | ||
|
|
21b0fdb0d4 | ||
|
|
564d0abebe | ||
|
|
413544b6d9 | ||
|
|
634e5826a5 | ||
|
|
c4f1e05dbd | ||
|
|
e2b4464917 | ||
|
|
10edc50f05 | ||
|
|
f353087104 | ||
|
|
79946b9c4f | ||
|
|
4cb96acd81 | ||
|
|
ca40c88ef4 | ||
|
|
e28889d120 | ||
|
|
890fee81b0 | ||
|
|
5e633b4ff7 | ||
|
|
7c11a196d5 | ||
|
|
8bf77c0755 | ||
|
|
8c41d3611c | ||
|
|
2aa1ec2d56 | ||
|
|
7a55995818 | ||
|
|
82a97251ea | ||
|
|
cbfd5b926b | ||
|
|
2e58e320b5 | ||
|
|
301ab7c7b4 | ||
|
|
0fce78c1c3 | ||
|
|
095e524fca | ||
|
|
88278af79a | ||
|
|
c864d804b6 | ||
|
|
51f418f15a | ||
|
|
9f38ee338c | ||
|
|
b2f2149431 | ||
|
|
ed61ad2a9c | ||
|
|
06e614017d | ||
|
|
2411e7cd3b | ||
|
|
8cfe44b71d | ||
|
|
1b47c1d076 | ||
|
|
b1594fbbe5 | ||
|
|
4dd62738df | ||
|
|
f6b4101127 | ||
|
|
d581286885 | ||
|
|
0b736f7702 | ||
|
|
b6825a493c | ||
|
|
c0b0869b03 | ||
|
|
2219cc99bf | ||
|
|
4681762e3c | ||
|
|
6b93b42877 | ||
|
|
d3cb2f58f1 | ||
|
|
9bb1dd2044 | ||
|
|
c3472caf8e | ||
|
|
f1bfcc2253 | ||
|
|
a64f1dd99b | ||
|
|
02791a6d18 | ||
|
|
672fd99ccc | ||
|
|
5b47f889b0 | ||
|
|
5db632e72a | ||
|
|
f5c4047e78 | ||
|
|
1f727f4b52 | ||
|
|
ebe4db78d4 | ||
|
|
dea6920b81 | ||
|
|
9e24f4ab32 | ||
|
|
dcbaa992d2 | ||
|
|
79cb59c574 | ||
|
|
ae456dfe5c | ||
|
|
176e11a597 | ||
|
|
5dc151d4d3 | ||
|
|
0dbea601a0 | ||
|
|
3d5594d656 | ||
|
|
284552fe26 | ||
|
|
c24f2a139f | ||
|
|
bd4c6bf1af | ||
|
|
fa56c2b387 | ||
|
|
cf34e915c4 | ||
|
|
14be49605f | ||
|
|
6b29adad1a | ||
|
|
e728a132d7 | ||
|
|
32b9f75376 | ||
|
|
1972af6da1 | ||
|
|
7db513297d | ||
|
|
bf2926e05a | ||
|
|
4dee320dd5 | ||
|
|
c316a7c215 | ||
|
|
73b68dfd88 | ||
|
|
ce938fb8c9 | ||
|
|
fb2f387407 | ||
|
|
9d2870d187 | ||
|
|
b611ec5f1e | ||
|
|
ddbbda4824 | ||
|
|
386155fa25 | ||
|
|
ff64e08bba | ||
|
|
a569d80680 | ||
|
|
461d0baee7 | ||
|
|
ef5d8ab946 | ||
|
|
bcda2e4b0d | ||
|
|
34d6c37df3 | ||
|
|
f207907b14 | ||
|
|
7ebdd34356 | ||
|
|
aa44b9ce53 | ||
|
|
af189129a4 | ||
|
|
c6a0e739be | ||
|
|
e52668857c | ||
|
|
c0c3a06ce4 | ||
|
|
f39d951904 | ||
|
|
968fcb70b0 | ||
|
|
b486d6f2b4 | ||
|
|
43127e3982 | ||
|
|
6c688faf22 | ||
|
|
0a35538b74 | ||
|
|
72f66767ca | ||
|
|
031adf99bf | ||
|
|
e3040e9e25 | ||
|
|
6f1eae68ff | ||
|
|
085474b8dc | ||
|
|
1d6a040129 | ||
|
|
a0e09803ac | ||
|
|
2fcc25aab6 | ||
|
|
67bb56bd1d | ||
|
|
d511c5102e | ||
|
|
d408fa2ff1 | ||
|
|
970c75c8f6 | ||
|
|
ee8e6404b0 | ||
|
|
17cb817594 | ||
|
|
594e379291 | ||
|
|
fd4f7d445c | ||
|
|
226b5f5492 | ||
|
|
9dc1c96377 | ||
|
|
4e1e6bad4e | ||
|
|
3de97a8892 | ||
|
|
c23a4a5827 | ||
|
|
4aabf3b8f5 | ||
|
|
6ce6413606 | ||
|
|
4fd5b0646d | ||
|
|
703daada09 | ||
|
|
d69c9c4b4f | ||
|
|
b22206c7bc | ||
|
|
a4f7975324 | ||
|
|
bcf88b7383 | ||
|
|
6a55bcb5e9 | ||
|
|
c7938c2d3e | ||
|
|
69a6db1904 | ||
|
|
1b6f782faf | ||
|
|
215aa14f25 | ||
|
|
4fc2e140bd | ||
|
|
740acc8934 | ||
|
|
72fc52251b | ||
|
|
0c79d7db94 | ||
|
|
d298999c3c | ||
|
|
632b4ed283 | ||
|
|
b111d1e830 | ||
|
|
56dee6b938 | ||
|
|
d101771758 | ||
|
|
a5b1c2cefa | ||
|
|
ca1d469e9a | ||
|
|
47d9d6c75a | ||
|
|
f2709679db | ||
|
|
c330caf4da | ||
|
|
7adced3db5 | ||
|
|
a0b6d1cd11 | ||
|
|
774096d16b | ||
|
|
76186bb595 | ||
|
|
e346198ff3 | ||
|
|
98faf8139e | ||
|
|
17b93dcb7c | ||
|
|
52ecf67ef7 | ||
|
|
f74ba2ea53 | ||
|
|
4fb0dde729 | ||
|
|
f94a34c330 | ||
|
|
c7c0e37bc4 | ||
|
|
2f4e42b9cf | ||
|
|
d61a443eb8 | ||
|
|
52cc6b4af0 | ||
|
|
31361a98bf | ||
|
|
87b7bb2ccb | ||
|
|
48c34f735a | ||
|
|
537f8fef8e | ||
|
|
83ae413176 | ||
|
|
80f7679b61 | ||
|
|
b8488e0d2b | ||
|
|
d0afabb6ab | ||
|
|
656478c68d | ||
|
|
fccfd343d7 | ||
|
|
5a9b4f4fc4 | ||
|
|
4509f2f997 | ||
|
|
f9e1fff983 | ||
|
|
a6ffc04d6d | ||
|
|
a5c7abe074 | ||
|
|
2793e3bd33 | ||
|
|
1b9b768939 | ||
|
|
642425bf2e | ||
|
|
cc500be1c9 | ||
|
|
13806c323e | ||
|
|
2f361a3757 | ||
|
|
2dfb254f7b | ||
|
|
28b150f854 | ||
|
|
676b57bff2 | ||
|
|
9424830516 | ||
|
|
00e8d10994 | ||
|
|
bf69ec2da4 | ||
|
|
7924bf65d2 | ||
|
|
867d7477ff | ||
|
|
f5410a6feb | ||
|
|
bb7e399c30 | ||
|
|
4bd5912695 | ||
|
|
8c07c0c36a | ||
|
|
9ae93f7b1f | ||
|
|
9f13d1e78f | ||
|
|
a0abdf7588 | ||
|
|
149abc896b | ||
|
|
069384b4f6 | ||
|
|
c644312d15 | ||
|
|
ac71d2b32e | ||
|
|
c093ac263f | ||
|
|
436fbd0a6e | ||
|
|
7274c10e9a | ||
|
|
f0afedc50c | ||
|
|
5bda973a43 | ||
|
|
b5d14279aa | ||
|
|
2e12958759 | ||
|
|
e4d22e7ef1 | ||
|
|
e59e64ec5d | ||
|
|
393eec80f9 | ||
|
|
06425cf932 | ||
|
|
afbd64bc4f | ||
|
|
2614b82c9a | ||
|
|
5b8cdaf2d9 | ||
|
|
ea6b385df8 | ||
|
|
8ca9093d7d | ||
|
|
ec841ca1e9 | ||
|
|
c4442fed95 | ||
|
|
2496795bd9 | ||
|
|
8d29804204 | ||
|
|
1523c592a6 | ||
|
|
f8c4410a38 | ||
|
|
df4706577e | ||
|
|
afd1f61cc5 | ||
|
|
31d857f817 | ||
|
|
4126250bf1 | ||
|
|
709f8dc63d | ||
|
|
3985c50777 | ||
|
|
f68ccf30c7 | ||
|
|
90767252f4 | ||
|
|
a7bdbfdabc | ||
|
|
9944d2f7f1 | ||
|
|
b42ec662c7 | ||
|
|
b35a9816c7 | ||
|
|
d33bb832ca | ||
|
|
3e7c5d8e1c | ||
|
|
894d1be1f4 | ||
|
|
93aa62ed64 | ||
|
|
83027df587 | ||
|
|
484e06b961 | ||
|
|
ed4595ad69 | ||
|
|
06d7604af7 | ||
|
|
ecf2bdcb7f | ||
|
|
3f1fa67f14 | ||
|
|
881a42adab | ||
|
|
3616148bbf | ||
|
|
24e56539c5 | ||
|
|
8e957cd53b | ||
|
|
4f6ec303b5 | ||
|
|
1f52a00373 | ||
|
|
e68a032a58 | ||
|
|
10715d856d | ||
|
|
e45a9bfd3a | ||
|
|
34e7c5cb1a | ||
|
|
8aaf188aa9 | ||
|
|
22703894ec | ||
|
|
b1713b3ff2 | ||
|
|
0875029d10 | ||
|
|
952cce7752 | ||
|
|
30c64f09d7 | ||
|
|
cbc0e329c6 | ||
|
|
f680a80d88 | ||
|
|
d8a6a0e012 | ||
|
|
3cc18d7bb7 | ||
|
|
1994f3acb2 | ||
|
|
c911065a14 | ||
|
|
40c605f7bc | ||
|
|
96a9107a27 | ||
|
|
4e76d6c9a6 | ||
|
|
8441cfd0e2 | ||
|
|
9cc2a9b6aa | ||
|
|
9b8436df4e | ||
|
|
c6a20b7e85 | ||
|
|
88b29d481a | ||
|
|
2efa4e067a | ||
|
|
eb7eac250b | ||
|
|
9f5488e3b2 | ||
|
|
cac4816afd | ||
|
|
4ed8eb02fa | ||
|
|
b203b5f71e | ||
|
|
468e3a6252 | ||
|
|
c6aa4223d9 | ||
|
|
c23a78694e | ||
|
|
509466a668 | ||
|
|
a10777a671 | ||
|
|
bcf67d1774 | ||
|
|
c8e5d2f3ce | ||
|
|
6d41b37e76 | ||
|
|
e1ef9df21c | ||
|
|
dab7ee72d0 | ||
|
|
6b28dbbd1e | ||
|
|
e0e0c0545a | ||
|
|
8acee5a055 | ||
|
|
dca9770ef4 | ||
|
|
ff18fbb868 | ||
|
|
cf4749276d | ||
|
|
578bb6aa7a | ||
|
|
1073929c80 | ||
|
|
e5dc00c81e | ||
|
|
1c3c6d3351 | ||
|
|
ce968a2fac | ||
|
|
40d1a3e7d7 | ||
|
|
a269d1b983 | ||
|
|
9d2cb4f6d2 | ||
|
|
74e25e165b | ||
|
|
f5690d51a8 | ||
|
|
e2071c2f8b | ||
|
|
f2345ebd66 | ||
|
|
671f4573da | ||
|
|
3d0a79ca23 | ||
|
|
f523a94227 | ||
|
|
267f35f777 | ||
|
|
71bdfda0b3 | ||
|
|
7662bf85c4 | ||
|
|
44a56865f5 | ||
|
|
7f037f4451 | ||
|
|
5c9884cdee | ||
|
|
5b4db785ca | ||
|
|
321f159032 | ||
|
|
54a02dc32c | ||
|
|
40bf5f5c40 | ||
|
|
bc54d945f6 | ||
|
|
7a2bfc52ae | ||
|
|
e88ee39d1d | ||
|
|
a5ee74ffa3 | ||
|
|
74652692e1 | ||
|
|
85f77658f3 | ||
|
|
f57c0adcb3 | ||
|
|
3fa1b524d7 | ||
|
|
76e51103c7 | ||
|
|
fcca2ccb84 | ||
|
|
eac694ae4c | ||
|
|
4caf857c76 | ||
|
|
e5d987a553 | ||
|
|
dde39e8a8f | ||
|
|
03b75b8da9 |
No files matched your search
@@ -66,7 +66,7 @@ body:
|
||||
attributes:
|
||||
label: Strata version
|
||||
description: "In the app: Settings > About. In the Terminal: `strata --version`. From source: the About tab of the web page, or `engine` in `GET /v1/status`, or the git commit."
|
||||
placeholder: "Strata 0.2.0-beta · engine 0.1.45.3"
|
||||
placeholder: "Strata 0.2.2 · engine 0.1.47"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
'use strict';
|
||||
// Format check for issues and pull requests (see CONTRIBUTING.md and .github/ISSUE_TEMPLATE/*.yml).
|
||||
// Pure functions only: no network, no filesystem. Used by intake.yml and reopen.yml through reset-bot.js.
|
||||
|
||||
const AREAS = ['Engine', 'GUI', 'TUI', 'Benchmark', 'Docs', 'Other'];
|
||||
const TITLE_RE = new RegExp('^\\[(' + AREAS.join('|') + ')\\] \\S');
|
||||
const NO_ANSWER = /^(_no response_|none|n\/a|na|-|todo|tbd|\.|\?)$/i;
|
||||
const NOT_RUN_LABELS = /^\*\*(passed|failed|not run)\s*:?\*\*\s*:?/gim;
|
||||
|
||||
// Version the reopened work must be tested on.
|
||||
const CURRENT_VERSION_RE = /\b0\.2\.\d+|0\.2\.0-beta/i;
|
||||
// Any version or commit.
|
||||
const ANY_VERSION_RE = /\bv?\d+\.\d+(\.\d+)*(-[a-z0-9.]+)?\b|\b[0-9a-f]{7,40}\b|\bcommit\b/i;
|
||||
const HARDWARE_RE = /\b(rtx|gtx|geforce|quadro|tesla|radeon|rx ?\d|instinct|arc|a\d{3,4}|b\d{2,3}|apple|m[1-5]( pro| max| ultra)?|nvidia|amd|intel|gpu|cpu|vram|\d+ ?gb|ram|windows|linux|ubuntu|debian|fedora|arch|macos|mac os|wsl)\b/i;
|
||||
const REVISION_RE = /0\.2\.\d|beta|\bmain\b|\b[0-9a-f]{7,40}\b/i;
|
||||
|
||||
function stripComments(text) {
|
||||
return String(text || '').replace(/<!--[\s\S]*?-->/g, '');
|
||||
}
|
||||
|
||||
function hasAreaPrefix(title) {
|
||||
return TITLE_RE.test(String(title || ''));
|
||||
}
|
||||
|
||||
// Issue forms render as "### Label\n\nvalue". Returns { label: value } for those headings.
|
||||
function parseSections(body) {
|
||||
const out = {};
|
||||
let cur = null;
|
||||
for (const line of stripComments(body).split(/\r?\n/)) {
|
||||
const m = /^#{1,6}\s+(.+?)\s*#*\s*$/.exec(line);
|
||||
if (m) {
|
||||
cur = m[1].trim();
|
||||
out[cur] = '';
|
||||
} else if (cur !== null) {
|
||||
out[cur] += line + '\n';
|
||||
}
|
||||
}
|
||||
for (const k of Object.keys(out)) out[k] = out[k].trim();
|
||||
return out;
|
||||
}
|
||||
|
||||
function realText(value) {
|
||||
const t = String(value || '').replace(NOT_RUN_LABELS, '').replace(/\s+/g, ' ').trim();
|
||||
return NO_ANSWER.test(t) ? '' : t;
|
||||
}
|
||||
|
||||
function findSection(sections, re) {
|
||||
for (const k of Object.keys(sections)) if (re.test(k)) return { key: k, value: sections[k] };
|
||||
return null;
|
||||
}
|
||||
|
||||
// Required parts of each issue form: [display name, heading regex, minimum characters of real text].
|
||||
const FORMS = {
|
||||
bug: {
|
||||
detect: /what happened/i,
|
||||
parts: [
|
||||
['Area', /^area$/i, 2],
|
||||
['Desktop app, Terminal or source?', /desktop app, terminal or source/i, 2],
|
||||
['What happened', /what happened/i, 20],
|
||||
['Reproduction status', /reproduction status/i, 2],
|
||||
['Strata version', /strata version/i, 3],
|
||||
['Graphics card, memory and system', /graphics card|memory and system/i, 3],
|
||||
],
|
||||
needsCurrentVersion: 'Strata version',
|
||||
},
|
||||
feature: {
|
||||
detect: /what do you want to do/i,
|
||||
parts: [
|
||||
['Area', /^area$/i, 2],
|
||||
['What do you want to do?', /what do you want to do/i, 20],
|
||||
],
|
||||
},
|
||||
question: {
|
||||
detect: /your question/i,
|
||||
parts: [
|
||||
['Area', /^area$/i, 2],
|
||||
['Your question', /your question/i, 15],
|
||||
],
|
||||
},
|
||||
benchmark: {
|
||||
detect: /what did you measure/i,
|
||||
parts: [
|
||||
['What did you measure, or what went wrong', /what did you measure/i, 30],
|
||||
['Reproduction status', /reproduction status/i, 2],
|
||||
],
|
||||
needsCurrentVersionInBody: true,
|
||||
},
|
||||
};
|
||||
|
||||
function checkIssue({ title, body, labels = [], mode = 'intake' }) {
|
||||
const missing = [];
|
||||
if (!hasAreaPrefix(title)) {
|
||||
missing.push('Start the title with its area: [Engine], [GUI], [TUI], [Benchmark], [Docs] or [Other], then a short description. Example: "[Engine] decode stops after 2 minutes".');
|
||||
}
|
||||
const text = stripComments(body);
|
||||
const sections = parseSections(text);
|
||||
const labelSet = new Set(labels.map((l) => String(l).toLowerCase()));
|
||||
const needCurrent = mode === 'reopen';
|
||||
|
||||
let formKey = null;
|
||||
for (const [key, f] of Object.entries(FORMS)) {
|
||||
if (findSection(sections, f.detect)) { formKey = key; break; }
|
||||
}
|
||||
|
||||
if (formKey) {
|
||||
const form = FORMS[formKey];
|
||||
for (const [name, re, min] of form.parts) {
|
||||
const s = findSection(sections, re);
|
||||
if (!s || realText(s.value).length < min) missing.push('Fill in "' + name + '".');
|
||||
}
|
||||
if (needCurrent && form.needsCurrentVersion) {
|
||||
const s = findSection(sections, /strata version/i);
|
||||
if (s && realText(s.value) && !CURRENT_VERSION_RE.test(s.value)) {
|
||||
missing.push('Re-test on Strata 0.2.0-beta and put that version in "' + form.needsCurrentVersion + '". If it no longer happens, say so in a comment instead.');
|
||||
}
|
||||
}
|
||||
if (needCurrent && form.needsCurrentVersionInBody && !CURRENT_VERSION_RE.test(text)) {
|
||||
missing.push('Say which Strata version you measured on. Re-measure on 0.2.0-beta if you can.');
|
||||
}
|
||||
return { ok: missing.length === 0, missing, kind: formKey };
|
||||
}
|
||||
|
||||
// Free-form text (older issues, blank issues).
|
||||
const plain = realText(text);
|
||||
const lenient = labelSet.has('enhancement') || labelSet.has('question');
|
||||
if (plain.length < (lenient ? 40 : 80)) {
|
||||
missing.push('Describe it in the issue body: what happened, what you expected, and the exact error if there is one. The issue forms (New issue > Bug report) list what to include.');
|
||||
}
|
||||
if (!lenient) {
|
||||
if (!ANY_VERSION_RE.test(text)) missing.push('Add the Strata version (Settings > About, or `strata --version`).');
|
||||
else if (needCurrent && !CURRENT_VERSION_RE.test(text)) missing.push('Re-test on Strata 0.2.0-beta and name that version in the issue. If it no longer happens, say so in a comment instead.');
|
||||
if (!HARDWARE_RE.test(text)) missing.push('Add your graphics card with its VRAM, your RAM and your operating system.');
|
||||
}
|
||||
return { ok: missing.length === 0, missing, kind: 'free-form' };
|
||||
}
|
||||
|
||||
function checkPull({ title, body, mode = 'intake' }) {
|
||||
const missing = [];
|
||||
if (!hasAreaPrefix(title)) {
|
||||
missing.push('Start the title with its area: [Engine], [GUI], [TUI], [Benchmark], [Docs] or [Other], then a short description. Example: "[Engine] fix decode stop on long prompts".');
|
||||
}
|
||||
const text = stripComments(body);
|
||||
const sections = parseSections(text);
|
||||
const problem = findSection(sections, /problem|result|summary|what.*change/i);
|
||||
const validation = findSection(sections, /validation|testing|tests?\b|how.*tested/i);
|
||||
|
||||
let valText = '';
|
||||
if (problem || validation) {
|
||||
if (!problem || realText(problem.value).length < 20) missing.push('Fill in "Problem and result": what triggered the change, and how it behaves before and after.');
|
||||
if (!validation || realText(validation.value).length < 20) {
|
||||
missing.push('Fill in "Validation": the exact commands, the revision and environment tested, what you saw. Keep Passed, Failed and Not run apart.');
|
||||
} else {
|
||||
valText = validation.value;
|
||||
}
|
||||
} else {
|
||||
const plain = realText(text);
|
||||
if (plain.length < 150) {
|
||||
missing.push('Describe the pull request: the problem and the result. The pull request template shows the parts (Problem and result, Related discussion, Approach, Validation, Limits).');
|
||||
}
|
||||
if (!/(problem|result|before|after|fix|because|bug)/i.test(plain)) missing.push('Add "Problem and result": what triggered the change, and how it behaves before and after.');
|
||||
if (!/(validat|tested|passed|ran |built|build|not run)/i.test(plain)) missing.push('Add "Validation": the exact commands, the revision and environment tested, and what you saw.');
|
||||
valText = plain;
|
||||
}
|
||||
if (mode === 'reopen' && valText && !REVISION_RE.test(valText)) {
|
||||
missing.push('Say in Validation which revision you tested: rebase on main (or 0.2.0-beta) and re-run your checks.');
|
||||
}
|
||||
return { ok: missing.length === 0, missing, kind: 'pull' };
|
||||
}
|
||||
|
||||
function checkItem({ isPull, ...rest }) {
|
||||
return isPull ? checkPull(rest) : checkIssue(rest);
|
||||
}
|
||||
|
||||
function checklist(missing) {
|
||||
return missing.map((m) => '- [ ] ' + m).join('\n');
|
||||
}
|
||||
|
||||
const CONTRIB = 'https://github.com/Niko1221/Strata/blob/main/CONTRIBUTING.md';
|
||||
|
||||
function intakeComment(isPull, missing) {
|
||||
return [
|
||||
'Thanks for the ' + (isPull ? 'pull request' : 'issue') + '. To keep the tracker readable, every item needs an area prefix in the title and the parts of the ' + (isPull ? 'pull request template' : 'issue form') + ' filled in. This one is missing:',
|
||||
'',
|
||||
checklist(missing),
|
||||
'',
|
||||
'Edit the ' + (isPull ? 'pull request' : 'issue') + ' (title and description) and this note goes away on its own. If it is still missing parts in 5 days it will be closed, which is not a rejection: fix it and comment `/reopen`.',
|
||||
'',
|
||||
'Details: [CONTRIBUTING.md](' + CONTRIB + ')',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function reopenMissingComment(isPull, missing) {
|
||||
return [
|
||||
'Not reopened yet. Still missing:',
|
||||
'',
|
||||
checklist(missing),
|
||||
'',
|
||||
'Edit the ' + (isPull ? 'pull request' : 'issue') + ' and comment `/reopen` again. Details: [CONTRIBUTING.md](' + CONTRIB + ')',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function reopenOkComment(isPull) {
|
||||
return 'Reopened, thank you. A maintainer will pick it up from here.' + (isPull ? ' If it conflicts with main, rebase it first; that speeds up review.' : '');
|
||||
}
|
||||
|
||||
function staleComment(isPull) {
|
||||
return [
|
||||
'Closing this ' + (isPull ? 'pull request' : 'issue') + ' because it still did not follow the format after 5 days. That is housekeeping, not a rejection.',
|
||||
'',
|
||||
'To bring it back: fix the title (area prefix) and the missing parts, then comment `/reopen`. A check runs and reopens it, or tells you what is still missing. See [CONTRIBUTING.md](' + CONTRIB + ').',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AREAS, TITLE_RE, hasAreaPrefix, parseSections, stripComments, realText,
|
||||
checkIssue, checkPull, checkItem, checklist,
|
||||
intakeComment, reopenMissingComment, reopenOkComment, staleComment,
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const c = require('./item-check');
|
||||
const bot = require('./reset-bot');
|
||||
|
||||
const bugForm = (over = {}) => {
|
||||
const f = {
|
||||
'Area': '[Engine] (speed, kernels, models, the server and API)',
|
||||
'Desktop app, Terminal or source?': 'Terminal (the `strata` command)',
|
||||
'What happened': 'Decode stops after two minutes with "CUDA error 700". I expected it to continue.',
|
||||
'Smallest steps or command that reproduce it': '_No response_',
|
||||
'Reproduction status': 'Reproduced locally',
|
||||
'Strata version': 'Strata 0.2.0-beta · engine 0.1.45.3',
|
||||
'Graphics card, memory and system': 'RTX 5070 12 GB · 64 GB RAM · Windows 11',
|
||||
...over,
|
||||
};
|
||||
return Object.entries(f).map(([k, v]) => `### ${k}\n\n${v}`).join('\n\n');
|
||||
};
|
||||
|
||||
test('title prefix', () => {
|
||||
assert.ok(c.hasAreaPrefix('[Engine] fix decode'));
|
||||
assert.ok(c.hasAreaPrefix('[Docs] x'));
|
||||
assert.ok(!c.hasAreaPrefix('Engine fix'));
|
||||
assert.ok(!c.hasAreaPrefix('[engine] fix'));
|
||||
assert.ok(!c.hasAreaPrefix('[Engine]'));
|
||||
assert.ok(!c.hasAreaPrefix('[Engine] '));
|
||||
assert.ok(!c.hasAreaPrefix('[Area] foo'));
|
||||
assert.ok(!c.hasAreaPrefix(''));
|
||||
assert.ok(!c.hasAreaPrefix(undefined));
|
||||
});
|
||||
|
||||
test('filled bug form passes in both modes', () => {
|
||||
for (const mode of ['intake', 'reopen']) {
|
||||
const r = c.checkIssue({ title: '[Engine] decode stops', body: bugForm(), labels: ['bug'], mode });
|
||||
assert.deepStrictEqual(r.missing, []);
|
||||
assert.strictEqual(r.kind, 'bug');
|
||||
}
|
||||
});
|
||||
|
||||
test('bug form: missing parts are named', () => {
|
||||
const r = c.checkIssue({ title: 'decode stops', body: bugForm({ 'Strata version': '_No response_', 'What happened': 'bad' }) });
|
||||
assert.ok(!r.ok);
|
||||
assert.ok(r.missing.some((m) => /title/i.test(m)));
|
||||
assert.ok(r.missing.some((m) => m.includes('What happened')));
|
||||
assert.ok(r.missing.some((m) => m.includes('Strata version')));
|
||||
});
|
||||
|
||||
test('reopen needs a 0.2 version, intake does not', () => {
|
||||
const body = bugForm({ 'Strata version': 'Strata 0.1.44' });
|
||||
assert.ok(c.checkIssue({ title: '[Engine] x y', body, mode: 'intake' }).ok);
|
||||
const r = c.checkIssue({ title: '[Engine] x y', body, mode: 'reopen' });
|
||||
assert.ok(!r.ok);
|
||||
assert.ok(r.missing.some((m) => m.includes('0.2.0-beta')));
|
||||
});
|
||||
|
||||
test('html comments and placeholders do not count', () => {
|
||||
const r = c.checkIssue({ title: '[Engine] x y', body: bugForm({ 'What happened': '<!-- describe -->' }) });
|
||||
assert.ok(r.missing.some((m) => m.includes('What happened')));
|
||||
});
|
||||
|
||||
test('feature and question forms', () => {
|
||||
const f = '### Area\n\n[GUI] (the desktop app)\n\n### What do you want to do?\n\nLet me pin a model to the top of the list in the app.';
|
||||
assert.ok(c.checkIssue({ title: '[GUI] pin models', body: f, mode: 'reopen' }).ok);
|
||||
assert.ok(!c.checkIssue({ title: '[GUI] pin models', body: '### Area\n\n[GUI]\n\n### What do you want to do?\n\n_No response_' }).ok);
|
||||
const q = '### Area\n\n[Other]\n\n### Your question\n\nHow do I move the model folder to another drive?';
|
||||
assert.ok(c.checkIssue({ title: '[Other] model folder', body: q }).ok);
|
||||
});
|
||||
|
||||
test('free-form issue', () => {
|
||||
const good = 'Strata 0.2.0-beta on an RTX 3060 12 GB with 32 GB RAM on Linux: the server hangs when I send two requests at once. Expected both to finish.';
|
||||
assert.ok(c.checkIssue({ title: '[Engine] hang on two requests', body: good, mode: 'reopen' }).ok);
|
||||
assert.ok(!c.checkIssue({ title: '[Engine] hang', body: 'it crashes', mode: 'intake' }).ok);
|
||||
const old = good.replace('0.2.0-beta', '0.1.40');
|
||||
assert.ok(c.checkIssue({ title: '[Engine] hang', body: old, mode: 'intake' }).ok);
|
||||
assert.ok(!c.checkIssue({ title: '[Engine] hang', body: old, mode: 'reopen' }).ok);
|
||||
assert.ok(c.checkIssue({ title: '[Other] idea', body: 'It would help if the app remembered my last model between runs, today I pick it again.', labels: ['enhancement'], mode: 'reopen' }).ok);
|
||||
});
|
||||
|
||||
const prBody = (over = {}) => {
|
||||
const p = {
|
||||
problem: 'Decode stopped on prompts above 8k because the stop check read the wrong length. Now it continues.',
|
||||
validation: '**Passed:** `ctest -R decode`, commit abcdef1, RTX 3060, 12 of 12.\n\n**Failed:** none observed\n\n**Not run:** HIP build',
|
||||
...over,
|
||||
};
|
||||
return `## Problem and result\n<!-- hint -->\n${p.problem}\n\n## Related discussion\nnone\n\n## Approach\nFix the length.\n\n## Validation\n${p.validation}\n\n## Limits\nnone`;
|
||||
};
|
||||
|
||||
test('filled PR passes', () => {
|
||||
assert.deepStrictEqual(c.checkPull({ title: '[Engine] fix stop', body: prBody(), mode: 'reopen' }).missing, []);
|
||||
});
|
||||
|
||||
test('untouched PR template fails', () => {
|
||||
const tpl = '## Problem and result\n<!-- What triggered -->\n\n## Validation\n<!-- cmds -->\n\n**Passed:**\n<!-- x -->\n\n**Failed:**\n\n**Not run:**\n';
|
||||
const r = c.checkPull({ title: '[Engine] fix stop', body: tpl });
|
||||
assert.ok(!r.ok);
|
||||
assert.ok(r.missing.some((m) => m.includes('Problem and result')));
|
||||
assert.ok(r.missing.some((m) => m.includes('Validation')));
|
||||
});
|
||||
|
||||
test('PR: empty body and bad title', () => {
|
||||
const r = c.checkPull({ title: 'fix', body: null });
|
||||
assert.ok(!r.ok);
|
||||
assert.ok(r.missing.length >= 2);
|
||||
});
|
||||
|
||||
test('PR reopen needs a revision', () => {
|
||||
const body = prBody({ validation: '**Passed:** I tried it and it worked for me on my machine, looked fine.' });
|
||||
assert.ok(c.checkPull({ title: '[Engine] x y', body, mode: 'intake' }).ok);
|
||||
assert.ok(!c.checkPull({ title: '[Engine] x y', body, mode: 'reopen' }).ok);
|
||||
});
|
||||
|
||||
test('free-form PR', () => {
|
||||
const body = 'This fixes a crash when the context is above 8k, because the length was read from the wrong field. Before it aborted, after it continues. I built it on CUDA and ran the decode test, passed.';
|
||||
assert.ok(c.checkPull({ title: '[Engine] fix crash', body }).ok);
|
||||
assert.ok(!c.checkPull({ title: '[Engine] fix crash', body: 'fixes stuff' }).ok);
|
||||
});
|
||||
|
||||
test('messages', () => {
|
||||
const m = c.intakeComment(false, ['a', 'b']);
|
||||
assert.ok(m.includes('- [ ] a') && m.includes('/reopen') && m.includes('CONTRIBUTING.md'));
|
||||
assert.ok(c.staleComment(true).includes('/reopen'));
|
||||
});
|
||||
|
||||
// handlers with a fake GitHub client
|
||||
function fake(calls, extra = {}) {
|
||||
const rest = {
|
||||
issues: {
|
||||
getLabel: async () => ({}),
|
||||
createLabel: async (a) => calls.push(['createLabel', a.name]),
|
||||
addLabels: async (a) => calls.push(['addLabels', a.labels]),
|
||||
removeLabel: async (a) => calls.push(['removeLabel', a.name]),
|
||||
createComment: async (a) => calls.push(['comment', a.body]),
|
||||
update: async (a) => calls.push(['update', a.state]),
|
||||
get: async () => ({ data: extra.live }),
|
||||
listEvents: async () => {},
|
||||
listForRepo: async () => {},
|
||||
},
|
||||
pulls: { get: async () => ({ data: { mergeable_state: 'clean' } }) },
|
||||
};
|
||||
return { rest, paginate: async (fn) => (fn === rest.issues.listEvents ? (extra.events || []) : (extra.items || [])) };
|
||||
}
|
||||
const core = { info() {} };
|
||||
const repo = { owner: 'o', repo: 'r' };
|
||||
|
||||
test('intake labels and comments once', async () => {
|
||||
const calls = [];
|
||||
const item = { number: 1, title: 'bad', body: '', labels: [], user: { type: 'User' }, author_association: 'NONE' };
|
||||
await bot.intake({ github: fake(calls), context: { repo, payload: { action: 'opened', issue: item } }, core });
|
||||
assert.deepStrictEqual(calls.map((c) => c[0]), ['addLabels', 'comment']);
|
||||
calls.length = 0;
|
||||
item.labels = [{ name: 'needs-format' }];
|
||||
await bot.intake({ github: fake(calls), context: { repo, payload: { action: 'edited', issue: item } }, core });
|
||||
assert.deepStrictEqual(calls, []);
|
||||
});
|
||||
|
||||
test('intake clears the label when edited into shape', async () => {
|
||||
const calls = [];
|
||||
const item = { number: 1, title: '[Engine] x y', body: bugForm(), labels: [{ name: 'needs-format' }], user: { type: 'User' } };
|
||||
await bot.intake({ github: fake(calls), context: { repo, payload: { action: 'edited', issue: item } }, core });
|
||||
assert.deepStrictEqual(calls.map((c) => c[0]), ['removeLabel', 'comment']);
|
||||
});
|
||||
|
||||
test('reopen: ignores strangers, reopens the author when it passes', async () => {
|
||||
const issue = { number: 5, state: 'closed', user: { login: 'amy' }, labels: [] };
|
||||
let calls = [];
|
||||
const comment = { body: '/reopen', user: { login: 'bob', type: 'User' }, author_association: 'NONE' };
|
||||
await bot.reopen({ github: fake(calls), context: { repo, payload: { issue, comment } }, core });
|
||||
assert.deepStrictEqual(calls, []);
|
||||
|
||||
const live = { number: 5, title: '[Engine] x y', body: bugForm(), labels: [] };
|
||||
comment.user.login = 'amy';
|
||||
await bot.reopen({ github: fake(calls, { live }), context: { repo, payload: { issue, comment } }, core });
|
||||
assert.deepStrictEqual(calls.map((c) => c[0]), ['update', 'removeLabel', 'comment']);
|
||||
assert.strictEqual(calls[0][1], 'open');
|
||||
|
||||
calls = [];
|
||||
live.title = 'no prefix';
|
||||
await bot.reopen({ github: fake(calls, { live }), context: { repo, payload: { issue, comment } }, core });
|
||||
assert.deepStrictEqual(calls.map((c) => c[0]), ['comment']);
|
||||
assert.ok(calls[0][1].includes('Not reopened'));
|
||||
});
|
||||
|
||||
test('sweep closes only old flagged items', async () => {
|
||||
const calls = [];
|
||||
const old = new Date(Date.now() - 6 * 86400000).toISOString();
|
||||
const fresh = new Date(Date.now() - 1 * 86400000).toISOString();
|
||||
const mk = (n, created) => ({ number: n, title: 'bad', body: '', labels: [{ name: 'needs-format' }], created_at: created });
|
||||
const gh = fake(calls, { items: [mk(1, old), mk(2, fresh)] });
|
||||
await bot.sweep({ github: gh, context: { repo }, core });
|
||||
assert.deepStrictEqual(calls.map((c) => c[0]), ['comment', 'update']);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
'use strict';
|
||||
// GitHub-side handlers for intake.yml, reopen.yml and the 5-day sweep.
|
||||
// They take the actions/github-script objects ({ github, context, core }) and never touch PR code:
|
||||
// everything comes from the event payload and the REST API.
|
||||
|
||||
const check = require('./item-check');
|
||||
|
||||
const LABEL = 'needs-format';
|
||||
const STALE_DAYS = 5;
|
||||
const MAINTAINER = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
||||
// Items a maintainer closed on purpose stay closed.
|
||||
const BLOCK_LABELS = new Set(['wontfix', 'invalid', 'duplicate', 'declined']);
|
||||
|
||||
function labelNames(item) {
|
||||
return (item.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
|
||||
}
|
||||
|
||||
async function ensureLabel(github, context) {
|
||||
const { owner, repo } = context.repo;
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner, repo, name: LABEL });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
await github.rest.issues.createLabel({
|
||||
owner, repo, name: LABEL, color: 'fbca04',
|
||||
description: 'Title or description does not follow CONTRIBUTING.md yet',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLabel(github, context, number) {
|
||||
const { owner, repo } = context.repo;
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: number, name: LABEL });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function itemFromPayload(context) {
|
||||
const p = context.payload;
|
||||
return p.pull_request || p.issue;
|
||||
}
|
||||
|
||||
function isPullItem(item, context) {
|
||||
return Boolean(context.payload.pull_request || item.pull_request);
|
||||
}
|
||||
|
||||
// issues: opened/edited/reopened, pull_request_target: opened/edited/reopened
|
||||
async function intake({ github, context, core }) {
|
||||
const { owner, repo } = context.repo;
|
||||
const item = itemFromPayload(context);
|
||||
if (!item || (item.user && item.user.type === 'Bot')) return;
|
||||
if (MAINTAINER.has(item.author_association) && context.payload.action === 'opened') {
|
||||
// Maintainer items are not nagged on creation.
|
||||
return;
|
||||
}
|
||||
const isPull = isPullItem(item, context);
|
||||
const labels = labelNames(item);
|
||||
const hasLabel = labels.includes(LABEL);
|
||||
const res = check.checkItem({ isPull, title: item.title, body: item.body, labels, mode: 'intake' });
|
||||
core.info('check ' + (isPull ? 'PR' : 'issue') + ' #' + item.number + ': ' + (res.ok ? 'ok' : res.missing.join(' | ')));
|
||||
|
||||
if (res.ok) {
|
||||
if (hasLabel) {
|
||||
await removeLabel(github, context, item.number);
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: item.number, body: 'Thanks, this follows the format now.' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (context.payload.action === 'edited' && !hasLabel) return; // edits only matter for items already flagged
|
||||
if (hasLabel) return; // one note only
|
||||
await ensureLabel(github, context);
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: item.number, labels: [LABEL] });
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: item.number, body: check.intakeComment(isPull, res.missing) });
|
||||
}
|
||||
|
||||
// issue_comment created, body starts with /reopen
|
||||
async function reopen({ github, context, core }) {
|
||||
const { owner, repo } = context.repo;
|
||||
const comment = context.payload.comment;
|
||||
const issue = context.payload.issue;
|
||||
if (!/^\s*\/reopen\b/.test(comment.body || '')) return;
|
||||
if (comment.user.type === 'Bot') return;
|
||||
const isAuthor = comment.user.login === issue.user.login;
|
||||
const isMaint = MAINTAINER.has(comment.author_association);
|
||||
if (!isAuthor && !isMaint) {
|
||||
core.info('ignored /reopen from ' + comment.user.login + ' (not the author or a maintainer)');
|
||||
return;
|
||||
}
|
||||
const isPull = Boolean(issue.pull_request);
|
||||
const say = (body) => github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body });
|
||||
const labels = labelNames(issue);
|
||||
const wasOpen = issue.state === 'open';
|
||||
|
||||
if (labels.some((l) => BLOCK_LABELS.has(l.toLowerCase())) && !isMaint) {
|
||||
await say('This one was closed on purpose (' + labels.filter((l) => BLOCK_LABELS.has(l.toLowerCase())).join(', ') + '). Please add your new evidence in a comment and a maintainer will decide.');
|
||||
return;
|
||||
}
|
||||
if (wasOpen && !labels.includes(LABEL)) {
|
||||
core.info('already open, nothing to do');
|
||||
return;
|
||||
}
|
||||
|
||||
// The bot reads the live item, not the possibly stale event copy.
|
||||
const live = (await github.rest.issues.get({ owner, repo, issue_number: issue.number })).data;
|
||||
const res = check.checkItem({ isPull, title: live.title, body: live.body, labels: labelNames(live), mode: 'reopen' });
|
||||
core.info('reopen check #' + live.number + ': ' + (res.ok ? 'ok' : res.missing.join(' | ')));
|
||||
|
||||
if (!res.ok) {
|
||||
await say(check.reopenMissingComment(isPull, res.missing));
|
||||
return;
|
||||
}
|
||||
if (!wasOpen) {
|
||||
try {
|
||||
await github.rest.issues.update({ owner, repo, issue_number: live.number, state: 'open' });
|
||||
} catch (e) {
|
||||
await say('The check passed, but GitHub would not reopen it (' + (e.message || e.status) + '). ' + (isPull ? 'If the branch was deleted, restore it or open a new pull request and link this one.' : 'Please open a new issue and link this one.'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
await removeLabel(github, context, live.number);
|
||||
let extra = '';
|
||||
if (isPull) {
|
||||
try {
|
||||
const pr = (await github.rest.pulls.get({ owner, repo, pull_number: live.number })).data;
|
||||
if (pr.mergeable_state === 'dirty') extra = '\n\nIt has conflicts with main now: please rebase it.';
|
||||
} catch (e) { /* best effort */ }
|
||||
}
|
||||
await say(check.reopenOkComment(isPull) + extra);
|
||||
}
|
||||
|
||||
async function labelTime(github, owner, repo, number, fallback) {
|
||||
const events = await github.paginate(github.rest.issues.listEvents, { owner, repo, issue_number: number, per_page: 100 });
|
||||
let t = null;
|
||||
for (const e of events) {
|
||||
if (e.event === 'labeled' && e.label && e.label.name === LABEL) t = e.created_at;
|
||||
}
|
||||
return t || fallback;
|
||||
}
|
||||
|
||||
// scheduled: close items that kept the needs-format label for more than 5 days
|
||||
async function sweep({ github, context, core }) {
|
||||
const { owner, repo } = context.repo;
|
||||
const cutoff = Date.now() - STALE_DAYS * 86400 * 1000;
|
||||
const items = await github.paginate(github.rest.issues.listForRepo, { owner, repo, state: 'open', labels: LABEL, per_page: 100 });
|
||||
core.info(items.length + ' open items with ' + LABEL);
|
||||
for (const item of items) {
|
||||
const since = new Date(await labelTime(github, owner, repo, item.number, item.created_at)).getTime();
|
||||
if (since > cutoff) continue;
|
||||
const isPull = Boolean(item.pull_request);
|
||||
// Re-check first: an edit that fixed it should have cleared the label already; do not close good items.
|
||||
const res = check.checkItem({ isPull, title: item.title, body: item.body, labels: labelNames(item), mode: 'intake' });
|
||||
if (res.ok) {
|
||||
await removeLabel(github, context, item.number);
|
||||
continue;
|
||||
}
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: item.number, body: check.staleComment(isPull) });
|
||||
await github.rest.issues.update({ owner, repo, issue_number: item.number, state: 'closed', state_reason: 'not_planned' });
|
||||
core.info('closed #' + item.number);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { intake, reopen, sweep, LABEL, STALE_DAYS };
|
||||
@@ -0,0 +1,114 @@
|
||||
name: Docker images
|
||||
|
||||
# Builds the Strata container images of one release from its engine packages and pushes them to ghcr.io.
|
||||
# Run by hand only (Actions tab -> Docker images -> Run workflow). Nothing starts it on its own.
|
||||
#
|
||||
# For each flavor the job downloads the engine package from the release and checks it against the signed update feed
|
||||
# (docker/fetch-engines.py: the feed's Ed25519 signature, the archive's SHA-256, the manifest hash, the package signature,
|
||||
# every file's hash), builds the image with docker/build.sh and, only when "push" is on, pushes
|
||||
# ghcr.io/niko1221/strata:<version>-<flavor> and ghcr.io/niko1221/strata:latest-<flavor>
|
||||
# The first run with "push" off builds everything without publishing it (a dry run).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Release tag whose engine packages go into the images (for example v0.2.2)'
|
||||
required: true
|
||||
type: string
|
||||
flavors:
|
||||
description: 'Flavors to build, comma separated (empty: every flavor the release has a package for)'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
push:
|
||||
description: 'Push the images to ghcr.io (off: build only, nothing is published)'
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
latest:
|
||||
description: 'Also move the latest-<flavor> tags (turn off for a pre-release or a test)'
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
source_ref:
|
||||
description: 'Git ref of the Dockerfile and the Terminal sources (empty: the release tag)'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
IMAGE: ghcr.io/niko1221/strata
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
flavors: ${{ steps.list.outputs.flavors }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.source_ref || inputs.release_tag }}
|
||||
- name: Which flavors does the release have a package for
|
||||
id: list
|
||||
env:
|
||||
WANT: ${{ inputs.flavors }}
|
||||
TAG: ${{ inputs.release_tag }}
|
||||
run: |
|
||||
set -eu
|
||||
args="--release-tag $TAG --list --skip-missing"
|
||||
[ -n "$WANT" ] && args="$args --flavors $WANT"
|
||||
python3 docker/fetch-engines.py $args > list.tsv
|
||||
cat list.tsv
|
||||
flavors=$(awk -F'\t' '$3 != "SKIP" {printf "%s\"%s\"", (n++ ? "," : ""), $1}' list.tsv)
|
||||
[ -n "$flavors" ] || { echo "the release has no engine package for any requested flavor"; exit 1; }
|
||||
echo "flavors=[$flavors]" >> "$GITHUB_OUTPUT"
|
||||
|
||||
image:
|
||||
needs: plan
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
flavor: ${{ fromJSON(needs.plan.outputs.flavors) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.source_ref || inputs.release_tag }}
|
||||
- name: Free disk space (the runner has about 14 GB; a package plus its image needs about 10 GB)
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL || true
|
||||
docker system prune -af >/dev/null 2>&1 || true
|
||||
df -h /
|
||||
- name: Log in to ghcr.io
|
||||
if: ${{ inputs.push }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REGISTRY_USER: ${{ github.actor }}
|
||||
run: printf '%s' "$REGISTRY_TOKEN" | docker login ghcr.io -u "$REGISTRY_USER" --password-stdin
|
||||
- name: Fetch, verify, build (and push when push is on)
|
||||
env:
|
||||
TAG: ${{ inputs.release_tag }}
|
||||
FLAVOR: ${{ matrix.flavor }}
|
||||
PUSH: ${{ inputs.push && '--push' || '' }}
|
||||
LATEST: ${{ !inputs.latest && '--no-latest' || '' }}
|
||||
run: |
|
||||
docker/publish.sh --release-tag "$TAG" --flavors "$FLAVOR" --image "$IMAGE" $PUSH $LATEST
|
||||
- name: Summary
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
{
|
||||
echo "### ${{ matrix.flavor }}"
|
||||
echo "- release: ${{ inputs.release_tag }}, pushed: ${{ inputs.push }}"
|
||||
echo "- image: \`$IMAGE:${INPUT_TAG#v}-${{ matrix.flavor }}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
env:
|
||||
INPUT_TAG: ${{ inputs.release_tag }}
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Intake
|
||||
|
||||
# New issues and pull requests that do not follow CONTRIBUTING.md get the needs-format label and one friendly note.
|
||||
# Items that keep the label for 5 days are closed by the daily sweep, with a note on how to /reopen.
|
||||
#
|
||||
# pull_request_target is used instead of pull_request on purpose: a pull request from a fork gets a read-only
|
||||
# GITHUB_TOKEN under pull_request, so it could not be labelled or commented on. It is safe here because this
|
||||
# workflow never checks out or runs the pull request's code. It checks out the default branch only (the bot
|
||||
# scripts) and reads the title and description from the event payload and the REST API.
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
pull_request_target:
|
||||
types: [opened, edited]
|
||||
schedule:
|
||||
- cron: '17 4 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: intake-${{ github.event.issue.number || github.event.pull_request.number || 'sweep' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check:
|
||||
if: github.event_name == 'issues' || github.event_name == 'pull_request_target'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github/scripts
|
||||
persist-credentials: false
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const bot = require('./.github/scripts/reset-bot.js');
|
||||
await bot.intake({ github, context, core });
|
||||
|
||||
sweep:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github/scripts
|
||||
persist-credentials: false
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const bot = require('./.github/scripts/reset-bot.js');
|
||||
await bot.sweep({ github, context, core });
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Reopen
|
||||
|
||||
# An author comments /reopen on their closed issue or pull request. The check runs and the item is reopened,
|
||||
# or the bot replies with what is still missing. GitHub does not let authors reopen items a maintainer closed,
|
||||
# so this workflow does it for them.
|
||||
#
|
||||
# issue_comment always runs the workflow file from the default branch with a write-capable token, which is what
|
||||
# we need for fork authors too. It never checks out or executes the pull request's code: it reads the live
|
||||
# title and description through the REST API. Only the author of the item or a maintainer can trigger it.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: reopen-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
reopen:
|
||||
if: startsWith(github.event.comment.body, '/reopen') && github.event.comment.user.type != 'Bot'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github/scripts
|
||||
persist-credentials: false
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const bot = require('./.github/scripts/reset-bot.js');
|
||||
await bot.reopen({ github, context, core });
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Scripts test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/scripts/**'
|
||||
- '.github/workflows/intake.yml'
|
||||
- '.github/workflows/reopen.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
node-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: node --test .github/scripts/item-check.test.js
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Strata runs the Qwen3.8-Flash-Next mixture-of-experts model (and its Coder, Swift 1.5, Unsloth, Gyro and NVFP4
|
||||
variants, and Qwen3.6-35B-A3B) on a normal PC: one NVIDIA or AMD graphics card plus system RAM on Windows or Linux,
|
||||
or an Apple Silicon Mac. Version 0.2.0-beta, engine 0.1.45.3. It has three parts:
|
||||
or an Apple Silicon Mac. Version 0.2.2, engine 0.1.47. It has three parts:
|
||||
|
||||
- **The app** (`apps/desktop`, the Tauri shell; `apps/host`, the Node host and the web UI). It is for people who do
|
||||
not want a terminal. The installer has the engine inside.
|
||||
@@ -45,7 +45,7 @@ Follow **[docs/AI_SETUP.md](docs/AI_SETUP.md)**. For an agent that has a shell,
|
||||
4. Connect the user's apps: `strata connect` lists them, `strata connect <app> --check` sends one prompt and one tool
|
||||
call the way that app does.
|
||||
|
||||
Never expose the server beyond `127.0.0.1` without an API key. `--access lan` makes a key for you. The old route (clone
|
||||
Keep the server on `127.0.0.1` unless the user asks for more. Beyond it an API key is recommended: `--access lan` makes one for you, and `--no-api-key` serves without one (a warning, never a refusal; on a public tunnel the warning is stronger). The old route (clone
|
||||
the repository and run `START-HERE.bat --yes` or `./setup.sh --yes`) still works and is the fallback when the
|
||||
Terminal cannot run. Strata's MCP server ([docs/MCP_SERVER.md](docs/MCP_SERVER.md)) offers the same steps as tools.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
rem Benchmark this Strata install the way it is used and write a report you can share (docs/COMMUNITY_BENCHMARKS.md).
|
||||
rem It starts the model with your settings (or uses the one already running), takes about 10 minutes and changes nothing.
|
||||
rem Options: --clients 4 (several requests at once), --long (a 32K prompt), --compare <older results folder>.
|
||||
rem This is the quick speed check. The standard benchmark (docs/BENCHMARK_PROTOCOL.md), whose results other people can compare with, is `strata bench run`.
|
||||
setlocal
|
||||
title Strata - benchmark
|
||||
cd /d "%~dp0"
|
||||
@@ -10,6 +11,8 @@ if not exist ".venv\Scripts\python.exe" (
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo Quick speed check. For results other people can compare with (the standard benchmark, a few minutes), run: .venv\Scripts\python.exe strata.py bench run
|
||||
echo.
|
||||
".venv\Scripts\python.exe" tools\strata_bench.py %*
|
||||
set "RC=%ERRORLEVEL%"
|
||||
pause
|
||||
|
||||
+70
-4
@@ -8,7 +8,7 @@
|
||||
# below refuses anything older: silently building for a pre-Ampere arch would produce a binary that runs
|
||||
# and is wrong about its own performance, which is the worst of both outcomes.
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(strata VERSION 0.1.45.3 LANGUAGES CXX)
|
||||
project(strata VERSION 0.1.47 LANGUAGES CXX)
|
||||
# EXPERIMENTAL: the Intel Arc engine, the SYCL port in sycl/ (PR #423; icpx + oneMKL, docs/INTEL_ARC.md). It is its
|
||||
# own project that takes the unported sources from src/; with this on, nothing below is configured.
|
||||
option(STRATA_ENABLE_SYCL "EXPERIMENTAL: build the Intel Arc (SYCL) engine from sycl/ - docs/INTEL_ARC.md" OFF)
|
||||
@@ -48,6 +48,17 @@ if(STRATA_PORTABLE AND MSVC)
|
||||
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded")
|
||||
endif()
|
||||
|
||||
# #3202: Windows hands a program its command line in the system's ANSI code page (cp1251 / cp866 on a Russian Windows, cp1252 on an
|
||||
# English one), but the engine reads every path as UTF-8 (the run configs and the server write UTF-8). A model folder with
|
||||
# Cyrillic letters arrived as other bytes, or as "?" where the page has no such letter, and the model did not open. This manifest
|
||||
# makes the process's ANSI code page UTF-8 (Windows 10 1903 and newer; older Windows ignores it and nothing changes), so the
|
||||
# command line, fopen and std::ifstream all take UTF-8.
|
||||
function(strata_utf8_manifest target)
|
||||
if(MSVC)
|
||||
target_sources(${target} PRIVATE ${CMAKE_CURRENT_FUNCTION_LIST_DIR}/cmake/strata-utf8.manifest)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
@@ -722,8 +733,16 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
endif()
|
||||
|
||||
add_executable(strata src/program/generate.cpp)
|
||||
strata_utf8_manifest(strata)
|
||||
target_include_directories(strata PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(strata PRIVATE strata_engine strata_prefill strata_spec ${_strata_gpu_runtime_target})
|
||||
if(WIN32 AND STRATA_ENABLE_HIP)
|
||||
# 0.2.1.2: the ROCm math libraries live in <package>\rocm\bin, which is on PATH only when the launcher starts the engine; load them on first
|
||||
# use (main() adds that folder to the DLL search first) so `strata.exe --version` works from the package folder. amdhip64_7.dll and the
|
||||
# C++ runtime sit beside the exe (tools/hip/package_windows.py) and stay normal imports.
|
||||
target_link_libraries(strata PRIVATE delayimp)
|
||||
target_link_options(strata PRIVATE "LINKER:/DELAYLOAD:hipblas.dll" "LINKER:/DELAYLOAD:libhipblaslt.dll" "LINKER:/DELAYLOAD:rocblas.dll")
|
||||
endif()
|
||||
|
||||
# NEW-MODELS-PLAN.md T1: Qwen3.6-35B-A3B's reference path (one GPU, the model resident, plain kernels), checked
|
||||
# against llama.cpp by tools/qwen35/; the engine itself does not run the family yet
|
||||
@@ -732,7 +751,10 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
target_link_libraries(strata-q36ref PRIVATE strata_kernels strata_warnings ${_strata_gpu_runtime_target})
|
||||
# the engine path for the same family (serve protocol, prompt chunks, decode graph): src/qwen35/q36_engine.cu, a
|
||||
# library that `strata` switches to for a qwen35moe model (the family switch at the top of its main) and that
|
||||
# strata-q36 wraps on its own
|
||||
# strata-q36 wraps on its own. Not on gfx906 (#3172, #3271): it calls cuBLAS entry points the wave64 compat layer does
|
||||
# not map (cublasSgemm, cublasGemmStridedBatchedEx) and its kernels assume 32-lane warps; main() guards every use with
|
||||
# STRATA_HAVE_QWEN35, so a gfx906 build links and runs the Qwen3.8 family, and `strata --families` says so
|
||||
if(NOT STRATA_HIP_GFX906)
|
||||
add_library(strata_qwen35 STATIC src/qwen35/q36_engine.cu)
|
||||
target_include_directories(strata_qwen35 PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(strata_qwen35 PRIVATE strata_prefill strata_kernels strata_warnings ${_strata_gpu_runtime_target})
|
||||
@@ -742,9 +764,11 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
target_compile_definitions(strata_qwen35 PRIVATE STRATA_Q36_CPU_POOL=1)
|
||||
endif()
|
||||
add_executable(strata-q36 src/qwen35/q36_main.cpp)
|
||||
strata_utf8_manifest(strata-q36)
|
||||
target_link_libraries(strata-q36 PRIVATE strata_qwen35 ${_strata_gpu_runtime_target})
|
||||
target_link_libraries(strata PRIVATE strata_qwen35)
|
||||
target_compile_definitions(strata PRIVATE STRATA_HAVE_QWEN35=1)
|
||||
endif()
|
||||
|
||||
if(STRATA_ENABLE_HIP)
|
||||
enable_testing()
|
||||
@@ -968,6 +992,12 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
target_include_directories(iq_multi_parity PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(iq_multi_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
|
||||
add_test(NAME iq_multi_parity COMMAND iq_multi_parity)
|
||||
# the Gyro trellis decode kernels (TQ2_T / TQK6 / TQK7) on synthetic blocks: decode-once vs per-column bitwise, output
|
||||
# checksums to compare two builds byte for byte, --bench for timings (GPU, no model)
|
||||
add_executable(tq_pascal_bench src/kernels/tq_pascal_bench.cpp)
|
||||
target_include_directories(tq_pascal_bench PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(tq_pascal_bench PRIVATE strata_kernels ${_strata_gpu_runtime_target})
|
||||
add_test(NAME tq_pascal_bench COMMAND tq_pascal_bench)
|
||||
set_tests_properties(iq_multi_parity PROPERTIES ENVIRONMENT "STRATA_KQ_SPLIT=1") # the Q4_K / Q5_K / Q8_0 Split path against the old kernels
|
||||
# the same Split path on the other routes it can meet: the generic multi kernels (no V2K) and the fused gate/up + SwiGLU kernel, and the sub-16-row
|
||||
# gate/up route switched off (a K-quant row has 160 dots, not the 80 the sub-16 rows assume: STRATA_KQ_SPLIT=1 must stay bitwise on all of them)
|
||||
@@ -1383,6 +1413,9 @@ if(STRATA_BUILD_TESTS)
|
||||
add_executable(arch_defaults_test src/core/arch_defaults_test.cpp src/core/arch_defaults.cpp)
|
||||
target_include_directories(arch_defaults_test PRIVATE include)
|
||||
add_test(NAME arch_defaults_test COMMAND arch_defaults_test)
|
||||
add_executable(uma_cache_test src/core/uma_cache_test.cpp)
|
||||
target_include_directories(uma_cache_test PRIVATE include)
|
||||
add_test(NAME uma_cache_test COMMAND uma_cache_test)
|
||||
add_executable(suffix_drafter_test src/spec/suffix_drafter_test.cpp)
|
||||
target_link_libraries(suffix_drafter_test PRIVATE strata_spec)
|
||||
add_test(NAME suffix_drafter_test COMMAND suffix_drafter_test)
|
||||
@@ -1392,6 +1425,9 @@ if(STRATA_BUILD_TESTS)
|
||||
add_executable(draft_policy_test src/spec/draft_policy_test.cpp)
|
||||
target_link_libraries(draft_policy_test PRIVATE strata_spec)
|
||||
add_test(NAME draft_policy_test COMMAND draft_policy_test)
|
||||
add_executable(spec_start_test src/spec/spec_start_test.cpp)
|
||||
target_include_directories(spec_start_test PRIVATE include)
|
||||
add_test(NAME spec_start_test COMMAND spec_start_test)
|
||||
endif()
|
||||
if(STRATA_BUILD_TESTS OR STRATA_BUILD_CONVERSATION_TESTS)
|
||||
add_executable(message_boundary_test src/program/message_boundary_test.cpp)
|
||||
@@ -2071,14 +2107,14 @@ if(STRATA_BUILD_TESTS)
|
||||
add_executable(native_hadamard_test src/kernels/cpu/native_hadamard_test.cpp)
|
||||
target_link_libraries(native_hadamard_test PRIVATE strata_kernels_cpu strata_artifact)
|
||||
add_test(NAME native_hadamard_test COMMAND native_hadamard_test)
|
||||
set_tests_properties(native_hadamard_test PROPERTIES TIMEOUT 300)
|
||||
set_tests_properties(native_hadamard_test PROPERTIES TIMEOUT 300 SKIP_RETURN_CODE 77)
|
||||
# the CPU trellis kernels (tq_cpu.hpp): AVX2 / AVX-512 vs the scalar kernel bit for bit, vs ggml-cpu's vec_dot
|
||||
# and to_float, plain and with the Hadamard rotation; `tq_cpu_parity --bench` is the micro-benchmark
|
||||
add_executable(tq_cpu_parity src/kernels/cpu/tq_cpu_parity.cpp)
|
||||
target_link_libraries(tq_cpu_parity PRIVATE strata_kernels_cpu)
|
||||
add_test(NAME tq_cpu_parity COMMAND tq_cpu_parity)
|
||||
add_test(NAME tq_cpu_parity_avx2 COMMAND ${CMAKE_COMMAND} -E env STRATA_FORCE_AVX2=1 $<TARGET_FILE:tq_cpu_parity>)
|
||||
set_tests_properties(tq_cpu_parity tq_cpu_parity_avx2 PROPERTIES TIMEOUT 300)
|
||||
set_tests_properties(tq_cpu_parity tq_cpu_parity_avx2 PROPERTIES TIMEOUT 300 SKIP_RETURN_CODE 77)
|
||||
endif()
|
||||
|
||||
add_executable(expert_parity src/kernels/cpu/expert_parity.cpp)
|
||||
@@ -2091,10 +2127,23 @@ if(STRATA_BUILD_TESTS)
|
||||
add_executable(pool_test src/kernels/cpu/pool_test.cpp)
|
||||
target_link_libraries(pool_test PRIVATE strata_kernels_cpu)
|
||||
add_test(NAME pool_test COMMAND pool_test --selftest WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
# where the workers are placed (one per physical core by default) and --pool-cores (real pools, no model)
|
||||
add_executable(pool_cores_test src/kernels/cpu/pool_cores_test.cpp)
|
||||
target_link_libraries(pool_cores_test PRIVATE strata_kernels_cpu)
|
||||
add_test(NAME pool_cores_test COMMAND pool_cores_test)
|
||||
# the default CPU expert worker count on mock CPUs (8c/16t, 16c/32t, 64c/128t, 2-socket 128c/256t, ...)
|
||||
add_executable(pool_plan_test src/kernels/cpu/pool_plan_test.cpp)
|
||||
target_include_directories(pool_plan_test PRIVATE include)
|
||||
add_test(NAME pool_plan_test COMMAND pool_plan_test)
|
||||
# 0.2.2: when the pool splits per NUMA node by itself (a fake sysfs node folder, mock cores; STRATA_NUMA_POOL=0, one node = unchanged)
|
||||
add_executable(numa_pool_test src/kernels/cpu/numa_pool_test.cpp)
|
||||
target_include_directories(numa_pool_test PRIVATE include)
|
||||
target_compile_features(numa_pool_test PRIVATE cxx_std_17)
|
||||
add_test(NAME numa_pool_test COMMAND numa_pool_test)
|
||||
# #3204: when the HIP start-up warm-up of hipBLAS may run (pure policy, no GPU)
|
||||
add_executable(warm_policy_test src/prefill/warm_policy_test.cpp)
|
||||
target_include_directories(warm_policy_test PRIVATE include)
|
||||
add_test(NAME warm_policy_test COMMAND warm_policy_test)
|
||||
# issue #29: tiny batches with sleeping workers, as on a big-VRAM card; a hang fails it
|
||||
add_executable(pool_stress src/kernels/cpu/pool_stress.cpp)
|
||||
target_link_libraries(pool_stress PRIVATE strata_kernels_cpu)
|
||||
@@ -2164,6 +2213,23 @@ if(STRATA_HIP_GFX906)
|
||||
file(GLOB _strata_mmq_cu ${_strata_mmq}/*.cu ${_strata_mmq}/template-instances/*.cu)
|
||||
list(APPEND _strata_cu ${_strata_mmq_cu})
|
||||
target_compile_definitions(strata_mmq PRIVATE GGML_USE_HIP)
|
||||
# #3172: strata_mmq_w4a4 is built from llama.cpp's headers too; without GGML_USE_HIP its common.cuh took the CUDA vendor header
|
||||
# (cuda.h, cuda_bf16.h: not found). Here it only reports that W4A4 (RTX 50's FP4 MMA) is not available.
|
||||
if(TARGET strata_mmq_w4a4)
|
||||
target_compile_definitions(strata_mmq_w4a4 PRIVATE GGML_USE_HIP)
|
||||
endif()
|
||||
# #3271: three more link errors of a 0.1.44+ gfx906 build, all sources that strata_mmq lists but the glob above never saw:
|
||||
# the trellis overlay's quantize.cu and instances (they are generated into the build tree, so the glob missed them: CMake drops a
|
||||
# .cu source whose language is not enabled) and moe_f16_kq.cu, which moe_fused_iq.cu calls (a stub here: no gfx11 device code, so
|
||||
# f16kq::covers() answers false and the int8 kernels run, as on every other card)
|
||||
if(_strata_mmq_tq_overlay)
|
||||
set(_strata_tq_hip ${_strata_tq_overlay_dir}/quantize.cu)
|
||||
foreach(_t tq2_t tqk6 tqk7)
|
||||
list(APPEND _strata_tq_hip ${_strata_tq_overlay_dir}/template-instances/mmq-instance-${_t}.cu)
|
||||
endforeach()
|
||||
list(APPEND _strata_cu ${_strata_tq_hip})
|
||||
endif()
|
||||
target_sources(strata_mmq PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/prefill/moe_f16_kq.cu)
|
||||
endif()
|
||||
set_source_files_properties(${_strata_cu} PROPERTIES LANGUAGE HIP)
|
||||
endif()
|
||||
|
||||
+9
-3
@@ -5,6 +5,8 @@
|
||||
#
|
||||
# docker build -t strata:src . # RTX 20/30/40/50 (compiles for each, ~25 min)
|
||||
# docker build -t strata:src --build-arg CUDA_ARCHITECTURES=86 . # one card only (faster); 86 = RTX 30, 89 = RTX 40, 120 = RTX 50
|
||||
# docker build -t strata:src-cuda12 --build-arg CUDA_IMAGE=nvidia/cuda:12.9.1-devel-ubuntu24.04 \
|
||||
# --build-arg CUDA_ARCHITECTURES="60;61;70;75;80;86;89" --build-arg BUILD_VISION=0 . # GTX 10 / P100 / V100 (driver 575+)
|
||||
# docker run --rm --gpus all -p 127.0.0.1:8080:8080 --ulimit memlock=-1 --shm-size 2g --stop-timeout 120 \
|
||||
# -v strata-data:/data -e MODEL=IQ3_XXS -e API_KEY=change-me-please-123 strata:src
|
||||
#
|
||||
@@ -35,7 +37,9 @@ RUN /venv/bin/python -c "import setup; print(setup.get_llama_cpp())"
|
||||
RUN set -e; \
|
||||
real=""; for a in $(echo "$CUDA_ARCHITECTURES" | tr ';,' ' '); do real="$real;${a}-real"; done; real=${real#;}; \
|
||||
last=$(echo "$CUDA_ARCHITECTURES" | tr ';,' ' ' | awk '{print $NF}'); \
|
||||
sm60=OFF; for a in $(echo "$CUDA_ARCHITECTURES" | tr ';,' ' '); do if [ "$a" -lt 75 ]; then sm60=ON; fi; done; \
|
||||
/venv/bin/cmake -G Ninja -S . -B build -DCMAKE_BUILD_TYPE=Release -DSTRATA_ENABLE_CUDA=ON -DSTRATA_BUILD_TESTS=OFF \
|
||||
-DSTRATA_EXPERIMENTAL_SM60=$sm60 \
|
||||
-DSTRATA_NATIVE_EXPERTS=ON -DSTRATA_PORTABLE=ON -DSTRATA_Q6K_EXPERTS=ON -DSTRATA_MMQ_KQUANTS=ON \
|
||||
"-DCMAKE_CUDA_ARCHITECTURES=${real};${last}-virtual" -DSTRATA_GGML_DIR=/src/third_party/llama.cpp; \
|
||||
/venv/bin/cmake --build build --target strata strata_kq -j "$(nproc)"; \
|
||||
@@ -50,19 +54,21 @@ RUN set -e; \
|
||||
[ -f build/libstrata_kq.so ] && cp build/libstrata_kq.so /stage/ || true; \
|
||||
[ -f build-vision/bin/strata-vision ] && cp build-vision/bin/strata-vision /stage/ || true; \
|
||||
ver="${ENGINE_VERSION:-$(sed -n 's/^[[:space:]]*project([^)]*VERSION[[:space:]]*\([0-9][0-9.]*\).*/\1/p' CMakeLists.txt | head -1)}"; \
|
||||
echo "{\"version\": \"$ver\", \"source\": \"docker build\", \"archs\": \"$CUDA_ARCHITECTURES\", \"cuda\": \"13.0\", \"portable\": true}" > /stage/BUILD.json; \
|
||||
cudafull=$(nvcc --version | sed -n 's/.*release \([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -1); cudamajor=${cudafull%%.*}; \
|
||||
if [ "$cudamajor" = 13 ]; then mindriver=580.65; else mindriver=575.51; fi; \
|
||||
echo "{\"version\": \"$ver\", \"source\": \"docker build\", \"archs\": \"$CUDA_ARCHITECTURES\", \"cuda\": \"$cudafull\", \"portable\": true}" > /stage/BUILD.json; \
|
||||
python3 packaging/collect_libs.py --binary build/strata --search /usr/local/cuda/lib64 --out /stage --also 'libcublasLt.so*'; \
|
||||
archs=""; for a in $(echo "$CUDA_ARCHITECTURES" | tr ';,' ' '); do archs="$archs --arch sm_$a"; done; \
|
||||
last=$(echo "$CUDA_ARCHITECTURES" | tr ';,' ' ' | awk '{print $NF}'); \
|
||||
python3 packaging/package_engine.py --src /stage --backend cuda --version "$ver" --out /engine $archs --arch "compute_$last" \
|
||||
--min-driver nvidia=580.65 --variant cuda13 --platform linux-x86_64 --build-id "docker-source-${ver}-cuda13.0" --include SYSTEM-LIBS.txt --include 'libstrata_kq*' \
|
||||
--min-driver nvidia=$mindriver --variant cuda$cudamajor --platform linux-x86_64 --build-id "docker-source-${ver}-cuda${cudafull}" --include SYSTEM-LIBS.txt --include 'libstrata_kq*' \
|
||||
--include strata-vision; \
|
||||
for d in /engine/*/; do python3 packaging/package_engine.py --verify "$d"; python3 packaging/binary_deps.py "$d"; done
|
||||
|
||||
# ---- 2. the Terminal (the release archive, built here)
|
||||
FROM ${RUNTIME_BASE} AS terminal
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 ca-certificates git && rm -rf /var/lib/apt/lists/*
|
||||
COPY . /src/
|
||||
RUN python3 /src/packaging/package_terminal.py --out /out --cache /cache \
|
||||
&& mkdir -p /opt/strata \
|
||||
|
||||
+22
-16
@@ -33,7 +33,7 @@ Bilder und arbeitet mit deinen Apps und Coding-Agents zusammen. Deine Chats und
|
||||
- **Kostenlos.** MIT-Lizenz, kein Konto, keine Cloud.
|
||||
- **Privat.** Keine Telemetrie. Was Strata herunterlädt, wann es das Internet nutzt und wie es Downloads prüft, steht in [docs/SAFETY.md](docs/SAFETY.md).
|
||||
|
||||
Version 0.2.0-beta · Engine 0.1.45.3
|
||||
Version 0.2.2 · Engine 0.1.47
|
||||
|
||||
## Was du brauchst
|
||||
|
||||
@@ -73,18 +73,20 @@ unsicher, welche du brauchst? Lies den Absatz unter der Tabelle.
|
||||
|
||||
| System | Deine Grafikkarte | Datei zum Herunterladen |
|
||||
| --- | --- | --- |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.0-beta-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda13.exe) |
|
||||
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.0-beta-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda12.exe) |
|
||||
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.0-beta-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-hip.exe) |
|
||||
| **Linux** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.0-beta-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon RX 7000 Serie | [`Strata-0.2.0-beta-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx7900.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 oder neuer) | eingebaut | [`Strata-0.2.0-beta-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-mac.dmg) |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
|
||||
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
|
||||
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
|
||||
| | AMD Radeon RX 7000 Serie oder Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX, Treiber 580 oder neuer | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, ältere Karte oder Treiber älter als 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon RX 9000 Serie oder Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon RX 7000 Serie | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 oder neuer) | eingebaut | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
|
||||
|
||||
**Wenn du unsicher bist:** Bei NVIDIA nimmst du `cuda13`, wenn dein Treiber Version 580 oder neuer ist, sonst `cuda12`.
|
||||
`cuda12` ist für ältere Karten und ältere Treiber. Die Dateien `hip` und `amd` sind für AMD-Karten. Für andere AMD-Karten
|
||||
(RX 6000 oder RX 7000 unter Windows) gibt es noch keine fertige App: siehe [AMD-Karten](docs/AMD_HIP.md).
|
||||
(RX 6000) gibt es noch keine fertige App: siehe [AMD-Karten](docs/AMD_HIP.md).
|
||||
|
||||
Danach:
|
||||
|
||||
@@ -160,9 +162,9 @@ Größen, mehr oder weniger stark komprimiert. Kleinere Größen sind schneller.
|
||||
| Größe | Benötigter Speicher (RAM + VRAM) | Download | Was es ist |
|
||||
| --- | ---: | ---: | --- |
|
||||
| **Gyro-S** | 27,6 GB | 58,5 GB | Für PCs, auf denen nichts anderes läuft. Die Qualität ist geringer als bei IQ2_XS. |
|
||||
| **Gyro-M** | 35 GB | 92,0 GB | Ungefähr die Qualität von IQ3_XXS bei etwa 20 % weniger GPU-Speicher. Es schreibt Antworten auf einer 12-GB-Karte und auf einer R9700 17-21 % langsamer als Q2_0. |
|
||||
| **Gyro-M** | 35 GB | 92,0 GB | Ungefähr die Qualität von IQ3_XXS bei etwa 20 % weniger GPU-Speicher. Es schreibt Antworten langsamer als Q2_0: 16 % auf einer RTX 3060 mit 12 GB, 21 % auf einer R9700. |
|
||||
|
||||
Gyro läuft auf NVIDIA- und AMD-Karten (bei AMD experimentell), nicht auf Intel Arc und nicht auf dem Mac. Details und Messwerte:
|
||||
Gyro läuft auf NVIDIA- und AMD-Karten (bei AMD experimentell) auf Intel-Arc-Karten unter Linux (getestet auf einer Arc A750) und ab 0.2.2 auf Macs mit Apple Silicon (Gyro-S auf einem Mac mini mit 32 GB: 9 Tokens/s, die Experten werden von der SSD gelesen). Details und Messwerte:
|
||||
[docs/GYRO.md](docs/GYRO.md). Starte es mit `strata launch --model gyro-s`.
|
||||
|
||||
### Die anderen Größen
|
||||
@@ -170,7 +172,7 @@ Gyro läuft auf NVIDIA- und AMD-Karten (bei AMD experimentell), nicht auf Intel
|
||||
| Dein Speicher (RAM + VRAM) | Nimm | Hinweise |
|
||||
| --- | --- | --- |
|
||||
| **32 GB** | **Gyro-S**, oder den **[Coder](docs/MODELS.md#coder)** für Code | Der Coder braucht 32 GB RAM und behält die Hälfte der Experten, deshalb ist er außerhalb von Code schwächer |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), oder **Gyro-M** (35 GB) | Swift denkt kürzer nach, bevor es antwortet. Gyro-M kommt der Qualität von IQ3_XXS nahe, schreibt Antworten aber 17-21 % langsamer als Q2_0 |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), oder **Gyro-M** (35 GB) | Swift denkt kürzer nach, bevor es antwortet. Gyro-M kommt der Qualität von IQ3_XXS nahe, schreibt Antworten aber 16-21 % langsamer als Q2_0 |
|
||||
| **48 GB** | **IQ3_XXS** (47,0 GB) | Langsamer, besser |
|
||||
| **64 GB** | **IQ3_S** (54,8 GB), das Original oder Swift 1.5 | Am langsamsten, am besten. Es erreicht in den veröffentlichten Tests das volle Modell |
|
||||
| **96 GB oder mehr** | **UD-IQ4_XS** von Unsloth (etwa 72 GB RAM) bis **Q8_0** (etwa 136 GB), 4 bis 8 Bit | Näher am vollen Modell. Bei weniger RAM liest Strata einen Teil von der SSD, das ist viel langsamer |
|
||||
@@ -230,11 +232,14 @@ Suchschlüssel hinzufügen. Die Suche ist die einzige Funktion, die etwas von de
|
||||
Um zwei Modelle auszuführen, schalte **Mehrere Modelle ausführen** ein und gib jedem Modell eine eigene Karte desselben
|
||||
Herstellers.
|
||||
|
||||
<p align="center"><img src="docs/media/runpagoda.png" width="900" alt="The Strata app's Monitor tab next to a coding agent"><br>
|
||||
<sub>Der <b>Monitor</b> der Strata-App (links), während ein Coding-Agent den Pagodengarten aus dem Video schreibt (rechts)</sub></p>
|
||||
<p align="center"><img src="docs/media/app-chat.png" width="900" alt="The Strata app: a chat where the agent built a small voxel pagoda garden, with the page open in the preview on the right"><br>
|
||||
<sub>Ein Chat in der Strata-App: Der Agent hat einen kleinen Voxel-Pagodengarten als eine HTML-Datei geschrieben, und die Seite öffnet sich in der Vorschau neben dem Gespräch</sub></p>
|
||||
|
||||
### Im Terminal
|
||||
|
||||
<p align="center"><img src="docs/media/terminal-monitor.png" width="900" alt="The Strata Terminal monitor overview while the model writes a reply"><br>
|
||||
<sub><code>strata monitor</code>, während das Modell antwortet: Geschwindigkeit, GPU, Speicher und die letzten Anfragen</sub></p>
|
||||
|
||||
```sh
|
||||
strata launch # guided screens: model, context, pictures, access, then serve
|
||||
strata launch --model q2_0 --yes --no-tui # no questions: serve, print the address, return
|
||||
@@ -277,7 +282,8 @@ Du kannst Bilder pro Modell ausschalten oder den Leser mit `--vision off`, `--vi
|
||||
|
||||
Die Zahlen hinter der Regel: Auf einer Radeon AI PRO R9700 brauchte der Bildleser der Karte 57 ms für ein Bild mit 260 Tokens,
|
||||
der des Prozessors 3,1 s. Auf Karten mit 16 GB (Tesla P100 und RTX A4000, Linux) kostete der Bildleser der Karte 3-5 % des
|
||||
Texttempos, weil er etwa 1,4 GB belegt, die sonst das Modell nutzen würde. Der Mac hat noch keinen Bildleser.
|
||||
Texttempos, weil er etwa 1,4 GB belegt, die sonst das Modell nutzen würde. Auf dem Mac liest die GPU, egal wie viel Speicher er hat (0,45 s für ein Bild mit 260 Tokens auf einem M4 mit 32 GB, etwa 1,2 GB des gemeinsamen
|
||||
Speichers); der Prozessor braucht dort etwa 50 s pro Bild und liest deshalb nur mit `--vision cpu`.
|
||||
|
||||
### Gut zu wissen
|
||||
|
||||
|
||||
+22
-16
@@ -33,7 +33,7 @@ con tus aplicaciones y tus agentes de programación. Tus chats y tus archivos se
|
||||
- **Gratis.** Licencia MIT, sin cuenta, sin nube.
|
||||
- **Privado.** Sin telemetría. Lo que Strata descarga, cuándo usa internet y cómo comprueba las descargas está en [docs/SAFETY.md](docs/SAFETY.md).
|
||||
|
||||
Versión 0.2.0-beta · motor 0.1.45.3
|
||||
Versión 0.2.2 · motor 0.1.47
|
||||
|
||||
## Lo que necesitas
|
||||
|
||||
@@ -73,18 +73,20 @@ sabes cuál elegir? Mira el párrafo que hay debajo de la tabla.
|
||||
|
||||
| Sistema | Tu tarjeta gráfica | Archivo que descargar |
|
||||
| --- | --- | --- |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.0-beta-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda13.exe) |
|
||||
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.0-beta-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda12.exe) |
|
||||
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.0-beta-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-hip.exe) |
|
||||
| **Linux** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.0-beta-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon serie RX 7000 | [`Strata-0.2.0-beta-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx7900.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 o posterior) | integrada | [`Strata-0.2.0-beta-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-mac.dmg) |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
|
||||
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
|
||||
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
|
||||
| | AMD Radeon serie RX 7000 o Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX, controlador 580 o posterior | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, tarjeta más antigua o controlador anterior a 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon serie RX 9000 o Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon serie RX 7000 | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 o posterior) | integrada | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
|
||||
|
||||
**Si no estás seguro:** con NVIDIA, elige `cuda13` si la versión de tu controlador es la 580 o posterior, y `cuda12` en caso
|
||||
contrario. `cuda12` es para tarjetas y controladores más antiguos. Los archivos `hip` y `amd` son para tarjetas AMD. Las
|
||||
demás tarjetas AMD (RX 6000, o RX 7000 en Windows) todavía no tienen una aplicación ya preparada: mira
|
||||
demás tarjetas AMD (RX 6000) todavía no tienen una aplicación ya preparada: mira
|
||||
[Tarjetas AMD](docs/AMD_HIP.md).
|
||||
|
||||
Después:
|
||||
@@ -160,9 +162,9 @@ comprimido. Los tamaños pequeños son más rápidos. Los grandes son algo más
|
||||
| Tamaño | Memoria que necesita (RAM + VRAM) | Descarga | Qué es |
|
||||
| --- | ---: | ---: | --- |
|
||||
| **Gyro-S** | 27,6 GB | 58,5 GB | Para PC donde no cabe nada más. La calidad es menor que la de IQ2_XS. |
|
||||
| **Gyro-M** | 35 GB | 92,0 GB | Calidad parecida a IQ3_XXS con un 20 % menos de memoria de GPU. Escribe las respuestas un 17-21 % más despacio que Q2_0 en una tarjeta de 12 GB y en una R9700. |
|
||||
| **Gyro-M** | 35 GB | 92,0 GB | Calidad parecida a IQ3_XXS con un 20 % menos de memoria de GPU. Escribe las respuestas un 16 % más despacio que Q2_0 en una RTX 3060 de 12 GB y un 21 % más despacio en una R9700. |
|
||||
|
||||
Gyro funciona en tarjetas NVIDIA y AMD (en AMD es experimental), no en Intel Arc ni en el Mac. Detalles y mediciones:
|
||||
Gyro funciona en tarjetas NVIDIA y AMD (en AMD es experimental) en tarjetas Intel Arc con Linux (probado en una Arc A750) y, desde 0.2.2, en Mac con Apple Silicon (Gyro-S en un Mac mini de 32 GB: 9 tokens/s, con los expertos leídos del SSD). Detalles y mediciones:
|
||||
[docs/GYRO.md](docs/GYRO.md). Arráncalo con `strata launch --model gyro-s`.
|
||||
|
||||
### Los demás tamaños
|
||||
@@ -170,7 +172,7 @@ Gyro funciona en tarjetas NVIDIA y AMD (en AMD es experimental), no en Intel Arc
|
||||
| Tu memoria (RAM + VRAM) | Elige | Notas |
|
||||
| --- | --- | --- |
|
||||
| **32 GB** | **Gyro-S**, o el **[Coder](docs/MODELS.md#coder)** para código | El Coder necesita 32 GB de RAM y conserva la mitad de los expertos, así que es más flojo fuera del código |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), o **Gyro-M** (35 GB) | Swift piensa menos tiempo antes de responder. Gyro-M se acerca a la calidad de IQ3_XXS, pero escribe las respuestas un 17-21 % más despacio que Q2_0 |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), o **Gyro-M** (35 GB) | Swift piensa menos tiempo antes de responder. Gyro-M se acerca a la calidad de IQ3_XXS, pero escribe las respuestas un 16-21 % más despacio que Q2_0 |
|
||||
| **48 GB** | **IQ3_XXS** (47,0 GB) | Más lento, mejor |
|
||||
| **64 GB** | **IQ3_S** (54,8 GB), el original o Swift 1.5 | El más lento, el mejor. Iguala al modelo completo en las pruebas publicadas |
|
||||
| **96 GB o más** | **UD-IQ4_XS** de Unsloth (unos 72 GB de RAM) hasta **Q8_0** (unos 136 GB), de 4 a 8 bits | Más cerca del modelo completo. Con menos RAM, Strata lee una parte desde el SSD, que es mucho más lento |
|
||||
@@ -228,11 +230,14 @@ Herramientas**. La búsqueda es la única función que envía algo fuera de tu P
|
||||
|
||||
Para ejecutar dos modelos, activa **Ejecutar varios modelos** y dale a cada modelo su propia tarjeta de la misma marca.
|
||||
|
||||
<p align="center"><img src="docs/media/runpagoda.png" width="900" alt="The Strata app's Monitor tab next to a coding agent"><br>
|
||||
<sub>El <b>Monitor</b> de la aplicación Strata (a la izquierda) mientras un agente de programación escribe el jardín de pagodas del vídeo (a la derecha)</sub></p>
|
||||
<p align="center"><img src="docs/media/app-chat.png" width="900" alt="The Strata app: a chat where the agent built a small voxel pagoda garden, with the page open in the preview on the right"><br>
|
||||
<sub>Un chat en la app de Strata: el agente escribió un pequeño jardín de pagoda de vóxeles en un solo archivo HTML, y la página se abre en la vista previa junto a la conversación</sub></p>
|
||||
|
||||
### En la Terminal
|
||||
|
||||
<p align="center"><img src="docs/media/terminal-monitor.png" width="900" alt="The Strata Terminal monitor overview while the model writes a reply"><br>
|
||||
<sub><code>strata monitor</code> mientras el modelo responde: velocidad, GPU, memoria y las últimas solicitudes</sub></p>
|
||||
|
||||
```sh
|
||||
strata launch # guided screens: model, context, pictures, access, then serve
|
||||
strata launch --model q2_0 --yes --no-tui # no questions: serve, print the address, return
|
||||
@@ -276,7 +281,8 @@ NVIDIA y AMD. Puedes desactivar las imágenes por modelo, o elegir el lector con
|
||||
|
||||
Las cifras detrás de la regla: en una Radeon AI PRO R9700, el lector de la tarjeta tardó 57 ms en una imagen de 260 tokens y
|
||||
el del procesador 3,1 s. En tarjetas de 16 GB (Tesla P100 y RTX A4000, Linux), el lector de la tarjeta costó un 3-5 % de
|
||||
velocidad en texto, porque ocupa unos 1,4 GB que de otro modo usaría el modelo. El Mac todavía no tiene lector de imágenes.
|
||||
velocidad en texto, porque ocupa unos 1,4 GB que de otro modo usaría el modelo. En un Mac lee la GPU, tenga la memoria que tenga (0,45 s para una imagen de 260 tokens en un M4 de 32 GB, unos 1,2 GB de la memoria compartida);
|
||||
el procesador tarda unos 50 s por imagen, así que solo lee con `--vision cpu`.
|
||||
|
||||
### Conviene saber
|
||||
|
||||
|
||||
+22
-17
@@ -33,7 +33,7 @@ fonctionne avec vos applications et vos agents de code. Vos conversations et vos
|
||||
- **Gratuit.** Licence MIT, pas de compte, pas de cloud.
|
||||
- **Privé.** Aucune télémétrie. Ce que Strata télécharge, quand il utilise internet et comment il vérifie les téléchargements est expliqué dans [docs/SAFETY.md](docs/SAFETY.md).
|
||||
|
||||
Version 0.2.0-beta · moteur 0.1.45.3
|
||||
Version 0.2.2 · moteur 0.1.47
|
||||
|
||||
## Ce qu'il vous faut
|
||||
|
||||
@@ -73,18 +73,20 @@ Vous ne savez pas lequel prendre ? Lisez le paragraphe sous le tableau.
|
||||
|
||||
| Système | Votre carte graphique | Fichier à télécharger |
|
||||
| --- | --- | --- |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.0-beta-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda13.exe) |
|
||||
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.0-beta-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda12.exe) |
|
||||
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.0-beta-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-hip.exe) |
|
||||
| **Linux** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.0-beta-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon série RX 7000 | [`Strata-0.2.0-beta-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx7900.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 ou plus récent) | intégrée | [`Strata-0.2.0-beta-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-mac.dmg) |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
|
||||
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
|
||||
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
|
||||
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX, pilote 580 ou plus récent | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, carte plus ancienne ou pilote antérieur à 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon série RX 7000 | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 ou plus récent) | intégrée | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
|
||||
|
||||
**Si vous hésitez :** pour NVIDIA, prenez `cuda13` si la version de votre pilote est 580 ou plus récente, sinon `cuda12`.
|
||||
`cuda12` convient aux cartes plus anciennes et aux pilotes plus anciens. Les fichiers `hip` et `amd` sont pour les cartes
|
||||
AMD. Les autres cartes AMD (RX 6000, ou RX 7000 sous Windows) n'ont pas encore d'application prête à l'emploi : voir
|
||||
AMD. Les autres cartes AMD (RX 6000) n'ont pas encore d'application prête à l'emploi : voir
|
||||
[Cartes AMD](docs/AMD_HIP.md).
|
||||
|
||||
Ensuite :
|
||||
@@ -161,9 +163,9 @@ plus ou moins compressé. Les petites tailles sont plus rapides. Les grandes son
|
||||
| Taille | Mémoire nécessaire (RAM + VRAM) | Téléchargement | Ce que c'est |
|
||||
| --- | ---: | ---: | --- |
|
||||
| **Gyro-S** | 27,6 Go | 58,5 Go | Pour les PC où rien d'autre ne tient. La qualité est inférieure à celle d'IQ2_XS. |
|
||||
| **Gyro-M** | 35 Go | 92,0 Go | À peu près la qualité d'IQ3_XXS avec environ 20 % de mémoire GPU en moins. Il écrit les réponses 17 à 21 % plus lentement que Q2_0 sur une carte de 12 Go et sur une R9700. |
|
||||
| **Gyro-M** | 35 Go | 92,0 Go | À peu près la qualité d'IQ3_XXS avec environ 20 % de mémoire GPU en moins. Il écrit les réponses 16 % plus lentement que Q2_0 sur une RTX 3060 de 12 Go et 21 % plus lentement sur une R9700. |
|
||||
|
||||
Gyro fonctionne sur les cartes NVIDIA et AMD (expérimental sur AMD), pas sur Intel Arc ni sur Mac. Détails et mesures :
|
||||
Gyro fonctionne sur les cartes NVIDIA et AMD (expérimental sur AMD) sur les cartes Intel Arc sous Linux (testé sur une Arc A750) et, depuis la 0.2.2, sur les Mac Apple Silicon (Gyro-S sur un Mac mini de 32 Go : 9 tokens/s, experts lus sur le SSD). Détails et mesures :
|
||||
[docs/GYRO.md](docs/GYRO.md). Lancez-le avec `strata launch --model gyro-s`.
|
||||
|
||||
### Les autres tailles
|
||||
@@ -171,7 +173,7 @@ Gyro fonctionne sur les cartes NVIDIA et AMD (expérimental sur AMD), pas sur In
|
||||
| Votre mémoire (RAM + VRAM) | Prenez | Remarques |
|
||||
| --- | --- | --- |
|
||||
| **32 Go** | **Gyro-S**, ou le **[Coder](docs/MODELS.md#coder)** pour le code | Le Coder demande 32 Go de RAM et garde la moitié des experts, il est donc moins bon hors du code |
|
||||
| **40 Go** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 Go), ou **Gyro-M** (35 Go) | Swift réfléchit moins longtemps avant de répondre. Gyro-M approche la qualité d'IQ3_XXS, mais il écrit les réponses 17 à 21 % plus lentement que Q2_0 |
|
||||
| **40 Go** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 Go), ou **Gyro-M** (35 Go) | Swift réfléchit moins longtemps avant de répondre. Gyro-M approche la qualité d'IQ3_XXS, mais il écrit les réponses 16 à 21 % plus lentement que Q2_0 |
|
||||
| **48 Go** | **IQ3_XXS** (47,0 Go) | Plus lent, meilleur |
|
||||
| **64 Go** | **IQ3_S** (54,8 Go), l'original ou Swift 1.5 | Le plus lent, le meilleur. Il égale le modèle complet dans les tests publiés |
|
||||
| **96 Go ou plus** | **UD-IQ4_XS** d'Unsloth (environ 72 Go de RAM) jusqu'à **Q8_0** (environ 136 Go), 4 à 8 bits | Plus proche du modèle complet. Avec moins de RAM, Strata lit une partie depuis le SSD, ce qui est beaucoup plus lent |
|
||||
@@ -230,11 +232,14 @@ Le chat peut chercher sur le web avec DuckDuckGo (ou Bing), sans compte. Vous po
|
||||
|
||||
Pour faire tourner deux modèles, activez **Run multiple models** (exécuter plusieurs modèles) et donnez à chacun sa propre carte de la même marque.
|
||||
|
||||
<p align="center"><img src="docs/media/runpagoda.png" width="900" alt="The Strata app's Monitor tab next to a coding agent"><br>
|
||||
<sub>Le <b>Monitor</b> de l'application Strata (à gauche) pendant qu'un agent de code écrit le jardin de pagode de la vidéo (à droite)</sub></p>
|
||||
<p align="center"><img src="docs/media/app-chat.png" width="900" alt="The Strata app: a chat where the agent built a small voxel pagoda garden, with the page open in the preview on the right"><br>
|
||||
<sub>Une discussion dans l'app Strata : l'agent a écrit un petit jardin de pagode en voxels dans un seul fichier HTML, et la page s'ouvre dans l'aperçu à côté de la conversation</sub></p>
|
||||
|
||||
### Dans le Terminal
|
||||
|
||||
<p align="center"><img src="docs/media/terminal-monitor.png" width="900" alt="The Strata Terminal monitor overview while the model writes a reply"><br>
|
||||
<sub><code>strata monitor</code> pendant que le modèle répond : vitesse, GPU, mémoire et les dernières requêtes</sub></p>
|
||||
|
||||
```sh
|
||||
strata launch # guided screens: model, context, pictures, access, then serve
|
||||
strata launch --model q2_0 --yes --no-tui # no questions: serve, print the address, return
|
||||
@@ -278,8 +283,8 @@ règle est la même pour NVIDIA et AMD. Vous pouvez désactiver les images par m
|
||||
|
||||
Les chiffres derrière la règle : sur une Radeon AI PRO R9700, le lecteur de la carte a pris 57 ms pour une image de
|
||||
260 tokens et celui du processeur 3,1 s. Sur des cartes de 16 Go (Tesla P100 et RTX A4000, Linux), le lecteur de la carte
|
||||
a coûté 3 à 5 % de vitesse sur le texte, parce qu'il occupe environ 1,4 Go que le modèle utiliserait sinon. Le Mac n'a pas
|
||||
encore de lecteur d'images.
|
||||
a coûté 3 à 5 % de vitesse sur le texte, parce qu'il occupe environ 1,4 Go que le modèle utiliserait sinon. Sur un Mac, le lecteur tourne sur le GPU quelle que soit la mémoire (0,45 s pour une image de 260 tokens sur un M4 de 32 Go, environ 1,2 Go de la
|
||||
mémoire partagée) ; le processeur met environ 50 s par image, il ne lit donc qu'avec `--vision cpu`.
|
||||
|
||||
### Bon à savoir
|
||||
|
||||
|
||||
+21
-16
@@ -33,7 +33,7 @@ Strata は **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next
|
||||
- **無料です。** MIT ライセンス。アカウントもクラウドも不要です。
|
||||
- **プライバシーを守ります。** テレメトリはありません。Strata が何をダウンロードするか、いつインターネットを使うか、ダウンロードをどう検証するかは [docs/SAFETY.md](docs/SAFETY.md) に書いてあります。
|
||||
|
||||
バージョン 0.2.0-beta · エンジン 0.1.45.3
|
||||
バージョン 0.2.2 · エンジン 0.1.47
|
||||
|
||||
## 必要な環境
|
||||
|
||||
@@ -72,17 +72,19 @@ Strata は **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next
|
||||
|
||||
| OS | グラフィックスカード | ダウンロードするファイル |
|
||||
| --- | --- | --- |
|
||||
| **Windows 10 / 11** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.0-beta-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda13.exe) |
|
||||
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.0-beta-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda12.exe) |
|
||||
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.0-beta-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-hip.exe) |
|
||||
| **Linux** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.0-beta-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon RX 7000 シリーズ | [`Strata-0.2.0-beta-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx7900.AppImage) |
|
||||
| **Mac**(Apple Silicon、macOS 15 以降) | 内蔵 | [`Strata-0.2.0-beta-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-mac.dmg) |
|
||||
| **Windows 10 / 11** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
|
||||
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
|
||||
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
|
||||
| | AMD Radeon RX 7000 シリーズまたは Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX、ドライバー 580 以降 | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA、古いカード、またはドライバーが 580 より古い場合 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon RX 9000 シリーズまたは Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon RX 7000 シリーズ | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac**(Apple Silicon、macOS 15 以降) | 内蔵 | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
|
||||
|
||||
**迷ったときは:** NVIDIA の場合、ドライバーがバージョン 580 以降なら `cuda13`、それより古ければ `cuda12` を選びます。`cuda12` は古いカードと古いドライバー向けです。
|
||||
`hip` と `amd` のファイルは AMD のカード用です。それ以外の AMD カード(RX 6000、または Windows の RX 7000)には、まだ用意されたアプリがありません。
|
||||
`hip` と `amd` のファイルは AMD のカード用です。それ以外の AMD カード(RX 6000)には、まだ用意されたアプリがありません。
|
||||
[AMD のカード](docs/AMD_HIP.md)を見てください。
|
||||
|
||||
そのあとの手順:
|
||||
@@ -154,9 +156,9 @@ AI ツールは [MCP サーバー](docs/MCP_SERVER.md)を通じて、Strata の
|
||||
| サイズ | 必要なメモリ(RAM + VRAM) | ダウンロード | 内容 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| **Gyro-S** | 27.6 GB | 58.5 GB | ほかのサイズが入らないPC向け。品質は IQ2_XS より下がります。 |
|
||||
| **Gyro-M** | 35 GB | 92.0 GB | 品質は IQ3_XXS 程度で、GPUメモリの使用量は約20%少なくなります。12 GBのカードと R9700 では、回答の書き出しが Q2_0 より17〜21%遅くなります。 |
|
||||
| **Gyro-M** | 35 GB | 92.0 GB | 品質は IQ3_XXS 程度で、GPUメモリの使用量は約20%少なくなります。回答の書き出しは Q2_0 より、12 GBの RTX 3060 で16%、R9700 で21%遅くなります。 |
|
||||
|
||||
Gyro は NVIDIA と AMD のカードで動きます(AMD は実験的)。Intel Arc と Mac では動きません。詳細と測定結果は
|
||||
Gyro は NVIDIA と AMD のカード(AMD は実験的)、および Linux 上の Intel Arc カード(Arc A750 で確認)で動きます。0.2.2 からは Apple Silicon の Mac でも動きます(32 GB の Mac mini で Gyro-S は 9 トークン/秒、エキスパートは SSD から読み込み)。詳細と測定結果は
|
||||
[docs/GYRO.md](docs/GYRO.md) にあります。`strata launch --model gyro-s` で起動します。
|
||||
|
||||
### ほかのサイズ
|
||||
@@ -164,7 +166,7 @@ Gyro は NVIDIA と AMD のカードで動きます(AMD は実験的)。Inte
|
||||
| メモリ(RAM + VRAM) | 選ぶモデル | 備考 |
|
||||
| --- | --- | --- |
|
||||
| **32 GB** | **Gyro-S**、またはコード向けの **[Coder](docs/MODELS.md#coder)** | Coder はRAM 32 GBが必要で、エキスパートを半分だけ保持するため、コード以外は弱くなります |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS(39.2 GB)、または **Gyro-M**(35 GB) | Swift は答える前に考える時間が短くなります。Gyro-M は品質が IQ3_XXS に近いものの、回答の書き出しは Q2_0 より17〜21%遅くなります |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS(39.2 GB)、または **Gyro-M**(35 GB) | Swift は答える前に考える時間が短くなります。Gyro-M は品質が IQ3_XXS に近いものの、回答の書き出しは Q2_0 より16〜21%遅くなります |
|
||||
| **48 GB** | **IQ3_XXS**(47.0 GB) | 遅くなりますが、品質は上がります |
|
||||
| **64 GB** | **IQ3_S**(54.8 GB)、オリジナルまたは Swift 1.5 | いちばん遅く、いちばん高品質です。公開されているテストでは完全版と同等です |
|
||||
| **96 GB 以上** | Unsloth の **UD-IQ4_XS**(RAM 約72 GB)から **Q8_0**(約136 GB)まで、4〜8ビット | 完全版に近づきます。RAM が足りないと、Strata は一部を SSD から読むため、ずっと遅くなります |
|
||||
@@ -219,11 +221,14 @@ Gyro は NVIDIA と AMD のカードで動きます(AMD は実験的)。Inte
|
||||
|
||||
2つのモデルを動かすには、**複数のモデルを実行**をオンにして、それぞれのモデルに同じメーカーのカードを1枚ずつ割り当てます。
|
||||
|
||||
<p align="center"><img src="docs/media/runpagoda.png" width="900" alt="The Strata app's Monitor tab next to a coding agent"><br>
|
||||
<sub>Strata アプリの<b>モニター</b>(左)と、動画の塔の庭園をコーディングエージェントが書いているところ(右)</sub></p>
|
||||
<p align="center"><img src="docs/media/app-chat.png" width="900" alt="The Strata app: a chat where the agent built a small voxel pagoda garden, with the page open in the preview on the right"><br>
|
||||
<sub>Strata アプリでのチャット。エージェントが小さなボクセルの塔の庭を 1 つの HTML ファイルとして書き、そのページが会話の横のプレビューで開きます</sub></p>
|
||||
|
||||
### ターミナルで
|
||||
|
||||
<p align="center"><img src="docs/media/terminal-monitor.png" width="900" alt="The Strata Terminal monitor overview while the model writes a reply"><br>
|
||||
<sub>モデルが返信を書いている間の <code>strata monitor</code>。速度、GPU、メモリ、最近のリクエストが表示されます</sub></p>
|
||||
|
||||
```sh
|
||||
strata launch # ガイド付きの画面: モデル、コンテキスト、画像、アクセス、そして開始
|
||||
strata launch --model q2_0 --yes --no-tui # 質問なし: 開始し、アドレスを表示して戻る
|
||||
@@ -265,7 +270,7 @@ strata doctor # インストール済みのエ
|
||||
|
||||
このルールの根拠となる数値です。Radeon AI PRO R9700 では、カードのリーダーは260トークンの画像に57 ms、プロセッサのリーダーは3.1秒かかりました。
|
||||
16 GBのカード(Tesla P100 と RTX A4000、Linux)では、カードのリーダーを使うとテキストの速度が3〜5%下がりました。
|
||||
本来モデルが使う約1.4 GBをリーダーが占めるためです。Mac にはまだ画像リーダーがありません。
|
||||
本来モデルが使う約1.4 GBをリーダーが占めるためです。Mac ではメモリ量にかかわらず GPU が読み取ります(32 GB の M4 で260トークンの画像に0.45秒、共有メモリを約1.2 GB使います)。プロセッサでは1枚あたり約50秒かかるため、`--vision cpu` を指定したときだけ使います。
|
||||
|
||||
### 知っておくと便利なこと
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ agents. Your chats and files stay on your PC.
|
||||
- **Free.** MIT license, no account, no cloud.
|
||||
- **Private.** No telemetry. What Strata downloads, when it uses the internet and how it checks downloads is in [docs/SAFETY.md](docs/SAFETY.md).
|
||||
|
||||
Version 0.2.0-beta · engine 0.1.45.3
|
||||
Version 0.2.2 · engine 0.1.47
|
||||
|
||||
## What you need
|
||||
|
||||
@@ -73,18 +73,21 @@ line under the table.
|
||||
|
||||
| System | Your graphics card | File to download |
|
||||
| --- | --- | --- |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.0-beta-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda13.exe) |
|
||||
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.0-beta-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda12.exe) |
|
||||
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.0-beta-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-hip.exe) |
|
||||
| **Linux** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.0-beta-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon RX 7000 series | [`Strata-0.2.0-beta-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx7900.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 or newer) | built in | [`Strata-0.2.0-beta-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-mac.dmg) |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
|
||||
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
|
||||
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
|
||||
| | AMD Radeon RX 7000 series or Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX, driver 580 or newer | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, older card or driver older than 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon RX 9000 series or Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon RX 7000 series | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 or newer) | built in | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
|
||||
|
||||
**If you are unsure:** for NVIDIA, take `cuda13` if your driver is version 580 or newer, otherwise `cuda12`. `cuda12` is
|
||||
for older cards and older drivers. The `hip` and `amd` files are for AMD cards. Other AMD cards (RX 6000, or RX 7000 on
|
||||
Windows) have no ready-made app yet: see [AMD cards](docs/AMD_HIP.md).
|
||||
for older cards and older drivers. The `hip` and `amd` files are for AMD cards. RX 6000 cards have no installer of
|
||||
their own: install `hip-rdna3` (Windows) or `amd-rx7900` (Linux), and on a PC with an RX 6000 card it fetches the RX 6000 engine by itself
|
||||
(also `strata update`). That engine is built but not yet tested on a real RX 6000 card: see [AMD cards](docs/AMD_HIP.md).
|
||||
|
||||
Then:
|
||||
|
||||
@@ -155,17 +158,19 @@ Smaller sizes are faster. Larger sizes are a bit smarter.
|
||||
| Size | Memory it needs (RAM + VRAM) | Download | What it is |
|
||||
| --- | ---: | ---: | --- |
|
||||
| **Gyro-S** | 27.6 GB | 58.5 GB | For PCs that fit nothing else. Quality is lower than IQ2_XS. |
|
||||
| **Gyro-M** | 35 GB | 92.0 GB | About IQ3_XXS quality with about 20% less GPU memory. It writes answers 17-21% slower than Q2_0 on a 12 GB card and on an R9700. |
|
||||
| **Gyro-M** | 35 GB | 92.0 GB | About IQ3_XXS quality with about 20% less GPU memory. It writes answers 16% (12 GB RTX 3060) to 21% (R9700) slower than Q2_0. |
|
||||
|
||||
Gyro runs on NVIDIA and AMD cards (AMD is experimental), not on Intel Arc or the Mac. Details and measurements:
|
||||
Gyro runs on NVIDIA and AMD cards (AMD is experimental) on Intel Arc cards on Linux (tested on an Arc A750) and, from 0.2.2, on Apple Silicon Macs (Gyro-S on a 32 GB Mac mini: 9 tok/s, streaming from the SSD; [docs/MAC.md](docs/MAC.md#gyro-s-and-gyro-m-agentionais-trellis-experts)). Details and measurements:
|
||||
[docs/GYRO.md](docs/GYRO.md). Start it with `strata launch --model gyro-s`.
|
||||
|
||||
### The other sizes
|
||||
|
||||
The tiers count your RAM plus your VRAM, less 5 GB that Windows and the system keep for themselves. Every other size stays a choice.
|
||||
|
||||
| Your memory (RAM + VRAM) | Take | Notes |
|
||||
| --- | --- | --- |
|
||||
| **32 GB** | **Gyro-S**, or the **[Coder](docs/MODELS.md#coder)** for code | The Coder needs 32 GB of RAM and keeps half of the experts, so it is weaker outside code |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39.2 GB), or **Gyro-M** (35 GB) | Swift thinks for a shorter time before it answers. Gyro-M is close to IQ3_XXS in quality, but it writes answers 17-21% slower than Q2_0 |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39.2 GB), or **Gyro-M** (35 GB) | Swift thinks for a shorter time before it answers. Gyro-M is close to IQ3_XXS in quality, but it writes answers 16-21% slower than Q2_0 |
|
||||
| **48 GB** | **IQ3_XXS** (47.0 GB) | Slower, better |
|
||||
| **64 GB** | **IQ3_S** (54.8 GB), the original or Swift 1.5 | The slowest, the best: it matches the full model on the published tests |
|
||||
| **96 GB or more** | Unsloth's **UD-IQ4_XS** (about 72 GB of RAM) to **Q8_0** (about 136 GB), 4 to 8 bit | Closer to the full model. With less RAM, Strata reads part of them from the SSD, which is much slower |
|
||||
@@ -223,11 +228,14 @@ Search is the one feature that sends something off your PC, the search words.
|
||||
|
||||
To run two models, turn on **Run multiple models** and give each model its own card of the same make.
|
||||
|
||||
<p align="center"><img src="docs/media/runpagoda.png" width="900" alt="The Strata app's Monitor tab next to a coding agent"><br>
|
||||
<sub>The Strata app's <b>Monitor</b> (left) while a coding agent writes the pagoda garden from the video (right)</sub></p>
|
||||
<p align="center"><img src="docs/media/app-chat.png" width="900" alt="The Strata app: a chat where the agent built a small voxel pagoda garden, with the page open in the preview on the right"><br>
|
||||
<sub>A chat in the Strata app: the agent wrote a small voxel pagoda garden as one HTML file, and the page opens in the preview next to the conversation</sub></p>
|
||||
|
||||
### In the Terminal
|
||||
|
||||
<p align="center"><img src="docs/media/terminal-monitor.png" width="900" alt="The Strata Terminal monitor overview while the model writes a reply"><br>
|
||||
<sub><code>strata monitor</code> while the model writes a reply: speed, GPU, memory and the recent requests</sub></p>
|
||||
|
||||
```sh
|
||||
strata launch # guided screens: model, context, pictures, access, then serve
|
||||
strata launch --model q2_0 --yes --no-tui # no questions: serve, print the address, return
|
||||
@@ -241,7 +249,7 @@ strata doctor # check the installed engine file
|
||||
|
||||
`strata --help` lists everything. Starting and serving never download anything. Only the install, `strata launch` (when
|
||||
you choose a model) and `strata update` do. To reach Strata from your phone or another PC, use
|
||||
`strata launch --access lan`. It makes an API key and will not start on a network without one. No browser opens unless
|
||||
`strata launch --access lan`. It makes an API key (recommended; add `--no-api-key` to go without, with a warning). No browser opens unless
|
||||
you add `--open`. Guide: [docs/TERMINAL_LAUNCH.md](docs/TERMINAL_LAUNCH.md). Whole client: [docs/TERMINAL.md](docs/TERMINAL.md).
|
||||
|
||||
### With AI agents
|
||||
@@ -270,7 +278,8 @@ off per model, or choose the reader with `--vision off`, `--vision gpu` or `--vi
|
||||
|
||||
The numbers behind the rule: on a Radeon AI PRO R9700 the card's reader took 57 ms for a 260-token picture and the
|
||||
processor's took 3.1 s. On 16 GB cards (Tesla P100 and RTX A4000, Linux) the card's reader cost 3-5% of text speed,
|
||||
because it holds about 1.4 GB that the model would use otherwise. The Mac has no picture reader yet.
|
||||
because it holds about 1.4 GB that the model would use otherwise. On a Mac the reader runs on the Mac's GPU whatever its memory (0.45 s for a 260-token picture on an M4 with 32 GB, about 1.2 GB of the shared
|
||||
memory); the processor reader needs about 50 s per picture there, so it runs only when you ask for it with `--vision cpu`.
|
||||
|
||||
### Good to know
|
||||
|
||||
|
||||
+22
-17
@@ -33,7 +33,7 @@ agentes de programação. Suas conversas e arquivos ficam no seu PC.
|
||||
- **Gratuito.** Licença MIT, sem conta, sem nuvem.
|
||||
- **Privado.** Sem telemetria. O que o Strata baixa, quando usa a internet e como confere os downloads está em [docs/SAFETY.md](docs/SAFETY.md).
|
||||
|
||||
Versão 0.2.0-beta · mecanismo 0.1.45.3
|
||||
Versão 0.2.2 · mecanismo 0.1.47
|
||||
|
||||
## O que você precisa
|
||||
|
||||
@@ -73,18 +73,19 @@ logo abaixo da tabela.
|
||||
|
||||
| Sistema | Sua placa de vídeo | Arquivo para baixar |
|
||||
| --- | --- | --- |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.0-beta-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda13.exe) |
|
||||
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.0-beta-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda12.exe) |
|
||||
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.0-beta-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-hip.exe) |
|
||||
| **Linux** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.0-beta-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon série RX 7000 | [`Strata-0.2.0-beta-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx7900.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 ou mais novo) | integrada | [`Strata-0.2.0-beta-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-mac.dmg) |
|
||||
| **Windows 10 / 11** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
|
||||
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
|
||||
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
|
||||
| | AMD Radeon série RX 7000 ou Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX, driver 580 ou mais novo | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA, placa mais antiga ou driver anterior ao 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon série RX 9000 ou Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon série RX 7000 | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac** (Apple Silicon, macOS 15 ou mais novo) | integrada | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
|
||||
|
||||
**Se você estiver em dúvida:** para NVIDIA, use `cuda13` se o seu driver for a versão 580 ou mais nova, e `cuda12` nos outros casos. O `cuda12`
|
||||
serve para placas e drivers mais antigos. Os arquivos `hip` e `amd` são para placas AMD. Outras placas AMD (RX 6000, ou RX 7000 no
|
||||
Windows) ainda não têm app pronto: veja [Placas AMD](docs/AMD_HIP.md).
|
||||
serve para placas e drivers mais antigos. Os arquivos `hip` e `amd` são para placas AMD. Outras placas AMD (RX 6000) ainda não têm app pronto: veja [Placas AMD](docs/AMD_HIP.md).
|
||||
|
||||
Depois:
|
||||
|
||||
@@ -155,9 +156,9 @@ O [Gyro](https://huggingface.co/agentionai/Qwen3.8-Flash-Next-Gyro-GGUF) (da age
|
||||
| Tamanho | Memória necessária (RAM + VRAM) | Download | O que é |
|
||||
| --- | ---: | ---: | --- |
|
||||
| **Gyro-S** | 27,6 GB | 58,5 GB | Para PCs onde nenhum outro tamanho cabe. A qualidade é menor que a do IQ2_XS. |
|
||||
| **Gyro-M** | 35 GB | 92,0 GB | Qualidade próxima à do IQ3_XXS, com cerca de 20% menos memória da GPU. Ele escreve as respostas 17 a 21% mais devagar que o Q2_0 numa placa de 12 GB e numa R9700. |
|
||||
| **Gyro-M** | 35 GB | 92,0 GB | Qualidade próxima à do IQ3_XXS, com cerca de 20% menos memória da GPU. Ele escreve as respostas 16% mais devagar que o Q2_0 numa RTX 3060 de 12 GB e 21% mais devagar numa R9700. |
|
||||
|
||||
O Gyro roda em placas NVIDIA e AMD (AMD é experimental), e não roda em Intel Arc nem no Mac. Detalhes e medições:
|
||||
O Gyro roda em placas NVIDIA e AMD (AMD é experimental) em placas Intel Arc no Linux (testado numa Arc A750) e, desde a 0.2.2, em Macs com Apple Silicon (Gyro-S num Mac mini de 32 GB: 9 tokens/s, com os especialistas lidos do SSD). Detalhes e medições:
|
||||
[docs/GYRO.md](docs/GYRO.md). Inicie com `strata launch --model gyro-s`.
|
||||
|
||||
### Os outros tamanhos
|
||||
@@ -165,7 +166,7 @@ O Gyro roda em placas NVIDIA e AMD (AMD é experimental), e não roda em Intel A
|
||||
| Sua memória (RAM + VRAM) | Escolha | Observações |
|
||||
| --- | --- | --- |
|
||||
| **32 GB** | **Gyro-S**, ou o **[Coder](docs/MODELS.md#coder)** para código | O Coder precisa de 32 GB de RAM e mantém metade dos experts, então é mais fraco fora de código |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), ou **Gyro-M** (35 GB) | O Swift pensa por menos tempo antes de responder. O Gyro-M chega perto do IQ3_XXS em qualidade, mas escreve as respostas 17 a 21% mais devagar que o Q2_0 |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS (39,2 GB), ou **Gyro-M** (35 GB) | O Swift pensa por menos tempo antes de responder. O Gyro-M chega perto do IQ3_XXS em qualidade, mas escreve as respostas 16 a 21% mais devagar que o Q2_0 |
|
||||
| **48 GB** | **IQ3_XXS** (47,0 GB) | Mais lento, melhor |
|
||||
| **64 GB** | **IQ3_S** (54,8 GB), o original ou o Swift 1.5 | O mais lento, o melhor: empata com o modelo completo nos testes publicados |
|
||||
| **96 GB ou mais** | **UD-IQ4_XS** da Unsloth (cerca de 72 GB de RAM) até **Q8_0** (cerca de 136 GB), de 4 a 8 bits | Mais perto do modelo completo. Com menos RAM, o Strata lê parte deles do SSD, o que é bem mais lento |
|
||||
@@ -222,11 +223,14 @@ A busca é o único recurso que envia algo para fora do seu PC: os termos da pes
|
||||
|
||||
Para rodar dois modelos, ative **Executar vários modelos** e dê a cada modelo a sua própria placa da mesma marca.
|
||||
|
||||
<p align="center"><img src="docs/media/runpagoda.png" width="900" alt="The Strata app's Monitor tab next to a coding agent"><br>
|
||||
<sub>O <b>Monitor</b> do app Strata (à esquerda) enquanto um agente de programação escreve o jardim de pagodes do vídeo (à direita)</sub></p>
|
||||
<p align="center"><img src="docs/media/app-chat.png" width="900" alt="The Strata app: a chat where the agent built a small voxel pagoda garden, with the page open in the preview on the right"><br>
|
||||
<sub>Um chat no app Strata: o agente escreveu um pequeno jardim de pagode em voxels em um único arquivo HTML, e a página abre na pré-visualização ao lado da conversa</sub></p>
|
||||
|
||||
### No Terminal
|
||||
|
||||
<p align="center"><img src="docs/media/terminal-monitor.png" width="900" alt="The Strata Terminal monitor overview while the model writes a reply"><br>
|
||||
<sub><code>strata monitor</code> enquanto o modelo responde: velocidade, GPU, memória e as solicitações recentes</sub></p>
|
||||
|
||||
```sh
|
||||
strata launch # telas guiadas: modelo, contexto, imagens, acesso e depois servir
|
||||
strata launch --model q2_0 --yes --no-tui # sem perguntas: serve, mostra o endereço e volta
|
||||
@@ -269,7 +273,8 @@ por modelo, ou escolha o leitor com `--vision off`, `--vision gpu` ou `--vision
|
||||
|
||||
Os números por trás da regra: numa Radeon AI PRO R9700, o leitor da placa levou 57 ms para uma imagem de 260 tokens e o do
|
||||
processador levou 3,1 s. Em placas de 16 GB (Tesla P100 e RTX A4000, Linux), o leitor da placa custou de 3 a 5% da velocidade do texto,
|
||||
porque ocupa cerca de 1,4 GB que o modelo usaria de outra forma. O Mac ainda não tem leitor de imagens.
|
||||
porque ocupa cerca de 1,4 GB que o modelo usaria de outra forma. No Mac, a GPU faz a leitura, seja qual for a memória (0,45 s para uma imagem de 260 tokens em um M4 de 32 GB, cerca de 1,2 GB da memória compartilhada);
|
||||
o processador leva cerca de 50 s por imagem, então só lê com `--vision cpu`.
|
||||
|
||||
### Bom saber
|
||||
|
||||
|
||||
+21
-16
@@ -33,7 +33,7 @@ Strata 运行 **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-N
|
||||
- **免费。** MIT 许可证,不用注册账号,不走云端。
|
||||
- **隐私。** 没有遥测。Strata 下载什么、什么时候联网、如何校验下载内容,都写在 [docs/SAFETY.md](docs/SAFETY.md)。
|
||||
|
||||
版本 0.2.0-beta · 引擎 0.1.45.3
|
||||
版本 0.2.2 · 引擎 0.1.47
|
||||
|
||||
## 硬件要求
|
||||
|
||||
@@ -72,17 +72,19 @@ Strata 运行 **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-N
|
||||
|
||||
| 系统 | 你的显卡 | 要下载的文件 |
|
||||
| --- | --- | --- |
|
||||
| **Windows 10 / 11** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.0-beta-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda13.exe) |
|
||||
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.0-beta-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-cuda12.exe) |
|
||||
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.0-beta-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-windows-hip.exe) |
|
||||
| **Linux** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.0-beta-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon RX 7000 系列 | [`Strata-0.2.0-beta-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-linux-amd-rx7900.AppImage) |
|
||||
| **Mac**(Apple 芯片,macOS 15 及以上) | 内置 | [`Strata-0.2.0-beta-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.0-beta-mac.dmg) |
|
||||
| **Windows 10 / 11** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.2-windows-cuda13.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda13.exe) |
|
||||
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.2-windows-cuda12.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-cuda12.exe) |
|
||||
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.2-windows-hip.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip.exe) |
|
||||
| | AMD Radeon RX 7000 系列或 Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-windows-hip-rdna3.exe`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-windows-hip-rdna3.exe) |
|
||||
| **Linux** | NVIDIA RTX,驱动 580 或更新 | [`Strata-0.2.2-linux-nvidia-cuda13.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda13.AppImage) |
|
||||
| | NVIDIA,较旧的显卡,或驱动低于 580 | [`Strata-0.2.2-linux-nvidia-cuda12.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-nvidia-cuda12.AppImage) |
|
||||
| | AMD Radeon RX 9000 系列或 Radeon AI PRO R9700 | [`Strata-0.2.2-linux-amd-rx9070.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx9070.AppImage) |
|
||||
| | AMD Radeon RX 7000 系列 | [`Strata-0.2.2-linux-amd-rx7900.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-rx7900.AppImage) |
|
||||
| | AMD Ryzen AI Max (Strix Halo) | [`Strata-0.2.2-linux-amd-strixhalo.AppImage`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-linux-amd-strixhalo.AppImage) |
|
||||
| **Mac**(Apple 芯片,macOS 15 及以上) | 内置 | [`Strata-0.2.2-mac.dmg`](https://github.com/Niko1221/Strata/releases/latest/download/Strata-0.2.2-mac.dmg) |
|
||||
|
||||
**不确定时:** NVIDIA 显卡的驱动版本在 580 及以上就选 `cuda13`,否则选 `cuda12`。`cuda12` 适用于较旧的显卡和驱动。
|
||||
`hip` 和 `amd` 的文件是给 AMD 显卡用的。其他 AMD 显卡(RX 6000,或 Windows 上的 RX 7000)目前还没有现成的应用,
|
||||
`hip` 和 `amd` 的文件是给 AMD 显卡用的。其他 AMD 显卡(RX 6000)目前还没有现成的应用,
|
||||
请看 [AMD 显卡](docs/AMD_HIP.md)。
|
||||
|
||||
然后:
|
||||
@@ -153,9 +155,9 @@ Set up Strata on this PC for me: https://github.com/Niko1221/Strata - follow doc
|
||||
| 尺寸 | 所需内存(RAM + VRAM) | 下载大小 | 说明 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| **Gyro-S** | 27.6 GB | 58.5 GB | 给其他尺寸都放不下的电脑。质量低于 IQ2_XS。 |
|
||||
| **Gyro-M** | 35 GB | 92.0 GB | 质量约相当于 IQ3_XXS,显存占用少约 20%。在 12 GB 显卡和 R9700 上,输出回答比 Q2_0 慢 17-21%。 |
|
||||
| **Gyro-M** | 35 GB | 92.0 GB | 质量约相当于 IQ3_XXS,显存占用少约 20%。输出回答比 Q2_0 慢,12 GB 的 RTX 3060 上慢 16%,R9700 上慢 21%。 |
|
||||
|
||||
Gyro 可在 NVIDIA 和 AMD 显卡上运行(AMD 为实验性支持),不支持 Intel Arc 和 Mac。详情和测试数据:
|
||||
Gyro 可在 NVIDIA 和 AMD 显卡(AMD 为实验性支持)以及 Linux 上的 Intel Arc 显卡(已在 Arc A750 上测试)上运行,自 0.2.2 起也可在 Apple Silicon 的 Mac 上运行(32 GB 的 Mac mini 上 Gyro-S 为 9 token/秒,专家权重从 SSD 读取)。详情和测试数据:
|
||||
[docs/GYRO.md](docs/GYRO.md)。用 `strata launch --model gyro-s` 启动。
|
||||
|
||||
### 其他尺寸
|
||||
@@ -163,7 +165,7 @@ Gyro 可在 NVIDIA 和 AMD 显卡上运行(AMD 为实验性支持),不支
|
||||
| 你的内存(RAM + VRAM) | 选择 | 备注 |
|
||||
| --- | --- | --- |
|
||||
| **32 GB** | **Gyro-S**,写代码可选 **[Coder](docs/MODELS.md#coder)** | Coder 需要 32 GB 内存,只保留一半的专家,所以代码以外的能力较弱 |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS(39.2 GB),或 **Gyro-M**(35 GB) | Swift 回答前思考的时间更短。Gyro-M 的质量接近 IQ3_XXS,但输出回答比 Q2_0 慢 17-21% |
|
||||
| **40 GB** | **[Swift 1.5](docs/MODELS.md#swift-15)** IQ2_XS(39.2 GB),或 **Gyro-M**(35 GB) | Swift 回答前思考的时间更短。Gyro-M 的质量接近 IQ3_XXS,但输出回答比 Q2_0 慢 16-21% |
|
||||
| **48 GB** | **IQ3_XXS**(47.0 GB) | 更慢,更好 |
|
||||
| **64 GB** | **IQ3_S**(54.8 GB),原版或 Swift 1.5 | 最慢,最好:在公开测试中与完整模型持平 |
|
||||
| **96 GB 及以上** | Unsloth 的 **UD-IQ4_XS**(约 72 GB 内存)到 **Q8_0**(约 136 GB),4 到 8 位 | 更接近完整模型。内存不足时,Strata 会从固态硬盘读取一部分,速度会慢很多 |
|
||||
@@ -218,11 +220,14 @@ Gyro 可在 NVIDIA 和 AMD 显卡上运行(AMD 为实验性支持),不支
|
||||
|
||||
要同时运行两个模型,请打开**运行多个模型**,并为每个模型分配同一品牌的单独一张显卡。
|
||||
|
||||
<p align="center"><img src="docs/media/runpagoda.png" width="900" alt="The Strata app's Monitor tab next to a coding agent"><br>
|
||||
<sub>Strata 应用的<b>监控</b>(左),右边是编程智能体正在写视频里的宝塔花园</sub></p>
|
||||
<p align="center"><img src="docs/media/app-chat.png" width="900" alt="The Strata app: a chat where the agent built a small voxel pagoda garden, with the page open in the preview on the right"><br>
|
||||
<sub>Strata 应用中的一次对话:智能体用一个 HTML 文件写出了一个小型体素宝塔花园,页面在对话旁的预览中打开</sub></p>
|
||||
|
||||
### 在终端里
|
||||
|
||||
<p align="center"><img src="docs/media/terminal-monitor.png" width="900" alt="The Strata Terminal monitor overview while the model writes a reply"><br>
|
||||
<sub>模型回复时的 <code>strata monitor</code>:速度、GPU、内存和最近的请求</sub></p>
|
||||
|
||||
```sh
|
||||
strata launch # 引导界面:模型、上下文、图片、访问方式,然后开始服务
|
||||
strata launch --model q2_0 --yes --no-tui # 不提问:开始服务,打印地址,然后返回
|
||||
@@ -263,7 +268,7 @@ strata doctor # 检查已安装的引擎文件
|
||||
|
||||
这条规则背后的数据:在 Radeon AI PRO R9700 上,显卡读一张 260 token 的图片用了 57 ms,处理器用了 3.1 秒。在 16 GB 的显卡
|
||||
(Tesla P100 和 RTX A4000,Linux)上,用显卡读图会让文字速度下降 3-5%,因为它占用约 1.4 GB 显存,否则这部分会被模型使用。
|
||||
Mac 目前还没有读图功能。
|
||||
在 Mac 上,无论内存多大,都由 GPU 读图(32 GB 的 M4 上读一张 260 token 的图片用 0.45 秒,占用约 1.2 GB 共享内存);处理器读一张约需 50 秒,所以只有指定 `--vision cpu` 时才用。
|
||||
|
||||
### 使用须知
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
rem Strata for Windows: the first run installs everything and starts the model; later runs just start it.
|
||||
rem Needs only an NVIDIA or AMD graphics driver. Python is installed for your user account if it is missing (no admin needed).
|
||||
setlocal
|
||||
rem Python reads and writes text as UTF-8, not in the Windows code page (cp1251, cp866, ...): a folder or user name with Cyrillic or other letters stays intact (#3202).
|
||||
set "PYTHONUTF8=1"
|
||||
title Strata
|
||||
cd /d "%~dp0"
|
||||
rem A bundled Python runtime (the python\ folder of the terminal archive) is used when it is there: nothing to find,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "strata-desktop",
|
||||
"productName": "Strata",
|
||||
"version": "0.2.0-beta",
|
||||
"version": "0.2.2",
|
||||
"private": true,
|
||||
"description": "Strata Desktop - local AI, tuned to one model.",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -3524,7 +3524,7 @@ checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
|
||||
|
||||
[[package]]
|
||||
name = "strata-desktop"
|
||||
version = "0.2.0-beta"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "strata-desktop"
|
||||
version = "0.2.0-beta"
|
||||
version = "0.2.2"
|
||||
description = "Strata Desktop — local AI, tuned to one model."
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
|
||||
@@ -168,7 +168,8 @@ VIAddVersionKey "ProductVersion" "${VERSION}"
|
||||
!define MUI_LANGDLL_REGISTRY_KEY "${MANUPRODUCTKEY}"
|
||||
!define MUI_LANGDLL_REGISTRY_VALUENAME "Installer Language"
|
||||
|
||||
; Strata's look and words: the app's light surface and ink, short copy, no directory page, straight on to the progress.
|
||||
; Strata's look and words: the app's light surface and ink, short copy. The install folder page is shown on a first or manual install (#3186 #3190: C: can be short on space) and
|
||||
; skipped in an update (passive), which goes on into the folder the earlier install wrote to the registry.
|
||||
!define MUI_BGCOLOR "F9FAFB"
|
||||
!define MUI_TEXTCOLOR "171B1E"
|
||||
!define MUI_INSTFILESPAGE_PROGRESSBAR "smooth"
|
||||
@@ -421,8 +422,19 @@ Function PageLeaveReinstall
|
||||
reinst_done:
|
||||
FunctionEnd
|
||||
|
||||
; 5. Choose install directory page
|
||||
!define MUI_PAGE_CUSTOMFUNCTION_PRE Skip
|
||||
; 5. Choose install directory page. The app is installed for this user only (no administrator), so the folder must be one the
|
||||
; user can write to: another drive (D:\Strata) or a folder in the profile, not Program Files. An update (passive: the app's own
|
||||
; updater) and a silent install skip it and keep the earlier folder (RestorePreviousInstallLocation).
|
||||
!ifdef MUI_PAGE_HEADER_TEXT
|
||||
!undef MUI_PAGE_HEADER_TEXT
|
||||
!endif
|
||||
!ifdef MUI_PAGE_HEADER_SUBTEXT
|
||||
!undef MUI_PAGE_HEADER_SUBTEXT
|
||||
!endif
|
||||
!define MUI_PAGE_HEADER_TEXT "Choose where to install Strata"
|
||||
!define MUI_PAGE_HEADER_SUBTEXT "Pick another drive if C: is short on space."
|
||||
!define MUI_DIRECTORYPAGE_TEXT_TOP "Strata is installed into this folder, for your user only (no administrator needed). Models and chats are stored separately, in the data folder you choose inside Strata."
|
||||
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
|
||||
; 6. Start menu shortcut page
|
||||
@@ -435,7 +447,15 @@ Var AppStartMenuFolder
|
||||
!endif
|
||||
!insertmacro MUI_PAGE_STARTMENU Application $AppStartMenuFolder
|
||||
|
||||
; 7. Installation page
|
||||
; 7. Installation page (a page macro uses up the header texts, so they are set again here)
|
||||
!ifdef MUI_PAGE_HEADER_TEXT
|
||||
!undef MUI_PAGE_HEADER_TEXT
|
||||
!endif
|
||||
!ifdef MUI_PAGE_HEADER_SUBTEXT
|
||||
!undef MUI_PAGE_HEADER_SUBTEXT
|
||||
!endif
|
||||
!define MUI_PAGE_HEADER_TEXT "Installing Strata"
|
||||
!define MUI_PAGE_HEADER_SUBTEXT "Copying the app. You can keep working meanwhile."
|
||||
!insertmacro MUI_PAGE_INSTFILES
|
||||
|
||||
; 8. Finish page
|
||||
|
||||
@@ -8,11 +8,21 @@
|
||||
//!
|
||||
//! `strata-desktop --save-logs` puts the newest crash log, the tails of the newest shell logs and the engine server logs into one text file
|
||||
//! in the Downloads folder. The same pieces go into Settings > Engine > Diagnostics > Save logs (the host reads this folder).
|
||||
//!
|
||||
//! Windows and macOS (one process owns the host and the window) use the same folder and file names for what happens while the app starts:
|
||||
//! `init_startup` opens the run log first thing in `main`, before anything that can fail, and installs a panic hook; a start that fails
|
||||
//! (the host does not come up, the window cannot be made, any panic) ends in `startup_failed`: the reason goes into the run log and a
|
||||
//! `desktop-crash-` file, and the person sees a plain message box instead of a process that vanished with no trace. On Windows the folder
|
||||
//! is `%LOCALAPPDATA%\Strata\logs`, next to the components folder.
|
||||
|
||||
// Linux uses the shell log, the crash log and --save-logs; Windows and macOS use the start-up half. Each side leaves the other unused.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
const KEEP: usize = 10;
|
||||
@@ -23,6 +33,23 @@ pub fn data_dir() -> PathBuf {
|
||||
if let Some(dir) = std::env::var_os("STRATA_DESKTOP_DATA_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
platform_data_dir()
|
||||
}
|
||||
|
||||
/// Windows: `%LOCALAPPDATA%\Strata`, the folder the host and Control already use for the components and the install.
|
||||
#[cfg(windows)]
|
||||
fn platform_data_dir() -> PathBuf {
|
||||
std::env::var_os("LOCALAPPDATA")
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_absolute())
|
||||
.or_else(|| std::env::var_os("USERPROFILE").map(|h| PathBuf::from(h).join("AppData").join("Local")))
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join("Strata")
|
||||
}
|
||||
|
||||
/// Linux and macOS: `$XDG_DATA_HOME/strata`, usually `~/.local/share/strata`.
|
||||
#[cfg(not(windows))]
|
||||
fn platform_data_dir() -> PathBuf {
|
||||
let base = std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_absolute())
|
||||
@@ -105,8 +132,11 @@ impl Log {
|
||||
log
|
||||
}
|
||||
pub fn line(&self, who: &str, text: &str) {
|
||||
if let Some(f) = self.file.lock().unwrap().as_mut() {
|
||||
let _ = writeln!(f, "[{who}] {text}");
|
||||
// never panics: this runs inside the panic hook too
|
||||
if let Ok(mut guard) = self.file.lock() {
|
||||
if let Some(f) = guard.as_mut() {
|
||||
let _ = writeln!(f, "[{who}] {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,8 +341,180 @@ pub fn save_logs(out_dir: &Path) -> std::io::Result<PathBuf> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------------------
|
||||
// Start-up failures (Windows and macOS)
|
||||
// ---------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/// The run log and whether a failure may show a message box. Set once, first thing in `main`.
|
||||
struct Run {
|
||||
log: Log,
|
||||
dialog: bool,
|
||||
}
|
||||
|
||||
static RUN: OnceLock<Run> = OnceLock::new();
|
||||
/// One message box per run: a failure inside the panic hook and the abort that follows must not show two.
|
||||
static REPORTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// This run's log, once `init_startup` has opened it.
|
||||
pub fn run_log() -> Option<Log> {
|
||||
RUN.get().map(|r| r.log.clone())
|
||||
}
|
||||
|
||||
/// Open the run log and install the panic hook. Call it first in `main`: nothing that can fail runs before it. `dialog` is false for
|
||||
/// the self-test (no person to read a message box); `STRATA_NO_DIALOG` does the same for scripted runs.
|
||||
pub fn init_startup(dialog: bool) {
|
||||
let dialog = dialog && std::env::var_os("STRATA_NO_DIALOG").is_none();
|
||||
let log = Log::open();
|
||||
log.line("shell", &system_line());
|
||||
if RUN.set(Run { log, dialog }).is_ok() {
|
||||
install_panic_hook();
|
||||
}
|
||||
}
|
||||
|
||||
/// "Windows 10.0.26300 (64-bit)" or the name of the system; one line for the log and the crash file.
|
||||
pub fn system_line() -> String {
|
||||
format!("System: {} {}", std::env::consts::OS, system_version())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn system_version() -> String {
|
||||
#[repr(C)]
|
||||
struct OsVersionInfo {
|
||||
size: u32,
|
||||
major: u32,
|
||||
minor: u32,
|
||||
build: u32,
|
||||
platform: u32,
|
||||
csd: [u16; 128],
|
||||
}
|
||||
#[link(name = "ntdll")]
|
||||
extern "system" {
|
||||
fn RtlGetVersion(info: *mut OsVersionInfo) -> i32;
|
||||
}
|
||||
let mut info = OsVersionInfo { size: std::mem::size_of::<OsVersionInfo>() as u32, major: 0, minor: 0, build: 0, platform: 0, csd: [0; 128] };
|
||||
// RtlGetVersion reports the real version (GetVersionEx lies to apps without a manifest entry)
|
||||
if unsafe { RtlGetVersion(&mut info) } == 0 {
|
||||
format!("{}.{}.{}", info.major, info.minor, info.build)
|
||||
} else {
|
||||
"version unknown".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn system_version() -> String {
|
||||
first_line("/etc/os-release").unwrap_or_default()
|
||||
}
|
||||
|
||||
/// What to tell a person, in plain words, for a technical reason. Always ends with the one next step.
|
||||
pub fn plain_reason(detail: &str) -> String {
|
||||
let lower = detail.to_ascii_lowercase();
|
||||
if lower.contains("webview2") || lower.contains("webview") {
|
||||
"Strata shows its window with Microsoft's WebView2 component, and it could not be started. \
|
||||
Update or repair \"Microsoft Edge WebView2 Runtime\" (Settings > Apps > Installed apps), or install it from \
|
||||
https://go.microsoft.com/fwlink/p/?LinkId=2124703, then start Strata again."
|
||||
.into()
|
||||
} else if lower.contains("app host") || lower.contains("node runtime") || lower.contains("could not start the app host") {
|
||||
"Strata's background program did not start. Security software may have blocked a file in the Strata folder. \
|
||||
Allow the Strata folder in it (or reinstall Strata) and start Strata again."
|
||||
.into()
|
||||
} else {
|
||||
"Strata stopped while it was starting. Starting it again may work; if it does not, please send us the log file named below.".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of the crash file for a failed start, as text (so it can be tested without a failure).
|
||||
pub fn startup_report(detail: &str, app_version: &str, log_path: Option<&Path>) -> String {
|
||||
let mut t = String::new();
|
||||
t.push_str("Strata app start failure\n========================\n\n");
|
||||
t.push_str(&format!("When: {} UTC\n", stamp(SystemTime::now())));
|
||||
t.push_str(&format!("App: Strata desktop {app_version}\n"));
|
||||
t.push_str(&format!("{}\n", system_line()));
|
||||
t.push_str(&format!("What: {detail}\n"));
|
||||
t.push_str(&format!("Meaning: {}\n", plain_reason(detail)));
|
||||
if let Some(p) = log_path {
|
||||
t.push_str(&format!("\nFull log of this run: {}\n", p.display()));
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Write the crash file of a failed start into `dir`; returns where it is.
|
||||
pub fn write_startup_failure(dir: &Path, detail: &str, log_path: Option<&Path>) -> Option<PathBuf> {
|
||||
std::fs::create_dir_all(dir).ok()?;
|
||||
prune(dir, "desktop-crash-", false);
|
||||
let path = dir.join(format!("desktop-crash-{}.log", stamp(SystemTime::now())));
|
||||
std::fs::write(&path, startup_report(detail, env!("CARGO_PKG_VERSION"), log_path)).ok()?;
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// The message box (Windows). Elsewhere the text goes to the terminal only.
|
||||
#[cfg(windows)]
|
||||
fn show_error(text: &str) {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
#[link(name = "user32")]
|
||||
extern "system" {
|
||||
fn MessageBoxW(window: *mut std::ffi::c_void, text: *const u16, caption: *const u16, kind: u32) -> i32;
|
||||
}
|
||||
const MB_ICONERROR: u32 = 0x10;
|
||||
const MB_SETFOREGROUND: u32 = 0x1_0000;
|
||||
const MB_TOPMOST: u32 = 0x4_0000;
|
||||
let wide = |s: &str| std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect::<Vec<u16>>();
|
||||
let (text, caption) = (wide(text), wide("Strata"));
|
||||
unsafe {
|
||||
MessageBoxW(std::ptr::null_mut(), text.as_ptr(), caption.as_ptr(), MB_ICONERROR | MB_SETFOREGROUND | MB_TOPMOST);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn show_error(text: &str) {
|
||||
eprintln!("[strata] {text}");
|
||||
}
|
||||
|
||||
/// Record a failed start (run log and crash file) and tell the person. Runs inside the panic hook as well, so it cannot panic.
|
||||
fn report_failure(detail: &str) {
|
||||
let Some(run) = RUN.get() else {
|
||||
eprintln!("[strata] {detail}");
|
||||
return;
|
||||
};
|
||||
run.log.line("shell", &format!("START FAILED: {detail}"));
|
||||
let dir = logs_dir();
|
||||
let saved = write_startup_failure(&dir, detail, run.log.path.as_deref());
|
||||
if let Some(p) = &saved {
|
||||
run.log.line("shell", &format!("The crash file is {}", p.display()));
|
||||
}
|
||||
if run.dialog && !REPORTED.swap(true, Ordering::SeqCst) {
|
||||
let short: String = detail.chars().take(400).collect();
|
||||
let place = saved.or_else(|| run.log.path.clone()).map(|p| p.display().to_string()).unwrap_or_else(|| dir.display().to_string());
|
||||
show_error(&format!("Strata could not start.\n\n{}\n\nWhat happened: {short}\n\nA log was saved here, please send it to us:\n{place}", plain_reason(detail)));
|
||||
}
|
||||
}
|
||||
|
||||
/// A start that cannot go on: say why (log, crash file, message box) and end the process with a failure code. Never returns.
|
||||
pub fn startup_failed(detail: &str) -> ! {
|
||||
report_failure(detail);
|
||||
std::process::exit(1)
|
||||
}
|
||||
|
||||
/// Every panic in the app (a Tauri setup error is one) ends in an abort with panic = "abort", which Windows reports as 0xc0000409 with
|
||||
/// nothing else. The hook runs before the abort: it writes what the panic said and where, and shows the message box.
|
||||
fn install_panic_hook() {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
previous(info);
|
||||
let message = if let Some(s) = info.payload().downcast_ref::<&str>() {
|
||||
(*s).to_string()
|
||||
} else if let Some(s) = info.payload().downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"a panic without a message".to_string()
|
||||
};
|
||||
let place = info.location().map(|l| format!("{}:{}", l.file(), l.line())).unwrap_or_else(|| "an unknown place".into());
|
||||
let thread = std::thread::current().name().unwrap_or("unnamed").to_string();
|
||||
report_failure(&format!("the app stopped unexpectedly: {message} (at {place}, thread {thread})"));
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn downloads_dir() -> PathBuf {
|
||||
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Downloads")).unwrap_or_else(std::env::temp_dir)
|
||||
std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(|h| PathBuf::from(h).join("Downloads")).unwrap_or_else(std::env::temp_dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -371,4 +573,28 @@ mod tests {
|
||||
std::env::remove_var("STRATA_DESKTOP_DATA_DIR");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
#[test]
|
||||
fn a_failed_start_is_explained_in_plain_words_and_written_to_a_crash_file() {
|
||||
let webview = plain_reason("Failed to setup app: error encountered during setup hook: WebView2 error: 0x8007139F");
|
||||
assert!(webview.contains("WebView2") && webview.contains("go.microsoft.com"));
|
||||
assert!(plain_reason("the Strata App Host did not become healthy at http://127.0.0.1:47831/api/health").contains("background program"));
|
||||
assert!(plain_reason("something else").contains("log file"));
|
||||
let dir = std::env::temp_dir().join(format!("strata-startlog-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = write_startup_failure(&dir, "the app stopped unexpectedly: boom (at main.rs:1, thread main)", Some(Path::new("/x/desktop-1.log"))).unwrap();
|
||||
assert!(path.file_name().unwrap().to_string_lossy().starts_with("desktop-crash-"));
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
for needle in ["boom", "main.rs:1", "/x/desktop-1.log", "0.2.0", "System:", "Meaning:"] {
|
||||
assert!(text.contains(needle), "{needle}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn on_windows_the_logs_sit_in_the_local_app_data_strata_folder() {
|
||||
let dir = platform_data_dir();
|
||||
assert!(dir.ends_with("Strata"), "{}", dir.display());
|
||||
assert!(system_version().split('.').count() == 3, "{}", system_version());
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,6 @@ use std::time::{Duration, Instant};
|
||||
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
mod updater;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod desktoplog;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod supervisor;
|
||||
@@ -491,9 +490,23 @@ fn start_host(app: &tauri::AppHandle) -> Result<u16, String> {
|
||||
*app.state::<ShutdownToken>().0.lock().unwrap() = Some(token);
|
||||
|
||||
let node = node_binary(&res)?;
|
||||
let child = cmd
|
||||
// The host's lines go to the run log (a packaged Windows app has no console, so they were lost before).
|
||||
let log = desktoplog::run_log();
|
||||
if log.is_some() {
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
}
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("could not start the App Host on {}: {e}", node.display()))?;
|
||||
if let Some(log) = log {
|
||||
let ring = desktoplog::Ring::default();
|
||||
if let Some(out) = child.stdout.take() {
|
||||
desktoplog::tee(out, "host", false, ring.clone(), log.clone());
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
desktoplog::tee(err, "host", true, ring, log);
|
||||
}
|
||||
}
|
||||
*app.state::<HostProcess>().0.lock().unwrap() = Some(child);
|
||||
*app.state::<HostPort>().0.lock().unwrap() = Some(port);
|
||||
|
||||
@@ -529,6 +542,19 @@ fn stop_host(app: &tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
/// End the host at once, without the clean stop (a start that failed: there is nothing to drain).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn kill_host(app: &tauri::AppHandle) {
|
||||
if let Some(state) = app.try_state::<HostProcess>() {
|
||||
if let Ok(mut guard) = state.0.lock() {
|
||||
if let Some(mut child) = guard.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A clean stop first: the host stops, through Control, the engines this app started, then exits. Engines another client
|
||||
/// started (the Terminal) are not touched. If it does not finish in time the process is ended.
|
||||
fn stop_host_child(child: &mut Child, port: Option<u16>, token: Option<&str>) {
|
||||
@@ -803,6 +829,10 @@ fn main() {
|
||||
// at the screen — the lesson from the Electron attempt, where a packaged app that never started
|
||||
// its host hung with no output at all.
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
// Windows and macOS: the run log and the panic hook come first, before anything that can fail. A start that goes wrong then leaves a
|
||||
// log, a crash file and a message box instead of a process that is gone with 0xc0000409 and nothing else (Linux: supervisor.rs).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
desktoplog::init_startup(self_test_path(&args).is_none());
|
||||
contain_app();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
@@ -849,14 +879,25 @@ fn run_embedded() {
|
||||
.manage(HostPort(Mutex::new(None)))
|
||||
.manage(ShutdownToken(Mutex::new(None)))
|
||||
.setup(|app| {
|
||||
// A way to prove the safety net on a computer where the app starts fine (tests only): a panic at the place a setup error ends in.
|
||||
if std::env::var_os("STRATA_DESKTOP_TEST_PANIC").is_some() {
|
||||
panic!("STRATA_DESKTOP_TEST_PANIC is set: the app panics on purpose");
|
||||
}
|
||||
let handle = app.handle().clone();
|
||||
let port = start_host(&handle).map_err(|e| {
|
||||
// A startup failure must be visible, not a blank window.
|
||||
eprintln!("[strata] {e}");
|
||||
Box::<dyn std::error::Error>::from(e)
|
||||
})?;
|
||||
let shell_token = handle.state::<ShutdownToken>().0.lock().unwrap().clone().unwrap_or_default();
|
||||
open_window(app, port, &shell_token)?;
|
||||
// A setup error would become a panic inside Tauri ("Failed to setup app"), and with panic = "abort" a crash with no message.
|
||||
// A failed start is reported (log, crash file, message box) and ends the app here instead.
|
||||
let port = match start_host(&handle) {
|
||||
Ok(port) => port,
|
||||
Err(e) => {
|
||||
kill_host(&handle);
|
||||
desktoplog::startup_failed(&e)
|
||||
}
|
||||
};
|
||||
let shell_token = handle.state::<ShutdownToken>().0.lock().map(|t| t.clone().unwrap_or_default()).unwrap_or_default();
|
||||
if let Err(e) = open_window(app, port, &shell_token) {
|
||||
kill_host(&handle);
|
||||
desktoplog::startup_failed(&format!("the Strata window could not be opened: {e}"))
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
@@ -865,7 +906,7 @@ fn run_embedded() {
|
||||
}
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building the Strata application")
|
||||
.unwrap_or_else(|e| desktoplog::startup_failed(&format!("the Strata application could not be built: {e}")))
|
||||
.run(|app, event| {
|
||||
if let tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit = event {
|
||||
stop_host(app);
|
||||
|
||||
@@ -27,8 +27,7 @@ START IT
|
||||
|
||||
Closing the window stops Strata and its engine.
|
||||
|
||||
Pictures: this Mac build has no picture reader yet, so models read text only here. Picture input works on the Windows and
|
||||
Linux builds.
|
||||
Pictures: on by default. This Mac build reads them on the Mac's GPU (under a second for a small picture on an M4).
|
||||
|
||||
|
||||
@INCLUDE:common-gatekeeper-mac.txt@
|
||||
|
||||
Generated
+28
-2
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "strata",
|
||||
"version": "0.2.0-beta",
|
||||
"version": "0.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "strata",
|
||||
"version": "0.2.0-beta",
|
||||
"version": "0.2.2",
|
||||
"dependencies": {
|
||||
"html-to-image": "^1.11.13",
|
||||
"katex": "^0.19.0",
|
||||
"pdfjs-dist": "^6.4.299",
|
||||
"prismjs": "1.30.0",
|
||||
"react": "^19.1.0",
|
||||
@@ -1608,6 +1609,15 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
|
||||
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1781,6 +1791,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.19.0.tgz",
|
||||
"integrity": "sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^15.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "strata",
|
||||
"private": true,
|
||||
"version": "0.2.0-beta",
|
||||
"version": "0.2.2",
|
||||
"type": "module",
|
||||
"description": "Strata \u2014 local AI, tuned to one model. Web shell + engine host.",
|
||||
"scripts": {
|
||||
@@ -28,6 +28,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"html-to-image": "^1.11.13",
|
||||
"katex": "^0.19.0",
|
||||
"pdfjs-dist": "^6.4.299",
|
||||
"prismjs": "1.30.0",
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -57,6 +57,7 @@ The chat's Mode menu is real Harness behaviour. Each turn the page sends the cha
|
||||
|
||||
- Manual relies on the gate, so a Manual turn does not start unless the bridge has confirmed the gate is loaded (`/strata-gate` command): without it Manual would be an open sandbox that nobody asks about.
|
||||
- The gate listens to `tools/pre-execute` (the seam the Harness's own Auto-review layer uses) and returns an `ask` decision; the Harness's approval service then asks, runs the tool only after `allowed-once`, and records the answer. Read-only tools and a short allowlist of look-only shell commands (Get-ChildItem, git status, ...) run unasked; MCP and unknown tools ask.
|
||||
- Web search and web pages (`web_search`, `web_fetch`) ask once per chat in Manual, not once per call. The gate asks through the approval service itself (`webDecision`: "The assistant wants to search the web for: ... Search words go to <service>." / "... open the web page ..."), remembers a yes for the chat (a helper agent shares its chat's answer, and two searches started together share one card), and on a no gives the model `WEB_REFUSAL` ("The person did not allow web access ... Go on without it and say so."). Plan and Bypass are unchanged. Settings -> Tools has a switch, "Let the assistant search the web without asking" (off by default, `webWithoutAsking` in `tools-settings.mjs`, passed to the gate as `config.web.skipAsk`), that skips the question in Manual. A scheduled task has nobody at the card, so its web call is answered no unless that switch is on. Tests: `tests/web-ask.test.mjs`.
|
||||
- The mode is read once per turn; an unknown or missing value is Manual.
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ChildProjector } from './children.mjs';
|
||||
import { nearestBudget } from './llm-shape.mjs';
|
||||
import { DeliverableTracker, ActivityGroups } from './deliverables.mjs';
|
||||
import { SkillsUsed, skillInfoFrom } from './skills-used.mjs';
|
||||
import { turnEngineSpeed } from './engine-speed.mjs';
|
||||
import { TurnGuard, guardNote, GUARD_NOTES } from './turn-guard.mjs';
|
||||
import { PlanStore, planPrompt, parsePlan, parsePoints, stepObjective, stepLine, firstSentence, stepSummary, planAsPrompt, editedSteps, planView, researchPrompt, researchWritePrompt, RESEARCH_NUDGE, parseSources } from './plan.mjs';
|
||||
|
||||
@@ -228,6 +229,13 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The local engine's own speed for this turn's requests (null when it cannot be read: the stream's estimate stays). */
|
||||
async function engineSpeedOf(route, projector) {
|
||||
const inst = localInstances().find(i => i.id === route.instanceId);
|
||||
if (!inst) return null;
|
||||
return turnEngineSpeed({ baseUrl: inst.baseUrl, key: inst.key, since: projector.startedAt, attempts: projector.streams.size, timeoutMs: 800 });
|
||||
}
|
||||
|
||||
/** Make sure a runtime exists that knows the route this turn needs. */
|
||||
async function ensureRuntime(choice, signal) {
|
||||
for (;;) {
|
||||
@@ -429,7 +437,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
turn.finish(`The assistant stopped answering for ${mins > 1 ? mins + ' minutes' : 'a minute'}, so I ended this turn. Anything it already finished is kept. Send your message again to go on.`);
|
||||
}, Math.min(15_000, Math.max(200, Math.floor(stallMs / 4))));
|
||||
turn.watchdog.unref?.();
|
||||
function finishTurn(failure) {
|
||||
async function finishTurn(failure) {
|
||||
if (turn.finished) return;
|
||||
turn.finished = true;
|
||||
clearTimeout(turn.settleTimer);
|
||||
@@ -450,7 +458,9 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
}
|
||||
if (noteReason && !extra.some(f => f.type === 'plan' && f.event === 'proposed')) { const note = guardNote(noteReason); if (note) extra.push(note); }
|
||||
for (const f of extra) emit(f);
|
||||
const frames = failure ? [{ type: 'error', message: plainAgentFailure(failure), details: failure, code: 'AGENT_STOPPED' }] : projector.finish({ model: route.name, contextMax: route.contextWindow });
|
||||
// The answer speed of a turn on this computer is the engine's own (tokens it produced over its decode time, as the Monitor shows it).
|
||||
const engine = !failure && route.kind === 'local' ? await engineSpeedOf(route, projector) : null;
|
||||
const frames = failure ? [{ type: 'error', message: plainAgentFailure(failure), details: failure, code: 'AGENT_STOPPED' }] : projector.finish({ model: route.name, contextMax: route.contextWindow, engine });
|
||||
for (const f of frames.slice(0, -1)) emit(f);
|
||||
// Messages typed while the goal ran and not delivered between steps go back to the page now, to be sent as ordinary messages.
|
||||
if (turn.goalRun && attached) {
|
||||
@@ -1180,6 +1190,12 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
.map(p => (p.sessionId === sessionId ? p : { ...p, callId: `${p.sessionId}:${p.callId}` })).map(({ callId, tool, reason, at }) => ({ callId, tool, reason, at }));
|
||||
}
|
||||
|
||||
/** Every chat that has an approval waiting, for the task board: the chat, its working folder and what is waiting (same items as pendingFor). */
|
||||
function pendingAll() {
|
||||
if (!pending.size) return [];
|
||||
return Object.keys(sessionMap).map(chatId => ({ chatId, folder: sessionMap[chatId]?.cwd ?? null, pending: pendingFor(chatId) })).filter(c => c.pending.length);
|
||||
}
|
||||
|
||||
async function close() {
|
||||
const r = rt; rt = null;
|
||||
if (r) { r.events?.close(); r.gw.close(); await r.hp.stop(); }
|
||||
@@ -1188,7 +1204,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
/** Let go of a chat's Harness session mapping (a task starts every run in a session of its own). */
|
||||
function forget(chatId) { if (chats.get(chatId)?.turn) return; delete sessionMap[chatId]; chats.delete(chatId); persist(); }
|
||||
|
||||
return { goalState, goalControl, harnessDir, status, runTurn, goalCommand, planCommand, researchCommand, llmTarget, setPlanBriefing, resetSession, reopenPlan, skipStep, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, workingFolder: chatId => sessionMap[chatId]?.cwd ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
|
||||
return { goalState, goalControl, harnessDir, status, runTurn, goalCommand, planCommand, researchCommand, llmTarget, setPlanBriefing, resetSession, reopenPlan, skipStep, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, pendingAll, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, workingFolder: chatId => sessionMap[chatId]?.cwd ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
|
||||
}
|
||||
|
||||
/** A goal that stopped by itself, in words. */
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -236,7 +236,7 @@ export class TurnProjector {
|
||||
return { type: 'agent-tool', tool: entry.card };
|
||||
}
|
||||
|
||||
/* The speed shown while a reply streams, and after it: everything the model generated (reasoning included) over the time its
|
||||
/* The speed shown while a reply streams, and after it: everything the model generated (reasoning and tool calls included) over the time its
|
||||
requests spent producing it, not over the whole turn (tool runs and the wait for the prompt are not decoding). One stream per model
|
||||
request; tokens are one per streamed chunk, or characters / 4 when a server batches. */
|
||||
#produced(attemptId, text, out) {
|
||||
@@ -284,6 +284,8 @@ export class TurnProjector {
|
||||
const call = this.composing.get(c.id) ?? { name: c.name ?? '', head: '', bytes: 0 };
|
||||
if (c.name) call.name = c.name;
|
||||
const piece = String(c.argumentsDelta ?? '');
|
||||
// Writing a tool call (a whole file, say) is decoding too: its tokens are in the engine's output count, so its time belongs in the speed.
|
||||
this.#produced(f.attemptId, piece, out);
|
||||
call.bytes += piece.length;
|
||||
if (call.head.length < 4000) call.head += piece;
|
||||
this.composing.set(c.id, call);
|
||||
@@ -393,7 +395,7 @@ export class TurnProjector {
|
||||
}
|
||||
|
||||
/** The terminal frames for a finished turn. */
|
||||
finish({ model, contextMax } = {}) {
|
||||
finish({ model, contextMax, engine = null } = {}) {
|
||||
const reason = this.ended ?? { kind: 'interrupted' };
|
||||
const durationMs = Date.now() - this.startedAt;
|
||||
const usage = this.usageSeen
|
||||
@@ -401,8 +403,15 @@ export class TurnProjector {
|
||||
: { tokensIn: null, tokensOut: null, usageSource: 'unavailable' };
|
||||
const decoded = this.decode();
|
||||
const generated = this.usageSeen && this.tokensOut > 0 ? this.tokensOut : decoded.tokens;
|
||||
const tps = decoded.seconds >= 0.3 && generated > 0 ? Number((generated / decoded.seconds).toFixed(1)) : null;
|
||||
const base = { durationMs, ttftMs: this.firstTokenAt ? this.firstTokenAt - this.startedAt : null, model, contextMax, tps, contextTokens: this.contextTokens };
|
||||
// Answer speed, one definition everywhere: all the tokens the model produced over its decode time. The engine's own figures (the ones the
|
||||
// Monitor shows) when the bridge could read them; otherwise the stream's own span. A very short reply has almost no span, so its decode time
|
||||
// is then the time from its first chunk to the end of the turn (only when no tool ran, so no tool time is in it): the number is still shown.
|
||||
let tps = null;
|
||||
if (engine?.tps > 0) tps = engine.tps;
|
||||
else if (generated > 0 && decoded.seconds >= 0.3) tps = Number((generated / decoded.seconds).toFixed(1));
|
||||
else if (generated > 0 && this.tools.size === 0 && this.firstTokenAt && Date.now() - this.firstTokenAt >= 50) tps = Number((generated / ((Date.now() - this.firstTokenAt) / 1000)).toFixed(1));
|
||||
const promptTps = engine?.promptTps > 0 ? engine.promptTps : null;
|
||||
const base = { durationMs, ttftMs: this.firstTokenAt ? this.firstTokenAt - this.startedAt : null, model, contextMax, tps, ...(promptTps ? { promptTps } : {}), contextTokens: this.contextTokens };
|
||||
switch (reason.kind) {
|
||||
case 'completed':
|
||||
return [{ type: 'done', ...base, ...usage, finishReason: 'stop' }];
|
||||
|
||||
@@ -13,6 +13,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { externalEnvironment } from '../external-env.mjs';
|
||||
import { utf8Script } from '../powershell-utf8.mjs';
|
||||
|
||||
const URL_PATTERN = /http:\/\/127\.0\.0\.1:(\d+)\/\?token=([A-Za-z0-9_-]+)/;
|
||||
|
||||
@@ -127,7 +128,7 @@ export class HarnessProcess {
|
||||
function commandLineOf(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return null;
|
||||
if (process.platform === 'win32') {
|
||||
const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`], { encoding: 'utf8', windowsHide: true, timeout: 15000 });
|
||||
const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', utf8Script(`(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`)], { encoding: 'utf8', windowsHide: true, timeout: 15000 });
|
||||
return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : null;
|
||||
}
|
||||
const r = spawnSync('ps', ['-p', String(pid), '-o', 'args='], { encoding: 'utf8' });
|
||||
|
||||
@@ -41,8 +41,7 @@ export const READ_ONLY_TOOLS = new Set([
|
||||
'read', 'glob', 'grep', 'read_image', 'working_directory', 'todo_write', 'skill', 'present', 'lsp',
|
||||
'get_goal', 'create_goal', 'update_goal', 'list_agents', 'list_subagent_models', 'job_list', 'job_output',
|
||||
'ask_user_question', 'exit_plan_mode',
|
||||
// Reading the internet is part of what the app promises; changing nothing here.
|
||||
'web_fetch', 'web_search',
|
||||
// (web_search and web_fetch are not here: the first use in a chat asks, see webDecision.)
|
||||
// Delegation: the child it starts is governed by this same gate.
|
||||
'subagent', 'subagent_fork', 'send_message', 'interrupt_agent', 'workflow',
|
||||
]);
|
||||
@@ -103,14 +102,37 @@ const clip = (value, max) => {
|
||||
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
||||
};
|
||||
|
||||
/** The tools that reach the internet. In Manual the first one in a chat asks once; the answer holds for the rest of that chat. */
|
||||
export const WEB_TOOLS = new Set(['web_search', 'web_fetch']);
|
||||
/** What the model reads when the person says no. Plain words, and it tells the model what to do next. */
|
||||
export const WEB_REFUSAL = 'The person did not allow web access, so nothing was searched or opened. Go on without it and say so.';
|
||||
|
||||
/**
|
||||
* The question for a web call, in plain words: what is about to leave this computer and where it goes.
|
||||
* @param {{name:string, arguments:any}} exec
|
||||
* @param {{provider?: string}} [options] provider is the search service's name as the person knows it
|
||||
*/
|
||||
export function webDecision(exec, options = {}) {
|
||||
const tool = String(exec.name ?? '');
|
||||
const args = exec.arguments && typeof exec.arguments === 'object' ? exec.arguments : {};
|
||||
if (tool === 'web_search') {
|
||||
const queries = Array.isArray(args.queries) ? args.queries : [args.query ?? args.q];
|
||||
const words = clip(queries.filter(q => typeof q === 'string' && q.trim()).join('; '), 160);
|
||||
return { kind: 'ask', web: true, reason: `The assistant wants to search the web for: ${words}. Search words go to ${options.provider || 'the search service'}.` };
|
||||
}
|
||||
return { kind: 'ask', web: true, reason: `The assistant wants to open the web page ${clip(args.url, 200)}. The page's site sees the request.` };
|
||||
}
|
||||
|
||||
/**
|
||||
* The decision for one call in Manual mode, or null when it needs no question.
|
||||
* @param {{name:string, arguments:any}} exec
|
||||
* @returns {{kind:'ask', reason:string}|null}
|
||||
* @param {{provider?: string}} [options]
|
||||
* @returns {{kind:'ask', reason:string, web?:true}|null}
|
||||
*/
|
||||
export function manualDecision(exec) {
|
||||
export function manualDecision(exec, options = {}) {
|
||||
const tool = String(exec.name ?? '');
|
||||
const args = exec.arguments && typeof exec.arguments === 'object' ? exec.arguments : {};
|
||||
if (WEB_TOOLS.has(tool)) return webDecision(exec, options);
|
||||
if (READ_ONLY_TOOLS.has(tool)) return null;
|
||||
if (SHELL_TOOLS.has(tool)) {
|
||||
if (isReadOnlyShell(args.command)) return null;
|
||||
@@ -150,7 +172,40 @@ export function planDecision(exec) {
|
||||
return { kind: 'deny', reason: 'Plan mode only looks and changes nothing, so it cannot use this tool. Switch to Manual or Bypass to use it.' };
|
||||
}
|
||||
|
||||
export function apply(ctx) {
|
||||
/**
|
||||
* @param {object} ctx
|
||||
* @param {{web?: {provider?: string, skipAsk?: boolean}}} [config] `web.provider`: the search service's name for the question;
|
||||
* `web.skipAsk`: the person's switch in Settings -> Tools, "search the web without asking" (off unless they turned it on)
|
||||
*/
|
||||
export function apply(ctx, config = {}) {
|
||||
const web = config.web ?? {};
|
||||
/** Chats (their first session) where the person said yes to the web. One yes holds for the rest of the chat. */
|
||||
const webAllowed = new Set();
|
||||
/** A question already on the screen for a chat, so two searches started together share one card. */
|
||||
const webAsking = new Map();
|
||||
const chatOf = (session, depth = 0) => {
|
||||
const parentId = session.header?.parentSession;
|
||||
if (session.header?.origin === 'subagent' && parentId && depth < 6) {
|
||||
const parent = ctx.sessions.get(parentId);
|
||||
if (parent) return chatOf(parent, depth + 1);
|
||||
}
|
||||
return session.id;
|
||||
};
|
||||
async function askWeb(exec, gate, agent) {
|
||||
const chat = chatOf(agent.session);
|
||||
let question = webAsking.get(chat);
|
||||
if (!question) {
|
||||
question = ctx.approval.request({ agent, toolName: exec.name, callId: exec.callId, reason: gate.reason, displayReason: { en: gate.reason }, signal: exec.signal });
|
||||
webAsking.set(chat, question);
|
||||
const forget = () => { if (webAsking.get(chat) === question) webAsking.delete(chat); };
|
||||
question.then(forget, forget);
|
||||
}
|
||||
const outcome = await question;
|
||||
if (outcome === 'allowed-once') { webAllowed.add(chat); return null; }
|
||||
if (outcome === 'cancelled') return { kind: 'cancel' };
|
||||
return { kind: 'deny', reason: WEB_REFUSAL };
|
||||
}
|
||||
|
||||
/** The preset a session is really working under: a child follows the chat that started it. */
|
||||
const effectivePreset = (session, depth = 0) => {
|
||||
const parentId = session.header?.parentSession;
|
||||
@@ -173,8 +228,17 @@ export function apply(ctx) {
|
||||
}
|
||||
if (preset === PLAN_PRESET) return planDecision(exec) ?? next();
|
||||
if (preset !== MANUAL_PRESET) return next();
|
||||
const gate = manualDecision(exec);
|
||||
const gate = manualDecision(exec, { provider: web.provider });
|
||||
if (!gate) return next();
|
||||
if (gate.web) {
|
||||
// The person's switch, or a yes already given in this chat: no question.
|
||||
if (web.skipAsk === true || webAllowed.has(chatOf(agent.session))) return next();
|
||||
// A helper's own approval policy is `never`; it must be `ask` for the question to reach the person.
|
||||
if (agent.session.header?.origin === 'subagent' && ctx.approval.overrideOf(agent.session) !== 'ask') {
|
||||
agent.session.append('approval/policy', { policy: 'ask' });
|
||||
}
|
||||
return (await askWeb(exec, gate, agent)) ?? next();
|
||||
}
|
||||
// A child's approval policy is pinned to `never` by the Harness; asking is how its request reaches the person.
|
||||
if (agent.session.header?.origin === 'subagent' && ctx.approval.overrideOf(agent.session) !== 'ask') {
|
||||
agent.session.append('approval/policy', { policy: 'ask' });
|
||||
|
||||
@@ -16,7 +16,7 @@ export const inject = ['web'];
|
||||
export function apply(ctx, config = {}) {
|
||||
const keyOf = () => (config.keyEnv ? process.env[config.keyEnv] ?? '' : '');
|
||||
// Tests only (the bridge option `searchMock`): the same search code on a pretend web, so nothing leaves the machine.
|
||||
const pretend = config.mock === 'limited' ? { fetch: async url => new Response('<html>please verify you are human: captcha</html>', { status: /duckduckgo/.test(String(url)) ? 202 : 429 }) } : null;
|
||||
const pretend = config.mock === 'offline' ? { fetch: async () => { throw new TypeError('fetch failed'); } } : config.mock === 'limited' ? { fetch: async url => new Response('<html>please verify you are human: captcha</html>', { status: /duckduckgo/.test(String(url)) ? 202 : 429 }) } : null;
|
||||
const provider = pretend ? createDuckDuckGoSearch(pretend)
|
||||
: config.provider === 'exa' ? createExaSearch({ key: keyOf })
|
||||
: config.provider === 'perplexity' ? createPerplexitySearch({ key: keyOf })
|
||||
|
||||
@@ -60,8 +60,11 @@ export const SEARCH_PLUGIN = fileURLToPath(new URL('./plugins/strata-search.mjs'
|
||||
/** The app's own tool server for the tools a person writes (instructions, commands). */
|
||||
export const CUSTOM_TOOLS_SCRIPT = fileURLToPath(new URL('./custom-tools-mcp.mjs', import.meta.url));
|
||||
|
||||
/** The search service as the person knows it, for the question that comes before the first web search in a chat. */
|
||||
const SEARCH_NAMES = Object.freeze({ builtin: 'DuckDuckGo, or Bing when DuckDuckGo is busy', exa: 'Exa', perplexity: 'Perplexity', deepseek: 'DeepSeek' });
|
||||
|
||||
/** What Settings -> Tools says when nothing was chosen. */
|
||||
const NO_TOOLS_SETTINGS = Object.freeze({ off: new Set(), search: { provider: 'builtin', key: '' }, browserEnabled: false, browserPath: null, browserCli: null, servers: {}, hasInstructionTools: false, hasCommandTools: false, toolFile: null });
|
||||
const NO_TOOLS_SETTINGS = Object.freeze({ off: new Set(), search: { provider: 'builtin', key: '' }, webWithoutAsking: false, browserEnabled: false, browserPath: null, browserCli: null, servers: {}, hasInstructionTools: false, hasCommandTools: false, toolFile: null });
|
||||
|
||||
/**
|
||||
* The tool servers (MCP) set up for the model in Settings, as Harness client rows. Their tools appear to the agent as
|
||||
@@ -281,7 +284,8 @@ export function buildProfile({ local = [], connections = [], gate = true, tools
|
||||
{ id: 'permission', config: { presets: Object.fromEntries(Object.values(MODE_PRESETS).map(({ key, sandbox, approval, name, description }) => [key, { sandbox, approval, name, description }])), defaultPreset: MODE_PRESETS[DEFAULT_MODE].key } },
|
||||
{ id: 'subagent', config: { maxActiveSubagents: slots > 0 ? Math.max(slots, MIN_LOCAL_HELPERS) : DEFAULT_API_HELPERS } },
|
||||
// Manual asks before a change runs, so the person sees one Approve/Deny card and nothing has happened yet.
|
||||
...(gate ? [{ insert: [{ id: 'strata-gate', name: GATE_PLUGIN }] }] : []),
|
||||
// Its web question names the search service the person chose (a keyed one only when its key is saved), and the person's switch skips the question.
|
||||
...(gate ? [{ insert: [{ id: 'strata-gate', name: GATE_PLUGIN, config: { web: { provider: SEARCH_NAMES[keyed ?? 'builtin'], skipAsk: tools.webWithoutAsking === true } } }] }] : []),
|
||||
// Helper agents on a model the person names: every model the app can reach is allowed, and the plugin makes the choice forgiving (plugins/strata-models.mjs).
|
||||
...(subagentRoutes.length ? [{ id: 'subagent-model-selection-settings', config: { enabled: true, allowedModels: subagentRoutes.map(r => ({ provider: r.provider, model: r.model })) } }] : []),
|
||||
...(subagentRoutes.length ? [{ insert: [{ id: 'strata-models', name: MODELS_PLUGIN, config: { routes: subagentRoutes } }] }] : []),
|
||||
|
||||
@@ -12,8 +12,8 @@ export const TOOL_GROUPS = [
|
||||
{ id: 'files', area: 'computer', name: 'Read and change files', about: 'Lets the assistant read, create and edit files in your chat folder.', asks: true, plugins: ['tool-fs'] },
|
||||
{ id: 'find', area: 'computer', name: 'Find files and text', about: 'Lets the assistant look for files by name and search inside them. It only reads.', asks: false, plugins: ['tool-fs-search'] },
|
||||
{ id: 'jobs', area: 'computer', name: 'Long-running jobs', about: 'Lets the assistant start a slow command in the background and check on it later.', asks: true, plugins: ['tool-jobs'] },
|
||||
{ id: 'fetch', area: 'web', name: 'Open web pages', about: 'Lets the assistant read a web page you point it to.', asks: false, web: 'fetch' },
|
||||
{ id: 'search', area: 'web', name: 'Search the web', about: 'Lets the assistant search the web when it needs fresh information.', asks: false, web: 'search' },
|
||||
{ id: 'fetch', area: 'web', name: 'Open web pages', about: 'Lets the assistant read a web page you point it to.', asks: true, web: 'fetch' },
|
||||
{ id: 'search', area: 'web', name: 'Search the web', about: 'Lets the assistant search the web when it needs fresh information.', asks: true, web: 'search' },
|
||||
{ id: 'helpers', area: 'together', name: 'Helper assistants', about: 'Lets the assistant start helpers that work on parts of a task at the same time.', asks: false, plugins: ['delegation'] },
|
||||
{ id: 'goals', area: 'together', name: 'Goals', about: 'Lets you give a goal with /goal. The assistant keeps working until it is done.', asks: false, plugins: ['command-goal', 'tool-goal'] },
|
||||
{ id: 'todo', area: 'together', name: 'To-do list', about: 'Lets the assistant keep a checklist while it works on a big task.', asks: false, plugins: ['tool-todo'] },
|
||||
|
||||
@@ -32,6 +32,8 @@ function normalize(raw) {
|
||||
off: [...new Set((Array.isArray(r.off) ? r.off : []).filter(id => GROUP_IDS.includes(id)))],
|
||||
search: { provider: SEARCH_PROVIDERS.includes(search.provider) ? search.provider : 'builtin', key: typeof search.key === 'string' ? search.key : '' },
|
||||
browser: { enabled: r.browser?.enabled === true },
|
||||
// Manual mode asks once per chat before the first web search or page. This switch (off unless the person turned it on) skips that question.
|
||||
webWithoutAsking: r.webWithoutAsking === true,
|
||||
custom: (Array.isArray(r.custom) ? r.custom : []).filter(t => t && NAME.test(t.name ?? '') && KINDS.includes(t.kind)).slice(0, MAX_CUSTOM).map(t => ({
|
||||
id: String(t.id), name: String(t.name), about: clean(t.about, 300), kind: t.kind, enabled: t.enabled !== false,
|
||||
instructions: clean(t.instructions, 20000), command: clean(t.command, 2000), address: clean(t.address, 2000),
|
||||
@@ -106,6 +108,7 @@ export function createAgentTools({ read, write, dataDir, browser = () => ({ foun
|
||||
return {
|
||||
groups: TOOL_GROUPS.map(g => ({ id: g.id, name: g.name, about: g.about, area: g.area, asks: g.asks, enabled: !s.off.includes(g.id) })),
|
||||
search: { provider: s.search.provider, keySet: !!s.search.key },
|
||||
webWithoutAsking: s.webWithoutAsking,
|
||||
// `found` means the browser tool can run: a browser on this computer AND the tool server in the agent component.
|
||||
browser: { enabled: s.browser.enabled, found: !!(probe.found && probe.cli), name: probe.name ?? null, ...(probe.found && !probe.cli ? { missing: 'component' } : {}) },
|
||||
custom: s.custom.map(t => ({
|
||||
@@ -131,6 +134,7 @@ export function createAgentTools({ read, write, dataDir, browser = () => ({ foun
|
||||
return {
|
||||
off: new Set(s.off),
|
||||
search: s.search,
|
||||
webWithoutAsking: s.webWithoutAsking,
|
||||
browserEnabled: s.browser.enabled && !!(browser().found && browser().cli),
|
||||
browserPath: browser().path ?? null,
|
||||
browserCli: browser().cli ?? null,
|
||||
@@ -160,6 +164,7 @@ export function createAgentTools({ read, write, dataDir, browser = () => ({ foun
|
||||
s.search = { ...s.search, key };
|
||||
}
|
||||
}
|
||||
if (typeof change?.webWithoutAsking === 'boolean') s.webWithoutAsking = change.webWithoutAsking;
|
||||
if (change?.browser && typeof change.browser.enabled === 'boolean') s.browser = { enabled: change.browser.enabled };
|
||||
if (change?.custom) {
|
||||
for (const [id, on] of Object.entries(change.custom)) {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* The copy is made under a hidden temporary name and renamed into place, so an interrupted run leaves nothing half-installed
|
||||
* (the next start removes the leftover and tries again).
|
||||
*/
|
||||
import { createReadStream, existsSync, readFileSync } from 'node:fs';
|
||||
import { createReadStream, existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { copyFile, mkdir, readdir, readFile, rename, rm, stat } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
@@ -64,6 +64,40 @@ export function nvidiaDriverVersion() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Where the builds are published; the wrong-make sentence points here. */
|
||||
export const RELEASES_URL = 'https://github.com/Niko1221/Strata/releases';
|
||||
|
||||
/**
|
||||
* The engine family this PC's graphics card needs ('cuda', 'hip', 'sycl'), or null when it cannot be told. The same order as Control
|
||||
* (NVIDIA first, then AMD, then Intel); STRATA_ENGINE_BACKEND wins. Reads only the driver and (Linux) the PCI vendor of the display adapters.
|
||||
*/
|
||||
export function gpuBackendHere(env = process.env) {
|
||||
const forced = String(env.STRATA_ENGINE_BACKEND ?? '').trim().toLowerCase();
|
||||
if (forced) return ['cuda', 'hip', 'sycl'].includes(forced) ? forced : null;
|
||||
if (nvidiaDriverVersion()) return 'cuda';
|
||||
try {
|
||||
const base = '/sys/class/drm';
|
||||
const vendors = new Set();
|
||||
for (const card of readdirSync(base)) {
|
||||
if (!/^card\d+$/.test(card)) continue;
|
||||
try { vendors.add(readFileSync(`${base}/${card}/device/vendor`, 'utf8').trim().toLowerCase()); } catch { /* no vendor file */ }
|
||||
}
|
||||
for (const [vendor, backend] of [['0x10de', 'cuda'], ['0x1002', 'hip'], ['0x8086', 'sycl']]) if (vendors.has(vendor)) return backend;
|
||||
} catch { /* not Linux */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
const HIP_FAMILIES = [['RX 9000', ['gfx1200', 'gfx1201']], ['RX 7000', ['gfx1100', 'gfx1101', 'gfx1102']], ['RX 6000', ['gfx1030', 'gfx1031']], ['Ryzen AI Max', ['gfx1151']]];
|
||||
const MAKE_NAME = { cuda: 'NVIDIA', hip: 'AMD', sycl: 'Intel' };
|
||||
|
||||
/** The plain sentence for a build made for another make of graphics card than this PC has. */
|
||||
export function wrongMakeNotice(manifest, here) {
|
||||
const built = manifest.backend;
|
||||
const families = [...new Set((manifest.archs ?? []).map(a => HIP_FAMILIES.find(([, list]) => list.includes(a))?.[0]).filter(Boolean))];
|
||||
const builtFor = built === 'hip' && families.length ? `AMD ${families.join(' and ')}` : MAKE_NAME[built] ?? built;
|
||||
return `This Strata is for ${builtFor} cards. Your PC has an ${MAKE_NAME[here] ?? here} card: download the ${MAKE_NAME[here] ?? here} version from ${RELEASES_URL}`;
|
||||
}
|
||||
|
||||
/** One engine can ship as several builds of the same backend (CUDA 13 and CUDA 12 differ in the oldest driver they run on). */
|
||||
const variantOf = manifest => `${manifest.platform}/${manifest.backend}/${manifest.min_driver?.nvidia ?? ''}`;
|
||||
|
||||
@@ -85,7 +119,7 @@ export async function packageIsWhole(folder, manifest, { deep = false } = {}) {
|
||||
* Install the bundled engine when it is needed.
|
||||
* Returns { action: 'installed' | 'repaired' | 'kept' | 'none' | 'refused', folder?, version?, reason? }; never throws.
|
||||
*/
|
||||
export async function installBundledEngine({ bundleDir, components, platform = null, log = () => {}, driver = undefined } = {}) {
|
||||
export async function installBundledEngine({ bundleDir, components, platform = null, log = () => {}, driver = undefined, backend = undefined } = {}) {
|
||||
try {
|
||||
if (!bundleDir || !components || !existsSync(bundleDir)) return { action: 'none' };
|
||||
let packages = [];
|
||||
@@ -96,6 +130,17 @@ export async function installBundledEngine({ bundleDir, components, platform = n
|
||||
}
|
||||
if (platform) packages = packages.filter(p => p.manifest.platform === platform);
|
||||
if (!packages.length) return { action: 'none' };
|
||||
// A build for another make of graphics card than this PC has can never run here: it is not unpacked, and the caller says so in plain words.
|
||||
const wanted = backend === undefined ? gpuBackendHere() : backend;
|
||||
if (wanted && ['cuda', 'hip', 'sycl'].includes(wanted)) {
|
||||
const gpu = packages.filter(p => ['cuda', 'hip', 'sycl'].includes(p.manifest.backend));
|
||||
const fits = packages.filter(p => !gpu.includes(p) || p.manifest.backend === wanted);
|
||||
if (gpu.length && !fits.length) {
|
||||
const first = gpu.sort((a, b) => compareVersions(b.manifest.version, a.manifest.version))[0].manifest;
|
||||
return { action: 'wrong-make', built: first.backend, here: wanted, notice: wrongMakeNotice(first, wanted) };
|
||||
}
|
||||
packages = fits;
|
||||
}
|
||||
// The CUDA builds the NVIDIA driver here can run: all of them when the driver is unknown or fits several; when none fits, the one with the lowest
|
||||
// requirement, so that Control can name the driver to install (copying the others would only waste disk).
|
||||
const driverHere = driver === undefined ? nvidiaDriverVersion() : driver;
|
||||
|
||||
@@ -103,7 +103,7 @@ export function expertParams(cores = null) {
|
||||
{ id: 'server.parallel', group: 'Server', label: 'Engine request slots', effect: 'This UI host dispatches one request at a time', type: 'number', value: 1, recommended: 1 },
|
||||
{ id: 'server.queue', group: 'Server', label: 'Queue depth', effect: 'Requests allowed to wait', type: 'number', value: 8, recommended: 8 },
|
||||
{ id: 'server.timeout', group: 'Server', label: 'Request timeout', effect: 'Seconds before a call is dropped', type: 'number', value: 600, recommended: 600 },
|
||||
{ id: 'server.cors', group: 'Server', label: 'Allow browser origins', effect: 'CORS for local web apps', type: 'toggle', value: false, recommended: false },
|
||||
{ id: 'server.cors', group: 'Server', label: 'Allow browser origins', effect: 'This switch does nothing in the app. Web pages from any site can already call the API shown under Serving. To allow only some sites, or to use the engine’s own server (port 8080), add "cors_origins": ["https://your.site"] to the model’s config file.', type: 'toggle', value: false, recommended: false },
|
||||
|
||||
// Advanced
|
||||
{ id: 'adv.rope', group: 'Advanced', label: 'RoPE scaling', effect: 'Extends context beyond training', type: 'number', value: 1, recommended: 1, advanced: true },
|
||||
|
||||
@@ -3,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() : '';
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* The first one that runs `cloudflared --version` and is new enough is used; the answer says which one and from where.
|
||||
* Nothing here downloads or installs anything.
|
||||
*/
|
||||
import { utf8Script } from './powershell-utf8.mjs';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access, stat } from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
@@ -134,7 +135,7 @@ export async function findRunningConnector({ platform = process.platform, exclud
|
||||
const skip = new Set(exclude.filter(Number.isInteger));
|
||||
let procs = [];
|
||||
if (platform === 'win32') {
|
||||
const out = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', "Get-CimInstance Win32_Process -Filter \"Name='cloudflared.exe'\" | ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }"]);
|
||||
const out = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', utf8Script("Get-CimInstance Win32_Process -Filter \"Name='cloudflared.exe'\" | ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }")]);
|
||||
procs = out.split(/\r?\n/).map(l => /^(\d+)\t(.*)$/.exec(l)).filter(Boolean).map(m => ({ pid: Number(m[1]), command: m[2] }));
|
||||
if (!procs.length) { // no PowerShell answer: at least the pids
|
||||
const list = await run('tasklist', ['/FI', 'IMAGENAME eq cloudflared.exe', '/NH', '/FO', 'CSV']);
|
||||
|
||||
@@ -207,6 +207,43 @@ export function createControl({ control = findControl(), env = process.env, run
|
||||
if (result.json?.plan && Array.isArray(result.json.plan.issues)) return result.json.plan;
|
||||
throw failure(result, 'The files could not be checked.');
|
||||
},
|
||||
/** What picking `to` as the models folder means (sizes, free space, which choices are possible, why not): nothing is changed. */
|
||||
async planModelsMove(from, to) {
|
||||
const result = await call(['models', 'move', to, ...(from ? ['--from', from] : []), '--plan', '--output', 'json'], { timeoutMs: 120_000 });
|
||||
if (result.json?.plan) return result.json.plan;
|
||||
throw failure(result, 'The folder could not be checked.');
|
||||
},
|
||||
/**
|
||||
* Move the model files to `to` (copied, checked, then the old copies removed). `onEvent` gets the progress lines; a file at `cancelFile` stops it
|
||||
* safely. Resolves with the summary (done / stopped, what moved, what is left); a failure is a ControlError whose `output` carries the details.
|
||||
*/
|
||||
async moveModels(from, to, { installDir = null, cancelFile = null, onEvent = null } = {}) {
|
||||
const args = ['models', 'move', to, ...(from ? ['--from', from] : []), ...(installDir ? ['--install-dir', installDir] : []), ...(cancelFile ? ['--cancel-file', cancelFile] : []), '--output', 'jsonl'];
|
||||
const result = await call(args, { timeoutMs: 24 * 3600_000, onLine: onEvent ?? (() => {}) });
|
||||
if (result.json?.ok && result.json.event === 'result') return result.json;
|
||||
const error = result.json?.error;
|
||||
throw Object.assign(new ControlError(error?.message ?? (result.stderr.split(/\r?\n/).filter(Boolean).pop() || 'The models could not be moved.'), { exitCode: result.code, output: result.json }), { code: error?.code ?? 'move_failed', details: error?.details ?? '' });
|
||||
},
|
||||
/** A move of the models folder that the last run left unfinished (`strata models move --status`): null, or {state, from, to, bytes}. */
|
||||
async interruptedModelsMove(dataFolder) {
|
||||
const result = await call(['models', 'move', '--status', ...(dataFolder ? ['--from', dataFolder] : []), '--output', 'json'], { timeoutMs: 30_000 });
|
||||
if (result.json?.ok) return result.json.interrupted ?? null;
|
||||
throw failure(result, 'The models folder could not be checked.');
|
||||
},
|
||||
/** "Finish it": the same move again, from where it stopped. Same events and result as moveModels. */
|
||||
async resumeModelsMove(dataFolder, { installDir = null, cancelFile = null, onEvent = null } = {}) {
|
||||
const args = ['models', 'move', '--resume', ...(dataFolder ? ['--from', dataFolder] : []), ...(installDir ? ['--install-dir', installDir] : []), ...(cancelFile ? ['--cancel-file', cancelFile] : []), '--output', 'jsonl'];
|
||||
const result = await call(args, { timeoutMs: 24 * 3600_000, onLine: onEvent ?? (() => {}) });
|
||||
if (result.json?.ok && result.json.event === 'result') return result.json;
|
||||
const error = result.json?.error;
|
||||
throw Object.assign(new ControlError(error?.message ?? (result.stderr.split(/\r?\n/).filter(Boolean).pop() || 'The models could not be moved.'), { exitCode: result.code, output: result.json }), { code: error?.code ?? 'move_failed', details: error?.details ?? '' });
|
||||
},
|
||||
/** "Leave it": keep the old folder, remove the partial copies in the new one. */
|
||||
async leaveModelsMove(dataFolder, { installDir = null } = {}) {
|
||||
const result = await call(['models', 'move', '--leave', ...(dataFolder ? ['--from', dataFolder] : []), ...(installDir ? ['--install-dir', installDir] : []), '--output', 'json'], { timeoutMs: 24 * 3600_000 });
|
||||
if (result.json?.ok) return result.json;
|
||||
throw Object.assign(failure(result, 'The partial copies could not be removed.'), { code: result.json?.error?.code ?? 'leave_failed', details: result.json?.error?.details ?? '' });
|
||||
},
|
||||
async saveProfile(name, configPath) {
|
||||
const listed = await call(['profile', 'list', '--output', 'json'], { timeoutMs: 20_000 });
|
||||
const known = (listed.json?.profiles ?? []).some(p => p.id === name);
|
||||
|
||||
@@ -56,6 +56,7 @@ export function watchedWindowsDialog(shell, args, { readyMs = 30_000, openMs = 2
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawnProcess(shell, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
let stdout = '', ready = false, settled = false;
|
||||
child.stdout?.setEncoding?.('utf8'); // a multi-byte letter of a Cyrillic folder must not be cut between two chunks
|
||||
const end = (fn, value) => { if (settled) return; settled = true; clearTimeout(timer); try { child.kill(); } catch { /* gone */ } fn(value); };
|
||||
const timer = setTimeout(() => end(resolve, ready ? { stdout: '' } : { noDialog: true }), readyMs);
|
||||
const later = () => { clearTimeout(timer); ready = true; setTimeout(() => end(resolve, { stdout: '' }), openMs).unref?.(); };
|
||||
@@ -181,13 +182,16 @@ export function chooserUnavailable(picked) {
|
||||
return { message: picked.reason, code: 'folder-chooser-unavailable', details: picked.detail };
|
||||
}
|
||||
|
||||
/** A typed path stands in for the dialog: it must be an absolute directory that exists. */
|
||||
export async function typedFolder(value, { isAbsolute }) {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
/** A typed path stands in for the dialog: it must be an absolute directory that exists. `fileType` (a RegExp on the name) also lets a
|
||||
* file of that kind through (the model page takes a folder or one .gguf file). Quotes around the path (Windows "Copy as path") go. */
|
||||
export async function typedFolder(value, { isAbsolute, fileType = null }) {
|
||||
let text = typeof value === 'string' ? value.trim() : '';
|
||||
if (text.length > 1 && /^(["'])[\s\S]*\1$/.test(text)) text = text.slice(1, -1).trim();
|
||||
if (!text || text.includes('\0')) return null;
|
||||
if (!isAbsolute(text)) throw new Error('Enter the full path of the folder.');
|
||||
if (!isAbsolute(text)) throw new Error(fileType ? 'Enter the full path of the folder or the file.' : 'Enter the full path of the folder.');
|
||||
let info;
|
||||
try { info = await stat(text); } catch { throw new Error('That folder does not exist.'); }
|
||||
if (!info.isDirectory()) throw new Error('Choose a folder, not a file.');
|
||||
try { info = await stat(text); } catch { throw new Error(fileType ? 'That folder or file does not exist.' : 'That folder does not exist.'); }
|
||||
if (info.isFile() && fileType?.test(text)) return text;
|
||||
if (!info.isDirectory()) throw new Error(fileType ? 'Choose a folder, or a .gguf file.' : 'Choose a folder, not a file.');
|
||||
return cleanChosenPath(text);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {execFile} from 'node:child_process';
|
||||
import {promisify} from 'node:util';
|
||||
import {utf8Script} from './powershell-utf8.mjs';
|
||||
const execute=promisify(execFile);
|
||||
let cached=null;
|
||||
export async function systemFonts({platform=process.platform,run=execute}={}){
|
||||
@@ -11,7 +12,7 @@ export async function systemFonts({platform=process.platform,run=execute}={}){
|
||||
const {stdout}=await read('/usr/sbin/system_profiler',['SPFontsDataType','-json','-detailLevel','mini']);
|
||||
const collect=value=>{if(!value||typeof value!=='object')return;if(typeof value.family==='string')names.push(value.family);for(const nested of Object.values(value))if(typeof nested==='object')collect(nested);};collect(JSON.parse(stdout));
|
||||
}else if(platform==='win32'){
|
||||
const {stdout}=await read('powershell.exe',['-NoProfile','-NonInteractive','-Command','Add-Type -AssemblyName System.Drawing; (New-Object System.Drawing.Text.InstalledFontCollection).Families.Name | ConvertTo-Json -Compress']);
|
||||
const {stdout}=await read('powershell.exe',['-NoProfile','-NonInteractive','-Command',utf8Script('Add-Type -AssemblyName System.Drawing; (New-Object System.Drawing.Text.InstalledFontCollection).Families.Name | ConvertTo-Json -Compress')]);
|
||||
const result=JSON.parse(stdout.replace(/^\uFEFF/,''));names=Array.isArray(result)?result:[result];
|
||||
}else{
|
||||
const {stdout}=await read('fc-list',['-f','%{family}\n']);names=stdout.split(/[\n,]/);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { parseCpuTopology, parseWindowsProcessors, parseNvidiaDevices, detectDrm
|
||||
import { CONTEXT_TOKENS, VARIANTS, footprint } from './catalog.mjs';
|
||||
import { MODELS_DIR, diskFreeGb, diskTotalGb } from './store.mjs';
|
||||
import { probe, round } from './util.mjs';
|
||||
import { utf8Script } from './powershell-utf8.mjs';
|
||||
|
||||
let cached = null;
|
||||
let cachedAt = 0;
|
||||
@@ -126,7 +127,7 @@ async function detectCpu(platform) {
|
||||
}}catch{}
|
||||
processors=parseCpuTopology(raw,model,process.arch,ghz,names);
|
||||
} else if(platform==='win32') {
|
||||
const raw=await probe('powershell.exe',['-NoProfile','-NonInteractive','-Command','Get-CimInstance Win32_Processor | Select-Object DeviceID,SocketDesignation,Name,NumberOfCores,NumberOfLogicalProcessors,MaxClockSpeed | ConvertTo-Json -Compress']);
|
||||
const raw=await probe('powershell.exe',['-NoProfile','-NonInteractive','-Command',utf8Script('Get-CimInstance Win32_Processor | Select-Object DeviceID,SocketDesignation,Name,NumberOfCores,NumberOfLogicalProcessors,MaxClockSpeed | ConvertTo-Json -Compress')]);
|
||||
processors=parseWindowsProcessors(raw,process.arch);
|
||||
}
|
||||
const names=[...new Set(processors.map(p=>p.model))];
|
||||
@@ -185,7 +186,7 @@ async function detectGpus(platform, totalGb) {
|
||||
}
|
||||
}
|
||||
if(platform==='win32') {
|
||||
const raw=await probe('powershell.exe',['-NoProfile','-NonInteractive','-Command','Get-CimInstance Win32_VideoController | Select-Object Name,PNPDeviceID | ConvertTo-Json -Compress']);
|
||||
const raw=await probe('powershell.exe',['-NoProfile','-NonInteractive','-Command',utf8Script('Get-CimInstance Win32_VideoController | Select-Object Name,PNPDeviceID | ConvertTo-Json -Compress')]);
|
||||
try {const parsed=JSON.parse(raw);for(const row of Array.isArray(parsed)?parsed:[parsed]) {
|
||||
if(!row?.Name||(/nvidia/i.test(row.Name)&&gpus.some(g=>g.vendor==='nvidia')))continue;
|
||||
const vendor=/amd|radeon/i.test(row.Name)?'amd':/intel/i.test(row.Name)?'intel':'unknown';
|
||||
|
||||
+49
-13
@@ -44,7 +44,7 @@ import * as serving from './serving.mjs';
|
||||
import * as store from './store.mjs';
|
||||
import * as supervisor from './supervisor.mjs';
|
||||
import * as engineInstall from './engine-install.mjs';
|
||||
import { detectBackend, currentBackend, streamChat } from './inference.mjs';
|
||||
import { detectBackend, currentBackend, streamChat, inferenceLine } from './inference.mjs';
|
||||
import { CONTEXT_TOKENS, VARIANTS, localModels, variant } from './catalog.mjs';
|
||||
import { browseLocalModels, inspectLocalModel } from './local-models.mjs';
|
||||
import { EventHub, error, json, readBody, readRaw, uid } from './util.mjs';
|
||||
@@ -58,6 +58,7 @@ import { createForkTimeskip } from './timeskip-fork.mjs';
|
||||
import { createChatFolders, chatRootOf, openFile, resolveChatFile, previewFile, rawFile, createAttachments, withAttachments } from './chat-files.mjs';
|
||||
import { createLlmProxy, samplingFromExpert } from './agent/llm-shape.mjs';
|
||||
import { checkUpdates, checkComponentUpdates } from './updates.mjs';
|
||||
import { plainNoModel } from './no-model-text.mjs';
|
||||
import { createAppUi } from './app-ui.mjs';
|
||||
import { appVersionFrom, appVersionToShow } from './app-version.mjs';
|
||||
import { createImageModels } from './image-models.mjs';
|
||||
@@ -71,6 +72,7 @@ import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { nativeGateway } from './native-gateway.mjs';
|
||||
import { proxyCompletion } from './inference.mjs';
|
||||
import { apiPath } from './api-paths.mjs';
|
||||
import { findFreePort, listenProblem } from './ports.mjs';
|
||||
import { validateChatImages } from './chat-images.mjs';
|
||||
import { createTimeSkip, measureFolder, timeskipRevisionMessage, imageTypeOf } from './timeskip.mjs';
|
||||
|
||||
@@ -126,6 +128,16 @@ const bundledEngine = control && !process.env.STRATA_NO_BUNDLED_ENGINE
|
||||
: Promise.resolve({ action: 'none' });
|
||||
let bundledEngineSettled = !(control && !process.env.STRATA_NO_BUNDLED_ENGINE);
|
||||
bundledEngine.then(() => { bundledEngineSettled = true; }, () => { bundledEngineSettled = true; });
|
||||
/* A build made for another make of graphics card is not unpacked; Control's answer then carries the plain sentence (with the right download) instead of its own. */
|
||||
let bundledWrongMake = null;
|
||||
bundledEngine.then(result => { if (result?.action === 'wrong-make') { bundledWrongMake = result.notice; console.log(result.notice); } }, () => {});
|
||||
if (control) {
|
||||
const engineOf = control.engine.bind(control);
|
||||
control.engine = async (...args) => {
|
||||
const answer = await engineOf(...args);
|
||||
return answer && !answer.usable && bundledWrongMake ? { ...answer, notice: bundledWrongMake, wrongMake: true } : answer;
|
||||
};
|
||||
}
|
||||
const native=createNativeControl({control,ready:bundledEngine,appUpdater,hardwareProbe:()=>(hardware.cachedHardware()?.gpus??[]).map((g,n)=>({index:g.index??n,vram_gb:g.vramGb})),directory:store.DATA_DIR,modelsDirectory:()=>store.MODELS_DIR,nativeDataDirectory:store.nativeDataDirectory,beforeLoad:async()=>{if(models.REAL&&models.library().loaded){if(serving.state().requests.some(r=>runtimeModels.state().instances.some(i=>i.id===r.instanceId)&&["queued","generating"].includes(r.state)))throw new Error("Finish requests before switching engines.");await models.unload();runtimeState();}},requests:()=>serving.state().requests,changed:value=>hub.send('native',value)});
|
||||
/* The agent: chats become DeepSeek Harness sessions when that component is installed. It is a separate
|
||||
pinned component (STRATA_HARNESS_DIR), not part of this host, so its absence is a visible capability
|
||||
@@ -240,7 +252,9 @@ const backend = await detectBackend({ force: true });
|
||||
|
||||
console.log(`strata host · http://${HOST}:${PORT}`);
|
||||
console.log(`hardware · ${boot.accelerator.label} / ${boot.accelerator.backend} / ${boot.cpu.cores} cores / ${Math.round(boot.memory.totalGb)} GB`);
|
||||
console.log(`inference · ${models.REAL ? (bundledEngineSettled ? backend.name : 'unpacking the bundled engine… (about a minute the first time)') : 'simulated (design iteration) — set STRATA_REAL_ENGINE=1 for a real engine'}`);
|
||||
const printInference = engine => console.log(`inference · ${models.REAL ? inferenceLine({ backend, engine, settled: bundledEngineSettled }) : 'simulated (design iteration) — set STRATA_REAL_ENGINE=1 for a real engine'}`);
|
||||
if (models.REAL && control && backend.kind === 'unavailable' && bundledEngineSettled) void control.engine().catch(() => null).then(printInference);
|
||||
else printInference(null);
|
||||
console.log(`models dir · ${store.MODELS_DIR}`);
|
||||
console.log(`workspace · ${WORKSPACE}`);
|
||||
|
||||
@@ -256,7 +270,7 @@ function setupState() {
|
||||
flowVersion: 2,
|
||||
completedAt: s.setup.completedAt,
|
||||
history: s.setup.history ?? [],
|
||||
app: { version: appVersion(), modelsDir: store.MODELS_DIR, dataDir: store.DATA_DIR },
|
||||
app: { version: appVersion(), modelsDir: store.MODELS_DIR, dataDir: store.DATA_DIR, modelsDirNotice: store.MODELS_DIR_NOTICE },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -572,7 +586,7 @@ async function runAgentTurn({ res, controller, chatId, body, message, workspaceI
|
||||
}
|
||||
else {
|
||||
try { instance = native.resolveInstance(body.nativeModelId ?? native.state().defaultId); }
|
||||
catch (e) { write({ type: 'error', message: e.message }); return; }
|
||||
catch (e) { write({ type: 'error', message: plainNoModel(e.message) }); return; }
|
||||
choice = { instanceId: instance.id };
|
||||
}
|
||||
const started = Date.now();
|
||||
@@ -604,7 +618,7 @@ async function runAgentTurn({ res, controller, chatId, body, message, workspaceI
|
||||
async function runTurn({ message, history = [], onFrame, signal, client, workspaceId = null, sessionId = null, remoteModel = null, runtimeModelId = undefined, nativeModelId, images=[], useTools=false, thinking='medium', thinkingBudget=0 }) {
|
||||
if (remoteModel) return runRemoteTurn({ selection: remoteModel, message, history, images, onFrame, signal, workspaceId });
|
||||
if(native.hasLive()||nativeModelId){
|
||||
let instance;try{instance=native.resolveInstance(nativeModelId??native.state().defaultId);}catch(e){onFrame({type:'error',message:e.message});return {error:e.message};}const started=Date.now(),cancel=new AbortController();
|
||||
let instance;try{instance=native.resolveInstance(nativeModelId??native.state().defaultId);}catch(e){const message=plainNoModel(e.message);onFrame({type:'error',message});return {error:message};}const started=Date.now(),cancel=new AbortController();
|
||||
const entry=serving.open({client:client??'Strata chat',endpoint:'/chat/completions',model:instance.apiModelId,instanceId:instance.id,gpuIds:instance.gpus.map(String),cancel:()=>cancel.abort()});
|
||||
try{serving.mark(entry.id,{state:'generating'});const applied=selectSkills(skillState,message,workspaceId);const result=await proxyCompletion({baseUrl:`http://127.0.0.1:${instance.port}/v1`,modelId:instance.apiModelId,authorization:instance.key,message,history,images,extra:{strata_mcp:useTools===true,...(thinkingBudget>0&&String(thinking).toLowerCase()!=='off'?{reasoning_budget_tokens:thinkingBudget}:{}),reasoning_effort:({off:'none',max:'high'}[String(thinking).toLowerCase()]??String(thinking).toLowerCase())},systemPrompt:[SYSTEM_PROMPT,skillInstructions(applied)].filter(Boolean).join('\n\n'),signal:signal?AbortSignal.any([signal,cancel.signal]):cancel.signal,onFrame:frame=>onFrame({...frame,requestId:entry.id,model:instance.label,contextMax:instance.metrics?.engine?.max_context??instance.context,skills:applied.map(s=>s.name)})});serving.mark(entry.id,{...result,usageSource:result.usageSource});serving.finish(entry.id,{tokensOut:result.tokensOut,durationMs:Date.now()-started});return result;}catch(e){serving.finish(entry.id,{durationMs:Date.now()-started,error:e.message});onFrame({type:'error',message:e.message,requestId:entry.id});return {error:e.message};}
|
||||
}
|
||||
@@ -696,6 +710,7 @@ async function runTurn({ message, history = [], onFrame, signal, client, workspa
|
||||
durationMs: Date.now() - started,
|
||||
ttftMs: firstTokenMs ?? undefined,
|
||||
cachedTokens: result.cachedTokens,
|
||||
tps: result.tps,
|
||||
usageSource: result.usageSource,
|
||||
});
|
||||
if(models.REAL){monitor.setRecordedContext({ ...result, requestId: entry.id, sessionId });monitor.idle();}
|
||||
@@ -793,7 +808,7 @@ function createTuning({ repoRoot, python, pythonArgs = [], env = process.env } =
|
||||
function runCli(args) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(python, [...pythonArgs, cli, ...args, '--output', 'json'], {
|
||||
cwd: repoRoot, windowsHide: true, env: { ...env, STRATA_TUNE_DIR: stateDir() },
|
||||
cwd: repoRoot, windowsHide: true, env: { ...env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8', STRATA_TUNE_DIR: stateDir() },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let out = '', err = '';
|
||||
@@ -895,8 +910,10 @@ const tasks = createTaskService({
|
||||
const taskTimer = setInterval(() => tasks.tick(), 15000);
|
||||
taskTimer.unref();
|
||||
|
||||
const localShare = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, port: Number(process.env.STRATA_SHARE_PORT ?? PORT + 1), changed: () => serving.setSharing(localShare.state()) });
|
||||
const tunnelPort = Number(process.env.STRATA_TUNNEL_PORT ?? PORT + 2);
|
||||
// The share and tunnel listeners take a free port (the next ones above the host's own) instead of a fixed PORT+1 / PORT+2 that Docker, WSL or Hyper-V may hold or reserve.
|
||||
const sharePort = await findFreePort(Number(process.env.STRATA_SHARE_PORT ?? PORT + 1), '0.0.0.0', { skip: [PORT] });
|
||||
const localShare = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, port: sharePort, changed: () => serving.setSharing(localShare.state()) });
|
||||
const tunnelPort = await findFreePort(Number(process.env.STRATA_TUNNEL_PORT ?? PORT + 2), '127.0.0.1', { skip: [PORT, sharePort] });
|
||||
const tunnelOrigin = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, host: '127.0.0.1', port: tunnelPort });
|
||||
const connector = createConnectorInstaller({ directory: join(store.HOME, 'engine', 'cloudflare'), componentDirectory: join(componentsDirectory(), 'cloudflared') });
|
||||
const cloudflare = createCloudflare({ origin: tunnelOrigin, originUrl: `http://127.0.0.1:${tunnelPort}`, hasKey: () => !!store.get().serving?.apiKey, changed: serving.setTunnel, locate: connector.locate, prepareConnector: connector.ensure, connectorInfo: connector.connector, findRunning: findRunningConnector });
|
||||
@@ -949,6 +966,17 @@ async function api(req, res, path, method, url) {
|
||||
if(path==='/api/native'&&method==='GET')return json(res,200,await native.refresh(url.searchParams.get('refresh')==='1'));
|
||||
if(path==='/api/native'&&method==='PATCH')return json(res,200,await native.configure(await readBody(req)));
|
||||
if(path==='/api/native/directory'&&method==='POST')return json(res,200,native.setDownloadDirectory((await readBody(req)).directory));
|
||||
/* Settings > Storage > Models folder: pick (or type) a folder, say what it means, then start the choice. Copying is Control's (strata models move). */
|
||||
if(path==='/api/native/models-folder/plan'&&method==='POST'){
|
||||
const typed=await typedFolder((await readBody(req)).path,{isAbsolute});
|
||||
const picked=typed?{path:typed}:await chooseFolder('Choose a folder for your models');
|
||||
if(picked.unavailable){const u=chooserUnavailable(picked);return error(res,501,u.message,{code:u.code});}
|
||||
if(picked.cancelled)return json(res,200,{cancelled:true});
|
||||
return json(res,200,{plan:await native.planModelsFolder(picked.path)});}
|
||||
if(path==='/api/native/models-folder/start'&&method==='POST'){const body=await readBody(req);const result=await native.startModelsFolder({folder:body.path,choice:body.choice,stopRunning:body.stopRunning===true});return json(res,result.id?202:200,result);}
|
||||
if(path==='/api/native/models-folder/interrupted'&&method==='GET')return json(res,200,{interrupted:await native.interruptedMove()});
|
||||
if(path==='/api/native/models-folder/resume'&&method==='POST'){const body=await readBody(req);return json(res,202,await native.finishInterruptedMove({stopRunning:body.stopRunning===true}));}
|
||||
if(path==='/api/native/models-folder/leave'&&method==='POST')return json(res,200,await native.leaveInterruptedMove());
|
||||
if(path==='/api/native/plan'&&method==='POST')return json(res,200,await native.plan(await readBody(req)));
|
||||
if(path==='/api/native/operations'&&method==='POST'){if(storageChanging)return error(res,409,'Wait for the model folder check to finish.');const body=await readBody(req);return json(res,202,native.operation(body.kind,body.args));}
|
||||
if(path==='/api/native/logs'&&method==='GET')return json(res,200,{logs:native.logs()});
|
||||
@@ -966,7 +994,7 @@ async function api(req, res, path, method, url) {
|
||||
return json(res,200,{path:file});}
|
||||
if(path==='/api/native/local/preview'&&method==='POST'){
|
||||
/* "Use a local GGUF": pick (or type) the folder, and Control says what is in it. Nothing is copied or changed. */
|
||||
const typed=await typedFolder((await readBody(req)).path,{isAbsolute});
|
||||
const typed=await typedFolder((await readBody(req)).path,{isAbsolute,fileType:/\.gguf$/i});
|
||||
const picked=typed?{path:typed}:await chooseFolder('Choose the folder that holds your GGUF files');
|
||||
if(picked.unavailable){const u=chooserUnavailable(picked);return error(res,501,u.message,{code:u.code});};
|
||||
if(picked.cancelled)return json(res,200,{cancelled:true});
|
||||
@@ -993,7 +1021,7 @@ async function api(req, res, path, method, url) {
|
||||
if(nativeSettingsFile&&method==='POST')return json(res,200,await native.openSettingsFile(nativeSettingsFile[1]));
|
||||
|
||||
// Credentials are handled only on the host. Responses and events expose a key-set flag.
|
||||
return error(res,404,'Unknown native control action.');}catch(e){return error(res,/Finish|Unload|running|current operation/.test(e.message)?409:400,e.message);}}
|
||||
return error(res,404,'Unknown native control action.');}catch(e){return error(res,/Finish|Unload|running|current operation/.test(e.message)?409:400,e.message,{...(e.code&&typeof e.code==='string'?{code:e.code}:{}),...(e.details?{details:String(e.details)}:{})});}}
|
||||
|
||||
if (path === '/api/model-providers' && method === 'GET') return json(res, 200, withPictureAnswers(modelProviders.list()));
|
||||
if (path === '/api/model-providers/discover' && method === 'POST') {
|
||||
@@ -1410,7 +1438,7 @@ async function api(req, res, path, method, url) {
|
||||
if (modelMatch) {
|
||||
const [, id, action] = modelMatch;
|
||||
if (method === 'DELETE') {if(runtimeModels.hasVariant(id))return error(res,409,'Unload every instance of this model before deleting its files.');return json(res, 200, runtimeModels.libraryState(models.remove(id)));}
|
||||
if (method === 'POST' && action === 'install') return json(res, 200, runtimeModels.libraryState(await models.install(id)));
|
||||
if (method === 'POST' && action === 'install') { try { return json(res, 200, runtimeModels.libraryState(await models.install(id))); } catch (err) { return error(res, err.status ?? 500, err.message); } }
|
||||
if (method === 'POST' && action === 'cancel') return json(res, 200, runtimeModels.libraryState(models.cancel(id)));
|
||||
if (method === 'POST' && action === 'load') {
|
||||
const hw = await hardware.detect();
|
||||
@@ -1844,8 +1872,10 @@ async function api(req, res, path, method, url) {
|
||||
try { return json(res, 200, agentTools.removeCustom(customToolRoute[1])); } catch (e) { return error(res, 400, e.message); }
|
||||
}
|
||||
if (path === '/api/agent/pending' && method === 'GET') {
|
||||
const chat = new URL(req.url, 'http://x').searchParams.get('chatId') ?? '';
|
||||
return json(res, 200, { pending: agent.pendingFor(chat) });
|
||||
const params = new URL(req.url, 'http://x').searchParams;
|
||||
// No chat named: every chat with an approval waiting (the task board lists them without opening each chat).
|
||||
if (!params.has('chatId')) return json(res, 200, { chats: agent.pendingAll() });
|
||||
return json(res, 200, { pending: agent.pendingFor(params.get('chatId') ?? '') });
|
||||
}
|
||||
/* Approve / Deny a tool the agent asked about. The answer reaches the Harness approval for exactly
|
||||
that chat and call; a stale or foreign id is refused. */
|
||||
@@ -2146,6 +2176,12 @@ async function staticFile(req, res, path) {
|
||||
|
||||
/* ------------------------------------------------------------------ start --- */
|
||||
|
||||
server.on('error', error => {
|
||||
if (server.listening) { console.error(`[strata] ${error.message}`); return; }
|
||||
// a taken or Windows-reserved port: one plain sentence instead of a stack trace
|
||||
console.error(`[strata] ${listenProblem(error, PORT, HOST)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
server.listen(PORT, HOST, () => {
|
||||
if (process.send) process.send({ type: 'strata-ready', port: PORT });
|
||||
// Engines a lost owner left behind (an earlier crash, an older version): ended after a PID + start time + command-line check.
|
||||
|
||||
@@ -523,3 +523,15 @@ async function demoCompletion({ message, modelLabel, onFrame, signal, engine, ca
|
||||
onFrame({ type: 'done', tokensIn: promptTokens, tokensOut: emitted, tps, durationMs, demo: true, usageSource: 'demo' });
|
||||
return { tokensIn: promptTokens, tokensOut: emitted, durationMs, tps, usageSource: 'demo' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The start-up line "inference · ...". The worker that serves a model is only running once a model is loaded, so on a restart nothing answers
|
||||
* at the worker's address even though the engine is installed and usable; the line says that instead of "No Strata engine".
|
||||
* `engine` is Control's answer (null when there is no Control, as in a source checkout).
|
||||
*/
|
||||
export function inferenceLine({ backend, engine = null, settled = true }) {
|
||||
if (!settled) return 'unpacking the bundled engine… (about a minute the first time)';
|
||||
if (backend?.kind !== 'unavailable' || !engine) return backend?.name ?? 'No Strata engine';
|
||||
if (engine.usable) return `Strata engine${engine.engine?.version ? ' ' + engine.engine.version : ''} is installed; no model is loaded yet`;
|
||||
return engine.notice ? `No Strata engine for this PC. ${engine.notice}` : backend.name;
|
||||
}
|
||||
@@ -2,14 +2,16 @@
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import { apiPath } from './api-paths.mjs';
|
||||
import { listenProblem } from './ports.mjs';
|
||||
/** Sharing without a key is allowed (owner decision, 0.2.2): the person is told once, in plain words, and the app keeps offering to make a key. */
|
||||
export const NO_KEY_WARNING = 'No API key is set, so any device on this network can use your model. Create an API key to protect it.';
|
||||
export function createLocalShare({ handle, hasKey, changed = () => {}, host = '0.0.0.0', port = 8792 }) {
|
||||
let listener = null, active = false;
|
||||
const address = () => Object.values(os.networkInterfaces()).flat().find(n => n?.family === 'IPv4' && !n.internal)?.address ?? '127.0.0.1';
|
||||
const state = () => ({ active, url: active ? `http://${host === '127.0.0.1' ? host : address()}:${listener.address().port}/v1` : null });
|
||||
const state = () => ({ active, url: active ? `http://${host === '127.0.0.1' ? host : address()}:${listener.address().port}/v1` : null, warning: active && !hasKey() ? NO_KEY_WARNING : null });
|
||||
async function start() {
|
||||
if (active) return state();
|
||||
if (listener) throw new Error('Sharing is already starting.');
|
||||
if (!hasKey()) throw new Error('Set an API key before sharing with other devices.');
|
||||
listener = http.createServer(async (req, res) => {
|
||||
const path = apiPath(new URL(req.url ?? '/', 'http://localhost').pathname);
|
||||
if (!['/v1/models','/v1/chat/completions','/v1/messages'].includes(path)) { res.writeHead(404); res.end('Not found'); return; }
|
||||
@@ -17,7 +19,7 @@ export function createLocalShare({ handle, hasKey, changed = () => {}, host = '0
|
||||
catch { if (!res.headersSent) res.writeHead(500); res.end('Request failed'); }
|
||||
});
|
||||
try { await new Promise((resolve,reject) => { listener.once('error',reject); listener.listen(port,host,resolve); }); }
|
||||
catch(e) { listener.close(); listener=null; throw e; }
|
||||
catch(e) { listener.close(); listener=null; throw new Error(listenProblem(e, port, host)); }
|
||||
active=true; changed(); return state();
|
||||
}
|
||||
async function stop() {
|
||||
|
||||
@@ -2,9 +2,10 @@ import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { posix, win32 } from 'node:path';
|
||||
import { utf8Script } from './powershell-utf8.mjs';
|
||||
|
||||
/** Resolve the user's Documents folder without evaluating shell configuration. */
|
||||
export function defaultModelDirectory({platform=process.platform,home=homedir(),env=process.env,read=readFileSync,windowsDocuments=()=>execFileSync('powershell.exe',['-NoProfile','-NonInteractive','-Command',"[Environment]::GetFolderPath('MyDocuments')"],{encoding:'utf8',timeout:5000,windowsHide:true}).trim()}={}) {
|
||||
export function defaultModelDirectory({platform=process.platform,home=homedir(),env=process.env,read=readFileSync,windowsDocuments=()=>execFileSync('powershell.exe',['-NoProfile','-NonInteractive','-Command',utf8Script("[Environment]::GetFolderPath('MyDocuments')")],{encoding:'utf8',timeout:5000,windowsHide:true}).trim()}={}) {
|
||||
const path=platform==='win32'?win32:posix;
|
||||
if(env.STRATA_MODELS_DIR)return path.resolve(env.STRATA_MODELS_DIR);
|
||||
let documents=env.STRATA_DOCUMENTS_DIR;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -30,6 +30,16 @@ export const PROVIDER_CATALOG = [
|
||||
const PROTOCOLS = ['openai-chat', 'openai-responses', 'anthropic'];
|
||||
const fail = message => { throw new Error(message); };
|
||||
const text = (value, max = 160) => typeof value === 'string' ? value.trim().slice(0, max) : '';
|
||||
/** The answer speed of a model API's reply: the tokens it reports over the time from its first streamed token to the end, never over the whole
|
||||
* wait (that would put the time to the first token, often seconds of prompt reading, into the speed). null when it reported no tokens;
|
||||
* with no usable first-token time (or under a quarter of a second) the whole time is the best there is. */
|
||||
export function answerSpeed(tokensOut, durationMs, firstTokenMs) {
|
||||
if (!Number.isFinite(tokensOut) || tokensOut <= 0) return null;
|
||||
const decodeMs = Number.isFinite(firstTokenMs) ? durationMs - firstTokenMs : NaN;
|
||||
const ms = decodeMs >= 250 ? decodeMs : durationMs;
|
||||
return Number((tokensOut / Math.max(0.001, ms / 1000)).toFixed(1));
|
||||
}
|
||||
|
||||
export function normalizeBaseUrl(value) {
|
||||
let url; try { url = new URL(text(value, 2048)); } catch { fail('Enter a full http:// or https:// API address.'); }
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) fail('Use an HTTP or HTTPS base URL without credentials, a query, or a fragment.');
|
||||
@@ -207,7 +217,7 @@ export function createModelProviders({ directory, changed = () => {}, fetchImpl
|
||||
if (!textLength) fail('The model returned no text. Choose a model that supports text chat.');
|
||||
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
||||
const durationMs = Date.now() - started;
|
||||
const result = { tokensIn, tokensOut, usageSource: tokensIn !== null && tokensOut !== null ? 'reported' : 'unavailable', durationMs, ttftMs: firstToken, tps: tokensOut !== null ? tokensOut / Math.max(0.001, durationMs / 1000) : null };
|
||||
const result = { tokensIn, tokensOut, usageSource: tokensIn !== null && tokensOut !== null ? 'reported' : 'unavailable', durationMs, ttftMs: firstToken, tps: answerSpeed(tokensOut, durationMs, firstToken) };
|
||||
onFrame({ type: 'done', ...result }); return result;
|
||||
}
|
||||
/** Connections WITH their keys, for the host's own agent bridge only; never serialised to a client. */
|
||||
|
||||
+10
-99
@@ -1,14 +1,11 @@
|
||||
/**
|
||||
* Model library.
|
||||
*
|
||||
* Downloads are real: weights stream from Hugging Face into the configured model folder (Documents/Strata/Models by default)
|
||||
* with progress, resume-by-restart and a size check. Loading starts a real
|
||||
* engine process through the supervisor — no model files, no process, no load.
|
||||
* With the real engine on, the app downloads models from the Models screen of the native engine, not from this list (see install). Loading
|
||||
* starts a real engine process through the supervisor — no model files, no process, no load.
|
||||
*/
|
||||
import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
|
||||
import { existsSync, rmSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
import { CONTEXT_TOKENS, VARIANTS, downloadUrl, footprint, localModels, refreshLocalCatalog, variant } from './catalog.mjs';
|
||||
|
||||
@@ -140,6 +137,12 @@ function emit() {
|
||||
|
||||
/* ---------------------------------------------------------------- download --- */
|
||||
|
||||
/**
|
||||
* With the real engine on, models come from the Models screen of the native engine (its installer pins every file by hash). This
|
||||
* starter list names files by name only and has no hash, so it is never downloaded: the route refuses before any request is made.
|
||||
*/
|
||||
export const LIST_DOWNLOAD_REFUSED = "The app downloads models from the Models screen of the native engine, not from this list.";
|
||||
|
||||
export async function install(id) {
|
||||
const v = variant(id);
|
||||
if (!v) throw new Error(`Unknown model ${id}`);
|
||||
@@ -153,99 +156,7 @@ export async function install(id) {
|
||||
if (downloads.has(id)) return library();
|
||||
|
||||
if (!REAL) return simulateInstall(v);
|
||||
|
||||
const dir = join(MODELS_DIR, v.id);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const finalPath = pathFor(v);
|
||||
const partPath = `${finalPath}.part`;
|
||||
const controller = new AbortController();
|
||||
downloads.set(id, controller);
|
||||
|
||||
const startedAt = Date.now();
|
||||
runtime.set(id, {
|
||||
state: 'downloading',
|
||||
progress: { pct: 0, mbps: 0, etaSec: 0, stage: `Fetching ${v.source.file}` },
|
||||
});
|
||||
emit();
|
||||
hubRef?.send('log', { level: 'info', message: `Downloading ${v.label} from ${v.source.repo}`, at: nowIso() });
|
||||
|
||||
// Keep the UI fed even while the socket is quiet.
|
||||
const ticker = setInterval(() => {
|
||||
const entry = runtime.get(id);
|
||||
if (!entry || entry.state !== 'downloading') return;
|
||||
emit();
|
||||
}, 400);
|
||||
|
||||
try {
|
||||
const res = await fetch(downloadUrl(v), { signal: controller.signal, redirect: 'follow' });
|
||||
if (!res.ok || !res.body) throw new Error(`Download failed (${res.status} ${res.statusText})`);
|
||||
|
||||
const total = Number(res.headers.get('content-length')) || v.source.bytes;
|
||||
let received = 0;
|
||||
let lastEmit = 0;
|
||||
|
||||
const progressStream = new ReadableStream({
|
||||
start(controllerStream) {
|
||||
const reader = res.body.getReader();
|
||||
const pump = async () => {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
received += value.byteLength;
|
||||
controllerStream.enqueue(value);
|
||||
const now = Date.now();
|
||||
if (now - lastEmit > 350) {
|
||||
lastEmit = now;
|
||||
const seconds = (now - startedAt) / 1000;
|
||||
const mbps = received / 1024 ** 2 / Math.max(seconds, 0.001);
|
||||
runtime.set(id, {
|
||||
state: 'downloading',
|
||||
progress: {
|
||||
pct: (received / total) * 100,
|
||||
mbps,
|
||||
etaSec: mbps > 0 ? (total - received) / 1024 ** 2 / mbps : 0,
|
||||
stage: received / total < 0.9 ? `Fetching ${v.source.file}` : 'Verifying and finalising',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
controllerStream.close();
|
||||
};
|
||||
void pump().catch((err) => controllerStream.error(err));
|
||||
},
|
||||
});
|
||||
|
||||
await pipeline(Readable.fromWeb(progressStream), createWriteStream(partPath));
|
||||
renameSync(partPath, finalPath);
|
||||
|
||||
const size = statSync(finalPath).size;
|
||||
if (size < v.source.bytes * 0.9) {
|
||||
throw new Error(`File is smaller than expected (${(size / 1e9).toFixed(2)} GB of ${(v.source.bytes / 1e9).toFixed(2)} GB)`);
|
||||
}
|
||||
|
||||
const s = getStore();
|
||||
update({
|
||||
installed: { ...(s.installed ?? {}), [id]: { installedAt: nowIso(), bytes: size } },
|
||||
current: s.current ?? id,
|
||||
});
|
||||
runtime.set(id, { state: 'installed' });
|
||||
hubRef?.send('log', { level: 'info', message: `${v.label} installed (${(size / 1e9).toFixed(2)} GB)`, at: nowIso() });
|
||||
} catch (err) {
|
||||
const aborted = err?.name === 'AbortError';
|
||||
try {
|
||||
if (existsSync(partPath)) rmSync(partPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
runtime.set(id, aborted ? { state: 'available' } : { state: 'error', error: err.message });
|
||||
if (!aborted) hubRef?.send('log', { level: 'error', message: `Download failed: ${err.message}`, at: nowIso() });
|
||||
} finally {
|
||||
clearInterval(ticker);
|
||||
downloads.delete(id);
|
||||
emit();
|
||||
}
|
||||
|
||||
return library();
|
||||
throw Object.assign(new Error(LIST_DOWNLOAD_REFUSED), { status: 409 });
|
||||
}
|
||||
|
||||
export function cancel(id) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -2,23 +2,26 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import {chooseParallel,applyParallel,withoutParallel,FALLBACK_NOTE} from './native-parallel.mjs';
|
||||
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, realpathSync, copyFileSync, renameSync, unlinkSync, chmodSync, statSync, accessSync, constants } from 'node:fs';
|
||||
import { join, resolve, dirname, basename } from 'node:path';
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, realpathSync, copyFileSync, renameSync, rmSync, statfsSync, unlinkSync, chmodSync, statSync, accessSync, constants } from 'node:fs';
|
||||
import { join, resolve, dirname, basename, sep, relative, isAbsolute } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createServer } from 'node:net';
|
||||
import { NATIVE_FIELDS, validateNativeSettings, validateMcpServers, publicMcpServers, publicNativeEnv, restoreNativeEnv } from './native-settings.mjs';
|
||||
import { resourceSettings, saferStart } from './native-resources.mjs';
|
||||
import { NATIVE_FIELDS, APP_OWNED_FIELDS, withoutDraftLayer, validateNativeSettings, validateMcpServers, publicMcpServers, publicNativeEnv, restoreNativeEnv } from './native-settings.mjs';
|
||||
import { resourceSettings, saferStart, reserveChoice } from './native-resources.mjs';
|
||||
import { USER_KEY, capture as captureUserArgs, merge as mergeUserArgs, setFlag as setUserFlag, stamped as stampedUserArgs } from './native-user-args.mjs';
|
||||
import { resolvePython, venvPython } from './python-resolver.mjs';
|
||||
import os from 'node:os';
|
||||
import { runtimeVision } from './native-vision.mjs';
|
||||
import { componentsDirectory, installerEnvironment, controlEnvironment } from './control-runtime.mjs';
|
||||
import { createEngineStep } from './engine-step.mjs';
|
||||
import { dataFolderFromPick, isDriveRoot, makeFolder, rememberDataFolder, syncDataFolder, terminalSettingsFile } from './model-folder.mjs';
|
||||
import { createAutoTune, writeTuneConfig } from './native-autotune.mjs';
|
||||
import { plainError, PHASE_TEXT } from './update-messages.mjs';
|
||||
import { checkRunConfig, describeIssues } from './run-config-check.mjs';
|
||||
import { openTextFile } from './open-text-file.mjs';
|
||||
import { markEngine, engineRefusal } from './engine-version.mjs';
|
||||
import { friendlyModelName } from './model-names.mjs';
|
||||
import { parseSaved as parseRoles, mirror as mirrorRoles } from './gpu-roles.mjs';
|
||||
|
||||
const ROOT=resolve(dirname(fileURLToPath(import.meta.url)),'..');
|
||||
const safeId=value=>typeof value==='string'&&/^[a-z0-9_-]{1,80}$/i.test(value);
|
||||
@@ -40,7 +43,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
updated or fetched from here. Without `control` (a source checkout) every path below is the original one. */
|
||||
const packaged=!!control;
|
||||
/* The auto-tuner and the saved tunings are Strata Control's (`strata tune auto`, `strata presets`): the app runs them and follows their status file. */
|
||||
const autotuner=createAutoTune({spawnProcess,workDir:join(directory,'native-tune'),launcher:()=>{if(packaged)return {command:control.control.python,prefix:[control.control.cli],cwd:control.control.dir,env:controlEnvironment(control.control)};const py=python();return {command:py.command,prefix:[...py.args,join(repo(),'strata.py')],cwd:repo(),env:{...process.env,PYTHONUNBUFFERED:'1',PYTHONIOENCODING:'utf-8'}};}});
|
||||
const autotuner=createAutoTune({spawnProcess,workDir:join(directory,'native-tune'),launcher:()=>{if(packaged)return {command:control.control.python,prefix:[control.control.cli],cwd:control.control.dir,env:controlEnvironment(control.control)};const py=python();return {command:py.command,prefix:[...py.args,join(repo(),'strata.py')],cwd:repo(),env:{...process.env,PYTHONUNBUFFERED:'1',PYTHONUTF8:'1',PYTHONIOENCODING:'utf-8'}};}});
|
||||
/* In the packaged app the model catalog, the GPU probe and the install job are Control's bundled tools/strata_mcp.py and
|
||||
setup.py, run on Control's own Python in the installer's mode (STRATA_CONTROL_INSTALL: no pip, no engine, no compiler).
|
||||
The install only runs when the user clicks Download; starting and serving (control.start) keep STRATA_NO_ACQUIRE. */
|
||||
@@ -50,10 +53,11 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
let defaultId=null,timer=null,closing=false,polling=false;
|
||||
const emit=()=>{const value=state();writeFileSync(join(directory,'native-jobs.json'),JSON.stringify(value.jobs),{mode:0o600});changed(value);};
|
||||
const append=(instanceId,line)=>{logs.push({at:new Date().toISOString(),instanceId,line:String(line).slice(0,2000)});if(logs.length>400)logs.shift();};
|
||||
function state(){return {...snapshot,downloadDirectory:preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),fields:nativeFields(),instances:[...instances.values()].map(({key,configPath,fingerprint,mcpServers,...i})=>structuredClone(i)),defaultId,jobs:[...jobs.values()].slice(-12).map(({process,abort,engineAbort,rawResult,...j})=>j),capabilities:{responses:sourceText().includes('/v1/responses'),updates:!!snapshot.available,rollback:previousEngine(),componentUpdates:packaged&&typeof control.update==='function',calibration:packaged?false:snapshot.hardware?.gpus?.some(g=>/nvidia/i.test(g.name??''))??false}};}
|
||||
function diskSpace(path){try{const s=statfsSync(path);return {free:Number(s.bavail)*Number(s.bsize),total:Number(s.blocks)*Number(s.bsize)};}catch{return null;}}
|
||||
function state(){const folder=preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),space=folder?diskSpace(folder):null;return {...snapshot,downloadDirectory:folder,downloadFreeBytes:space?.free??null,downloadTotalBytes:space?.total??null,fields:nativeFields(),instances:[...instances.values()].map(({key,configPath,fingerprint,mcpServers,...i})=>structuredClone(i)),defaultId,jobs:[...jobs.values()].slice(-12).map(({process,abort,engineAbort,rawResult,...j})=>j),capabilities:{responses:sourceText().includes('/v1/responses'),updates:!!snapshot.available,rollback:previousEngine(),componentUpdates:packaged&&typeof control.update==='function',calibration:packaged?false:snapshot.hardware?.gpus?.some(g=>/nvidia/i.test(g.name??''))??false}};}
|
||||
let sourceCache={path:'',time:0,text:''};
|
||||
function sourceText(){try{const path=join(packaged?control.control.dir:preferences.repo,'serve/server.py'),time=statSync(path).mtimeMs;if(sourceCache.path===path&&sourceCache.time===time)return sourceCache.text;sourceCache={path,time,text:readFileSync(path,'utf8')};return sourceCache.text;}catch{return '';}}
|
||||
function nativeFields(){const source=sourceText();return NATIVE_FIELDS.map(field=>({...field,supported:field.id.startsWith('vision_')||field.id.startsWith('mcp_')||field.id==='vram_reserve_mib'||source.includes(field.id)}));}
|
||||
function nativeFields(){const source=sourceText();return NATIVE_FIELDS.map(field=>({...field,supported:APP_OWNED_FIELDS.includes(field.id)||field.id.startsWith('vision_')||field.id.startsWith('mcp_')||field.id==='vram_reserve_mib'||source.includes(field.id)}));}
|
||||
const modelArena=id=>({arenaGb:(snapshot.catalog?.families??[]).flatMap(f=>f.sizes??[]).find(s=>s.id===id)?.experts_gb});
|
||||
/* A start that ends with `server_exited` carries the engine's last log line with it; the app turns the code into words and
|
||||
keeps this text under "Details" (src/lib/plain-errors.ts). */
|
||||
@@ -69,8 +73,12 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
for(const name of names){const path=join(dir,name),config=readJson(path,null);if(!config||typeof config!=='object')continue;
|
||||
const next={...config,...(config.cwd!==undefined?{cwd:fix(config.cwd)}:{}),...(Array.isArray(config.args)?{args:config.args.map(fix)}:{})};
|
||||
if(JSON.stringify(next)!==JSON.stringify(config)){try{writeModelConfig(path,next);}catch{/* read-only folder: the load reports it */}}}}
|
||||
function installedInfo(info){if(!info)return info;return {...info,models:(info.models??[]).map(entry=>{const cfg=/^strata-[a-z0-9_-]+\.json$/i.test(entry.config??'')?readJson(entry.configPath??join(repo(),entry.config)):{};const at=cfg.args?.indexOf('--native')??-1,nativeFile=at>=0?cfg.args[at+1]:null;const dataFolder=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))==='models'?dirname(dirname(dirname(nativeFile))):info.data_folder;return {...entry,diskGb:entry.diskGb??entry.disk_gb,diskBytes:entry.diskBytes??entry.disk_bytes,sharedBytes:entry.sharedBytes??entry.shared_bytes,removableGb:entry.removableGb??entry.removable_gb,dataFolder,visionPrepared:!!cfg.vision,/* Picture reading is ON unless the person turned it off: no saved choice and not loaded is undefined, not false (owner rule: pictures on by default). */visionEnabled:cfg.ui_vision_enabled??(()=>{const running=[...instances.values()].find(i=>i.model===entry.model&&i.status==='ready');return running?!!running.vision:undefined;})(),visionDevice:cfg.ui_vision_device,resourceProfile:cfg.ui_resource_profile,lowRamMode:cfg.args?.includes('--resident-experts')?'resident':cfg.args?.includes('--mmap-experts')?'mmap':'auto'};})};}
|
||||
function installedInfo(info){if(!info)return info;return {...info,models:(info.models??[]).map(entry=>{const cfg=/^strata-[a-z0-9_-]+\.json$/i.test(entry.config??'')?readJson(entry.configPath??join(repo(),entry.config)):{};const at=cfg.args?.indexOf('--native')??-1,nativeFile=at>=0?cfg.args[at+1]:null;const dataFolder=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))==='models'?dirname(dirname(dirname(nativeFile))):info.data_folder;/* A model the person pointed at their own GGUF folder (not <data>/models/<tag>): remembered, so a later load runs setup with that folder again instead of downloading the files (#3199). */const ownGgufDir=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))!=='models'&&existsSync(nativeFile)?dirname(nativeFile):undefined;return {...entry,ownGgufDir,diskGb:entry.diskGb??entry.disk_gb,diskBytes:entry.diskBytes??entry.disk_bytes,sharedBytes:entry.sharedBytes??entry.shared_bytes,removableGb:entry.removableGb??entry.removable_gb,dataFolder,visionPrepared:!!cfg.vision,/* The picture files are prepared when the run config has a vision section: the Models screen then loads straight away instead of running setup again (it used to read 'off' for every model, which sent every load of a picture-capable model through "Apply settings and load", and setup writes the arguments, the draft layer and the reader back each time). */images:cfg.vision?'yes':'off',draftLayer:cfg.mtp!==false,/* Picture reading is ON unless the person turned it off: no saved choice and not loaded is undefined, not false (owner rule: pictures on by default). */visionEnabled:cfg.ui_vision_enabled??(()=>{const running=[...instances.values()].find(i=>i.model===entry.model&&i.status==='ready');return running?!!running.vision:undefined;})(),visionDevice:cfg.ui_vision_device,resourceProfile:cfg.ui_resource_profile,lowRamMode:cfg.args?.includes('--resident-experts')?'resident':cfg.args?.includes('--mmap-experts')?'mmap':'auto'};})};}
|
||||
function installRoot(){const found=[preferences.installDir].find(d=>d&&existsSync(d));if(found)return realpathSync(found);mkdirSync(defaultInstall(),{recursive:true});return realpathSync(defaultInstall());}
|
||||
/* The settings file Strata Control and the installer read (control-runtime.mjs sets STRATA_SETTINGS_FILE to it): the app's data folder is written there. */
|
||||
function controlSettingsFile(){return process.env.STRATA_SETTINGS_FILE||join(dirname(componentsDirectory()),'app-settings.json');}
|
||||
/* One models folder for the app and the Terminal: the one changed last wins and the other settings file is updated (nothing is moved). */
|
||||
function shareDataFolder(){if(!packaged)return;try{let at=0;if(preferences.downloadDirectory)try{at=statSync(settingsPath).mtimeMs/1000;}catch{/* no file yet */}const found=syncDataFolder({appFile:controlSettingsFile(),terminalFile:terminalSettingsFile(),app:preferences.downloadDirectory?{folder:preferences.downloadDirectory,at}:null});if(found.from==='terminal'&&found.folder&&found.folder!==preferences.downloadDirectory){preferences={...preferences,downloadDirectory:found.folder};writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});}}catch{/* the shared setting is a convenience: never stop the app */}}
|
||||
function repo(){if(packaged)return installRoot();const path=realpathSync(preferences.repo);if(!existsSync(join(path,'setup.py'))||!existsSync(join(path,'tools/strata_mcp.py'))||!existsSync(join(path,'serve/server.py')))throw new Error('Choose the Strata repository folder containing setup.py and tools/strata_mcp.py.');return path;}
|
||||
function python(){return resolvePython({repo:preferences.repo});}
|
||||
function send(method,params,timeout=30000){return new Promise((resolve,reject)=>{const id=++seq;const alarm=setTimeout(()=>{pending.delete(id);reject(new Error('The native engine did not respond in time. Check engine logs.'));},timeout);pending.set(id,{resolve,reject,alarm});child.stdin.write(JSON.stringify({jsonrpc:'2.0',id,method,params})+'\n');});}
|
||||
@@ -80,7 +88,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
const folder=repo();
|
||||
buffer='';const py0=packaged?{command:control.control.python,args:[]}:python();
|
||||
const script=packaged?join(control.control.dir,'tools/strata_mcp.py'):join(folder,'tools/strata_mcp.py');
|
||||
const client=child=spawnProcess(py0.command,[...py0.args,script,...(packaged?['--root',folder,'--src',control.control.dir]:[])],{cwd:packaged?control.control.dir:folder,windowsHide:true,stdio:['pipe','pipe','pipe'],env:packaged?installerEnvironment(control.control,{installDir:folder}):{...process.env,PYTHONUNBUFFERED:'1',PYTHONIOENCODING:'utf-8'}});
|
||||
const client=child=spawnProcess(py0.command,[...py0.args,script,...(packaged?['--root',folder,'--src',control.control.dir]:[])],{cwd:packaged?control.control.dir:folder,windowsHide:true,stdio:['pipe','pipe','pipe'],env:packaged?installerEnvironment(control.control,{installDir:folder}):{...process.env,PYTHONUNBUFFERED:'1',PYTHONUTF8:'1',PYTHONIOENCODING:'utf-8'}});
|
||||
client.stdout.on('data',chunk=>{if(child!==client)return;buffer+=chunk.toString();let end;while((end=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,end);buffer=buffer.slice(end+1);let message;try{message=JSON.parse(line);}catch{continue;}const p=pending.get(message.id);if(p){clearTimeout(p.alarm);pending.delete(message.id);message.error?p.reject(new Error(message.error.message)):p.resolve(message.result);}}});
|
||||
child.stderr.on('data',chunk=>append('control',chunk.toString()));
|
||||
client.on('error',error=>{if(child===client){snapshot.error=error.message;disconnect();}});
|
||||
@@ -102,7 +110,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
const models=(list.models??[]).filter(m=>m.component_status!=='invalid'&&m.source_config).map(m=>({...(disk[m.id]??{}),model:m.id,config:basename(m.source_config),configPath:m.source_config,model_name:m.name,context:m.context_tokens??0,backend:m.backend,ready:m.component_status==='present'&&!(disk[m.id]?.partsMissing),images:'off',port:undefined,last_used:null}));
|
||||
const cores=os.cpus().length;
|
||||
return {summary:engine.usable?`Strata Control is ready; engine ${engine.engine.version} (${engine.engine.platform}-${engine.engine.backend}).`:(engine.notice??'No engine for this PC was found.'),
|
||||
installDir:list.install_dir,engine:{usable:engine.usable,notice:engine.notice,version:engine.engine?.version??null,backend:engine.backend,componentsDir:engine.components_dir,previous:engine.previous??null,pinned:engine.pinned??null,updateNotice:engine.update_notice??null},
|
||||
installDir:list.install_dir,engine:{usable:engine.usable,notice:engine.notice,wrongMake:engine.wrongMake===true,version:engine.engine?.version??null,backend:engine.backend,componentsDir:engine.components_dir,previous:engine.previous??null,pinned:engine.pinned??null,updateNotice:engine.update_notice??null},
|
||||
installed:{strata_folder:list.install_dir,data_folder:null,engine:engine.engine?{version:engine.engine.version,backend:engine.engine.backend,folder:engine.engine.root}:{folder:null,version:null},models,usage:installer.installed?.usage??null},
|
||||
hardware:installer.hardware?{...installer.hardware,cpu:{...(installer.hardware.cpu??{}),cores:installer.hardware.cpu?.cores??cores},gpus:installer.hardware.gpus??[]}:{cpu:{cores},ram_gb:Math.round(os.totalmem()/1024**3),gpus:[]},
|
||||
recommendation:installer.recommendation??null,installerError:installer.error??null,install:installer.install??null,running:null};
|
||||
@@ -120,7 +128,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
refreshing=(async()=>{try{const [catalog,status]=packaged?await (async()=>{const s=await tool('strata_status',{hardware:true});const c=await tool('strata_models').catch(error=>({families:[],contexts:[],recommendation:{family:null,model:null},context_note:'',error:error.message}));return [c,s];})():await Promise.all([tool('strata_models'),tool('strata_status',{hardware:true})]);let visionReader=false;if(packaged){try{const e=await control.engine();const root=e?.engine?.root??(e?.engine?.exe?dirname(e.engine.exe):'');visionReader=!!root&&['strata-vision','strata-vision.exe'].some(n=>existsSync(join(root,n)));}catch{visionReader=false;}}const markImages=c=>c&&Array.isArray(c.families)?{...c,families:c.families.map(f=>({...f,imagesCapable:!!f.images,images:!!f.images&&visionReader,sizes:(f.sizes??[]).map(s=>({...s,imagesCapable:!!s.images,images:!!s.images&&visionReader}))}))}:c;snapshot={available:true,repo:packaged?status.installDir:repo(),version:packaged?(control.version??null):await execute('git',['describe','--tags','--always'],repo()).then(s=>s.trim()).catch(()=>null),summary:status.summary,catalog:markEngine(packaged?markImages(catalog):catalog,status.engine),engineInfo:{version:status.engine?.version??null,source:status.engine?.source??null},visionReader:packaged?visionReader:true,installed:installedInfo(status.installed),hardware:status.hardware,error:null,install:status.install,external:status.running??null,...(packaged?{packaged:true,installDir:status.installDir,engine:status.engine,installerError:status.installerError}:{})};lastRead=Date.now();if(status.install?.running&&![...jobs.values()].some(j=>j.state==='running'))begin('install',j=>watchInstall(j,status.install));}catch(error){snapshot={...snapshot,available:false,error:error.message};lastRead=Date.now();}emit();return state();})().finally(()=>{refreshing=null;});const run=refreshing;if(!force&&snapshot.available){run.catch(()=>{});return state();}return run;
|
||||
}
|
||||
function configure({repository}){if(packaged){if(instances.size||[...jobs.values()].some(j=>j.state==='running'))throw new Error('Unload models and finish operations before changing the installation.');const next=realpathSync(String(repository));if(!statSync(next).isDirectory())throw new Error('Choose the folder that holds your Strata model configurations.');preferences={...preferences,installDir:next};packagedInstall=null;disconnect();writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});lastRead=0;return refresh(true);}if(instances.size||[...jobs.values()].some(j=>j.state==='running'))throw new Error('Unload models and finish operations before changing the installation.');const next=realpathSync(String(repository));const old=preferences;preferences={...preferences,repo:next};try{repo();}catch(e){preferences=old;throw e;}disconnect();writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});lastRead=0;return refresh(true);}
|
||||
function setDownloadDirectory(input){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before changing the folder.');if(typeof input!=='string'||!input.trim()||/[\r\n\0]/.test(input))throw new Error('Choose a model folder.');const path=realpathSync(input.trim());if(!statSync(path).isDirectory())throw new Error('Choose a folder, not a file.');accessSync(path,constants.R_OK|constants.W_OK);preferences={...preferences,downloadDirectory:path};writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});emit();return state();}
|
||||
function setDownloadDirectory(input,{insideJob=false}={}){if(!insideJob&&[...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before changing the folder.');if(typeof input!=='string'||!input.trim()||/[\r\n\0]/.test(input))throw new Error('Choose a model folder.');let path;try{path=realpathSync(input.trim());}catch{throw new Error('That folder does not exist. Check the path, or that the drive is connected.');}if(!statSync(path).isDirectory())throw new Error('Choose a folder, not a file.');/* A whole drive (D:\) is not filled with models: D:\Strata is made and used. */if(isDriveRoot(path)){const own=dataFolderFromPick(path).path;makeFolder(own);path=realpathSync(own);}try{accessSync(path,constants.R_OK|constants.W_OK);}catch{throw new Error('Strata cannot write in that folder. Choose a folder you can save files in.');}preferences={...preferences,downloadDirectory:path};/* the same choice for the installer and the Terminal: the Control settings file */if(packaged)rememberDataFolder(controlSettingsFile(),path,Date.now()/1000,true);writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});shareDataFolder();emit();return state();}
|
||||
function recordFile(model){if(!safeId(model))throw new Error('Choose an installed model.');const entry=snapshot.installed?.models?.find(i=>i.model===model);if(!entry)throw new Error('This model is not installed. Download and prepare it first.');const path=realpathSync(entry.configPath??join(repo(),entry.config));if(dirname(path)!==repo()||!/^strata-[a-z0-9_-]+\.json$/i.test(basename(path)))throw new Error('The model configuration is outside the Strata installation.');return {entry,path};}
|
||||
function record(model){const {entry,path}=recordFile(model);return {entry,path,config:readJson(path)};}
|
||||
/* The text file a power user edits by hand: where it is and what is wrong in it (run-config-check.mjs). */
|
||||
@@ -129,10 +137,12 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
/* A mistake in the file holds the start back with the line, instead of an engine that dies on it. Warnings (an unknown key) never do. */
|
||||
function assertSettingsFile(path){const check=checkRunConfig(readFileSync(path,'utf8'));if(!check.ok)throw new Error(`The settings file for this model has a mistake, so the model was not started. ${describeIssues(check.issues,'error').join(' ')}`);}
|
||||
function settings(model){const {config}=record(model);const values=Object.fromEntries(NATIVE_FIELDS.map(f=>[f.id,config[f.id]??null]));if(typeof values.aliases==='string')values.aliases=values.aliases.split(',').map(v=>v.trim()).filter(Boolean);values.sampling=config.sampling??{};values.args=config.args??[];values.env=publicNativeEnv(config.env??{});return {model,values,mcp:publicMcpServers(config.mcp_servers??config.mcpServers??{}),restartNeeded:true};}
|
||||
function save(model,{values,mcp}){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before saving native settings.');const {path,config}=record(model);if(busy(requests().filter(r=>instances.get(r.instanceId)?.model===model)))throw new Error('Finish this model’s requests before saving its settings.');const patch=validateNativeSettings(values??{}),next={...config};for(const field of nativeFields())if(field.supported===false&&patch[field.id]!=null&&JSON.stringify(patch[field.id])!==JSON.stringify(config[field.id]))throw new Error(`${field.label} needs a newer native Strata installation. Update it in Settings → About.`);for(const [key,value] of Object.entries(patch)){if(key==='env')next.env=restoreNativeEnv(value,config.env??{});else if(value===null)delete next[key];else next[key]=value;}if(mcp!==undefined){next.mcp_servers=validateMcpServers(mcp,config.mcp_servers??config.mcpServers??{});delete next.mcpServers;}copyFileSync(path,path+'.bak');chmodSync(path+'.bak',0o600);const temp=path+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,path);return settings(model);}
|
||||
function save(model,{values,mcp}){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before saving native settings.');const {path,config}=record(model);if(busy(requests().filter(r=>instances.get(r.instanceId)?.model===model)))throw new Error('Finish this model’s requests before saving its settings.');const patch=validateNativeSettings(values??{}),next={...config};for(const field of nativeFields())if(field.supported===false&&patch[field.id]!=null&&JSON.stringify(patch[field.id])!==JSON.stringify(config[field.id]))throw new Error(`${field.label} needs a newer native Strata installation. Update it in Settings → About.`);for(const [key,value] of Object.entries(patch)){if(key==='env')next.env=restoreNativeEnv(value,config.env??{});else if(value===null)delete next[key];else next[key]=value;}if('vram_reserve_mib' in patch){const base=captureUserArgs(next);const mine=setUserFlag(base,'--vram-reserve-mib',patch.vram_reserve_mib);if(mine.length)next[USER_KEY]=mine;else delete next[USER_KEY];next.args=mine.length&&patch.vram_reserve_mib!==null?mergeUserArgs(next.args??[],mine):setUserFlag(next.args??[],'--vram-reserve-mib',null);delete next.ui_reserve_applied;if(Array.isArray(next.args_written))next.args_written=[...next.args];}if(mcp!==undefined){next.mcp_servers=validateMcpServers(mcp,config.mcp_servers??config.mcpServers??{});delete next.mcpServers;}copyFileSync(path,path+'.bak');chmodSync(path+'.bak',0o600);const temp=path+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,path);return settings(model);}
|
||||
function previousEngine(){if(packaged)return !!snapshot.engine?.previous;try{const data=snapshot.installed?.data_dir??snapshot.installed?.data_folder;return [join(preferences.repo,'engine/.previous'),data&&join(data,'engine/.previous')].filter(Boolean).some(existsSync)&&readFileSync(join(preferences.repo,'setup.py'),'utf8').includes('--rollback-engine');}catch{return false;}}
|
||||
async function plan(args){await refresh();if(!snapshot.available)throw new Error(snapshot.error);return tool('strata_install',installArgs(args,false));}
|
||||
function installArgs(args,confirm){const allowed=['family','model','context','vision','kv','gpu','gpus','backend','low_ram','port','gguf_dir'];const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model);const existing=snapshot.installed?.models?.find(m=>m.model===size?.id);/* A model adopted from the person's own GGUF folder reports that folder's parent as its data folder: it is theirs, not Strata's, so a second preparation never uses it for the packs. */const data=nativeDataDirectory(args.data_dir??(args.gguf_dir?undefined:existing?.dataFolder)??preferences.downloadDirectory??modelsDirectory?.());return {...(data?{data_dir:data}:{}),...Object.fromEntries(allowed.filter(k=>args[k]!==undefined).map(k=>[k,args[k]])),/* Picture reading is only downloaded when it was asked for (Read pictures); the default stays the smaller download. */...(packaged&&args.vision===undefined?{vision:'no'}:{}),confirm};}
|
||||
/* The folder the person picked in the first-run chooser can be the one that holds their GGUF files (or a folder inside it). It is theirs, not a Strata data folder: packs are never made in it, and a Strata folder around it (<Strata>/models/<tag>) is used instead (#3507, #3424). */
|
||||
function ownFilesDataFolder(candidate,args){if(!candidate||args.data_dir!==undefined||!args.gguf_dir)return candidate;const same=(a,b)=>{const x=resolve(a),y=resolve(b);return process.platform==='win32'?x.toLowerCase()===y.toLowerCase():x===y;};const inside=(a,b)=>{const rel=relative(resolve(b),resolve(a));return rel===''||(!!rel&&!rel.startsWith('..')&&!isAbsolute(rel));};if(!same(candidate,args.gguf_dir)&&!inside(candidate,args.gguf_dir))return candidate;const models=dirname(resolve(args.gguf_dir)),top=dirname(models);return basename(models).toLowerCase()==='models'&&/strata/i.test(basename(top))?top:undefined;}
|
||||
function installArgs(args,confirm){const allowed=['family','model','context','vision','kv','gpu','gpus','backend','low_ram','port','gguf_dir'];const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model);const existing=snapshot.installed?.models?.find(m=>m.model===size?.id);/* A model adopted from the person's own GGUF folder reports that folder's parent as its data folder: it is theirs, not Strata's, so a second preparation never uses it for the packs. */if(args.gguf_dir===undefined&&existing?.ownGgufDir)args={...args,gguf_dir:existing.ownGgufDir};const data=nativeDataDirectory(ownFilesDataFolder(args.data_dir??(args.gguf_dir?undefined:existing?.dataFolder)??preferences.downloadDirectory??modelsDirectory?.(),args));if(data&&packaged&&!args.data_dir)rememberDataFolder(controlSettingsFile(),data);return {...(data?{data_dir:data}:{}),...Object.fromEntries(allowed.filter(k=>args[k]!==undefined).map(k=>[k,args[k]])),/* One card is "gpu", never "gpus" (the installer wants two or more there: "Gpus='0' is not valid", #3203). */...(/^\d{1,2}$/.test(String(args.gpus??''))?{gpu:Number(args.gpus),gpus:undefined}:{}),/* Picture reading is only downloaded when it was asked for (Read pictures); the default stays the smaller download. */...(packaged&&args.vision===undefined?{vision:'no'}:{}),confirm};}
|
||||
|
||||
function begin(kind,run,metadata={}){if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or cancel the current engine operation first.');const job={id:randomUUID(),kind,state:'running',startedAt:new Date().toISOString(),summary:'Starting…',result:null,error:null,...metadata};for(const [id,old] of jobs)if(jobs.size>=12&&old.state!=='running')jobs.delete(id);jobs.set(job.id,job);emit();void run(job).then(result=>{if(job.state==='running'){Object.assign(job,{state:'done',summary:result?.summary??'Complete',result,finishedAt:new Date().toISOString()});}}).catch(error=>{if(job.state==='running')Object.assign(job,{state:'error',error:error.message,summary:error.message,...(error.details?{details:error.details}:{}),finishedAt:new Date().toISOString()});}).finally(()=>{if(job.kind.startsWith('update')||job.kind==='rollback')disconnect();lastRead=0;emit();void refresh(true);});return state().jobs.find(j=>j.id===job.id);}
|
||||
async function watchInstall(job,current){job.summary=current.step??'Resuming preparation status…';job.result={install:current};while(job.state==='running'){await new Promise(r=>setTimeout(r,1500));const result=await tool('strata_install');job.summary=result.summary;job.result=result;emit();if(!result.install?.running){if(result.install?.exit_code!==0)throw new Error(result.install?.error??'Preparation did not finish. Retry to reuse completed files.');return {summary:'Model preparation complete. Load it from Models.'};}}}
|
||||
@@ -192,10 +202,24 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
function buildConfig(args,config){
|
||||
const vision=runtimeVision(config,args.vision);
|
||||
const visionPreferences=args.vision!==undefined?{ui_vision_enabled:args.vision!=='no',ui_vision_device:args.vision==='no'?config.ui_vision_device??'yes':args.vision}:{};
|
||||
const resources=args.resourceProfile?resourceSettings({...config,vision},snapshot.hardware,args.resourceProfile,modelArena(args.model)):null;
|
||||
/* The engine options the person set by hand (user_args, and what they wrote into "args" since the last write) go over everything the app generates, and an
|
||||
explicit GPU-memory reserve (the Expert setting, user_args, a value they wrote) is never replaced by the safety default (#3215 #3270 #3216). */
|
||||
const values0=args.values??{},cleared='vram_reserve_mib' in values0&&(values0.vram_reserve_mib===null||values0.vram_reserve_mib===''),base0=cleared?{...config,vram_reserve_mib:undefined,[USER_KEY]:setUserFlag(config[USER_KEY],'--vram-reserve-mib',null)}:config;
|
||||
let mine=captureUserArgs(base0);const asked=cleared?null:reserveChoice({...base0,[USER_KEY]:mine},snapshot.hardware,values0).asked;
|
||||
if(asked!==null)mine=setUserFlag(mine,'--vram-reserve-mib',asked);
|
||||
const resources=args.resourceProfile?resourceSettings({...base0,[USER_KEY]:mine,vision},snapshot.hardware,args.resourceProfile,modelArena(args.model),values0):null;
|
||||
const cfg={...config,...visionPreferences,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),host:'127.0.0.1',open_browser:false,...validateNativeSettings(args.values??{}),vision};
|
||||
if(args.gpus!==undefined){const detected=new Set((snapshot.hardware?.gpus??[]).map(g=>g.index));if(!Array.isArray(args.gpus)||!args.gpus.length||new Set(args.gpus).size!==args.gpus.length||args.gpus.some(g=>!Number.isInteger(g)||!detected.has(g)))throw new Error('Choose distinct detected GPUs. CPU-only text inference is not supported by this native adapter.');cfg.gpu=args.gpus.length===1?args.gpus[0]:args.gpus;cfg.layer_split=args.gpus.length>1?validateNativeSettings({layer_split:args.layerSplit??'auto'}).layer_split:'auto';if(cfg.layer_split!=='auto'&&cfg.layer_split.split(',').length!==args.gpus.length-1)throw new Error('Choose one layer split point between each pair of GPUs.');}
|
||||
if(cfg.vram_reserve_mib!==undefined&&cfg.vram_reserve_mib!==null){cfg.args=[...(cfg.args??[])];const at=cfg.args.indexOf('--vram-reserve-mib');if(at>=0)cfg.args.splice(at,2);cfg.args.push('--vram-reserve-mib',String(cfg.vram_reserve_mib));}if(cfg.vision){if(cfg.vision_tokens!=null)cfg.vision={...cfg.vision,max_tokens:cfg.vision_tokens};if(cfg.vision_min_tokens!=null)cfg.vision={...cfg.vision,min_tokens:cfg.vision_min_tokens};}cfg.mcp={...(cfg.mcp??{}),...(cfg.mcp_timeout_s!=null?{timeout_s:cfg.mcp_timeout_s}:{}),...(cfg.mcp_max_rounds!=null?{max_rounds:cfg.mcp_max_rounds}:{}),...(cfg.mcp_max_result_chars!=null?{max_result_chars:cfg.mcp_max_result_chars}:{})};
|
||||
/* The app's suggested cards only fill a gap: a model whose settings file already names its GPUs ("gpu": [1, 0], with "gpu_order": "as_given") keeps them (#3256). A card the person picked in the screen always wins. */
|
||||
const namedInFile=(Array.isArray(config.gpu)?config.gpu.length>0:config.gpu!==undefined&&config.gpu!==null&&config.gpu!=='')||!!config.gpu_roles;
|
||||
/* Roles chosen in the screen (Main / Helper / Off per card, gpu-roles.mjs): the card list is the roles' mirror - Helpers first, the Main card LAST, Off cards left out - with "gpu_roles" beside it. Every card Off is allowed: the server says plainly what the engine can do. */
|
||||
if(args.gpuRoles!==undefined){const make=snapshot.hardware?.engine_vendor,cards=(snapshot.hardware?.gpus??[]).filter(g=>!make||!g.vendor||g.vendor===make),named=parseRoles(args.gpuRoles);if(!named)throw new Error('Choose Main, Helper or Off for each graphics card, with one Main.');const known=new Set(cards.map(g=>g.index));if(Object.keys(named).some(k=>!known.has(Number(k))))throw new Error('Choose detected graphics cards.');const roles=Object.fromEntries(cards.map(g=>[g.index,named[g.index]??'off'])),m=mirrorRoles(roles,cards);for(const k of ['gpu','gpu_order','gpu_none','gpu_roles','gpu_cards','hip_ordinal'])delete cfg[k];Object.assign(cfg,m);const count=Array.isArray(m.gpu)?m.gpu.length:m.gpu_none?0:1;cfg.layer_split=count>1?validateNativeSettings({layer_split:args.layerSplit??'auto'}).layer_split:'auto';if(cfg.layer_split!=='auto'&&cfg.layer_split.split(',').length!==count-1)throw new Error('Choose one layer split point between each pair of GPUs.');}
|
||||
else if(args.gpus!==undefined&&!(args.gpusFromRecommendation&&namedInFile)){const detected=new Set((snapshot.hardware?.gpus??[]).map(g=>g.index));if(!Array.isArray(args.gpus)||!args.gpus.length||new Set(args.gpus).size!==args.gpus.length||args.gpus.some(g=>!Number.isInteger(g)||!detected.has(g)))throw new Error('Choose distinct detected GPUs. CPU-only text inference is not supported by this native adapter.');cfg.gpu=args.gpus.length===1?args.gpus[0]:args.gpus;for(const k of ['gpu_roles','gpu_cards','gpu_none'])delete cfg[k];delete cfg.hip_ordinal;cfg.layer_split=args.gpus.length>1?validateNativeSettings({layer_split:args.layerSplit??'auto'}).layer_split:'auto';if(cfg.layer_split!=='auto'&&cfg.layer_split.split(',').length!==args.gpus.length-1)throw new Error('Choose one layer split point between each pair of GPUs.');}
|
||||
if(cfg.vram_reserve_mib!==undefined&&cfg.vram_reserve_mib!==null){cfg.args=[...(cfg.args??[])];const at=cfg.args.indexOf('--vram-reserve-mib');if(at>=0)cfg.args.splice(at,2);cfg.args.push('--vram-reserve-mib',String(cfg.vram_reserve_mib));}
|
||||
if(mine.length){cfg.args=mergeUserArgs(cfg.args??[],mine);cfg[USER_KEY]=mine;}else delete cfg[USER_KEY];
|
||||
if(cfg.vision){if(cfg.vision_tokens!=null)cfg.vision={...cfg.vision,max_tokens:cfg.vision_tokens};if(cfg.vision_min_tokens!=null)cfg.vision={...cfg.vision,min_tokens:cfg.vision_min_tokens};}cfg.mcp={...(cfg.mcp??{}),...(cfg.mcp_timeout_s!=null?{timeout_s:cfg.mcp_timeout_s}:{}),...(cfg.mcp_max_rounds!=null?{max_rounds:cfg.mcp_max_rounds}:{}),...(cfg.mcp_max_result_chars!=null?{max_result_chars:cfg.mcp_max_result_chars}:{})};
|
||||
/* The model's own choices win over what setup wrote into the arguments: "Draft layer (MTP)" off leaves --mtp out of every start, and a picture reading that is off leaves --vision out (setup writes both again whenever it runs; the saved settings are applied here, on every load). */
|
||||
if(cfg.mtp===false&&Array.isArray(cfg.args))cfg.args=withoutDraftLayer(cfg.args);
|
||||
if(!cfg.vision&&Array.isArray(cfg.args))cfg.args=cfg.args.filter(a=>a!=='--vision');
|
||||
for(const arg of cfg.args??[])if(arg.endsWith('.gguf')&&!existsSync(resolve(repo(),arg)))throw new Error('An engine argument names a missing GGUF. Restore the saved arguments or repair the files.');
|
||||
/* The app's own choice of request slots (4, fewer where the card has no room) is part of the configuration, so a tune measures the engine a load will start: slots take VRAM from the expert cache. */
|
||||
const parallelChoice=packaged?chooseParallel({config:cfg,values:args.values??{},hardware:snapshot.hardware?.gpus?.length?snapshot.hardware:{gpus:hardwareProbe()??[]}}):null;
|
||||
@@ -258,14 +282,14 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
try{await startWith(retryConfig);append(id,'Strata Control reports the engine is ready (second try).');}
|
||||
catch(second){const said2=humanStartError(second);instance.status='error';instance.error=said2;/* the first stop and the second try stay in job.details; the pop-up shows the last stop once */emit();throw new Error(instance.error);}}}
|
||||
} else {
|
||||
proc=spawnProcess(py,[join(repo(),'serve/server.py'),'--engine','strata','--config',configPath,'--port',String(cfg.port),'--host','127.0.0.1'],{cwd:repo(),detached:process.platform!=='win32',stdio:['ignore','pipe','pipe'],env:{...process.env,PYTHONUNBUFFERED:'1',PYTHONIOENCODING:'utf-8',STRATA_API_KEY:''}});
|
||||
proc=spawnProcess(py,[join(repo(),'serve/server.py'),'--engine','strata','--config',configPath,'--port',String(cfg.port),'--host','127.0.0.1'],{cwd:repo(),detached:process.platform!=='win32',stdio:['ignore','pipe','pipe'],env:{...process.env,PYTHONUNBUFFERED:'1',PYTHONUTF8:'1',PYTHONIOENCODING:'utf-8',STRATA_API_KEY:''}});
|
||||
instances.set(id,instance);processes.set(id,proc);if(!defaultId||!args.multiple||wasDefault)defaultId=id;
|
||||
for(const stream of [proc.stdout,proc.stderr])stream.on('data',chunk=>{const lines=chunk.toString().split('\n').filter(Boolean);lines.forEach(line=>append(id,line.replaceAll(key,'[redacted]')));job.summary=lines.at(-1)??job.summary;emit();});
|
||||
proc.on('error',error=>{instance.status='error';instance.error=error.message;emit();});proc.on('exit',code=>{if(instances.has(id)&&instance.status!=='unloading'){instance.status='error';instance.error=`Engine stopped${code===null?'':` (exit ${code})`}. Open diagnostics or reload.`;emit();}});emit();
|
||||
}
|
||||
const deadline=Date.now()+300000;while(Date.now()<deadline&&job.state==='running'){
|
||||
if(instance.status==='error')throw new Error(instance.error);
|
||||
try{const response=await fetcher(`http://127.0.0.1:${instance.port}/health`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(job.state!=='running'){await unload(id);return;}if(response.ok){instance.status='ready';instance.phase='idle';if(resources||args.vision!==undefined)writeModelConfig(record(args.model).path,{...config,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),...visionPreferences});emit();ensurePoll();instance.parallelNote=parallelNote;return {summary:retried?`${instance.label} is ready, after a second try: ${retried.words}`:`${instance.label} is ready.${parallelNote?' '+parallelNote:''}`,instanceId:id};}}catch{}
|
||||
try{const response=await fetcher(`http://127.0.0.1:${instance.port}/health`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(job.state!=='running'){await unload(id);return;}if(response.ok){instance.status='ready';instance.phase='idle';if(resources||args.vision!==undefined||JSON.stringify(cfg[USER_KEY]??null)!==JSON.stringify(config[USER_KEY]??null))writeModelConfig(record(args.model).path,{...config,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),...visionPreferences,...(cfg[USER_KEY]?{[USER_KEY]:cfg[USER_KEY]}:{})});emit();ensurePoll();instance.parallelNote=parallelNote;return {summary:retried?`${instance.label} is ready, after a second try: ${retried.words}`:`${instance.label} is ready.${parallelNote?' '+parallelNote:''}`,instanceId:id};}}catch{}
|
||||
await new Promise(resolve=>setTimeout(resolve,1200));
|
||||
}
|
||||
if(job.state!=='running'){await unload(id);return;}
|
||||
@@ -285,7 +309,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
else if(job.kind==='update-engine'){if(!snapshot.installed?.models?.length)throw new Error('Prepare a model first. Its installer downloads the matching engine.');argv=[join(folder,'setup.py'),'--update'];}
|
||||
else if(job.kind==='update-strata'){const status=await execute('git',['status','--porcelain'],folder);if(status.trim())throw new Error('This Strata checkout has local edits. Save or commit them before updating.');const remote=await execute('git',['remote','get-url','origin'],folder);if(!/^https:\/\/github\.com\/Niko1221\/Strata(?:\.git)?\s*$/.test(remote))throw new Error('The native installation must use the official Strata repository to update here.');await execute('git',['pull','--ff-only'],folder);argv=[join(folder,'setup.py'),'--update'];}
|
||||
else throw new Error('Unsupported native operation.');
|
||||
return new Promise((resolve,reject)=>{const proc=spawnProcess(command,[...prefix,...argv],{cwd:folder,windowsHide:true,detached:process.platform!=='win32',stdio:['ignore','pipe','pipe'],env:{...process.env,PYTHONUNBUFFERED:'1'}});job.process=proc;for(const stream of [proc.stdout,proc.stderr])stream.on('data',chunk=>{chunk.toString().split('\n').filter(Boolean).forEach(line=>append(job.id,line));job.summary=chunk.toString().trim().slice(-500);emit();});proc.on('error',reject);proc.on('exit',code=>{delete job.process;code===0?resolve({summary:job.kind==='calibrate'?'Calibration complete. Native settings were saved.':'Native installation updated.'}):reject(new Error(`Operation stopped (exit ${code}). Open diagnostics for details.`));});});
|
||||
return new Promise((resolve,reject)=>{const proc=spawnProcess(command,[...prefix,...argv],{cwd:folder,windowsHide:true,detached:process.platform!=='win32',stdio:['ignore','pipe','pipe'],env:{...process.env,PYTHONUNBUFFERED:'1',PYTHONUTF8:'1',PYTHONIOENCODING:'utf-8'}});job.process=proc;for(const stream of [proc.stdout,proc.stderr])stream.on('data',chunk=>{chunk.toString().split('\n').filter(Boolean).forEach(line=>append(job.id,line));job.summary=chunk.toString().trim().slice(-500);emit();});proc.on('error',reject);proc.on('exit',code=>{delete job.process;code===0?resolve({summary:job.kind==='calibrate'?'Calibration complete. Native settings were saved.':'Native installation updated.'}):reject(new Error(`Operation stopped (exit ${code}). Open diagnostics for details.`));});});
|
||||
}
|
||||
/* Packaged app: engine and Control updates and the engine rollback go through Control's explicit update step (`strata update`,
|
||||
`strata components rollback`), never through git, setup.py or a download in this process. The app itself is updated by the
|
||||
@@ -318,7 +342,8 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
}
|
||||
function updateSummary(done){const r=(done.results??[]).filter(x=>x.status==='updated').map(x=>x.component==='engine'?'The engine is updated and ready.':'Strata is updated. It takes effect the next time you open the app.');return r.length?r.join(' '):'Everything is already up to date.';}
|
||||
function execute(cmd,args,cwd){return new Promise((resolve,reject)=>{let output='';const process=spawnProcess(cmd,args,{cwd,stdio:['ignore','pipe','pipe']});process.stdout.on('data',c=>{output+=c;if(output.length>100000)process.kill();});process.stderr.on('data',c=>append('update',c.toString()));process.on('error',reject);process.on('exit',code=>code===0?resolve(output):reject(new Error('Git could not update this installation. Open diagnostics.')));});}
|
||||
function writeModelConfig(path,next){copyFileSync(path,path+'.bak');chmodSync(path+'.bak',0o600);const temp=path+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,path);}
|
||||
/* Every write of a model's run config keeps the person's options: those they wrote into "args" or "user_args" since the file was last written are read from the file itself, go over what the caller built, and "args_written" is refreshed. */
|
||||
function writeModelConfig(path,next){const mine=mergeUserArgs(captureUserArgs(readJson(path)),next[USER_KEY]??[]);next=stampedUserArgs(next,mine);copyFileSync(path,path+'.bak');chmodSync(path+'.bak',0o600);const temp=path+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,path);}
|
||||
async function tune(job,args){
|
||||
await refresh(true);if(job.state!=='running')return;
|
||||
const chosen=record(args.model);resourceSettings(chosen.config,snapshot.hardware,args.profile,modelArena(args.model));
|
||||
@@ -358,10 +383,104 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
return autotuner.start(job,job.kind==='recheck'?'recheck':'tune',{configPath:path,presetId:args.presetId,mode:args.mode,use:args.use,goal:room?'room':'speed',leaveRam:room?20:0,allowSmallDifferences:args.allowSmallDifferences===true,name:args.name},{onProgress:p=>{job.summary=p.text??job.summary;emit();}});
|
||||
}
|
||||
function operation(kind,args={}){if(!['install','load','benchmark','tune','autotune','recheck','calibrate','update-engine','update-strata','rollback','remove'].includes(kind))throw new Error('Choose a supported native operation.');if(!snapshot.available)throw new Error(snapshot.error??'Connect the native Strata installation first.');if(kind==='install'||kind==='load'){const refusal=engineRefusal(snapshot.catalog,args.model,snapshot.engineInfo);if(refusal)throw new Error(refusal);}return begin(kind,job=>kind==='install'?install(job,args):kind==='load'?loadEngine(job,args):kind==='benchmark'?benchmark(job,args):kind==='tune'?tune(job,args):kind==='autotune'||kind==='recheck'?autotune(job,args):kind==='remove'?removeModel(job,args):program(job,args),{guided:args.guided===true,model:args.model,...(args.family?{family:args.family}:{})});}
|
||||
async function cancel(id){const job=jobs.get(id);if(!job||job.state!=='running')throw new Error('This operation is no longer running.');if(job.kind==='install'){job.engineAbort?.abort();await tool('strata_install',{cancel:true});}else if(job.abort)job.abort.abort();else if(job.process){if(!['tune','calibrate','autotune','recheck'].includes(job.kind))throw new Error('File updates cannot be cancelled safely while files are being replaced.');job.state='cancelled';await stopProcess(job.process);}job.state='cancelled';job.summary=job.kind==='install'?'Stopped. Completed downloads are kept.':'Stopped.';emit();return state();}
|
||||
/* ---- The models folder card (0.2.2): what a picked folder means, and the three choices. The copying is Control's (strata models move). ---- */
|
||||
const currentModelsFolder=()=>preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder)??null;
|
||||
const moveFile=()=>join(directory,'models-move.cancel');
|
||||
const folderWords=error=>({message:error.message,details:error.details||''});
|
||||
/** A picked folder as the data folder: a whole drive (D:\) becomes D:\Strata, and the person is told (the models then live in D:\Strata\Models). */
|
||||
function pickedModelsFolder(input){
|
||||
if(typeof input!=='string'||!input.trim()||/[\r\n\0]/.test(input))throw new Error('Choose a models folder.');
|
||||
const picked=resolve(input.trim()),target=dataFolderFromPick(picked);
|
||||
return {picked,folder:target.path,wholeDrive:!!target.note,shown:target.note?join(target.path,'Models'):target.path};
|
||||
}
|
||||
async function planModelsFolder(input){
|
||||
if(!packaged)throw new Error('Changing the models folder is part of the Strata app.');
|
||||
const now=currentModelsFolder();if(!now)throw new Error('Strata is still reading your models folder. Try again in a moment.');
|
||||
const pick=pickedModelsFolder(input),plan=await control.planModelsMove(now,pick.folder);
|
||||
return {...plan,...pick,current:now,running:instances.size,busy:[...jobs.values()].some(j=>j.state==='running')};
|
||||
}
|
||||
/* "Start empty": the old models are no longer listed. Their run configs are moved (not deleted) into old-models beside the others; the model files are not touched. */
|
||||
function setAsideConfigs(old){
|
||||
const folder=repo(),kept=join(folder,'old-models'),aside=[];
|
||||
const under=value=>typeof value==='string'&&[ 'models','packs','mtp' ].some(item=>{const base=join(old,item).toLowerCase();const v=value.toLowerCase();return v===base||v.startsWith(base+sep)||v.startsWith(base+'/');});
|
||||
const scan=value=>Array.isArray(value)?value.some(scan):value&&typeof value==='object'?Object.values(value).some(scan):under(value);
|
||||
for(const name of readdirSync(folder)){
|
||||
if(!/^strata-[a-z0-9_-]+\.json$/i.test(name)||name.endsWith('.shared-settings.json'))continue;
|
||||
if(!scan(readJson(join(folder,name))))continue;
|
||||
mkdirSync(kept,{recursive:true});renameSync(join(folder,name),join(kept,name));aside.push(name);
|
||||
}
|
||||
return aside;
|
||||
}
|
||||
async function startModelsFolder({folder:input,choice,stopRunning=false}){
|
||||
if(!packaged)throw new Error('Changing the models folder is part of the Strata app.');
|
||||
if(!['move','keep','empty'].includes(choice))throw new Error('Choose what to do with your models.');
|
||||
if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or stop the download to change the folder.');
|
||||
const plan=await planModelsFolder(input);
|
||||
if(plan.issue)throw Object.assign(new Error(plan.issue.message),{details:plan.issue.details,code:plan.issue.code});
|
||||
if(!plan.choices[choice].possible)throw Object.assign(new Error(plan.choices[choice].reason||'That choice is not possible here.'),{code:'not_possible'});
|
||||
if(instances.size){
|
||||
if(!stopRunning)throw Object.assign(new Error('A model is running. Stop it first, then change the folder.'),{code:'running'});
|
||||
for(const id of [...instances.keys()])await unload(id);
|
||||
}
|
||||
const old=plan.current,target=plan.folder;
|
||||
if(choice!=='move'){
|
||||
makeFolder(target);
|
||||
const aside=choice==='empty'?setAsideConfigs(old):[];
|
||||
setDownloadDirectory(target);lastRead=0;await refresh(true);
|
||||
return {done:true,choice,folder:target,oldFolder:old,setAside:aside};
|
||||
}
|
||||
return runModelsMove(old,target,(from,to,options)=>control.moveModels(from,to,options));
|
||||
}
|
||||
/* The move as a job (the progress dialog reads it). `run` is moveModels for a new move, resumeModelsMove for "Finish it". */
|
||||
function runModelsMove(old,target,run){
|
||||
rmSync(moveFile(),{force:true});
|
||||
return begin('models-move',async job=>{
|
||||
job.cancelFile=moveFile();job.summary='Starting…';
|
||||
try{
|
||||
const result=await run(old,target,{installDir:repo(),cancelFile:moveFile(),onEvent:event=>{
|
||||
if(event.event!=='progress')return;
|
||||
job.progress={done:event.done,total:event.total,file:event.file,group:event.group,index:event.index,count:event.count,secondsLeft:event.seconds_left||0,sameDrive:!!event.same_drive};
|
||||
job.summary=`${Math.round(event.done/1e8)/10} of ${Math.round(event.total/1e8)/10} GB`;emit();}});
|
||||
const finished={done:!!result.done,stopped:!!result.stopped,moved:result.moved??[],remaining:result.remaining??[],bytes:result.bytes_moved??0,total:result.total_bytes??0,from:old,to:target,files:result.files_moved??0,sameDrive:!!result.same_drive};
|
||||
/* Only a whole move changes the folder the app uses: a stopped move leaves it where it was, and every model works where it is. */
|
||||
if(result.done){setDownloadDirectory(target,{insideJob:true});finished.folder=target;}
|
||||
job.progress=null;
|
||||
return {summary:result.done?'Your models are moved.':'Moving stopped. Nothing was lost.',...finished};
|
||||
}catch(error){
|
||||
const left=error.output;
|
||||
job.result={failed:true,code:error.code,moved:left?.moved??[],remaining:left?.remaining??[],bytes:left?.bytes_moved??0,total:left?.total_bytes??0,from:old,to:target};
|
||||
job.progress=null;
|
||||
throw error;
|
||||
}finally{rmSync(moveFile(),{force:true});}
|
||||
},{family:undefined});
|
||||
}
|
||||
/* "A move was interrupted. Finish it or leave it": the next start finds the unfinished move (Control keeps a note in the models folder in use). */
|
||||
async function interruptedMove(){
|
||||
if(!packaged)return null;
|
||||
const now=currentModelsFolder();if(!now)return null;
|
||||
if([...jobs.values()].some(j=>j.kind==='models-move'&&j.state==='running'))return null;
|
||||
try{return await control.interruptedModelsMove(now);}catch{return null;}
|
||||
}
|
||||
async function finishInterruptedMove({stopRunning=false}={}){
|
||||
const st=await interruptedMove();if(!st)throw new Error('No move was interrupted.');
|
||||
if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or stop the download to change the folder.');
|
||||
if(instances.size){
|
||||
if(!stopRunning)throw Object.assign(new Error('A model is running. Stop it first, then change the folder.'),{code:'running'});
|
||||
for(const id of [...instances.keys()])await unload(id);
|
||||
}
|
||||
return runModelsMove(st.from,st.to,(from,to,options)=>control.resumeModelsMove(from,options));
|
||||
}
|
||||
async function leaveInterruptedMove(){
|
||||
const st=await interruptedMove();if(!st)throw new Error('No move was interrupted.');
|
||||
if([...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish or stop the download to change the folder.');
|
||||
const result=await control.leaveModelsMove(st.from,{installDir:repo()});
|
||||
lastRead=0;await refresh(true);
|
||||
return {done:true,folder:st.from,partial:st.to,removed:result.removed??0,restored:result.restored??0};
|
||||
}
|
||||
async function cancel(id){const job=jobs.get(id);if(!job||job.state!=='running')throw new Error('This operation is no longer running.');if(job.kind==='install'){job.engineAbort?.abort();await tool('strata_install',{cancel:true});}else if(job.kind==='models-move'){/* a safe stop: the copy in flight is dropped, files already moved stay */try{writeFileSync(job.cancelFile,'stop');}catch{/* the run ends anyway */}job.summary='Stopping…';emit();return state();}else if(job.abort)job.abort.abort();else if(job.process){if(!['tune','calibrate','autotune','recheck'].includes(job.kind))throw new Error('File updates cannot be cancelled safely while files are being replaced.');job.state='cancelled';await stopProcess(job.process);}job.state='cancelled';job.summary=job.kind==='install'?'Stopped. Completed downloads are kept.':'Stopped.';emit();return state();}
|
||||
async function close(){closing=true;clearInterval(timer);const installing=packaged&&child&&[...jobs.values()].some(j=>j.kind==='install'&&j.state==='running');if(installing)await send('tools/call',{name:'strata_install',arguments:{cancel:true}},15000).catch(()=>{});const operations=[];for(const job of jobs.values()){job.abort?.abort();if(job.process)operations.push(stopProcess(job.process));if(job.state==='running')job.state='cancelled';}await Promise.all(operations);await Promise.all([...instances.keys()].map(id=>unload(id,true).catch(()=>{})));disconnect();}
|
||||
// Remove nonserializable process references from public jobs.
|
||||
const publicState=()=>{const value=state();value.jobs=value.jobs.map(({process,engineAbort,rawResult,...job})=>job);return value;};
|
||||
async function localPreview(path){if(!packaged)throw new Error('Checking local model files is part of the Strata app.');if(typeof path!=='string'||!path.trim()||[...path].some(c=>c.charCodeAt(0)<32))throw new Error('Choose a folder.');return control.importPlan(path.trim());}
|
||||
return {state:publicState,tuneStatus,tuneEffective,tuneOff,tunePresets:{list:()=>autotuner.list(),action:(kind,id,extra)=>autotuner.action(kind,id,extra),undoFor:async args=>autotuner.undoFor(tuneConfig(args).path)},refresh,localPreview,configure,setDownloadDirectory,plan,settings,save,settingsFile,openSettingsFile,operation,cancel,unload,resolveInstance,poll,logs:()=>logs.slice(),benchmarkReport:()=>readJson(join(directory,'native-benchmark.json'),null),setDefault(id){defaultId=resolveInstance(id).id;emit();return publicState();},hasLive:()=>[...instances.values()].some(i=>i.status==='ready'),readyInstances:()=>[...instances.values()].filter(i=>i.status==='ready'),close};
|
||||
return {state:publicState,tuneStatus,tuneEffective,tuneOff,tunePresets:{list:()=>autotuner.list(),action:(kind,id,extra)=>autotuner.action(kind,id,extra),undoFor:async args=>autotuner.undoFor(tuneConfig(args).path)},refresh,localPreview,configure,setDownloadDirectory,planModelsFolder,startModelsFolder,interruptedMove,finishInterruptedMove,leaveInterruptedMove,plan,settings,save,settingsFile,openSettingsFile,operation,cancel,unload,resolveInstance,poll,logs:()=>logs.slice(),benchmarkReport:()=>readJson(join(directory,'native-benchmark.json'),null),setDefault(id){defaultId=resolveInstance(id).id;emit();return publicState();},hasLive:()=>[...instances.values()].some(i=>i.status==='ready'),readyInstances:()=>[...instances.values()].filter(i=>i.status==='ready'),close};
|
||||
}
|
||||
@@ -41,6 +41,12 @@ export const CACHE_LOSS_SHARE = 0.2;
|
||||
* about 6.9 GiB goes to everything else; a second card in a layer split about 2 GiB).
|
||||
*/
|
||||
export const FIRST_CARD_OTHER_GB = 6.9;
|
||||
/**
|
||||
* The draft layer (MTP) is part of that 6.9: about 1 GB of graphics memory (786 MiB of files and the draft head's ~218 MiB,
|
||||
* phaseA-tests/notes-021-settings.md). With "Draft layer (MTP)" off in Expert settings (`"mtp": false`) the engine does not load it, so
|
||||
* the card has that much more cache and the slots may use it.
|
||||
*/
|
||||
export const MTP_VRAM_GB = 1.0;
|
||||
export const NEXT_CARD_OTHER_GB = 2.0;
|
||||
/**
|
||||
* `--batch-mtp` lets each slot verify draft tokens. Where it pays and where it does not (all measured):
|
||||
@@ -77,7 +83,7 @@ export function engineFacts(config = {}) {
|
||||
const gpus = Array.isArray(config.gpu) ? config.gpu : config.gpu === undefined || config.gpu === null ? [0] : [config.gpu];
|
||||
return {
|
||||
args, context, kv, family, gpus,
|
||||
mtp: args.includes('--mtp'),
|
||||
mtp: args.includes('--mtp') && config.mtp !== false,
|
||||
batchMtp: args.includes('--batch-mtp'),
|
||||
explicitBatch: args.includes('--batch') || args.includes('--slots'),
|
||||
};
|
||||
@@ -107,7 +113,7 @@ export function chooseParallel({ config = {}, values = {}, hardware = null, requ
|
||||
if (!cards.length) return { slots: PARALLEL_POLICY === 'fixed' ? requested : 1, batchMtp: false, explicit: false, requested, note: null, slotGb: slot };
|
||||
const total = cards.reduce((a, b) => a + b, 0);
|
||||
const oneCard = facts.gpus.length === 1 && cards.length === 1;
|
||||
const soloCache = Math.max(0, total - FIRST_CARD_OTHER_GB - NEXT_CARD_OTHER_GB * (cards.length - 1));
|
||||
const soloCache = Math.max(0, total - (FIRST_CARD_OTHER_GB - (facts.mtp ? 0 : MTP_VRAM_GB)) - NEXT_CARD_OTHER_GB * (cards.length - 1));
|
||||
let slots = requested;
|
||||
if (PARALLEL_POLICY === 'fit') while (slots > 1 && slots * slot > CACHE_LOSS_SHARE * soloCache) slots -= 1;
|
||||
const note = slots >= requested ? null
|
||||
|
||||
@@ -1,10 +1,62 @@
|
||||
import {USER_KEY,flagValue} from './native-user-args.mjs';
|
||||
|
||||
/** The engine's own default reserve, and the one setup writes for pictures read on the GPU: neither is a choice of the person. */
|
||||
const ENGINE_DEFAULT_RESERVE_MIB=700;
|
||||
/** A card that drives no display needs little beside the engine; the engine keeps the last few hundred MiB for allocations it makes after the cache. */
|
||||
const COMPUTE_ONLY_RESERVE_MIB=512;
|
||||
|
||||
const wholeMib=v=>v!==null&&v!==undefined&&v!==''&&Number.isFinite(Number(v))&&Number(v)>=0?Math.trunc(Number(v)):null;
|
||||
|
||||
/** The graphics memory the person asked to keep free, or null when they asked for nothing (#3215 #3270): the Expert setting of this load,
|
||||
* "user_args", the setting saved for the model ("vram_reserve_mib", unless it is the value the app itself wrote last), or a value written by hand into "args"
|
||||
* (not the engine's default 700, and not the one the app wrote). Any number counts, 0 too. */
|
||||
function candidateReserve(config,values={},legacyDefaults=new Set()){
|
||||
if(values&&typeof values==='object'&&'vram_reserve_mib' in values){const v=wholeMib(values.vram_reserve_mib);if(v!==null)return v;}
|
||||
const user=wholeMib(flagValue(config?.[USER_KEY],'--vram-reserve-mib'));if(user!==null)return user;
|
||||
const applied=config?.ui_reserve_applied,legacy=applied===undefined&&config?.ui_resource_baseline!==undefined; // 0.2.0-beta wrote the key itself, without saying so
|
||||
const key=wholeMib(config?.vram_reserve_mib),arg=wholeMib(flagValue(config?.args,'--vram-reserve-mib'));
|
||||
const keyOk=key!==null&&key!==applied,argOk=arg!==null&&arg!==applied&&arg!==ENGINE_DEFAULT_RESERVE_MIB;
|
||||
if(legacy)return [arg,key].map((n,i)=>(i===0?argOk:keyOk)?n:null).find(n=>n!==null&&!(n===1536||n===2048||legacyDefaults.has(n)))??null; // a 0.2.0-beta file: the app wrote both, so what is not one of its own numbers is the person's
|
||||
return keyOk?key:argOk?arg:null;
|
||||
}
|
||||
|
||||
/** What the app keeps free when the person asked for nothing: [work, performance]. A card that drives a display (or is not known to be headless) gets no
|
||||
* less than 1536 MiB on a card of 16 GB or less, 1024 above, 2048 with pictures (the engine warns below ~1-1.5 GiB of free VRAM: the driver then pages GPU memory to
|
||||
* system RAM and decoding falls to a few tokens a second, #2065); "work" also keeps a fifth of the card. A card the probe says drives no display keeps 512 MiB
|
||||
* (700 with pictures) on either profile. */
|
||||
export function defaultReserves(config,hardware,baselineReserve=0){
|
||||
const placement=Array.isArray(config.gpu)?config.gpu:[config.gpu??0];
|
||||
const placed=(hardware?.gpus??[]).filter(g=>placement.includes(g.index)),gpuMemory=placed.map(g=>g.vram_gb).filter(n=>Number.isFinite(n)&&n>0);
|
||||
const headless=placed.length>0&&placed.every(computeOnly); // no display on the card (the desktop is on an integrated GPU): no desktop to keep room for
|
||||
const floor=headless?(config.vision?ENGINE_DEFAULT_RESERVE_MIB:COMPUTE_ONLY_RESERVE_MIB):gpuMemory.length&&Math.min(...gpuMemory)>16?1024:1536;
|
||||
const safety=headless?floor:Math.max(config.vision?2048:floor,baselineReserve);
|
||||
/* A card that shares the PC's memory (Strix Halo, other APUs) has no memory of its own to keep a fifth of: that fifth is RAM, and "Leave 20% for my work" already keeps it
|
||||
* through the RAM budget. A fifth of the shared pool here held back 16 GB of expert cache for nothing (#3510); the display floor stays. */
|
||||
const shared=placed.length>0&&placed.some(g=>g.uma===true||g.unified===true);
|
||||
return {work:gpuMemory.length&&!headless&&!shared?Math.max(safety,Math.floor(Math.min(...gpuMemory)*1024*.2)):safety,performance:safety};
|
||||
}
|
||||
|
||||
/** The reserve the person asked for (null: nothing) and the default for each profile. A 0.2.0-beta file has the key and the same number in "args" with no note that the
|
||||
* app wrote them: a number that is one of the app's two defaults is the app's, any other is the person's. */
|
||||
export function reserveChoice(config,hardware,values={},baselineReserve=0){
|
||||
const defaults=defaultReserves(config,hardware,config.ui_resource_baseline?.reserve??baselineReserve);
|
||||
const asked=candidateReserve(config,values,new Set([defaults.work,defaults.performance]));
|
||||
return {asked,defaults};
|
||||
}
|
||||
export const explicitReserve=(config,values={},hardware=null)=>reserveChoice(config,hardware,values).asked;
|
||||
|
||||
/** True when the probe says this card drives no display: only compute runs on it. Unknown counts as a display card. */
|
||||
const computeOnly=g=>g?.drives_display===false;
|
||||
|
||||
/** Headroom is a worker/memory budget, not a hard utilisation throttle. */
|
||||
export function resourceSettings(config,hardware,profile,model={}){
|
||||
export function resourceSettings(config,hardware,profile,model={},values={}){
|
||||
if(!['work','performance'].includes(profile))throw new Error('Choose a resource preference.');
|
||||
const args=[...(config.args??[])];
|
||||
const remove=flag=>{for(let at=args.indexOf(flag);at>=0;at=args.indexOf(flag))args.splice(at,2);};
|
||||
const value=flag=>{const at=args.indexOf(flag);return at>=0?Number(args[at+1])||0:0;};
|
||||
const baseline=config.ui_resource_baseline??{workers:value('--pool-workers'),reserve:Math.max(Number(config.vram_reserve_mib)||0,value('--vram-reserve-mib'))};
|
||||
const first=reserveChoice(config,hardware,values).asked;
|
||||
const baseline=config.ui_resource_baseline??{workers:value('--pool-workers'),reserve:first!==null?0:Math.max(Number(config.vram_reserve_mib)||0,value('--vram-reserve-mib'))};
|
||||
const {asked,defaults}=reserveChoice(config,hardware,values,baseline.reserve);
|
||||
if(profile==='performance'&&config.ui_resource_profile==='work'){
|
||||
remove('--pool-workers');if(baseline.workers>0)args.push('--pool-workers',String(baseline.workers));
|
||||
}
|
||||
@@ -15,13 +67,8 @@ export function resourceSettings(config,hardware,profile,model={}){
|
||||
args.push('--pool-workers',String(previous>0?Math.min(previous,workers):workers));
|
||||
}
|
||||
// Preserve calibrated worker counts and the installation's safety/vision reserve.
|
||||
const placement=Array.isArray(config.gpu)?config.gpu:[config.gpu??0];
|
||||
const gpuMemory=(hardware?.gpus??[]).filter(g=>placement.includes(g.index)).map(g=>g.vram_gb).filter(n=>Number.isFinite(n)&&n>0);
|
||||
/* The engine warns below ~1-1.5 GiB of free VRAM (the driver then pages GPU memory to system RAM and decoding falls to a few tokens a
|
||||
second, #2065), so neither profile may reserve less: 1536 MiB on a card of 16 GB or less, 1024 above; with pictures 2048. */
|
||||
const floor=gpuMemory.length&&Math.min(...gpuMemory)>16?1024:1536;
|
||||
const safety=Math.max(config.vision?2048:floor,baseline.reserve);
|
||||
const reserve=profile==='work'&&gpuMemory.length?Math.max(safety,Math.floor(Math.min(...gpuMemory)*1024*.2)):safety;
|
||||
/* A reserve the person set is theirs, 0 included: no floor and no share of the card (#3215 #3270). Without one the card gets the safety default (defaultReserves). */
|
||||
const reserve=asked!==null?asked:defaults[profile];
|
||||
remove('--vram-reserve-mib');args.push('--vram-reserve-mib',String(reserve));
|
||||
// RAM: "leave 20% for my work" keeps the model's RAM within 80% of the PC. A model whose experts do not fit there (the
|
||||
// setup rule: experts plus 10 GB) keeps only that many GiB of them in RAM and reads the rest from the model files
|
||||
@@ -37,7 +84,7 @@ export function resourceSettings(config,hardware,profile,model={}){
|
||||
// available for other programs (it keeps fewer experts if the PC is busy), and it said nothing about this before.
|
||||
let ramHeadroom=false;
|
||||
if(ramBudget&&!(config.env?.STRATA_RESIDENT_HEADROOM_GIB)){env={...(env??config.env??{}),STRATA_RESIDENT_HEADROOM_GIB:String(Math.ceil(ram*.2))};ramHeadroom=true;}
|
||||
return {args,vram_reserve_mib:reserve,...(env?{env}:{}),ui_resource_baseline:{...baseline,ramBudget,ramHeadroom}};
|
||||
return {args,vram_reserve_mib:reserve,...(asked!==null?{}:{ui_reserve_applied:reserve}),...(env?{env}:{}),ui_resource_baseline:{...baseline,ramBudget,ramHeadroom}};
|
||||
}
|
||||
|
||||
/* One automatic second try after a start that died. Returns null when there is nothing safer to try (the caller then reports the first
|
||||
@@ -49,6 +96,23 @@ export function saferStart(config,hardware,model,failure){
|
||||
if(/0xC0000135|0xC000007B|0xC0000142|0xC000001D|0xC0000005|0xC0000409|0xC0000022|error while loading shared libraries|cannot open shared object file|\bENOENT\b|exit code 127|exit code 139|illegal instruction/i.test(text))return null;
|
||||
const ranOut=/out of memory|memory guard|\boom\b|\bkilled\b|exit code 137|cannot allocate|not enough memory|bad_alloc|insufficient memory/i.test(text),first=ranOut?'The first start ran out of memory':'The first start did not finish';
|
||||
if(!/server_exited|exited before|died before|engine stopped|pinned-memory-exhausted|mapped staging|out of memory|memory guard|\bkilled\b/i.test(text))return null;
|
||||
// The engine stopped because the GPU's expert cache got no room (a 10 GB card with the app's 20% reserve and pictures on): the engine can shrink its own
|
||||
// reserve only when none was given, and the app always gives one. Ask for less kept free (never under 1024 MiB: below that the driver pages
|
||||
// graphics memory to RAM, #2065) and, when that is not enough, for a shorter context. Never saved.
|
||||
if(/no VRAM (?:is|was) left for the expert cache|needs the profile-filled VRAM expert tier/i.test(text)){
|
||||
const args=[...(config.args??[])],at=args.indexOf('--vram-reserve-mib'),cur=at>=0?Number(args[at+1])||0:Number(config.vram_reserve_mib)||0;
|
||||
const short=Number((text.match(/about (\d+) MiB more than this card has free/)??[])[1])||700,need=short+256;
|
||||
const notes=[];let left=need;
|
||||
if(cur>1024){const next=Math.max(1024,cur-need);left=need-(cur-next);if(next<cur){if(at>=0)args[at+1]=String(next);else args.push('--vram-reserve-mib',String(next));notes.push(`${next} MiB of graphics memory kept free for other programs (was ${cur})`);}}
|
||||
const cx=args.indexOf('--max-context'),ctx=cx>=0?Number(args[cx+1])||0:0,sess=text.match(/takes (\d+) MiB at (\d+) tokens/);
|
||||
if(left>0&&ctx>16384&&sess){
|
||||
const session=Number(sess[1]),tokens=Number(sess[2]);let next=ctx;
|
||||
while(next>16384&&session*(ctx-next)/tokens<left)next=Math.max(16384,Math.floor(next/2));
|
||||
if(next<ctx){args[cx+1]=String(next);notes.push(`a shorter conversation context (${next} tokens, was ${ctx})`);}
|
||||
}
|
||||
if(!notes.length)return null;
|
||||
return {config:{...config,args},words:`This graphics card had no room left for the model's expert cache, so Strata started again with ${notes.join(' and ')}. Nothing is saved; change it in the model's settings if you want it permanently.`,detail:notes.join('; ')};
|
||||
}
|
||||
const args=[...(config.args??[])],env={...(config.env??{})};
|
||||
const ram=Number(hardware?.ram_gb)||0,arena=Number(model?.arenaGb)||0;
|
||||
// Windows lets a program lock only about half the RAM for the graphics card; the engine pins the model's experts up to that.
|
||||
|
||||
@@ -7,6 +7,7 @@ export const NATIVE_FIELDS=[
|
||||
{id:'aliases',label:'API model aliases',group:'Serving',type:'array',hint:'Comma-separated names clients may use for this model.'},
|
||||
{id:'idle_unload_s',label:'Unload after idle seconds',group:'Serving',type:'number',min:0,max:86400,hint:'Zero keeps the model loaded.'},
|
||||
{id:'lazy_load',label:'Load on first request',group:'Serving',type:'boolean'},
|
||||
{id:'mtp',label:'Draft layer (MTP)',group:'Serving',type:'boolean',hint:'The draft layer guesses several words ahead, which makes answers faster, and costs about 1 GB of graphics memory and some RAM. Turn it off if the model stops while it starts, or the card is small: the expert cache gets that memory back and answers are slower. Default is on.'},
|
||||
{id:'batchMtp',label:'MTP per request',group:'Serving',type:'boolean',hint:'Each concurrent request uses the draft layer. Left alone, Strata turns it on for one graphics card with 24 GB or more and several requests at once, and off otherwise; choose on or off here to decide yourself.'},
|
||||
{id:'parallel',label:'Concurrent requests',group:'Serving',type:'number',min:1,max:8,hint:'More slots need more memory and can slow each response.'},
|
||||
{id:'engine_silence_s',label:'Engine stall timeout (seconds)',group:'Recovery',type:'number',min:10,max:3600},
|
||||
@@ -22,6 +23,14 @@ export const NATIVE_FIELDS=[
|
||||
{id:'mcp_max_rounds',label:'Maximum tool rounds',group:'Tools',type:'number',min:1,max:100},
|
||||
{id:'mcp_max_result_chars',label:'Maximum tool result characters',group:'Tools',type:'number',min:100,max:1000000},
|
||||
];
|
||||
/** Settings the app itself applies when it starts a model (they are not read from the engine's source, so the engine's text never says it supports them). */
|
||||
export const APP_OWNED_FIELDS=['mtp','batchMtp'];
|
||||
/** The engine arguments without the draft layer: --mtp <folder or count>, --spec-min-p <p> (its confidence threshold) and --batch-mtp. --spec stays: prompt lookup still uses its windows. Same rule as strata_control/draft_layer.py. */
|
||||
export function withoutDraftLayer(args){
|
||||
const out=[];const list=(args??[]).map(String);
|
||||
for(let i=0;i<list.length;i++){const a=list[i];if(a==='--batch-mtp')continue;if(a==='--mtp'||a==='--spec-min-p'){if(i+1<list.length&&!list[i+1].startsWith('--'))i++;continue;}out.push(a);}
|
||||
return out;
|
||||
}
|
||||
export function validateNativeSettings(values){
|
||||
if(!values||typeof values!=='object'||Array.isArray(values))throw new Error('Native settings must be an object.');
|
||||
const result={};
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/** The engine options a person set by hand in a model's run config (strata_control/user_args.py is the same rule in Python).
|
||||
* "user_args" holds them; "args_written" is the whole "args" as setup or the app last wrote it, so an option in "args" that is not in
|
||||
* that copy was added or changed by hand since. Every load and every write puts the person's options over the generated ones. */
|
||||
export const USER_KEY='user_args',WRITTEN_KEY='args_written';
|
||||
/** Options the app or setup choose from their own answers (context, KV type, pictures, draft layer, layer split): never taken from "args" as hand edits. */
|
||||
export const CHOICE_FLAGS=new Set(['--max-context','--kv','--vision','--mtp','--spec-min-p','--batch-mtp','--layer-split']);
|
||||
|
||||
export function groups(args){
|
||||
const out=[];
|
||||
for(const raw of Array.isArray(args)?args:[]){const a=String(raw);if(a.startsWith('--')||!out.length)out.push([a]);else out[out.length-1].push(a);}
|
||||
return out;
|
||||
}
|
||||
export const flat=gs=>gs.flat();
|
||||
export const flagOf=g=>g?.[0]?.startsWith('--')?g[0]:'';
|
||||
/** `args` with every option the person set replaced by theirs (appended after the generated ones). */
|
||||
export function merge(args,user){
|
||||
const ug=groups(user),mine=new Set(ug.map(flagOf).filter(Boolean));
|
||||
return flat([...groups(args).filter(g=>!mine.has(flagOf(g))),...ug]);
|
||||
}
|
||||
function byFlag(gs){const m=new Map();for(const g of gs){const f=flagOf(g);if(!m.has(f))m.set(f,[]);m.get(f).push(g);}return m;}
|
||||
const same=(a,b)=>JSON.stringify(a??null)===JSON.stringify(b??null);
|
||||
/** The person's options of a config: its user_args, with the options of "args" that differ from "args_written" on top. */
|
||||
export function capture(cfg,skip=CHOICE_FLAGS){
|
||||
const mine=byFlag(groups(cfg?.[USER_KEY]));
|
||||
if(Array.isArray(cfg?.[WRITTEN_KEY])&&Array.isArray(cfg?.args)){
|
||||
const cur=byFlag(groups(cfg.args)),was=byFlag(groups(cfg[WRITTEN_KEY]));
|
||||
for(const [flag,gs] of cur)if(flag&&!skip.has(flag)&&!same(was.get(flag),gs))mine.set(flag,gs);
|
||||
for(const flag of [...mine.keys()])if(was.has(flag)&&!cur.has(flag))mine.delete(flag);
|
||||
}
|
||||
return flat([...mine.values()].flat());
|
||||
}
|
||||
export function setFlag(user,flag,value){
|
||||
const rest=groups(user).filter(g=>flagOf(g)!==flag);
|
||||
if(value!==null&&value!==undefined)rest.push([flag,String(value)]);
|
||||
return flat(rest);
|
||||
}
|
||||
export function flagValue(args,flag){
|
||||
const list=(Array.isArray(args)?args:[]).map(String),at=list.indexOf(flag);
|
||||
return at>=0&&at+1<list.length?list[at+1]:null;
|
||||
}
|
||||
/** `config` with the person's options merged into "args", recorded in "user_args", and "args_written" refreshed (only when the config has one). */
|
||||
export function stamped(config,user){
|
||||
const next={...config};
|
||||
if(user.length){next.args=merge(next.args,user);next[USER_KEY]=user;}else delete next[USER_KEY];
|
||||
if(WRITTEN_KEY in config)next[WRITTEN_KEY]=[...(next.args??[])];
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/** A chat sent while no model can answer: the engine's own wording ("Choose a loaded model ID.") said nothing to a person. */
|
||||
export const plainNoModel = message => /not ready/i.test(message) ? 'Your model is still loading. You can chat in a moment.'
|
||||
: /choose a loaded model/i.test(message) ? 'No model is loaded. Load one in Models first.' : message;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Windows PowerShell writes to a pipe in the console's OEM code page (cp866 on a Russian Windows, cp437 on an English one),
|
||||
* not in UTF-8, while Node reads the pipe as UTF-8 - so a Cyrillic folder name came back as mojibake or as question marks (#3202).
|
||||
* Every script whose output can hold a folder, file or font name starts with this line, before anything is written.
|
||||
*/
|
||||
export const POWERSHELL_UTF8 = '[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false; $OutputEncoding = [Console]::OutputEncoding; ';
|
||||
|
||||
/** The script with the UTF-8 line in front of it. */
|
||||
export const utf8Script = script => POWERSHELL_UTF8 + script;
|
||||
@@ -1,4 +1,8 @@
|
||||
/** Verified file identities for the optional real engine. Never relabel these as demo weights. */
|
||||
/**
|
||||
* The starter list shown with the optional real engine. It is display only: nothing downloads from it (models.install refuses when the real
|
||||
* engine is on), because these files are named without a hash. Models come from the Models screen of the native engine, which pins them.
|
||||
* Never relabel these as demo weights.
|
||||
*/
|
||||
export const REAL_VARIANTS = [
|
||||
{
|
||||
id: 'qwen2.5-0.5b-q4km',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Native Save As for a rendered Monitor PNG. The client cannot choose a host path. */
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { exec } from './util.mjs';
|
||||
import { utf8Script } from './powershell-utf8.mjs';
|
||||
|
||||
export function snapshotBytes(data) {
|
||||
if (typeof data !== 'string' || data.length > 24 * 1024 * 1024 || !/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(data)) throw new Error('Expected a PNG screenshot smaller than 18 MB.');
|
||||
@@ -14,7 +15,7 @@ export async function saveSnapshot(data, { platform = process.platform, run = ex
|
||||
let result;
|
||||
try {
|
||||
if (platform === 'darwin') result = await run('osascript', ['-e', `POSIX path of (choose file name with prompt "Save Monitor screenshot" default name "${name}")`]);
|
||||
else if (platform === 'win32') result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', `Add-Type -AssemblyName System.Windows.Forms; $dialog = New-Object System.Windows.Forms.SaveFileDialog; $dialog.Filter = 'PNG image (*.png)|*.png'; $dialog.FileName = '${name}'; if ($dialog.ShowDialog() -eq 'OK') { $dialog.FileName }`]);
|
||||
else if (platform === 'win32') result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', utf8Script(`Add-Type -AssemblyName System.Windows.Forms; $dialog = New-Object System.Windows.Forms.SaveFileDialog; $dialog.Filter = 'PNG image (*.png)|*.png'; $dialog.FileName = '${name}'; if ($dialog.ShowDialog() -eq 'OK') { $dialog.FileName }`)]);
|
||||
else result = await run('zenity', ['--file-selection', '--save', '--confirm-overwrite', '--title=Save Monitor screenshot', `--filename=${name}`, '--file-filter=PNG images | *.png']);
|
||||
} catch (e) {
|
||||
if (/User canceled|\(-128\)/i.test(e.stderr ?? '') || (platform === 'linux' && e.code === 1)) return { cancelled: true };
|
||||
|
||||
+31
-15
@@ -1,6 +1,7 @@
|
||||
import { migrateSetup } from './setup.mjs';
|
||||
/** Tiny persisted state for the host: one JSON file under STRATA_HOME. */
|
||||
import { accessSync, constants, mkdirSync, readFileSync, writeFileSync, existsSync, realpathSync, readdirSync, statSync, statfsSync, symlinkSync } from 'node:fs';
|
||||
import { alignModels, isDriveRoot, libraryFolderFromPick, makeFolder } from './model-folder.mjs';
|
||||
import { dirname, isAbsolute, join, parse, relative, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { defaultModelDirectory } from './model-directory-default.mjs';
|
||||
@@ -58,29 +59,42 @@ function read() {
|
||||
|
||||
let state = read();
|
||||
const DEFAULT_MODELS_DIRECTORY=defaultModelDirectory();
|
||||
function initialModelsDirectory() {
|
||||
if(state.modelsDir)return state.modelsDir;
|
||||
const legacy=realpathSync(join(HOME,'models'));
|
||||
// Keep an existing library in place. A new install uses Documents/Strata/Models.
|
||||
/**
|
||||
* The model folder for a new install: the saved one, an existing library inside STRATA_HOME, else Documents/Strata/Models. A Documents folder
|
||||
* that cannot be made (Controlled folder access, a disconnected share, an offline OneDrive known folder) must not stop the host from starting:
|
||||
* the models go to STRATA_HOME/models instead, and `notice` says so in plain words.
|
||||
*/
|
||||
export function pickModelsDirectory({ saved, home, preferred, fixedByEnv = false }) {
|
||||
if (saved) return { directory: saved, notice: '' };
|
||||
const fallback = join(home, 'models');
|
||||
let legacy = fallback;
|
||||
try { legacy = realpathSync(fallback); } catch { /* the folder is made at start; keep the plain path */ }
|
||||
function hasWeights(folder,depth=0){try{return readdirSync(folder,{withFileTypes:true}).some(entry=>entry.isFile()&&/\.gguf(?:\.part|\.partial)?$/i.test(entry.name)||entry.isDirectory()&&depth<4&&hasWeights(join(folder,entry.name),depth+1));}catch{return false;}}
|
||||
if(!process.env.STRATA_MODELS_DIR&&hasWeights(legacy))return legacy;
|
||||
const directory=DEFAULT_MODELS_DIRECTORY;
|
||||
mkdirSync(directory,{recursive:true});
|
||||
return realpathSync(directory);
|
||||
// Keep an existing library in place. A new install uses Documents/Strata/Models.
|
||||
if (!fixedByEnv && hasWeights(legacy)) return { directory: legacy, notice: '' };
|
||||
try {
|
||||
mkdirSync(preferred, { recursive: true });
|
||||
accessSync(preferred, constants.R_OK | constants.W_OK | constants.X_OK);
|
||||
return { directory: realpathSync(preferred), notice: '' };
|
||||
} catch (error) {
|
||||
return { directory: legacy, notice: `Strata could not use ${preferred} for models (${error.code ?? error.message}), so models go to ${legacy}. You can pick another folder in Settings.` };
|
||||
}
|
||||
}
|
||||
export let MODELS_DIR = initialModelsDirectory();
|
||||
const MODELS_PICK = pickModelsDirectory({ saved: state.modelsDir, home: HOME, preferred: DEFAULT_MODELS_DIRECTORY, fixedByEnv: !!process.env.STRATA_MODELS_DIR });
|
||||
/** Plain words for the person when the usual model folder could not be used (empty otherwise). */
|
||||
export const MODELS_DIR_NOTICE = MODELS_PICK.notice;
|
||||
if (MODELS_DIR_NOTICE) console.warn(`[strata] ${MODELS_DIR_NOTICE}`);
|
||||
export let MODELS_DIR = MODELS_PICK.directory;
|
||||
|
||||
/** Native setup writes weights to data_dir/models. Keep its data root one level up. */
|
||||
export function nativeDataDirectory(input) {
|
||||
if(!input||process.env.STRATA_MODELS_DIR||!existsSync(DEFAULT_MODELS_DIRECTORY))return input;
|
||||
if(MODELS_DIR_NOTICE)return input;
|
||||
const root=realpathSync(DEFAULT_MODELS_DIRECTORY);
|
||||
if(resolve(input)!==resolve(DEFAULT_MODELS_DIRECTORY)&&realpathSync(input)!==root)return input;
|
||||
const data=dirname(root),nativeModels=join(data,'models');
|
||||
// On Windows/macOS case-insensitive volumes, Models already resolves as models.
|
||||
// On Linux, a relative alias keeps the native layout inside the requested folder.
|
||||
if(!existsSync(nativeModels))symlinkSync(process.platform==='win32'?root:'Models',nativeModels,process.platform==='win32'?'junction':'dir');
|
||||
const a=statSync(nativeModels),b=statSync(root);
|
||||
if(realpathSync(nativeModels)!==root&&!(a.ino&&a.ino===b.ino&&a.dev===b.dev))throw new Error('The default model folder conflicts with another library. Choose a different folder.');
|
||||
const data=dirname(root);
|
||||
// `models` and `Models` are one folder on Windows and macOS; where they are not (Linux) a link makes them one, and where no link can be made (exFAT, a share) models go in `models`.
|
||||
alignModels(data,root);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -95,6 +109,8 @@ export function validateModelsDirectory(input) {
|
||||
if (!statSync(directory).isDirectory()) throw new Error();
|
||||
accessSync(directory, constants.R_OK | constants.W_OK | constants.X_OK);
|
||||
} catch { throw new Error('This folder is unavailable or not writable. Check the path and folder permissions.'); }
|
||||
// A whole drive (D:\) is not filled with models: D:\Strata\Models is made and used.
|
||||
if (isDriveRoot(directory)) directory = realpathSync(makeFolder(libraryFolderFromPick(directory).path));
|
||||
const data = realpathSync(DATA_DIR);
|
||||
const contains = (parent, child) => { const r = relative(parent, child); return !r || !r.startsWith('..') && !isAbsolute(r); };
|
||||
if (directory === parse(directory).root || contains(directory, data) || contains(data, directory)) throw new Error('Choose a separate folder for models, outside Strata’s app data.');
|
||||
|
||||
@@ -33,3 +33,19 @@ export function taskReport({ text, cards }) {
|
||||
if (attention) out += `\n\n> ${attention}`;
|
||||
return { text: out, attention, demo: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* One plain line for a board card: the first sentence of the answer, with the markdown taken off and the "What it did" list and the
|
||||
* "needed a yes" note left out. '' when there is nothing to say.
|
||||
*/
|
||||
export function lastLine(text, max = 160) {
|
||||
let body = String(text ?? '').split(/\n\n---\n\n/)[0].replace(/\n\n> [\s\S]*$/, '');
|
||||
body = body.replace(/```[\s\S]*?```/g, ' ').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/^\s*(#{1,6}|[-*+]|\d+[.)]|>)\s+/gm, '').replace(/[*_`~]/g, '');
|
||||
body = body.replace(/\s+/g, ' ').trim();
|
||||
if (!body) return '';
|
||||
const sentence = /^.*?[.!?](?=\s|$)/.exec(body)?.[0] ?? body;
|
||||
// A very short first sentence ("Done.") says nothing: take the next one too.
|
||||
const two = /^.*?[.!?](?=\s|$)\s*.*?[.!?](?=\s|$)/.exec(body)?.[0] ?? body;
|
||||
const pick = sentence.length < 24 && body.length > sentence.length ? two : sentence;
|
||||
return clip(pick, max);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
* first thing that needs a yes and says what that was) or `bypass` (full access). The default is `plan`.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { lastLine } from './task-report.mjs';
|
||||
const frequencies = ['hourly', 'daily', 'weekdays', 'weekly'];
|
||||
export const TASK_MODES = ['plan', 'manual', 'bypass'];
|
||||
export function validateTask(input) {
|
||||
@@ -52,6 +53,8 @@ export function createTaskService({ read, write, run, notify = () => {}, now = D
|
||||
if (old?.status === 'running') throw new Error('Stop the current run before editing.');
|
||||
const fields = validateTask({ ...old, ...input });
|
||||
const task = { ...old, ...fields, id: old?.id ?? randomUUID(), createdAt: old?.createdAt ?? new Date(now()).toISOString(), status: old?.status ?? 'idle', archived: input.archived ?? old?.archived ?? false };
|
||||
// "I have seen this": the board hides a finished or failed run the person dismissed. It is only a mark; the result stays in the task.
|
||||
if ('ackedAt' in input) task.ackedAt = input.ackedAt ? String(input.ackedAt) : null;
|
||||
task.nextRunAt = task.enabled && !task.archived ? nextRun(task, now()) : null;
|
||||
records = old ? records.map(t => t.id === id ? task : t) : [...records, task]; save(); return task;
|
||||
}
|
||||
@@ -59,12 +62,13 @@ export function createTaskService({ read, write, run, notify = () => {}, now = D
|
||||
const t = find(id); if (t.status === 'running') return;
|
||||
if (t.archived) throw new Error('Restore this task first.');
|
||||
const controller = new AbortController(); controllers.set(id, controller);
|
||||
t.status = 'running'; t.lastRunAt = new Date(now()).toISOString(); t.error = null; t.files = [];
|
||||
// A new run replaces the last one's problem (error and the note that something needed a yes) and its dismissal, so the board only ever shows the newest.
|
||||
t.status = 'running'; t.lastRunAt = new Date(now()).toISOString(); t.error = null; t.attention = null; t.ackedAt = null; t.lastLine = null; t.finishedAt = null; t.files = [];
|
||||
t.nextRunAt = t.enabled ? nextRun(t, now()) : null; save();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs); timeout.unref?.();
|
||||
try { const result = await run(t, controller.signal); t.output = result.text.slice(0, 100000); t.demo = result.demo; t.attention = result.attention ?? null; t.files = Array.isArray(result.files) ? result.files.slice(0, 30) : []; t.status = 'done'; }
|
||||
try { const result = await run(t, controller.signal); t.output = result.text.slice(0, 100000); t.demo = result.demo; t.attention = result.attention ?? null; t.lastLine = lastLine(result.text); t.files = Array.isArray(result.files) ? result.files.slice(0, 30) : []; t.status = 'done'; }
|
||||
catch (e) { t.status = controller.signal.aborted ? 'cancelled' : 'error'; t.error = controller.signal.aborted ? 'Run stopped.' : String(e.message ?? e); }
|
||||
finally { clearTimeout(timeout); controllers.delete(id); save(); try { notify(t); } catch { /* a notification is a courtesy */ } }
|
||||
finally { t.finishedAt = new Date(now()).toISOString(); clearTimeout(timeout); controllers.delete(id); save(); try { notify(t); } catch { /* a notification is a courtesy */ } }
|
||||
}
|
||||
function tick() { for (const t of records) if (t.enabled && !t.archived && t.status !== 'running' && t.nextRunAt && Date.parse(t.nextRunAt) <= now()) void execute(t.id); }
|
||||
return { list, put, execute, tick, stop: id => controllers.get(id)?.abort(), close: () => controllers.forEach(c => c.abort()) };
|
||||
|
||||
@@ -305,7 +305,7 @@ export function createTimeSkip({ repoRoot, python, pythonArgs = [], timeoutMs =
|
||||
const child = spawn(python, [...pythonArgs, cli, ...args, '--output', 'json'], {
|
||||
cwd: repoRoot,
|
||||
windowsHide: true,
|
||||
env: { ...env, STRATA_TIMESKIP_DIR: root },
|
||||
env: { ...env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8', STRATA_TIMESKIP_DIR: root },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
@@ -452,7 +452,7 @@ export function createTimeSkip({ repoRoot, python, pythonArgs = [], timeoutMs =
|
||||
'--attempts', String(Math.max(0, Math.min(10, Number(attempts) || 0))), '--output', 'json'];
|
||||
if (reviseCommand) args.push('--revise-command', reviseCommand);
|
||||
const child = spawn(python, [...pythonArgs, cli, ...args], {
|
||||
cwd: repoRoot, windowsHide: true, env: { ...env, ...extraEnv, STRATA_TIMESKIP_DIR: root }, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
cwd: repoRoot, windowsHide: true, env: { ...env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8', ...extraEnv, STRATA_TIMESKIP_DIR: root }, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let out = '', err = '', closed = null;
|
||||
child.stdout.on('data', b => { out += b.toString(); });
|
||||
|
||||
@@ -13,6 +13,13 @@ export const PHASE_TEXT = {
|
||||
done: 'Done.',
|
||||
};
|
||||
|
||||
/** True when the failure looks like a computer with no internet (no DNS, no route, refused), not a slow or broken server. */
|
||||
export function looksOffline(error) {
|
||||
const text = [error?.message, error?.cause?.message, error?.cause?.code, error?.code, error].filter(x => typeof x === 'string').join(' ');
|
||||
return /ENOTFOUND|EAI_AGAIN|ENETUNREACH|ENETDOWN|EHOSTUNREACH|ECONNREFUSED|getaddrinfo|fetch failed|network is unreachable|name or service not known|temporary failure in name resolution|nodename nor servname|No address associated|URLError/i.test(text);
|
||||
}
|
||||
export const OFFLINE_MESSAGE = "No internet connection. Try again when you're online.";
|
||||
|
||||
/** { message, details, next } for an error thrown by Control's update calls (or the shell bridge). `subject` is "engine" or "Strata". */
|
||||
export function plainUpdateError(error, subject = 'engine') {
|
||||
const raw = String(error?.message ?? error ?? '');
|
||||
@@ -30,6 +37,9 @@ export function plainUpdateError(error, subject = 'engine') {
|
||||
next = `Wait a moment, then press ${button} in Settings.`;
|
||||
} else if (/test start|did not answer a one-token|did not become ready/i.test(raw)) {
|
||||
message = `The new ${subject} didn't start correctly, so nothing was changed. ${keep}`;
|
||||
} else if (looksOffline(error) || looksOffline(raw)) {
|
||||
message = 'No internet connection.';
|
||||
next = "Try again when you're online.";
|
||||
} else if (/could not be read|interrupted|timed out|timeout|ECONN|ENOTFOUND|fetch failed|could not reach|server answered|not answer within/i.test(raw)) {
|
||||
message = "Couldn't reach the update server.";
|
||||
next = `Check your internet connection, then press ${button} in Settings.`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { plainUpdateError } from './update-messages.mjs';
|
||||
import { OFFLINE_MESSAGE, looksOffline, plainUpdateError } from './update-messages.mjs';
|
||||
|
||||
/** Read-only release checks. Installing/rolling back needs a managed installer. */
|
||||
const RELEASES = 'https://api.github.com/repos/Niko1221/Strata/releases?per_page=30';
|
||||
@@ -33,7 +33,7 @@ export async function checkUpdates({ appVersion, engineVersion }, fetcher = fetc
|
||||
strata = { status: newer ? 'available' : 'current', latestVersion: latest.tag_name, releaseUrl, message: newer ? `Strata ${latest.tag_name} is available.` : 'Strata is up to date.' };
|
||||
}
|
||||
} catch (error) {
|
||||
strata = { status: 'error', message: error.name === 'TimeoutError' ? 'The release check timed out. Try again.' : error.message ?? 'Could not check for updates.' };
|
||||
strata = { status: 'error', message: looksOffline(error) ? OFFLINE_MESSAGE : error.name === 'TimeoutError' ? 'The release check timed out. Try again.' : error.message ?? 'Could not check for updates.' };
|
||||
}
|
||||
return {
|
||||
checkedAt: new Date().toISOString(),
|
||||
@@ -56,7 +56,7 @@ export async function checkComponentUpdates({ control, appUpdater = null, appVer
|
||||
report = await control.updateCheck({ appVersion });
|
||||
} catch (error) {
|
||||
const plain = plainUpdateError(error, 'Strata');
|
||||
const message = /update server|aren't available/.test(plain.headline) ? plain.message : `Couldn't check for updates. Try again in a moment.`;
|
||||
const message = /update server|aren't available|No internet/.test(plain.headline) ? plain.message : `Couldn't check for updates. Try again in a moment.`;
|
||||
return { checkedAt, strata: { installedVersion: appVersion ?? control.version ?? null, status: 'error', message, details: plain.details, canInstall: false }, engine: { installedVersion: null, status: 'error', message, details: plain.details, canInstall: false } };
|
||||
}
|
||||
const mib = bytes => `${Math.round((bytes ?? 0) / (1 << 20))} MB`;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { navigate, href, useLocation } from '../lib/router';
|
||||
import { blankTask, type Task } from '../lib/tasks';
|
||||
import { TaskEditor } from '../components/TaskEditor';
|
||||
import { pushToast, setNavOpen } from '../lib/store';
|
||||
import { useBoard } from '../lib/use-board';
|
||||
type TaskLeaf = Task & {
|
||||
id: string;
|
||||
};
|
||||
@@ -14,6 +15,7 @@ export function TaskTree({ collapsed }: {
|
||||
}) {
|
||||
const [editor, setEditor] = useState<Task | null>(null);
|
||||
const location = useLocation();
|
||||
const { board } = useBoard();
|
||||
const [open, setOpen] = useState(() => localStorage.getItem('strata.tasks-open') !== 'false');
|
||||
const [tasks, setTasks] = useState<TaskLeaf[]>([]), [remove, setRemove] = useState<TaskLeaf | null>(null), [error, setError] = useState(''), [busy, setBusy] = useState(false);
|
||||
const [leaving, setLeaving] = useState<string | null>(null);
|
||||
@@ -76,8 +78,8 @@ export function TaskTree({ collapsed }: {
|
||||
if (collapsed)
|
||||
return dialogs;
|
||||
return <section className="task-tree" aria-label={tr("Scheduled tasks")}>
|
||||
<div className="ws__head"><button className="navgroup__label task-tree__heading" aria-expanded={open} onClick={() => { setOpen(!open); localStorage.setItem('strata.tasks-open', String(!open)); }}>{tr("Tasks")}<Icon name="chevron" size={12} className={`ws__chev ${open ? 'is-open' : ''}`}/></button><div className="ws__tools"><IconBtn name="plus" title={tr("Add a task")} onClick={() => { setNavOpen(false); setEditor(blankTask()); }}/></div></div>
|
||||
{tr(open ? <div className="navsessions">{tr(tasks.map(t => <div className="ws__session row task-tree-row" data-leaving={leaving === t.id || undefined} key={t.id}><a className="navsession" href={href('tasks', t.id)} aria-current={location.route === 'tasks' && location.param === t.id ? 'page' : undefined} title={t.title}><span className="navsession__title">{t.title}</span>{tr(!t.enabled ? <Icon name="clock" size={12}/> : null)}</a><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Edit task {0}", [t.title])} title={tr("Edit {0}", [t.title])} disabled={t.status === 'running'} onClick={() => { setNavOpen(false); setEditor({ ...t }); }}><Icon name="pencil" size={15}/></button><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Delete task {0}", [t.title])} disabled={t.status === 'running'} title={tr(t.status === 'running' ? 'Stop this task before deleting' : `Delete ${t.title}`)} onClick={() => { setNavOpen(false); setRemove(t); setError(''); }}><Icon name="trash" size={15}/></button></div>))}{tr(!tasks.length ? <p className="small muted ws__empty">{tr("No tasks yet")}</p> : null)}{tr(error && !remove ? <p className="xs inline-error">{tr("Couldn\u2019t load tasks.")}</p> : null)}</div> : null)}
|
||||
<div className="ws__head"><button className="navgroup__label task-tree__heading" aria-expanded={open} onClick={() => { setOpen(!open); localStorage.setItem('strata.tasks-open', String(!open)); }}>{tr("Tasks")}{tr(board.need ? <span className="navcount" title={tr("{0} need your attention", [board.need])}><span className="sr-only">{tr("{0} need your attention", [board.need])}</span><span aria-hidden="true">{board.need}</span></span> : null)}<Icon name="chevron" size={12} className={`ws__chev ${open ? 'is-open' : ''}`}/></button><div className="ws__tools"><IconBtn name="plus" title={tr("Add a task")} onClick={() => { setNavOpen(false); setEditor(blankTask()); }}/></div></div>
|
||||
{tr(open ? <div className="navsessions"><div className="ws__session row task-tree-row"><a className="navsession" href="#/tasks?view=board" aria-current={location.route === 'tasks' && !location.param && location.query.get('view') === 'board' ? 'page' : undefined} onClick={() => setNavOpen(false)}><span className="navsession__title">{tr("Task board")}</span></a></div>{tr(tasks.map(t => <div className="ws__session row task-tree-row" data-leaving={leaving === t.id || undefined} key={t.id}><a className="navsession" href={href('tasks', t.id)} aria-current={location.route === 'tasks' && location.param === t.id ? 'page' : undefined} title={t.title}><span className="navsession__title">{t.title}</span>{tr(board.problemTasks.includes(t.id) ? <span className="navsession__dot navsession__dot--warn" role="img" aria-label={tr("Needs attention")} title={tr("Needs attention")}/> : null)}{tr(!t.enabled ? <Icon name="clock" size={12}/> : null)}</a><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Edit task {0}", [t.title])} title={tr("Edit {0}", [t.title])} disabled={t.status === 'running'} onClick={() => { setNavOpen(false); setEditor({ ...t }); }}><Icon name="pencil" size={15}/></button><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Delete task {0}", [t.title])} disabled={t.status === 'running'} title={tr(t.status === 'running' ? 'Stop this task before deleting' : `Delete ${t.title}`)} onClick={() => { setNavOpen(false); setRemove(t); setError(''); }}><Icon name="trash" size={15}/></button></div>))}{tr(!tasks.length ? <p className="small muted ws__empty">{tr("No tasks yet")}</p> : null)}{tr(error && !remove ? <p className="xs inline-error">{tr("Couldn\u2019t load tasks.")}</p> : null)}</div> : null)}
|
||||
{dialogs}
|
||||
</section>;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export function CloudflareDialog({ open, onClose, onCreateKey }: {
|
||||
const steps = mode === 'quick' ? ['Address', 'Connector', 'API key', 'Connect'] : ['Address', 'Connector', 'API key', 'Domain', 'Connect'];
|
||||
const last = steps.length - 1;
|
||||
const installed = !!tunnel?.installed, keyReady = !!serving?.access.apiKeySet, other = tunnel?.external ?? null;
|
||||
const canContinue = step === 1 ? installed : step === 2 ? keyReady : step === 3 && mode === 'domain' ? !!hostname.trim() && !!token.trim() : true;
|
||||
const canContinue = step === 1 ? installed : step === 2 ? true : step === 3 && mode === 'domain' ? !!hostname.trim() && !!token.trim() : true;
|
||||
async function inspect() { setPending(true); setError(''); try {
|
||||
setDetected(await api.get<TunnelState>('/api/serving/tunnel'));
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export function CloudflareDialog({ open, onClose, onCreateKey }: {
|
||||
} }
|
||||
const titles = ['Choose your address', installed ? 'Connector ready' : 'Preparing your connector', 'Protect your connection', ...(mode === 'domain' ? ['Set up your domain'] : []), 'Ready to connect'];
|
||||
return <Dialog open={open} title={tr("Connect with Cloudflare")} onClose={dismiss} actions={<>
|
||||
{tr(active ? <><Btn onClick={dismiss}>{tr("Done")}</Btn><Btn disabled={pending || tunnel?.status === 'stopping'} onClick={() => void stop()}>{tr(tunnel?.status === 'stopping' ? 'Stopping…' : 'Stop tunnel')}</Btn></> : <><Btn disabled={pending} onClick={back}>{tr(step ? 'Back' : 'Cancel')}</Btn>{tr(step === last ? <Btn variant="primary" disabled={pending || !installed || !keyReady || (mode === 'domain' && (!hostname.trim() || !token.trim()))} onClick={() => void connect()}>{tr(pending ? 'Connecting…' : (error || tunnel?.error) ? 'Try again' : mode === 'quick' ? 'Create temporary URL' : 'Connect domain')}</Btn> : <Btn variant="primary" disabled={pending || !canContinue} onClick={next}>{tr("Continue")}<Icon name="chevron" className="wizard-next" size={14}/></Btn>)}</>)}
|
||||
{tr(active ? <><Btn onClick={dismiss}>{tr("Done")}</Btn><Btn disabled={pending || tunnel?.status === 'stopping'} onClick={() => void stop()}>{tr(tunnel?.status === 'stopping' ? 'Stopping…' : 'Stop tunnel')}</Btn></> : <><Btn disabled={pending} onClick={back}>{tr(step ? 'Back' : 'Cancel')}</Btn>{tr(step === last ? <Btn variant="primary" disabled={pending || !installed || (mode === 'domain' && (!hostname.trim() || !token.trim()))} onClick={() => void connect()}>{tr(pending ? 'Connecting…' : (error || tunnel?.error) ? 'Try again' : mode === 'quick' ? 'Create temporary URL' : 'Connect domain')}</Btn> : <Btn variant="primary" disabled={pending || !canContinue} onClick={next}>{tr("Continue")}<Icon name="chevron" className="wizard-next" size={14}/></Btn>)}</>)}
|
||||
</>}>
|
||||
<div className="tunnel-wizard">
|
||||
{tr(!active ? <ol className="tunnel-progress" aria-label={tr("Connection progress")}>{tr(steps.map((label, i) => <li key={label} data-current={step === i || undefined} data-complete={step > i || undefined}><button disabled={i >= step || pending} aria-current={step === i ? 'step' : undefined} onClick={() => { setError(''); setStep(i); }}><span>{tr(step > i ? <Icon name="check" size={13}/> : i + 1)}</span><small>{tr(label)}</small></button></li>))}</ol> : null)}
|
||||
@@ -92,9 +92,9 @@ export function CloudflareDialog({ open, onClose, onCreateKey }: {
|
||||
(e.currentTarget.parentElement?.children[mode === 'quick' ? 1 : 0] as HTMLElement)?.focus();
|
||||
} }} onClick={() => setMode(choice.value)}><Icon name={choice.icon} size={20}/><span><strong>{tr(choice.title)}</strong><small>{tr(choice.detail)}</small></span><span className="tunnel-choice-dot">{tr(mode === choice.value ? <Icon name="check" size={13}/> : null)}</span></button>))}</div></> : null)}
|
||||
{tr(step === 1 ? <div className="tunnel-automatic" data-ready={installed || undefined} aria-live="polite" aria-busy={pending}><span className="tunnel-stage__symbol"><Icon name={installed ? 'check' : error ? 'warning' : 'refresh'} size={28}/></span><p className="small muted">{tr(installed ? 'Your Cloudflare connector is installed and ready.' : error ? 'Automatic setup needs another try.' : 'Strata downloads and sets up the Cloudflare connector for you.')}</p>{tr(installed ? <>{tr(tunnel?.connector ? <p className="xs muted">{tr("Using {0}", [tunnel.connector.summary])}</p> : null)}{tr(other ? <p className="xs muted" role="status">{tr(other.others.length === 1 && !other.own.length ? "cloudflared is already running for another tunnel (PID {0})." : other.own.length ? "Strata’s tunnel for this address is already running (PID {0})." : "cloudflared is already running for other tunnels (PIDs {0}).", [(other.own.length ? other.own : other.others).join(", ")])}</p> : null)}</> : error ? <Btn onClick={() => setRetry(v => v + 1)}>{tr("Retry setup")}</Btn> : <span className="small muted" role="status">{tr("Setting up\u2026")}</span>)}</div> : null)}
|
||||
{tr(step === 2 ? <><p className="small muted">{tr("Only apps with your Strata API key can use this connection.")}</p>{tr(keyReady ? <div className="tunnel-check"><Icon name="shield"/><div><strong>{tr("API key ready")}</strong><span>{tr("Use your existing key in the app you connect.")}</span></div></div> : <div className="tunnel-key-step"><Icon name="shield" size={30}/><p className="small">{tr("Create a key to protect your API. You\u2019ll return here when it\u2019s ready.")}</p><Btn onClick={onCreateKey}>{tr("Create an API key")}</Btn></div>)}</> : null)}
|
||||
{tr(step === 2 ? <><p className="small muted">{tr(keyReady ? "Only apps with your Strata API key can use this connection." : "Without an API key, anyone who finds the link can use your model.")}</p>{tr(keyReady ? <div className="tunnel-check"><Icon name="shield"/><div><strong>{tr("API key ready")}</strong><span>{tr("Use your existing key in the app you connect.")}</span></div></div> : <div className="tunnel-key-step"><Icon name="shield" size={30}/><p className="small">{tr("Create a key to protect your API. You\u2019ll return here when it\u2019s ready. You can also continue without one, but anyone who finds the link can then use your model.")}</p><Btn onClick={onCreateKey}>{tr("Create an API key (recommended)")}</Btn></div>)}</> : null)}
|
||||
{tr(mode === 'domain' && step === 3 ? <><p className="small muted">{tr("Create a tunnel in your ")}<a href="https://dash.cloudflare.com/" target="_blank" rel="noreferrer">{tr("Cloudflare dashboard \u2197")}</a>{tr(". Copy the token from its connector setup.")}</p><label className="input-field"><span>{tr("Tunnel token")}</span><input type="password" className="st-input" autoComplete="off" spellCheck={false} value={token} onChange={e => setToken(e.target.value)} placeholder={tr("Paste only the token after --token")}/></label><label className="input-field"><span>{tr("Your hostname")}</span><input className="st-input" autoComplete="off" spellCheck={false} value={hostname} onChange={e => setHostname(e.target.value)} placeholder={tr("ai.example.com")}/></label><div><p className="small muted">{tr("Add a published application route for this hostname with this service URL:")}</p><div className="install-command"><code>{tunnel?.originUrl ?? 'Loading…'}</code><IconBtn name="copy" title={tr("Copy tunnel service URL")} disabled={!tunnel?.originUrl} onClick={() => void navigator.clipboard.writeText(tunnel!.originUrl)}/></div></div><p className="xs muted">{tr("The token is used for this connection only and is not saved.")}</p></> : null)}
|
||||
{tr(step === last ? <><p className="small muted">{tr(mode === 'quick' ? 'Cloudflare will create your address when you connect.' : 'Start the connector for the domain you configured.')}</p><dl className="tunnel-review"><div><dt>{tr("Address")}</dt><dd>{tr(mode === 'quick' ? 'Free temporary URL' : hostname)}</dd></div><div><dt>{tr("Connector")}</dt><dd><Icon name="check" size={14}/>{tr(tunnel?.connector?.version ? "Installed · cloudflared {0}" : "Installed", tunnel?.connector?.version ? [tunnel.connector.version] : undefined)}</dd></div><div><dt>{tr("Access")}</dt><dd><Icon name="shield" size={14}/>{tr("API key required")}</dd></div></dl><p className="small muted">{tr(mode === 'quick' ? 'The address changes each time. Turn off streaming in your client; replies arrive all at once.' : 'Use this hostname and your Strata API key in your client. Streaming is supported.')}</p><p className="xs muted">{tr("Only the inference API is shared. Your computer and Strata must stay on.")}</p></> : null)}
|
||||
{tr(step === last ? <><p className="small muted">{tr(mode === 'quick' ? 'Cloudflare will create your address when you connect.' : 'Start the connector for the domain you configured.')}</p><dl className="tunnel-review"><div><dt>{tr("Address")}</dt><dd>{tr(mode === 'quick' ? 'Free temporary URL' : hostname)}</dd></div><div><dt>{tr("Connector")}</dt><dd><Icon name="check" size={14}/>{tr(tunnel?.connector?.version ? "Installed · cloudflared {0}" : "Installed", tunnel?.connector?.version ? [tunnel.connector.version] : undefined)}</dd></div><div><dt>{tr("Access")}</dt><dd><Icon name="shield" size={14}/>{tr(keyReady ? "API key required" : "No API key: anyone with the link can use it")}</dd></div></dl><p className="small muted">{tr(mode === 'quick' ? 'The address changes each time. Turn off streaming in your client; replies arrive all at once.' : 'Use this hostname and your Strata API key in your client. Streaming is supported.')}</p><p className="xs muted">{tr("Only the inference API is shared. Your computer and Strata must stay on.")}</p></> : null)}
|
||||
</section>)}
|
||||
{tr(error || tunnel?.error ? <ErrorNotice message={error || tunnel?.error || 'Connection failed'} next={tr("Check your internet connection, then retry.")}/> : null)}
|
||||
</div>
|
||||
|
||||
@@ -8,5 +8,6 @@ export const engineMissing = (native: NativeState | null | undefined) => !!nativ
|
||||
|
||||
export function EngineWarning({ native }: { native: NativeState | null | undefined }) {
|
||||
if (!engineMissing(native)) return null;
|
||||
if (native?.engine?.wrongMake && native.engine.notice) return <div className="engine-warning" role="status"><Icon name="info" size={18}/><div><p>{native.engine.notice}</p></div></div>;
|
||||
return <div className="engine-warning" role="status"><Icon name="info" size={18}/><div><p>{tr("Strata didn’t recognise an engine for this computer. The model will download, but it may not run until the right engine is installed (Settings → Engine).")}</p>{native?.engine?.notice ? <small className="muted">{native.engine.notice}</small> : null}</div></div>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { installProgress, installStage, stageText, useDownloadEta } from '../lib
|
||||
import { VersionPicker } from './VersionPicker';
|
||||
import { DownloadParts } from './DownloadParts';
|
||||
import { EngineWarning } from './EngineWarning';
|
||||
import { recommendedVersions } from '../lib/native-fit';
|
||||
import { OTHER_NOTE, PICK_NOTE, recommendedVersions } from '../lib/native-fit';
|
||||
import { nativeCatalogModels, catalogVersionLabel } from '../lib/native-catalog';
|
||||
type Choices = NonNullable<NonNullable<SetupState['choices']>['onboarding']>;
|
||||
function radioKeys(event: KeyboardEvent<HTMLDivElement>) { if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key))
|
||||
@@ -78,6 +78,19 @@ export function GuidedSetup() {
|
||||
throw e;
|
||||
} });
|
||||
}
|
||||
/* Files the person already has are checked and adopted by the "Use a local GGUF" page, not made the data folder (that downloaded them again, #3507). */
|
||||
async function ownModel(path?: string) {
|
||||
await perform(async () => { leaving.current = true; if (saveTimer.current)
|
||||
clearTimeout(saveTimer.current); try {
|
||||
await persist(step, { manual: true }, true);
|
||||
openSetup(false);
|
||||
navigate('models', 'local', path ? { path } : undefined);
|
||||
}
|
||||
catch (e) {
|
||||
leaving.current = false;
|
||||
throw e;
|
||||
} });
|
||||
}
|
||||
async function finish() {
|
||||
if (leaving.current)
|
||||
return;
|
||||
@@ -165,12 +178,14 @@ export function GuidedSetup() {
|
||||
} });
|
||||
return () => { cancelled = true; };
|
||||
}, [step, plan, preparing, native?.available, version?.id, context, directory]);
|
||||
const recommendedCards = recommendedVersions(selectedFamily?.sizes ?? []).map(x => x.v);
|
||||
const recommendedRows = recommendedVersions(selectedFamily?.sizes ?? [], native?.catalog?.recommendation);
|
||||
const recommendedCards = recommendedRows.map(x => x.v);
|
||||
const pickNote = (v: { id: string }) => recommendedRows.find(x => x.v.id === v.id && [PICK_NOTE, OTHER_NOTE].includes(x.r.note))?.r.note ?? '';
|
||||
// The recommended four first, then a list with every other version (the Models page's version list); a family without a recommended set shows them all.
|
||||
const cards = recommendedCards.length ? recommendedCards : selectedFamily?.sizes ?? [];
|
||||
const others = recommendedCards.length ? (selectedFamily?.sizes ?? []).filter(v => !recommendedCards.includes(v)) : [];
|
||||
const readyIds = new Set((native?.installed?.models ?? []).filter(m => m.ready).map(m => m.model));
|
||||
const versionCard = (v: NonNullable<typeof version>) => <button key={v.id} type="button" role="radio" tabIndex={version?.id === v.id ? 0 : -1} aria-checked={version?.id === v.id} data-selected={version?.id === v.id} onClick={() => { setSize(v.id); setPlan(null); }}><span className="onboard-version-top"><span className="onboard-version-copy"><strong>{tr(catalogVersionLabel(v))}</strong><small>{tr(v.model)}</small></span><span className="onboard-version-radio">{tr(version?.id === v.id ? <Icon name="check" size={12}/> : null)}</span></span><span className="onboard-version-bottom"><span className="onboard-version-size"><Icon name="download" size={15}/>{tr(v.download_gb.toFixed(1))} <small>{tr("GB")}</small></span><span className="onboard-version-fit">{tr(v.on_this_pc === 'does not fit' || v.on_this_pc === 'tight' || v.on_this_pc?.includes('slower') || v.on_this_pc?.startsWith('experimental:') ? <><Icon name="clock" size={13}/>{tr("May run slower")}</> : v.installed ? <><Icon name="check" size={13}/>{tr("On this computer")}</> : <>{tr("Download size")}</>)}</span></span></button>;
|
||||
const versionCard = (v: NonNullable<typeof version>) => <button key={v.id} type="button" role="radio" tabIndex={version?.id === v.id ? 0 : -1} aria-checked={version?.id === v.id} data-selected={version?.id === v.id} onClick={() => { setSize(v.id); setPlan(null); }}><span className="onboard-version-top"><span className="onboard-version-copy"><strong>{tr(catalogVersionLabel(v))}</strong><small>{tr(v.model)}{tr(pickNote(v) ? <> · {tr(pickNote(v))}</> : null)}</small></span><span className="onboard-version-radio">{tr(version?.id === v.id ? <Icon name="check" size={12}/> : null)}</span></span><span className="onboard-version-bottom"><span className="onboard-version-size"><Icon name="download" size={15}/>{tr(v.download_gb.toFixed(1))} <small>{tr("GB")}</small></span><span className="onboard-version-fit">{tr(v.on_this_pc === 'does not fit' || v.on_this_pc === 'tight' || v.on_this_pc?.includes('slower') || v.on_this_pc?.startsWith('experimental:') ? <><Icon name="clock" size={13}/>{tr("May run slower")}</> : v.installed ? <><Icon name="check" size={13}/>{tr("On this computer")}</> : <>{tr("Download size")}</>)}</span></span></button>;
|
||||
const counter = downloadCounter(plan?.plan?.download_gb ?? version?.download_gb, progress, preparing);
|
||||
const eta = useDownloadEta(preparing ? progress : undefined, installJob?.id);
|
||||
const title = step === 1 ? 'State of the art. At home.' : step === 2 ? 'Select the model you want to run.' : step === 3 ? 'Downloading the model...' : tuneJob?.state === 'running' ? 'A little tuning. A better fit.' : 'How you would like to work with Strata?';
|
||||
@@ -190,9 +205,9 @@ export function GuidedSetup() {
|
||||
<OnboardingMemory hardware={hardware} native={native?.hardware}/>{tr(!version || version.needsNewerEngine ? <p className="small muted">{tr("This model needs a newer engine. Update your engine or import a local GGUF for this model.")}</p> : null)}
|
||||
<div className="onboard-version-heading"><strong>{tr("Choose a version")}</strong><span>{tr(version ? `${catalogVersionLabel(version)} selected` : '')}</span></div>
|
||||
<div className="onboard-versions" role="radiogroup" aria-label={tr("Model version")} onKeyDown={radioKeys}>{tr(cards.map(v => versionCard(v)))}</div>
|
||||
{tr(others.length ? <div className="onboard-more-versions"><VersionPicker versions={others} selectedId={version?.id ?? ''} hardware={hardware ? { ram_gb: native?.hardware?.ram_gb ?? hardware.memory.totalGb, vram_gb: Math.max(0, ...(native?.hardware?.gpus ?? []).filter(g => g.usable !== false).map(g => g.vram_gb)) } : null} downloaded={readyIds} showRecommended={false} moreLabel="More versions" onSelect={id => { setSize(id); setPlan(null); }} onOwnGguf={() => setFolderOpen(true)}/></div> : null)}
|
||||
{tr(others.length ? <div className="onboard-more-versions"><VersionPicker versions={others} selectedId={version?.id ?? ''} hardware={hardware ? { ram_gb: native?.hardware?.ram_gb ?? hardware.memory.totalGb, vram_gb: Math.max(0, ...(native?.hardware?.gpus ?? []).filter(g => g.usable !== false).map(g => g.vram_gb)) } : null} downloaded={readyIds} showRecommended={false} moreLabel="More versions" onSelect={id => { setSize(id); setPlan(null); }} onOwnGguf={() => void ownModel()}/></div> : null)}
|
||||
<div className="onboard-folder"><Icon name="folder" size={20}/><div><strong>{tr(directory ? 'Your model folder' : 'Already downloaded the model?')}</strong><small>{tr(directory || "Choose the folder and we'll start your models with Strata.")}</small></div><Btn size="sm" onClick={() => setFolderOpen(v => !v)}>{tr(folderOpen ? 'Close' : directory ? 'Change' : 'Choose folder')}</Btn></div>
|
||||
{tr(folderOpen ? <OnboardingFolder directory={folder} onChoose={path => void perform(async () => { await nativeAction('directory', { directory: path }); setDirectory(path); setFolderOpen(false); setPlan(null); })}/> : null)}
|
||||
{tr(folderOpen ? <OnboardingFolder directory={folder} onChoose={(path, ggufFiles) => ggufFiles > 0 && !/strata/i.test(path.split(/[\\/]/).filter(Boolean).pop() ?? '') ? void ownModel(path) : void perform(async () => { await nativeAction('directory', { directory: path }); setDirectory(path); setFolderOpen(false); setPlan(null); })}/> : null)}
|
||||
</> : <div className="onboard-engine-connect"><Icon name="models" size={24}/><strong>{tr("Connect your Strata installation")}</strong><Btn onClick={() => void skip(false, 'Engine')}>{tr("Connect engine")}</Btn></div>)}
|
||||
</div> : step === 3 ? <div className="onboard-prepare-stage">
|
||||
<section className="onboard-download"><EngineWarning native={native}/><div className="onboard-download-identity"><Icon name="models" size={19}/><strong>{tr(selectedFamily?.title)}</strong><span>{tr(version ? catalogVersionLabel(version) : '')}</span><small>{tr(version?.model)}</small></div><OnboardingArt kind="download" progress={progress?.percent ?? 0} counter={counter}/><div className="onboard-download-info">
|
||||
@@ -226,7 +241,7 @@ function OnboardingError({ message, onFolder }: {
|
||||
}
|
||||
function OnboardingFolder({ directory, onChoose }: {
|
||||
directory: string;
|
||||
onChoose: (path: string) => void;
|
||||
onChoose: (path: string, ggufFiles: number) => void;
|
||||
}) {
|
||||
const [path, setPath] = useState(directory), [folder, setFolder] = useState<LocalModelFolder | null>(null), [pending, setPending] = useState(false), [error, setError] = useState('');
|
||||
async function browse(input: string, initial = false) { setPending(true); setError(''); try {
|
||||
@@ -250,5 +265,5 @@ function OnboardingFolder({ directory, onChoose }: {
|
||||
setPending(false);
|
||||
} }
|
||||
useEffect(() => { void browse(directory, true); }, []);
|
||||
return <section className="onboard-folder-browser" aria-label={tr("Choose your model folder")}><form onSubmit={e => { e.preventDefault(); void browse(path); }}><input className="st-input" aria-label={tr("Existing model folder")} value={path} onChange={e => { setPath(e.target.value); setFolder(null); }} placeholder={tr("Full folder path")} spellCheck={false}/><Btn type="submit" size="sm" disabled={pending || !path.trim()}>{tr(pending ? 'Checking…' : 'Open')}</Btn></form><p>{tr("Choose the Strata data folder that holds your model downloads. Matching files are reused; missing files are prepared automatically.")}</p><div className="onboard-folder-shortcuts">{folder?.shortcuts?.map(item => <Btn key={item.path} size="sm" disabled={pending} onClick={() => void browse(item.path)}>{tr(item.name)}</Btn>)}</div><div className="onboard-folder-files">{tr(folder?.parent ? <button onClick={() => void browse(folder.parent!)}><Icon name="folder" size={16}/>{tr(" Up a folder")}</button> : null)}{folder?.entries.filter(e => e.kind === 'folder').slice(0, 30).map(e => <button key={e.path} onClick={() => void browse(e.path)}><Icon name="folder" size={16}/>{tr(e.name)}<Icon name="chevron" size={12}/></button>)}{tr(folder ? <small>{tr(folder.entries.filter(e => e.kind === 'model').length)}{tr(" GGUF files in this folder. Nested downloads are checked by the installer.")}</small> : null)}</div>{tr(folder ? <Btn variant="primary" size="sm" disabled={pending} onClick={() => onChoose(folder.directory)}>{tr("Use this folder")}</Btn> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>;
|
||||
return <section className="onboard-folder-browser" aria-label={tr("Choose your model folder")}><form onSubmit={e => { e.preventDefault(); void browse(path); }}><input className="st-input" aria-label={tr("Existing model folder")} value={path} onChange={e => { setPath(e.target.value); setFolder(null); }} placeholder={tr("Full folder path")} spellCheck={false}/><Btn type="submit" size="sm" disabled={pending || !path.trim()}>{tr(pending ? 'Checking…' : 'Open')}</Btn></form><p>{tr("Choose the Strata data folder that holds your model downloads. Matching files are reused; missing files are prepared automatically.")}</p><div className="onboard-folder-shortcuts">{folder?.shortcuts?.map(item => <Btn key={item.path} size="sm" disabled={pending} onClick={() => void browse(item.path)}>{tr(item.name)}</Btn>)}</div><div className="onboard-folder-files">{tr(folder?.parent ? <button onClick={() => void browse(folder.parent!)}><Icon name="folder" size={16}/>{tr(" Up a folder")}</button> : null)}{folder?.entries.filter(e => e.kind === 'folder').slice(0, 30).map(e => <button key={e.path} onClick={() => void browse(e.path)}><Icon name="folder" size={16}/>{tr(e.name)}<Icon name="chevron" size={12}/></button>)}{tr(folder ? <small>{tr(folder.entries.filter(e => e.kind === 'model').length)}{tr(" GGUF files in this folder. Nested downloads are checked by the installer.")}</small> : null)}</div>{tr(folder ? <Btn variant="primary" size="sm" disabled={pending} onClick={() => onChoose(folder.directory, folder.entries.filter(e => e.kind === 'model').length)}>{tr("Use this folder")}</Btn> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>;
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import { tr } from '../lib/i18n';
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
import { Btn, IconBtn } from './ui';
|
||||
import { SyntaxCode } from './SyntaxCode';
|
||||
function inline(text: string, onOpenLink?: (url: string) => void): ReactNode {
|
||||
import { MathView } from './MathView';
|
||||
import { displayBlock, hasMath, splitMath } from '../lib/math';
|
||||
function inlineMarks(text: string, onOpenLink?: (url: string) => void): ReactNode {
|
||||
return text.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*\n]+\*|\[[^\]]+\]\([^\s)]+\))/g).map((part, i) => {
|
||||
if (part.startsWith('`') && part.endsWith('`'))
|
||||
return <code key={i} className="md-code">{part.slice(1, -1)}</code>;
|
||||
@@ -20,6 +22,18 @@ function inline(text: string, onOpenLink?: (url: string) => void): ReactNode {
|
||||
return <Fragment key={i}>{part}</Fragment>;
|
||||
});
|
||||
}
|
||||
/** Text with its inline marks and, outside code spans, its math (#3268). Math is cut out first: a * or _ inside a formula is not emphasis. */
|
||||
function inline(text: string, onOpenLink?: (url: string) => void): ReactNode {
|
||||
if (!hasMath(text))
|
||||
return inlineMarks(text, onOpenLink);
|
||||
return text.split(/(`[^`]+`)/g).map((chunk, c) => {
|
||||
if (chunk.length > 1 && chunk.startsWith('`') && chunk.endsWith('`'))
|
||||
return <Fragment key={c}>{inlineMarks(chunk, onOpenLink)}</Fragment>;
|
||||
return <Fragment key={c}>{splitMath(chunk).map((part, p) => part.kind === 'math'
|
||||
? <MathView key={p} tex={part.tex} display={part.display}/>
|
||||
: <Fragment key={p}>{inlineMarks(part.text, onOpenLink)}</Fragment>)}</Fragment>;
|
||||
});
|
||||
}
|
||||
export function Markdown({ text, onOpenLink, onPreviewHtml }: {
|
||||
text: string;
|
||||
onOpenLink?: (url: string) => void;
|
||||
@@ -43,6 +57,12 @@ export function Markdown({ text, onOpenLink, onPreviewHtml }: {
|
||||
nodes.push(<div className="st-code" key={key}><div className="st-code__head"><span>{language || tr('code')}</span>{tr(/^html$/i.test(language) && onPreviewHtml ? <Btn size="sm" onClick={() => onPreviewHtml(body.join('\n'))}>{tr("Open preview")}</Btn> : null)}<IconBtn name="copy" title={tr("Copy code")} onClick={() => void navigator.clipboard.writeText(body.join('\n'))}/></div><SyntaxCode text={body.join('\n')} language={language}/></div>);
|
||||
continue;
|
||||
}
|
||||
const block = displayBlock(lines, i);
|
||||
if (block) {
|
||||
nodes.push(<div className="md-math-block" key={i}><MathView tex={block.tex.trim()} display/></div>);
|
||||
i += block.used;
|
||||
continue;
|
||||
}
|
||||
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (heading) {
|
||||
const Tag = heading[1].length <= 2 ? 'h2' : heading[1].length === 3 ? 'h3' : 'h4';
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* One formula of an answer (#3268), drawn by KaTeX. KaTeX and its fonts are bundled with the app (no network) and load as a separate
|
||||
* chunk the first time an answer holds math, so a chat without math pays nothing. Until the chunk is there, and if it cannot load,
|
||||
* the formula shows as its source text, as before.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Katex = typeof import('katex').default;
|
||||
let loading: Promise<Katex> | null = null;
|
||||
const drawn = new Map<string, string>();
|
||||
|
||||
function loadKatex(): Promise<Katex> {
|
||||
loading ??= Promise.all([import('katex'), import('katex/dist/katex.min.css')]).then(([module]) => module.default);
|
||||
return loading;
|
||||
}
|
||||
|
||||
function draw(katex: Katex, tex: string, display: boolean): string {
|
||||
const key = (display ? 'D' : 'I') + tex;
|
||||
const known = drawn.get(key);
|
||||
if (known !== undefined)
|
||||
return known;
|
||||
// trust stays off (no \href, \url, \includegraphics); a broken formula is shown as its source, not as an error page
|
||||
const html = katex.renderToString(tex, { displayMode: display, throwOnError: false, strict: 'ignore', trust: false, maxSize: 30, maxExpand: 500 });
|
||||
if (drawn.size > 400)
|
||||
drawn.clear();
|
||||
drawn.set(key, html);
|
||||
return html;
|
||||
}
|
||||
|
||||
const source = (tex: string, display: boolean) => (display ? `$$${tex}$$` : `$${tex}$`);
|
||||
|
||||
export function MathView({ tex, display }: { tex: string; display: boolean }) {
|
||||
const [html, setHtml] = useState<string | null>(() => drawn.get((display ? 'D' : 'I') + tex) ?? null);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
loadKatex().then(katex => { if (live) setHtml(draw(katex, tex, display)); }).catch(() => { /* stays as source text */ });
|
||||
return () => { live = false; };
|
||||
}, [tex, display]);
|
||||
const cls = display ? 'md-math md-math--display' : 'md-math';
|
||||
if (html === null)
|
||||
return <code className={cls + ' md-math--source'}>{source(tex, display)}</code>;
|
||||
return <span className={cls} dangerouslySetInnerHTML={{ __html: html }}/>;
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { tr, localeTag } from '../lib/i18n';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Bar, Btn, Dialog, Icon } from './ui';
|
||||
import { api, ApiError } from '../lib/client';
|
||||
import { nativeAction, refreshNative, useNative, type NativeJob, type NativeState } from '../lib/native';
|
||||
import { nativeShortName } from '../lib/native-names';
|
||||
import { sizeGb } from '../lib/format';
|
||||
|
||||
type Choice = 'move' | 'keep' | 'empty';
|
||||
interface Plan {
|
||||
ok: boolean;
|
||||
issue: { code: string; message: string; details: string } | null;
|
||||
old: string; new: string; same_drive: boolean;
|
||||
free_bytes: number; models_bytes: number; needed_bytes: number;
|
||||
models: { group: string; name: string; bytes: number }[];
|
||||
choices: Record<Choice, { possible: boolean; reason: string }>;
|
||||
folder: string; picked: string; wholeDrive: boolean; shown: string; current: string; running: number; busy: boolean;
|
||||
}
|
||||
interface Outcome { done?: boolean; stopped?: boolean; failed?: boolean; code?: string; moved?: string[]; remaining?: string[]; bytes?: number; total?: number; from?: string; to?: string; sameDrive?: boolean }
|
||||
|
||||
const gb = (bytes: number) => sizeGb(bytes / 1e9);
|
||||
const driveOf = (path: string) => /^[A-Za-z]:/.test(path) ? path.slice(0, 2) : path;
|
||||
const list = (items: string[]) => { try { return new Intl.ListFormat(localeTag(), { type: 'conjunction' }).format(items); } catch { return items.join(', '); } };
|
||||
|
||||
/** The model a folder name belongs to, in the words the Storage list uses ("Gyro-S"); the draft layer and loose files have no name of their own. */
|
||||
function modelName(group: string, native: NativeState | null) {
|
||||
const hit = native?.installed?.models.find(m => m.model.toLowerCase() === group.toLowerCase());
|
||||
return hit ? nativeShortName(hit.model) : null;
|
||||
}
|
||||
|
||||
function timeLeft(seconds: number) {
|
||||
if (seconds <= 0) return null;
|
||||
if (seconds < 60) return tr('Less than a minute left');
|
||||
const minutes = Math.ceil(seconds / 60);
|
||||
if (minutes <= 1) return tr('About a minute left');
|
||||
if (minutes < 120) return tr('About {0} minutes left', [minutes]);
|
||||
return tr('About {0} hours left', [Math.ceil(minutes / 60)]);
|
||||
}
|
||||
|
||||
function Failure({ message, next, details }: { message: string; next?: string; details?: string }) {
|
||||
return <div className="error-notice" role="alert"><Icon name="error" size={18}/><div><strong>{tr(message)}</strong>{next ? <p>{tr(next)}</p> : null}{details ? <details className="error-detail"><summary className="small">{tr('Details')}</summary><pre className="small muted">{details}</pre></details> : null}</div></div>;
|
||||
}
|
||||
|
||||
/** Settings > Storage > Models folder: the card, the folder dialog with its three choices, the progress of a move and what came of it. */
|
||||
export function ModelsFolderCard({ blocked }: { blocked: boolean }) {
|
||||
const native = useNative();
|
||||
const [plan, setPlan] = useState<Plan | null>(null);
|
||||
const [stage, setStage] = useState<'issue' | 'drive' | 'choose' | null>(null);
|
||||
const [choice, setChoice] = useState<Choice>('move');
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<{ message: string; details?: string } | null>(null);
|
||||
const [typing, setTyping] = useState(false), [typed, setTyped] = useState('');
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [outcome, setOutcome] = useState<{ job: NativeJob } | null>(null);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [unfinished, setUnfinished] = useState<{ from: string; to: string } | null>(null);
|
||||
const [resumed, setResumed] = useState<{ from: string; to: string } | null>(null);
|
||||
const folder = native?.downloadDirectory ?? '';
|
||||
const free = native?.downloadFreeBytes ?? null;
|
||||
const usage = native?.installed?.usage?.total_bytes ?? 0;
|
||||
const moving = native?.jobs.find(j => j.kind === 'models-move' && j.state === 'running') ?? null;
|
||||
const finished = jobId ? native?.jobs.find(j => j.id === jobId && j.kind === 'models-move' && j.state !== 'running') : undefined;
|
||||
useEffect(() => { if (finished && !outcome) { setOutcome({ job: finished }); setStopping(false); void refreshNative(true); } }, [finished, outcome]);
|
||||
const total = native?.downloadTotalBytes ?? null;
|
||||
const used = free !== null && total ? Math.min(100, Math.max(1, ((total - free) / total) * 100)) : 0;
|
||||
useEffect(() => { if (moving && !jobId) setJobId(moving.id); }, [moving, jobId]);
|
||||
|
||||
/* A move that a crash or a stop left half done: found again at the next start, with its two choices. */
|
||||
useEffect(() => {
|
||||
if (moving || outcome) return;
|
||||
let live = true;
|
||||
api.get<{ interrupted: { from: string; to: string } | null }>('/api/native/models-folder/interrupted')
|
||||
.then(r => { if (live) setUnfinished(r.interrupted); }).catch(() => { if (live) setUnfinished(null); });
|
||||
return () => { live = false; };
|
||||
}, [moving, outcome, folder]);
|
||||
async function finishMove() {
|
||||
if (!unfinished) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.post<NativeJob>('/api/native/models-folder/resume', { stopRunning: true });
|
||||
setResumed(unfinished);
|
||||
setUnfinished(null);
|
||||
if (result.id) { setJobId(result.id); setOutcome(null); await refreshNative(true); }
|
||||
}
|
||||
catch (e) { setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details }); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
async function leaveMove() {
|
||||
if (!unfinished) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.post('/api/native/models-folder/leave', {});
|
||||
setNotice(tr('The partial copies were removed. Your models are in {0}.', [unfinished.from]));
|
||||
setUnfinished(null);
|
||||
await refreshNative(true);
|
||||
}
|
||||
catch (e) { setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details }); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
|
||||
function leave() { setPlan(null); setStage(null); setError(null); setTyping(false); }
|
||||
async function pick(path = '') {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
setNotice('');
|
||||
try {
|
||||
const result = await api.post<{ cancelled?: boolean; plan?: Plan }>('/api/native/models-folder/plan', { path });
|
||||
if (result.cancelled || !result.plan) return;
|
||||
const next = result.plan;
|
||||
setTyping(false);
|
||||
setPlan(next);
|
||||
if (next.issue?.code === 'same') { setPlan(null); return; }
|
||||
setChoice(next.choices.move.possible ? 'move' : 'keep');
|
||||
setStage(next.issue ? 'issue' : next.wholeDrive ? 'drive' : 'choose');
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof ApiError && e.status === 501) { setTyping(true); setError({ message: e.message }); }
|
||||
else setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details });
|
||||
}
|
||||
finally { setPending(false); }
|
||||
}
|
||||
async function start() {
|
||||
if (!plan) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.post<NativeJob & { done?: boolean; setAside?: string[] }>('/api/native/models-folder/start', { path: plan.picked, choice, stopRunning: true });
|
||||
if (result.id) { setJobId(result.id); setOutcome(null); setStage(null); await refreshNative(true); setPlan(prev => prev); return; }
|
||||
setNotice(choice === 'keep' ? tr('Strata now also uses {0}. New downloads go there, and your current models stay where they are.', [plan.shown])
|
||||
: tr('Strata now uses {0}. Your old model files are still in {1}; Strata does not delete them.', [plan.shown, plan.current]));
|
||||
await refreshNative(true);
|
||||
leave();
|
||||
}
|
||||
catch (e) { setError({ message: (e as Error).message, details: ((e as ApiError).body as { details?: string } | undefined)?.details }); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
async function stop() {
|
||||
if (!moving) return;
|
||||
setStopping(true);
|
||||
try { await nativeAction(`operations/${moving.id}/cancel`); }
|
||||
catch (e) { setError({ message: (e as Error).message }); }
|
||||
}
|
||||
function closeResult() { setOutcome(null); setJobId(null); setResumed(null); leave(); setNotice(''); }
|
||||
|
||||
const progress = moving?.progress as { done: number; total: number; file: string; group: string; index: number; count: number; secondsLeft: number; sameDrive: boolean } | null | undefined;
|
||||
const percent = progress && progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0;
|
||||
const planNames = useMemo(() => (plan?.models ?? []).map(m => ({ ...m, label: modelName(m.group, native) })).filter(m => m.label), [plan, native]);
|
||||
const savedPlan = useMemo(() => plan, [plan]);
|
||||
|
||||
return <>
|
||||
<section className="storage-directory" aria-labelledby="models-folder-title">
|
||||
<div className="storage-directory__heading"><span><Icon name="folder" size={18}/><strong id="models-folder-title">{tr('Models folder')}</strong></span><Btn size="sm" disabled={!folder || blocked || pending || !!moving} onClick={() => void pick()}>{tr(pending && !plan ? 'Waiting for the folder…' : 'Change folder')}</Btn></div>
|
||||
<p className="small muted">{tr('Strata saves the big model files here.')}</p>
|
||||
<code className="storage-directory__path">{folder || tr('Reading model directory…')}</code>
|
||||
{free !== null ? <div className="models-free"><Bar value={used} label={tr('Disk use')}/><div className="models-free__row small"><span><strong>{tr('{0} free', [gb(free)])}</strong> {tr('on this drive')}</span><span className="muted">{tr('Your models use {0}', [gb(usage)])}</span></div></div> : null}
|
||||
{blocked && !moving ? <p className="small muted">{tr('Finish or stop the download to change the folder.')}</p> : null}
|
||||
{typing ? <form className="storage-directory__edit" onSubmit={e => { e.preventDefault(); if (typed.trim()) void pick(typed.trim()); }}>
|
||||
<label htmlFor="models-folder-path" className="small">{tr('Directory path')}</label>
|
||||
<input id="models-folder-path" className="st-input" value={typed} onChange={e => setTyped(e.target.value)} placeholder={tr('Full path to an existing folder')} spellCheck={false} autoFocus/>
|
||||
<div className="row"><Btn variant="primary" type="submit" disabled={pending || !typed.trim()}>{tr('Use this directory')}</Btn></div>
|
||||
</form> : null}
|
||||
{unfinished && !moving ? <div className="models-notice models-notice--info" role="alert"><Icon name="info" size={18}/><div>
|
||||
<strong>{tr('A move was interrupted. Finish it or leave it.')}</strong><br/>
|
||||
{tr('Strata was moving your models from {0} to {1} when it stopped. Nothing is lost.', [unfinished.from, unfinished.to])}
|
||||
<p className="small muted">{tr('Finishing puts all your models in {1}. Leaving it keeps them in {0} and removes the partial copies from {1}.', [unfinished.from, unfinished.to])}</p>
|
||||
<div className="row"><Btn variant="primary" size="sm" disabled={pending || blocked} onClick={() => void finishMove()}>{tr('Finish the move')}</Btn><Btn size="sm" disabled={pending || blocked} onClick={() => void leaveMove()}>{tr('Leave it')}</Btn></div>
|
||||
</div></div> : null}
|
||||
{notice ? <p className="small storage-directory__notice" role="status"><Icon name="check" size={15}/>{notice}</p> : null}
|
||||
{error && !stage ? <Failure message={error.message} details={error.details}/> : null}
|
||||
</section>
|
||||
|
||||
<Dialog className="models-dialog" open={stage === 'issue' && !!plan?.issue} compact title={tr(plan?.issue?.code === 'not_found' ? "That folder isn't there" : "That folder can't be used")} onClose={leave}
|
||||
actions={<>{plan?.issue?.code === 'not_found' ? null : <Btn onClick={leave}>{tr('Cancel')}</Btn>}<Btn variant="primary" onClick={() => { leave(); void pick(); }}>{tr('Choose another folder')}</Btn></>}>
|
||||
{plan?.issue ? <Failure message={plan.issue.message} details={plan.issue.details}
|
||||
next={plan.issue.code === 'not_writable' ? 'Choose a folder you own, for example one in Documents or on another drive.' : plan.issue.code === 'not_found' ? 'Check that the drive is plugged in, then pick the folder again. Your models were not touched.' : undefined}/> : null}
|
||||
</Dialog>
|
||||
|
||||
<Dialog className="models-dialog" open={stage === 'drive' && !!plan} compact title={tr('Use this folder for your models?')} onClose={leave}
|
||||
actions={<><Btn onClick={leave}>{tr('Cancel')}</Btn><Btn variant="primary" onClick={() => setStage('choose')}>{tr('Continue')}</Btn></>}>
|
||||
{plan ? <>
|
||||
<div className="models-picked"><span className="small muted">{tr('New folder')}</span><code className="storage-directory__path">{plan.shown}</code><span className="small muted">{tr('{0} free on this drive', [gb(plan.free_bytes)])}</span></div>
|
||||
<div className="models-notice models-notice--info"><Icon name="info" size={18}/><div>{tr('You picked the whole drive {0}. Strata keeps its files in a folder of their own, so your models go in {1}.', [plan.picked, plan.shown])}</div></div>
|
||||
<p className="small muted">{tr('The three choices come next.')}</p>
|
||||
</> : null}
|
||||
</Dialog>
|
||||
|
||||
<Dialog className="models-dialog" open={stage === 'choose' && !!plan} title={tr('Use this folder for your models?')} onClose={() => { if (!pending) leave(); }}
|
||||
actions={plan ? <>{plan.choices.move.possible ? <Btn disabled={pending} onClick={leave}>{tr('Cancel')}</Btn> : <Btn disabled={pending} onClick={() => { leave(); void pick(); }}>{tr('Choose another folder')}</Btn>}<Btn variant="primary" disabled={pending} onClick={() => void start()}>{tr(pending ? 'Working…' : choice === 'move' ? 'Move my models' : choice === 'keep' ? 'Use both folders' : 'Start empty')}</Btn></> : null}>
|
||||
{plan ? <ChooseBody plan={plan} choice={choice} setChoice={setChoice} names={planNames} error={error}/> : null}
|
||||
</Dialog>
|
||||
|
||||
<Dialog className="models-dialog" open={!!moving && !outcome} compact noClose title={tr('Moving your models')} onClose={() => undefined}
|
||||
actions={<Btn disabled={stopping} onClick={() => void stop()}>{tr(stopping ? 'Stopping…' : 'Stop moving')}</Btn>}>
|
||||
<div className="models-free"><Bar value={percent} label={tr('Progress')}/><div className="models-free__row small"><span><strong>{tr('{0} of {1} GB', [progress ? (progress.done / 1e9 >= 10 ? Math.round(progress.done / 1e9) : (progress.done / 1e9).toFixed(1)) : 0, Math.round((progress?.total ?? 0) / 1e9)])}</strong></span><span className="muted">{progress && !progress.sameDrive ? timeLeft(progress.secondsLeft) : null}</span></div></div>
|
||||
<p className="small">{tr('Keep Strata open. You can keep using other apps. Your models cannot be loaded until the move is done.')}</p>
|
||||
{progress ? <details className="models-details"><summary>{tr('Details')}</summary><ul>
|
||||
<li>{tr('Now copying: {0}, file {1} of {2}', [modelName(progress.group, native) ?? progress.group, progress.index, progress.count])}</li>
|
||||
<li>{tr('From {0} to {1}', [savedPlan?.current ?? resumed?.from ?? '', savedPlan?.folder ?? resumed?.to ?? ''])}</li></ul></details> : null}
|
||||
</Dialog>
|
||||
|
||||
<ResultDialog outcome={outcome?.job ?? null} plan={savedPlan} native={native} onClose={closeResult} onAgain={() => { closeResult(); void pick(); }}/>
|
||||
</>;
|
||||
}
|
||||
|
||||
function ChooseBody({ plan, choice, setChoice, names, error }: { plan: Plan; choice: Choice; setChoice: (c: Choice) => void; names: (Plan['models'][number] & { label: string | null })[]; error: { message: string; details?: string } | null }) {
|
||||
const none = plan.models_bytes === 0;
|
||||
const short = !none && !plan.choices.move.possible;
|
||||
const lacking = Math.max(0, plan.needed_bytes - plan.free_bytes);
|
||||
const options: { id: Choice; title: string; text: string; need: string }[] = [
|
||||
{ id: 'move', title: 'Move my models there', text: none ? 'You have no models to move.' : plan.same_drive ? 'Strata moves your {0} of models to the new folder. This is the same drive, so it takes a moment.' : 'Strata copies your {0} of models to the new folder, checks them, then removes the old copies.',
|
||||
need: none ? '' : plan.same_drive ? 'Needs no extra space.' : 'Needs {0} free in the new folder. You have {1}.' },
|
||||
{ id: 'keep', title: 'Keep them where they are and also use the new folder', text: 'Nothing is copied. Your current models keep working, and new downloads go to the new folder.', need: short ? 'Needs no space now. New downloads must fit in {1}.' : 'Needs no space now.' },
|
||||
{ id: 'empty', title: 'Start empty', text: 'Strata uses the new folder from now on and does not list your current models. Your old files are not deleted.', need: 'Needs no space now. Each model you download later needs its own space.' },
|
||||
];
|
||||
const values = [gb(plan.models_bytes), gb(plan.free_bytes)];
|
||||
const running = plan.running > 0;
|
||||
return <>
|
||||
<div className="models-picked"><span className="small muted">{tr('New folder')}</span><code className="storage-directory__path">{plan.shown}</code><span className="small muted">{tr('{0} free on this drive', [gb(plan.free_bytes)])}</span></div>
|
||||
{short ? <div className="models-notice models-notice--warn" role="alert"><Icon name="warning" size={18}/><div><strong>{tr('Your models need {0}, this drive has {1} free.', [gb(plan.needed_bytes), gb(plan.free_bytes)])}</strong><br/>{tr('Free up about {0} there, pick another folder, or choose one of the other two options.', [gb(lacking)])}</div></div> : null}
|
||||
<fieldset className="models-choices" aria-label={tr('What should happen to your models')}>
|
||||
{options.map(o => { const possible = plan.choices[o.id].possible; return <label className="models-choice" key={o.id} data-disabled={!possible || undefined}>
|
||||
<input type="radio" name="models-folder-choice" checked={choice === o.id} disabled={!possible} onChange={() => setChoice(o.id)}/>
|
||||
<span><strong>{tr(o.title)}</strong>{o.id === 'move' && possible && !none ? <span className="models-choice__tag">{tr('Recommended')}</span> : null}<br/><span className="small">{tr(o.text, o.id === 'move' ? [gb(plan.models_bytes)] : undefined)}</span></span>
|
||||
{o.need ? <span className="models-choice__need">{tr(o.need, o.id === 'move' ? [gb(plan.needed_bytes), gb(plan.free_bytes)] : [values[0], values[1]])}</span> : null}
|
||||
</label>; })}
|
||||
</fieldset>
|
||||
{running ? <p className="small muted">{tr('A running model is stopped first; load it again when the move is done.')}</p> : null}
|
||||
{error ? <Failure message={error.message} details={error.details}/> : null}
|
||||
<details className="models-details"><summary>{tr('Details')}</summary><ul>
|
||||
{names.length ? <li>{tr('{0} would move.', [list(names.map(m => `${m.label}, ${gb(m.bytes)}`))])}</li> : null}
|
||||
{short ? <li>{tr('Free space is measured on the drive that holds {0}.', [plan.shown])}</li> : <>
|
||||
<li>{tr('Old folder: {0}', [plan.current])}</li>
|
||||
<li>{tr('Strata copies one file at a time and checks its size before it removes the old one.')}</li>
|
||||
<li>{tr('You can stop at any time; files already moved stay usable.')}</li></>}
|
||||
</ul></details>
|
||||
</>;
|
||||
}
|
||||
|
||||
function ResultDialog({ outcome, plan, native, onClose, onAgain }: { outcome: NativeJob | null; plan: Plan | null; native: NativeState | null; onClose: () => void; onAgain: () => void }) {
|
||||
if (!outcome) return null;
|
||||
const result = (outcome.result ?? {}) as Outcome;
|
||||
const sized = (groups: string[]) => list(groups.map(g => { const m = plan?.models.find(x => x.group === g); const name = modelName(g, native); return name ? (m ? `${name} (${gb(m.bytes)})` : name) : null; }).filter((x): x is string => !!x));
|
||||
if (outcome.state === 'error') {
|
||||
const full = result.code === 'disk_full';
|
||||
const where = driveOf(result.to ?? '');
|
||||
const some = (result.moved?.length ?? 0) > 0;
|
||||
return <Dialog open compact title={tr(full ? 'The drive ran out of space' : 'The move stopped')} onClose={onClose}
|
||||
actions={<><Btn onClick={onClose}>{tr('Close')}</Btn><Btn variant="primary" onClick={onAgain}>{tr('Choose another folder')}</Btn></>}>
|
||||
<div className="error-notice" role="alert"><Icon name="error" size={18}/><div>
|
||||
<strong>{full ? tr('{0} filled up after {1}, so the move stopped.', [where, gb(result.bytes ?? 0)]) : tr(outcome.error ?? 'The models could not be moved.')}</strong>
|
||||
<p>{full ? (some ? tr('Nothing was lost. Models already moved are in the new folder, the rest are still in {0}, and the half-copied file was removed. Free up space on {1} or choose another folder, then try again.', [result.from ?? '', where])
|
||||
: tr('Nothing was lost. All your models are still in {0}, and the half-copied file was removed. Free up space on {1} or choose another folder, then try again.', [result.from ?? '', where]))
|
||||
: tr('Nothing was lost. Your models that were not moved are still in {0}. Choose another folder to try again.', [result.from ?? ''])}</p>
|
||||
{outcome.details ? <details className="error-detail"><summary className="small">{tr('Details')}</summary><pre className="small muted">{outcome.details}</pre></details> : null}
|
||||
</div></div>
|
||||
</Dialog>;
|
||||
}
|
||||
if (result.stopped) {
|
||||
const moved = sized(result.moved ?? []), left = sized(result.remaining ?? []);
|
||||
return <Dialog open compact title={tr('Moving stopped')} onClose={onClose} actions={<Btn variant="primary" onClick={onClose}>{tr('Close')}</Btn>}>
|
||||
<div className="models-notice models-notice--info"><Icon name="info" size={18}/><div><strong>{tr('Nothing was lost.')}</strong><br/>
|
||||
{moved ? tr('{0} is in the new folder.', [moved]) + ' ' : ''}{left ? tr('{0} is still in the old folder.', [left]) + ' ' : ''}{moved && left ? tr('Both work.') : ''}</div></div>
|
||||
<p className="small muted">{tr('Models in both folders are listed as normal. You can finish moving later with Change folder.')}</p>
|
||||
</Dialog>;
|
||||
}
|
||||
return <Dialog open compact title={tr('Your models are moved')} onClose={onClose} actions={<Btn variant="primary" onClick={onClose}>{tr('Done')}</Btn>}>
|
||||
<div className="models-ok"><span className="models-ok__mark"><Icon name="check" size={20}/></span><div><strong>{(() => { const [before, after] = tr('{0} now live in {1}', [gb(result.total ?? 0), '@@']).split('@@'); return <>{before}<code className="models-path">{plan?.shown ?? result.to ?? ''}</code>{after}</>; })()}</strong>
|
||||
{result.sameDrive ? null : <><br/><span className="small muted">{tr('The old copies were removed and {0} is free again on {1}.', [gb(result.total ?? 0), driveOf(result.from ?? '')])}</span></>}</div></div>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -4,7 +4,9 @@ import { Screen } from './layout';
|
||||
import { VersionPicker } from './VersionPicker';
|
||||
import { FamilySwitch } from './FamilySwitch';
|
||||
import { PcFitCard } from './PcFitCard';
|
||||
import { fitOn } from '../lib/native-fit';
|
||||
import { fitOn, memoryNeeded } from '../lib/native-fit';
|
||||
import { GpuRoles } from './GpuRoles';
|
||||
import { fromLegacy, normalize as normalizeRoles, order as roleOrder, recommend as recommendRoles, rows as roleRows, sameRoles, vramOn, type RoleCard, type Roles } from '../lib/gpu-roles';
|
||||
import { Btn, Dialog, ErrorNotice, Icon, Select } from './ui';
|
||||
import { installProgress, installStage, stageText, useDownloadEta } from '../lib/download-eta';
|
||||
import { useKept } from '../lib/kept-state';
|
||||
@@ -37,8 +39,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
// The choices are kept while the page is closed (Models -> Monitor -> Models used to start them over and a person lost track of what was downloading).
|
||||
const [family, setFamily] = useKept('models.family', ''), [size, setSize] = useKept('models.size', ''), [context, setContext] = useKept('models.context', 32768), [vision, setVision] = useKept('models.vision', 'yes');
|
||||
const manual = useStore(s => s.setup?.choices?.onboarding?.manual ?? false);
|
||||
const [expert, setExpert] = useKept('models.expert', manual), [multiple, setMultiple] = useKept('models.multiple', false), [gpus, setGpus] = useKept<number[]>('models.gpus', []), [kv, setKv] = useKept('models.kv', 'int8'), [lowRam, setLowRam] = useKept('models.lowRam', 'auto'), [split, setSplit] = useKept('models.split', 'auto'), [deviceChoice, setDeviceChoice] = useKept('models.device', 'auto');
|
||||
const [cardsKey, setCardsKey] = useKept<string | null>('models.cards', null);
|
||||
const [expert, setExpert] = useKept('models.expert', manual), [multiple, setMultiple] = useKept('models.multiple', false), [roles, setRoles] = useKept<Roles | null>('models.roles', null), [kv, setKv] = useKept('models.kv', 'int8'), [lowRam, setLowRam] = useKept('models.lowRam', 'auto'), [split, setSplit] = useKept('models.split', 'auto');
|
||||
const [resourceProfile, setResourceProfile] = useKept<'work' | 'performance'>('models.profile', 'work');
|
||||
const [pending, setPending] = useState(false), [error, setError] = useState(''), [plan, setPlan] = useState<Record<string, any> | null>(null), [loadedError, setLoadedError] = useState(''), [tuneOpen, setTuneOpen] = useState(false), [jobId, setJobId] = useKept('models.job', ''), [manage, setManage] = useState(false);
|
||||
const [toastedJob, setToastedJob] = useKept('models.toasted', ''), [appliedSig, setAppliedSig] = useKept('models.applied', '');
|
||||
@@ -53,6 +54,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
// The model that is running is shown as running, with Unload: not offered for loading again.
|
||||
const downloaded = native?.installed?.models ?? [], allSizes = families.flatMap(f => f.sizes);
|
||||
// This computer for the fit figures: the biggest usable graphics card, or the ones chosen under Expert settings.
|
||||
const engineVendor = native?.hardware?.engine_vendor ?? null, roleCards: RoleCard[] = roleRows(native?.hardware?.gpus as RoleCard[] | undefined, engineVendor);
|
||||
const pcGpus = (native?.hardware?.gpus ?? []).filter(g => g.usable !== false), pcGpu = [...pcGpus].sort((x, y) => y.vram_gb - x.vram_gb)[0] ?? null;
|
||||
const pcRam = native?.hardware?.ram_gb || hardware?.memory.totalGb || null, pcVram = pcGpu?.vram_gb ?? 0;
|
||||
// What the models that stay loaded (Run multiple models) already take: the fit figures are for what is left.
|
||||
@@ -84,20 +86,21 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
// Graphics cards (strata_control/gpu_fit.py: the one rule of the app, the Terminal and setup): the recommended option is pre-selected, the person may pick another
|
||||
// A card a loaded model already uses (a second model on it usually does not fit): said on its row, and not the pre-selected one while another is free.
|
||||
const usedBy = (cards: number[]) => (native?.instances ?? []).filter(i => i.status !== 'error' && i.model !== selectedSize?.id && i.gpus.some(g => cards.includes(g))).map(i => i.label);
|
||||
const cardOptions = selectedSize?.gpu_options && selectedSize.gpu_options.options.length >= 2 ? selectedSize.gpu_options : null;
|
||||
const loadedCards = loadedSelected ? cardOptions?.options.find(o => o.cards.length === loadedSelected.gpus.length && o.cards.every(c => loadedSelected.gpus.includes(c))) : undefined;
|
||||
const activeCards = cardOptions ? (cardOptions.options.find(o => o.key === cardsKey) ?? loadedCards ?? (multiple ? cardOptions.options.find(o => o.recommended && !usedBy(o.cards).length) ?? cardOptions.options.find(o => !usedBy(o.cards).length) : undefined) ?? cardOptions.options.find(o => o.recommended) ?? cardOptions.options[0]) : null;
|
||||
const cardsGpus: number[] = activeCards?.cards ?? [];
|
||||
const chosenCards = expert && gpus.length ? gpus : cardsGpus;
|
||||
const fitHardware = { ram_gb: pcRam ? Math.max(0, pcRam - keptRam) : pcRam, vram_gb: Math.max(0, pcVram - keptVramOn(chosenCards)) };
|
||||
// "(card 1)" in a card name is the number the screens call GPU 2 (Monitor, the model picker, the loaded list): one numbering everywhere.
|
||||
const gpuNumbers = (text: string) => text.replace(/\(card (\d+)\)/g, (_m, n) => `(GPU ${Number(n) + 1})`);
|
||||
// The fit sentence for a card choice, less what the kept models take of RAM and of that card's memory.
|
||||
const lessKept = (words: string, cards: number[]) => { const gone = Math.round(keptRam + keptVramOn(cards)); return gone > 0 ? words.replace(/of the (\d+) GB available/, (_m, n) => `of the ${Math.max(0, Number(n) - gone)} GB available`) : words; };
|
||||
const shownSize = useRef(selectedSize?.id);
|
||||
useEffect(() => { if (shownSize.current !== selectedSize?.id) { shownSize.current = selectedSize?.id; setCardsKey(null); } }, [selectedSize?.id]);
|
||||
const values = useMemo(() => ({ family: selectedSize?.sourceFamily, model: selectedSize?.model, context, vision: selectedSize?.images ? vision : 'no', kv, low_ram: lowRam, resourceProfile, ...(expert && gpus.length ? { gpus: gpus.join(',') } : cardsGpus.length > 1 ? { gpus: cardsGpus.join(',') } : cardsGpus.length === 1 ? { gpu: cardsGpus[0] } : {}), loadAfter: true, multiple: expert && multiple }), [selectedSize?.sourceFamily, selectedSize?.model, context, vision, kv, lowRam, resourceProfile, gpus.join(','), cardsGpus.join(','), expert, multiple]);
|
||||
const downloadValues = { ...values, vision: selectedSize?.images ? 'yes' : 'no', loadVision: selectedSize?.images ? vision : 'no' };
|
||||
// Main / Helper / Off per card (src/lib/gpu-roles.ts, strata_control/gpu_roles.py): the recommendation until the person changes a role; a loaded model shows what it was started with.
|
||||
// With Run multiple models, a card another loaded model already uses starts Off for this one (while a free card exists).
|
||||
const busy = (i: number) => expert && multiple && usedBy([i]).length > 0, freeCards = roleCards.filter(c => !busy(c.index));
|
||||
const recommended = freeCards.length && freeCards.length < roleCards.length ? { ...recommendRoles(freeCards, engineVendor), roles: { ...Object.fromEntries(roleCards.map(c => [c.index, 'off'])), ...recommendRoles(freeCards, engineVendor).roles } as Roles } : recommendRoles(roleCards, engineVendor);
|
||||
const loadedRoles = loadedSelected && roleCards.length > 1 ? fromLegacy(loadedSelected.gpus, roleCards, 'as_given') : null;
|
||||
const effectiveRoles: Roles = roleCards.length > 1 ? (roles ? normalizeRoles(roles, roleCards) : loadedRoles ?? recommended.roles) : recommended.roles;
|
||||
const customRoles = !sameRoles(effectiveRoles, recommended.roles);
|
||||
const changeRoles = (next: Roles) => setRoles(sameRoles(next, recommended.roles) ? null : next);
|
||||
const cardsGpus: number[] = roleCards.length > 1 ? roleOrder(effectiveRoles, roleCards) : [];
|
||||
const chosenCards = cardsGpus;
|
||||
const fitHardware = { ram_gb: pcRam ? Math.max(0, pcRam - keptRam) : pcRam, vram_gb: Math.max(0, (roleCards.length > 1 ? vramOn(effectiveRoles, roleCards) : pcVram) - keptVramOn(chosenCards)) };
|
||||
// A layer split written for another number of cards no longer fits: back to automatic.
|
||||
useEffect(() => { if (cardsGpus.length <= 1 && split !== 'auto') setSplit('auto'); }, [cardsGpus.length]);
|
||||
const values = useMemo(() => ({ family: selectedSize?.sourceFamily, model: selectedSize?.model, context, vision: selectedSize?.images ? vision : 'no', kv, low_ram: lowRam, resourceProfile, ...(cardsGpus.length > 1 ? { gpus: cardsGpus.join(',') } : cardsGpus.length === 1 ? { gpu: cardsGpus[0] } : {}), loadAfter: true, multiple: expert && multiple }), [selectedSize?.sourceFamily, selectedSize?.model, context, vision, kv, lowRam, resourceProfile, cardsGpus.join(','), expert, multiple]);
|
||||
const downloadValues = { ...values, vision: selectedSize?.images && vision !== 'no' ? 'yes' : 'no', loadVision: selectedSize?.images ? vision : 'no' };
|
||||
useEffect(() => { if (!native?.catalog || family)
|
||||
return; const current = native.instances.find(i => i.id === native.defaultId); const rec = native.catalog.recommendation ?? { family: null, model: null }; const loadedFamily = families.find(f => f.sizes.some(s => s.id === current?.model)); const have = new Set((native.installed?.models ?? []).filter(m => m.ready).map(m => m.model)); const installedFamily = families.find(f => f.sizes.some(s => have.has(s.id))); const f = loadedFamily ?? installedFamily ?? families.find(f => f.family === rec.family || f.sizes.some(s => s.sourceFamily === rec.family)) ?? families[0]; if (f) {
|
||||
setFamily(f.family);
|
||||
@@ -147,8 +150,8 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
setPending(false);
|
||||
} }
|
||||
async function load() { if (!selectedSize)
|
||||
return; await perform(async () => { if (installed?.ready && context === installed.context && (!selectedSize?.images || installed.images && installed.images !== 'off') && (!installed.kv || kv === installed.kv || kv === tunedNow?.fields.kv) && (lowRam === (installed.lowRamMode ?? 'auto') || lowRam === tunedNow?.fields.lowRam)) {
|
||||
const result = await nativeAction<NativeJob>('operations', { kind: 'load', args: { model: selectedSize.id, resourceProfile, vision: selectedSize?.images ? vision : 'no', multiple: expert && multiple, ...(expert && gpus.length ? { gpus, layerSplit: split } : cardsGpus.length ? { gpus: cardsGpus } : {}), values: {} } });
|
||||
return; await perform(async () => { if (installed?.ready && context === installed.context && (!selectedSize?.images || vision === 'no' || installed.images && installed.images !== 'off') && (!installed.kv || kv === installed.kv || kv === tunedNow?.fields.kv) && (lowRam === (installed.lowRamMode ?? 'auto') || lowRam === tunedNow?.fields.lowRam)) {
|
||||
const result = await nativeAction<NativeJob>('operations', { kind: 'load', args: { model: selectedSize.id, resourceProfile, vision: selectedSize?.images ? vision : 'no', multiple: expert && multiple, ...(roleCards.length > 1 ? (roles && customRoles ? { gpuRoles: effectiveRoles, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : cardsGpus.length ? { gpus: cardsGpus, gpusFromRecommendation: true, layerSplit: expert && cardsGpus.length > 1 ? split : 'auto' } : {}) : {}), values: {} } });
|
||||
setJobId(result.id);
|
||||
}
|
||||
else
|
||||
@@ -164,21 +167,22 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
setPending(false);
|
||||
} }
|
||||
async function download() { await perform(async () => { const result = await nativeAction<NativeJob>('operations', { kind: 'install', args: downloadValues }); setJobId(result.id); setPlan(null); }); }
|
||||
return <Screen plainHeader title={<span className="sr-only">{tr("Models")}</span>} titleDetail={<FamilySwitch families={families} selected={selectedFamily?.family} onPick={f => { setFamily(f.family); setSize((f.sizes.find(s => native?.installed?.models.some(m => m.model === s.id && m.ready)) ?? f.sizes[0])?.id ?? ''); setVision('yes'); setError(''); }}/>} actions={<Btn icon="sliders" aria-pressed={expert} aria-expanded={expert} aria-controls="native-expert-settings" onClick={() => { setExpert(v => !v); setMultiple(false); setGpus([]); setDeviceChoice('auto'); }}>{tr(expert ? 'Close expert mode' : 'Expert mode')}</Btn>}>
|
||||
return <Screen plainHeader title={<span className="sr-only">{tr("Models")}</span>} titleDetail={<FamilySwitch families={families} selected={selectedFamily?.family} onPick={f => { setFamily(f.family); setSize((f.sizes.find(s => native?.installed?.models.some(m => m.model === s.id && m.ready)) ?? f.sizes[0])?.id ?? ''); setVision('yes'); setError(''); }}/>} actions={<Btn icon="sliders" aria-pressed={expert} aria-expanded={expert} aria-controls="native-expert-settings" onClick={() => { setExpert(v => !v); setMultiple(false); }}>{tr(expert ? 'Close expert mode' : 'Expert mode')}</Btn>}>
|
||||
<div className="native-model-layout native-model-layout--fit">
|
||||
<div className="stack">
|
||||
{tr(native?.instances.length && expert ? <section className="st-card native-running"><h2 className="h4">{tr(native.instances.length === 1 ? 'Loaded model' : 'Loaded models')}</h2>{tr(native.instances.map(i => <div key={i.id}><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.length ? i.gpus.map(g => `GPU ${g + 1}`).join(' + ') : 'Automatic GPU')} · {tr(i.status === 'ready' ? i.phase : i.status)}{tr(native.defaultId === i.id ? ' · Chat default' : '')}</small>{tr(i.error ? <small className="inline-error">{tr(plainFailureText(i.error).message)}</small> : null)}</span>{tr(expert && multiple && native.defaultId !== i.id ? <Btn size="sm" variant="ghost" onClick={() => void loadedAction(`instances/${i.id}/default`)}>{tr("Use in chat")}</Btn> : null)}<Btn size="sm" disabled={pending || !!active} onClick={() => void loadedAction(`instances/${i.id}/unload`)}>{tr("Unload")}</Btn></div>))}{tr(loadedError ? <ErrorNotice message={loadedError} next={tr("Stop or answer the chat that is still using this model, then try again.")}/> : null)}</section> : null)}
|
||||
{tr(native?.instances.length && expert ? <section className="st-card native-running"><h2 className="h4">{tr(native.instances.length === 1 ? 'Loaded model' : 'Loaded models')}</h2>{tr(native.instances.map(i => <div key={i.id}><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.length ? i.gpus.map((g, n) => `GPU ${g + 1}${i.gpus.length > 1 && n === i.gpus.length - 1 ? ' (Main)' : ''}`).join(' + ') : 'Automatic GPU')} · {tr(i.status === 'ready' ? i.phase : i.status)}{tr(native.defaultId === i.id ? ' · Chat default' : '')}</small>{tr(i.error ? <small className="inline-error">{tr(plainFailureText(i.error).message)}</small> : null)}</span>{tr(expert && multiple && native.defaultId !== i.id ? <Btn size="sm" variant="ghost" onClick={() => void loadedAction(`instances/${i.id}/default`)}>{tr("Use in chat")}</Btn> : null)}<Btn size="sm" disabled={pending || !!active} onClick={() => void loadedAction(`instances/${i.id}/unload`)}>{tr("Unload")}</Btn></div>))}{tr(loadedError ? <ErrorNotice message={loadedError} next={tr("Stop or answer the chat that is still using this model, then try again.")}/> : null)}</section> : null)}
|
||||
<section className="st-card native-choice">{tr(selectedFamily && (!selectedSize || selectedSize.needsNewerEngine) ? <><h2 className="h3">{tr("This model needs a newer engine.")}</h2><p className="small muted">{tr("Update your engine or import a local GGUF for this model.")}</p><Btn onClick={() => { setSettingsSection('Engine'); go('settings'); }}>{tr("Update engine")}</Btn><Btn variant="ghost" onClick={onImport ?? onOtherModels}>{tr("Use a local GGUF\u2026")}</Btn></> : null)}{tr(!selectedFamily ? <><h2 className="h3">{tr("Set up Strata")}</h2><p className="small muted">{tr("Connect your local installation to choose an optimized model.")}</p><Btn variant="primary" onClick={onConnectEngine ?? (() => { setSettingsSection('Engine'); go('settings'); })}>{tr("Connect engine")}</Btn>{tr(native?.error ? <p className="small inline-error">{tr(native.error)}</p> : null)}<Btn variant="ghost" onClick={onOtherModels}>{tr("Other local models")}</Btn></> : null)}
|
||||
{tr(selectedSize ? <>
|
||||
<div className="native-choice__head"><span className="native-choice__label">{tr("Version")}</span><span className="native-choice__tools"><NativePresets values={values} onApply={v => { const f = families.find(f => f.family === v.family || f.sizes.some(s => s.sourceFamily === v.family)); if (!f)
|
||||
return; presetPending.current = true; setFamily(f.family); setSize(f.sizes.find(s => s.model === v.model && (s.sourceFamily === v.family || f.family === v.family))?.id ?? ''); setContext(v.context ?? 32768); setVision(v.vision ?? 'no'); setKv(v.kv ?? 'int8'); setLowRam(v.low_ram ?? 'auto'); }}/><Btn icon="models" onClick={() => setManage(true)}>{tr("Manage models")}</Btn></span></div>
|
||||
<div className="native-version"><VersionPicker versions={allSizes} selectedId={selectedSize.id} hardware={fitHardware} downloaded={readyIds} onSelect={id => { const f = families.find(f => f.sizes.some(s => s.id === id)); if (f) { setFamily(f.family); setSize(id); setError(''); } }} onOwnGguf={() => (onImport ?? onOtherModels)()}/></div>
|
||||
<div className="native-version"><VersionPicker pick={native?.catalog?.recommendation} versions={allSizes} selectedId={selectedSize.id} hardware={fitHardware} downloaded={readyIds} onSelect={id => { const f = families.find(f => f.sizes.some(s => s.id === id)); if (f) { setFamily(f.family); setSize(id); setError(''); } }} onOwnGguf={() => (onImport ?? onOtherModels)()}/></div>
|
||||
{tr(noFit ? <p className="small muted native-spill-note"><Icon name="disk" size={16}/>{tr("May run slower \u00B7 Model data can be read from SSD.")}</p> : null)}
|
||||
<div className="native-choice__row">
|
||||
<div className="native-choice__col"><span className="native-choice__label">{tr("Conversation context")}</span><Select ariaLabel={tr("Conversation context")} value={String(context)} options={(native?.catalog?.contexts ?? [8192, 32768, 65536, 131072, 262144]).map(n => ({ value: String(n), label: `${n / 1024}K` }))} onChange={n => setContext(Number(n))}/></div>
|
||||
<div className="native-choice__col"><span className="native-choice__label">{tr("Conversation context")}</span><Select ariaLabel={tr("Conversation context")} value={String(context)} options={contextChoices(native?.catalog?.contexts ?? [8192, 32768, 65536, 131072, 262144], context).map(n => ({ value: String(n), label: n % 1024 === 0 ? `${n / 1024}K` : `${n.toLocaleString()} tokens` }))} onChange={n => setContext(Number(n))}/></div>
|
||||
<div className="native-choice__col"><span className="native-choice__label">{tr("Computer use")}</span><div className="native-resource-choice"><button aria-pressed={resourceProfile === 'work'} data-selected={resourceProfile === 'work'} onClick={() => setResourceProfile('work')}><Icon name="computer" size={20}/><span><strong>{tr("Leave 20% for my work")}</strong><small>{tr("Headroom for your other apps")}</small></span>{tr(resourceProfile === 'work' ? <Icon name="check" size={15}/> : null)}</button><button aria-pressed={resourceProfile === 'performance'} data-selected={resourceProfile === 'performance'} onClick={() => setResourceProfile('performance')}><Icon name="bolt" size={20}/><span><strong>{tr("Full performance")}</strong><small>{tr("Give Strata priority")}</small></span>{tr(resourceProfile === 'performance' ? <Icon name="check" size={15}/> : null)}</button></div></div>
|
||||
</div>
|
||||
{tr(cardOptions ? <div className="native-cards"><span className="native-choice__label">{tr("Graphics cards")}</span><div className="native-cards__list" role="radiogroup" aria-label={tr("Graphics cards")}>{cardOptions.options.map(o => { const on = activeCards?.key === o.key; return <button key={o.key} type="button" role="radio" aria-checked={on} data-selected={on} disabled={!!active} className="native-cards__opt" onClick={() => setCardsKey(o.key)}><span className="native-cards__dot" aria-hidden="true"/><span><strong>{gpuNumbers(o.label)}</strong><small data-warn={o.verdict === 'spills' || o.verdict === 'tight' || usedBy(o.cards).length > 0}>{[lessKept(o.words, o.cards), ...o.notes, ...(usedBy(o.cards).length ? [tr('Already used by {0}', [usedBy(o.cards).join(', ')])] : [])].join('. ')}.</small></span></button>; })}{cardOptions.ignored.length ? <p className="small muted native-cards__note">{`Not used: ${cardOptions.ignored.map(g => `${g.name ?? 'a card'} (${g.why_ignored})`).join('; ')}.`}</p> : null}</div></div> : null)}
|
||||
{roleCards.length > 1 ? <GpuRoles cards={roleCards} roles={effectiveRoles} custom={customRoles} recommendedRoles={recommended.roles} vendor={engineVendor} disabled={!!active} onChange={changeRoles} onBack={() => setRoles(null)} memoryGb={selectedSize ? memoryNeeded(selectedSize) || null : null} ramGb={pcRam} spillGb={selectedSize ? fitOn(selectedSize, fitHardware).spillGb : 0} usedBy={i => usedBy([i])}/> : null}
|
||||
{tr(roleCards.length > 1 && (native?.hardware?.gpus ?? []).length > roleCards.length ? <p className="small muted native-cards__note">{tr('Not used: {0} (another make of graphics card than this engine drives).', [(native?.hardware?.gpus ?? []).filter(g => !roleCards.some(r => r.index === g.index && r.vendor === g.vendor)).map(g => g.name).join(', ')])}</p> : null)}
|
||||
{tr(selectedSize?.imagesCapable && !selectedSize.images ? <p className="small muted native-spill-note">{tr("This model can read pictures, but the engine installed here does not include the picture reader yet, so Read pictures is off. Update the engine in Settings → About to turn it on.")}</p> : null)}
|
||||
{tr(selectedSize?.images ? <><div className="native-image-reading"><label><input type="checkbox" checked={vision !== 'no'} disabled={!selectedSize?.images || !!active} onChange={e => setVision(e.target.checked ? 'yes' : 'no')}/><span><strong>{tr("Read pictures ")}<small>{tr("On by default")}</small></strong><small>{tr("Understand photos, screenshots and charts. The picture files download automatically. Turn it off to save memory.")}</small></span></label>{tr(expert && vision !== 'no' ? <Select ariaLabel={tr("Image encoder device")} value={vision === 'yes' ? 'yes' : vision} options={[{ value: 'yes', label: 'Automatic' }, { value: 'gpu', label: 'GPU' }, { value: 'cpu', label: 'CPU' }]} onChange={setVision}/> : null)}</div>{tr(vision !== 'no' && installed?.ready && installed.visionPrepared === false ? <div className="native-vision-missing" role="status"><span>{tr("Picture reading needs a few more files that are not on this computer yet.")}</span><Btn size="sm" disabled={pending || !!active} onClick={() => void download()}>{tr("Download picture-reading files")}</Btn></div> : null)}</> : null)}
|
||||
</> : null)}
|
||||
@@ -187,10 +191,10 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
{tr(job?.state === 'error' ? <ErrorNotice message={job.error ?? job.summary} action={tr("Retry")} onRetry={() => void load()}/> : null)}
|
||||
{tr(error ? <ErrorNotice message={error}/> : null)}
|
||||
{selectedSize && installed?.ready ? <NativeTuneBadge model={selectedSize.id} resourceProfile={resourceProfile} vision={selectedSize.images ? vision : 'no'}/> : null}
|
||||
<div className="native-model-actions">{tr(loadedSelected ? <><Btn disabled={pending || !!active} onClick={() => void perform(() => nativeAction(`instances/${loadedSelected.id}/unload`))}>{tr("Unload")}</Btn>{tr(onReady ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <span className="native-ready">{tr("Ready")}</span>)}</> : onReady && native?.instances.some(i => i.status === 'ready') ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <><Btn icon="gauge" disabled={!installed?.ready || !native?.available || !!active || !selectedSize || selectedSize.needsNewerEngine} onClick={() => setTuneOpen(true)}>{tr("Tune model")}</Btn><Btn variant="primary" icon="models" disabled={pending || !native?.available || !!active || !selectedSize || !!selectedSize.needsNewerEngine || expert && deviceChoice === 'split' && gpus.length < 2} onClick={() => void load()}>{tr(pending ? 'Checking…' : installed?.ready ? 'Load model' : 'Download & load')}</Btn></>)}</div>
|
||||
<div className="native-model-actions">{tr(loadedSelected ? <><Btn disabled={pending || !!active} onClick={() => void perform(() => nativeAction(`instances/${loadedSelected.id}/unload`))}>{tr("Unload")}</Btn>{tr(onReady ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <span className="native-ready">{tr("Ready")}</span>)}</> : onReady && native?.instances.some(i => i.status === 'ready') ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <><Btn icon="gauge" disabled={!installed?.ready || !native?.available || !!active || !selectedSize || selectedSize.needsNewerEngine} onClick={() => setTuneOpen(true)}>{tr("Tune model")}</Btn><Btn variant="primary" icon="models" disabled={pending || !native?.available || !!active || !selectedSize || !!selectedSize.needsNewerEngine} onClick={() => void load()}>{tr(pending ? 'Checking…' : installed?.ready ? 'Load model' : 'Download & load')}</Btn></>)}</div>
|
||||
</section>
|
||||
{tr(expert && IMAGE_GENERATION_VISIBLE ? <><details className="native-generation"><summary>{tr("Image generation ")}<span className="muted">{tr("Optional")}</span></summary><RunImageModel library={imageLibrary} disabled={pending || !!active} onChange={() => { }}/></details></> : null)}
|
||||
{tr(expert ? <section id="native-expert-settings" ref={expertSection} className="st-card native-execution"><h2 className="h4">{tr("Expert settings")}</h2>{tr(tunedNow ? <p className="small muted native-tuned-note">{tr("Showing the values of your tuning '{0}'.", [tunedNow.name])}</p> : null)}<label className="native-toggle"><span><strong>{tr("Run multiple models")}</strong><small>{tr("Loading another model keeps the others loaded.")}</small></span><button className="st-toggle" role="switch" aria-label={tr("Run multiple models")} aria-checked={multiple} onClick={() => setMultiple(v => !v)}/></label><h3 className="small strong">{tr("Run on")}</h3><p className="small muted">{tr("Automatic uses the installation\u2019s GPU placement. Choose a GPU, or split this model across several GPUs.")}</p><Select ariaLabel={tr("Run on")} value={deviceChoice} options={[{ value: 'auto', label: 'Automatic' }, ...(native?.hardware?.gpus ?? []).map(g => ({ value: String(g.index), label: `GPU ${g.index + 1} · ${g.name}` })), ...((native?.hardware?.gpus.length ?? 0) > 1 ? [{ value: 'split', label: 'Split across GPUs…' }] : [])]} onChange={value => { setDeviceChoice(value); setGpus(value === 'auto' ? [] : value === 'split' ? (native?.hardware?.gpus ?? []).map(g => g.index) : [Number(value)]); }}/>{tr(deviceChoice === 'split' ? <div className="native-gpu-options">{tr(native?.hardware?.gpus.map(g => <label key={g.index}><input type="checkbox" checked={gpus.includes(g.index)} onChange={e => setGpus(old => e.target.checked ? [...old, g.index] : old.filter(i => i !== g.index))}/><span>{tr("GPU ")}{tr(g.index + 1)} · {tr(g.name)}<small>{tr(g.vram_gb)}{tr(" GB VRAM")}</small></span></label>))}</div> : null)}{tr(gpus.length > 1 ? <label className="input-field"><span>{tr("Layer split")}</span><input className="st-input" value={split} placeholder={tr("auto or 16,32")} onChange={e => setSplit(e.target.value)}/></label> : null)}<div className="native-field-grid"><label className="input-field"><span>{tr("Context memory")}</span><Select ariaLabel={tr("Native KV format")} value={kv} options={[{ value: 'int8', label: 'INT8' }, { value: 'q4_0', label: 'Q4' }, { value: 'k8v4', label: 'K8 / V4' }]} onChange={setKv}/></label><label className="input-field"><span>{tr("Low-RAM mode")}</span><Select ariaLabel={tr("Native low RAM mode")} value={lowRam} options={['auto', 'resident', 'mmap'].map(v => ({ value: v, label: v === 'auto' ? 'Automatic' : v === 'resident' ? 'Keep experts in RAM' : 'Read experts from disk' }))} onChange={setLowRam}/></label></div>{tr(installed ? <><NativeSettingsFile model={installed.model}/><NativeSettings model={installed.model}/></> : <p className="small muted">{tr("More engine parameters become available after preparing this version.")}</p>)}</section> : null)}
|
||||
{tr(expert ? <section id="native-expert-settings" ref={expertSection} className="st-card native-execution"><h2 className="h4">{tr("Expert settings")}</h2>{tr(tunedNow ? <p className="small muted native-tuned-note">{tr("Showing the values of your tuning '{0}'.", [tunedNow.name])}</p> : null)}<label className="native-toggle"><span><strong>{tr("Run multiple models")}</strong><small>{tr("Loading another model keeps the others loaded.")}</small></span><button className="st-toggle" role="switch" aria-label={tr("Run multiple models")} aria-checked={multiple} onClick={() => setMultiple(v => !v)}/></label>{tr(cardsGpus.length > 1 ? <label className="input-field"><span>{tr("Layer split")}</span><input className="st-input" value={split} placeholder={tr("auto or 16,32")} onChange={e => setSplit(e.target.value)}/></label> : null)}<div className="native-field-grid"><label className="input-field"><span>{tr("Context memory")}</span><Select ariaLabel={tr("Native KV format")} value={kv} options={[{ value: 'int8', label: 'INT8' }, { value: 'q4_0', label: 'Q4' }, { value: 'k8v4', label: 'K8 / V4' }]} onChange={setKv}/></label><label className="input-field"><span>{tr("Low-RAM mode")}</span><Select ariaLabel={tr("Native low RAM mode")} value={lowRam} options={['auto', 'resident', 'mmap'].map(v => ({ value: v, label: v === 'auto' ? 'Automatic' : v === 'resident' ? 'Keep experts in RAM' : 'Read experts from disk' }))} onChange={setLowRam}/></label></div>{tr(installed ? <><NativeSettingsFile model={installed.model}/><NativeSettings model={installed.model}/></> : <p className="small muted">{tr("More engine parameters become available after preparing this version.")}</p>)}</section> : null)}
|
||||
</div>
|
||||
<aside className="stack stack--lg"><PcFitCard gpuName={pcGpu?.name} ramGb={pcRam} vramGb={pcVram} inUse={keptModels.length ? { ramGb: Math.round(keptRam), vramGb: Math.round(keptVramOn(chosenCards)) } : null} fit={selectedSize ? fitOn(selectedSize, fitHardware) : null}/><section className="st-card"><h2 className="h4">{tr("Model files")}</h2><p className="small muted">{tr("Strata prepares all shards, the draft layer and shared files. Completed downloads are reused.")}</p><code className="native-path">{installed?.dataFolder ?? native?.downloadDirectory ?? native?.installed?.data_folder ?? 'Choose an installation'}</code><Btn size="sm" variant="ghost" onClick={() => { setSettingsSection('Storage'); go('settings'); }}>{tr("Storage settings")}</Btn></section><Btn size="sm" variant="ghost" onClick={onOtherModels}>{tr("Other local models\u2026")}</Btn></aside>
|
||||
</div>
|
||||
@@ -209,6 +213,10 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
</Screen>;
|
||||
}
|
||||
/** setup's "5/7: downloading …" as a sentence a person can read. */
|
||||
/** The listed context sizes, plus the model's own when it is a size set by hand in its run config (the select would show nothing, and a load would silently pick another). */
|
||||
export function contextChoices(listed: number[], current: number): number[] {
|
||||
return current > 0 && !listed.includes(current) ? [...listed, current].sort((a, b) => a - b) : listed;
|
||||
}
|
||||
function installStepLabel(step?: string): string {
|
||||
const n = Number(step?.match(/^(\d+)\//)?.[1]);
|
||||
if (!n)
|
||||
@@ -226,4 +234,4 @@ catch (e) {
|
||||
}
|
||||
finally {
|
||||
setStopping(false);
|
||||
} } return <section className="st-card native-progress" aria-live="polite"><div className="section-toolbar"><h2 className="h4">{tr(job.kind === 'install' ? (label ? tr('Preparing {0}', [label]) : 'Preparing your model') : job.kind === 'load' ? 'Loading model' : job.kind === 'autotune' || job.kind === 'recheck' ? 'Tuning your model' : 'Engine operation')}</h2>{tr(job.kind === 'install' || job.kind === 'load' || job.kind === 'autotune' || job.kind === 'recheck' ? <div className="onboard-progress-actions">{tr(eta ? <span className="small muted">{eta}</span> : null)}<Btn size="sm" disabled={stopping} onClick={() => void stop()}>{tr(stopping ? 'Stopping…' : 'Stop')}</Btn></div> : null)}</div><p className="small">{tr(job.kind === 'install' ? (download && /^5\//.test(job.result?.install?.step ?? '') ? stageText(installStage(job.result?.install)) : installStepLabel(job.result?.install?.step)) : job.summary)}</p>{tr(download ? <><progress max={100} value={download.percent ?? 0}/><p className="xs muted">{tr(download.file === 'model files' ? 'Model files' : download.file === 'picture reader' ? 'Picture reader' : download.file)} · {tr(download.percent ?? 0)}%{tr(Number.isFinite(download.done_gb) && Number.isFinite(download.total_gb) ? ` · ${download.done_gb!.toFixed(1)} / ${download.total_gb!.toFixed(1)} GB` : '')}</p></> : null)}<DownloadParts parts={job.result?.install?.parts}/>{tr(detailsText ? <details><summary className="small">{tr("Details")}</summary><pre className="small muted error-detail-text">{detailsText}</pre></details> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>; }
|
||||
} } return <section className="st-card native-progress" aria-live="polite"><div className="section-toolbar"><h2 className="h4">{tr(job.kind === 'install' ? (label ? tr('Preparing {0}', [label]) : 'Preparing your model') : job.kind === 'load' ? 'Loading model' : job.kind === 'autotune' || job.kind === 'recheck' ? 'Tuning your model' : 'Engine operation')}</h2>{tr(job.kind === 'install' || job.kind === 'load' || job.kind === 'autotune' || job.kind === 'recheck' ? <div className="onboard-progress-actions">{tr(eta ? <span className="small muted">{eta}</span> : null)}<Btn size="sm" disabled={stopping} onClick={() => void stop()}>{tr(stopping ? 'Stopping…' : 'Stop')}</Btn></div> : null)}</div><p className="small">{tr(job.kind === 'install' ? (job.result?.install?.label ?? (download && /^5\//.test(job.result?.install?.step ?? '') ? stageText(installStage(job.result?.install)) : installStepLabel(job.result?.install?.step))) : job.summary)}</p>{tr(download ? <><progress max={100} value={download.percent ?? 0}/><p className="xs muted">{tr(download.file === 'model files' ? 'Model files' : download.file === 'picture reader' ? 'Picture reader' : download.file === 'checking files' ? 'Checking files' : download.file === 'MTP draft layer' ? 'Draft layer' : download.file)} · {tr(download.percent ?? 0)}%{tr(Number.isFinite(download.done_gb) && Number.isFinite(download.total_gb) ? ` · ${download.done_gb!.toFixed(1)} / ${download.total_gb!.toFixed(1)} GB` : '')}</p></> : null)}<DownloadParts parts={job.result?.install?.parts}/>{tr(detailsText ? <details><summary className="small">{tr("Details")}</summary><pre className="small muted error-detail-text">{detailsText}</pre></details> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>; }
|
||||
@@ -2,6 +2,7 @@ import {localeTag} from '../lib/i18n';
|
||||
import { tr } from '../lib/i18n';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../lib/client';
|
||||
import { fetchUpdateCheck, publishUpdateCheck, useInstalledVersions, useLastUpdateCheck } from '../lib/update-check';
|
||||
import { nativeAction, useNative, type NativeJob } from '../lib/native';
|
||||
import { Btn, Dialog, ErrorNotice, Select } from './ui';
|
||||
import { NativeProgress } from './NativeModels';
|
||||
@@ -53,14 +54,28 @@ export function NativeOperations() {
|
||||
instanceId: string;
|
||||
line: string;
|
||||
}[]>([]), [showLogs, setShowLogs] = useState(false), [confirm, setConfirm] = useState(false);
|
||||
const [update, setUpdate] = useState<'checking' | 'available' | 'current' | 'unknown'>('checking');
|
||||
// Opening this page shows the last check kept on this computer and makes no network call. The check runs when "Check for updates" is pressed.
|
||||
const versions = useInstalledVersions();
|
||||
const last = useLastUpdateCheck(versions);
|
||||
const [checking, setChecking] = useState(false), [checkError, setCheckError] = useState('');
|
||||
const update: 'checking' | 'available' | 'current' | 'unknown' = checking ? 'checking' : last?.engine.status === 'available' ? 'available' : last?.engine.status === 'current' ? 'current' : 'unknown';
|
||||
const active = native?.jobs.find(j => j.state === 'running');
|
||||
// The engine's update is offered only when the update check says there is one.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void api.get<{ engine?: { status?: string } }>('/api/updates/check').then(r => { if (alive) setUpdate(r.engine?.status === 'available' ? 'available' : r.engine?.status === 'current' ? 'current' : 'unknown'); }).catch(() => { if (alive) setUpdate('unknown'); });
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
async function checkForUpdates() {
|
||||
setChecking(true);
|
||||
setCheckError('');
|
||||
try {
|
||||
const data = await fetchUpdateCheck();
|
||||
publishUpdateCheck(data, versions);
|
||||
if (data.engine.status === 'error')
|
||||
setCheckError(data.engine.message);
|
||||
}
|
||||
catch (e) {
|
||||
setCheckError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!showLogs)
|
||||
return;
|
||||
@@ -98,7 +113,7 @@ export function NativeOperations() {
|
||||
}
|
||||
}
|
||||
return <div className="stack stack--lg native-engine-settings">
|
||||
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Update component")}</h2>{tr(update === 'available' ? <Btn size="sm" variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => setConfirm(true)}>{tr("Update available")}</Btn> : null)}</div><p className="small muted">{tr("Update the engine for this computer. Your models and settings are kept.")}</p>{tr(update === 'current' ? <p className="small muted">{tr("Up to date")}</p> : null)}{tr(update === 'available' && (native?.instances.length ?? 0) > 0 ? <p className="small muted">{tr("Open Models and unload the running native models first.")}</p> : null)}</section>
|
||||
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Update component")}</h2>{tr(update === 'available' ? <Btn size="sm" variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => setConfirm(true)}>{tr("Update available")}</Btn> : null)}{tr(update !== 'available' ? <Btn size="sm" disabled={update === 'checking'} onClick={() => void checkForUpdates()}>{tr(update === 'checking' ? 'Checking…' : 'Check for updates')}</Btn> : null)}</div><p className="small muted">{tr("Update the engine for this computer. Your models and settings are kept.")}</p>{tr(update === 'current' ? <p className="small muted">{tr("Up to date")}</p> : null)}{tr(checkError ? <p className="small inline-error" role="alert">{tr(checkError)}</p> : null)}{tr(update === 'available' && (native?.instances.length ?? 0) > 0 ? <p className="small muted">{tr("Open Models and unload the running native models first.")}</p> : null)}</section>
|
||||
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Diagnostics")}</h2><Btn size="sm" onClick={() => setShowLogs(true)}>{tr("View logs")}</Btn></div><p className="small muted">{tr("Downloads, preparation and engine failures include their original messages here.")}</p>{tr(native?.jobs.filter(j => j.state === 'error').slice(-3).map(j => <ErrorNotice key={j.id} message={j.error ?? j.summary} detail={j.details}/>))}</section>
|
||||
<Dialog open={showLogs} wide title={tr("Engine logs")} onClose={() => setShowLogs(false)} actions={<><Btn icon="download" disabled={saving} onClick={() => void saveLogs()}>{tr(saving ? 'Working…' : 'Save logs')}</Btn><Btn variant="primary" onClick={() => setShowLogs(false)}>{tr("Close")}</Btn></>}>
|
||||
<pre className="native-log" aria-label={tr("Engine log")}>{logs.length ? logs.map(l => `${l.at} · ${l.line}`).join(String.fromCharCode(10)) : tr('No engine messages yet.')}</pre>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { pickerGroups, pickerVersionState } from '../lib/model-picker';
|
||||
import { sizeGb } from '../lib/format';
|
||||
import { useNative } from '../lib/native';
|
||||
import { NativeDeleteDialog } from './NativeDelete';
|
||||
import { ModelsFolderCard } from './ModelsFolderDialog';
|
||||
import { nativeShortName } from '../lib/native-names';
|
||||
export function SettingsStorage() {
|
||||
const state = useStore(s => s);
|
||||
@@ -77,7 +78,9 @@ export function SettingsStorage() {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
const downloading = !!native?.jobs.some(j => j.state === 'running' && j.kind !== 'models-move') || !!library?.downloads.active || !!state.imageModels?.variants.some(v => v.state === 'downloading');
|
||||
return <>
|
||||
{native?.available && native.downloadDirectory ? <ModelsFolderCard blocked={downloading}/> : null}
|
||||
<section className="storage-directory" aria-labelledby="storage-directory-title">
|
||||
<div className="storage-directory__heading"><span><Icon name="folder" size={18}/><strong id="storage-directory-title">{tr("Model directory")}</strong></span><Btn size="sm" disabled={!currentDirectory || directoryPending || pending} onClick={() => { setDirectory(currentDirectory); setEditing(!editing); setDirectoryError(''); setNotice(''); }}>{tr(editing ? 'Cancel' : 'Change directory')}</Btn></div>
|
||||
<p className="small muted">{tr("Your models are saved here. Use an existing folder to reuse supported downloads.")}</p>
|
||||
@@ -95,6 +98,7 @@ export function SettingsStorage() {
|
||||
<div className="row"><Btn variant="primary" type="submit" disabled={directoryPending || directoryBlocked || !directory.trim()}>{tr(directoryPending ? 'Checking files…' : changing && loadedCount > 0 ? 'Unload and change directory' : changing ? 'Use this directory' : 'Check folder')}</Btn></div>
|
||||
</form> : <Btn size="sm" icon="refresh" disabled={directoryPending || directoryBlocked || !currentDirectory} onClick={() => void saveDirectory(currentDirectory)}>{tr(directoryPending ? 'Checking files…' : 'Check folder')}</Btn>)}
|
||||
{tr(directoryBlocked && !directoryPending ? <p className="small muted">{tr(!lifecycle.allowed ? lifecycle.reason : 'Finish or cancel downloads and loading to change the directory.')}</p> : null)}
|
||||
{tr(state.setup?.app.modelsDirNotice && !notice ? <p className="small storage-directory__notice" role="status"><Icon name="info" size={15}/>{tr(state.setup.app.modelsDirNotice)}</p> : null)}
|
||||
{tr(notice ? <p className="small storage-directory__notice" role="status"><Icon name="check" size={15}/>{tr(notice)}</p> : null)}
|
||||
{tr(directoryError ? <ErrorNotice message={directoryError}/> : null)}
|
||||
</section>
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface CustomTool {
|
||||
export interface ToolsState {
|
||||
groups: ToolGroup[];
|
||||
search: { provider: 'builtin' | 'exa' | 'perplexity' | 'deepseek'; keySet: boolean };
|
||||
/** Skip the once-per-chat question before the first web search or page in Manual mode. Off unless the person turned it on. */
|
||||
webWithoutAsking: boolean;
|
||||
browser: { enabled: boolean; found: boolean; name: string | null; missing?: 'component' };
|
||||
custom: CustomTool[];
|
||||
}
|
||||
@@ -139,6 +141,9 @@ export function SettingsTools({ preview }: { preview?: ToolsState }) {
|
||||
<Btn size="sm" disabled={busy || !key.trim()} onClick={() => { void save({ search: { provider: state.search.provider, key: key.trim() } }); setKey(''); pushToast('success', tr('Key saved')); }}>{tr('Save key')}</Btn>
|
||||
</div>
|
||||
</Field> : null}
|
||||
<Field label={tr('Let the assistant search the web without asking')} hint={tr('In Manual mode the assistant asks once per chat before it searches the web or opens a web page. Turn this on to skip that question.')}>
|
||||
<button className="st-toggle" role="switch" aria-checked={state.webWithoutAsking} aria-label={tr('Let the assistant search the web without asking')} disabled={busy} onClick={() => void save({ webWithoutAsking: !state.webWithoutAsking })} />
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ import type { SoftwareUpdate, UpdateCheckResult } from '../lib/types';
|
||||
import { nativeAction, useNative } from '../lib/native';
|
||||
import { NativeProgress } from './NativeModels';
|
||||
import { appVersionLabel } from '../lib/app-version';
|
||||
import { fetchUpdateCheck, publishUpdateCheck, useInstalledVersions, useLastUpdateCheck } from '../lib/update-check';
|
||||
/** A check from an earlier day shows its date too, because the result is kept between visits. */
|
||||
function checkedLabel(at: string) {
|
||||
const when = new Date(at), sameDay = when.toDateString() === new Date().toDateString();
|
||||
return sameDay ? when.toLocaleTimeString(localeTag(), { hour: '2-digit', minute: '2-digit' }) : when.toLocaleString(localeTag(), { dateStyle: 'medium', timeStyle: 'short' });
|
||||
}
|
||||
/** Installed versions are real host values. Installation needs a host updater. */
|
||||
export function SettingsUpdates() {
|
||||
const native = useNative();
|
||||
@@ -16,12 +22,16 @@ export function SettingsUpdates() {
|
||||
const [rollback, setRollback] = useState(false);
|
||||
const [component, setComponent] = useState<'strata' | 'engine'>('strata');
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [result, setResult] = useState<UpdateCheckResult | null>(null);
|
||||
// Opening Settings shows the last check kept on this computer. The network is touched only when "Check for updates" is pressed.
|
||||
const versions = useInstalledVersions();
|
||||
const last = useLastUpdateCheck(versions);
|
||||
const [fresh, setFresh] = useState<UpdateCheckResult | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const request = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => request.current?.abort(), []);
|
||||
const installed = component === 'strata' ? appVersion : native?.available ? (native.installed?.engine?.version ?? 'Not installed') : engineVersion;
|
||||
const active = native?.jobs.find(j => j.state === 'running');
|
||||
const result = checking || error ? null : (fresh ?? last);
|
||||
async function operate(kind: string) {
|
||||
setError('');
|
||||
try {
|
||||
@@ -39,16 +49,13 @@ export function SettingsUpdates() {
|
||||
request.current = controller;
|
||||
setChecking(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
setFresh(null);
|
||||
try {
|
||||
const response = await fetch('/api/updates/check', { signal: controller.signal });
|
||||
if (!response.ok)
|
||||
throw new Error('Could not check for updates. Try again.');
|
||||
const data = await response.json() as UpdateCheckResult;
|
||||
if (!data.strata?.status || !data.engine?.status || !data.checkedAt)
|
||||
throw new Error('The update service returned an incomplete result.');
|
||||
if (!controller.signal.aborted)
|
||||
setResult(data);
|
||||
const data = await fetchUpdateCheck(controller.signal);
|
||||
if (!controller.signal.aborted) {
|
||||
setFresh(data);
|
||||
publishUpdateCheck(data, versions);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
if (!controller.signal.aborted)
|
||||
@@ -72,7 +79,7 @@ export function SettingsUpdates() {
|
||||
{tr(versionRow('Strata', appVersion, result?.strata))}
|
||||
{tr(versionRow('Engine', native?.available ? (native.installed?.engine?.version ?? 'Not installed') : engineVersion, result?.engine))}
|
||||
<div className="settings-update-actions"><Btn size="sm" icon="refresh" disabled={checking} className={checking ? 'settings-update-checking' : undefined} onClick={() => void check()}>{tr(checking ? 'Checking…' : error || result?.strata.status === 'error' ? 'Try again' : result ? 'Check again' : 'Check for updates')}</Btn><Btn size="sm" variant="ghost" onClick={() => setRollback(true)}>{tr("Roll back\u2026")}</Btn></div>
|
||||
<div className="settings-update-inline" role="status" aria-live="polite">{tr(checking ? 'Checking for new versions…' : error ? <span className="inline-error">{tr(error)}</span> : result ? <><span className="sr-only">{tr(result.strata.message)} {tr(result.engine.message)} </span>{tr("Checked at ")}{tr(new Date(result.checkedAt).toLocaleTimeString(localeTag(), { hour: '2-digit', minute: '2-digit' }))}</> : null)}</div>
|
||||
<div className="settings-update-inline" role="status" aria-live="polite">{tr(checking ? 'Checking for new versions…' : error ? <span className="inline-error">{tr(error)}</span> : result ? <><span className="sr-only">{tr(result.strata.message)} {tr(result.engine.message)} </span>{tr("Checked at ")}{tr(checkedLabel(result.checkedAt))}</> : null)}</div>
|
||||
</div>
|
||||
<Dialog open={rollback} compact title={tr("Roll back a version")} onClose={() => setRollback(false)} actions={<Btn onClick={() => setRollback(false)}>{tr("Done")}</Btn>}>
|
||||
<div className="settings-update-dialog">
|
||||
@@ -87,7 +94,7 @@ export function SettingsUpdates() {
|
||||
</>;
|
||||
}
|
||||
|
||||
/** "Strata 0.2.0-beta · Engine 0.1.45.3": the app's own version first, the engine's beside it (About). */
|
||||
/** "Strata 0.2.2 · Engine 0.1.47": the app's own version first, the engine's beside it (About). */
|
||||
export function AboutVersions() {
|
||||
const native = useNative();
|
||||
const app = appVersionLabel(useStore(s => s.setup?.app.version), native?.version);
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useRef, useState, type ReactNode } from 'react';
|
||||
import { tr, localeTag } from '../lib/i18n';
|
||||
import { api } from '../lib/client';
|
||||
import { go, answerChatTool, continueChatInNewChat, continueTaskInNewChat, dismissChat, selectSession, stopChat, stopLoop } from '../lib/store';
|
||||
import { pushToast } from '../lib/store';
|
||||
import { askQuestion } from '../lib/ask';
|
||||
import { LOOP_MAX_FAILURES, loopEvery } from '../lib/loop';
|
||||
import { plainFailure } from '../lib/plain-errors';
|
||||
import { userError } from '../lib/format';
|
||||
import { type BoardAction, type BoardCard, type BoardColumn, type BoardReason } from '../lib/board';
|
||||
import { useBoard, refreshBoard } from '../lib/use-board';
|
||||
import { Btn, Icon, IconBtn, type IconName } from './ui';
|
||||
import { FloatingMenu } from './FloatingMenu';
|
||||
|
||||
/**
|
||||
* The task board: what is running, what waits for a yes, what found a problem and what finished, in four columns. The cards come from
|
||||
* src/lib/board.ts; this file only draws them and calls the same endpoints and store functions the rest of the app uses. Cards are not
|
||||
* dragged: they move by themselves when the facts change.
|
||||
*/
|
||||
|
||||
const COLUMNS: { id: BoardColumn; name: string; hint?: string; tone: 'run' | 'wait' | 'bad' | 'done' }[] = [
|
||||
{ id: 'running', name: 'Running', tone: 'run' },
|
||||
{ id: 'waiting', name: 'Waiting for you', hint: 'Stopped at a question only you can answer.', tone: 'wait' },
|
||||
{ id: 'attention', name: 'Needs attention', hint: 'Found a problem. Stays here until you deal with it.', tone: 'bad' },
|
||||
{ id: 'done', name: 'Done', hint: 'Finished cleanly in the last 7 days.', tone: 'done' },
|
||||
];
|
||||
|
||||
const ACTION: Record<BoardAction, { label: string; icon?: IconName }> = {
|
||||
'stop-task': { label: 'Stop', icon: 'stop' },
|
||||
'run-again': { label: 'Run again', icon: 'refresh' },
|
||||
'open-task': { label: 'Open task', icon: 'pencil' },
|
||||
'open-chat': { label: 'Open chat', icon: 'chat' },
|
||||
'stop-chat': { label: 'Stop chat', icon: 'stop' },
|
||||
'stop-loop': { label: 'Stop loop', icon: 'stop' },
|
||||
approve: { label: 'Approve', icon: 'check' },
|
||||
deny: { label: 'Deny' },
|
||||
continue: { label: 'Continue in a new chat', icon: 'chat' },
|
||||
dismiss: { label: 'Dismiss', icon: 'x' },
|
||||
'view-result': { label: 'View result' },
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ words */
|
||||
|
||||
/** "every 10 min": short enough for a card; whole hours keep the loop's own words ("every hour"). */
|
||||
const everyShort = (ms: number) => (ms % 3_600_000 === 0 || ms < 60_000 ? loopEvery(ms) : tr('every {0} min', [Math.round(ms / 60_000)]));
|
||||
|
||||
const span = (ms: number) => {
|
||||
const min = Math.floor(ms / 60_000);
|
||||
if (min < 1) return tr('under 1 min');
|
||||
if (min < 60) return tr('{0} min', [min]);
|
||||
if (min < 48 * 60) return tr('{0} h', [Math.floor(min / 60)]);
|
||||
return tr('{0} d', [Math.floor(min / 1440)]);
|
||||
};
|
||||
const ago = (ms: number) => (ms < 60_000 ? tr('just now') : tr('{0} ago', [span(ms)]));
|
||||
const clock = (at: number) => new Date(at).toLocaleTimeString(localeTag(), { hour: '2-digit', minute: '2-digit' });
|
||||
/** "today 07:00", "yesterday", "Mon 07:00" for a day that has a time on it. */
|
||||
function day(at: number, now: number) {
|
||||
const d = new Date(at), n = new Date(now);
|
||||
const midnight = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
||||
const days = Math.round((midnight(n) - midnight(d)) / 86_400_000);
|
||||
if (days <= 0) return tr('today {0}', [clock(at)]);
|
||||
if (days === 1) return tr('yesterday');
|
||||
return d.toLocaleDateString(localeTag(), { weekday: 'short', day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
export function cardTime(card: BoardCard, now: number): string {
|
||||
switch (card.timeKind) {
|
||||
case 'started': return tr('started {0}', [ago(now - card.at)]);
|
||||
case 'waiting': return tr('waiting {0}', [span(now - card.at)]);
|
||||
case 'next': return card.nextAt && card.nextAt > now ? tr('next in {0}', [span(card.nextAt - now)]) : tr('answering now');
|
||||
case 'stopped': return tr('stopped {0}', [now - card.at < 86_400_000 ? clock(card.at) : day(card.at, now)]);
|
||||
default: return card.source === 'task' ? day(card.at, now) : now - card.at < 86_400_000 ? ago(now - card.at) : day(card.at, now);
|
||||
}
|
||||
}
|
||||
|
||||
function ReasonText({ reason }: { reason: BoardReason }) {
|
||||
let text = '', raw: string | undefined;
|
||||
switch (reason.kind) {
|
||||
case 'task-failed': case 'chat-failed': {
|
||||
const plain = plainFailure(reason.detail ?? '');
|
||||
const known = plain.kind !== 'plain';
|
||||
text = known ? tr(plain.message) : tr(userError(String(reason.detail ?? '').slice(0, 240)) || 'It did not finish.');
|
||||
// The words a person reads are the sentence; the original text stays one click away for experts.
|
||||
raw = known ? (plain.detail ?? reason.detail) : undefined;
|
||||
if (!known && !reason.detail) text = tr('It did not finish.');
|
||||
break;
|
||||
}
|
||||
case 'task-stopped': text = tr('This run was stopped before it finished.'); break;
|
||||
case 'task-closed': text = tr('Strata closed while this was running, so it did not finish.'); break;
|
||||
case 'task-needs-yes': text = tr('Finished, but wanted to change something and could not ask. Nothing was changed.'); break;
|
||||
case 'loop-failed': text = tr('Stopped: {0} runs in a row did not work.', [LOOP_MAX_FAILURES]); break;
|
||||
default: text = reason.ask ? askQuestion(reason.ask) : reason.fallback ?? '';
|
||||
}
|
||||
return <span>{text}{raw ? <details className="bcard__detail"><summary>{tr('Details')}</summary><pre>{raw}</pre></details> : null}</span>;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- card */
|
||||
|
||||
function Card({ card, now, busy, canRun, run, openMenu }: { card: BoardCard; now: number; busy: boolean; canRun: boolean; run: (card: BoardCard, action: BoardAction) => void; openMenu: (card: BoardCard, anchor: HTMLElement) => void }) {
|
||||
const titleId = `bcard-${card.key.replace(/[^a-z0-9]+/gi, '-')}`;
|
||||
const source = card.source === 'task' ? <><Icon name="clock" size={14}/>{tr('Scheduled task')}</> : card.source === 'chat' ? <><Icon name="chat" size={14}/>{tr('Chat')}</> : <><Icon name="refresh" size={14}/>{tr('Loop · {0}', [everyShort(card.everyMs ?? 0)])}</>;
|
||||
const where = card.folder || card.meta ? <div className="bcard__where">{card.folder ? <><Icon name="folder" size={14}/>{card.folder}</> : null}{card.folder && card.meta ? ' · ' : ''}{card.meta ? tr(card.meta.text, card.meta.params) : null}</div> : null;
|
||||
const button = (action: BoardAction, variant: 'primary' | 'secondary' | 'ghost') => {
|
||||
const a = ACTION[action];
|
||||
const off = busy || (action === 'run-again' && !canRun);
|
||||
return <Btn key={action} size="sm" variant={variant} icon={variant === 'ghost' ? undefined : a.icon} disabled={off} title={action === 'run-again' && !canRun ? tr('Load a model first') : undefined} onClick={() => run(card, action)}>{tr(a.label)}</Btn>;
|
||||
};
|
||||
const iconFor = (action: BoardAction, label?: string) => <IconBtn key={action} name={ACTION[action].icon ?? 'x'} title={tr(label ?? ACTION[action].label)} disabled={busy} onClick={() => run(card, action)}/>;
|
||||
|
||||
let actions: ReactNode;
|
||||
if (card.column === 'waiting') {
|
||||
actions = card.approve
|
||||
? <>{button('approve', 'primary')}{button('deny', 'secondary')}<span className="spacer"/>{iconFor('open-chat')}</>
|
||||
: <>{button(card.primary, 'secondary')}</>;
|
||||
} else if (card.column === 'done') {
|
||||
actions = <>{card.chip ? <span className={`bchip${card.chip.tone === 'good' ? ' bchip--good' : ''}`}><Icon name="check" size={12}/>{tr(card.chip.text)}</span> : null}<span className="spacer"/>{button(card.primary, 'ghost')}{iconFor('dismiss')}</>;
|
||||
} else {
|
||||
const variant = card.column === 'attention' ? 'primary' : 'secondary';
|
||||
actions = <>{button(card.primary, variant)}<span className="spacer"/>
|
||||
{card.menu.length === 1 ? iconFor(card.menu[0]) : card.menu.length > 1
|
||||
? <button type="button" className="st-btn st-btn--icon" aria-haspopup="menu" aria-label={tr('More actions for {0}', [card.title])} title={tr('More actions')} disabled={busy} onClick={e => openMenu(card, e.currentTarget)}><Icon name="ellipsis" size={19}/></button> : null}</>;
|
||||
}
|
||||
|
||||
return <article className="bcard" aria-labelledby={titleId} data-source={card.source}>
|
||||
<div className="bcard__top"><span className="bsrc">{source}</span><span className="btime">{cardTime(card, now)}</span></div>
|
||||
<h3 className="bcard__title" id={titleId}>{card.title}</h3>
|
||||
{where}
|
||||
{card.line ? <p className="bcard__line">{card.line}</p> : null}
|
||||
{card.reason ? <div className={`bcard__why bcard__why--${card.reason.tone}`}><Icon name={card.reason.tone === 'bad' ? 'error' : card.reason.tone === 'warn' ? 'warning' : 'shield'} size={16}/><ReasonText reason={card.reason}/></div> : null}
|
||||
{card.busy ? <div className="bcard__bar bcard__bar--busy" aria-hidden="true"><i/></div> : null}
|
||||
<div className="bcard__actions">{actions}</div>
|
||||
</article>;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ board */
|
||||
|
||||
export function TaskBoard({ query, canRun, onViewResult }: { query: string; canRun: boolean; onViewResult: (taskId: string) => void }) {
|
||||
const { board, ready, tasks } = useBoard();
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({});
|
||||
const [menu, setMenu] = useState<{ card: BoardCard; anchor: HTMLElement } | null>(null);
|
||||
const now = Date.now();
|
||||
const q = query.trim().toLowerCase();
|
||||
const shown = (list: BoardCard[]) => (q ? list.filter(c => `${c.title} ${c.line ?? ''} ${c.folder ?? ''}`.toLowerCase().includes(q)) : list);
|
||||
const guard = useRef(new Set<string>());
|
||||
|
||||
async function run(card: BoardCard, action: BoardAction) {
|
||||
if (guard.current.has(card.key)) return;
|
||||
const task = card.source === 'task' ? tasks.find(t => t.id === card.id) : undefined;
|
||||
const local = () => {
|
||||
if (action === 'open-chat') { selectSession(card.id); go('chat', card.id); }
|
||||
else if (action === 'open-task') go('tasks', card.id);
|
||||
else if (action === 'view-result') onViewResult(card.id);
|
||||
else if (action === 'stop-chat') stopChat(card.id);
|
||||
else if (action === 'stop-loop') stopLoop(card.id);
|
||||
else if (action === 'approve' || action === 'deny') { if (card.approve) answerChatTool(card.id, card.approve.messageId, card.approve.toolId, action === 'approve'); }
|
||||
else if (action === 'continue') { if (task) continueTaskInNewChat(task); else continueChatInNewChat(card.id); }
|
||||
else if (action === 'dismiss' && card.source !== 'task') dismissChat(card.id);
|
||||
else return false;
|
||||
return true;
|
||||
};
|
||||
if (local()) return;
|
||||
guard.current.add(card.key);
|
||||
setBusy(b => ({ ...b, [card.key]: true }));
|
||||
try {
|
||||
if (action === 'stop-task') await api.post(`/api/tasks/${card.id}/stop`);
|
||||
else if (action === 'run-again') await api.post(`/api/tasks/${card.id}/run`);
|
||||
else if (action === 'dismiss') await api.patch(`/api/tasks/${card.id}`, { ackedAt: new Date().toISOString() });
|
||||
window.dispatchEvent(new Event('strata:tasks'));
|
||||
} catch (e) {
|
||||
pushToast('error', 'That did not work', e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
guard.current.delete(card.key);
|
||||
setBusy(b => ({ ...b, [card.key]: false }));
|
||||
refreshBoard();
|
||||
}
|
||||
}
|
||||
|
||||
const openMenu = (card: BoardCard, anchor: HTMLElement) => setMenu(m => (m?.card.key === card.key ? null : { card, anchor }));
|
||||
const total = Object.values(board.columns).reduce((n, c) => n + c.length, 0);
|
||||
|
||||
return <div className="board-wrap">
|
||||
<p className="board-note"><Icon name="info" size={16}/>{tr('Cards move on their own as things happen. A card in Needs attention stays there until you deal with it or dismiss it.')}</p>
|
||||
<p className="sr-only" role="status" aria-live="polite">{board.need ? tr('{0} need your attention', [board.need]) : tr('Nothing needs you right now.')}</p>
|
||||
<div className="board">
|
||||
{COLUMNS.map(col => {
|
||||
const cards = shown(board.columns[col.id]);
|
||||
const headId = `bcol-${col.id}`;
|
||||
return <section key={col.id} className={`bcol bcol--${col.id}`} data-tone={col.tone} aria-labelledby={headId}>
|
||||
<div className="bcol__head"><span className="bcol__dot" aria-hidden="true"/><h2 className="bcol__name" id={headId}>{tr(col.name)}</h2><span className="bcol__count" aria-label={tr('{0} cards', [cards.length])}>{cards.length}</span></div>
|
||||
{col.hint ? <p className="bcol__hint">{tr(col.hint)}</p> : null}
|
||||
{cards.map(card => <Card key={card.key} card={card} now={now} busy={!!busy[card.key]} canRun={canRun} run={run} openMenu={openMenu}/>)}
|
||||
{!cards.length ? <p className="bcol__empty">{ready ? tr(q && total ? 'No matching cards.' : 'Nothing here right now.') : tr('Loading tasks…')}</p> : null}
|
||||
</section>;
|
||||
})}
|
||||
</div>
|
||||
{board.upcoming.length ? <div className="upnext" role="region" aria-label={tr('Coming up')}><Icon name="clock" size={18}/><span className="navgroup__label">{tr('Coming up')}</span>
|
||||
{board.upcoming.map((u, i) => <span className="upnext__item" key={u.id}>{i ? <span className="sep" aria-hidden="true"/> : null}<a href={`#/tasks/${u.id}`}>{u.title}</a> <span className="i">{day(u.at, now)}</span></span>)}</div> : null}
|
||||
{menu ? <FloatingMenu anchor={menu.anchor} title={tr('More actions for {0}', [menu.card.title])} width={230} onClose={() => setMenu(null)}>
|
||||
{menu.card.menu.map(action => <div key={action}>{action === 'dismiss' && menu.card.menu.length > 1 ? <hr className="board-menu-sep"/> : null}<button type="button" role="menuitem" className="menu__item" onClick={() => { const card = menu.card; setMenu(null); void run(card, action); }}><Icon name={ACTION[action].icon ?? 'x'}/>{tr(ACTION[action].label)}</button></div>)}
|
||||
</FloatingMenu> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export function ChatTelemetry({ session }: {
|
||||
const approx = m.estimated ? tr('≈ ') : tr('');
|
||||
const dur = (n: number) => elapsed(n);
|
||||
return <div className="telemetry xs muted" aria-label={tr("Chat statistics")}>
|
||||
<Chip icon="gauge" label={tr("{0}{1} tok/s{2}", [approx, speed === null ? tr('—') : Math.round(speed), m.streaming ? tr('') : tr(' · last reply')])} live={around(m.streaming ? "{0}{1} tok/s" : "{0}{1} tok/s · last reply", approx, <Num value={speed} format={int}/>)} title={tr("Session statistics")}>{tr(m.estimated ? <p className="xs muted">{tr("Live estimate from response length and elapsed time. Final usage comes from the engine.")}</p> : null)}<Row label={tr("Thinking time")} value={<Num value={m.thinkMs} format={dur}/>}/><Row label={tr("Writing time")} value={<Num value={m.writeMs} format={dur}/>}/><Row label={tr("Tool time")} value={<Num value={m.toolMs} format={dur}/>}/><Row label={tr("Average time to first token")} value={m.ttft === null ? tr('Not reported') : <Num value={m.ttft} format={dur}/>}/><Row label={tr("Latest response speed")} value={speed === null ? tr('Not reported') : <>{<Num value={speed} format={int}/>}{tr(' tokens/s')}</>}/><Row label={tr("Turns / tool calls")} value={<><Num value={m.turns} format={int}/> / <Num value={m.steps} format={int}/></>}/></Chip>
|
||||
<Chip icon="gauge" label={tr("{0}{1} tok/s{2}", [approx, speed === null ? tr('—') : Math.round(speed), m.streaming ? tr('') : tr(' · last reply')])} live={around(m.streaming ? "{0}{1} tok/s" : "{0}{1} tok/s · last reply", approx, <Num value={speed} format={int}/>)} title={tr("Session statistics")}>{tr(m.estimated ? <p className="xs muted">{tr("Live estimate from response length and elapsed time. Final usage comes from the engine.")}</p> : null)}<Row label={tr("Thinking time")} value={<Num value={m.thinkMs} format={dur}/>}/><Row label={tr("Writing time")} value={<Num value={m.writeMs} format={dur}/>}/><Row label={tr("Tool time")} value={<Num value={m.toolMs} format={dur}/>}/><Row label={tr("Average time to first token")} value={m.ttft === null ? tr('Not reported') : <Num value={m.ttft} format={dur}/>}/><Row label={tr("Latest response speed")} value={speed === null ? tr('Not reported') : <>{<Num value={speed} format={int}/>}{tr(' tokens/s')}</>}/><Row label={tr("Prompt reading")} value={m.promptSpeed === null ? tr('Not reported') : <>{<Num value={m.promptSpeed} format={int}/>}{tr(' tokens/s')}</>}/><Row label={tr("Turns / tool calls")} value={<><Num value={m.turns} format={int}/> / <Num value={m.steps} format={int}/></>}/></Chip>
|
||||
<Chip icon="memory" label={tr("{0}{1} total tokens", [approx, compactTokens(m.total)])} live={around("{0}{1} total tokens", approx, <Num value={m.total} format={compactTokens}/>)} title={tr("Token usage")}><Row label={tr("Total in this chat")} value={<Num value={m.total} format={compactTokens}/>}/><Row label={tr("Input tokens")} value={<Num value={m.input} format={compactTokens}/>}/><Row label={tr("Output tokens")} value={<Num value={m.output} format={compactTokens}/>}/></Chip>
|
||||
<Chip visual={<ContextRing percent={m.pct}/>} label={tr(m.pct === null ? '—' : `${Math.round(m.pct)}% of context`)} live={m.pct === null ? undefined : <><Num value={m.pct} format={int}/>{tr('% of context')}</>} title={tr("Context length")}><Row label={tr("Context used")} value={m.pct === null ? tr('Not reported') : <><Num value={m.pct} format={int}/>%</>}/><div className="context-meter" role="meter" aria-label={tr("Context window used")} aria-valuemin={0} aria-valuemax={100} aria-valuenow={m.pct ?? undefined}><span style={{ width: `${m.pct ?? 0}%` }}/></div><Row label={tr("Current context")} value={<Num value={m.used} format={compactTokens}/>}/><Row label={tr("Context limit")} value={<Num value={m.max} format={compactTokens}/>}/><Row label={tr("Space remaining")} value={m.used !== null && m.max !== null ? <Num value={Math.max(0, m.max - m.used)} format={compactTokens}/> : '—'}/></Chip>
|
||||
</div>;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { FloatingMenu } from './FloatingMenu';
|
||||
import { cx } from '../lib/format';
|
||||
import { versionNote, type CatalogVersion } from '../lib/native-catalog';
|
||||
import { nvfp4Version } from '../lib/native-names';
|
||||
import { VERSION_GROUPS, fitOn, groupOpen, recommendedVersions, type FitHardware } from '../lib/native-fit';
|
||||
import { VERSION_GROUPS, fitOn, groupOpen, recommendedVersions, type FitHardware, type PcPick } from '../lib/native-fit';
|
||||
|
||||
/** The name a version goes by in the list: the quantization, with the maker where the quantization alone is ambiguous. */
|
||||
export function versionName(v: CatalogVersion, withMaker = false): string {
|
||||
@@ -26,7 +26,7 @@ const gb = (n: number) => `${Number.isInteger(n) ? n : n.toFixed(1)} GB`;
|
||||
* which stays on the SSD, is not in it); the download size is secondary. "Uses SSD N GB" appears only where this computer would read
|
||||
* part of the model from its SSD. The first group is a fixed list of four; the rest fold away.
|
||||
*/
|
||||
export function VersionPicker({ versions, selectedId, hardware, onSelect, onOwnGguf, downloaded, showRecommended = true, moreLabel }: {
|
||||
export function VersionPicker({ versions, selectedId, hardware, onSelect, onOwnGguf, downloaded, showRecommended = true, moreLabel, pick }: {
|
||||
versions: CatalogVersion[];
|
||||
selectedId: string;
|
||||
hardware: FitHardware | null | undefined;
|
||||
@@ -38,13 +38,15 @@ export function VersionPicker({ versions, selectedId, hardware, onSelect, onOwnG
|
||||
showRecommended?: boolean;
|
||||
/** What the button says while the selected version is one of the recommended four shown elsewhere. */
|
||||
moreLabel?: string;
|
||||
/** The engine's own pick for this PC (catalog.recommendation); it comes first in the Recommended group. */
|
||||
pick?: PcPick | null;
|
||||
}) {
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
// A group is open when the person opened it; until they touch it, only the group that holds the selected version is open.
|
||||
const [opened, setOpened] = useState<Record<string, boolean>>({});
|
||||
const selected = versions.find(v => v.id === selectedId);
|
||||
const recommended = recommendedVersions(versions);
|
||||
const recommended = recommendedVersions(versions, pick);
|
||||
// Where the recommended four are shown outside the menu, the menu holds only the others.
|
||||
const apart = new Set(showRecommended ? [] : recommended.map(x => x.v.id));
|
||||
const rest = versions.filter(v => !apart.has(v.id));
|
||||
|
||||
@@ -128,6 +128,7 @@ export function Seg<T extends string>({ value, options, onChange, ariaLabel, pan
|
||||
options: {
|
||||
value: T;
|
||||
label: string;
|
||||
icon?: IconName;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
@@ -150,7 +151,7 @@ export function Seg<T extends string>({ value, options, onChange, ariaLabel, pan
|
||||
onChange(options[next].value);
|
||||
(e.currentTarget.parentElement?.children[next] as HTMLElement)?.focus();
|
||||
}} title={tr(o.title)} onClick={() => onChange(o.value)}>
|
||||
{tr(o.label)}
|
||||
{tr(o.icon ? <Icon name={o.icon}/> : null)}{tr(o.label)}
|
||||
</button>)))}
|
||||
</div>);
|
||||
}
|
||||
@@ -383,7 +384,7 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
|
||||
</>);
|
||||
}
|
||||
/** One modal surface for confirmations and narrow-screen inspectors. */
|
||||
export function Dialog({ open, title, onClose, children, actions, headerActions, compact = false, sheet = false, wide = false, hideTitle = false, className }: {
|
||||
export function Dialog({ open, title, onClose, children, actions, headerActions, compact = false, sheet = false, wide = false, hideTitle = false, className, noClose = false }: {
|
||||
open: boolean;
|
||||
title: ReactNode;
|
||||
onClose: () => void;
|
||||
@@ -395,6 +396,8 @@ export function Dialog({ open, title, onClose, children, actions, headerActions,
|
||||
wide?: boolean;
|
||||
hideTitle?: boolean;
|
||||
className?: string;
|
||||
/** No close button: the person must use the dialog's own buttons (a move that is running). */
|
||||
noClose?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const id = useId();
|
||||
@@ -417,7 +420,7 @@ export function Dialog({ open, title, onClose, children, actions, headerActions,
|
||||
return createPortal(<div className="dialog-layer">
|
||||
<div className="dialog-scrim" onClick={onClose} aria-hidden="true"/>
|
||||
<section ref={ref} className={cx(`${sheet ? 'dialog-surface dialog-surface--sheet' : 'dialog-surface'}${wide ? ' dialog-surface--wide' : ''}${compact ? ' dialog-surface--compact' : ''}`, className)} role="dialog" aria-modal="true" aria-labelledby={id} tabIndex={-1}>
|
||||
<header className="dialog-head"><h2 className={hideTitle ? 'sr-only' : 'h3'} id={id}>{tr(title)}</h2><div className="dialog-head__actions">{tr(headerActions)}<IconBtn name="x" title={typeof title === 'string' ? tr("Close {0}", [title]) : tr("Close")} onClick={onClose}/></div></header>
|
||||
<header className="dialog-head"><h2 className={hideTitle ? 'sr-only' : 'h3'} id={id}>{tr(title)}</h2><div className="dialog-head__actions">{tr(headerActions)}{noClose ? null : <IconBtn name="x" title={typeof title === 'string' ? tr("Close {0}", [title]) : tr("Close")} onClick={onClose}/>}</div></header>
|
||||
<div className="dialog-body">{tr(children)}</div>
|
||||
{tr(actions ? <footer className="dialog-actions">{tr(actions)}</footer> : null)}
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The version the person sees as "Strata". It is the DESKTOP APP's version (0.2.0-beta), not that of Strata Control, the tools the app runs
|
||||
* (0.1.45, the version of the Terminal). Showing Control's number as "Strata" made a tester report "Strata 0.1.45" for the 0.2.0-beta app.
|
||||
* The version the person sees as "Strata". It is the DESKTOP APP's version (0.2.2), not that of Strata Control, the tools the app runs
|
||||
* (0.1.45, the version of the Terminal). Showing Control's number as "Strata" made a tester report "Strata 0.1.45" for the 0.2.2 app.
|
||||
* Without a real app version (a source checkout reports 'dev') the other number is the best there is.
|
||||
*/
|
||||
export function appVersionLabel(app: string | null | undefined, control?: string | null): string | undefined {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { Ask } from './ask';
|
||||
import type { Session, ToolCall } from './types';
|
||||
import type { Task } from './tasks';
|
||||
|
||||
/**
|
||||
* The task board (Tasks screen, "Board"). One pure function turns what the app already knows (scheduled tasks from the host, chats and
|
||||
* their loops from the browser, the approvals the host is holding) into cards in four columns. Nothing here is stored as "board state":
|
||||
* a card is where the facts put it, and it moves when the facts change. It never touches the DOM, the store or the clock, so it is tested
|
||||
* like loop.ts.
|
||||
*
|
||||
* One card per thing: a chat shows once, in the first place that fits (waiting for you, running, needs attention, done), and a task
|
||||
* shows once. A task or chat the person dismissed stays off the board until a newer run or turn brings a problem back.
|
||||
*/
|
||||
|
||||
export type BoardColumn = 'running' | 'waiting' | 'attention' | 'done';
|
||||
|
||||
/** What a button on a card does. The component maps each one to a call; the order of `menu` is the order shown. */
|
||||
export type BoardAction = 'stop-task' | 'run-again' | 'open-task' | 'open-chat' | 'stop-chat' | 'stop-loop' | 'approve' | 'deny' | 'continue' | 'dismiss' | 'view-result';
|
||||
|
||||
/** Why a card needs the person. `kind` names the sentence; the component writes it in the person's language. */
|
||||
export interface BoardReason {
|
||||
tone: 'bad' | 'warn' | 'ask';
|
||||
kind: 'task-failed' | 'task-stopped' | 'task-closed' | 'task-needs-yes' | 'chat-failed' | 'loop-failed' | 'ask';
|
||||
/** The original error text, for the failures a person can open under Details. */
|
||||
detail?: string;
|
||||
/** The approval question, for `ask`. */
|
||||
ask?: Ask;
|
||||
/** What the tool wants to do, when the host sent no question. */
|
||||
fallback?: string;
|
||||
params?: (string | number)[];
|
||||
}
|
||||
|
||||
export interface BoardCard {
|
||||
/** Stable across refreshes: the source and its id. */
|
||||
key: string;
|
||||
column: BoardColumn;
|
||||
source: 'task' | 'chat' | 'loop';
|
||||
/** Task id for a task; chat id for a chat or a loop. */
|
||||
id: string;
|
||||
title: string;
|
||||
/** Milliseconds. What the time on the card is about, and the sort key (newest first). */
|
||||
at: number;
|
||||
timeKind: 'started' | 'waiting' | 'next' | 'stopped' | 'plain';
|
||||
/** For a loop that is running: when its next answer is due. */
|
||||
nextAt?: number;
|
||||
/** A loop's rhythm in milliseconds, for "Loop · every 10 min". */
|
||||
everyMs?: number;
|
||||
folder?: string;
|
||||
/** The muted part after the folder: "2 helpers working", "answer 3 of 6". */
|
||||
meta?: { text: string; params?: (string | number)[] };
|
||||
line?: string;
|
||||
reason?: BoardReason;
|
||||
/** Done cards: the chip beside the button. */
|
||||
chip?: { text: string; tone: 'good' | 'plain' };
|
||||
/** A card whose run is going on shows a moving bar. */
|
||||
busy?: boolean;
|
||||
primary: BoardAction;
|
||||
menu: BoardAction[];
|
||||
/** Set on a Waiting card that may be answered from the board (a file read or write inside the chat's folder). */
|
||||
approve?: { messageId: string; toolId: string };
|
||||
}
|
||||
|
||||
export interface BoardPending { chatId: string; callIds: string[]; at?: number; folder?: string | null }
|
||||
|
||||
export interface BoardInput {
|
||||
tasks: readonly Task[];
|
||||
sessions: readonly Session[];
|
||||
/** Chats with a turn going. */
|
||||
liveChats: readonly string[];
|
||||
/** Chats with a /loop going. */
|
||||
loopChats: readonly string[];
|
||||
/** What the host holds, per chat. `null` while it is not known (not asked yet or unreachable): the cards in the chats are trusted then. */
|
||||
pending: readonly BoardPending[] | null;
|
||||
activeSession: string | null;
|
||||
now: number;
|
||||
days?: number;
|
||||
}
|
||||
|
||||
export interface BoardResult {
|
||||
columns: Record<BoardColumn, BoardCard[]>;
|
||||
/** Waiting for you plus Needs attention: the number the sidebar shows. */
|
||||
need: number;
|
||||
/** The next scheduled runs. */
|
||||
upcoming: { id: string; title: string; at: number }[];
|
||||
/** Task ids that have a problem the person has not dismissed (the sidebar's dot). */
|
||||
problemTasks: string[];
|
||||
}
|
||||
|
||||
export const BOARD_DAYS = 7;
|
||||
const DAY = 86_400_000;
|
||||
|
||||
const time = (iso?: string | null) => { const n = iso ? Date.parse(iso) : NaN; return Number.isFinite(n) ? n : 0; };
|
||||
|
||||
/** The last folder name of a path: how a chat's working folder is shown on a card. */
|
||||
export function folderName(path?: string | null): string | undefined {
|
||||
const parts = String(path ?? '').split(/[\\/]+/).filter(Boolean);
|
||||
return parts.length ? parts[parts.length - 1] : undefined;
|
||||
}
|
||||
|
||||
const normal = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
|
||||
/**
|
||||
* Is `target` inside `folder`? Relative paths count (the agent works in the folder) unless they climb out with "..". Anything else
|
||||
* (another drive, another tree, a path that cannot be told) is not: those need the whole conversation in front of the person.
|
||||
*/
|
||||
export function isInside(target: string | undefined | null, folder: string | undefined | null): boolean {
|
||||
if (!target || !folder) return false;
|
||||
const t = normal(String(target).trim()), f = normal(String(folder).trim());
|
||||
if (!t || !f || t.split('/').includes('..')) return false;
|
||||
const windows = /^[a-z]:/i.test(f) || String(folder).includes('\\');
|
||||
const absolute = /^([a-z]:)?\//i.test(t);
|
||||
if (!absolute) return !/^[a-z]:/i.test(t);
|
||||
const a = windows ? t.toLowerCase() : t, b = windows ? f.toLowerCase() : f;
|
||||
return a === b || a.startsWith(b + '/');
|
||||
}
|
||||
|
||||
/** Can this waiting card be answered from the board? A file read or write inside the chat's folder; shell commands and connected tools open the chat. */
|
||||
export function canApproveOnBoard(tool: Pick<ToolCall, 'scope' | 'target'>, folder: string | undefined | null): boolean {
|
||||
return (tool.scope === 'read' || tool.scope === 'write') && isInside(tool.target, folder);
|
||||
}
|
||||
|
||||
/** One plain line from an answer: markdown off, first sentence (or two when the first is tiny), cut at 160 characters. */
|
||||
export function plainLine(text: string | undefined | null, max = 160): string {
|
||||
let body = String(text ?? '').replace(/```[\s\S]*?```/g, ' ').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/^\s*(#{1,6}|[-*+]|\d+[.)]|>)\s+/gm, '').replace(/[*_`~]/g, '');
|
||||
body = body.replace(/\s+/g, ' ').trim();
|
||||
if (!body) return '';
|
||||
const sentence = /^.*?[.!?](?=\s|$)/.exec(body)?.[0] ?? body;
|
||||
const two = /^.*?[.!?](?=\s|$)\s*.*?[.!?](?=\s|$)/.exec(body)?.[0] ?? body;
|
||||
const pick = sentence.length < 24 && body.length > sentence.length ? two : sentence;
|
||||
return pick.length > max ? `${pick.slice(0, max - 1).trimEnd()}…` : pick;
|
||||
}
|
||||
|
||||
function lastAssistant(s: Session) {
|
||||
for (let i = s.messages.length - 1; i >= 0; i--) { const m = s.messages[i]; if (m.role === 'assistant' && !(m as { local?: boolean }).local) return m; }
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Dismissed: the person dismissed it at or after the moment of the thing shown. */
|
||||
const dismissed = (ackedAt: string | null | undefined, since: number) => !!ackedAt && time(ackedAt) >= since;
|
||||
|
||||
export function boardCards(input: BoardInput): BoardResult {
|
||||
const { now } = input, window = (input.days ?? BOARD_DAYS) * DAY;
|
||||
const live = new Set(input.liveChats), loops = new Set(input.loopChats);
|
||||
const columns: Record<BoardColumn, BoardCard[]> = { running: [], waiting: [], attention: [], done: [] };
|
||||
const problemTasks: string[] = [];
|
||||
const upcoming: BoardResult['upcoming'] = [];
|
||||
|
||||
/* ---- scheduled tasks ---- */
|
||||
for (const t of input.tasks) {
|
||||
if (t.archived || !t.id) continue;
|
||||
const base = { key: `task:${t.id}`, source: 'task' as const, id: t.id, title: t.title };
|
||||
const ran = time(t.lastRunAt), fin = time(t.finishedAt) || ran;
|
||||
if (t.enabled && t.nextRunAt) upcoming.push({ id: t.id, title: t.title, at: time(t.nextRunAt) });
|
||||
if (t.status === 'running') { columns.running.push({ ...base, column: 'running', at: ran, timeKind: 'started', line: plainLine(t.lastLine ?? '') || undefined, busy: true, primary: 'stop-task', menu: ['open-task'] }); continue; }
|
||||
if (dismissed(t.ackedAt, fin)) continue;
|
||||
const failed = t.status === 'error' || t.status === 'cancelled' || t.status === 'interrupted';
|
||||
if (failed) {
|
||||
problemTasks.push(t.id);
|
||||
const kind = t.status === 'error' ? 'task-failed' : t.status === 'cancelled' ? 'task-stopped' : 'task-closed';
|
||||
columns.attention.push({ ...base, column: 'attention', at: fin || ran, timeKind: 'plain', reason: { tone: 'bad', kind, ...(t.status === 'error' && t.error ? { detail: t.error } : {}) }, primary: 'run-again', menu: ['open-task', 'continue', 'dismiss'] });
|
||||
} else if (t.attention) {
|
||||
problemTasks.push(t.id);
|
||||
// A run that was auto-denied: the useful next step is a chat that can carry on with the person's yes.
|
||||
columns.attention.push({ ...base, column: 'attention', at: fin || ran, timeKind: 'plain', line: plainLine(t.lastLine ?? t.output) || undefined, reason: { tone: 'warn', kind: 'task-needs-yes', detail: t.attention }, primary: 'continue', menu: ['open-task', 'dismiss'] });
|
||||
} else if (t.status === 'done' && fin && now - fin <= window) {
|
||||
columns.done.push({ ...base, column: 'done', at: fin, timeKind: 'plain', line: plainLine(t.lastLine ?? t.output) || undefined, chip: { text: 'Finished', tone: 'good' }, primary: 'view-result', menu: ['dismiss'] });
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- chats and loops ---- */
|
||||
const pendingBy = new Map((input.pending ?? []).map(p => [p.chatId, p]));
|
||||
for (const s of input.sessions) {
|
||||
if (s.archived || s.showcase) continue;
|
||||
const base = { id: s.id, title: s.title, folder: folderName(s.workspace) };
|
||||
const answer = lastAssistant(s);
|
||||
|
||||
// Waiting for you: a card the host still holds.
|
||||
const held = pendingBy.get(s.id);
|
||||
let proposed: { messageId: string; tool: ToolCall } | undefined;
|
||||
for (const m of s.messages) for (const tool of m.tools ?? []) if (tool.live && tool.state === 'proposed' && (input.pending === null || held?.callIds.includes(tool.id))) { proposed = { messageId: m.id, tool }; break; }
|
||||
if (proposed) {
|
||||
const { tool, messageId } = proposed;
|
||||
const folder = held?.folder ?? s.workspace;
|
||||
const ok = canApproveOnBoard(tool, folder);
|
||||
columns.waiting.push({ ...base, key: `chat:${s.id}`, source: 'chat', column: 'waiting', at: held?.at || time(s.updatedAt), timeKind: 'waiting',
|
||||
reason: { tone: 'ask', kind: 'ask', ...(tool.ask ? { ask: tool.ask } : {}), fallback: tool.effect || tool.preview || tool.name },
|
||||
primary: ok ? 'approve' : 'open-chat', menu: ok ? ['deny', 'open-chat'] : [], ...(ok ? { approve: { messageId, toolId: tool.id } } : {}) });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Running: a loop that is going, or a turn that is.
|
||||
if (loops.has(s.id) && s.loop) {
|
||||
const l = s.loop;
|
||||
columns.running.push({ ...base, key: `loop:${s.id}`, source: 'loop', column: 'running', at: l.startedAt, timeKind: 'next', nextAt: live.has(s.id) ? undefined : l.nextAt, everyMs: l.everyMs, title: l.prompt || s.title,
|
||||
meta: { text: l.maxRuns ? 'answer {0} of {1}' : 'answer {0}', params: l.maxRuns ? [l.answers + 1, l.maxRuns] : [l.answers + 1] }, line: plainLine(l.lastText) || undefined, busy: live.has(s.id), primary: 'stop-loop', menu: ['open-chat'] });
|
||||
continue;
|
||||
}
|
||||
if (live.has(s.id)) {
|
||||
const helpers = (s.agents ?? []).filter(a => a.status === 'working').length;
|
||||
const lastUser = [...s.messages].reverse().find(m => m.role === 'user');
|
||||
columns.running.push({ ...base, key: `chat:${s.id}`, source: 'chat', column: 'running', at: time(lastUser?.at) || time(s.updatedAt), timeKind: 'started', busy: true,
|
||||
...(helpers ? { meta: { text: helpers === 1 ? '1 helper working' : '{0} helpers working', params: [helpers] } } : {}), line: plainLine(answer?.content) || undefined, primary: 'open-chat', menu: ['stop-chat'] });
|
||||
continue;
|
||||
}
|
||||
|
||||
// A loop that ended (or that the window closed on) speaks for the whole chat.
|
||||
if (s.loop) {
|
||||
const l = s.loop;
|
||||
const why = l.state === 'running' ? 'closed' : l.endedWhy ?? 'closed';
|
||||
const when = l.endedAt ?? time(s.updatedAt);
|
||||
if (dismissed(s.ackedAt, when) || now - when > window) continue;
|
||||
const common = { ...base, key: `loop:${s.id}`, source: 'loop' as const, at: when, timeKind: 'stopped' as const, everyMs: l.everyMs, title: l.prompt || s.title, line: plainLine(l.lastText) || undefined };
|
||||
if (why === 'failures') columns.attention.push({ ...common, column: 'attention', reason: { tone: 'bad', kind: 'loop-failed' }, primary: 'open-chat', menu: ['continue', 'dismiss'] });
|
||||
else columns.done.push({ ...common, column: 'done', chip: why === 'stopped' ? { text: 'Stopped by you', tone: 'plain' } : why === 'closed' ? { text: 'Stopped when Strata closed', tone: 'plain' } : { text: 'Ended on its own', tone: 'plain' }, primary: 'open-chat', menu: ['dismiss'] });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Chats: only what has something to do, in the window.
|
||||
const when = time(s.updatedAt);
|
||||
if (!when || now - when > window || dismissed(s.ackedAt, when) || !answer || answer.streaming) continue;
|
||||
if (answer.error) {
|
||||
columns.attention.push({ ...base, key: `chat:${s.id}`, source: 'chat', column: 'attention', at: when, timeKind: 'plain', reason: { tone: 'bad', kind: 'chat-failed', detail: answer.error }, primary: 'open-chat', menu: ['continue', 'dismiss'] });
|
||||
continue;
|
||||
}
|
||||
const unread = s.id !== input.activeSession && (s.lastReadAt ? s.updatedAt > s.lastReadAt : s.messages.length > 0);
|
||||
if (unread && answer.content?.trim() && s.messages[s.messages.length - 1] === answer) {
|
||||
columns.done.push({ ...base, key: `chat:${s.id}`, source: 'chat', column: 'done', at: when, timeKind: 'plain', line: plainLine(answer.content) || undefined, chip: { text: 'Finished', tone: 'good' }, primary: 'open-chat', menu: ['dismiss'] });
|
||||
}
|
||||
}
|
||||
|
||||
for (const list of Object.values(columns)) list.sort((a, b) => b.at - a.at);
|
||||
upcoming.sort((a, b) => a.at - b.at);
|
||||
return { columns, need: columns.waiting.length + columns.attention.length, upcoming: upcoming.slice(0, 5), problemTasks };
|
||||
}
|
||||
@@ -16,12 +16,15 @@ export function chatMetrics(session:Session|null, monitor:MonitorState|null, con
|
||||
const used=live?(liveUsed??finished):(finished??liveUsed);
|
||||
const max=remote?(replies.at(-1)?.contextMax||null):configuredContext??(active?monitor!.context.maxTokens:latest?.contextMax??null);
|
||||
const streaming=replies.find(m=>m.streaming);
|
||||
const speed=streaming?.tps??[...replies].reverse().find(m=>m.tps!==undefined)?.tps??null;
|
||||
// Answer speed: everything the model generated over its decode time. While a reply streams it is that reply's; after it, the LAST reply's own
|
||||
// (never an older reply's number under the words "last reply").
|
||||
const lastReply=streaming??replies.at(-1);
|
||||
const speed=lastReply?.tps??null,promptSpeed=lastReply?.promptTps??null;
|
||||
const ttfts=replies.flatMap(m=>m.ttftMs===undefined?[]:[m.ttftMs]);
|
||||
const hasUsage=session?.statsAvailable===true;
|
||||
const llmTotal=replies.reduce((n,m)=>n+(m.durationMs??(m.streaming?Math.max(0,now-(Date.parse(m.at)||now)):0)),0),thinkTotal=replies.reduce((n,m)=>n+(m.reasoningMs??(m.streaming&&m.reasoningStartedAt?Math.max(0,now-m.reasoningStartedAt):0)),0);
|
||||
return {streaming:!!streaming,writeMs:Math.max(0,llmTotal-thinkTotal),estimated:streaming?.usageSource==='estimated',hasReplies:replies.length>0,example:!!session?.showcase||replies.some(m=>m.usageSource==='demo'),
|
||||
speed:active&&monitor?.phase==='generating'?monitor.outputTps:speed,
|
||||
speed:active&&['generating','thinking'].includes(monitor?.phase??'')&&monitor!.outputTps>0?monitor!.outputTps:speed,promptSpeed,
|
||||
total:session?.statsAvailable===false?null:hasUsage?session.tokensIn+session.tokensOut+(streaming?.tokens??0)+(streaming?.inputTokens??0):replies.every(m=>m.inputTokens!==undefined&&m.tokens!==undefined)?replies.reduce((n,m)=>n+m.inputTokens!+m.tokens!,0):null,
|
||||
input:hasUsage?session.tokensIn:null,output:hasUsage?session.tokensOut:null,
|
||||
llmMs:replies.reduce((n,m)=>n+(m.durationMs??(m.streaming?Math.max(0,now-(Date.parse(m.at)||now)):0)),0),
|
||||
|
||||
@@ -55,12 +55,12 @@ export function installProgress(install: InstallParts | undefined): { done_gb: n
|
||||
}
|
||||
|
||||
/** Which part of a multi-part install is being fetched now: the model's parts (with "part 2 of 4"), the picture reader or the engine. */
|
||||
export interface InstallStage { kind: 'model' | 'vision' | 'engine' | 'other'; index?: number; count?: number }
|
||||
export interface InstallStage { kind: 'model' | 'vision' | 'engine' | 'draft' | 'other'; index?: number; count?: number }
|
||||
export function installStage(install: { parts?: { kind?: string; state?: string; files_done?: number; files_total?: number }[]; running?: boolean } | undefined): InstallStage | undefined {
|
||||
const parts = install?.parts ?? [];
|
||||
const now = parts.find(p => p.state === 'downloading') ?? parts.find(p => !['done', 'current', 'skipped', 'failed'].includes(p.state ?? ''));
|
||||
if (!now) return undefined;
|
||||
const kind = now.kind === 'model' || now.kind === 'vision' || now.kind === 'engine' ? now.kind : 'other';
|
||||
const kind = now.kind === 'model' || now.kind === 'vision' || now.kind === 'engine' || now.kind === 'draft' ? now.kind : 'other';
|
||||
const count = Number(now.files_total) > 1 ? Number(now.files_total) : undefined;
|
||||
return { kind, ...(count ? { index: Math.min(count, Number(now.files_done ?? 0) + 1), count } : {}) };
|
||||
}
|
||||
@@ -68,6 +68,7 @@ export function installStage(install: { parts?: { kind?: string; state?: string;
|
||||
export function stageText(stage: InstallStage | undefined): string {
|
||||
if (!stage) return tr('Downloading model files…');
|
||||
if (stage.kind === 'vision') return tr('Downloading the picture reader…');
|
||||
if (stage.kind === 'draft') return tr('Preparing your model…'); // the speed-up layer's own download (setup step 6)
|
||||
if (stage.kind === 'engine') return tr('Downloading the engine for this PC…');
|
||||
if (stage.count && stage.index) return tr('Downloading model files · part {0} of {1}', [stage.index, stage.count]);
|
||||
return tr('Downloading model files…');
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Main / Helper / Off for every graphics card. The same rules as strata_control/gpu_roles.py (the Terminal, setup and the server); both run
|
||||
* tests/data/gpu_roles_vectors.json.
|
||||
*
|
||||
* - Main: the card that runs the LAST stage of the layer split (the output head, the draft layer and the check of its guesses). Exactly one.
|
||||
* - Helper: holds a share of the model, so more of it fits on graphics cards instead of being read from the drive.
|
||||
* - Off: Strata does not touch it.
|
||||
*
|
||||
* The engine takes the cards in stage order (`gpu: [helper, ..., main]`, `gpu_order: "as_given"`), so Main is the LAST one.
|
||||
*/
|
||||
export type Role = 'main' | 'helper' | 'off';
|
||||
export const ROLES: Role[] = ['main', 'helper', 'off'];
|
||||
export type Roles = Record<number, Role>;
|
||||
|
||||
export interface RoleCard {
|
||||
index: number;
|
||||
name: string;
|
||||
vram_gb: number;
|
||||
/** SMs x clock (or the AMD equivalent): only the order between cards matters. */
|
||||
speed?: number;
|
||||
usable?: boolean;
|
||||
problem?: string | null;
|
||||
vendor?: string;
|
||||
uma?: boolean;
|
||||
integrated?: boolean;
|
||||
unified_gb?: number;
|
||||
/** PCI bus id when known: used to notice a renumbering. */
|
||||
bus?: string;
|
||||
}
|
||||
|
||||
export const OFF_AT_OR_BELOW_GB = 8;
|
||||
const SAME_SPEED = 1.05;
|
||||
const MEMORY_GUARD_SPEED = 0.5;
|
||||
const MIN_CARD_GB = 4;
|
||||
const SPLIT_CARD_OVERHEAD_GB = 2;
|
||||
export const CPU_ONLY_LINE = 'Performance is degraded on CPU only.';
|
||||
export const NO_CARD_LINE = 'This engine needs a graphics card to start. Turn one on.';
|
||||
|
||||
const mem = (c: RoleCard) => Number(c.vram_gb) || 0;
|
||||
const speed = (c: RoleCard) => (typeof c.speed === 'number' && c.speed > 0 ? c.speed : 0);
|
||||
const shares = (c: RoleCard) => !!(c.integrated || c.uma || c.unified_gb || c.vendor === 'apple');
|
||||
const canRun = (c: RoleCard) => c.usable !== false;
|
||||
const rnd = (n: number) => Math.round(n);
|
||||
|
||||
export function shortName(c: RoleCard): string {
|
||||
let n = String(c.name || `GPU ${c.index + 1}`);
|
||||
for (const junk of ['NVIDIA GeForce ', 'NVIDIA ', 'GeForce ', 'AMD Radeon ', 'AMD ', 'Intel(R) ', 'Intel ']) if (n.startsWith(junk)) { n = n.slice(junk.length); break; }
|
||||
return n.trim();
|
||||
}
|
||||
/** The number the screens show: GPU 1 is card 0. */
|
||||
export const gpuLabel = (c: RoleCard) => `GPU ${c.index + 1}`;
|
||||
|
||||
/** The cards shown as rows: every card the engine's make can name, in card order. */
|
||||
export function rows<T extends RoleCard>(cards: T[] | null | undefined, vendor?: string | null): T[] {
|
||||
const list = cards ?? [];
|
||||
const make = vendor || pickVendor(list);
|
||||
const mine = make ? list.filter(c => !c.vendor || c.vendor === make) : list;
|
||||
return [...mine].sort((a, b) => a.index - b.index);
|
||||
}
|
||||
function pickVendor(cards: RoleCard[]): string | null {
|
||||
const makes = new Set(cards.filter(c => canRun(c) && c.vendor).map(c => c.vendor as string));
|
||||
if (makes.size <= 1) return [...makes][0] ?? null;
|
||||
return makes.has('nvidia') ? 'nvidia' : cards[0]?.vendor ?? null;
|
||||
}
|
||||
|
||||
function eligible(cs: RoleCard[]): RoleCard[] {
|
||||
const real = cs.filter(c => canRun(c) && !shares(c) && mem(c) >= MIN_CARD_GB);
|
||||
return real.length ? real : cs.filter(canRun);
|
||||
}
|
||||
|
||||
export type MainWhy = 'only' | 'faster' | 'same' | 'memory' | 'none';
|
||||
|
||||
export function pickMain(cs: RoleCard[]): { main: RoleCard | null; why: MainWhy } {
|
||||
const pool = eligible(cs);
|
||||
if (!pool.length) return { main: null, why: 'none' };
|
||||
if (pool.length === 1) return { main: pool[0], why: 'only' };
|
||||
const top = Math.max(...pool.map(speed));
|
||||
const group = top > 0 ? pool.filter(c => speed(c) * SAME_SPEED >= top) : [...pool];
|
||||
const byMem = (a: RoleCard, b: RoleCard) => rnd(mem(b)) - rnd(mem(a)) || a.index - b.index;
|
||||
let main = [...group].sort(byMem)[0];
|
||||
let why: MainWhy = group.length === 1 ? 'faster' : 'same';
|
||||
if (group.length > 1 && new Set(group.map(c => `${c.name}|${rnd(mem(c))}`)).size === 1) main = [...group].sort((a, b) => b.index - a.index)[0]; // identical cards: the HIGHEST number is Main
|
||||
if (top <= 0 && new Set(pool.map(c => rnd(mem(c)))).size > 1) why = 'memory';
|
||||
const bigger = pool.filter(c => rnd(mem(c)) > rnd(mem(main)) && (top <= 0 || speed(c) >= MEMORY_GUARD_SPEED * top));
|
||||
if (bigger.length) { main = [...bigger].sort(byMem)[0]; why = 'memory'; }
|
||||
return { main, why };
|
||||
}
|
||||
|
||||
export type OffWhy = 'cannot_run' | 'igpu' | 'small' | 'helper';
|
||||
export interface Recommendation { roles: Roles; main: number | null; why: MainWhy; reasons: Record<number, MainWhy | OffWhy> }
|
||||
|
||||
/** The best card is Main; every other card starts as Helper, EXCEPT a card with 8 GB or less, built-in graphics beside a real card, and a card the engine cannot run: those start Off. */
|
||||
export function recommend(cards: RoleCard[] | null | undefined, vendor?: string | null): Recommendation {
|
||||
const cs = rows(cards, vendor);
|
||||
const { main, why } = pickMain(cs);
|
||||
const roles: Roles = {}, reasons: Recommendation['reasons'] = {};
|
||||
for (const c of cs) {
|
||||
const i = c.index;
|
||||
if (main && c === main) { roles[i] = 'main'; reasons[i] = why; }
|
||||
else if (!canRun(c)) { roles[i] = 'off'; reasons[i] = 'cannot_run'; }
|
||||
else if (shares(c) && main) { roles[i] = 'off'; reasons[i] = 'igpu'; }
|
||||
else if (rnd(mem(c)) <= OFF_AT_OR_BELOW_GB) { roles[i] = 'off'; reasons[i] = 'small'; }
|
||||
else { roles[i] = 'helper'; reasons[i] = 'helper'; }
|
||||
}
|
||||
return { roles, main: main ? main.index : null, why, reasons };
|
||||
}
|
||||
|
||||
const on = (roles: Roles) => Object.keys(roles).map(Number).filter(i => roles[i] !== 'off');
|
||||
const byNumber = (roles: Roles) => Object.keys(roles).map(Number).sort((a, b) => a - b);
|
||||
|
||||
/** Exactly one Main among the cards that are on; a lone card that is on is Main. */
|
||||
export function normalize(roles: Roles, cards: RoleCard[]): Roles {
|
||||
const cs = new Map(cards.map(c => [c.index, c]));
|
||||
const out: Roles = {};
|
||||
for (const i of byNumber(roles)) if (cs.has(i)) out[i] = ROLES.includes(roles[i]) ? roles[i] : 'off';
|
||||
const onList = on(out), mains = onList.filter(i => out[i] === 'main');
|
||||
if (onList.length && !mains.length) {
|
||||
const best = [...onList].sort((a, b) => speed(cs.get(b)!) - speed(cs.get(a)!) || rnd(mem(cs.get(b)!)) - rnd(mem(cs.get(a)!)) || a - b)[0];
|
||||
out[best] = 'main';
|
||||
}
|
||||
for (const extra of mains.slice(1)) out[extra] = 'helper';
|
||||
if (onList.length === 1) out[onList[0]] = 'main';
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface RoleChange { roles: Roles; note: string | null }
|
||||
|
||||
/** One click: the new roles and a code for the quiet line (old_main_helper:<i>, promoted:<i>, only_main). */
|
||||
export function setRole(roles: Roles, cards: RoleCard[], idx: number, role: Role): RoleChange {
|
||||
const cs = new Map(cards.map(c => [c.index, c]));
|
||||
const out: Roles = { ...roles };
|
||||
let note: string | null = null;
|
||||
if (!cs.has(idx) || !ROLES.includes(role)) return { roles: out, note };
|
||||
const ids = byNumber(out);
|
||||
const fastest = (list: number[]) => [...list].sort((a, b) => speed(cs.get(b)!) - speed(cs.get(a)!) || a - b);
|
||||
if (role === 'main') {
|
||||
const old = ids.find(i => i !== idx && out[i] === 'main');
|
||||
out[idx] = 'main';
|
||||
if (old !== undefined) { out[old] = 'helper'; note = `old_main_helper:${old}`; }
|
||||
} else if (role === 'helper') {
|
||||
if (out[idx] === 'main') {
|
||||
const others = fastest(ids.filter(i => i !== idx && out[i] === 'helper'));
|
||||
if (!others.length) return { roles: out, note: 'only_main' };
|
||||
out[idx] = 'helper'; out[others[0]] = 'main'; note = `promoted:${others[0]}`;
|
||||
} else out[idx] = 'helper';
|
||||
} else {
|
||||
const wasMain = out[idx] === 'main';
|
||||
out[idx] = 'off';
|
||||
if (wasMain) {
|
||||
const next = fastest(ids.filter(i => out[i] === 'helper'));
|
||||
if (next.length) { out[next[0]] = 'main'; note = `promoted:${next[0]}`; }
|
||||
}
|
||||
}
|
||||
const onList = on(out);
|
||||
if (onList.length === 1) out[onList[0]] = 'main';
|
||||
else if (onList.length && !onList.some(i => out[i] === 'main')) out[onList[0]] = 'main';
|
||||
return { roles: out, note };
|
||||
}
|
||||
|
||||
/** The engine's card list: Helpers (slowest first, ties by number), then the Main card LAST; Off cards left out. */
|
||||
export function order(roles: Roles, cards: RoleCard[]): number[] {
|
||||
const cs = new Map(cards.map(c => [c.index, c]));
|
||||
const r = normalize(roles, cards);
|
||||
const helpers = byNumber(r).filter(i => r[i] === 'helper').sort((a, b) => speed(cs.get(a)!) - speed(cs.get(b)!) || a - b);
|
||||
return [...helpers, ...byNumber(r).filter(i => r[i] === 'main')];
|
||||
}
|
||||
|
||||
export interface RoleMirror { gpu_roles: Record<string, Role>; gpu: number | number[]; gpu_order?: 'as_given'; gpu_none?: true; gpu_cards: Record<string, string> }
|
||||
/** What a writer stores: "gpu_roles" plus the legacy mirror older engines and the Monitor read. Nothing on: "gpu": [] and "gpu_none": true. */
|
||||
export function mirror(roles: Roles, cards: RoleCard[]): RoleMirror {
|
||||
const r = normalize(roles, cards), lst = order(r, cards);
|
||||
const out: RoleMirror = { gpu_roles: Object.fromEntries(byNumber(r).map(i => [String(i), r[i]])), gpu: [], gpu_cards: {} };
|
||||
if (lst.length) { out.gpu = lst.length === 1 ? lst[0] : lst; out.gpu_order = 'as_given'; } else out.gpu_none = true;
|
||||
for (const c of cards) if (c.index in r) out.gpu_cards[String(c.index)] = [shortName(c), c.bus ?? ''].filter(Boolean).join('|');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Roles of an old "gpu" setting, for display: the LAST card of the engine's order is Main, earlier ones Helpers, unlisted cards Off. */
|
||||
export function fromLegacy(gpu: unknown, cards: RoleCard[], gpuOrder?: string | null): Roles {
|
||||
const cs = new Map(cards.map(c => [c.index, c]));
|
||||
let items: number[] = Array.isArray(gpu) ? gpu.map(Number) : gpu === null || gpu === undefined || gpu === '' ? [] : String(gpu).split(',').filter(x => x.trim() !== '').map(Number);
|
||||
items = items.filter(i => cs.has(i));
|
||||
if (!items.length) return { ...recommend(cards).roles };
|
||||
if (items.length > 1 && gpuOrder !== 'as_given') {
|
||||
const sp = items.map(i => speed(cs.get(i)!));
|
||||
if (sp.every(x => x > 0) && Math.max(...sp) >= SAME_SPEED * Math.min(...sp)) items = [...items].sort((a, b) => speed(cs.get(a)!) - speed(cs.get(b)!));
|
||||
}
|
||||
const roles: Roles = {};
|
||||
for (const c of cards) roles[c.index] = 'off';
|
||||
for (const i of items.slice(0, -1)) roles[i] = 'helper';
|
||||
roles[items[items.length - 1]] = 'main';
|
||||
return roles;
|
||||
}
|
||||
|
||||
export const sameRoles = (a: Roles, b: Roles) => { const ka = byNumber(a), kb = byNumber(b); return ka.length === kb.length && ka.every((k, n) => k === kb[n] && a[k] === b[k]); };
|
||||
|
||||
export interface RoleWarning { code: 'igpu_main' | 'cannot_run' | 'low_memory' | 'cpu_only'; card: number | null; text: string; fix: { card: number; role: Role } | null }
|
||||
|
||||
/** One short line each, only what a person could not know. Never blocking. */
|
||||
export function warnings(roles: Roles, cards: RoleCard[], opts: { spillGb?: number; engineRunsCpuOnly?: boolean } = {}): RoleWarning[] {
|
||||
const cs = new Map(cards.map(c => [c.index, c]));
|
||||
const out: RoleWarning[] = [];
|
||||
for (const i of byNumber(roles)) {
|
||||
const c = cs.get(i), role = roles[i];
|
||||
if (!c || role === 'off') continue;
|
||||
if (!canRun(c)) out.push({ code: 'cannot_run', card: i, text: `${shortName(c)} cannot run Strata.`, fix: { card: i, role: 'off' } });
|
||||
else if (role === 'main' && shares(c) && cards.some(x => !shares(x) && canRun(x))) {
|
||||
const real = cards.filter(x => !shares(x) && canRun(x)).sort((a, b) => speed(b) - speed(a) || mem(b) - mem(a) || a.index - b.index)[0];
|
||||
out.push({ code: 'igpu_main', card: i, text: `${shortName(c)} is built-in graphics; it is slow as Main.`, fix: { card: real.index, role: 'main' } });
|
||||
}
|
||||
}
|
||||
if (!on(roles).length) out.push({ code: 'cpu_only', card: null, text: opts.engineRunsCpuOnly ? CPU_ONLY_LINE : NO_CARD_LINE, fix: null });
|
||||
else if ((opts.spillGb ?? 0) >= 1) out.push({ code: 'low_memory', card: null, text: `Not enough memory for this setup: about ${Math.round(opts.spillGb!)} GB is read from the drive.`, fix: null });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The graphics memory the cards that are on give for the model (an extra card costs its own dense layers); 0 when all are off. */
|
||||
export function vramOn(roles: Roles, cards: RoleCard[]): number {
|
||||
const cs = new Map(cards.map(c => [c.index, c]));
|
||||
const onCards = on(roles).map(i => cs.get(i)).filter((c): c is RoleCard => !!c && !shares(c));
|
||||
return Math.max(0, onCards.reduce((n, c) => n + mem(c), 0) - SPLIT_CARD_OVERHEAD_GB * Math.max(0, onCards.length - 1));
|
||||
}
|
||||
|
||||
export function noteText(code: string | null, cards: RoleCard[]): string {
|
||||
if (!code) return '';
|
||||
const [kind, arg] = code.split(':');
|
||||
const c = arg !== undefined ? cards.find(x => x.index === Number(arg)) : undefined;
|
||||
const name = c ? shortName(c) : 'That card';
|
||||
if (kind === 'old_main_helper') return `${name} is now a Helper.`;
|
||||
if (kind === 'promoted') return `${name} is now Main.`;
|
||||
if (kind === 'only_main') return 'The only card that is on has to be Main.';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** The one plain line above the cards. */
|
||||
export function sentence(cards: RoleCard[] | null | undefined, vendor?: string | null): string {
|
||||
const cs = rows(cards, vendor), rec = recommend(cards, vendor);
|
||||
if (rec.main === null) return 'No graphics card that Strata can use was found.';
|
||||
const byi = new Map(cs.map(c => [c.index, c]));
|
||||
const m = byi.get(rec.main)!;
|
||||
if (cs.length === 1) return `Strata will use your ${shortName(m)}.`;
|
||||
const why = { same: 'the cards are alike', memory: 'it has more memory', faster: 'it is faster', only: 'it is the only card', none: '' }[rec.why];
|
||||
let s = `${shortName(m)} is Main: ${why}.`;
|
||||
const helpers = byNumber(rec.roles).filter(i => rec.roles[i] === 'helper').map(i => shortName(byi.get(i)!));
|
||||
if (helpers.length) s += ` ${helpers.join(' and ')} ${helpers.length > 1 ? 'help' : 'helps'}.`;
|
||||
for (const i of byNumber(rec.roles)) if (rec.roles[i] === 'off') {
|
||||
const code = rec.reasons[i], c = byi.get(i)!;
|
||||
s += ` ${shortName(c)} starts Off: ${code === 'cannot_run' ? 'Strata cannot run it.' : code === 'igpu' ? 'built-in graphics.' : `${OFF_AT_OR_BELOW_GB} GB or less.`}`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** `Main: GPU 2 RTX 4070 Ti. Helper: GPU 1 RTX 3060.` */
|
||||
export function describe(roles: Roles, cards: RoleCard[]): string {
|
||||
const cs = new Map(cards.map(c => [c.index, c]));
|
||||
const parts: string[] = [];
|
||||
for (const [role, word] of [['main', 'Main'], ['helper', 'Helper'], ['off', 'Off']] as const) {
|
||||
const names = byNumber(roles).filter(i => roles[i] === role).map(i => (cs.has(i) ? `GPU ${i + 1} ${shortName(cs.get(i)!)}` : `GPU ${i + 1}`));
|
||||
if (names.length) parts.push(`${word}: ${names.join(', ')}.`);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/** A saved "gpu_roles" object -> roles; null when it is not one. */
|
||||
export function parseSaved(value: unknown): Roles | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const out: Roles = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (!/^\d+$/.test(k.trim()) || !ROLES.includes(String(v).toLowerCase() as Role)) return null;
|
||||
out[Number(k)] = String(v).toLowerCase() as Role;
|
||||
}
|
||||
if (!Object.keys(out).length || Object.values(out).filter(r => r === 'main').length > 1) return null;
|
||||
return out;
|
||||
}
|
||||
@@ -36,7 +36,7 @@ const patterns=Object.entries(english).filter(([,value])=>/\{\d+\}/.test(value))
|
||||
expression+=escape(value.slice(at));
|
||||
return{id,slots,pattern:new RegExp('^'+expression+'$'),specificity:value.replace(/\{\d+\}/g,'').length};
|
||||
}).sort((a,b)=>b.specificity-a.specificity);
|
||||
const localizedTemplateSlots:Record<string,number[]>={s2473:[1,2],s0825:[0,1],s2455:[0],s2456:[0]};
|
||||
const localizedTemplateSlots:Record<string,number[]>={s2473:[1,2],s0825:[0,1],s2455:[0],s2456:[0],s7400:[1]};
|
||||
|
||||
/** Translate interface copy only. Non-string React children pass through unchanged. */
|
||||
export function tr<T>(value:T,values?:readonly unknown[]):T{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { tr } from './i18n';
|
||||
import type { LoopRecord } from './types';
|
||||
import { LOOP_MAX_FAILURES, LOOP_MAX_RUNS, judgeAnswer, loopEndNote, loopEvery, loopLimitNote, withStopRule, type LoopLimits, type LoopStop } from './loop';
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,8 @@ export interface LoopHost {
|
||||
now(): number;
|
||||
setTimer(fn: () => void, ms: number): unknown;
|
||||
clearTimer(handle: unknown): void;
|
||||
/** Keep a small record of the loop on the chat (running, or how it ended) so the task board can show it. Optional: the tests do not need it. */
|
||||
track?(sessionId: string, record: LoopRecord): void;
|
||||
}
|
||||
|
||||
export interface LoopStart extends LoopLimits { everyMs: number; prompt: string }
|
||||
@@ -30,6 +33,8 @@ interface LoopState extends LoopStart {
|
||||
startedAt: number;
|
||||
timer: unknown;
|
||||
running: boolean;
|
||||
lastText?: string;
|
||||
nextAt?: number;
|
||||
}
|
||||
|
||||
export class LoopScheduler {
|
||||
@@ -41,6 +46,15 @@ export class LoopScheduler {
|
||||
|
||||
isRunning(sessionId: string | null): boolean { return !!sessionId && this.loops.has(sessionId); }
|
||||
|
||||
/** The chats that have a loop going. */
|
||||
ids(): string[] { return [...this.loops.keys()]; }
|
||||
|
||||
private record(sessionId: string, loop: LoopState, endedWhy?: NonNullable<LoopRecord['endedWhy']>) {
|
||||
const record: LoopRecord = { prompt: loop.prompt, everyMs: loop.everyMs, startedAt: loop.startedAt, state: endedWhy ? 'ended' : 'running', answers: loop.answers, ...(loop.maxRuns !== undefined ? { maxRuns: loop.maxRuns } : {}), ...(loop.lastText ? { lastText: loop.lastText } : {}) };
|
||||
if (endedWhy) { record.endedAt = this.host.now(); record.endedWhy = endedWhy; } else if (loop.nextAt !== undefined) record.nextAt = loop.nextAt;
|
||||
try { this.host.track?.(sessionId, record); } catch { /* the record is a courtesy to the board */ }
|
||||
}
|
||||
|
||||
/** What the person is told for /loop with nothing after it. */
|
||||
describe(sessionId: string): string | null {
|
||||
const loop = this.loops.get(sessionId);
|
||||
@@ -49,11 +63,15 @@ export class LoopScheduler {
|
||||
return tr('A loop is running in this chat: it sends "{0}" {1}, while this chat is open. Type /loop stop to stop it.', [loop.prompt, loopEvery(loop.everyMs)]) + (limit ? ' ' + limit : '');
|
||||
}
|
||||
|
||||
stop(sessionId: string): boolean {
|
||||
/** The person stopped it (/loop stop or the board's Stop loop). */
|
||||
stop(sessionId: string): boolean { return this.end(sessionId, 'stopped'); }
|
||||
|
||||
private end(sessionId: string, why: NonNullable<LoopRecord['endedWhy']>): boolean {
|
||||
const loop = this.loops.get(sessionId);
|
||||
if (!loop) return false;
|
||||
if (loop.timer) this.host.clearTimer(loop.timer);
|
||||
this.loops.delete(sessionId);
|
||||
this.record(sessionId, loop, why);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -62,6 +80,7 @@ export class LoopScheduler {
|
||||
if (this.loops.has(sessionId)) { this.host.note(sessionId, tr('This chat already has a loop. Type /loop stop first, then start a new one.')); return false; }
|
||||
const loop: LoopState = { ...command, answers: 0, failures: 0, startedAt: this.host.now(), timer: null, running: false };
|
||||
this.loops.set(sessionId, loop);
|
||||
this.record(sessionId, loop);
|
||||
const limit = loopLimitNote(loop);
|
||||
this.host.note(sessionId, tr('Loop started: "{0}" {1}, while this chat is open. Type /loop stop to stop it. For work that must go on with Strata closed, use the Tasks screen.', [loop.prompt, loopEvery(loop.everyMs)]) + (limit ? ' ' + limit : ''));
|
||||
await this.run(sessionId);
|
||||
@@ -79,6 +98,7 @@ export class LoopScheduler {
|
||||
// The person may have typed /loop stop while the answer was coming: nothing more happens then.
|
||||
if (this.loops.get(sessionId) !== loop) return;
|
||||
if (result.answered) {
|
||||
loop.lastText = String(result.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 200);
|
||||
if (result.failed) loop.failures += 1; else { loop.failures = 0; loop.answers += 1; }
|
||||
why = judgeAnswer(loop, loop, result, this.host.now());
|
||||
}
|
||||
@@ -94,13 +114,15 @@ export class LoopScheduler {
|
||||
}
|
||||
|
||||
private finish(sessionId: string, loop: LoopState, why: LoopStop) {
|
||||
this.stop(sessionId);
|
||||
this.end(sessionId, why);
|
||||
this.host.note(sessionId, loopEndNote(why, loop.answers));
|
||||
}
|
||||
|
||||
private schedule(sessionId: string, ms: number) {
|
||||
const loop = this.loops.get(sessionId);
|
||||
if (!loop) return;
|
||||
loop.nextAt = this.host.now() + ms;
|
||||
this.record(sessionId, loop);
|
||||
loop.timer = this.host.setTimer(() => void this.tick(sessionId), ms);
|
||||
}
|
||||
|
||||
@@ -108,7 +130,7 @@ export class LoopScheduler {
|
||||
const loop = this.loops.get(sessionId);
|
||||
if (!loop) return;
|
||||
loop.timer = null;
|
||||
if (!this.host.exists(sessionId)) { this.stop(sessionId); return; }
|
||||
if (!this.host.exists(sessionId)) { this.end(sessionId, 'stopped'); return; }
|
||||
// The time is up, or the count is reached: never fire again, even if the last answer was not judged (a busy chat).
|
||||
if (loop.maxMs !== undefined && this.host.now() - loop.startedAt >= loop.maxMs) { this.finish(sessionId, loop, 'time'); return; }
|
||||
if (loop.maxRuns !== undefined && loop.answers >= loop.maxRuns) { this.finish(sessionId, loop, 'count'); return; }
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Finding the math in an answer (#3268). Pure text work, no rendering: the pieces are drawn by components/MathView.tsx.
|
||||
*
|
||||
* Recognised, as models write them:
|
||||
* $$ ... $$ and \[ ... \] display math (may span lines)
|
||||
* $ ... $ and \( ... \) inline math (one line)
|
||||
* A dollar sign that is money stays text: "costs $5 and $10" has no math, because an opening $ must be followed by a non-space,
|
||||
* a closing $ must follow a non-space and must not be followed by a digit. `\$` is a literal dollar sign.
|
||||
* Code (backtick spans, fenced blocks) is never searched: the caller splits it off first.
|
||||
*/
|
||||
export type MathPart = { kind: 'text'; text: string } | { kind: 'math'; tex: string; display: boolean };
|
||||
|
||||
const isSpace = (c: string | undefined) => c === undefined || /\s/.test(c);
|
||||
const isDigit = (c: string | undefined) => c !== undefined && c >= '0' && c <= '9';
|
||||
|
||||
/** Where the unescaped `close` starts at or after `from`, or -1. `\\` and `\$` style escapes are skipped. */
|
||||
function findClose(text: string, from: number, close: string, oneLine: boolean): number {
|
||||
for (let i = from; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (oneLine && c === '\n')
|
||||
return -1;
|
||||
if (c === '\\' && close !== '\\)' && close !== '\\]') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (text.startsWith(close, i))
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function splitMath(text: string): MathPart[] {
|
||||
const parts: MathPart[] = [];
|
||||
let plain = '';
|
||||
const flush = () => {
|
||||
if (plain) {
|
||||
parts.push({ kind: 'text', text: plain });
|
||||
plain = '';
|
||||
}
|
||||
};
|
||||
const push = (tex: string, display: boolean) => {
|
||||
if (!tex.trim())
|
||||
return false;
|
||||
flush();
|
||||
parts.push({ kind: 'math', tex: tex.trim(), display });
|
||||
return true;
|
||||
};
|
||||
for (let i = 0; i < text.length;) {
|
||||
const c = text[i];
|
||||
if (c === '\\') {
|
||||
const next = text[i + 1];
|
||||
if (next === '$') { // \$ is a dollar sign
|
||||
plain += '$';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (next === '(' || next === '[') {
|
||||
const close = next === '(' ? '\\)' : '\\]';
|
||||
const end = findClose(text, i + 2, close, next === '(');
|
||||
if (end > 0 && push(text.slice(i + 2, end), next === '[')) {
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
plain += c + (next ?? '');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === '$') {
|
||||
if (text[i + 1] === '$') {
|
||||
const end = findClose(text, i + 2, '$$', false);
|
||||
if (end > 0 && push(text.slice(i + 2, end), true)) {
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
plain += '$$';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (!isSpace(text[i + 1])) {
|
||||
const end = findClose(text, i + 1, '$', true);
|
||||
if (end > i + 1 && !isSpace(text[end - 1]) && !isDigit(text[end + 1]) && push(text.slice(i + 1, end), false)) {
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
plain += c;
|
||||
i++;
|
||||
}
|
||||
flush();
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** True when the text holds any math, so a plain answer costs nothing. */
|
||||
export function hasMath(text: string): boolean {
|
||||
return text.includes('$') || text.includes('\\(') || text.includes('\\[');
|
||||
}
|
||||
|
||||
/**
|
||||
* A block of display math that starts a line: `$$ ... $$` on one line, or `$$` / `\[` on its own line up to the line with the closing
|
||||
* delimiter. Returns the formula and the number of lines used, or null (an unclosed block, e.g. while the answer is still streaming,
|
||||
* stays text).
|
||||
*/
|
||||
export function displayBlock(lines: string[], at: number): { tex: string; used: number } | null {
|
||||
const first = lines[at].trim();
|
||||
const opens = first.startsWith('$$') ? '$$' : first.startsWith('\\[') ? '\\[' : '';
|
||||
if (!opens)
|
||||
return null;
|
||||
const closes = opens === '$$' ? '$$' : '\\]';
|
||||
const rest = first.slice(2);
|
||||
const sameLine = rest.indexOf(closes);
|
||||
if (sameLine >= 0) {
|
||||
// "$$ x $$" alone on the line; text after the closing delimiter makes it an inline display in a paragraph instead
|
||||
return rest.slice(sameLine + closes.length).trim() ? null : { tex: rest.slice(0, sameLine), used: 1 };
|
||||
}
|
||||
const body = [rest];
|
||||
for (let j = at + 1; j < lines.length && j < at + 60; j++) {
|
||||
const t = lines[j].trim();
|
||||
const end = t.indexOf(closes);
|
||||
if (end >= 0) {
|
||||
if (t.slice(end + closes.length).trim())
|
||||
return null;
|
||||
body.push(t.slice(0, end));
|
||||
return { tex: body.join('\n'), used: j - at + 1 };
|
||||
}
|
||||
body.push(lines[j]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export interface Fit {
|
||||
}
|
||||
|
||||
const DENSE_GB = 5, SYSTEM_ROOM_GB = 10, DENSE_EXTRA_GB = 1;
|
||||
/** The draft layer (MTP) takes about 1 GB of graphics memory (786 MiB of files + the draft head's ~218 MiB); it comes out of the expert cache while MTP is on (setup.py MTP_VRAM_GB). */
|
||||
export const MTP_VRAM_GB = 1;
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
|
||||
export function memoryNeeded(v: FitInput): number {
|
||||
@@ -28,16 +30,16 @@ export function memoryNeeded(v: FitInput): number {
|
||||
return round1((v.experts_gb ?? v.ram_needed_gb ?? 0) + DENSE_EXTRA_GB);
|
||||
}
|
||||
|
||||
export function fitOn(v: FitInput, hw: FitHardware | null | undefined): Fit {
|
||||
export function fitOn(v: FitInput, hw: FitHardware | null | undefined, mtp = true): Fit {
|
||||
const memoryGb = memoryNeeded(v);
|
||||
const ram = Number(hw?.ram_gb), vram = Number(hw?.vram_gb) || 0;
|
||||
if (!Number.isFinite(ram) || ram <= 0) return { memoryGb, vramUseGb: 0, ramUseGb: 0, spillGb: 0, known: false };
|
||||
const experts = Math.max(0, memoryNeeded(v) - DENSE_EXTRA_GB);
|
||||
const onGpu = Math.max(0, Math.min(experts, vram - DENSE_GB));
|
||||
const onGpu = Math.max(0, Math.min(experts, vram - DENSE_GB - (mtp && vram > 0 ? MTP_VRAM_GB : 0)));
|
||||
const ramRoom = Math.max(0, ram - SYSTEM_ROOM_GB);
|
||||
const spillGb = Math.max(0, experts - onGpu - ramRoom);
|
||||
const inRam = Math.max(0, experts - onGpu - spillGb);
|
||||
const vramUseGb = vram > 0 ? Math.min(vram, DENSE_GB + onGpu) : 0;
|
||||
const vramUseGb = vram > 0 ? Math.min(vram, DENSE_GB + (mtp ? MTP_VRAM_GB : 0) + onGpu) : 0;
|
||||
return { memoryGb, vramUseGb: round1(vramUseGb), ramUseGb: round1(inRam + DENSE_EXTRA_GB * (vram > 0 ? 0 : 1)), spillGb: round1(spillGb), known: true };
|
||||
}
|
||||
|
||||
@@ -62,7 +64,43 @@ export const RECOMMENDED: { family: string; model: string; note: string }[] = [
|
||||
* (the running version's group used to stay open whatever was clicked). */
|
||||
export const groupOpen = (opened: Record<string, boolean>, id: string, containsSelected: boolean): boolean => opened[id] ?? containsSelected;
|
||||
|
||||
/** The recommended four, found in a version list (a version the catalog does not offer is skipped). */
|
||||
export function recommendedVersions<V extends { sourceFamily: string; model: string }>(versions: V[]): { r: typeof RECOMMENDED[number]; v: V }[] {
|
||||
return RECOMMENDED.map(r => ({ r, v: versions.find(v => v.sourceFamily === r.family && v.model === r.model) })).filter((x): x is { r: typeof RECOMMENDED[number]; v: V } => !!x.v);
|
||||
/** What the engine's own pick for this PC is (catalog.recommendation: the memory tiers of setup.py and tools/strata_mcp.py, the same answer `strata launch` shows). */
|
||||
export interface PcPick { family?: string | null; model?: string | null; why?: string }
|
||||
export const PICK_NOTE = 'the pick for this PC';
|
||||
export const OTHER_NOTE = 'the other choice';
|
||||
|
||||
/**
|
||||
* The recommended set, found in a version list (a version the catalog does not offer is skipped). The four fixed cards are only a display list;
|
||||
* the pick comes from the engine's own recommendation for this PC (RAM + VRAM tiers: 40 GB Swift 1.5 IQ2_XS, 48 GB IQ3_XXS, 64 GB and up IQ3_S), so
|
||||
* the page and `strata launch` agree. The pick comes first and is marked; when the engine names Gyro-M as the other choice (40 GB tier, NVIDIA and AMD only,
|
||||
* which the engine decides) that card follows it. Without a pick the list is the fixed four.
|
||||
*/
|
||||
export function recommendedVersions<V extends { sourceFamily: string; model: string }>(versions: V[], pick?: PcPick | null): { r: typeof RECOMMENDED[number]; v: V }[] {
|
||||
type Row = { r: typeof RECOMMENDED[number]; v: V };
|
||||
const find = (family: string, model: string) => versions.find(v => v.sourceFamily === family && v.model === model);
|
||||
const rows: Row[] = RECOMMENDED.map(r => ({ r, v: find(r.family, r.model) })).filter((x): x is Row => !!x.v);
|
||||
if (!rows.length || !pick?.family || !pick.model) return rows;
|
||||
const chosen = find(pick.family, pick.model);
|
||||
if (!chosen) return rows;
|
||||
const rest = rows.filter(x => x.v !== chosen);
|
||||
const first: Row = { r: { family: pick.family, model: pick.model, note: PICK_NOTE }, v: chosen };
|
||||
const out: Row[] = [first];
|
||||
const gyroM = /Gyro-M/.test(pick.why ?? '') ? find('gyro', 'Gyro-M') : undefined;
|
||||
if (gyroM) out.push({ r: { family: 'gyro', model: 'Gyro-M', note: OTHER_NOTE }, v: gyroM });
|
||||
return [...out, ...rest.filter(x => x.v !== gyroM)];
|
||||
}
|
||||
|
||||
/** A card as the installer's hardware report lists it. */
|
||||
export interface RunCard { index: number; name: string; vram_gb: number; usable?: boolean; vendor?: string; uma?: boolean; integrated?: boolean; unified_gb?: number }
|
||||
|
||||
/**
|
||||
* The cards a model can be placed on: the ones this engine can drive (strata_control/gpu_fit.py `usable_cards` and `for_vendor`, same rule).
|
||||
* A card the engine has no code for, a card of the other make (an engine drives one make; the report numbers each make from 0, so
|
||||
* two cards could share a number) and a chip that shares the system memory while the computer has a card of its own are left out.
|
||||
*/
|
||||
export function runCards<T extends RunCard>(gpus: T[] | null | undefined, engineVendor?: string | null): T[] {
|
||||
const sharesMemory = (g: RunCard) => !!(g.integrated || g.uma || g.unified_gb);
|
||||
const mine = (gpus ?? []).filter(g => g.usable !== false && (!engineVendor || !g.vendor || g.vendor === engineVendor));
|
||||
const hasReal = mine.some(g => !sharesMemory(g) && g.vram_gb >= 4);
|
||||
return mine.filter(g => !(hasReal && sharesMemory(g)));
|
||||
}
|
||||
Loaded 100 of 590 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user