Files
Behavision/demo/console.html
Suriyakumarvijayanayagam f97ffc913a demo console: polled frames and the real stats field names
The camera used an MJPEG stream through the proxy and the engine's
stats under names it does not use (frames/faces rather than
frames_processed/faces_seen), so the picture was blank and the counter
read zero. The engine re-serves its latest frame until the pipeline
produces a new one, so a polled still is the same picture with none of
the multipart fragility - which matters when the audience is in the
room.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-24 13:50:08 +05:30

259 lines
14 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Behavision — live demo</title>
<style>
:root{
--bg:#0A0E12; --s1:#11171C; --s2:#161D24; --s3:#1D262E;
--line:#24303A; --line2:#1B242C;
--ink:#E8EEF3; --ink2:#9FB0BD; --ink3:#6B7E8C;
--accent:#3DD0C4; --accent-dim:#123039;
--ok:#3FBF7F; --warn:#E0A33A; --bad:#E15B4C;
--mono:'SF Mono',ui-monospace,Menlo,monospace;
--font:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--ink);font-family:var(--font);font-size:14px;line-height:1.5;
-webkit-font-smoothing:antialiased;padding:18px;min-height:100vh}
.top{display:flex;align-items:center;gap:14px;margin-bottom:16px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:10px;margin-right:auto}
.brand img{width:26px;height:26px;object-fit:contain}
.brand b{font-size:16px;letter-spacing:-.01em}
.brand span{color:var(--ink3);font-size:12px}
.pill{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:99px;
border:1px solid var(--line);background:var(--s1);font-size:12px;color:var(--ink2)}
.dot{width:7px;height:7px;border-radius:99px;background:var(--ink3);flex:none}
.dot.ok{background:var(--ok);box-shadow:0 0 0 3px rgba(63,191,127,.16)}
.dot.bad{background:var(--bad);box-shadow:0 0 0 3px rgba(225,91,76,.16)}
.dot.warn{background:var(--warn);box-shadow:0 0 0 3px rgba(224,163,58,.16)}
.grid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:14px;align-items:start}
@media(max-width:1100px){.grid{grid-template-columns:minmax(0,1fr)}}
.card{background:var(--s1);border:1px solid var(--line);border-radius:12px;overflow:hidden}
.card h2{font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--ink3);
padding:12px 16px;border-bottom:1px solid var(--line2);display:flex;align-items:center;gap:10px}
.card h2 .grow{margin-left:auto;font-weight:500;letter-spacing:0;text-transform:none;font-size:12px;color:var(--ink3)}
.pad{padding:16px}
.cam{position:relative;aspect-ratio:16/9;background:#05090C}
.cam img{width:100%;height:100%;object-fit:cover;display:block}
.cam .none{position:absolute;inset:0;display:grid;place-items:center;color:var(--ink3);font-size:13px;text-align:center;padding:20px}
.cam .tag{position:absolute;top:10px;left:10px;background:rgba(10,14,18,.78);backdrop-filter:blur(8px);
border:1px solid var(--line);border-radius:8px;padding:5px 10px;font-size:11.5px;font-family:var(--mono)}
/* the chain */
.chain{display:flex;flex-direction:column;gap:0}
.step{display:grid;grid-template-columns:26px 1fr auto;gap:12px;align-items:start;padding:11px 16px;
border-bottom:1px solid var(--line2);opacity:.38;transition:opacity .25s}
.step:last-child{border-bottom:0}
.step.on{opacity:1}
.step .n{width:22px;height:22px;border-radius:99px;display:grid;place-items:center;font-size:11px;font-weight:600;
background:var(--s3);color:var(--ink3);border:1px solid var(--line);margin-top:1px}
.step.on .n{background:var(--accent);color:#04161B;border-color:transparent}
.step b{font-size:13.5px;font-weight:550;display:block}
.step small{color:var(--ink2);font-size:12px;display:block;margin-top:1px;font-family:var(--mono)}
.step .ms{font-family:var(--mono);font-size:11.5px;color:var(--accent);white-space:nowrap;margin-top:2px}
.empty{padding:34px 16px;text-align:center;color:var(--ink3);font-size:13px;line-height:1.6}
.empty b{display:block;color:var(--ink2);font-size:14px;margin-bottom:5px}
/* customer */
.who{display:flex;gap:13px;align-items:center;padding:16px;border-bottom:1px solid var(--line2)}
.av{width:50px;height:50px;border-radius:10px;background:var(--s3);border:1px solid var(--line);
display:grid;place-items:center;font-weight:600;font-size:17px;color:var(--ink2);flex:none;overflow:hidden}
.av img{width:100%;height:100%;object-fit:cover}
.who .n{font-size:16px;font-weight:600;letter-spacing:-.01em}
.who .m{color:var(--ink3);font-size:12.5px;margin-top:2px}
.badge{display:inline-block;padding:2px 8px;border-radius:99px;font-size:10.5px;font-weight:600;
letter-spacing:.04em;text-transform:uppercase}
.badge.new{background:var(--accent-dim);color:var(--accent)}
.badge.seen{background:rgba(63,191,127,.14);color:var(--ok)}
label{display:block;font-size:11.5px;font-weight:550;color:var(--ink2);margin-bottom:5px}
input{width:100%;background:var(--s2);border:1px solid var(--line);border-radius:7px;padding:9px 11px;
color:var(--ink);font:inherit;font-size:13.5px}
input:focus{outline:none;border-color:var(--accent)}
.row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px}
button{background:var(--accent);color:#04161B;border:0;border-radius:7px;padding:9px 16px;
font:inherit;font-size:13px;font-weight:600;cursor:pointer}
button:disabled{opacity:.45;cursor:default}
button.sec{background:var(--s3);color:var(--ink);border:1px solid var(--line)}
.saved{color:var(--ok);font-size:12.5px;margin-top:9px;display:flex;align-items:center;gap:6px}
/* raw json */
pre{font-family:var(--mono);font-size:11px;line-height:1.55;color:var(--ink2);
background:#080C10;border-top:1px solid var(--line2);padding:13px 16px;margin:0;
max-height:230px;overflow:auto;white-space:pre-wrap;word-break:break-word}
.req{font-family:var(--mono);font-size:11.5px;color:var(--accent);padding:10px 16px;background:var(--s2)}
.req .st{float:right;color:var(--ink3)}
.hint{color:var(--ink3);font-size:12px;padding:10px 16px 14px;line-height:1.55}
.stack{display:flex;flex-direction:column;gap:14px}
</style>
</head>
<body>
<div class="top">
<div class="brand">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDIxcy04LTQuNS04LTEwYTQuNSA0LjUgMCAwIDEgOC0yLjggNC41IDQuNSAwIDAgMSA4IDIuOGMwIDUuNS04IDEwLTggMTB6IiBmaWxsPSIjRjJDMTFGIi8+PC9zdmc+" alt="">
<div><b>Behavision</b> <span id="site">— live demo</span></div>
</div>
<span class="pill"><i class="dot" id="d-eng"></i><span id="t-eng">engine…</span></span>
<span class="pill"><i class="dot" id="d-cam"></i><span id="t-cam">camera…</span></span>
<span class="pill"><i class="dot" id="d-cloud"></i><span id="t-cloud">cloud…</span></span>
</div>
<div class="grid">
<div class="stack">
<div class="card">
<h2>The camera <span class="grow" id="camname"></span></h2>
<div class="cam">
<img id="feed" alt="" style="display:none">
<div class="none" id="feednone">waiting for the camera…</div>
<div class="tag" id="camtag" style="display:none"></div>
</div>
</div>
<div class="card">
<h2>The customer <span class="grow">type a name, then walk past again</span></h2>
<div id="cust">
<div class="empty"><b>Nobody yet</b>Walk in front of the camera.</div>
</div>
</div>
</div>
<div class="stack">
<div class="card">
<h2>What just happened <span class="grow" id="lat"></span></h2>
<div id="chain"><div class="empty"><b>Waiting</b>Every step below lights up as it really happens.</div></div>
</div>
<div class="card">
<h2>What the mobile app receives</h2>
<div class="req" id="m-req">GET /api/visits<span class="st" id="m-st"></span></div>
<pre id="m-body">…</pre>
</div>
<div class="card">
<h2>What the dashboard receives</h2>
<div class="req" id="d-req">GET /api/reports/footfall<span class="st" id="d-st"></span></div>
<pre id="d-body">…</pre>
<div class="hint">Both of these are the real production API at mcp.loyaly.ai, called from this
machine with a staff login — not a mock, and not the local engine.</div>
</div>
</div>
</div>
<script>
const $ = s => document.querySelector(s);
let camStarted = null, current = null, savedFor = null;
function setPill(dot, text, tone, label){
$(dot).className = 'dot' + (tone ? ' ' + tone : '');
$(text).textContent = label;
}
function initials(name, ref){
const m = /^Visitor (\d+)$/.exec((name||'').trim());
if (m) return m[1];
const w = (name||'').trim().split(/\s+/).filter(Boolean);
if (!w.length) return '?';
return (w[0][0] + (w[1]?.[0] ?? '')).toUpperCase();
}
function drawChain(e){
if (!e){ $('#chain').innerHTML = '<div class="empty"><b>Waiting</b>Every step below lights up as it really happens.</div>'; $('#lat').textContent=''; return; }
const g = e.engine, c = e.cloud;
const steps = [
[true, 'Camera saw a face', g.quality != null ? `quality ${(+g.quality).toFixed(2)} · camera ${g.camera}` : `camera ${g.camera}`, ''],
[true, e.kind === 'new' ? 'Engine: nobody it knows → enrolled' : 'Engine: matched a returning customer',
(g.label || '') + (g.similarity != null && g.similarity >= 0 ? ` · similarity ${(+g.similarity).toFixed(2)}` : '') , ''],
[true, 'Agent queued the visit', 'durable on this disk until the broker confirms', ''],
[!!c, 'Broker delivered it', 'MQTT over TLS to mcp.loyaly.ai', ''],
[!!c, 'Server recorded it', c ? `${c.site} · ${c.is_new ? 'new customer' : 'returning'}` : 'waiting…', ''],
[!!c, 'Mobile + dashboard can see it', c ? `visit ${String(c.visit_id).slice(0,8)}` : 'waiting…',
e.latency != null ? `+${e.latency}s` : ''],
];
$('#chain').innerHTML = steps.map(([on,title,sub,ms],i)=>
`<div class="step ${on?'on':''}"><div class="n">${i+1}</div><div><b>${title}</b><small>${sub}</small></div><div class="ms">${ms}</div></div>`
).join('');
$('#lat').textContent = e.latency != null ? `camera → cloud in ${e.latency}s` : '';
}
function drawCustomer(e){
const c = e && e.cloud;
if (!c){ if(!current) $('#cust').innerHTML = '<div class="empty"><b>Nobody yet</b>Walk in front of the camera.</div>'; return; }
const changed = !current || current.visitor_id !== c.visitor_id || current.visit_id !== c.visit_id;
if (!changed) return;
current = c;
const name = c.label || 'Unrecognised';
const img = c.image && c.image.available && c.image.url;
$('#cust').innerHTML = `
<div class="who">
<div class="av">${img ? `<img src="${img}">` : initials(name)}</div>
<div style="flex:1;min-width:0">
<div class="n">${name}</div>
<div class="m">${c.ref ? c.ref + ' · ' : ''}${c.is_new ? 'first time here' : 'returning'}${c.similarity>0 ? ' · match ' + (+c.similarity).toFixed(2) : ''}</div>
</div>
<span class="badge ${c.is_new?'new':'seen'}">${c.is_new?'new':'returning'}</span>
</div>
<div class="pad">
<div class="row">
<div><label>Name</label><input id="f-name" placeholder="e.g. Suriya" value=""></div>
<div><label>Phone</label><input id="f-phone" placeholder="+91…" value=""></div>
</div>
<button id="save">Save to the customer record</button>
<div id="savedmsg"></div>
<div class="hint" style="padding:12px 0 0">This writes to the production API. Walk past again and
the name comes back through the cloud instead of “${name}”.</div>
</div>`;
$('#save').onclick = async () => {
const b = $('#save'); b.disabled = true; b.textContent = 'Saving…';
const r = await fetch('/api/profile', {method:'POST', headers:{'content-type':'application/json'},
body: JSON.stringify({id: c.visitor_id, full_name: $('#f-name').value, phone: $('#f-phone').value})});
const d = await r.json();
b.disabled = false; b.textContent = 'Save to the customer record';
$('#savedmsg').innerHTML = (d.status===200||d.status===204)
? '<div class="saved">✓ Saved — PUT /api/visitors/'+String(c.visitor_id).slice(0,8)+'…/profile → '+d.status+'</div>'
: '<div class="saved" style="color:var(--bad)">'+(d.body&&d.body.message||('HTTP '+d.status))+'</div>';
savedFor = c.visitor_id;
};
}
async function tick(){
let s;
try { s = await (await fetch('/api/snapshot')).json(); } catch { return; }
const eng = s.engine || {};
setPill('#d-eng','#t-eng', eng.up ? 'ok' : 'bad', eng.up ? ('engine · ' + (eng.model||'starting')) : 'engine starting…');
const cam = (eng.cameras||[])[0];
setPill('#d-cam','#t-cam', cam && cam.connected ? 'ok' : 'warn',
cam ? (cam.connected ? `camera live · ${cam.frames||0} frames` : 'camera connecting…') : 'no camera yet');
setPill('#d-cloud','#t-cloud', s.cloud_ok ? 'ok' : 'bad', s.cloud_ok ? 'cloud connected' : 'cloud unreachable');
$('#camname').textContent = cam ? cam.id : '';
if (cam && cam.connected){
if (camStarted !== cam.id){
camStarted = cam.id;
$('#feed').style.display = 'block'; $('#feednone').style.display = 'none';
$('#camtag').style.display = 'block';
// A polled still rather than an MJPEG stream: the engine re-serves its
// latest frame anyway, and a multipart stream through a proxy is one
// more thing to fail in front of an audience.
setInterval(() => { $('#feed').src = '/camera.jpg?id=' +
encodeURIComponent(camStarted) + '&t=' + Date.now(); }, 350);
}
$('#camtag').textContent = cam.id + (cam.tracks ? ` · ${cam.tracks} in frame` : '');
}
const e = (s.chain||[])[0];
drawChain(e); drawCustomer(e);
$('#m-st').textContent = s.mobile.status;
$('#m-body').textContent = JSON.stringify(s.mobile.body, null, 1).slice(0, 2600);
$('#d-st').textContent = s.dashboard.status;
$('#d-body').textContent = JSON.stringify(s.dashboard.body, null, 1).slice(0, 1800);
}
tick(); setInterval(tick, 1500);
</script>
</body>
</html>