import { localAI, isLocalAIAvailable } from '../lib/localInferenceClient.js'; import { t, tf } from '../lib/i18n.js'; // ─── Icons ──────────────────────────────────────────────────────────────────── const DownloadIcon = ``; const TrashIcon = ``; const CheckIcon = ``; // ─── Helpers ───────────────────────────────────────────────────────────────── function fmtGB(gb) { return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(gb * 1024).toFixed(0)} MB`; } function tagEl(text) { const span = document.createElement('span'); span.className = 'px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-white/5 text-muted'; span.textContent = text; return span; } // ─── Binary Status Bar ──────────────────────────────────────────────────────── function BinaryStatusBar(onStatusChange) { const bar = document.createElement('div'); bar.className = 'flex items-center justify-between gap-3 p-3 rounded-xl bg-white/3 border border-white/5'; const label = document.createElement('div'); label.className = 'flex flex-col gap-0.5'; label.innerHTML = ` sd.cpp inference engine ${t('localModels.checking')} `; const btn = document.createElement('button'); btn.id = 'binary-action-btn'; btn.className = 'px-3 py-1.5 rounded-lg text-xs font-bold transition-all hidden'; btn.textContent = t('localModels.installEngine'); bar.appendChild(label); bar.appendChild(btn); const progressBar = document.createElement('div'); progressBar.className = 'h-1 rounded-full bg-white/5 mt-2 hidden overflow-hidden'; progressBar.id = 'binary-progress-bar'; progressBar.innerHTML = `
`; bar.appendChild(progressBar); const refresh = async () => { const status = await localAI.getBinaryStatus(); const text = bar.querySelector('#binary-status-text'); if (status.exists) { text.textContent = t('localModels.installed'); text.className = 'text-[11px] text-green-400'; btn.classList.add('hidden'); } else { text.textContent = t('localModels.notInstalled'); text.className = 'text-[11px] text-yellow-400'; btn.textContent = t('localModels.installEngine'); btn.className = 'px-3 py-1.5 rounded-lg text-xs font-bold bg-primary text-black transition-all'; btn.classList.remove('hidden'); } if (onStatusChange) onStatusChange(status.exists); }; btn.onclick = async () => { btn.disabled = true; btn.textContent = t('localModels.downloading'); progressBar.classList.remove('hidden'); const unsub = localAI.onDownloadProgress(({ id, phase, progress }) => { if (id !== '__binary__') return; const fill = document.getElementById('binary-progress-fill'); const text = bar.querySelector('#binary-status-text'); if (fill) fill.style.width = `${Math.round(progress * 100)}%`; if (text) text.textContent = phase === 'extracting' ? t('localModels.extracting') : `${t('localModels.downloading')} ${Math.round(progress * 100)}%`; }); try { await localAI.downloadBinary(); unsub(); progressBar.classList.add('hidden'); await refresh(); } catch (err) { unsub(); const text = bar.querySelector('#binary-status-text'); if (text) text.textContent = `Error: ${err.message}`; btn.disabled = false; btn.textContent = t('common.retry'); } }; if (isLocalAIAvailable()) refresh(); return bar; } // ─── Auxiliary file row (text encoder / VAE for Z-Image) ───────────────────── function AuxRow(label, auxKey, initStatus, onStateChange) { const row = document.createElement('div'); row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-white/3 border border-white/5'; const isReady = initStatus === 'downloaded'; row.innerHTML = `
${isReady ? `${CheckIcon}` : `!`} ${label}
${isReady ? `${t('localModels.ready')}` : ``}
`; const btn = row.querySelector('.aux-dl-btn'); if (btn) { btn.onclick = async () => { btn.disabled = true; btn.innerHTML = ``; const progWrap = row.querySelector('.aux-progress'); const progFill = row.querySelector('.aux-fill'); const progText = row.querySelector('.aux-text'); progWrap.classList.remove('hidden'); const auxId = auxKey === 'llm' ? '__llm__' : '__vae__'; const unsub = localAI.onDownloadProgress(({ id, phase, progress }) => { if (id !== auxId) return; progFill.style.width = `${Math.round(progress * 100)}%`; progText.textContent = phase === 'done' ? t('localModels.complete') : `${t('localModels.downloading')} ${Math.round(progress * 100)}%`; }); try { await localAI.downloadAuxiliary(auxKey); unsub(); if (onStateChange) onStateChange(); } catch (err) { unsub(); progText.textContent = `Error: ${err.message}`; btn.disabled = false; btn.innerHTML = `${DownloadIcon} ${t('common.retry')}`; } }; } return row; } // ─── Wan2GP Server Config ──────────────────────────────────────────────────── function Wan2gpConfigBar(onChange) { const wrap = document.createElement('div'); wrap.className = 'flex flex-col gap-3 p-3 rounded-xl bg-white/3 border border-white/5'; wrap.innerHTML = `
Wan2GP server (optional) Run Wan2GP on a CUDA box (python wgp.py --listen --server-name 0.0.0.0) to unlock video models from this UI.
${t('localModels.notConfigured')}
`; const input = wrap.querySelector('#wan2gp-url'); const testBtn = wrap.querySelector('#wan2gp-test'); const saveBtn = wrap.querySelector('#wan2gp-save'); const statusEl = wrap.querySelector('#wan2gp-status'); const setStatus = (text, kind = 'muted') => { const colorMap = { muted: 'text-muted', ok: 'text-green-400', warn: 'text-yellow-400', err: 'text-red-400' }; statusEl.className = `text-[11px] ${colorMap[kind] || colorMap.muted}`; statusEl.textContent = text; }; (async () => { const cfg = await localAI.getWan2gpConfig(); if (cfg.url) { input.value = cfg.url; const r = await localAI.probeWan2gp(cfg.url); setStatus(r.ok ? `Connected · Gradio ${r.version}` : `Saved URL not reachable: ${r.error}`, r.ok ? 'ok' : 'warn'); } else { setStatus(t('localModels.notConfiguredNote'), 'muted'); } })(); testBtn.onclick = async () => { const url = input.value.trim(); if (!url) { setStatus('Enter a URL first', 'warn'); return; } setStatus(t('localModels.probing'), 'muted'); testBtn.disabled = true; try { const r = await localAI.probeWan2gp(url); setStatus(r.ok ? `Reachable · Gradio ${r.version}` : `Unreachable: ${r.error}`, r.ok ? 'ok' : 'err'); } finally { testBtn.disabled = false; } }; saveBtn.onclick = async () => { const url = input.value.trim(); saveBtn.disabled = true; try { await localAI.setWan2gpUrl(url); const r = url ? await localAI.probeWan2gp(url) : { ok: false, error: 'cleared' }; setStatus(r.ok ? `Saved · Connected to Gradio ${r.version}` : (url ? `Saved, not reachable: ${r.error}` : 'Cleared'), r.ok ? 'ok' : 'warn'); onChange?.(); } finally { saveBtn.disabled = false; } }; return wrap; } // ─── Model Card ─────────────────────────────────────────────────────────────── function Wan2gpModelCard(model) { const card = document.createElement('div'); card.className = 'flex items-start justify-between gap-3 p-4 rounded-xl border border-white/5 bg-white/3'; const ready = !!model.ready; card.innerHTML = `
${model.name} ${ready ? `${CheckIcon}` : ''}

