summaryrefslogtreecommitdiff
path: root/api/static/js
diff options
context:
space:
mode:
Diffstat (limited to 'api/static/js')
-rw-r--r--api/static/js/api.js46
-rw-r--r--api/static/js/components/config.js107
-rw-r--r--api/static/js/components/results.js296
-rw-r--r--api/static/js/components/terminal.js49
-rw-r--r--api/static/js/main.js66
-rw-r--r--api/static/js/ui.js62
6 files changed, 626 insertions, 0 deletions
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 =>
+ `<option value="${s.name}">${s.name}</option>`
+ ).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 =>
+ `<option value="${m.id}">${m.id} (${m.provider})</option>`
+ ).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 =>
+ `<option value="${l}">${l.toUpperCase()}</option>`
+ ).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 => `<option value="${f}">${f}</option>`).join('')
+ : '<option value="">No files found</option>';
+
+ 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 => `<option value="${f}">${f}</option>`).join('')
+ : '<option value="">No judge reports found</option>';
+
+ 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 = '<div class="empty-state">Loading data...</div>';
+
+ try {
+ const data = await api.get(`/api/results?file=${encodeURIComponent(filePath)}`);
+ if (Object.keys(data).length === 0 || data.error) {
+ ui.elements.csvResults.innerHTML = '<div class="empty-state">Failed to load data.</div>';
+ 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 = `<div class="empty-state">Error: ${content.error}</div>`;
+ return;
+ }
+
+ let html = `<div class="data-table-wrapper">
+ <div class="data-table-title">${filePath}</div>
+ <div style="overflow-x: auto;">
+ <table class="data-table">
+ <thead><tr>${content.columns.map(c => `<th>${c}</th>`).join('')}</tr></thead>
+ <tbody>
+ ${content.data.map(row => `
+ <tr>${content.columns.map(col => `<td>${row[col] ?? ''}</td>`).join('')}</tr>
+ `).join('')}
+ </tbody>
+ </table>
+ </div>
+ </div>`;
+
+ ui.elements.csvResults.innerHTML = html;
+ } catch (err) {
+ ui.elements.csvResults.innerHTML = `<div class="empty-state">Error: ${err.message}</div>`;
+ }
+ },
+
+ async loadChartData(filePath) {
+ if (!filePath) return;
+
+ // Clean up old charts
+ activeCharts.forEach(chart => chart.destroy());
+ activeCharts = [];
+ ui.elements.interactiveCharts.innerHTML = '<div class="empty-state" style="grid-column: 1 / -1;">Generating charts...</div>';
+
+ 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 = '<div class="empty-state" style="grid-column: 1 / -1;">Not enough data to graph.</div>';
+ 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 = '<div class="empty-state" style="grid-column: 1 / -1;">No plottable categorical columns found in this CSV.</div>';
+ }
+
+ } catch (err) {
+ ui.elements.interactiveCharts.innerHTML = `<div class="empty-state" style="grid-column: 1 / -1;">Chart Error: ${err.message}</div>`;
+ }
+ },
+
+ 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 = `<canvas id="${canvasId}"></canvas>`;
+ 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 = `<canvas id="${canvasId}"></canvas>`;
+ 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 => `
+ <div class="image-card">
+ <img src="/api/results/images/${img.name}" alt="${img.name}" loading="lazy">
+ <div class="image-card-footer">${img.name}</div>
+ </div>
+ `).join('');
+ },
+
+ async loadJudgeData(filePath) {
+ if (!filePath) return;
+ ui.elements.judgeResults.innerHTML = '<div class="empty-state">Loading data...</div>';
+
+ try {
+ const data = await api.get(`/api/judge/results?file=${encodeURIComponent(filePath)}`);
+ if (Object.keys(data).length === 0 || data.message) {
+ ui.elements.judgeResults.innerHTML = '<div class="empty-state">No data available in this report.</div>';
+ return;
+ }
+
+ const content = Object.values(data)[0];
+ if (content.error) {
+ ui.elements.judgeResults.innerHTML = `<div class="empty-state">Error: ${content.error}</div>`;
+ return;
+ }
+
+ let html = '';
+ if (content.type === 'csv') {
+ html += `<div class="data-table-wrapper">
+ <div class="data-table-title">${filePath}</div>
+ <div style="overflow-x: auto;">
+ <table class="data-table">
+ <thead><tr>${content.columns.map(c => `<th>${c}</th>`).join('')}</tr></thead>
+ <tbody>
+ ${content.data.map(row => `
+ <tr>${content.columns.map(col => `<td>${row[col] ?? ''}</td>`).join('')}</tr>
+ `).join('')}
+ </tbody>
+ </table>
+ </div>
+ </div>`;
+ } else {
+ // Render JSON as pretty block
+ html += `<div class="data-table-wrapper">
+ <div class="data-table-title">${filePath}</div>
+ <pre style="padding: 16px; color: #f0f0f2; font-family: monospace; white-space: pre-wrap; word-wrap: break-word;">${JSON.stringify(content.data, null, 2)}</pre>
+ </div>`;
+ }
+ ui.elements.judgeResults.innerHTML = html;
+ } catch (err) {
+ ui.elements.judgeResults.innerHTML = `<div class="empty-state">Error: ${err.message}</div>`;
+ }
+ }
+};
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 = '<div class="terminal-line muted">Logs cleared.</div>';
+ }
+};
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);
+ });
+ });
+ }
+};