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> | |