${model.description}

${model.type.toUpperCase()} via Wan2GP ${(model.tags || []).filter(t => !['featured', 'remote'].includes(t)).map(t => `${t}`).join('')}
${ready ? t('localModels.available') : t('localModels.offline')}
`; return card; } function ModelCard(model, onStateChange) { if (model.provider === 'wan2gp') return Wan2gpModelCard(model); const card = document.createElement('div'); card.className = 'flex flex-col gap-3 p-4 rounded-xl border border-white/5 bg-white/3 hover:border-white/10 transition-all'; const isDownloaded = model.state === 'downloaded'; const auxStatus = model.auxiliaryStatus || {}; const auxReady = !model.requiresAuxiliary || (auxStatus.llm === 'downloaded' && auxStatus.vae === 'downloaded'); const fullyReady = isDownloaded && auxReady; card.innerHTML = `
${model.name} ${model.featured ? `${t('localModels.featured')}` : ''} ${fullyReady ? `${CheckIcon}` : ''}

${model.description}

${model.type.toUpperCase()} ${fmtGB(model.sizeGB)} ${(model.tags || []).filter(t => t !== 'featured').map(t => `${t}`).join('')}
${isDownloaded ? `` : `` }
${model.requiresAuxiliary ? `
` : ''} `; // Auxiliary files section for Z-Image if (model.requiresAuxiliary) { const auxSection = card.querySelector('.aux-section'); auxSection.appendChild(document.createElement('span')).className = 'text-[10px] text-muted uppercase tracking-wider font-bold'; auxSection.querySelector('span').textContent = t('localModels.requiredComponents'); auxSection.appendChild(AuxRow('Qwen3-4B Text Encoder (2.4 GB)', 'llm', auxStatus.llm, onStateChange)); auxSection.appendChild(AuxRow('FLUX VAE (335 MB)', 'vae', auxStatus.vae, onStateChange)); } const progressWrap = card.querySelector('.progress-wrap'); const progressFill = card.querySelector('.progress-fill'); const progressText = card.querySelector('.progress-text'); const downloadBtn = card.querySelector('.download-btn'); if (downloadBtn) { downloadBtn.onclick = async () => { downloadBtn.disabled = true; downloadBtn.innerHTML = ` ${t('localModels.starting')}`; progressWrap.classList.remove('hidden'); const unsub = localAI.onDownloadProgress(({ id, phase, progress }) => { if (id !== model.id) return; progressFill.style.width = `${Math.round(progress * 100)}%`; progressText.textContent = phase === 'done' ? t('localModels.complete') : `${t('localModels.downloading')} ${Math.round(progress * 100)}%`; }); try { await localAI.downloadModel(model.id); unsub(); if (onStateChange) onStateChange(); } catch (err) { unsub(); progressText.textContent = `Error: ${err.message}`; downloadBtn.disabled = false; downloadBtn.innerHTML = `${DownloadIcon} ${t('common.retry')}`; } }; } const deleteBtn = card.querySelector('.delete-btn'); if (deleteBtn) { deleteBtn.onclick = async () => { if (!confirm(tf('localModels.deleteConfirm', model.name))) return; await localAI.deleteModel(model.id); if (onStateChange) onStateChange(); }; } return card; } // ─── Main component ─────────────────────────────────────────────────────────── export function LocalModelManager() { const root = document.createElement('div'); root.className = 'flex flex-col gap-5'; if (!isLocalAIAvailable()) { root.innerHTML = `

