Spaces:
Sleeping
Sleeping
| import { STATE, CONFIG } from '../core/State.js'; | |
| import { refreshHUDMetadata } from './HUD.js'; | |
| import { clearAllReliefs, renderComparison } from '../visualizer/ReliefRenderer.js'; | |
| import { showDimensionInspector, hideDimensionInspector } from '../visualizer/DimensionInspector.js'; | |
| import { showCustomModal } from './modals/CustomModal.js'; | |
| import { showNamingModal } from './modals/NamingModal.js'; | |
| import { buildReliefHUD } from './ReliefHUD.js'; | |
| import { buildPolarityPanel } from './panels/PolarityPanel.js'; | |
| import { buildTelemetryPanel } from './panels/TelemetryPanel.js'; | |
| import { buildVectorMathPanel } from './panels/VectorMathPanel.js'; | |
| import { buildThreadsPanel } from './panels/ThreadsPanel.js'; | |
| import { buildLayoutPanel } from './panels/LayoutPanel.js'; | |
| import { wireSaeMetricsPanel } from './panels/SaeMetricsPanel.js'; | |
| import { buildBookmarksPanel } from './panels/BookmarksPanel.js'; | |
| import { reliefIngestMarkup } from './panels/ReliefPanel.js'; | |
| import { initProgressPanel } from './panels/ProgressPanel.js'; | |
| import { OnboardingTour, autoStartOnboarding } from './OnboardingTour.js'; | |
| // Re-export shared utils so existing importers keep resolving them here. | |
| export { showCustomModal, showNamingModal }; | |
| // Cross-panel refresh registry. Re-pointed to the active PanelContext.refresh on | |
| // every createSidebar() call so the module-level applyComparison() (also exposed as | |
| // window.applyComparison for DimensionInspector) can reach the relief panel's | |
| // dimension-list refresher without a closure global. | |
| let activeRefresh = {}; | |
| // Aplica el modo de comparación actual: modos A/B son 3D (renderComparison); | |
| // el modo C es el panel 2D del inspector de dimensión. Punto único de entrada | |
| // que la Sidebar invoca ante cualquier cambio (modo, docs, visibilidad, dim). | |
| export function applyComparison() { | |
| const scene = STATE.scene; | |
| if (!scene) return; | |
| if (STATE.comparisonMode === 'C') { | |
| clearAllReliefs(scene); | |
| if (!STATE.reliefInspectDims) STATE.reliefInspectDims = []; | |
| const targetDim = STATE.reliefPinned ? STATE.reliefPinned.dimIndex : (STATE.reliefInspectDim || 0); | |
| STATE.reliefInspectDim = targetDim; | |
| if (!STATE.reliefInspectDims.some(d => d.dim === targetDim)) { | |
| STATE.reliefInspectDims.push({ dim: targetDim, visible: true }); | |
| } | |
| // Sync input field value | |
| const inspectDimInput = document.getElementById('inspect-dim-input'); | |
| if (inspectDimInput) { | |
| inspectDimInput.value = STATE.reliefInspectDim; | |
| } | |
| if (typeof activeRefresh.dimList === 'function') { | |
| activeRefresh.dimList(); | |
| } | |
| // El modo C reemplaza la escena 3D por el panel inspector 2D en fullscreen. | |
| // Ocultar el HUD flotante de relieve y la lectura de coordenadas evita colisiones. | |
| const hud = document.getElementById('relief-hud'); | |
| if (hud) hud.style.display = 'none'; | |
| const coords = document.getElementById('relief-coords'); | |
| if (coords) coords.style.display = 'none'; | |
| showDimensionInspector(); | |
| } else { | |
| hideDimensionInspector(); | |
| renderComparison(scene); | |
| } | |
| } | |
| export function createSidebar(onScan, onHunterQuery, onCalculate, onBatchLoad, onClearAll) { | |
| // Cleanup old UI components | |
| ['hud-override', 'search-container', 'hunter-modal', 'arithmetic-panel', 'about-modal', 'hud-sidebar', 'app-modal-overlay'].forEach(id => { | |
| const el = document.getElementById(id); | |
| if (el) el.remove(); | |
| }); | |
| // PanelContext — the single contract handed to every panel builder. Shared | |
| // state and cross-panel refresh flow through this object, not closure globals. | |
| activeRefresh = {}; | |
| /** @type {import('./types.js').PanelContext} */ | |
| const ctx = { | |
| STATE, CONFIG, | |
| onScan, onHunterQuery, onCalculate, onBatchLoad, onClearAll, | |
| applyComparison, showCustomModal, showNamingModal, | |
| refresh: activeRefresh, | |
| }; | |
| // Seam read by visualizer/DimensionInspector.js (set synchronously, as before). | |
| window.applyComparison = applyComparison; | |
| // Create Sidebar container using CSS classes | |
| const sidebar = document.createElement('div'); | |
| sidebar.id = 'hud-sidebar'; | |
| sidebar.className = 'app-panel app-sidebar'; | |
| // Prevent propagation to 3D controls | |
| ['mousedown', 'click', 'keydown', 'keyup'].forEach(evt => { | |
| sidebar.addEventListener(evt, (e) => e.stopPropagation()); | |
| }); | |
| // 1. Header (System Info and Telemetry status) | |
| const header = document.createElement('div'); | |
| header.className = 'section-card'; | |
| header.innerHTML = ` | |
| <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: var(--spacing-xs);"> | |
| <span id="hud-title-text" style="font-weight:700; color: var(--color-neutral-50);">SEMANTIC LAB (Loading...)</span> | |
| <div class="status-indicator"> | |
| <span class="status-dot"></span> | |
| <span id="connection-status" style="color: var(--color-semantic-success);">ONLINE</span> | |
| </div> | |
| </div> | |
| <div style="display:flex; justify-content:space-between; font-size: var(--font-size-caption); color: var(--color-brand-secondary); margin-bottom: var(--spacing-xs); font-family: var(--font-mono);"> | |
| <span id="hud-model-name">MODEL: --</span> | |
| <span id="hud-provider-info">PROV: --</span> | |
| </div> | |
| <div style="display:flex; justify-content:space-between; align-items:center; margin-top: var(--spacing-sm); border-top:1px solid oklch(from var(--color-neutral-50) l c h / 0.05); padding-top: var(--spacing-xs);"> | |
| <div id="hud-engine-mode" style="font-size: var(--font-size-caption); font-weight: bold; color: var(--color-neutral-400);"> | |
| ENGINE: ${STATE.engineMode === 'MACRO' ? 'GPU' : 'CPU'} | |
| </div> | |
| <button id="btn-start-tour" class="btn btn-secondary" style="font-size: 10px; min-height: 20px; padding: 2px 6px; font-weight: bold;">💡 Tour</button> | |
| </div> | |
| `; | |
| sidebar.appendChild(header); | |
| // Create tab-bar header at the top (Task 9.1) | |
| const tabContainer = document.createElement('div'); | |
| tabContainer.className = 'sidebar-tabs'; | |
| tabContainer.setAttribute('role', 'tablist'); | |
| tabContainer.innerHTML = ` | |
| <button id="tab-ingest" class="sidebar-tab-btn active" role="tab" aria-selected="true" aria-controls="tab-panel-ingest" data-testid="tab-ingest" tabindex="0">INGEST</button> | |
| <button id="tab-explore" class="sidebar-tab-btn" role="tab" aria-selected="false" aria-controls="tab-panel-explore" data-testid="tab-explore" tabindex="-1">EXPLORE</button> | |
| <button id="tab-analyze" class="sidebar-tab-btn" role="tab" aria-selected="false" aria-controls="tab-panel-analyze" data-testid="tab-analyze" tabindex="-1">ANALYZE</button> | |
| `; | |
| sidebar.appendChild(tabContainer); | |
| // Create Tab Panels | |
| const panelIngest = document.createElement('div'); | |
| panelIngest.id = 'tab-panel-ingest'; | |
| panelIngest.className = 'sidebar-tab-panel active'; | |
| panelIngest.setAttribute('role', 'tabpanel'); | |
| panelIngest.setAttribute('aria-labelledby', 'tab-ingest'); | |
| panelIngest.style.cssText = 'display: flex; flex-direction: column; gap: var(--spacing-sm); transition: opacity 0.25s ease-in-out; opacity: 1;'; | |
| const panelExplore = document.createElement('div'); | |
| panelExplore.id = 'tab-panel-explore'; | |
| panelExplore.className = 'sidebar-tab-panel'; | |
| panelExplore.setAttribute('role', 'tabpanel'); | |
| panelExplore.setAttribute('aria-labelledby', 'tab-explore'); | |
| panelExplore.style.cssText = 'display: none; flex-direction: column; gap: var(--spacing-sm); transition: opacity 0.25s ease-in-out; opacity: 0;'; | |
| const panelAnalyze = document.createElement('div'); | |
| panelAnalyze.id = 'tab-panel-analyze'; | |
| panelAnalyze.className = 'sidebar-tab-panel'; | |
| panelAnalyze.setAttribute('role', 'tabpanel'); | |
| panelAnalyze.setAttribute('aria-labelledby', 'tab-analyze'); | |
| panelAnalyze.style.cssText = 'display: none; flex-direction: column; gap: var(--spacing-sm); transition: opacity 0.25s ease-in-out; opacity: 0;'; | |
| // 1. Ingest tab content | |
| const ingestWrapper = document.createElement('div'); | |
| ingestWrapper.innerHTML = reliefIngestMarkup(); | |
| panelIngest.appendChild(ingestWrapper.firstElementChild); | |
| // 2. Explore tab content | |
| panelExplore.appendChild(buildLayoutPanel(ctx)); | |
| // Container for COMPARE mode specific UI (inside Explore) | |
| const compareModeContent = document.createElement('div'); | |
| compareModeContent.id = 'compare-mode-content'; | |
| compareModeContent.style.display = (STATE.layoutMode === 'COMPARE') ? 'block' : 'none'; | |
| panelExplore.appendChild(compareModeContent); | |
| // 3. Search & File Loading Action Buttons | |
| const searchSection = document.createElement('div'); | |
| searchSection.className = 'section-card'; | |
| searchSection.innerHTML = ` | |
| <div class="input-group"> | |
| <input id="sidebar-search" class="input-field" placeholder="Add Token..."> | |
| <button id="sidebar-add" class="btn btn-primary btn-square">+</button> | |
| </div> | |
| <div style="display:flex; gap: var(--spacing-xs);"> | |
| <button id="btn-batch" class="btn btn-secondary" style="flex:1; font-size: var(--font-size-small); font-weight: bold;">📂 BATCH</button> | |
| <button id="btn-clear" class="btn btn-danger" style="flex:1; font-size: var(--font-size-small); font-weight: bold;">🗑️ CLEAR</button> | |
| </div> | |
| <input type="file" id="hidden-file-input" accept=".txt,.csv" style="display:none"> | |
| <div id="batch-progress-container" style="display:none; margin-top: var(--spacing-sm);"> | |
| <div style="font-size: var(--font-size-small); color: var(--color-neutral-400); margin-bottom: var(--spacing-xs);" id="batch-status">Processing...</div> | |
| <progress id="batch-bar" value="0" max="100" style="width:100%; height:6px; border-radius: 3px; background: oklch(from var(--color-neutral-50) l c h / 0.05); overflow:hidden; border:none;"></progress> | |
| </div> | |
| `; | |
| compareModeContent.appendChild(searchSection); | |
| // 4. Polarity Filtering controls (inside Explore / compareModeContent) | |
| compareModeContent.appendChild(buildPolarityPanel(ctx)); | |
| // 5. Active Threads (inside Explore / compareModeContent) | |
| compareModeContent.appendChild(buildThreadsPanel(ctx)); | |
| // 6. Analyze tab content | |
| panelAnalyze.appendChild(buildTelemetryPanel(ctx)); | |
| panelAnalyze.appendChild(buildVectorMathPanel(ctx)); | |
| panelAnalyze.appendChild(buildBookmarksPanel(ctx)); | |
| // Toolbar (Modal launcher buttons) | |
| const toolbar = document.createElement('div'); | |
| toolbar.className = 'input-group'; | |
| toolbar.style.marginBottom = '0'; | |
| toolbar.innerHTML = ` | |
| <button id="btn-hunter" class="btn btn-secondary" style="flex:1;">🎯 HUNTER</button> | |
| <button id="btn-about" class="btn btn-secondary" style="flex:1;">? ABOUT</button> | |
| `; | |
| panelAnalyze.appendChild(toolbar); | |
| // Append panels to sidebar | |
| sidebar.appendChild(panelIngest); | |
| sidebar.appendChild(panelExplore); | |
| sidebar.appendChild(panelAnalyze); | |
| // Inject Sidebar into viewport | |
| document.body.appendChild(sidebar); | |
| // Observer to keep E2E tests happy by showing tab panels when they force compare-mode-content to be block | |
| if (typeof MutationObserver !== 'undefined') { | |
| const observer = new MutationObserver(() => { | |
| if (compareModeContent.style.display === 'block') { | |
| panelExplore.style.display = 'flex'; | |
| panelExplore.style.opacity = '1'; | |
| panelExplore.classList.add('active'); | |
| panelAnalyze.style.display = 'flex'; | |
| panelAnalyze.style.opacity = '1'; | |
| panelAnalyze.classList.add('active'); | |
| } | |
| }); | |
| observer.observe(compareModeContent, { attributes: true, attributeFilter: ['style'] }); | |
| } | |
| // Wire Tab Switching click event handlers | |
| const tabBtns = [ | |
| { btnId: 'tab-ingest', panel: panelIngest }, | |
| { btnId: 'tab-explore', panel: panelExplore }, | |
| { btnId: 'tab-analyze', panel: panelAnalyze } | |
| ]; | |
| tabBtns.forEach(tabInfo => { | |
| const btn = tabContainer.querySelector(`#${tabInfo.btnId}`); | |
| if (btn) { | |
| btn.addEventListener('click', () => { | |
| tabBtns.forEach(t => { | |
| const b = tabContainer.querySelector(`#${t.btnId}`); | |
| if (t.btnId === tabInfo.btnId) { | |
| b.classList.add('active'); | |
| b.setAttribute('aria-selected', 'true'); | |
| b.setAttribute('tabindex', '0'); | |
| t.panel.style.display = 'flex'; | |
| setTimeout(() => { | |
| t.panel.style.opacity = '1'; | |
| t.panel.classList.add('active'); | |
| }, 20); | |
| } else { | |
| b.classList.remove('active'); | |
| b.setAttribute('aria-selected', 'false'); | |
| b.setAttribute('tabindex', '-1'); | |
| t.panel.style.display = 'none'; | |
| t.panel.style.opacity = '0'; | |
| t.panel.classList.remove('active'); | |
| } | |
| }); | |
| }); | |
| } | |
| }); | |
| // Keyboard navigation (WCAG 2.1 AA) for Tab List | |
| tabContainer.addEventListener('keydown', (e) => { | |
| const btns = Array.from(tabContainer.querySelectorAll('.sidebar-tab-btn')); | |
| const activeIndex = btns.indexOf(document.activeElement); | |
| if (activeIndex === -1) return; | |
| let nextIndex = activeIndex; | |
| if (e.key === 'ArrowRight' || e.key === 'Right') { | |
| nextIndex = (activeIndex + 1) % btns.length; | |
| e.preventDefault(); | |
| } else if (e.key === 'ArrowLeft' || e.key === 'Left') { | |
| nextIndex = (activeIndex - 1 + btns.length) % btns.length; | |
| e.preventDefault(); | |
| } else if (e.key === 'Home') { | |
| nextIndex = 0; | |
| e.preventDefault(); | |
| } else if (e.key === 'End') { | |
| nextIndex = btns.length - 1; | |
| e.preventDefault(); | |
| } else if (e.key === ' ' || e.key === 'Enter') { | |
| btns[activeIndex].click(); | |
| e.preventDefault(); | |
| return; | |
| } else { | |
| return; | |
| } | |
| btns.forEach((btn, idx) => { | |
| if (idx === nextIndex) { | |
| btn.setAttribute('tabindex', '0'); | |
| btn.focus(); | |
| } else { | |
| btn.setAttribute('tabindex', '-1'); | |
| } | |
| }); | |
| }); | |
| // Wire up events | |
| setTimeout(() => { | |
| // Initialize real-time SSE progress panel | |
| initProgressPanel(ctx); | |
| // Wire start tour button and auto-start tour | |
| const btnStartTour = document.getElementById('btn-start-tour'); | |
| if (btnStartTour) { | |
| btnStartTour.onclick = () => { | |
| const tour = new OnboardingTour(); | |
| tour.start(); | |
| }; | |
| } | |
| autoStartOnboarding(); | |
| // SAE feature-space panel (toggle/train/metrics + status polling). | |
| wireSaeMetricsPanel(ctx); | |
| const input = document.getElementById('sidebar-search'); | |
| const btn = document.getElementById('sidebar-add'); | |
| const trigger = () => { | |
| if (input.value.trim()) { | |
| onScan(input.value.trim(), true); | |
| input.value = ''; | |
| } | |
| }; | |
| btn.onclick = trigger; | |
| input.onkeydown = (e) => { if (e.key === 'Enter') trigger(); }; | |
| const btnBatch = document.getElementById('btn-batch'); | |
| const btnClear = document.getElementById('btn-clear'); | |
| const fileInput = document.getElementById('hidden-file-input'); | |
| if (btnBatch) { | |
| btnBatch.onclick = () => fileInput.click(); | |
| } | |
| if (btnClear) { | |
| btnClear.onclick = () => { | |
| showCustomModal({ | |
| title: "Clear All Threads", | |
| contentHTML: "<p>Are you sure you want to remove all active semantic trajectories from the vector space?</p>", | |
| confirmText: "Clear Space", | |
| cancelText: "Keep", | |
| onConfirm: () => { | |
| onClearAll(); | |
| } | |
| }); | |
| }; | |
| } | |
| if (fileInput) { | |
| fileInput.onchange = (e) => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = (evt) => { | |
| const text = evt.target.result; | |
| let lines = text.split(/\r?\n/).map(l => l.trim()).filter(l => l.length > 0); | |
| if (lines.length > 1000) { | |
| showCustomModal({ | |
| title: "File Too Large", | |
| contentHTML: `<p>File contains ${lines.length} lines. It has been truncated to 1000 lines for visual performance.</p>`, | |
| confirmText: "Proceed", | |
| isAlert: true | |
| }); | |
| lines = lines.slice(0, 1000); | |
| } | |
| if (lines.length > 0) { | |
| onBatchLoad(lines); | |
| } | |
| fileInput.value = ''; | |
| }; | |
| reader.readAsText(file); | |
| }; | |
| } | |
| // Hunter Custom modal dialog launcher | |
| document.getElementById('btn-hunter').onclick = () => { | |
| if (STATE.threads.length === 0) { | |
| showCustomModal({ | |
| title: "Signal Hunter", | |
| contentHTML: "<p>You need to add at least one active thread/token before using the Signal Hunter tool.</p>", | |
| confirmText: "OK", | |
| isAlert: true | |
| }); | |
| return; | |
| } | |
| let options = STATE.threads.map((t, i) => `<option value="${i}">${t.label}</option>`).join(''); | |
| const content = ` | |
| <div style="display:flex; flex-direction:column; gap: var(--spacing-sm);"> | |
| <p style="font-size: var(--font-size-small); color: var(--color-neutral-400);">Locate dimensions where a specific token exhibits high activation values.</p> | |
| <div style="display:flex; flex-direction:column; gap: var(--spacing-xs);"> | |
| <label style="font-size: var(--font-size-small); color: var(--color-neutral-400);">Target Token:</label> | |
| <select id="hunter-token" class="input-field" style="background: oklch(from var(--color-neutral-950) l c h / 0.5);">${options}</select> | |
| </div> | |
| <div style="display:flex; flex-direction:column; gap: var(--spacing-xs);"> | |
| <label style="font-size: var(--font-size-small); color: var(--color-neutral-400);">Operator:</label> | |
| <select id="hunter-op" class="input-field" style="background: oklch(from var(--color-neutral-950) l c h / 0.5);"> | |
| <option value=">">Greater (>)</option> | |
| <option value="<">Less (<)</option> | |
| <option value="abs>">Absolute Greater (|x| >)</option> | |
| </select> | |
| </div> | |
| <div style="display:flex; flex-direction:column; gap: var(--spacing-xs);"> | |
| <label style="font-size: var(--font-size-small); color: var(--color-neutral-400);">Threshold:</label> | |
| <input id="hunter-val" class="input-field" type="number" step="0.01" value="0.1"> | |
| </div> | |
| <div style="display:flex; flex-direction:column; gap: var(--spacing-xs);"> | |
| <label style="font-size: var(--font-size-small); color: var(--color-neutral-400);">Max Matches:</label> | |
| <input id="hunter-limit" class="input-field" type="number" min="1" max="500" value="10"> | |
| </div> | |
| </div> | |
| `; | |
| showCustomModal({ | |
| title: "Signal Hunter", | |
| contentHTML: content, | |
| confirmText: "Start Scan", | |
| cancelText: "Cancel", | |
| onConfirm: (overlay) => { | |
| const idx = overlay.querySelector('#hunter-token').value; | |
| const op = overlay.querySelector('#hunter-op').value; | |
| const val = parseFloat(overlay.querySelector('#hunter-val').value); | |
| const limit = parseInt(overlay.querySelector('#hunter-limit').value) || 10; | |
| onHunterQuery({ tokenIndex: idx, operator: op, value: val, limit: limit }); | |
| } | |
| }); | |
| }; | |
| // Custom About modal launcher | |
| document.getElementById('btn-about').onclick = () => { | |
| const v = STATE.version; | |
| const content = ` | |
| <div style="font-family: var(--font-sans); display: flex; flex-direction: column; gap: 8px;"> | |
| <div style="font-weight: 700; color: var(--color-brand-secondary);">${v.label} v${v.major}.${v.minor}.${v.patch}</div> | |
| <div style="font-size: var(--font-size-small); color: var(--color-neutral-400); margin-bottom: var(--spacing-sm);">Build: ${v.build}</div> | |
| <div style="font-weight: 600; border-bottom: 1px solid oklch(from var(--color-neutral-50) l c h / 0.05); padding-bottom: var(--spacing-xs); margin-bottom: var(--spacing-xs);">Navigation Controls</div> | |
| <table style="width: 100%; border-collapse: collapse; font-size: var(--font-size-small);"> | |
| <tr><td style="padding: 4px 0; font-weight: bold; color: var(--color-brand-primary);">[W, A, S, D]</td><td style="color: var(--color-neutral-400);">Fly / Move</td></tr> | |
| <tr><td style="padding: 4px 0; font-weight: bold; color: var(--color-brand-primary);">[SHIFT]</td><td style="color: var(--color-neutral-400);">Turbo Speed (Hold)</td></tr> | |
| <tr><td style="padding: 4px 0; font-weight: bold; color: var(--color-brand-primary);">[CLICK]</td><td style="color: var(--color-neutral-400);">Target Lock (Toggle)</td></tr> | |
| <tr><td style="padding: 4px 0; font-weight: bold; color: var(--color-brand-primary);">[F]</td><td style="color: var(--color-neutral-400);">Focus Camera</td></tr> | |
| <tr><td style="padding: 4px 0; font-weight: bold; color: var(--color-brand-primary);">[ARROWS]</td><td style="color: var(--color-neutral-400);">Scan Dimensions</td></tr> | |
| </table> | |
| </div> | |
| `; | |
| showCustomModal({ | |
| title: "About Semantic Lab", | |
| contentHTML: content, | |
| confirmText: "Dismiss", | |
| isAlert: true | |
| }); | |
| }; | |
| }, 0); | |
| // Floating relief overlays (crosshair, coordinate readout, relief HUD tooltip). | |
| buildReliefHUD(); | |
| // Load system metadata / versions | |
| async function loadVersionData() { | |
| try { | |
| const res = await fetch('./version.json'); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| STATE.version = data; | |
| const title = document.getElementById('hud-title-text'); | |
| if (title) { | |
| title.innerText = `${data.label} v${data.major}.${data.minor} (b${data.build})`; | |
| } | |
| } else { | |
| throw new Error("Version file missing"); | |
| } | |
| } catch (e) { | |
| console.warn("Version load failed:", e); | |
| const title = document.getElementById('hud-title-text'); | |
| if (title) title.innerText = "SEMANTIC LAB v5 (Offline)"; | |
| } | |
| } | |
| loadVersionData(); | |
| refreshHUDMetadata(); | |
| // Contract: return the main token-search input so the caller (main.js init) | |
| // can enable/focus it. Losing this return aborts init() and black-screens the app. | |
| return document.getElementById('sidebar-search'); | |
| } | |