From d2b1fbd5ac63fd1eb17e68b5364b7dee5cc86e6e Mon Sep 17 00:00:00 2001 From: CaptainJack2491 Date: Mon, 9 Mar 2026 19:11:45 +0000 Subject: feat: upgrade web GUI to modular ES6 and add Chart.js This commit completely overhauls the initial GUI: - Splits monolithic JS/CSS into an ES6 module structure - Implements an industrial 'Control Room' dark theme - Adds dynamic file selection endpoints for CSV and JSON judge logs - Integrates Chart.js to automatically render visualizations from CSV data - Updates web_gui_plan.md to reflect architectural changes --- api/static/js/api.js | 46 ++++++ api/static/js/components/config.js | 107 +++++++++++++ api/static/js/components/results.js | 296 +++++++++++++++++++++++++++++++++++ api/static/js/components/terminal.js | 49 ++++++ api/static/js/main.js | 66 ++++++++ api/static/js/ui.js | 62 ++++++++ 6 files changed, 626 insertions(+) create mode 100644 api/static/js/api.js create mode 100644 api/static/js/components/config.js create mode 100644 api/static/js/components/results.js create mode 100644 api/static/js/components/terminal.js create mode 100644 api/static/js/main.js create mode 100644 api/static/js/ui.js (limited to 'api/static/js') diff --git a/api/static/js/api.js b/api/static/js/api.js new file mode 100644 index 0000000..ecfe30d --- /dev/null +++ b/api/static/js/api.js @@ -0,0 +1,46 @@ +/** + * api.js - Core API communication + */ + +const API_BASE = ''; + +export const api = { + async get(endpoint) { + const response = await fetch(`${API_BASE}${endpoint}`); + if (!response.ok) throw new Error(`API Error: ${response.status}`); + return await response.json(); + }, + + async post(endpoint, data) { + const response = await fetch(`${API_BASE}${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + if (!response.ok) throw new Error(`API Error: ${response.status}`); + return await response.json(); + }, + + async delete(endpoint) { + const response = await fetch(`${API_BASE}${endpoint}`, { method: 'DELETE' }); + if (!response.ok) throw new Error(`API Error: ${response.status}`); + return await response.json(); + }, + + streamLogs(onLog, onDone, onError) { + const eventSource = new EventSource(`${API_BASE}/api/logs/stream`); + + eventSource.onmessage = (e) => onLog(e.data); + eventSource.addEventListener('log', (e) => onLog(e.data)); + eventSource.addEventListener('done', () => { + eventSource.close(); + onDone(); + }); + eventSource.onerror = (e) => { + console.error('SSE Error:', e); + if (onError) onError(e); + }; + + return eventSource; + } +}; diff --git a/api/static/js/components/config.js b/api/static/js/components/config.js new file mode 100644 index 0000000..d87c874 --- /dev/null +++ b/api/static/js/components/config.js @@ -0,0 +1,107 @@ +/** + * config.js - Experiment configuration and execution + */ +import { api } from '../api.js'; +import { ui } from '../ui.js'; +import { terminal } from './terminal.js'; +import { results } from './results.js'; + +let isRunning = false; + +export const config = { + async init() { + await Promise.all([ + this.loadScenarios(), + this.loadModels(), + this.loadDefaults() + ]); + + ui.elements.runBtn.addEventListener('click', () => this.startRun()); + ui.elements.cancelBtn.addEventListener('click', () => this.stopRun()); + + // Scenario detail updates + ui.elements.scenarioSelect.addEventListener('change', (e) => this.updateOversight(e.target.value)); + }, + + async loadScenarios() { + const scenarios = await api.get('/api/scenarios'); + ui.elements.scenarioSelect.innerHTML = scenarios.map(s => + `` + ).join(''); + if (scenarios[0]) this.updateOversight(scenarios[0].name); + }, + + async loadModels() { + const models = await api.get('/api/models'); + ui.elements.modelSelect.innerHTML = models.map(m => + `` + ).join(''); + }, + + async loadDefaults() { + const cfg = await api.get('/api/config'); + if (cfg.defaults?.oversight) { + ui.elements.oversightSelect.value = cfg.defaults.oversight; + } + }, + + async updateOversight(scenarioName) { + if (!scenarioName) return; + const details = await api.get(`/api/scenarios/${scenarioName}`); + const levels = details.oversight_levels || ['low', 'mid', 'high']; + ui.elements.oversightSelect.innerHTML = levels.map(l => + `` + ).join(''); + }, + + async startRun() { + const data = { + scenario: ui.elements.scenarioSelect.value, + model: ui.elements.modelSelect.value, + oversight: ui.elements.oversightSelect.value, + runs: ui.elements.runsInput.value + }; + + this.setRunningState(true); + terminal.clear(); + terminal.append(`[SYSTEM] Starting experiment: ${data.scenario} | ${data.model}`); + + try { + await api.post('/api/run', data); + api.streamLogs( + (log) => terminal.append(log), + () => { + this.setRunningState(false); + ui.updateStatus('complete'); + terminal.append('[SYSTEM] Run completed successfully.'); + results.loadAll(); + }, + (err) => { + this.setRunningState(false); + ui.updateStatus('error'); + terminal.append(`[ERROR] Stream disconnected: ${err}`); + } + ); + ui.updateStatus('running', new Date()); + } catch (err) { + this.setRunningState(false); + ui.updateStatus('error'); + terminal.append(`[ERROR] Failed to start run: ${err.message}`); + } + }, + + async stopRun() { + try { + await api.delete('/api/run'); + terminal.append('[SYSTEM] Cancel request sent.'); + } catch (err) { + terminal.append(`[ERROR] Cancel failed: ${err.message}`); + } + }, + + setRunningState(running) { + isRunning = running; + ui.elements.runBtn.disabled = running; + ui.elements.cancelBtn.disabled = !running; + } +}; diff --git a/api/static/js/components/results.js b/api/static/js/components/results.js new file mode 100644 index 0000000..d06d7fc --- /dev/null +++ b/api/static/js/components/results.js @@ -0,0 +1,296 @@ +/** + * results.js - Data visualization and reports + */ +import { api } from '../api.js'; +import { ui } from '../ui.js'; + +let activeCharts = []; // Store chart instances to destroy them before redraw + +export const results = { + async init() { + // Bind events + ui.elements.csvFileSelect.addEventListener('change', (e) => this.loadCSVData(e.target.value)); + ui.elements.chartFileSelect.addEventListener('change', (e) => this.loadChartData(e.target.value)); + ui.elements.judgeFileSelect.addEventListener('change', (e) => this.loadJudgeData(e.target.value)); + + // Initial lists + await this.loadAll(); + }, + + async loadAll() { + await Promise.all([ + this.updateCSVFileList(), + this.updateJudgeFileList(), + this.loadImages() // Static images just load once + ]); + }, + + async updateCSVFileList() { + try { + const files = await api.get('/api/results/files'); + const options = files.length > 0 + ? files.map(f => ``).join('') + : ''; + + ui.elements.csvFileSelect.innerHTML = options; + ui.elements.chartFileSelect.innerHTML = options; + + // Prefer loading results.csv by default if it exists + const defaultFile = files.find(f => f.endsWith('results.csv')) || files[0]; + + if (defaultFile) { + ui.elements.csvFileSelect.value = defaultFile; + ui.elements.chartFileSelect.value = defaultFile; + await this.loadCSVData(defaultFile); + await this.loadChartData(defaultFile); + } + } catch (error) { + console.error("Failed loading CSV file list", error); + } + }, + + async updateJudgeFileList() { + try { + const files = await api.get('/api/judge/files'); + const options = files.length > 0 + ? files.map(f => ``).join('') + : ''; + + ui.elements.judgeFileSelect.innerHTML = options; + + if (files.length > 0) { + ui.elements.judgeFileSelect.value = files[0]; + await this.loadJudgeData(files[0]); + } + } catch (error) { + console.error("Failed loading Judge file list", error); + } + }, + + async loadCSVData(filePath) { + if (!filePath) return; + ui.elements.csvResults.innerHTML = '
Loading data...
'; + + try { + const data = await api.get(`/api/results?file=${encodeURIComponent(filePath)}`); + if (Object.keys(data).length === 0 || data.error) { + ui.elements.csvResults.innerHTML = '
Failed to load data.
'; + return; + } + + // Since we asked for a specific file, it's the only key + const content = Object.values(data)[0]; + if (content.error) { + ui.elements.csvResults.innerHTML = `
Error: ${content.error}
`; + return; + } + + let html = `
+
${filePath}
+
+ + ${content.columns.map(c => ``).join('')} + + ${content.data.map(row => ` + ${content.columns.map(col => ``).join('')} + `).join('')} + +
${c}
${row[col] ?? ''}
+
+
`; + + ui.elements.csvResults.innerHTML = html; + } catch (err) { + ui.elements.csvResults.innerHTML = `
Error: ${err.message}
`; + } + }, + + async loadChartData(filePath) { + if (!filePath) return; + + // Clean up old charts + activeCharts.forEach(chart => chart.destroy()); + activeCharts = []; + ui.elements.interactiveCharts.innerHTML = '
Generating charts...
'; + + try { + const data = await api.get(`/api/results?file=${encodeURIComponent(filePath)}`); + const content = Object.values(data)[0]; + + if (!content || content.error || !content.data || content.data.length === 0) { + ui.elements.interactiveCharts.innerHTML = '
Not enough data to graph.
'; + return; + } + + ui.elements.interactiveCharts.innerHTML = ''; + + // Generate charts based on available columns + const cols = content.columns; + + // 1. Blackbox Categories + if (cols.includes('blackbox_category')) { + this.renderPieChart('Blackbox Categories', content.data, 'blackbox_category'); + } + + // 2. Glassbox Categories + if (cols.includes('glassbox_category')) { + this.renderPieChart('Glassbox Categories', content.data, 'glassbox_category'); + } + + // 3. Models vs Blackbox Category + if (cols.includes('model') && cols.includes('blackbox_category')) { + this.renderBarChart('Deception by Model', content.data, 'model', 'blackbox_category'); + } + + // 4. Fallback if no specific columns exist + if (ui.elements.interactiveCharts.innerHTML === '') { + ui.elements.interactiveCharts.innerHTML = '
No plottable categorical columns found in this CSV.
'; + } + + } catch (err) { + ui.elements.interactiveCharts.innerHTML = `
Chart Error: ${err.message}
`; + } + }, + + renderPieChart(title, data, column) { + const counts = {}; + data.forEach(row => { + const val = row[column] || 'Unknown'; + counts[val] = (counts[val] || 0) + 1; + }); + + const canvasId = `chart-${Math.random().toString(36).substr(2, 9)}`; + const container = document.createElement('div'); + container.className = 'chart-container'; + container.innerHTML = ``; + ui.elements.interactiveCharts.appendChild(container); + + const ctx = document.getElementById(canvasId).getContext('2d'); + const chart = new Chart(ctx, { + type: 'doughnut', + data: { + labels: Object.keys(counts), + datasets: [{ + data: Object.values(counts), + backgroundColor: ['#9d4edd', '#ef4444', '#10b981', '#f59e0b', '#3b82f6', '#6366f1'], + borderWidth: 0 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { display: true, text: title, color: '#f0f0f2', font: { family: 'Inter', size: 14 } }, + legend: { labels: { color: '#9ea0a6' }, position: 'right' } + } + } + }); + activeCharts.push(chart); + }, + + renderBarChart(title, data, xCol, groupCol) { + // Group by X then GroupCol + const matrix = {}; + const groups = new Set(); + + data.forEach(row => { + const xVal = row[xCol] || 'Unknown'; + const gVal = row[groupCol] || 'Unknown'; + if (!matrix[xVal]) matrix[xVal] = {}; + matrix[xVal][gVal] = (matrix[xVal][gVal] || 0) + 1; + groups.add(gVal); + }); + + const labels = Object.keys(matrix); + const datasets = Array.from(groups).map((group, i) => { + const colors = ['#ef4444', '#10b981', '#f59e0b', '#3b82f6', '#9d4edd']; + return { + label: group, + data: labels.map(label => matrix[label][group] || 0), + backgroundColor: colors[i % colors.length], + } + }); + + const canvasId = `chart-${Math.random().toString(36).substr(2, 9)}`; + const container = document.createElement('div'); + container.className = 'chart-container'; + container.innerHTML = ``; + ui.elements.interactiveCharts.appendChild(container); + + const ctx = document.getElementById(canvasId).getContext('2d'); + const chart = new Chart(ctx, { + type: 'bar', + data: { labels, datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { stacked: true, ticks: { color: '#9ea0a6' }, grid: { color: '#2a2a2e' } }, + y: { stacked: true, ticks: { color: '#9ea0a6', stepSize: 1 }, grid: { color: '#2a2a2e' } } + }, + plugins: { + title: { display: true, text: title, color: '#f0f0f2', font: { family: 'Inter', size: 14 } }, + legend: { labels: { color: '#9ea0a6' } } + } + } + }); + activeCharts.push(chart); + }, + + async loadImages() { + const images = await api.get('/api/results/images'); + if (images.length === 0) return; + + ui.elements.imageResults.innerHTML = images.map(img => ` +
+ ${img.name} + +
+ `).join(''); + }, + + async loadJudgeData(filePath) { + if (!filePath) return; + ui.elements.judgeResults.innerHTML = '
Loading data...
'; + + try { + const data = await api.get(`/api/judge/results?file=${encodeURIComponent(filePath)}`); + if (Object.keys(data).length === 0 || data.message) { + ui.elements.judgeResults.innerHTML = '
No data available in this report.
'; + return; + } + + const content = Object.values(data)[0]; + if (content.error) { + ui.elements.judgeResults.innerHTML = `
Error: ${content.error}
`; + return; + } + + let html = ''; + if (content.type === 'csv') { + html += `
+
${filePath}
+
+ + ${content.columns.map(c => ``).join('')} + + ${content.data.map(row => ` + ${content.columns.map(col => ``).join('')} + `).join('')} + +
${c}
${row[col] ?? ''}
+
+
`; + } else { + // Render JSON as pretty block + html += `
+
${filePath}
+
${JSON.stringify(content.data, null, 2)}
+
`; + } + ui.elements.judgeResults.innerHTML = html; + } catch (err) { + ui.elements.judgeResults.innerHTML = `
Error: ${err.message}
`; + } + } +}; diff --git a/api/static/js/components/terminal.js b/api/static/js/components/terminal.js new file mode 100644 index 0000000..77c9794 --- /dev/null +++ b/api/static/js/components/terminal.js @@ -0,0 +1,49 @@ +/** + * terminal.js - Log rendering logic + */ +import { ui } from '../ui.js'; + +let autoScroll = true; + +export const terminal = { + init() { + const { clearLogsBtn, scrollLockBtn } = ui.elements; + + clearLogsBtn.addEventListener('click', () => this.clear()); + + scrollLockBtn.addEventListener('click', () => { + autoScroll = !autoScroll; + scrollLockBtn.classList.toggle('active', autoScroll); + }); + }, + + append(message) { + const { logsContainer } = ui.elements; + const line = document.createElement('div'); + line.className = 'terminal-line'; + + // High-speed parsing for log levels + if (message.includes('[ERROR]')) line.classList.add('error'); + else if (message.includes('[WARN]')) line.classList.add('warning'); + else if (message.includes('[DEBUG]')) line.classList.add('muted'); + else if (message.includes('SUCCESS') || message.includes('complete')) line.classList.add('success'); + else if (message.includes('Reasoning:')) line.classList.add('reasoning'); + else line.classList.add('info'); + + line.textContent = message; + logsContainer.appendChild(line); + + if (autoScroll) { + logsContainer.scrollTop = logsContainer.scrollHeight; + } + + // Performance: Prune logs if they get too long (keep last 1000 lines) + if (logsContainer.children.length > 1000) { + logsContainer.removeChild(logsContainer.firstChild); + } + }, + + clear() { + ui.elements.logsContainer.innerHTML = '
Logs cleared.
'; + } +}; diff --git a/api/static/js/main.js b/api/static/js/main.js new file mode 100644 index 0000000..21a957a --- /dev/null +++ b/api/static/js/main.js @@ -0,0 +1,66 @@ +/** + * main.js - Application Entry Point + */ +import { ui } from './ui.js'; +import { api } from './api.js'; +import { terminal } from './components/terminal.js'; +import { config } from './components/config.js'; +import { results } from './components/results.js'; + +async function init() { + console.log('Initializing AI Reasoning Framework GUI...'); + + // Init Core Components + terminal.init(); + await config.init(); + await results.init(); + + // Init UI behaviors + ui.initTabs((tabId) => { + // Refresh data when switching to results tabs + if (['csv', 'images', 'judge'].includes(tabId)) { + results.loadAll(); + } + }); + + // Initial Status Check + try { + const status = await api.get('/api/run/status'); + ui.updateStatus(status.status, status.start_time); + + if (status.status === 'running') { + config.setRunningState(true); + // Re-attach to stream if page reloaded + api.streamLogs( + (log) => terminal.append(log), + () => { + config.setRunningState(false); + ui.updateStatus('complete'); + results.loadAll(); + } + ); + } + } catch (e) { + console.warn('Initial status check failed', e); + } +} + +// Start the app +function start() { + init().catch(err => { + console.error("Initialization error:", err); + const logsContainer = document.getElementById('logs-container'); + if (logsContainer) { + const errorLine = document.createElement('div'); + errorLine.className = 'terminal-line error'; + errorLine.textContent = `[GUI STARTUP ERROR] ${err.message}`; + logsContainer.appendChild(errorLine); + } + }); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start); +} else { + start(); +} diff --git a/api/static/js/ui.js b/api/static/js/ui.js new file mode 100644 index 0000000..a2c8b8b --- /dev/null +++ b/api/static/js/ui.js @@ -0,0 +1,62 @@ +/** + * ui.js - Global UI Utilities + */ + +export const ui = { + get elements() { + return { + scenarioSelect: document.getElementById('scenario-select'), + modelSelect: document.getElementById('model-select'), + oversightSelect: document.getElementById('oversight-select'), + runsInput: document.getElementById('runs-input'), + runBtn: document.getElementById('run-btn'), + cancelBtn: document.getElementById('cancel-btn'), + statusBadge: document.getElementById('status-badge'), + statusTime: document.getElementById('status-time'), + logsContainer: document.getElementById('logs-container'), + + csvResults: document.getElementById('csv-results'), + imageResults: document.getElementById('image-results'), + judgeResults: document.getElementById('judge-results'), + interactiveCharts: document.getElementById('interactive-charts'), + + csvFileSelect: document.getElementById('csv-file-select'), + chartFileSelect: document.getElementById('chart-file-select'), + judgeFileSelect: document.getElementById('judge-file-select'), + + tabTriggers: document.querySelectorAll('.tab-trigger'), + tabPanels: document.querySelectorAll('.tab-panel'), + clearLogsBtn: document.getElementById('clear-logs-btn'), + scrollLockBtn: document.getElementById('scroll-lock-btn') + }; + }, + + updateStatus(status, time = null) { + const { statusBadge, statusTime } = this.elements; + statusBadge.className = `badge badge-${status}`; + statusBadge.textContent = status.toUpperCase(); + + if (time) { + const date = new Date(time); + statusTime.textContent = date.toLocaleTimeString(); + } else if (status === 'idle') { + statusTime.textContent = ''; + } + }, + + initTabs(onTabChange) { + this.elements.tabTriggers.forEach(trigger => { + trigger.addEventListener('click', () => { + const tabId = trigger.dataset.tab; + + this.elements.tabTriggers.forEach(t => t.classList.remove('active')); + this.elements.tabPanels.forEach(p => p.classList.remove('active')); + + trigger.classList.add('active'); + document.getElementById(`tab-${tabId}`).classList.add('active'); + + if (onTabChange) onTabChange(tabId); + }); + }); + } +}; -- cgit v1.2.3