Files
2026-04-09 09:16:07 +02:00

402 lines
15 KiB
Python

"""
AutoDev - Web UI
Serves a live dashboard showing LLM activity, file tree, and file contents.
Uses SSE (Server-Sent Events) for real-time updates. No external dependencies.
"""
import http.server
import json
import os
import queue
import threading
import urllib.parse
from . import config
# Global event system — broadcast to all connected SSE clients
_clients: list = [] # list of queue.Queue, one per connected client
_clients_lock = threading.Lock()
_event_history: list = [] # buffer of all past events for new clients
_workdir: str = ""
_shutdown: bool = False
_server = None
def push_event(event_type: str, data: dict):
"""Push an event to all connected web clients."""
msg = {"type": event_type, "data": data}
_event_history.append(msg)
with _clients_lock:
for q in _clients:
try:
q.put_nowait(msg)
except queue.Full:
pass
class WebHandler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass # Suppress default logging
def do_GET(self):
try:
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
params = urllib.parse.parse_qs(parsed.query)
if path == "/":
self._serve_html()
elif path == "/events":
self._serve_sse()
elif path == "/api/files":
self._serve_file_tree()
elif path == "/api/file":
filepath = params.get("path", [""])[0]
self._serve_file_content(filepath)
else:
self.send_error(404)
except (BrokenPipeError, ConnectionResetError, OSError):
pass
def _serve_html(self):
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(HTML_PAGE.encode("utf-8"))
def _serve_sse(self):
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
client_queue = queue.Queue(maxsize=5000)
# Replay event history for late-connecting clients
for past_event in _event_history:
client_queue.put_nowait(past_event)
with _clients_lock:
_clients.append(client_queue)
try:
while not _shutdown:
try:
event = client_queue.get(timeout=1)
line = f"data: {json.dumps(event)}\n\n"
self.wfile.write(line.encode("utf-8"))
self.wfile.flush()
except queue.Empty:
self.wfile.write(b": keepalive\n\n")
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, OSError):
pass
finally:
with _clients_lock:
if client_queue in _clients:
_clients.remove(client_queue)
def _serve_file_tree(self):
files = []
for root, dirs, filenames in os.walk(_workdir):
# Skip hidden dirs and autodev backups
dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"]
for fname in sorted(filenames):
if fname.startswith("."):
continue
full = os.path.join(root, fname)
rel = os.path.relpath(full, _workdir)
try:
size = os.path.getsize(full)
except OSError:
size = 0
files.append({"path": rel, "size": size})
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(files).encode("utf-8"))
def _serve_file_content(self, filepath: str):
if not filepath:
self.send_error(400, "Missing path parameter")
return
full = os.path.realpath(os.path.join(_workdir, filepath))
if not full.startswith(os.path.realpath(_workdir)):
self.send_error(403, "Path outside workspace")
return
try:
with open(full, "r") as f:
content = f.read()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"path": filepath, "content": content}).encode("utf-8"))
except (IOError, UnicodeDecodeError):
self.send_error(404, "File not found or not readable")
def stop_web_server():
global _shutdown
_shutdown = True
if _server:
threading.Thread(target=_server.shutdown, daemon=True).start()
def start_web_server(port: int, workdir: str):
"""Start the web UI server in a background daemon thread."""
global _workdir
_workdir = workdir
class ThreadedHTTPServer(http.server.HTTPServer):
allow_reuse_address = True
daemon_threads = True
def process_request(self, request, client_address):
t = threading.Thread(target=self._handle, args=(request, client_address),
daemon=True)
t.start()
def _handle(self, request, client_address):
try:
self.finish_request(request, client_address)
except Exception:
pass
try:
self.shutdown_request(request)
except Exception:
pass
server = ThreadedHTTPServer(("0.0.0.0", port), WebHandler)
global _server
_server = server
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
return server
HTML_PAGE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AutoDev — Live Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'SF Mono', 'Consolas', 'Monaco', monospace; background: #0d1117; color: #c9d1d9; height: 100vh; overflow: hidden; }
.layout { display: grid; grid-template-columns: 300px 1fr; grid-template-rows: 40px 1fr; height: 100vh; }
/* Header */
.header { grid-column: 1 / -1; background: #161b22; border-bottom: 1px solid #21262d; padding: 8px 16px; display: flex; align-items: center; gap: 16px; font-size: 13px; }
.header .title { color: #58a6ff; font-weight: 700; font-size: 14px; }
.header .status { color: #3fb950; }
.header .info { color: #8b949e; }
/* Left panel: plan progress + file tree + file viewer */
.left { display: flex; flex-direction: column; border-right: 1px solid #21262d; overflow: hidden; }
.plan-progress { flex: 0 0 auto; max-height: 35%; overflow-y: auto; border-bottom: 1px solid #21262d; padding: 8px; }
.file-tree { flex: 0 0 25%; overflow-y: auto; border-bottom: 1px solid #21262d; padding: 8px; }
.file-viewer { flex: 1; overflow-y: auto; padding: 8px; min-height: 0; }
/* Right panel: LLM activity log */
.right { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.activity { flex: 1; overflow-y: auto; padding: 12px; min-height: 0; }
.panel-title { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #8b949e; padding: 8px 0 6px; border-bottom: 1px solid #21262d; margin-bottom: 8px; }
/* Plan steps */
.step-item { padding: 5px 8px; font-size: 12px; border-radius: 4px; margin-bottom: 2px; display: flex; align-items: flex-start; gap: 8px; }
.step-icon { flex-shrink: 0; width: 18px; text-align: center; }
.step-pending .step-icon { color: #484f58; }
.step-active .step-icon { color: #d29922; }
.step-ok .step-icon { color: #3fb950; }
.step-error .step-icon { color: #f85149; }
.step-active { background: #d2992211; }
.step-phase { color: #8b949e; font-size: 10px; text-transform: uppercase; }
.step-desc { color: #c9d1d9; line-height: 1.3; }
.step-ok .step-desc { color: #8b949e; }
/* File tree */
.file-item { padding: 4px 8px; cursor: pointer; font-size: 13px; border-radius: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-item:hover { background: #161b22; }
.file-item.active { background: #1f6feb33; color: #58a6ff; }
.file-size { color: #484f58; font-size: 11px; float: right; }
/* File viewer */
.file-content { font-size: 12px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; color: #e6edf3; }
.file-path { font-size: 11px; color: #58a6ff; padding-bottom: 6px; border-bottom: 1px solid #21262d; margin-bottom: 8px; }
/* Activity log */
.log-entry { padding: 4px 0; font-size: 13px; line-height: 1.4; border-bottom: 1px solid #21262d0a; }
.log-time { color: #484f58; font-size: 11px; }
.log-ok { color: #3fb950; }
.log-error { color: #f85149; }
.log-warn { color: #d29922; }
.log-action { color: #58a6ff; font-weight: 600; }
.log-detail { color: #c9d1d9; }
.log-llm { color: #bc8cff; font-weight: 600; }
.log-llm-content { color: #bc8cff; opacity: 0.8; font-size: 12px; white-space: pre-wrap; word-break: break-all; margin: 4px 0 8px 0; padding: 6px 8px; background: #161b22; border-radius: 4px; border-left: 2px solid #bc8cff; max-height: 400px; overflow-y: auto; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }
</style>
</head>
<body>
<div class="layout">
<div class="header">
<span class="title">⚡ AutoDev</span>
<span class="status" id="status">Connecting...</span>
<span class="info" id="info"></span>
</div>
<div class="left">
<div class="plan-progress">
<div class="panel-title">📋 Plan Progress</div>
<div id="plan">Waiting for plan...</div>
</div>
<div class="file-tree">
<div class="panel-title">📁 Project Files</div>
<div id="files"></div>
</div>
<div class="file-viewer">
<div class="panel-title">📄 File Content</div>
<div class="file-path" id="file-path">Select a file</div>
<pre class="file-content" id="file-content"></pre>
</div>
</div>
<div class="right">
<div class="activity">
<div class="panel-title">🤖 LLM Activity</div>
<div id="log"></div>
</div>
</div>
</div>
<script>
const logEl = document.getElementById('log');
const filesEl = document.getElementById('files');
const planEl = document.getElementById('plan');
const filePathEl = document.getElementById('file-path');
const fileContentEl = document.getElementById('file-content');
const statusEl = document.getElementById('status');
const infoEl = document.getElementById('info');
let activeFile = null;
let planSteps = [];
let activeStep = -1;
function connect() {
const es = new EventSource('/events');
es.onopen = () => { statusEl.textContent = '● Connected'; statusEl.style.color = '#3fb950'; };
es.onerror = () => { statusEl.textContent = '● Reconnecting...'; statusEl.style.color = '#d29922'; };
es.onmessage = (e) => { handleEvent(JSON.parse(e.data)); };
}
function handleEvent(evt) {
const d = evt.data;
if (evt.type === 'log') {
addLogEntry(d);
refreshFiles();
if (activeFile) loadFile(activeFile);
if (d.action === 'step_start') {
activeStep++;
updatePlanUI();
}
} else if (evt.type === 'plan') {
planSteps = d.steps || [];
activeStep = d.start_step - 1;
for (let i = 0; i < d.start_step; i++) {
if (planSteps[i]) planSteps[i].status = 'ok';
}
infoEl.textContent = d.project + '' + planSteps.length + ' steps';
updatePlanUI();
} else if (evt.type === 'step_done') {
if (planSteps[d.step]) {
planSteps[d.step].status = d.status;
}
updatePlanUI();
} else if (evt.type === 'llm_response') {
addLogEntry({action: 'thinking', detail: d.response || '', status: 'llm'});
}
}
function updatePlanUI() {
if (!planSteps.length) return;
let html = '';
planSteps.forEach((s, i) => {
let cls = 'step-pending';
let icon = '';
if (s.status === 'ok') { cls = 'step-ok'; icon = ''; }
else if (s.status === 'error') { cls = 'step-error'; icon = ''; }
else if (i === activeStep + 1 || (i === activeStep && s.status === 'pending')) {
cls = 'step-active'; icon = '';
}
html += '<div class="step-item ' + cls + '">'
+ '<span class="step-icon">' + icon + '</span>'
+ '<div><span class="step-phase">' + s.phase + '</span> '
+ '<span class="step-desc">' + escapeHtml(s.description) + '</span></div>'
+ '</div>';
});
// Summary
const done = planSteps.filter(s => s.status === 'ok').length;
const failed = planSteps.filter(s => s.status === 'error').length;
html = '<div style="font-size:11px;color:#8b949e;margin-bottom:6px">'
+ done + '/' + planSteps.length + ' complete'
+ (failed ? ' · <span style="color:#f85149">' + failed + ' failed</span>' : '')
+ '</div>' + html;
planEl.innerHTML = html;
}
function addLogEntry(d) {
const div = document.createElement('div');
div.className = 'log-entry';
const time = new Date().toLocaleTimeString();
const isLlm = d.status === 'llm';
if (isLlm) {
const text = (d.detail || '').replace(/^```[a-z]*\n?/gm, '').replace(/```$/gm, '').trim();
div.innerHTML = '<span class="log-time">' + time + '</span> '
+ '<span class="log-llm">🧠 thinking</span>'
+ '<pre class="log-llm-content">' + escapeHtml(text) + '</pre>';
} else {
const cls = d.status === 'ok' ? 'log-ok' : d.status === 'error' ? 'log-error' : d.status === 'warn' ? 'log-warn' : 'log-ok';
const icon = d.status === 'ok' ? '' : d.status === 'error' ? '' : d.status === 'warn' ? '' : '';
div.innerHTML = '<span class="log-time">' + time + '</span> '
+ '<span class="' + cls + '">[' + icon + ']</span> '
+ '<span class="log-action">' + (d.action || '') + '</span> '
+ '<span class="log-detail">' + escapeHtml(d.detail || '') + '</span>';
}
logEl.prepend(div);
}
function escapeHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function refreshFiles() {
fetch('/api/files').then(r => r.json()).then(files => {
filesEl.innerHTML = '';
files.forEach(f => {
const div = document.createElement('div');
div.className = 'file-item' + (f.path === activeFile ? ' active' : '');
const sz = f.size > 1024 ? (f.size/1024).toFixed(1)+'K' : f.size+'B';
div.innerHTML = escapeHtml(f.path) + '<span class="file-size">' + sz + '</span>';
div.onclick = () => loadFile(f.path);
filesEl.appendChild(div);
});
}).catch(() => {});
}
function loadFile(path) {
activeFile = path;
fetch('/api/file?path=' + encodeURIComponent(path))
.then(r => r.json())
.then(d => { filePathEl.textContent = d.path; fileContentEl.textContent = d.content; })
.catch(() => { fileContentEl.textContent = '(could not load)'; });
document.querySelectorAll('.file-item').forEach(el => {
el.classList.toggle('active', el.textContent.startsWith(path));
});
}
connect();
refreshFiles();
setInterval(refreshFiles, 5000);
</script>
</body>
</html>"""