Compare commits
5 Commits
fix/format
...
feat/web-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9edd5383e7 | ||
|
|
f6c072f136 | ||
| 5b62bb8d81 | |||
| 10f9fd690a | |||
| c6f2855745 |
@@ -2,6 +2,11 @@
|
|||||||
OpenAI-compatible API server platform adapter.
|
OpenAI-compatible API server platform adapter.
|
||||||
|
|
||||||
Exposes an HTTP server with endpoints:
|
Exposes an HTTP server with endpoints:
|
||||||
|
- GET / — Hermes Web Console operator cockpit
|
||||||
|
- GET /api/gui/health — cockpit health payload
|
||||||
|
- GET /api/gui/browser/status — browser runtime status
|
||||||
|
- POST /api/gui/browser/heal — self-healing browser cleanup
|
||||||
|
- GET /api/gui/discovery — ecosystem discovery for compatible frontends
|
||||||
- POST /v1/chat/completions — OpenAI Chat Completions format (stateless; opt-in session continuity via X-Hermes-Session-Id header)
|
- POST /v1/chat/completions — OpenAI Chat Completions format (stateless; opt-in session continuity via X-Hermes-Session-Id header)
|
||||||
- POST /v1/responses — OpenAI Responses API format (stateful via previous_response_id)
|
- POST /v1/responses — OpenAI Responses API format (stateful via previous_response_id)
|
||||||
- GET /v1/responses/{response_id} — Retrieve a stored response
|
- GET /v1/responses/{response_id} — Retrieve a stored response
|
||||||
@@ -2303,6 +2308,30 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||||||
# BasePlatformAdapter interface
|
# BasePlatformAdapter interface
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _register_routes(self, app: "web.Application") -> None:
|
||||||
|
"""Register API and operator-cockpit routes on an aiohttp app."""
|
||||||
|
from gateway.platforms.api_server_ui import maybe_register_web_console
|
||||||
|
|
||||||
|
app.router.add_get("/health", self._handle_health)
|
||||||
|
app.router.add_get("/health/detailed", self._handle_health_detailed)
|
||||||
|
app.router.add_get("/v1/health", self._handle_health)
|
||||||
|
app.router.add_get("/v1/models", self._handle_models)
|
||||||
|
app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
|
||||||
|
app.router.add_post("/v1/responses", self._handle_responses)
|
||||||
|
app.router.add_get("/v1/responses/{response_id}", self._handle_get_response)
|
||||||
|
app.router.add_delete("/v1/responses/{response_id}", self._handle_delete_response)
|
||||||
|
app.router.add_get("/api/jobs", self._handle_list_jobs)
|
||||||
|
app.router.add_post("/api/jobs", self._handle_create_job)
|
||||||
|
app.router.add_get("/api/jobs/{job_id}", self._handle_get_job)
|
||||||
|
app.router.add_patch("/api/jobs/{job_id}", self._handle_update_job)
|
||||||
|
app.router.add_delete("/api/jobs/{job_id}", self._handle_delete_job)
|
||||||
|
app.router.add_post("/api/jobs/{job_id}/pause", self._handle_pause_job)
|
||||||
|
app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job)
|
||||||
|
app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job)
|
||||||
|
app.router.add_post("/v1/runs", self._handle_runs)
|
||||||
|
app.router.add_get("/v1/runs/{run_id}/events", self._handle_run_events)
|
||||||
|
maybe_register_web_console(app)
|
||||||
|
|
||||||
async def connect(self) -> bool:
|
async def connect(self) -> bool:
|
||||||
"""Start the aiohttp web server."""
|
"""Start the aiohttp web server."""
|
||||||
if not AIOHTTP_AVAILABLE:
|
if not AIOHTTP_AVAILABLE:
|
||||||
@@ -2313,26 +2342,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||||||
mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None]
|
mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None]
|
||||||
self._app = web.Application(middlewares=mws)
|
self._app = web.Application(middlewares=mws)
|
||||||
self._app["api_server_adapter"] = self
|
self._app["api_server_adapter"] = self
|
||||||
self._app.router.add_get("/health", self._handle_health)
|
self._register_routes(self._app)
|
||||||
self._app.router.add_get("/health/detailed", self._handle_health_detailed)
|
|
||||||
self._app.router.add_get("/v1/health", self._handle_health)
|
|
||||||
self._app.router.add_get("/v1/models", self._handle_models)
|
|
||||||
self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
|
|
||||||
self._app.router.add_post("/v1/responses", self._handle_responses)
|
|
||||||
self._app.router.add_get("/v1/responses/{response_id}", self._handle_get_response)
|
|
||||||
self._app.router.add_delete("/v1/responses/{response_id}", self._handle_delete_response)
|
|
||||||
# Cron jobs management API
|
|
||||||
self._app.router.add_get("/api/jobs", self._handle_list_jobs)
|
|
||||||
self._app.router.add_post("/api/jobs", self._handle_create_job)
|
|
||||||
self._app.router.add_get("/api/jobs/{job_id}", self._handle_get_job)
|
|
||||||
self._app.router.add_patch("/api/jobs/{job_id}", self._handle_update_job)
|
|
||||||
self._app.router.add_delete("/api/jobs/{job_id}", self._handle_delete_job)
|
|
||||||
self._app.router.add_post("/api/jobs/{job_id}/pause", self._handle_pause_job)
|
|
||||||
self._app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job)
|
|
||||||
self._app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job)
|
|
||||||
# Structured event streaming
|
|
||||||
self._app.router.add_post("/v1/runs", self._handle_runs)
|
|
||||||
self._app.router.add_get("/v1/runs/{run_id}/events", self._handle_run_events)
|
|
||||||
# Start background sweep to clean up orphaned (unconsumed) run streams
|
# Start background sweep to clean up orphaned (unconsumed) run streams
|
||||||
sweep_task = asyncio.create_task(self._sweep_orphaned_runs())
|
sweep_task = asyncio.create_task(self._sweep_orphaned_runs())
|
||||||
try:
|
try:
|
||||||
|
|||||||
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)
|
||||||
68
tests/gateway/test_api_server_web_console.py
Normal file
68
tests/gateway/test_api_server_web_console.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import pytest
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp.test_utils import TestClient, TestServer
|
||||||
|
|
||||||
|
from gateway.config import PlatformConfig
|
||||||
|
from gateway.platforms.api_server import APIServerAdapter, cors_middleware, security_headers_middleware
|
||||||
|
|
||||||
|
|
||||||
|
def _make_adapter(api_key: str = '') -> APIServerAdapter:
|
||||||
|
extra = {'key': api_key} if api_key else {}
|
||||||
|
return APIServerAdapter(PlatformConfig(enabled=True, extra=extra))
|
||||||
|
|
||||||
|
|
||||||
|
def _create_app(adapter: APIServerAdapter) -> web.Application:
|
||||||
|
mws = [mw for mw in (cors_middleware, security_headers_middleware) if mw is not None]
|
||||||
|
app = web.Application(middlewares=mws)
|
||||||
|
app['api_server_adapter'] = adapter
|
||||||
|
adapter._register_routes(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebConsoleRoutes:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_root_serves_web_console_html(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
app = _create_app(adapter)
|
||||||
|
async with TestClient(TestServer(app)) as cli:
|
||||||
|
resp = await cli.get('/')
|
||||||
|
assert resp.status == 200
|
||||||
|
text = await resp.text()
|
||||||
|
assert 'Hermes Web Console' in text
|
||||||
|
assert '/api/gui/browser/status' in text
|
||||||
|
assert '/api/gui/browser/heal' in text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browser_status_returns_json(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
app = _create_app(adapter)
|
||||||
|
async with TestClient(TestServer(app)) as cli:
|
||||||
|
from unittest.mock import patch
|
||||||
|
with patch('gateway.platforms.api_server_ui.browser_runtime_status', return_value={'mode': 'local', 'session_count': 0, 'available': True}):
|
||||||
|
resp = await cli.get('/api/gui/browser/status')
|
||||||
|
assert resp.status == 200
|
||||||
|
data = await resp.json()
|
||||||
|
assert data['mode'] == 'local'
|
||||||
|
assert data['session_count'] == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browser_status_requires_auth_when_key_set(self):
|
||||||
|
adapter = _make_adapter(api_key='sk-secret')
|
||||||
|
app = _create_app(adapter)
|
||||||
|
async with TestClient(TestServer(app)) as cli:
|
||||||
|
resp = await cli.get('/api/gui/browser/status')
|
||||||
|
assert resp.status == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browser_heal_invokes_runtime_heal(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
app = _create_app(adapter)
|
||||||
|
async with TestClient(TestServer(app)) as cli:
|
||||||
|
from unittest.mock import patch
|
||||||
|
with patch('gateway.platforms.api_server_ui.browser_runtime_heal', return_value={'success': True, 'before': {'session_count': 1}, 'after': {'session_count': 0}}) as mock_heal:
|
||||||
|
resp = await cli.post('/api/gui/browser/heal')
|
||||||
|
assert resp.status == 200
|
||||||
|
data = await resp.json()
|
||||||
|
assert data['success'] is True
|
||||||
|
assert data['after']['session_count'] == 0
|
||||||
|
mock_heal.assert_called_once_with()
|
||||||
61
tests/tools/test_browser_runtime_cockpit.py
Normal file
61
tests/tools/test_browser_runtime_cockpit.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
|
||||||
|
class TestBrowserRuntimeCockpit:
|
||||||
|
def setup_method(self):
|
||||||
|
import tools.browser_tool as bt
|
||||||
|
self.bt = bt
|
||||||
|
self.orig_active = bt._active_sessions.copy()
|
||||||
|
self.orig_last = bt._session_last_activity.copy()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
self.bt._active_sessions.clear()
|
||||||
|
self.bt._active_sessions.update(self.orig_active)
|
||||||
|
self.bt._session_last_activity.clear()
|
||||||
|
self.bt._session_last_activity.update(self.orig_last)
|
||||||
|
|
||||||
|
def test_runtime_status_reports_mode_and_sessions(self):
|
||||||
|
import tools.browser_tool as bt
|
||||||
|
|
||||||
|
bt._active_sessions['task-a'] = {
|
||||||
|
'session_name': 'sess-a',
|
||||||
|
'bb_session_id': 'bb_123',
|
||||||
|
'cdp_url': 'ws://browser/devtools/browser/abc',
|
||||||
|
}
|
||||||
|
bt._session_last_activity['task-a'] = 111.0
|
||||||
|
|
||||||
|
provider = Mock()
|
||||||
|
provider.provider_name.return_value = 'browserbase'
|
||||||
|
|
||||||
|
with patch('tools.browser_tool._get_cdp_override', return_value='ws://browser/devtools/browser/override'), \
|
||||||
|
patch('tools.browser_tool._get_cloud_provider', return_value=provider), \
|
||||||
|
patch('tools.browser_tool.check_browser_requirements', return_value=True), \
|
||||||
|
patch('tools.browser_tool._find_agent_browser', return_value='/usr/local/bin/agent-browser'):
|
||||||
|
status = bt.browser_runtime_status()
|
||||||
|
|
||||||
|
assert status['mode'] == 'cdp'
|
||||||
|
assert status['available'] is True
|
||||||
|
assert status['cloud_provider'] == 'browserbase'
|
||||||
|
assert status['session_count'] == 1
|
||||||
|
assert status['active_sessions'][0]['task_id'] == 'task-a'
|
||||||
|
assert status['self_healing']['orphan_reaper'] is True
|
||||||
|
|
||||||
|
def test_runtime_heal_cleans_sessions(self):
|
||||||
|
import tools.browser_tool as bt
|
||||||
|
|
||||||
|
bt._active_sessions['task-a'] = {'session_name': 'sess-a'}
|
||||||
|
bt._active_sessions['task-b'] = {'session_name': 'sess-b'}
|
||||||
|
|
||||||
|
with patch('tools.browser_tool.cleanup_all_browsers') as mock_cleanup, \
|
||||||
|
patch('tools.browser_tool._reap_orphaned_browser_sessions') as mock_reap, \
|
||||||
|
patch('tools.browser_tool.browser_runtime_status', side_effect=[
|
||||||
|
{'session_count': 2, 'mode': 'local', 'available': True},
|
||||||
|
{'session_count': 0, 'mode': 'local', 'available': True},
|
||||||
|
]):
|
||||||
|
result = bt.browser_runtime_heal()
|
||||||
|
|
||||||
|
mock_cleanup.assert_called_once_with()
|
||||||
|
mock_reap.assert_called_once_with()
|
||||||
|
assert result['success'] is True
|
||||||
|
assert result['before']['session_count'] == 2
|
||||||
|
assert result['after']['session_count'] == 0
|
||||||
322
tools/browser_harness.py
Normal file
322
tools/browser_harness.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
"""
|
||||||
|
Self-Healing Browser CDP Layer — browser-harness.
|
||||||
|
|
||||||
|
Thin browser automation layer with:
|
||||||
|
- CDP (Chrome DevTools Protocol) connection
|
||||||
|
- Self-healing on disconnects
|
||||||
|
- Session persistence
|
||||||
|
- Screenshot capture
|
||||||
|
- DOM inspection
|
||||||
|
- Navigation with retry
|
||||||
|
|
||||||
|
Source-backed: browser-harness architecture pattern.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import subprocess
|
||||||
|
import socket
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BrowserSession:
|
||||||
|
"""Browser session state."""
|
||||||
|
cdp_url: str
|
||||||
|
websocket_url: Optional[str] = None
|
||||||
|
page_id: Optional[str] = None
|
||||||
|
connected: bool = False
|
||||||
|
last_heartbeat: float = 0.0
|
||||||
|
reconnect_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class SelfHealingBrowser:
|
||||||
|
"""
|
||||||
|
Self-healing browser CDP layer.
|
||||||
|
|
||||||
|
Maintains connection to Chrome/Chromium via CDP,
|
||||||
|
automatically reconnects on disconnect, and provides
|
||||||
|
high-level browser automation primitives.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cdp_url: str = "http://localhost:9222",
|
||||||
|
max_reconnects: int = 5,
|
||||||
|
heartbeat_interval: int = 30,
|
||||||
|
):
|
||||||
|
self.cdp_url = cdp_url
|
||||||
|
self.max_reconnects = max_reconnects
|
||||||
|
self.heartbeat_interval = heartbeat_interval
|
||||||
|
self.session = BrowserSession(cdp_url=cdp_url)
|
||||||
|
self._ws = None
|
||||||
|
|
||||||
|
def connect(self) -> bool:
|
||||||
|
"""Connect to Chrome CDP."""
|
||||||
|
try:
|
||||||
|
import websocket
|
||||||
|
|
||||||
|
# Get WebSocket URL from CDP
|
||||||
|
import urllib.request
|
||||||
|
resp = urllib.request.urlopen(f"{self.cdp_url}/json/version")
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
|
||||||
|
ws_url = data.get("webSocketDebuggerUrl")
|
||||||
|
if not ws_url:
|
||||||
|
logger.error("No WebSocket URL from CDP")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.session.websocket_url = ws_url
|
||||||
|
self._ws = websocket.create_connection(ws_url)
|
||||||
|
self.session.connected = True
|
||||||
|
self.session.last_heartbeat = time.time()
|
||||||
|
|
||||||
|
logger.info("Connected to CDP: %s", ws_url)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to connect to CDP: %s", e)
|
||||||
|
self.session.connected = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
"""Disconnect from CDP."""
|
||||||
|
if self._ws:
|
||||||
|
try:
|
||||||
|
self._ws.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self._ws = None
|
||||||
|
self.session.connected = False
|
||||||
|
|
||||||
|
def reconnect(self) -> bool:
|
||||||
|
"""Attempt to reconnect with backoff."""
|
||||||
|
if self.session.reconnect_count >= self.max_reconnects:
|
||||||
|
logger.error("Max reconnects (%d) reached", self.max_reconnects)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.disconnect()
|
||||||
|
|
||||||
|
# Exponential backoff
|
||||||
|
wait = 2 ** self.session.reconnect_count
|
||||||
|
logger.info("Reconnecting in %ds (attempt %d/%d)",
|
||||||
|
wait, self.session.reconnect_count + 1, self.max_reconnects)
|
||||||
|
time.sleep(wait)
|
||||||
|
|
||||||
|
self.session.reconnect_count += 1
|
||||||
|
|
||||||
|
if self.connect():
|
||||||
|
self.session.reconnect_count = 0
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def ensure_connected(self) -> bool:
|
||||||
|
"""Ensure connection is alive, reconnect if needed."""
|
||||||
|
if self.session.connected and self._ws:
|
||||||
|
return True
|
||||||
|
return self.reconnect()
|
||||||
|
|
||||||
|
def send_cdp(self, method: str, params: Optional[Dict] = None) -> Optional[Dict]:
|
||||||
|
"""Send CDP command and return result."""
|
||||||
|
if not self.ensure_connected():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = {
|
||||||
|
"id": int(time.time() * 1000),
|
||||||
|
"method": method,
|
||||||
|
"params": params or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
self._ws.send(json.dumps(msg))
|
||||||
|
response = json.loads(self._ws.recv())
|
||||||
|
|
||||||
|
if "error" in response:
|
||||||
|
logger.error("CDP error: %s", response["error"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
return response.get("result")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("CDP command failed: %s", e)
|
||||||
|
self.session.connected = False
|
||||||
|
return None
|
||||||
|
|
||||||
|
def navigate(self, url: str, wait_load: bool = True) -> bool:
|
||||||
|
"""Navigate to URL."""
|
||||||
|
result = self.send_cdp("Page.navigate", {"url": url})
|
||||||
|
if not result:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if wait_load:
|
||||||
|
time.sleep(2) # Simple wait; could use Page.loadEventFired
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def screenshot(self, path: Optional[str] = None) -> Optional[str]:
|
||||||
|
"""Take screenshot."""
|
||||||
|
result = self.send_cdp("Page.captureScreenshot", {"format": "png"})
|
||||||
|
if not result or "data" not in result:
|
||||||
|
return None
|
||||||
|
|
||||||
|
import base64
|
||||||
|
img_data = base64.b64decode(result["data"])
|
||||||
|
|
||||||
|
if path:
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(img_data)
|
||||||
|
return path
|
||||||
|
else:
|
||||||
|
# Save to temp
|
||||||
|
import tempfile
|
||||||
|
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
|
||||||
|
tmp.write(img_data)
|
||||||
|
tmp.close()
|
||||||
|
return tmp.name
|
||||||
|
|
||||||
|
def get_dom(self) -> Optional[str]:
|
||||||
|
"""Get page HTML."""
|
||||||
|
result = self.send_cdp("Runtime.evaluate", {
|
||||||
|
"expression": "document.documentElement.outerHTML"
|
||||||
|
})
|
||||||
|
if result and "result" in result:
|
||||||
|
return result["result"].get("value")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def evaluate_js(self, expression: str) -> Any:
|
||||||
|
"""Evaluate JavaScript expression."""
|
||||||
|
result = self.send_cdp("Runtime.evaluate", {"expression": expression})
|
||||||
|
if result and "result" in result:
|
||||||
|
return result["result"].get("value")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def click(self, selector: str) -> bool:
|
||||||
|
"""Click element by CSS selector."""
|
||||||
|
js = f"""
|
||||||
|
(() => {{
|
||||||
|
const el = document.querySelector('{selector}');
|
||||||
|
if (el) {{ el.click(); return true; }}
|
||||||
|
return false;
|
||||||
|
}})()
|
||||||
|
"""
|
||||||
|
return self.evaluate_js(js) == True
|
||||||
|
|
||||||
|
def type_text(self, selector: str, text: str) -> bool:
|
||||||
|
"""Type text into input field."""
|
||||||
|
js = f"""
|
||||||
|
(() => {{
|
||||||
|
const el = document.querySelector('{selector}');
|
||||||
|
if (el) {{
|
||||||
|
el.focus();
|
||||||
|
el.value = '{text}';
|
||||||
|
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
return false;
|
||||||
|
}})()
|
||||||
|
"""
|
||||||
|
return self.evaluate_js(js) == True
|
||||||
|
|
||||||
|
def get_elements(self, selector: str) -> List[Dict]:
|
||||||
|
"""Get elements matching selector."""
|
||||||
|
js = f"""
|
||||||
|
(() => {{
|
||||||
|
const els = document.querySelectorAll('{selector}');
|
||||||
|
return Array.from(els).map(el => ({{
|
||||||
|
tag: el.tagName,
|
||||||
|
text: el.textContent?.substring(0, 100),
|
||||||
|
id: el.id,
|
||||||
|
classes: el.className,
|
||||||
|
}}));
|
||||||
|
}})()
|
||||||
|
"""
|
||||||
|
result = self.evaluate_js(js)
|
||||||
|
return result if isinstance(result, list) else []
|
||||||
|
|
||||||
|
def heartbeat(self) -> bool:
|
||||||
|
"""Check if connection is alive."""
|
||||||
|
if not self.session.connected:
|
||||||
|
return False
|
||||||
|
|
||||||
|
result = self.send_cdp("Runtime.evaluate", {"expression": "1+1"})
|
||||||
|
if result:
|
||||||
|
self.session.last_heartbeat = time.time()
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.session.connected = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.connect()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
self.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserHarness:
|
||||||
|
"""
|
||||||
|
High-level browser harness with self-healing.
|
||||||
|
|
||||||
|
Provides a simple interface for browser automation
|
||||||
|
with automatic reconnection and error recovery.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cdp_url: str = "http://localhost:9222"):
|
||||||
|
self.browser = SelfHealingBrowser(cdp_url)
|
||||||
|
|
||||||
|
def run(self, url: str, actions: List[Dict]) -> Dict:
|
||||||
|
"""
|
||||||
|
Run browser automation sequence.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Starting URL
|
||||||
|
actions: List of actions (navigate, click, type, screenshot, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with results
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
with self.browser as b:
|
||||||
|
# Navigate to URL
|
||||||
|
if not b.navigate(url):
|
||||||
|
return {"success": False, "error": "Navigation failed"}
|
||||||
|
|
||||||
|
for action in actions:
|
||||||
|
action_type = action.get("type")
|
||||||
|
|
||||||
|
if action_type == "screenshot":
|
||||||
|
path = b.screenshot(action.get("path"))
|
||||||
|
results.append({"type": "screenshot", "path": path})
|
||||||
|
|
||||||
|
elif action_type == "click":
|
||||||
|
success = b.click(action["selector"])
|
||||||
|
results.append({"type": "click", "success": success})
|
||||||
|
|
||||||
|
elif action_type == "type":
|
||||||
|
success = b.type_text(action["selector"], action["text"])
|
||||||
|
results.append({"type": "type", "success": success})
|
||||||
|
|
||||||
|
elif action_type == "evaluate":
|
||||||
|
value = b.evaluate_js(action["expression"])
|
||||||
|
results.append({"type": "evaluate", "value": value})
|
||||||
|
|
||||||
|
elif action_type == "wait":
|
||||||
|
time.sleep(action.get("seconds", 1))
|
||||||
|
results.append({"type": "wait", "seconds": action["seconds"]})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"results": results,
|
||||||
|
"session": {
|
||||||
|
"connected": self.browser.session.connected,
|
||||||
|
"reconnects": self.browser.session.reconnect_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2218,6 +2218,70 @@ def cleanup_all_browsers() -> None:
|
|||||||
_command_timeout_resolved = False
|
_command_timeout_resolved = False
|
||||||
|
|
||||||
|
|
||||||
|
def browser_runtime_status() -> Dict[str, Any]:
|
||||||
|
"""Return a machine-readable snapshot of the current browser runtime."""
|
||||||
|
cdp_override = _get_cdp_override()
|
||||||
|
provider = _get_cloud_provider()
|
||||||
|
mode = "cdp" if cdp_override else ("cloud" if provider is not None else "local")
|
||||||
|
|
||||||
|
browser_cmd = None
|
||||||
|
browser_error = None
|
||||||
|
try:
|
||||||
|
browser_cmd = _find_agent_browser()
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
browser_error = str(exc)
|
||||||
|
|
||||||
|
with _cleanup_lock:
|
||||||
|
sessions = []
|
||||||
|
for task_id, info in _active_sessions.items():
|
||||||
|
sessions.append({
|
||||||
|
"task_id": task_id,
|
||||||
|
"session_name": info.get("session_name"),
|
||||||
|
"cloud_session_id": info.get("bb_session_id"),
|
||||||
|
"cdp_url": info.get("cdp_url"),
|
||||||
|
"last_activity": _session_last_activity.get(task_id),
|
||||||
|
})
|
||||||
|
sessions.sort(key=lambda item: item["task_id"])
|
||||||
|
recording_count = len(_recording_sessions)
|
||||||
|
cleanup_thread_running = bool(_cleanup_running)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mode": mode,
|
||||||
|
"available": check_browser_requirements(),
|
||||||
|
"cloud_provider": provider.provider_name() if provider is not None else None,
|
||||||
|
"cdp_override": cdp_override or None,
|
||||||
|
"agent_browser": {
|
||||||
|
"available": browser_cmd is not None,
|
||||||
|
"command": browser_cmd,
|
||||||
|
"error": browser_error,
|
||||||
|
},
|
||||||
|
"session_count": len(sessions),
|
||||||
|
"active_sessions": sessions,
|
||||||
|
"recording_count": recording_count,
|
||||||
|
"cleanup_thread_running": cleanup_thread_running,
|
||||||
|
"inactivity_timeout_seconds": BROWSER_SESSION_INACTIVITY_TIMEOUT,
|
||||||
|
"self_healing": {
|
||||||
|
"inactivity_cleanup": True,
|
||||||
|
"orphan_reaper": True,
|
||||||
|
"emergency_cleanup": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def browser_runtime_heal() -> Dict[str, Any]:
|
||||||
|
"""Run the browser layer's self-healing cleanup sequence."""
|
||||||
|
before = browser_runtime_status()
|
||||||
|
cleanup_all_browsers()
|
||||||
|
_reap_orphaned_browser_sessions()
|
||||||
|
after = browser_runtime_status()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Browser runtime cleanup completed.",
|
||||||
|
"before": before,
|
||||||
|
"after": after,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Requirements Check
|
# Requirements Check
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -44,6 +44,34 @@ from typing import Dict, Any, Optional, Tuple
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_error(
|
||||||
|
message: str,
|
||||||
|
skill_name: str = None,
|
||||||
|
file_path: str = None,
|
||||||
|
suggestion: str = None,
|
||||||
|
context: dict = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Format an error with rich context for better debugging."""
|
||||||
|
parts = [message]
|
||||||
|
if skill_name:
|
||||||
|
parts.append(f"Skill: {skill_name}")
|
||||||
|
if file_path:
|
||||||
|
parts.append(f"File: {file_path}")
|
||||||
|
if suggestion:
|
||||||
|
parts.append(f"Suggestion: {suggestion}")
|
||||||
|
if context:
|
||||||
|
for key, value in context.items():
|
||||||
|
parts.append(f"{key}: {value}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": " | ".join(parts),
|
||||||
|
"skill_name": skill_name,
|
||||||
|
"file_path": file_path,
|
||||||
|
"suggestion": suggestion,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Import security scanner — agent-created skills get the same scrutiny as
|
# Import security scanner — agent-created skills get the same scrutiny as
|
||||||
# community hub installs.
|
# community hub installs.
|
||||||
try:
|
try:
|
||||||
|
|||||||
207
tools/web_cockpit.py
Normal file
207
tools/web_cockpit.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
"""
|
||||||
|
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}"
|
||||||
Reference in New Issue
Block a user