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>
This commit is contained in:
Teknium 2026-04-12 22:26:28 -07:00 committed by GitHub
parent c052cf0eea
commit e2a9b5369f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 10187 additions and 3 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
# Auto-generated files — collapse diffs and exclude from language stats
web/package-lock.json linguist-generated=true

3
.gitignore vendored
View file

@ -51,6 +51,9 @@ ignored/
.worktrees/ .worktrees/
environments/benchmarks/evals/ environments/benchmarks/evals/
# Web UI build output
hermes_cli/web_dist/
# Release script temp files # Release script temp files
.release_notes.md .release_notes.md
mini-swe-agent/ mini-swe-agent/

4
cli.py
View file

@ -5419,6 +5419,10 @@ class HermesCLI:
self._handle_paste_command() self._handle_paste_command()
elif canonical == "image": elif canonical == "image":
self._handle_image_command(cmd_original) self._handle_image_command(cmd_original)
elif canonical == "reload":
from hermes_cli.config import reload_env
count = reload_env()
print(f" Reloaded .env ({count} var(s) updated)")
elif canonical == "reload-mcp": elif canonical == "reload-mcp":
with self._busy_command(self._slow_command_status(cmd_original)): with self._busy_command(self._slow_command_status(cmd_original)):
self._reload_mcp() self._reload_mcp()

View file

@ -129,6 +129,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills", CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
cli_only=True, args_hint="[subcommand]", cli_only=True, args_hint="[subcommand]",
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")), subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills"),
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills", CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
aliases=("reload_mcp",)), aliases=("reload_mcp",)),
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills", CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",

View file

@ -2636,6 +2636,28 @@ def save_env_value_secure(key: str, value: str) -> Dict[str, Any]:
def reload_env() -> int:
"""Re-read ~/.hermes/.env into os.environ. Returns count of vars updated.
Adds/updates vars that changed and removes vars that were deleted from
the .env file (but only vars known to Hermes OPTIONAL_ENV_VARS and
_EXTRA_ENV_KEYS to avoid clobbering unrelated environment).
"""
env_vars = load_env()
known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS
count = 0
for key, value in env_vars.items():
if os.environ.get(key) != value:
os.environ[key] = value
count += 1
# Remove known Hermes vars that are no longer in .env
for key in known_keys:
if key not in env_vars and key in os.environ:
del os.environ[key]
count += 1
return count
def get_env_value(key: str) -> Optional[str]: def get_env_value(key: str) -> Optional[str]:
"""Get a value from ~/.hermes/.env or environment.""" """Get a value from ~/.hermes/.env or environment."""
# Check environment first # Check environment first

View file

@ -2976,6 +2976,44 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0)
return default return default
def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
"""Build the web UI frontend if npm is available.
Args:
web_dir: Path to the ``web/`` source directory.
fatal: If True, print error guidance and return False on failure
instead of a soft warning (used by ``hermes web``).
Returns True if the build succeeded or was skipped (no package.json).
"""
if not (web_dir / "package.json").exists():
return True
import shutil
npm = shutil.which("npm")
if not npm:
if fatal:
print("Web UI frontend not built and npm is not available.")
print("Install Node.js, then run: cd web && npm install && npm run build")
return not fatal
print("→ Building web UI...")
r1 = subprocess.run([npm, "install", "--silent"], cwd=web_dir, capture_output=True)
if r1.returncode != 0:
print(f" {'' if fatal else ''} Web UI npm install failed"
+ ("" if fatal else " (hermes web will not be available)"))
if fatal:
print(" Run manually: cd web && npm install && npm run build")
return False
r2 = subprocess.run([npm, "run", "build"], cwd=web_dir, capture_output=True)
if r2.returncode != 0:
print(f" {'' if fatal else ''} Web UI build failed"
+ ("" if fatal else " (hermes web will not be available)"))
if fatal:
print(" Run manually: cd web && npm install && npm run build")
return False
print(" ✓ Web UI built")
return True
def _update_via_zip(args): def _update_via_zip(args):
"""Update Hermes Agent by downloading a ZIP archive. """Update Hermes Agent by downloading a ZIP archive.
@ -3071,6 +3109,9 @@ def _update_via_zip(args):
) )
_install_python_dependencies_with_optional_fallback(pip_cmd) _install_python_dependencies_with_optional_fallback(pip_cmd)
# Build web UI frontend (optional — requires npm)
_build_web_ui(PROJECT_ROOT / "web")
# Sync skills # Sync skills
try: try:
from tools.skills_sync import sync_skills from tools.skills_sync import sync_skills
@ -3818,6 +3859,9 @@ def cmd_update(args):
print("→ Updating Node.js dependencies...") print("→ Updating Node.js dependencies...")
subprocess.run(["npm", "install", "--silent"], cwd=PROJECT_ROOT, check=False) subprocess.run(["npm", "install", "--silent"], cwd=PROJECT_ROOT, check=False)
# Build web UI frontend (optional — requires npm)
_build_web_ui(PROJECT_ROOT / "web")
print() print()
print("✓ Code updated!") print("✓ Code updated!")
@ -4099,7 +4143,7 @@ def _coalesce_session_name_args(argv: list) -> list:
"chat", "model", "gateway", "setup", "whatsapp", "login", "logout", "auth", "chat", "model", "gateway", "setup", "whatsapp", "login", "logout", "auth",
"status", "cron", "doctor", "config", "pairing", "skills", "tools", "status", "cron", "doctor", "config", "pairing", "skills", "tools",
"mcp", "sessions", "insights", "version", "update", "uninstall", "mcp", "sessions", "insights", "version", "update", "uninstall",
"profile", "profile", "dashboard",
} }
_SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"} _SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"}
@ -4377,6 +4421,27 @@ def cmd_profile(args):
sys.exit(1) sys.exit(1)
def cmd_dashboard(args):
"""Start the web UI server."""
try:
import fastapi # noqa: F401
import uvicorn # noqa: F401
except ImportError:
print("Web UI dependencies not installed.")
print("Install them with: pip install hermes-agent[web]")
sys.exit(1)
if not _build_web_ui(PROJECT_ROOT / "web", fatal=True):
sys.exit(1)
from hermes_cli.web_server import start_server
start_server(
host=args.host,
port=args.port,
open_browser=not args.no_open,
)
def cmd_completion(args): def cmd_completion(args):
"""Print shell completion script.""" """Print shell completion script."""
from hermes_cli.profiles import generate_bash_completion, generate_zsh_completion from hermes_cli.profiles import generate_bash_completion, generate_zsh_completion
@ -5862,6 +5927,19 @@ Examples:
) )
completion_parser.set_defaults(func=cmd_completion) completion_parser.set_defaults(func=cmd_completion)
# =========================================================================
# dashboard command
# =========================================================================
dashboard_parser = subparsers.add_parser(
"dashboard",
help="Start the web UI dashboard",
description="Launch the Hermes Agent web dashboard for managing config, API keys, and sessions",
)
dashboard_parser.add_argument("--port", type=int, default=9119, help="Port (default 9119)")
dashboard_parser.add_argument("--host", default="127.0.0.1", help="Host (default 127.0.0.1)")
dashboard_parser.add_argument("--no-open", action="store_true", help="Don't open browser automatically")
dashboard_parser.set_defaults(func=cmd_dashboard)
# ========================================================================= # =========================================================================
# logs command # logs command
# ========================================================================= # =========================================================================

929
hermes_cli/web_server.py Normal file
View file

