Files
hermes-agent/gateway/platforms/api_server_ui.py
TERRA 9edd5383e7
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Failing after 36s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 31s
Tests / e2e (pull_request) Successful in 3m37s
Tests / test (pull_request) Failing after 38m26s
feat: add hermes web console cockpit and browser self-healing (#394)
2026-04-21 02:00:41 -04:00

195 lines
7.7 KiB
Python

"""Thin operator web console for the API server.
This keeps the UI intentionally small: an aiohttp-mounted cockpit that
surfaces Hermes health, browser runtime state, and ecosystem discovery
without introducing a second heavyweight frontend architecture.
"""
from __future__ import annotations
import json
from html import escape
from typing import Any, Dict
from aiohttp import web
from tools.browser_tool import browser_runtime_heal, browser_runtime_status
_DISCOVERY_FRONTENDS = [
"Open WebUI",
"LobeChat",
"LibreChat",
"AnythingLLM",
"NextChat",
"ChatBox",
]
def _adapter(request: web.Request):
return request.app["api_server_adapter"]
def _auth_or_none(request: web.Request):
adapter = _adapter(request)
return adapter._check_auth(request)
def _render_console_html(adapter) -> str:
health = {
"platform": "api_server",
"host": adapter._host,
"port": adapter._port,
"model": adapter._model_name,
"auth_required": bool(adapter._api_key),
}
health_json = escape(json.dumps(health, indent=2, ensure_ascii=False))
return f'''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hermes Web Console</title>
<style>
:root {{ color-scheme: dark; --bg: #0b1020; --panel: #121933; --fg: #e5ecff; --muted: #9aa8d1; --accent: #72b8ff; --good: #6dde8a; }}
body {{ margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--bg); color: var(--fg); }}
header {{ padding: 20px 24px; border-bottom: 1px solid #243056; }}
main {{ padding: 24px; display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }}
.panel {{ background: var(--panel); border: 1px solid #243056; border-radius: 12px; padding: 16px; box-shadow: 0 10px 30px rgba(0,0,0,.2); }}
h1, h2 {{ margin: 0 0 12px; }}
h1 {{ font-size: 24px; color: var(--accent); }}
h2 {{ font-size: 16px; color: var(--accent); }}
p, li, label {{ color: var(--muted); line-height: 1.5; }}
pre {{ margin: 0; white-space: pre-wrap; word-break: break-word; color: var(--fg); }}
button, input {{ font: inherit; }}
button {{ background: #1e2a52; color: var(--fg); border: 1px solid #39508f; border-radius: 8px; padding: 10px 14px; cursor: pointer; }}
button:hover {{ border-color: var(--accent); }}
input {{ width: 100%; box-sizing: border-box; background: #0d142a; color: var(--fg); border: 1px solid #243056; border-radius: 8px; padding: 10px 12px; margin-bottom: 12px; }}
.row {{ display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }}
.badge {{ display: inline-block; color: var(--good); border: 1px solid #2f6940; border-radius: 999px; padding: 2px 10px; margin-left: 10px; font-size: 12px; }}
ul {{ margin: 0; padding-left: 18px; }}
code {{ color: var(--good); }}
</style>
</head>
<body>
<header>
<h1>Hermes Web Console <span class="badge">operator cockpit</span></h1>
<p>Thin web UI over the existing API server, browser runtime, and streaming endpoints.</p>
</header>
<main>
<section class="panel">
<h2>Gateway Health</h2>
<pre id="health">{health_json}</pre>
</section>
<section class="panel">
<h2>Browser Cockpit</h2>
<label for="apiKey">Optional API key (only needed when API_SERVER_KEY is configured)</label>
<input id="apiKey" type="password" placeholder="sk-... or bearer token">
<div class="row">
<button id="refreshBtn">Refresh Browser Status</button>
<button id="healBtn">Heal Browser Layer</button>
</div>
<pre id="browserStatus">Loading...</pre>
</section>
<section class="panel">
<h2>Ecosystem Discovery</h2>
<ul>
<li><code>GET /v1/models</code> — OpenAI-compatible model discovery</li>
<li><code>POST /v1/chat/completions</code> — chat frontend compatibility</li>
<li><code>POST /v1/responses</code> — stateful responses API</li>
<li><code>POST /v1/runs</code> + <code>GET /v1/runs/{{run_id}}/events</code> — SSE lifecycle stream</li>
<li><code>GET /api/gui/browser/status</code> — browser runtime status</li>
<li><code>POST /api/gui/browser/heal</code> — cleanup + orphan reaper</li>
</ul>
<pre id="discovery">Loading...</pre>
</section>
</main>
<script>
function authHeaders() {{
const key = document.getElementById('apiKey').value.trim();
return key ? {{ 'Authorization': 'Bearer ' + key }} : {{}};
}}
async function loadJson(path, options) {{
const response = await fetch(path, options);
const text = await response.text();
try {{ return {{ status: response.status, body: JSON.parse(text) }}; }}
catch (_) {{ return {{ status: response.status, body: {{ raw: text }} }}; }}
}}
async function refreshBrowser() {{
const result = await loadJson('/api/gui/browser/status', {{ headers: authHeaders() }});
document.getElementById('browserStatus').textContent = JSON.stringify(result, null, 2);
}}
async function healBrowser() {{
const result = await loadJson('/api/gui/browser/heal', {{ method: 'POST', headers: authHeaders() }});
document.getElementById('browserStatus').textContent = JSON.stringify(result, null, 2);
}}
async function loadDiscovery() {{
const result = await loadJson('/api/gui/discovery');
document.getElementById('discovery').textContent = JSON.stringify(result, null, 2);
}}
document.getElementById('refreshBtn').addEventListener('click', refreshBrowser);
document.getElementById('healBtn').addEventListener('click', healBrowser);
refreshBrowser();
loadDiscovery();
</script>
</body>
</html>'''
async def handle_web_console_index(request: web.Request) -> web.Response:
return web.Response(text=_render_console_html(_adapter(request)), content_type="text/html")
async def handle_gui_health(request: web.Request) -> web.Response:
adapter = _adapter(request)
return web.json_response({
"status": "ok",
"platform": "api_server",
"host": adapter._host,
"port": adapter._port,
"model": adapter._model_name,
"auth_required": bool(adapter._api_key),
})
async def handle_browser_status(request: web.Request) -> web.Response:
auth_err = _auth_or_none(request)
if auth_err is not None:
return auth_err
return web.json_response(browser_runtime_status())
async def handle_browser_heal(request: web.Request) -> web.Response:
auth_err = _auth_or_none(request)
if auth_err is not None:
return auth_err
return web.json_response(browser_runtime_heal())
async def handle_discovery(request: web.Request) -> web.Response:
adapter = _adapter(request)
return web.json_response({
"frontends": _DISCOVERY_FRONTENDS,
"operator_cockpit": {
"root": "/",
"health": "/api/gui/health",
"browser_status": "/api/gui/browser/status",
"browser_heal": "/api/gui/browser/heal",
},
"openai_compatible": {
"models": "/v1/models",
"chat_completions": "/v1/chat/completions",
"responses": "/v1/responses",
"runs": "/v1/runs",
"run_events": "/v1/runs/{run_id}/events",
"model_name": adapter._model_name,
},
})
def maybe_register_web_console(app: web.Application) -> None:
app.router.add_get("/", handle_web_console_index)
app.router.add_get("/api/gui/health", handle_gui_health)
app.router.add_get("/api/gui/browser/status", handle_browser_status)
app.router.add_post("/api/gui/browser/heal", handle_browser_heal)
app.router.add_get("/api/gui/discovery", handle_discovery)