402 lines
15 KiB
Python
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>"""
|