Compare commits

...

3 Commits

Author SHA1 Message Date
Austin Pickett
13bbd56438 Merge branch 'main' into feat/web-ui 2026-03-30 05:57:50 -07:00
Austin Pickett
572d7bd9f4 chore: fix merge conflicts 2026-03-29 20:52:18 -04:00
Austin Pickett
6d13dab7c9 feat: web ui to manage hermes agent 2026-03-29 20:42:56 -04:00
41 changed files with 5949 additions and 2 deletions

5
.gitignore vendored
View File

@@ -38,7 +38,7 @@ agent-browser/
privvy*
images/
__pycache__/
hermes_agent.egg-info/
*.egg-info/
wandb/
testlogs
@@ -51,6 +51,9 @@ ignored/
.worktrees/
environments/benchmarks/evals/
# Web UI build output
hermes_cli/web_dist/
# Release script temp files
.release_notes.md
mini-swe-agent/

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.11

4
cli.py
View File

@@ -3846,6 +3846,10 @@ class HermesCLI:
self._show_insights(cmd_original)
elif canonical == "paste":
self._handle_paste_command()
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":
with self._busy_command(self._slow_command_status(cmd_original)):
self._reload_mcp()

View File

@@ -109,6 +109,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
cli_only=True, args_hint="[subcommand]",
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",
aliases=("reload_mcp",)),
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",

View File

@@ -1672,6 +1672,51 @@ def save_env_value_secure(key: str, value: str) -> Dict[str, Any]:
}
def delete_env_value(key: str) -> bool:
"""Remove a key from ~/.hermes/.env. Returns True if the key was found and removed."""
env_path = get_env_path()
if not env_path.exists():
return False
read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {}
with open(env_path, **read_kw) as f:
lines = f.readlines()
new_lines = [l for l in lines if not l.strip().startswith(f"{key}=")]
if len(new_lines) == len(lines):
return False
fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_')
try:
with os.fdopen(fd, 'w', **write_kw) as f:
f.writelines(new_lines)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, env_path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
_secure_file(env_path)
os.environ.pop(key, None)
return True
def reload_env() -> int:
"""Re-read ~/.hermes/.env into os.environ. Returns count of vars updated."""
env_vars = load_env()
count = 0
for key, value in env_vars.items():
if os.environ.get(key) != value:
os.environ[key] = value
count += 1
return count
def get_env_value(key: str) -> Optional[str]:
"""Get a value from ~/.hermes/.env or environment."""

View File

