// Multi-model panel (Phase 4, extracted from Sidebar.js in H4 4.3).
// Model selector, per-document cache status, re-ingest with progress, and
// side-by-side compare (Mode A). Markup is nested inside the relief controls
// (composed by ReliefPanel); wiring runs in the sidebar's deferred pass.
//
// Cross-panel: publishes ctx.refresh.modelPanel + ctx.refresh.modelCacheStatus;
// reads ctx.refresh.reliefFiles / docList / comparisonModeUI.
import { STATE, addReliefDoc } from '../../core/State.js';
import { cycleUiTheme, getUiThemeSummary } from '../themeBootstrap.js';
/** @typedef {import('../types.js').PanelContext} PanelContext */
/**
* Markup for the model-compare-section (verbatim).
* @returns {string}
*/
export function modelPanelMarkup() {
return `
Model (exp.):
i
active: --
style: --
🖥
⚠️ X axes (dimensions) are not comparable across models: comparison is of STRUCTURE / silhouette, not cell by cell.
🔁 Re-ingest
⚖️ Compare
`;
}
/**
* Wire the multi-model panel. Publishes ctx.refresh.modelPanel + modelCacheStatus.
* @param {PanelContext} ctx
* @returns {void}
*/
export function wireModelPanel(ctx) {
const { showCustomModal, applyComparison } = ctx;
const reliefFileSelector = document.getElementById('relief-file-selector');
const modelSelector = document.getElementById('model-selector');
const modelActiveBadge = document.getElementById('model-active-badge');
const modelCacheStatus = document.getElementById('model-cache-status');
const btnModelReingest = document.getElementById('btn-model-reingest');
const btnModelCompare = document.getElementById('btn-model-compare');
const modelReingestProgress = document.getElementById('model-reingest-progress');
const modelReingestFill = document.getElementById('model-reingest-fill');
const modelReingestLabel = document.getElementById('model-reingest-label');
const btnModelReingestCancel = document.getElementById('btn-model-reingest-cancel');
const uiThemeLabel = document.getElementById('ui-theme-label');
const btnUiThemeCycle = document.getElementById('btn-ui-theme-cycle');
let reingestPolling = false;
const refreshUiThemeLabel = () => {
if (!uiThemeLabel) return;
const { label, index } = getUiThemeSummary();
const text = `style: ${label} (${index}/10)`;
uiThemeLabel.textContent = text;
uiThemeLabel.title = text;
};
const currentReliefFilename = () =>
(reliefFileSelector && reliefFileSelector.value) ||
(STATE.reliefData && STATE.reliefData.filename) || '';
const dimForModel = (key) => {
const m = (STATE.models || []).find(x => x.key === key);
return m ? m.dim : '?';
};
const refreshModelCacheStatus = () => {
if (!modelCacheStatus) return;
const file = currentReliefFilename();
const sel = STATE.selectedModelKey;
if (!file) {
modelCacheStatus.textContent = 'Select a PDF to see its cache per model.';
if (btnModelReingest) btnModelReingest.disabled = true;
if (btnModelCompare) btnModelCompare.disabled = true;
return;
}
const cachedIn = (STATE.filesModels && STATE.filesModels[file]) || [];
const isCached = cachedIn.includes(sel);
modelCacheStatus.innerHTML =
`cache: ${cachedIn.length ? cachedIn.map(k => `${k}(${dimForModel(k)}D)`).join(', ') : '—'}` +
` · ${sel}: ${isCached ? '✅ cached' : '⚠️ requires re-ingestion'}`;
if (btnModelReingest) btnModelReingest.disabled = isCached;
if (btnModelCompare) btnModelCompare.disabled = cachedIn.length < 2;
};
const refreshModelPanel = () => {
if (modelSelector) {
const prev = STATE.selectedModelKey;
modelSelector.innerHTML = '';
(STATE.models || []).forEach(m => {
const opt = document.createElement('option');
opt.value = m.key;
const isDefault = m.key === STATE.defaultModelKey;
opt.textContent = `${m.key} · ${m.dim}D${isDefault ? ' (base)' : ''}`;
opt.title = m.notes || m.hf_name;
modelSelector.appendChild(opt);
});
if ((STATE.models || []).some(m => m.key === prev)) modelSelector.value = prev;
else if (STATE.models && STATE.models[0]) {
STATE.selectedModelKey = STATE.models[0].key;
modelSelector.value = STATE.selectedModelKey;
}
}
if (modelActiveBadge) modelActiveBadge.textContent = `active: ${STATE.activeModelKey} (${dimForModel(STATE.activeModelKey)}D)`;
refreshModelCacheStatus();
};
// Publish cross-panel refreshers (relief panel calls modelPanel after listing files).
ctx.refresh.modelPanel = refreshModelPanel;
ctx.refresh.modelCacheStatus = refreshModelCacheStatus;
if (modelSelector) {
modelSelector.onchange = function() {
STATE.selectedModelKey = this.value;
refreshModelCacheStatus();
};
}
const pollReingest = async (jobId) => {
reingestPolling = true;
if (modelReingestProgress) modelReingestProgress.style.display = 'flex';
try {
while (reingestPolling) {
const st = await STATE.provider.reingestStatus(jobId);
if (!st) break;
const pct = st.total > 0 ? Math.round((st.processed / st.total) * 100) : 0;
if (modelReingestFill) modelReingestFill.style.width = `${pct}%`;
if (modelReingestLabel) {
modelReingestLabel.textContent =
`${st.model}: ${st.processed}/${st.total} (re=${st.reingested} skip=${st.skipped}) ${st.current ? '· ' + st.current.slice(0, 18) : ''}`;
}
if (['done', 'cancelled', 'error'].includes(st.status)) {
if (modelReingestLabel) {
modelReingestLabel.textContent = st.status === 'error'
? `❌ ${st.error || 'error'}`
: `${st.status} · re=${st.reingested} skip=${st.skipped}`;
}
// El modelo objetivo quedó como modelo activo en el backend.
STATE.activeModelKey = st.model;
break;
}
await new Promise(r => setTimeout(r, 700));
}
} catch (e) {
if (modelReingestLabel) modelReingestLabel.textContent = `❌ ${e.message}`;
} finally {
reingestPolling = false;
await ctx.refresh.reliefFiles?.(currentReliefFilename());
refreshModelPanel();
setTimeout(() => { if (modelReingestProgress) modelReingestProgress.style.display = 'none'; }, 2500);
}
};
if (btnModelReingest) {
btnModelReingest.onclick = async () => {
if (!STATE.provider || !STATE.provider.ready) return;
const key = STATE.selectedModelKey;
try {
btnModelReingest.disabled = true;
if (modelReingestProgress) modelReingestProgress.style.display = 'flex';
if (modelReingestFill) modelReingestFill.style.width = '0%';
if (modelReingestLabel) modelReingestLabel.textContent = `Loading model ${key}...`;
const job = await STATE.provider.reingest(key);
modelReingestProgress.dataset.jobId = job.job_id;
await pollReingest(job.job_id);
} catch (e) {
if (modelReingestLabel) modelReingestLabel.textContent = `❌ ${e.message}`;
refreshModelCacheStatus();
}
};
}
if (btnModelReingestCancel) {
btnModelReingestCancel.onclick = async () => {
const jobId = modelReingestProgress && modelReingestProgress.dataset.jobId;
if (jobId && STATE.provider) {
try { await STATE.provider.reingestCancel(jobId); } catch (_) {}
}
};
}
if (btnModelCompare) {
btnModelCompare.onclick = async () => {
const file = currentReliefFilename();
if (!file) return;
const cachedIn = (STATE.filesModels && STATE.filesModels[file]) || [];
if (cachedIn.length < 2) {
showCustomModal({
title: "Multi-model Comparison",
contentHTML: `This document is cached under a single model (${cachedIn.join(', ') || '—'}).
` +
`Re-ingest the same PDF under another model to compare them side by side.
`,
confirmText: "OK", isAlert: true
});
return;
}
try {
// Cargar el MISMO documento bajo cada modelo cacheado (lee cache, sin
// cargar modelos en RAM) y dibujarlos lado a lado con el Modo A.
for (const key of cachedIn) {
if (STATE.featureSpace === 'SAE') break; // SAE vive sobre el espacio base
const md = await STATE.provider.getReliefMatrix(file, 'RAW', key);
if (md && md.matrix && md.matrix.length) {
md.filename = md.filename || file;
md.model_key = key;
addReliefDoc(md);
}
}
STATE.comparisonMode = 'A';
const cmSel = document.getElementById('comparison-mode-selector');
if (cmSel) cmSel.value = 'A';
ctx.refresh.comparisonModeUI?.();
ctx.refresh.docList?.();
applyComparison();
} catch (e) {
showCustomModal({
title: "Error", contentHTML: `${e.message}
`,
confirmText: "OK", isAlert: true
});
}
};
}
// Update model cache status when the user picks a different PDF.
if (reliefFileSelector) {
reliefFileSelector.addEventListener('change', refreshModelCacheStatus);
}
// Populate the multi-model panel (models may already be loaded by the provider).
refreshModelPanel();
refreshUiThemeLabel();
if (btnUiThemeCycle) {
btnUiThemeCycle.onclick = () => {
cycleUiTheme();
refreshUiThemeLabel();
};
}
}