Spaces:
Sleeping
Sleeping
| // 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 ` | |
| <!-- Modelo de embeddings (Fase 4, experimental). Eje independiente del toggle RAW/SAE. --> | |
| <div id="model-compare-section" style="display:flex; flex-direction:column; gap:3px; margin-top: var(--spacing-xs); padding-top: var(--spacing-xs); border-top: 1px solid oklch(from var(--color-neutral-50) l c h / 0.05);"> | |
| <div style="display:flex; justify-content:space-between; align-items:center;"> | |
| <label style="font-size: var(--font-size-caption); color: var(--color-neutral-400); display:inline-flex; align-items:center; gap:4px;"> | |
| Model (exp.): | |
| <span class="tooltip-trigger" style="cursor:pointer; display:inline-flex; justify-content:center; align-items:center; width:12px; height:12px; border-radius:50%; background:oklch(from var(--color-neutral-50) l c h / 0.15); font-size:8px; font-weight:bold;" title="Each model is its own vector space (mpnet 768D, minilm 384D). Switching models requires re-ingesting the PDF under that model. X dimensions are NOT comparable across models.">i</span> | |
| </label> | |
| <span id="model-active-badge" style="font-size:9px; font-family:var(--font-mono); color:var(--color-brand-secondary);">active: --</span> | |
| </div> | |
| <div id="ui-theme-row" style="display:flex; justify-content:space-between; align-items:center; gap:6px;"> | |
| <span id="ui-theme-label" style="font-size:9px; font-family:var(--font-mono); color:var(--color-neutral-400); flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="Current UI theme">style: --</span> | |
| <button id="btn-ui-theme-cycle" type="button" class="btn btn-secondary" title="Cycle UI theme (1–10)" aria-label="Cycle UI theme" style="font-size: var(--font-size-small); min-height:22px; padding:0 8px; flex-shrink:0;">🖥</button> | |
| </div> | |
| <select id="model-selector" class="input-field" style="background: oklch(from var(--color-neutral-950) l c h / 0.5);"></select> | |
| <div id="model-axes-note" style="font-size:9px; color:var(--color-semantic-warning); line-height:1.3;">⚠️ X axes (dimensions) are not comparable across models: comparison is of STRUCTURE / silhouette, not cell by cell.</div> | |
| <div id="model-cache-status" style="font-size:9px; color:var(--color-neutral-400); min-height:12px; font-family:var(--font-mono);"></div> | |
| <div style="display:flex; gap:4px;"> | |
| <button id="btn-model-reingest" class="btn btn-secondary" style="flex:1; font-size: var(--font-size-small);">🔁 Re-ingest</button> | |
| <button id="btn-model-compare" class="btn btn-accent" style="flex:1; font-size: var(--font-size-small); font-weight:bold;">⚖️ Compare</button> | |
| </div> | |
| <div id="model-reingest-progress" style="display:none; flex-direction:column; gap:2px; margin-top:2px;"> | |
| <div class="ingest-track"><div id="model-reingest-fill" class="ingest-fill" style="width:0%"></div></div> | |
| <div style="display:flex; justify-content:space-between; align-items:center;"> | |
| <span id="model-reingest-label" style="font-size:9px; color:var(--color-neutral-400); font-family:var(--font-mono);"></span> | |
| <button id="btn-model-reingest-cancel" class="btn btn-danger" style="font-size:9px; min-height:18px; padding:0 6px;">Cancel</button> | |
| </div> | |
| </div> | |
| </div>`; | |
| } | |
| /** | |
| * 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: `<p>This document is cached under a single model (${cachedIn.join(', ') || '—'}).</p>` + | |
| `<p>Re-ingest the same PDF under another model to compare them side by side.</p>`, | |
| 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: `<p style="color:var(--color-semantic-error);">${e.message}</p>`, | |
| 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(); | |
| }; | |
| } | |
| } | |