From 2ed454c66d9865a8abcbecc0081f7023cf4b50f9 Mon Sep 17 00:00:00 2001 From: CaptainJack2491 Date: Mon, 9 Mar 2026 18:18:48 +0000 Subject: feat: Add web GUI for experiment framework - Add FastAPI backend (api/server.py) with endpoints for: - Config read/write - Scenario/model discovery - Experiment run management (start/cancel/status) - Real-time log streaming via SSE - Results fetching (CSV, images, judge results) - Add vanilla JS frontend (api/static/): - Clean dark-themed dashboard - Configuration panel with dropdowns - Live log terminal - Results viewer with tabs - Add documentation (docs/web_gui_plan.md) Dependencies added: fastapi, uvicorn, sse-starlette --- api/__init__.py | 1 + api/endpoints/__init__.py | 1 + api/server.py | 491 +++++++++++++++++++++++++++++++++++++++++++ api/static/app.js | 516 ++++++++++++++++++++++++++++++++++++++++++++++ api/static/index.html | 111 ++++++++++ api/static/style.css | 461 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1581 insertions(+) create mode 100644 api/__init__.py create mode 100644 api/endpoints/__init__.py create mode 100644 api/server.py create mode 100644 api/static/app.js create mode 100644 api/static/index.html create mode 100644 api/static/style.css (limited to 'api') diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..7183f8a --- /dev/null +++ b/api/__init__.py @@ -0,0 +1 @@ +# API module for Web GUI diff --git a/api/endpoints/__init__.py b/api/endpoints/__init__.py new file mode 100644 index 0000000..4663a01 --- /dev/null +++ b/api/endpoints/__init__.py @@ -0,0 +1 @@ +# API Endpoints diff --git a/api/server.py b/api/server.py new file mode 100644 index 0000000..ee5fd2b --- /dev/null +++ b/api/server.py @@ -0,0 +1,491 @@ +""" +FastAPI server for the Web GUI. +Wraps core project functionality without modifying it. +""" +import os +import sys +import asyncio +import subprocess +from pathlib import Path +from typing import Optional, Dict, Any, List +from datetime import datetime +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException, BackgroundTasks, Request +from fastapi.responses import HTMLResponse, FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from sse_starlette.sse import EventSourceResponse +import yaml + +# Add project root to path to import core modules +PROJECT_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.config_loader import ConfigLoader + + +# Global state for run management +class RunManager: + """Manages experiment runs.""" + + def __init__(self): + self.current_process: Optional[subprocess.Popen] = None + self.status: str = "idle" # idle, running, complete, error + self.start_time: Optional[datetime] = None + self.log_file_path: Optional[str] = None + self.config: Optional[ConfigLoader] = None + + def load_config(self, config_path: str = "config.yaml"): + """Load configuration and update log file path.""" + self.config = ConfigLoader(config_path) + self.config.load() + self.log_file_path = self.config.logging_config.get('file') + + # If relative path, make it absolute from project root + if self.log_file_path and not os.path.isabs(self.log_file_path): + self.log_file_path = os.path.join(PROJECT_ROOT, self.log_file_path) + + return self.config + + async def start_run(self, config_path: str = "config.yaml"): + """Start an experiment run in background.""" + if self.status == "running": + raise HTTPException(status_code=409, detail="A run is already in progress") + + # Load config to get log file path + self.load_config(config_path) + + self.status = "running" + self.start_time = datetime.now() + + # Start the run in background + cmd = [sys.executable, "-m", "uv", "run", "src/main.py"] + self.current_process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + cwd=PROJECT_ROOT + ) + + return {"status": "started", "message": "Experiment run started"} + + def cancel_run(self): + """Cancel the current run.""" + if self.current_process: + self.current_process.terminate() + self.current_process = None + self.status = "cancelled" + return {"status": "cancelled"} + return {"status": "idle", "message": "No run to cancel"} + + def get_status(self): + """Get current run status.""" + if self.current_process and self.current_process.poll() is None: + self.status = "running" + elif self.status == "running": + self.status = "complete" + + return { + "status": self.status, + "start_time": self.start_time.isoformat() if self.start_time else None, + } + + +# Global instance +run_manager = RunManager() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan handler.""" + # Startup: Load config + try: + run_manager.load_config() + except Exception as e: + print(f"Warning: Could not load config: {e}") + + yield + + # Shutdown: Cancel any running process + if run_manager.current_process: + run_manager.cancel_run() + + +# Create FastAPI app +app = FastAPI( + title="AI Agent Reasoning Experiment Framework", + description="Web GUI for running experiments and viewing results", + version="0.1.0", + lifespan=lifespan +) + +# Mount static files +static_dir = Path(__file__).parent / "static" +if static_dir.exists(): + app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + + +# ============================================================================ +# Root Endpoint - Serve HTML +# ============================================================================ + +@app.get("/", response_class=HTMLResponse) +async def root(): + """Serve the main HTML page.""" + index_path = static_dir / "index.html" + if index_path.exists(): + return FileResponse(index_path) + return HTMLResponse(content="

