← back to Model Arena
model-arena: arena server + neon UI — challenges, per-host ollama queue, opt-in metered adapters, votes + win-rate ledger, sort+density, basic auth
e2ce01534bb852e156bba567e3dc04371d1ebe00 · 2026-07-22 22:02:30 -0700 · Steve
Files touched
M BRIEF.mdD README.mdD data/products.jsonM public/index.htmlM server.js
Diff
commit e2ce01534bb852e156bba567e3dc04371d1ebe00
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 22 22:02:30 2026 -0700
model-arena: arena server + neon UI — challenges, per-host ollama queue, opt-in metered adapters, votes + win-rate ledger, sort+density, basic auth
---
BRIEF.md | 28 ++++-
README.md | 19 ---
data/products.json | 6 -
public/index.html | 362 +++++++++++++++++++++++++++++++++++++++++++++--------
server.js | 325 ++++++++++++++++++++++++++++++++++++++++++-----
5 files changed, 628 insertions(+), 112 deletions(-)
diff --git a/BRIEF.md b/BRIEF.md
index c167b3a..1b16cfe 100644
--- a/BRIEF.md
+++ b/BRIEF.md
@@ -1,5 +1,25 @@
# model-arena — brief
-- Client:
-- Outcome / "high value":
-- Constraints (brand, domain, deadline):
-- Reference / competitor URLs:
+
+- **Seed:** Julian Goldie SEO (@JulianGoldieSEO) X post, Jul 20 2026
+ (https://x.com/JulianGoldieSEO/status/2079008392811544994) — head-to-head
+ real-world testing of Kimi K3 vs Claude Fable 5 vs GPT 5.6 on build
+ challenges (Skyrim-style open world, Dragon Realm, Neon City driving).
+ Takeaway: *"Don't marry one AI model. Build a workflow where every model
+ does the job it performs best at — trust real-world testing over
+ leaderboard screenshots."*
+- **Client:** internal (Steve) first; productizable later.
+- **Outcome / "high value":** a Model Arena — fan ONE real-world build
+ challenge out to multiple AI models, render each model's single-file HTML
+ artifact side-by-side in sandboxed iframes, crown a winner per challenge,
+ and accumulate a **real-world win-rate ledger per model** over time.
+ First-party benchmark data nobody else has (Codex panel: "defensible moat").
+- **Direction locked by DTD panel 2026-07-22: unanimous 5/5** for the arena
+ (vs an AI-SEO tool or a static SEO landing page). Panel cost ~$0.006.
+- **Constraints:**
+ - $0 by default — local Ollama models (Mac2 localhost + Mac1 192.168.1.133).
+ - Paid model calls (Claude / Kimi / Codex / Grok) are OPT-IN, per-run,
+ cost-estimated up front and cost-logged after.
+ - Demo behind basic auth `admin / DW2024!` (env-overridable `BASIC_AUTH`).
+ - House rules: grid gets sort + density slider; admin cards show created
+ date + time; gitified; zero-dep Node server.
+ - Launch is GATED — nothing deploys or goes public without Steve's go.
diff --git a/README.md b/README.md
deleted file mode 100644
index 9c6f5b4..0000000
--- a/README.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# Project
-
-Scaffolded by the Claude web-dev accelerator. Storefront starter with a
-server-side-sorted product grid + density slider (both persist in localStorage).
-
-## Run
-```bash
-PORT=3900 node server.js # open http://localhost:3900
-BASIC_AUTH="admin:DW2024!" PORT=3900 node server.js # gated
-```
-
-## Fill it in
-- `data/products.json` — array of `{ title, sku, price, hex, image }`.
-- `server.js` `sortProducts()` — add sort modes as needed.
-- `public/index.html` — the grid + controls.
-
-## Preview publicly
-From the accelerator dir: `scripts/preview-tunnel.sh <slug> <port>` stands up
-`<slug>.agentabrams.com` (basic-auth gated). The DNS step is Steve-gated.
diff --git a/data/products.json b/data/products.json
deleted file mode 100644
index 8eee5f2..0000000
--- a/data/products.json
+++ /dev/null
@@ -1,6 +0,0 @@
-[
- { "title": "Sample One", "sku": "SMP-001", "price": 129.00, "hex": "#2b3a55", "image": "" },
- { "title": "Sample Two", "sku": "SMP-002", "price": 89.00, "hex": "#d8c3a5", "image": "" },
- { "title": "Sample Three", "sku": "SMP-003", "price": 210.00, "hex": "#8e8d8a", "image": "" },
- { "title": "Sample Four", "sku": "SMP-004", "price": 54.00, "hex": "#e98074", "image": "" }
-]
diff --git a/public/index.html b/public/index.html
index ceb7ab1..b5ecc07 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,55 +1,315 @@
-<!doctype html><html lang="en"><head><meta charset="utf-8">
-<meta name="viewport" content="width=device-width,initial-scale=1"><title>Storefront starter</title>
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Model Arena — real-world testing over leaderboard screenshots</title>
<style>
- :root{--cols:4}
- *{box-sizing:border-box} body{margin:0;font:15px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a;background:#fafafa}
- header{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:14px 20px;border-bottom:1px solid #e6e6e6;position:sticky;top:0;background:#fff;z-index:5}
- h1{font-size:17px;margin:0;font-weight:700} .spacer{flex:1}
- label{font-size:12px;color:#666;display:flex;gap:6px;align-items:center}
- select,input[type=range]{font:inherit} .count{font-size:12px;color:#999}
- .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px;padding:20px}
- .card{background:#fff;border:1px solid #e6e6e6;border-radius:8px;overflow:hidden}
- .card img{width:100%;aspect-ratio:1/1;object-fit:cover;display:block;background:#f0f0f0}
- .meta{padding:8px 10px} .t{font-size:13px;font-weight:600;margin:0 0 2px} .s{font-size:11px;color:#888}
- .p{font-size:13px;margin-top:4px} .empty{padding:40px;color:#999;text-align:center}
-</style></head><body>
+:root{
+ --bg:#07080f; --panel:#0d0f1c; --panel2:#12152a; --line:#1e2342;
+ --neon:#00e5ff; --neon2:#ff2ea6; --gold:#ffc72e; --ok:#2eff8f; --err:#ff5d5d;
+ --txt:#dfe4ff; --dim:#7d84ad; --cols:3;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+body{background:var(--bg);color:var(--txt);font:14px/1.5 "SF Mono",ui-monospace,Menlo,monospace;min-height:100vh}
+body::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:0;
+ background:radial-gradient(60% 40% at 20% 0%,rgba(0,229,255,.07),transparent 60%),
+ radial-gradient(50% 35% at 85% 10%,rgba(255,46,166,.06),transparent 60%)}
+header{position:sticky;top:0;z-index:5;background:rgba(7,8,15,.92);backdrop-filter:blur(6px);
+ border-bottom:1px solid var(--line);padding:14px 22px;display:flex;align-items:baseline;gap:16px;flex-wrap:wrap}
+h1{font-size:19px;letter-spacing:2px;text-transform:uppercase}
+h1 .a{color:var(--neon)} h1 .b{color:var(--neon2)}
+.tag{color:var(--dim);font-size:12px;font-style:italic}
+nav{margin-left:auto;display:flex;gap:8px}
+nav button{background:none;border:1px solid var(--line);color:var(--dim);padding:6px 14px;cursor:pointer;
+ font:inherit;font-size:12px;letter-spacing:1px;text-transform:uppercase}
+nav button.on{color:var(--neon);border-color:var(--neon);box-shadow:0 0 12px rgba(0,229,255,.25)}
+main{position:relative;z-index:1;max-width:1500px;margin:0 auto;padding:20px 22px}
+.controls{display:flex;gap:14px;align-items:center;flex-wrap:wrap;margin-bottom:16px}
+.controls label{color:var(--dim);font-size:12px}
+select{background:var(--panel);color:var(--txt);border:1px solid var(--line);padding:6px 8px;font:inherit;font-size:12px}
+input[type=range]{accent-color:var(--neon);width:130px}
+.btn{background:linear-gradient(90deg,rgba(0,229,255,.15),rgba(255,46,166,.15));border:1px solid var(--neon);
+ color:var(--neon);padding:8px 18px;cursor:pointer;font:inherit;font-size:12px;letter-spacing:1px;text-transform:uppercase}
+.btn:hover{box-shadow:0 0 14px rgba(0,229,255,.4)}
+.btn.pink{border-color:var(--neon2);color:var(--neon2)}
+.btn[disabled]{opacity:.4;cursor:default;box-shadow:none}
+.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px}
+.card{background:var(--panel);border:1px solid var(--line);padding:14px;cursor:pointer;position:relative}
+.card:hover{border-color:var(--neon);box-shadow:0 0 16px rgba(0,229,255,.15)}
+.card h3{font-size:14px;margin-bottom:6px;color:#fff}
+.when{color:var(--dim);font-size:11px;margin-bottom:8px}
+.chips{display:flex;gap:6px;flex-wrap:wrap}
+.chip{font-size:10px;padding:2px 8px;border:1px solid var(--line);color:var(--dim);border-radius:999px}
+.chip.done{border-color:var(--ok);color:var(--ok)}
+.chip.running{border-color:var(--neon);color:var(--neon);animation:pulse 1.2s infinite}
+.chip.queued{border-color:var(--dim)}
+.chip.error{border-color:var(--err);color:var(--err)}
+.chip.winner{border-color:var(--gold);color:var(--gold);font-weight:700}
+@keyframes pulse{50%{opacity:.35}}
+.empty{color:var(--dim);padding:40px;text-align:center;border:1px dashed var(--line)}
+/* new challenge */
+.form{background:var(--panel);border:1px solid var(--line);padding:20px;max-width:860px}
+.form h2,#detail h2,#board h2{font-size:15px;letter-spacing:2px;text-transform:uppercase;color:var(--neon);margin-bottom:14px}
+.form input[type=text],.form textarea{width:100%;background:var(--panel2);border:1px solid var(--line);color:var(--txt);
+ padding:10px;font:inherit;margin-bottom:12px}
+.form textarea{height:110px;resize:vertical}
+.presets{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}
+.preset{font-size:11px;padding:4px 10px;border:1px solid var(--neon2);color:var(--neon2);border-radius:999px;cursor:pointer}
+.preset:hover{background:rgba(255,46,166,.12)}
+.models{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:8px;margin-bottom:14px}
+.mrow{display:flex;align-items:center;gap:8px;border:1px solid var(--line);padding:8px 10px;font-size:12px}
+.mrow.off{opacity:.45}
+.mrow .cost{margin-left:auto;font-size:11px}
+.mrow .cost.free{color:var(--ok)} .mrow .cost.paid{color:var(--gold)}
+.note{color:var(--dim);font-size:11px;margin-bottom:12px}
+/* detail */
+#detail .meta{color:var(--dim);font-size:12px;margin-bottom:14px;white-space:pre-wrap}
+.arena{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:16px}
+.pane{background:var(--panel);border:1px solid var(--line)}
+.pane.winner{border-color:var(--gold);box-shadow:0 0 18px rgba(255,199,46,.25)}
+.pane .bar{display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--line);font-size:12px}
+.pane .bar .name{color:#fff;font-weight:700}
+.pane .bar .stat{color:var(--dim);font-size:11px}
+.pane .bar .right{margin-left:auto;display:flex;gap:6px}
+.pane .bar button{font-size:10px;padding:3px 10px}
+.pane iframe{width:100%;height:460px;border:0;background:#000;display:block}
+.pane .msg{height:460px;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:12px;padding:20px;text-align:center}
+/* leaderboard */
+table{width:100%;border-collapse:collapse;font-size:13px}
+th,td{padding:10px 12px;border-bottom:1px solid var(--line);text-align:left}
+th{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:1px}
+tr.first td{color:var(--gold)}
+.back{color:var(--neon);cursor:pointer;font-size:12px;margin-bottom:12px;display:inline-block}
+.view{display:none}.view.on{display:block}
+</style>
+</head>
+<body>
<header>
- <h1>Storefront starter</h1>
- <label>Sort
- <select id="sort">
- <option value="newest">Newest</option>
- <option value="title">Title A→Z</option>
- <option value="sku">SKU A→Z</option>
- <option value="price-asc">Price ↑</option>
- <option value="price-desc">Price ↓</option>
- <option value="light">Light → Dark</option>
- <option value="dark">Dark → Light</option>
- </select>
- </label>
- <label>Density <input id="density" type="range" min="2" max="8" value="4"></label>
- <span class="spacer"></span><span class="count" id="count"></span>
+ <h1><span class="a">MODEL</span> <span class="b">ARENA</span></h1>
+ <span class="tag">"Don't marry one AI model — trust real-world testing over leaderboard screenshots."</span>
+ <nav>
+ <button id="nav-challenges" class="on">Challenges</button>
+ <button id="nav-new">+ New Battle</button>
+ <button id="nav-board">Leaderboard</button>
+ </nav>
</header>
-<div class="grid" id="grid"><div class="empty">Loading…</div></div>
+<main>
+ <section id="challenges" class="view on">
+ <div class="controls">
+ <label>Sort
+ <select id="sort">
+ <option value="newest">Newest</option>
+ <option value="oldest">Oldest</option>
+ <option value="title">Title A→Z</option>
+ <option value="models">Most models</option>
+ <option value="winner">Decided first</option>
+ </select>
+ </label>
+ <label>Density <input type="range" id="density" min="1" max="6" step="1"></label>
+ <span id="count" class="tag"></span>
+ </div>
+ <div id="list" class="grid"></div>
+ </section>
+
+ <section id="new" class="view">
+ <div class="form">
+ <h2>New Battle</h2>
+ <input type="text" id="f-title" placeholder="Title (e.g. Neon City Driving)">
+ <div class="presets" id="presets"></div>
+ <textarea id="f-prompt" placeholder="The real-world build challenge, e.g. 'Build a playable neon-city driving game in a single HTML file…'"></textarea>
+ <div class="models" id="model-picks"></div>
+ <div class="note">Local models are $0 and pre-checked. Metered models are opt-in per battle — estimated cost shown, actuals logged to the cost ledger.</div>
+ <div class="note" id="est"></div>
+ <button class="btn" id="f-go">⚔ Fight</button>
+ </div>
+ </section>
+
+ <section id="detail" class="view">
+ <span class="back" id="back">← back to challenges</span>
+ <h2 id="d-title"></h2>
+ <div class="meta" id="d-meta"></div>
+ <div class="arena" id="d-arena"></div>
+ </section>
+
+ <section id="board" class="view">
+ <h2>Real-World Leaderboard</h2>
+ <div class="note" id="b-note"></div>
+ <table>
+ <thead><tr><th>#</th><th>Model</th><th>Kind</th><th>Battles judged</th><th>Wins</th><th>Win rate</th><th>Errors</th><th>Avg gen time</th><th>Spend</th></tr></thead>
+ <tbody id="b-rows"></tbody>
+ </table>
+ </section>
+</main>
<script>
-// House rule: sort + density both persist in localStorage so the choice survives reloads.
-const $=s=>document.querySelector(s), esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
-const sortSel=$('#sort'), dens=$('#density');
-sortSel.value=localStorage.getItem('sort')||'newest'; dens.value=localStorage.getItem('cols')||'4';
-function applyCols(){document.documentElement.style.setProperty('--cols',dens.value);localStorage.setItem('cols',dens.value);}
-applyCols();
-async function load(){
- const r=await fetch('/api/products?sort='+encodeURIComponent(sortSel.value)); const d=await r.json();
- $('#count').textContent=d.count+' items';
- $('#grid').innerHTML = d.products.length ? d.products.map(p=>`
- <div class="card">
- <img src="${esc(p.image||'')}" alt="${esc(p.title||'')}" loading="lazy">
- <div class="meta"><p class="t">${esc(p.title||'Untitled')}</p>
- <div class="s">${esc(p.sku||p.handle||'')}</div>
- ${p.price?`<div class="p">$${Number(p.price).toFixed(2)}</div>`:''}
- </div>
- </div>`).join('') : '<div class="empty">No products yet — fill data/products.json.</div>';
-}
-sortSel.onchange=()=>{localStorage.setItem('sort',sortSel.value);load();};
-dens.oninput=applyCols;
-load();
-</script></body></html>
+const $ = s => document.querySelector(s);
+const API = location.origin;
+let models = [], current = null, pollTimer = null;
+
+const PRESETS = [
+ { t:'Neon City Driving', p:'Build a playable neon-city night driving game: a road with moving lane markers, neon buildings scrolling past, a car the player steers left/right with arrow keys, obstacles to dodge, and a score counter. Rich neon atmosphere matters.' },
+ { t:'Dragon Realm', p:'Build an animated dragon realm scene: a dragon flying over mountains with snowfall, dynamic lighting, and at least one interactive element (click to make the dragon breathe fire). Visual quality matters most.' },
+ { t:'Open World Explorer', p:'Build a tiny Skyrim-style top-down open world: procedurally scattered trees/rocks/water, a character moved with WASD, day-night lighting cycle, and a minimap.' },
+ { t:'Particle Fireworks', p:'Build an interactive fireworks simulator: click anywhere to launch a rocket that explodes into physics-based particles with trails, gravity, and glow. Include an auto-show mode.' },
+ { t:'Synthwave Visualizer', p:'Build a synthwave music visualizer using WebAudio oscillators (no external audio files): a retro sun over an animated perspective grid that pulses to generated music, with play/stop controls.' },
+];
+
+function fmtWhen(iso){ return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }
+function mLabel(id){ const m = models.find(x=>x.id===id); return m ? m.label : id; }
+
+// ---- views ----
+function show(view){
+ document.querySelectorAll('.view').forEach(v=>v.classList.remove('on'));
+ document.querySelectorAll('nav button').forEach(b=>b.classList.remove('on'));
+ $('#'+view).classList.add('on');
+ const nb = $('#nav-'+view); if (nb) nb.classList.add('on');
+ if (view!=='detail' && pollTimer){ clearInterval(pollTimer); pollTimer=null; }
+}
+$('#nav-challenges').onclick = ()=>{ show('challenges'); loadList(); };
+$('#nav-new').onclick = ()=>show('new');
+$('#nav-board').onclick = ()=>{ show('board'); loadBoard(); };
+$('#back').onclick = ()=>{ show('challenges'); loadList(); };
+
+// ---- controls persistence (house rule) ----
+$('#sort').value = localStorage.getItem('ma-sort') || 'newest';
+$('#density').value = localStorage.getItem('ma-cols') || 3;
+document.documentElement.style.setProperty('--cols', $('#density').value);
+$('#sort').onchange = ()=>{ localStorage.setItem('ma-sort', $('#sort').value); loadList(); };
+$('#density').oninput = ()=>{ localStorage.setItem('ma-cols', $('#density').value);
+ document.documentElement.style.setProperty('--cols', $('#density').value); };
+
+// ---- challenge list ----
+async function loadList(){
+ const r = await fetch(API+'/api/challenges?sort='+$('#sort').value);
+ const d = await r.json();
+ $('#count').textContent = d.count + ' battle' + (d.count===1?'':'s');
+ const el = $('#list'); el.innerHTML = '';
+ if (!d.count){ el.innerHTML = '<div class="empty">No battles yet — hit <b>+ New Battle</b> and let the models fight.</div>'; return; }
+ for (const c of d.challenges){
+ const div = document.createElement('div'); div.className='card';
+ const chips = c.runs.map(r=>{
+ const win = c.winner===r.model ? ' winner' : '';
+ return `<span class="chip ${r.status}${win}">${mLabel(r.model)}${c.winner===r.model?' 👑':''}${r.status==='error'?' ✗':''}</span>`;
+ }).join('');
+ div.innerHTML = `<h3>${esc(c.title)}</h3>
+ <div class="when" title="${c.created_at}">🕓 ${fmtWhen(c.created_at)}</div>
+ <div class="chips">${chips}</div>`;
+ div.onclick = ()=>openDetail(c.id);
+ el.appendChild(div);
+ }
+}
+function esc(s){ return String(s).replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
+
+// ---- new battle ----
+async function loadModels(){
+ models = await (await fetch(API+'/api/models')).json();
+ const el = $('#model-picks'); el.innerHTML='';
+ for (const m of models){
+ const row = document.createElement('label');
+ row.className = 'mrow' + (m.available? '' : ' off');
+ row.innerHTML = `<input type="checkbox" value="${m.id}" ${m.kind==='local'?'checked':''} ${m.available?'':'disabled'}>
+ ${m.label}
+ <span class="cost ${m.kind==='local'?'free':'paid'}">${m.kind==='local'?'$0 local':(m.available?('~$'+m.estCost.toFixed(2)):'no key')}</span>`;
+ el.appendChild(row);
+ }
+ el.addEventListener('change', updateEst); updateEst();
+ const pr = $('#presets'); pr.innerHTML='';
+ for (const p of PRESETS){
+ const b = document.createElement('span'); b.className='preset'; b.textContent=p.t;
+ b.onclick = ()=>{ $('#f-title').value=p.t; $('#f-prompt').value=p.p; };
+ pr.appendChild(b);
+ }
+}
+function picked(){ return [...document.querySelectorAll('#model-picks input:checked')].map(i=>i.value); }
+function updateEst(){
+ const est = picked().reduce((s,id)=>{ const m=models.find(x=>x.id===id); return s+(m?m.estCost:0); },0);
+ $('#est').textContent = 'Estimated battle cost: ' + (est ? '~$'+est.toFixed(2)+' (metered models selected)' : '$0 (all local)');
+}
+$('#f-go').onclick = async ()=>{
+ const title=$('#f-title').value.trim(), prompt=$('#f-prompt').value.trim(), ids=picked();
+ if (!title||!prompt||!ids.length) return alert('Need a title, a challenge prompt, and at least one model.');
+ $('#f-go').disabled = true;
+ const r = await fetch(API+'/api/challenges',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title,prompt,models:ids})});
+ $('#f-go').disabled = false;
+ if (!r.ok) return alert('Error: '+(await r.text()));
+ const c = await r.json();
+ $('#f-title').value=''; $('#f-prompt').value='';
+ openDetail(c.id);
+};
+
+// ---- detail ----
+async function openDetail(id){
+ show('detail');
+ await renderDetail(id);
+ if (pollTimer) clearInterval(pollTimer);
+ pollTimer = setInterval(async ()=>{
+ const c = await renderDetail(id, true);
+ if (c && !c.runs.some(r=>r.status==='running'||r.status==='queued')){ clearInterval(pollTimer); pollTimer=null; renderDetail(id); }
+ }, 4000);
+}
+async function renderDetail(id, statusOnly){
+ const r = await fetch(API+'/api/challenges/'+id);
+ if (!r.ok) return null;
+ const c = await r.json(); current = c;
+ $('#d-title').textContent = c.title + (c.winner ? ' — 👑 ' + mLabel(c.winner) : '');
+ $('#d-meta').textContent = '🕓 ' + fmtWhen(c.created_at) + '\n' + c.prompt;
+ const arena = $('#d-arena');
+ const sig = c.runs.map(r=>r.model+':'+r.status).join('|') + '|' + c.winner;
+ if (statusOnly && arena.dataset.sig === sig) return c;
+ arena.dataset.sig = sig;
+ arena.innerHTML = '';
+ for (const run of c.runs){
+ const pane = document.createElement('div');
+ pane.className = 'pane' + (c.winner===run.model?' winner':'');
+ const stats = [];
+ if (run.seconds!=null) stats.push(run.seconds+'s');
+ if (run.bytes) stats.push(Math.round(run.bytes/1024)+' KB');
+ stats.push(run.cost ? '$'+run.cost.toFixed(3) : '$0');
+ let bodyHtml;
+ if (run.status==='done')
+ bodyHtml = `<iframe sandbox="allow-scripts" src="${API}/artifact/${c.id}/${run.model}" title="${mLabel(run.model)}"></iframe>`;
+ else if (run.status==='error')
+ bodyHtml = `<div class="msg">✗ ${esc(run.error||'failed')}</div>`;
+ else
+ bodyHtml = `<div class="msg">⏳ ${run.status}… local models render one at a time</div>`;
+ pane.innerHTML = `<div class="bar">
+ <span class="name">${mLabel(run.model)}</span>
+ <span class="stat">${run.status==='done'?stats.join(' · '):run.status}</span>
+ <span class="right">
+ ${run.status==='done'&&c.winner!==run.model?`<button class="btn" data-win="${run.model}">👑 Crown</button>`:''}
+ ${c.winner===run.model?'<span class="chip winner">WINNER</span>':''}
+ ${(run.status==='error'||run.status==='done')?`<button class="btn pink" data-retry="${run.model}">↻</button>`:''}
+ </span>
+ </div>${bodyHtml}`;
+ arena.appendChild(pane);
+ }
+ arena.querySelectorAll('[data-win]').forEach(b=>b.onclick=async e=>{
+ await fetch(API+'/api/challenges/'+c.id+'/vote',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({winner:e.target.dataset.win})});
+ renderDetail(c.id);
+ });
+ arena.querySelectorAll('[data-retry]').forEach(b=>b.onclick=async e=>{
+ await fetch(API+'/api/challenges/'+c.id+'/retry/'+e.target.dataset.retry,{method:'POST'});
+ openDetail(c.id);
+ });
+ return c;
+}
+
+// ---- leaderboard ----
+async function loadBoard(){
+ const d = await (await fetch(API+'/api/ledger')).json();
+ $('#b-note').textContent = d.votes + ' battle' + (d.votes===1?'':'s') + ' judged. Win rate = wins / battles the model completed an artifact in AND a winner was crowned.';
+ const tb = $('#b-rows'); tb.innerHTML='';
+ d.models.forEach((s,i)=>{
+ const tr = document.createElement('tr');
+ if (i===0 && s.winRate!=null) tr.className='first';
+ tr.innerHTML = `<td>${i+1}</td><td>${s.label}</td><td>${s.kind}</td><td>${s.battles}</td><td>${s.wins}</td>
+ <td>${s.winRate==null?'—':s.winRate+'%'}</td><td>${s.errors}</td>
+ <td>${s.avgSeconds==null?'—':s.avgSeconds+'s'}</td><td>${s.spend?'$'+s.spend.toFixed(2):'$0'}</td>`;
+ tb.appendChild(tr);
+ });
+}
+
+loadModels().then(loadList);
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 57f5df5..db279a7 100644
--- a/server.js
+++ b/server.js
@@ -1,41 +1,302 @@
-// Storefront starter — zero-dep Node http. Serves data/products.json with SERVER-SIDE sort
-// (house rule: every product grid gets sort + a density slider). Optional basic auth via BASIC_AUTH.
-const http = require('http'), fs = require('fs'), path = require('path');
-const PORT = parseInt(process.env.PORT || '3900', 10);
-const BASIC_AUTH = process.env.BASIC_AUTH || ''; // "user:pass" to gate; empty = open
+// Model Arena — fan one build challenge out to multiple AI models, render each
+// model's single-file HTML artifact side-by-side, crown a winner, keep a
+// real-world win-rate ledger. Zero-dep Node. $0 by default (local Ollama);
+// metered models are opt-in per-run and cost-logged.
+const http = require('http'), fs = require('fs'), path = require('path'), os = require('os');
+const crypto = require('crypto');
+
+const PORT = parseInt(process.env.PORT || '9758', 10);
+const BASIC_AUTH = process.env.BASIC_AUTH !== undefined ? process.env.BASIC_AUTH : 'admin:DW2024!';
const DIR = __dirname;
+const DATA = path.join(DIR, 'data');
+const ART = path.join(DATA, 'artifacts');
+const CH_FILE = path.join(DATA, 'challenges.json');
+const VOTES_FILE = path.join(DATA, 'votes.jsonl');
+const COST_FILE = path.join(DATA, 'costlog.jsonl');
+const GLOBAL_LEDGER = path.join(os.homedir(), '.claude', 'cost-ledger.jsonl');
+
+fs.mkdirSync(ART, { recursive: true });
+
+// ---------- model roster ----------
+// Local ollama models are free and enabled by default. Metered models require
+// their env key AND explicit per-run selection in the UI (never pre-checked).
+const MODELS = [
+ { id: 'qwen3-14b', label: 'Qwen3 14B', kind: 'local', host: 'http://localhost:11434', model: 'qwen3:14b', estCost: 0 },
+ { id: 'gemma3-12b', label: 'Gemma3 12B', kind: 'local', host: 'http://localhost:11434', model: 'gemma3:12b', estCost: 0 },
+ { id: 'hermes3-8b', label: 'Hermes3 8B', kind: 'local', host: 'http://localhost:11434', model: 'hermes3:8b', estCost: 0 },
+ { id: 'qwen25-7b', label: 'Qwen2.5 7B (Mac1)', kind: 'local', host: 'http://192.168.1.133:11434', model: 'qwen2.5:7b', estCost: 0 },
+ { id: 'claude', label: 'Claude Fable 5', kind: 'metered', provider: 'anthropic', model: 'claude-fable-5', envKey: 'ANTHROPIC_API_KEY', estCost: 0.15 },
+ { id: 'kimi', label: 'Kimi K2.5', kind: 'metered', provider: 'moonshot', model: 'kimi-k2.5', envKey: 'MOONSHOT_API_KEY', estCost: 0.03 },
+ { id: 'gpt', label: 'GPT-5.3', kind: 'metered', provider: 'openai', model: 'gpt-5.3', envKey: 'OPENAI_API_KEY', estCost: 0.10 },
+ { id: 'grok', label: 'Grok 4.5', kind: 'metered', provider: 'xai', model: process.env.GROK_MODEL || 'grok-4.5', envKey: 'XAI_API_KEY', estCost: 0.10 },
+];
+
+// ---------- storage ----------
+function loadChallenges() { try { return JSON.parse(fs.readFileSync(CH_FILE, 'utf8')); } catch { return []; } }
+function saveChallenges(list) {
+ const tmp = CH_FILE + '.tmp';
+ fs.writeFileSync(tmp, JSON.stringify(list, null, 2));
+ fs.renameSync(tmp, CH_FILE);
+}
+let challenges = loadChallenges();
+// anything left "running" from a previous process died with it
+for (const c of challenges) for (const r of c.runs) if (r.status === 'running' || r.status === 'queued') { r.status = 'error'; r.error = 'server restarted mid-run'; }
+saveChallenges(challenges);
+
+function appendJsonl(file, obj) { try { fs.appendFileSync(file, JSON.stringify(obj) + '\n'); } catch {} }
+
+// ---------- generation ----------
+const ARENA_PROMPT = (challenge) => `You are competing in a model arena against other AI models. Challenge:
+
+${challenge}
+
+Rules:
+- Return ONE complete, self-contained single-file HTML document (inline CSS + JS, no external resources, no network requests).
+- It must run instantly when opened in a browser iframe.
+- Output ONLY the HTML document starting with <!DOCTYPE html>. No markdown fences, no commentary before or after.`;
+
+function extractHtml(text) {
+ if (!text) return null;
+ let t = String(text).replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
+ const fence = t.match(/```(?:html)?\s*([\s\S]*?)```/i);
+ if (fence && /<html[\s>]/i.test(fence[1])) t = fence[1].trim();
+ const start = t.search(/<!doctype html|<html[\s>]/i);
+ if (start === -1) return null;
+ const endIdx = t.toLowerCase().lastIndexOf('</html>');
+ return endIdx === -1 ? t.slice(start) : t.slice(start, endIdx + 7);
+}
+
+function httpJson(url, opts, body, timeoutMs) {
+ return new Promise((resolve, reject) => {
+ const u = new URL(url);
+ const mod = u.protocol === 'https:' ? require('https') : require('http');
+ const req = mod.request(u, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) } }, res => {
+ let buf = '';
+ res.on('data', d => buf += d);
+ res.on('end', () => {
+ try { resolve({ status: res.statusCode, json: JSON.parse(buf) }); }
+ catch { resolve({ status: res.statusCode, json: null, raw: buf.slice(0, 500) }); }
+ });
+ });
+ req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout after ' + timeoutMs / 1000 + 's')));
+ req.on('error', reject);
+ req.end(JSON.stringify(body));
+ });
+}
+
+// serialize local generations per host so ollama doesn't thrash model loads
+const hostQueues = {};
+function onHost(host, fn) {
+ const prev = hostQueues[host] || Promise.resolve();
+ const next = prev.then(fn, fn);
+ hostQueues[host] = next.catch(() => {});
+ return next;
+}
+
+async function generateLocal(m, prompt) {
+ const r = await httpJson(m.host + '/api/chat', {}, {
+ model: m.model,
+ stream: false,
+ messages: [{ role: 'user', content: prompt }],
+ options: { temperature: 0.7, num_predict: 6144 },
+ }, 600000);
+ if (!r.json || !r.json.message) throw new Error('ollama bad response: ' + (r.raw || r.status));
+ return { text: r.json.message.content, cost: 0, tokens: { out: r.json.eval_count || 0 } };
+}
+
+// metered pricing per 1M tokens [in, out] — rough, for the cost line
+const PRICING = {
+ anthropic: [5, 25], moonshot: [0.6, 2.5], openai: [1.75, 14], xai: [3, 15],
+};
+function meteredCost(provider, inTok, outTok) {
+ const [pi, po] = PRICING[provider] || [0, 0];
+ return (inTok * pi + outTok * po) / 1e6;
+}
+
+async function generateMetered(m, prompt) {
+ const key = process.env[m.envKey];
+ if (!key) throw new Error(m.envKey + ' not set — metered model unavailable');
+ let text, inTok = 0, outTok = 0;
+ if (m.provider === 'anthropic') {
+ const r = await httpJson('https://api.anthropic.com/v1/messages',
+ { headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' } },
+ { model: m.model, max_tokens: 16000, messages: [{ role: 'user', content: prompt }] }, 300000);
+ if (!r.json || !r.json.content) throw new Error('anthropic error: ' + JSON.stringify(r.json && r.json.error || r.raw).slice(0, 200));
+ text = r.json.content.map(b => b.text || '').join('');
+ inTok = (r.json.usage && r.json.usage.input_tokens) || 0; outTok = (r.json.usage && r.json.usage.output_tokens) || 0;
+ } else {
+ const base = { moonshot: 'https://api.moonshot.ai/v1', openai: 'https://api.openai.com/v1', xai: 'https://api.x.ai/v1' }[m.provider];
+ const r = await httpJson(base + '/chat/completions', { headers: { Authorization: 'Bearer ' + key } },
+ { model: m.model, temperature: m.provider === 'moonshot' ? 1 : 0.7, max_tokens: 16000, messages: [{ role: 'user', content: prompt }] }, 300000);
+ const ch = r.json && r.json.choices && r.json.choices[0];
+ if (!ch) throw new Error(m.provider + ' error: ' + JSON.stringify(r.json && r.json.error || r.raw).slice(0, 200));
+ text = ch.message.content || ch.message.reasoning_content || '';
+ inTok = (r.json.usage && r.json.usage.prompt_tokens) || 0; outTok = (r.json.usage && r.json.usage.completion_tokens) || 0;
+ }
+ const cost = meteredCost(m.provider, inTok, outTok);
+ const entry = { ts: new Date().toISOString(), provider: m.provider, model: m.model, task: 'model-arena', input_tokens: inTok, output_tokens: outTok, cost_usd: +cost.toFixed(6) };
+ appendJsonl(COST_FILE, entry);
+ appendJsonl(GLOBAL_LEDGER, entry);
+ return { text, cost, tokens: { in: inTok, out: outTok } };
+}
+
+function runModel(challenge, modelId) {
+ const m = MODELS.find(x => x.id === modelId);
+ const run = challenge.runs.find(r => r.model === modelId);
+ if (!m || !run) return;
+ run.status = 'queued'; run.error = null; saveChallenges(challenges);
+ const prompt = ARENA_PROMPT(challenge.prompt);
+ const exec = async () => {
+ run.status = 'running'; run.started_at = new Date().toISOString(); saveChallenges(challenges);
+ const t0 = Date.now();
+ try {
+ const out = m.kind === 'local' ? await generateLocal(m, prompt) : await generateMetered(m, prompt);
+ const html = extractHtml(out.text);
+ if (!html) throw new Error('no HTML document in model output (' + String(out.text || '').length + ' chars)');
+ const dir = path.join(ART, challenge.id);
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, modelId + '.html'), html);
+ run.status = 'done'; run.seconds = Math.round((Date.now() - t0) / 1000);
+ run.cost = +(out.cost || 0).toFixed(4); run.bytes = Buffer.byteLength(html);
+ } catch (e) {
+ run.status = 'error'; run.error = String(e.message || e).slice(0, 300);
+ run.seconds = Math.round((Date.now() - t0) / 1000);
+ }
+ run.finished_at = new Date().toISOString();
+ saveChallenges(challenges);
+ };
+ if (m.kind === 'local') onHost(m.host, exec); else exec();
+}
-function load() { try { return JSON.parse(fs.readFileSync(path.join(DIR, 'data', 'products.json'), 'utf8')); } catch { return []; } }
+// ---------- ledger ----------
+function buildLedger() {
+ const stats = {};
+ for (const m of MODELS) stats[m.id] = { id: m.id, label: m.label, kind: m.kind, battles: 0, wins: 0, errors: 0, totalSeconds: 0, doneRuns: 0, spend: 0 };
+ for (const c of challenges) {
+ for (const r of c.runs) {
+ const s = stats[r.model]; if (!s) continue;
+ if (r.status === 'done') { s.doneRuns++; s.totalSeconds += r.seconds || 0; s.spend += r.cost || 0; }
+ if (r.status === 'error') s.errors++;
+ if (c.winner && r.status === 'done') { s.battles++; if (c.winner === r.model) s.wins++; }
+ }
+ }
+ return Object.values(stats).map(s => ({
+ ...s,
+ winRate: s.battles ? +(100 * s.wins / s.battles).toFixed(1) : null,
+ avgSeconds: s.doneRuns ? Math.round(s.totalSeconds / s.doneRuns) : null,
+ spend: +s.spend.toFixed(4),
+ })).sort((a, b) => (b.winRate || 0) - (a.winRate || 0) || b.wins - a.wins);
+}
+
+// ---------- http ----------
function authed(req) {
if (!BASIC_AUTH) return true;
const m = (req.headers.authorization || '').match(/^Basic\s+(.+)$/i);
- if (!m) return false; try { return Buffer.from(m[1], 'base64').toString() === BASIC_AUTH; } catch { return false; }
-}
-// Sort modes mirror the DW sort-skill: newest, title/sku A→Z, price ↑↓, light→dark (by dominant hex).
-function luminance(hex) { if (!/^#?[0-9a-f]{6}$/i.test(hex || '')) return 999; const h = hex.replace('#', '');
- const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
- return 0.2126 * r + 0.7152 * g + 0.0722 * b; }
-function sortProducts(items, mode) {
- const a = items.slice();
- switch (mode) {
- case 'title': return a.sort((x, y) => (x.title || '').localeCompare(y.title || ''));
- case 'sku': return a.sort((x, y) => (x.sku || x.handle || '').localeCompare(y.sku || y.handle || ''));
- case 'price-asc': return a.sort((x, y) => (x.price || 0) - (y.price || 0));
- case 'price-desc': return a.sort((x, y) => (y.price || 0) - (x.price || 0));
- case 'light': return a.sort((x, y) => luminance(y.hex) - luminance(x.hex));
- case 'dark': return a.sort((x, y) => luminance(x.hex) - luminance(y.hex));
- default: return a; // 'newest' = natural order
- }
-}
-http.createServer((req, res) => {
- if (!authed(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="preview"' }); return res.end('auth required'); }
+ if (!m) return false;
+ try { return Buffer.from(m[1], 'base64').toString() === BASIC_AUTH; } catch { return false; }
+}
+function send(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); }
+function readBody(req) { return new Promise(r => { let b = ''; req.on('data', d => b += d); req.on('end', () => { try { r(JSON.parse(b || '{}')); } catch { r({}); } }); }); }
+
+const server = http.createServer(async (req, res) => {
+ if (!authed(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="model-arena"' }); return res.end('auth required'); }
const u = new URL(req.url, 'http://x');
- if (u.pathname === '/api/products') {
- const out = sortProducts(load(), u.searchParams.get('sort') || 'newest');
- res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ count: out.length, products: out }));
+ const p = u.pathname;
+
+ if (p === '/api/models') {
+ return send(res, 200, MODELS.map(m => ({
+ id: m.id, label: m.label, kind: m.kind, model: m.model,
+ available: m.kind === 'local' ? true : !!process.env[m.envKey],
+ estCost: m.estCost,
+ })));
+ }
+
+ if (p === '/api/challenges' && req.method === 'GET') {
+ const sort = u.searchParams.get('sort') || 'newest';
+ let list = challenges.slice();
+ if (sort === 'oldest') { /* natural order */ }
+ else if (sort === 'title') list.sort((a, b) => a.title.localeCompare(b.title));
+ else if (sort === 'models') list.sort((a, b) => b.runs.length - a.runs.length);
+ else if (sort === 'winner') list.sort((a, b) => (b.winner ? 1 : 0) - (a.winner ? 1 : 0));
+ else list.reverse(); // newest first
+ return send(res, 200, { count: list.length, challenges: list });
+ }
+
+ if (p === '/api/challenges' && req.method === 'POST') {
+ const body = await readBody(req);
+ const title = String(body.title || '').trim().slice(0, 120);
+ const prompt = String(body.prompt || '').trim().slice(0, 4000);
+ const ids = Array.isArray(body.models) ? body.models.filter(id => MODELS.some(m => m.id === id)) : [];
+ if (!title || !prompt || !ids.length) return send(res, 400, { error: 'title, prompt, and at least one model required' });
+ const c = {
+ id: crypto.randomBytes(6).toString('hex'),
+ title, prompt,
+ created_at: new Date().toISOString(),
+ winner: null,
+ runs: ids.map(id => ({ model: id, status: 'queued', error: null, seconds: null, cost: null })),
+ };
+ challenges.push(c); saveChallenges(challenges);
+ for (const id of ids) runModel(c, id);
+ return send(res, 201, c);
+ }
+
+ let m;
+ if ((m = p.match(/^\/api\/challenges\/([a-f0-9]+)$/)) && req.method === 'GET') {
+ const c = challenges.find(x => x.id === m[1]);
+ return c ? send(res, 200, c) : send(res, 404, { error: 'not found' });
}
- const f = u.pathname === '/' ? 'index.html' : u.pathname.replace(/^\//, '');
+
+ if ((m = p.match(/^\/api\/challenges\/([a-f0-9]+)\/vote$/)) && req.method === 'POST') {
+ const c = challenges.find(x => x.id === m[1]);
+ if (!c) return send(res, 404, { error: 'not found' });
+ const body = await readBody(req);
+ const winner = String(body.winner || '');
+ if (!c.runs.some(r => r.model === winner && r.status === 'done')) return send(res, 400, { error: 'winner must be a model with a done artifact' });
+ c.winner = winner; c.voted_at = new Date().toISOString(); saveChallenges(challenges);
+ appendJsonl(VOTES_FILE, { ts: c.voted_at, challenge: c.id, title: c.title, winner, field: c.runs.filter(r => r.status === 'done').map(r => r.model) });
+ return send(res, 200, c);
+ }
+
+ if ((m = p.match(/^\/api\/challenges\/([a-f0-9]+)\/retry\/([\w-]+)$/)) && req.method === 'POST') {
+ const c = challenges.find(x => x.id === m[1]);
+ if (!c) return send(res, 404, { error: 'not found' });
+ if (!c.runs.some(r => r.model === m[2])) c.runs.push({ model: m[2], status: 'queued', error: null, seconds: null, cost: null });
+ runModel(c, m[2]);
+ return send(res, 200, c);
+ }
+
+ if ((m = p.match(/^\/api\/challenges\/([a-f0-9]+)$/)) && req.method === 'DELETE') {
+ const idx = challenges.findIndex(x => x.id === m[1]);
+ if (idx === -1) return send(res, 404, { error: 'not found' });
+ challenges.splice(idx, 1); saveChallenges(challenges);
+ fs.rmSync(path.join(ART, m[1]), { recursive: true, force: true });
+ return send(res, 200, { ok: true });
+ }
+
+ if (p === '/api/ledger') return send(res, 200, { models: buildLedger(), votes: challenges.filter(c => c.winner).length });
+
+ if ((m = p.match(/^\/artifact\/([a-f0-9]+)\/([\w-]+)$/))) {
+ const fp = path.join(ART, m[1], m[2] + '.html');
+ if (!fs.existsSync(fp)) { res.writeHead(404); return res.end('no artifact'); }
+ res.writeHead(200, {
+ 'Content-Type': 'text/html; charset=utf-8',
+ // artifacts are untrusted model output — no network, no top-level nav
+ 'Content-Security-Policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; media-src data:; font-src data:",
+ 'X-Frame-Options': 'SAMEORIGIN',
+ });
+ return res.end(fs.readFileSync(fp));
+ }
+
+ const f = p === '/' ? 'index.html' : p.replace(/^\//, '');
const fp = path.join(DIR, 'public', path.basename(f));
- if (fs.existsSync(fp)) { res.writeHead(200, { 'Content-Type': f.endsWith('.html') ? 'text/html' : 'text/plain' }); return res.end(fs.readFileSync(fp)); }
+ if (fs.existsSync(fp) && fs.statSync(fp).isFile()) {
+ const type = f.endsWith('.html') ? 'text/html' : f.endsWith('.js') ? 'text/javascript' : f.endsWith('.css') ? 'text/css' : 'text/plain';
+ res.writeHead(200, { 'Content-Type': type });
+ return res.end(fs.readFileSync(fp));
+ }
res.writeHead(404); res.end('not found');
-}).listen(PORT, function () { console.log('[' + path.basename(DIR) + '] http://localhost:' + this.address().port); });
+});
+
+server.listen(PORT, function () {
+ console.log('[model-arena] http://localhost:' + this.address().port + (BASIC_AUTH ? ' (basic auth on)' : ''));
+});
← 0db8665 initial scaffold (model-arena) via web-dev accelerator
·
back to Model Arena
·
model-arena: README + first real battle (hermes3 13s vs gemm 304fb75 →