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')}`
: ``}
${t('localModels.downloading')}
`;
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
? ``
: ``
}
${t('localModels.preparing')}
${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;
}