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. */ },
};
}
|