| |
| |
| |
| |
| |
| |
| |
| |
| (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); |
| } |
|
|
| |
| 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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; |
| } |
|
|
| |
| 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); |
| }); |
| } |
|
|
| |
| 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"); |
| }); |
| } |
|
|
| |
| 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()); |
| } |
|
|
| |
| 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" }); |
| } |
| }); |
| } |
|
|
| |
| 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()); |
| } |
| }); |
| }); |
| } |
|
|
| |
| 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("")}`; |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| function showView(which) { |
| state.view = which; |
| $("#viewChatList").classList.toggle("hidden", which !== "list"); |
| $("#viewThread").classList.toggle("hidden", which !== "thread"); |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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>`; |
| } |
|
|
| |
| 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); |
|
|
| |
| $$(".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); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| 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." }; |
| } |
|
|
| |
| 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(); |
| })(); |
|
|