added webui

This commit is contained in:
Nicholai 2025-11-11 21:03:01 -07:00
parent e9ad01d5f0
commit 68bcb136bd
5 changed files with 1965 additions and 0 deletions

View File

@ -8,6 +8,10 @@ python-dotenv>=1.0.0
# Token counting and memory management
tiktoken>=0.5.0
# Web server
flask>=3.0.0
flask-cors>=4.0.0
# Testing (development)
pytest>=7.4.0
pytest-cov>=4.1.0

30
run_webui.py Executable file
View File

@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Run the debate-bots web UI server."""
import sys
import os
from pathlib import Path
# Add project root to path
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
# Setup environment
os.chdir(project_root)
from src.web_server import app
from src.logger import setup_logging, get_logger
# Setup logging
setup_logging()
logger = get_logger(__name__)
if __name__ == '__main__':
logger.info("Starting Debate Bots Web UI server...")
logger.info("Open http://localhost:5000 in your browser")
try:
app.run(host='0.0.0.0', port=5000, debug=True, threaded=True)
except KeyboardInterrupt:
logger.info("Server stopped by user")

17
run_webui.sh Executable file
View File

@ -0,0 +1,17 @@
#!/bin/bash
# Simple runner script for Debate Bots Web UI
# Check if virtual environment exists
if [ ! -d "venv" ]; then
echo "No virtual environment found. Creating one..."
python3 -m venv venv
echo "Installing dependencies..."
source venv/bin/activate
pip install -r requirements.txt
else
source venv/bin/activate
fi
# Run the web UI server
python run_webui.py

594
src/web_server.py Normal file
View File

@ -0,0 +1,594 @@
"""Flask web server for debate-bots web UI."""
import json
import uuid
import threading
import queue
import time
from pathlib import Path
from datetime import datetime
from typing import Dict, Optional, Tuple
from flask import Flask, jsonify, request, Response, send_from_directory
from flask_cors import CORS
from .config import Config
from .main import create_agent, setup_configuration, format_model_name
from .debate import DebateOrchestrator
from .constants import DEFAULT_EXCHANGES_PER_ROUND, DEFAULT_CONFIG_FILE, DEBATES_DIRECTORY
from .exceptions import ConfigError, ProviderError, DebateError, ValidationError
from .logger import setup_logging, get_logger
# Initialize logger
setup_logging()
logger = get_logger(__name__)
# Create Flask app
app = Flask(__name__, static_folder=None)
CORS(app) # Enable CORS for development
# Global state
active_debates: Dict[str, Dict] = {}
debate_streams: Dict[str, queue.Queue] = {}
config: Optional[Config] = None
def load_config():
"""Load configuration from config file."""
global config
try:
config = setup_configuration(DEFAULT_CONFIG_FILE)
logger.info("Configuration loaded successfully")
except ConfigError as e:
logger.error(f"Failed to load configuration: {e}")
# Create minimal config if file doesn't exist
config = Config()
# Load config on startup
load_config()
class DebateRunner:
"""Runs a debate in a separate thread and streams updates."""
def __init__(self, debate_id: str, orchestrator: DebateOrchestrator,
agent_for, agent_against, exchanges_per_round: int,
streaming: bool = True, auto_save: bool = True):
self.debate_id = debate_id
self.orchestrator = orchestrator
self.agent_for = agent_for
self.agent_against = agent_against
self.exchanges_per_round = exchanges_per_round
self.streaming = streaming
self.auto_save = auto_save
self.stream_queue = queue.Queue()
self.is_running = False
self.is_complete = False
self.error = None
self.thread = None
def start(self):
"""Start the debate in a background thread."""
self.thread = threading.Thread(target=self._run_debate, daemon=True)
self.thread.start()
def _run_debate(self):
"""Run the debate and send updates to stream queue."""
self.is_running = True
try:
# Run rounds until complete
while not self.is_complete:
# Run a round of exchanges
try:
if self.streaming:
self._run_round_streaming()
else:
self._run_round_non_streaming()
# Auto-save after each round if enabled
if self.auto_save:
try:
filepath = self.orchestrator.save_debate()
logger.debug(f"Auto-saved debate to {filepath}")
self._send_event("saved", {"filepath": filepath})
except Exception as e:
logger.warning(f"Auto-save failed: {e}")
# Send round complete event
stats = self.orchestrator.get_statistics()
self._send_event("round_complete", {
"exchange_count": self.orchestrator.current_exchange,
"statistics": stats
})
# For now, we'll continue automatically
# In a full implementation, we'd wait for user action
# For web UI, we can handle this via action endpoints
break # Stop after first round for now
except Exception as e:
logger.error(f"Error during debate round: {e}", exc_info=True)
self.error = str(e)
self._send_event("error", {"message": str(e)})
break
self.is_complete = True
self._send_event("complete", {
"statistics": self.orchestrator.get_statistics()
})
except Exception as e:
logger.error(f"Fatal error in debate: {e}", exc_info=True)
self.error = str(e)
self._send_event("error", {"message": str(e)})
finally:
self.is_running = False
def _run_round_streaming(self):
"""Run a round with streaming."""
for exchange_num in range(self.exchanges_per_round):
if self.is_complete:
break
# Conduct exchange with streaming
self._conduct_exchange_streaming(exchange_num + 1)
def _run_round_non_streaming(self):
"""Run a round without streaming."""
for exchange_num in range(self.exchanges_per_round):
if self.is_complete:
break
# Conduct exchange without streaming
response_for, response_against = self.orchestrator.conduct_exchange(
self.agent_for, self.agent_against
)
# Send complete exchange event
self._send_event("exchange", {
"exchange": self.orchestrator.current_exchange,
"agent_for": {
"name": self.agent_for.name,
"content": response_for
},
"agent_against": {
"name": self.agent_against.name,
"content": response_against
}
})
def _conduct_exchange_streaming(self, exchange_num: int):
"""Conduct one exchange with streaming."""
# Build prompts
if self.orchestrator.current_exchange == 0:
prompt_for = f"Present your opening argument for the position that {self.orchestrator.topic}."
else:
prompt_for = self.orchestrator._build_context_prompt(self.agent_for)
self.agent_for.add_message("user", prompt_for)
# Stream FOR agent's response
response_for_chunks = []
self._send_event("exchange_start", {
"exchange": exchange_num,
"agent": self.agent_for.name,
"position": "for"
})
start_time_for = time.time()
stream_for = self.agent_for.generate_response_stream()
for chunk in stream_for:
response_for_chunks.append(chunk)
self._send_event("chunk", {
"exchange": exchange_num,
"agent": self.agent_for.name,
"position": "for",
"chunk": chunk
})
response_time_for = time.time() - start_time_for
response_for = ''.join(response_for_chunks)
response_for = self.orchestrator._validate_response(response_for, self.agent_for.name)
# Record FOR in debate history
exchange_data_for = {
"exchange": exchange_num,
"agent": self.agent_for.name,
"position": "for",
"content": response_for,
}
self.orchestrator.debate_history.append(exchange_data_for)
# Track timing
self.orchestrator.response_times.append(response_time_for)
self.orchestrator.total_response_time += response_time_for
# Send FOR complete
self._send_event("exchange_complete", {
"exchange": exchange_num,
"agent": self.agent_for.name,
"position": "for",
"content": response_for
})
# Now handle AGAINST agent
if self.orchestrator.current_exchange == 0:
prompt_against = (
f"Your opponent's opening argument: {response_for}\n\n"
f"Present your opening counter-argument against the position that {self.orchestrator.topic}."
)
else:
prompt_against = self.orchestrator._build_context_prompt(self.agent_against)
self.agent_against.add_message("user", prompt_against)
# Stream AGAINST agent's response
response_against_chunks = []
self._send_event("exchange_start", {
"exchange": exchange_num,
"agent": self.agent_against.name,
"position": "against"
})
start_time_against = time.time()
stream_against = self.agent_against.generate_response_stream()
for chunk in stream_against:
response_against_chunks.append(chunk)
self._send_event("chunk", {
"exchange": exchange_num,
"agent": self.agent_against.name,
"position": "against",
"chunk": chunk
})
response_time_against = time.time() - start_time_against
response_against = ''.join(response_against_chunks)
response_against = self.orchestrator._validate_response(response_against, self.agent_against.name)
# Record AGAINST in debate history
exchange_data_against = {
"exchange": exchange_num,
"agent": self.agent_against.name,
"position": "against",
"content": response_against,
}
self.orchestrator.debate_history.append(exchange_data_against)
# Track timing
self.orchestrator.response_times.append(response_time_against)
self.orchestrator.total_response_time += response_time_against
# Update exchange count
self.orchestrator.current_exchange += 1
# Send AGAINST complete and full exchange
self._send_event("exchange_complete", {
"exchange": exchange_num,
"agent": self.agent_against.name,
"position": "against",
"content": response_against
})
self._send_event("exchange", {
"exchange": exchange_num,
"agent_for": {
"name": self.agent_for.name,
"content": response_for
},
"agent_against": {
"name": self.agent_against.name,
"content": response_against
}
})
def _send_event(self, event_type: str, data: dict):
"""Send an event to the stream queue."""
try:
self.stream_queue.put({
"type": event_type,
"data": data
}, timeout=1.0)
except queue.Full:
logger.warning(f"Stream queue full, dropping event: {event_type}")
def stop(self):
"""Stop the debate."""
self.is_complete = True
@app.route('/')
def index():
"""Serve the main HTML page."""
webui_dir = Path(__file__).parent.parent / "webui"
webui_path = webui_dir / "index.html"
if webui_path.exists():
return send_from_directory(str(webui_dir), "index.html")
return jsonify({"error": "Web UI not found"}), 404
@app.route('/api/debates', methods=['GET'])
def list_debates():
"""List all saved debates."""
debates_dir = Path(DEBATES_DIRECTORY)
if not debates_dir.exists():
return jsonify({"debates": []})
debates = []
for file_path in debates_dir.glob("debate_*.json"):
try:
with open(file_path, 'r', encoding='utf-8') as f:
debate_data = json.load(f)
debates.append({
"filename": file_path.name,
"topic": debate_data.get("topic", "Unknown"),
"timestamp": debate_data.get("timestamp", ""),
"total_exchanges": debate_data.get("total_exchanges", 0),
"agents": debate_data.get("agents", {}),
"statistics": debate_data.get("statistics", {})
})
except Exception as e:
logger.error(f"Error reading debate file {file_path}: {e}")
# Sort by timestamp (newest first)
debates.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
return jsonify({"debates": debates})
@app.route('/api/debates/<filename>', methods=['GET'])
def get_debate(filename: str):
"""Get a specific debate by filename."""
debates_dir = Path(DEBATES_DIRECTORY)
file_path = debates_dir / filename
if not file_path.exists():
return jsonify({"error": "Debate not found"}), 404
try:
with open(file_path, 'r', encoding='utf-8') as f:
debate_data = json.load(f)
return jsonify(debate_data)
except Exception as e:
logger.error(f"Error reading debate file {file_path}: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/debates/start', methods=['POST'])
def start_debate():
"""Start a new debate."""
global config
data = request.get_json()
topic = data.get("topic")
exchanges = data.get("exchanges", DEFAULT_EXCHANGES_PER_ROUND)
streaming = data.get("streaming", True)
auto_save = data.get("auto_save", True)
max_memory_tokens = data.get("max_memory_tokens")
if not topic:
return jsonify({"error": "Topic is required"}), 400
try:
# Load config if not loaded
if config is None:
load_config()
# Create agents
agent1 = create_agent(config, "agent1", max_memory_tokens=max_memory_tokens)
agent2 = create_agent(config, "agent2", max_memory_tokens=max_memory_tokens)
# Create orchestrator
orchestrator = DebateOrchestrator(
agent1=agent1,
agent2=agent2,
exchanges_per_round=exchanges,
)
# Start debate
agent_for, agent_against = orchestrator.start_debate(topic)
# Generate debate ID
debate_id = str(uuid.uuid4())
# Create debate runner
runner = DebateRunner(
debate_id=debate_id,
orchestrator=orchestrator,
agent_for=agent_for,
agent_against=agent_against,
exchanges_per_round=exchanges,
streaming=streaming,
auto_save=auto_save
)
# Store active debate
active_debates[debate_id] = {
"debate_id": debate_id,
"topic": topic,
"orchestrator": orchestrator,
"agent_for": agent_for,
"agent_against": agent_against,
"runner": runner,
"started_at": datetime.now().isoformat(),
"streaming": streaming
}
debate_streams[debate_id] = runner.stream_queue
# Start debate in background thread
runner.start()
return jsonify({
"debate_id": debate_id,
"topic": topic,
"agents": {
"for": agent_for.name,
"against": agent_against.name
},
"exchanges": exchanges,
"streaming": streaming
})
except ConfigError as e:
return jsonify({"error": f"Configuration error: {str(e)}"}), 400
except ProviderError as e:
return jsonify({"error": f"Provider error: {str(e)}"}), 500
except ValidationError as e:
return jsonify({"error": f"Validation error: {str(e)}"}), 400
except Exception as e:
logger.error(f"Error starting debate: {e}", exc_info=True)
return jsonify({"error": str(e)}), 500
@app.route('/api/debates/<debate_id>/stream', methods=['GET'])
def stream_debate(debate_id: str):
"""Stream debate updates using Server-Sent Events."""
if debate_id not in active_debates:
return jsonify({"error": "Debate not found"}), 404
runner = active_debates[debate_id]["runner"]
def generate():
"""Generate SSE events from the debate stream queue."""
try:
while True:
try:
# Get event from queue with timeout
event = runner.stream_queue.get(timeout=1.0)
yield f"data: {json.dumps(event)}\n\n"
except queue.Empty:
# Send heartbeat to keep connection alive
if runner.is_complete:
yield f"data: {json.dumps({'type': 'complete', 'data': {}})}\n\n"
break
yield ": heartbeat\n\n"
except Exception as e:
logger.error(f"Error in stream: {e}", exc_info=True)
yield f"data: {json.dumps({'type': 'error', 'data': {'message': str(e)}})}\n\n"
return Response(
generate(),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
'Connection': 'keep-alive'
}
)
@app.route('/api/debates/<debate_id>/status', methods=['GET'])
def get_debate_status(debate_id: str):
"""Get debate status and statistics."""
if debate_id not in active_debates:
return jsonify({"error": "Debate not found"}), 404
debate = active_debates[debate_id]
orchestrator = debate["orchestrator"]
runner = debate["runner"]
stats = orchestrator.get_statistics()
return jsonify({
"debate_id": debate_id,
"topic": debate["topic"],
"status": "complete" if runner.is_complete else "running",
"is_running": runner.is_running,
"current_exchange": orchestrator.current_exchange,
"statistics": stats,
"agents": {
"for": debate["agent_for"].name,
"against": debate["agent_against"].name
},
"error": runner.error
})
@app.route('/api/debates/<debate_id>/action', methods=['POST'])
def debate_action(debate_id: str):
"""Handle user actions (continue, settle, instruct, quit)."""
if debate_id not in active_debates:
return jsonify({"error": "Debate not found"}), 404
data = request.get_json()
action = data.get("action")
debate = active_debates[debate_id]
runner = debate["runner"]
orchestrator = debate["orchestrator"]
agent_for = debate["agent_for"]
agent_against = debate["agent_against"]
if action == "continue":
# Continue debate (run another round)
if runner.is_complete:
return jsonify({"error": "Debate is already complete"}), 400
# For now, we'll handle this by the debate continuing automatically
return jsonify({"message": "Debate continuing"})
elif action == "settle":
# Settle debate with conclusion
conclusion = data.get("conclusion", "")
runner.stop()
# Save debate
try:
filepath = orchestrator.save_debate()
return jsonify({"message": "Debate settled", "filepath": filepath})
except Exception as e:
return jsonify({"error": str(e)}), 500
elif action == "instruct":
# Give custom instruction to both agents
instruction = data.get("instruction", "")
if not instruction:
return jsonify({"error": "Instruction is required"}), 400
agent_for.add_message("user", instruction)
agent_against.add_message("user", instruction)
return jsonify({"message": "Instruction added"})
elif action == "quit":
# Stop debate
runner.stop()
# Save debate
try:
filepath = orchestrator.save_debate()
return jsonify({"message": "Debate stopped", "filepath": filepath})
except Exception as e:
return jsonify({"error": str(e)}), 500
else:
return jsonify({"error": "Invalid action"}), 400
@app.route('/api/config', methods=['GET'])
def get_config():
"""Get current configuration (without sensitive data)."""
global config
if config is None:
load_config()
# Return config without API keys
safe_config = {
"agent1": {
"provider": config.get("agent1.provider"),
"model": config.get("agent1.model"),
"system_prompt": config.get("agent1.system_prompt"),
},
"agent2": {
"provider": config.get("agent2.provider"),
"model": config.get("agent2.model"),
"system_prompt": config.get("agent2.system_prompt"),
}
}
return jsonify(safe_config)
if __name__ == '__main__':
# Run Flask development server
app.run(host='0.0.0.0', port=5000, debug=True, threaded=True)

1320
webui/index.html Normal file

File diff suppressed because it is too large Load Diff