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* privvy*
images/ images/
__pycache__/ __pycache__/
hermes_agent.egg-info/ *.egg-info/
wandb/ wandb/
testlogs testlogs
@@ -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/

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) self._show_insights(cmd_original)
elif canonical == "paste": elif canonical == "paste":
self._handle_paste_command() 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": 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

@@ -109,6 +109,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

@@ -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]: def get_env_value(key: str) -> Optional[str]:
"""Get a value from ~/.hermes/.env or environment.""" """Get a value from ~/.hermes/.env or environment."""

View File

@@ -41,6 +41,7 @@ Usage:
hermes sessions browse Interactive session picker with search hermes sessions browse Interactive session picker with search
hermes claw migrate --dry-run # Preview migration without changes hermes claw migrate --dry-run # Preview migration without changes
hermes web # Start web UI dashboard
""" """
import argparse import argparse
@@ -2489,6 +2490,48 @@ def _clear_bytecode_cache(root: Path) -> int:
pass pass
dirnames.clear() # nothing left to recurse into dirnames.clear() # nothing left to recurse into
return removed 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): def _update_via_zip(args):
@@ -2599,6 +2642,20 @@ def _update_via_zip(args):
print(" ⚠ Optional extras failed, installing base dependencies...") print(" ⚠ Optional extras failed, installing base dependencies...")
subprocess.run(pip_cmd + ["install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True) 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 # Sync skills
try: try:
from tools.skills_sync import sync_skills from tools.skills_sync import sync_skills
@@ -3010,6 +3067,22 @@ 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
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()
print("✓ Code updated!") print("✓ Code updated!")
@@ -3268,7 +3341,7 @@ def _coalesce_session_name_args(argv: list) -> list:
"chat", "model", "gateway", "setup", "whatsapp", "login", "logout", "chat", "model", "gateway", "setup", "whatsapp", "login", "logout",
"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", "web",
} }
_SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"} _SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"}
@@ -4813,6 +4886,17 @@ For more help on a command:
help="Shell type (default: bash)", help="Shell type (default: bash)",
) )
completion_parser.set_defaults(func=cmd_completion) 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 # 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 json
import asyncio import asyncio
import os
import time
import logging import logging
import threading import threading
from typing import Dict, Any, List, Optional, Tuple 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"} _AGENT_LOOP_TOOLS = {"todo", "memory", "session_search", "delegate_task"}
_READ_SEARCH_TOOLS = {"read_file", "search_files"} _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( def handle_function_call(
function_name: str, function_name: str,
@@ -390,6 +418,8 @@ def handle_function_call(
Returns: Returns:
Function result as a JSON string. Function result as a JSON string.
""" """
_maybe_reload_env()
# Notify the read-loop tracker when a non-read/search tool runs, # Notify the read-loop tracker when a non-read/search tool runs,
# so the *consecutive* counter resets (reads after other work are fine). # so the *consecutive* counter resets (reads after other work are fine).
if function_name not in _READ_SEARCH_TOOLS: if function_name not in _READ_SEARCH_TOOLS:

View File

@@ -67,6 +67,7 @@ rl = [
"wandb>=0.15.0,<1", "wandb>=0.15.0,<1",
] ]
yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git ; python_version >= '3.12'"] 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 = [ all = [
"hermes-agent[modal]", "hermes-agent[modal]",
"hermes-agent[daytona]", "hermes-agent[daytona]",
@@ -85,6 +86,7 @@ all = [
"hermes-agent[acp]", "hermes-agent[acp]",
"hermes-agent[voice]", "hermes-agent[voice]",
"hermes-agent[dingtalk]", "hermes-agent[dingtalk]",
"hermes-agent[web]",
"hermes-agent[feishu]", "hermes-agent[feishu]",
] ]
@@ -96,6 +98,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", "rl_cli", "utils"] 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] [tool.setuptools.packages.find]
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "honcho_integration", "acp_adapter"] 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" log_success "WhatsApp bridge dependencies installed"
fi 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() { 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",
},
},
});