index.html not found

", status_code=404) + + +# ============================================================================ +# Config Endpoints +# ============================================================================ + +@app.get("/api/config") +async def get_config(): + """Read current config.yaml.""" + config_path = PROJECT_ROOT / "config.yaml" + if not config_path.exists(): + raise HTTPException(status_code=404, detail="config.yaml not found") + + with open(config_path) as f: + config_data = yaml.safe_load(f) + + return config_data + + +@app.put("/api/config") +async def update_config(config_data: Dict[str, Any]): + """Update config.yaml.""" + config_path = PROJECT_ROOT / "config.yaml" + + with open(config_path, 'w') as f: + yaml.dump(config_data, f, default_flow_style=False) + + # Reload config in run manager + run_manager.load_config() + + return {"status": "saved", "message": "Configuration updated"} + + +@app.get("/api/logging") +async def get_logging_config(): + """Get logging configuration including file path.""" + config = run_manager.config + if not config: + raise HTTPException(status_code=500, detail="Config not loaded") + + return { + "level": config.logging_config.get('level'), + "format": config.logging_config.get('format'), + "output": config.logging_config.get('output'), + "file": config.logging_config.get('file'), + "file_absolute": run_manager.log_file_path + } + + +# ============================================================================ +# Discovery Endpoints +# ============================================================================ + +@app.get("/api/scenarios") +async def list_scenarios(): + """List all available scenarios from scenarios/ directory.""" + scenarios_dir = PROJECT_ROOT / "scenarios" + if not scenarios_dir.exists(): + return [] + + scenarios = [] + for item in scenarios_dir.iterdir(): + if item.is_dir() and not item.name.startswith('.'): + # Check for oversight levels + oversight_dir = item / "oversight" + oversight_levels = [] + if oversight_dir.exists(): + oversight_levels = [f.stem for f in oversight_dir.glob("*.md")] + + scenarios.append({ + "name": item.name, + "path": str(item.relative_to(PROJECT_ROOT)), + "oversight_levels": oversight_levels + }) + + return scenarios + + +@app.get("/api/scenarios/{scenario_name}") +async def get_scenario(scenario_name: str): + """Get details for a specific scenario.""" + scenario_path = PROJECT_ROOT / "scenarios" / scenario_name + if not scenario_path.exists(): + raise HTTPException(status_code=404, detail="Scenario not found") + + # Read scenario files + files = {} + for md_file in scenario_path.glob("*.md"): + if md_file.name != "regex_rules.yaml": + with open(md_file) as f: + files[md_file.stem] = f.read() + + # Check oversight levels + oversight_dir = scenario_path / "oversight" + oversight_levels = {} + if oversight_dir.exists(): + for md_file in oversight_dir.glob("*.md"): + with open(md_file) as f: + oversight_levels[md_file.stem] = f.read() + + return { + "name": scenario_name, + "files": files, + "oversight_levels": oversight_levels + } + + +@app.get("/api/models") +async def list_models(): + """List models from config.""" + config = run_manager.config + if not config: + raise HTTPException(status_code=500, detail="Config not loaded") + + return [ + { + "id": model.id, + "provider": model.provider, + "temperature": model.temperature, + "max_tokens": model.max_tokens + } + for model in config.models + ] + + +@app.get("/api/providers") +async def list_providers(): + """List providers from config.""" + config = run_manager.config + if not config: + raise HTTPException(status_code=500, detail="Config not loaded") + + return { + name: { + "base_url": provider.base_url, + "api_key_env": provider.api_key_env + } + for name, provider in config.providers.items() + } + + +# ============================================================================ +# Execution Endpoints +# ============================================================================ + +@app.post("/api/run") +async def start_run(background_tasks: BackgroundTasks): + """Start an experiment run.""" + try: + result = await run_manager.start_run() + return result + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/run/status") +async def get_run_status(): + """Get current run status.""" + return run_manager.get_status() + + +@app.delete("/api/run") +async def cancel_run(): + """Cancel the current run.""" + return run_manager.cancel_run() + + +@app.get("/api/logs/stream") +async def log_stream(): + """Stream logs in real-time using SSE.""" + async def event_generator(): + log_file = run_manager.log_file_path + + if not log_file or not os.path.exists(log_file): + yield {"event": "error", "data": "Log file not found"} + return + + # Track file position for tailing + file_pos = 0 + + while True: + # Check if process is still running + status = run_manager.get_status() + if status["status"] == "idle" and not run_manager.current_process: + break + + try: + if os.path.exists(log_file): + with open(log_file, 'r') as f: + f.seek(file_pos) + new_lines = f.readlines() + file_pos = f.tell() + + for line in new_lines: + yield {"event": "log", "data": line.rstrip()} + + # Check if process ended + if run_manager.current_process and run_manager.current_process.poll() is not None: + # Process finished, yield remaining logs + if os.path.exists(log_file): + with open(log_file, 'r') as f: + f.seek(file_pos) + remaining = f.read() + if remaining: + yield {"event": "log", "data": remaining} + break + + except Exception as e: + yield {"event": "error", "data": str(e)} + break + + await asyncio.sleep(0.5) + + yield {"event": "done", "data": "Run completed"} + + return EventSourceResponse(event_generator()) + + +# ============================================================================ +# Results Endpoints +# ============================================================================ + +@app.get("/api/results") +async def get_results(): + """Get experiment results as JSON.""" + config = run_manager.config + if not config: + raise HTTPException(status_code=500, detail="Config not loaded") + + output_dir = PROJECT_ROOT / config.output_dir + + # Look for CSV files + csv_files = list(output_dir.glob("*.csv")) if output_dir.exists() else [] + + results = {} + for csv_file in csv_files: + import pandas as pd + import math + try: + df = pd.read_csv(csv_file) + + # Convert NaN values to None for JSON serialization + def clean_value(val): + if isinstance(val, float) and (math.isnan(val) or math.isinf(val)): + return None + return val + + # Clean each row + cleaned_data = [] + for record in df.to_dict(orient="records"): + cleaned_record = {k: clean_value(v) for k, v in record.items()} + cleaned_data.append(cleaned_record) + + results[csv_file.stem] = { + "columns": df.columns.tolist(), + "data": cleaned_data + } + except Exception as e: + results[csv_file.stem] = {"error": str(e)} + + return results + + +@app.get("/api/results/images") +async def list_result_images(): + """List generated visualization images.""" + config = run_manager.config + if not config: + return [] + + output_dir = PROJECT_ROOT / config.output_dir + viz_dir = output_dir / "viz" + + if not viz_dir.exists(): + return [] + + images = [] + for img in viz_dir.glob("*"): + if img.suffix.lower() in ['.png', '.jpg', '.jpeg', '.gif', '.svg']: + images.append({ + "name": img.name, + "path": str(img.relative_to(PROJECT_ROOT)) + }) + + return images + + +@app.get("/api/results/images/{image_name}") +async def get_result_image(image_name: str): + """Serve a specific image.""" + config = run_manager.config + if not config: + raise HTTPException(status_code=500, detail="Config not loaded") + + output_dir = PROJECT_ROOT / config.output_dir + viz_dir = output_dir / "viz" + image_path = viz_dir / image_name + + if not image_path.exists(): + raise HTTPException(status_code=404, detail="Image not found") + + return FileResponse(image_path) + + +# ============================================================================ +# Judge Results Endpoints +# ============================================================================ + +@app.get("/api/judge/results") +async def get_judge_results(): + """Get judge results if available.""" + config = run_manager.config + if not config: + raise HTTPException(status_code=500, detail="Config not loaded") + + judge_log_dir = config.logging_config.get('file') + if judge_log_dir: + judge_dir = Path(judge_log_dir).parent / "judge" + else: + judge_dir = PROJECT_ROOT / "logs" / "judge" + + if not judge_dir.exists(): + return {"message": "No judge results found"} + + # Look for judge result files + import glob + result_files = list(judge_dir.glob("*.csv")) + list(judge_dir.glob("*.json")) + + results = {} + for rf in result_files: + if rf.suffix == '.csv': + import pandas as pd + df = pd.read_csv(rf) + results[rf.stem] = { + "type": "csv", + "columns": df.columns.tolist(), + "data": df.to_dict(orient="records") + } + elif rf.suffix == '.json': + import json + with open(rf) as f: + results[rf.stem] = {"type": "json", "data": json.load(f)} + + return results + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/api/static/app.js b/api/static/app.js new file mode 100644 index 0000000..0dcbd01 --- /dev/null +++ b/api/static/app.js @@ -0,0 +1,516 @@ +/* ========================================================================== + AI Agent Reasoning Experiment Framework - Web GUI App + ========================================================================== */ + +const API_BASE = ''; + +// State +let eventSource = null; +let isRunning = false; + +// ========================================================================== +// DOM Elements +// ========================================================================== + +const elements = { + // Configuration + scenarioSelect: document.getElementById('scenario-select'), + modelSelect: document.getElementById('model-select'), + oversightSelect: document.getElementById('oversight-select'), + runsInput: document.getElementById('runs-input'), + + // Buttons + runBtn: document.getElementById('run-btn'), + cancelBtn: document.getElementById('cancel-btn'), + clearLogsBtn: document.getElementById('clear-logs-btn'), + + // Status + statusBadge: document.getElementById('status-badge'), + statusTime: document.getElementById('status-time'), + + // Logs + logsContainer: document.getElementById('logs-container'), + + // Results + csvResults: document.getElementById('csv-results'), + imageResults: document.getElementById('image-results'), + judgeResults: document.getElementById('judge-results'), + + // Tabs + tabBtns: document.querySelectorAll('.tab-btn'), + tabContents: document.querySelectorAll('.tab-content'), +}; + +// ========================================================================== +// Initialization +// ========================================================================== + +document.addEventListener('DOMContentLoaded', async () => { + await loadConfig(); + await loadScenarios(); + await loadModels(); + setupEventListeners(); + startStatusPolling(); +}); + +// ========================================================================== +// API Functions +// ========================================================================== + +async function loadConfig() { + try { + const response = await fetch(`${API_BASE}/api/config`); + const config = await response.json(); + + // Set default values from config + if (config.defaults?.oversight) { + elements.oversightSelect.value = config.defaults.oversight; + } + } catch (error) { + console.error('Failed to load config:', error); + } +} + +async function loadScenarios() { + try { + const response = await fetch(`${API_BASE}/api/scenarios`); + const scenarios = await response.json(); + + elements.scenarioSelect.innerHTML = scenarios.map(s => + `` + ).join(''); + + // If only one scenario, auto-select it and load oversight levels + if (scenarios.length === 1) { + elements.scenarioSelect.value = scenarios[0].name; + updateOversightLevels(scenarios[0]); + } + } catch (error) { + console.error('Failed to load scenarios:', error); + elements.scenarioSelect.innerHTML = ''; + } +} + +async function loadModels() { + try { + const response = await fetch(`${API_BASE}/api/models`); + const models = await response.json(); + + elements.modelSelect.innerHTML = models.map(m => + `` + ).join(''); + } catch (error) { + console.error('Failed to load models:', error); + elements.modelSelect.innerHTML = ''; + } +} + +async function startRun() { + const scenario = elements.scenarioSelect.value; + const model = elements.modelSelect.value; + const oversight = elements.oversightSelect.value; + const runs = elements.runsInput.value; + + if (!scenario || !model) { + alert('Please select a scenario and model'); + return; + } + + // Show loading state + elements.runBtn.classList.add('loading'); + elements.runBtn.disabled = true; + elements.cancelBtn.disabled = false; + isRunning = true; + + // Clear logs + elements.logsContainer.innerHTML = ''; + appendLog('info', `Starting experiment: ${scenario} with ${model} (oversight: ${oversight}, runs: ${runs})`); + + try { + // TODO: For now, we just trigger the run with current config + // In the future, we could modify config via API + const response = await fetch(`${API_BASE}/api/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scenario, model, oversight, runs }) + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + // Start log streaming + startLogStream(); + updateStatus('running'); + + } catch (error) { + console.error('Failed to start run:', error); + appendLog('error', `Failed to start: ${error.message}`); + resetRunState(); + } +} + +async function cancelRun() { + try { + const response = await fetch(`${API_BASE}/api/run`, { + method: 'DELETE' + }); + + const result = await response.json(); + appendLog('warn', 'Run cancelled by user'); + updateStatus('cancelled'); + + } catch (error) { + console.error('Failed to cancel run:', error); + } finally { + stopLogStream(); + resetRunState(); + } +} + +// ========================================================================== +// Log Streaming +// ========================================================================== + +function startLogStream() { + stopLogStream(); // Close any existing connection + + eventSource = new EventSource(`${API_BASE}/api/logs/stream`); + + eventSource.onmessage = (event) => { + const data = event.data; + if (data) { + appendLog('info', data); + } + }; + + eventSource.addEventListener('log', (event) => { + const data = event.data; + if (data) { + appendLog('info', data); + } + }); + + eventSource.addEventListener('done', (event) => { + appendLog('info', '=== Run Complete ==='); + stopLogStream(); + isRunning = false; + updateStatus('complete'); + resetRunState(); + loadResults(); + }); + + eventSource.addEventListener('error', (event) => { + console.error('SSE Error:', event); + }); +} + +function stopLogStream() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } +} + +function appendLog(level, message) { + // Remove empty state if present + const emptyState = elements.logsContainer.querySelector('.logs-empty'); + if (emptyState) { + emptyState.remove(); + } + + const line = document.createElement('div'); + line.className = 'log-line'; + + // Color based on content + let logLevel = 'log-level-3'; + if (message.includes('[ERROR]') || message.includes('error') || message.includes('Error')) { + logLevel = 'log-level-1'; + } else if (message.includes('[WARN]') || message.includes('warning') || message.includes('Warning')) { + logLevel = 'log-level-2'; + } else if (message.includes('[DEBUG]') || message.includes('[DEBUG+]')) { + logLevel = 'log-level-4'; + } + + line.classList.add(logLevel); + line.textContent = message; + + elements.logsContainer.appendChild(line); + + // Auto-scroll to bottom + elements.logsContainer.scrollTop = elements.logsContainer.scrollHeight; +} + +function clearLogs() { + elements.logsContainer.innerHTML = '
Run an experiment to see logs...
'; +} + +// ========================================================================== +// Status Polling +// ========================================================================== + +let statusPollingInterval = null; + +function startStatusPolling() { + statusPollingInterval = setInterval(async () => { + try { + const response = await fetch(`${API_BASE}/api/run/status`); + const status = await response.json(); + + if (status.status !== 'idle' && !isRunning) { + // Something is running but we don't know about it + isRunning = true; + elements.runBtn.classList.add('loading'); + elements.runBtn.disabled = true; + elements.cancelBtn.disabled = false; + startLogStream(); + } + + updateStatusDisplay(status.status, status.start_time); + + } catch (error) { + console.error('Status poll error:', error); + } + }, 2000); +} + +function updateStatus(status) { + updateStatusDisplay(status); + + if (status === 'running') { + elements.runBtn.classList.add('loading'); + elements.runBtn.disabled = true; + elements.cancelBtn.disabled = false; + } +} + +function updateStatusDisplay(status, startTime = null) { + elements.statusBadge.className = `badge badge-${status}`; + + const statusText = { + 'idle': 'Idle', + 'running': 'Running...', + 'complete': 'Complete', + 'error': 'Error', + 'cancelled': 'Cancelled' + }; + + elements.statusBadge.textContent = statusText[status] || status; + + if (startTime) { + const date = new Date(startTime); + elements.statusTime.textContent = `Started: ${date.toLocaleTimeString()}`; + } else if (status === 'idle') { + elements.statusTime.textContent = ''; + } +} + +function resetRunState() { + elements.runBtn.classList.remove('loading'); + elements.runBtn.disabled = false; + elements.cancelBtn.disabled = true; + isRunning = false; +} + +// ========================================================================== +// Results Loading +// ========================================================================== + +async function loadResults() { + await Promise.all([ + loadCSVResults(), + loadImageResults(), + loadJudgeResults() + ]); +} + +async function loadCSVResults() { + try { + const response = await fetch(`${API_BASE}/api/results`); + const results = await response.json(); + + if (Object.keys(results).length === 0) { + elements.csvResults.innerHTML = '
No results yet
'; + return; + } + + let html = ''; + + for (const [filename, data] of Object.entries(results)) { + if (data.error) { + html += `

${filename}

Error: ${data.error}

`; + continue; + } + + html += `

${filename}.csv

`; + html += '
'; + + // Header + html += ''; + for (const col of data.columns) { + html += ``; + } + html += ''; + + // Body + html += ''; + for (const row of data.data) { + html += ''; + for (const col of data.columns) { + html += ``; + } + html += ''; + } + html += '
${col}
${row[col] ?? ''}
'; + } + + elements.csvResults.innerHTML = html || '
No results yet
'; + + } catch (error) { + console.error('Failed to load CSV results:', error); + } +} + +async function loadImageResults() { + try { + const response = await fetch(`${API_BASE}/api/results/images`); + const images = await response.json(); + + if (images.length === 0) { + elements.imageResults.innerHTML = '
No visualizations yet
'; + return; + } + + elements.imageResults.innerHTML = images.map(img => ` +
+ ${img.name} +
${img.name}
+
+ `).join(''); + + } catch (error) { + console.error('Failed to load images:', error); + } +} + +async function loadJudgeResults() { + try { + const response = await fetch(`${API_BASE}/api/judge/results`); + const results = await response.json(); + + if (results.message || Object.keys(results).length === 0) { + elements.judgeResults.innerHTML = '
No judge results yet
'; + return; + } + + let html = ''; + + for (const [filename, data] of Object.entries(results)) { + if (data.type === 'csv') { + html += `

${filename}

`; + html += '
'; + + html += ''; + for (const col of data.columns) { + html += ``; + } + html += ''; + + for (const row of data.data) { + html += ''; + for (const col of data.columns) { + html += ``; + } + html += ''; + } + html += '
${col}
${row[col] ?? ''}
'; + } else { + html += `

${filename}

${JSON.stringify(data.data, null, 2)}
`; + } + } + + elements.judgeResults.innerHTML = html || '
No judge results
'; + + } catch (error) { + console.error('Failed to load judge results:', error); + } +} + +// ========================================================================== +// Event Listeners +// ========================================================================== + +function setupEventListeners() { + // Run button + elements.runBtn.addEventListener('click', startRun); + + // Cancel button + elements.cancelBtn.addEventListener('click', cancelRun); + + // Clear logs button + elements.clearLogsBtn.addEventListener('click', clearLogs); + + // Scenario selection - update oversight levels + elements.scenarioSelect.addEventListener('change', async (e) => { + const scenarioName = e.target.value; + if (!scenarioName) return; + + try { + const response = await fetch(`${API_BASE}/api/scenarios/${scenarioName}`); + const scenario = await response.json(); + updateOversightLevels(scenario); + } catch (error) { + console.error('Failed to load scenario details:', error); + } + }); + + // Tab switching + elements.tabBtns.forEach(btn => { + btn.addEventListener('click', () => { + const tabId = btn.dataset.tab; + + // Update active tab button + elements.tabBtns.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + // Update active tab content + elements.tabContents.forEach(content => { + content.classList.remove('active'); + if (content.id === `tab-${tabId}`) { + content.classList.add('active'); + } + }); + + // Load results if switching to results tab + if (tabId === 'csv' || tabId === 'images' || tabId === 'judge') { + loadResults(); + } + }); + }); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + // Ctrl/Cmd + Enter to run + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !isRunning) { + startRun(); + } + }); +} + +function updateOversightLevels(scenario) { + const oversightSelect = elements.oversightSelect; + const levels = scenario.oversight_levels || []; + + if (levels.length === 0) { + // No scenario-specific oversight, use global levels + oversightSelect.innerHTML = ` + + + + `; + } else { + oversightSelect.innerHTML = levels.map(level => + `` + ).join(''); + } +} diff --git a/api/static/index.html b/api/static/index.html new file mode 100644 index 0000000..cdf8d2f --- /dev/null +++ b/api/static/index.html @@ -0,0 +1,111 @@ + + + + + + AI Agent Reasoning Experiment Framework + + + +
+
+

🤖 AI Agent Reasoning Experiment Framework

+

Run experiments, analyze deception, and visualize results

+
+ +
+ +
+

⚙️ Configuration

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+

📊 Status

+
+ Idle + +
+
+ + +
+

📜 Live Logs

+
+
Run an experiment to see logs...
+
+
+ +
+
+ + +
+

📈 Results

+
+ + + +
+ +
+
+
No results yet. Run an experiment first.
+
+
+ +
+
+
No visualizations yet. Run an experiment first.
+
+
+ +
+
+
No judge results yet.
+
+
+
+
+ +
+

AI Agent Reasoning Experiment Framework • Dissertation Project

+
+
+ + + + diff --git a/api/static/style.css b/api/static/style.css new file mode 100644 index 0000000..8a1a13a --- /dev/null +++ b/api/static/style.css @@ -0,0 +1,461 @@ +/* ========================================================================== + AI Agent Reasoning Experiment Framework - Web GUI Styles + ========================================================================== */ + +:root { + --bg-primary: #0f1419; + --bg-secondary: #1a1f26; + --bg-tertiary: #242b33; + --bg-card: #1e2530; + --text-primary: #e7e9ea; + --text-secondary: #8b98a5; + --text-muted: #5c6c7a; + --accent-primary: #1d9bf0; + --accent-primary-hover: #1a8cd8; + --accent-success: #00ba7c; + --accent-warning: #ffd400; + --accent-danger: #f4212e; + --border-color: #2f3942; + --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + --font-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-family); + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; +} + +.container { + max-width: 1400px; + margin: 0 auto; + padding: 20px; +} + +/* ========================================================================== + Header + ========================================================================== */ + +header { + text-align: center; + padding: 40px 0 30px; + border-bottom: 1px solid var(--border-color); + margin-bottom: 30px; +} + +header h1 { + font-size: 2rem; + font-weight: 700; + margin-bottom: 8px; + background: linear-gradient(135deg, var(--accent-primary), #a855f7); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +header .subtitle { + color: var(--text-secondary); + font-size: 1.1rem; +} + +/* ========================================================================== + Cards & Sections + ========================================================================== */ + +.card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 24px; + margin-bottom: 24px; +} + +.card h2 { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 20px; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 10px; +} + +/* ========================================================================== + Configuration Grid + ========================================================================== */ + +.config-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + margin-bottom: 24px; +} + +.config-item { + display: flex; + flex-direction: column; + gap: 8px; +} + +.config-item label { + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 500; +} + +.config-item select, +.config-item input { + padding: 10px 14px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-tertiary); + color: var(--text-primary); + font-size: 0.95rem; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.config-item select:focus, +.config-item input:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px rgba(29, 155, 240, 0.15); +} + +.config-actions { + display: flex; + gap: 12px; +} + +/* ========================================================================== + Buttons + ========================================================================== */ + +.btn { + padding: 12px 24px; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: var(--accent-primary); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--accent-primary-hover); + transform: translateY(-1px); +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--border-color); +} + +.btn-danger { + background: transparent; + color: var(--accent-danger); + border: 1px solid var(--accent-danger); +} + +.btn-danger:hover:not(:disabled) { + background: var(--accent-danger); + color: white; +} + +.btn .btn-spinner { + display: none; +} + +.btn.loading .btn-text { + display: none; +} + +.btn.loading .btn-spinner { + display: inline; +} + +/* ========================================================================== + Status Badge + ========================================================================== */ + +.status-indicator { + display: flex; + align-items: center; + gap: 16px; +} + +.badge { + padding: 6px 14px; + border-radius: 20px; + font-size: 0.875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.badge-idle { + background: var(--bg-tertiary); + color: var(--text-secondary); +} + +.badge-running { + background: rgba(29, 155, 240, 0.15); + color: var(--accent-primary); + animation: pulse 2s infinite; +} + +.badge-complete { + background: rgba(0, 186, 124, 0.15); + color: var(--accent-success); +} + +.badge-error { + background: rgba(244, 33, 46, 0.15); + color: var(--accent-danger); +} + +.badge-cancelled { + background: rgba(255, 212, 0, 0.15); + color: var(--accent-warning); +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +#status-time { + color: var(--text-muted); + font-size: 0.875rem; +} + +/* ========================================================================== + Logs Container + ========================================================================== */ + +.logs-container { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + font-family: var(--font-mono); + font-size: 0.8rem; + max-height: 400px; + overflow-y: auto; + line-height: 1.8; +} + +.logs-container::-webkit-scrollbar { + width: 8px; +} + +.logs-container::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +.logs-container::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +.log-line { + padding: 2px 0; + white-space: pre-wrap; + word-break: break-all; +} + +.log-line:hover { + background: var(--bg-secondary); +} + +.log-level-1 { color: var(--accent-danger); } +.log-level-2 { color: var(--accent-warning); } +.log-level-3 { color: var(--text-primary); } +.log-level-4 { color: var(--text-muted); } + +.logs-empty { + color: var(--text-muted); + text-align: center; + padding: 40px; +} + +.logs-actions { + margin-top: 12px; +} + +/* ========================================================================== + Tabs + ========================================================================== */ + +.tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--border-color); + margin-bottom: 20px; +} + +.tab-btn { + padding: 12px 20px; + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s; + margin-bottom: -1px; +} + +.tab-btn:hover { + color: var(--text-primary); +} + +.tab-btn.active { + color: var(--accent-primary); + border-bottom-color: var(--accent-primary); +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* ========================================================================== + Results Tables + ========================================================================== */ + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.data-table th, +.data-table td { + padding: 12px; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +.data-table th { + background: var(--bg-tertiary); + color: var(--text-secondary); + font-weight: 600; + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.5px; +} + +.data-table tr:hover td { + background: var(--bg-secondary); +} + +.data-table td { + color: var(--text-primary); +} + +/* ========================================================================== + Images Grid + ========================================================================== */ + +.image-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; +} + +.image-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; +} + +.image-card img { + width: 100%; + height: auto; + display: block; +} + +.image-card .image-title { + padding: 12px; + font-size: 0.875rem; + color: var(--text-secondary); + text-align: center; +} + +/* ========================================================================== + Empty States + ========================================================================== */ + +.empty-state { + text-align: center; + padding: 40px; + color: var(--text-muted); + background: var(--bg-secondary); + border-radius: 8px; + border: 1px dashed var(--border-color); +} + +/* ========================================================================== + Footer + ========================================================================== */ + +footer { + text-align: center; + padding: 30px; + color: var(--text-muted); + font-size: 0.875rem; +} + +/* ========================================================================== + Responsive + ========================================================================== */ + +@media (max-width: 768px) { + .container { + padding: 12px; + } + + header h1 { + font-size: 1.5rem; + } + + .config-grid { + grid-template-columns: 1fr; + } + + .logs-container { + max-height: 300px; + font-size: 0.75rem; + } +} -- cgit v1.2.3