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
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
208 lines
8.8 KiB
Python
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}"
|