"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", "")); 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", `conv ${k.conversion}%`)); 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, ` ${k.sentiment}`)); foot.appendChild(el("span", "badge", `topic ${escapeHtml(k.topic)}`)); foot.appendChild(el("span", "badge", `msgs ${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: {: {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 = '' + sources.map(s => ``).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 = ` NEW SERVICE
${escapeHtml(name)}
${escapeHtml(desc)}
chats ${members.length} avg conv ${avgConv}%
`; 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 = `chats ${s.members.length}avg conv ${s.avgConv}%`; 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 = `

${escapeHtml(desc)}

`; 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);