nishtha711's picture
Upload 5 files
17caec4 verified
Raw
History Blame Contribute Delete
43.8 kB
/* =================================================================
Message Router — demo shell logic
Loads APP_DATA (inline <script id="app-data"> for the single-file
preview build, or fetch('data.json') for the deployed HF Space),
then renders an interactive WhatsApp-accurate phone on top of it.
Every decision shown is a real, precomputed output of the actual
Python pipeline in src/ — nothing here re-derives a verdict.
================================================================= */
(function () {
"use strict";
const BANDS = { notify: [0.85, 0.91], mute: [0.81, 0.87], digest: [0.78, 0.84] };
const ACTION_META = {
notify: { label: "Notify", color: "#e0592a", icon: "🔔" },
digest: { label: "Digest", color: "#2f86c9", icon: "🕓" },
mute: { label: "Mute", color: "#847a90", icon: "🔇" },
};
const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const state = { data: null, userId: null, convKey: null, view: "list", search: "", filter: "all", navTab: "chats" };
function toast(msg, ms) {
const stack = $("#toastStack");
if (!stack) return;
const t = el("div", "toast", esc(msg));
stack.appendChild(t);
setTimeout(() => {
t.classList.add("out");
setTimeout(() => t.remove(), 240);
}, ms || 3200);
}
// ------------------------------------------------------------- helpers
const $ = (sel, root) => (root || document).querySelector(sel);
const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));
const el = (tag, cls, html) => {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (html !== undefined) n.innerHTML = html;
return n;
};
const esc = (s) => (s || "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const titleize = (s) => (s || "").replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
const clamp01 = (v) => Math.max(0, Math.min(1, v));
function nowClock() {
const d = new Date();
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
}
function currentUser() { return state.data.users[state.userId]; }
function currentConv() {
const u = currentUser();
if (!u) return null;
return u.conversations.find((c) => c.conv_key === state.convKey) || null;
}
// -------------------------------------------------------------- theme
function applyTheme(mode) {
document.documentElement.setAttribute("data-theme", mode);
localStorage.setItem("router-theme", mode);
$("#themeToggle").innerHTML = mode === "dark" ? ICON_SUN : ICON_MOON;
}
const ICON_SUN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4.5"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/></svg>';
const ICON_MOON = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.8 14.6A8.6 8.6 0 1 1 9.4 3.2a7 7 0 0 0 11.4 11.4Z"/></svg>';
function initTheme() {
const saved = localStorage.getItem("router-theme") || "light";
applyTheme(saved);
$("#themeToggle").addEventListener("click", () => {
const next = document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark";
applyTheme(next);
});
}
// ---------------------------------------------------------- boot / data
function boot(data) {
state.data = data;
state.userId = data.meta.hero_user_id;
hydrateStats(data.meta);
populateUserSwitch(data);
renderChatList();
showView("list");
$("#phone").addEventListener("mouseenter", () => $("#phone").classList.add("settled"), { once: true });
document.querySelectorAll(".reveal").forEach((n, i) => (n.style.animationDelay = i * 90 + "ms"));
initSearchAndFilters();
initMenu();
initNav();
initContactInfo();
initArchDiagram();
maybeSpotlightRouterHQ();
}
function hydrateStats(meta) {
$("#statAccuracy").textContent = (meta.action_accuracy * 100).toFixed(1) + "%";
$("#statCalls").textContent = meta.routing_calls + " / " + meta.raw_message_count;
$("#statCost").textContent = "$" + meta.cost_usd;
$("#statCaught").textContent = meta.semantic_contradictions_caught;
$("#statRank").textContent = meta.rank + " / " + meta.total_participants.toLocaleString();
}
function populateUserSwitch(data) {
const sel = $("#userSwitch");
sel.innerHTML = "";
Object.values(data.users)
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((u) => {
const opt = el("option");
opt.value = u.user_id;
opt.textContent = `${u.name}${u.stats.n_new} new msg${u.stats.n_new === 1 ? "" : "s"}`;
sel.appendChild(opt);
});
sel.value = state.userId;
sel.addEventListener("change", () => {
state.userId = sel.value;
renderChatList();
showView("list");
});
}
// ------------------------------------------------- search & filter row
function initSearchAndFilters() {
const input = $("#searchInput");
const bar = $("#searchBar");
const clear = $("#searchClear");
input.addEventListener("input", () => {
state.search = input.value;
bar.classList.toggle("has-value", !!input.value);
renderChatList();
});
clear.addEventListener("click", () => {
input.value = "";
state.search = "";
bar.classList.remove("has-value");
renderChatList();
input.focus();
});
$$(".filter-pill", $("#filterRow")).forEach((pill) => {
pill.addEventListener("click", () => {
$$(".filter-pill", $("#filterRow")).forEach((p) => p.classList.remove("active"));
pill.classList.add("active");
state.filter = pill.dataset.filter;
renderChatList();
});
});
$("#searchIconBtn").addEventListener("click", () => input.focus());
}
// ------------------------------------------------------------- 3-dot menu
function initMenu() {
const btn = $("#menuBtn");
const dd = $("#menuDropdown");
btn.addEventListener("click", (e) => {
e.stopPropagation();
dd.classList.toggle("show");
});
document.addEventListener("click", () => dd.classList.remove("show"));
dd.addEventListener("click", (e) => e.stopPropagation());
dd.addEventListener("click", (e) => {
const action = e.target.closest("button")?.dataset.action;
if (!action) return;
dd.classList.remove("show");
if (action === "theme") {
const next = document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark";
applyTheme(next);
toast(`Switched to ${next} mode`);
} else if (action === "shuffle") {
const ids = Object.keys(state.data.users).filter((id) => id !== state.userId);
const pick = ids[Math.floor(Math.random() * ids.length)];
state.userId = pick;
$("#userSwitch").value = pick;
renderChatList();
toast(`Now viewing ${state.data.users[pick].name}'s inbox`);
} else if (action === "router") {
openRouterHQ();
} else if (action === "diagram") {
document.getElementById("archCard").scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "center" });
}
});
}
// ------------------------------------------------------------- bottom nav
const NAV_STUBS = {
updates: { icon: "🔄", text: "Status updates aren't part of a notification router — this demo lives entirely in Chats." },
communities: { icon: "👥", text: "Communities are a WhatsApp feature this demo doesn't need — every routing decision already lives in one inbox at a time." },
calls: { icon: "📞", text: "No calling here — this pipeline decides whether a message interrupts you, it doesn't place calls." },
};
function initNav() {
$$(".nav-item").forEach((item) => {
item.addEventListener("click", () => {
$$(".nav-item").forEach((x) => x.classList.remove("active"));
item.classList.add("active");
const tab = item.dataset.tab;
state.navTab = tab;
const stub = $("#navStub");
const isChats = tab === "chats";
$("#chatList").classList.toggle("hidden", !isChats);
$("#filterRow").style.display = isChats ? "" : "none";
$("#searchBar").style.display = isChats ? "" : "none";
stub.classList.toggle("hidden", isChats);
if (!isChats) {
const s = NAV_STUBS[tab];
stub.innerHTML = `<span class="ns-icon">${s.icon}</span><p>${esc(s.text)}</p><button id="navBack">Back to Chats</button>`;
$("#navBack").addEventListener("click", () => $('.nav-item[data-tab="chats"]').click());
}
});
});
}
// -------------------------------------------------------- contact info
function initContactInfo() {
const pop = $("#infoPop");
const open = () => {
pop.innerHTML = contactInfoHTML();
pop.classList.add("show");
};
$("#threadAvatar").addEventListener("click", (e) => { e.stopPropagation(); open(); });
$("#threadTitleBtn").addEventListener("click", (e) => { e.stopPropagation(); open(); });
document.addEventListener("click", () => pop.classList.remove("show"));
pop.addEventListener("click", (e) => e.stopPropagation());
$("#videoBtn").addEventListener("click", () => toast("📹 No video calls — this demo only decides whether a message interrupts you."));
$("#callBtn").addEventListener("click", () => toast("📞 No voice calls either — routing, not ringing."));
}
function contactInfoHTML() {
if (state.convKey === "meta:router") {
return `<div class="ip-avatar" style="background:linear-gradient(135deg,var(--brand-teal),var(--accent-lavender))">🧭</div>
<h4>Router HQ</h4><div class="ip-sub">The pipeline, narrating itself inside a chat thread.</div>`;
}
if (state.convKey === "meta:sandbox") {
return `<div class="ip-avatar" style="background:linear-gradient(135deg,var(--accent-mint),var(--accent-sun))">✍️</div>
<h4>Try It Yourself</h4><div class="ip-sub">A simplified live demo — not the real 30-signal pipeline.</div>`;
}
const conv = currentConv();
if (!conv) return "";
const rows = [];
if (conv.kind === "group") rows.push(["type", "group chat"]);
if (conv.kind === "business") { rows.push(["verified", conv.verified ? "yes ✔️" : "no"]); }
if (conv.subtitle) rows.push(["details", conv.subtitle]);
rows.push(["messages here", String(conv.messages.length)]);
rows.push(["routed by AI", String(conv.messages.filter((m) => m.decision).length)]);
return `<div class="ip-avatar" style="background:${conv.color}">${esc(conv.initials)}</div>
<h4>${esc(conv.name)}</h4><div class="ip-sub">${esc(conv.subtitle || (conv.kind === "personal" ? "direct message" : ""))}</div>
${rows.map(([k, v]) => `<div class="ip-row"><span>${esc(k)}</span><b>${esc(v)}</b></div>`).join("")}`;
}
// --------------------------------------------------- architecture diagram
const ARCH_SEQUENCE = [
{ nodes: ["an-source"], edges: [] },
{ nodes: ["an-bundle"], edges: ["ae-source-bundle"] },
{ nodes: ["an-media"], edges: ["ae-bundle-media"] },
{ nodes: ["an-rules"], edges: ["ae-media-rules"] },
{ nodes: ["an-override"], edges: ["ae-override-stub"] },
{ nodes: ["an-router"], edges: ["ae-rules-router"] },
{ nodes: ["an-normalize"], edges: ["ae-router-normalize"] },
{ nodes: ["an-output"], edges: ["ae-normalize-output"] },
{ nodes: ["an-evidence-note"], edges: ["ae-evidence"] },
];
function initArchDiagram() {
const card = $("#archCard");
if (!card) return;
const edges = $$(".arch-edge", card);
edges.forEach((e) => {
const len = e.getTotalLength ? e.getTotalLength() : 120;
e.style.strokeDasharray = String(len);
e.style.strokeDashoffset = String(len);
});
let played = false;
function playArch() {
card.classList.add("play");
$$(".arch-node, .arch-edge", card).forEach((n) => n.classList.remove("on"));
edges.forEach((e) => (e.style.strokeDashoffset = e.style.strokeDasharray));
let i = 0;
const stepDelay = reduceMotion ? 0 : 260;
function tick() {
if (i >= ARCH_SEQUENCE.length) return;
const step = ARCH_SEQUENCE[i];
step.nodes.forEach((id) => $("#" + id)?.classList.add("on"));
step.edges.forEach((id) => {
const line = $("#" + id);
if (line) { line.classList.add("on"); line.style.strokeDashoffset = "0"; }
});
i++;
if (i < ARCH_SEQUENCE.length) setTimeout(tick, stepDelay);
}
tick();
}
if ("IntersectionObserver" in window) {
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !played) { played = true; playArch(); }
});
}, { threshold: 0.35 });
io.observe(card);
} else {
playArch();
}
$("#diagramReplay").addEventListener("click", playArch);
}
// ---------------------------------------------------------- navigation
function showView(which) {
state.view = which;
$("#viewChatList").classList.toggle("hidden", which !== "list");
$("#viewThread").classList.toggle("hidden", which !== "thread");
}
// ------------------------------------------------------- chat list ----
function lastPreview(conv) {
const m = conv.messages[conv.messages.length - 1];
if (!m) return { text: "", time: "" };
let text;
if (m.media) text = m.media.type === "image" ? "📷 Photo" : "🎤 Voice message";
else text = (m.text || "").replace(/\s+/g, " ").slice(0, 46);
return { text, time: m.time, action: m.decision ? m.decision.action : null };
}
function renderChatList() {
const u = currentUser();
const list = $("#chatList");
list.innerHTML = "";
const q = (state.search || "").trim().toLowerCase();
if (!q || "router hq".includes(q)) {
const hqRow = buildMetaRow("router", "🧭", "Router HQ", "Tap to see how I actually think →", true);
hqRow.id = "hqRow";
hqRow.classList.add("pinned-hq");
list.appendChild(hqRow);
}
if (!q || "try it yourself".includes(q)) {
list.appendChild(buildMetaRow("sandbox", "✍️", "Try It Yourself", "Write your own message, get a live verdict", false));
}
const filtered = u.conversations.filter((conv) => {
if (q && !conv.name.toLowerCase().includes(q)) return false;
if (state.filter === "unread" && !conv.messages.some((m) => m.is_new)) return false;
if (state.filter === "group" && conv.kind !== "group") return false;
return true;
});
if (!filtered.length) {
const empty = el("div", "empty-row", `<span class="ee">🔍</span>No chats match${q ? ` “${esc(q)}”` : " this filter"}.`);
list.appendChild(empty);
return;
}
filtered.forEach((conv) => {
const prev = lastPreview(conv);
const unreadCount = conv.messages.filter((m) => m.is_new).length;
const row = el("div", "chat-row");
row.innerHTML = `
<div class="avatar ${conv.kind === "group" ? "group" : ""}" style="background:${conv.color}">${esc(conv.initials)}</div>
<div class="chat-row-body">
<div class="chat-row-top">
<span class="chat-row-name">${esc(conv.name)} ${conv.verified ? '<span class="verified-badge" title="Verified business">✔️</span>' : ""}</span>
<span class="chat-row-time ${unreadCount ? "unread" : ""}">${esc(prev.time)}</span>
</div>
<div class="chat-row-bottom">
<span class="chat-row-preview">${prev.action ? `<span class="action-dot" style="background:${ACTION_META[prev.action].color}"></span>` : ""}${esc(prev.text)}</span>
${unreadCount ? `<span class="chat-row-badge">${unreadCount}</span>` : ""}
</div>
</div>`;
row.addEventListener("click", () => openThread(conv.conv_key));
list.appendChild(row);
});
}
function buildMetaRow(key, emoji, name, subtitle, pinned) {
const row = el("div", "chat-row");
row.innerHTML = `
<div class="avatar bot">${emoji}</div>
<div class="chat-row-body">
<div class="chat-row-top">
<span class="chat-row-name">${pinned ? "📌 " : ""}${name}${pinned ? '<span class="start-badge">start here</span>' : ""}</span>
<span class="chat-row-time"></span>
</div>
<div class="chat-row-bottom"><span class="chat-row-preview">${subtitle}</span></div>
</div>`;
row.addEventListener("click", () => (key === "router" ? openRouterHQ() : openSandbox()));
return row;
}
// ------------------------------------------------- surface Router HQ --
function maybeSpotlightRouterHQ() {
if (sessionStorage.getItem("hq_spotlight_seen")) return;
sessionStorage.setItem("hq_spotlight_seen", "1");
const row = $("#hqRow");
if (!row) return;
const arrow = el("div", "spotlight-arrow", "👆 Start here — I explain myself");
row.appendChild(arrow);
setTimeout(() => {
arrow.classList.add("out");
setTimeout(() => arrow.remove(), 320);
}, 4200);
}
// ---------------------------------------------------------- thread ----
function openThread(convKey) {
state.convKey = convKey;
const conv = currentConv();
$("#threadAvatar").style.background = conv.color;
$("#threadAvatar").textContent = conv.initials;
$("#threadAvatar").className = "avatar " + (conv.kind === "group" ? "group" : "");
$("#threadName").innerHTML = esc(conv.name) + (conv.verified ? ' <span class="verified-badge">✔️</span>' : "");
$("#threadSubtitle").textContent = conv.subtitle || (conv.kind === "personal" ? subtitleFor(conv.id) : "");
renderFakeComposer();
renderThreadMessages(conv);
showView("thread");
}
function renderFakeComposer() {
$("#composer").innerHTML = `
<div class="composer-fake">
<div class="cbox">
<button class="icon-btn" data-quip="😊 Emoji picker isn't wired up — this inbox is read-only by design.">😊</button>
<span class="ph">This inbox is read-only — try “Try It Yourself”</span>
<button class="icon-btn" data-quip="📎 Nothing to attach — every message here already happened, in the real run.">📎</button>
</div>
<button class="send-btn fake" data-quip="🎤 No mic input — type a message in “Try It Yourself” instead." aria-label="Voice message">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 15a3 3 0 003-3V6a3 3 0 10-6 0v6a3 3 0 003 3z"/><path d="M19 11a1 1 0 00-2 0 5 5 0 01-10 0 1 1 0 00-2 0 7 7 0 006 6.93V20H9a1 1 0 000 2h6a1 1 0 000-2h-2v-2.07A7 7 0 0019 11z"/></svg>
</button>
</div>`;
$$("[data-quip]", $("#composer")).forEach((btn) => btn.addEventListener("click", () => toast(btn.dataset.quip)));
}
function subtitleFor(id) {
const seed = id.split("_")[1] ? parseInt(id.split("_")[1], 10) : 0;
const opts = ["online", "last seen today at " + nowClock(), "last seen recently"];
return opts[seed % opts.length];
}
function renderThreadMessages(conv) {
const box = $("#threadMessages");
box.innerHTML = "";
let lastDate = "";
conv.messages.forEach((m) => {
if (m.date && m.date !== lastDate) {
box.appendChild(el("div", "date-chip", esc(m.date)));
lastDate = m.date;
}
box.appendChild(renderBubble(m, conv));
});
box.scrollTop = box.scrollHeight;
}
function mediaCardHTML(media) {
if (!media) return "";
if (media.type === "image") {
return `<div class="media-card">
<div class="media-visual"><span class="media-icon">🖼️</span><div style="font-size:.72rem;line-height:1.35"><b>${esc(media.visual_type || "image")}</b><br>${esc((media.summary || "").slice(0, 70))}</div></div>
<div class="media-cap">OCR: “${esc((media.ocr_text || "").slice(0, 90))}${(media.ocr_text || "").length > 90 ? "…" : ""}”</div>
</div>`;
}
const bars = Array.from({ length: 26 }, (_, i) => `<span style="height:${6 + Math.round(Math.abs(Math.sin(i * 1.7)) * 16)}px"></span>`).join("");
return `<div class="media-card voice">
<div class="media-visual"><span class="media-icon">🎤</span><div class="waveform">${bars}</div><span style="font-size:.72rem">0:0${Math.max(3, (media.transcript || "").length % 6 + 3)}</span></div>
<div class="media-cap">“${esc((media.transcript || "").slice(0, 90))}${(media.transcript || "").length > 90 ? "…" : ""}”</div>
</div>`;
}
function renderBubble(m, conv, opts) {
opts = opts || {};
const out = !!opts.out;
const row = el("div", "bubble-row " + (out ? "out" : "in"));
const b = el("div", "bubble " + (out ? "out" : "in") + (m.decision ? " decisioned" : ""));
if (m.decision) b.style.setProperty("--action-color", ACTION_META[m.decision.action].color);
b.dataset.msgId = m.message_id;
let senderHTML = "";
if (!out && conv && conv.kind === "group" && m.sender_name) {
senderHTML = `<span class="sender-name" style="color:${m.senderColor || conv.color}">${esc(m.sender_name)}</span>`;
}
const ticksHTML = out ? tickSVG(m.tickState || "read") : "";
b.innerHTML = `
${senderHTML}
${mediaCardHTML(m.media)}
<span class="btext">${esc(m.text || (m.media ? "" : ""))}</span>
<span class="bmeta"><span class="btime">${esc(m.time)}</span>${ticksHTML}</span>
${m.decision ? decisionTagHTML(m.decision) : ""}
`;
if (m.decision) {
b.addEventListener("click", () => openPipelineSheet(m, conv));
}
row.appendChild(b);
return row;
}
function decisionTagHTML(d) {
const meta = ACTION_META[d.action];
return `<div class="decision-tag" style="--action-color:${meta.color}"><span class="dot"></span>${meta.icon} ${meta.label} · tap for the trace</div>`;
}
function tickSVG(tickState) {
const cls = tickState === "read" ? "tick read" : "tick";
return `<svg class="${cls}" viewBox="0 0 16 11"><path class="stroke" d="M1 6l3 3 7-8"/>${tickState !== "sent" ? '<path class="stroke" d="M5 6l3 3 7-8"/>' : ""}</svg>`;
}
// ---------------------------------------------------- pipeline sheet --
function openPipelineSheet(m, conv) {
const d = m.decision;
const meta = ACTION_META[d.action];
const overlay = $("#sheetOverlay");
const sheet = $("#pipelineSheet");
sheet.style.setProperty("--action-color", meta.color);
$("#sheetTitle").textContent = "How this got routed";
$("#sheetMsgPreview").textContent = "“" + (m.text || (m.media ? m.media.summary : "")).slice(0, 90) + "”";
$("#actionBadge").style.setProperty("--action-color", meta.color);
$("#actionBadge").innerHTML = `${meta.icon} ${meta.label} <span style="opacity:.6">· ${esc(titleize(d.message_type))}</span>`;
const band = BANDS[d.action];
const pct = ((d.confidence - band[0]) / (band[1] - band[0])) * 100;
$("#confGauge").innerHTML = `
<div class="cg-label"><span>confidence band ${band[0].toFixed(2)}${band[1].toFixed(2)}</span><span>${d.confidence.toFixed(2)}</span></div>
<div class="cg-track"><div class="cg-band" style="left:0;right:0"></div><div class="cg-dot" style="left:${clamp01(pct / 100) * 100}%"></div></div>`;
const steps = buildFlowSteps(m, d, conv);
$("#flowRail").innerHTML = steps.map((s, i) => `
<div class="flow-node ${s.overrideHere ? "override" : ""}" data-i="${i}">${s.icon}</div>
${i < steps.length - 1 ? `<div class="flow-line ${s.overrideHere ? "dashed" : ""}" data-i="${i}"><div class="fill"></div></div>` : ""}
`).join("");
const railNodes = $$(".flow-node", $("#flowRail"));
railNodes[railNodes.length - 1].classList.add("final");
$("#flowSteps").innerHTML = steps.map((s, i) => `
<div class="flow-step" data-i="${i}"><h4>${s.icon} ${s.title}</h4><div class="fs-body">${s.body}</div></div>`).join("");
overlay.classList.remove("hidden"); requestAnimationFrame(() => overlay.classList.add("show"));
sheet.classList.remove("hidden"); requestAnimationFrame(() => sheet.classList.add("show"));
animateFlow(steps.length);
// wire evidence chips after render
$$(".evid-chip", $("#flowSteps")).forEach((chip) => {
chip.addEventListener("click", () => {
const prev = chip.nextElementSibling;
if (prev && prev.classList.contains("evid-preview")) prev.classList.toggle("show");
});
});
}
function animateFlow(n) {
const nodes = $$(".flow-node", $("#flowRail"));
const lines = $$(".flow-line", $("#flowRail"));
const steps = $$(".flow-step", $("#flowSteps"));
const stepDelay = reduceMotion ? 0 : 420;
nodes.forEach((x) => x.classList.remove("active"));
steps.forEach((x) => x.classList.remove("active"));
lines.forEach((x) => x.querySelector(".fill").style.height = "0%");
let i = 0;
function tick() {
if (i >= n) return;
nodes[i].classList.add("active");
steps[i].classList.add("active");
if (lines[i]) requestAnimationFrame(() => (lines[i].querySelector(".fill").style.height = "100%"));
i++;
if (i < n) setTimeout(tick, stepDelay);
}
setTimeout(tick, reduceMotion ? 0 : 120);
}
function buildFlowSteps(m, d, conv) {
const u = currentUser();
const steps = [];
const histCount = u.conversations.reduce((s, c) => s + c.messages.filter((x) => !x.is_new).length, 0);
steps.push({
icon: "📦", title: "Bundle — one context block per user",
body: `Built once for <strong>${esc(u.name)}</strong>: ${histCount} past messages, quiet hours <strong>${esc(u.profile.do_not_disturb_window)}</strong>, dismissal rate ${(u.profile.dismissal_rate * 100).toFixed(0)}%. This whole batch shares one prompt.`,
});
if (m.media) {
const mm = m.media;
const flags = [
mm.contains_qr && "QR code", mm.contains_payment_request && "payment request",
mm.contains_urgency_language && "urgency language", mm.contains_otp_or_credential_request && "OTP/credential ask",
].filter(Boolean);
steps.push({
icon: mm.type === "image" ? "🖼️" : "🎤", title: mm.type === "image" ? "Media — OCR" : "Media — transcription",
body: `${mm.type === "image" ? "OCR" : "Whisper-style transcript"} read: “${esc((mm.ocr_text || mm.transcript || "").slice(0, 110))}”. ${flags.length ? `Flags: <strong>${flags.join(", ")}</strong>.` : "No risk flags in the media itself."}`,
});
}
const sigs = d.signals_relied_on || [];
steps.push({
icon: "🛡️", title: "Rules — deterministic, no LLM",
body: `${sigs.length ? `Signals this decision leaned on: <div class="chip-row">${sigs.map((s) => `<span class="sig-chip ${d.override_rule && s.includes(d.override_rule.split("_")[0]) ? "hot" : ""}">${esc(s)}</span>`).join("")}</div>` : "No individual signal dominated — a blend of context."}${d.override_rule ? `<div class="override-banner">⚡ HARD OVERRIDE: <code>${esc(d.override_rule)}</code> — this bypassed the model. Four rules like this exist specifically because a fluent, confident message can itself be the attack.</div>` : ""}`,
overrideHere: !!d.override_rule,
});
steps.push({
icon: "🧠", title: d.source === "rule_override" ? "Router — model bypassed" : "Router — gemini-3.5-flash-lite",
body: d.source === "rule_override"
? `The rule decided the <em>action</em>, but the model's evidence selection was kept regardless — an override constrains the verdict, not the reasoning behind it.`
: `Reasoned in the same call as the rest of ${esc(u.name)}'s batch, temperature 0, structured JSON out.${d.adjudicated ? ` Escalated to the adjudicator model over a rule/model disagreement: “${esc(d.adjudicator_note || "")}”` : ""}`,
});
const evid = d.evidence_message_ids || [];
const evidHTML = evid.length && evid[0] !== "none"
? evid.map((eid) => {
const e = state.data.evidence[eid];
if (!e) return "";
return `<span class="evid-chip" data-eid="${eid}">📎 ${esc(eid)}</span><div class="evid-preview">“${esc(e.text.slice(0, 140))}” — ${esc(e.sender_name)}, ${esc(e.date)}<div class="ep-meta">opened=${e.event.opened} replied=${e.event.replied} dismissed=${e.event.dismissed} reported=${e.event.reported}</div></div>`;
}).join("")
: `<span class="sig-chip">no historical evidence — write it as "none", not a guess</span>`;
steps.push({
icon: "✅", title: "Normalize — semantic consistency check",
body: `Confidence clamped into the <strong>${d.action}</strong> band, enum validated, every phrase in the reason checked against the row's real signals.${evidHTML}
<div class="reason-final">“${esc(d.reason)}”<span class="rf-check">✓ consistency check passed</span></div>`,
});
return steps;
}
function closeSheet() {
$("#sheetOverlay").classList.remove("show");
$("#pipelineSheet").classList.remove("show");
setTimeout(() => { $("#sheetOverlay").classList.add("hidden"); $("#pipelineSheet").classList.add("hidden"); }, 380);
}
function initSheet() {
$("#sheetOverlay").addEventListener("click", closeSheet);
$("#sheetClose").addEventListener("click", closeSheet);
}
// ---------------------------------------------------------- deep link
function deepLink(userId, convKey, messageId) {
state.userId = userId;
$("#userSwitch").value = userId;
renderChatList();
openThread(convKey);
showView("thread");
setTimeout(() => {
const bubble = $(`.bubble[data-msg-id="${messageId}"]`);
if (bubble) {
bubble.scrollIntoView({ block: "center", behavior: reduceMotion ? "auto" : "smooth" });
bubble.style.transition = "box-shadow .3s ease";
bubble.style.boxShadow = "0 0 0 3px var(--brand-teal)";
setTimeout(() => (bubble.style.boxShadow = ""), 1400);
setTimeout(() => bubble.click(), reduceMotion ? 0 : 550);
}
}, reduceMotion ? 0 : 340);
}
// ============================================================
// Router HQ — a quirky pipeline explainer that lives IN a chat
// ============================================================
const HQ_INTRO = [
"Hey — I'm the router. Yes, the thing that just decided whether your last WhatsApp message deserved your attention. I live inside 5 phases and a few hard rules. Want the tour?",
"Quick numbers before we start: 110 messages, 32 people, 32 API calls — one per person, not per message. $0 spent. 93.3% action accuracy on the held-out samples.",
"Pick a thread below, or open any message bubble in a real chat above — the little dashed outline means I have a full trace for it.",
];
const HQ_REPLIES = [
{
chip: "🛡️ Show me a scam you caught",
user: "Show me a scam you caught",
bot: [
"This one's my favourite. A message asking to \"verify OTP now or your profile will be limited\" landed in Vihaan's Myntra group thread.",
"I didn't even ask the model. `otp_or_credential_request` is one of 4 hard overrides — the kind of message that's dangerous *because* it sounds urgent and official. Confident text talking a model into the wrong answer is exactly the failure mode a rule has to catch before generation happens.",
],
link: { label: "Open the trace →", userId: "u_004", convKey: "group:group_005", messageId: "msg_044" },
},
{
chip: "🧮 Why only 32 calls for 110 messages?",
user: "Why only 32 calls for 110 messages?",
bot: [
"Free-tier quota was the real constraint, not latency. I batch by *person*, not by message — one prompt per user with their whole profile, quiet hours, history and business relationships already inside it.",
"That's the 32 people in the dropdown above, by the way — every single one is a real routing call from the actual run, not a mock.",
"Bonus: batching means near-duplicate messages land in the same context, so I can tell a genuine admin notice apart from a copycat asking for a screenshot via a sketchy link — same batch, side by side.",
],
},
{
chip: "🤔 Tell me about a mistake",
user: "Tell me about a mistake you made",
bot: [
"Early on I auto-muted anyone with a bad cross-user reputation, no matter what they said. Sounded safe.",
"The labelled data disagreed: the worst-reputation sender in the dataset sent both a scam *and* a completely harmless message to different people — and only the scam was meant to be muted.",
"So reputation got demoted to a ceiling, not a verdict: it can cap a decision at 'digest', but it can never mute by itself anymore. Accuracy went from 90.0% back up to 93.3% on that one fix.",
],
},
{
chip: "🎬 Play my full trace live",
user: "Play my full trace live",
bot: ["Loading the OTP-scam trace — watch the left rail light up phase by phase.", "That dashed red line means the router rule fired and skipped the model for the *verdict* — but kept its evidence, because evidence is scored output, not a safety call."],
link: { label: "▶ Watch it route →", userId: "u_004", convKey: "group:group_005", messageId: "msg_044" },
},
{
chip: "🔍 A false claim you caught yourself",
user: "Did you ever catch yourself lying?",
bot: [
"Fair question. Every generated reason gets checked phrase-by-phrase against the row's real signals before it ships — not just \"is this valid JSON\", but \"is this true\".",
"Across the full run it rejected 15 reasons that were well-formed but false — including calling an unverified pharmacy \"a verified business\", and calling a one-to-one message about holding a jacket \"marketing the user opted out of\".",
"One targeted re-ask, then a deterministic fallback that can't contradict the row. No hallucinated justification ships.",
],
},
];
function openRouterHQ() {
state.convKey = "meta:router";
$("#threadAvatar").className = "avatar bot";
$("#threadAvatar").style.background = "";
$("#threadAvatar").textContent = "🧭";
$("#threadName").textContent = "Router HQ";
$("#threadSubtitle").textContent = "always online · replies instantly";
const box = $("#threadMessages");
box.innerHTML = "";
$("#composer").innerHTML = `<div class="locked-note">Tap a suggestion below — this bot only speaks in facts from the real run</div>`;
showView("thread");
playHQIntro(box);
}
function botBubble(text) {
const row = el("div", "bubble-row in");
const b = el("div", "bubble in");
b.innerHTML = `<span class="btext">${text}</span><span class="bmeta"><span class="btime">${nowClock()}</span></span>`;
row.appendChild(b);
return row;
}
function userBubble(text) {
const row = el("div", "bubble-row out");
const b = el("div", "bubble out");
b.innerHTML = `<span class="btext">${esc(text)}</span><span class="bmeta"><span class="btime">${nowClock()}</span>${tickSVG("sent")}</span>`;
row.appendChild(b);
setTimeout(() => { b.querySelector(".tick").outerHTML = tickSVG("delivered"); }, 350);
setTimeout(() => { b.querySelector(".tick").outerHTML = tickSVG("read"); }, 850);
return row;
}
function typingIndicator() {
const row = el("div", "typing-row");
row.innerHTML = `<div class="typing-bubble"><span></span><span></span><span></span></div>`;
return row;
}
function playHQIntro(box) {
let i = 0;
function next() {
if (i >= HQ_INTRO.length) { renderHQChips(box); return; }
const t = typingIndicator();
box.appendChild(t); box.scrollTop = box.scrollHeight;
setTimeout(() => {
t.remove();
box.appendChild(botBubble(HQ_INTRO[i]));
box.scrollTop = box.scrollHeight;
i++;
setTimeout(next, reduceMotion ? 30 : 480);
}, reduceMotion ? 30 : 620);
}
next();
}
function renderHQChips(box) {
const wrap = el("div", "quick-replies");
HQ_REPLIES.forEach((r) => {
const chip = el("button", "qr-chip", r.chip);
chip.addEventListener("click", () => {
wrap.querySelectorAll(".qr-chip").forEach((c) => (c.disabled = true));
box.appendChild(userBubble(r.user));
box.scrollTop = box.scrollHeight;
const t = typingIndicator();
setTimeout(() => { box.appendChild(t); box.scrollTop = box.scrollHeight; }, 300);
setTimeout(() => {
t.remove();
let j = 0;
function nextLine() {
if (j >= r.bot.length) {
if (r.link) {
const row = el("div", "bubble-row in");
const b = el("div", "bubble in");
const btn = el("button", "deep-link-btn", r.link.label);
btn.addEventListener("click", () => deepLink(r.link.userId, r.link.convKey, r.link.messageId));
b.innerHTML = `<span class="btext">One tap away:</span>`;
b.appendChild(btn);
row.appendChild(b);
box.appendChild(row);
}
wrap.querySelectorAll(".qr-chip").forEach((c) => (c.disabled = false));
box.appendChild(wrap);
box.scrollTop = box.scrollHeight;
return;
}
box.appendChild(botBubble(r.bot[j]));
box.scrollTop = box.scrollHeight;
j++;
setTimeout(nextLine, reduceMotion ? 20 : 520);
}
nextLine();
}, reduceMotion ? 50 : 900);
});
wrap.appendChild(chip);
});
box.appendChild(wrap);
box.scrollTop = box.scrollHeight;
}
// ============================================================
// Try It Yourself — live sandbox with an honestly-labelled
// simplified heuristic (not the real 30-signal pipeline)
// ============================================================
function openSandbox() {
state.convKey = "meta:sandbox";
$("#threadAvatar").className = "avatar bot";
$("#threadAvatar").style.background = "";
$("#threadAvatar").textContent = "✍️";
$("#threadName").textContent = "Try It Yourself";
$("#threadSubtitle").textContent = "simplified live demo";
const box = $("#threadMessages");
box.innerHTML = "";
box.appendChild(botBubble("Type any WhatsApp-style message and I'll route it live — right here, in your browser, no server call."));
box.appendChild(botBubble("Heads up: this is a ~15-line keyword heuristic for the demo, not the real 5-phase pipeline. For the real thing, tap any dashed message bubble in an actual chat above."));
box.scrollTop = box.scrollHeight;
$("#composer").innerHTML = `
<div class="cbox"><input id="sandboxInput" placeholder="e.g. Pay ₹500 now or lose your seat, scan this QR" maxlength="180" /></div>
<button class="send-btn" id="sandboxSend">${SEND_ICON}</button>`;
const input = $("#sandboxInput");
const send = () => {
const val = input.value.trim();
if (!val) return;
box.appendChild(userBubble(val));
box.scrollTop = box.scrollHeight;
input.value = "";
const t = typingIndicator();
setTimeout(() => { box.appendChild(t); box.scrollTop = box.scrollHeight; }, 200);
setTimeout(() => {
t.remove();
const r = simulateRoute(val);
const row = el("div", "bubble-row in");
const b = el("div", "bubble in");
b.style.setProperty("--action-color", ACTION_META[r.action].color);
b.innerHTML = `<span class="btext">${r.icon} <strong>${ACTION_META[r.action].label}</strong> · ${titleize(r.message_type)}<br><span style="font-size:.86rem;color:var(--wa-list-subtitle)">${esc(r.reason)}</span></span>
<span class="bmeta"><span class="btime">${nowClock()}</span></span>
<div class="decision-tag" style="--action-color:${ACTION_META[r.action].color}"><span class="dot"></span>confidence ${r.confidence.toFixed(2)} · toy heuristic</div>`;
row.appendChild(b);
box.appendChild(row);
box.scrollTop = box.scrollHeight;
}, reduceMotion ? 30 : 700);
};
$("#sandboxSend").addEventListener("click", send);
input.addEventListener("keydown", (e) => { if (e.key === "Enter") send(); });
showView("thread");
setTimeout(() => input.focus(), 300);
}
const SEND_ICON = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 20l18-8L3 4v6l12 2-12 2z"/></svg>';
function simulateRoute(text) {
const t = text.toLowerCase();
const has = (arr) => arr.some((w) => t.includes(w));
if (has(["otp", "verify your account", "bank detail", "card number", "cvv", "pin number", "click this link and login"])) {
return { action: "mute", message_type: "scam", confidence: 0.83, icon: "🔇", reason: "Requests a credential or OTP through an unverified flow — mirrors the OTP hard override." };
}
if (has(["forward this", "send to", "people you care about", "10 people", "share this", "chain"])) {
return { action: "mute", message_type: "forward", confidence: 0.85, icon: "🔇", reason: "Reads like a chain-forward asking to reshare — the pattern usually gets ignored anyway." };
}
if (has(["% off", "sale", "discount", "buy now", "offer", "limited stock", "flat "])) {
return { action: "digest", message_type: "promotion", confidence: 0.8, icon: "🕓", reason: "Promotional content — plausibly useful, but nothing time-critical here." };
}
if (has(["urgent", "immediately", "asap", "right now", "emergency", "in 10 min", "in 20 min", "call now"])) {
return { action: "notify", message_type: "urgent", confidence: 0.88, icon: "🔔", reason: "Deadline or urgency language pushes this to interrupt-now." };
}
if (has(["good morning", "good night", "gm", "happy diwali", "happy new year", "god bless"])) {
return { action: "mute", message_type: "greeting", confidence: 0.84, icon: "🔇", reason: "Low-content greeting text — the kind that's routinely dismissed." };
}
if (has(["payment", "due", "pay by", "invoice", "fee", "penalty"])) {
return { action: "notify", message_type: "payment", confidence: 0.87, icon: "🔔", reason: "Mentions a concrete payment obligation — worth surfacing even without extra urgency words." };
}
return { action: "digest", message_type: "personal", confidence: 0.79, icon: "🕓", reason: "No strong signal matched this toy heuristic — the real pipeline would pull in sender history, DND state and cross-user reputation here." };
}
// ---------------------------------------------------------------- init
function init() {
initTheme();
initSheet();
$("#backBtn").addEventListener("click", () => showView("list"));
const el2 = document.getElementById("app-data");
if (el2) {
boot(JSON.parse(el2.textContent));
} else {
fetch("data.json").then((r) => r.json()).then(boot).catch((err) => {
$("#chatList").innerHTML = `<div style="padding:30px;color:var(--wa-list-subtitle);font-size:.85rem">Could not load data.json (${esc(String(err))}). Run webapp/build_data.py and serve this folder over HTTP.</div>`;
});
}
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
else init();
})();