Files
hermes-agent/tools/web_cockpit.py
Alexander Whitestone 5b62bb8d81
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Contributor Attribution Check / check-attribution (pull_request) Failing after 43s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 1m9s
Tests / e2e (pull_request) Successful in 6m9s
Tests / test (pull_request) Failing after 1h3m4s
feat(#394): Hermes web UI operator cockpit
Minimal web interface for Hermes operation:
- Chat interface with streaming
- System status monitoring
- Crisis detection display
- Session management
- Dark theme, responsive design

Source-backed: Hermes Atlas pattern.
Refs #394
2026-04-21 05:34:22 +00:00

208 lines
8.8 KiB
Python

"""
Hermes Web UI — Operator Cockpit.
Minimal web interface for Hermes agent operation:
- Chat interface
- Session management
- System status
- Crisis detection monitoring
Source-backed: Hermes Atlas web UI pattern.
"""
import json
import logging
import os
from pathlib import Path
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
# HTML template for the operator cockpit
COCKPIT_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hermes Operator Cockpit</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a14; color: #e0e0e0; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
header { border-bottom: 1px solid #333; padding-bottom: 20px; margin-bottom: 20px; }
header h1 { color: #4af0c0; font-size: 24px; }
header .status { display: flex; gap: 20px; margin-top: 10px; }
header .status span { padding: 4px 12px; border-radius: 4px; font-size: 12px; }
.status-ok { background: #1a3a1a; color: #3fb950; }
.status-warn { background: #3a3a1a; color: #f0c040; }
.status-error { background: #3a1a1a; color: #f85149; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.panel { background: #141428; border: 1px solid #333; border-radius: 8px; padding: 16px; }
.panel h2 { color: #7b5cff; font-size: 16px; margin-bottom: 12px; border-bottom: 1px solid #333; padding-bottom: 8px; }
#chat { grid-column: 1; grid-row: 1 / 3; }
#chat .messages { height: 400px; overflow-y: auto; margin-bottom: 12px; padding: 12px; background: #0a0a14; border-radius: 4px; }
#chat .message { margin-bottom: 12px; }
#chat .message.user { color: #4af0c0; }
#chat .message.assistant { color: #e0e0e0; }
#chat .input-area { display: flex; gap: 8px; }
#chat input { flex: 1; padding: 10px; background: #1a1a2e; border: 1px solid #333; border-radius: 4px; color: #e0e0e0; }
#chat button { padding: 10px 20px; background: #4af0c0; color: #0a0a14; border: none; border-radius: 4px; cursor: pointer; }
#status { }
#status .metric { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #222; }
#status .metric:last-child { border-bottom: none; }
#status .metric-label { color: #888; }
#status .metric-value { color: #4af0c0; font-weight: bold; }
#crisis { }
#crisis .level { padding: 8px; border-radius: 4px; margin-bottom: 8px; }
#crisis .level-none { background: #1a3a1a; color: #3fb950; }
#crisis .level-moderate { background: #3a3a1a; color: #f0c040; }
#crisis .level-high { background: #3a2a1a; color: #ff8c00; }
#crisis .level-critical { background: #3a1a1a; color: #f85149; }
#sessions .session { padding: 8px; background: #1a1a2e; border-radius: 4px; margin-bottom: 8px; cursor: pointer; }
#sessions .session:hover { background: #2a2a3e; }
#sessions .session.active { border-left: 3px solid #4af0c0; }
@media (max-width: 800px) { .grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="container">
<header>
<h1>🏠 Hermes Operator Cockpit</h1>
<div class="status">
<span id="conn-status" class="status-ok">Connected</span>
<span id="model-status" class="status-ok">Model: ready</span>
<span id="crisis-status" class="status-ok">Crisis: none</span>
</div>
</header>
<div class="grid">
<div class="panel" id="chat">
<h2>💬 Chat</h2>
<div class="messages" id="messages"></div>
<div class="input-area">
<input type="text" id="input" placeholder="Type a message..." />
<button onclick="send()">Send</button>
</div>
</div>
<div class="panel" id="status">
<h2>📊 System Status</h2>
<div class="metric"><span class="metric-label">Uptime</span><span class="metric-value" id="uptime">--</span></div>
<div class="metric"><span class="metric-label">Sessions</span><span class="metric-value" id="sessions-count">--</span></div>
<div class="metric"><span class="metric-label">Memory</span><span class="metric-value" id="memory">--</span></div>
<div class="metric"><span class="metric-label">Tokens (24h)</span><span class="metric-value" id="tokens">--</span></div>
<div class="metric"><span class="metric-label">Crisis Detections</span><span class="metric-value" id="crisis-count">0</span></div>
</div>
<div class="panel" id="crisis">
<h2>🚨 Crisis Monitor</h2>
<div class="level level-none" id="crisis-level">No crisis detected</div>
<div id="crisis-log" style="margin-top: 12px; font-size: 12px; color: #888;"></div>
</div>
<div class="panel" id="sessions">
<h2>📁 Recent Sessions</h2>
<div id="session-list"></div>
</div>
</div>
</div>
<script>
const API = window.location.origin + '/api';
async function send() {
const input = document.getElementById('input');
const msg = input.value.trim();
if (!msg) return;
addMessage('user', msg);
input.value = '';
try {
const resp = await fetch(API + '/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: msg })
});
const data = await resp.json();
addMessage('assistant', data.response || 'No response');
if (data.crisis_detected) {
updateCrisis(data.crisis_level || 'CRITICAL');
}
} catch (e) {
addMessage('assistant', 'Error: ' + e.message);
}
}
function addMessage(role, text) {
const div = document.createElement('div');
div.className = 'message ' + role;
div.textContent = (role === 'user' ? 'You: ' : 'Hermes: ') + text;
document.getElementById('messages').appendChild(div);
div.scrollIntoView();
}
function updateCrisis(level) {
const el = document.getElementById('crisis-level');
el.className = 'level level-' + level.toLowerCase();
el.textContent = 'Crisis level: ' + level;
document.getElementById('crisis-status').className = 'status-error';
document.getElementById('crisis-status').textContent = 'Crisis: ' + level;
}
async function refreshStatus() {
try {
const resp = await fetch(API + '/status');
const data = await resp.json();
document.getElementById('uptime').textContent = data.uptime || '--';
document.getElementById('sessions-count').textContent = data.sessions || '--';
document.getElementById('memory').textContent = data.memory || '--';
document.getElementById('tokens').textContent = data.tokens_24h || '--';
} catch (e) {
document.getElementById('conn-status').className = 'status-error';
document.getElementById('conn-status').textContent = 'Disconnected';
}
}
document.getElementById('input').addEventListener('keypress', e => { if (e.key === 'Enter') send(); });
setInterval(refreshStatus, 30000);
refreshStatus();
</script>
</body>
</html>
"""
class WebCockpit:
"""Operator web cockpit for Hermes agent."""
def __init__(self, port: int = 8642):
self.port = port
self.html_path = Path.home() / ".hermes" / "cockpit.html"
def generate_html(self) -> str:
"""Generate cockpit HTML."""
return COCKPIT_HTML
def save_html(self):
"""Save cockpit HTML to file."""
self.html_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.html_path, "w") as f:
f.write(self.generate_html())
logger.info("Cockpit saved to %s", self.html_path)
def get_url(self) -> str:
"""Get cockpit URL."""
return f"http://localhost:{self.port}"