File size: 6,902 Bytes
39371ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
import { createSHA256 } from 'hash-wasm';
import manifest from './model-manifest.json';

export const REVISION = '04a6c49fcba3a65a0351c92644c3a7e9d4343059';
export const MODEL_ID = 'Mike0021/MiniCPM5-2B-ONNX';
export const MODEL_BYTES = Object.values(manifest.files).reduce((n, f) => n + f.bytes, 0);
const CHUNK = 32 * 1024 ** 2;
const sleep = (ms, signal) => new Promise((resolve, reject) => {
  signal?.throwIfAborted();
  const abort = () => { clearTimeout(timer); reject(signal.reason); };
  const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve(); }, ms);
  signal?.addEventListener('abort', abort, { once: true });
});

async function fetchPart(url, start, end, total, signal) {
  for (let attempt = 0; ; attempt++) {
    try {
      const part = total > CHUNK;
      const response = await fetch(url, { headers: part ? { Range: `bytes=${start}-${end}` } : {},
        signal: AbortSignal.any([signal, AbortSignal.timeout(90_000)]), cache: 'no-store' });
      if (!response.ok) throw Error(`Download returned HTTP ${response.status}`);
      if (part && response.status !== 206) {
        await response.body?.cancel();
        throw Error('The model host did not honor byte ranges.');
      }
      const range = response.headers.get('Content-Range');
      if (range && range !== `bytes ${start}-${end}/${total}`) {
        await response.body?.cancel(); throw Error('Incorrect download range.');
      }
      const data = new Uint8Array(await response.arrayBuffer());
      if (data.length !== end - start + 1) throw Error('Incomplete download chunk.');
      return data;
    } catch (error) {
      signal.throwIfAborted();
      if (attempt === 2) throw error;
      await sleep(500 * 2 ** attempt, signal);
    }
  }
}

export async function prepareModelCache(baseURL, options) {
  if (navigator.locks) return navigator.locks.request('pi-minicpm5-download-' + REVISION,
    { signal: options.signal }, () => downloadModel(baseURL, options));
  return downloadModel(baseURL, options);
}

async function readCacheState(create = false) {
  if (!navigator.storage?.getDirectory) throw Error('This app needs browser file storage (OPFS) to cache the 1.84 GB model. Open it in a regular browser tab.');
  const root = await (await navigator.storage.getDirectory()).getDirectoryHandle('pi-minicpm5-' + REVISION, { create });
  const receiptsFile = await root.getFileHandle('verified.json', { create });
  let receipts;
  try { receipts = JSON.parse(await (await receiptsFile.getFile()).text()); } catch { receipts = {}; }
  if (!receipts || typeof receipts !== 'object' || Array.isArray(receipts)) receipts = {};
  let cached = 0;
  const pending = [];
  const key = file => encodeURIComponent(file);
  for (const [file, spec] of Object.entries(manifest.files)) {
    try {
      const stored = await (await root.getFileHandle(key(file))).getFile();
      const receipt = receipts[file];
      if (stored.size === spec.bytes && receipt?.sha256 === spec.sha256 && receipt.modified === stored.lastModified) {
        cached += spec.bytes; continue;
      }
    } catch (e) { if (e.name !== 'NotFoundError') throw e; }
    pending.push([file, spec]);
  }
  return { root, receiptsFile, receipts, pending, cached };
}

export async function isModelCached() {
  try { return (await readCacheState()).pending.length === 0; }
  catch { return false; }
}

function cacheMiss() {
  return Object.assign(Error('The saved model is incomplete. Confirm the download to restore it.'), { code: 'MODEL_CACHE_MISS' });
}

async function downloadModel(baseURL, { signal, cachedOnly = false, onProgress = () => {} }) {
  let state;
  try { state = await readCacheState(!cachedOnly); }
  catch (error) { if (cachedOnly && error.name === 'NotFoundError') throw cacheMiss(); throw error; }
  const { root, receiptsFile, receipts, pending, cached } = state;
  // Recheck under the download lock: another tab or eviction may have changed
  // the cache after the UI checked it. Automatic restores never fetch weights.
  if (cachedOnly && pending.length) throw cacheMiss();
  let completed = cached;
  const key = file => encodeURIComponent(file);
  onProgress({ phase: 'download', loaded: completed, total: MODEL_BYTES, cached });
  if (pending.length) {
    const estimate = await navigator.storage.estimate();
    if (estimate.quota && estimate.quota - estimate.usage < MODEL_BYTES - completed + CHUNK) {
      throw Error('Not enough browser storage for the model. Free at least 2 GB and retry.');
    }
  }
  const controller = new AbortController();
  const combined = AbortSignal.any([signal, controller.signal]);
  let receiptWrites = Promise.resolve();
  const saveReceipts = () => {
    const serialized = JSON.stringify(receipts);
    const write = receiptWrites.then(async () => {
      const stream = await receiptsFile.createWritable(); await stream.write(serialized); await stream.close();
    });
    receiptWrites = write.catch(() => {}); return write;
  };
  async function worker() {
    while (pending.length) {
      combined.throwIfAborted();
      const [file, spec] = pending.shift();
      const handle = await root.getFileHandle(key(file), { create: true });
      const output = await handle.createWritable();
      try {
        const hash = await createSHA256(); hash.init();
        for (let start = 0; start < spec.bytes; start += CHUNK) {
          const end = Math.min(start + CHUNK, spec.bytes) - 1;
          const data = await fetchPart(baseURL + file, start, end, spec.bytes, combined);
          combined.throwIfAborted();
          hash.update(data); await output.write(data); completed += data.length;
          onProgress({ phase: 'download', file, loaded: completed, total: MODEL_BYTES, cached });
        }
        if (hash.digest('hex') !== spec.sha256) throw Error('SHA-256 verification failed: ' + file);
        await output.close();
        receipts[file] = { sha256: spec.sha256, modified: (await handle.getFile()).lastModified };
        await saveReceipts();
      } catch (error) { await output.abort().catch(() => {}); controller.abort(error); throw error; }
    }
  }
  const outcomes = await Promise.allSettled(Array.from({ length: 6 }, worker));
  const failed = outcomes.find(r => r.status === 'rejected');
  if (failed) throw failed.reason;
  await receiptWrites;
  return {
    cachedBytes: cached,
    async match(request) {
      const url = typeof request === 'string' ? request : request.url;
      if (!url.startsWith(baseURL)) return undefined;
      const file = url.slice(baseURL.length);
      if (!Object.hasOwn(manifest.files, file)) return undefined;
      const stored = await (await root.getFileHandle(key(file))).getFile();
      return new Response(stored, { headers: { 'Content-Length': String(stored.size) } });
    },
    async put() { /* All supported files are already downloaded and verified. */ },
  };
}