mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-06 02:37:05 +08:00
Adds an embedded web UI dashboard accessible via `hermes web`. Provides a browser-based interface for: - Monitoring agent status, gateway, and active/recent sessions - Editing config.yaml with a schema-driven form editor - Managing API keys in .env (set, clear, view redacted) Backend: FastAPI server (hermes_cli/web_server.py) with REST endpoints. Frontend: Vite + React + TypeScript + Tailwind v4 SPA (web/ directory). Also adds: - `/reload` slash command for hot-reloading .env variables - `delete_env_value()` and `reload_env()` utilities in config.py - `[web]` optional dependency extra (fastapi + uvicorn) - Web build step in `hermes update` (both git and zip paths) - hermes_cli/web_dist/ to .gitignore and package-data Salvaged from PR #1813 by austinpickett onto current main. Fixes applied during salvage: - Replaced hardcoded CONFIG_SCHEMA with dynamic generation from DEFAULT_CONFIG - Restricted CORS to localhost origins (was allow_origins=[*]) - Dropped _maybe_reload_env from model_tools.py (stale reverts to core dispatch) - Dropped .python-version file - Skipped all stale-branch reverts (pyproject.toml, model_tools.py, etc.)
399 lines
12 KiB
Python
399 lines
12 KiB
Python
"""
|
|
Hermes Agent — Web UI server.
|
|
|
|
Provides a FastAPI backend serving the Vite/React frontend and REST API
|
|
endpoints for managing configuration, environment variables, and sessions.
|
|
|
|
Usage:
|
|
python -m hermes_cli.main web # Start on http://127.0.0.1:9119
|
|
python -m hermes_cli.main web --port 8080
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
|
if str(PROJECT_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
from hermes_cli import __version__, __release_date__
|
|
from hermes_cli.config import (
|
|
DEFAULT_CONFIG,
|
|
OPTIONAL_ENV_VARS,
|
|
get_config_path,
|
|
get_env_path,
|
|
get_hermes_home,
|
|
load_config,
|
|
load_env,
|
|
save_config,
|
|
save_env_value,
|
|
delete_env_value,
|
|
check_config_version,
|
|
redact_key,
|
|
)
|
|
from gateway.status import get_running_pid, read_runtime_status
|
|
|
|
try:
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pydantic import BaseModel
|
|
except ImportError:
|
|
raise SystemExit(
|
|
"Web UI requires fastapi and uvicorn.\n"
|
|
"Run 'hermes web' to auto-install, or: pip install hermes-agent[web]"
|
|
)
|
|
|
|
WEB_DIST = Path(__file__).parent / "web_dist"
|
|
|
|
app = FastAPI(title="Hermes Agent", version=__version__)
|
|
|
|
# CORS: restrict to localhost origins only. The web UI is intended to run
|
|
# locally; binding to 0.0.0.0 with allow_origins=["*"] would let any website
|
|
# read/modify config and secrets.
|
|
_LOCALHOST_ORIGINS = [
|
|
"http://localhost:9119",
|
|
"http://127.0.0.1:9119",
|
|
"http://localhost:3000", # Vite dev server
|
|
"http://127.0.0.1:3000",
|
|
"http://localhost:5173", # Vite default
|
|
"http://127.0.0.1:5173",
|
|
]
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=_LOCALHOST_ORIGINS,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config schema — auto-generated from DEFAULT_CONFIG
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Manual overrides for fields that need select options or custom types
|
|
_SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
|
"model": {
|
|
"type": "string",
|
|
"description": "Default model (e.g. anthropic/claude-sonnet-4.6)",
|
|
},
|
|
"terminal.backend": {
|
|
"type": "select",
|
|
"description": "Terminal execution backend",
|
|
"options": ["local", "docker", "ssh", "modal", "daytona", "singularity"],
|
|
},
|
|
"terminal.modal_mode": {
|
|
"type": "select",
|
|
"description": "Modal sandbox mode",
|
|
"options": ["sandbox", "function"],
|
|
},
|
|
"tts.provider": {
|
|
"type": "select",
|
|
"description": "Text-to-speech provider",
|
|
"options": ["edge", "elevenlabs", "openai", "neutts"],
|
|
},
|
|
"stt.provider": {
|
|
"type": "select",
|
|
"description": "Speech-to-text provider",
|
|
"options": ["local", "openai", "mistral"],
|
|
},
|
|
"display.skin": {
|
|
"type": "select",
|
|
"description": "CLI visual theme",
|
|
"options": ["default", "ares", "mono", "slate"],
|
|
},
|
|
"display.resume_display": {
|
|
"type": "select",
|
|
"description": "How resumed sessions display history",
|
|
"options": ["minimal", "full", "off"],
|
|
},
|
|
"display.busy_input_mode": {
|
|
"type": "select",
|
|
"description": "Input behavior while agent is running",
|
|
"options": ["queue", "interrupt", "block"],
|
|
},
|
|
"memory.provider": {
|
|
"type": "select",
|
|
"description": "Memory provider plugin",
|
|
"options": ["builtin", "honcho"],
|
|
},
|
|
"approvals.mode": {
|
|
"type": "select",
|
|
"description": "Dangerous command approval mode",
|
|
"options": ["ask", "yolo", "deny"],
|
|
},
|
|
"context.engine": {
|
|
"type": "select",
|
|
"description": "Context management engine",
|
|
"options": ["default", "custom"],
|
|
},
|
|
"human_delay.mode": {
|
|
"type": "select",
|
|
"description": "Simulated typing delay mode",
|
|
"options": ["off", "typing", "fixed"],
|
|
},
|
|
"logging.level": {
|
|
"type": "select",
|
|
"description": "Log level for agent.log",
|
|
"options": ["DEBUG", "INFO", "WARNING", "ERROR"],
|
|
},
|
|
"agent.service_tier": {
|
|
"type": "select",
|
|
"description": "API service tier (OpenAI/Anthropic)",
|
|
"options": ["", "auto", "default", "flex"],
|
|
},
|
|
"delegation.reasoning_effort": {
|
|
"type": "select",
|
|
"description": "Reasoning effort for delegated subagents",
|
|
"options": ["", "low", "medium", "high"],
|
|
},
|
|
}
|
|
|
|
|
|
def _infer_type(value: Any) -> str:
|
|
"""Infer a UI field type from a Python value."""
|
|
if isinstance(value, bool):
|
|
return "boolean"
|
|
if isinstance(value, int):
|
|
return "number"
|
|
if isinstance(value, float):
|
|
return "number"
|
|
if isinstance(value, list):
|
|
return "list"
|
|
if isinstance(value, dict):
|
|
return "object"
|
|
return "string"
|
|
|
|
|
|
def _build_schema_from_config(
|
|
config: Dict[str, Any],
|
|
prefix: str = "",
|
|
) -> Dict[str, Dict[str, Any]]:
|
|
"""Walk DEFAULT_CONFIG and produce a flat dot-path → field schema dict."""
|
|
schema: Dict[str, Dict[str, Any]] = {}
|
|
for key, value in config.items():
|
|
full_key = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
|
|
category = key if not prefix else prefix.split(".")[0]
|
|
|
|
# Skip internal / version keys
|
|
if full_key in ("_config_version",):
|
|
continue
|
|
|
|
if isinstance(value, dict):
|
|
# Recurse into nested dicts
|
|
schema.update(_build_schema_from_config(value, full_key))
|
|
else:
|
|
entry: Dict[str, Any] = {
|
|
"type": _infer_type(value),
|
|
"description": full_key.replace(".", " → ").replace("_", " ").title(),
|
|
"category": category,
|
|
}
|
|
# Apply manual overrides
|
|
if full_key in _SCHEMA_OVERRIDES:
|
|
entry.update(_SCHEMA_OVERRIDES[full_key])
|
|
schema[full_key] = entry
|
|
return schema
|
|
|
|
|
|
CONFIG_SCHEMA = _build_schema_from_config(DEFAULT_CONFIG)
|
|
|
|
|
|
class ConfigUpdate(BaseModel):
|
|
config: dict
|
|
|
|
|
|
class EnvVarUpdate(BaseModel):
|
|
key: str
|
|
value: str
|
|
|
|
|
|
class EnvVarDelete(BaseModel):
|
|
key: str
|
|
|
|
|
|
@app.get("/api/status")
|
|
async def get_status():
|
|
current_ver, latest_ver = check_config_version()
|
|
|
|
gateway_pid = get_running_pid()
|
|
gateway_running = gateway_pid is not None
|
|
|
|
gateway_state = None
|
|
gateway_platforms: dict = {}
|
|
gateway_exit_reason = None
|
|
gateway_updated_at = None
|
|
runtime = read_runtime_status()
|
|
if runtime:
|
|
gateway_state = runtime.get("gateway_state")
|
|
gateway_platforms = runtime.get("platforms") or {}
|
|
gateway_exit_reason = runtime.get("exit_reason")
|
|
gateway_updated_at = runtime.get("updated_at")
|
|
if not gateway_running:
|
|
gateway_state = gateway_state if gateway_state in ("stopped", "startup_failed") else "stopped"
|
|
|
|
active_sessions = 0
|
|
try:
|
|
from hermes_state import SessionDB
|
|
db = SessionDB()
|
|
sessions = db.list_sessions_rich(limit=50)
|
|
now = time.time()
|
|
active_sessions = sum(
|
|
1 for s in sessions
|
|
if s.get("ended_at") is None
|
|
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"version": __version__,
|
|
"release_date": __release_date__,
|
|
"hermes_home": str(get_hermes_home()),
|
|
"config_path": str(get_config_path()),
|
|
"env_path": str(get_env_path()),
|
|
"config_version": current_ver,
|
|
"latest_config_version": latest_ver,
|
|
"gateway_running": gateway_running,
|
|
"gateway_pid": gateway_pid,
|
|
"gateway_state": gateway_state,
|
|
"gateway_platforms": gateway_platforms,
|
|
"gateway_exit_reason": gateway_exit_reason,
|
|
"gateway_updated_at": gateway_updated_at,
|
|
"active_sessions": active_sessions,
|
|
}
|
|
|
|
|
|
@app.get("/api/sessions")
|
|
async def get_sessions():
|
|
try:
|
|
from hermes_state import SessionDB
|
|
db = SessionDB()
|
|
sessions = db.list_sessions_rich(limit=20)
|
|
now = time.time()
|
|
for s in sessions:
|
|
s["is_active"] = (
|
|
s.get("ended_at") is None
|
|
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
|
)
|
|
return sessions
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/config")
|
|
async def get_config():
|
|
return load_config()
|
|
|
|
|
|
@app.get("/api/config/defaults")
|
|
async def get_defaults():
|
|
return DEFAULT_CONFIG
|
|
|
|
|
|
@app.get("/api/config/schema")
|
|
async def get_schema():
|
|
return CONFIG_SCHEMA
|
|
|
|
|
|
@app.put("/api/config")
|
|
async def update_config(body: ConfigUpdate):
|
|
try:
|
|
save_config(body.config)
|
|
return {"ok": True}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/env")
|
|
async def get_env_vars():
|
|
env_on_disk = load_env()
|
|
result = {}
|
|
for var_name, info in OPTIONAL_ENV_VARS.items():
|
|
value = env_on_disk.get(var_name)
|
|
result[var_name] = {
|
|
"is_set": bool(value),
|
|
"redacted_value": redact_key(value) if value else None,
|
|
"description": info.get("description", ""),
|
|
"url": info.get("url"),
|
|
"category": info.get("category", ""),
|
|
"is_password": info.get("password", False),
|
|
"tools": info.get("tools", []),
|
|
"advanced": info.get("advanced", False),
|
|
}
|
|
return result
|
|
|
|
|
|
@app.put("/api/env")
|
|
async def set_env_var(body: EnvVarUpdate):
|
|
try:
|
|
save_env_value(body.key, body.value)
|
|
return {"ok": True, "key": body.key}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.delete("/api/env")
|
|
async def remove_env_var(body: EnvVarDelete):
|
|
try:
|
|
removed = delete_env_value(body.key)
|
|
if not removed:
|
|
raise HTTPException(status_code=404, detail=f"{body.key} not found in .env")
|
|
return {"ok": True, "key": body.key}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
def mount_spa(application: FastAPI):
|
|
"""Mount the built SPA. Falls back to index.html for client-side routing."""
|
|
if not WEB_DIST.exists():
|
|
@application.get("/{full_path:path}")
|
|
async def no_frontend(full_path: str):
|
|
return JSONResponse(
|
|
{"error": "Frontend not built. Run: cd web && npm run build"},
|
|
status_code=404,
|
|
)
|
|
return
|
|
|
|
application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
|
|
|
|
@application.get("/{full_path:path}")
|
|
async def serve_spa(full_path: str):
|
|
file_path = WEB_DIST / full_path
|
|
if full_path and file_path.exists() and file_path.is_file():
|
|
return FileResponse(file_path)
|
|
return FileResponse(WEB_DIST / "index.html")
|
|
|
|
|
|
mount_spa(app)
|
|
|
|
|
|
def start_server(host: str = "127.0.0.1", port: int = 9119, open_browser: bool = True):
|
|
"""Start the web UI server."""
|
|
import uvicorn
|
|
|
|
# Add the actual server origin to allowed CORS origins
|
|
server_origin = f"http://{host}:{port}"
|
|
if server_origin not in _LOCALHOST_ORIGINS:
|
|
_LOCALHOST_ORIGINS.append(server_origin)
|
|
|
|
if open_browser:
|
|
import threading
|
|
import webbrowser
|
|
|
|
def _open():
|
|
import time as _t
|
|
_t.sleep(1.0)
|
|
webbrowser.open(f"http://{host}:{port}")
|
|
|
|
threading.Thread(target=_open, daemon=True).start()
|
|
|
|
print(f" Hermes Web UI → http://{host}:{port}")
|
|
uvicorn.run(app, host=host, port=port, log_level="warning")
|