@@ -41,6 +41,7 @@ Usage:
hermes sessions browse Interactive session picker with search
hermes claw migrate --dry-run # Preview migration without changes
hermes web # Start web UI dashboard
"""
import argparse
@@ -2489,6 +2490,48 @@ def _clear_bytecode_cache(root: Path) -> int:
pass
dirnames.clear() # nothing left to recurse into
return removed
def cmd_web(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)
web_dist = PROJECT_ROOT / "hermes_cli" / "web_dist"
web_src = PROJECT_ROOT / "web"
if not web_dist.exists() and (web_src / "package.json").exists():
import shutil
npm = shutil.which("npm")
if npm:
import subprocess
print("→ Web UI not built yet — building now...")
r1 = subprocess.run([npm, "install", "--silent"], cwd=web_src, capture_output=True)
if r1.returncode == 0:
r2 = subprocess.run([npm, "run", "build"], cwd=web_src, capture_output=True)
if r2.returncode == 0:
print(" ✓ Web UI built")
else:
print(" ✗ Web UI build failed")
print(" Run manually: cd web && npm install && npm run build")
sys.exit(1)
else:
print(" ✗ npm install failed")
print(" Run manually: cd web && npm install && npm run build")
sys.exit(1)
else:
print("Web UI frontend not built and npm is not available.")
print("Install Node.js, then run: cd web && npm install && npm run build")
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 _update_via_zip(args):
@@ -2599,6 +2642,20 @@ def _update_via_zip(args):
print(" ⚠ Optional extras failed, installing base dependencies...")
subprocess.run(pip_cmd + ["install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True)
# Build web UI frontend
web_dir = PROJECT_ROOT / "web"
if (web_dir / "package.json").exists() and shutil.which("npm"):
print("→ Building web UI...")
r1 = subprocess.run(["npm", "install", "--silent"], cwd=web_dir, capture_output=True)
if r1.returncode == 0:
r2 = subprocess.run(["npm", "run", "build"], cwd=web_dir, capture_output=True)
if r2.returncode == 0:
print(" ✓ Web UI built")
else:
print(" ⚠ Web UI build failed (hermes web will not be available)")
else:
print(" ⚠ Web UI npm install failed (hermes web will not be available)")
# Sync skills
try:
from tools.skills_sync import sync_skills
@@ -3010,6 +3067,22 @@ def cmd_update(args):
print("→ Updating Node.js dependencies...")
subprocess.run(["npm", "install", "--silent"], cwd=PROJECT_ROOT, check=False)
# Build web UI frontend
web_dir = PROJECT_ROOT / "web"
if (web_dir / "package.json").exists():
import shutil
if shutil.which("npm"):
print("→ Building web UI...")
r1 = subprocess.run(["npm", "install", "--silent"], cwd=web_dir, capture_output=True)
if r1.returncode == 0:
r2 = subprocess.run(["npm", "run", "build"], cwd=web_dir, capture_output=True)
if r2.returncode == 0:
print(" ✓ Web UI built")
else:
print(" ⚠ Web UI build failed (hermes web will not be available)")
else:
print(" ⚠ Web UI npm install failed (hermes web will not be available)")
print()
print("✓ Code updated!")
@@ -3268,7 +3341,7 @@ def _coalesce_session_name_args(argv: list) -> list:
"chat", "model", "gateway", "setup", "whatsapp", "login", "logout",
"status", "cron", "doctor", "config", "pairing", "skills", "tools",
"mcp", "sessions", "insights", "version", "update", "uninstall",
"profile",
"profile", "web",
}
_SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"}
@@ -4813,6 +4886,17 @@ For more help on a command:
help="Shell type (default: bash)",
)
completion_parser.set_defaults(func=cmd_completion)
# web command
# =========================================================================
web_parser = subparsers.add_parser(
"web",
help="Start the web UI",
description="Launch the Hermes Agent web dashboard"
)
web_parser.add_argument("--port", type=int, default=9119, help="Port (default 9119)")
web_parser.add_argument("--host", default="127.0.0.1", help="Host (default 127.0.0.1)")
web_parser.add_argument("--no-open", action="store_true", help="Don't open browser automatically")
web_parser.set_defaults(func=cmd_web)
# =========================================================================
# Parse and execute

346
hermes_cli/web_server.py Normal file
View File

@@ -0,0 +1,346 @@
"""
Hermes Agent — Web UI server.
Provides a FastAPI backend serving the Vite/React frontend and REST API
endpoints for managing configuration, environment variables, and sessions.
Usage:
python -m hermes_cli.main web # Start on http://127.0.0.1:9119
python -m hermes_cli.main web --port 8080
"""
import os
import sys
import time
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from hermes_cli import __version__, __release_date__
from hermes_cli.config import (
DEFAULT_CONFIG,
OPTIONAL_ENV_VARS,
get_config_path,
get_env_path,
get_hermes_home,
load_config,
load_env,
save_config,
save_env_value,
delete_env_value,
check_config_version,
redact_key,
)
from gateway.status import get_running_pid, read_runtime_status
try:
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
except ImportError:
raise SystemExit(
"Web UI requires fastapi and uvicorn.\n"
"Run 'hermes web' to auto-install, or: pip install hermes-agent[web]"
)
WEB_DIST = Path(__file__).parent / "web_dist"
app = FastAPI(title="Hermes Agent", version=__version__)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
CONFIG_SCHEMA = {
"model": {
"type": "string",
"description": "Default model for chat",
"category": "general",
},
"provider": {
"type": "select",
"description": "LLM provider",
"options": ["auto", "openrouter", "nous", "anthropic", "openai", "codex", "custom"],
"category": "general",
},
"system_prompt": {
"type": "text",
"description": "System prompt prepended to every conversation",
"category": "general",
},
"toolsets": {
"type": "list",
"description": "Enabled toolsets",
"category": "general",
},
"agent.max_turns": {
"type": "number",
"description": "Maximum agent turns per conversation",
"category": "agent",
},
"terminal.backend": {
"type": "select",
"description": "Terminal execution backend",
"options": ["local", "docker", "ssh", "modal", "daytona", "singularity"],
"category": "terminal",
},
"terminal.timeout": {
"type": "number",
"description": "Command timeout (seconds)",
"category": "terminal",
},
"terminal.cwd": {
"type": "string",
"description": "Working directory for terminal commands",
"category": "terminal",
},
"browser.inactivity_timeout": {
"type": "number",
"description": "Browser inactivity timeout (seconds)",
"category": "browser",
},
"compression.enabled": {
"type": "boolean",
"description": "Enable context compression",
"category": "compression",
},
"compression.threshold": {
"type": "number",
"description": "Context window usage threshold to trigger compression (0-1)",
"category": "compression",
},
"display.compact": {
"type": "boolean",
"description": "Compact display mode",
"category": "display",
},
"display.personality": {
"type": "select",
"description": "Agent personality",
"options": ["kawaii", "professional", "minimal", "hacker"],
"category": "display",
},
"display.show_reasoning": {
"type": "boolean",
"description": "Show model reasoning/thinking",
"category": "display",
},
"display.bell_on_complete": {
"type": "boolean",
"description": "Ring terminal bell when agent finishes",
"category": "display",
},
"tts.provider": {
"type": "select",
"description": "Text-to-speech provider",
"options": ["edge", "elevenlabs", "openai"],
"category": "tts",
},
"checkpoints.enabled": {
"type": "boolean",
"description": "Enable filesystem checkpoints before destructive ops",
"category": "checkpoints",
},
"checkpoints.max_snapshots": {
"type": "number",
"description": "Max checkpoint snapshots per directory",
"category": "checkpoints",
},
}
class ConfigUpdate(BaseModel):
config: dict
class EnvVarUpdate(BaseModel):
key: str
value: str
class EnvVarDelete(BaseModel):
key: str
@app.get("/api/status")
async def get_status():
current_ver, latest_ver = check_config_version()
gateway_pid = get_running_pid()
gateway_running = gateway_pid is not None
gateway_state = None
gateway_platforms: dict = {}
gateway_exit_reason = None
gateway_updated_at = None
runtime = read_runtime_status()
if runtime:
gateway_state = runtime.get("gateway_state")
gateway_platforms = runtime.get("platforms") or {}
gateway_exit_reason = runtime.get("exit_reason")
gateway_updated_at = runtime.get("updated_at")
if not gateway_running:
gateway_state = gateway_state if gateway_state in ("stopped", "startup_failed") else "stopped"
active_sessions = 0
try:
from hermes_state import SessionDB
db = SessionDB()
sessions = db.list_sessions_rich(limit=50)
now = time.time()
active_sessions = sum(
1 for s in sessions
if s.get("ended_at") is None
and (now - s.get("last_active", s.get("started_at", 0))) < 300
)
except Exception:
pass
return {
"version": __version__,
"release_date": __release_date__,
"hermes_home": str(get_hermes_home()),
"config_path": str(get_config_path()),
"env_path": str(get_env_path()),
"config_version": current_ver,
"latest_config_version": latest_ver,
"gateway_running": gateway_running,
"gateway_pid": gateway_pid,
"gateway_state": gateway_state,
"gateway_platforms": gateway_platforms,
"gateway_exit_reason": gateway_exit_reason,
"gateway_updated_at": gateway_updated_at,
"active_sessions": active_sessions,
}
@app.get("/api/sessions")
async def get_sessions():
try:
from hermes_state import SessionDB
db = SessionDB()
sessions = db.list_sessions_rich(limit=20)
now = time.time()
for s in sessions:
s["is_active"] = (
s.get("ended_at") is None
and (now - s.get("last_active", s.get("started_at", 0))) < 300
)
return sessions
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/config")
async def get_config():
return load_config()
@app.get("/api/config/defaults")
async def get_defaults():
return DEFAULT_CONFIG
@app.get("/api/config/schema")
async def get_schema():
return CONFIG_SCHEMA
@app.put("/api/config")
async def update_config(body: ConfigUpdate):
try:
save_config(body.config)
return {"ok": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/env")
async def get_env_vars():
env_on_disk = load_env()
result = {}
for var_name, info in OPTIONAL_ENV_VARS.items():
value = env_on_disk.get(var_name)
result[var_name] = {
"is_set": bool(value),
"redacted_value": redact_key(value) if value else None,
"description": info.get("description", ""),
"url": info.get("url"),
"category": info.get("category", ""),
"is_password": info.get("password", False),
"tools": info.get("tools", []),
"advanced": info.get("advanced", False),
}
return result
@app.put("/api/env")
async def set_env_var(body: EnvVarUpdate):
try:
save_env_value(body.key, body.value)
return {"ok": True, "key": body.key}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.delete("/api/env")
async def remove_env_var(body: EnvVarDelete):
try:
removed = delete_env_value(body.key)
if not removed:
raise HTTPException(status_code=404, detail=f"{body.key} not found in .env")
return {"ok": True, "key": body.key}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
def mount_spa(application: FastAPI):
"""Mount the built SPA. Falls back to index.html for client-side routing."""
if not WEB_DIST.exists():
@application.get("/{full_path:path}")
async def no_frontend(full_path: str):
return JSONResponse(
{"error": "Frontend not built. Run: cd web && npm run build"},
status_code=404,
)
return
application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
@application.get("/{full_path:path}")
async def serve_spa(full_path: str):
file_path = WEB_DIST / full_path
if full_path and file_path.exists() and file_path.is_file():
return FileResponse(file_path)
return FileResponse(WEB_DIST / "index.html")
mount_spa(app)
def start_server(host: str = "127.0.0.1", port: int = 9119, open_browser: bool = True):
"""Start the web UI server."""
import uvicorn
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

@@ -22,6 +22,8 @@ Public API (signatures preserved from the original 2,400-line version):
import json
import asyncio
import os
import time
import logging
import threading
from typing import Dict, Any, List, Optional, Tuple
@@ -364,6 +366,32 @@ def get_tool_definitions(
_AGENT_LOOP_TOOLS = {"todo", "memory", "session_search", "delegate_task"}
_READ_SEARCH_TOOLS = {"read_file", "search_files"}
# Auto-reload .env: check file mtime at most every 5 seconds so new API keys
# take effect without manual /reload or session restart.
_env_last_check: float = 0.0
_env_last_mtime: float = 0.0
_ENV_CHECK_INTERVAL = 5.0
def _maybe_reload_env() -> None:
"""Stat ~/.hermes/.env and reload into os.environ if it changed."""
global _env_last_check, _env_last_mtime
now = time.monotonic()
if now - _env_last_check < _ENV_CHECK_INTERVAL:
return
_env_last_check = now
try:
env_path = os.path.join(os.path.expanduser("~"), ".hermes", ".env")
mtime = os.path.getmtime(env_path)
if mtime != _env_last_mtime:
_env_last_mtime = mtime
from hermes_cli.config import reload_env
reload_env()
except FileNotFoundError:
pass
except Exception:
pass
def handle_function_call(
function_name: str,
@@ -390,6 +418,8 @@ def handle_function_call(
Returns:
Function result as a JSON string.
"""
_maybe_reload_env()
# Notify the read-loop tracker when a non-read/search tool runs,
# so the *consecutive* counter resets (reads after other work are fine).
if function_name not in _READ_SEARCH_TOOLS:

