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.
@@ -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
- async function open(memberOrOptions = 0, options = {}) {
280
- let memberIndex = memberOrOptions;
281
- if (!Number.isSafeInteger(memberOrOptions)) {
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
- const member = metadata.files[memberIndex];
292
- const { signal } = options;
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.url || member.url);
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: finalUrl.href,
331
- cancel: (reason) => response.body.cancel(reason),
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
- async function write(name, body, { signal } = {}) {
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 undefined;
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 = [await removeEntry(names.manifest)];
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
- let removed = await removeNames(storageName(source));
506
- if (sourceMetadata(source).legacy) {
507
- removed = await removeNames(storageName(source, { legacy: true })) || removed;
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 storedModelFiles(names, signal) {
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 (!modelFile) return null;
528
- modelFiles.push(modelFile);
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
- let names = storageName(source);
537
- let modelFiles = await storedModelFiles(names, signal);
538
- if (!modelFiles && sourceMetadata(source).legacy) {
539
- await removeNames(names);
540
- const legacyNames = storageName(source, { legacy: true });
541
- const legacyFiles = await storedModelFiles(legacyNames, signal);
542
- if (legacyFiles) {
543
- names = legacyNames;
544
- modelFiles = legacyFiles;
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
- await removeNames(names);
551
- return null;
552
- }
553
- try {
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 remove(source);
568
- const modelFiles = [];
569
- try {
570
- for (let index = 0; index < members.length; index += 1) {
571
- const member = members[index];
572
- const opened = await source.open(index, { signal });
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 write(names.models[index].name, opened.body, { signal });
575
- const modelFile = await file(names.models[index].name);
576
- if (!modelFile) {
577
- throw fail(
578
- "ARCANE_AI_MODEL_CACHE_REJECTED",
579
- "A stored model file could not be reopened.",
580
- );
581
- }
582
- modelFiles.push(modelFile);
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
- await opened.cancel?.(error).catch(() => undefined);
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
- await remove(source).catch(() => undefined);
594
- throw error;
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();