Spaces:
Running
Running
| <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, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">") | |
| .replace(/"/g, """); | |
| } | |
| </script> | |
| </body> | |
| </html> | |