feat: model comparison viewer — side-by-side 8B / 14B / 36B inference
Some checks failed
CI / validate (pull_request) Failing after 8s
CI / auto-merge (pull_request) Has been skipped

Adds a full-screen overlay panel allowing side-by-side comparison of
three model sizes (8B, 14B, 36B). Key features:

- Three-column grid: each column shows model label, badge (FAST /
  BALANCED / DEEP), live metrics (tok/s, latency ms, VRAM GB), and a
  typewriter-streamed response output
- Prompt input row with RUN button; Enter key also triggers inference
- Simulated inference with per-model latency stagger and character-stream
  speed matching realistic tok/s baselines
- [M] keyboard shortcut toggles the panel; Escape closes it
- HUD ⚖ button in the top-right control strip
- Matches existing dark-space holographic theme and CSS design system
- Hides cleanly in photo mode; no interference with sovereignty buffer

Fixes #282

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-24 00:56:49 -04:00
parent beee17f43c
commit 5ad6f9fd62
3 changed files with 492 additions and 0 deletions

204
app.js
View File

@@ -1453,6 +1453,210 @@ document.addEventListener('keydown', (e) => {
sovereigntyBufferTimer = setTimeout(() => { sovereigntyBuffer = ''; }, 3000);
});
// === MODEL COMPARISON VIEWER ===
// Side-by-side inference viewer for 8B, 14B, and 36B models.
// Uses simulated responses with realistic per-model characteristics
// (speed, latency, memory, verbosity) since there is no local inference backend.
const MODEL_SPECS = {
'8b': { tps: 85, latMs: 210, memGb: 5.2, badgeColor: '#44ddaa' },
'14b': { tps: 52, latMs: 380, memGb: 8.9, badgeColor: '#ffcc44' },
'36b': { tps: 24, latMs: 870, memGb: 22.4, badgeColor: '#ff66aa' },
};
// Canned response fragments keyed by prompt theme — cycled for demo purposes.
const MODEL_RESPONSES = {
'8b': [
'A fast, compact response. The 8B model prioritises throughput over depth, giving quick answers with solid accuracy on common tasks.',
'Short context window and fewer parameters mean this model excels at summarisation, classification, and single-turn Q&A.',
'Inference is snappy — ideal for high-volume pipelines where latency matters more than nuance.',
],
'14b': [
'A balanced 14B model delivers noticeably richer reasoning. It can hold more context and produces more coherent multi-step answers than the 8B tier.',
'Good trade-off between speed and quality: fast enough for interactive use, smart enough for light analytical tasks and code generation.',
'Memory overhead stays under 10 GB, making it deployable on a single consumer GPU with room to spare.',
],
'36b': [
'At 36B parameters the model enters a different reasoning tier. Responses are longer, more nuanced, and better at multi-hop inference — at the cost of roughly 4× the latency of the 8B model.\n\nFor sovereign infrastructure, the extra depth is worth it when correctness matters more than speed.',
'Larger weights capture more rare-domain knowledge. Code generation, legal summarisation, and philosophical dialogue all improve markedly over smaller models.',
'The 36B tier is well-suited for batch workloads where latency is tolerable: nightly analysis, long-document synthesis, or chain-of-thought reasoning pipelines.',
],
};
let modelCompareOpen = false;
let modelCompareRunning = false;
let _modelCompareTimers = [];
const modelComparePanel = document.getElementById('model-compare');
const modelCompareInput = /** @type {HTMLInputElement} */ (document.getElementById('model-compare-input'));
const modelCompareRunBtn = document.getElementById('model-compare-run');
const modelCompareCloseBtn = document.getElementById('model-compare-close');
const modelCompareToggleBtn = document.getElementById('model-compare-toggle');
/**
* Opens or closes the model comparison viewer.
* @param {boolean} [force] - If provided, sets open state explicitly.
*/
function toggleModelCompare(force) {
modelCompareOpen = (force !== undefined) ? force : !modelCompareOpen;
if (modelCompareOpen) {
modelComparePanel.classList.remove('model-compare-hidden');
modelCompareToggleBtn.classList.add('active');
modelCompareInput.focus();
} else {
modelComparePanel.classList.add('model-compare-hidden');
modelCompareToggleBtn.classList.remove('active');
}
}
/**
* Clears all in-progress typewriter timers.
*/
function clearModelTimers() {
_modelCompareTimers.forEach(id => clearTimeout(id));
_modelCompareTimers = [];
}
/**
* Resets all model card outputs to idle state.
*/
function resetModelOutputs() {
clearModelTimers();
modelCompareRunning = false;
modelCompareRunBtn.disabled = false;
for (const model of ['8b', '14b', '36b']) {
const out = document.querySelector(`.model-output[data-output="${model}"]`);
if (out) { out.textContent = ''; out.classList.remove('running'); }
for (const key of ['tps', 'lat', 'mem']) {
const el = document.querySelector(`.model-card[data-model="${model}"] .metric-val[data-key="${key}"]`);
if (el) el.textContent = '—';
}
}
}
/**
* Typewriter-streams text into an element, then calls onDone when finished.
* @param {HTMLElement} el
* @param {string} text
* @param {number} delay - ms between characters
* @param {Function} onDone
*/
function typewriterStream(el, text, delay, onDone) {
let i = 0;
const cursor = document.createElement('span');
cursor.className = 'cursor';
el.appendChild(cursor);
function step() {
if (i < text.length) {
cursor.insertAdjacentText('beforebegin', text[i]);
i++;
const id = setTimeout(step, delay);
_modelCompareTimers.push(id);
} else {
cursor.remove();
if (onDone) onDone();
}
}
step();
}
/**
* Runs a comparison inference for all three model sizes.
*/
function runModelComparison() {
if (modelCompareRunning) return;
const prompt = modelCompareInput.value.trim();
if (!prompt) {
modelCompareInput.focus();
return;
}
resetModelOutputs();
modelCompareRunning = true;
modelCompareRunBtn.disabled = true;
// Pick response index deterministically from prompt length for variety
const idx = prompt.length % 3;
const models = ['8b', '14b', '36b'];
let doneCount = 0;
for (const model of models) {
const spec = MODEL_SPECS[model];
const responseText = MODEL_RESPONSES[model][idx];
// Jitter metrics slightly around baseline
const jitter = () => (Math.random() - 0.5) * 0.1;
const tps = Math.round(spec.tps * (1 + jitter()));
const lat = Math.round(spec.latMs * (1 + jitter()));
const mem = (spec.memGb * (1 + jitter() * 0.5)).toFixed(1);
// Show metrics immediately
const card = document.querySelector(`.model-card[data-model="${model}"]`);
card.querySelector('.metric-val[data-key="tps"]').textContent = tps;
card.querySelector('.metric-val[data-key="lat"]').textContent = lat;
card.querySelector('.metric-val[data-key="mem"]').textContent = mem;
const out = card.querySelector('.model-output');
out.classList.add('running');
// Stagger start by latency to simulate real inference delays
const startDelay = spec.latMs;
const charDelay = Math.round(1000 / spec.tps);
const startId = setTimeout(() => {
typewriterStream(out, responseText, charDelay, () => {
out.classList.remove('running');
doneCount++;
if (doneCount === models.length) {
modelCompareRunning = false;
modelCompareRunBtn.disabled = false;
}
});
}, startDelay);
_modelCompareTimers.push(startId);
}
}
// Wire up controls
if (modelCompareCloseBtn) {
modelCompareCloseBtn.addEventListener('click', () => {
clearModelTimers();
resetModelOutputs();
toggleModelCompare(false);
});
}
if (modelCompareToggleBtn) {
modelCompareToggleBtn.addEventListener('click', () => toggleModelCompare());
}
if (modelCompareRunBtn) {
modelCompareRunBtn.addEventListener('click', runModelComparison);
}
if (modelCompareInput) {
modelCompareInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') runModelComparison();
if (e.key === 'Escape') {
clearModelTimers();
resetModelOutputs();
toggleModelCompare(false);
}
e.stopPropagation(); // prevent sovereignty buffer accumulation
});
}
// [M] key toggles the panel (only when input is not focused)
document.addEventListener('keydown', (e) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (document.activeElement === modelCompareInput) return;
if (e.key === 'm' || e.key === 'M') {
toggleModelCompare();
}
});
window.addEventListener('beforeunload', () => {
wsClient.disconnect();
});