View File

@@ -67,6 +67,7 @@ rl = [
"wandb>=0.15.0,<1",
]
yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git ; python_version >= '3.12'"]
web = ["fastapi>=0.115.0", "uvicorn>=0.34.0"]
all = [
"hermes-agent[modal]",
"hermes-agent[daytona]",
@@ -85,6 +86,7 @@ all = [
"hermes-agent[acp]",
"hermes-agent[voice]",
"hermes-agent[dingtalk]",
"hermes-agent[web]",
"hermes-agent[feishu]",
]
@@ -96,6 +98,9 @@ hermes-acp = "acp_adapter.entry:main"
[tool.setuptools]
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "rl_cli", "utils"]
[tool.setuptools.package-data]
hermes_cli = ["web_dist/**/*"]
[tool.setuptools.packages.find]
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "honcho_integration", "acp_adapter"]

View File

@@ -920,6 +920,15 @@ install_node_deps() {
}
log_success "WhatsApp bridge dependencies installed"
fi
# Build web UI frontend
if [ -f "$INSTALL_DIR/web/package.json" ]; then
log_info "Building web UI..."
cd "$INSTALL_DIR/web"
npm install --silent 2>/dev/null && npm run build 2>/dev/null && \
log_success "Web UI built" || \
log_warn "Web UI build failed (hermes web will not be available)"
fi
}
run_setup_wizard() {

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>

3906
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
web/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"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",
"react-router-dom": "^7.13.1",
"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

51
web/src/App.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { useState } from "react";
import { Activity, KeyRound, Settings } from "lucide-react";
import StatusPage from "@/pages/StatusPage";
import ConfigPage from "@/pages/ConfigPage";
import EnvPage from "@/pages/EnvPage";
const NAV_ITEMS = [
{ id: "status", label: "Status", icon: Activity },
{ id: "config", label: "Config", icon: Settings },
{ id: "env", label: "API Keys", icon: KeyRound },
] as const;
type PageId = (typeof NAV_ITEMS)[number]["id"];
export default function App() {
const [page, setPage] = useState<PageId>("status");
return (
<div className="flex min-h-screen flex-col bg-background text-foreground">
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-14 max-w-5xl items-center gap-6 px-6">
<span className="text-lg font-bold tracking-tight">Hermes Agent</span>
<nav className="flex items-center gap-1">
{NAV_ITEMS.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => setPage(id)}
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors cursor-pointer ${
page === id
? "bg-secondary text-secondary-foreground"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"
}`}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</nav>
</div>
</header>
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-8">
{page === "status" && <StatusPage />}
{page === "config" && <ConfigPage />}
{page === "env" && <EnvPage />}
</main>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
export function AutoField({
schemaKey,
schema,
value,
onChange,
}: AutoFieldProps) {
const label = schemaKey.split(".").pop() ?? schemaKey;
const description = String(schema.description ?? "");
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>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
{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>
);
}
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>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
<Switch checked={!!value} onCheckedChange={onChange} />
</div>
);
}
if (schema.type === "select") {
const options = (schema.options as string[]) ?? [];
return (
<div className="grid gap-2">
<Label className="text-sm">{label}</Label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
<Select value={String(value ?? "")} onChange={(e) => onChange(e.target.value)}>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</Select>
</div>
);
}
if (schema.type === "number") {
return (
<div className="grid gap-2">
<Label className="text-sm">{label}</Label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
<Input
type="number"
value={String(value ?? "")}
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
);
}
if (schema.type === "text") {
return (
<div className="grid gap-2">
<Label className="text-sm">{label}</Label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
<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-2">
<Label className="text-sm">{label}</Label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
<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>
);
}
return (
<div className="grid gap-2">
<Label className="text-sm">{label}</Label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
<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,15 @@
export function Toast({ toast }: { toast: { message: string; type: "success" | "error" } | null }) {
if (!toast) return null;
return (
<div
className={`fixed top-4 right-4 z-50 rounded-lg px-4 py-2 text-sm font-medium shadow-lg ${
toast.type === "success"
? "bg-success/20 text-success border border-success/30"
: "bg-destructive/20 text-destructive border border-destructive/30"
}`}
>
{toast.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 rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
success: "border-transparent bg-success/20 text-success",
warning: "border-transparent bg-warning/20 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 rounded-md text-sm font-medium transition-colors cursor-pointer"
+ " disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md 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(
"rounded-xl border border-border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
}
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-6 pt-0", 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 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors",
"placeholder:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"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(
"text-sm font-medium 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 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"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 rounded-full border-2 border-transparent shadow-sm transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-primary" : "bg-input",
className,
)}
onClick={() => onCheckedChange(!checked)}
>
<span
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform",
checked ? "translate-x-4" : "translate-x-0",
)}
/>
</button>
);
}

View File

@@ -0,0 +1,49 @@
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 gap-1 rounded-lg bg-muted p-1 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(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all cursor-pointer",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
active ? "bg-background text-foreground shadow" : "hover:bg-background/50",
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 };
}

39
web/src/index.css Normal file
View File

@@ -0,0 +1,39 @@
@import "tailwindcss";
@theme {
--color-background: oklch(0.145 0 0);
--color-foreground: oklch(0.95 0 0);
--color-card: oklch(0.17 0 0);
--color-card-foreground: oklch(0.95 0 0);
--color-primary: oklch(0.7 0.15 250);
--color-primary-foreground: oklch(0.98 0 0);
--color-secondary: oklch(0.22 0 0);
--color-secondary-foreground: oklch(0.9 0 0);
--color-muted: oklch(0.2 0 0);
--color-muted-foreground: oklch(0.6 0 0);
--color-accent: oklch(0.25 0 0);
--color-accent-foreground: oklch(0.95 0 0);
--color-destructive: oklch(0.6 0.2 25);
--color-destructive-foreground: oklch(0.98 0 0);
--color-success: oklch(0.7 0.18 155);
--color-warning: oklch(0.75 0.15 75);
--color-border: oklch(0.25 0 0);
--color-input: oklch(0.25 0 0);
--color-ring: oklch(0.7 0.15 250);
}
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
background: var(--color-background);
color: var(--color-foreground);
-webkit-font-smoothing: antialiased;
}
code {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 0.85em;
padding: 0.15em 0.4em;
border-radius: 0.25rem;
background: var(--color-secondary);
}

88
web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,88 @@
const BASE = "";
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();
}
export const api = {
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
getSessions: () => fetchJSON<SessionInfo[]>("/api/sessions"),
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
getSchema: () => fetchJSON<Record<string, unknown>>("/api/config/schema"),
saveConfig: (config: Record<string, unknown>) =>
fetchJSON<{ ok: boolean }>("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ config }),
}),
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 }),
}),
};
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;
model: string;
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;
}
export interface EnvVarInfo {
is_set: boolean;
redacted_value: string | null;
description: string;
url: string | null;
category: string;
is_password: boolean;
tools: string[];
advanced: boolean;
}

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;
}

6
web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

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,149 @@
import { useEffect, useRef, useState } from "react";
import { Download, RotateCcw, Save, Upload } 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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
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 [defaults, setDefaults] = useState<Record<string, unknown> | null>(null);
const [saving, setSaving] = useState(false);
const { toast, showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
api.getConfig().then(setConfig).catch(() => {});
api.getSchema().then((s) => setSchema(s as Record<string, Record<string, unknown>>)).catch(() => {});
api.getDefaults().then(setDefaults).catch(() => {});
}, []);
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 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);
};
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>
);
}
const categories = [...new Set(Object.values(schema).map((s) => String(s.category ?? "general")))];
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
<div className="flex items-center justify-between flex-wrap gap-2">
<p className="text-sm text-muted-foreground">
Edit <code>~/.hermes/config.yaml</code>
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-3 w-3" />
Export
</Button>
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
<Upload className="h-3 w-3" />
Import
</Button>
<input ref={fileInputRef} type="file" accept=".json,.yaml,.yml" className="hidden" onChange={handleImport} />
<Button variant="outline" size="sm" onClick={handleReset}>
<RotateCcw className="h-3 w-3" />
Reset
</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
<Tabs defaultValue={categories[0]}>
{(active, setActive) => (
<>
<TabsList className="flex-wrap">
{categories.map((cat) => (
<TabsTrigger key={cat} value={cat} active={active === cat} onClick={() => setActive(cat)}>
{cat.charAt(0).toUpperCase() + cat.slice(1)}
</TabsTrigger>
))}
</TabsList>
<Card>
<CardHeader>
<CardTitle className="text-base capitalize">{active}</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
{Object.entries(schema)
.filter(([, s]) => String(s.category ?? "general") === active)
.map(([key, s]) => (
<AutoField
key={key}
schemaKey={key}
schema={s}
value={getNestedValue(config, key)}
onChange={(v) => setConfig(setNestedValue(config, key, v))}
/>
))}
</CardContent>
</Card>
</>
)}
</Tabs>
</div>
);
}

240
web/src/pages/EnvPage.tsx Normal file
View File

@@ -0,0 +1,240 @@
import { useEffect, useState } from "react";
import {
ExternalLink,
Eye,
EyeOff,
KeyRound,
MessageSquare,
Save,
Settings,
Trash2,
Zap,
} 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";
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 },
};
export default function EnvPage() {
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null);
const [edits, setEdits] = useState<Record<string, string>>({});
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
const [saving, setSaving] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
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 next = { ...prev };
delete next[key];
return next;
});
showToast(`${key} saved — active sessions will pick this up automatically`, "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 next = { ...prev };
delete next[key];
return next;
});
showToast(`${key} removed`, "success");
} catch (e) {
showToast(`Failed to remove ${key}: ${e}`, "error");
} finally {
setSaving(null);
}
};
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 categories = Object.keys(CATEGORY_META);
const grouped = categories.map((cat) => ({
...CATEGORY_META[cat],
category: cat,
entries: Object.entries(vars).filter(
([, info]) => info.category === cat && (showAdvanced || !info.advanced),
),
}));
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-xs text-muted-foreground/70">
Changes are saved to disk immediately. Active sessions pick up new keys automatically within a few seconds.
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? "Hide Advanced" : "Show Advanced"}
</Button>
</div>
{grouped.map(({ label, icon: Icon, entries, category }) => {
if (entries.length === 0) return null;
return (
<Card key={category}>
<CardHeader>
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{label}</CardTitle>
</div>
<CardDescription>
{entries.filter(([, i]) => i.is_set).length} of {entries.length} configured
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
{entries.map(([key, info]) => (
<div key={key} className="grid gap-2 rounded-lg 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 text-xs">{key}</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-xs text-primary hover:underline"
>
Get key <ExternalLink className="h-3 w-3" />
</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-[10px]">
{tool}
</Badge>
))}
</div>
)}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
type={showValues[key] ? "text" : "password"}
value={
edits[key] !== undefined
? edits[key]
: info.is_set
? info.redacted_value ?? ""
: ""
}
onChange={(e) => setEdits({ ...edits, [key]: e.target.value })}
onFocus={() => {
if (edits[key] === undefined && info.is_set) {
setEdits({ ...edits, [key]: "" });
}
}}
placeholder={info.is_set ? "(click to replace)" : "Enter value..."}
className="pr-9 font-mono text-xs"
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
onClick={() => setShowValues({ ...showValues, [key]: !showValues[key] })}
>
{showValues[key] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{edits[key] !== undefined && (
<Button
size="sm"
onClick={() => handleSave(key)}
disabled={saving === key || !edits[key]}
>
<Save className="h-3 w-3" />
{saving === key ? "..." : "Save"}
</Button>
)}
{info.is_set && edits[key] === undefined && (
<Button
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleClear(key)}
disabled={saving === key}
>
<Trash2 className="h-3 w-3" />
{saving === key ? "..." : "Clear"}
</Button>
)}
</div>
</div>
))}
</CardContent>
</Card>
);
})}
</div>
);
}

View File

@@ -0,0 +1,295 @@
import { useEffect, useState } from "react";
import {
Activity,
AlertTriangle,
Clock,
Cpu,
Database,
Radio,
Shield,
Wifi,
WifiOff,
} from "lucide-react";
import { api } from "@/lib/api";
import type { PlatformStatus, SessionInfo, StatusResponse } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
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`;
}
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`;
}
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 configNeedsMigration = status.config_version < status.latest_config_version;
const gwBadge = gatewayBadge(status);
const items = [
{
icon: Cpu,
label: "Agent",
value: `v${status.version}`,
badgeText: "Live",
badgeVariant: "success" as const,
},
{
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",
},
{
icon: Radio,
label: "Gateway",
value: gatewayValue(status),
badgeText: gwBadge.label,
badgeVariant: gwBadge.badge,
},
{
icon: Shield,
label: "Config Version",
value: `v${status.config_version}`,
badgeText: configNeedsMigration ? "Migrate" : "Current",
badgeVariant: (configNeedsMigration ? "warning" : "success") as "warning" | "success",
},
];
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);
return (
<div className="flex flex-col gap-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{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">{value}</div>
<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>
{label === "Gateway" && !status.gateway_running && status.gateway_exit_reason && (
<p className="mt-2 text-xs text-destructive">{status.gateway_exit_reason}</p>
)}
</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 rounded-lg 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">
{s.model} · {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 rounded-lg 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">
{s.model} · {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}
</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 rounded-lg 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",
},
},
});