chat-birdseye / app.js
josrano's picture
Add universal bookmarklet extractor + ChatGPT/Claude export-file auto-detect
664b8ad verified
Raw
History Blame Contribute Delete
30 kB
"use strict";
// ---------- State ----------
let CHATS = [];
let ACTIVE = null; // chat currently open in modal
let LLM_ON = false;
const $ = (s) => document.querySelector(s);
const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; };
const fmtAgo = (iso) => {
const s = (Date.now() - new Date(iso).getTime()) / 1000;
if (s < 60) return "just now";
if (s < 3600) return Math.floor(s / 60) + "m ago";
if (s < 86400) return Math.floor(s / 3600) + "h ago";
return Math.floor(s / 86400) + "d ago";
};
// ---------- Heuristic KPIs ----------
const POS = ["good", "great", "love", "loving", "perfect", "thanks", "thank", "excited", "happy", "works", "worked", "awesome", "fast", "easy", "upgrade", "sign", "book", "quote", "ready", "move forward"];
const NEG = ["refund", "cancel", "frustrating", "bug", "error", "broken", "switch", "competitor", "downgrade", "churn", "complaint", "fail", "slow", "crash", "500", "angry", "disappointed"];
const BUY = ["price", "pricing", "quote", "demo", "subscribe", "upgrade", "sign up", "contract", "annual", "seats", "tier", "pro plan", "enterprise", "paid", "book"];
const TOPIC_KW = {
"sales": ["price", "pricing", "quote", "demo", "upgrade", "subscribe", "contract", "seats", "enterprise", "annual", "paid tier", "sign"],
"support": ["bug", "error", "500", "crash", "broken", "not working", "fail", "help", "issue", "ticket"],
"churn-risk": ["cancel", "refund", "downgrade", "switch", "competitor", "budget", "leave"],
"onboarding": ["api", "integration", "setup", "getting started", "how do i", "install", "connect", "401"],
"feedback": ["feature", "request", "suggestion", "would be great", "wish", "dark mode"],
"casual": ["recipe", "hi", "hello", "thanks", "chat"]
};
function scoreHeuristic(chat) {
const text = chat.messages.map(m => (m.role + ": " + m.content)).join(" \n ").toLowerCase();
const pos = POS.reduce((n, w) => n + (text.split(w).length - 1), 0);
const neg = NEG.reduce((n, w) => n + (text.split(w).length - 1), 0);
const buy = BUY.reduce((n, w) => n + (text.split(w).length - 1), 0);
// Sentiment: -1..1
let sent = (pos - neg) / Math.max(1, pos + neg);
const sentiment = sent > 0.25 ? "positive" : sent < -0.25 ? "negative" : "neutral";
// Conversion likelihood 0..100
let conv = 35 + buy * 12 - neg * 10 + (sentiment === "positive" ? 12 : 0) + (sentiment === "negative" ? -10 : 0);
// last-message momentum: customer asking to book/quote/sign near the end boosts
const lastCustomer = [...chat.messages].reverse().find(m => m.role === "customer" || m.role === "user");
if (lastCustomer && ["book", "quote", "ready", "move forward", "sign", "upgrade"].some(w => lastCustomer.content.toLowerCase().includes(w))) conv += 15;
conv = Math.max(2, Math.min(99, Math.round(conv)));
// Topic
let best = "casual", bestScore = 0;
for (const [topic, kws] of Object.entries(TOPIC_KW)) {
const sc = kws.reduce((n, w) => n + (text.includes(w) ? 1 : 0), 0);
if (sc > bestScore) { bestScore = sc; best = topic; }
}
if (bestScore === 0) best = "general";
// Engagement
const ts = chat.messages.map(m => new Date(m.ts).getTime()).filter(Boolean);
const gaps = [];
for (let i = 1; i < ts.length; i++) gaps.push((ts[i] - ts[i - 1]) / 60000);
const avgRespMin = gaps.length ? Math.round(gaps.reduce((a, b) => a + b, 0) / gaps.length) : null;
const engagement = {
messageCount: chat.messages.length,
avgRespMin,
lastActiveAgo: fmtAgo(chat.updatedAt || chat.createdAt)
};
return { conversion: conv, sentiment, sentimentScore: +sent.toFixed(2), topic: best, engagement };
}
// ---------- Rendering ----------
function kpisFor(chat) {
return chat._kpis && chat._kpis.source === "llm" ? chat._kpis : (chat._heur || (chat._heur = scoreHeuristic(chat)));
}
function currentList() {
const q = $("#search").value.trim().toLowerCase();
const src = $("#sourceFilter").value;
const sort = $("#sortBy").value;
let list = CHATS.filter(c => {
if (src && c.source !== src) return false;
if (q && !(c.title.toLowerCase().includes(q) || c.messages.some(m => m.content.toLowerCase().includes(q)))) return false;
return true;
});
const k = (c) => kpisFor(c);
list.sort((a, b) => {
if (sort === "updated") return new Date(b.updatedAt) - new Date(a.updatedAt);
if (sort === "conversion") return k(b).conversion - k(a).conversion;
if (sort === "sentiment") return k(b).sentimentScore - k(a).sentimentScore;
if (sort === "engagement") return k(b).engagement.messageCount - k(a).engagement.messageCount;
return 0;
});
return list;
}
function render() {
if (PHYS) return; // physics mode manages its own DOM
const grid = $("#grid");
grid.innerHTML = "";
const list = currentList();
$("#empty").classList.toggle("hidden", list.length > 0);
for (const chat of list) grid.appendChild(tile(chat));
}
function tile(chat) {
const k = kpisFor(chat);
const snip = chat.messages.map(m => m.content).join(" ").slice(0, 240);
const t = el("div", "tile");
t.onclick = () => openModal(chat);
const head = el("div", "tile-head");
head.appendChild(el("div", "traffic", "<i></i><i></i><i></i>"));
head.appendChild(el("div", "tile-title", escapeHtml(chat.title)));
head.appendChild(el("div", "tile-source", escapeHtml(chat.source)));
t.appendChild(head);
const body = el("div", "tile-body");
body.appendChild(el("div", "tile-snip", escapeHtml(snip)));
const bar = el("div", "conv-bar");
bar.appendChild(el("i", null)).style.width = k.conversion + "%";
body.appendChild(bar);
t.appendChild(body);
const foot = el("div", "tile-foot");
foot.appendChild(el("span", "badge conv", `<span class="k">conv</span> <b>${k.conversion}%</b>`));
const sentCls = k.sentiment === "positive" ? "sent-good" : k.sentiment === "negative" ? "sent-bad" : "sent-mid";
const sentDot = k.sentiment === "positive" ? "var(--good)" : k.sentiment === "negative" ? "var(--bad)" : "var(--muted)";
foot.appendChild(el("span", "badge " + sentCls, `<span class="dot" style="background:${sentDot}"></span> ${k.sentiment}`));
foot.appendChild(el("span", "badge", `<span class="k">topic</span> ${escapeHtml(k.topic)}`));
foot.appendChild(el("span", "badge", `<span class="k">msgs</span> ${k.engagement.messageCount}`));
if (k.source === "llm") foot.appendChild(el("span", "llm-tag", "LLM"));
t.appendChild(foot);
return t;
}
// ---------- Modal ----------
function openModal(chat) {
ACTIVE = chat;
const k = kpisFor(chat);
$(".modal-title").textContent = chat.title;
const kpis = $(".modal-kpis"); kpis.innerHTML = "";
kpis.appendChild(kpiCard("Conversion", k.conversion + "%"));
kpis.appendChild(kpiCard("Sentiment", k.sentiment, k.sentimentScore != null ? ("score " + k.sentimentScore) : ""));
kpis.appendChild(kpiCard("Topic / intent", k.topic));
kpis.appendChild(kpiCard("Messages", k.engagement.messageCount));
kpis.appendChild(kpiCard("Avg response", k.engagement.avgRespMin == null ? "—" : k.engagement.avgRespMin + " min"));
kpis.appendChild(kpiCard("Last active", k.engagement.lastActiveAgo));
if (k.source === "llm") kpis.appendChild(kpiCard("Scored by", "LLM", k.model || ""));
const body = $(".modal-body"); body.innerHTML = "";
for (const m of chat.messages) {
const wrap = el("div", "msg " + m.role);
wrap.appendChild(el("div", "who", m.role + " · " + fmtAgo(m.ts)));
wrap.appendChild(el("div", "bubble", escapeHtml(m.content)));
body.appendChild(wrap);
}
$("#llmStatus").textContent = k.source === "llm" ? "Already enriched by LLM." : "";
$("#modal").classList.remove("hidden");
}
function kpiCard(label, value, sub) {
const c = el("div", "kpi");
c.appendChild(el("div", "label", label));
c.appendChild(el("div", "value", escapeHtml(String(value))));
if (sub) c.appendChild(el("div", "hint", escapeHtml(sub)));
return c;
}
function closeModal() { $("#modal").classList.add("hidden"); ACTIVE = null; }
// ---------- LLM scoring ----------
async function runLLM(chat) {
const token = $("#hfToken").value.trim();
const model = $("#llmModel").value.trim() || "Qwen/Qwen2.5-7B-Instruct";
if (!token) { $("#llmStatus").textContent = "Add your HF token in the LLM panel first."; return; }
$("#llmStatus").textContent = "Scoring…";
const transcript = chat.messages.map(m => `${m.role}: ${m.content}`).join("\n");
const sys = "You score a customer chat. Return ONLY compact JSON with keys: conversion (int 0-100), sentiment (one of positive|neutral|negative), sentimentScore (float -1..1), topic (short snake_case label), engagementMessageCount (int), reasoning (one short sentence).";
const user = `Chat title: ${chat.title}\nSource: ${chat.source}\nTranscript:\n${transcript}`;
try {
const r = await fetch("https://router.huggingface.co/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": "Bearer " + token },
body: JSON.stringify({ model, messages: [{ role: "system", content: sys }, { role: "user", content: user }], max_tokens: 220, temperature: 0.2 })
});
if (!r.ok) { const t = await r.text(); throw new Error("HTTP " + r.status + ": " + t.slice(0, 200)); }
const data = await r.json();
const txt = data.choices?.[0]?.message?.content || "";
const m = txt.match(/\{[\s\S]*\}/);
if (!m) throw new Error("No JSON in model output: " + txt.slice(0, 160));
const j = JSON.parse(m[0]);
chat._kpis = {
source: "llm", model,
conversion: Math.max(0, Math.min(100, parseInt(j.conversion, 10) || 0)),
sentiment: ["positive", "neutral", "negative"].includes(j.sentiment) ? j.sentiment : "neutral",
sentimentScore: Number(j.sentimentScore) || 0,
topic: String(j.topic || "general").replace(/\s+/g, "_").toLowerCase(),
engagement: { messageCount: chat.messages.length, avgRespMin: chat._heur?.engagement?.avgRespMin ?? null, lastActiveAgo: fmtAgo(chat.updatedAt || chat.createdAt) },
reasoning: j.reasoning || ""
};
$("#llmStatus").textContent = "LLM scored." + (j.reasoning ? " " + j.reasoning : "");
openModal(chat); // re-render with new kpis
render();
} catch (e) {
$("#llmStatus").textContent = "LLM error: " + e.message;
}
}
// ---------- Load JSON ----------
// ---------- Export-file auto-detecter ----------
// Detects ChatGPT's conversations.json and Claude's export format,
// normalizes to our schema. Returns an array of chats or null if unrecognized.
function normalizeImport(data) {
// Already our schema: array of {id, source, title, messages}
if (Array.isArray(data) && data.length && data[0].messages && Array.isArray(data[0].messages)) {
return data;
}
// ChatGPT export: array of {id, title, mapping: {<uuid>: {message: {author:{role}, content:{content_type, parts}, create_time}}}}
if (Array.isArray(data) && data.length && data[0].mapping) {
return data.map(c => {
const msgs = [];
// walk the mapping tree in order — nodes have .message and .children
const nodes = Object.values(c.mapping || {});
// sort by create_time if available
nodes.sort((a, b) => (a.message?.create_time || 0) - (b.message?.create_time || 0));
for (const n of nodes) {
const m = n.message;
if (!m || !m.content) continue;
const role = m.author?.role === "user" ? "user" : m.author?.role === "assistant" ? "assistant" : m.author?.role || "system";
let content = "";
if (Array.isArray(m.content.parts)) content = m.content.parts.join("\n");
else if (typeof m.content.parts === "string") content = m.content.parts;
else if (m.content.text) content = m.content.text;
if (content && role !== "system" && role !== "tool") {
msgs.push({ role, content, ts: m.create_time ? new Date(m.create_time * 1000).toISOString() : new Date().toISOString() });
}
}
return {
id: c.id || "gpt_" + Math.random().toString(36).slice(2),
source: "ChatGPT",
title: c.title || "(untitled)",
createdAt: c.create_time ? new Date(c.create_time * 1000).toISOString() : new Date().toISOString(),
updatedAt: c.update_time ? new Date(c.update_time * 1000).toISOString() : new Date().toISOString(),
messages: msgs
};
}).filter(c => c.messages.length);
}
// Claude export: typically {conversations: [...]} or array with chat_name/messages
if (data && typeof data === "object" && !Array.isArray(data)) {
let convos = data.conversations || data.chats || data;
if (!Array.isArray(convos) && Array.isArray(data.data)) convos = data.data;
if (Array.isArray(convos) && convos.length) {
return convos.map((c, i) => {
const msgs = (c.messages || c.chat_messages || []).map(m => ({
role: m.sender === "human" || m.role === "user" ? "user" : "assistant",
content: m.text || m.content || "",
ts: m.created_at || m.ts || new Date().toISOString()
})).filter(m => m.content);
return {
id: c.uuid || c.id || "claude_" + i,
source: "Claude",
title: c.name || c.title || c.chat_name || "(untitled)",
createdAt: c.created_at || new Date().toISOString(),
updatedAt: c.updated_at || c.created_at || new Date().toISOString(),
messages: msgs
};
}).filter(c => c.messages.length);
}
}
return null;
}
function loadChats(arr) {
if (!Array.isArray(arr)) throw new Error("JSON must be an array of chats");
if (PHYS) exitPhysics();
const norm = arr.map((c, i) => ({
id: c.id || "c" + i,
source: c.source || "Unknown",
title: c.title || "(untitled)",
createdAt: c.createdAt || new Date().toISOString(),
updatedAt: c.updatedAt || c.createdAt || new Date().toISOString(),
messages: (c.messages || []).map(m => ({ role: m.role || "user", content: m.content || "", ts: m.ts || new Date().toISOString() }))
}));
CHATS = norm;
rebuildSourceFilter();
render();
}
function rebuildSourceFilter() {
const sel = $("#sourceFilter");
const cur = sel.value;
const sources = [...new Set(CHATS.map(c => c.source))].sort();
sel.innerHTML = '<option value="">All sources</option>' + sources.map(s => `<option>${escapeHtml(s)}</option>`).join("");
if (sources.includes(cur)) sel.value = cur;
}
// ---------- Physics: drifting, attaching, emergent services ----------
let PHYS = null; // { raf, tiles:[], services:[], canvas, ctx, w, h, running, svcSeq }
const TOPIC_NOUN = {
sales: "Sales", support: "Support", "churn-risk": "Retention",
onboarding: "Onboarding", feedback: "Feedback", casual: "Community", general: "Ops"
};
const SVC_SUFFIX = ["Orchestrator", "Pipeline", "Engine", "Concierge", "Copilot", "Mesh", "Hub", "Studio", "Forge", "Loom"];
const SVC_VERB = ["auto-composed", "emergent", "self-assembled", "synthesized"];
function serviceName(topics, salt) {
const nouns = [...new Set(topics.map(t => TOPIC_NOUN[t] || t))].slice(0, 2);
const idx = (nouns.join("").length + (salt || 0)) % SVC_SUFFIX.length;
return nouns.join(" ") + " " + SVC_SUFFIX[idx];
}
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function enterPhysics() {
if (PHYS) { exitPhysics(); return; }
const list = currentList();
if (!list.length) return;
PHYS = { raf: 0, tiles: [], services: [], running: true, svcSeq: 0 };
$("#grid").classList.add("hidden");
$("#empty").classList.add("hidden");
$("#stage").classList.remove("hidden");
PHYS.canvas = $("#links");
PHYS.ctx = PHYS.canvas.getContext("2d");
resizeStage();
const bodies = $("#bodies"); bodies.innerHTML = "";
const servicesEl = $("#services"); servicesEl.innerHTML = "";
const W = PHYS.w, H = PHYS.h;
list.forEach((chat, i) => {
const node = tile(chat);
node.classList.add("body");
bodies.appendChild(node);
const ang = (i / list.length) * Math.PI * 2;
const r = 140 + Math.random() * 80;
PHYS.tiles.push({
chat, el: node,
x: W / 2 + Math.cos(ang) * r - 120,
y: H / 2 + Math.sin(ang) * r - 110,
vx: (Math.random() - 0.5) * 0.8, vy: (Math.random() - 0.5) * 0.8,
w: 240, h: 200, service: null, attached: false
});
});
$("#playBtn").textContent = "⏸ Pause";
$("#playBtn").classList.add("on");
PHYS.raf = requestAnimationFrame(physicsStep);
}
function exitPhysics() {
if (!PHYS) return;
PHYS.running = false;
cancelAnimationFrame(PHYS.raf);
$("#stage").classList.add("hidden");
$("#grid").classList.remove("hidden");
$("#playBtn").textContent = "▶ Play";
$("#playBtn").classList.remove("on");
$("#bodies").innerHTML = "";
$("#services").innerHTML = "";
PHYS = null;
render();
}
function resizeStage() {
if (!PHYS) return;
const stage = $("#stage");
const r = stage.getBoundingClientRect();
PHYS.w = r.width; PHYS.h = r.height;
const dpr = window.devicePixelRatio || 1;
PHYS.canvas.width = r.width * dpr; PHYS.canvas.height = r.height * dpr;
PHYS.canvas.style.width = r.width + "px"; PHYS.canvas.style.height = r.height + "px";
PHYS.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function attachable(a, b) {
const ka = kpisFor(a.chat), kb = kpisFor(b.chat);
if (ka.topic === kb.topic) return true;
// high-intent chats also attract each other regardless of topic
if (ka.conversion >= 65 && kb.conversion >= 65) return true;
if (ka.sentiment === kb.sentiment && ka.sentiment !== "neutral") return true;
return false;
}
function physicsStep() {
if (!PHYS || !PHYS.running) return;
const T = PHYS.tiles, S = PHYS.services, W = PHYS.w, H = PHYS.h;
for (let i = 0; i < T.length; i++) {
const a = T[i];
// gentle centering
a.vx += (W / 2 - (a.x + a.w / 2)) * 0.0003;
a.vy += (H / 2 - (a.y + a.h / 2)) * 0.0003;
// tiny brownian drift so they "move by themselves"
a.vx += (Math.random() - 0.5) * 0.05;
a.vy += (Math.random() - 0.5) * 0.05;
for (let j = 0; j < T.length; j++) {
if (i === j) continue;
const b = T[j];
const dx = (b.x + b.w / 2) - (a.x + a.w / 2);
const dy = (b.y + b.h / 2) - (a.y + a.h / 2);
const d2 = dx * dx + dy * dy; const d = Math.sqrt(d2) || 0.01;
// universal repulsion (prevents overlap)
const rep = 14000 / Math.max(d2, 400);
a.vx -= (dx / d) * rep; a.vy -= (dy / d) * rep;
// attraction when attachable & within reach
if (attachable(a, b) && d < 320) {
const spring = (d - 170) * 0.0025;
a.vx += (dx / d) * spring; a.vy += (dy / d) * spring;
}
}
// pull toward own service centroid
if (a.service) {
const dx = a.service.x - (a.x + a.w / 2);
const dy = a.service.y - (a.y + a.h / 2);
const d = Math.hypot(dx, dy) || 0.01;
const spring = (d - 70) * 0.006;
a.vx += (dx / d) * spring; a.vy += (dy / d) * spring;
}
}
// integrate + damping + bounds
for (const a of T) {
a.vx *= 0.9; a.vy *= 0.9;
a.x += a.vx; a.y += a.vy;
a.x = clamp(a.x, 12, W - a.w - 12);
a.y = clamp(a.y, 12, H - a.h - 12);
a.el.style.transform = `translate(${a.x}px, ${a.y}px)`;
}
// services drift toward their member centroid
for (const s of S) {
if (s.members.length) {
let cx = 0, cy = 0;
for (const m of s.members) { cx += m.x + m.w / 2; cy += m.y + m.h / 2; }
cx /= s.members.length; cy /= s.members.length;
s.vx += (cx - s.x) * 0.03; s.vy += (cy - s.y) * 0.03;
}
s.vx *= 0.85; s.vy *= 0.85;
s.x += s.vx; s.y += s.vy;
s.x = clamp(s.x, 12, W - 200);
s.y = clamp(s.y, 12, H - 90);
s.el.style.transform = `translate(${s.x - 100}px, ${s.y - 40}px)`;
}
reconcileClusters();
drawLinks();
PHYS.raf = requestAnimationFrame(physicsStep);
}
// union-find over tiles to detect attached clusters
function findComponents() {
const T = PHYS.tiles, n = T.length;
const parent = Array.from({ length: n }, (_, i) => i);
const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent[ra] = rb; };
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const a = T[i], b = T[j];
const dx = (a.x + a.w / 2) - (b.x + b.w / 2);
const dy = (a.y + a.h / 2) - (b.y + b.h / 2);
const d = Math.hypot(dx, dy);
if (attachable(a, b) && d < 230) union(i, j);
}
}
const comps = {};
for (let i = 0; i < n; i++) { const r = find(i); (comps[r] = comps[r] || []).push(i); }
// mark attached visual state
for (let i = 0; i < n; i++) {
const r = find(i);
T[i].attached = comps[r].length > 1;
T[i].el.classList.toggle("attached", T[i].attached);
}
return Object.values(comps).map(idx => idx.map(i => T[i]));
}
function reconcileClusters() {
const comps = findComponents();
const S = PHYS.services;
// map each member chat id -> component
const compOf = new Map();
comps.forEach((members, ci) => members.forEach(m => compOf.set(m.chat.id, ci)));
// services whose members no longer form a ≥3 cluster are removed
const toRemove = [];
for (const s of S) {
const stillBig = comps.some(members => members.length >= 3 && members.every(m => compOf.get(m.chat.id) === compOf.get(s.members[0].chat.id)));
if (!stillBig) toRemove.push(s);
}
toRemove.forEach(removeService);
// for each big component, ensure exactly one service
for (const members of comps) {
if (members.length < 3) continue;
const root = compOf.get(members[0].chat.id);
const existing = S.filter(s => s.members.length && compOf.get(s.members[0].chat.id) === root);
if (existing.length) {
const svc = existing[0];
svc.members = members;
members.forEach(m => m.service = svc);
existing.slice(1).forEach(removeService);
refreshServiceCard(svc);
} else {
spawnService(members);
}
}
}
function spawnService(members) {
const topics = [...new Set(members.map(m => kpisFor(m.chat).topic))];
const salt = PHYS.svcSeq++;
const name = serviceName(topics, salt);
const avgConv = Math.round(members.reduce((s, m) => s + kpisFor(m.chat).conversion, 0) / members.length);
const verb = SVC_VERB[salt % SVC_VERB.length];
const desc = `${verb} from ${members.length} chats · ${topics.join(", ")} · avg ${avgConv}% conversion`;
let cx = 0, cy = 0;
for (const m of members) { cx += m.x + m.w / 2; cy += m.y + m.h / 2; }
cx /= members.length; cy /= members.length;
const node = el("div", "service");
node.innerHTML = `
<span class="svc-new">NEW SERVICE</span>
<div class="svc-spark">✨</div>
<div class="svc-name">${escapeHtml(name)}</div>
<div class="svc-desc">${escapeHtml(desc)}</div>
<div class="svc-meta">
<span class="badge"><span class="k">chats</span> ${members.length}</span>
<span class="badge conv"><span class="k">avg conv</span> <b>${avgConv}%</b></span>
</div>`;
node.onclick = () => openServiceModal(name, desc, members);
$("#services").appendChild(node);
const s = { id: "svc" + salt, name, desc, topics: new Set(topics), members: [...members], x: cx, y: cy, vx: 0, vy: 0, el: node, avgConv };
members.forEach(m => m.service = s);
PHYS.services.push(s);
}
function refreshServiceCard(s) {
const topics = [...new Set(s.members.map(m => kpisFor(m.chat).topic))];
s.topics = new Set(topics);
s.avgConv = Math.round(s.members.reduce((acc, m) => acc + kpisFor(m.chat).conversion, 0) / s.members.length);
const meta = s.el.querySelector(".svc-meta");
if (meta) meta.innerHTML = `<span class="badge"><span class="k">chats</span> ${s.members.length}</span><span class="badge conv"><span class="k">avg conv</span> <b>${s.avgConv}%</b></span>`;
const desc = s.el.querySelector(".svc-desc");
if (desc) desc.textContent = `emergent from ${s.members.length} chats · ${topics.join(", ")} · avg ${s.avgConv}% conversion`;
}
function removeService(s) {
s.el.remove();
s.members.forEach(m => { if (m.service === s) m.service = null; });
const i = PHYS.services.indexOf(s);
if (i >= 0) PHYS.services.splice(i, 1);
}
function openServiceModal(name, desc, members) {
ACTIVE = null;
$(".modal-title").textContent = "✨ " + name;
const kpis = $(".modal-kpis"); kpis.innerHTML = "";
const avgConv = Math.round(members.reduce((s, m) => s + kpisFor(m.chat).conversion, 0) / members.length);
kpis.appendChild(kpiCard("Type", "Emergent service"));
kpis.appendChild(kpiCard("Members", members.length + " chats"));
kpis.appendChild(kpiCard("Avg conversion", avgConv + "%"));
kpis.appendChild(kpiCard("Topics", [...new Set(members.map(m => kpisFor(m.chat).topic))].join(", ")));
const body = $(".modal-body"); body.innerHTML = `<p class="hint">${escapeHtml(desc)}</p>`;
for (const m of members) {
const wrap = el("div", "msg user");
wrap.style.cursor = "pointer";
wrap.onclick = () => { closeModal(); setTimeout(() => openModal(m.chat), 50); };
wrap.appendChild(el("div", "who", escapeHtml(m.chat.source + " · " + m.chat.title)));
wrap.appendChild(el("div", "bubble", escapeHtml(m.chat.messages.map(x => x.content).join(" ").slice(0, 160) + "…")));
body.appendChild(wrap);
}
$("#llmStatus").textContent = "";
$("#modal").classList.remove("hidden");
}
function drawLinks() {
const ctx = PHYS.ctx, W = PHYS.w, H = PHYS.h;
ctx.clearRect(0, 0, W, H);
const T = PHYS.tiles;
// connectors between attached pairs
for (let i = 0; i < T.length; i++) {
for (let j = i + 1; j < T.length; j++) {
const a = T[i], b = T[j];
const dx = (a.x + a.w / 2) - (b.x + b.w / 2);
const dy = (a.y + a.h / 2) - (b.y + b.h / 2);
const d = Math.hypot(dx, dy);
if (attachable(a, b) && d < 230) {
const alpha = Math.max(0.05, 1 - d / 230);
ctx.strokeStyle = `rgba(124,131,255,${alpha * 0.7})`;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(a.x + a.w / 2, a.y + a.h / 2);
ctx.lineTo(b.x + b.w / 2, b.y + b.h / 2);
ctx.stroke();
}
}
}
// halos around service nodes
for (const s of PHYS.services) {
const pulse = 0.5 + 0.5 * Math.sin(Date.now() / 400);
ctx.beginPath();
ctx.arc(s.x, s.y, 60 + pulse * 8, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(176,124,255,${0.25 + pulse * 0.2})`;
ctx.lineWidth = 2;
ctx.stroke();
// faint link from service to each member
ctx.strokeStyle = "rgba(176,124,255,0.25)";
ctx.lineWidth = 1;
for (const m of s.members) {
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(m.x + m.w / 2, m.y + m.h / 2);
ctx.stroke();
}
}
}
// ---------- Utils ----------
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
// ---------- Wire up ----------
function init() {
loadChats(window.SAMPLE_CHATS || []);
$("#search").addEventListener("input", render);
$("#sourceFilter").addEventListener("change", render);
$("#sortBy").addEventListener("change", render);
$("#sampleBtn").addEventListener("click", () => { loadChats(window.SAMPLE_CHATS || []); });
$("#loadBtn").addEventListener("click", () => { $("#loadStatus").textContent = ""; $("#loadModal").classList.remove("hidden"); });
$("#jsonLoadConfirm").addEventListener("click", () => {
try {
const txt = $("#jsonInput").value.trim();
if (!txt) { $("#loadStatus").textContent = "Paste JSON or choose a file."; return; }
const raw = JSON.parse(txt);
const arr = normalizeImport(raw);
if (!arr) { $("#loadStatus").textContent = "Unrecognized format. Use our schema, a ChatGPT export, or a Claude export."; return; }
loadChats(arr);
$("#loadStatus").textContent = "Loaded " + arr.length + " real chats from " + [...new Set(arr.map(c => c.source))].join(", ") + ".";
setTimeout(() => { $("#loadModal").classList.add("hidden"); $("#jsonInput").value = ""; $("#fileInput").value = ""; }, 600);
} catch (e) { $("#loadStatus").textContent = "Error: " + e.message; }
});
$("#fileInput").addEventListener("change", async () => {
const f = $("#fileInput").files[0]; if (!f) return;
try { $("#jsonInput").value = await f.text(); } catch (e) { $("#loadStatus").textContent = "Read error: " + e.message; }
});
$("#llmToggle").addEventListener("click", () => {
LLM_ON = !LLM_ON;
$("#llmToggle").textContent = "LLM: " + (LLM_ON ? "on" : "off");
$("#llmToggle").classList.toggle("on", LLM_ON);
$("#llmPanel").classList.toggle("hidden", !LLM_ON);
});
$("#llmRunBtn").addEventListener("click", () => { if (ACTIVE) runLLM(ACTIVE); });
$("#playBtn").addEventListener("click", () => { if (PHYS) exitPhysics(); else enterPhysics(); });
window.addEventListener("resize", () => { if (PHYS) resizeStage(); });
// "Get real chats" modal + bookmarklet
const bmUrl = window.BIRDS_EYE_BOOKMARKLET || "#";
$("#bookmarkletLink").href = bmUrl;
$("#realBtn").addEventListener("click", () => { $("#realModal").classList.remove("hidden"); });
$("#copyBmBtn").addEventListener("click", async () => {
try { await navigator.clipboard.writeText(bmUrl); $("#copyBmBtn").textContent = "Copied!"; setTimeout(() => $("#copyBmBtn").textContent = "Copy URL", 1500); }
catch (e) { $("#copyBmBtn").textContent = "Press ⌘C"; }
});
document.querySelectorAll("[data-close]").forEach(e => e.addEventListener("click", closeModal));
document.addEventListener("keydown", e => { if (e.key === "Escape") { closeModal(); $("#loadModal").classList.add("hidden"); if (PHYS) exitPhysics(); } });
// restore HF token
const tok = localStorage.getItem("hf_token");
if (tok) { $("#hfToken").value = tok; }
$("#hfToken").addEventListener("change", () => localStorage.setItem("hf_token", $("#hfToken").value));
}
document.addEventListener("DOMContentLoaded", init);