View File

@@ -36,6 +36,9 @@
<button id="export-session" class="chat-toggle-btn" aria-label="Export session as markdown" title="Export session log as Markdown">
📥
</button>
<button id="model-compare-toggle" class="chat-toggle-btn" aria-label="Open model comparison viewer" title="Model comparison viewer [M]">
</button>
<audio id="ambient-sound" src="ambient.mp3" loop></audio>
</div>
@@ -51,6 +54,57 @@
<div id="sovereignty-msg">⚡ SOVEREIGNTY ⚡</div>
<!-- Model Comparison Viewer -->
<div id="model-compare" class="model-compare-hidden" role="dialog" aria-label="Model comparison viewer">
<div id="model-compare-header">
<span id="model-compare-title">MODEL COMPARE — 8B / 14B / 36B</span>
<button id="model-compare-close" aria-label="Close model comparison viewer"></button>
</div>
<div id="model-compare-prompt-row">
<input id="model-compare-input" type="text" placeholder="Enter prompt to compare models..." autocomplete="off" spellcheck="false" />
<button id="model-compare-run">RUN</button>
</div>
<div id="model-compare-grid">
<div class="model-card" data-model="8b">
<div class="model-card-header">
<span class="model-label">8B</span>
<span class="model-badge">FAST</span>
</div>
<div class="model-metrics">
<span class="metric"><span class="metric-val" data-key="tps"></span> tok/s</span>
<span class="metric"><span class="metric-val" data-key="lat"></span> ms</span>
<span class="metric"><span class="metric-val" data-key="mem"></span> GB</span>
</div>
<div class="model-output" data-output="8b"></div>
</div>
<div class="model-card" data-model="14b">
<div class="model-card-header">
<span class="model-label">14B</span>
<span class="model-badge">BALANCED</span>
</div>
<div class="model-metrics">
<span class="metric"><span class="metric-val" data-key="tps"></span> tok/s</span>
<span class="metric"><span class="metric-val" data-key="lat"></span> ms</span>
<span class="metric"><span class="metric-val" data-key="mem"></span> GB</span>
</div>
<div class="model-output" data-output="14b"></div>
</div>
<div class="model-card" data-model="36b">
<div class="model-card-header">
<span class="model-label">36B</span>
<span class="model-badge">DEEP</span>
</div>
<div class="model-metrics">
<span class="metric"><span class="metric-val" data-key="tps"></span> tok/s</span>
<span class="metric"><span class="metric-val" data-key="lat"></span> ms</span>
<span class="metric"><span class="metric-val" data-key="mem"></span> GB</span>
</div>
<div class="model-output" data-output="36b"></div>
</div>
</div>
<div id="model-compare-hint">[M] toggle &nbsp;|&nbsp; [Enter] run</div>
</div>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});

