Files
hermes-agent/hermes_cli/web_server.py

930 lines
30 KiB
Python
Raw Normal View History

feat: web UI dashboard for managing Hermes Agent (#8756) * feat: web UI dashboard for managing Hermes Agent (salvage of #8204/#7621) Adds an embedded web UI dashboard accessible via `hermes web`: - Status page: agent version, active sessions, gateway status, connected platforms - Config editor: schema-driven form with tabbed categories, import/export, reset - API Keys page: set, clear, and view redacted values with category grouping - Sessions, Skills, Cron, Logs, and Analytics pages Backend: - hermes_cli/web_server.py: FastAPI server with REST endpoints - hermes_cli/config.py: reload_env() utility for hot-reloading .env - hermes_cli/main.py: `hermes web` subcommand (--port, --host, --no-open) - cli.py / commands.py: /reload slash command for .env hot-reload - pyproject.toml: [web] optional dependency extra (fastapi + uvicorn) - Both update paths (git + zip) auto-build web frontend when npm available Frontend: - Vite + React + TypeScript + Tailwind v4 SPA in web/ - shadcn/ui-style components, Nous design language - Auto-refresh status page, toast notifications, masked password inputs Security: - Path traversal guard (resolve().is_relative_to()) on SPA file serving - CORS localhost-only via allow_origin_regex - Generic error messages (no internal leak), SessionDB handles closed properly Tests: 47 tests covering reload_env, redact_key, API endpoints, schema generation, path traversal, category merging, internal key stripping, and full config round-trip. Original work by @austinpickett (PR #1813), salvaged by @kshitijk4poor (PR #7621 → #8204), re-salvaged onto current main with stale-branch regressions removed. * fix(web): clean up status page cards, always rebuild on `hermes web` - Remove config version migration alert banner from status page - Remove config version card (internal noise, not surfaced in TUI) - Reorder status cards: Agent → Gateway → Active Sessions (3-col grid) - `hermes web` now always rebuilds from source before serving, preventing stale web_dist when editing frontend files * feat(web): full-text search across session messages - Add GET /api/sessions/search endpoint backed by FTS5 - Auto-append prefix wildcards so partial words match (e.g. 'nimb' → 'nimby') - Debounced search (300ms) with spinner in the search icon slot - Search results show FTS5 snippets with highlighted match delimiters - Expanding a search hit auto-scrolls to the first matching message - Matching messages get a warning ring + 'match' badge - Inline term highlighting within Markdown (text, bold, italic, headings, lists) - Clear button (x) on search input for quick reset --------- Co-authored-by: emozilla <emozilla@nousresearch.com>
2026-04-12 22:26:28 -07:00
"""
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 logging
import os
import secrets
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
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,
remove_env_value,
check_config_version,
redact_key,
)
from gateway.status import get_running_pid, read_runtime_status
try:
from fastapi import FastAPI, HTTPException, Request
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"
_log = logging.getLogger(__name__)
app = FastAPI(title="Hermes Agent", version=__version__)
# ---------------------------------------------------------------------------
# Session token for protecting sensitive endpoints (reveal).
# Generated fresh on every server start — dies when the process exits.
# Injected into the SPA HTML so only the legitimate web UI can use it.
# ---------------------------------------------------------------------------
_SESSION_TOKEN = secrets.token_urlsafe(32)
# Simple rate limiter for the reveal endpoint
_reveal_timestamps: List[float] = []
_REVEAL_MAX_PER_WINDOW = 5
_REVEAL_WINDOW_SECONDS = 30
# 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.
app.add_middleware(
CORSMiddleware,
allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$",
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)",
"category": "general",
},
"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"],
},
}
# Categories with fewer fields get merged into "general" to avoid tab sprawl.
_CATEGORY_MERGE: Dict[str, str] = {
"privacy": "security",
"context": "agent",
"skills": "agent",
"cron": "agent",
"network": "agent",
"checkpoints": "agent",
"approvals": "security",
"human_delay": "display",
"smart_model_routing": "agent",
}
# Display order for tabs — unlisted categories sort alphabetically after these.
_CATEGORY_ORDER = [
"general", "agent", "terminal", "display", "delegation",
"memory", "compression", "security", "browser", "voice",
"tts", "stt", "logging", "discord", "auxiliary",
]
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 prefix else key
# Skip internal / version keys
if full_key in ("_config_version",):
continue
# Category is the first path component for nested keys, or "general"
# for top-level scalar fields (model, toolsets, timezone, etc.).
if prefix:
category = prefix.split(".")[0]
elif isinstance(value, dict):
category = key
else:
category = "general"
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])
# Merge small categories
entry["category"] = _CATEGORY_MERGE.get(entry["category"], entry["category"])
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
class EnvVarReveal(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
configured_gateway_platforms: set[str] | None = None
try:
from gateway.config import load_gateway_config
gateway_config = load_gateway_config()
configured_gateway_platforms = {
platform.value for platform in gateway_config.get_connected_platforms()
}
except Exception:
configured_gateway_platforms = None
runtime = read_runtime_status()
if runtime:
gateway_state = runtime.get("gateway_state")
gateway_platforms = runtime.get("platforms") or {}
if configured_gateway_platforms is not None:
gateway_platforms = {
key: value
for key, value in gateway_platforms.items()
if key in configured_gateway_platforms
}
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"
gateway_platforms = {}
active_sessions = 0
try:
from hermes_state import SessionDB
db = SessionDB()
try:
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
)
finally:
db.close()
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()
try:
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
finally:
db.close()
except Exception as e:
_log.exception("GET /api/sessions failed")
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/api/sessions/search")
async def search_sessions(q: str = "", limit: int = 20):
"""Full-text search across session message content using FTS5."""
if not q or not q.strip():
return {"results": []}
try:
from hermes_state import SessionDB
db = SessionDB()
try:
# Auto-add prefix wildcards so partial words match
# e.g. "nimb" → "nimb*" matches "nimby"
# Preserve quoted phrases and existing wildcards as-is
import re
terms = []
for token in re.findall(r'"[^"]*"|\S+', q.strip()):
if token.startswith('"') or token.endswith("*"):
terms.append(token)
else:
terms.append(token + "*")
prefix_query = " ".join(terms)
matches = db.search_messages(query=prefix_query, limit=limit)
# Group by session_id — return unique sessions with their best snippet
seen: dict = {}
for m in matches:
sid = m["session_id"]
if sid not in seen:
seen[sid] = {
"session_id": sid,
"snippet": m.get("snippet", ""),
"role": m.get("role"),
"source": m.get("source"),
"model": m.get("model"),
"session_started": m.get("session_started"),
}
return {"results": list(seen.values())}
finally:
db.close()
except Exception:
_log.exception("GET /api/sessions/search failed")
raise HTTPException(status_code=500, detail="Search failed")
def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize config for the web UI.
Hermes supports ``model`` as either a bare string (``"anthropic/claude-sonnet-4"``)
or a dict (``{default: ..., provider: ..., base_url: ...}``). The schema is built
from DEFAULT_CONFIG where ``model`` is a string, but user configs often have the
dict form. Normalize to the string form so the frontend schema matches.
"""
config = dict(config) # shallow copy
model_val = config.get("model")
if isinstance(model_val, dict):
config["model"] = model_val.get("default", model_val.get("name", ""))
return config
@app.get("/api/config")
async def get_config():
config = _normalize_config_for_web(load_config())
# Strip internal keys that the frontend shouldn't see or send back
return {k: v for k, v in config.items() if not k.startswith("_")}
@app.get("/api/config/defaults")
async def get_defaults():
return DEFAULT_CONFIG
@app.get("/api/config/schema")
async def get_schema():
return {"fields": CONFIG_SCHEMA, "category_order": _CATEGORY_ORDER}
def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
"""Reverse _normalize_config_for_web before saving.
Reconstructs ``model`` as a dict by reading the current on-disk config
to recover model subkeys (provider, base_url, api_mode, etc.) that were
stripped from the GET response. The frontend only sees model as a flat
string; the rest is preserved transparently.
"""
config = dict(config)
# Remove any _model_meta that might have leaked in (shouldn't happen
# with the stripped GET response, but be defensive)
config.pop("_model_meta", None)
model_val = config.get("model")
if isinstance(model_val, str) and model_val:
# Read the current disk config to recover model subkeys
try:
disk_config = load_config()
disk_model = disk_config.get("model")
if isinstance(disk_model, dict):
# Preserve all subkeys, update default with the new value
disk_model["default"] = model_val
config["model"] = disk_model
except Exception:
pass # can't read disk config — just use the string form
return config
@app.put("/api/config")
async def update_config(body: ConfigUpdate):
try:
save_config(_denormalize_config_from_web(body.config))
return {"ok": True}
except Exception as e:
_log.exception("PUT /api/config failed")
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/api/auth/session-token")
async def get_session_token():
"""Return the ephemeral session token for this server instance.
The token protects sensitive endpoints (reveal). It's served to the SPA
which stores it in memory it's never persisted and dies when the server
process exits. CORS already restricts this to localhost origins.
"""
return {"token": _SESSION_TOKEN}
@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:
_log.exception("PUT /api/env failed")
raise HTTPException(status_code=500, detail="Internal server error")
@app.delete("/api/env")
async def remove_env_var(body: EnvVarDelete):
try:
removed = remove_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:
_log.exception("DELETE /api/env failed")
raise HTTPException(status_code=500, detail="Internal server error")
@app.post("/api/env/reveal")
async def reveal_env_var(body: EnvVarReveal, request: Request):
"""Return the real (unredacted) value of a single env var.
Protected by:
- Ephemeral session token (generated per server start, injected into SPA)
- Rate limiting (max 5 reveals per 30s window)
- Audit logging
"""
# --- Token check ---
auth = request.headers.get("authorization", "")
if auth != f"Bearer {_SESSION_TOKEN}":
raise HTTPException(status_code=401, detail="Unauthorized")
# --- Rate limit ---
now = time.time()
cutoff = now - _REVEAL_WINDOW_SECONDS
_reveal_timestamps[:] = [t for t in _reveal_timestamps if t > cutoff]
if len(_reveal_timestamps) >= _REVEAL_MAX_PER_WINDOW:
raise HTTPException(status_code=429, detail="Too many reveal requests. Try again shortly.")
_reveal_timestamps.append(now)
# --- Reveal ---
env_on_disk = load_env()
value = env_on_disk.get(body.key)
if value is None:
raise HTTPException(status_code=404, detail=f"{body.key} not found in .env")
_log.info("env/reveal: %s", body.key)
return {"key": body.key, "value": value}
# ---------------------------------------------------------------------------
# Session detail endpoints
# ---------------------------------------------------------------------------
@app.get("/api/sessions/{session_id}")
async def get_session_detail(session_id: str):
from hermes_state import SessionDB
db = SessionDB()
try:
sid = db.resolve_session_id(session_id)
session = db.get_session(sid) if sid else None
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return session
finally:
db.close()
@app.get("/api/sessions/{session_id}/messages")
async def get_session_messages(session_id: str):
from hermes_state import SessionDB
db = SessionDB()
try:
sid = db.resolve_session_id(session_id)
if not sid:
raise HTTPException(status_code=404, detail="Session not found")
messages = db.get_messages(sid)
return {"session_id": sid, "messages": messages}
finally:
db.close()
@app.delete("/api/sessions/{session_id}")
async def delete_session_endpoint(session_id: str):
from hermes_state import SessionDB
db = SessionDB()
try:
if not db.delete_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return {"ok": True}
finally:
db.close()
# ---------------------------------------------------------------------------
# Log viewer endpoint
# ---------------------------------------------------------------------------
@app.get("/api/logs")
async def get_logs(
file: str = "agent",
lines: int = 100,
level: Optional[str] = None,
component: Optional[str] = None,
):
from hermes_cli.logs import _read_tail, LOG_FILES
log_name = LOG_FILES.get(file)
if not log_name:
raise HTTPException(status_code=400, detail=f"Unknown log file: {file}")
log_path = get_hermes_home() / "logs" / log_name
if not log_path.exists():
return {"file": file, "lines": []}
try:
from hermes_logging import COMPONENT_PREFIXES
except ImportError:
COMPONENT_PREFIXES = {}
has_filters = bool(level or component)
comp_prefixes = COMPONENT_PREFIXES.get(component, ()) if component else ()
result = _read_tail(
log_path, min(lines, 500),
has_filters=has_filters,
min_level=level,
component_prefixes=comp_prefixes,
)
return {"file": file, "lines": result}
# ---------------------------------------------------------------------------
# Cron job management endpoints
# ---------------------------------------------------------------------------
class CronJobCreate(BaseModel):
prompt: str
schedule: str
name: str = ""
deliver: str = "local"
class CronJobUpdate(BaseModel):
updates: dict
@app.get("/api/cron/jobs")
async def list_cron_jobs():
from cron.jobs import list_jobs
return list_jobs(include_disabled=True)
@app.get("/api/cron/jobs/{job_id}")
async def get_cron_job(job_id: str):
from cron.jobs import get_job
job = get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
@app.post("/api/cron/jobs")
async def create_cron_job(body: CronJobCreate):
from cron.jobs import create_job
try:
job = create_job(prompt=body.prompt, schedule=body.schedule,
name=body.name, deliver=body.deliver)
return job
except Exception as e:
_log.exception("POST /api/cron/jobs failed")
raise HTTPException(status_code=400, detail=str(e))
@app.put("/api/cron/jobs/{job_id}")
async def update_cron_job(job_id: str, body: CronJobUpdate):
from cron.jobs import update_job
job = update_job(job_id, body.updates)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
@app.post("/api/cron/jobs/{job_id}/pause")
async def pause_cron_job(job_id: str):
from cron.jobs import pause_job
job = pause_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
@app.post("/api/cron/jobs/{job_id}/resume")
async def resume_cron_job(job_id: str):
from cron.jobs import resume_job
job = resume_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
@app.post("/api/cron/jobs/{job_id}/trigger")
async def trigger_cron_job(job_id: str):
from cron.jobs import trigger_job
job = trigger_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
@app.delete("/api/cron/jobs/{job_id}")
async def delete_cron_job(job_id: str):
from cron.jobs import remove_job
if not remove_job(job_id):
raise HTTPException(status_code=404, detail="Job not found")
return {"ok": True}
# ---------------------------------------------------------------------------
# Skills & Tools endpoints
# ---------------------------------------------------------------------------
class SkillToggle(BaseModel):
name: str
enabled: bool
@app.get("/api/skills")
async def get_skills():
from tools.skills_tool import _find_all_skills
from hermes_cli.skills_config import get_disabled_skills
config = load_config()
disabled = get_disabled_skills(config)
skills = _find_all_skills(skip_disabled=True)
for s in skills:
s["enabled"] = s["name"] not in disabled
return skills
@app.put("/api/skills/toggle")
async def toggle_skill(body: SkillToggle):
from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
config = load_config()
disabled = get_disabled_skills(config)
if body.enabled:
disabled.discard(body.name)
else:
disabled.add(body.name)
save_disabled_skills(config, disabled)
return {"ok": True, "name": body.name, "enabled": body.enabled}
@app.get("/api/tools/toolsets")
async def get_toolsets():
from hermes_cli.tools_config import (
_get_effective_configurable_toolsets,
_get_platform_tools,
_toolset_has_keys,
)
from toolsets import resolve_toolset
config = load_config()
enabled_toolsets = _get_platform_tools(
config,
"cli",
include_default_mcp_servers=False,
)
result = []
for name, label, desc in _get_effective_configurable_toolsets():
try:
tools = sorted(set(resolve_toolset(name)))
except Exception:
tools = []
is_enabled = name in enabled_toolsets
result.append({
"name": name, "label": label, "description": desc,
"enabled": is_enabled,
"available": is_enabled,
"configured": _toolset_has_keys(name, config),
"tools": tools,
})
return result
# ---------------------------------------------------------------------------
# Raw YAML config endpoint
# ---------------------------------------------------------------------------
class RawConfigUpdate(BaseModel):
yaml_text: str
@app.get("/api/config/raw")
async def get_config_raw():
path = get_config_path()
if not path.exists():
return {"yaml": ""}
return {"yaml": path.read_text(encoding="utf-8")}
@app.put("/api/config/raw")
async def update_config_raw(body: RawConfigUpdate):
try:
parsed = yaml.safe_load(body.yaml_text)
if not isinstance(parsed, dict):
raise HTTPException(status_code=400, detail="YAML must be a mapping")
save_config(parsed)
return {"ok": True}
except yaml.YAMLError as e:
raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}")
# ---------------------------------------------------------------------------
# Token / cost analytics endpoint
# ---------------------------------------------------------------------------
@app.get("/api/analytics/usage")
async def get_usage_analytics(days: int = 30):
from hermes_state import SessionDB
db = SessionDB()
try:
cutoff = time.time() - (days * 86400)
cur = db._conn.execute("""
SELECT date(started_at, 'unixepoch') as day,
SUM(input_tokens) as input_tokens,
SUM(output_tokens) as output_tokens,
SUM(cache_read_tokens) as cache_read_tokens,
SUM(reasoning_tokens) as reasoning_tokens,
COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost,
COALESCE(SUM(actual_cost_usd), 0) as actual_cost,
COUNT(*) as sessions
FROM sessions WHERE started_at > ?
GROUP BY day ORDER BY day
""", (cutoff,))
daily = [dict(r) for r in cur.fetchall()]
cur2 = db._conn.execute("""
SELECT model,
SUM(input_tokens) as input_tokens,
SUM(output_tokens) as output_tokens,
COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost,
COUNT(*) as sessions
FROM sessions WHERE started_at > ? AND model IS NOT NULL
GROUP BY model ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC
""", (cutoff,))
by_model = [dict(r) for r in cur2.fetchall()]
cur3 = db._conn.execute("""
SELECT SUM(input_tokens) as total_input,
SUM(output_tokens) as total_output,
SUM(cache_read_tokens) as total_cache_read,
SUM(reasoning_tokens) as total_reasoning,
COALESCE(SUM(estimated_cost_usd), 0) as total_estimated_cost,
COALESCE(SUM(actual_cost_usd), 0) as total_actual_cost,
COUNT(*) as total_sessions
FROM sessions WHERE started_at > ?
""", (cutoff,))
totals = dict(cur3.fetchone())
return {"daily": daily, "by_model": by_model, "totals": totals, "period_days": days}
finally:
db.close()
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
# Prevent path traversal via url-encoded sequences (%2e%2e/)
if (
full_path
and file_path.resolve().is_relative_to(WEB_DIST.resolve())
and file_path.exists()
and file_path.is_file()
):
return FileResponse(file_path)
return FileResponse(
WEB_DIST / "index.html",
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
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
if host not in ("127.0.0.1", "localhost", "::1"):
import logging
logging.warning(
"Binding to %s — the web UI exposes config and API keys. "
"Only bind to non-localhost if you trust all users on the network.", host,
)
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")