arcane-os 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +47 -11
- package/browser-runtime/ai/browser-wasm-llm-provider.mjs +1071 -91
- package/package.json +1 -1
- package/runtime/arcane/components/chat.html +133 -10
- package/runtime/arcane/components/local-ai-status.html +2 -2
- package/runtime/arcane/modules/AI.js +22 -6
- package/runtime/arcane/modules/LocalAIReadiness.js +8 -2
|
@@ -15,6 +15,7 @@ const completeValue = (value) => value;
|
|
|
15
15
|
const MODEL_MANIFEST_SCHEMA = "arcane.ai.browser-wasm.model.v4";
|
|
16
16
|
const BROWSER_MODEL_SOURCES = new WeakSet();
|
|
17
17
|
const BROWSER_MODEL_SOURCE_METADATA = new WeakMap();
|
|
18
|
+
const BROWSER_MODEL_SOURCE_TRANSPORTS = new WeakMap();
|
|
18
19
|
const MODEL_DESCRIPTOR_METADATA = new WeakMap();
|
|
19
20
|
const DBOPFS_MODEL_STORES = new WeakSet();
|
|
20
21
|
const V1_LLM_PROVIDER_ADAPTERS = new WeakMap();
|
|
@@ -25,6 +26,11 @@ const WEBGPU_ADAPTER_SELECTION_PROTOCOL = "arcane-ai-webgpu-adapter-selection/1"
|
|
|
25
26
|
const CHROME_HIGH_PERFORMANCE_GPU_FLAG_URL =
|
|
26
27
|
"chrome://flags/#force-high-performance-gpu";
|
|
27
28
|
const MODEL_LOAD_HEARTBEAT_MS = 5_000;
|
|
29
|
+
const DEFAULT_MODEL_DOWNLOAD_CONCURRENCY = 4;
|
|
30
|
+
const MODEL_DOWNLOAD_PROGRESS_INTERVAL_MS = 250;
|
|
31
|
+
const MODEL_DOWNLOAD_SPEED_WINDOW_MS = 5_000;
|
|
32
|
+
const MODEL_DOWNLOAD_MAX_RANGE_PARTS = 16;
|
|
33
|
+
const MODEL_DOWNLOAD_TARGET_RANGE_BYTES = 128_000_000;
|
|
28
34
|
const INTEL_VENDOR_ID = 0x8086;
|
|
29
35
|
const CAPABILITY_POLICY_PROTOCOL = "arcane-ai-browser-capability-policy/1";
|
|
30
36
|
let highPerformanceGpuNoticeShown = false;
|
|
@@ -50,6 +56,78 @@ function normalizationSignal(error, signal) {
|
|
|
50
56
|
return error?.code === "ARCANE_AI_WORKER_TERMINATION_UNCONFIRMED" ? null : signal;
|
|
51
57
|
}
|
|
52
58
|
|
|
59
|
+
async function cancelReadableBody(body, reason) {
|
|
60
|
+
try {
|
|
61
|
+
await body?.cancel?.(reason);
|
|
62
|
+
} catch {
|
|
63
|
+
// Cancellation is cleanup; preserve the transfer failure that prompted it.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function cancelOpenedDownload(opened, reason) {
|
|
68
|
+
try {
|
|
69
|
+
await opened?.cancel?.(reason);
|
|
70
|
+
} catch {
|
|
71
|
+
// Cancellation is cleanup; preserve the transfer failure that prompted it.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function httpContentRange(value) {
|
|
76
|
+
const match = /^bytes ([0-9]+)-([0-9]+)\/([0-9]+)$/iu.exec(
|
|
77
|
+
String(value ?? "").trim(),
|
|
78
|
+
);
|
|
79
|
+
if (!match) return null;
|
|
80
|
+
const start = Number(match[1]);
|
|
81
|
+
const end = Number(match[2]);
|
|
82
|
+
const total = Number(match[3]);
|
|
83
|
+
if (
|
|
84
|
+
!Number.isSafeInteger(start)
|
|
85
|
+
|| !Number.isSafeInteger(end)
|
|
86
|
+
|| !Number.isSafeInteger(total)
|
|
87
|
+
|| start < 0
|
|
88
|
+
|| end < start
|
|
89
|
+
|| total <= end
|
|
90
|
+
) return null;
|
|
91
|
+
return completeValue({ start, end, total });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function httpContentLength(value) {
|
|
95
|
+
const length = Number(String(value ?? "").trim());
|
|
96
|
+
return Number.isSafeInteger(length) && length > 0 ? length : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function downloadConcurrencyValue(value) {
|
|
100
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
101
|
+
throw new RangeError("Model downloadConcurrency must be a positive safe integer.");
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function modelHttpRanges(total) {
|
|
107
|
+
const count = Math.min(
|
|
108
|
+
MODEL_DOWNLOAD_MAX_RANGE_PARTS,
|
|
109
|
+
Math.ceil(total / MODEL_DOWNLOAD_TARGET_RANGE_BYTES),
|
|
110
|
+
total,
|
|
111
|
+
);
|
|
112
|
+
const width = Math.floor(total / count);
|
|
113
|
+
const remainder = total % count;
|
|
114
|
+
const ranges = [];
|
|
115
|
+
let start = 0;
|
|
116
|
+
for (let index = 0; index < count; index += 1) {
|
|
117
|
+
const length = width + (index < remainder ? 1 : 0);
|
|
118
|
+
const end = start + length - 1;
|
|
119
|
+
ranges.push(completeValue({ start, end, total }));
|
|
120
|
+
start = end + 1;
|
|
121
|
+
}
|
|
122
|
+
return completeValue(ranges);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function progressClock() {
|
|
126
|
+
return typeof globalThis.performance?.now === "function"
|
|
127
|
+
? globalThis.performance.now()
|
|
128
|
+
: Date.now();
|
|
129
|
+
}
|
|
130
|
+
|
|
53
131
|
function modelSourceUrl(value) {
|
|
54
132
|
let url;
|
|
55
133
|
try {
|
|
@@ -134,6 +212,9 @@ function descriptorFile(value, { fallbackName = null } = {}) {
|
|
|
134
212
|
name: descriptorFileName(value, url, fallbackName),
|
|
135
213
|
url: url.href,
|
|
136
214
|
};
|
|
215
|
+
if (Number.isSafeInteger(value.bytes) && value.bytes > 0) {
|
|
216
|
+
file.bytes = value.bytes;
|
|
217
|
+
}
|
|
137
218
|
return completeValue(file);
|
|
138
219
|
}
|
|
139
220
|
|
|
@@ -177,11 +258,13 @@ function modelDescriptor(value) {
|
|
|
177
258
|
const publicFiles = completeValue(files.map((file) => completeValue({
|
|
178
259
|
name: file.name,
|
|
179
260
|
url: file.url,
|
|
261
|
+
...(file.bytes === undefined ? {} : { bytes: file.bytes }),
|
|
180
262
|
})));
|
|
181
263
|
let descriptor;
|
|
182
264
|
if (legacy) {
|
|
183
265
|
const [file] = files;
|
|
184
266
|
descriptor = { id, url: file.url };
|
|
267
|
+
if (file.bytes !== undefined) descriptor.bytes = file.bytes;
|
|
185
268
|
} else {
|
|
186
269
|
descriptor = { id, files: publicFiles };
|
|
187
270
|
}
|
|
@@ -276,61 +359,183 @@ export function createBrowserModelSource(descriptor, {
|
|
|
276
359
|
} = {}) {
|
|
277
360
|
const model = modelDescriptor(descriptor);
|
|
278
361
|
const metadata = MODEL_DESCRIPTOR_METADATA.get(model);
|
|
362
|
+
const rangeRequestUrls = new Array(metadata.files.length).fill(null);
|
|
279
363
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (metadata.files.length !== 1) {
|
|
284
|
-
throw new TypeError("A browser model file index is required for a multi-file source.");
|
|
285
|
-
}
|
|
286
|
-
memberIndex = 0;
|
|
287
|
-
options = memberOrOptions ?? {};
|
|
364
|
+
function selectedMember(memberIndex) {
|
|
365
|
+
if (!Number.isSafeInteger(memberIndex)) {
|
|
366
|
+
throw new TypeError("A browser model file index must be a safe integer.");
|
|
288
367
|
}
|
|
289
368
|
if (memberIndex < 0 || memberIndex >= metadata.files.length) {
|
|
290
369
|
throw new RangeError("Browser model file index is out of range.");
|
|
291
370
|
}
|
|
292
|
-
|
|
293
|
-
|
|
371
|
+
return metadata.files[memberIndex];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function request(memberIndex, { signal, range = null, url = null } = {}) {
|
|
375
|
+
const member = selectedMember(memberIndex);
|
|
294
376
|
throwIfAborted(signal, "install");
|
|
295
377
|
const fetchFunction = fetchImpl ?? globalThis.fetch?.bind(globalThis);
|
|
296
378
|
if (typeof fetchFunction !== "function") {
|
|
297
379
|
throw fail("ARCANE_AI_MODEL_SOURCE_UNAVAILABLE", "Browser fetch is unavailable.");
|
|
298
380
|
}
|
|
381
|
+
const requestOptions = {
|
|
382
|
+
cache: "no-store",
|
|
383
|
+
redirect: "follow",
|
|
384
|
+
signal,
|
|
385
|
+
};
|
|
386
|
+
if (range) requestOptions.headers = { Range: `bytes=${range.start}-${range.end}` };
|
|
299
387
|
|
|
300
388
|
let response;
|
|
301
389
|
try {
|
|
302
|
-
response = await fetchFunction(member.url,
|
|
303
|
-
cache: "no-store",
|
|
304
|
-
redirect: "follow",
|
|
305
|
-
signal,
|
|
306
|
-
});
|
|
390
|
+
response = await fetchFunction(url ?? member.url, requestOptions);
|
|
307
391
|
} catch (error) {
|
|
308
392
|
if (signal?.aborted || error?.name === "AbortError") throwIfAborted(signal, "install");
|
|
309
393
|
throw fail("ARCANE_AI_MODEL_DOWNLOAD_FAILED", "The model download failed.", error);
|
|
310
394
|
}
|
|
311
|
-
if (!response?.ok) {
|
|
312
|
-
await response?.body?.cancel?.().catch(() => undefined);
|
|
313
|
-
throw fail(
|
|
314
|
-
"ARCANE_AI_MODEL_DOWNLOAD_FAILED",
|
|
315
|
-
`The model server returned HTTP ${response?.status ?? "unknown"}.`,
|
|
316
|
-
);
|
|
317
|
-
}
|
|
318
395
|
let finalUrl;
|
|
319
396
|
try {
|
|
320
|
-
finalUrl = new URL(response
|
|
397
|
+
finalUrl = new URL(response?.url || url || member.url);
|
|
321
398
|
} catch {
|
|
322
399
|
finalUrl = null;
|
|
323
400
|
}
|
|
324
|
-
finalUrl ??= new URL(member.url);
|
|
401
|
+
finalUrl ??= new URL(url ?? member.url);
|
|
402
|
+
return completeValue({ member, response, finalUrl: finalUrl.href });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function rejectHttpResponse(download) {
|
|
406
|
+
await cancelReadableBody(download.response?.body);
|
|
407
|
+
throw fail(
|
|
408
|
+
"ARCANE_AI_MODEL_DOWNLOAD_FAILED",
|
|
409
|
+
`The model server returned HTTP ${download.response?.status ?? "unknown"}.`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function openedDownload(download) {
|
|
414
|
+
const { member, response, finalUrl } = download;
|
|
325
415
|
if (!response.body || typeof response.body.getReader !== "function") {
|
|
326
416
|
throw fail("ARCANE_AI_MODEL_SOURCE_INVALID", "The model response did not provide a byte stream.");
|
|
327
417
|
}
|
|
418
|
+
async function cancel(reason) {
|
|
419
|
+
await response.body.cancel(reason);
|
|
420
|
+
}
|
|
328
421
|
return completeValue({
|
|
329
422
|
body: response.body,
|
|
330
423
|
requestedUrl: member.url,
|
|
331
|
-
finalUrl
|
|
332
|
-
|
|
424
|
+
finalUrl,
|
|
425
|
+
contentLength: httpContentLength(response.headers?.get?.("content-length")),
|
|
426
|
+
cancel,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function open(memberOrOptions = 0, options = {}) {
|
|
431
|
+
let memberIndex = memberOrOptions;
|
|
432
|
+
if (!Number.isSafeInteger(memberOrOptions)) {
|
|
433
|
+
if (metadata.files.length !== 1) {
|
|
434
|
+
throw new TypeError("A browser model file index is required for a multi-file source.");
|
|
435
|
+
}
|
|
436
|
+
memberIndex = 0;
|
|
437
|
+
options = memberOrOptions ?? {};
|
|
438
|
+
}
|
|
439
|
+
const { signal } = options;
|
|
440
|
+
const download = await request(memberIndex, { signal });
|
|
441
|
+
if (!download.response?.ok) await rejectHttpResponse(download);
|
|
442
|
+
return openedDownload(download);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function probeRange(memberIndex, { signal } = {}) {
|
|
446
|
+
const download = await request(memberIndex, {
|
|
447
|
+
signal,
|
|
448
|
+
range: { start: 0, end: 0 },
|
|
449
|
+
});
|
|
450
|
+
if (download.response?.status === 200) {
|
|
451
|
+
if (download.finalUrl !== download.member.url) {
|
|
452
|
+
let redirectedProbe = null;
|
|
453
|
+
try {
|
|
454
|
+
redirectedProbe = await request(memberIndex, {
|
|
455
|
+
signal,
|
|
456
|
+
range: { start: 0, end: 0 },
|
|
457
|
+
url: download.finalUrl,
|
|
458
|
+
});
|
|
459
|
+
if (redirectedProbe.response?.status === 206) {
|
|
460
|
+
const header = redirectedProbe.response.headers?.get?.("content-range");
|
|
461
|
+
const observed = httpContentRange(header);
|
|
462
|
+
const total = observed?.total
|
|
463
|
+
?? httpContentLength(download.response.headers?.get?.("content-length"))
|
|
464
|
+
?? selectedMember(memberIndex).bytes
|
|
465
|
+
?? null;
|
|
466
|
+
if (
|
|
467
|
+
(!header || observed)
|
|
468
|
+
&& (!observed || (observed.start === 0 && observed.end === 0))
|
|
469
|
+
&& Number.isSafeInteger(total)
|
|
470
|
+
&& total > 0
|
|
471
|
+
) {
|
|
472
|
+
await cancelReadableBody(download.response.body);
|
|
473
|
+
await cancelReadableBody(redirectedProbe.response.body);
|
|
474
|
+
rangeRequestUrls[memberIndex] = redirectedProbe.finalUrl;
|
|
475
|
+
return completeValue({ kind: "supported", total });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
await cancelReadableBody(redirectedProbe.response?.body);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
if (signal?.aborted) {
|
|
481
|
+
await cancelReadableBody(download.response.body, signal.reason);
|
|
482
|
+
throw error;
|
|
483
|
+
}
|
|
484
|
+
await cancelReadableBody(redirectedProbe?.response?.body, error);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return completeValue({ kind: "complete", opened: openedDownload(download) });
|
|
488
|
+
}
|
|
489
|
+
if (download.response?.status !== 206) await rejectHttpResponse(download);
|
|
490
|
+
const header = download.response.headers?.get?.("content-range");
|
|
491
|
+
const observed = httpContentRange(header);
|
|
492
|
+
await cancelReadableBody(download.response.body);
|
|
493
|
+
if (
|
|
494
|
+
(header && !observed)
|
|
495
|
+
|| (observed && (observed.start !== 0 || observed.end !== 0))
|
|
496
|
+
) {
|
|
497
|
+
return completeValue({ kind: "unsupported" });
|
|
498
|
+
}
|
|
499
|
+
const total = observed?.total ?? selectedMember(memberIndex).bytes ?? null;
|
|
500
|
+
if (total === null) return completeValue({ kind: "unsupported" });
|
|
501
|
+
rangeRequestUrls[memberIndex] = download.finalUrl;
|
|
502
|
+
return completeValue({ kind: "supported", total });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function openRange(memberIndex, { signal, start, end, total } = {}) {
|
|
506
|
+
if (
|
|
507
|
+
!Number.isSafeInteger(start)
|
|
508
|
+
|| !Number.isSafeInteger(end)
|
|
509
|
+
|| !Number.isSafeInteger(total)
|
|
510
|
+
|| start < 0
|
|
511
|
+
|| end < start
|
|
512
|
+
|| end >= total
|
|
513
|
+
) {
|
|
514
|
+
throw new RangeError("Browser model HTTP range framing is invalid.");
|
|
515
|
+
}
|
|
516
|
+
const download = await request(memberIndex, {
|
|
517
|
+
signal,
|
|
518
|
+
range: { start, end },
|
|
519
|
+
url: rangeRequestUrls[memberIndex],
|
|
333
520
|
});
|
|
521
|
+
const header = download.response?.headers?.get?.("content-range");
|
|
522
|
+
const observed = httpContentRange(header);
|
|
523
|
+
if (
|
|
524
|
+
download.response?.status !== 206
|
|
525
|
+
|| (header && !observed)
|
|
526
|
+
|| (observed && (
|
|
527
|
+
observed.start !== start
|
|
528
|
+
|| observed.end !== end
|
|
529
|
+
|| observed.total !== total
|
|
530
|
+
))
|
|
531
|
+
) {
|
|
532
|
+
await cancelReadableBody(download.response?.body);
|
|
533
|
+
throw fail(
|
|
534
|
+
"ARCANE_AI_MODEL_DOWNLOAD_FAILED",
|
|
535
|
+
"The model server did not preserve the requested HTTP byte range.",
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
return openedDownload(download);
|
|
334
539
|
}
|
|
335
540
|
|
|
336
541
|
const sourceRecord = {
|
|
@@ -346,6 +551,7 @@ export function createBrowserModelSource(descriptor, {
|
|
|
346
551
|
const source = completeValue(sourceRecord);
|
|
347
552
|
BROWSER_MODEL_SOURCES.add(source);
|
|
348
553
|
BROWSER_MODEL_SOURCE_METADATA.set(source, metadata);
|
|
554
|
+
BROWSER_MODEL_SOURCE_TRANSPORTS.set(source, completeValue({ probeRange, openRange }));
|
|
349
555
|
return source;
|
|
350
556
|
}
|
|
351
557
|
|
|
@@ -420,6 +626,34 @@ function storageName(source, { legacy = false } = {}) {
|
|
|
420
626
|
});
|
|
421
627
|
}
|
|
422
628
|
|
|
629
|
+
function rangePartName(modelName, range) {
|
|
630
|
+
return `${modelName}.range-${range.start}-${range.end}-of-${range.total}.part`;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function rangePartPrefix(modelName) {
|
|
634
|
+
return `${modelName}.range-`;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function rangePartDetails(modelName, name) {
|
|
638
|
+
const prefix = rangePartPrefix(modelName);
|
|
639
|
+
if (!name.startsWith(prefix)) return null;
|
|
640
|
+
const match = /^([0-9]+)-([0-9]+)-of-([0-9]+)\.part$/u.exec(name.slice(prefix.length));
|
|
641
|
+
if (!match) return null;
|
|
642
|
+
const start = Number(match[1]);
|
|
643
|
+
const end = Number(match[2]);
|
|
644
|
+
const total = Number(match[3]);
|
|
645
|
+
if (
|
|
646
|
+
!Number.isSafeInteger(start)
|
|
647
|
+
|| !Number.isSafeInteger(end)
|
|
648
|
+
|| !Number.isSafeInteger(total)
|
|
649
|
+
|| start < 0
|
|
650
|
+
|| end < start
|
|
651
|
+
|| total <= end
|
|
652
|
+
) return null;
|
|
653
|
+
const range = completeValue({ start, end, total });
|
|
654
|
+
return rangePartName(modelName, range) === name ? range : null;
|
|
655
|
+
}
|
|
656
|
+
|
|
423
657
|
function securitySnapshot(security) {
|
|
424
658
|
return security?.secure === true ? { secure: true } : undefined;
|
|
425
659
|
}
|
|
@@ -433,6 +667,7 @@ export function createDbopfsModelStore({
|
|
|
433
667
|
dbopfs,
|
|
434
668
|
tableName = "arcane_ai_browser_models",
|
|
435
669
|
estimateStorage = null,
|
|
670
|
+
downloadConcurrency = DEFAULT_MODEL_DOWNLOAD_CONCURRENCY,
|
|
436
671
|
} = {}) {
|
|
437
672
|
if (!dbopfs || (typeof dbopfs !== "object" && typeof dbopfs !== "function")) {
|
|
438
673
|
throw new TypeError("createDbopfsModelStore requires an existing DBOPFS instance.");
|
|
@@ -446,6 +681,7 @@ export function createDbopfsModelStore({
|
|
|
446
681
|
if (estimateStorage !== null && typeof estimateStorage !== "function") {
|
|
447
682
|
throw new TypeError("estimateStorage must be a function or null.");
|
|
448
683
|
}
|
|
684
|
+
const workerLimit = downloadConcurrencyValue(downloadConcurrency);
|
|
449
685
|
let tablePromise = null;
|
|
450
686
|
|
|
451
687
|
async function table() {
|
|
@@ -478,38 +714,630 @@ export function createDbopfsModelStore({
|
|
|
478
714
|
}
|
|
479
715
|
}
|
|
480
716
|
|
|
481
|
-
|
|
717
|
+
function createDownloadProgressReporter(members, onProgress, retainFailure) {
|
|
718
|
+
const memberTotals = members.map((member) => member.bytes ?? null);
|
|
719
|
+
let loadedBytes = 0;
|
|
720
|
+
let completed = 0;
|
|
721
|
+
let activeTransfers = 0;
|
|
722
|
+
let transferMode = members.length === 1 ? "probing" : "files";
|
|
723
|
+
let timer = null;
|
|
724
|
+
let lastPublishedAt = 0;
|
|
725
|
+
let samples = [];
|
|
726
|
+
|
|
727
|
+
function totalBytesValue() {
|
|
728
|
+
if (memberTotals.some((value) => value === null)) return null;
|
|
729
|
+
const totalBytes = memberTotals.reduce((sum, value) => sum + value, 0);
|
|
730
|
+
return Number.isSafeInteger(totalBytes) ? totalBytes : null;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function progressRecord(now) {
|
|
734
|
+
samples.push(completeValue({ at: now, loadedBytes }));
|
|
735
|
+
const oldestUsefulTime = now - MODEL_DOWNLOAD_SPEED_WINDOW_MS;
|
|
736
|
+
while (samples.length > 1 && samples[1].at <= oldestUsefulTime) samples.shift();
|
|
737
|
+
const firstSample = samples[0];
|
|
738
|
+
const elapsedSeconds = (now - firstSample.at) / 1_000;
|
|
739
|
+
const sampledBytes = loadedBytes - firstSample.loadedBytes;
|
|
740
|
+
const bytesPerSecond = sampledBytes > 0 && elapsedSeconds > 0
|
|
741
|
+
? Math.round(sampledBytes / elapsedSeconds)
|
|
742
|
+
: null;
|
|
743
|
+
const totalBytes = totalBytesValue();
|
|
744
|
+
const remainingBytes = totalBytes === null
|
|
745
|
+
? null
|
|
746
|
+
: Math.max(0, totalBytes - loadedBytes);
|
|
747
|
+
const etaSeconds = remainingBytes === 0
|
|
748
|
+
? 0
|
|
749
|
+
: bytesPerSecond === null || remainingBytes === null
|
|
750
|
+
? null
|
|
751
|
+
: Math.ceil(remainingBytes / bytesPerSecond);
|
|
752
|
+
return completeValue({
|
|
753
|
+
phase: "download",
|
|
754
|
+
completed,
|
|
755
|
+
total: members.length,
|
|
756
|
+
unit: "files",
|
|
757
|
+
heartbeat: false,
|
|
758
|
+
loadedBytes,
|
|
759
|
+
totalBytes,
|
|
760
|
+
remainingBytes,
|
|
761
|
+
bytesPerSecond,
|
|
762
|
+
etaSeconds,
|
|
763
|
+
activeTransfers,
|
|
764
|
+
transferLimit: workerLimit,
|
|
765
|
+
transferMode,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function stopTimer() {
|
|
770
|
+
if (timer === null) return;
|
|
771
|
+
clearInterval(timer);
|
|
772
|
+
timer = null;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function publish({ force = false } = {}) {
|
|
776
|
+
if (onProgress === null) return;
|
|
777
|
+
const now = progressClock();
|
|
778
|
+
if (!force && now - lastPublishedAt < MODEL_DOWNLOAD_PROGRESS_INTERVAL_MS) return;
|
|
779
|
+
onProgress(progressRecord(now));
|
|
780
|
+
lastPublishedAt = now;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function publishSafely(options) {
|
|
784
|
+
try {
|
|
785
|
+
publish(options);
|
|
786
|
+
return true;
|
|
787
|
+
} catch (error) {
|
|
788
|
+
stopTimer();
|
|
789
|
+
retainFailure(error);
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function setMemberTotal(memberIndex, totalBytes) {
|
|
795
|
+
if (!Number.isSafeInteger(totalBytes) || totalBytes <= 0) return;
|
|
796
|
+
if (memberTotals[memberIndex] === totalBytes) return;
|
|
797
|
+
memberTotals[memberIndex] = totalBytes;
|
|
798
|
+
publishSafely({ force: true });
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function addBytes(value) {
|
|
802
|
+
if (!Number.isSafeInteger(value) || value <= 0) return;
|
|
803
|
+
loadedBytes += value;
|
|
804
|
+
publishSafely();
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function discardBytes(value) {
|
|
808
|
+
if (!Number.isSafeInteger(value) || value <= 0) return;
|
|
809
|
+
loadedBytes = Math.max(0, loadedBytes - value);
|
|
810
|
+
samples = [];
|
|
811
|
+
publishSafely({ force: true });
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return completeValue({
|
|
815
|
+
addBytes,
|
|
816
|
+
beginTransfer() {
|
|
817
|
+
activeTransfers += 1;
|
|
818
|
+
publishSafely({ force: true });
|
|
819
|
+
},
|
|
820
|
+
completeMember(memberIndex, totalBytes) {
|
|
821
|
+
if (Number.isSafeInteger(totalBytes) && totalBytes > 0) {
|
|
822
|
+
memberTotals[memberIndex] = totalBytes;
|
|
823
|
+
}
|
|
824
|
+
completed += 1;
|
|
825
|
+
publishSafely({ force: true });
|
|
826
|
+
},
|
|
827
|
+
discardBytes,
|
|
828
|
+
dispose: stopTimer,
|
|
829
|
+
endTransfer({ publishProgress = true } = {}) {
|
|
830
|
+
activeTransfers = Math.max(0, activeTransfers - 1);
|
|
831
|
+
if (publishProgress) publishSafely({ force: true });
|
|
832
|
+
},
|
|
833
|
+
finish() {
|
|
834
|
+
publishSafely({ force: true });
|
|
835
|
+
},
|
|
836
|
+
setMemberTotal,
|
|
837
|
+
setMode(value) {
|
|
838
|
+
if (transferMode === value) return;
|
|
839
|
+
transferMode = value;
|
|
840
|
+
publishSafely({ force: true });
|
|
841
|
+
},
|
|
842
|
+
restoreBytes(value) {
|
|
843
|
+
if (!Number.isSafeInteger(value) || value <= 0) return;
|
|
844
|
+
loadedBytes += value;
|
|
845
|
+
samples = [];
|
|
846
|
+
publishSafely({ force: true });
|
|
847
|
+
},
|
|
848
|
+
restoreMember(memberIndex, totalBytes) {
|
|
849
|
+
if (!Number.isSafeInteger(totalBytes) || totalBytes <= 0) return;
|
|
850
|
+
memberTotals[memberIndex] = totalBytes;
|
|
851
|
+
loadedBytes += totalBytes;
|
|
852
|
+
completed += 1;
|
|
853
|
+
samples = [];
|
|
854
|
+
publishSafely({ force: true });
|
|
855
|
+
},
|
|
856
|
+
start() {
|
|
857
|
+
if (!publishSafely({ force: true })) return;
|
|
858
|
+
if (onProgress !== null && timer === null) {
|
|
859
|
+
timer = setInterval(function publishDownloadProgressTick() {
|
|
860
|
+
publishSafely();
|
|
861
|
+
}, MODEL_DOWNLOAD_PROGRESS_INTERVAL_MS);
|
|
862
|
+
}
|
|
863
|
+
},
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async function write(name, body, {
|
|
868
|
+
signal,
|
|
869
|
+
onChunk = null,
|
|
870
|
+
onDiscard = null,
|
|
871
|
+
} = {}) {
|
|
482
872
|
const directory = await table();
|
|
483
873
|
const handle = await directory.getFileHandle(name, { create: true });
|
|
484
874
|
const writable = await handle.createWritable();
|
|
875
|
+
let written = 0;
|
|
485
876
|
try {
|
|
486
877
|
for await (const chunk of byteChunks(body, signal)) {
|
|
487
878
|
await writable.write(chunk);
|
|
879
|
+
written += chunk.byteLength;
|
|
880
|
+
onChunk?.(chunk.byteLength);
|
|
881
|
+
throwIfAborted(signal, "install");
|
|
488
882
|
}
|
|
489
883
|
throwIfAborted(signal, "install");
|
|
490
884
|
await writable.close();
|
|
491
|
-
return
|
|
885
|
+
return written;
|
|
492
886
|
} catch (error) {
|
|
493
887
|
await writable.abort?.(error).catch(() => undefined);
|
|
494
888
|
await directory.removeEntry(name).catch(() => undefined);
|
|
889
|
+
onDiscard?.(written);
|
|
890
|
+
throw error;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async function storedModelFile(name) {
|
|
895
|
+
const modelFile = await file(name);
|
|
896
|
+
if (!modelFile || modelFile.size === 0) {
|
|
897
|
+
throw fail(
|
|
898
|
+
"ARCANE_AI_MODEL_CACHE_REJECTED",
|
|
899
|
+
"A stored model file could not be reopened as a non-empty model.",
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
return modelFile;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async function writeOpenedModel(name, opened, {
|
|
906
|
+
signal,
|
|
907
|
+
onChunk = null,
|
|
908
|
+
onDiscard = null,
|
|
909
|
+
} = {}) {
|
|
910
|
+
let written = 0;
|
|
911
|
+
try {
|
|
912
|
+
written = await write(name, opened.body, { signal, onChunk, onDiscard });
|
|
913
|
+
return await storedModelFile(name);
|
|
914
|
+
} catch (error) {
|
|
915
|
+
if (written > 0) onDiscard?.(written);
|
|
916
|
+
await cancelOpenedDownload(opened, error);
|
|
495
917
|
throw error;
|
|
496
918
|
}
|
|
497
919
|
}
|
|
498
920
|
|
|
921
|
+
async function writeParallelRanges(
|
|
922
|
+
source,
|
|
923
|
+
memberIndex,
|
|
924
|
+
name,
|
|
925
|
+
total,
|
|
926
|
+
{
|
|
927
|
+
signal,
|
|
928
|
+
progress,
|
|
929
|
+
retainInstallFailure = null,
|
|
930
|
+
rangeWorkerLimit = workerLimit,
|
|
931
|
+
} = {},
|
|
932
|
+
) {
|
|
933
|
+
const transport = BROWSER_MODEL_SOURCE_TRANSPORTS.get(source);
|
|
934
|
+
const ranges = modelHttpRanges(total);
|
|
935
|
+
const partFiles = new Array(ranges.length);
|
|
936
|
+
const pendingRangeIndexes = [];
|
|
937
|
+
for (let rangeIndex = 0; rangeIndex < ranges.length; rangeIndex += 1) {
|
|
938
|
+
throwIfAborted(signal, "install");
|
|
939
|
+
const range = ranges[rangeIndex];
|
|
940
|
+
const partName = rangePartName(name, range);
|
|
941
|
+
const partFile = await file(partName);
|
|
942
|
+
const expected = range.end - range.start + 1;
|
|
943
|
+
if (partFile?.size === expected) {
|
|
944
|
+
partFiles[rangeIndex] = partFile;
|
|
945
|
+
progress?.restoreBytes(expected);
|
|
946
|
+
} else {
|
|
947
|
+
if (partFile) await removeEntry(partName);
|
|
948
|
+
pendingRangeIndexes.push(rangeIndex);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
const linked = linkAbortSignal(signal);
|
|
952
|
+
const downloadSignal = linked.controller.signal;
|
|
953
|
+
let nextPendingIndex = 0;
|
|
954
|
+
let failure = null;
|
|
955
|
+
|
|
956
|
+
function retainFailure(error) {
|
|
957
|
+
if (failure === null) failure = error;
|
|
958
|
+
if (!downloadSignal.aborted) linked.controller.abort(error);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
async function transferRangeWorker() {
|
|
962
|
+
while (true) {
|
|
963
|
+
const pendingIndex = nextPendingIndex;
|
|
964
|
+
nextPendingIndex += 1;
|
|
965
|
+
if (pendingIndex >= pendingRangeIndexes.length) return;
|
|
966
|
+
const rangeIndex = pendingRangeIndexes[pendingIndex];
|
|
967
|
+
const range = ranges[rangeIndex];
|
|
968
|
+
const partName = rangePartName(name, range);
|
|
969
|
+
let opened = null;
|
|
970
|
+
let writable = null;
|
|
971
|
+
let received = 0;
|
|
972
|
+
progress?.beginTransfer();
|
|
973
|
+
try {
|
|
974
|
+
opened = await transport.openRange(memberIndex, {
|
|
975
|
+
signal: downloadSignal,
|
|
976
|
+
start: range.start,
|
|
977
|
+
end: range.end,
|
|
978
|
+
total: range.total,
|
|
979
|
+
});
|
|
980
|
+
const directory = await table();
|
|
981
|
+
const handle = await directory.getFileHandle(partName, { create: true });
|
|
982
|
+
writable = await handle.createWritable();
|
|
983
|
+
const expected = range.end - range.start + 1;
|
|
984
|
+
for await (const chunk of byteChunks(opened.body, downloadSignal)) {
|
|
985
|
+
const nextReceived = received + chunk.byteLength;
|
|
986
|
+
if (!Number.isSafeInteger(nextReceived) || nextReceived > expected) {
|
|
987
|
+
throw fail(
|
|
988
|
+
"ARCANE_AI_MODEL_DOWNLOAD_FAILED",
|
|
989
|
+
"The model server returned more content than the requested HTTP byte range.",
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
await writable.write(chunk);
|
|
993
|
+
received = nextReceived;
|
|
994
|
+
progress?.addBytes(chunk.byteLength);
|
|
995
|
+
throwIfAborted(downloadSignal, "install");
|
|
996
|
+
}
|
|
997
|
+
if (received !== expected) {
|
|
998
|
+
throw fail(
|
|
999
|
+
"ARCANE_AI_MODEL_DOWNLOAD_FAILED",
|
|
1000
|
+
"The model server returned less content than the requested HTTP byte range.",
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
throwIfAborted(downloadSignal, "install");
|
|
1004
|
+
await writable.close();
|
|
1005
|
+
writable = null;
|
|
1006
|
+
partFiles[rangeIndex] = await storedModelFile(partName);
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
retainInstallFailure?.(error);
|
|
1009
|
+
retainFailure(error);
|
|
1010
|
+
await cancelOpenedDownload(opened, error);
|
|
1011
|
+
try {
|
|
1012
|
+
await writable?.abort?.(error);
|
|
1013
|
+
} catch {
|
|
1014
|
+
// The range part is removed below even if its writable already closed.
|
|
1015
|
+
}
|
|
1016
|
+
await removeEntry(partName).catch(() => undefined);
|
|
1017
|
+
progress?.discardBytes(received);
|
|
1018
|
+
throw error;
|
|
1019
|
+
} finally {
|
|
1020
|
+
progress?.endTransfer({ publishProgress: failure === null });
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const workers = [];
|
|
1026
|
+
const workerCount = Math.min(rangeWorkerLimit, pendingRangeIndexes.length);
|
|
1027
|
+
for (let index = 0; index < workerCount; index += 1) {
|
|
1028
|
+
workers.push(transferRangeWorker());
|
|
1029
|
+
}
|
|
1030
|
+
try {
|
|
1031
|
+
const results = await Promise.allSettled(workers);
|
|
1032
|
+
if (failure === null) {
|
|
1033
|
+
for (const result of results) {
|
|
1034
|
+
if (result.status === "rejected") {
|
|
1035
|
+
failure = result.reason;
|
|
1036
|
+
break;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
if (failure !== null) throw failure;
|
|
1041
|
+
throwIfAborted(downloadSignal, "install");
|
|
1042
|
+
const modelFile = new Blob(partFiles, { type: "application/octet-stream" });
|
|
1043
|
+
await removeStaleRangePartsAfterCompletion(name, ranges);
|
|
1044
|
+
return modelFile;
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
retainFailure(error);
|
|
1047
|
+
throw failure;
|
|
1048
|
+
} finally {
|
|
1049
|
+
linked.release();
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
async function installOneFile(
|
|
1054
|
+
source,
|
|
1055
|
+
memberIndex,
|
|
1056
|
+
name,
|
|
1057
|
+
{
|
|
1058
|
+
signal,
|
|
1059
|
+
progress,
|
|
1060
|
+
retainFailure = null,
|
|
1061
|
+
multiFile = false,
|
|
1062
|
+
rangeWorkerLimit = workerLimit,
|
|
1063
|
+
} = {},
|
|
1064
|
+
) {
|
|
1065
|
+
const transport = BROWSER_MODEL_SOURCE_TRANSPORTS.get(source);
|
|
1066
|
+
if (!transport) return null;
|
|
1067
|
+
if (!multiFile) progress?.setMode("probing");
|
|
1068
|
+
progress?.beginTransfer();
|
|
1069
|
+
let probe;
|
|
1070
|
+
try {
|
|
1071
|
+
probe = await transport.probeRange(memberIndex, { signal });
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
retainFailure?.(error);
|
|
1074
|
+
progress?.endTransfer({ publishProgress: false });
|
|
1075
|
+
throw error;
|
|
1076
|
+
}
|
|
1077
|
+
if (probe.kind === "complete") {
|
|
1078
|
+
if (!multiFile) progress?.setMode("single");
|
|
1079
|
+
progress?.setMemberTotal(memberIndex, probe.opened.contentLength);
|
|
1080
|
+
try {
|
|
1081
|
+
const modelFile = await writeOpenedModel(name, probe.opened, {
|
|
1082
|
+
signal,
|
|
1083
|
+
onChunk: progress?.addBytes,
|
|
1084
|
+
onDiscard: progress?.discardBytes,
|
|
1085
|
+
});
|
|
1086
|
+
await removeRangePartsAfterWholeFile(name, probe.opened.contentLength);
|
|
1087
|
+
return modelFile;
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
retainFailure?.(error);
|
|
1090
|
+
throw error;
|
|
1091
|
+
} finally {
|
|
1092
|
+
progress?.endTransfer({ publishProgress: false });
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
progress?.endTransfer({ publishProgress: false });
|
|
1096
|
+
if (probe.kind !== "supported") return null;
|
|
1097
|
+
if (!multiFile) progress?.setMode("ranges");
|
|
1098
|
+
progress?.setMemberTotal(memberIndex, probe.total);
|
|
1099
|
+
return writeParallelRanges(source, memberIndex, name, probe.total, {
|
|
1100
|
+
signal,
|
|
1101
|
+
progress,
|
|
1102
|
+
rangeWorkerLimit,
|
|
1103
|
+
retainInstallFailure: retainFailure,
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
|
|
499
1107
|
async function removeNames(names) {
|
|
500
|
-
const removed = [
|
|
1108
|
+
const removed = [];
|
|
501
1109
|
for (const entry of names.models) removed.push(await removeEntry(entry.name));
|
|
1110
|
+
removed.push(await removeEntry(names.manifest));
|
|
1111
|
+
return removed.some(Boolean);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
async function memberRangePartNames(modelName) {
|
|
1115
|
+
const directory = await table();
|
|
1116
|
+
if (typeof directory.entries !== "function") return null;
|
|
1117
|
+
const prefix = rangePartPrefix(modelName);
|
|
1118
|
+
const names = [];
|
|
1119
|
+
for await (const [name] of directory.entries()) {
|
|
1120
|
+
if (name.startsWith(prefix)) names.push(name);
|
|
1121
|
+
}
|
|
1122
|
+
return names;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
async function removeMemberRangeParts(modelName, {
|
|
1126
|
+
except = null,
|
|
1127
|
+
total = null,
|
|
1128
|
+
} = {}) {
|
|
1129
|
+
const existingNames = await memberRangePartNames(modelName);
|
|
1130
|
+
const names = existingNames ?? (
|
|
1131
|
+
Number.isSafeInteger(total) && total > 0
|
|
1132
|
+
? modelHttpRanges(total).map((range) => rangePartName(modelName, range))
|
|
1133
|
+
: []
|
|
1134
|
+
);
|
|
1135
|
+
const removed = [];
|
|
1136
|
+
for (const name of names) {
|
|
1137
|
+
if (except?.has(name)) continue;
|
|
1138
|
+
removed.push(await removeEntry(name));
|
|
1139
|
+
}
|
|
1140
|
+
return removed.some(Boolean);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
async function removeRangePartsAfterWholeFile(modelName, total) {
|
|
1144
|
+
try {
|
|
1145
|
+
await removeMemberRangeParts(modelName, { total });
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
globalThis.console?.warn?.(
|
|
1148
|
+
"Arcane could not remove superseded browser model range parts.",
|
|
1149
|
+
error,
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
async function removeStaleRangePartsAfterCompletion(modelName, ranges) {
|
|
1155
|
+
const keep = new Set(ranges.map((range) => rangePartName(modelName, range)));
|
|
1156
|
+
try {
|
|
1157
|
+
await removeMemberRangeParts(modelName, {
|
|
1158
|
+
except: keep,
|
|
1159
|
+
total: ranges[0]?.total ?? null,
|
|
1160
|
+
});
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
globalThis.console?.warn?.(
|
|
1163
|
+
"Arcane could not remove superseded browser model range parts.",
|
|
1164
|
+
error,
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
async function removeRangePartsBehindWholeFiles(members, names, modelFiles) {
|
|
1170
|
+
const completeMembers = [];
|
|
1171
|
+
for (let memberIndex = 0; memberIndex < modelFiles.length; memberIndex += 1) {
|
|
1172
|
+
if (!modelFiles[memberIndex]) continue;
|
|
1173
|
+
completeMembers.push(completeValue({
|
|
1174
|
+
modelName: names.models[memberIndex].name,
|
|
1175
|
+
total: members[memberIndex].bytes,
|
|
1176
|
+
}));
|
|
1177
|
+
}
|
|
1178
|
+
if (completeMembers.length === 0) return;
|
|
1179
|
+
try {
|
|
1180
|
+
const directory = await table();
|
|
1181
|
+
if (typeof directory.entries === "function") {
|
|
1182
|
+
const prefixes = completeMembers.map(({ modelName }) => rangePartPrefix(modelName));
|
|
1183
|
+
const entries = [];
|
|
1184
|
+
for await (const [name] of directory.entries()) {
|
|
1185
|
+
if (prefixes.some((prefix) => name.startsWith(prefix))) entries.push(name);
|
|
1186
|
+
}
|
|
1187
|
+
for (const name of entries) await removeEntry(name);
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
for (const { modelName, total } of completeMembers) {
|
|
1191
|
+
await removeMemberRangeParts(modelName, { total });
|
|
1192
|
+
}
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
globalThis.console?.warn?.(
|
|
1195
|
+
"Arcane could not remove superseded browser model range parts.",
|
|
1196
|
+
error,
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
async function removeRangeParts(source, names) {
|
|
1202
|
+
const members = sourceMetadata(source).files;
|
|
1203
|
+
const directory = await table();
|
|
1204
|
+
if (typeof directory.entries === "function") {
|
|
1205
|
+
const prefixes = names.models.map((entry) => rangePartPrefix(entry.name));
|
|
1206
|
+
const entries = [];
|
|
1207
|
+
for await (const [name] of directory.entries()) {
|
|
1208
|
+
if (prefixes.some((prefix) => name.startsWith(prefix))) entries.push(name);
|
|
1209
|
+
}
|
|
1210
|
+
const removed = [];
|
|
1211
|
+
for (const name of entries) removed.push(await removeEntry(name));
|
|
1212
|
+
return removed.some(Boolean);
|
|
1213
|
+
}
|
|
1214
|
+
const removed = [];
|
|
1215
|
+
for (let memberIndex = 0; memberIndex < members.length; memberIndex += 1) {
|
|
1216
|
+
const total = members[memberIndex].bytes;
|
|
1217
|
+
if (!Number.isSafeInteger(total) || total <= 0) continue;
|
|
1218
|
+
for (const range of modelHttpRanges(total)) {
|
|
1219
|
+
removed.push(await removeEntry(rangePartName(names.models[memberIndex].name, range)));
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
502
1222
|
return removed.some(Boolean);
|
|
503
1223
|
}
|
|
504
1224
|
|
|
505
1225
|
async function remove(source) {
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
1226
|
+
const names = storageName(source);
|
|
1227
|
+
const legacyNames = storageName(source, { legacy: true });
|
|
1228
|
+
let legacyManifest = null;
|
|
1229
|
+
let removeLegacy = sourceMetadata(source).legacy;
|
|
1230
|
+
try {
|
|
1231
|
+
legacyManifest = await legacyManifestForSource(source, legacyNames);
|
|
1232
|
+
removeLegacy ||= Boolean(legacyManifest);
|
|
1233
|
+
} catch (error) {
|
|
1234
|
+
globalThis.console?.warn?.(
|
|
1235
|
+
"Arcane could not inspect the legacy browser model cache during removal.",
|
|
1236
|
+
error,
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
let removed = await removeNames(names);
|
|
1240
|
+
removed = await removeRangeParts(source, names) || removed;
|
|
1241
|
+
if (removeLegacy) {
|
|
1242
|
+
removed = await removeNames(
|
|
1243
|
+
legacyStorageNames(source, legacyNames, legacyManifest),
|
|
1244
|
+
) || removed;
|
|
509
1245
|
}
|
|
510
1246
|
return removed;
|
|
511
1247
|
}
|
|
512
1248
|
|
|
1249
|
+
async function legacyManifestForSource(source, legacyNames) {
|
|
1250
|
+
const manifestFile = await file(legacyNames.manifest);
|
|
1251
|
+
if (!manifestFile) return null;
|
|
1252
|
+
let manifest;
|
|
1253
|
+
try {
|
|
1254
|
+
manifest = JSON.parse(await manifestFile.text());
|
|
1255
|
+
} catch {
|
|
1256
|
+
return null;
|
|
1257
|
+
}
|
|
1258
|
+
const model = manifest?.model;
|
|
1259
|
+
return manifest?.complete === true && model?.id === source.id ? manifest : null;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function legacyStorageNames(source, names, manifest) {
|
|
1263
|
+
if (!manifest) return names;
|
|
1264
|
+
const safeId = source.id.replace(/[^a-z0-9._-]+/giu, "_");
|
|
1265
|
+
const storedNames = new Set(names.models.map((entry) => entry.name));
|
|
1266
|
+
const candidates = [];
|
|
1267
|
+
if (Array.isArray(manifest.model?.files)) candidates.push(...manifest.model.files);
|
|
1268
|
+
if (manifest.model && typeof manifest.model === "object") candidates.push(manifest.model);
|
|
1269
|
+
if (Array.isArray(manifest.files)) candidates.push(...manifest.files);
|
|
1270
|
+
for (const candidate of candidates) {
|
|
1271
|
+
if (!candidate || typeof candidate !== "object") continue;
|
|
1272
|
+
if (
|
|
1273
|
+
candidate.name === undefined
|
|
1274
|
+
&& candidate.url === undefined
|
|
1275
|
+
&& candidate.immutableUrl === undefined
|
|
1276
|
+
) continue;
|
|
1277
|
+
try {
|
|
1278
|
+
const url = modelSourceUrl(candidate.url ?? candidate.immutableUrl)
|
|
1279
|
+
?? new URL("https://arcane.invalid/model.gguf");
|
|
1280
|
+
const name = descriptorFileName(candidate, url);
|
|
1281
|
+
storedNames.add(`${safeId}--${name}`);
|
|
1282
|
+
} catch {
|
|
1283
|
+
// Keep cleanup limited to valid filenames attributable to this manifest.
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
return completeValue({
|
|
1287
|
+
...names,
|
|
1288
|
+
models: completeValue([...storedNames].map((name) => completeValue({ name }))),
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
async function legacyNamespaceMatchesSource(source, legacyNames) {
|
|
1293
|
+
const metadata = sourceMetadata(source);
|
|
1294
|
+
if (metadata.legacy) return true;
|
|
1295
|
+
if (metadata.files.length !== 1) return false;
|
|
1296
|
+
const manifest = await legacyManifestForSource(source, legacyNames);
|
|
1297
|
+
if (!manifest) return false;
|
|
1298
|
+
const model = manifest.model;
|
|
1299
|
+
const [member] = metadata.files;
|
|
1300
|
+
if (model.url === member.url) return true;
|
|
1301
|
+
if (model.name === member.name && model.immutableUrl === member.url) return true;
|
|
1302
|
+
return Array.isArray(model.files)
|
|
1303
|
+
&& model.files.length === 1
|
|
1304
|
+
&& model.files[0]?.name === member.name
|
|
1305
|
+
&& model.files[0]?.url === member.url;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
async function legacyCacheForSource(source, signal) {
|
|
1309
|
+
const names = storageName(source, { legacy: true });
|
|
1310
|
+
if (!await legacyNamespaceMatchesSource(source, names)) return null;
|
|
1311
|
+
const files = await storedModelFiles(names, signal);
|
|
1312
|
+
return files ? completeValue({ files, names }) : null;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
async function removeLegacyCacheAfterReplacement(source) {
|
|
1316
|
+
try {
|
|
1317
|
+
const names = storageName(source, { legacy: true });
|
|
1318
|
+
const manifest = await legacyManifestForSource(source, names);
|
|
1319
|
+
if (!sourceMetadata(source).legacy && !manifest) return;
|
|
1320
|
+
await removeNames(legacyStorageNames(source, names, manifest));
|
|
1321
|
+
} catch (error) {
|
|
1322
|
+
globalThis.console?.warn?.(
|
|
1323
|
+
"Arcane could not remove the superseded legacy browser model cache.",
|
|
1324
|
+
error,
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
async function removeIncompleteReplacement(source, names) {
|
|
1330
|
+
try {
|
|
1331
|
+
await removeNames(names);
|
|
1332
|
+
await removeRangeParts(source, names);
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
globalThis.console?.warn?.(
|
|
1335
|
+
"Arcane could not remove an incomplete duplicate browser model cache.",
|
|
1336
|
+
error,
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
513
1341
|
async function storagePolicy({ cached = false } = {}) {
|
|
514
1342
|
return completeValue({
|
|
515
1343
|
compatibility: "compatible",
|
|
@@ -520,13 +1348,104 @@ export function createDbopfsModelStore({
|
|
|
520
1348
|
});
|
|
521
1349
|
}
|
|
522
1350
|
|
|
523
|
-
async function
|
|
1351
|
+
async function availableModelFiles(names, signal) {
|
|
524
1352
|
const modelFiles = [];
|
|
525
1353
|
for (const entry of names.models) {
|
|
526
1354
|
throwIfAborted(signal, "install");
|
|
527
1355
|
const modelFile = await file(entry.name);
|
|
528
|
-
if (
|
|
529
|
-
|
|
1356
|
+
if (modelFile?.size === 0) {
|
|
1357
|
+
await removeEntry(entry.name);
|
|
1358
|
+
modelFiles.push(null);
|
|
1359
|
+
} else {
|
|
1360
|
+
modelFiles.push(modelFile);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
return modelFiles;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
async function storedModelFiles(names, signal) {
|
|
1367
|
+
const modelFiles = await availableModelFiles(names, signal);
|
|
1368
|
+
return modelFiles.every(Boolean) ? modelFiles : null;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
async function storedRangeCandidate(modelName, total, signal) {
|
|
1372
|
+
const partFiles = [];
|
|
1373
|
+
let lastModified = 0;
|
|
1374
|
+
for (const range of modelHttpRanges(total)) {
|
|
1375
|
+
throwIfAborted(signal, "install");
|
|
1376
|
+
const partName = rangePartName(modelName, range);
|
|
1377
|
+
const partFile = await file(partName);
|
|
1378
|
+
if (!partFile) return null;
|
|
1379
|
+
const expected = range.end - range.start + 1;
|
|
1380
|
+
if (partFile.size !== expected) {
|
|
1381
|
+
await removeEntry(partName);
|
|
1382
|
+
return null;
|
|
1383
|
+
}
|
|
1384
|
+
partFiles.push(partFile);
|
|
1385
|
+
if (Number.isFinite(partFile.lastModified)) {
|
|
1386
|
+
lastModified = Math.max(lastModified, partFile.lastModified);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
return completeValue({
|
|
1390
|
+
file: new Blob(partFiles, { type: "application/octet-stream" }),
|
|
1391
|
+
lastModified,
|
|
1392
|
+
total,
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
async function storedRangeMember(member, modelName, signal) {
|
|
1397
|
+
const declaredTotal = Number.isSafeInteger(member.bytes) && member.bytes > 0
|
|
1398
|
+
? member.bytes
|
|
1399
|
+
: null;
|
|
1400
|
+
if (declaredTotal !== null) {
|
|
1401
|
+
const declared = await storedRangeCandidate(modelName, declaredTotal, signal);
|
|
1402
|
+
if (declared) {
|
|
1403
|
+
await removeStaleRangePartsAfterCompletion(
|
|
1404
|
+
modelName,
|
|
1405
|
+
modelHttpRanges(declaredTotal),
|
|
1406
|
+
);
|
|
1407
|
+
return declared.file;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
const names = await memberRangePartNames(modelName);
|
|
1412
|
+
if (names === null) return null;
|
|
1413
|
+
const discoveredTotals = new Set();
|
|
1414
|
+
for (const name of names) {
|
|
1415
|
+
const details = rangePartDetails(modelName, name);
|
|
1416
|
+
if (details && details.total !== declaredTotal) discoveredTotals.add(details.total);
|
|
1417
|
+
}
|
|
1418
|
+
let selected = null;
|
|
1419
|
+
const totals = [...discoveredTotals].sort((left, right) => right - left);
|
|
1420
|
+
for (const total of totals) {
|
|
1421
|
+
const candidate = await storedRangeCandidate(modelName, total, signal);
|
|
1422
|
+
if (
|
|
1423
|
+
candidate
|
|
1424
|
+
&& (
|
|
1425
|
+
selected === null
|
|
1426
|
+
|| candidate.lastModified > selected.lastModified
|
|
1427
|
+
)
|
|
1428
|
+
) selected = candidate;
|
|
1429
|
+
}
|
|
1430
|
+
if (selected === null) return null;
|
|
1431
|
+
await removeStaleRangePartsAfterCompletion(
|
|
1432
|
+
modelName,
|
|
1433
|
+
modelHttpRanges(selected.total),
|
|
1434
|
+
);
|
|
1435
|
+
return selected.file;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
async function availableResumableModelFiles(source, names, signal) {
|
|
1439
|
+
const members = sourceMetadata(source).files;
|
|
1440
|
+
const modelFiles = await availableModelFiles(names, signal);
|
|
1441
|
+
await removeRangePartsBehindWholeFiles(members, names, modelFiles);
|
|
1442
|
+
for (let memberIndex = 0; memberIndex < modelFiles.length; memberIndex += 1) {
|
|
1443
|
+
if (modelFiles[memberIndex]) continue;
|
|
1444
|
+
modelFiles[memberIndex] = await storedRangeMember(
|
|
1445
|
+
members[memberIndex],
|
|
1446
|
+
names.models[memberIndex].name,
|
|
1447
|
+
signal,
|
|
1448
|
+
);
|
|
530
1449
|
}
|
|
531
1450
|
return modelFiles;
|
|
532
1451
|
}
|
|
@@ -534,32 +1453,22 @@ export function createDbopfsModelStore({
|
|
|
534
1453
|
async function openCached(source, {
|
|
535
1454
|
signal,
|
|
536
1455
|
} = {}) {
|
|
537
|
-
|
|
538
|
-
let modelFiles = await
|
|
539
|
-
if (
|
|
540
|
-
await
|
|
541
|
-
|
|
542
|
-
const
|
|
543
|
-
if (
|
|
544
|
-
|
|
545
|
-
modelFiles =
|
|
546
|
-
} else {
|
|
547
|
-
await removeNames(legacyNames);
|
|
1456
|
+
const names = storageName(source);
|
|
1457
|
+
let modelFiles = await availableResumableModelFiles(source, names, signal);
|
|
1458
|
+
if (modelFiles.every(Boolean)) {
|
|
1459
|
+
await removeLegacyCacheAfterReplacement(source);
|
|
1460
|
+
} else {
|
|
1461
|
+
const legacyCache = await legacyCacheForSource(source, signal);
|
|
1462
|
+
if (legacyCache) {
|
|
1463
|
+
await removeIncompleteReplacement(source, names);
|
|
1464
|
+
modelFiles = legacyCache.files;
|
|
548
1465
|
}
|
|
549
1466
|
}
|
|
550
|
-
if (!modelFiles)
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
return completeValue({
|
|
556
|
-
files: completeValue(modelFiles),
|
|
557
|
-
file: modelFiles.length === 1 ? modelFiles[0] : null,
|
|
558
|
-
});
|
|
559
|
-
} catch (error) {
|
|
560
|
-
if (!signal?.aborted) await removeNames(names);
|
|
561
|
-
throw error;
|
|
562
|
-
}
|
|
1467
|
+
if (!modelFiles.every(Boolean)) return null;
|
|
1468
|
+
return completeValue({
|
|
1469
|
+
files: completeValue(modelFiles),
|
|
1470
|
+
file: modelFiles.length === 1 ? modelFiles[0] : null,
|
|
1471
|
+
});
|
|
563
1472
|
}
|
|
564
1473
|
|
|
565
1474
|
async function install(source, { signal, onProgress = null } = {}) {
|
|
@@ -568,48 +1477,118 @@ export function createDbopfsModelStore({
|
|
|
568
1477
|
}
|
|
569
1478
|
const names = storageName(source);
|
|
570
1479
|
const members = sourceMetadata(source).files;
|
|
571
|
-
await
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
1480
|
+
const modelFiles = await availableResumableModelFiles(source, names, signal);
|
|
1481
|
+
const linked = linkAbortSignal(signal);
|
|
1482
|
+
const downloadSignal = linked.controller.signal;
|
|
1483
|
+
let nextMemberIndex = 0;
|
|
1484
|
+
let failure = null;
|
|
1485
|
+
|
|
1486
|
+
function retainFailure(error) {
|
|
1487
|
+
if (failure === null) failure = error;
|
|
1488
|
+
if (!downloadSignal.aborted) linked.controller.abort(error);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
const progress = createDownloadProgressReporter(members, onProgress, retainFailure);
|
|
1492
|
+
|
|
1493
|
+
async function installMember(memberIndex) {
|
|
1494
|
+
let modelFile = null;
|
|
1495
|
+
modelFile = await installOneFile(
|
|
1496
|
+
source,
|
|
1497
|
+
memberIndex,
|
|
1498
|
+
names.models[memberIndex].name,
|
|
1499
|
+
{
|
|
1500
|
+
signal: downloadSignal,
|
|
1501
|
+
progress,
|
|
1502
|
+
retainFailure,
|
|
1503
|
+
multiFile: members.length > 1,
|
|
1504
|
+
rangeWorkerLimit: members.length === 1 ? workerLimit : 1,
|
|
1505
|
+
},
|
|
1506
|
+
);
|
|
1507
|
+
if (!modelFile) {
|
|
1508
|
+
progress.setMode(members.length === 1 ? "single" : "files");
|
|
1509
|
+
progress.beginTransfer();
|
|
1510
|
+
let opened = null;
|
|
584
1511
|
try {
|
|
585
|
-
await
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
1512
|
+
opened = await source.open(memberIndex, { signal: downloadSignal });
|
|
1513
|
+
progress.setMemberTotal(memberIndex, opened.contentLength);
|
|
1514
|
+
modelFile = await writeOpenedModel(
|
|
1515
|
+
names.models[memberIndex].name,
|
|
1516
|
+
opened,
|
|
1517
|
+
{
|
|
1518
|
+
signal: downloadSignal,
|
|
1519
|
+
onChunk: progress.addBytes,
|
|
1520
|
+
onDiscard: progress.discardBytes,
|
|
1521
|
+
},
|
|
1522
|
+
);
|
|
1523
|
+
await removeRangePartsAfterWholeFile(
|
|
1524
|
+
names.models[memberIndex].name,
|
|
1525
|
+
opened.contentLength,
|
|
1526
|
+
);
|
|
594
1527
|
} catch (error) {
|
|
595
|
-
|
|
1528
|
+
retainFailure(error);
|
|
596
1529
|
throw error;
|
|
1530
|
+
} finally {
|
|
1531
|
+
progress.endTransfer({ publishProgress: false });
|
|
597
1532
|
}
|
|
598
1533
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
1534
|
+
throwIfAborted(downloadSignal, "install");
|
|
1535
|
+
modelFiles[memberIndex] = modelFile;
|
|
1536
|
+
progress.completeMember(memberIndex, modelFile.size);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
async function installWorker() {
|
|
1540
|
+
while (true) {
|
|
1541
|
+
const memberIndex = nextMemberIndex;
|
|
1542
|
+
nextMemberIndex += 1;
|
|
1543
|
+
if (memberIndex >= members.length) return;
|
|
1544
|
+
if (modelFiles[memberIndex]) continue;
|
|
1545
|
+
try {
|
|
1546
|
+
await installMember(memberIndex);
|
|
1547
|
+
} catch (error) {
|
|
1548
|
+
retainFailure(error);
|
|
1549
|
+
throw error;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
try {
|
|
1555
|
+
progress.start();
|
|
1556
|
+
let pendingMembers = members.length;
|
|
1557
|
+
for (let memberIndex = 0; memberIndex < modelFiles.length; memberIndex += 1) {
|
|
1558
|
+
const modelFile = modelFiles[memberIndex];
|
|
1559
|
+
if (!modelFile) continue;
|
|
1560
|
+
pendingMembers -= 1;
|
|
1561
|
+
progress.restoreMember(memberIndex, modelFile.size);
|
|
1562
|
+
}
|
|
1563
|
+
const workers = [];
|
|
1564
|
+
const workerCount = Math.min(workerLimit, pendingMembers);
|
|
1565
|
+
for (let index = 0; index < workerCount; index += 1) {
|
|
1566
|
+
workers.push(installWorker());
|
|
1567
|
+
}
|
|
1568
|
+
const results = await Promise.allSettled(workers);
|
|
1569
|
+
if (failure === null) {
|
|
1570
|
+
for (const result of results) {
|
|
1571
|
+
if (result.status === "rejected") {
|
|
1572
|
+
failure = result.reason;
|
|
1573
|
+
break;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
if (failure !== null) throw failure;
|
|
1578
|
+
await removeLegacyCacheAfterReplacement(source);
|
|
1579
|
+
progress.finish();
|
|
1580
|
+
if (failure !== null) throw failure;
|
|
1581
|
+
throwIfAborted(downloadSignal, "install");
|
|
606
1582
|
return completeValue({
|
|
607
1583
|
files: completeValue(modelFiles),
|
|
608
1584
|
file: modelFiles.length === 1 ? modelFiles[0] : null,
|
|
609
1585
|
});
|
|
610
1586
|
} catch (error) {
|
|
611
|
-
|
|
612
|
-
throw
|
|
1587
|
+
retainFailure(error);
|
|
1588
|
+
throw failure;
|
|
1589
|
+
} finally {
|
|
1590
|
+
progress.dispose();
|
|
1591
|
+
linked.release();
|
|
613
1592
|
}
|
|
614
1593
|
}
|
|
615
1594
|
|
|
@@ -650,6 +1629,7 @@ export function createDbopfsModelStore({
|
|
|
650
1629
|
const store = completeValue({
|
|
651
1630
|
kind: "arcane-dbopfs-model-store",
|
|
652
1631
|
tableName,
|
|
1632
|
+
downloadConcurrency: workerLimit,
|
|
653
1633
|
adapter: dbopfs,
|
|
654
1634
|
ready: () => table().then(() => undefined),
|
|
655
1635
|
install,
|