mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 23:11:37 +08:00
Compare commits
3 Commits
codex-port
...
feat/web-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13bbd56438 | ||
|
|
572d7bd9f4 | ||
|
|
6d13dab7c9 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.11
|
||||||
4
cli.py
4
cli.py
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
346
hermes_cli/web_server.py
Normal 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")
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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
48
web/README.md
Normal 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
23
web/eslint.config.js
Normal 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
13
web/index.html
Normal 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
3906
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
web/package.json
Normal file
37
web/package.json
Normal 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
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
51
web/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
web/src/components/AutoField.tsx
Normal file
127
web/src/components/AutoField.tsx
Normal 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;
|
||||||
|
}
|
||||||
15
web/src/components/Toast.tsx
Normal file
15
web/src/components/Toast.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
web/src/components/ui/badge.tsx
Normal file
29
web/src/components/ui/badge.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
38
web/src/components/ui/button.tsx
Normal file
38
web/src/components/ui/button.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
29
web/src/components/ui/card.tsx
Normal file
29
web/src/components/ui/card.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
16
web/src/components/ui/input.tsx
Normal file
16
web/src/components/ui/input.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
web/src/components/ui/label.tsx
Normal file
13
web/src/components/ui/label.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
web/src/components/ui/select.tsx
Normal file
15
web/src/components/ui/select.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
web/src/components/ui/separator.tsx
Normal file
19
web/src/components/ui/separator.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
web/src/components/ui/switch.tsx
Normal file
37
web/src/components/ui/switch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
web/src/components/ui/tabs.tsx
Normal file
49
web/src/components/ui/tabs.tsx
Normal 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
15
web/src/hooks/useToast.ts
Normal 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
39
web/src/index.css
Normal 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
88
web/src/lib/api.ts
Normal 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
23
web/src/lib/nested.ts
Normal 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
6
web/src/lib/utils.ts
Normal 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
10
web/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
149
web/src/pages/ConfigPage.tsx
Normal file
149
web/src/pages/ConfigPage.tsx
Normal 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
240
web/src/pages/EnvPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
295
web/src/pages/StatusPage.tsx
Normal file
295
web/src/pages/StatusPage.tsx
Normal 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
34
web/tsconfig.app.json
Normal 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
7
web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
web/tsconfig.node.json
Normal file
26
web/tsconfig.node.json
Normal 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
22
web/vite.config.ts
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user