@ -0,0 +1,929 @@
"""
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")

View file

@ -76,6 +76,7 @@ termux = [
] ]
dingtalk = ["dingtalk-stream>=0.1.0,<1"] dingtalk = ["dingtalk-stream>=0.1.0,<1"]
feishu = ["lark-oapi>=1.5.3,<2"] feishu = ["lark-oapi>=1.5.3,<2"]
web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"]
rl = [ rl = [
"atroposlib @ git+https://github.com/NousResearch/atropos.git", "atroposlib @ git+https://github.com/NousResearch/atropos.git",
"tinker @ git+https://github.com/thinking-machines-lab/tinker.git", "tinker @ git+https://github.com/thinking-machines-lab/tinker.git",
@ -107,6 +108,7 @@ all = [
"hermes-agent[dingtalk]", "hermes-agent[dingtalk]",
"hermes-agent[feishu]", "hermes-agent[feishu]",
"hermes-agent[mistral]", "hermes-agent[mistral]",
"hermes-agent[web]",
] ]
[project.scripts] [project.scripts]
@ -117,6 +119,9 @@ hermes-acp = "acp_adapter.entry:main"
[tool.setuptools] [tool.setuptools]
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "rl_cli", "utils"] py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "rl_cli", "utils"]
[tool.setuptools.package-data]
hermes_cli = ["web_dist/**/*"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"] include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"]

View file

@ -0,0 +1,675 @@
"""Tests for hermes_cli.web_server and related config utilities."""
import os
import json
import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from hermes_cli.config import (
DEFAULT_CONFIG,
reload_env,
redact_key,
_EXTRA_ENV_KEYS,
OPTIONAL_ENV_VARS,
)
# ---------------------------------------------------------------------------
# reload_env tests
# ---------------------------------------------------------------------------
class TestReloadEnv:
"""Tests for reload_env() — re-reads .env into os.environ."""
def test_adds_new_vars(self, tmp_path):
"""reload_env() adds vars from .env that are not in os.environ."""
env_file = tmp_path / ".env"
env_file.write_text("TEST_RELOAD_VAR=hello123\n")
with patch("hermes_cli.config.get_env_path", return_value=env_file):
os.environ.pop("TEST_RELOAD_VAR", None)
count = reload_env()
assert count >= 1
assert os.environ.get("TEST_RELOAD_VAR") == "hello123"
os.environ.pop("TEST_RELOAD_VAR", None)
def test_updates_changed_vars(self, tmp_path):
"""reload_env() updates vars whose value changed on disk."""
env_file = tmp_path / ".env"
env_file.write_text("TEST_RELOAD_VAR=old_value\n")
with patch("hermes_cli.config.get_env_path", return_value=env_file):
os.environ["TEST_RELOAD_VAR"] = "old_value"
# Now change the file
env_file.write_text("TEST_RELOAD_VAR=new_value\n")
count = reload_env()
assert count >= 1
assert os.environ.get("TEST_RELOAD_VAR") == "new_value"
os.environ.pop("TEST_RELOAD_VAR", None)
def test_removes_deleted_known_vars(self, tmp_path):
"""reload_env() removes known Hermes vars not present in .env."""
env_file = tmp_path / ".env"
env_file.write_text("") # empty .env
# Pick a known key from OPTIONAL_ENV_VARS
known_key = next(iter(OPTIONAL_ENV_VARS.keys()))
with patch("hermes_cli.config.get_env_path", return_value=env_file):
os.environ[known_key] = "stale_value"
count = reload_env()
assert known_key not in os.environ
assert count >= 1
def test_does_not_remove_unknown_vars(self, tmp_path):
"""reload_env() preserves non-Hermes env vars even when absent from .env."""
env_file = tmp_path / ".env"
env_file.write_text("")
with patch("hermes_cli.config.get_env_path", return_value=env_file):
os.environ["MY_CUSTOM_UNRELATED_VAR"] = "keep_me"
reload_env()
assert os.environ.get("MY_CUSTOM_UNRELATED_VAR") == "keep_me"
os.environ.pop("MY_CUSTOM_UNRELATED_VAR", None)
# ---------------------------------------------------------------------------
# redact_key tests
# ---------------------------------------------------------------------------
class TestRedactKey:
def test_long_key_shows_prefix_suffix(self):
result = redact_key("sk-1234567890abcdef")
assert result.startswith("sk-1")
assert result.endswith("cdef")
assert "..." in result
def test_short_key_fully_masked(self):
assert redact_key("short") == "***"
def test_empty_key(self):
result = redact_key("")
assert "not set" in result.lower() or result == "***" or "\x1b" in result
# ---------------------------------------------------------------------------
# web_server tests (FastAPI endpoints)
# ---------------------------------------------------------------------------
class TestWebServerEndpoints:
"""Test the FastAPI REST endpoints using Starlette TestClient."""
@pytest.fixture(autouse=True)
def _setup_test_client(self):
"""Create a TestClient — import is deferred to avoid requiring fastapi."""
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app
self.client = TestClient(app)
def test_get_status(self):
resp = self.client.get("/api/status")
assert resp.status_code == 200
data = resp.json()
assert "version" in data
assert "hermes_home" in data
assert "active_sessions" in data
def test_get_status_filters_unconfigured_gateway_platforms(self, monkeypatch):
import gateway.config as gateway_config
import hermes_cli.web_server as web_server
class _Platform:
def __init__(self, value):
self.value = value
class _GatewayConfig:
def get_connected_platforms(self):
return [_Platform("telegram")]
monkeypatch.setattr(web_server, "get_running_pid", lambda: 1234)
monkeypatch.setattr(
web_server,
"read_runtime_status",
lambda: {
"gateway_state": "running",
"updated_at": "2026-04-12T00:00:00+00:00",
"platforms": {
"telegram": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"},
"whatsapp": {"state": "retrying", "updated_at": "2026-04-12T00:00:00+00:00"},
"feishu": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"},
},
},
)
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
monkeypatch.setattr(gateway_config, "load_gateway_config", lambda: _GatewayConfig())
resp = self.client.get("/api/status")
assert resp.status_code == 200
assert resp.json()["gateway_platforms"] == {
"telegram": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"},
}
def test_get_status_hides_stale_platforms_when_gateway_not_running(self, monkeypatch):
import gateway.config as gateway_config
import hermes_cli.web_server as web_server
class _GatewayConfig:
def get_connected_platforms(self):
return []
monkeypatch.setattr(web_server, "get_running_pid", lambda: None)
monkeypatch.setattr(
web_server,
"read_runtime_status",
lambda: {
"gateway_state": "startup_failed",
"updated_at": "2026-04-12T00:00:00+00:00",
"platforms": {
"whatsapp": {"state": "retrying", "updated_at": "2026-04-12T00:00:00+00:00"},
"feishu": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"},
},
},
)
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
monkeypatch.setattr(gateway_config, "load_gateway_config", lambda: _GatewayConfig())
resp = self.client.get("/api/status")
assert resp.status_code == 200
assert resp.json()["gateway_state"] == "startup_failed"
assert resp.json()["gateway_platforms"] == {}
def test_get_config_schema(self):
resp = self.client.get("/api/config/schema")
assert resp.status_code == 200
data = resp.json()
assert "fields" in data
assert "category_order" in data
schema = data["fields"]
assert len(schema) > 100 # Should have 150+ fields
assert "model" in schema
# Verify category_order is a non-empty list
assert isinstance(data["category_order"], list)
assert len(data["category_order"]) > 0
assert "general" in data["category_order"]
def test_get_config_defaults(self):
resp = self.client.get("/api/config/defaults")
assert resp.status_code == 200
defaults = resp.json()
assert "model" in defaults
def test_get_env_vars(self):
resp = self.client.get("/api/env")
assert resp.status_code == 200
data = resp.json()
# Should contain known env var names
assert any(k.endswith("_API_KEY") or k.endswith("_TOKEN") for k in data.keys())
def test_reveal_env_var(self, tmp_path):
"""POST /api/env/reveal should return the real unredacted value."""
from hermes_cli.config import save_env_value
from hermes_cli.web_server import _SESSION_TOKEN
save_env_value("TEST_REVEAL_KEY", "super-secret-value-12345")
resp = self.client.post(
"/api/env/reveal",
json={"key": "TEST_REVEAL_KEY"},
headers={"Authorization": f"Bearer {_SESSION_TOKEN}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["key"] == "TEST_REVEAL_KEY"
assert data["value"] == "super-secret-value-12345"
def test_reveal_env_var_not_found(self):
"""POST /api/env/reveal should 404 for unknown keys."""
from hermes_cli.web_server import _SESSION_TOKEN
resp = self.client.post(
"/api/env/reveal",
json={"key": "NONEXISTENT_KEY_XYZ"},
headers={"Authorization": f"Bearer {_SESSION_TOKEN}"},
)
assert resp.status_code == 404
def test_reveal_env_var_no_token(self, tmp_path):
"""POST /api/env/reveal without token should return 401."""
from hermes_cli.config import save_env_value
save_env_value("TEST_REVEAL_NOAUTH", "secret-value")
resp = self.client.post(
"/api/env/reveal",
json={"key": "TEST_REVEAL_NOAUTH"},
)
assert resp.status_code == 401
def test_reveal_env_var_bad_token(self, tmp_path):
"""POST /api/env/reveal with wrong token should return 401."""
from hermes_cli.config import save_env_value
save_env_value("TEST_REVEAL_BADAUTH", "secret-value")
resp = self.client.post(
"/api/env/reveal",
json={"key": "TEST_REVEAL_BADAUTH"},
headers={"Authorization": "Bearer wrong-token-here"},
)
assert resp.status_code == 401
def test_session_token_endpoint(self):
"""GET /api/auth/session-token should return a token."""
from hermes_cli.web_server import _SESSION_TOKEN
resp = self.client.get("/api/auth/session-token")
assert resp.status_code == 200
assert resp.json()["token"] == _SESSION_TOKEN
def test_path_traversal_blocked(self):
"""Verify URL-encoded path traversal is blocked."""
# %2e%2e = ..
resp = self.client.get("/%2e%2e/%2e%2e/etc/passwd")
# Should return 200 with index.html (SPA fallback), not the actual file
assert resp.status_code in (200, 404)
if resp.status_code == 200:
# Should be the SPA fallback, not the system file
assert "root:" not in resp.text
def test_path_traversal_dotdot_blocked(self):
"""Direct .. path traversal via encoded sequences."""
resp = self.client.get("/%2e%2e/hermes_cli/web_server.py")
assert resp.status_code in (200, 404)
if resp.status_code == 200:
assert "FastAPI" not in resp.text # Should not serve the actual source
# ---------------------------------------------------------------------------
# _build_schema_from_config tests
# ---------------------------------------------------------------------------
class TestBuildSchemaFromConfig:
def test_produces_expected_field_count(self):
from hermes_cli.web_server import CONFIG_SCHEMA
# DEFAULT_CONFIG has ~150+ leaf fields
assert len(CONFIG_SCHEMA) > 100
def test_schema_entries_have_required_fields(self):
from hermes_cli.web_server import CONFIG_SCHEMA
for key, entry in list(CONFIG_SCHEMA.items())[:10]:
assert "type" in entry, f"Missing type for {key}"
assert "category" in entry, f"Missing category for {key}"
def test_overrides_applied(self):
from hermes_cli.web_server import CONFIG_SCHEMA
# terminal.backend should be a select with options
if "terminal.backend" in CONFIG_SCHEMA:
entry = CONFIG_SCHEMA["terminal.backend"]
assert entry["type"] == "select"
assert "options" in entry
assert "local" in entry["options"]
def test_empty_prefix_produces_correct_keys(self):
from hermes_cli.web_server import _build_schema_from_config
test_config = {"model": "test", "nested": {"key": "val"}}
schema = _build_schema_from_config(test_config)
assert "model" in schema
assert "nested.key" in schema
def test_top_level_scalars_get_general_category(self):
"""Top-level scalar fields should be in 'general' category."""
from hermes_cli.web_server import CONFIG_SCHEMA
assert CONFIG_SCHEMA["model"]["category"] == "general"
def test_nested_keys_get_parent_category(self):
"""Nested fields should use the top-level parent as their category."""
from hermes_cli.web_server import CONFIG_SCHEMA
if "agent.max_turns" in CONFIG_SCHEMA:
assert CONFIG_SCHEMA["agent.max_turns"]["category"] == "agent"
def test_category_merge_applied(self):
"""Small categories should be merged into larger ones."""
from hermes_cli.web_server import CONFIG_SCHEMA
categories = {e["category"] for e in CONFIG_SCHEMA.values()}
# These should be merged away
assert "privacy" not in categories # merged into security
assert "context" not in categories # merged into agent
def test_no_single_field_categories(self):
"""After merging, no category should have just 1 field."""
from hermes_cli.web_server import CONFIG_SCHEMA
from collections import Counter
cats = Counter(e["category"] for e in CONFIG_SCHEMA.values())
for cat, count in cats.items():
assert count >= 2, f"Category '{cat}' has only {count} field(s) — should be merged"
# ---------------------------------------------------------------------------
# Config round-trip tests
# ---------------------------------------------------------------------------
class TestConfigRoundTrip:
"""Verify config survives GET → edit → PUT without data loss."""
@pytest.fixture(autouse=True)
def _setup(self):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app
self.client = TestClient(app)
def test_get_config_no_internal_keys(self):
"""GET /api/config should not expose _config_version or _model_meta."""
config = self.client.get("/api/config").json()
internal = [k for k in config if k.startswith("_")]
assert not internal, f"Internal keys leaked to frontend: {internal}"
def test_get_config_model_is_string(self):
"""GET /api/config should normalize model dict to a string."""
config = self.client.get("/api/config").json()
assert isinstance(config.get("model"), str), \
f"model should be string, got {type(config.get('model'))}"
def test_round_trip_preserves_model_subkeys(self):
"""Save and reload should not lose model.provider, model.base_url, etc."""
from hermes_cli.config import load_config, save_config
# Set up a config with model as a dict (the common user config form)
save_config({
"model": {
"default": "anthropic/claude-sonnet-4",
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
"api_mode": "openai",
}
})
before = load_config()
assert isinstance(before.get("model"), dict)
original_keys = set(before["model"].keys())
# GET → PUT unchanged
web_config = self.client.get("/api/config").json()
assert isinstance(web_config.get("model"), str), "GET should normalize model to string"
self.client.put("/api/config", json={"config": web_config})
after = load_config()
assert isinstance(after.get("model"), dict), "model should still be a dict after save"
assert set(after["model"].keys()) >= original_keys, \
f"Lost model subkeys: {original_keys - set(after['model'].keys())}"
def test_edit_model_name_preserved(self):
"""Changing the model string should update model.default on disk."""
from hermes_cli.config import load_config
web_config = self.client.get("/api/config").json()
original_model = web_config["model"]
# Change model
web_config["model"] = "test/editing-model"
self.client.put("/api/config", json={"config": web_config})
after = load_config()
if isinstance(after.get("model"), dict):
assert after["model"]["default"] == "test/editing-model"
else:
assert after["model"] == "test/editing-model"
# Restore
web_config["model"] = original_model
self.client.put("/api/config", json={"config": web_config})
def test_edit_nested_value(self):
"""Editing a nested config value should persist correctly."""
from hermes_cli.config import load_config
web_config = self.client.get("/api/config").json()
original_turns = web_config.get("agent", {}).get("max_turns")
# Change max_turns
if "agent" not in web_config:
web_config["agent"] = {}
web_config["agent"]["max_turns"] = 42
self.client.put("/api/config", json={"config": web_config})
after = load_config()
assert after.get("agent", {}).get("max_turns") == 42
# Restore
web_config["agent"]["max_turns"] = original_turns
self.client.put("/api/config", json={"config": web_config})
def test_schema_types_match_config_values(self):
"""Every schema field should have a matching-type value in the config."""
config = self.client.get("/api/config").json()
schema_resp = self.client.get("/api/config/schema").json()
schema = schema_resp["fields"]
def get_nested(obj, path):
parts = path.split(".")
cur = obj
for p in parts:
if cur is None or not isinstance(cur, dict):
return None
cur = cur.get(p)
return cur
mismatches = []
for key, entry in schema.items():
val = get_nested(config, key)
if val is None:
continue # not set in user config — fine
expected = entry["type"]
if expected in ("string", "select") and not isinstance(val, str):
mismatches.append(f"{key}: expected str, got {type(val).__name__}")
elif expected == "number" and not isinstance(val, (int, float)):
mismatches.append(f"{key}: expected number, got {type(val).__name__}")
elif expected == "boolean" and not isinstance(val, bool):
mismatches.append(f"{key}: expected bool, got {type(val).__name__}")
elif expected == "list" and not isinstance(val, list):
mismatches.append(f"{key}: expected list, got {type(val).__name__}")
assert not mismatches, f"Type mismatches:\n" + "\n".join(mismatches)
# ---------------------------------------------------------------------------
# New feature endpoint tests
# ---------------------------------------------------------------------------
class TestNewEndpoints:
"""Tests for session detail, logs, cron, skills, tools, raw config, analytics."""
@pytest.fixture(autouse=True)
def _setup(self):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app
self.client = TestClient(app)
def test_get_logs_default(self):
resp = self.client.get("/api/logs")
assert resp.status_code == 200
data = resp.json()
assert "file" in data
assert "lines" in data
assert isinstance(data["lines"], list)
def test_get_logs_invalid_file(self):
resp = self.client.get("/api/logs?file=nonexistent")
assert resp.status_code == 400
def test_cron_list(self):
resp = self.client.get("/api/cron/jobs")
assert resp.status_code == 200
assert isinstance(resp.json(), list)
def test_cron_job_not_found(self):
resp = self.client.get("/api/cron/jobs/nonexistent-id")
assert resp.status_code == 404
def test_skills_list(self):
resp = self.client.get("/api/skills")
assert resp.status_code == 200
skills = resp.json()
assert isinstance(skills, list)
if skills:
assert "name" in skills[0]
assert "enabled" in skills[0]
def test_skills_list_includes_disabled_skills(self, monkeypatch):
import tools.skills_tool as skills_tool
import hermes_cli.skills_config as skills_config
import hermes_cli.web_server as web_server
def _fake_find_all_skills(*, skip_disabled=False):
if skip_disabled:
return [
{"name": "active-skill", "description": "active", "category": "demo"},
{"name": "disabled-skill", "description": "disabled", "category": "demo"},
]
return [
{"name": "active-skill", "description": "active", "category": "demo"},
]
monkeypatch.setattr(skills_tool, "_find_all_skills", _fake_find_all_skills)
monkeypatch.setattr(skills_config, "get_disabled_skills", lambda config: {"disabled-skill"})
monkeypatch.setattr(web_server, "load_config", lambda: {"skills": {"disabled": ["disabled-skill"]}})
resp = self.client.get("/api/skills")
assert resp.status_code == 200
assert resp.json() == [
{
"name": "active-skill",
"description": "active",
"category": "demo",
"enabled": True,
},
{
"name": "disabled-skill",
"description": "disabled",
"category": "demo",
"enabled": False,
},
]
def test_toolsets_list(self):
resp = self.client.get("/api/tools/toolsets")
assert resp.status_code == 200
toolsets = resp.json()
assert isinstance(toolsets, list)
if toolsets:
assert "name" in toolsets[0]
assert "label" in toolsets[0]
assert "enabled" in toolsets[0]
def test_toolsets_list_matches_cli_enabled_state(self, monkeypatch):
import hermes_cli.tools_config as tools_config
import toolsets as toolsets_module
import hermes_cli.web_server as web_server
monkeypatch.setattr(
tools_config,
"_get_effective_configurable_toolsets",
lambda: [
("web", "🔍 Web Search & Scraping", "web_search, web_extract"),
("skills", "📚 Skills", "list, view, manage"),
("memory", "💾 Memory", "persistent memory across sessions"),
],
)
monkeypatch.setattr(
tools_config,
"_get_platform_tools",
lambda config, platform, include_default_mcp_servers=False: {"web", "skills"},
)
monkeypatch.setattr(
tools_config,
"_toolset_has_keys",
lambda ts_key, config=None: ts_key != "web",
)
monkeypatch.setattr(
toolsets_module,
"resolve_toolset",
lambda name: {
"web": ["web_search", "web_extract"],
"skills": ["skills_list", "skill_view"],
"memory": ["memory_read"],
}[name],
)
monkeypatch.setattr(web_server, "load_config", lambda: {"platform_toolsets": {"cli": ["web", "skills"]}})
resp = self.client.get("/api/tools/toolsets")
assert resp.status_code == 200
assert resp.json() == [
{
"name": "web",
"label": "🔍 Web Search & Scraping",
"description": "web_search, web_extract",
"enabled": True,
"available": True,
"configured": False,
"tools": ["web_extract", "web_search"],
},
{
"name": "skills",
"label": "📚 Skills",
"description": "list, view, manage",
"enabled": True,
"available": True,
"configured": True,
"tools": ["skill_view", "skills_list"],
},
{
"name": "memory",
"label": "💾 Memory",
"description": "persistent memory across sessions",
"enabled": False,
"available": False,
"configured": True,
"tools": ["memory_read"],
},
]
def test_config_raw_get(self):
resp = self.client.get("/api/config/raw")
assert resp.status_code == 200
assert "yaml" in resp.json()
def test_config_raw_put_valid(self):
resp = self.client.put(
"/api/config/raw",
json={"yaml_text": "model: test\ntoolsets:\n - all\n"},
)
assert resp.status_code == 200
assert resp.json()["ok"] is True
def test_config_raw_put_invalid(self):
resp = self.client.put(
"/api/config/raw",
json={"yaml_text": "- this is a list not a dict"},
)
assert resp.status_code == 400
def test_analytics_usage(self):
resp = self.client.get("/api/analytics/usage?days=7")
assert resp.status_code == 200
data = resp.json()
assert "daily" in data
assert "by_model" in data
assert "totals" in data
assert isinstance(data["daily"], list)
assert "total_sessions" in data["totals"]
def test_session_token_endpoint(self):
from hermes_cli.web_server import _SESSION_TOKEN
resp = self.client.get("/api/auth/session-token")
assert resp.status_code == 200
assert resp.json()["token"] == _SESSION_TOKEN

48
web/README.md Normal file
View file

@ -0,0 +1,48 @@
# Hermes Agent — Web UI
Browser-based dashboard for managing Hermes Agent configuration, API keys, and monitoring active sessions.
## Stack
- **Vite** + **React 19** + **TypeScript**
- **Tailwind CSS v4** with custom dark theme
- **shadcn/ui**-style components (hand-rolled, no CLI dependency)
## Development
```bash
# Start the backend API server
cd ../
python -m hermes_cli.main web --no-open
# In another terminal, start the Vite dev server (with HMR + API proxy)
cd web/
npm run dev
```
The Vite dev server proxies `/api` requests to `http://127.0.0.1:9119` (the FastAPI backend).
## Build
```bash
npm run build
```
This outputs to `../hermes_cli/web_dist/`, which the FastAPI server serves as a static SPA. The built assets are included in the Python package via `pyproject.toml` package-data.
## Structure
```
src/
├── components/ui/ # Reusable UI primitives (Card, Badge, Button, Input, etc.)
├── lib/
│ ├── api.ts # API client — typed fetch wrappers for all backend endpoints
│ └── utils.ts # cn() helper for Tailwind class merging
├── pages/
│ ├── StatusPage # Agent status, active/recent sessions
│ ├── ConfigPage # Dynamic config editor (reads schema from backend)
│ └── EnvPage # API key management with save/clear
├── App.tsx # Main layout and navigation
├── main.tsx # React entry point
└── index.css # Tailwind imports and theme variables
```

23
web/eslint.config.js Normal file
View file

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
web/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermes Agent</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3835
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

36
web/package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
}
}

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

117
web/src/App.tsx Normal file
View file

@ -0,0 +1,117 @@
import { useState, useEffect } from "react";
import { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react";
import StatusPage from "@/pages/StatusPage";
import ConfigPage from "@/pages/ConfigPage";
import EnvPage from "@/pages/EnvPage";
import SessionsPage from "@/pages/SessionsPage";
import LogsPage from "@/pages/LogsPage";
import AnalyticsPage from "@/pages/AnalyticsPage";
import CronPage from "@/pages/CronPage";
import SkillsPage from "@/pages/SkillsPage";
const NAV_ITEMS = [
{ id: "status", label: "Status", icon: Activity },
{ id: "sessions", label: "Sessions", icon: MessageSquare },
{ id: "analytics", label: "Analytics", icon: BarChart3 },
{ id: "logs", label: "Logs", icon: FileText },
{ id: "cron", label: "Cron", icon: Clock },
{ id: "skills", label: "Skills", icon: Package },
{ id: "config", label: "Config", icon: Settings },
{ id: "env", label: "Keys", icon: KeyRound },
] as const;
type PageId = (typeof NAV_ITEMS)[number]["id"];
const PAGE_COMPONENTS: Record<PageId, React.FC> = {
status: StatusPage,
sessions: SessionsPage,
analytics: AnalyticsPage,
logs: LogsPage,
cron: CronPage,
skills: SkillsPage,
config: ConfigPage,
env: EnvPage,
};
export default function App() {
const [page, setPage] = useState<PageId>("status");
const [animKey, setAnimKey] = useState(0);
useEffect(() => {
setAnimKey((k) => k + 1);
}, [page]);
const PageComponent = PAGE_COMPONENTS[page];
return (
<div className="flex min-h-screen flex-col bg-background text-foreground">
{/* Global grain + warm glow (matches landing page) */}
<div className="noise-overlay" />
<div className="warm-glow" />
{/* ---- Header with grid-border nav ---- */}
<header className="sticky top-0 z-40 border-b border-border bg-background/90 backdrop-blur-sm">
<div className="mx-auto flex h-12 max-w-[1400px] items-stretch">
{/* Brand */}
<div className="flex items-center border-r border-border px-5 shrink-0">
<span className="font-collapse text-xl font-bold tracking-wider uppercase blend-lighter">
Hermes<br className="hidden sm:inline" /><span className="sm:hidden"> </span>Agent
</span>
</div>
{/* Nav grid — Mondwest labels like the landing page nav */}
<nav className="flex items-stretch overflow-x-auto scrollbar-none">
{NAV_ITEMS.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => setPage(id)}
className={`group relative inline-flex items-center gap-1.5 border-r border-border px-4 py-2 font-display text-[0.8rem] tracking-[0.12em] uppercase whitespace-nowrap transition-colors cursor-pointer shrink-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
page === id
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Icon className="h-3.5 w-3.5" />
{label}
{/* Hover highlight */}
<span className="absolute inset-0 bg-foreground pointer-events-none transition-opacity duration-150 group-hover:opacity-5 opacity-0" />
{/* Active indicator — dither bar */}
{page === id && (
<span className="absolute bottom-0 left-0 right-0 h-px bg-foreground" />
)}
</button>
))}
</nav>
{/* Version badge */}
<div className="ml-auto flex items-center px-4 text-muted-foreground">
<span className="font-display text-[0.7rem] tracking-[0.15em] uppercase opacity-50">
Web UI
</span>
</div>
</div>
</header>
<main
key={animKey}
className="relative z-2 mx-auto w-full max-w-[1400px] flex-1 px-6 py-8"
style={{ animation: "fade-in 150ms ease-out" }}
>
<PageComponent />
</main>
{/* ---- Footer ---- */}
<footer className="relative z-2 border-t border-border">
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-3">
<span className="font-display text-[0.8rem] tracking-[0.12em] uppercase opacity-50">
Hermes Agent
</span>
<span className="font-display text-[0.7rem] tracking-[0.15em] uppercase text-foreground/40">
Nous Research
</span>
</div>
</footer>
</div>
);
}

View file

@ -0,0 +1,151 @@
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
function FieldHint({ schema, schemaKey }: { schema: Record<string, unknown>; schemaKey: string }) {
const keyPath = schemaKey.includes(".") ? schemaKey : "";
const description = schema.description ? String(schema.description) : "";
if (!keyPath && !description) return null;
return (
<div className="flex flex-col gap-0.5">
{keyPath && <span className="text-[10px] font-mono text-muted-foreground/50">{keyPath}</span>}
{description && <span className="text-xs text-muted-foreground/70">{description}</span>}
</div>
);
}
export function AutoField({
schemaKey,
schema,
value,
onChange,
}: AutoFieldProps) {
const rawLabel = schemaKey.split(".").pop() ?? schemaKey;
const label = rawLabel.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
if (schema.type === "boolean") {
return (
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-0.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
</div>
<Switch checked={!!value} onCheckedChange={onChange} />
</div>
);
}
if (schema.type === "select") {
const options = (schema.options as string[]) ?? [];
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Select value={String(value ?? "")} onChange={(e) => onChange(e.target.value)}>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt || "(none)"}
</option>
))}
</Select>
</div>
);
}
if (schema.type === "number") {
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Input
type="number"
value={value === undefined || value === null ? "" : String(value)}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
onChange(0);
return;
}
const n = Number(raw);
if (!Number.isNaN(n)) {
onChange(n);
}
}}
/>
</div>
);
}
if (schema.type === "text") {
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
if (schema.type === "list") {
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Input
value={Array.isArray(value) ? value.join(", ") : String(value ?? "")}
onChange={(e) =>
onChange(
e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
)
}
placeholder="comma-separated values"
/>
</div>
);
}
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
return (
<div className="grid gap-3 rounded-lg border border-border p-3">
<Label className="text-xs font-medium">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
{Object.entries(obj).map(([subKey, subVal]) => (
<div key={subKey} className="grid gap-1">
<Label className="text-xs text-muted-foreground">{subKey}</Label>
<Input
value={String(subVal ?? "")}
onChange={(e) => onChange({ ...obj, [subKey]: e.target.value })}
className="text-xs"
/>
</div>
))}
</div>
);
}
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Input value={String(value ?? "")} onChange={(e) => onChange(e.target.value)} />
</div>
);
}
interface AutoFieldProps {
schemaKey: string;
schema: Record<string, unknown>;
value: unknown;
onChange: (v: unknown) => void;
}

View file

@ -0,0 +1,279 @@
import { useMemo } from "react";
/**
* Lightweight markdown renderer for LLM output.
* Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules.
* NOT a full CommonMark parser optimized for typical assistant message patterns.
*/
export function Markdown({ content, highlightTerms }: { content: string; highlightTerms?: string[] }) {
const blocks = useMemo(() => parseBlocks(content), [content]);
return (
<div className="text-sm text-foreground leading-relaxed space-y-2">
{blocks.map((block, i) => (
<Block key={i} block={block} highlightTerms={highlightTerms} />
))}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type BlockNode =
| { type: "code"; lang: string; content: string }
| { type: "heading"; level: number; content: string }
| { type: "hr" }
| { type: "list"; ordered: boolean; items: string[] }
| { type: "paragraph"; content: string };
/* ------------------------------------------------------------------ */
/* Block parser */
/* ------------------------------------------------------------------ */
function parseBlocks(text: string): BlockNode[] {
const lines = text.split("\n");
const blocks: BlockNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Fenced code block
const fenceMatch = line.match(/^```(\w*)/);
if (fenceMatch) {
const lang = fenceMatch[1] || "";
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
i++; // skip closing ```
blocks.push({ type: "code", lang, content: codeLines.join("\n") });
continue;
}
// Heading
const headingMatch = line.match(/^(#{1,4})\s+(.+)/);
if (headingMatch) {
blocks.push({ type: "heading", level: headingMatch[1].length, content: headingMatch[2] });
i++;
continue;
}
// Horizontal rule
if (/^[-*_]{3,}\s*$/.test(line)) {
blocks.push({ type: "hr" });
i++;
continue;
}
// Unordered list
if (/^[-*+]\s/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^[-*+]\s/.test(lines[i])) {
items.push(lines[i].replace(/^[-*+]\s/, ""));
i++;
}
blocks.push({ type: "list", ordered: false, items });
continue;
}
// Ordered list
if (/^\d+[.)]\s/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\d+[.)]\s/.test(lines[i])) {
items.push(lines[i].replace(/^\d+[.)]\s/, ""));
i++;
}
blocks.push({ type: "list", ordered: true, items });
continue;
}
// Empty line
if (line.trim() === "") {
i++;
continue;
}
// Paragraph — collect consecutive non-empty, non-special lines
const paraLines: string[] = [];
while (
i < lines.length &&
lines[i].trim() !== "" &&
!lines[i].match(/^```/) &&
!lines[i].match(/^#{1,4}\s/) &&
!lines[i].match(/^[-*+]\s/) &&
!lines[i].match(/^\d+[.)]\s/) &&
!lines[i].match(/^[-*_]{3,}\s*$/)
) {
paraLines.push(lines[i]);
i++;
}
if (paraLines.length > 0) {
blocks.push({ type: "paragraph", content: paraLines.join("\n") });
}
}
return blocks;
}
/* ------------------------------------------------------------------ */
/* Block renderer */
/* ------------------------------------------------------------------ */
function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: string[] }) {
switch (block.type) {
case "code":
return (
<pre className="rounded-md bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto">
<code>{block.content}</code>
</pre>
);
case "heading": {
const Tag = `h${Math.min(block.level, 4)}` as "h1" | "h2" | "h3" | "h4";
const sizes: Record<string, string> = {
h1: "text-base font-bold",
h2: "text-sm font-bold",
h3: "text-sm font-semibold",
h4: "text-sm font-medium",
};
return <Tag className={sizes[Tag]}><InlineContent text={block.content} highlightTerms={highlightTerms} /></Tag>;
}
case "hr":
return <hr className="border-border" />;
case "list": {
const Tag = block.ordered ? "ol" : "ul";
return (
<Tag className={`space-y-0.5 ${block.ordered ? "list-decimal" : "list-disc"} pl-5 text-sm`}>
{block.items.map((item, i) => (
<li key={i}><InlineContent text={item} highlightTerms={highlightTerms} /></li>
))}
</Tag>
);
}
case "paragraph":
return <p><InlineContent text={block.content} highlightTerms={highlightTerms} /></p>;
}
}
/* ------------------------------------------------------------------ */
/* Inline parser + renderer */
/* ------------------------------------------------------------------ */
type InlineNode =
| { type: "text"; content: string }
| { type: "code"; content: string }
| { type: "bold"; content: string }
| { type: "italic"; content: string }
| { type: "link"; text: string; href: string }
| { type: "br" };
function parseInline(text: string): InlineNode[] {
const nodes: InlineNode[] = [];
// Pattern priority: code > link > bold > italic > bare URL > line break
const pattern = /(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push({ type: "text", content: text.slice(lastIndex, match.index) });
}
if (match[1]) {
// Inline code
nodes.push({ type: "code", content: match[1].slice(1, -1) });
} else if (match[2]) {
// [text](url) link
nodes.push({ type: "link", text: match[3], href: match[4] });
} else if (match[5]) {
// **bold**
nodes.push({ type: "bold", content: match[6] });
} else if (match[7]) {
// *italic*
nodes.push({ type: "italic", content: match[8] });
} else if (match[9]) {
// Bare URL
nodes.push({ type: "link", text: match[9], href: match[9] });
} else if (match[10]) {
// Line break within paragraph
nodes.push({ type: "br" });
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
nodes.push({ type: "text", content: text.slice(lastIndex) });
}
return nodes;
}
function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?: string[] }) {
const nodes = useMemo(() => parseInline(text), [text]);
return (
<>
{nodes.map((node, i) => {
switch (node.type) {
case "text":
return <HighlightedText key={i} text={node.content} terms={highlightTerms} />;
case "code":
return (
<code key={i} className="rounded bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90">
{node.content}
</code>
);
case "bold":
return <strong key={i} className="font-semibold"><HighlightedText text={node.content} terms={highlightTerms} /></strong>;
case "italic":
return <em key={i}><HighlightedText text={node.content} terms={highlightTerms} /></em>;
case "link":
return (
<a
key={i}
href={node.href}
target="_blank"
rel="noreferrer"
className="text-primary underline underline-offset-2 decoration-primary/30 hover:decoration-primary/60 transition-colors"
>
{node.text}
</a>
);
case "br":
return <br key={i} />;
}
})}
</>
);
}
/** Highlight search terms within a plain text string. */
function HighlightedText({ text, terms }: { text: string; terms?: string[] }) {
if (!terms || terms.length === 0) return <>{text}</>;
// Build a regex that matches any of the search terms (case-insensitive)
const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const regex = new RegExp(`(${escaped.join("|")})`, "gi");
const parts = text.split(regex);
return (
<>
{parts.map((part, i) =>
regex.test(part) ? (
<mark key={i} className="bg-warning/30 text-warning rounded-sm px-0.5">{part}</mark>
) : (
<span key={i}>{part}</span>
)
)}
</>
);
}

View file

@ -0,0 +1,36 @@
import { useEffect, useState } from "react";
export function Toast({ toast }: { toast: { message: string; type: "success" | "error" } | null }) {
const [visible, setVisible] = useState(false);
const [current, setCurrent] = useState(toast);
useEffect(() => {
if (toast) {
setCurrent(toast);
setVisible(true);
} else {
setVisible(false);
const timer = setTimeout(() => setCurrent(null), 200);
return () => clearTimeout(timer);
}
}, [toast]);
if (!current) return null;
return (
<div
role="status"
aria-live="polite"
className={`fixed top-4 right-4 z-50 border px-4 py-2.5 font-courier text-xs tracking-wider uppercase backdrop-blur-sm ${
current.type === "success"
? "bg-success/15 text-success border-success/30"
: "bg-destructive/15 text-destructive border-destructive/30"
}`}
style={{
animation: visible ? "toast-in 200ms ease-out forwards" : "toast-out 200ms ease-in forwards",
}}
>
{current.message}
</div>
);
}

View file

@ -0,0 +1,29 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center border px-2 py-0.5 font-compressed text-[0.65rem] tracking-[0.15em] uppercase transition-colors",
{
variants: {
variant: {
default: "border-foreground/20 bg-foreground/10 text-foreground",
secondary: "border-border bg-secondary text-secondary-foreground",
destructive: "border-destructive/30 bg-destructive/15 text-destructive",
outline: "border-border text-muted-foreground",
success: "grain border-emerald-600/30 bg-emerald-950/70 text-emerald-400",
warning: "border-warning/30 bg-warning/15 text-warning",
},
},
defaultVariants: {
variant: "default",
},
},
);
export function Badge({
className,
variant,
...props
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}

View file

@ -0,0 +1,38 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-display text-xs tracking-[0.1em] uppercase transition-colors cursor-pointer"
+ " disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-foreground/90 text-background hover:bg-foreground",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-border bg-transparent hover:bg-foreground/10 hover:text-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-foreground/10 hover:text-foreground",
link: "text-foreground underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3 text-[0.65rem]",
lg: "h-10 px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export function Button({
className,
variant,
size,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}

View file

@ -0,0 +1,29 @@
import { cn } from "@/lib/utils";
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"border border-border bg-card/80 text-card-foreground",
className,
)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col gap-1.5 p-4 border-b border-border", className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn("font-expanded text-sm font-bold tracking-[0.08em] uppercase blend-lighter", className)} {...props} />;
}
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("font-display text-xs text-muted-foreground", className)} {...props} />;
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-4", className)} {...props} />;
}

View file

@ -0,0 +1,16 @@
import { cn } from "@/lib/utils";
export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={cn(
"flex h-9 w-full border border-border bg-background/40 px-3 py-1 font-courier text-sm transition-colors",
"placeholder:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}

View file

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
export function Label({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) {
return (
<label
className={cn(
"font-display text-xs tracking-[0.1em] uppercase leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
);
}

View file

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils";
export function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select
className={cn(
"flex h-9 w-full border border-border bg-background/40 px-3 py-1 font-courier text-sm",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}

View file

@ -0,0 +1,19 @@
import { cn } from "@/lib/utils";
export function Separator({
className,
orientation = "horizontal",
...props
}: React.HTMLAttributes<HTMLDivElement> & { orientation?: "horizontal" | "vertical" }) {
return (
<div
role="separator"
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className,
)}
{...props}
/>
);
}

View file

@ -0,0 +1,37 @@
import { cn } from "@/lib/utils";
export function Switch({
checked,
onCheckedChange,
className,
disabled,
}: {
checked: boolean;
onCheckedChange: (v: boolean) => void;
className?: string;
disabled?: boolean;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border border-border transition-colors",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30",
"disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-foreground/15 border-foreground/30" : "bg-background",
className,
)}
onClick={() => onCheckedChange(!checked)}
>
<span
className={cn(
"pointer-events-none block h-3.5 w-3.5 transition-transform",
checked ? "translate-x-4 bg-foreground" : "translate-x-0.5 bg-muted-foreground",
)}
/>
</button>
);
}

View file

@ -0,0 +1,51 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
export function Tabs({
defaultValue,
children,
className,
}: {
defaultValue: string;
children: (active: string, setActive: (v: string) => void) => React.ReactNode;
className?: string;
}) {
const [active, setActive] = useState(defaultValue);
return <div className={cn("flex flex-col gap-4", className)}>{children(active, setActive)}</div>;
}
export function TabsList({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"inline-flex h-9 items-center justify-start border-b border-border text-muted-foreground",
className,
)}
{...props}
/>
);
}
export function TabsTrigger({
active,
value,
onClick,
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { active: boolean; value: string }) {
return (
<button
type="button"
className={cn(
"relative inline-flex items-center justify-center whitespace-nowrap px-3 py-1.5 font-display text-xs tracking-[0.1em] uppercase transition-all cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
active
? "text-foreground after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-foreground"
: "hover:text-foreground",
className,
)}
onClick={onClick}
{...props}
/>
);
}

15
web/src/hooks/useToast.ts Normal file
View file

@ -0,0 +1,15 @@
import { useCallback, useState } from "react";
export function useToast(duration = 3000) {
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const showToast = useCallback(
(message: string, type: "success" | "error") => {
setToast({ message, type });
setTimeout(() => setToast(null), duration);
},
[duration],
);
return { toast, showToast };
}

197
web/src/index.css Normal file
View file

@ -0,0 +1,197 @@
@import "tailwindcss";
/* ------------------------------------------------------------------ */
/* Hermes Agent — Design tokens */
/* Matched to hermes-agent.nousresearch.com (dark teal theme) */
/* ------------------------------------------------------------------ */
/* --- Font faces --- */
@font-face { font-family: "Collapse"; src: url("/fonts/Collapse-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
@font-face { font-family: "Collapse"; src: url("/fonts/Collapse-Bold.woff2") format("woff2"); font-weight: 700; font-display: swap; }
@font-face { font-family: "Courier Prime"; src: url("/fonts/CourierPrime-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
@font-face { font-family: "Courier Prime"; src: url("/fonts/CourierPrime-Bold.woff2") format("woff2"); font-weight: 700; font-display: swap; }
@font-face { font-family: "RulesCompressed"; src: url("/fonts/RulesCompressed-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
@font-face { font-family: "RulesCompressed"; src: url("/fonts/RulesCompressed-Medium.woff2") format("woff2"); font-weight: 600; font-display: swap; }
@font-face { font-family: "RulesExpanded"; src: url("/fonts/RulesExpanded-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
@font-face { font-family: "RulesExpanded"; src: url("/fonts/RulesExpanded-Bold.woff2") format("woff2"); font-weight: 700; font-display: swap; }
@font-face { font-family: "Mondwest"; src: url("/fonts/Mondwest-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
@theme {
/* ---- Hermes palette (dark teal, from live site) ---- */
--color-background: #041C1C;
--color-foreground: #ffe6cb;
--color-card: #062424;
--color-card-foreground: #ffe6cb;
--color-primary: #ffe6cb;
--color-primary-foreground: #041C1C;
--color-secondary: #0a2e2e;
--color-secondary-foreground: #ffe6cb;
--color-muted: #083030;
--color-muted-foreground: #8aaa9a;
--color-accent: #0c3838;
--color-accent-foreground: #ffe6cb;
--color-destructive: #fb2c36;
--color-destructive-foreground: #fff;
--color-success: #4ade80;
--color-warning: #ffbd38;
--color-border: color-mix(in srgb, #ffe6cb 15%, transparent);
--color-input: color-mix(in srgb, #ffe6cb 15%, transparent);
--color-ring: #ffe6cb;
--color-popover: #062424;
--color-popover-foreground: #ffe6cb;
/* ---- Font stacks ---- */
--font-sans: "Mondwest", Arial, sans-serif;
--font-mono: "Courier Prime", "Courier New", monospace;
--font-display: "Mondwest", Arial, sans-serif;
--font-expanded: "RulesExpanded", Arial, sans-serif;
--font-compressed: "RulesCompressed", Arial, sans-serif;
}
/* ---- Global body ---- */
body {
margin: 0;
font-family: "Mondwest", Arial, sans-serif;
background: var(--color-background);
color: var(--color-foreground);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* ---- Selection ---- */
::selection {
background: var(--color-foreground);
color: var(--color-background);
}
/* ---- Scrollbars (thin, subtle) ---- */
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
*:hover {
scrollbar-color: color-mix(in srgb, var(--color-foreground) 15%, transparent) transparent;
}
html, body {
scrollbar-color: color-mix(in srgb, var(--color-foreground) 25%, transparent) transparent;
}
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--color-foreground) 20%, transparent);
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--color-foreground) 35%, transparent);
}
/* ---- Hide scrollbar utility ---- */
.scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
/* ---- Code blocks ---- */
code {
font-family: "Courier Prime", "Courier New", monospace;
font-size: 0.85em;
padding: 0.15em 0.4em;
border-radius: 0;
background: color-mix(in srgb, var(--color-foreground) 8%, transparent);
}
/* ---- Dither texture ---- */
.dither {
background: repeating-conic-gradient(currentColor 0% 25%, #0000 0% 50%) 0 0 / 2px 2px;
}
/* ---- Blink cursor (only on group hover, like canonical) ---- */
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.blink {
display: none;
}
.group:hover .blink {
display: inline-block;
animation: blink 1s step-end infinite;
}
/* ---- Page transitions ---- */
@keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes toast-out {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(16px); }
}
/* ---- Plus-lighter blend for headings ---- */
.blend-lighter {
mix-blend-mode: plus-lighter;
}
/* ---- Font utilities ---- */
.font-display { font-family: "Mondwest", Arial, sans-serif; }
.font-expanded { font-family: "RulesExpanded", Arial, sans-serif; }
.font-compressed { font-family: "RulesCompressed", Arial, sans-serif; }
.font-courier { font-family: "Courier Prime", "Courier New", monospace; }
.font-collapse { font-family: "Collapse", Arial, sans-serif; }
.font-mono-ui { font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace; }
/* ---- Subtle grain overlay for badges ---- */
.grain {
position: relative;
}
.grain::after {
content: "";
position: absolute;
inset: 0;
opacity: 0.12;
pointer-events: none;
background: repeating-conic-gradient(currentColor 0% 25%, #0000 0% 50%) 0 0 / 2px 2px;
}
/* ---- Global noise grain (canonical: color-dodge, #eaeaea, high density) ---- */
.noise-overlay {
pointer-events: none;
position: fixed;
inset: 0;
z-index: 101;
mix-blend-mode: color-dodge;
opacity: 0.10;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' fill='%23eaeaea' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E");
background-size: 512px 512px;
}
/* ---- Vignette (canonical: top-left amber radial, lighten blend) ---- */
.warm-glow {
pointer-events: none;
position: fixed;
inset: 0;
z-index: 99;
mix-blend-mode: lighten;
opacity: 0.22;
background: radial-gradient(ellipse at 0% 0%, rgba(255,189,56,0.35) 0%, rgba(255,189,56,0) 60%);
}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

260
web/src/lib/api.ts Normal file
View file

@ -0,0 +1,260 @@
const BASE = "";
// Ephemeral session token for protected endpoints (reveal).
// Fetched once on first reveal request and cached in memory.
let _sessionToken: string | null = null;
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${url}`, init);
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`${res.status}: ${text}`);
}
return res.json();
}
async function getSessionToken(): Promise<string> {
if (_sessionToken) return _sessionToken;
const resp = await fetchJSON<{ token: string }>("/api/auth/session-token");
_sessionToken = resp.token;
return _sessionToken;
}
export const api = {
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
getSessions: () => fetchJSON<SessionInfo[]>("/api/sessions"),
getSessionMessages: (id: string) =>
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
deleteSession: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => {
const qs = new URLSearchParams();
if (params.file) qs.set("file", params.file);
if (params.lines) qs.set("lines", String(params.lines));
if (params.level && params.level !== "ALL") qs.set("level", params.level);
if (params.component && params.component !== "all") qs.set("component", params.component);
return fetchJSON<LogsResponse>(`/api/logs?${qs.toString()}`);
},
getAnalytics: (days: number) =>
fetchJSON<AnalyticsResponse>(`/api/analytics/usage?days=${days}`),
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
saveConfig: (config: Record<string, unknown>) =>
fetchJSON<{ ok: boolean }>("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ config }),
}),
getConfigRaw: () => fetchJSON<{ yaml: string }>("/api/config/raw"),
saveConfigRaw: (yaml_text: string) =>
fetchJSON<{ ok: boolean }>("/api/config/raw", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ yaml_text }),
}),
getEnvVars: () => fetchJSON<Record<string, EnvVarInfo>>("/api/env"),
setEnvVar: (key: string, value: string) =>
fetchJSON<{ ok: boolean }>("/api/env", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, value }),
}),
deleteEnvVar: (key: string) =>
fetchJSON<{ ok: boolean }>("/api/env", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
}),
revealEnvVar: async (key: string) => {
const token = await getSessionToken();
return fetchJSON<{ key: string; value: string }>("/api/env/reveal", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ key }),
});
},
// Cron jobs
getCronJobs: () => fetchJSON<CronJob[]>("/api/cron/jobs"),
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }) =>
fetchJSON<CronJob>("/api/cron/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(job),
}),
pauseCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/pause`, { method: "POST" }),
resumeCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/resume`, { method: "POST" }),
triggerCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/trigger`, { method: "POST" }),
deleteCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}`, { method: "DELETE" }),
// Skills & Toolsets
getSkills: () => fetchJSON<SkillInfo[]>("/api/skills"),
toggleSkill: (name: string, enabled: boolean) =>
fetchJSON<{ ok: boolean }>("/api/skills/toggle", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, enabled }),
}),
getToolsets: () => fetchJSON<ToolsetInfo[]>("/api/tools/toolsets"),
// Session search (FTS5)
searchSessions: (q: string) =>
fetchJSON<SessionSearchResponse>(`/api/sessions/search?q=${encodeURIComponent(q)}`),
};
export interface PlatformStatus {
error_code?: string;
error_message?: string;
state: string;
updated_at: string;
}
export interface StatusResponse {
active_sessions: number;
config_path: string;
config_version: number;
env_path: string;
gateway_exit_reason: string | null;
gateway_pid: number | null;
gateway_platforms: Record<string, PlatformStatus>;
gateway_running: boolean;
gateway_state: string | null;
gateway_updated_at: string | null;
hermes_home: string;
latest_config_version: number;
release_date: string;
version: string;
}
export interface SessionInfo {
id: string;
source: string | null;
model: string | null;
title: string | null;
started_at: number;
ended_at: number | null;
last_active: number;
is_active: boolean;
message_count: number;
tool_call_count: number;
input_tokens: number;
output_tokens: number;
preview: string | null;
}
export interface EnvVarInfo {
is_set: boolean;
redacted_value: string | null;
description: string;
url: string | null;
category: string;
is_password: boolean;
tools: string[];
advanced: boolean;
}
export interface SessionMessage {
role: "user" | "assistant" | "system" | "tool";
content: string | null;
tool_calls?: Array<{
id: string;
function: { name: string; arguments: string };
}>;
tool_name?: string;
tool_call_id?: string;
timestamp?: number;
}
export interface SessionMessagesResponse {
session_id: string;
messages: SessionMessage[];
}
export interface LogsResponse {
file: string;
lines: string[];
}
export interface AnalyticsDailyEntry {
day: string;
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
reasoning_tokens: number;
estimated_cost: number;
actual_cost: number;
sessions: number;
}
export interface AnalyticsModelEntry {
model: string;
input_tokens: number;
output_tokens: number;
estimated_cost: number;
sessions: number;
}
export interface AnalyticsResponse {
daily: AnalyticsDailyEntry[];
by_model: AnalyticsModelEntry[];
totals: {
total_input: number;
total_output: number;
total_cache_read: number;
total_reasoning: number;
total_estimated_cost: number;
total_actual_cost: number;
total_sessions: number;
};
}
export interface CronJob {
id: string;
name?: string;
prompt: string;
schedule: string;
status: "enabled" | "paused" | "error";
deliver?: string;
last_run_at?: string | null;
next_run_at?: string | null;
error?: string | null;
}
export interface SkillInfo {
name: string;
description: string;
category: string;
enabled: boolean;
}
export interface ToolsetInfo {
name: string;
label: string;
description: string;
enabled: boolean;
configured: boolean;
tools: string[];
}
export interface SessionSearchResult {
session_id: string;
snippet: string;
role: string | null;
source: string | null;
model: string | null;
session_started: number | null;
}
export interface SessionSearchResponse {
results: SessionSearchResult[];
}

23
web/src/lib/nested.ts Normal file
View file

@ -0,0 +1,23 @@
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
const parts = path.split(".");
let cur: unknown = obj;
for (const p of parts) {
if (cur == null || typeof cur !== "object") return undefined;
cur = (cur as Record<string, unknown>)[p];
}
return cur;
}
export function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
const clone = structuredClone(obj);
const parts = path.split(".");
let cur: Record<string, unknown> = clone;
for (let i = 0; i < parts.length - 1; i++) {
if (cur[parts[i]] == null || typeof cur[parts[i]] !== "object") {
cur[parts[i]] = {};
}
cur = cur[parts[i]] as Record<string, unknown>;
}
cur[parts[parts.length - 1]] = value;
return clone;
}

26
web/src/lib/utils.ts Normal file
View file

@ -0,0 +1,26 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Relative time from a Unix epoch timestamp (seconds). */
export function timeAgo(ts: number): string {
const delta = Date.now() / 1000 - ts;
if (delta < 60) return "just now";
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
if (delta < 172800) return "yesterday";
return `${Math.floor(delta / 86400)}d ago`;
}
/** Relative time from an ISO-8601 timestamp string. */
export function isoTimeAgo(iso: string): string {
const delta = (Date.now() - new Date(iso).getTime()) / 1000;
if (delta < 0 || Number.isNaN(delta)) return "unknown";
if (delta < 60) return "just now";
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
return `${Math.floor(delta / 86400)}d ago`;
}

10
web/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View file

@ -0,0 +1,370 @@
import { useEffect, useState, useCallback } from "react";
import {
BarChart3,
Coins,
Cpu,
Database,
Hash,
TrendingUp,
} from "lucide-react";
import { api } from "@/lib/api";
import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
const PERIODS = [
{ label: "7d", days: 7 },
{ label: "30d", days: 30 },
{ label: "90d", days: 90 },
] as const;
const CHART_HEIGHT_PX = 160;
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
function formatCost(n: number): string {
if (n < 0.01) return `$${n.toFixed(4)}`;
return `$${n.toFixed(2)}`;
}
/** Pick the best cost value: actual > estimated > 0 */
function bestCost(entry: { estimated_cost: number; actual_cost?: number }): number {
if (entry.actual_cost && entry.actual_cost > 0) return entry.actual_cost;
return entry.estimated_cost;
}
function formatDate(day: string): string {
try {
const d = new Date(day + "T00:00:00");
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
} catch {
return day;
}
}
function SummaryCard({
icon: Icon,
label,
value,
sub,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: string;
sub?: string;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{label}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
</CardContent>
</Card>
);
}
function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
if (daily.length === 0) return null;
const maxTokens = Math.max(...daily.map((d) => d.input_tokens + d.output_tokens), 1);
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Daily Token Usage</CardTitle>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 rounded-sm bg-[#ffe6cb]" />
Input
</div>
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 rounded-sm bg-emerald-500" />
Output
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-end gap-[2px]" style={{ height: CHART_HEIGHT_PX }}>
{daily.map((d) => {
const total = d.input_tokens + d.output_tokens;
const inputH = Math.round((d.input_tokens / maxTokens) * CHART_HEIGHT_PX);
const outputH = Math.round((d.output_tokens / maxTokens) * CHART_HEIGHT_PX);
const cacheReadPct = d.cache_read_tokens > 0
? Math.round((d.cache_read_tokens / (d.input_tokens + d.cache_read_tokens)) * 100)
: 0;
return (
<div
key={d.day}
className="flex-1 min-w-0 group relative flex flex-col justify-end"
style={{ height: CHART_HEIGHT_PX }}
>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
<div className="rounded-md bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
<div className="font-medium">{formatDate(d.day)}</div>
<div>Input: {formatTokens(d.input_tokens)}</div>
<div>Output: {formatTokens(d.output_tokens)}</div>
{cacheReadPct > 0 && <div>Cache hit: {cacheReadPct}%</div>}
<div>Total: {formatTokens(total)}</div>
{bestCost(d) > 0 && <div>Cost: {formatCost(bestCost(d))}</div>}
</div>
</div>
{/* Input bar */}
<div
className="w-full bg-[#ffe6cb]/70"
style={{ height: Math.max(inputH, total > 0 ? 1 : 0) }}
/>
{/* Output bar */}
<div
className="w-full bg-emerald-500/70"
style={{ height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0) }}
/>
</div>
);
})}
</div>
{/* X-axis labels */}
<div className="flex justify-between mt-2 text-[10px] text-muted-foreground">
<span>{daily.length > 0 ? formatDate(daily[0].day) : ""}</span>
{daily.length > 2 && (
<span>{formatDate(daily[Math.floor(daily.length / 2)].day)}</span>
)}
<span>{daily.length > 1 ? formatDate(daily[daily.length - 1].day) : ""}</span>
</div>
</CardContent>
</Card>
);
}
function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
if (daily.length === 0) return null;
const sorted = [...daily].reverse();
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Daily Breakdown</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<th className="text-left py-2 pr-4 font-medium">Date</th>
<th className="text-right py-2 px-4 font-medium">Sessions</th>
<th className="text-right py-2 px-4 font-medium">Input</th>
<th className="text-right py-2 px-4 font-medium">Output</th>
<th className="text-right py-2 px-4 font-medium">Cache Hit</th>
<th className="text-right py-2 pl-4 font-medium">Cost</th>
</tr>
</thead>
<tbody>
{sorted.map((d) => {
const cost = bestCost(d);
const cacheHitPct = d.cache_read_tokens > 0 && d.input_tokens > 0
? Math.round((d.cache_read_tokens / d.input_tokens) * 100)
: 0;
return (
<tr key={d.day} className="border-b border-border/50 hover:bg-secondary/20 transition-colors">
<td className="py-2 pr-4 font-medium">{formatDate(d.day)}</td>
<td className="text-right py-2 px-4 text-muted-foreground">{d.sessions}</td>
<td className="text-right py-2 px-4">
<span className="text-[#ffe6cb]">{formatTokens(d.input_tokens)}</span>
</td>
<td className="text-right py-2 px-4">
<span className="text-emerald-400">{formatTokens(d.output_tokens)}</span>
</td>
<td className="text-right py-2 px-4 text-muted-foreground">
{cacheHitPct > 0 ? `${cacheHitPct}%` : "—"}
</td>
<td className="text-right py-2 pl-4 text-muted-foreground">
{cost > 0 ? formatCost(cost) : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
if (models.length === 0) return null;
const sorted = [...models].sort(
(a, b) => b.input_tokens + b.output_tokens - (a.input_tokens + a.output_tokens),
);
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Cpu className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Per-Model Breakdown</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<th className="text-left py-2 pr-4 font-medium">Model</th>
<th className="text-right py-2 px-4 font-medium">Sessions</th>
<th className="text-right py-2 px-4 font-medium">Tokens</th>
<th className="text-right py-2 pl-4 font-medium">Cost</th>
</tr>
</thead>
<tbody>
{sorted.map((m) => (
<tr key={m.model} className="border-b border-border/50 hover:bg-secondary/20 transition-colors">
<td className="py-2 pr-4">
<span className="font-mono-ui text-xs">{m.model}</span>
</td>
<td className="text-right py-2 px-4 text-muted-foreground">{m.sessions}</td>
<td className="text-right py-2 px-4">
<span className="text-[#ffe6cb]">{formatTokens(m.input_tokens)}</span>
{" / "}
<span className="text-emerald-400">{formatTokens(m.output_tokens)}</span>
</td>
<td className="text-right py-2 pl-4 text-muted-foreground">
{m.estimated_cost > 0 ? formatCost(m.estimated_cost) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
export default function AnalyticsPage() {
const [days, setDays] = useState(30);
const [data, setData] = useState<AnalyticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true);
setError(null);
api
.getAnalytics(days)
.then(setData)
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}, [days]);
useEffect(() => {
load();
}, [load]);
return (
<div className="flex flex-col gap-6">
{/* Period selector */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground font-medium">Period:</span>
{PERIODS.map((p) => (
<Button
key={p.label}
variant={days === p.days ? "default" : "outline"}
size="sm"
className="text-xs h-7"
onClick={() => setDays(p.days)}
>
{p.label}
</Button>
))}
</div>
{loading && !data && (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)}
{error && (
<Card>
<CardContent className="py-6">
<p className="text-sm text-destructive text-center">{error}</p>
</CardContent>
</Card>
)}
{data && (
<>
{/* Summary cards — matches hermes's token model */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<SummaryCard
icon={Hash}
label="Total Tokens"
value={formatTokens(data.totals.total_input + data.totals.total_output)}
sub={`${formatTokens(data.totals.total_input)} in / ${formatTokens(data.totals.total_output)} out`}
/>
<SummaryCard
icon={Database}
label="Cache Hit"
value={data.totals.total_cache_read > 0
? `${Math.round((data.totals.total_cache_read / (data.totals.total_input + data.totals.total_cache_read)) * 100)}%`
: "—"}
sub={`${formatTokens(data.totals.total_cache_read)} tokens from cache`}
/>
<SummaryCard
icon={Coins}
label="Total Cost"
value={formatCost(
data.totals.total_actual_cost > 0
? data.totals.total_actual_cost
: data.totals.total_estimated_cost
)}
sub={data.totals.total_actual_cost > 0 ? "actual" : `estimated · last ${days}d`}
/>
<SummaryCard
icon={BarChart3}
label="Total Sessions"
value={String(data.totals.total_sessions)}
sub={`~${(data.totals.total_sessions / days).toFixed(1)}/day avg`}
/>
</div>
{/* Bar chart */}
<TokenBarChart daily={data.daily} />
{/* Tables */}
<DailyTable daily={data.daily} />
<ModelTable models={data.by_model} />
</>
)}
{data && data.daily.length === 0 && data.by_model.length === 0 && (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center text-muted-foreground">
<BarChart3 className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">No usage data for this period</p>
<p className="text-xs mt-1 text-muted-foreground/60">Start a session to see analytics here</p>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View file

@ -0,0 +1,451 @@
import { useEffect, useRef, useState, useMemo } from "react";
import {
Code,
Download,
FormInput,
RotateCcw,
Save,
Search,
Upload,
X,
ChevronRight,
Settings2,
FileText,
} from "lucide-react";
import { api } from "@/lib/api";
import { getNestedValue, setNestedValue } from "@/lib/nested";
import { useToast } from "@/hooks/useToast";
import { Toast } from "@/components/Toast";
import { AutoField } from "@/components/AutoField";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const CATEGORY_ICONS: Record<string, string> = {
general: "⚙️",
agent: "🤖",
terminal: "💻",
display: "🎨",
delegation: "👥",
memory: "🧠",
compression: "📦",
security: "🔒",
browser: "🌐",
voice: "🎙️",
tts: "🔊",
stt: "👂",
logging: "📋",
discord: "💬",
auxiliary: "🔧",
};
function prettyCategoryName(cat: string): string {
if (cat === "tts") return "Text-to-Speech";
if (cat === "stt") return "Speech-to-Text";
return cat.charAt(0).toUpperCase() + cat.slice(1);
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function ConfigPage() {
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
const [schema, setSchema] = useState<Record<string, Record<string, unknown>> | null>(null);
const [categoryOrder, setCategoryOrder] = useState<string[]>([]);
const [defaults, setDefaults] = useState<Record<string, unknown> | null>(null);
const [saving, setSaving] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [yamlMode, setYamlMode] = useState(false);
const [yamlText, setYamlText] = useState("");
const [yamlLoading, setYamlLoading] = useState(false);
const [yamlSaving, setYamlSaving] = useState(false);
const [activeCategory, setActiveCategory] = useState<string>("");
const { toast, showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
api.getConfig().then(setConfig).catch(() => {});
api
.getSchema()
.then((resp) => {
setSchema(resp.fields as Record<string, Record<string, unknown>>);
setCategoryOrder(resp.category_order ?? []);
})
.catch(() => {});
api.getDefaults().then(setDefaults).catch(() => {});
}, []);
// Set active category when categories load
useEffect(() => {
if (categoryOrder.length > 0 && !activeCategory) {
setActiveCategory(categoryOrder[0]);
}
}, [categoryOrder, activeCategory]);
// Load YAML when switching to YAML mode
useEffect(() => {
if (yamlMode) {
setYamlLoading(true);
api
.getConfigRaw()
.then((resp) => setYamlText(resp.yaml))
.catch(() => showToast("Failed to load raw config", "error"))
.finally(() => setYamlLoading(false));
}
}, [yamlMode]);
/* ---- Categories ---- */
const categories = useMemo(() => {
if (!schema) return [];
const allCats = [...new Set(Object.values(schema).map((s) => String(s.category ?? "general")))];
const ordered = categoryOrder.filter((c) => allCats.includes(c));
const extra = allCats.filter((c) => !categoryOrder.includes(c)).sort();
return [...ordered, ...extra];
}, [schema, categoryOrder]);
/* ---- Category field counts ---- */
const categoryCounts = useMemo(() => {
if (!schema) return {};
const counts: Record<string, number> = {};
for (const s of Object.values(schema)) {
const cat = String(s.category ?? "general");
counts[cat] = (counts[cat] || 0) + 1;
}
return counts;
}, [schema]);
/* ---- Search ---- */
const isSearching = searchQuery.trim().length > 0;
const lowerSearch = searchQuery.toLowerCase();
const searchMatchedFields = useMemo(() => {
if (!isSearching || !schema) return [];
return Object.entries(schema).filter(([key, s]) => {
const label = key.split(".").pop() ?? key;
const humanLabel = label.replace(/_/g, " ");
return (
key.toLowerCase().includes(lowerSearch) ||
humanLabel.toLowerCase().includes(lowerSearch) ||
String(s.category ?? "").toLowerCase().includes(lowerSearch) ||
String(s.description ?? "").toLowerCase().includes(lowerSearch)
);
});
}, [isSearching, lowerSearch, schema]);
/* ---- Active tab fields ---- */
const activeFields = useMemo(() => {
if (!schema || isSearching) return [];
return Object.entries(schema).filter(
([, s]) => String(s.category ?? "general") === activeCategory
);
}, [schema, activeCategory, isSearching]);
/* ---- Handlers ---- */
const handleSave = async () => {
if (!config) return;
setSaving(true);
try {
await api.saveConfig(config);
showToast("Configuration saved", "success");
} catch (e) {
showToast(`Failed to save: ${e}`, "error");
} finally {
setSaving(false);
}
};
const handleYamlSave = async () => {
setYamlSaving(true);
try {
await api.saveConfigRaw(yamlText);
showToast("YAML config saved", "success");
api.getConfig().then(setConfig).catch(() => {});
} catch (e) {
showToast(`Failed to save YAML: ${e}`, "error");
} finally {
setYamlSaving(false);
}
};
const handleReset = () => {
if (defaults) setConfig(structuredClone(defaults));
};
const handleExport = () => {
if (!config) return;
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "hermes-config.json";
a.click();
URL.revokeObjectURL(url);
};
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const imported = JSON.parse(reader.result as string);
setConfig(imported);
showToast("Config imported — review and save", "success");
} catch {
showToast("Invalid JSON file", "error");
}
};
reader.readAsText(file);
};
/* ---- Loading ---- */
if (!config || !schema) {
return (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
/* ---- Render field list (shared between search & normal) ---- */
const renderFields = (fields: [string, Record<string, unknown>][], showCategory = false) => {
let lastSection = "";
let lastCat = "";
return fields.map(([key, s]) => {
const parts = key.split(".");
const section = parts.length > 1 ? parts[0] : "";
const cat = String(s.category ?? "general");
const showCatBadge = showCategory && cat !== lastCat;
const showSection = !showCategory && section && section !== lastSection && section !== activeCategory;
lastSection = section;
lastCat = cat;
return (
<div key={key}>
{showCatBadge && (
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
<span className="text-base">{CATEGORY_ICONS[cat] || "📄"}</span>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{prettyCategoryName(cat)}
</span>
<div className="flex-1 border-t border-border" />
</div>
)}
{showSection && (
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{section.replace(/_/g, " ")}
</span>
<div className="flex-1 border-t border-border" />
</div>
)}
<div className="py-1">
<AutoField
schemaKey={key}
schema={s}
value={getNestedValue(config, key)}
onChange={(v) => setConfig(setNestedValue(config, key, v))}
/>
</div>
</div>
);
});
};
return (
<div className="flex flex-col gap-4">
<Toast toast={toast} />
{/* ═══════════════ Header Bar ═══════════════ */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5 rounded">
~/.hermes/config.yaml
</code>
</div>
<div className="flex items-center gap-1.5">
<Button variant="ghost" size="sm" onClick={handleExport} title="Export config as JSON" aria-label="Export config">
<Download className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()} title="Import config from JSON" aria-label="Import config">
<Upload className="h-3.5 w-3.5" />
</Button>
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
<Button variant="ghost" size="sm" onClick={handleReset} title="Reset to defaults" aria-label="Reset to defaults">
<RotateCcw className="h-3.5 w-3.5" />
</Button>
<div className="w-px h-5 bg-border mx-1" />
<Button
variant={yamlMode ? "default" : "outline"}
size="sm"
onClick={() => setYamlMode(!yamlMode)}
className="gap-1.5"
>
{yamlMode ? (
<>
<FormInput className="h-3.5 w-3.5" />
Form
</>
) : (
<>
<Code className="h-3.5 w-3.5" />
YAML
</>
)}
</Button>
{yamlMode ? (
<Button size="sm" onClick={handleYamlSave} disabled={yamlSaving} className="gap-1.5">
<Save className="h-3.5 w-3.5" />
{yamlSaving ? "Saving..." : "Save"}
</Button>
) : (
<Button size="sm" onClick={handleSave} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" />
{saving ? "Saving..." : "Save"}
</Button>
)}
</div>
</div>
{/* ═══════════════ YAML Mode ═══════════════ */}
{yamlMode ? (
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4" />
Raw YAML Configuration
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{yamlLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : (
<textarea
className="flex min-h-[600px] w-full bg-transparent px-4 py-3 text-sm font-mono leading-relaxed placeholder:text-muted-foreground focus-visible:outline-none border-t border-border"
value={yamlText}
onChange={(e) => setYamlText(e.target.value)}
spellCheck={false}
/>
)}
</CardContent>
</Card>
) : (
/* ═══════════════ Form Mode ═══════════════ */
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
{/* ---- Sidebar ---- */}
<div className="w-52 shrink-0">
<div className="sticky top-[72px] flex flex-col gap-1">
{/* Search */}
<div className="relative mb-2">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="pl-8 h-8 text-xs"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery("")}
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Category nav */}
{categories.map((cat) => {
const isActive = !isSearching && activeCategory === cat;
return (
<button
key={cat}
type="button"
onClick={() => {
setSearchQuery("");
setActiveCategory(cat);
}}
className={`group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span className="text-sm leading-none">{CATEGORY_ICONS[cat] || "📄"}</span>
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span>
<span className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}>
{categoryCounts[cat] || 0}
</span>
{isActive && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
);
})}
</div>
</div>
{/* ---- Content ---- */}
<div className="flex-1 min-w-0">
{isSearching ? (
/* Search results */
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Search className="h-4 w-4" />
Search Results
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{searchMatchedFields.length} field{searchMatchedFields.length !== 1 ? "s" : ""}
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-2 px-4 pb-4">
{searchMatchedFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No fields match "<span className="text-foreground">{searchQuery}</span>"
</p>
) : (
renderFields(searchMatchedFields, true)
)}
</CardContent>
</Card>
) : (
/* Active category */
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<span className="text-base">{CATEGORY_ICONS[activeCategory] || "📄"}</span>
{prettyCategoryName(activeCategory)}
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{activeFields.length} field{activeFields.length !== 1 ? "s" : ""}
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-2 px-4 pb-4">
{renderFields(activeFields)}
</CardContent>
</Card>
)}
</div>
</div>
)}
</div>
);
}

279
web/src/pages/CronPage.tsx Normal file
View file

@ -0,0 +1,279 @@
import { useEffect, useState } from "react";
import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
import { api } from "@/lib/api";
import type { CronJob } from "@/lib/api";
import { useToast } from "@/hooks/useToast";
import { Toast } from "@/components/Toast";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select } from "@/components/ui/select";
function formatTime(iso?: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return d.toLocaleString();
}
const STATUS_VARIANT: Record<string, "success" | "warning" | "destructive"> = {
enabled: "success",
paused: "warning",
error: "destructive",
};
export default function CronPage() {
const [jobs, setJobs] = useState<CronJob[]>([]);
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
// New job form state
const [prompt, setPrompt] = useState("");
const [schedule, setSchedule] = useState("");
const [name, setName] = useState("");
const [deliver, setDeliver] = useState("local");
const [creating, setCreating] = useState(false);
const loadJobs = () => {
api
.getCronJobs()
.then(setJobs)
.catch(() => showToast("Failed to load cron jobs", "error"))
.finally(() => setLoading(false));
};
useEffect(() => {
loadJobs();
}, []);
const handleCreate = async () => {
if (!prompt.trim() || !schedule.trim()) {
showToast("Prompt and schedule are required", "error");
return;
}
setCreating(true);
try {
await api.createCronJob({
prompt: prompt.trim(),
schedule: schedule.trim(),
name: name.trim() || undefined,
deliver,
});
showToast("Cron job created", "success");
setPrompt("");
setSchedule("");
setName("");
setDeliver("local");
loadJobs();
} catch (e) {
showToast(`Failed to create job: ${e}`, "error");
} finally {
setCreating(false);
}
};
const handlePauseResume = async (job: CronJob) => {
try {
if (job.status === "paused") {
await api.resumeCronJob(job.id);
showToast(`Resumed "${job.name || job.prompt.slice(0, 30)}"`, "success");
} else {
await api.pauseCronJob(job.id);
showToast(`Paused "${job.name || job.prompt.slice(0, 30)}"`, "success");
}
loadJobs();
} catch (e) {
showToast(`Action failed: ${e}`, "error");
}
};
const handleTrigger = async (job: CronJob) => {
try {
await api.triggerCronJob(job.id);
showToast(`Triggered "${job.name || job.prompt.slice(0, 30)}"`, "success");
loadJobs();
} catch (e) {
showToast(`Trigger failed: ${e}`, "error");
}
};
const handleDelete = async (job: CronJob) => {
try {
await api.deleteCronJob(job.id);
showToast(`Deleted "${job.name || job.prompt.slice(0, 30)}"`, "success");
loadJobs();
} catch (e) {
showToast(`Delete failed: ${e}`, "error");
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
{/* Create new job form */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Plus className="h-4 w-4" />
New Cron Job
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="cron-name">Name (optional)</Label>
<Input
id="cron-name"
placeholder="e.g. Daily summary"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-prompt">Prompt</Label>
<textarea
id="cron-prompt"
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="What should the agent do on each run?"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="grid gap-2">
<Label htmlFor="cron-schedule">Schedule (cron expression)</Label>
<Input
id="cron-schedule"
placeholder="0 9 * * *"
value={schedule}
onChange={(e) => setSchedule(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-deliver">Deliver to</Label>
<Select
id="cron-deliver"
value={deliver}
onChange={(e) => setDeliver(e.target.value)}
>
<option value="local">Local</option>
<option value="telegram">Telegram</option>
<option value="discord">Discord</option>
<option value="slack">Slack</option>
<option value="email">Email</option>
</Select>
</div>
<div className="flex items-end">
<Button onClick={handleCreate} disabled={creating} className="w-full">
<Plus className="h-3 w-3" />
{creating ? "Creating..." : "Create"}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Jobs list */}
<div className="flex flex-col gap-3">
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Clock className="h-4 w-4" />
Scheduled Jobs ({jobs.length})
</h2>
{jobs.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No cron jobs configured. Create one above.
</CardContent>
</Card>
)}
{jobs.map((job) => (
<Card key={job.id}>
<CardContent className="flex items-center gap-4 py-4">
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">
{job.name || job.prompt.slice(0, 60) + (job.prompt.length > 60 ? "..." : "")}
</span>
<Badge variant={STATUS_VARIANT[job.status] ?? "secondary"}>
{job.status}
</Badge>
{job.deliver && job.deliver !== "local" && (
<Badge variant="outline">{job.deliver}</Badge>
)}
</div>
{job.name && (
<p className="text-xs text-muted-foreground truncate mb-1">
{job.prompt.slice(0, 100)}{job.prompt.length > 100 ? "..." : ""}
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="font-mono">{job.schedule}</span>
<span>Last: {formatTime(job.last_run_at)}</span>
<span>Next: {formatTime(job.next_run_at)}</span>
</div>
{job.error && (
<p className="text-xs text-destructive mt-1">{job.error}</p>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
title={job.status === "paused" ? "Resume" : "Pause"}
aria-label={job.status === "paused" ? "Resume job" : "Pause job"}
onClick={() => handlePauseResume(job)}
>
{job.status === "paused" ? (
<Play className="h-4 w-4 text-success" />
) : (
<Pause className="h-4 w-4 text-warning" />
)}
</Button>
<Button
variant="ghost"
size="icon"
title="Trigger now"
aria-label="Trigger job now"
onClick={() => handleTrigger(job)}
>
<Zap className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Delete"
aria-label="Delete job"
onClick={() => handleDelete(job)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

614
web/src/pages/EnvPage.tsx Normal file
View file

@ -0,0 +1,614 @@
import { useEffect, useState, useMemo } from "react";
import {
Eye,
EyeOff,
ExternalLink,
KeyRound,
MessageSquare,
Pencil,
Save,
Settings,
Trash2,
X,
Zap,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { api } from "@/lib/api";
import type { EnvVarInfo } from "@/lib/api";
import { useToast } from "@/hooks/useToast";
import { Toast } from "@/components/Toast";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
/* ------------------------------------------------------------------ */
/* Provider grouping */
/* ------------------------------------------------------------------ */
/** Map env-var key prefixes to a human-friendly provider name + ordering. */
const PROVIDER_GROUPS: { prefix: string; name: string; priority: number }[] = [
// Nous Portal first
{ prefix: "NOUS_", name: "Nous Portal", priority: 0 },
// Then alphabetical by display name
{ prefix: "ANTHROPIC_", name: "Anthropic", priority: 1 },
{ prefix: "DASHSCOPE_", name: "DashScope (Qwen)", priority: 2 },
{ prefix: "HERMES_QWEN_", name: "DashScope (Qwen)", priority: 2 },
{ prefix: "DEEPSEEK_", name: "DeepSeek", priority: 3 },
{ prefix: "GOOGLE_", name: "Gemini", priority: 4 },
{ prefix: "GEMINI_", name: "Gemini", priority: 4 },
{ prefix: "GLM_", name: "GLM / Z.AI", priority: 5 },
{ prefix: "ZAI_", name: "GLM / Z.AI", priority: 5 },
{ prefix: "Z_AI_", name: "GLM / Z.AI", priority: 5 },
{ prefix: "HF_", name: "Hugging Face", priority: 6 },
{ prefix: "KIMI_", name: "Kimi / Moonshot", priority: 7 },
{ prefix: "MINIMAX_CN_", name: "MiniMax (China)", priority: 9 },
{ prefix: "MINIMAX_", name: "MiniMax", priority: 8 },
{ prefix: "OPENCODE_GO_", name: "OpenCode Go", priority: 10 },
{ prefix: "OPENCODE_ZEN_", name: "OpenCode Zen", priority: 11 },
{ prefix: "OPENROUTER_", name: "OpenRouter", priority: 12 },
{ prefix: "XIAOMI_", name: "Xiaomi MiMo", priority: 13 },
];
function getProviderGroup(key: string): string {
for (const g of PROVIDER_GROUPS) {
if (key.startsWith(g.prefix)) return g.name;
}
return "Other";
}
function getProviderPriority(groupName: string): number {
const entry = PROVIDER_GROUPS.find((g) => g.name === groupName);
return entry?.priority ?? 99;
}
interface ProviderGroup {
name: string;
priority: number;
entries: [string, EnvVarInfo][];
hasAnySet: boolean;
}
const CATEGORY_META: Record<string, { label: string; icon: typeof KeyRound }> = {
provider: { label: "LLM Providers", icon: Zap },
tool: { label: "Tool API Keys", icon: KeyRound },
messaging: { label: "Messaging Platforms", icon: MessageSquare },
setting: { label: "Agent Settings", icon: Settings },
};
/* ------------------------------------------------------------------ */
/* EnvVarRow — single key edit row */
/* ------------------------------------------------------------------ */
function EnvVarRow({
varKey,
info,
edits,
setEdits,
revealed,
saving,
onSave,
onClear,
onReveal,
onCancelEdit,
compact = false,
}: {
varKey: string;
info: EnvVarInfo;
edits: Record<string, string>;
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
revealed: Record<string, string>;
saving: string | null;
onSave: (key: string) => void;
onClear: (key: string) => void;
onReveal: (key: string) => void;
onCancelEdit: (key: string) => void;
compact?: boolean;
}) {
const isEditing = edits[varKey] !== undefined;
const isRevealed = !!revealed[varKey];
const displayValue = isRevealed ? revealed[varKey] : (info.redacted_value ?? "---");
// Compact inline row for unset, non-editing keys (used inside provider groups)
if (compact && !info.is_set && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 py-1.5 opacity-50 hover:opacity-100 transition-opacity">
<div className="flex items-center gap-2 min-w-0">
<span className="font-mono-ui text-[0.7rem] text-muted-foreground">{varKey}</span>
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">{info.description}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{info.url && (
<a href={info.url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
Get key <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<Button size="sm" variant="outline" className="h-6 text-[0.6rem] px-2"
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
<Pencil className="h-2.5 w-2.5" />
Set
</Button>
</div>
</div>
);
}
// Non-compact unset row
if (!info.is_set && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 border border-border/50 px-4 py-2.5 opacity-60 hover:opacity-100 transition-opacity">
<div className="flex items-center gap-3 min-w-0">
<Label className="font-mono-ui text-[0.7rem] text-muted-foreground">{varKey}</Label>
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">{info.description}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{info.url && (
<a href={info.url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
Get key <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<Button size="sm" variant="outline" className="h-7 text-[0.6rem]"
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
<Pencil className="h-3 w-3" />
Set
</Button>
</div>
</div>
);
}
// Full expanded row for set keys or keys being edited
return (
<div className="grid gap-2 border border-border p-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<Label className="font-mono-ui text-[0.7rem]">{varKey}</Label>
<Badge variant={info.is_set ? "success" : "outline"}>
{info.is_set ? "Set" : "Not set"}
</Badge>
</div>
{info.url && (
<a href={info.url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
Get key <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
<p className="text-xs text-muted-foreground">{info.description}</p>
{info.tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{info.tools.map((tool) => (
<Badge key={tool} variant="secondary" className="text-[0.6rem] py-0 px-1.5">{tool}</Badge>
))}
</div>
)}
{!isEditing && (
<div className="flex items-center gap-2">
<div className={`flex-1 border border-border px-3 py-2 font-mono-ui text-xs ${
isRevealed ? "bg-background text-foreground select-all" : "bg-muted/30 text-muted-foreground"
}`}>
{info.is_set ? displayValue : "---"}
</div>
{info.is_set && (
<Button size="sm" variant="ghost" onClick={() => onReveal(varKey)}
title={isRevealed ? "Hide value" : "Show real value"}
aria-label={isRevealed ? `Hide ${varKey}` : `Reveal ${varKey}`}>
{isRevealed
? <EyeOff className="h-4 w-4" />
: <Eye className="h-4 w-4" />}
</Button>
)}
<Button size="sm" variant="outline"
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
<Pencil className="h-3 w-3" />
{info.is_set ? "Replace" : "Set"}
</Button>
{info.is_set && (
<Button size="sm" variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onClear(varKey)} disabled={saving === varKey}>
<Trash2 className="h-3 w-3" />
{saving === varKey ? "..." : "Clear"}
</Button>
)}
</div>
)}
{isEditing && (
<div className="flex items-center gap-2">
<Input autoFocus type="text" value={edits[varKey]}
onChange={(e) => setEdits((prev) => ({ ...prev, [varKey]: e.target.value }))}
placeholder={info.is_set ? `Replace current value (${info.redacted_value ?? "---"})` : "Enter value..."}
className="flex-1 font-mono-ui text-xs" />
<Button size="sm" onClick={() => onSave(varKey)}
disabled={saving === varKey || !edits[varKey]}>
<Save className="h-3 w-3" />
{saving === varKey ? "..." : "Save"}
</Button>
<Button size="sm" variant="ghost" onClick={() => onCancelEdit(varKey)}>
<X className="h-3 w-3" /> Cancel
</Button>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* ProviderGroupCard — groups API key + base URL per provider */
/* ------------------------------------------------------------------ */
function ProviderGroupCard({
group,
edits,
setEdits,
revealed,
saving,
onSave,
onClear,
onReveal,
onCancelEdit,
}: {
group: ProviderGroup;
edits: Record<string, string>;
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
revealed: Record<string, string>;
saving: string | null;
onSave: (key: string) => void;
onClear: (key: string) => void;
onReveal: (key: string) => void;
onCancelEdit: (key: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
// Separate API keys from base URLs and other settings
const apiKeys = group.entries.filter(([k]) => k.endsWith("_API_KEY") || k.endsWith("_TOKEN"));
const baseUrls = group.entries.filter(([k]) => k.endsWith("_BASE_URL"));
const other = group.entries.filter(([k]) => !k.endsWith("_API_KEY") && !k.endsWith("_TOKEN") && !k.endsWith("_BASE_URL"));
const hasAnyConfigured = group.entries.some(([, info]) => info.is_set);
const configuredCount = group.entries.filter(([, info]) => info.is_set).length;
// Get a representative URL for "Get key" link
const keyUrl = apiKeys.find(([, info]) => info.url)?.[1]?.url ?? null;
return (
<div className="border border-border">
{/* Header — always visible */}
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 cursor-pointer hover:bg-primary/5 transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
{expanded ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
<span className="font-semibold text-sm tracking-wide">{group.name}</span>
{hasAnyConfigured && (
<Badge variant="success" className="text-[0.6rem]">
{configuredCount} set
</Badge>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{keyUrl && (
<a href={keyUrl} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
onClick={(e) => e.stopPropagation()}>
Get key <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<span className="text-[0.65rem] text-muted-foreground/60">
{group.entries.length} key{group.entries.length !== 1 ? "s" : ""}
</span>
</div>
</button>
{/* Expanded content */}
{expanded && (
<div className="border-t border-border px-4 py-3 grid gap-2">
{/* API keys first (most important) */}
{apiKeys.map(([key, info]) => (
<EnvVarRow
key={key} varKey={key} info={info} compact
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
/>
))}
{/* Base URLs (secondary) */}
{baseUrls.map(([key, info]) => (
<EnvVarRow
key={key} varKey={key} info={info} compact
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
/>
))}
{/* Anything else */}
{other.map(([key, info]) => (
<EnvVarRow
key={key} varKey={key} info={info} compact
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
/>
))}
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Main page */
/* ------------------------------------------------------------------ */
export default function EnvPage() {
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null);
const [edits, setEdits] = useState<Record<string, string>>({});
const [revealed, setRevealed] = useState<Record<string, string>>({});
const [saving, setSaving] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(true); // Show all providers by default
const { toast, showToast } = useToast();
useEffect(() => {
api.getEnvVars().then(setVars).catch(() => {});
}, []);
const handleSave = async (key: string) => {
const value = edits[key];
if (!value) return;
setSaving(key);
try {
await api.setEnvVar(key, value);
setVars((prev) =>
prev
? {
...prev,
[key]: { ...prev[key], is_set: true, redacted_value: value.slice(0, 4) + "..." + value.slice(-4) },
}
: prev,
);
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
showToast(`${key} saved`, "success");
} catch (e) {
showToast(`Failed to save ${key}: ${e}`, "error");
} finally {
setSaving(null);
}
};
const handleClear = async (key: string) => {
setSaving(key);
try {
await api.deleteEnvVar(key);
setVars((prev) =>
prev
? { ...prev, [key]: { ...prev[key], is_set: false, redacted_value: null } }
: prev,
);
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
showToast(`${key} removed`, "success");
} catch (e) {
showToast(`Failed to remove ${key}: ${e}`, "error");
} finally {
setSaving(null);
}
};
const handleReveal = async (key: string) => {
if (revealed[key]) {
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
return;
}
try {
const resp = await api.revealEnvVar(key);
setRevealed((prev) => ({ ...prev, [key]: resp.value }));
} catch {
showToast(`Failed to reveal ${key}`, "error");
}
};
const cancelEdit = (key: string) => {
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
};
/* ---- Build provider groups ---- */
const { providerGroups, nonProviderGrouped } = useMemo(() => {
if (!vars) return { providerGroups: [], nonProviderGrouped: [] };
const providerEntries = Object.entries(vars).filter(
([, info]) => info.category === "provider" && (showAdvanced || !info.advanced),
);
// Group by provider
const groupMap = new Map<string, [string, EnvVarInfo][]>();
for (const entry of providerEntries) {
const groupName = getProviderGroup(entry[0]);
if (!groupMap.has(groupName)) groupMap.set(groupName, []);
groupMap.get(groupName)!.push(entry);
}
const groups: ProviderGroup[] = Array.from(groupMap.entries())
.map(([name, entries]) => ({
name,
priority: getProviderPriority(name),
entries,
hasAnySet: entries.some(([, info]) => info.is_set),
}))
.sort((a, b) => a.priority - b.priority);
// Non-provider categories
const otherCategories = ["tool", "messaging", "setting"];
const nonProvider = otherCategories.map((cat) => {
const entries = Object.entries(vars).filter(
([, info]) => info.category === cat && (showAdvanced || !info.advanced),
);
const setEntries = entries.filter(([, info]) => info.is_set);
const unsetEntries = entries.filter(([, info]) => !info.is_set);
return {
...CATEGORY_META[cat],
category: cat,
setEntries,
unsetEntries,
totalEntries: entries.length,
};
});
return { providerGroups: groups, nonProviderGrouped: nonProvider };
}, [vars, showAdvanced]);
if (!vars) {
return (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
const totalProviders = providerGroups.length;
const configuredProviders = providerGroups.filter((g) => g.hasAnySet).length;
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<p className="text-sm text-muted-foreground">
Manage API keys and secrets stored in <code>~/.hermes/.env</code>
</p>
<p className="text-[0.7rem] text-muted-foreground/70">
Changes are saved to disk immediately. Active sessions pick up new keys automatically.
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => setShowAdvanced(!showAdvanced)}>
{showAdvanced ? "Hide Advanced" : "Show Advanced"}
</Button>
</div>
{/* ═══════════════ LLM Providers (grouped) ═══════════════ */}
<Card>
<CardHeader className="sticky top-14 z-10 bg-card border-b border-border">
<div className="flex items-center gap-2">
<Zap className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">LLM Providers</CardTitle>
</div>
<CardDescription>
{configuredProviders} of {totalProviders} providers configured
</CardDescription>
</CardHeader>
<CardContent className="grid gap-0 p-0">
{providerGroups.map((group) => (
<ProviderGroupCard
key={group.name}
group={group}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit}
/>
))}
</CardContent>
</Card>
{/* ═══════════════ Other categories (flat) ═══════════════ */}
{nonProviderGrouped.map(({ label, icon: Icon, setEntries, unsetEntries, totalEntries, category }) => {
if (totalEntries === 0) return null;
return (
<Card key={category}>
<CardHeader className="sticky top-14 z-10 bg-card border-b border-border">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{label}</CardTitle>
</div>
<CardDescription>
{setEntries.length} of {totalEntries} configured
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 pt-4">
{setEntries.map(([key, info]) => (
<EnvVarRow
key={key} varKey={key} info={info}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit}
/>
))}
{unsetEntries.length > 0 && (
<CollapsibleUnset
category={category}
unsetEntries={unsetEntries}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit}
/>
)}
</CardContent>
</Card>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ */
/* CollapsibleUnset — for non-provider categories */
/* ------------------------------------------------------------------ */
function CollapsibleUnset({
category: _category,
unsetEntries,
edits,
setEdits,
revealed,
saving,
onSave,
onClear,
onReveal,
onCancelEdit,
}: {
category: string;
unsetEntries: [string, EnvVarInfo][];
edits: Record<string, string>;
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
revealed: Record<string, string>;
saving: string | null;
onSave: (key: string) => void;
onClear: (key: string) => void;
onReveal: (key: string) => void;
onCancelEdit: (key: string) => void;
}) {
const [collapsed, setCollapsed] = useState(true);
return (
<>
<button
type="button"
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer pt-1"
onClick={() => setCollapsed(!collapsed)}
>
{collapsed
? <ChevronRight className="h-3 w-3" />
: <ChevronDown className="h-3 w-3" />}
<span>{unsetEntries.length} not configured</span>
</button>
{!collapsed && unsetEntries.map(([key, info]) => (
<EnvVarRow
key={key} varKey={key} info={info}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
/>
))}
</>
);
}

175
web/src/pages/LogsPage.tsx Normal file
View file

@ -0,0 +1,175 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { FileText, RefreshCw } from "lucide-react";
import { api } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
const FILES = ["agent", "errors", "gateway"] as const;
const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const;
const COMPONENTS = ["all", "gateway", "agent", "tools", "cli", "cron"] as const;
const LINE_COUNTS = [50, 100, 200, 500] as const;
function classifyLine(line: string): "error" | "warning" | "info" | "debug" {
const upper = line.toUpperCase();
if (upper.includes("ERROR") || upper.includes("CRITICAL") || upper.includes("FATAL")) return "error";
if (upper.includes("WARNING") || upper.includes("WARN")) return "warning";
if (upper.includes("DEBUG")) return "debug";
return "info";
}
const LINE_COLORS: Record<string, string> = {
error: "text-destructive",
warning: "text-warning",
info: "text-foreground",
debug: "text-muted-foreground/60",
};
function FilterBar<T extends string>({
label,
options,
value,
onChange,
}: {
label: string;
options: readonly T[];
value: T;
onChange: (v: T) => void;
}) {
return (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground font-medium w-20 shrink-0">{label}</span>
<div className="flex gap-1 flex-wrap">
{options.map((opt) => (
<Button
key={opt}
variant={value === opt ? "default" : "outline"}
size="sm"
className="text-xs h-7 px-2.5"
onClick={() => onChange(opt)}
>
{opt}
</Button>
))}
</div>
</div>
);
}
export default function LogsPage() {
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
const [component, setComponent] = useState<(typeof COMPONENTS)[number]>("all");
const [lineCount, setLineCount] = useState<(typeof LINE_COUNTS)[number]>(100);
const [autoRefresh, setAutoRefresh] = useState(false);
const [lines, setLines] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const fetchLogs = useCallback(() => {
setLoading(true);
setError(null);
api
.getLogs({ file, lines: lineCount, level, component })
.then((resp) => {
setLines(resp.lines);
// Auto-scroll to bottom
setTimeout(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, 50);
})
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}, [file, lineCount, level, component]);
// Initial load + refetch on filter change
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
// Auto-refresh polling
useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(fetchLogs, 5000);
return () => clearInterval(interval);
}, [autoRefresh, fetchLogs]);
return (
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Logs</CardTitle>
{loading && (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)}
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Switch
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
<Label className="text-xs">Auto-refresh</Label>
{autoRefresh && (
<Badge variant="success" className="text-[10px]">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
Live
</Badge>
)}
</div>
<Button variant="outline" size="sm" onClick={fetchLogs} className="text-xs h-7">
<RefreshCw className="h-3 w-3 mr-1" />
Refresh
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 mb-4">
<FilterBar label="File" options={FILES} value={file} onChange={setFile} />
<FilterBar label="Level" options={LEVELS} value={level} onChange={setLevel} />
<FilterBar label="Component" options={COMPONENTS} value={component} onChange={setComponent} />
<FilterBar
label="Lines"
options={LINE_COUNTS.map(String) as unknown as readonly string[]}
value={String(lineCount)}
onChange={(v) => setLineCount(Number(v) as (typeof LINE_COUNTS)[number])}
/>
</div>
{error && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3 mb-4">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div
ref={scrollRef}
className="border border-border bg-background p-4 font-mono-ui text-xs leading-5 overflow-auto max-h-[600px] min-h-[200px]"
>
{lines.length === 0 && !loading && (
<p className="text-muted-foreground text-center py-8">No log lines found</p>
)}
{lines.map((line, i) => {
const cls = classifyLine(line);
return (
<div key={i} className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1 rounded`}>
{line}
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,429 @@
import { useEffect, useState, useCallback, useRef } from "react";
import {
ChevronDown,
ChevronRight,
MessageSquare,
Search,
Trash2,
Clock,
Terminal,
Globe,
MessageCircle,
Hash,
X,
} from "lucide-react";
import { api } from "@/lib/api";
import type { SessionInfo, SessionMessage, SessionSearchResult } from "@/lib/api";
import { timeAgo } from "@/lib/utils";
import { Markdown } from "@/components/Markdown";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
const ROLE_STYLES: Record<string, { bg: string; text: string; label: string }> = {
user: { bg: "bg-primary/10", text: "text-primary", label: "User" },
assistant: { bg: "bg-success/10", text: "text-success", label: "Assistant" },
system: { bg: "bg-muted", text: "text-muted-foreground", label: "System" },
tool: { bg: "bg-warning/10", text: "text-warning", label: "Tool" },
};
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> = {
cli: { icon: Terminal, color: "text-primary" },
telegram: { icon: MessageCircle, color: "text-[oklch(0.65_0.15_250)]" },
discord: { icon: Hash, color: "text-[oklch(0.65_0.15_280)]" },
slack: { icon: MessageSquare, color: "text-[oklch(0.7_0.15_155)]" },
whatsapp: { icon: Globe, color: "text-success" },
cron: { icon: Clock, color: "text-warning" },
};
/** Render an FTS5 snippet with highlighted matches.
* The backend wraps matches in >>> and <<< delimiters. */
function SnippetHighlight({ snippet }: { snippet: string }) {
const parts: React.ReactNode[] = [];
const regex = />>>(.*?)<<</g;
let last = 0;
let match: RegExpExecArray | null;
let i = 0;
while ((match = regex.exec(snippet)) !== null) {
if (match.index > last) {
parts.push(snippet.slice(last, match.index));
}
parts.push(
<mark key={i++} className="bg-warning/30 text-warning rounded-sm px-0.5">
{match[1]}
</mark>
);
last = regex.lastIndex;
}
if (last < snippet.length) {
parts.push(snippet.slice(last));
}
return (
<p className="text-xs text-muted-foreground/80 truncate max-w-lg mt-0.5">
{parts}
</p>
);
}
function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name: string; arguments: string } } }) {
const [open, setOpen] = useState(false);
let args = toolCall.function.arguments;
try {
args = JSON.stringify(JSON.parse(args), null, 2);
} catch {
// keep as-is
}
return (
<div className="mt-2 rounded-md border border-warning/20 bg-warning/5">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-warning cursor-pointer hover:bg-warning/10 transition-colors"
onClick={() => setOpen(!open)}
aria-label={`${open ? "Collapse" : "Expand"} tool call ${toolCall.function.name}`}
>
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<span className="font-mono-ui font-medium">{toolCall.function.name}</span>
<span className="text-warning/50 ml-auto">{toolCall.id}</span>
</button>
{open && (
<pre className="border-t border-warning/20 px-3 py-2 text-xs text-warning/80 overflow-x-auto whitespace-pre-wrap font-mono">
{args}
</pre>
)}
</div>
);
}
function MessageBubble({ msg, highlight }: { msg: SessionMessage; highlight?: string }) {
const style = ROLE_STYLES[msg.role] ?? ROLE_STYLES.system;
const label = msg.tool_name ? `Tool: ${msg.tool_name}` : style.label;
// Check if any search term appears as a prefix of any word in content
const isHit = (() => {
if (!highlight || !msg.content) return false;
const content = msg.content.toLowerCase();
const terms = highlight.toLowerCase().split(/\s+/).filter(Boolean);
return terms.some((term) => content.includes(term));
})();
// Split search query into terms for inline highlighting
const highlightTerms = isHit && highlight
? highlight.split(/\s+/).filter(Boolean)
: undefined;
return (
<div className={`${style.bg} p-3 ${isHit ? "ring-1 ring-warning/40" : ""}`} data-search-hit={isHit || undefined}>
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-semibold ${style.text}`}>{label}</span>
{isHit && (
<Badge variant="warning" className="text-[9px] py-0 px-1.5">match</Badge>
)}
{msg.timestamp && (
<span className="text-[10px] text-muted-foreground">{timeAgo(msg.timestamp)}</span>
)}
</div>
{msg.content && (
msg.role === "system"
? <div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">{msg.content}</div>
: <Markdown content={msg.content} highlightTerms={highlightTerms} />
)}
{msg.tool_calls && msg.tool_calls.length > 0 && (
<div className="mt-1">
{msg.tool_calls.map((tc) => (
<ToolCallBlock key={tc.id} toolCall={tc} />
))}
</div>
)}
</div>
);
}
/** Message list with auto-scroll to first search hit. */
function MessageList({ messages, highlight }: { messages: SessionMessage[]; highlight?: string }) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!highlight || !containerRef.current) return;
// Scroll to first hit after render
const timer = setTimeout(() => {
const hit = containerRef.current?.querySelector("[data-search-hit]");
if (hit) {
hit.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, 50);
return () => clearTimeout(timer);
}, [messages, highlight]);
return (
<div ref={containerRef} className="flex flex-col gap-3 max-h-[600px] overflow-y-auto pr-2">
{messages.map((msg, i) => (
<MessageBubble key={i} msg={msg} highlight={highlight} />
))}
</div>
);
}
function SessionRow({
session,
snippet,
searchQuery,
isExpanded,
onToggle,
onDelete,
}: {
session: SessionInfo;
snippet?: string;
searchQuery?: string;
isExpanded: boolean;
onToggle: () => void;
onDelete: () => void;
}) {
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isExpanded && messages === null && !loading) {
setLoading(true);
api
.getSessionMessages(session.id)
.then((resp) => setMessages(resp.messages))
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}
}, [isExpanded, session.id, messages, loading]);
const sourceInfo = (session.source ? SOURCE_CONFIG[session.source] : null) ?? { icon: Globe, color: "text-muted-foreground" };
const SourceIcon = sourceInfo.icon;
const hasTitle = session.title && session.title !== "Untitled";
return (
<div className={`border overflow-hidden transition-colors ${
session.is_active
? "border-success/30 bg-success/[0.03]"
: "border-border"
}`}>
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-secondary/30 transition-colors"
onClick={onToggle}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className={`shrink-0 ${sourceInfo.color}`}>
<SourceIcon className="h-4 w-4" />
</div>
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-sm truncate pr-2 ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}>
{hasTitle ? session.title : (session.preview ? session.preview.slice(0, 60) : "Untitled session")}
</span>
{session.is_active && (
<Badge variant="success" className="text-[10px] shrink-0">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
Live
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="truncate max-w-[180px]">{(session.model ?? "unknown").split("/").pop()}</span>
<span className="text-border">&#183;</span>
<span>{session.message_count} msgs</span>
{session.tool_call_count > 0 && (
<>
<span className="text-border">&#183;</span>
<span>{session.tool_call_count} tools</span>
</>
)}
<span className="text-border">&#183;</span>
<span>{timeAgo(session.last_active)}</span>
</div>
{snippet && (
<SnippetHighlight snippet={snippet} />
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className="text-[10px]">
{session.source ?? "local"}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
aria-label="Delete session"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{isExpanded && (
<div className="border-t border-border bg-background/50 p-4">
{loading && (
<div className="flex items-center justify-center py-8">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)}
{error && (
<p className="text-sm text-destructive py-4 text-center">{error}</p>
)}
{messages && messages.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">No messages</p>
)}
{messages && messages.length > 0 && (
<MessageList messages={messages} highlight={searchQuery} />
)}
</div>
)}
</div>
);
}
export default function SessionsPage() {
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [searchResults, setSearchResults] = useState<SessionSearchResult[] | null>(null);
const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const loadSessions = useCallback(() => {
api
.getSessions()
.then(setSessions)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
loadSessions();
}, [loadSessions]);
// Debounced FTS search
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!search.trim()) {
setSearchResults(null);
setSearching(false);
return;
}
setSearching(true);
debounceRef.current = setTimeout(() => {
api
.searchSessions(search.trim())
.then((resp) => setSearchResults(resp.results))
.catch(() => setSearchResults(null))
.finally(() => setSearching(false));
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [search]);
const handleDelete = async (id: string) => {
try {
await api.deleteSession(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
if (expandedId === id) setExpandedId(null);
} catch {
// ignore
}
};
// Build snippet map from search results (session_id → snippet)
const snippetMap = new Map<string, string>();
if (searchResults) {
for (const r of searchResults) {
snippetMap.set(r.session_id, r.snippet);
}
}
// When searching, filter sessions to those with FTS matches;
// when not searching, show all sessions
const filtered = searchResults
? sessions.filter((s) => snippetMap.has(s.id))
: sessions;
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
return (
<div className="flex flex-col gap-4">
{/* Header outside card for lighter feel */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-muted-foreground" />
<h1 className="text-base font-semibold">Sessions</h1>
<Badge variant="secondary" className="text-xs">
{sessions.length}
</Badge>
</div>
<div className="relative w-64">
{searching ? (
<div className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin rounded-full border-[1.5px] border-primary border-t-transparent" />
) : (
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
)}
<Input
placeholder="Search message content..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 pr-7 h-8 text-xs"
/>
{search && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
onClick={() => setSearch("")}
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Clock className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">
{search ? "No sessions match your search" : "No sessions yet"}
</p>
{!search && (
<p className="text-xs mt-1 text-muted-foreground/60">Start a conversation to see it here</p>
)}
</div>
) : (
<div className="flex flex-col gap-1.5">
{filtered.map((s) => (
<SessionRow
key={s.id}
session={s}
snippet={snippetMap.get(s.id)}
searchQuery={search || undefined}
isExpanded={expandedId === s.id}
onToggle={() =>
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onDelete={() => handleDelete(s.id)}
/>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,439 @@
import { useEffect, useState, useMemo } from "react";
import {
Package,
Search,
Wrench,
ChevronDown,
ChevronRight,
Filter,
X,
} from "lucide-react";
import { api } from "@/lib/api";
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
import { useToast } from "@/hooks/useToast";
import { Toast } from "@/components/Toast";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
/* ------------------------------------------------------------------ */
/* Types & helpers */
/* ------------------------------------------------------------------ */
interface CategoryGroup {
name: string; // display name
key: string; // raw key (or "__none__")
skills: SkillInfo[];
enabledCount: number;
}
const CATEGORY_LABELS: Record<string, string> = {
mlops: "MLOps",
"mlops/cloud": "MLOps / Cloud",
"mlops/evaluation": "MLOps / Evaluation",
"mlops/inference": "MLOps / Inference",
"mlops/models": "MLOps / Models",
"mlops/training": "MLOps / Training",
"mlops/vector-databases": "MLOps / Vector DBs",
mcp: "MCP",
"red-teaming": "Red Teaming",
ocr: "OCR",
p5js: "p5.js",
ai: "AI",
ux: "UX",
ui: "UI",
};
function prettyCategory(raw: string | null | undefined): string {
if (!raw) return "General";
if (CATEGORY_LABELS[raw]) return CATEGORY_LABELS[raw];
return raw
.split(/[-_/]/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function SkillsPage() {
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [toolsets, setToolsets] = useState<ToolsetInfo[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
// Start collapsed by default
const [collapsedCategories, setCollapsedCategories] = useState<Set<string> | "all">("all");
const { toast, showToast } = useToast();
useEffect(() => {
Promise.all([api.getSkills(), api.getToolsets()])
.then(([s, t]) => {
setSkills(s);
setToolsets(t);
})
.catch(() => showToast("Failed to load skills/toolsets", "error"))
.finally(() => setLoading(false));
}, []);
/* ---- Toggle skill ---- */
const handleToggleSkill = async (skill: SkillInfo) => {
setTogglingSkills((prev) => new Set(prev).add(skill.name));
try {
await api.toggleSkill(skill.name, !skill.enabled);
setSkills((prev) =>
prev.map((s) =>
s.name === skill.name ? { ...s, enabled: !s.enabled } : s
)
);
showToast(
`${skill.name} ${skill.enabled ? "disabled" : "enabled"}`,
"success"
);
} catch {
showToast(`Failed to toggle ${skill.name}`, "error");
} finally {
setTogglingSkills((prev) => {
const next = new Set(prev);
next.delete(skill.name);
return next;
});
}
};
/* ---- Derived data ---- */
const lowerSearch = search.toLowerCase();
const filteredSkills = useMemo(() => {
return skills.filter((s) => {
const matchesSearch =
!search ||
s.name.toLowerCase().includes(lowerSearch) ||
s.description.toLowerCase().includes(lowerSearch) ||
(s.category ?? "").toLowerCase().includes(lowerSearch);
const matchesCategory =
!activeCategory ||
(activeCategory === "__none__" ? !s.category : s.category === activeCategory);
return matchesSearch && matchesCategory;
});
}, [skills, search, lowerSearch, activeCategory]);
const categoryGroups: CategoryGroup[] = useMemo(() => {
const map = new Map<string, SkillInfo[]>();
for (const s of filteredSkills) {
const key = s.category || "__none__";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(s);
}
// Sort: General first, then alphabetical
const entries = [...map.entries()].sort((a, b) => {
if (a[0] === "__none__") return -1;
if (b[0] === "__none__") return 1;
return a[0].localeCompare(b[0]);
});
return entries.map(([key, list]) => ({
key,
name: prettyCategory(key === "__none__" ? null : key),
skills: list.sort((a, b) => a.name.localeCompare(b.name)),
enabledCount: list.filter((s) => s.enabled).length,
}));
}, [filteredSkills]);
const allCategories = useMemo(() => {
const cats = new Map<string, number>();
for (const s of skills) {
const key = s.category || "__none__";
cats.set(key, (cats.get(key) || 0) + 1);
}
return [...cats.entries()]
.sort((a, b) => {
if (a[0] === "__none__") return -1;
if (b[0] === "__none__") return 1;
return a[0].localeCompare(b[0]);
})
.map(([key, count]) => ({ key, name: prettyCategory(key === "__none__" ? null : key), count }));
}, [skills]);
const enabledCount = skills.filter((s) => s.enabled).length;
const filteredToolsets = useMemo(() => {
return toolsets.filter(
(t) =>
!search ||
t.name.toLowerCase().includes(lowerSearch) ||
t.label.toLowerCase().includes(lowerSearch) ||
t.description.toLowerCase().includes(lowerSearch)
);
}, [toolsets, search, lowerSearch]);
const isCollapsed = (key: string): boolean => {
if (collapsedCategories === "all") return true;
return collapsedCategories.has(key);
};
const toggleCollapse = (key: string) => {
setCollapsedCategories((prev) => {
if (prev === "all") {
// Switching from "all collapsed" → expand just this one
const allKeys = new Set(categoryGroups.map((g) => g.key));
allKeys.delete(key);
return allKeys;
}
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
/* ---- Loading ---- */
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
{/* ═══════════════ Header + Search ═══════════════ */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-muted-foreground" />
<h1 className="text-base font-semibold">Skills</h1>
<span className="text-xs text-muted-foreground">
{enabledCount}/{skills.length} enabled
</span>
</div>
</div>
{/* ═══════════════ Search + Category Filter ═══════════════ */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Search skills and toolsets..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Category pills */}
{allCategories.length > 1 && (
<div className="flex items-center gap-2 flex-wrap">
<Filter className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<button
type="button"
className={`inline-flex items-center px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
!activeCategory
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
}`}
onClick={() => setActiveCategory(null)}
>
All ({skills.length})
</button>
{allCategories.map(({ key, name, count }) => (
<button
key={key}
type="button"
className={`inline-flex items-center px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
activeCategory === key
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
}`}
onClick={() =>
setActiveCategory(activeCategory === key ? null : key)
}
>
{name}
<span className="ml-1 opacity-60">{count}</span>
</button>
))}
</div>
)}
{/* ═══════════════ Skills by Category ═══════════════ */}
<section className="flex flex-col gap-3">
{filteredSkills.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
{skills.length === 0
? "No skills found. Skills are loaded from ~/.hermes/skills/"
: "No skills match your search or filter."}
</CardContent>
</Card>
) : (
categoryGroups.map(({ key, name, skills: catSkills, enabledCount: catEnabled }) => {
const collapsed = isCollapsed(key);
return (
<Card key={key}>
<CardHeader
className="cursor-pointer select-none py-3 px-4"
onClick={() => toggleCollapse(key)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{collapsed ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<CardTitle className="text-sm font-medium">{name}</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{catSkills.length} skill{catSkills.length !== 1 ? "s" : ""}
</Badge>
</div>
<Badge
variant={catEnabled === catSkills.length ? "success" : "outline"}
className="text-[10px]"
>
{catEnabled}/{catSkills.length} enabled
</Badge>
</div>
</CardHeader>
{collapsed ? (
/* Peek: show first few skill names so collapsed isn't blank */
<div className="px-4 pb-3 flex items-center min-h-[28px]">
<p className="text-xs text-muted-foreground/60 truncate leading-normal">
{catSkills.slice(0, 4).map((s) => s.name).join(", ")}
{catSkills.length > 4 && `, +${catSkills.length - 4} more`}
</p>
</div>
) : (
<CardContent className="pt-0 px-4 pb-3">
<div className="grid gap-1">
{catSkills.map((skill) => (
<div
key={skill.name}
className="group flex items-start gap-3 rounded-md px-3 py-2.5 transition-colors hover:bg-muted/40"
>
<div className="pt-0.5 shrink-0">
<Switch
checked={skill.enabled}
onCheckedChange={() => handleToggleSkill(skill)}
disabled={togglingSkills.has(skill.name)}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span
className={`font-mono-ui text-sm ${
skill.enabled
? "text-foreground"
: "text-muted-foreground"
}`}
>
{skill.name}
</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{skill.description || "No description available."}
</p>
</div>
</div>
))}
</div>
</CardContent>
)}
</Card>
);
})
)}
</section>
{/* ═══════════════ Toolsets ═══════════════ */}
<section className="flex flex-col gap-4">
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Wrench className="h-4 w-4" />
Toolsets ({filteredToolsets.length})
</h2>
{filteredToolsets.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No toolsets match the search.
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filteredToolsets.map((ts) => {
// Strip emoji prefix from label for cleaner display
const labelText = ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || ts.name;
const emoji = ts.label.match(/^[\p{Emoji}]+/u)?.[0] || "🔧";
return (
<Card key={ts.name} className="relative overflow-hidden">
<CardContent className="py-4">
<div className="flex items-start gap-3">
<div className="text-2xl shrink-0 leading-none mt-0.5">{emoji}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">{labelText}</span>
<Badge
variant={ts.enabled ? "success" : "outline"}
className="text-[10px]"
>
{ts.enabled ? "active" : "inactive"}
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-2">
{ts.description}
</p>
{ts.enabled && !ts.configured && (
<p className="text-[10px] text-amber-300/80 mb-2">
Setup needed
</p>
)}
{ts.tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{ts.tools.map((tool) => (
<Badge
key={tool}
variant="secondary"
className="text-[10px] font-mono"
>
{tool}
</Badge>
))}
</div>
)}
{ts.tools.length === 0 && (
<span className="text-[10px] text-muted-foreground/60">
{ts.enabled ? `${ts.name} toolset` : "Disabled for CLI"}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,303 @@
import { useEffect, useState } from "react";
import {
Activity,
AlertTriangle,
Clock,
Cpu,
Database,
Radio,
Wifi,
WifiOff,
} from "lucide-react";
import { api } from "@/lib/api";
import type { PlatformStatus, SessionInfo, StatusResponse } from "@/lib/api";
import { timeAgo, isoTimeAgo } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
const PLATFORM_STATE_BADGE: Record<string, { variant: "success" | "warning" | "destructive"; label: string }> = {
connected: { variant: "success", label: "Connected" },
disconnected: { variant: "warning", label: "Disconnected" },
fatal: { variant: "destructive", label: "Error" },
};
const GATEWAY_STATE_DISPLAY: Record<string, { badge: "success" | "warning" | "destructive" | "outline"; label: string }> = {
running: { badge: "success", label: "Running" },
starting: { badge: "warning", label: "Starting" },
startup_failed: { badge: "destructive", label: "Failed" },
stopped: { badge: "outline", label: "Stopped" },
};
function gatewayValue(status: StatusResponse): string {
if (status.gateway_running) return `PID ${status.gateway_pid}`;
if (status.gateway_state === "startup_failed") return "Start failed";
return "Not running";
}
function gatewayBadge(status: StatusResponse) {
const info = status.gateway_state ? GATEWAY_STATE_DISPLAY[status.gateway_state] : null;
if (info) return info;
return status.gateway_running
? { badge: "success" as const, label: "Running" }
: { badge: "outline" as const, label: "Off" };
}
export default function StatusPage() {
const [status, setStatus] = useState<StatusResponse | null>(null);
const [sessions, setSessions] = useState<SessionInfo[]>([]);
useEffect(() => {
const load = () => {
api.getStatus().then(setStatus).catch(() => {});
api.getSessions().then(setSessions).catch(() => {});
};
load();
const interval = setInterval(load, 5000);
return () => clearInterval(interval);
}, []);
if (!status) {
return (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
const gwBadge = gatewayBadge(status);
const items = [
{
icon: Cpu,
label: "Agent",
value: `v${status.version}`,
badgeText: "Live",
badgeVariant: "success" as const,
},
{
icon: Radio,
label: "Gateway",
value: gatewayValue(status),
badgeText: gwBadge.label,
badgeVariant: gwBadge.badge,
},
{
icon: Activity,
label: "Active Sessions",
value: status.active_sessions > 0 ? `${status.active_sessions} running` : "None",
badgeText: status.active_sessions > 0 ? "Live" : "Off",
badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as "success" | "outline",
},
];
const platforms = Object.entries(status.gateway_platforms ?? {});
const activeSessions = sessions.filter((s) => s.is_active);
const recentSessions = sessions.filter((s) => !s.is_active).slice(0, 5);
// Collect alerts that need attention
const alerts: { message: string; detail?: string }[] = [];
if (status.gateway_state === "startup_failed") {
alerts.push({
message: "Gateway failed to start",
detail: status.gateway_exit_reason ?? undefined,
});
}
const failedPlatforms = platforms.filter(([, info]) => info.state === "fatal" || info.state === "disconnected");
for (const [name, info] of failedPlatforms) {
alerts.push({
message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${info.state === "fatal" ? "error" : "disconnected"}`,
detail: info.error_message ?? undefined,
});
}
return (
<div className="flex flex-col gap-6">
{/* Alert banner — breaks grid monotony for critical states */}
{alerts.length > 0 && (
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col gap-2 min-w-0">
{alerts.map((alert, i) => (
<div key={i}>
<p className="text-sm font-medium text-destructive">{alert.message}</p>
{alert.detail && (
<p className="text-xs text-destructive/70 mt-0.5">{alert.detail}</p>
)}
</div>
))}
</div>
</div>
</div>
)}
<div className="grid gap-4 sm:grid-cols-3">
{items.map(({ icon: Icon, label, value, badgeText, badgeVariant }) => (
<Card key={label}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{label}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-display">{value}</div>
{badgeText && (
<Badge variant={badgeVariant} className="mt-2">
{badgeVariant === "success" && (
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
)}
{badgeText}
</Badge>
)}
</CardContent>
</Card>
))}
</div>
{platforms.length > 0 && (
<PlatformsCard platforms={platforms} />
)}
{activeSessions.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Activity className="h-5 w-5 text-success" />
<CardTitle className="text-base">Active Sessions</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{activeSessions.map((s) => (
<div
key={s.id}
className="flex items-center justify-between border border-border p-3"
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{s.title ?? "Untitled"}</span>
<Badge variant="success" className="text-[10px]">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
Live
</Badge>
</div>
<span className="text-xs text-muted-foreground">
<span className="font-mono-ui">{s.model ?? "unknown"}</span> · {s.message_count} msgs · {timeAgo(s.last_active)}
</span>
</div>
</div>
))}
</CardContent>
</Card>
)}
{recentSessions.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Recent Sessions</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{recentSessions.map((s) => (
<div
key={s.id}
className="flex items-center justify-between border border-border p-3"
>
<div className="flex flex-col gap-1">
<span className="font-medium text-sm">{s.title ?? "Untitled"}</span>
<span className="text-xs text-muted-foreground">
<span className="font-mono-ui">{s.model ?? "unknown"}</span> · {s.message_count} msgs · {timeAgo(s.last_active)}
</span>
{s.preview && (
<span className="text-xs text-muted-foreground/70 truncate max-w-md">
{s.preview}
</span>
)}
</div>
<Badge variant="outline" className="text-[10px]">
<Database className="mr-1 h-3 w-3" />
{s.source ?? "local"}
</Badge>
</div>
))}
</CardContent>
</Card>
)}
</div>
);
}
function PlatformsCard({ platforms }: PlatformsCardProps) {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Radio className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Connected Platforms</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{platforms.map(([name, info]) => {
const display = PLATFORM_STATE_BADGE[info.state] ?? {
variant: "outline" as const,
label: info.state,
};
const IconComponent = info.state === "connected" ? Wifi : info.state === "fatal" ? AlertTriangle : WifiOff;
return (
<div
key={name}
className="flex items-center justify-between border border-border p-3"
>
<div className="flex items-center gap-3">
<IconComponent className={`h-4 w-4 ${
info.state === "connected"
? "text-success"
: info.state === "fatal"
? "text-destructive"
: "text-warning"
}`} />
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium capitalize">{name}</span>
{info.error_message && (
<span className="text-xs text-destructive">{info.error_message}</span>
)}
{info.updated_at && (
<span className="text-xs text-muted-foreground">
Last update: {isoTimeAgo(info.updated_at)}
</span>
)}
</div>
</div>
<Badge variant={display.variant}>
{display.variant === "success" && (
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
)}
{display.label}
</Badge>
</div>
);
})}
</CardContent>
</Card>
);
}
interface PlatformsCardProps {
platforms: [string, PlatformStatus][];
}

34
web/tsconfig.app.json Normal file
View file

@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
web/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
web/tsconfig.node.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

22
web/vite.config.ts Normal file
View file

@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: "../hermes_cli/web_dist",
emptyOutDir: true,
},
server: {
proxy: {
"/api": "http://127.0.0.1:9119",
},
},
});