234
style.css
View File

@@ -243,3 +243,237 @@ body.photo-mode #overview-indicator {
50% { opacity: 0.15; }
100% { opacity: 0.05; }
}
/* === MODEL COMPARISON VIEWER === */
#model-compare {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(96vw, 900px);
background: rgba(0, 2, 14, 0.95);
border: 1px solid var(--color-primary);
box-shadow: 0 0 32px rgba(68, 136, 255, 0.25), inset 0 0 20px rgba(68, 136, 255, 0.04);
z-index: 50;
font-family: var(--font-body);
color: var(--color-text);
display: flex;
flex-direction: column;
gap: 0;
animation: model-compare-appear 0.18s ease-out;
}
.model-compare-hidden {
display: none !important;
}
@keyframes model-compare-appear {
from { opacity: 0; transform: translate(-50%, -52%); }
to { opacity: 1; transform: translate(-50%, -50%); }
}
#model-compare-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--color-secondary);
background: rgba(68, 136, 255, 0.06);
}
#model-compare-title {
font-size: 11px;
letter-spacing: 0.18em;
color: var(--color-primary);
text-transform: uppercase;
}
#model-compare-close {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 14px;
cursor: pointer;
padding: 2px 6px;
font-family: var(--font-body);
transition: color 0.15s;
}
#model-compare-close:hover {
color: var(--color-primary);
}
#model-compare-prompt-row {
display: flex;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid rgba(51, 68, 136, 0.5);
}
#model-compare-input {
flex: 1;
background: rgba(0, 6, 20, 0.8);
border: 1px solid var(--color-secondary);
color: var(--color-text);
font-family: var(--font-body);
font-size: 12px;
padding: 6px 10px;
outline: none;
border-radius: 2px;
transition: border-color 0.15s;
}
#model-compare-input:focus {
border-color: var(--color-primary);
}
#model-compare-input::placeholder {
color: var(--color-text-muted);
}
#model-compare-run {
background: var(--color-primary);
color: var(--color-bg);
border: none;
font-family: var(--font-body);
font-size: 11px;
font-weight: bold;
letter-spacing: 0.1em;
padding: 6px 16px;
cursor: pointer;
border-radius: 2px;
transition: background-color 0.15s;
}
#model-compare-run:hover {
background: #6699ff;
}
#model-compare-run:disabled {
background: var(--color-secondary);
color: var(--color-text-muted);
cursor: not-allowed;
}
#model-compare-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0;
}
.model-card {
padding: 10px 12px;
border-right: 1px solid rgba(51, 68, 136, 0.4);
display: flex;
flex-direction: column;
gap: 6px;
min-height: 220px;
}
.model-card:last-child {
border-right: none;
}
.model-card-header {
display: flex;
align-items: center;
gap: 8px;
}
.model-label {
font-size: 15px;
font-weight: bold;
color: var(--color-primary);
letter-spacing: 0.05em;
}
.model-badge {
font-size: 9px;
letter-spacing: 0.12em;
color: var(--color-text-muted);
border: 1px solid rgba(51, 68, 136, 0.6);
padding: 1px 5px;
}
.model-card[data-model="8b"] .model-badge { color: #44ddaa; border-color: #44ddaa44; }
.model-card[data-model="14b"] .model-badge { color: #ffcc44; border-color: #ffcc4444; }
.model-card[data-model="36b"] .model-badge { color: #ff66aa; border-color: #ff66aa44; }
.model-metrics {
display: flex;
gap: 10px;
font-size: 10px;
color: var(--color-text-muted);
letter-spacing: 0.05em;
padding-bottom: 6px;
border-bottom: 1px solid rgba(51, 68, 136, 0.3);
}
.metric-val {
color: var(--color-text);
}
.model-output {
font-size: 11px;
line-height: 1.55;
color: #aabbcc;
flex: 1;
white-space: pre-wrap;
word-break: break-word;
min-height: 120px;
}
.model-output.running {
color: #ccd6f6;
}
.model-output .cursor {
display: inline-block;
width: 6px;
height: 11px;
background: var(--color-primary);
vertical-align: text-bottom;
animation: blink-cursor 0.7s step-end infinite;
}
@keyframes blink-cursor {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
#model-compare-hint {
font-size: 9px;
letter-spacing: 0.12em;
color: var(--color-text-muted);
text-align: center;
padding: 6px 12px;
border-top: 1px solid rgba(51, 68, 136, 0.4);
}
/* Hide during photo mode */
body.photo-mode #model-compare {
display: none !important;
}
#model-compare-toggle {
margin-left: 8px;
background-color: var(--color-secondary);
color: var(--color-text);
padding: 4px 8px;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
font-family: var(--font-body);
transition: background-color 0.2s ease;
}
#model-compare-toggle:hover {
background-color: var(--color-primary);
color: var(--color-bg);
}
#model-compare-toggle.active {
background-color: var(--color-primary);
color: var(--color-bg);
}