SOC-2602-Visualizer / index.html
marcodsn's picture
Update index.html
739aaff verified
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SOC Visualizer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { font-family: system-ui, -apple-system, sans-serif; background: #fafafa; line-height: 1.5; color: #333; height: 100%; overflow: hidden; }
.app-container { display: flex; height: 100vh; }
.sidebar { width: 400px; flex-shrink: 0; background: #fff; border-right: 1px solid #e0e0e0; display: flex; flex-direction: column; overflow-y: auto; }
.main-content { flex-grow: 1; display: none; flex-direction: column; overflow: hidden; }
.input-section { padding: 20px; border-bottom: 1px solid #e0e0e0; }
.input-section h1 { margin-bottom: 16px; font-size: 20px; font-weight: 500; }
.input-section h2 { font-size: 14px; font-weight: 500; margin-top: 16px; margin-bottom: 8px; color: #555; }
.input-group { margin-bottom: 12px; }
.input-group label { display: block; font-size: 13px; margin-bottom: 4px; }
.input-group select, #jsonInput { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 2px; font-size: 13px; background: #fff; }
#jsonInput { height: 120px; font-family: monospace; font-size: 12px; resize: vertical; }
#loadBtn, #parseBtn { margin-top: 8px; padding: 8px 16px; background: #333; color: #fff; border: none; border-radius: 2px; cursor: pointer; font-size: 13px; width: 100%; }
#loadBtn:hover, #parseBtn:hover { background: #555; }
#loadBtn:disabled { background: #ccc; cursor: not-allowed; }
.error { color: #d73a49; margin-top: 8px; font-size: 13px; display: none; }
.header-info { padding: 20px; display: none; }
.header-info h2 { margin-bottom: 12px; font-size: 15px; font-weight: 500; }
.personas-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; }
.persona-chip { padding: 8px 10px; border-radius: 2px; font-size: 13px; font-weight: 500; text-align: center; }
.persona-chip.p1 { background: #e1e1e1; }
.persona-chip.p2 { background: #f0f0f0; }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; }
.meta-item .label { color: #999; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
.meta-item .value { font-size: 13px; font-weight: 500; }
.initial-state-box { font-size: 12px; color: #555; background: #f5f5f5; padding: 8px 10px; border-radius: 2px; margin-bottom: 12px; font-style: italic; }
.instant-events-section .ie-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: #999; margin-bottom: 6px; }
.instant-events-section ul { list-style: none; }
.instant-events-section li { font-size: 12px; color: #555; padding: 3px 0; border-bottom: 1px solid #f0f0f0; }
.instant-events-section li:last-child { border-bottom: none; }
.instant-events-section li::before { content: "⚡ "; }
.chat-area { padding: 20px 40px; overflow-y: auto; flex-grow: 1; }
.state-divider { text-align: center; margin: 20px 0 12px; position: relative; }
.state-divider::before { content: ""; display: block; position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: #e0e0e0; }
.state-divider span { position: relative; background: #fafafa; padding: 0 10px; font-size: 11px; color: #999; }
.instant-event-bubble { margin: 8px auto 12px; max-width: 75%; background: #fffbf0; border: 1px solid #ffe082; border-radius: 4px; padding: 8px 12px; font-size: 12px; color: #6d5c00; font-style: italic; text-align: center; }
.message-group { margin-bottom: 4px; }
.message { max-width: 70%; margin-bottom: 4px; padding: 8px 12px; border-radius: 4px; font-size: 14px; }
.message.p1 { background: #e1e1e1; margin-left: auto; }
.message.p2 { background: #f0f0f0; margin-right: auto; }
.sender-name { font-weight: 500; font-size: 10px; margin-bottom: 3px; opacity: 0.6; text-transform: uppercase; letter-spacing: 0.5px; }
.message-content { word-wrap: break-word; }
.msg-footer { font-size: 10px; opacity: 0.4; margin-top: 3px; text-align: right; }
.audio-msg::before { content: "🎤 "; font-style: normal; }
.sticker-msg { font-style: italic; color: #888; font-size: 12px; }
.sticker-msg::before { content: "🖼️ "; }
.exhausted-marker { text-align: center; margin: 20px 0; font-size: 12px; color: #bbb; letter-spacing: 1px; }
.summary-section { margin: 20px 0; padding: 12px 14px; background: #f8f8f8; border-radius: 2px; border-left: 3px solid #ddd; font-size: 13px; color: #555; }
.summary-section .summary-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: #aaa; margin-bottom: 6px; }
@media (max-width: 768px) { .sidebar { width: 300px; } .message { max-width: 85%; } .chat-area { padding: 20px; } }
</style>
</head>
<body>
<div class="app-container">
<div class="sidebar">
<div class="input-section">
<h1>SOC Visualizer</h1>
<div class="input-group">
<label for="fileSelector">1. Select a file</label>
<select id="fileSelector">
<option value="">-- Choose a file --</option>
</select>
</div>
<div class="input-group">
<label for="lineSelector">2. Select a conversation</label>
<select id="lineSelector" disabled></select>
</div>
<button id="loadBtn" disabled>Load Conversation</button>
<hr style="margin: 24px 0" />
<h2>Or Paste Manually</h2>
<textarea id="jsonInput" placeholder="Paste a single JSON conversation object here..."></textarea>
<button id="parseBtn">Parse Manual Input</button>
<div id="error" class="error"></div>
</div>
<div class="header-info" id="headerInfo">
<h2>Conversation Details</h2>
<div id="personasRow" class="personas-row"></div>
<div id="metaGrid" class="meta-grid"></div>
<div id="initialStateDiv" class="initial-state-box"></div>
<div id="instantEventsDiv" class="instant-events-section"></div>
</div>
</div>
<div class="main-content" id="mainContent">
<div class="chat-area" id="chatArea"></div>
</div>
</div>
<script>
// --- CONFIGURATION ---
const fileSources = [
{
name: "marcodsn/SOC-2602",
url: "https://huggingface.co/datasets/marcodsn/SOC-2602/resolve/main/data.jsonl"
},
];
const fileCache = {};
// --- DOM ---
const fileSelector = document.getElementById("fileSelector");
const lineSelector = document.getElementById("lineSelector");
const loadBtn = document.getElementById("loadBtn");
const parseBtn = document.getElementById("parseBtn");
const jsonInput = document.getElementById("jsonInput");
const errorDiv = document.getElementById("error");
const headerInfo = document.getElementById("headerInfo");
const mainContent = document.getElementById("mainContent");
const chatArea = document.getElementById("chatArea");
// --- Bootstrap ---
document.addEventListener("DOMContentLoaded", () => {
fileSources.forEach(src => {
const opt = document.createElement("option");
opt.value = src.url;
opt.textContent = src.name;
fileSelector.appendChild(opt);
});
});
fileSelector.addEventListener("change", handleFileSelection);
loadBtn.addEventListener("click", loadSelectedConversation);
parseBtn.addEventListener("click", () => processAndRender(jsonInput.value.trim()));
jsonInput.addEventListener("keydown", e => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") processAndRender(jsonInput.value.trim());
});
// --- File loading ---
async function handleFileSelection() {
const url = fileSelector.value;
lineSelector.innerHTML = "";
resetError();
if (!url) { lineSelector.disabled = true; loadBtn.disabled = true; return; }
try {
const content = await fetchAndCacheFile(url);
const lines = content.split('\n').filter(l => l.trim());
lineSelector.dataset.lines = JSON.stringify(lines);
lines.forEach((line, i) => {
const opt = document.createElement("option");
opt.value = i;
try {
const data = JSON.parse(line);
const names = data.meta?.persona_names || ["P1", "P2"];
const state = data.meta?.experience_meta?.initial_state || "";
const topic = state.split(';')[0].trim() || `Conversation ${i + 1}`;
opt.textContent = `#${i + 1} ${names[0]} & ${names[1]}: ${topic}`;
} catch { opt.textContent = `Conversation ${i + 1} (unparsable)`; }
lineSelector.appendChild(opt);
});
lineSelector.disabled = false;
loadBtn.disabled = false;
} catch (err) {
showError(`Failed to load file: ${err.message}`);
lineSelector.disabled = true;
loadBtn.disabled = true;
}
}
async function fetchAndCacheFile(url) {
if (fileCache[url]) return fileCache[url];
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
fileCache[url] = text;
return text;
}
function loadSelectedConversation() {
const idx = lineSelector.value;
const lines = JSON.parse(lineSelector.dataset.lines || "[]");
if (lines[idx]) processAndRender(lines[idx]);
}
function processAndRender(jsonString) {
resetError();
headerInfo.style.display = "none";
mainContent.style.display = "none";
if (!jsonString) { showError("Input cannot be empty."); return; }
try {
const data = JSON.parse(jsonString);
headerInfo.style.display = "block";
mainContent.style.display = "flex";
renderConversation(data);
} catch (e) { showError(`Error parsing JSON: ${e.message}`); }
}
function showError(msg) { errorDiv.textContent = msg; errorDiv.style.display = "block"; }
function resetError() { errorDiv.style.display = "none"; }
// --- Turn XML parser ---
// Uses the browser's HTML parser for robustness with real-world LLM text.
// Handles <state>, <instant_event>, <message>, <predefined_topics_exhausted/>.
function parseTurn(turnStr) {
const exhausted = turnStr.includes("predefined_topics_exhausted");
// Remove self-closing tag before HTML parsing (HTML5 ignores the slash)
const cleaned = turnStr.replace(/<predefined_topics_exhausted\s*\/?>/gi, "");
const doc = new DOMParser().parseFromString(`<div>${cleaned}</div>`, "text/html");
const state = doc.querySelector("state")?.textContent.trim() || "";
const instantEvent = doc.querySelector("instant_event")?.textContent.trim() || null;
const messages = [...doc.querySelectorAll("message")].map(m => ({
time: m.getAttribute("t") || "",
date: m.getAttribute("d") || "",
type: m.getAttribute("type") || "text",
content: m.textContent || ""
}));
return { state, instantEvent, messages, exhausted };
}
// --- Render header (sidebar details) ---
function renderHeader(data) {
const meta = data.meta || {};
const exp = meta.experience_meta || {};
const names = meta.persona_names || ["Persona 1", "Persona 2"];
document.getElementById("personasRow").innerHTML =
`<div class="persona-chip p1">◀ ${esc(names[0])}</div>` +
`<div class="persona-chip p2">${esc(names[1])} ▶</div>`;
const style = meta.conversation_style || exp.conversation_style || "—";
const cadence = meta.message_cadence || exp.message_cadence || "—";
const nTurns = meta.n_turns ?? (data.turns?.length ?? "—");
const model = (meta.model || exp.model || "—").split("/").pop();
document.getElementById("metaGrid").innerHTML = `
<div class="meta-item"><div class="label">Style</div><div class="value">${esc(style)}</div></div>
<div class="meta-item"><div class="label">Cadence</div><div class="value">${esc(cadence)}</div></div>
<div class="meta-item"><div class="label">Turns</div><div class="value">${nTurns}</div></div>
<div class="meta-item"><div class="label">Model</div><div class="value" style="font-size:11px">${esc(model)}</div></div>
`;
document.getElementById("initialStateDiv").textContent =
exp.initial_state || "(no initial state)";
const events = exp.instant_events || [];
const evDiv = document.getElementById("instantEventsDiv");
evDiv.innerHTML = events.length
? `<div class="ie-label">Instant Events</div><ul>${events.map(e => `<li>${esc(e)}</li>`).join("")}</ul>`
: "";
}
// --- Render chat ---
function renderChat(data) {
chatArea.innerHTML = "";
const names = data.meta?.persona_names || ["Persona 1", "Persona 2"];
const turns = data.turns || [];
let lastTopic = null;
turns.forEach((turnStr, i) => {
const isP1 = (i % 2 === 0);
const cls = isP1 ? "p1" : "p2";
const name = isP1 ? names[0] : names[1];
const turn = parseTurn(turnStr);
// State-topic divider
const topic = turn.state.split(";")[0].trim();
if (topic && topic !== lastTopic) {
lastTopic = topic;
const div = document.createElement("div");
div.className = "state-divider";
div.innerHTML = `<span>📍 ${esc(topic)}</span>`;
chatArea.appendChild(div);
}
// Instant-event thought bubble
if (turn.instantEvent) {
const bubble = document.createElement("div");
bubble.className = "instant-event-bubble";
bubble.textContent = turn.instantEvent;
chatArea.appendChild(bubble);
}
// Messages
if (turn.messages.length > 0) {
const group = document.createElement("div");
group.className = "message-group";
turn.messages.forEach(msg => {
const msgDiv = document.createElement("div");
msgDiv.className = `message ${cls}`;
const senderDiv = document.createElement("div");
senderDiv.className = "sender-name";
senderDiv.textContent = name;
const contentDiv = document.createElement("div");
if (msg.type === "audio") {
contentDiv.className = "message-content audio-msg";
// Strip the "[voice note]" prefix added by the model
contentDiv.textContent = msg.content.replace(/^\[voice note\]\s*/i, "");
} else if (msg.type === "sticker") {
contentDiv.className = "message-content sticker-msg";
contentDiv.textContent = msg.content;
} else {
contentDiv.className = "message-content";
contentDiv.innerHTML = esc(msg.content).replace(/\n/g, "<br>");
}
msgDiv.appendChild(senderDiv);
msgDiv.appendChild(contentDiv);
if (msg.time) {
const footer = document.createElement("div");
footer.className = "msg-footer";
footer.textContent = msg.date ? `${msg.time} · ${msg.date}` : msg.time;
msgDiv.appendChild(footer);
}
group.appendChild(msgDiv);
});
chatArea.appendChild(group);
}
// End-of-conversation marker
if (turn.exhausted) {
const marker = document.createElement("div");
marker.className = "exhausted-marker";
marker.textContent = "— topics exhausted —";
chatArea.appendChild(marker);
}
});
// Optional final summary
if (data.final_summary) {
const summaryDiv = document.createElement("div");
summaryDiv.className = "summary-section";
summaryDiv.innerHTML = `<div class="summary-label">Summary</div>${esc(data.final_summary)}`;
chatArea.appendChild(summaryDiv);
}
chatArea.scrollTop = 0;
}
function renderConversation(data) {
renderHeader(data);
renderChat(data);
}
// Minimal HTML escaping (XSS prevention)
function esc(str) {
if (!str) return "";
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
</script>
</body>
</html>