📦 giamat13 / model-locel-offline-import-helper

מדריך שילוב מלא

כיצד להוסיף מערכת בחירת מודלי AI מקומיים (offline) עם זיהוי RAM אוטומטי, progress bar, cache ו-fallback לפרויקט שלך.

// תוכן עניינים
  1. איך המערכת עובדת
  2. מבנה models.json
  3. שלב 1 — משתנים גלובליים
  4. שלב 2 — זיהוי RAM אוטומטי
  5. שלב 3 — טעינת models.json
  6. שלב 4 — loadEmbedder
  7. שלב 5 — UI של בחירת מודל
  8. שלב 6 — שימוש במודל
  9. שלב 7 — אתחול
  10. קוד מלא להדבקה

⚙️ איך המערכת עובדת

המערכת טוענת מודלי embedding מ-HuggingFace דרך transformers.js ישירות בדפדפן, ללא שרת. המודלים נשמרים ב-Cache API של הדפדפן (IndexedDB), כך שבפעם הבאה הטעינה מיידית.

אתחול הדף — loadModelsJson() מ-GitHub Raw
getRecommendedModel() — בוחר מודל לפי RAM זמין
initModelPicker() — בונה UI של רשימת המודלים
loadEmbedder(key) — מוריד + מאתחל pipeline עם progress
מודל זמין — getBestAvailable() מחזיר אותו לשימוש
💡 המודלים נשמרים ב-Cache API של הדפדפן — לא localStorage. הם יוצגו כ-"cached" גם אחרי רענון דף, עד שהמשתמש ימחק אותם ידנית.

📄 מבנה models.json

הקובץ models.json מאוחסן ב-repo ומכיל מערך של מודלים. כל שינוי שם מתעדכן אוטומטית לכל המשתמשים.

json — models.json
[
  {
    "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":   "ענק"
  }
  // ... ועוד מודלים
]
שדהסוגתיאור
keystringמזהה ייחודי — משמש כ-key בכל הקוד
idstringשם המודל ב-HuggingFace (Xenova/...)
labelstringשם תצוגה למשתמש
sizeMBnumberגודל משוער ב-MB — משמש לחישוב progress
langsstringתיאור שפות נתמכות (תצוגה בלבד)
ramMBnumberRAM נדרש בפועל (MB) — משמש ל-getRecommendedModel() להתאמה לזיכרון הפנוי
tierstringקטגוריה לקבוצה ויזואלית ברשימה (מיני / קל / בינוני / חזק / כבד / ענק)
⚠️ מודלים עם repo מוגבל (gated) ב-HuggingFace יחזירו שגיאת 401 — המערכת מטפלת בהם אוטומטית ומסמנת אותם כנעולים.

01 // משתנים גלובליים

הוסף את המשתנים האלה בתחילת ה-<script> שלך, לפני כל שאר הקוד.

javascript
// ── 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') || '{}');
ℹ️ ה-prefix ht_ ב-localStorage הוא שרירותי — שנה אותו לפרויקט שלך כדי להימנע מהתנגשויות.

02 // זיהוי RAM אוטומטי

getRecommendedModel() משתמש ב-navigator.deviceMemory ו-performance.memory כדי להעריך את הRAM הפנוי, ואז עובר דינמית על MODELS (כפי שנטען מ-models.json) ומחזיר את המודל הכבד ביותר שמתאים לזיכרון — ללא שמות קשיחים בקוד.

javascript
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 מתעדכן אוטומטית.

03 // טעינת models.json

טוענת את רשימת המודלים מ-GitHub. אם הטעינה נכשלת (offline / שגיאת רשת) — נטענת רשימת fallback מינימלית.

javascript
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: 'כבד'   },

    };
  }
}

04 // loadEmbedder — לב המערכת

הפונקציה הראשית שמורידה ומאתחלת pipeline. כוללת progress bar בזמן אמת, טיפול ב-401, abort, ו-cache.

// selectModel — בחירת מודל

javascript
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);
}

// loadEmbedder — הורדה + אתחול

javascript
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  = '';
}

05 // UI — HTML + initModelPicker

כל מודל מקבל אוטומטית אלמנטי progress ייחודיים לפי ה-key שלו. הפונקציה בונה את ה-HTML ומעדכנת badges/states.

// HTML נדרש

html
<!-- מיכל הרשימה — initModelPicker ממלא אותו -->
<div id="modelList"></div>

<!-- מידע RAM + המלצה -->
<div id="ramInfo"></div>

// initModelPicker

javascript
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();
}

06 // שימוש במודל

כך תקרא למודל אחרי שהוא נטען — לחישוב embeddings, דמיון סמנטי, חיפוש וכו'.

javascript
// קבל את המודל הטוב ביותר הזמין עכשיו
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));
}

07 // אתחול — סדר הקריאות חשוב!

יש לקרוא ל-loadModelsJson() לפני כל שאר הקוד שתלוי ב-MODELS. עטוף ב-async IIFE בתחתית הסקריפט.

javascript
// ── בתחתית הסקריפט, אחרי כל הפונקציות ──

// קוד שלא תלוי ב-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.

08 // קוד מלא להדבקה — Copy & Paste

כל הקוד הנדרש בבלוק אחד. הדבק בתחתית ה-<script> שלך ואתחל בתחתית הדף.

javascript — FULL SYSTEM
// ═══════════════════════════════════════════════════════════════
//  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();
})();