// 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 `
active: --
style: --
⚠️ X axes (dimensions) are not comparable across models: comparison is of STRUCTURE / silhouette, not cell by cell.
`; } /** * 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(); }; } }