Spaces:
Running
Running
| ; | |
| // ---------- 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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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); | |