feat: add hermes web console cockpit and browser self-healing (#394)
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
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
This commit is contained in:
194
gateway/platforms/api_server_ui.py
Normal file
194
gateway/platforms/api_server_ui.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user