debate-bots/webui/index.html
2025-11-11 21:03:01 -07:00

1321 lines
46 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Debate Bots - Web UI</title>
<link rel="canonical" href="/">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--color-for: #10b981;
--color-against: #ef4444;
--color-bg: #0f172a;
--color-surface: #1e293b;
--color-surface-light: #334155;
--color-text: #e2e8f0;
--color-text-dim: #94a3b8;
--color-border: #475569;
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* Header */
header {
background: var(--color-surface);
border-bottom: 2px solid var(--color-border);
padding: 20px;
margin-bottom: 30px;
border-radius: 8px;
box-shadow: var(--shadow);
}
header h1 {
font-size: 2rem;
margin-bottom: 10px;
background: linear-gradient(135deg, var(--color-for), var(--color-against));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
header .subtitle {
color: var(--color-text-dim);
font-size: 0.9rem;
}
/* Layout */
.main-layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: 20px;
}
@media (max-width: 1024px) {
.main-layout {
grid-template-columns: 1fr;
}
}
/* Sidebar */
.sidebar {
background: var(--color-surface);
border-radius: 8px;
padding: 20px;
box-shadow: var(--shadow);
height: fit-content;
position: sticky;
top: 20px;
}
.sidebar h2 {
font-size: 1.2rem;
margin-bottom: 15px;
color: var(--color-text);
}
/* Debate List */
.debate-list {
max-height: 400px;
overflow-y: auto;
margin-bottom: 20px;
}
.debate-item {
background: var(--color-surface-light);
padding: 12px;
margin-bottom: 10px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
border-left: 3px solid transparent;
}
.debate-item:hover {
background: var(--color-surface);
border-left-color: var(--color-primary);
}
.debate-item.active {
border-left-color: var(--color-primary);
background: var(--color-surface);
}
.debate-item .topic {
font-weight: 600;
margin-bottom: 5px;
font-size: 0.9rem;
}
.debate-item .meta {
font-size: 0.75rem;
color: var(--color-text-dim);
}
/* New Debate Form */
.new-debate-form {
background: var(--color-surface-light);
padding: 15px;
border-radius: 6px;
margin-top: 20px;
}
.new-debate-form h3 {
font-size: 1rem;
margin-bottom: 15px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-size: 0.85rem;
color: var(--color-text-dim);
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text);
font-size: 0.9rem;
}
.form-group textarea {
min-height: 80px;
resize: vertical;
}
.form-group input[type="number"] {
width: 100px;
}
.form-group input[type="checkbox"] {
width: auto;
margin-right: 8px;
}
.checkbox-group {
display: flex;
align-items: center;
}
/* Buttons */
button {
background: var(--color-primary);
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
transition: all 0.2s;
width: 100%;
}
button:hover:not(:disabled) {
background: var(--color-primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.secondary {
background: var(--color-surface-light);
color: var(--color-text);
}
button.secondary:hover:not(:disabled) {
background: var(--color-surface);
}
/* Main Content */
.main-content {
background: var(--color-surface);
border-radius: 8px;
padding: 20px;
box-shadow: var(--shadow);
min-height: 600px;
}
.view-toggle {
display: flex;
gap: 10px;
margin-bottom: 20px;
justify-content: flex-end;
}
.view-toggle button {
width: auto;
padding: 8px 16px;
font-size: 0.85rem;
}
.view-toggle button.active {
background: var(--color-primary);
}
/* Debate Display */
.debate-display {
margin-bottom: 30px;
}
.debate-topic {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 20px;
padding: 15px;
background: var(--color-surface-light);
border-radius: 6px;
border-left: 4px solid var(--color-primary);
}
.debate-info {
display: flex;
gap: 20px;
margin-bottom: 20px;
font-size: 0.9rem;
color: var(--color-text-dim);
}
/* Exchange Display */
.exchanges-container {
margin-bottom: 30px;
}
.exchanges-container.view-side-by-side .exchange {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 30px;
}
.exchanges-container.view-sequential .exchange {
display: block;
margin-bottom: 30px;
}
.exchange {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.exchange-header {
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 10px;
color: var(--color-text-dim);
}
.exchange-content {
background: var(--color-surface-light);
padding: 20px;
border-radius: 6px;
border-left: 4px solid;
min-height: 100px;
}
.exchange-content.for {
border-left-color: var(--color-for);
}
.exchange-content.against {
border-left-color: var(--color-against);
}
.exchange-content.streaming {
position: relative;
}
.exchange-content.streaming::after {
content: '...';
animation: blink 1s infinite;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
.agent-name {
font-weight: 700;
margin-bottom: 10px;
font-size: 1rem;
}
.agent-name.for {
color: var(--color-for);
}
.agent-name.against {
color: var(--color-against);
}
.exchange-content .content {
line-height: 1.8;
}
.exchange-content .content p {
margin-bottom: 10px;
}
/* Statistics Panel */
.statistics {
background: var(--color-surface-light);
padding: 20px;
border-radius: 6px;
margin-bottom: 20px;
}
.statistics h3 {
font-size: 1.1rem;
margin-bottom: 15px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
}
.stat-item {
background: var(--color-bg);
padding: 12px;
border-radius: 4px;
}
.stat-item .label {
font-size: 0.75rem;
color: var(--color-text-dim);
margin-bottom: 5px;
}
.stat-item .value {
font-size: 1.2rem;
font-weight: 700;
}
/* Loading Spinner */
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid var(--color-border);
border-radius: 50%;
border-top-color: var(--color-primary);
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error Message */
.error {
background: #7f1d1d;
color: #fca5a5;
padding: 15px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid var(--color-against);
}
/* Empty State */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--color-text-dim);
}
.empty-state h3 {
font-size: 1.2rem;
margin-bottom: 10px;
}
/* Action Buttons */
.action-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.action-buttons button {
width: auto;
flex: 1;
}
/* Responsive */
@media (max-width: 768px) {
.exchanges-container.view-side-by-side .exchange {
grid-template-columns: 1fr;
}
.stat-grid {
grid-template-columns: 1fr;
}
.view-toggle {
flex-direction: column;
}
.view-toggle button {
width: 100%;
}
}
/* Scrollbar */
.debate-list::-webkit-scrollbar {
width: 8px;
}
.debate-list::-webkit-scrollbar-track {
background: var(--color-surface-light);
border-radius: 4px;
}
.debate-list::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 4px;
}
.debate-list::-webkit-scrollbar-thumb:hover {
background: var(--color-text-dim);
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🤖 Debate Bots</h1>
<div class="subtitle">Two LLMs Enter, One Topic Wins</div>
</header>
<div class="main-layout">
<!-- Sidebar -->
<div class="sidebar">
<h2>Saved Debates</h2>
<div class="debate-list" id="debateList">
<div class="loading"></div>
</div>
<div class="new-debate-form">
<h3>Start New Debate</h3>
<form id="newDebateForm">
<div class="form-group">
<label for="topic">Topic</label>
<textarea id="topic" name="topic" placeholder="Enter debate topic..." required></textarea>
</div>
<div class="form-group">
<label for="exchanges">Exchanges per Round</label>
<input type="number" id="exchanges" name="exchanges" value="10" min="1" max="50">
</div>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="streaming" name="streaming" checked>
<label for="streaming">Enable Streaming</label>
</div>
</div>
<button type="submit">Start Debate</button>
</form>
</div>
</div>
<!-- Main Content -->
<div class="main-content">
<div id="debateContent">
<div class="empty-state">
<h3>Welcome to Debate Bots</h3>
<p>Select a saved debate or start a new one to begin.</p>
</div>
</div>
</div>
</div>
</div>
<script>
// API base URL
const API_BASE = '/api';
// Global state
let currentDebateId = null;
let currentEventSource = null;
let currentView = 'side-by-side'; // or 'sequential'
let exchanges = {};
let currentStats = null;
let renderTimeout = null;
let exchangeElements = {}; // Cache of DOM elements for each exchange
let lastRenderTime = 0;
let pendingUpdates = new Set(); // Track which exchanges need updates
const RENDER_DEBOUNCE_MS = 100; // Only render every 100ms during streaming
const RENDER_THROTTLE_MS = 16; // Minimum time between renders (~60fps)
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadDebates();
setupEventListeners();
loadViewPreference();
});
// Load view preference from localStorage
function loadViewPreference() {
const saved = localStorage.getItem('debateView');
if (saved) {
currentView = saved;
updateViewToggle();
}
}
// Save view preference to localStorage
function saveViewPreference() {
localStorage.setItem('debateView', currentView);
}
// Setup event listeners
function setupEventListeners() {
document.getElementById('newDebateForm').addEventListener('submit', handleNewDebate);
}
// Load debates list
async function loadDebates() {
try {
const response = await fetch(`${API_BASE}/debates`);
const data = await response.json();
renderDebateList(data.debates || []);
} catch (error) {
console.error('Error loading debates:', error);
document.getElementById('debateList').innerHTML = '<div class="error">Error loading debates</div>';
}
}
// Render debate list
function renderDebateList(debates) {
const list = document.getElementById('debateList');
if (debates.length === 0) {
list.innerHTML = '<div style="color: var(--color-text-dim); text-align: center; padding: 20px;">No saved debates</div>';
return;
}
list.innerHTML = debates.map(debate => `
<div class="debate-item" data-filename="${debate.filename}">
<div class="topic">${escapeHtml(debate.topic)}</div>
<div class="meta">${debate.total_exchanges} exchanges • ${formatDate(debate.timestamp)}</div>
</div>
`).join('');
// Add click listeners
list.querySelectorAll('.debate-item').forEach(item => {
item.addEventListener('click', () => {
const filename = item.dataset.filename;
loadDebate(filename);
// Update active state
list.querySelectorAll('.debate-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
});
});
}
// Load a specific debate
async function loadDebate(filename) {
try {
const response = await fetch(`${API_BASE}/debates/${filename}`);
const data = await response.json();
renderDebate(data, false);
} catch (error) {
console.error('Error loading debate:', error);
showError('Error loading debate');
}
}
// Handle new debate form submission
async function handleNewDebate(e) {
e.preventDefault();
const formData = new FormData(e.target);
const topic = formData.get('topic');
const exchanges = parseInt(formData.get('exchanges')) || 10;
const streaming = formData.get('streaming') === 'on';
if (!topic) {
showError('Topic is required');
return;
}
try {
const response = await fetch(`${API_BASE}/debates/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
topic,
exchanges,
streaming,
auto_save: true
})
});
const data = await response.json();
if (response.ok) {
currentDebateId = data.debate_id;
startStreaming(data.debate_id);
renderDebateHeader(data);
} else {
showError(data.error || 'Error starting debate');
}
} catch (error) {
console.error('Error starting debate:', error);
showError('Error starting debate');
}
}
// Start streaming debate updates
function startStreaming(debateId) {
// Close existing stream
if (currentEventSource) {
currentEventSource.close();
}
// Reset exchanges
exchanges = {};
currentStats = null;
// Create new EventSource
currentEventSource = new EventSource(`${API_BASE}/debates/${debateId}/stream`);
currentEventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleStreamEvent(data);
} catch (error) {
console.error('Error parsing stream event:', error);
}
};
currentEventSource.onerror = (error) => {
console.error('Stream error:', error);
// Try to reconnect or show error
};
}
// Handle stream events
function handleStreamEvent(event) {
switch (event.type) {
case 'exchange_start':
handleExchangeStart(event.data);
break;
case 'chunk':
handleChunk(event.data);
break;
case 'exchange_complete':
handleExchangeComplete(event.data);
break;
case 'exchange':
handleExchange(event.data);
break;
case 'round_complete':
handleRoundComplete(event.data);
break;
case 'complete':
handleComplete(event.data);
break;
case 'error':
showError(event.data.message);
break;
case 'saved':
console.log('Debate saved:', event.data.filepath);
break;
}
}
// Handle exchange start
function handleExchangeStart(data) {
const { exchange, agent, position } = data;
if (!exchanges[exchange]) {
exchanges[exchange] = {
exchange,
for: { name: '', content: '', streaming: false },
against: { name: '', content: '', streaming: false }
};
}
if (position === 'for') {
exchanges[exchange].for.name = agent;
exchanges[exchange].for.streaming = true;
exchanges[exchange].for.content = '';
} else {
exchanges[exchange].against.name = agent;
exchanges[exchange].against.streaming = true;
exchanges[exchange].against.content = '';
}
// Create or update exchange element
ensureExchangeElement(exchange);
}
// Handle chunk
function handleChunk(data) {
const { exchange, agent, position, chunk } = data;
if (!exchanges[exchange]) {
exchanges[exchange] = {
exchange,
for: { name: '', content: '', streaming: false },
against: { name: '', content: '', streaming: false }
};
}
// Accumulate chunks in memory
if (position === 'for') {
exchanges[exchange].for.content += chunk;
} else {
exchanges[exchange].against.content += chunk;
}
// Mark this exchange for update
pendingUpdates.add(`${exchange}-${position}`);
// Schedule DOM update (throttled)
scheduleUpdate();
}
// Schedule a throttled DOM update
function scheduleUpdate() {
if (renderTimeout) {
return; // Update already scheduled
}
const now = Date.now();
const timeSinceLastRender = now - lastRenderTime;
if (timeSinceLastRender >= RENDER_THROTTLE_MS) {
// Update immediately if enough time has passed
flushUpdates();
} else {
// Schedule update after debounce period
renderTimeout = setTimeout(() => {
flushUpdates();
}, RENDER_DEBOUNCE_MS);
}
}
// Flush all pending updates to DOM
function flushUpdates() {
if (pendingUpdates.size === 0) {
renderTimeout = null;
return;
}
// Create a copy of pending updates to avoid modifying set during iteration
const updatesToProcess = Array.from(pendingUpdates);
pendingUpdates.clear();
// Process all pending updates
updatesToProcess.forEach(updateKey => {
const [exchangeNum, position] = updateKey.split('-');
updateExchangeContentDirect(exchangeNum, position, false);
});
lastRenderTime = Date.now();
renderTimeout = null;
}
// Handle exchange complete
function handleExchangeComplete(data) {
const { exchange, agent, position, content } = data;
if (exchanges[exchange]) {
if (position === 'for') {
exchanges[exchange].for.streaming = false;
if (content) {
exchanges[exchange].for.content = content;
}
} else {
exchanges[exchange].against.streaming = false;
if (content) {
exchanges[exchange].against.content = content;
}
}
}
// Final render with markdown
updateExchangeContent(exchange, position, true);
}
// Handle full exchange
function handleExchange(data) {
const { exchange, agent_for, agent_against } = data;
if (!exchanges[exchange]) {
exchanges[exchange] = {
exchange,
for: { name: agent_for.name, content: agent_for.content, streaming: false },
against: { name: agent_against.name, content: agent_against.content, streaming: false }
};
} else {
exchanges[exchange].for.name = agent_for.name;
exchanges[exchange].for.content = agent_for.content;
exchanges[exchange].for.streaming = false;
exchanges[exchange].against.name = agent_against.name;
exchanges[exchange].against.content = agent_against.content;
exchanges[exchange].against.streaming = false;
}
// Update both positions with final markdown rendering
updateExchangeContent(exchange, 'for', true);
updateExchangeContent(exchange, 'against', true);
}
// Handle round complete
function handleRoundComplete(data) {
currentStats = data.statistics;
renderStatistics(data.statistics);
}
// Handle complete
function handleComplete(data) {
if (currentEventSource) {
currentEventSource.close();
currentEventSource = null;
}
if (data.statistics) {
currentStats = data.statistics;
renderStatistics(data.statistics);
}
// Reload debates list
loadDebates();
}
// Render debate header
function renderDebateHeader(data) {
const content = document.getElementById('debateContent');
content.innerHTML = `
<div class="debate-topic">${escapeHtml(data.topic)}</div>
<div class="debate-info">
<span><strong>FOR:</strong> ${escapeHtml(data.agents.for)}</span>
<span><strong>AGAINST:</strong> ${escapeHtml(data.agents.against)}</span>
</div>
<div class="view-toggle">
<button class="view-toggle-btn ${currentView === 'side-by-side' ? 'active' : ''}"
onclick="setView('side-by-side')">Side-by-Side</button>
<button class="view-toggle-btn ${currentView === 'sequential' ? 'active' : ''}"
onclick="setView('sequential')">Sequential</button>
</div>
<div class="exchanges-container view-${currentView}" id="exchangesContainer">
<div class="empty-state">Waiting for exchanges...</div>
</div>
<div id="statistics"></div>
`;
}
// Render debate (for saved debates)
function renderDebate(data, isStreaming = false) {
const content = document.getElementById('debateContent');
content.innerHTML = `
<div class="debate-topic">${escapeHtml(data.topic)}</div>
<div class="debate-info">
<span><strong>FOR:</strong> ${escapeHtml(data.agents.agent1.name)}</span>
<span><strong>AGAINST:</strong> ${escapeHtml(data.agents.agent2.name)}</span>
<span>${formatDate(data.timestamp)}</span>
</div>
<div class="view-toggle">
<button class="view-toggle-btn ${currentView === 'side-by-side' ? 'active' : ''}"
onclick="setView('side-by-side')">Side-by-Side</button>
<button class="view-toggle-btn ${currentView === 'sequential' ? 'active' : ''}"
onclick="setView('sequential')">Sequential</button>
</div>
<div class="exchanges-container view-${currentView}" id="exchangesContainer"></div>
<div id="statistics"></div>
`;
// Group exchanges by exchange number
const exchangesByNum = {};
data.exchanges.forEach(ex => {
if (!exchangesByNum[ex.exchange]) {
exchangesByNum[ex.exchange] = {
exchange: ex.exchange,
for: null,
against: null
};
}
if (ex.position === 'for') {
exchangesByNum[ex.exchange].for = {
name: ex.agent,
content: ex.content,
streaming: false
};
} else {
exchangesByNum[ex.exchange].against = {
name: ex.agent,
content: ex.content,
streaming: false
};
}
});
// Convert to array format
exchanges = exchangesByNum;
renderExchanges();
if (data.statistics) {
renderStatistics(data.statistics);
}
}
// Ensure exchange element exists in DOM
function ensureExchangeElement(exchangeNum) {
const container = document.getElementById('exchangesContainer');
if (!container) return;
const ex = exchanges[exchangeNum];
if (!ex) return;
// Check if element already exists
if (exchangeElements[exchangeNum]) {
return;
}
// Create new exchange element
const exchangeDiv = document.createElement('div');
exchangeDiv.className = 'exchange';
exchangeDiv.id = `exchange-${exchangeNum}`;
const header = document.createElement('div');
header.className = 'exchange-header';
header.textContent = `Exchange ${exchangeNum}`;
exchangeDiv.appendChild(header);
if (currentView === 'side-by-side') {
// Create side-by-side layout
const forDiv = document.createElement('div');
forDiv.className = 'exchange-content for';
forDiv.id = `exchange-${exchangeNum}-for`;
const forName = document.createElement('div');
forName.className = 'agent-name for';
forName.textContent = ex.for && ex.for.name ? `${ex.for.name} (FOR)` : '... (FOR)';
forDiv.appendChild(forName);
const forContent = document.createElement('div');
forContent.className = 'content';
forContent.id = `exchange-${exchangeNum}-for-content`;
forContent.textContent = '';
forDiv.appendChild(forContent);
const againstDiv = document.createElement('div');
againstDiv.className = 'exchange-content against';
againstDiv.id = `exchange-${exchangeNum}-against`;
const againstName = document.createElement('div');
againstName.className = 'agent-name against';
againstName.textContent = ex.against && ex.against.name ? `${ex.against.name} (AGAINST)` : '... (AGAINST)';
againstDiv.appendChild(againstName);
const againstContent = document.createElement('div');
againstContent.className = 'content';
againstContent.id = `exchange-${exchangeNum}-against-content`;
againstContent.textContent = '';
againstDiv.appendChild(againstContent);
exchangeDiv.appendChild(forDiv);
exchangeDiv.appendChild(againstDiv);
} else {
// Create sequential layout
if (ex.for) {
const forDiv = document.createElement('div');
forDiv.className = 'exchange-content for';
forDiv.id = `exchange-${exchangeNum}-for`;
forDiv.style.marginBottom = '15px';
const forName = document.createElement('div');
forName.className = 'agent-name for';
forName.textContent = `${ex.for.name || 'Unknown'} (FOR)`;
forDiv.appendChild(forName);
const forContent = document.createElement('div');
forContent.className = 'content';
forContent.id = `exchange-${exchangeNum}-for-content`;
forContent.textContent = '';
forDiv.appendChild(forContent);
exchangeDiv.appendChild(forDiv);
}
if (ex.against) {
const againstDiv = document.createElement('div');
againstDiv.className = 'exchange-content against';
againstDiv.id = `exchange-${exchangeNum}-against`;
const againstName = document.createElement('div');
againstName.className = 'agent-name against';
againstName.textContent = `${ex.against.name || 'Unknown'} (AGAINST)`;
againstDiv.appendChild(againstName);
const againstContent = document.createElement('div');
againstContent.className = 'content';
againstContent.id = `exchange-${exchangeNum}-against-content`;
againstContent.textContent = '';
againstDiv.appendChild(againstContent);
exchangeDiv.appendChild(againstDiv);
}
}
// Insert in correct order
const exchangeNumbers = Object.keys(exchanges).map(n => parseInt(n)).sort((a, b) => a - b);
const currentIndex = exchangeNumbers.indexOf(parseInt(exchangeNum));
if (currentIndex === 0) {
container.insertBefore(exchangeDiv, container.firstChild);
} else {
const prevExchangeNum = exchangeNumbers[currentIndex - 1];
const prevElement = document.getElementById(`exchange-${prevExchangeNum}`);
if (prevElement && prevElement.nextSibling) {
container.insertBefore(exchangeDiv, prevElement.nextSibling);
} else {
container.appendChild(exchangeDiv);
}
}
// Cache the element
exchangeElements[exchangeNum] = {
element: exchangeDiv,
forContent: document.getElementById(`exchange-${exchangeNum}-for-content`),
againstContent: document.getElementById(`exchange-${exchangeNum}-against-content`),
forDiv: document.getElementById(`exchange-${exchangeNum}-for`),
againstDiv: document.getElementById(`exchange-${exchangeNum}-against`)
};
// Remove empty state if present
const emptyState = container.querySelector('.empty-state');
if (emptyState) {
emptyState.remove();
}
}
// Update exchange content directly (called after throttling)
function updateExchangeContentDirect(exchangeNum, position, final = false) {
const ex = exchanges[exchangeNum];
if (!ex) return;
// Ensure element exists
ensureExchangeElement(exchangeNum);
const cached = exchangeElements[exchangeNum];
if (!cached) return;
// Use requestAnimationFrame to batch DOM updates
requestAnimationFrame(() => {
// Update content based on position
if (position === 'for' && ex.for) {
const contentEl = cached.forContent;
const divEl = cached.forDiv;
if (contentEl && divEl) {
// Update streaming class
if (ex.for.streaming) {
divEl.classList.add('streaming');
} else {
divEl.classList.remove('streaming');
}
// During streaming: use textContent (fast, no markdown parsing)
// When complete: parse markdown once
if (final || !ex.for.streaming) {
contentEl.innerHTML = marked.parse(ex.for.content || '');
} else {
contentEl.textContent = ex.for.content || '';
}
}
} else if (position === 'against' && ex.against) {
const contentEl = cached.againstContent;
const divEl = cached.againstDiv;
if (contentEl && divEl) {
// Update streaming class
if (ex.against.streaming) {
divEl.classList.add('streaming');
} else {
divEl.classList.remove('streaming');
}
// During streaming: use textContent (fast, no markdown parsing)
// When complete: parse markdown once
if (final || !ex.against.streaming) {
contentEl.innerHTML = marked.parse(ex.against.content || '');
} else {
contentEl.textContent = ex.against.content || '';
}
}
}
// Scroll to bottom
const container = document.getElementById('exchangesContainer');
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}
// Update exchange content (public API - handles throttling)
function updateExchangeContent(exchangeNum, position, final = false) {
if (final) {
// Clear any pending updates for this exchange
pendingUpdates.delete(`${exchangeNum}-for`);
pendingUpdates.delete(`${exchangeNum}-against`);
// Clear timeout and flush immediately
if (renderTimeout) {
clearTimeout(renderTimeout);
renderTimeout = null;
}
// Update directly (no throttling for final update)
updateExchangeContentDirect(exchangeNum, position, true);
} else {
// For streaming updates, use throttled updates
pendingUpdates.add(`${exchangeNum}-${position}`);
scheduleUpdate();
}
}
// Render exchanges (for initial render or view change)
function renderExchanges() {
const container = document.getElementById('exchangesContainer');
if (!container) return;
// Update container class for current view
container.className = `exchanges-container view-${currentView}`;
const exchangeNumbers = Object.keys(exchanges).sort((a, b) => parseInt(a) - parseInt(b));
if (exchangeNumbers.length === 0) {
container.innerHTML = '<div class="empty-state">No exchanges yet</div>';
exchangeElements = {};
return;
}
// Clear cache and rebuild
exchangeElements = {};
container.innerHTML = '';
// Create all exchange elements
exchangeNumbers.forEach(exNum => {
ensureExchangeElement(exNum);
const ex = exchanges[exNum];
// Update content (will render markdown)
if (ex.for && ex.for.content) {
updateExchangeContent(exNum, 'for', true);
}
if (ex.against && ex.against.content) {
updateExchangeContent(exNum, 'against', true);
}
});
// Scroll to bottom
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
});
}
// Render statistics
function renderStatistics(stats) {
const container = document.getElementById('statistics');
if (!container || !stats) return;
const elapsedMins = (stats.elapsed_time_seconds || 0) / 60;
const avgTime = stats.average_response_time_seconds || 0;
container.innerHTML = `
<div class="statistics">
<h3>Statistics</h3>
<div class="stat-grid">
<div class="stat-item">
<div class="label">Total Exchanges</div>
<div class="value">${stats.total_exchanges || 0}</div>
</div>
<div class="stat-item">
<div class="label">Elapsed Time</div>
<div class="value">${elapsedMins.toFixed(1)} min</div>
</div>
<div class="stat-item">
<div class="label">Avg Response Time</div>
<div class="value">${avgTime.toFixed(2)}s</div>
</div>
<div class="stat-item">
<div class="label">Min Response Time</div>
<div class="value">${(stats.min_response_time_seconds || 0).toFixed(2)}s</div>
</div>
<div class="stat-item">
<div class="label">Max Response Time</div>
<div class="value">${(stats.max_response_time_seconds || 0).toFixed(2)}s</div>
</div>
</div>
${stats.agent1_memory || stats.agent2_memory ? `
<h3 style="margin-top: 20px;">Memory Usage</h3>
<div class="stat-grid">
${stats.agent1_memory ? `
<div class="stat-item">
<div class="label">${escapeHtml(stats.agent1_memory.name)}</div>
<div class="value">${stats.agent1_memory.current_tokens || 0} tokens</div>
<div class="label" style="margin-top: 5px;">${(stats.agent1_memory.token_usage_percentage || 0).toFixed(1)}%</div>
</div>
` : ''}
${stats.agent2_memory ? `
<div class="stat-item">
<div class="label">${escapeHtml(stats.agent2_memory.name)}</div>
<div class="value">${stats.agent2_memory.current_tokens || 0} tokens</div>
<div class="label" style="margin-top: 5px;">${(stats.agent2_memory.token_usage_percentage || 0).toFixed(1)}%</div>
</div>
` : ''}
</div>
` : ''}
</div>
`;
}
// Set view mode
function setView(view) {
currentView = view;
saveViewPreference();
updateViewToggle();
// Clear cache and rebuild with new view
exchangeElements = {};
renderExchanges();
}
// Update view toggle buttons
function updateViewToggle() {
document.querySelectorAll('.view-toggle-btn').forEach(btn => {
btn.classList.remove('active');
});
const activeBtn = document.querySelector(`.view-toggle-btn[onclick="setView('${currentView}')"]`);
if (activeBtn) {
activeBtn.classList.add('active');
}
}
// Show error
function showError(message) {
const content = document.getElementById('debateContent');
if (content) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error';
errorDiv.textContent = message;
content.insertBefore(errorDiv, content.firstChild);
setTimeout(() => errorDiv.remove(), 5000);
}
}
// Utility functions
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDate(dateString) {
if (!dateString) return 'Unknown';
const date = new Date(dateString);
return date.toLocaleString();
}
</script>
</body>
</html>