Spaces:
Running
Running
Download storage.js from lemonteaa/llama-cpp-wasm: direct link, hf CLI and curl.
- Browser
- Download file 9.66 kB
-
https://huggingface.co/spaces/lemonteaa/llama-cpp-wasm/resolve/main/storage.js
- Command line
-
hf download hf://spaces/lemonteaa/llama-cpp-wasm/storage.js
-
curl -L -o storage.js https://huggingface.co/spaces/lemonteaa/llama-cpp-wasm/resolve/main/storage.js
9.66 kB
| // IndexedDB model library. | |
| // | |
| // v2 layout (this file): | |
| // models store, keyPath "id" -> one record per model, id = uuid | |
| // modelChunks store, keyPath [modelId, index] -> { modelId, index, data: ArrayBuffer } | |
| // | |
| // Why a uuid primary key: one browser should hold many models, the same file may | |
| // legitimately be registered twice under different names, and records must stay | |
| // addressable after a rename. Nothing user-visible keys off the id. | |
| // | |
| // Why chunks instead of one blob: | |
| // 1. pause/resume - a Range request continues from meta.received without | |
| // rewriting hundreds of MB already stored; | |
| // 2. loading - chunks stream straight into the Emscripten FS, so no second | |
| // full-size buffer is assembled in JS memory; | |
| // 3. hashing - the same generator feeds the SHA-256, 4 MiB at a time. | |
| // | |
| // A record with source "file" normally has *no* chunks: the bytes stay on the | |
| // user's disk and are read in slices at load time. `copied` marks the opt-in | |
| // case where the user asked for a copy to live in the browser instead. | |
| export const DB_NAME = "llmjs-models"; | |
| export const DB_VERSION = 2; | |
| export const CHUNK_SIZE = 4 * 1024 * 1024; | |
| const MODEL_STORE = "models"; | |
| const CHUNK_STORE = "modelChunks"; | |
| let dbPromise = null; | |
| export function newModelId() { | |
| if (globalThis.crypto && typeof crypto.randomUUID === "function") return crypto.randomUUID(); | |
| const bytes = crypto.getRandomValues(new Uint8Array(16)); | |
| return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); | |
| } | |
| export function nameFromUrl(url) { | |
| try { | |
| const base = new URL(url, "http://localhost/").pathname.split("/").filter(Boolean).pop() || "model"; | |
| return base.replace(/\.gguf$/i, ""); | |
| } catch (_) { | |
| return "model"; | |
| } | |
| } | |
| // --- v1 -> v2 --------------------------------------------------------------- | |
| // v1 keyed records by url (out-of-line keys) and chunks by "url|00000000". | |
| // Migration runs inside the versionchange transaction: copy the old stores into | |
| // the new ones, then drop the old ones. | |
| function migrateV1(db, tx, models, chunks) { | |
| const v1meta = tx.objectStore("meta"); | |
| const v1chunks = tx.objectStore("chunks"); | |
| const ids = new Map(); | |
| const metaCursor = v1meta.openCursor(); | |
| metaCursor.onsuccess = () => { | |
| const cursor = metaCursor.result; | |
| if (cursor) { | |
| const old = cursor.value || {}; | |
| const id = newModelId(); | |
| if (old.url) ids.set(old.url, id); | |
| models.put({ | |
| id, | |
| name: nameFromUrl(old.url || ""), | |
| source: "url", | |
| url: old.url || null, | |
| fileName: null, | |
| total: old.total || 0, | |
| received: old.received || 0, | |
| complete: !!old.complete, | |
| copied: true, // v1 only ever stored downloaded bytes | |
| sha256: null, | |
| expectedSha256: null, | |
| created: old.updated || Date.now(), | |
| updated: old.updated || Date.now(), | |
| }); | |
| cursor.continue(); | |
| return; | |
| } | |
| const chunkCursor = v1chunks.openCursor(); | |
| chunkCursor.onsuccess = () => { | |
| const c = chunkCursor.result; | |
| if (c) { | |
| const key = String(c.key); | |
| const bar = key.lastIndexOf("|"); | |
| const modelId = ids.get(key.slice(0, bar)); | |
| const index = Number(key.slice(bar + 1)); | |
| if (modelId !== undefined && Number.isFinite(index)) { | |
| chunks.put({ modelId, index, data: c.value }); | |
| } | |
| c.continue(); | |
| return; | |
| } | |
| db.deleteObjectStore("meta"); | |
| db.deleteObjectStore("chunks"); | |
| }; | |
| }; | |
| } | |
| export function openDB() { | |
| if (!dbPromise) { | |
| dbPromise = new Promise((resolve, reject) => { | |
| if (typeof indexedDB === "undefined") { | |
| reject(new Error("IndexedDB is not available in this context")); | |
| return; | |
| } | |
| const req = indexedDB.open(DB_NAME, DB_VERSION); | |
| req.onupgradeneeded = (event) => { | |
| const db = req.result; | |
| const tx = req.transaction; | |
| const models = db.objectStoreNames.contains(MODEL_STORE) | |
| ? tx.objectStore(MODEL_STORE) | |
| : db.createObjectStore(MODEL_STORE, { keyPath: "id" }); | |
| const chunks = db.objectStoreNames.contains(CHUNK_STORE) | |
| ? tx.objectStore(CHUNK_STORE) | |
| : db.createObjectStore(CHUNK_STORE, { keyPath: ["modelId", "index"] }); | |
| if (event.oldVersion === 1) migrateV1(db, tx, models, chunks); | |
| }; | |
| req.onsuccess = () => resolve(req.result); | |
| req.onerror = () => reject(req.error); | |
| }); | |
| } | |
| return dbPromise; | |
| } | |
| const result = (req) => new Promise((res, rej) => { | |
| req.onsuccess = () => res(req.result); | |
| req.onerror = () => rej(req.error); | |
| }); | |
| const completed = (tx) => new Promise((res, rej) => { | |
| tx.oncomplete = () => res(); | |
| tx.onerror = () => rej(tx.error); | |
| tx.onabort = () => rej(tx.error); | |
| }); | |
| const chunkRange = (modelId, from = 0) => | |
| IDBKeyRange.bound([modelId, from], [modelId, Number.MAX_SAFE_INTEGER]); | |
| // --- models ---------------------------------------------------------------- | |
| export async function getModel(id) { | |
| const db = await openDB(); | |
| return result(db.transaction(MODEL_STORE, "readonly").objectStore(MODEL_STORE).get(id)); | |
| } | |
| export async function putModel(model) { | |
| const db = await openDB(); | |
| const record = { ...model, updated: Date.now() }; | |
| const tx = db.transaction(MODEL_STORE, "readwrite"); | |
| tx.objectStore(MODEL_STORE).put(record); | |
| await completed(tx); | |
| return record; | |
| } | |
| export async function updateModel(id, patch) { | |
| const model = await getModel(id); | |
| if (!model) throw new Error(`unknown model ${id}`); | |
| return putModel({ ...model, ...patch, id }); | |
| } | |
| // most recently touched first | |
| export async function listModels() { | |
| const db = await openDB(); | |
| const all = await result(db.transaction(MODEL_STORE, "readonly").objectStore(MODEL_STORE).getAll()); | |
| return all.sort((a, b) => (b.updated || 0) - (a.updated || 0)); | |
| } | |
| export async function findModelByUrl(url) { | |
| const all = await listModels(); | |
| return all.find((m) => m.url === url); | |
| } | |
| // --- chunks ---------------------------------------------------------------- | |
| export async function putChunk(modelId, index, buffer) { | |
| const db = await openDB(); | |
| const tx = db.transaction(CHUNK_STORE, "readwrite"); | |
| tx.objectStore(CHUNK_STORE).put({ modelId, index, data: buffer }); | |
| await completed(tx); | |
| } | |
| export async function getChunk(modelId, index) { | |
| const db = await openDB(); | |
| const row = await result(db.transaction(CHUNK_STORE, "readonly").objectStore(CHUNK_STORE).get([modelId, index])); | |
| return row ? row.data : undefined; | |
| } | |
| export async function chunkCount(modelId) { | |
| const db = await openDB(); | |
| return result(db.transaction(CHUNK_STORE, "readonly").objectStore(CHUNK_STORE).count(chunkRange(modelId))); | |
| } | |
| // ascending chunk indexes currently stored for this model | |
| export async function chunkIndexes(modelId) { | |
| const db = await openDB(); | |
| const keys = await result(db.transaction(CHUNK_STORE, "readonly").objectStore(CHUNK_STORE).getAllKeys(chunkRange(modelId))); | |
| return keys.map((k) => (Array.isArray(k) ? k[1] : Number(k))).sort((a, b) => a - b); | |
| } | |
| // Yields the stored bytes in order. Reads in small batches so the transaction | |
| // is never held open across awaits (which would let it auto-commit) and so a | |
| // 4 GB model never lands in memory as one buffer. | |
| export async function* iterateChunks(modelId, batch = 4) { | |
| let start = 0; | |
| for (;;) { | |
| const db = await openDB(); | |
| const rows = await result( | |
| db.transaction(CHUNK_STORE, "readonly").objectStore(CHUNK_STORE) | |
| .getAll(chunkRange(modelId, start), batch), | |
| ); | |
| if (!rows.length) return; | |
| const from = start; | |
| for (let i = 0; i < rows.length; i++) { | |
| yield { index: from + i, data: rows[i].data }; | |
| } | |
| if (rows.length < batch) return; | |
| start += rows.length; | |
| } | |
| } | |
| export async function clearChunks(modelId) { | |
| const db = await openDB(); | |
| const tx = db.transaction(CHUNK_STORE, "readwrite"); | |
| const store = tx.objectStore(CHUNK_STORE); | |
| let deleted = 0; | |
| const req = store.openCursor(chunkRange(modelId)); | |
| req.onsuccess = () => { | |
| const cursor = req.result; | |
| if (!cursor) return; | |
| cursor.delete(); | |
| deleted++; | |
| cursor.continue(); | |
| }; | |
| await completed(tx); | |
| return deleted; | |
| } | |
| // --- lifecycle ------------------------------------------------------------- | |
| // removes the record and everything stored for it (the "delete" button) | |
| export async function deleteModel(modelId) { | |
| const chunks = await clearChunks(modelId); | |
| const db = await openDB(); | |
| const tx = db.transaction(MODEL_STORE, "readwrite"); | |
| tx.objectStore(MODEL_STORE).delete(modelId); | |
| await completed(tx); | |
| return chunks; | |
| } | |
| export async function clearAll() { | |
| const models = await listModels(); | |
| for (const m of models) await deleteModel(m.id); | |
| return models.length; | |
| } | |
| export async function storageEstimate() { | |
| if (navigator.storage && navigator.storage.estimate) { | |
| try { return await navigator.storage.estimate(); } catch (_) { /* ignore */ } | |
| } | |
| return null; | |
| } | |