arcane-os 0.4.1 → 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 +25 -0
- package/README.md +47 -11
- package/browser-runtime/ai/browser-wasm-llm-provider.mjs +1140 -79
- package/package.json +1 -1
- package/runtime/arcane/components/chat.html +162 -5
- 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();
|
|
@@ -24,6 +25,12 @@ const WEBGPU_ADAPTER_SELECTED_EVENT = "arcane.ai.browser-wasm.webgpu.adapter.sel
|
|
|
24
25
|
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";
|
|
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;
|
|
27
34
|
const INTEL_VENDOR_ID = 0x8086;
|
|
28
35
|
const CAPABILITY_POLICY_PROTOCOL = "arcane-ai-browser-capability-policy/1";
|
|
29
36
|
let highPerformanceGpuNoticeShown = false;
|
|
@@ -49,6 +56,78 @@ function normalizationSignal(error, signal) {
|
|
|
49
56
|
return error?.code === "ARCANE_AI_WORKER_TERMINATION_UNCONFIRMED" ? null : signal;
|
|
50
57
|
}
|
|
51
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
|
+
|
|
52
131
|
function modelSourceUrl(value) {
|
|
53
132
|
let url;
|
|
54
133
|
try {
|
|
@@ -133,6 +212,9 @@ function descriptorFile(value, { fallbackName = null } = {}) {
|
|
|
133
212
|
name: descriptorFileName(value, url, fallbackName),
|
|
134
213
|
url: url.href,
|
|
135
214
|
};
|
|
215
|
+
if (Number.isSafeInteger(value.bytes) && value.bytes > 0) {
|
|
216
|
+
file.bytes = value.bytes;
|
|
217
|
+
}
|
|
136
218
|
return completeValue(file);
|
|
137
219
|
}
|
|
138
220
|
|
|
@@ -176,11 +258,13 @@ function modelDescriptor(value) {
|
|
|
176
258
|
const publicFiles = completeValue(files.map((file) => completeValue({
|
|
177
259
|
name: file.name,
|
|
178
260
|
url: file.url,
|
|
261
|
+
...(file.bytes === undefined ? {} : { bytes: file.bytes }),
|
|
179
262
|
})));
|
|
180
263
|
let descriptor;
|
|
181
264
|
if (legacy) {
|
|
182
265
|
const [file] = files;
|
|
183
266
|
descriptor = { id, url: file.url };
|
|
267
|
+
if (file.bytes !== undefined) descriptor.bytes = file.bytes;
|
|
184
268
|
} else {
|
|
185
269
|
descriptor = { id, files: publicFiles };
|
|
186
270
|
}
|
|
@@ -275,63 +359,185 @@ export function createBrowserModelSource(descriptor, {
|
|
|
275
359
|
} = {}) {
|
|
276
360
|
const model = modelDescriptor(descriptor);
|
|
277
361
|
const metadata = MODEL_DESCRIPTOR_METADATA.get(model);
|
|
362
|
+
const rangeRequestUrls = new Array(metadata.files.length).fill(null);
|
|
278
363
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (metadata.files.length !== 1) {
|
|
283
|
-
throw new TypeError("A browser model file index is required for a multi-file source.");
|
|
284
|
-
}
|
|
285
|
-
memberIndex = 0;
|
|
286
|
-
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.");
|
|
287
367
|
}
|
|
288
368
|
if (memberIndex < 0 || memberIndex >= metadata.files.length) {
|
|
289
369
|
throw new RangeError("Browser model file index is out of range.");
|
|
290
370
|
}
|
|
291
|
-
|
|
292
|
-
|
|
371
|
+
return metadata.files[memberIndex];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function request(memberIndex, { signal, range = null, url = null } = {}) {
|
|
375
|
+
const member = selectedMember(memberIndex);
|
|
293
376
|
throwIfAborted(signal, "install");
|
|
294
377
|
const fetchFunction = fetchImpl ?? globalThis.fetch?.bind(globalThis);
|
|
295
378
|
if (typeof fetchFunction !== "function") {
|
|
296
379
|
throw fail("ARCANE_AI_MODEL_SOURCE_UNAVAILABLE", "Browser fetch is unavailable.");
|
|
297
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}` };
|
|
298
387
|
|
|
299
388
|
let response;
|
|
300
389
|
try {
|
|
301
|
-
response = await fetchFunction(member.url,
|
|
302
|
-
cache: "no-store",
|
|
303
|
-
redirect: "follow",
|
|
304
|
-
signal,
|
|
305
|
-
});
|
|
390
|
+
response = await fetchFunction(url ?? member.url, requestOptions);
|
|
306
391
|
} catch (error) {
|
|
307
392
|
if (signal?.aborted || error?.name === "AbortError") throwIfAborted(signal, "install");
|
|
308
393
|
throw fail("ARCANE_AI_MODEL_DOWNLOAD_FAILED", "The model download failed.", error);
|
|
309
394
|
}
|
|
310
|
-
if (!response?.ok) {
|
|
311
|
-
await response?.body?.cancel?.().catch(() => undefined);
|
|
312
|
-
throw fail(
|
|
313
|
-
"ARCANE_AI_MODEL_DOWNLOAD_FAILED",
|
|
314
|
-
`The model server returned HTTP ${response?.status ?? "unknown"}.`,
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
395
|
let finalUrl;
|
|
318
396
|
try {
|
|
319
|
-
finalUrl = new URL(response
|
|
397
|
+
finalUrl = new URL(response?.url || url || member.url);
|
|
320
398
|
} catch {
|
|
321
399
|
finalUrl = null;
|
|
322
400
|
}
|
|
323
|
-
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;
|
|
324
415
|
if (!response.body || typeof response.body.getReader !== "function") {
|
|
325
416
|
throw fail("ARCANE_AI_MODEL_SOURCE_INVALID", "The model response did not provide a byte stream.");
|
|
326
417
|
}
|
|
418
|
+
async function cancel(reason) {
|
|
419
|
+
await response.body.cancel(reason);
|
|
420
|
+
}
|
|
327
421
|
return completeValue({
|
|
328
422
|
body: response.body,
|
|
329
423
|
requestedUrl: member.url,
|
|
330
|
-
finalUrl
|
|
331
|
-
|
|
424
|
+
finalUrl,
|
|
425
|
+
contentLength: httpContentLength(response.headers?.get?.("content-length")),
|
|
426
|
+
cancel,
|
|
332
427
|
});
|
|
333
428
|
}
|
|
334
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],
|
|
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);
|
|
539
|
+
}
|
|
540
|
+
|
|
335
541
|
const sourceRecord = {
|
|
336
542
|
kind: "arcane-browser-model-source",
|
|
337
543
|
...model,
|
|
@@ -345,6 +551,7 @@ export function createBrowserModelSource(descriptor, {
|
|
|
345
551
|
const source = completeValue(sourceRecord);
|
|
346
552
|
BROWSER_MODEL_SOURCES.add(source);
|
|
347
553
|
BROWSER_MODEL_SOURCE_METADATA.set(source, metadata);
|
|
554
|
+
BROWSER_MODEL_SOURCE_TRANSPORTS.set(source, completeValue({ probeRange, openRange }));
|
|
348
555
|
return source;
|
|
349
556
|
}
|
|
350
557
|
|
|
@@ -419,6 +626,34 @@ function storageName(source, { legacy = false } = {}) {
|
|
|
419
626
|
});
|
|
420
627
|
}
|
|
421
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
|
+
|
|
422
657
|
function securitySnapshot(security) {
|
|
423
658
|
return security?.secure === true ? { secure: true } : undefined;
|
|
424
659
|
}
|
|
@@ -432,6 +667,7 @@ export function createDbopfsModelStore({
|
|
|
432
667
|
dbopfs,
|
|
433
668
|
tableName = "arcane_ai_browser_models",
|
|
434
669
|
estimateStorage = null,
|
|
670
|
+
downloadConcurrency = DEFAULT_MODEL_DOWNLOAD_CONCURRENCY,
|
|
435
671
|
} = {}) {
|
|
436
672
|
if (!dbopfs || (typeof dbopfs !== "object" && typeof dbopfs !== "function")) {
|
|
437
673
|
throw new TypeError("createDbopfsModelStore requires an existing DBOPFS instance.");
|
|
@@ -445,6 +681,7 @@ export function createDbopfsModelStore({
|
|
|
445
681
|
if (estimateStorage !== null && typeof estimateStorage !== "function") {
|
|
446
682
|
throw new TypeError("estimateStorage must be a function or null.");
|
|
447
683
|
}
|
|
684
|
+
const workerLimit = downloadConcurrencyValue(downloadConcurrency);
|
|
448
685
|
let tablePromise = null;
|
|
449
686
|
|
|
450
687
|
async function table() {
|
|
@@ -477,38 +714,630 @@ export function createDbopfsModelStore({
|
|
|
477
714
|
}
|
|
478
715
|
}
|
|
479
716
|
|
|
480
|
-
|
|
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
|
+
} = {}) {
|
|
481
872
|
const directory = await table();
|
|
482
873
|
const handle = await directory.getFileHandle(name, { create: true });
|
|
483
874
|
const writable = await handle.createWritable();
|
|
875
|
+
let written = 0;
|
|
484
876
|
try {
|
|
485
877
|
for await (const chunk of byteChunks(body, signal)) {
|
|
486
878
|
await writable.write(chunk);
|
|
879
|
+
written += chunk.byteLength;
|
|
880
|
+
onChunk?.(chunk.byteLength);
|
|
881
|
+
throwIfAborted(signal, "install");
|
|
487
882
|
}
|
|
488
883
|
throwIfAborted(signal, "install");
|
|
489
884
|
await writable.close();
|
|
490
|
-
return
|
|
885
|
+
return written;
|
|
491
886
|
} catch (error) {
|
|
492
887
|
await writable.abort?.(error).catch(() => undefined);
|
|
493
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);
|
|
917
|
+
throw error;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
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 });
|
|
494
1075
|
throw error;
|
|
495
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
|
+
});
|
|
496
1105
|
}
|
|
497
1106
|
|
|
498
1107
|
async function removeNames(names) {
|
|
499
|
-
const removed = [
|
|
1108
|
+
const removed = [];
|
|
500
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
|
+
}
|
|
501
1222
|
return removed.some(Boolean);
|
|
502
1223
|
}
|
|
503
1224
|
|
|
504
1225
|
async function remove(source) {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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;
|
|
508
1245
|
}
|
|
509
1246
|
return removed;
|
|
510
1247
|
}
|
|
511
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
|
+
|
|
512
1341
|
async function storagePolicy({ cached = false } = {}) {
|
|
513
1342
|
return completeValue({
|
|
514
1343
|
compatibility: "compatible",
|
|
@@ -519,13 +1348,104 @@ export function createDbopfsModelStore({
|
|
|
519
1348
|
});
|
|
520
1349
|
}
|
|
521
1350
|
|
|
522
|
-
async function
|
|
1351
|
+
async function availableModelFiles(names, signal) {
|
|
523
1352
|
const modelFiles = [];
|
|
524
1353
|
for (const entry of names.models) {
|
|
525
1354
|
throwIfAborted(signal, "install");
|
|
526
1355
|
const modelFile = await file(entry.name);
|
|
527
|
-
if (
|
|
528
|
-
|
|
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
|
+
);
|
|
529
1449
|
}
|
|
530
1450
|
return modelFiles;
|
|
531
1451
|
}
|
|
@@ -533,73 +1453,162 @@ export function createDbopfsModelStore({
|
|
|
533
1453
|
async function openCached(source, {
|
|
534
1454
|
signal,
|
|
535
1455
|
} = {}) {
|
|
536
|
-
|
|
537
|
-
let modelFiles = await
|
|
538
|
-
if (
|
|
539
|
-
await
|
|
540
|
-
|
|
541
|
-
const
|
|
542
|
-
if (
|
|
543
|
-
|
|
544
|
-
modelFiles =
|
|
545
|
-
} else {
|
|
546
|
-
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;
|
|
547
1465
|
}
|
|
548
1466
|
}
|
|
549
|
-
if (!modelFiles)
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
return completeValue({
|
|
555
|
-
files: completeValue(modelFiles),
|
|
556
|
-
file: modelFiles.length === 1 ? modelFiles[0] : null,
|
|
557
|
-
});
|
|
558
|
-
} catch (error) {
|
|
559
|
-
if (!signal?.aborted) await removeNames(names);
|
|
560
|
-
throw error;
|
|
561
|
-
}
|
|
1467
|
+
if (!modelFiles.every(Boolean)) return null;
|
|
1468
|
+
return completeValue({
|
|
1469
|
+
files: completeValue(modelFiles),
|
|
1470
|
+
file: modelFiles.length === 1 ? modelFiles[0] : null,
|
|
1471
|
+
});
|
|
562
1472
|
}
|
|
563
1473
|
|
|
564
|
-
async function install(source, { signal } = {}) {
|
|
1474
|
+
async function install(source, { signal, onProgress = null } = {}) {
|
|
1475
|
+
if (onProgress !== null && typeof onProgress !== "function") {
|
|
1476
|
+
throw new TypeError("Model store onProgress must be a function or null.");
|
|
1477
|
+
}
|
|
565
1478
|
const names = storageName(source);
|
|
566
1479
|
const members = sourceMetadata(source).files;
|
|
567
|
-
await
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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;
|
|
573
1511
|
try {
|
|
574
|
-
await
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
+
);
|
|
583
1527
|
} catch (error) {
|
|
584
|
-
|
|
1528
|
+
retainFailure(error);
|
|
585
1529
|
throw error;
|
|
1530
|
+
} finally {
|
|
1531
|
+
progress.endTransfer({ publishProgress: false });
|
|
586
1532
|
}
|
|
587
1533
|
}
|
|
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");
|
|
588
1582
|
return completeValue({
|
|
589
1583
|
files: completeValue(modelFiles),
|
|
590
1584
|
file: modelFiles.length === 1 ? modelFiles[0] : null,
|
|
591
1585
|
});
|
|
592
1586
|
} catch (error) {
|
|
593
|
-
|
|
594
|
-
throw
|
|
1587
|
+
retainFailure(error);
|
|
1588
|
+
throw failure;
|
|
1589
|
+
} finally {
|
|
1590
|
+
progress.dispose();
|
|
1591
|
+
linked.release();
|
|
595
1592
|
}
|
|
596
1593
|
}
|
|
597
1594
|
|
|
598
1595
|
async function ensure(source, {
|
|
599
1596
|
signal,
|
|
600
1597
|
onCapabilityPolicy,
|
|
1598
|
+
onProgress = null,
|
|
601
1599
|
offline = false,
|
|
602
1600
|
} = {}) {
|
|
1601
|
+
if (onProgress !== null && typeof onProgress !== "function") {
|
|
1602
|
+
throw new TypeError("Model store onProgress must be a function or null.");
|
|
1603
|
+
}
|
|
1604
|
+
const total = sourceMetadata(source).files.length;
|
|
1605
|
+
onProgress?.(completeValue({
|
|
1606
|
+
phase: "cache-check",
|
|
1607
|
+
completed: 0,
|
|
1608
|
+
total,
|
|
1609
|
+
unit: "files",
|
|
1610
|
+
heartbeat: false,
|
|
1611
|
+
}));
|
|
603
1612
|
const cached = await openCached(source, { signal });
|
|
604
1613
|
if (cached) {
|
|
605
1614
|
const storage = await storagePolicy({ cached: true });
|
|
@@ -611,7 +1620,7 @@ export function createDbopfsModelStore({
|
|
|
611
1620
|
}
|
|
612
1621
|
const storage = await storagePolicy();
|
|
613
1622
|
onCapabilityPolicy?.(storage);
|
|
614
|
-
const installed = await install(source, { signal });
|
|
1623
|
+
const installed = await install(source, { signal, onProgress });
|
|
615
1624
|
const admittedStorage = await storagePolicy({ cached: true });
|
|
616
1625
|
onCapabilityPolicy?.(admittedStorage);
|
|
617
1626
|
return completeValue({ ...installed, cache: "installed", storage: admittedStorage });
|
|
@@ -620,6 +1629,7 @@ export function createDbopfsModelStore({
|
|
|
620
1629
|
const store = completeValue({
|
|
621
1630
|
kind: "arcane-dbopfs-model-store",
|
|
622
1631
|
tableName,
|
|
1632
|
+
downloadConcurrency: workerLimit,
|
|
623
1633
|
adapter: dbopfs,
|
|
624
1634
|
ready: () => table().then(() => undefined),
|
|
625
1635
|
install,
|
|
@@ -2068,16 +3078,54 @@ export function createBrowserWasmLlmProvider({
|
|
|
2068
3078
|
activeSecurity = effectiveSecurity;
|
|
2069
3079
|
return loadPromise;
|
|
2070
3080
|
}
|
|
3081
|
+
if (
|
|
3082
|
+
options.onProgress !== undefined
|
|
3083
|
+
&& options.onProgress !== null
|
|
3084
|
+
&& typeof options.onProgress !== "function"
|
|
3085
|
+
) {
|
|
3086
|
+
throw new TypeError("Browser-WASM load onProgress must be a function or null.");
|
|
3087
|
+
}
|
|
2071
3088
|
const externalSignal = options.signal ?? context.signal ?? null;
|
|
2072
3089
|
const linked = linkAbortSignal(externalSignal);
|
|
2073
3090
|
const signal = linked.controller.signal;
|
|
2074
3091
|
const generation = ++lifecycleGeneration;
|
|
3092
|
+
const reportProgress = typeof context.reportProgress === "function"
|
|
3093
|
+
? context.reportProgress
|
|
3094
|
+
: options.onProgress ?? null;
|
|
3095
|
+
const progressStartedAt = Date.now();
|
|
3096
|
+
let currentProgress = null;
|
|
3097
|
+
let progressHeartbeat = null;
|
|
3098
|
+
|
|
3099
|
+
function publishModelLoadProgress(progress) {
|
|
3100
|
+
if (!reportProgress) return;
|
|
3101
|
+
currentProgress = { ...progress, heartbeat: false };
|
|
3102
|
+
reportProgress(completeValue({
|
|
3103
|
+
...currentProgress,
|
|
3104
|
+
elapsedMs: Math.max(0, Date.now() - progressStartedAt),
|
|
3105
|
+
}));
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
function publishModelLoadHeartbeat() {
|
|
3109
|
+
if (!reportProgress || !currentProgress) return;
|
|
3110
|
+
reportProgress(completeValue({
|
|
3111
|
+
...currentProgress,
|
|
3112
|
+
heartbeat: true,
|
|
3113
|
+
elapsedMs: Math.max(0, Date.now() - progressStartedAt),
|
|
3114
|
+
}));
|
|
3115
|
+
}
|
|
3116
|
+
|
|
2075
3117
|
loadAbort = linked.controller;
|
|
2076
3118
|
activeSource = requestedSource;
|
|
2077
3119
|
activeSecurity = effectiveSecurity;
|
|
2078
3120
|
activeLoadPlan = requestedLoadPlan;
|
|
2079
3121
|
state = "loading";
|
|
2080
3122
|
errorState = null;
|
|
3123
|
+
if (reportProgress) {
|
|
3124
|
+
progressHeartbeat = globalThis.setInterval(
|
|
3125
|
+
publishModelLoadHeartbeat,
|
|
3126
|
+
MODEL_LOAD_HEARTBEAT_MS,
|
|
3127
|
+
);
|
|
3128
|
+
}
|
|
2081
3129
|
loadPromise = (async () => {
|
|
2082
3130
|
try {
|
|
2083
3131
|
throwIfAborted(signal, "load");
|
|
@@ -2085,6 +3133,7 @@ export function createBrowserWasmLlmProvider({
|
|
|
2085
3133
|
signal,
|
|
2086
3134
|
offline: options.offline === true,
|
|
2087
3135
|
onCapabilityPolicy: (value) => { storagePolicies.set(activeSource.id, value); },
|
|
3136
|
+
onProgress: publishModelLoadProgress,
|
|
2088
3137
|
});
|
|
2089
3138
|
cacheState = admitted.cache;
|
|
2090
3139
|
throwIfAborted(signal, "load");
|
|
@@ -2096,6 +3145,13 @@ export function createBrowserWasmLlmProvider({
|
|
|
2096
3145
|
throw fail("ARCANE_AI_OPERATION_SUPERSEDED", "The model load was superseded by unload.");
|
|
2097
3146
|
}
|
|
2098
3147
|
const members = sourceMetadata(activeSource).files;
|
|
3148
|
+
publishModelLoadProgress({
|
|
3149
|
+
phase: "initialize",
|
|
3150
|
+
completed: members.length,
|
|
3151
|
+
total: members.length,
|
|
3152
|
+
unit: "files",
|
|
3153
|
+
heartbeat: false,
|
|
3154
|
+
});
|
|
2099
3155
|
const modelFiles = admitted.files.map((file, index) => (
|
|
2100
3156
|
typeof globalThis.File === "function"
|
|
2101
3157
|
? new File([file], members[index].name, { type: "application/octet-stream" })
|
|
@@ -2148,6 +3204,10 @@ export function createBrowserWasmLlmProvider({
|
|
|
2148
3204
|
}
|
|
2149
3205
|
throw normalized;
|
|
2150
3206
|
} finally {
|
|
3207
|
+
if (progressHeartbeat !== null) {
|
|
3208
|
+
globalThis.clearInterval(progressHeartbeat);
|
|
3209
|
+
progressHeartbeat = null;
|
|
3210
|
+
}
|
|
2151
3211
|
if (loadAbort === linked.controller) loadAbort = null;
|
|
2152
3212
|
linked.release();
|
|
2153
3213
|
loadPromise = null;
|
|
@@ -2478,6 +3538,7 @@ export function adaptV1LlmProvider(provider) {
|
|
|
2478
3538
|
...loadOptions,
|
|
2479
3539
|
modelId: selection.modelId,
|
|
2480
3540
|
signal,
|
|
3541
|
+
onProgress: progress,
|
|
2481
3542
|
...(security?.secure===true?{security:{secure:true}}:{}),
|
|
2482
3543
|
});
|
|
2483
3544
|
return status();
|