mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
* 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>
929 lines
30 KiB
Python
929 lines
30 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 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")
|