feat: model comparison viewer — side-by-side 8B / 14B / 36B inference
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:
204
app.js
204
app.js
@@ -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();
|
||||
});
|
||||
|
||||
54
index.html
54
index.html
@@ -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 | [Enter] run</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
|
||||
234
style.css
234
style.css
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user