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> | |
| /* --- Reset and Global Styles --- */ | |
| * { 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; } | |
| /* --- Main Layout (Flexbox) --- */ | |
| .app-container { display: flex; height: 100vh; } | |
| .sidebar { width: 400px; flex-shrink: 0; background: #ffffff; border-right: 1px solid #e0e0e0; display: flex; flex-direction: column; overflow-y: auto; } | |
| .main-content { flex-grow: 1; display: none; flex-direction: column; } | |
| /* --- Sidebar Components --- */ | |
| .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-color: #fff; } | |
| #jsonInput { height: 120px; font-family: monospace; font-size: 12px; resize: vertical; } | |
| #loadBtn, #parseBtn { margin-top: 8px; padding: 8px 16px; background: #333; color: white; 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: 16px; font-weight: 500; } | |
| .situation-info { margin-bottom: 16px; font-size: 13px; } | |
| .situation-info p { margin-bottom: 4px; } | |
| .personas { display: grid; grid-template-columns: 1fr; gap: 16px; font-size: 13px; } | |
| .persona { padding: 12px; border: 1px solid #e0e0e0; border-radius: 2px; background: #fdfdfd; } | |
| .persona h3 { margin-bottom: 6px; font-size: 14px; font-weight: 500; } | |
| .traits { margin: 6px 0; } | |
| .trait { display: inline-block; background: #f0f0f0; padding: 1px 6px; margin: 0 4px 4px 0; border-radius: 2px; font-size: 11px; } | |
| /* --- Chat Area (Right Panel) --- */ | |
| .chat-area { padding: 20px 40px; overflow-y: auto; flex-grow: 1; } | |
| .message-group { margin-bottom: 16px; } | |
| .message { max-width: 70%; margin-bottom: 6px; padding: 10px 12px; border-radius: 4px; font-size: 14px; } | |
| .message.persona1 { background: #e1e1e1; margin-left: auto; } | |
| .message.persona2 { background: #f0f0f0; margin-right: auto; } | |
| .sender-name { font-weight: 500; font-size: 11px; margin-bottom: 4px; opacity: 0.7; text-transform: uppercase; } | |
| /* --- Message Content and Special Elements --- */ | |
| .message-content { word-wrap: break-word; } | |
| .special-element { margin: 6px 0; padding: 6px 8px; background: rgba(0, 0, 0, 0.03); border-left: 2px solid #ccc; font-size: 12px; font-style: italic; } | |
| .image-element { border-left-color: #4caf50; } | |
| .video-element { border-left-color: #4caf50; } | |
| .audio-element { border-left-color: #ff9800; } | |
| .delay-element { border-left-color: #9c27b0; text-align: center; } | |
| .gif-element { border-left-color: #03a9f4; } | |
| .code { background: #f0f0f0; padding: 1px 4px; border-radius: 2px; font-family: monospace; font-size: 12px; } | |
| /* --- Responsive Adjustments --- */ | |
| @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 class="situation-info" id="situationInfo"></div> | |
| <div class="personas" id="personasInfo"></div> | |
| </div> | |
| </div> | |
| <div class="main-content" id="mainContent"> | |
| <div class="chat-area" id="chatArea"></div> | |
| </div> | |
| </div> | |
| <script> | |
| // --- CONFIGURATION --- | |
| // IMPORTANT: Replace these with the raw URLs of the files in your public Hugging Face dataset repository. | |
| // To get the raw URL, go to your file on the Hub and click the "raw" button. | |
| const fileSources = [ | |
| { | |
| name: "marcodsn/SOC-2508", | |
| url: "https://huggingface.co/datasets/marcodsn/SOC-2508/resolve/main/data.jsonl" | |
| }, | |
| // { | |
| // name: "Your Next File", | |
| // url: "https://huggingface.co/datasets/your-username/your-dataset-name/raw/main/your_file.jsonl" | |
| // } | |
| ]; | |
| // This object will cache file content after the first fetch | |
| const fileCache = {}; | |
| // --- DOM Elements --- | |
| 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"); | |
| // --- Event Listeners --- | |
| document.addEventListener("DOMContentLoaded", initialize); | |
| fileSelector.addEventListener("change", handleFileSelection); | |
| loadBtn.addEventListener("click", loadSelectedConversation); | |
| parseBtn.addEventListener("click", parseManualInput); | |
| jsonInput.addEventListener("keydown", (e) => { | |
| if ((e.ctrlKey || e.metaKey) && e.key === "Enter") parseManualInput(); | |
| }); | |
| // --- Functions --- | |
| function initialize() { | |
| fileSources.forEach((source) => { | |
| const option = document.createElement("option"); | |
| option.value = source.url; // The value is the full URL | |
| option.textContent = source.name; // The text is the friendly name | |
| fileSelector.appendChild(option); | |
| }); | |
| } | |
| async function handleFileSelection() { | |
| const selectedUrl = fileSelector.value; | |
| lineSelector.innerHTML = ""; // Clear previous options | |
| resetError(); | |
| if (selectedUrl) { | |
| try { | |
| const content = await fetchAndCacheFile(selectedUrl); | |
| const lines = content.split('\n').filter(line => line.trim() !== ""); | |
| lineSelector.dataset.lines = JSON.stringify(lines); | |
| lines.forEach((line, index) => { | |
| const option = document.createElement("option"); | |
| option.value = index; | |
| try { | |
| const data = JSON.parse(line); | |
| option.textContent = `Conversation ${index + 1}: ${data.experience.topic}`; | |
| } catch (e) { | |
| option.textContent = `Conversation ${index + 1} (unparsable)`; | |
| } | |
| lineSelector.appendChild(option); | |
| }); | |
| lineSelector.disabled = false; | |
| loadBtn.disabled = false; | |
| } catch (err) { | |
| showError(`Failed to load file: ${err.message}`); | |
| lineSelector.disabled = true; | |
| loadBtn.disabled = true; | |
| } | |
| } else { | |
| lineSelector.disabled = true; | |
| loadBtn.disabled = true; | |
| } | |
| } | |
| async function fetchAndCacheFile(url) { | |
| if (fileCache[url]) { | |
| return fileCache[url]; | |
| } | |
| const response = await fetch(url); | |
| if (!response.ok) { | |
| throw new Error(`HTTP error! status: ${response.status}`); | |
| } | |
| const textContent = await response.text(); | |
| fileCache[url] = textContent; | |
| return textContent; | |
| } | |
| function loadSelectedConversation() { | |
| const lineIndex = lineSelector.value; | |
| const lines = JSON.parse(lineSelector.dataset.lines || "[]"); | |
| if (lines && lineIndex !== null && lines[lineIndex]) { | |
| const jsonString = lines[lineIndex]; | |
| processAndRender(jsonString); | |
| } | |
| } | |
| function parseManualInput() { | |
| const jsonString = jsonInput.value.trim(); | |
| processAndRender(jsonString); | |
| } | |
| 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(message) { | |
| errorDiv.textContent = message; | |
| errorDiv.style.display = "block"; | |
| } | |
| function resetError() { | |
| errorDiv.style.display = "none"; | |
| } | |
| function renderConversation(data) { | |
| renderHeader(data); | |
| renderChat(data.chat_parts, data.experience); | |
| } | |
| function renderHeader(data) { | |
| const situationInfo = document.getElementById("situationInfo"); | |
| const personasInfo = document.getElementById("personasInfo"); | |
| situationInfo.innerHTML = `<p><strong>Relationship:</strong> ${data.experience.relationship}</p><p><strong>Context:</strong> ${data.experience.situation}</p><p><strong>Topic:</strong> ${data.experience.topic}</p>`; | |
| const p1 = data.experience.persona1, p2 = data.experience.persona2; | |
| personasInfo.innerHTML = `<div class="persona"><h3>${p1.name} (${p1.age})</h3><div class="traits">${p1.traits.map(t=>`<span class="trait">${t}</span>`).join("")}</div><p><strong>Background:</strong> ${p1.background}</p><p><strong>Style:</strong> ${p1.chatting_style}</p></div><div class="persona"><h3>${p2.name} (${p2.age})</h3><div class="traits">${p2.traits.map(t=>`<span class="trait">${t}</span>`).join("")}</div><p><strong>Background:</strong> ${p2.background}</p><p><strong>Style:</strong> ${p2.chatting_style}</p></div>`; | |
| } | |
| function renderChat(chatParts, experience) { | |
| chatArea.innerHTML = ""; | |
| chatParts.forEach(part => { | |
| const senderClass = part.sender === experience.persona1.id ? "persona1" : "persona2"; | |
| const senderName = part.sender === experience.persona1.id ? experience.persona1.name : experience.persona2.name; | |
| const messageGroup = document.createElement("div"); | |
| messageGroup.className = "message-group"; | |
| part.messages.forEach(messageContent => { | |
| const messageDiv = document.createElement("div"); | |
| messageDiv.className = `message ${senderClass}`; | |
| const senderDiv = document.createElement("div"); | |
| senderDiv.className = "sender-name"; | |
| senderDiv.textContent = senderName; | |
| const contentDiv = document.createElement("div"); | |
| contentDiv.className = "message-content"; | |
| contentDiv.innerHTML = formatMessage(messageContent); | |
| messageDiv.appendChild(senderDiv); | |
| messageDiv.appendChild(contentDiv); | |
| messageGroup.appendChild(messageDiv); | |
| }); | |
| chatArea.appendChild(messageGroup); | |
| }); | |
| // CHANGE 1: Set scroll to top instead of bottom | |
| chatArea.scrollTop = 0; | |
| } | |
| function formatMessage(content) { | |
| content = content.replace(/</g, "<").replace(/>/g, ">"); | |
| content = content.replace(/<image>(.*?)<\/image>/g, '<div class="special-element image-element">π· Image: $1</div>'); | |
| content = content.replace(/<video>(.*?)<\/video>/g, '<div class="special-element video-element">π₯ Video: $1</div>'); | |
| content = content.replace(/<audio>(.*?)<\/audio>/g, '<div class="special-element audio-element">π Audio: $1</div>'); | |
| content = content.replace(/<gif>(.*?)<\/gif>/g, '<div class="special-element gif-element">ποΈ GIF: $1</div>'); | |
| // CHANGE 2: Replaced the old delay regex with a more robust parser that can handle days, hours, and minutes in any order. | |
| content = content.replace(/<delay\s+([^&]*?)\/?>/g, (match, attributes) => { | |
| const parts = {}; | |
| const attrRegex = /(\w+)="(\d+)"/g; | |
| let attrMatch; | |
| while ((attrMatch = attrRegex.exec(attributes)) !== null) { | |
| parts[attrMatch[1]] = attrMatch[2]; // e.g., parts['days'] = '1' | |
| } | |
| const d = parts.days ? `${parts.days}d ` : ""; | |
| const h = parts.hours ? `${parts.hours}h ` : ""; | |
| const m = parts.minutes ? `${parts.minutes}m` : ""; | |
| const timeString = (d + h + m).trim() || "unknown"; | |
| return `<div class="special-element delay-element">β±οΈ Delay: ${timeString}</div>`; | |
| }); | |
| content = content.replace(/<end\/>/g, '<div class="special-element">π End</div>'); | |
| content = content.replace(/<code>(.*?)<\/code>/g, '<span class="code">$1</span>'); | |
| content = content.replace(/\n/g, "<br>"); | |
| return content; | |
| } | |
| </script> | |
| </body> | |
| </html> | |