${t('localModels.title')}

${t('localModels.webOnly')}

`; return root; } // ── Section: engine status const engineSection = document.createElement('div'); engineSection.className = 'flex flex-col gap-2'; engineSection.innerHTML = `

${t('localModels.inferenceEngine')}

`; let binaryReady = false; const binaryBar = BinaryStatusBar((ready) => { binaryReady = ready; }); engineSection.appendChild(binaryBar); const wan2gpBar = Wan2gpConfigBar(() => renderModels()); engineSection.appendChild(wan2gpBar); root.appendChild(engineSection); // ── Section: models const modelsSection = document.createElement('div'); modelsSection.className = 'flex flex-col gap-3'; modelsSection.innerHTML = `

${t('localModels.title')}

${t('localModels.checkingStorage')}
`; root.appendChild(modelsSection); const listEl = modelsSection.querySelector('#local-model-list'); const storageEl = modelsSection.querySelector('#local-model-storage'); const refreshStorageInfo = async () => { try { const status = await localAI.getBinaryStatus(); const storagePath = status.modelsDir || status.dataDir; storageEl.textContent = storagePath ? `${t('localModels.storedIn')} ${storagePath}` : t('localModels.storedDefault'); if (storagePath && status.envVar) { storageEl.title = `Set ${status.envVar} before launch to change this location`; } } catch (_) { storageEl.textContent = t('localModels.storedDefault'); } }; const renderModels = async () => { listEl.innerHTML = `
${t('localModels.loading')}
`; try { const models = await localAI.listModels(); listEl.innerHTML = ''; models.forEach(m => { listEl.appendChild(ModelCard(m, renderModels)); }); } catch (err) { listEl.innerHTML = `
${t('localModels.errorLoading')}${err.message}
`; } }; refreshStorageInfo(); renderModels(); return root; }