כיצד להוסיף מערכת בחירת מודלי AI מקומיים (offline) עם זיהוי RAM אוטומטי, progress bar, cache ו-fallback לפרויקט שלך.
המערכת טוענת מודלי embedding מ-HuggingFace דרך transformers.js ישירות בדפדפן, ללא שרת. המודלים נשמרים ב-Cache API של הדפדפן (IndexedDB), כך שבפעם הבאה הטעינה מיידית.
loadModelsJson() מ-GitHub Raw
getRecommendedModel() — בוחר מודל לפי RAM זמין
initModelPicker() — בונה UI של רשימת המודלים
loadEmbedder(key) — מוריד + מאתחל pipeline עם progress
getBestAvailable() מחזיר אותו לשימוש
הקובץ models.json מאוחסן ב-repo ומכיל מערך של מודלים. כל שינוי שם מתעדכן אוטומטית לכל המשתמשים.
[
{
"key": "albert_small",
"id": "Xenova/paraphrase-albert-small-v2",
"label": "Albert-Small",
"sizeMB": 13,
"ramMB": 45,
"langs": "אנגלית",
"tier": "מיני"
},
{
"key": "bge_m3",
"id": "Xenova/bge-m3",
"label": "BGE-M3",
"sizeMB": 570,
"ramMB": 1150,
"langs": "100+ שפות · BAAI",
"tier": "חזק"
},
{
"key": "gte_qwen2_1b5",
"id": "Xenova/gte-Qwen2-1.5B-instruct",
"label": "GTE-Qwen2-1.5B",
"sizeMB": 900,
"ramMB": 1800,
"langs": "100+ שפות · Alibaba",
"tier": "כבד"
},
{
"key": "gte_qwen2_7b",
"id": "Xenova/gte-Qwen2-7B-instruct",
"label": "GTE-Qwen2-7B",
"sizeMB": 4200,
"ramMB": 5800,
"langs": "100+ שפות · Alibaba",
"tier": "ענק"
}
// ... ועוד מודלים
]
| שדה | סוג | תיאור |
|---|---|---|
| key | string | מזהה ייחודי — משמש כ-key בכל הקוד |
| id | string | שם המודל ב-HuggingFace (Xenova/...) |
| label | string | שם תצוגה למשתמש |
| sizeMB | number | גודל משוער ב-MB — משמש לחישוב progress |
| langs | string | תיאור שפות נתמכות (תצוגה בלבד) |
| ramMB | number | RAM נדרש בפועל (MB) — משמש ל-getRecommendedModel() להתאמה לזיכרון הפנוי |
| tier | string | קטגוריה לקבוצה ויזואלית ברשימה (מיני / קל / בינוני / חזק / כבד / ענק) |
הוסף את המשתנים האלה בתחילת ה-<script> שלך, לפני כל שאר הקוד.
// ── Model system globals ── const MODELS_URL = 'https://raw.githubusercontent.com/giamat13/model-locel-offline-import-helper/main/models.json'; let MODELS = {}; // populated from remote JSON let cachedEmbedders = {}; // key → loaded pipeline let activeLoads = {}; // key → abort context let lockedModels = new Set(JSON.parse(localStorage.getItem('ht_locked_models') || '[]')); let selectedModel = localStorage.getItem('ht_model') || null; // resolved after loadModelsJson() let modelCacheStatus = JSON.parse(localStorage.getItem('ht_model_cached') || '{}');
ht_ ב-localStorage הוא שרירותי — שנה אותו לפרויקט שלך כדי להימנע מהתנגשויות.
getRecommendedModel() משתמש ב-navigator.deviceMemory ו-performance.memory
כדי להעריך את הRAM הפנוי, ואז עובר דינמית על MODELS (כפי שנטען מ-models.json) ומחזיר
את המודל הכבד ביותר שמתאים לזיכרון — ללא שמות קשיחים בקוד.
function getRecommendedModel() { const totalGB = navigator.deviceMemory || 4; const mem = performance.memory; let freeGB = totalGB; if (mem) { const usedGB = mem.usedJSHeapSize / 1073741824; const limitGB = mem.jsHeapSizeLimit / 1073741824; freeGB = Math.min(totalGB, limitGB) - usedGB; } const freeBytes = freeGB * 1024; // convert GB → MB for comparison with ramMB // בחר את המודל הכבד ביותר שמתאים לזיכרון — ישירות מ-MODELS (models.json) const candidates = Object.entries(MODELS) .filter(([k]) => !lockedModels.has(k)) .sort((a, b) => b[1].sizeMB - a[1].sizeMB); // כבד → קל for (const [k, m] of candidates) { // sizeMB * 2 = ~RAM נדרש (weights + runtime overhead) if (m.sizeMB * 1048576 * 2 <= freeBytes) return k; } // fallback: המודל הקל ביותר return candidates[candidates.length - 1]?.[0] || Object.keys(MODELS)[0]; } function getRamInfo() { const totalGB = navigator.deviceMemory; const mem = performance.memory; if (!totalGB && !mem) return null; const info = {}; if (totalGB) info.total = totalGB; if (mem) { info.used = (mem.usedJSHeapSize / 1073741824).toFixed(2); info.limit = (mem.jsHeapSizeLimit / 1073741824).toFixed(2); } return info; } function getBestAvailable() { if (cachedEmbedders[selectedModel]) return cachedEmbedders[selectedModel]; return Object.values(cachedEmbedders)[0] || null; }
ramMB מכל מודל ב-MODELS ומחזירה את הכבד ביותר
שמתאים לזיכרון הפנוי. אם ramMB חסר — fallback לסף sizeMB × 2.
אין שמות מודלים קשיחים בקוד — כל שינוי ב-models.json מתעדכן אוטומטית.
טוענת את רשימת המודלים מ-GitHub. אם הטעינה נכשלת (offline / שגיאת רשת) — נטענת רשימת fallback מינימלית.
async function loadModelsJson() { try { const res = await fetch(MODELS_URL); if (!res.ok) throw new Error('fetch failed'); const arr = await res.json(); // convert array → keyed object { multi_minilm_l6: {...}, labse: {...} } MODELS = Object.fromEntries(arr.map(m => [m.key, m])); } catch(e) { console.warn('Could not load models.json, using fallback', e); // fallback מינימלי — המערכת עדיין תעבוד MODELS = { albert_small: { id: 'Xenova/paraphrase-albert-small-v2', label: 'Albert-Small', sizeMB: 13, ramMB: 45, langs: 'אנגלית', tier: 'מיני' }, minilm_l3: { id: 'Xenova/paraphrase-MiniLM-L3-v2', label: 'MiniLM-L3', sizeMB: 18, ramMB: 60, langs: 'אנגלית', tier: 'מיני' }, multi_qa_minilm: { id: 'Xenova/multi-qa-MiniLM-L6-cos-v1', label: 'MultiQA-MiniLM-L6', sizeMB: 23, ramMB: 80, langs: 'אנגלית', tier: 'מיני' }, all_minilm_l6: { id: 'Xenova/all-MiniLM-L6-v2', label: 'All-MiniLM-L6', sizeMB: 23, ramMB: 80, langs: 'אנגלית', tier: 'מיני' }, msmarco_minilm: { id: 'Xenova/msmarco-MiniLM-L-6-v3', label: 'MS-MARCO-MiniLM-L6', sizeMB: 23, ramMB: 80, langs: 'אנגלית', tier: 'מיני' }, all_minilm_l12: { id: 'Xenova/all-MiniLM-L12-v2', label: 'All-MiniLM-L12', sizeMB: 33, ramMB: 100, langs: 'אנגלית', tier: 'מיני' }, me5_small: { id: 'Xenova/multilingual-e5-small', label: 'ME5-Small', sizeMB: 117, ramMB: 250, langs: '100 שפות', tier: 'קל' }, multi_minilm_l12: { id: 'Xenova/paraphrase-multilingual-MiniLM-L12-v2', label: 'Multi-MiniLM-L12', sizeMB: 120, ramMB: 260, langs: '50+ שפות', tier: 'קל' }, distiluse_v2: { id: 'Xenova/distiluse-base-multilingual-cased-v2', label: 'DistilUSE-v2', sizeMB: 130, ramMB: 280, langs: '50+ שפות', tier: 'קל' }, mbert_cased: { id: 'Xenova/bert-base-multilingual-cased', label: 'mBERT-Cased', sizeMB: 178, ramMB: 370, langs: '104 שפות · Google', tier: 'קל' }, me5_base: { id: 'Xenova/multilingual-e5-base', label: 'ME5-Base', sizeMB: 280, ramMB: 580, langs: '100 שפות', tier: 'בינוני'}, xlm_roberta_base: { id: 'Xenova/xlm-roberta-base', label: 'XLM-RoBERTa-Base', sizeMB: 280, ramMB: 590, langs: '100 שפות · Meta', tier: 'בינוני'}, labse: { id: 'Xenova/LaBSE', label: 'LaBSE', sizeMB: 471, ramMB: 950, langs: '109 שפות · Google', tier: 'חזק' }, me5_large: { id: 'Xenova/multilingual-e5-large', label: 'ME5-Large', sizeMB: 560, ramMB: 1100, langs: '100 שפות', tier: 'חזק' }, bge_m3: { id: 'Xenova/bge-m3', label: 'BGE-M3', sizeMB: 570, ramMB: 1150, langs: '100+ שפות · BAAI', tier: 'חזק' }, all_mpnet_base: { id: 'Xenova/all-mpnet-base-v2', label: 'All-MPNet-Base', sizeMB: 420, ramMB: 850, langs: 'אנגלית · MPNet', tier: 'כבד' }, e5_large_v2: { id: 'Xenova/e5-large-v2', label: 'E5-Large-v2', sizeMB: 670, ramMB: 1350, langs: 'אנגלית · Microsoft', tier: 'כבד' }, gte_large: { id: 'Xenova/gte-large', label: 'GTE-Large', sizeMB: 670, ramMB: 1350, langs: 'אנגלית · Alibaba', tier: 'כבד' }, instructor_large: { id: 'Xenova/instructor-large', label: 'Instructor-Large', sizeMB: 670, ramMB: 1400, langs: 'אנגלית · HKUNLP', tier: 'כבד' }, }; } }
הפונקציה הראשית שמורידה ומאתחלת pipeline. כוללת progress bar בזמן אמת, טיפול ב-401, abort, ו-cache.
function selectModel(key) { if (lockedModels.has(key)) { showToast('⛔ מודל זה דורש הרשאה ב-HuggingFace'); return; } if (selectedModel === key && cachedEmbedders[key]) return; // בטל טעינות קודמות Object.keys(activeLoads).forEach(k => { if (k !== key) activeLoads[k].aborted = true; }); selectedModel = key; localStorage.setItem('ht_model', key); initModelPicker(); if (!cachedEmbedders[key]) loadEmbedder(key); }
async function loadEmbedder(key) { key = key || selectedModel; // guard — MODELS טרם נטען if (!MODELS[key]) { console.warn(`loadEmbedder: unknown key "${key}"`); return null; } if (cachedEmbedders[key]) { updateDlDone(key); return cachedEmbedders[key]; } if (activeLoads[key] && !activeLoads[key].aborted) return null; const ctx = { aborted: false }; activeLoads[key] = ctx; initModelPicker(); // אלמנטי UI לפי key const wrap = document.getElementById('dlwrap-' + key); const fill = document.getElementById('dlfill-' + key); const lbl = document.getElementById('dllabel-' + key); const spd = document.getElementById('dlspeed-' + key); const eta = document.getElementById('dleta-' + key); if (wrap) wrap.style.display = 'block'; if (fill) fill.style.width = '0%'; if (lbl) lbl.textContent = 'מתחיל...'; // מעקב bytes לחישוב progress ומהירות const fileBytes = {}; let lastLoaded = 0, lastTime = Date.now(); let sessionTotalBytes = 0, sessionTotalMs = 0; try { const { pipeline, env } = await import( 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/transformers.min.js' ); env.allowLocalModels = false; env.useBrowserCache = true; const pipe = await pipeline('feature-extraction', MODELS[key].id, { quantized: true, progress_callback: (p) => { if (ctx.aborted) return; if (p.status === 'progress' && p.file) { if (!fileBytes[p.file]) fileBytes[p.file] = { loaded: 0, total: 0 }; if (p.loaded != null) fileBytes[p.file].loaded = p.loaded; if (p.total != null) fileBytes[p.file].total = p.total; const totalLoaded = Object.values(fileBytes).reduce((s,f) => s+f.loaded, 0); const totalSize = Object.values(fileBytes).reduce((s,f) => s+f.total, 0); const denom = totalSize > 0 ? totalSize : MODELS[key].sizeMB * 1048576; const pct = Math.min(99, Math.round(totalLoaded / denom * 100)); if (fill) fill.style.width = pct + '%'; const now = Date.now(); const dtSec = (now - lastTime) / 1000; const dBytes = totalLoaded - lastLoaded; if (dtSec >= 0.5 && dBytes > 0) { sessionTotalBytes += dBytes; sessionTotalMs += dtSec * 1000; const avgKbps = sessionTotalBytes / 1024 / (sessionTotalMs / 1000); const remaining = denom > 0 ? (denom - totalLoaded) / (avgKbps * 1024) : 0; const etaSec = Math.round(remaining); const etaStr = etaSec > 60 ? `${Math.floor(etaSec/60)}:${String(etaSec%60).padStart(2,'0')} min` : `${etaSec}s`; if (lbl) lbl.textContent = `${(totalLoaded/1048576).toFixed(1)} / ${(denom/1048576).toFixed(0)} MB (${pct}%)`; if (spd) spd.textContent = avgKbps > 1024 ? `${(avgKbps/1024).toFixed(1)} MB/s` : `${avgKbps.toFixed(0)} KB/s`; if (eta) eta.textContent = remaining > 0 ? `~${etaStr} left` : ''; lastLoaded = totalLoaded; lastTime = now; } } if (p.status === 'ready') { if (fill) fill.style.width = '100%'; if (lbl) lbl.textContent = `✦ ${MODELS[key].label} ready`; if (spd) spd.textContent = ''; if (eta) eta.textContent = ''; } } }); if (ctx.aborted) return getBestAvailable(); cachedEmbedders[key] = pipe; modelCacheStatus[key] = true; localStorage.setItem('ht_model_cached', JSON.stringify(modelCacheStatus)); updateDlDone(key); initModelPicker(); return pipe; } catch(e) { const is401 = e && (e.message || '').toLowerCase().includes('unauthorized'); if (!ctx.aborted) { if (is401) { // מודל gated — סמן כנעול ועבור ל-fallback lockedModels.add(key); localStorage.setItem('ht_locked_models', JSON.stringify([...lockedModels])); if (lbl) lbl.textContent = '🔒 requires HF access'; const fallback = Object.keys(MODELS).find(k => !lockedModels.has(k) && k !== key); if (fallback && selectedModel === key) { selectedModel = fallback; localStorage.setItem('ht_model', fallback); loadEmbedder(fallback); } initModelPicker(); } else { if (lbl) lbl.textContent = '⚠ load error'; } } console.error(e); return getBestAvailable(); } finally { if (!ctx.aborted) delete activeLoads[key]; } } function updateDlDone(key) { const fill = document.getElementById('dlfill-' + key); const lbl = document.getElementById('dllabel-' + key); const spd = document.getElementById('dlspeed-' + key); const eta = document.getElementById('dleta-' + key); if (fill) fill.style.width = '100%'; if (lbl) lbl.textContent = `✦ ${MODELS[key].label} ready`; if (spd) spd.textContent = ''; if (eta) eta.textContent = ''; }
כל מודל מקבל אוטומטית אלמנטי progress ייחודיים לפי ה-key שלו. הפונקציה בונה את ה-HTML ומעדכנת badges/states.
<!-- מיכל הרשימה — initModelPicker ממלא אותו --> <div id="modelList"></div> <!-- מידע RAM + המלצה --> <div id="ramInfo"></div>
function initModelPicker() { const rec = getRecommendedModel(); const list = document.getElementById('modelList'); // בנה HTML פעם אחת בלבד if (list && !list.hasChildNodes()) { let lastTier = null, html = ''; Object.entries(MODELS).forEach(([k, m]) => { if (m.tier !== lastTier) { html += `<div class="tier-label">── ${m.tier} ──</div>`; lastTier = m.tier; } const sizeTxt = m.sizeMB >= 1000 ? `${(m.sizeMB/1024).toFixed(1)}GB` : `~${m.sizeMB}MB`; html += ` <div class="model-option" id="opt-${k}" onclick="selectModel('${k}')"> <div class="model-name">${m.label}</div> <span class="model-badge" id="badge-${k}">${sizeTxt}</span> <button id="del-${k}" onclick="deleteModel(event,'${k}')" style="display:none">🗑</button> <div id="dlwrap-${k}" style="display:none"> <div class="dl-bar"><div class="dl-fill" id="dlfill-${k}"></div></div> <div class="dl-info"> <span id="dllabel-${k}"></span> <span id="dlspeed-${k}"></span> <span id="dleta-${k}"></span> </div> </div> </div>`; }); list.innerHTML = html; } // עדכן RAM info const ramEl = document.getElementById('ramInfo'); if (ramEl) { const info = getRamInfo(); if (info) { const parts = []; if (info.total) parts.push(`RAM: ${info.total}GB`); if (info.used) parts.push(`Used: ${info.used}/${info.limit}GB`); ramEl.textContent = parts.join(' · ') + ` → Recommended: ${MODELS[rec]?.label}`; } } // עדכן state של כל option Object.keys(MODELS).forEach(k => { const opt = document.getElementById('opt-' + k); const sizeBadge = document.getElementById('badge-' + k); const del = document.getElementById('del-' + k); if (!opt) return; opt.classList.toggle('active', k === selectedModel); opt.style.opacity = lockedModels.has(k) ? '0.45' : ''; if (sizeBadge) { if (lockedModels.has(k)) { sizeBadge.textContent = '🔒 locked'; sizeBadge.style.color = '#e8445a'; } else if (modelCacheStatus[k]) { sizeBadge.textContent = '✓ cached'; sizeBadge.style.color = '#4af3c8'; } } if (del) del.style.display = (modelCacheStatus[k] || activeLoads[k]) ? 'inline' : 'none'; }); } async function deleteModel(e, key) { e.stopPropagation(); if (activeLoads[key]) activeLoads[key].aborted = true; delete cachedEmbedders[key]; delete modelCacheStatus[key]; localStorage.setItem('ht_model_cached', JSON.stringify(modelCacheStatus)); // נסה לנקות מה-Cache API של הדפדפן try { const caches = await window.caches.keys(); for (const name of caches) { const cache = await window.caches.open(name); const keys = await cache.keys(); for (const req of keys) { if (req.url.includes(MODELS[key].id)) await cache.delete(req); } } } catch(err) {} initModelPicker(); }
כך תקרא למודל אחרי שהוא נטען — לחישוב embeddings, דמיון סמנטי, חיפוש וכו'.
// קבל את המודל הטוב ביותר הזמין עכשיו const model = getBestAvailable() || await loadEmbedder(); if (!model) return null; // עדיין לא נטען // embedding לטקסט בודד const result = await model('שלום עולם', { pooling: 'mean', normalize: true }); const vector = Array.from(result.data); // Float32Array → Array // דמיון cosine בין שני טקסטים function cosineSim(a, b) { const dot = a.reduce((s, v, i) => s + v * b[i], 0); const na = Math.sqrt(a.reduce((s, v) => s + v*v, 0)); const nb = Math.sqrt(b.reduce((s, v) => s + v*v, 0)); return dot / (na * nb); // 0 (שונה מאוד) עד 1 (זהה) } async function getSimilarity(textA, textB) { const m = getBestAvailable() || await loadEmbedder(); if (!m) return null; const [ea, eb] = await Promise.all([ m(textA, { pooling: 'mean', normalize: true }), m(textB, { pooling: 'mean', normalize: true }), ]); return cosineSim(Array.from(ea.data), Array.from(eb.data)); }
יש לקרוא ל-loadModelsJson() לפני כל שאר הקוד שתלוי ב-MODELS.
עטוף ב-async IIFE בתחתית הסקריפט.
// ── בתחתית הסקריפט, אחרי כל הפונקציות ── // קוד שלא תלוי ב-MODELS — ניתן לקרוא מיד initTheme(); renderUI(); // קוד שתלוי ב-MODELS — חייב להמתין לטעינה (async () => { await loadModelsJson(); // selectedModel היה null — עכשיו MODELS נטען, getRecommendedModel() עובד if (!selectedModel || !MODELS[selectedModel] || lockedModels.has(selectedModel)) { selectedModel = getRecommendedModel(); // קורא ל-MODELS דינמית, ללא שמות קשיחים localStorage.setItem('ht_model', selectedModel); } initModelPicker(); // ← חייב אחרי loadModelsJson loadEmbedder(); // ← מתחיל הורדה ברקע })();
loadEmbedder() או initModelPicker()
לפני await loadModelsJson() — MODELS יהיה ריק ותתקבל שגיאת Cannot read properties of undefined.
כל הקוד הנדרש בבלוק אחד. הדבק בתחתית ה-<script> שלך ואתחל בתחתית הדף.
// ═══════════════════════════════════════════════════════════════ // model-locel-offline-import-helper — full integration // https://github.com/giamat13/model-locel-offline-import-helper // ═══════════════════════════════════════════════════════════════ const MODELS_URL = 'https://raw.githubusercontent.com/giamat13/model-locel-offline-import-helper/main/models.json'; let MODELS = {}; let cachedEmbedders = {}; let activeLoads = {}; let lockedModels = new Set(JSON.parse(localStorage.getItem('ht_locked_models') || '[]')); let selectedModel = localStorage.getItem('ht_model') || null; // resolved after loadModelsJson() let modelCacheStatus = JSON.parse(localStorage.getItem('ht_model_cached') || '{}'); function getRecommendedModel() { const totalGB = navigator.deviceMemory || 4; const mem = performance.memory; let freeGB = totalGB; if (mem) { freeGB = Math.min(totalGB, mem.jsHeapSizeLimit / 1073741824) - mem.usedJSHeapSize / 1073741824; } const freeBytes = freeGB * 1024; // convert GB → MB for comparison with ramMB const candidates = Object.entries(MODELS) .filter(([k]) => !lockedModels.has(k)) .sort((a, b) => b[1].sizeMB - a[1].sizeMB); for (const [k, m] of candidates) { if (m.sizeMB * 1048576 * 2 <= freeBytes) return k; } if (freeGB >= 0.2) return 'me5_small'; return 'multi_minilm_l6'; } function getRamInfo() { const totalGB = navigator.deviceMemory; const mem = performance.memory; if (!totalGB && !mem) return null; const info = {}; if (totalGB) info.total = totalGB; if (mem) { info.used = (mem.usedJSHeapSize / 1073741824).toFixed(2); info.limit = (mem.jsHeapSizeLimit / 1073741824).toFixed(2); } return info; } function getBestAvailable() { return cachedEmbedders[selectedModel] || Object.values(cachedEmbedders)[0] || null; } async function loadModelsJson() { try { const res = await fetch(MODELS_URL); if (!res.ok) throw new Error('fetch failed'); MODELS = Object.fromEntries((await res.json()).map(m => [m.key, m])); } catch(e) { console.warn('models.json fallback', e); MODELS = { multi_minilm_l12: { id: 'Xenova/paraphrase-multilingual-MiniLM-L12-v2', label: 'Multi-MiniLM-L12', sizeMB: 120, langs: '50+', tier: 'light' }, labse: { id: 'Xenova/LaBSE', label: 'LaBSE', sizeMB: 471, langs: '109', tier: 'heavy' }, }; } } function initModelPicker() { const rec = getRecommendedModel(); const list = document.getElementById('modelList'); if (list && !list.hasChildNodes()) { let lastTier = null, html = ''; Object.entries(MODELS).forEach(([k, m]) => { if (m.tier !== lastTier) { html += `<div class="tier-label">── ${m.tier} ──</div>`; lastTier = m.tier; } const sz = m.sizeMB >= 1000 ? `${(m.sizeMB/1024).toFixed(1)}GB` : `~${m.sizeMB}MB`; html += `<div class="model-option" id="opt-${k}" onclick="selectModel('${k}')"> <span class="model-name">${m.label}</span> <span class="model-badge" id="badge-${k}">${sz}</span> <button id="del-${k}" onclick="deleteModel(event,'${k}')" style="display:none">🗑</button> <div id="dlwrap-${k}" style="display:none"> <div class="dl-bar"><div class="dl-fill" id="dlfill-${k}"></div></div> <div><span id="dllabel-${k}"></span> <span id="dlspeed-${k}"></span> <span id="dleta-${k}"></span></div> </div> </div>`; }); list.innerHTML = html; } const ramEl = document.getElementById('ramInfo'); if (ramEl) { const info = getRamInfo(); if (info) ramEl.textContent = [info.total ? `RAM: ${info.total}GB` : '', info.used ? `Used: ${info.used}/${info.limit}GB` : ''].filter(Boolean).join(' · ') + ` → Recommended: ${MODELS[rec]?.label}`; } Object.keys(MODELS).forEach(k => { const opt = document.getElementById('opt-'+k); const badge = document.getElementById('badge-'+k); const del = document.getElementById('del-'+k); if (!opt) return; opt.classList.toggle('active', k === selectedModel); opt.style.opacity = lockedModels.has(k) ? '0.45' : ''; if (badge) { if (lockedModels.has(k)) { badge.textContent = '🔒 locked'; badge.style.color = '#e8445a'; } else if (modelCacheStatus[k]) { badge.textContent = '✓ cached'; badge.style.color = '#4af3c8'; } } if (del) del.style.display = (modelCacheStatus[k] || activeLoads[k]) ? 'inline' : 'none'; }); } function selectModel(key) { if (lockedModels.has(key)) { alert('⛔ Model requires HuggingFace access'); return; } if (selectedModel === key && cachedEmbedders[key]) return; Object.keys(activeLoads).forEach(k => { if (k !== key) activeLoads[k].aborted = true; }); selectedModel = key; localStorage.setItem('ht_model', key); initModelPicker(); if (!cachedEmbedders[key]) loadEmbedder(key); } async function loadEmbedder(key) { key = key || selectedModel; if (!MODELS[key]) { console.warn(`Unknown model key: "${key}"`); return null; } if (cachedEmbedders[key]) { updateDlDone(key); return cachedEmbedders[key]; } if (activeLoads[key] && !activeLoads[key].aborted) return null; const ctx = { aborted: false }; activeLoads[key] = ctx; initModelPicker(); const wrap=document.getElementById('dlwrap-'+key), fill=document.getElementById('dlfill-'+key), lbl=document.getElementById('dllabel-'+key), spd=document.getElementById('dlspeed-'+key), eta=document.getElementById('dleta-'+key); if(wrap)wrap.style.display='block'; if(fill)fill.style.width='0%'; if(lbl)lbl.textContent='Loading...'; const fileBytes={}, startTime=Date.now(); let lastLoaded=0, lastTime=startTime, sessionBytes=0, sessionMs=0; try { const {pipeline,env} = await import('https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/transformers.min.js'); env.allowLocalModels=false; env.useBrowserCache=true; const pipe = await pipeline('feature-extraction', MODELS[key].id, { quantized:true, progress_callback(p) { if(ctx.aborted)return; if(p.status==='progress'&&p.file){ if(!fileBytes[p.file])fileBytes[p.file]={loaded:0,total:0}; if(p.loaded!=null)fileBytes[p.file].loaded=p.loaded; if(p.total!=null)fileBytes[p.file].total=p.total; const tl=Object.values(fileBytes).reduce((s,f)=>s+f.loaded,0); const ts=Object.values(fileBytes).reduce((s,f)=>s+f.total,0); const denom=ts>0?ts:MODELS[key].sizeMB*1048576; const pct=Math.min(99,Math.round(tl/denom*100)); if(fill)fill.style.width=pct+'%'; const now=Date.now(),dt=(now-lastTime)/1000,db=tl-lastLoaded; if(dt>=0.5&&db>0){ sessionBytes+=db; sessionMs+=dt*1000; const avg=sessionBytes/1024/(sessionMs/1000); const rem=denom>0?(denom-tl)/(avg*1024):0; if(lbl)lbl.textContent=`${(tl/1048576).toFixed(1)}/${(denom/1048576).toFixed(0)}MB (${pct}%)`; if(spd)spd.textContent=avg>1024?`${(avg/1024).toFixed(1)}MB/s`:`${avg.toFixed(0)}KB/s`; if(eta)eta.textContent=rem>0?`~${rem>60?Math.floor(rem/60)+':'+String(Math.round(rem%60)).padStart(2,'0')+'m':Math.round(rem)+'s'}`:''; lastLoaded=tl; lastTime=now; } } if(p.status==='ready'){if(fill)fill.style.width='100%';if(lbl)lbl.textContent=`✦ ${MODELS[key].label} ready`;if(spd)spd.textContent='';if(eta)eta.textContent='';} } }); if(ctx.aborted)return getBestAvailable(); cachedEmbedders[key]=pipe; modelCacheStatus[key]=true; localStorage.setItem('ht_model_cached',JSON.stringify(modelCacheStatus)); updateDlDone(key); initModelPicker(); return pipe; } catch(e) { const is401=(e?.message||'').toLowerCase().includes('unauthorized'); if(!ctx.aborted){ if(is401){ lockedModels.add(key); localStorage.setItem('ht_locked_models',JSON.stringify([...lockedModels])); if(lbl)lbl.textContent='🔒 requires HF access'; const fb=Object.keys(MODELS).find(k=>!lockedModels.has(k)&&k!==key); if(fb&&selectedModel===key){selectedModel=fb;localStorage.setItem('ht_model',fb);loadEmbedder(fb);} initModelPicker(); } else { if(lbl)lbl.textContent='⚠ Load error'; } } console.error(e); return getBestAvailable(); } finally { if(!ctx.aborted)delete activeLoads[key]; } } function updateDlDone(key){ const f=document.getElementById('dlfill-'+key),l=document.getElementById('dllabel-'+key), s=document.getElementById('dlspeed-'+key),e=document.getElementById('dleta-'+key); if(f)f.style.width='100%';if(l)l.textContent=`✦ ${MODELS[key].label} ready`;if(s)s.textContent='';if(e)e.textContent=''; } async function deleteModel(ev, key){ ev.stopPropagation(); if(activeLoads[key])activeLoads[key].aborted=true; delete cachedEmbedders[key]; delete modelCacheStatus[key]; localStorage.setItem('ht_model_cached',JSON.stringify(modelCacheStatus)); try { for(const n of await window.caches.keys()){ const c=await window.caches.open(n); for(const r of await c.keys()) if(r.url.includes(MODELS[key].id))await c.delete(r); } } catch(err){} initModelPicker(); } // ── INIT (put at bottom of script) ── (async () => { await loadModelsJson(); // selectedModel is null until here — getRecommendedModel() reads MODELS dynamically if (!selectedModel || !MODELS[selectedModel] || lockedModels.has(selectedModel)) { selectedModel = getRecommendedModel(); localStorage.setItem('ht_model', selectedModel); } initModelPicker(); loadEmbedder(); })();