llm-semantic-visualizer / src /ui /panels /ModelPanel.js
hbauzan's picture
Release v4.17.7 — sync from GitHub main
819a47f
Raw
History Blame Contribute Delete
14.3 kB
// 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();
};
}
}