|
1 | | -// HACK: moving global variables from index.html to here for loadRemote |
2 | | - |
3 | | -let dbVersion = 1 |
4 | | -let dbName = 'whisper.ggerganov.com'; |
5 | | -let indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB |
6 | | - |
7 | | -// fetch a remote file from remote URL using the Fetch API |
8 | | -async function fetchRemote(url, cbProgress, cbPrint) { |
9 | | - cbPrint('fetchRemote: downloading with fetch()...'); |
10 | | - |
11 | | - const response = await fetch( |
12 | | - url, |
13 | | - { |
14 | | - method: 'GET', |
15 | | - } |
16 | | - ); |
| 1 | +// Robust, silent caching + URL fallbacks for Whisper model binaries |
| 2 | + |
| 3 | +var dbVersion = 1; |
| 4 | +var dbName = 'whisper.ggerganov.com'; |
| 5 | +var _indexedDB = |
| 6 | + (window.indexedDB || |
| 7 | + window.mozIndexedDB || |
| 8 | + window.webkitIndexedDB || |
| 9 | + window.msIndexedDB); |
| 10 | + |
| 11 | +// --- tiny helpers ----------------------------------------------------------- |
| 12 | + |
| 13 | +function log(cbPrint, msg){ try { cbPrint && cbPrint(msg); } catch(_){} } |
| 14 | + |
| 15 | +function isValidModelBytes(buf, contentType) { |
| 16 | + if (!buf) return false; |
| 17 | + // Reject obvious HTML/JSON or tiny payloads (typical CRA index.html ~2–5 KB) |
| 18 | + if (buf.byteLength < 4096) return false; |
| 19 | + if (!contentType) return true; |
| 20 | + const ct = String(contentType).toLowerCase(); |
| 21 | + if (ct.includes('text/html')) return false; |
| 22 | + if (ct.includes('json')) return false; |
| 23 | + return true; |
| 24 | +} |
17 | 25 |
|
18 | | - if (!response.ok) { |
19 | | - cbPrint('fetchRemote: failed to fetch ' + url); |
20 | | - return; |
| 26 | +// Fetch bytes from a URL (no prompt, no cache), return Uint8Array or null |
| 27 | +async function fetchBytes(url, cbProgress, cbPrint) { |
| 28 | + log(cbPrint, 'fetchRemote: GET ' + url); |
| 29 | + const res = await fetch(url, { cache: 'no-cache', method: 'GET' }); |
| 30 | + if (!res.ok) { |
| 31 | + log(cbPrint, 'fetchRemote: HTTP ' + res.status + ' @ ' + url); |
| 32 | + return null; |
| 33 | + } |
| 34 | + const totalHdr = res.headers.get('content-length'); |
| 35 | + const total = totalHdr ? parseInt(totalHdr, 10) : undefined; |
| 36 | + |
| 37 | + // If stream not available, do arrayBuffer directly |
| 38 | + if (!res.body || !res.body.getReader) { |
| 39 | + const buf = await res.arrayBuffer(); |
| 40 | + if (!isValidModelBytes(buf, res.headers.get('content-type'))) return null; |
| 41 | + cbProgress && cbProgress(1); |
| 42 | + return new Uint8Array(buf); |
| 43 | + } |
| 44 | + |
| 45 | + const reader = res.body.getReader(); |
| 46 | + let received = 0, chunks = [], lastReport = -1; |
| 47 | + |
| 48 | + while (true) { |
| 49 | + const { value, done } = await reader.read(); |
| 50 | + if (done) break; |
| 51 | + chunks.push(value); |
| 52 | + received += value.length; |
| 53 | + if (total) { |
| 54 | + const frac = received / total; |
| 55 | + cbProgress && cbProgress(frac); |
| 56 | + const bucket = Math.round(frac * 10); |
| 57 | + if (bucket !== lastReport) { |
| 58 | + log(cbPrint, 'fetchRemote: fetching ' + (bucket * 10) + '% ...'); |
| 59 | + lastReport = bucket; |
| 60 | + } |
21 | 61 | } |
| 62 | + } |
| 63 | + |
| 64 | + // Concat |
| 65 | + const out = new Uint8Array(received); |
| 66 | + let pos = 0; |
| 67 | + for (let i = 0; i < chunks.length; i++) { |
| 68 | + out.set(chunks[i], pos); |
| 69 | + pos += chunks[i].length; |
| 70 | + } |
| 71 | + |
| 72 | + // Final sanity |
| 73 | + const ct = res.headers.get('content-type'); |
| 74 | + if (!isValidModelBytes(out, ct)) return null; |
| 75 | + if (!total) { cbProgress && cbProgress(1); } |
| 76 | + |
| 77 | + return out; |
| 78 | +} |
22 | 79 |
|
23 | | - const contentLength = response.headers.get('content-length'); |
24 | | - const total = parseInt(contentLength, 10); |
25 | | - const reader = response.body.getReader(); |
26 | | - |
27 | | - var chunks = []; |
28 | | - var receivedLength = 0; |
29 | | - var progressLast = -1; |
| 80 | +// IDB helpers |
| 81 | +function idbGet(db, key) { |
| 82 | + return new Promise(resolve => { |
| 83 | + try { |
| 84 | + const tx = db.transaction(['models'], 'readonly'); |
| 85 | + const os = tx.objectStore('models'); |
| 86 | + const rq = os.get(key); |
| 87 | + rq.onsuccess = () => resolve(rq.result || null); |
| 88 | + rq.onerror = () => resolve(null); |
| 89 | + } catch (_) { resolve(null); } |
| 90 | + }); |
| 91 | +} |
30 | 92 |
|
31 | | - while (true) { |
32 | | - const { done, value } = await reader.read(); |
| 93 | +function idbPut(db, key, bytes, cbPrint) { |
| 94 | + return new Promise(resolve => { |
| 95 | + try { |
| 96 | + const tx = db.transaction(['models'], 'readwrite'); |
| 97 | + const os = tx.objectStore('models'); |
| 98 | + const rq = os.put(bytes, key); |
| 99 | + rq.onsuccess = () => { log(cbPrint, 'loadRemote: stored in IDB: ' + key); resolve(true); }; |
| 100 | + rq.onerror = () => { log(cbPrint, 'loadRemote: IDB put failed (non-fatal)'); resolve(false); }; |
| 101 | + } catch (e) { |
| 102 | + log(cbPrint, 'loadRemote: IDB exception: ' + e); |
| 103 | + resolve(false); |
| 104 | + } |
| 105 | + }); |
| 106 | +} |
33 | 107 |
|
34 | | - if (done) { |
35 | | - break; |
36 | | - } |
| 108 | +function openDB(cbPrint) { |
| 109 | + return new Promise(resolve => { |
| 110 | + const rq = _indexedDB.open(dbName, dbVersion); |
| 111 | + rq.onupgradeneeded = (ev) => { |
| 112 | + const db = ev.target.result; |
| 113 | + if (!db.objectStoreNames.contains('models')) { |
| 114 | + db.createObjectStore('models', { autoIncrement: false }); |
| 115 | + log(cbPrint, 'loadRemote: created IDB store'); |
| 116 | + } |
| 117 | + }; |
| 118 | + rq.onsuccess = () => resolve(rq.result); |
| 119 | + rq.onerror = () => resolve(null); |
| 120 | + rq.onblocked = () => resolve(null); |
| 121 | + rq.onabort = () => resolve(null); |
| 122 | + }); |
| 123 | +} |
37 | 124 |
|
38 | | - chunks.push(value); |
39 | | - receivedLength += value.length; |
| 125 | +// --- PUBLIC: try a list of URLs, use cache per-URL, stop on first good one --- |
| 126 | +export async function loadRemoteWithFallbacks(urls, dst, size_mb, cbProgress, cbReady, cbCancel, cbPrint) { |
| 127 | + try { |
| 128 | + if (navigator.storage?.estimate) { |
| 129 | + const est = await navigator.storage.estimate(); |
| 130 | + log(cbPrint, 'loadRemote: storage quota: ' + est.quota + ' bytes'); |
| 131 | + log(cbPrint, 'loadRemote: storage usage: ' + est.usage + ' bytes'); |
| 132 | + } |
| 133 | + } catch (_) {} |
40 | 134 |
|
41 | | - if (contentLength) { |
42 | | - cbProgress(receivedLength/total); |
| 135 | + // De-dup & filter falsy |
| 136 | + const list = Array.from(new Set((urls || []).filter(Boolean))); |
| 137 | + if (!list.length) { cbCancel && cbCancel(); return; } |
43 | 138 |
|
44 | | - var progressCur = Math.round((receivedLength / total) * 10); |
45 | | - if (progressCur != progressLast) { |
46 | | - cbPrint('fetchRemote: fetching ' + 10*progressCur + '% ...'); |
47 | | - progressLast = progressCur; |
48 | | - } |
49 | | - } |
50 | | - } |
| 139 | + const db = await openDB(cbPrint); |
51 | 140 |
|
52 | | - var position = 0; |
53 | | - var chunksAll = new Uint8Array(receivedLength); |
| 141 | + for (let i = 0; i < list.length; i++) { |
| 142 | + const url = list[i]; |
54 | 143 |
|
55 | | - for (var chunk of chunks) { |
56 | | - chunksAll.set(chunk, position); |
57 | | - position += chunk.length; |
| 144 | + // 1) Cache hit? |
| 145 | + if (db) { |
| 146 | + const cached = await idbGet(db, url); |
| 147 | + if (cached && cached.byteLength > 4096) { |
| 148 | + log(cbPrint, `loadRemote: cache hit for ${url}`); |
| 149 | + cbReady && cbReady(dst, cached instanceof Uint8Array ? cached : new Uint8Array(cached)); |
| 150 | + return; |
| 151 | + } |
58 | 152 | } |
59 | 153 |
|
60 | | - return chunksAll; |
61 | | -} |
62 | | - |
63 | | -// load remote data |
64 | | -// - check if the data is already in the IndexedDB |
65 | | -// - if not, fetch it from the remote URL and store it in the IndexedDB |
66 | | -export function loadRemote(url, dst, size_mb, cbProgress, cbReady, cbCancel, cbPrint) { |
67 | | - if (!navigator.storage || !navigator.storage.estimate) { |
68 | | - cbPrint('loadRemote: navigator.storage.estimate() is not supported'); |
| 154 | + log(cbPrint, `loadRemote: cache miss; downloading ~${size_mb} MB`); |
| 155 | + const bytes = await fetchBytes(url, cbProgress, cbPrint); |
| 156 | + if (bytes && bytes.byteLength > 4096) { |
| 157 | + if (db) { await idbPut(db, url, bytes, cbPrint); } |
| 158 | + cbReady && cbReady(dst, bytes); |
| 159 | + return; |
69 | 160 | } else { |
70 | | - // query the storage quota and print it |
71 | | - navigator.storage.estimate().then(function (estimate) { |
72 | | - cbPrint('loadRemote: storage quota: ' + estimate.quota + ' bytes'); |
73 | | - cbPrint('loadRemote: storage usage: ' + estimate.usage + ' bytes'); |
74 | | - }); |
| 161 | + log(cbPrint, `fetchWithFallbacks: "${url}" did not look like a model, trying next...`); |
75 | 162 | } |
| 163 | + } |
76 | 164 |
|
77 | | - // check if the data is already in the IndexedDB |
78 | | - var rq = indexedDB.open(dbName, dbVersion); |
79 | | - |
80 | | - rq.onupgradeneeded = function (event) { |
81 | | - var db = event.target.result; |
82 | | - if (db.version == 1) { |
83 | | - var os = db.createObjectStore('models', { autoIncrement: false }); |
84 | | - cbPrint('loadRemote: created IndexedDB ' + db.name + ' version ' + db.version); |
85 | | - } else { |
86 | | - // clear the database |
87 | | - var os = event.currentTarget.transaction.objectStore('models'); |
88 | | - os.clear(); |
89 | | - cbPrint('loadRemote: cleared IndexedDB ' + db.name + ' version ' + db.version); |
90 | | - } |
91 | | - }; |
92 | | - |
93 | | - rq.onsuccess = function (event) { |
94 | | - var db = event.target.result; |
95 | | - var tx = db.transaction(['models'], 'readonly'); |
96 | | - var os = tx.objectStore('models'); |
97 | | - var rq = os.get(url); |
98 | | - |
99 | | - rq.onsuccess = function (event) { |
100 | | - if (rq.result) { |
101 | | - cbPrint('loadRemote: "' + url + '" is already in the IndexedDB'); |
102 | | - cbReady(dst, rq.result); |
103 | | - } else { |
104 | | - // data is not in the IndexedDB |
105 | | - cbPrint('loadRemote: "' + url + '" is not in the IndexedDB'); |
106 | | - |
107 | | - // alert and ask the user to confirm |
108 | | - if (!confirm( |
109 | | - 'You are about to download ' + size_mb + ' MB of data.\n' + |
110 | | - 'The model data will be cached in the browser for future use.\n\n' + |
111 | | - 'Press OK to continue.')) { |
112 | | - cbCancel(); |
113 | | - return; |
114 | | - } |
115 | | - |
116 | | - fetchRemote(url, cbProgress, cbPrint).then(function (data) { |
117 | | - if (data) { |
118 | | - // store the data in the IndexedDB |
119 | | - var rq = indexedDB.open(dbName, dbVersion); |
120 | | - rq.onsuccess = function (event) { |
121 | | - var db = event.target.result; |
122 | | - var tx = db.transaction(['models'], 'readwrite'); |
123 | | - var os = tx.objectStore('models'); |
124 | | - |
125 | | - var rq = null; |
126 | | - try { |
127 | | - var rq = os.put(data, url); |
128 | | - } catch (e) { |
129 | | - cbPrint('loadRemote: failed to store "' + url + '" in the IndexedDB: \n' + e); |
130 | | - cbCancel(); |
131 | | - return; |
132 | | - } |
133 | | - |
134 | | - rq.onsuccess = function (event) { |
135 | | - cbPrint('loadRemote: "' + url + '" stored in the IndexedDB'); |
136 | | - cbReady(dst, data); |
137 | | - }; |
138 | | - |
139 | | - rq.onerror = function (event) { |
140 | | - cbPrint('loadRemote: failed to store "' + url + '" in the IndexedDB'); |
141 | | - cbCancel(); |
142 | | - }; |
143 | | - }; |
144 | | - } |
145 | | - }); |
146 | | - } |
147 | | - }; |
148 | | - |
149 | | - rq.onerror = function (event) { |
150 | | - cbPrint('loadRemote: failed to get data from the IndexedDB'); |
151 | | - cbCancel(); |
152 | | - }; |
153 | | - }; |
154 | | - |
155 | | - rq.onerror = function (event) { |
156 | | - cbPrint('loadRemote: failed to open IndexedDB'); |
157 | | - cbCancel(); |
158 | | - }; |
159 | | - |
160 | | - rq.onblocked = function (event) { |
161 | | - cbPrint('loadRemote: failed to open IndexedDB: blocked'); |
162 | | - cbCancel(); |
163 | | - }; |
164 | | - |
165 | | - rq.onabort = function (event) { |
166 | | - cbPrint('loadRemote: failed to open IndexedDB: abort'); |
167 | | - cbCancel(); |
168 | | - }; |
| 165 | + // All failed |
| 166 | + log(cbPrint, 'loadRemote: all fetch attempts failed'); |
| 167 | + cbCancel && cbCancel(); |
169 | 168 | } |
0 commit comments