mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: web UI dashboard for managing Hermes Agent (#8756)
* feat: web UI dashboard for managing Hermes Agent (salvage of #8204/#7621) Adds an embedded web UI dashboard accessible via `hermes web`: - Status page: agent version, active sessions, gateway status, connected platforms - Config editor: schema-driven form with tabbed categories, import/export, reset - API Keys page: set, clear, and view redacted values with category grouping - Sessions, Skills, Cron, Logs, and Analytics pages Backend: - hermes_cli/web_server.py: FastAPI server with REST endpoints - hermes_cli/config.py: reload_env() utility for hot-reloading .env - hermes_cli/main.py: `hermes web` subcommand (--port, --host, --no-open) - cli.py / commands.py: /reload slash command for .env hot-reload - pyproject.toml: [web] optional dependency extra (fastapi + uvicorn) - Both update paths (git + zip) auto-build web frontend when npm available Frontend: - Vite + React + TypeScript + Tailwind v4 SPA in web/ - shadcn/ui-style components, Nous design language - Auto-refresh status page, toast notifications, masked password inputs Security: - Path traversal guard (resolve().is_relative_to()) on SPA file serving - CORS localhost-only via allow_origin_regex - Generic error messages (no internal leak), SessionDB handles closed properly Tests: 47 tests covering reload_env, redact_key, API endpoints, schema generation, path traversal, category merging, internal key stripping, and full config round-trip. Original work by @austinpickett (PR #1813), salvaged by @kshitijk4poor (PR #7621 → #8204), re-salvaged onto current main with stale-branch regressions removed. * fix(web): clean up status page cards, always rebuild on `hermes web` - Remove config version migration alert banner from status page - Remove config version card (internal noise, not surfaced in TUI) - Reorder status cards: Agent → Gateway → Active Sessions (3-col grid) - `hermes web` now always rebuilds from source before serving, preventing stale web_dist when editing frontend files * feat(web): full-text search across session messages - Add GET /api/sessions/search endpoint backed by FTS5 - Auto-append prefix wildcards so partial words match (e.g. 'nimb' → 'nimby') - Debounced search (300ms) with spinner in the search icon slot - Search results show FTS5 snippets with highlighted match delimiters - Expanding a search hit auto-scrolls to the first matching message - Matching messages get a warning ring + 'match' badge - Inline term highlighting within Markdown (text, bold, italic, headings, lists) - Clear button (x) on search input for quick reset --------- Co-authored-by: emozilla <emozilla@nousresearch.com>
This commit is contained in:
parent
c052cf0eea
commit
e2a9b5369f
55 changed files with 10187 additions and 3 deletions
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Auto-generated files — collapse diffs and exclude from language stats
|
||||||
|
web/package-lock.json linguist-generated=true
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -51,6 +51,9 @@ ignored/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
environments/benchmarks/evals/
|
environments/benchmarks/evals/
|
||||||
|
|
||||||
|
# Web UI build output
|
||||||
|
hermes_cli/web_dist/
|
||||||
|
|
||||||
# Release script temp files
|
# Release script temp files
|
||||||
.release_notes.md
|
.release_notes.md
|
||||||
mini-swe-agent/
|
mini-swe-agent/
|
||||||
|
|
|
||||||
4
cli.py
4
cli.py
|
|
@ -5419,6 +5419,10 @@ class HermesCLI:
|
||||||
self._handle_paste_command()
|
self._handle_paste_command()
|
||||||
elif canonical == "image":
|
elif canonical == "image":
|
||||||
self._handle_image_command(cmd_original)
|
self._handle_image_command(cmd_original)
|
||||||
|
elif canonical == "reload":
|
||||||
|
from hermes_cli.config import reload_env
|
||||||
|
count = reload_env()
|
||||||
|
print(f" Reloaded .env ({count} var(s) updated)")
|
||||||
elif canonical == "reload-mcp":
|
elif canonical == "reload-mcp":
|
||||||
with self._busy_command(self._slow_command_status(cmd_original)):
|
with self._busy_command(self._slow_command_status(cmd_original)):
|
||||||
self._reload_mcp()
|
self._reload_mcp()
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||||
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
||||||
cli_only=True, args_hint="[subcommand]",
|
cli_only=True, args_hint="[subcommand]",
|
||||||
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
|
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
|
||||||
|
CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills"),
|
||||||
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
|
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
|
||||||
aliases=("reload_mcp",)),
|
aliases=("reload_mcp",)),
|
||||||
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
|
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
|
||||||
|
|
|
||||||
|
|
@ -2636,6 +2636,28 @@ def save_env_value_secure(key: str, value: str) -> Dict[str, Any]:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def reload_env() -> int:
|
||||||
|
"""Re-read ~/.hermes/.env into os.environ. Returns count of vars updated.
|
||||||
|
|
||||||
|
Adds/updates vars that changed and removes vars that were deleted from
|
||||||
|
the .env file (but only vars known to Hermes — OPTIONAL_ENV_VARS and
|
||||||
|
_EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).
|
||||||
|
"""
|
||||||
|
env_vars = load_env()
|
||||||
|
known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS
|
||||||
|
count = 0
|
||||||
|
for key, value in env_vars.items():
|
||||||
|
if os.environ.get(key) != value:
|
||||||
|
os.environ[key] = value
|
||||||
|
count += 1
|
||||||
|
# Remove known Hermes vars that are no longer in .env
|
||||||
|
for key in known_keys:
|
||||||
|
if key not in env_vars and key in os.environ:
|
||||||
|
del os.environ[key]
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
def get_env_value(key: str) -> Optional[str]:
|
def get_env_value(key: str) -> Optional[str]:
|
||||||
"""Get a value from ~/.hermes/.env or environment."""
|
"""Get a value from ~/.hermes/.env or environment."""
|
||||||
# Check environment first
|
# Check environment first
|
||||||
|
|
|
||||||
|
|
@ -2976,6 +2976,44 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0)
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||||
|
"""Build the web UI frontend if npm is available.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
web_dir: Path to the ``web/`` source directory.
|
||||||
|
fatal: If True, print error guidance and return False on failure
|
||||||
|
instead of a soft warning (used by ``hermes web``).
|
||||||
|
|
||||||
|
Returns True if the build succeeded or was skipped (no package.json).
|
||||||
|
"""
|
||||||
|
if not (web_dir / "package.json").exists():
|
||||||
|
return True
|
||||||
|
import shutil
|
||||||
|
npm = shutil.which("npm")
|
||||||
|
if not npm:
|
||||||
|
if fatal:
|
||||||
|
print("Web UI frontend not built and npm is not available.")
|
||||||
|
print("Install Node.js, then run: cd web && npm install && npm run build")
|
||||||
|
return not fatal
|
||||||
|
print("→ Building web UI...")
|
||||||
|
r1 = subprocess.run([npm, "install", "--silent"], cwd=web_dir, capture_output=True)
|
||||||
|
if r1.returncode != 0:
|
||||||
|
print(f" {'✗' if fatal else '⚠'} Web UI npm install failed"
|
||||||
|
+ ("" if fatal else " (hermes web will not be available)"))
|
||||||
|
if fatal:
|
||||||
|
print(" Run manually: cd web && npm install && npm run build")
|
||||||
|
return False
|
||||||
|
r2 = subprocess.run([npm, "run", "build"], cwd=web_dir, capture_output=True)
|
||||||
|
if r2.returncode != 0:
|
||||||
|
print(f" {'✗' if fatal else '⚠'} Web UI build failed"
|
||||||
|
+ ("" if fatal else " (hermes web will not be available)"))
|
||||||
|
if fatal:
|
||||||
|
print(" Run manually: cd web && npm install && npm run build")
|
||||||
|
return False
|
||||||
|
print(" ✓ Web UI built")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _update_via_zip(args):
|
def _update_via_zip(args):
|
||||||
"""Update Hermes Agent by downloading a ZIP archive.
|
"""Update Hermes Agent by downloading a ZIP archive.
|
||||||
|
|
||||||
|
|
@ -3070,7 +3108,10 @@ def _update_via_zip(args):
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
_install_python_dependencies_with_optional_fallback(pip_cmd)
|
_install_python_dependencies_with_optional_fallback(pip_cmd)
|
||||||
|
|
||||||
|
# Build web UI frontend (optional — requires npm)
|
||||||
|
_build_web_ui(PROJECT_ROOT / "web")
|
||||||
|
|
||||||
# Sync skills
|
# Sync skills
|
||||||
try:
|
try:
|
||||||
from tools.skills_sync import sync_skills
|
from tools.skills_sync import sync_skills
|
||||||
|
|
@ -3817,7 +3858,10 @@ def cmd_update(args):
|
||||||
if shutil.which("npm"):
|
if shutil.which("npm"):
|
||||||
print("→ Updating Node.js dependencies...")
|
print("→ Updating Node.js dependencies...")
|
||||||
subprocess.run(["npm", "install", "--silent"], cwd=PROJECT_ROOT, check=False)
|
subprocess.run(["npm", "install", "--silent"], cwd=PROJECT_ROOT, check=False)
|
||||||
|
|
||||||
|
# Build web UI frontend (optional — requires npm)
|
||||||
|
_build_web_ui(PROJECT_ROOT / "web")
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("✓ Code updated!")
|
print("✓ Code updated!")
|
||||||
|
|
||||||
|
|
@ -4099,7 +4143,7 @@ def _coalesce_session_name_args(argv: list) -> list:
|
||||||
"chat", "model", "gateway", "setup", "whatsapp", "login", "logout", "auth",
|
"chat", "model", "gateway", "setup", "whatsapp", "login", "logout", "auth",
|
||||||
"status", "cron", "doctor", "config", "pairing", "skills", "tools",
|
"status", "cron", "doctor", "config", "pairing", "skills", "tools",
|
||||||
"mcp", "sessions", "insights", "version", "update", "uninstall",
|
"mcp", "sessions", "insights", "version", "update", "uninstall",
|
||||||
"profile",
|
"profile", "dashboard",
|
||||||
}
|
}
|
||||||
_SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"}
|
_SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"}
|
||||||
|
|
||||||
|
|
@ -4377,6 +4421,27 @@ def cmd_profile(args):
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_dashboard(args):
|
||||||
|
"""Start the web UI server."""
|
||||||
|
try:
|
||||||
|
import fastapi # noqa: F401
|
||||||
|
import uvicorn # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
print("Web UI dependencies not installed.")
|
||||||
|
print("Install them with: pip install hermes-agent[web]")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not _build_web_ui(PROJECT_ROOT / "web", fatal=True):
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
from hermes_cli.web_server import start_server
|
||||||
|
start_server(
|
||||||
|
host=args.host,
|
||||||
|
port=args.port,
|
||||||
|
open_browser=not args.no_open,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def cmd_completion(args):
|
def cmd_completion(args):
|
||||||
"""Print shell completion script."""
|
"""Print shell completion script."""
|
||||||
from hermes_cli.profiles import generate_bash_completion, generate_zsh_completion
|
from hermes_cli.profiles import generate_bash_completion, generate_zsh_completion
|
||||||
|
|
@ -5862,6 +5927,19 @@ Examples:
|
||||||
)
|
)
|
||||||
completion_parser.set_defaults(func=cmd_completion)
|
completion_parser.set_defaults(func=cmd_completion)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# dashboard command
|
||||||
|
# =========================================================================
|
||||||
|
dashboard_parser = subparsers.add_parser(
|
||||||
|
"dashboard",
|
||||||
|
help="Start the web UI dashboard",
|
||||||
|
description="Launch the Hermes Agent web dashboard for managing config, API keys, and sessions",
|
||||||
|
)
|
||||||
|
dashboard_parser.add_argument("--port", type=int, default=9119, help="Port (default 9119)")
|
||||||
|
dashboard_parser.add_argument("--host", default="127.0.0.1", help="Host (default 127.0.0.1)")
|
||||||
|
dashboard_parser.add_argument("--no-open", action="store_true", help="Don't open browser automatically")
|
||||||
|
dashboard_parser.set_defaults(func=cmd_dashboard)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# logs command
|
# logs command
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
929
hermes_cli/web_server.py
Normal file
929
hermes_cli/web_server.py
Normal file
|
|
@ -0,0 +1,929 @@
|
||||||
|
"""
|
||||||
|
Hermes Agent — Web UI server.
|
||||||
|
|
||||||
|
Provides a FastAPI backend serving the Vite/React frontend and REST API
|
||||||
|
endpoints for managing configuration, environment variables, and sessions.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m hermes_cli.main web # Start on http://127.0.0.1:9119
|
||||||
|
python -m hermes_cli.main web --port 8080
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from hermes_cli import __version__, __release_date__
|
||||||
|
from hermes_cli.config import (
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
OPTIONAL_ENV_VARS,
|
||||||
|
get_config_path,
|
||||||
|
get_env_path,
|
||||||
|
get_hermes_home,
|
||||||
|
load_config,
|
||||||
|
load_env,
|
||||||
|
save_config,
|
||||||
|
save_env_value,
|
||||||
|
remove_env_value,
|
||||||
|
check_config_version,
|
||||||
|
redact_key,
|
||||||
|
)
|
||||||
|
from gateway.status import get_running_pid, read_runtime_status
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
except ImportError:
|
||||||
|
raise SystemExit(
|
||||||
|
"Web UI requires fastapi and uvicorn.\n"
|
||||||
|
"Run 'hermes web' to auto-install, or: pip install hermes-agent[web]"
|
||||||
|
)
|
||||||
|
|
||||||
|
WEB_DIST = Path(__file__).parent / "web_dist"
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI(title="Hermes Agent", version=__version__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session token for protecting sensitive endpoints (reveal).
|
||||||
|
# Generated fresh on every server start — dies when the process exits.
|
||||||
|
# Injected into the SPA HTML so only the legitimate web UI can use it.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_SESSION_TOKEN = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
# Simple rate limiter for the reveal endpoint
|
||||||
|
_reveal_timestamps: List[float] = []
|
||||||
|
_REVEAL_MAX_PER_WINDOW = 5
|
||||||
|
_REVEAL_WINDOW_SECONDS = 30
|
||||||
|
|
||||||
|
# CORS: restrict to localhost origins only. The web UI is intended to run
|
||||||
|
# locally; binding to 0.0.0.0 with allow_origins=["*"] would let any website
|
||||||
|
# read/modify config and secrets.
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$",
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config schema — auto-generated from DEFAULT_CONFIG
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Manual overrides for fields that need select options or custom types
|
||||||
|
_SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
||||||
|
"model": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Default model (e.g. anthropic/claude-sonnet-4.6)",
|
||||||
|
"category": "general",
|
||||||
|
},
|
||||||
|
"terminal.backend": {
|
||||||
|
"type": "select",
|
||||||
|
"description": "Terminal execution backend",
|
||||||
|
"options": ["local", "docker", "ssh", "modal", "daytona", "singularity"],
|
||||||
|
},
|
||||||
|
"terminal.modal_mode": {
|
||||||
|
"type": "select",
|
||||||
|
"description": "Modal sandbox mode",
|
||||||
|
"options": ["sandbox", "function"],
|
||||||
|
},
|
||||||
|
"tts.provider": {
|
||||||
|
"type": "select",
|
||||||
|
"description": "Text-to-speech provider",
|
||||||
|
"options": ["edge", "elevenlabs", "openai", "neutts"],
|
||||||
|
},
|
||||||
|
"stt.provider": {
|
||||||
|
"type": "select",
|
||||||
|
"description": "Speech-to-text provider",
|
||||||
|
"options": ["local", "openai", "mistral"],
|
||||||
|
},
|
||||||
|
"display.skin": {
|
||||||
|
"type": "select",
|
||||||
|
"description": "CLI visual theme",
|
||||||
|
"options": ["default", "ares", "mono", "slate"],
|
||||||
|
},
|
||||||
|
"display.resume_display": {
|
||||||
|
"type": "select",
|
||||||
|
"description": "How resumed sessions display history",
|
||||||
|
"options": ["minimal", "full", "off"],
|
||||||
|
},
|
||||||
|
"display.busy_input_mode": {
|
||||||
|
"type": "select",
|
||||||
|
"description": "Input behavior while agent is running",
|
||||||
|
"options": ["queue", "interrupt", "block"],
|
||||||
|
},
|
||||||
|
"memory.provider": {
|
||||||
|
"type": "select",
|
||||||
|
"description": "Memory provider plugin",
|
||||||
|
"options": ["builtin", "honcho"],
|
||||||
|
},
|
||||||
|
"approvals.mode": {
|
||||||
|
"type": "select",
|
||||||
|
"description": "Dangerous command approval mode",
|
||||||
|
"options": ["ask", "yolo", "deny"],
|
||||||
|
},
|
||||||
|
"context.engine": {
|
||||||
|
"type": "select",
|
||||||
|
"description": "Context management engine",
|
||||||
|
"options": ["default", "custom"],
|
||||||
|
},
|
||||||
|
"human_delay.mode": {
|
||||||
|
"type": "select",
|
||||||
|
"description": "Simulated typing delay mode",
|
||||||
|
"options": ["off", "typing", "fixed"],
|
||||||
|
},
|
||||||
|
"logging.level": {
|
||||||
|
"type": "select",
|
||||||
|
"description": "Log level for agent.log",
|
||||||
|
"options": ["DEBUG", "INFO", "WARNING", "ERROR"],
|
||||||
|
},
|
||||||
|
"agent.service_tier": {
|
||||||
|
"type": "select",
|
||||||
|
"description": "API service tier (OpenAI/Anthropic)",
|
||||||
|
"options": ["", "auto", "default", "flex"],
|
||||||
|
},
|
||||||
|
"delegation.reasoning_effort": {
|
||||||
|
"type": "select",
|
||||||
|
"description": "Reasoning effort for delegated subagents",
|
||||||
|
"options": ["", "low", "medium", "high"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Categories with fewer fields get merged into "general" to avoid tab sprawl.
|
||||||
|
_CATEGORY_MERGE: Dict[str, str] = {
|
||||||
|
"privacy": "security",
|
||||||
|
"context": "agent",
|
||||||
|
"skills": "agent",
|
||||||
|
"cron": "agent",
|
||||||
|
"network": "agent",
|
||||||
|
"checkpoints": "agent",
|
||||||
|
"approvals": "security",
|
||||||
|
"human_delay": "display",
|
||||||
|
"smart_model_routing": "agent",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display order for tabs — unlisted categories sort alphabetically after these.
|
||||||
|
_CATEGORY_ORDER = [
|
||||||
|
"general", "agent", "terminal", "display", "delegation",
|
||||||
|
"memory", "compression", "security", "browser", "voice",
|
||||||
|
"tts", "stt", "logging", "discord", "auxiliary",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_type(value: Any) -> str:
|
||||||
|
"""Infer a UI field type from a Python value."""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "boolean"
|
||||||
|
if isinstance(value, int):
|
||||||
|
return "number"
|
||||||
|
if isinstance(value, float):
|
||||||
|
return "number"
|
||||||
|
if isinstance(value, list):
|
||||||
|
return "list"
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return "object"
|
||||||
|
return "string"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_schema_from_config(
|
||||||
|
config: Dict[str, Any],
|
||||||
|
prefix: str = "",
|
||||||
|
) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""Walk DEFAULT_CONFIG and produce a flat dot-path → field schema dict."""
|
||||||
|
schema: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for key, value in config.items():
|
||||||
|
full_key = f"{prefix}.{key}" if prefix else key
|
||||||
|
|
||||||
|
# Skip internal / version keys
|
||||||
|
if full_key in ("_config_version",):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Category is the first path component for nested keys, or "general"
|
||||||
|
# for top-level scalar fields (model, toolsets, timezone, etc.).
|
||||||
|
if prefix:
|
||||||
|
category = prefix.split(".")[0]
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
category = key
|
||||||
|
else:
|
||||||
|
category = "general"
|
||||||
|
|
||||||
|
if isinstance(value, dict):
|
||||||
|
# Recurse into nested dicts
|
||||||
|
schema.update(_build_schema_from_config(value, full_key))
|
||||||
|
else:
|
||||||
|
entry: Dict[str, Any] = {
|
||||||
|
"type": _infer_type(value),
|
||||||
|
"description": full_key.replace(".", " → ").replace("_", " ").title(),
|
||||||
|
"category": category,
|
||||||
|
}
|
||||||
|
# Apply manual overrides
|
||||||
|
if full_key in _SCHEMA_OVERRIDES:
|
||||||
|
entry.update(_SCHEMA_OVERRIDES[full_key])
|
||||||
|
# Merge small categories
|
||||||
|
entry["category"] = _CATEGORY_MERGE.get(entry["category"], entry["category"])
|
||||||
|
schema[full_key] = entry
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = _build_schema_from_config(DEFAULT_CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigUpdate(BaseModel):
|
||||||
|
config: dict
|
||||||
|
|
||||||
|
|
||||||
|
class EnvVarUpdate(BaseModel):
|
||||||
|
key: str
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
|
class EnvVarDelete(BaseModel):
|
||||||
|
key: str
|
||||||
|
|
||||||
|
|
||||||
|
class EnvVarReveal(BaseModel):
|
||||||
|
key: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/status")
|
||||||
|
async def get_status():
|
||||||
|
current_ver, latest_ver = check_config_version()
|
||||||
|
|
||||||
|
gateway_pid = get_running_pid()
|
||||||
|
gateway_running = gateway_pid is not None
|
||||||
|
|
||||||
|
gateway_state = None
|
||||||
|
gateway_platforms: dict = {}
|
||||||
|
gateway_exit_reason = None
|
||||||
|
gateway_updated_at = None
|
||||||
|
configured_gateway_platforms: set[str] | None = None
|
||||||
|
try:
|
||||||
|
from gateway.config import load_gateway_config
|
||||||
|
|
||||||
|
gateway_config = load_gateway_config()
|
||||||
|
configured_gateway_platforms = {
|
||||||
|
platform.value for platform in gateway_config.get_connected_platforms()
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
configured_gateway_platforms = None
|
||||||
|
|
||||||
|
runtime = read_runtime_status()
|
||||||
|
if runtime:
|
||||||
|
gateway_state = runtime.get("gateway_state")
|
||||||
|
gateway_platforms = runtime.get("platforms") or {}
|
||||||
|
if configured_gateway_platforms is not None:
|
||||||
|
gateway_platforms = {
|
||||||
|
key: value
|
||||||
|
for key, value in gateway_platforms.items()
|
||||||
|
if key in configured_gateway_platforms
|
||||||
|
}
|
||||||
|
gateway_exit_reason = runtime.get("exit_reason")
|
||||||
|
gateway_updated_at = runtime.get("updated_at")
|
||||||
|
if not gateway_running:
|
||||||
|
gateway_state = gateway_state if gateway_state in ("stopped", "startup_failed") else "stopped"
|
||||||
|
gateway_platforms = {}
|
||||||
|
|
||||||
|
active_sessions = 0
|
||||||
|
try:
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
db = SessionDB()
|
||||||
|
try:
|
||||||
|
sessions = db.list_sessions_rich(limit=50)
|
||||||
|
now = time.time()
|
||||||
|
active_sessions = sum(
|
||||||
|
1 for s in sessions
|
||||||
|
if s.get("ended_at") is None
|
||||||
|
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": __version__,
|
||||||
|
"release_date": __release_date__,
|
||||||
|
"hermes_home": str(get_hermes_home()),
|
||||||
|
"config_path": str(get_config_path()),
|
||||||
|
"env_path": str(get_env_path()),
|
||||||
|
"config_version": current_ver,
|
||||||
|
"latest_config_version": latest_ver,
|
||||||
|
"gateway_running": gateway_running,
|
||||||
|
"gateway_pid": gateway_pid,
|
||||||
|
"gateway_state": gateway_state,
|
||||||
|
"gateway_platforms": gateway_platforms,
|
||||||
|
"gateway_exit_reason": gateway_exit_reason,
|
||||||
|
"gateway_updated_at": gateway_updated_at,
|
||||||
|
"active_sessions": active_sessions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/sessions")
|
||||||
|
async def get_sessions():
|
||||||
|
try:
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
db = SessionDB()
|
||||||
|
try:
|
||||||
|
sessions = db.list_sessions_rich(limit=20)
|
||||||
|
now = time.time()
|
||||||
|
for s in sessions:
|
||||||
|
s["is_active"] = (
|
||||||
|
s.get("ended_at") is None
|
||||||
|
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
||||||
|
)
|
||||||
|
return sessions
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
_log.exception("GET /api/sessions failed")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/sessions/search")
|
||||||
|
async def search_sessions(q: str = "", limit: int = 20):
|
||||||
|
"""Full-text search across session message content using FTS5."""
|
||||||
|
if not q or not q.strip():
|
||||||
|
return {"results": []}
|
||||||
|
try:
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
db = SessionDB()
|
||||||
|
try:
|
||||||
|
# Auto-add prefix wildcards so partial words match
|
||||||
|
# e.g. "nimb" → "nimb*" matches "nimby"
|
||||||
|
# Preserve quoted phrases and existing wildcards as-is
|
||||||
|
import re
|
||||||
|
terms = []
|
||||||
|
for token in re.findall(r'"[^"]*"|\S+', q.strip()):
|
||||||
|
if token.startswith('"') or token.endswith("*"):
|
||||||
|
terms.append(token)
|
||||||
|
else:
|
||||||
|
terms.append(token + "*")
|
||||||
|
prefix_query = " ".join(terms)
|
||||||
|
matches = db.search_messages(query=prefix_query, limit=limit)
|
||||||
|
# Group by session_id — return unique sessions with their best snippet
|
||||||
|
seen: dict = {}
|
||||||
|
for m in matches:
|
||||||
|
sid = m["session_id"]
|
||||||
|
if sid not in seen:
|
||||||
|
seen[sid] = {
|
||||||
|
"session_id": sid,
|
||||||
|
"snippet": m.get("snippet", ""),
|
||||||
|
"role": m.get("role"),
|
||||||
|
"source": m.get("source"),
|
||||||
|
"model": m.get("model"),
|
||||||
|
"session_started": m.get("session_started"),
|
||||||
|
}
|
||||||
|
return {"results": list(seen.values())}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
_log.exception("GET /api/sessions/search failed")
|
||||||
|
raise HTTPException(status_code=500, detail="Search failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Normalize config for the web UI.
|
||||||
|
|
||||||
|
Hermes supports ``model`` as either a bare string (``"anthropic/claude-sonnet-4"``)
|
||||||
|
or a dict (``{default: ..., provider: ..., base_url: ...}``). The schema is built
|
||||||
|
from DEFAULT_CONFIG where ``model`` is a string, but user configs often have the
|
||||||
|
dict form. Normalize to the string form so the frontend schema matches.
|
||||||
|
"""
|
||||||
|
config = dict(config) # shallow copy
|
||||||
|
model_val = config.get("model")
|
||||||
|
if isinstance(model_val, dict):
|
||||||
|
config["model"] = model_val.get("default", model_val.get("name", ""))
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/config")
|
||||||
|
async def get_config():
|
||||||
|
config = _normalize_config_for_web(load_config())
|
||||||
|
# Strip internal keys that the frontend shouldn't see or send back
|
||||||
|
return {k: v for k, v in config.items() if not k.startswith("_")}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/config/defaults")
|
||||||
|
async def get_defaults():
|
||||||
|
return DEFAULT_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/config/schema")
|
||||||
|
async def get_schema():
|
||||||
|
return {"fields": CONFIG_SCHEMA, "category_order": _CATEGORY_ORDER}
|
||||||
|
|
||||||
|
|
||||||
|
def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Reverse _normalize_config_for_web before saving.
|
||||||
|
|
||||||
|
Reconstructs ``model`` as a dict by reading the current on-disk config
|
||||||
|
to recover model subkeys (provider, base_url, api_mode, etc.) that were
|
||||||
|
stripped from the GET response. The frontend only sees model as a flat
|
||||||
|
string; the rest is preserved transparently.
|
||||||
|
"""
|
||||||
|
config = dict(config)
|
||||||
|
# Remove any _model_meta that might have leaked in (shouldn't happen
|
||||||
|
# with the stripped GET response, but be defensive)
|
||||||
|
config.pop("_model_meta", None)
|
||||||
|
|
||||||
|
model_val = config.get("model")
|
||||||
|
if isinstance(model_val, str) and model_val:
|
||||||
|
# Read the current disk config to recover model subkeys
|
||||||
|
try:
|
||||||
|
disk_config = load_config()
|
||||||
|
disk_model = disk_config.get("model")
|
||||||
|
if isinstance(disk_model, dict):
|
||||||
|
# Preserve all subkeys, update default with the new value
|
||||||
|
disk_model["default"] = model_val
|
||||||
|
config["model"] = disk_model
|
||||||
|
except Exception:
|
||||||
|
pass # can't read disk config — just use the string form
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/config")
|
||||||
|
async def update_config(body: ConfigUpdate):
|
||||||
|
try:
|
||||||
|
save_config(_denormalize_config_from_web(body.config))
|
||||||
|
return {"ok": True}
|
||||||
|
except Exception as e:
|
||||||
|
_log.exception("PUT /api/config failed")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/auth/session-token")
|
||||||
|
async def get_session_token():
|
||||||
|
"""Return the ephemeral session token for this server instance.
|
||||||
|
|
||||||
|
The token protects sensitive endpoints (reveal). It's served to the SPA
|
||||||
|
which stores it in memory — it's never persisted and dies when the server
|
||||||
|
process exits. CORS already restricts this to localhost origins.
|
||||||
|
"""
|
||||||
|
return {"token": _SESSION_TOKEN}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/env")
|
||||||
|
async def get_env_vars():
|
||||||
|
env_on_disk = load_env()
|
||||||
|
result = {}
|
||||||
|
for var_name, info in OPTIONAL_ENV_VARS.items():
|
||||||
|
value = env_on_disk.get(var_name)
|
||||||
|
result[var_name] = {
|
||||||
|
"is_set": bool(value),
|
||||||
|
"redacted_value": redact_key(value) if value else None,
|
||||||
|
"description": info.get("description", ""),
|
||||||
|
"url": info.get("url"),
|
||||||
|
"category": info.get("category", ""),
|
||||||
|
"is_password": info.get("password", False),
|
||||||
|
"tools": info.get("tools", []),
|
||||||
|
"advanced": info.get("advanced", False),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/env")
|
||||||
|
async def set_env_var(body: EnvVarUpdate):
|
||||||
|
try:
|
||||||
|
save_env_value(body.key, body.value)
|
||||||
|
return {"ok": True, "key": body.key}
|
||||||
|
except Exception as e:
|
||||||
|
_log.exception("PUT /api/env failed")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/env")
|
||||||
|
async def remove_env_var(body: EnvVarDelete):
|
||||||
|
try:
|
||||||
|
removed = remove_env_value(body.key)
|
||||||
|
if not removed:
|
||||||
|
raise HTTPException(status_code=404, detail=f"{body.key} not found in .env")
|
||||||
|
return {"ok": True, "key": body.key}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
_log.exception("DELETE /api/env failed")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/env/reveal")
|
||||||
|
async def reveal_env_var(body: EnvVarReveal, request: Request):
|
||||||
|
"""Return the real (unredacted) value of a single env var.
|
||||||
|
|
||||||
|
Protected by:
|
||||||
|
- Ephemeral session token (generated per server start, injected into SPA)
|
||||||
|
- Rate limiting (max 5 reveals per 30s window)
|
||||||
|
- Audit logging
|
||||||
|
"""
|
||||||
|
# --- Token check ---
|
||||||
|
auth = request.headers.get("authorization", "")
|
||||||
|
if auth != f"Bearer {_SESSION_TOKEN}":
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
# --- Rate limit ---
|
||||||
|
now = time.time()
|
||||||
|
cutoff = now - _REVEAL_WINDOW_SECONDS
|
||||||
|
_reveal_timestamps[:] = [t for t in _reveal_timestamps if t > cutoff]
|
||||||
|
if len(_reveal_timestamps) >= _REVEAL_MAX_PER_WINDOW:
|
||||||
|
raise HTTPException(status_code=429, detail="Too many reveal requests. Try again shortly.")
|
||||||
|
_reveal_timestamps.append(now)
|
||||||
|
|
||||||
|
# --- Reveal ---
|
||||||
|
env_on_disk = load_env()
|
||||||
|
value = env_on_disk.get(body.key)
|
||||||
|
if value is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"{body.key} not found in .env")
|
||||||
|
|
||||||
|
_log.info("env/reveal: %s", body.key)
|
||||||
|
return {"key": body.key, "value": value}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session detail endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/sessions/{session_id}")
|
||||||
|
async def get_session_detail(session_id: str):
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
db = SessionDB()
|
||||||
|
try:
|
||||||
|
sid = db.resolve_session_id(session_id)
|
||||||
|
session = db.get_session(sid) if sid else None
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
return session
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/sessions/{session_id}/messages")
|
||||||
|
async def get_session_messages(session_id: str):
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
db = SessionDB()
|
||||||
|
try:
|
||||||
|
sid = db.resolve_session_id(session_id)
|
||||||
|
if not sid:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
messages = db.get_messages(sid)
|
||||||
|
return {"session_id": sid, "messages": messages}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/sessions/{session_id}")
|
||||||
|
async def delete_session_endpoint(session_id: str):
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
db = SessionDB()
|
||||||
|
try:
|
||||||
|
if not db.delete_session(session_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
return {"ok": True}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Log viewer endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/logs")
|
||||||
|
async def get_logs(
|
||||||
|
file: str = "agent",
|
||||||
|
lines: int = 100,
|
||||||
|
level: Optional[str] = None,
|
||||||
|
component: Optional[str] = None,
|
||||||
|
):
|
||||||
|
from hermes_cli.logs import _read_tail, LOG_FILES
|
||||||
|
|
||||||
|
log_name = LOG_FILES.get(file)
|
||||||
|
if not log_name:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown log file: {file}")
|
||||||
|
log_path = get_hermes_home() / "logs" / log_name
|
||||||
|
if not log_path.exists():
|
||||||
|
return {"file": file, "lines": []}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from hermes_logging import COMPONENT_PREFIXES
|
||||||
|
except ImportError:
|
||||||
|
COMPONENT_PREFIXES = {}
|
||||||
|
|
||||||
|
has_filters = bool(level or component)
|
||||||
|
comp_prefixes = COMPONENT_PREFIXES.get(component, ()) if component else ()
|
||||||
|
result = _read_tail(
|
||||||
|
log_path, min(lines, 500),
|
||||||
|
has_filters=has_filters,
|
||||||
|
min_level=level,
|
||||||
|
component_prefixes=comp_prefixes,
|
||||||
|
)
|
||||||
|
return {"file": file, "lines": result}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cron job management endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class CronJobCreate(BaseModel):
|
||||||
|
prompt: str
|
||||||
|
schedule: str
|
||||||
|
name: str = ""
|
||||||
|
deliver: str = "local"
|
||||||
|
|
||||||
|
|
||||||
|
class CronJobUpdate(BaseModel):
|
||||||
|
updates: dict
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cron/jobs")
|
||||||
|
async def list_cron_jobs():
|
||||||
|
from cron.jobs import list_jobs
|
||||||
|
return list_jobs(include_disabled=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cron/jobs/{job_id}")
|
||||||
|
async def get_cron_job(job_id: str):
|
||||||
|
from cron.jobs import get_job
|
||||||
|
job = get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cron/jobs")
|
||||||
|
async def create_cron_job(body: CronJobCreate):
|
||||||
|
from cron.jobs import create_job
|
||||||
|
try:
|
||||||
|
job = create_job(prompt=body.prompt, schedule=body.schedule,
|
||||||
|
name=body.name, deliver=body.deliver)
|
||||||
|
return job
|
||||||
|
except Exception as e:
|
||||||
|
_log.exception("POST /api/cron/jobs failed")
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/cron/jobs/{job_id}")
|
||||||
|
async def update_cron_job(job_id: str, body: CronJobUpdate):
|
||||||
|
from cron.jobs import update_job
|
||||||
|
job = update_job(job_id, body.updates)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cron/jobs/{job_id}/pause")
|
||||||
|
async def pause_cron_job(job_id: str):
|
||||||
|
from cron.jobs import pause_job
|
||||||
|
job = pause_job(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cron/jobs/{job_id}/resume")
|
||||||
|
async def resume_cron_job(job_id: str):
|
||||||
|
from cron.jobs import resume_job
|
||||||
|
job = resume_job(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cron/jobs/{job_id}/trigger")
|
||||||
|
async def trigger_cron_job(job_id: str):
|
||||||
|
from cron.jobs import trigger_job
|
||||||
|
job = trigger_job(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/cron/jobs/{job_id}")
|
||||||
|
async def delete_cron_job(job_id: str):
|
||||||
|
from cron.jobs import remove_job
|
||||||
|
if not remove_job(job_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Skills & Tools endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SkillToggle(BaseModel):
|
||||||
|
name: str
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/skills")
|
||||||
|
async def get_skills():
|
||||||
|
from tools.skills_tool import _find_all_skills
|
||||||
|
from hermes_cli.skills_config import get_disabled_skills
|
||||||
|
config = load_config()
|
||||||
|
disabled = get_disabled_skills(config)
|
||||||
|
skills = _find_all_skills(skip_disabled=True)
|
||||||
|
for s in skills:
|
||||||
|
s["enabled"] = s["name"] not in disabled
|
||||||
|
return skills
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/skills/toggle")
|
||||||
|
async def toggle_skill(body: SkillToggle):
|
||||||
|
from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
|
||||||
|
config = load_config()
|
||||||
|
disabled = get_disabled_skills(config)
|
||||||
|
if body.enabled:
|
||||||
|
disabled.discard(body.name)
|
||||||
|
else:
|
||||||
|
disabled.add(body.name)
|
||||||
|
save_disabled_skills(config, disabled)
|
||||||
|
return {"ok": True, "name": body.name, "enabled": body.enabled}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/tools/toolsets")
|
||||||
|
async def get_toolsets():
|
||||||
|
from hermes_cli.tools_config import (
|
||||||
|
_get_effective_configurable_toolsets,
|
||||||
|
_get_platform_tools,
|
||||||
|
_toolset_has_keys,
|
||||||
|
)
|
||||||
|
from toolsets import resolve_toolset
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
enabled_toolsets = _get_platform_tools(
|
||||||
|
config,
|
||||||
|
"cli",
|
||||||
|
include_default_mcp_servers=False,
|
||||||
|
)
|
||||||
|
result = []
|
||||||
|
for name, label, desc in _get_effective_configurable_toolsets():
|
||||||
|
try:
|
||||||
|
tools = sorted(set(resolve_toolset(name)))
|
||||||
|
except Exception:
|
||||||
|
tools = []
|
||||||
|
is_enabled = name in enabled_toolsets
|
||||||
|
result.append({
|
||||||
|
"name": name, "label": label, "description": desc,
|
||||||
|
"enabled": is_enabled,
|
||||||
|
"available": is_enabled,
|
||||||
|
"configured": _toolset_has_keys(name, config),
|
||||||
|
"tools": tools,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Raw YAML config endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class RawConfigUpdate(BaseModel):
|
||||||
|
yaml_text: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/config/raw")
|
||||||
|
async def get_config_raw():
|
||||||
|
path = get_config_path()
|
||||||
|
if not path.exists():
|
||||||
|
return {"yaml": ""}
|
||||||
|
return {"yaml": path.read_text(encoding="utf-8")}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/config/raw")
|
||||||
|
async def update_config_raw(body: RawConfigUpdate):
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(body.yaml_text)
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="YAML must be a mapping")
|
||||||
|
save_config(parsed)
|
||||||
|
return {"ok": True}
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Token / cost analytics endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/analytics/usage")
|
||||||
|
async def get_usage_analytics(days: int = 30):
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
db = SessionDB()
|
||||||
|
try:
|
||||||
|
cutoff = time.time() - (days * 86400)
|
||||||
|
cur = db._conn.execute("""
|
||||||
|
SELECT date(started_at, 'unixepoch') as day,
|
||||||
|
SUM(input_tokens) as input_tokens,
|
||||||
|
SUM(output_tokens) as output_tokens,
|
||||||
|
SUM(cache_read_tokens) as cache_read_tokens,
|
||||||
|
SUM(reasoning_tokens) as reasoning_tokens,
|
||||||
|
COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost,
|
||||||
|
COALESCE(SUM(actual_cost_usd), 0) as actual_cost,
|
||||||
|
COUNT(*) as sessions
|
||||||
|
FROM sessions WHERE started_at > ?
|
||||||
|
GROUP BY day ORDER BY day
|
||||||
|
""", (cutoff,))
|
||||||
|
daily = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur2 = db._conn.execute("""
|
||||||
|
SELECT model,
|
||||||
|
SUM(input_tokens) as input_tokens,
|
||||||
|
SUM(output_tokens) as output_tokens,
|
||||||
|
COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost,
|
||||||
|
COUNT(*) as sessions
|
||||||
|
FROM sessions WHERE started_at > ? AND model IS NOT NULL
|
||||||
|
GROUP BY model ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC
|
||||||
|
""", (cutoff,))
|
||||||
|
by_model = [dict(r) for r in cur2.fetchall()]
|
||||||
|
|
||||||
|
cur3 = db._conn.execute("""
|
||||||
|
SELECT SUM(input_tokens) as total_input,
|
||||||
|
SUM(output_tokens) as total_output,
|
||||||
|
SUM(cache_read_tokens) as total_cache_read,
|
||||||
|
SUM(reasoning_tokens) as total_reasoning,
|
||||||
|
COALESCE(SUM(estimated_cost_usd), 0) as total_estimated_cost,
|
||||||
|
COALESCE(SUM(actual_cost_usd), 0) as total_actual_cost,
|
||||||
|
COUNT(*) as total_sessions
|
||||||
|
FROM sessions WHERE started_at > ?
|
||||||
|
""", (cutoff,))
|
||||||
|
totals = dict(cur3.fetchone())
|
||||||
|
|
||||||
|
return {"daily": daily, "by_model": by_model, "totals": totals, "period_days": days}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def mount_spa(application: FastAPI):
|
||||||
|
"""Mount the built SPA. Falls back to index.html for client-side routing."""
|
||||||
|
if not WEB_DIST.exists():
|
||||||
|
@application.get("/{full_path:path}")
|
||||||
|
async def no_frontend(full_path: str):
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Frontend not built. Run: cd web && npm run build"},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
|
||||||
|
|
||||||
|
@application.get("/{full_path:path}")
|
||||||
|
async def serve_spa(full_path: str):
|
||||||
|
file_path = WEB_DIST / full_path
|
||||||
|
# Prevent path traversal via url-encoded sequences (%2e%2e/)
|
||||||
|
if (
|
||||||
|
full_path
|
||||||
|
and file_path.resolve().is_relative_to(WEB_DIST.resolve())
|
||||||
|
and file_path.exists()
|
||||||
|
and file_path.is_file()
|
||||||
|
):
|
||||||
|
return FileResponse(file_path)
|
||||||
|
return FileResponse(
|
||||||
|
WEB_DIST / "index.html",
|
||||||
|
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
mount_spa(app)
|
||||||
|
|
||||||
|
|
||||||
|
def start_server(host: str = "127.0.0.1", port: int = 9119, open_browser: bool = True):
|
||||||
|
"""Start the web UI server."""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
if host not in ("127.0.0.1", "localhost", "::1"):
|
||||||
|
import logging
|
||||||
|
logging.warning(
|
||||||
|
"Binding to %s — the web UI exposes config and API keys. "
|
||||||
|
"Only bind to non-localhost if you trust all users on the network.", host,
|
||||||
|
)
|
||||||
|
|
||||||
|
if open_browser:
|
||||||
|
import threading
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
def _open():
|
||||||
|
import time as _t
|
||||||
|
_t.sleep(1.0)
|
||||||
|
webbrowser.open(f"http://{host}:{port}")
|
||||||
|
|
||||||
|
threading.Thread(target=_open, daemon=True).start()
|
||||||
|
|
||||||
|
print(f" Hermes Web UI → http://{host}:{port}")
|
||||||
|
uvicorn.run(app, host=host, port=port, log_level="warning")
|
||||||
|
|
@ -76,6 +76,7 @@ termux = [
|
||||||
]
|
]
|
||||||
dingtalk = ["dingtalk-stream>=0.1.0,<1"]
|
dingtalk = ["dingtalk-stream>=0.1.0,<1"]
|
||||||
feishu = ["lark-oapi>=1.5.3,<2"]
|
feishu = ["lark-oapi>=1.5.3,<2"]
|
||||||
|
web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"]
|
||||||
rl = [
|
rl = [
|
||||||
"atroposlib @ git+https://github.com/NousResearch/atropos.git",
|
"atroposlib @ git+https://github.com/NousResearch/atropos.git",
|
||||||
"tinker @ git+https://github.com/thinking-machines-lab/tinker.git",
|
"tinker @ git+https://github.com/thinking-machines-lab/tinker.git",
|
||||||
|
|
@ -107,6 +108,7 @@ all = [
|
||||||
"hermes-agent[dingtalk]",
|
"hermes-agent[dingtalk]",
|
||||||
"hermes-agent[feishu]",
|
"hermes-agent[feishu]",
|
||||||
"hermes-agent[mistral]",
|
"hermes-agent[mistral]",
|
||||||
|
"hermes-agent[web]",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
@ -117,6 +119,9 @@ hermes-acp = "acp_adapter.entry:main"
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "rl_cli", "utils"]
|
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "rl_cli", "utils"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
hermes_cli = ["web_dist/**/*"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"]
|
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"]
|
||||||
|
|
||||||
|
|
|
||||||
675
tests/hermes_cli/test_web_server.py
Normal file
675
tests/hermes_cli/test_web_server.py
Normal file
|
|
@ -0,0 +1,675 @@
|
||||||
|
"""Tests for hermes_cli.web_server and related config utilities."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from hermes_cli.config import (
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
reload_env,
|
||||||
|
redact_key,
|
||||||
|
_EXTRA_ENV_KEYS,
|
||||||
|
OPTIONAL_ENV_VARS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# reload_env tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestReloadEnv:
|
||||||
|
"""Tests for reload_env() — re-reads .env into os.environ."""
|
||||||
|
|
||||||
|
def test_adds_new_vars(self, tmp_path):
|
||||||
|
"""reload_env() adds vars from .env that are not in os.environ."""
|
||||||
|
env_file = tmp_path / ".env"
|
||||||
|
env_file.write_text("TEST_RELOAD_VAR=hello123\n")
|
||||||
|
with patch("hermes_cli.config.get_env_path", return_value=env_file):
|
||||||
|
os.environ.pop("TEST_RELOAD_VAR", None)
|
||||||
|
count = reload_env()
|
||||||
|
assert count >= 1
|
||||||
|
assert os.environ.get("TEST_RELOAD_VAR") == "hello123"
|
||||||
|
os.environ.pop("TEST_RELOAD_VAR", None)
|
||||||
|
|
||||||
|
def test_updates_changed_vars(self, tmp_path):
|
||||||
|
"""reload_env() updates vars whose value changed on disk."""
|
||||||
|
env_file = tmp_path / ".env"
|
||||||
|
env_file.write_text("TEST_RELOAD_VAR=old_value\n")
|
||||||
|
with patch("hermes_cli.config.get_env_path", return_value=env_file):
|
||||||
|
os.environ["TEST_RELOAD_VAR"] = "old_value"
|
||||||
|
# Now change the file
|
||||||
|
env_file.write_text("TEST_RELOAD_VAR=new_value\n")
|
||||||
|
count = reload_env()
|
||||||
|
assert count >= 1
|
||||||
|
assert os.environ.get("TEST_RELOAD_VAR") == "new_value"
|
||||||
|
os.environ.pop("TEST_RELOAD_VAR", None)
|
||||||
|
|
||||||
|
def test_removes_deleted_known_vars(self, tmp_path):
|
||||||
|
"""reload_env() removes known Hermes vars not present in .env."""
|
||||||
|
env_file = tmp_path / ".env"
|
||||||
|
env_file.write_text("") # empty .env
|
||||||
|
# Pick a known key from OPTIONAL_ENV_VARS
|
||||||
|
known_key = next(iter(OPTIONAL_ENV_VARS.keys()))
|
||||||
|
with patch("hermes_cli.config.get_env_path", return_value=env_file):
|
||||||
|
os.environ[known_key] = "stale_value"
|
||||||
|
count = reload_env()
|
||||||
|
assert known_key not in os.environ
|
||||||
|
assert count >= 1
|
||||||
|
|
||||||
|
def test_does_not_remove_unknown_vars(self, tmp_path):
|
||||||
|
"""reload_env() preserves non-Hermes env vars even when absent from .env."""
|
||||||
|
env_file = tmp_path / ".env"
|
||||||
|
env_file.write_text("")
|
||||||
|
with patch("hermes_cli.config.get_env_path", return_value=env_file):
|
||||||
|
os.environ["MY_CUSTOM_UNRELATED_VAR"] = "keep_me"
|
||||||
|
reload_env()
|
||||||
|
assert os.environ.get("MY_CUSTOM_UNRELATED_VAR") == "keep_me"
|
||||||
|
os.environ.pop("MY_CUSTOM_UNRELATED_VAR", None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# redact_key tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRedactKey:
|
||||||
|
def test_long_key_shows_prefix_suffix(self):
|
||||||
|
result = redact_key("sk-1234567890abcdef")
|
||||||
|
assert result.startswith("sk-1")
|
||||||
|
assert result.endswith("cdef")
|
||||||
|
assert "..." in result
|
||||||
|
|
||||||
|
def test_short_key_fully_masked(self):
|
||||||
|
assert redact_key("short") == "***"
|
||||||
|
|
||||||
|
def test_empty_key(self):
|
||||||
|
result = redact_key("")
|
||||||
|
assert "not set" in result.lower() or result == "***" or "\x1b" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# web_server tests (FastAPI endpoints)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebServerEndpoints:
|
||||||
|
"""Test the FastAPI REST endpoints using Starlette TestClient."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _setup_test_client(self):
|
||||||
|
"""Create a TestClient — import is deferred to avoid requiring fastapi."""
|
||||||
|
try:
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("fastapi/starlette not installed")
|
||||||
|
|
||||||
|
from hermes_cli.web_server import app
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def test_get_status(self):
|
||||||
|
resp = self.client.get("/api/status")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "version" in data
|
||||||
|
assert "hermes_home" in data
|
||||||
|
assert "active_sessions" in data
|
||||||
|
|
||||||
|
def test_get_status_filters_unconfigured_gateway_platforms(self, monkeypatch):
|
||||||
|
import gateway.config as gateway_config
|
||||||
|
import hermes_cli.web_server as web_server
|
||||||
|
|
||||||
|
class _Platform:
|
||||||
|
def __init__(self, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
class _GatewayConfig:
|
||||||
|
def get_connected_platforms(self):
|
||||||
|
return [_Platform("telegram")]
|
||||||
|
|
||||||
|
monkeypatch.setattr(web_server, "get_running_pid", lambda: 1234)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
web_server,
|
||||||
|
"read_runtime_status",
|
||||||
|
lambda: {
|
||||||
|
"gateway_state": "running",
|
||||||
|
"updated_at": "2026-04-12T00:00:00+00:00",
|
||||||
|
"platforms": {
|
||||||
|
"telegram": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"},
|
||||||
|
"whatsapp": {"state": "retrying", "updated_at": "2026-04-12T00:00:00+00:00"},
|
||||||
|
"feishu": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
|
||||||
|
monkeypatch.setattr(gateway_config, "load_gateway_config", lambda: _GatewayConfig())
|
||||||
|
|
||||||
|
resp = self.client.get("/api/status")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["gateway_platforms"] == {
|
||||||
|
"telegram": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"},
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_get_status_hides_stale_platforms_when_gateway_not_running(self, monkeypatch):
|
||||||
|
import gateway.config as gateway_config
|
||||||
|
import hermes_cli.web_server as web_server
|
||||||
|
|
||||||
|
class _GatewayConfig:
|
||||||
|
def get_connected_platforms(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(web_server, "get_running_pid", lambda: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
web_server,
|
||||||
|
"read_runtime_status",
|
||||||
|
lambda: {
|
||||||
|
"gateway_state": "startup_failed",
|
||||||
|
"updated_at": "2026-04-12T00:00:00+00:00",
|
||||||
|
"platforms": {
|
||||||
|
"whatsapp": {"state": "retrying", "updated_at": "2026-04-12T00:00:00+00:00"},
|
||||||
|
"feishu": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
|
||||||
|
monkeypatch.setattr(gateway_config, "load_gateway_config", lambda: _GatewayConfig())
|
||||||
|
|
||||||
|
resp = self.client.get("/api/status")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["gateway_state"] == "startup_failed"
|
||||||
|
assert resp.json()["gateway_platforms"] == {}
|
||||||
|
|
||||||
|
def test_get_config_schema(self):
|
||||||
|
resp = self.client.get("/api/config/schema")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "fields" in data
|
||||||
|
assert "category_order" in data
|
||||||
|
schema = data["fields"]
|
||||||
|
assert len(schema) > 100 # Should have 150+ fields
|
||||||
|
assert "model" in schema
|
||||||
|
# Verify category_order is a non-empty list
|
||||||
|
assert isinstance(data["category_order"], list)
|
||||||
|
assert len(data["category_order"]) > 0
|
||||||
|
assert "general" in data["category_order"]
|
||||||
|
|
||||||
|
def test_get_config_defaults(self):
|
||||||
|
resp = self.client.get("/api/config/defaults")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
defaults = resp.json()
|
||||||
|
assert "model" in defaults
|
||||||
|
|
||||||
|
def test_get_env_vars(self):
|
||||||
|
resp = self.client.get("/api/env")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
# Should contain known env var names
|
||||||
|
assert any(k.endswith("_API_KEY") or k.endswith("_TOKEN") for k in data.keys())
|
||||||
|
|
||||||
|
def test_reveal_env_var(self, tmp_path):
|
||||||
|
"""POST /api/env/reveal should return the real unredacted value."""
|
||||||
|
from hermes_cli.config import save_env_value
|
||||||
|
from hermes_cli.web_server import _SESSION_TOKEN
|
||||||
|
save_env_value("TEST_REVEAL_KEY", "super-secret-value-12345")
|
||||||
|
resp = self.client.post(
|
||||||
|
"/api/env/reveal",
|
||||||
|
json={"key": "TEST_REVEAL_KEY"},
|
||||||
|
headers={"Authorization": f"Bearer {_SESSION_TOKEN}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["key"] == "TEST_REVEAL_KEY"
|
||||||
|
assert data["value"] == "super-secret-value-12345"
|
||||||
|
|
||||||
|
def test_reveal_env_var_not_found(self):
|
||||||
|
"""POST /api/env/reveal should 404 for unknown keys."""
|
||||||
|
from hermes_cli.web_server import _SESSION_TOKEN
|
||||||
|
resp = self.client.post(
|
||||||
|
"/api/env/reveal",
|
||||||
|
json={"key": "NONEXISTENT_KEY_XYZ"},
|
||||||
|
headers={"Authorization": f"Bearer {_SESSION_TOKEN}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_reveal_env_var_no_token(self, tmp_path):
|
||||||
|
"""POST /api/env/reveal without token should return 401."""
|
||||||
|
from hermes_cli.config import save_env_value
|
||||||
|
save_env_value("TEST_REVEAL_NOAUTH", "secret-value")
|
||||||
|
resp = self.client.post(
|
||||||
|
"/api/env/reveal",
|
||||||
|
json={"key": "TEST_REVEAL_NOAUTH"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_reveal_env_var_bad_token(self, tmp_path):
|
||||||
|
"""POST /api/env/reveal with wrong token should return 401."""
|
||||||
|
from hermes_cli.config import save_env_value
|
||||||
|
save_env_value("TEST_REVEAL_BADAUTH", "secret-value")
|
||||||
|
resp = self.client.post(
|
||||||
|
"/api/env/reveal",
|
||||||
|
json={"key": "TEST_REVEAL_BADAUTH"},
|
||||||
|
headers={"Authorization": "Bearer wrong-token-here"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_session_token_endpoint(self):
|
||||||
|
"""GET /api/auth/session-token should return a token."""
|
||||||
|
from hermes_cli.web_server import _SESSION_TOKEN
|
||||||
|
resp = self.client.get("/api/auth/session-token")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["token"] == _SESSION_TOKEN
|
||||||
|
|
||||||
|
def test_path_traversal_blocked(self):
|
||||||
|
"""Verify URL-encoded path traversal is blocked."""
|
||||||
|
# %2e%2e = ..
|
||||||
|
resp = self.client.get("/%2e%2e/%2e%2e/etc/passwd")
|
||||||
|
# Should return 200 with index.html (SPA fallback), not the actual file
|
||||||
|
assert resp.status_code in (200, 404)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
# Should be the SPA fallback, not the system file
|
||||||
|
assert "root:" not in resp.text
|
||||||
|
|
||||||
|
def test_path_traversal_dotdot_blocked(self):
|
||||||
|
"""Direct .. path traversal via encoded sequences."""
|
||||||
|
resp = self.client.get("/%2e%2e/hermes_cli/web_server.py")
|
||||||
|
assert resp.status_code in (200, 404)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
assert "FastAPI" not in resp.text # Should not serve the actual source
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _build_schema_from_config tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildSchemaFromConfig:
|
||||||
|
def test_produces_expected_field_count(self):
|
||||||
|
from hermes_cli.web_server import CONFIG_SCHEMA
|
||||||
|
# DEFAULT_CONFIG has ~150+ leaf fields
|
||||||
|
assert len(CONFIG_SCHEMA) > 100
|
||||||
|
|
||||||
|
def test_schema_entries_have_required_fields(self):
|
||||||
|
from hermes_cli.web_server import CONFIG_SCHEMA
|
||||||
|
for key, entry in list(CONFIG_SCHEMA.items())[:10]:
|
||||||
|
assert "type" in entry, f"Missing type for {key}"
|
||||||
|
assert "category" in entry, f"Missing category for {key}"
|
||||||
|
|
||||||
|
def test_overrides_applied(self):
|
||||||
|
from hermes_cli.web_server import CONFIG_SCHEMA
|
||||||
|
# terminal.backend should be a select with options
|
||||||
|
if "terminal.backend" in CONFIG_SCHEMA:
|
||||||
|
entry = CONFIG_SCHEMA["terminal.backend"]
|
||||||
|
assert entry["type"] == "select"
|
||||||
|
assert "options" in entry
|
||||||
|
assert "local" in entry["options"]
|
||||||
|
|
||||||
|
def test_empty_prefix_produces_correct_keys(self):
|
||||||
|
from hermes_cli.web_server import _build_schema_from_config
|
||||||
|
test_config = {"model": "test", "nested": {"key": "val"}}
|
||||||
|
schema = _build_schema_from_config(test_config)
|
||||||
|
assert "model" in schema
|
||||||
|
assert "nested.key" in schema
|
||||||
|
|
||||||
|
def test_top_level_scalars_get_general_category(self):
|
||||||
|
"""Top-level scalar fields should be in 'general' category."""
|
||||||
|
from hermes_cli.web_server import CONFIG_SCHEMA
|
||||||
|
assert CONFIG_SCHEMA["model"]["category"] == "general"
|
||||||
|
|
||||||
|
def test_nested_keys_get_parent_category(self):
|
||||||
|
"""Nested fields should use the top-level parent as their category."""
|
||||||
|
from hermes_cli.web_server import CONFIG_SCHEMA
|
||||||
|
if "agent.max_turns" in CONFIG_SCHEMA:
|
||||||
|
assert CONFIG_SCHEMA["agent.max_turns"]["category"] == "agent"
|
||||||
|
|
||||||
|
def test_category_merge_applied(self):
|
||||||
|
"""Small categories should be merged into larger ones."""
|
||||||
|
from hermes_cli.web_server import CONFIG_SCHEMA
|
||||||
|
categories = {e["category"] for e in CONFIG_SCHEMA.values()}
|
||||||
|
# These should be merged away
|
||||||
|
assert "privacy" not in categories # merged into security
|
||||||
|
assert "context" not in categories # merged into agent
|
||||||
|
|
||||||
|
def test_no_single_field_categories(self):
|
||||||
|
"""After merging, no category should have just 1 field."""
|
||||||
|
from hermes_cli.web_server import CONFIG_SCHEMA
|
||||||
|
from collections import Counter
|
||||||
|
cats = Counter(e["category"] for e in CONFIG_SCHEMA.values())
|
||||||
|
for cat, count in cats.items():
|
||||||
|
assert count >= 2, f"Category '{cat}' has only {count} field(s) — should be merged"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config round-trip tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigRoundTrip:
|
||||||
|
"""Verify config survives GET → edit → PUT without data loss."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _setup(self):
|
||||||
|
try:
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("fastapi/starlette not installed")
|
||||||
|
from hermes_cli.web_server import app
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def test_get_config_no_internal_keys(self):
|
||||||
|
"""GET /api/config should not expose _config_version or _model_meta."""
|
||||||
|
config = self.client.get("/api/config").json()
|
||||||
|
internal = [k for k in config if k.startswith("_")]
|
||||||
|
assert not internal, f"Internal keys leaked to frontend: {internal}"
|
||||||
|
|
||||||
|
def test_get_config_model_is_string(self):
|
||||||
|
"""GET /api/config should normalize model dict to a string."""
|
||||||
|
config = self.client.get("/api/config").json()
|
||||||
|
assert isinstance(config.get("model"), str), \
|
||||||
|
f"model should be string, got {type(config.get('model'))}"
|
||||||
|
|
||||||
|
def test_round_trip_preserves_model_subkeys(self):
|
||||||
|
"""Save and reload should not lose model.provider, model.base_url, etc."""
|
||||||
|
from hermes_cli.config import load_config, save_config
|
||||||
|
|
||||||
|
# Set up a config with model as a dict (the common user config form)
|
||||||
|
save_config({
|
||||||
|
"model": {
|
||||||
|
"default": "anthropic/claude-sonnet-4",
|
||||||
|
"provider": "openrouter",
|
||||||
|
"base_url": "https://openrouter.ai/api/v1",
|
||||||
|
"api_mode": "openai",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
before = load_config()
|
||||||
|
assert isinstance(before.get("model"), dict)
|
||||||
|
original_keys = set(before["model"].keys())
|
||||||
|
|
||||||
|
# GET → PUT unchanged
|
||||||
|
web_config = self.client.get("/api/config").json()
|
||||||
|
assert isinstance(web_config.get("model"), str), "GET should normalize model to string"
|
||||||
|
|
||||||
|
self.client.put("/api/config", json={"config": web_config})
|
||||||
|
|
||||||
|
after = load_config()
|
||||||
|
assert isinstance(after.get("model"), dict), "model should still be a dict after save"
|
||||||
|
assert set(after["model"].keys()) >= original_keys, \
|
||||||
|
f"Lost model subkeys: {original_keys - set(after['model'].keys())}"
|
||||||
|
|
||||||
|
def test_edit_model_name_preserved(self):
|
||||||
|
"""Changing the model string should update model.default on disk."""
|
||||||
|
from hermes_cli.config import load_config
|
||||||
|
|
||||||
|
web_config = self.client.get("/api/config").json()
|
||||||
|
original_model = web_config["model"]
|
||||||
|
|
||||||
|
# Change model
|
||||||
|
web_config["model"] = "test/editing-model"
|
||||||
|
self.client.put("/api/config", json={"config": web_config})
|
||||||
|
|
||||||
|
after = load_config()
|
||||||
|
if isinstance(after.get("model"), dict):
|
||||||
|
assert after["model"]["default"] == "test/editing-model"
|
||||||
|
else:
|
||||||
|
assert after["model"] == "test/editing-model"
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
web_config["model"] = original_model
|
||||||
|
self.client.put("/api/config", json={"config": web_config})
|
||||||
|
|
||||||
|
def test_edit_nested_value(self):
|
||||||
|
"""Editing a nested config value should persist correctly."""
|
||||||
|
from hermes_cli.config import load_config
|
||||||
|
|
||||||
|
web_config = self.client.get("/api/config").json()
|
||||||
|
original_turns = web_config.get("agent", {}).get("max_turns")
|
||||||
|
|
||||||
|
# Change max_turns
|
||||||
|
if "agent" not in web_config:
|
||||||
|
web_config["agent"] = {}
|
||||||
|
web_config["agent"]["max_turns"] = 42
|
||||||
|
|
||||||
|
self.client.put("/api/config", json={"config": web_config})
|
||||||
|
|
||||||
|
after = load_config()
|
||||||
|
assert after.get("agent", {}).get("max_turns") == 42
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
web_config["agent"]["max_turns"] = original_turns
|
||||||
|
self.client.put("/api/config", json={"config": web_config})
|
||||||
|
|
||||||
|
def test_schema_types_match_config_values(self):
|
||||||
|
"""Every schema field should have a matching-type value in the config."""
|
||||||
|
config = self.client.get("/api/config").json()
|
||||||
|
schema_resp = self.client.get("/api/config/schema").json()
|
||||||
|
schema = schema_resp["fields"]
|
||||||
|
|
||||||
|
def get_nested(obj, path):
|
||||||
|
parts = path.split(".")
|
||||||
|
cur = obj
|
||||||
|
for p in parts:
|
||||||
|
if cur is None or not isinstance(cur, dict):
|
||||||
|
return None
|
||||||
|
cur = cur.get(p)
|
||||||
|
return cur
|
||||||
|
|
||||||
|
mismatches = []
|
||||||
|
for key, entry in schema.items():
|
||||||
|
val = get_nested(config, key)
|
||||||
|
if val is None:
|
||||||
|
continue # not set in user config — fine
|
||||||
|
expected = entry["type"]
|
||||||
|
if expected in ("string", "select") and not isinstance(val, str):
|
||||||
|
mismatches.append(f"{key}: expected str, got {type(val).__name__}")
|
||||||
|
elif expected == "number" and not isinstance(val, (int, float)):
|
||||||
|
mismatches.append(f"{key}: expected number, got {type(val).__name__}")
|
||||||
|
elif expected == "boolean" and not isinstance(val, bool):
|
||||||
|
mismatches.append(f"{key}: expected bool, got {type(val).__name__}")
|
||||||
|
elif expected == "list" and not isinstance(val, list):
|
||||||
|
mismatches.append(f"{key}: expected list, got {type(val).__name__}")
|
||||||
|
assert not mismatches, f"Type mismatches:\n" + "\n".join(mismatches)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# New feature endpoint tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewEndpoints:
|
||||||
|
"""Tests for session detail, logs, cron, skills, tools, raw config, analytics."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _setup(self):
|
||||||
|
try:
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("fastapi/starlette not installed")
|
||||||
|
from hermes_cli.web_server import app
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def test_get_logs_default(self):
|
||||||
|
resp = self.client.get("/api/logs")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "file" in data
|
||||||
|
assert "lines" in data
|
||||||
|
assert isinstance(data["lines"], list)
|
||||||
|
|
||||||
|
def test_get_logs_invalid_file(self):
|
||||||
|
resp = self.client.get("/api/logs?file=nonexistent")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_cron_list(self):
|
||||||
|
resp = self.client.get("/api/cron/jobs")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert isinstance(resp.json(), list)
|
||||||
|
|
||||||
|
def test_cron_job_not_found(self):
|
||||||
|
resp = self.client.get("/api/cron/jobs/nonexistent-id")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_skills_list(self):
|
||||||
|
resp = self.client.get("/api/skills")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
skills = resp.json()
|
||||||
|
assert isinstance(skills, list)
|
||||||
|
if skills:
|
||||||
|
assert "name" in skills[0]
|
||||||
|
assert "enabled" in skills[0]
|
||||||
|
|
||||||
|
def test_skills_list_includes_disabled_skills(self, monkeypatch):
|
||||||
|
import tools.skills_tool as skills_tool
|
||||||
|
import hermes_cli.skills_config as skills_config
|
||||||
|
import hermes_cli.web_server as web_server
|
||||||
|
|
||||||
|
def _fake_find_all_skills(*, skip_disabled=False):
|
||||||
|
if skip_disabled:
|
||||||
|
return [
|
||||||
|
{"name": "active-skill", "description": "active", "category": "demo"},
|
||||||
|
{"name": "disabled-skill", "description": "disabled", "category": "demo"},
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
{"name": "active-skill", "description": "active", "category": "demo"},
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(skills_tool, "_find_all_skills", _fake_find_all_skills)
|
||||||
|
monkeypatch.setattr(skills_config, "get_disabled_skills", lambda config: {"disabled-skill"})
|
||||||
|
monkeypatch.setattr(web_server, "load_config", lambda: {"skills": {"disabled": ["disabled-skill"]}})
|
||||||
|
|
||||||
|
resp = self.client.get("/api/skills")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == [
|
||||||
|
{
|
||||||
|
"name": "active-skill",
|
||||||
|
"description": "active",
|
||||||
|
"category": "demo",
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "disabled-skill",
|
||||||
|
"description": "disabled",
|
||||||
|
"category": "demo",
|
||||||
|
"enabled": False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_toolsets_list(self):
|
||||||
|
resp = self.client.get("/api/tools/toolsets")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
toolsets = resp.json()
|
||||||
|
assert isinstance(toolsets, list)
|
||||||
|
if toolsets:
|
||||||
|
assert "name" in toolsets[0]
|
||||||
|
assert "label" in toolsets[0]
|
||||||
|
assert "enabled" in toolsets[0]
|
||||||
|
|
||||||
|
def test_toolsets_list_matches_cli_enabled_state(self, monkeypatch):
|
||||||
|
import hermes_cli.tools_config as tools_config
|
||||||
|
import toolsets as toolsets_module
|
||||||
|
import hermes_cli.web_server as web_server
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tools_config,
|
||||||
|
"_get_effective_configurable_toolsets",
|
||||||
|
lambda: [
|
||||||
|
("web", "🔍 Web Search & Scraping", "web_search, web_extract"),
|
||||||
|
("skills", "📚 Skills", "list, view, manage"),
|
||||||
|
("memory", "💾 Memory", "persistent memory across sessions"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tools_config,
|
||||||
|
"_get_platform_tools",
|
||||||
|
lambda config, platform, include_default_mcp_servers=False: {"web", "skills"},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tools_config,
|
||||||
|
"_toolset_has_keys",
|
||||||
|
lambda ts_key, config=None: ts_key != "web",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
toolsets_module,
|
||||||
|
"resolve_toolset",
|
||||||
|
lambda name: {
|
||||||
|
"web": ["web_search", "web_extract"],
|
||||||
|
"skills": ["skills_list", "skill_view"],
|
||||||
|
"memory": ["memory_read"],
|
||||||
|
}[name],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(web_server, "load_config", lambda: {"platform_toolsets": {"cli": ["web", "skills"]}})
|
||||||
|
|
||||||
|
resp = self.client.get("/api/tools/toolsets")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == [
|
||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"label": "🔍 Web Search & Scraping",
|
||||||
|
"description": "web_search, web_extract",
|
||||||
|
"enabled": True,
|
||||||
|
"available": True,
|
||||||
|
"configured": False,
|
||||||
|
"tools": ["web_extract", "web_search"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "skills",
|
||||||
|
"label": "📚 Skills",
|
||||||
|
"description": "list, view, manage",
|
||||||
|
"enabled": True,
|
||||||
|
"available": True,
|
||||||
|
"configured": True,
|
||||||
|
"tools": ["skill_view", "skills_list"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "memory",
|
||||||
|
"label": "💾 Memory",
|
||||||
|
"description": "persistent memory across sessions",
|
||||||
|
"enabled": False,
|
||||||
|
"available": False,
|
||||||
|
"configured": True,
|
||||||
|
"tools": ["memory_read"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_config_raw_get(self):
|
||||||
|
resp = self.client.get("/api/config/raw")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "yaml" in resp.json()
|
||||||
|
|
||||||
|
def test_config_raw_put_valid(self):
|
||||||
|
resp = self.client.put(
|
||||||
|
"/api/config/raw",
|
||||||
|
json={"yaml_text": "model: test\ntoolsets:\n - all\n"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["ok"] is True
|
||||||
|
|
||||||
|
def test_config_raw_put_invalid(self):
|
||||||
|
resp = self.client.put(
|
||||||
|
"/api/config/raw",
|
||||||
|
json={"yaml_text": "- this is a list not a dict"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_analytics_usage(self):
|
||||||
|
resp = self.client.get("/api/analytics/usage?days=7")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "daily" in data
|
||||||
|
assert "by_model" in data
|
||||||
|
assert "totals" in data
|
||||||
|
assert isinstance(data["daily"], list)
|
||||||
|
assert "total_sessions" in data["totals"]
|
||||||
|
|
||||||
|
def test_session_token_endpoint(self):
|
||||||
|
from hermes_cli.web_server import _SESSION_TOKEN
|
||||||
|
resp = self.client.get("/api/auth/session-token")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["token"] == _SESSION_TOKEN
|
||||||
48
web/README.md
Normal file
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>
|
||||||
3835
web/package-lock.json
generated
Normal file
3835
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
36
web/package.json
Normal file
36
web/package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss": "^4.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/node": "^24.12.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.56.1",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
web/public/favicon.ico
Normal file
BIN
web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
BIN
web/public/fonts/Collapse-Bold.woff2
Normal file
BIN
web/public/fonts/Collapse-Bold.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/Collapse-Regular.woff2
Normal file
BIN
web/public/fonts/Collapse-Regular.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/CourierPrime-Bold.woff2
Normal file
BIN
web/public/fonts/CourierPrime-Bold.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/CourierPrime-Regular.woff2
Normal file
BIN
web/public/fonts/CourierPrime-Regular.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/Mondwest-Regular.woff2
Normal file
BIN
web/public/fonts/Mondwest-Regular.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/RulesCompressed-Medium.woff2
Normal file
BIN
web/public/fonts/RulesCompressed-Medium.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/RulesCompressed-Regular.woff2
Normal file
BIN
web/public/fonts/RulesCompressed-Regular.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/RulesExpanded-Bold.woff2
Normal file
BIN
web/public/fonts/RulesExpanded-Bold.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/RulesExpanded-Regular.woff2
Normal file
BIN
web/public/fonts/RulesExpanded-Regular.woff2
Normal file
Binary file not shown.
117
web/src/App.tsx
Normal file
117
web/src/App.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react";
|
||||||
|
import StatusPage from "@/pages/StatusPage";
|
||||||
|
import ConfigPage from "@/pages/ConfigPage";
|
||||||
|
import EnvPage from "@/pages/EnvPage";
|
||||||
|
import SessionsPage from "@/pages/SessionsPage";
|
||||||
|
import LogsPage from "@/pages/LogsPage";
|
||||||
|
import AnalyticsPage from "@/pages/AnalyticsPage";
|
||||||
|
import CronPage from "@/pages/CronPage";
|
||||||
|
import SkillsPage from "@/pages/SkillsPage";
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ id: "status", label: "Status", icon: Activity },
|
||||||
|
{ id: "sessions", label: "Sessions", icon: MessageSquare },
|
||||||
|
{ id: "analytics", label: "Analytics", icon: BarChart3 },
|
||||||
|
{ id: "logs", label: "Logs", icon: FileText },
|
||||||
|
{ id: "cron", label: "Cron", icon: Clock },
|
||||||
|
{ id: "skills", label: "Skills", icon: Package },
|
||||||
|
{ id: "config", label: "Config", icon: Settings },
|
||||||
|
{ id: "env", label: "Keys", icon: KeyRound },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type PageId = (typeof NAV_ITEMS)[number]["id"];
|
||||||
|
|
||||||
|
const PAGE_COMPONENTS: Record<PageId, React.FC> = {
|
||||||
|
status: StatusPage,
|
||||||
|
sessions: SessionsPage,
|
||||||
|
analytics: AnalyticsPage,
|
||||||
|
logs: LogsPage,
|
||||||
|
cron: CronPage,
|
||||||
|
skills: SkillsPage,
|
||||||
|
config: ConfigPage,
|
||||||
|
env: EnvPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [page, setPage] = useState<PageId>("status");
|
||||||
|
const [animKey, setAnimKey] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAnimKey((k) => k + 1);
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
const PageComponent = PAGE_COMPONENTS[page];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-background text-foreground">
|
||||||
|
{/* Global grain + warm glow (matches landing page) */}
|
||||||
|
<div className="noise-overlay" />
|
||||||
|
<div className="warm-glow" />
|
||||||
|
|
||||||
|
{/* ---- Header with grid-border nav ---- */}
|
||||||
|
<header className="sticky top-0 z-40 border-b border-border bg-background/90 backdrop-blur-sm">
|
||||||
|
<div className="mx-auto flex h-12 max-w-[1400px] items-stretch">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="flex items-center border-r border-border px-5 shrink-0">
|
||||||
|
<span className="font-collapse text-xl font-bold tracking-wider uppercase blend-lighter">
|
||||||
|
Hermes<br className="hidden sm:inline" /><span className="sm:hidden"> </span>Agent
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav grid — Mondwest labels like the landing page nav */}
|
||||||
|
<nav className="flex items-stretch overflow-x-auto scrollbar-none">
|
||||||
|
{NAV_ITEMS.map(({ id, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage(id)}
|
||||||
|
className={`group relative inline-flex items-center gap-1.5 border-r border-border px-4 py-2 font-display text-[0.8rem] tracking-[0.12em] uppercase whitespace-nowrap transition-colors cursor-pointer shrink-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
|
||||||
|
page === id
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{label}
|
||||||
|
{/* Hover highlight */}
|
||||||
|
<span className="absolute inset-0 bg-foreground pointer-events-none transition-opacity duration-150 group-hover:opacity-5 opacity-0" />
|
||||||
|
{/* Active indicator — dither bar */}
|
||||||
|
{page === id && (
|
||||||
|
<span className="absolute bottom-0 left-0 right-0 h-px bg-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Version badge */}
|
||||||
|
<div className="ml-auto flex items-center px-4 text-muted-foreground">
|
||||||
|
<span className="font-display text-[0.7rem] tracking-[0.15em] uppercase opacity-50">
|
||||||
|
Web UI
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main
|
||||||
|
key={animKey}
|
||||||
|
className="relative z-2 mx-auto w-full max-w-[1400px] flex-1 px-6 py-8"
|
||||||
|
style={{ animation: "fade-in 150ms ease-out" }}
|
||||||
|
>
|
||||||
|
<PageComponent />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* ---- Footer ---- */}
|
||||||
|
<footer className="relative z-2 border-t border-border">
|
||||||
|
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-3">
|
||||||
|
<span className="font-display text-[0.8rem] tracking-[0.12em] uppercase opacity-50">
|
||||||
|
Hermes Agent
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-[0.7rem] tracking-[0.15em] uppercase text-foreground/40">
|
||||||
|
Nous Research
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
web/src/components/AutoField.tsx
Normal file
151
web/src/components/AutoField.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
||||||
|
function FieldHint({ schema, schemaKey }: { schema: Record<string, unknown>; schemaKey: string }) {
|
||||||
|
const keyPath = schemaKey.includes(".") ? schemaKey : "";
|
||||||
|
const description = schema.description ? String(schema.description) : "";
|
||||||
|
|
||||||
|
if (!keyPath && !description) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{keyPath && <span className="text-[10px] font-mono text-muted-foreground/50">{keyPath}</span>}
|
||||||
|
{description && <span className="text-xs text-muted-foreground/70">{description}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutoField({
|
||||||
|
schemaKey,
|
||||||
|
schema,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: AutoFieldProps) {
|
||||||
|
const rawLabel = schemaKey.split(".").pop() ?? schemaKey;
|
||||||
|
const label = rawLabel.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
|
||||||
|
if (schema.type === "boolean") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<Label className="text-sm">{label}</Label>
|
||||||
|
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||||
|
</div>
|
||||||
|
<Switch checked={!!value} onCheckedChange={onChange} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.type === "select") {
|
||||||
|
const options = (schema.options as string[]) ?? [];
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label className="text-sm">{label}</Label>
|
||||||
|
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||||
|
<Select value={String(value ?? "")} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt} value={opt}>
|
||||||
|
{opt || "(none)"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.type === "number") {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label className="text-sm">{label}</Label>
|
||||||
|
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={value === undefined || value === null ? "" : String(value)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value;
|
||||||
|
if (raw === "") {
|
||||||
|
onChange(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isNaN(n)) {
|
||||||
|
onChange(n);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.type === "text") {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label className="text-sm">{label}</Label>
|
||||||
|
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||||
|
<textarea
|
||||||
|
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.type === "list") {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label className="text-sm">{label}</Label>
|
||||||
|
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||||
|
<Input
|
||||||
|
value={Array.isArray(value) ? value.join(", ") : String(value ?? "")}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(
|
||||||
|
e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="comma-separated values"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 rounded-lg border border-border p-3">
|
||||||
|
<Label className="text-xs font-medium">{label}</Label>
|
||||||
|
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||||
|
{Object.entries(obj).map(([subKey, subVal]) => (
|
||||||
|
<div key={subKey} className="grid gap-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">{subKey}</Label>
|
||||||
|
<Input
|
||||||
|
value={String(subVal ?? "")}
|
||||||
|
onChange={(e) => onChange({ ...obj, [subKey]: e.target.value })}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label className="text-sm">{label}</Label>
|
||||||
|
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||||
|
<Input value={String(value ?? "")} onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoFieldProps {
|
||||||
|
schemaKey: string;
|
||||||
|
schema: Record<string, unknown>;
|
||||||
|
value: unknown;
|
||||||
|
onChange: (v: unknown) => void;
|
||||||
|
}
|
||||||
279
web/src/components/Markdown.tsx
Normal file
279
web/src/components/Markdown.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight markdown renderer for LLM output.
|
||||||
|
* Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules.
|
||||||
|
* NOT a full CommonMark parser — optimized for typical assistant message patterns.
|
||||||
|
*/
|
||||||
|
export function Markdown({ content, highlightTerms }: { content: string; highlightTerms?: string[] }) {
|
||||||
|
const blocks = useMemo(() => parseBlocks(content), [content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-foreground leading-relaxed space-y-2">
|
||||||
|
{blocks.map((block, i) => (
|
||||||
|
<Block key={i} block={block} highlightTerms={highlightTerms} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type BlockNode =
|
||||||
|
| { type: "code"; lang: string; content: string }
|
||||||
|
| { type: "heading"; level: number; content: string }
|
||||||
|
| { type: "hr" }
|
||||||
|
| { type: "list"; ordered: boolean; items: string[] }
|
||||||
|
| { type: "paragraph"; content: string };
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Block parser */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function parseBlocks(text: string): BlockNode[] {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
const blocks: BlockNode[] = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
// Fenced code block
|
||||||
|
const fenceMatch = line.match(/^```(\w*)/);
|
||||||
|
if (fenceMatch) {
|
||||||
|
const lang = fenceMatch[1] || "";
|
||||||
|
const codeLines: string[] = [];
|
||||||
|
i++;
|
||||||
|
while (i < lines.length && !lines[i].startsWith("```")) {
|
||||||
|
codeLines.push(lines[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
i++; // skip closing ```
|
||||||
|
blocks.push({ type: "code", lang, content: codeLines.join("\n") });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heading
|
||||||
|
const headingMatch = line.match(/^(#{1,4})\s+(.+)/);
|
||||||
|
if (headingMatch) {
|
||||||
|
blocks.push({ type: "heading", level: headingMatch[1].length, content: headingMatch[2] });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal rule
|
||||||
|
if (/^[-*_]{3,}\s*$/.test(line)) {
|
||||||
|
blocks.push({ type: "hr" });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unordered list
|
||||||
|
if (/^[-*+]\s/.test(line)) {
|
||||||
|
const items: string[] = [];
|
||||||
|
while (i < lines.length && /^[-*+]\s/.test(lines[i])) {
|
||||||
|
items.push(lines[i].replace(/^[-*+]\s/, ""));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
blocks.push({ type: "list", ordered: false, items });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordered list
|
||||||
|
if (/^\d+[.)]\s/.test(line)) {
|
||||||
|
const items: string[] = [];
|
||||||
|
while (i < lines.length && /^\d+[.)]\s/.test(lines[i])) {
|
||||||
|
items.push(lines[i].replace(/^\d+[.)]\s/, ""));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
blocks.push({ type: "list", ordered: true, items });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty line
|
||||||
|
if (line.trim() === "") {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph — collect consecutive non-empty, non-special lines
|
||||||
|
const paraLines: string[] = [];
|
||||||
|
while (
|
||||||
|
i < lines.length &&
|
||||||
|
lines[i].trim() !== "" &&
|
||||||
|
!lines[i].match(/^```/) &&
|
||||||
|
!lines[i].match(/^#{1,4}\s/) &&
|
||||||
|
!lines[i].match(/^[-*+]\s/) &&
|
||||||
|
!lines[i].match(/^\d+[.)]\s/) &&
|
||||||
|
!lines[i].match(/^[-*_]{3,}\s*$/)
|
||||||
|
) {
|
||||||
|
paraLines.push(lines[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (paraLines.length > 0) {
|
||||||
|
blocks.push({ type: "paragraph", content: paraLines.join("\n") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Block renderer */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: string[] }) {
|
||||||
|
switch (block.type) {
|
||||||
|
case "code":
|
||||||
|
return (
|
||||||
|
<pre className="rounded-md bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto">
|
||||||
|
<code>{block.content}</code>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "heading": {
|
||||||
|
const Tag = `h${Math.min(block.level, 4)}` as "h1" | "h2" | "h3" | "h4";
|
||||||
|
const sizes: Record<string, string> = {
|
||||||
|
h1: "text-base font-bold",
|
||||||
|
h2: "text-sm font-bold",
|
||||||
|
h3: "text-sm font-semibold",
|
||||||
|
h4: "text-sm font-medium",
|
||||||
|
};
|
||||||
|
return <Tag className={sizes[Tag]}><InlineContent text={block.content} highlightTerms={highlightTerms} /></Tag>;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "hr":
|
||||||
|
return <hr className="border-border" />;
|
||||||
|
|
||||||
|
case "list": {
|
||||||
|
const Tag = block.ordered ? "ol" : "ul";
|
||||||
|
return (
|
||||||
|
<Tag className={`space-y-0.5 ${block.ordered ? "list-decimal" : "list-disc"} pl-5 text-sm`}>
|
||||||
|
{block.items.map((item, i) => (
|
||||||
|
<li key={i}><InlineContent text={item} highlightTerms={highlightTerms} /></li>
|
||||||
|
))}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "paragraph":
|
||||||
|
return <p><InlineContent text={block.content} highlightTerms={highlightTerms} /></p>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Inline parser + renderer */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type InlineNode =
|
||||||
|
| { type: "text"; content: string }
|
||||||
|
| { type: "code"; content: string }
|
||||||
|
| { type: "bold"; content: string }
|
||||||
|
| { type: "italic"; content: string }
|
||||||
|
| { type: "link"; text: string; href: string }
|
||||||
|
| { type: "br" };
|
||||||
|
|
||||||
|
function parseInline(text: string): InlineNode[] {
|
||||||
|
const nodes: InlineNode[] = [];
|
||||||
|
// Pattern priority: code > link > bold > italic > bare URL > line break
|
||||||
|
const pattern = /(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
nodes.push({ type: "text", content: text.slice(lastIndex, match.index) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match[1]) {
|
||||||
|
// Inline code
|
||||||
|
nodes.push({ type: "code", content: match[1].slice(1, -1) });
|
||||||
|
} else if (match[2]) {
|
||||||
|
// [text](url) link
|
||||||
|
nodes.push({ type: "link", text: match[3], href: match[4] });
|
||||||
|
} else if (match[5]) {
|
||||||
|
// **bold**
|
||||||
|
nodes.push({ type: "bold", content: match[6] });
|
||||||
|
} else if (match[7]) {
|
||||||
|
// *italic*
|
||||||
|
nodes.push({ type: "italic", content: match[8] });
|
||||||
|
} else if (match[9]) {
|
||||||
|
// Bare URL
|
||||||
|
nodes.push({ type: "link", text: match[9], href: match[9] });
|
||||||
|
} else if (match[10]) {
|
||||||
|
// Line break within paragraph
|
||||||
|
nodes.push({ type: "br" });
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
nodes.push({ type: "text", content: text.slice(lastIndex) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?: string[] }) {
|
||||||
|
const nodes = useMemo(() => parseInline(text), [text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{nodes.map((node, i) => {
|
||||||
|
switch (node.type) {
|
||||||
|
case "text":
|
||||||
|
return <HighlightedText key={i} text={node.content} terms={highlightTerms} />;
|
||||||
|
case "code":
|
||||||
|
return (
|
||||||
|
<code key={i} className="rounded bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90">
|
||||||
|
{node.content}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
case "bold":
|
||||||
|
return <strong key={i} className="font-semibold"><HighlightedText text={node.content} terms={highlightTerms} /></strong>;
|
||||||
|
case "italic":
|
||||||
|
return <em key={i}><HighlightedText text={node.content} terms={highlightTerms} /></em>;
|
||||||
|
case "link":
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={node.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-primary underline underline-offset-2 decoration-primary/30 hover:decoration-primary/60 transition-colors"
|
||||||
|
>
|
||||||
|
{node.text}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
case "br":
|
||||||
|
return <br key={i} />;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Highlight search terms within a plain text string. */
|
||||||
|
function HighlightedText({ text, terms }: { text: string; terms?: string[] }) {
|
||||||
|
if (!terms || terms.length === 0) return <>{text}</>;
|
||||||
|
|
||||||
|
// Build a regex that matches any of the search terms (case-insensitive)
|
||||||
|
const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||||
|
const regex = new RegExp(`(${escaped.join("|")})`, "gi");
|
||||||
|
const parts = text.split(regex);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.map((part, i) =>
|
||||||
|
regex.test(part) ? (
|
||||||
|
<mark key={i} className="bg-warning/30 text-warning rounded-sm px-0.5">{part}</mark>
|
||||||
|
) : (
|
||||||
|
<span key={i}>{part}</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
web/src/components/Toast.tsx
Normal file
36
web/src/components/Toast.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function Toast({ toast }: { toast: { message: string; type: "success" | "error" } | null }) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [current, setCurrent] = useState(toast);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (toast) {
|
||||||
|
setCurrent(toast);
|
||||||
|
setVisible(true);
|
||||||
|
} else {
|
||||||
|
setVisible(false);
|
||||||
|
const timer = setTimeout(() => setCurrent(null), 200);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className={`fixed top-4 right-4 z-50 border px-4 py-2.5 font-courier text-xs tracking-wider uppercase backdrop-blur-sm ${
|
||||||
|
current.type === "success"
|
||||||
|
? "bg-success/15 text-success border-success/30"
|
||||||
|
: "bg-destructive/15 text-destructive border-destructive/30"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
animation: visible ? "toast-in 200ms ease-out forwards" : "toast-out 200ms ease-in forwards",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{current.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 border px-2 py-0.5 font-compressed text-[0.65rem] tracking-[0.15em] uppercase transition-colors",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-foreground/20 bg-foreground/10 text-foreground",
|
||||||
|
secondary: "border-border bg-secondary text-secondary-foreground",
|
||||||
|
destructive: "border-destructive/30 bg-destructive/15 text-destructive",
|
||||||
|
outline: "border-border text-muted-foreground",
|
||||||
|
success: "grain border-emerald-600/30 bg-emerald-950/70 text-emerald-400",
|
||||||
|
warning: "border-warning/30 bg-warning/15 text-warning",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
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 font-display text-xs tracking-[0.1em] uppercase transition-colors cursor-pointer"
|
||||||
|
+ " disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-foreground/90 text-background hover:bg-foreground",
|
||||||
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline: "border border-border bg-transparent hover:bg-foreground/10 hover:text-foreground",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-foreground/10 hover:text-foreground",
|
||||||
|
link: "text-foreground underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 px-3 text-[0.65rem]",
|
||||||
|
lg: "h-10 px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
...props
|
||||||
|
}: React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {
|
||||||
|
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||||
|
}
|
||||||
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(
|
||||||
|
"border border-border bg-card/80 text-card-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("flex flex-col gap-1.5 p-4 border-b border-border", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
return <h3 className={cn("font-expanded text-sm font-bold tracking-[0.08em] uppercase blend-lighter", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
|
return <p className={cn("font-display text-xs text-muted-foreground", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("p-4", className)} {...props} />;
|
||||||
|
}
|
||||||
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 border border-border bg-background/40 px-3 py-1 font-courier text-sm transition-colors",
|
||||||
|
"placeholder:text-muted-foreground",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
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(
|
||||||
|
"font-display text-xs tracking-[0.1em] uppercase 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 border border-border bg-background/40 px-3 py-1 font-courier text-sm",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 border border-border transition-colors",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
checked ? "bg-foreground/15 border-foreground/30" : "bg-background",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={() => onCheckedChange(!checked)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-3.5 w-3.5 transition-transform",
|
||||||
|
checked ? "translate-x-4 bg-foreground" : "translate-x-0.5 bg-muted-foreground",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
web/src/components/ui/tabs.tsx
Normal file
51
web/src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function Tabs({
|
||||||
|
defaultValue,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
defaultValue: string;
|
||||||
|
children: (active: string, setActive: (v: string) => void) => React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [active, setActive] = useState(defaultValue);
|
||||||
|
return <div className={cn("flex flex-col gap-4", className)}>{children(active, setActive)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabsList({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-9 items-center justify-start border-b border-border text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabsTrigger({
|
||||||
|
active,
|
||||||
|
value,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { active: boolean; value: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex items-center justify-center whitespace-nowrap px-3 py-1.5 font-display text-xs tracking-[0.1em] uppercase transition-all cursor-pointer",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
|
active
|
||||||
|
? "text-foreground after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-foreground"
|
||||||
|
: "hover:text-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
web/src/hooks/useToast.ts
Normal file
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 };
|
||||||
|
}
|
||||||
197
web/src/index.css
Normal file
197
web/src/index.css
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Hermes Agent — Design tokens */
|
||||||
|
/* Matched to hermes-agent.nousresearch.com (dark teal theme) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/* --- Font faces --- */
|
||||||
|
@font-face { font-family: "Collapse"; src: url("/fonts/Collapse-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
|
||||||
|
@font-face { font-family: "Collapse"; src: url("/fonts/Collapse-Bold.woff2") format("woff2"); font-weight: 700; font-display: swap; }
|
||||||
|
@font-face { font-family: "Courier Prime"; src: url("/fonts/CourierPrime-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
|
||||||
|
@font-face { font-family: "Courier Prime"; src: url("/fonts/CourierPrime-Bold.woff2") format("woff2"); font-weight: 700; font-display: swap; }
|
||||||
|
@font-face { font-family: "RulesCompressed"; src: url("/fonts/RulesCompressed-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
|
||||||
|
@font-face { font-family: "RulesCompressed"; src: url("/fonts/RulesCompressed-Medium.woff2") format("woff2"); font-weight: 600; font-display: swap; }
|
||||||
|
@font-face { font-family: "RulesExpanded"; src: url("/fonts/RulesExpanded-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
|
||||||
|
@font-face { font-family: "RulesExpanded"; src: url("/fonts/RulesExpanded-Bold.woff2") format("woff2"); font-weight: 700; font-display: swap; }
|
||||||
|
@font-face { font-family: "Mondwest"; src: url("/fonts/Mondwest-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* ---- Hermes palette (dark teal, from live site) ---- */
|
||||||
|
--color-background: #041C1C;
|
||||||
|
--color-foreground: #ffe6cb;
|
||||||
|
--color-card: #062424;
|
||||||
|
--color-card-foreground: #ffe6cb;
|
||||||
|
--color-primary: #ffe6cb;
|
||||||
|
--color-primary-foreground: #041C1C;
|
||||||
|
--color-secondary: #0a2e2e;
|
||||||
|
--color-secondary-foreground: #ffe6cb;
|
||||||
|
--color-muted: #083030;
|
||||||
|
--color-muted-foreground: #8aaa9a;
|
||||||
|
--color-accent: #0c3838;
|
||||||
|
--color-accent-foreground: #ffe6cb;
|
||||||
|
--color-destructive: #fb2c36;
|
||||||
|
--color-destructive-foreground: #fff;
|
||||||
|
--color-success: #4ade80;
|
||||||
|
--color-warning: #ffbd38;
|
||||||
|
--color-border: color-mix(in srgb, #ffe6cb 15%, transparent);
|
||||||
|
--color-input: color-mix(in srgb, #ffe6cb 15%, transparent);
|
||||||
|
--color-ring: #ffe6cb;
|
||||||
|
--color-popover: #062424;
|
||||||
|
--color-popover-foreground: #ffe6cb;
|
||||||
|
|
||||||
|
/* ---- Font stacks ---- */
|
||||||
|
--font-sans: "Mondwest", Arial, sans-serif;
|
||||||
|
--font-mono: "Courier Prime", "Courier New", monospace;
|
||||||
|
--font-display: "Mondwest", Arial, sans-serif;
|
||||||
|
--font-expanded: "RulesExpanded", Arial, sans-serif;
|
||||||
|
--font-compressed: "RulesCompressed", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Global body ---- */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Mondwest", Arial, sans-serif;
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Selection ---- */
|
||||||
|
::selection {
|
||||||
|
background: var(--color-foreground);
|
||||||
|
color: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scrollbars (thin, subtle) ---- */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
}
|
||||||
|
*:hover {
|
||||||
|
scrollbar-color: color-mix(in srgb, var(--color-foreground) 15%, transparent) transparent;
|
||||||
|
}
|
||||||
|
html, body {
|
||||||
|
scrollbar-color: color-mix(in srgb, var(--color-foreground) 25%, transparent) transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in srgb, var(--color-foreground) 20%, transparent);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-foreground) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Hide scrollbar utility ---- */
|
||||||
|
.scrollbar-none {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.scrollbar-none::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Code blocks ---- */
|
||||||
|
code {
|
||||||
|
font-family: "Courier Prime", "Courier New", monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 0.15em 0.4em;
|
||||||
|
border-radius: 0;
|
||||||
|
background: color-mix(in srgb, var(--color-foreground) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Dither texture ---- */
|
||||||
|
.dither {
|
||||||
|
background: repeating-conic-gradient(currentColor 0% 25%, #0000 0% 50%) 0 0 / 2px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Blink cursor (only on group hover, like canonical) ---- */
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
.blink {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.group:hover .blink {
|
||||||
|
display: inline-block;
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Page transitions ---- */
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes toast-in {
|
||||||
|
from { opacity: 0; transform: translateX(16px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
@keyframes toast-out {
|
||||||
|
from { opacity: 1; transform: translateX(0); }
|
||||||
|
to { opacity: 0; transform: translateX(16px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Plus-lighter blend for headings ---- */
|
||||||
|
.blend-lighter {
|
||||||
|
mix-blend-mode: plus-lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Font utilities ---- */
|
||||||
|
.font-display { font-family: "Mondwest", Arial, sans-serif; }
|
||||||
|
.font-expanded { font-family: "RulesExpanded", Arial, sans-serif; }
|
||||||
|
.font-compressed { font-family: "RulesCompressed", Arial, sans-serif; }
|
||||||
|
.font-courier { font-family: "Courier Prime", "Courier New", monospace; }
|
||||||
|
.font-collapse { font-family: "Collapse", Arial, sans-serif; }
|
||||||
|
.font-mono-ui { font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace; }
|
||||||
|
|
||||||
|
/* ---- Subtle grain overlay for badges ---- */
|
||||||
|
.grain {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.grain::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0.12;
|
||||||
|
pointer-events: none;
|
||||||
|
background: repeating-conic-gradient(currentColor 0% 25%, #0000 0% 50%) 0 0 / 2px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Global noise grain (canonical: color-dodge, #eaeaea, high density) ---- */
|
||||||
|
.noise-overlay {
|
||||||
|
pointer-events: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 101;
|
||||||
|
mix-blend-mode: color-dodge;
|
||||||
|
opacity: 0.10;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' fill='%23eaeaea' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E");
|
||||||
|
background-size: 512px 512px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Vignette (canonical: top-left amber radial, lighten blend) ---- */
|
||||||
|
.warm-glow {
|
||||||
|
pointer-events: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 99;
|
||||||
|
mix-blend-mode: lighten;
|
||||||
|
opacity: 0.22;
|
||||||
|
background: radial-gradient(ellipse at 0% 0%, rgba(255,189,56,0.35) 0%, rgba(255,189,56,0) 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Reduced motion ---- */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
260
web/src/lib/api.ts
Normal file
260
web/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
const BASE = "";
|
||||||
|
|
||||||
|
// Ephemeral session token for protected endpoints (reveal).
|
||||||
|
// Fetched once on first reveal request and cached in memory.
|
||||||
|
let _sessionToken: string | null = null;
|
||||||
|
|
||||||
|
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${url}`, init);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => res.statusText);
|
||||||
|
throw new Error(`${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSessionToken(): Promise<string> {
|
||||||
|
if (_sessionToken) return _sessionToken;
|
||||||
|
const resp = await fetchJSON<{ token: string }>("/api/auth/session-token");
|
||||||
|
_sessionToken = resp.token;
|
||||||
|
return _sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
|
||||||
|
getSessions: () => fetchJSON<SessionInfo[]>("/api/sessions"),
|
||||||
|
getSessionMessages: (id: string) =>
|
||||||
|
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
|
||||||
|
deleteSession: (id: string) =>
|
||||||
|
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.file) qs.set("file", params.file);
|
||||||
|
if (params.lines) qs.set("lines", String(params.lines));
|
||||||
|
if (params.level && params.level !== "ALL") qs.set("level", params.level);
|
||||||
|
if (params.component && params.component !== "all") qs.set("component", params.component);
|
||||||
|
return fetchJSON<LogsResponse>(`/api/logs?${qs.toString()}`);
|
||||||
|
},
|
||||||
|
getAnalytics: (days: number) =>
|
||||||
|
fetchJSON<AnalyticsResponse>(`/api/analytics/usage?days=${days}`),
|
||||||
|
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
|
||||||
|
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
|
||||||
|
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
|
||||||
|
saveConfig: (config: Record<string, unknown>) =>
|
||||||
|
fetchJSON<{ ok: boolean }>("/api/config", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ config }),
|
||||||
|
}),
|
||||||
|
getConfigRaw: () => fetchJSON<{ yaml: string }>("/api/config/raw"),
|
||||||
|
saveConfigRaw: (yaml_text: string) =>
|
||||||
|
fetchJSON<{ ok: boolean }>("/api/config/raw", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ yaml_text }),
|
||||||
|
}),
|
||||||
|
getEnvVars: () => fetchJSON<Record<string, EnvVarInfo>>("/api/env"),
|
||||||
|
setEnvVar: (key: string, value: string) =>
|
||||||
|
fetchJSON<{ ok: boolean }>("/api/env", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ key, value }),
|
||||||
|
}),
|
||||||
|
deleteEnvVar: (key: string) =>
|
||||||
|
fetchJSON<{ ok: boolean }>("/api/env", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
}),
|
||||||
|
revealEnvVar: async (key: string) => {
|
||||||
|
const token = await getSessionToken();
|
||||||
|
return fetchJSON<{ key: string; value: string }>("/api/env/reveal", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cron jobs
|
||||||
|
getCronJobs: () => fetchJSON<CronJob[]>("/api/cron/jobs"),
|
||||||
|
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }) =>
|
||||||
|
fetchJSON<CronJob>("/api/cron/jobs", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(job),
|
||||||
|
}),
|
||||||
|
pauseCronJob: (id: string) =>
|
||||||
|
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/pause`, { method: "POST" }),
|
||||||
|
resumeCronJob: (id: string) =>
|
||||||
|
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/resume`, { method: "POST" }),
|
||||||
|
triggerCronJob: (id: string) =>
|
||||||
|
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/trigger`, { method: "POST" }),
|
||||||
|
deleteCronJob: (id: string) =>
|
||||||
|
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// Skills & Toolsets
|
||||||
|
getSkills: () => fetchJSON<SkillInfo[]>("/api/skills"),
|
||||||
|
toggleSkill: (name: string, enabled: boolean) =>
|
||||||
|
fetchJSON<{ ok: boolean }>("/api/skills/toggle", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, enabled }),
|
||||||
|
}),
|
||||||
|
getToolsets: () => fetchJSON<ToolsetInfo[]>("/api/tools/toolsets"),
|
||||||
|
|
||||||
|
// Session search (FTS5)
|
||||||
|
searchSessions: (q: string) =>
|
||||||
|
fetchJSON<SessionSearchResponse>(`/api/sessions/search?q=${encodeURIComponent(q)}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PlatformStatus {
|
||||||
|
error_code?: string;
|
||||||
|
error_message?: string;
|
||||||
|
state: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusResponse {
|
||||||
|
active_sessions: number;
|
||||||
|
config_path: string;
|
||||||
|
config_version: number;
|
||||||
|
env_path: string;
|
||||||
|
gateway_exit_reason: string | null;
|
||||||
|
gateway_pid: number | null;
|
||||||
|
gateway_platforms: Record<string, PlatformStatus>;
|
||||||
|
gateway_running: boolean;
|
||||||
|
gateway_state: string | null;
|
||||||
|
gateway_updated_at: string | null;
|
||||||
|
hermes_home: string;
|
||||||
|
latest_config_version: number;
|
||||||
|
release_date: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionInfo {
|
||||||
|
id: string;
|
||||||
|
source: string | null;
|
||||||
|
model: string | null;
|
||||||
|
title: string | null;
|
||||||
|
started_at: number;
|
||||||
|
ended_at: number | null;
|
||||||
|
last_active: number;
|
||||||
|
is_active: boolean;
|
||||||
|
message_count: number;
|
||||||
|
tool_call_count: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
preview: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvVarInfo {
|
||||||
|
is_set: boolean;
|
||||||
|
redacted_value: string | null;
|
||||||
|
description: string;
|
||||||
|
url: string | null;
|
||||||
|
category: string;
|
||||||
|
is_password: boolean;
|
||||||
|
tools: string[];
|
||||||
|
advanced: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionMessage {
|
||||||
|
role: "user" | "assistant" | "system" | "tool";
|
||||||
|
content: string | null;
|
||||||
|
tool_calls?: Array<{
|
||||||
|
id: string;
|
||||||
|
function: { name: string; arguments: string };
|
||||||
|
}>;
|
||||||
|
tool_name?: string;
|
||||||
|
tool_call_id?: string;
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionMessagesResponse {
|
||||||
|
session_id: string;
|
||||||
|
messages: SessionMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogsResponse {
|
||||||
|
file: string;
|
||||||
|
lines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsDailyEntry {
|
||||||
|
day: string;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cache_read_tokens: number;
|
||||||
|
reasoning_tokens: number;
|
||||||
|
estimated_cost: number;
|
||||||
|
actual_cost: number;
|
||||||
|
sessions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsModelEntry {
|
||||||
|
model: string;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
estimated_cost: number;
|
||||||
|
sessions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsResponse {
|
||||||
|
daily: AnalyticsDailyEntry[];
|
||||||
|
by_model: AnalyticsModelEntry[];
|
||||||
|
totals: {
|
||||||
|
total_input: number;
|
||||||
|
total_output: number;
|
||||||
|
total_cache_read: number;
|
||||||
|
total_reasoning: number;
|
||||||
|
total_estimated_cost: number;
|
||||||
|
total_actual_cost: number;
|
||||||
|
total_sessions: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CronJob {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
prompt: string;
|
||||||
|
schedule: string;
|
||||||
|
status: "enabled" | "paused" | "error";
|
||||||
|
deliver?: string;
|
||||||
|
last_run_at?: string | null;
|
||||||
|
next_run_at?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillInfo {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolsetInfo {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
configured: boolean;
|
||||||
|
tools: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionSearchResult {
|
||||||
|
session_id: string;
|
||||||
|
snippet: string;
|
||||||
|
role: string | null;
|
||||||
|
source: string | null;
|
||||||
|
model: string | null;
|
||||||
|
session_started: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionSearchResponse {
|
||||||
|
results: SessionSearchResult[];
|
||||||
|
}
|
||||||
23
web/src/lib/nested.ts
Normal file
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;
|
||||||
|
}
|
||||||
26
web/src/lib/utils.ts
Normal file
26
web/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relative time from a Unix epoch timestamp (seconds). */
|
||||||
|
export function timeAgo(ts: number): string {
|
||||||
|
const delta = Date.now() / 1000 - ts;
|
||||||
|
if (delta < 60) return "just now";
|
||||||
|
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
|
||||||
|
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
|
||||||
|
if (delta < 172800) return "yesterday";
|
||||||
|
return `${Math.floor(delta / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relative time from an ISO-8601 timestamp string. */
|
||||||
|
export function isoTimeAgo(iso: string): string {
|
||||||
|
const delta = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||||
|
if (delta < 0 || Number.isNaN(delta)) return "unknown";
|
||||||
|
if (delta < 60) return "just now";
|
||||||
|
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
|
||||||
|
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
|
||||||
|
return `${Math.floor(delta / 86400)}d ago`;
|
||||||
|
}
|
||||||
10
web/src/main.tsx
Normal file
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>,
|
||||||
|
);
|
||||||
370
web/src/pages/AnalyticsPage.tsx
Normal file
370
web/src/pages/AnalyticsPage.tsx
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Coins,
|
||||||
|
Cpu,
|
||||||
|
Database,
|
||||||
|
Hash,
|
||||||
|
TrendingUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry } from "@/lib/api";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const PERIODS = [
|
||||||
|
{ label: "7d", days: 7 },
|
||||||
|
{ label: "30d", days: 30 },
|
||||||
|
{ label: "90d", days: 90 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CHART_HEIGHT_PX = 160;
|
||||||
|
|
||||||
|
function formatTokens(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCost(n: number): string {
|
||||||
|
if (n < 0.01) return `$${n.toFixed(4)}`;
|
||||||
|
return `$${n.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick the best cost value: actual > estimated > 0 */
|
||||||
|
function bestCost(entry: { estimated_cost: number; actual_cost?: number }): number {
|
||||||
|
if (entry.actual_cost && entry.actual_cost > 0) return entry.actual_cost;
|
||||||
|
return entry.estimated_cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(day: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(day + "T00:00:00");
|
||||||
|
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
|
} catch {
|
||||||
|
return day;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{label}</CardTitle>
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{value}</div>
|
||||||
|
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||||
|
if (daily.length === 0) return null;
|
||||||
|
|
||||||
|
const maxTokens = Math.max(...daily.map((d) => d.input_tokens + d.output_tokens), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">Daily Token Usage</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="h-2.5 w-2.5 rounded-sm bg-[#ffe6cb]" />
|
||||||
|
Input
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="h-2.5 w-2.5 rounded-sm bg-emerald-500" />
|
||||||
|
Output
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-end gap-[2px]" style={{ height: CHART_HEIGHT_PX }}>
|
||||||
|
{daily.map((d) => {
|
||||||
|
const total = d.input_tokens + d.output_tokens;
|
||||||
|
const inputH = Math.round((d.input_tokens / maxTokens) * CHART_HEIGHT_PX);
|
||||||
|
const outputH = Math.round((d.output_tokens / maxTokens) * CHART_HEIGHT_PX);
|
||||||
|
const cacheReadPct = d.cache_read_tokens > 0
|
||||||
|
? Math.round((d.cache_read_tokens / (d.input_tokens + d.cache_read_tokens)) * 100)
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={d.day}
|
||||||
|
className="flex-1 min-w-0 group relative flex flex-col justify-end"
|
||||||
|
style={{ height: CHART_HEIGHT_PX }}
|
||||||
|
>
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
|
||||||
|
<div className="rounded-md bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
|
||||||
|
<div className="font-medium">{formatDate(d.day)}</div>
|
||||||
|
<div>Input: {formatTokens(d.input_tokens)}</div>
|
||||||
|
<div>Output: {formatTokens(d.output_tokens)}</div>
|
||||||
|
{cacheReadPct > 0 && <div>Cache hit: {cacheReadPct}%</div>}
|
||||||
|
<div>Total: {formatTokens(total)}</div>
|
||||||
|
{bestCost(d) > 0 && <div>Cost: {formatCost(bestCost(d))}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Input bar */}
|
||||||
|
<div
|
||||||
|
className="w-full bg-[#ffe6cb]/70"
|
||||||
|
style={{ height: Math.max(inputH, total > 0 ? 1 : 0) }}
|
||||||
|
/>
|
||||||
|
{/* Output bar */}
|
||||||
|
<div
|
||||||
|
className="w-full bg-emerald-500/70"
|
||||||
|
style={{ height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* X-axis labels */}
|
||||||
|
<div className="flex justify-between mt-2 text-[10px] text-muted-foreground">
|
||||||
|
<span>{daily.length > 0 ? formatDate(daily[0].day) : ""}</span>
|
||||||
|
{daily.length > 2 && (
|
||||||
|
<span>{formatDate(daily[Math.floor(daily.length / 2)].day)}</span>
|
||||||
|
)}
|
||||||
|
<span>{daily.length > 1 ? formatDate(daily[daily.length - 1].day) : ""}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||||
|
if (daily.length === 0) return null;
|
||||||
|
|
||||||
|
const sorted = [...daily].reverse();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">Daily Breakdown</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||||
|
<th className="text-left py-2 pr-4 font-medium">Date</th>
|
||||||
|
<th className="text-right py-2 px-4 font-medium">Sessions</th>
|
||||||
|
<th className="text-right py-2 px-4 font-medium">Input</th>
|
||||||
|
<th className="text-right py-2 px-4 font-medium">Output</th>
|
||||||
|
<th className="text-right py-2 px-4 font-medium">Cache Hit</th>
|
||||||
|
<th className="text-right py-2 pl-4 font-medium">Cost</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map((d) => {
|
||||||
|
const cost = bestCost(d);
|
||||||
|
const cacheHitPct = d.cache_read_tokens > 0 && d.input_tokens > 0
|
||||||
|
? Math.round((d.cache_read_tokens / d.input_tokens) * 100)
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<tr key={d.day} className="border-b border-border/50 hover:bg-secondary/20 transition-colors">
|
||||||
|
<td className="py-2 pr-4 font-medium">{formatDate(d.day)}</td>
|
||||||
|
<td className="text-right py-2 px-4 text-muted-foreground">{d.sessions}</td>
|
||||||
|
<td className="text-right py-2 px-4">
|
||||||
|
<span className="text-[#ffe6cb]">{formatTokens(d.input_tokens)}</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-2 px-4">
|
||||||
|
<span className="text-emerald-400">{formatTokens(d.output_tokens)}</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-2 px-4 text-muted-foreground">
|
||||||
|
{cacheHitPct > 0 ? `${cacheHitPct}%` : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-2 pl-4 text-muted-foreground">
|
||||||
|
{cost > 0 ? formatCost(cost) : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
||||||
|
if (models.length === 0) return null;
|
||||||
|
|
||||||
|
const sorted = [...models].sort(
|
||||||
|
(a, b) => b.input_tokens + b.output_tokens - (a.input_tokens + a.output_tokens),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Cpu className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">Per-Model Breakdown</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||||
|
<th className="text-left py-2 pr-4 font-medium">Model</th>
|
||||||
|
<th className="text-right py-2 px-4 font-medium">Sessions</th>
|
||||||
|
<th className="text-right py-2 px-4 font-medium">Tokens</th>
|
||||||
|
<th className="text-right py-2 pl-4 font-medium">Cost</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map((m) => (
|
||||||
|
<tr key={m.model} className="border-b border-border/50 hover:bg-secondary/20 transition-colors">
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<span className="font-mono-ui text-xs">{m.model}</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-2 px-4 text-muted-foreground">{m.sessions}</td>
|
||||||
|
<td className="text-right py-2 px-4">
|
||||||
|
<span className="text-[#ffe6cb]">{formatTokens(m.input_tokens)}</span>
|
||||||
|
{" / "}
|
||||||
|
<span className="text-emerald-400">{formatTokens(m.output_tokens)}</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-2 pl-4 text-muted-foreground">
|
||||||
|
{m.estimated_cost > 0 ? formatCost(m.estimated_cost) : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnalyticsPage() {
|
||||||
|
const [days, setDays] = useState(30);
|
||||||
|
const [data, setData] = useState<AnalyticsResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
api
|
||||||
|
.getAnalytics(days)
|
||||||
|
.then(setData)
|
||||||
|
.catch((err) => setError(String(err)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [days]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Period selector */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground font-medium">Period:</span>
|
||||||
|
{PERIODS.map((p) => (
|
||||||
|
<Button
|
||||||
|
key={p.label}
|
||||||
|
variant={days === p.days ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="text-xs h-7"
|
||||||
|
onClick={() => setDays(p.days)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !data && (
|
||||||
|
<div className="flex items-center justify-center py-24">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-6">
|
||||||
|
<p className="text-sm text-destructive text-center">{error}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
{/* Summary cards — matches hermes's token model */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<SummaryCard
|
||||||
|
icon={Hash}
|
||||||
|
label="Total Tokens"
|
||||||
|
value={formatTokens(data.totals.total_input + data.totals.total_output)}
|
||||||
|
sub={`${formatTokens(data.totals.total_input)} in / ${formatTokens(data.totals.total_output)} out`}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
icon={Database}
|
||||||
|
label="Cache Hit"
|
||||||
|
value={data.totals.total_cache_read > 0
|
||||||
|
? `${Math.round((data.totals.total_cache_read / (data.totals.total_input + data.totals.total_cache_read)) * 100)}%`
|
||||||
|
: "—"}
|
||||||
|
sub={`${formatTokens(data.totals.total_cache_read)} tokens from cache`}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
icon={Coins}
|
||||||
|
label="Total Cost"
|
||||||
|
value={formatCost(
|
||||||
|
data.totals.total_actual_cost > 0
|
||||||
|
? data.totals.total_actual_cost
|
||||||
|
: data.totals.total_estimated_cost
|
||||||
|
)}
|
||||||
|
sub={data.totals.total_actual_cost > 0 ? "actual" : `estimated · last ${days}d`}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
icon={BarChart3}
|
||||||
|
label="Total Sessions"
|
||||||
|
value={String(data.totals.total_sessions)}
|
||||||
|
sub={`~${(data.totals.total_sessions / days).toFixed(1)}/day avg`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bar chart */}
|
||||||
|
<TokenBarChart daily={data.daily} />
|
||||||
|
|
||||||
|
{/* Tables */}
|
||||||
|
<DailyTable daily={data.daily} />
|
||||||
|
<ModelTable models={data.by_model} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && data.daily.length === 0 && data.by_model.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12">
|
||||||
|
<div className="flex flex-col items-center text-muted-foreground">
|
||||||
|
<BarChart3 className="h-8 w-8 mb-3 opacity-40" />
|
||||||
|
<p className="text-sm font-medium">No usage data for this period</p>
|
||||||
|
<p className="text-xs mt-1 text-muted-foreground/60">Start a session to see analytics here</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
451
web/src/pages/ConfigPage.tsx
Normal file
451
web/src/pages/ConfigPage.tsx
Normal file
|
|
@ -0,0 +1,451 @@
|
||||||
|
import { useEffect, useRef, useState, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Code,
|
||||||
|
Download,
|
||||||
|
FormInput,
|
||||||
|
RotateCcw,
|
||||||
|
Save,
|
||||||
|
Search,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
ChevronRight,
|
||||||
|
Settings2,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { getNestedValue, setNestedValue } from "@/lib/nested";
|
||||||
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
import { Toast } from "@/components/Toast";
|
||||||
|
import { AutoField } from "@/components/AutoField";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const CATEGORY_ICONS: Record<string, string> = {
|
||||||
|
general: "⚙️",
|
||||||
|
agent: "🤖",
|
||||||
|
terminal: "💻",
|
||||||
|
display: "🎨",
|
||||||
|
delegation: "👥",
|
||||||
|
memory: "🧠",
|
||||||
|
compression: "📦",
|
||||||
|
security: "🔒",
|
||||||
|
browser: "🌐",
|
||||||
|
voice: "🎙️",
|
||||||
|
tts: "🔊",
|
||||||
|
stt: "👂",
|
||||||
|
logging: "📋",
|
||||||
|
discord: "💬",
|
||||||
|
auxiliary: "🔧",
|
||||||
|
};
|
||||||
|
|
||||||
|
function prettyCategoryName(cat: string): string {
|
||||||
|
if (cat === "tts") return "Text-to-Speech";
|
||||||
|
if (cat === "stt") return "Speech-to-Text";
|
||||||
|
return cat.charAt(0).toUpperCase() + cat.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export default function ConfigPage() {
|
||||||
|
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [schema, setSchema] = useState<Record<string, Record<string, unknown>> | null>(null);
|
||||||
|
const [categoryOrder, setCategoryOrder] = useState<string[]>([]);
|
||||||
|
const [defaults, setDefaults] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [yamlMode, setYamlMode] = useState(false);
|
||||||
|
const [yamlText, setYamlText] = useState("");
|
||||||
|
const [yamlLoading, setYamlLoading] = useState(false);
|
||||||
|
const [yamlSaving, setYamlSaving] = useState(false);
|
||||||
|
const [activeCategory, setActiveCategory] = useState<string>("");
|
||||||
|
const { toast, showToast } = useToast();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getConfig().then(setConfig).catch(() => {});
|
||||||
|
api
|
||||||
|
.getSchema()
|
||||||
|
.then((resp) => {
|
||||||
|
setSchema(resp.fields as Record<string, Record<string, unknown>>);
|
||||||
|
setCategoryOrder(resp.category_order ?? []);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
api.getDefaults().then(setDefaults).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set active category when categories load
|
||||||
|
useEffect(() => {
|
||||||
|
if (categoryOrder.length > 0 && !activeCategory) {
|
||||||
|
setActiveCategory(categoryOrder[0]);
|
||||||
|
}
|
||||||
|
}, [categoryOrder, activeCategory]);
|
||||||
|
|
||||||
|
// Load YAML when switching to YAML mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (yamlMode) {
|
||||||
|
setYamlLoading(true);
|
||||||
|
api
|
||||||
|
.getConfigRaw()
|
||||||
|
.then((resp) => setYamlText(resp.yaml))
|
||||||
|
.catch(() => showToast("Failed to load raw config", "error"))
|
||||||
|
.finally(() => setYamlLoading(false));
|
||||||
|
}
|
||||||
|
}, [yamlMode]);
|
||||||
|
|
||||||
|
/* ---- Categories ---- */
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
if (!schema) return [];
|
||||||
|
const allCats = [...new Set(Object.values(schema).map((s) => String(s.category ?? "general")))];
|
||||||
|
const ordered = categoryOrder.filter((c) => allCats.includes(c));
|
||||||
|
const extra = allCats.filter((c) => !categoryOrder.includes(c)).sort();
|
||||||
|
return [...ordered, ...extra];
|
||||||
|
}, [schema, categoryOrder]);
|
||||||
|
|
||||||
|
/* ---- Category field counts ---- */
|
||||||
|
const categoryCounts = useMemo(() => {
|
||||||
|
if (!schema) return {};
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const s of Object.values(schema)) {
|
||||||
|
const cat = String(s.category ?? "general");
|
||||||
|
counts[cat] = (counts[cat] || 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [schema]);
|
||||||
|
|
||||||
|
/* ---- Search ---- */
|
||||||
|
const isSearching = searchQuery.trim().length > 0;
|
||||||
|
const lowerSearch = searchQuery.toLowerCase();
|
||||||
|
|
||||||
|
const searchMatchedFields = useMemo(() => {
|
||||||
|
if (!isSearching || !schema) return [];
|
||||||
|
return Object.entries(schema).filter(([key, s]) => {
|
||||||
|
const label = key.split(".").pop() ?? key;
|
||||||
|
const humanLabel = label.replace(/_/g, " ");
|
||||||
|
return (
|
||||||
|
key.toLowerCase().includes(lowerSearch) ||
|
||||||
|
humanLabel.toLowerCase().includes(lowerSearch) ||
|
||||||
|
String(s.category ?? "").toLowerCase().includes(lowerSearch) ||
|
||||||
|
String(s.description ?? "").toLowerCase().includes(lowerSearch)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [isSearching, lowerSearch, schema]);
|
||||||
|
|
||||||
|
/* ---- Active tab fields ---- */
|
||||||
|
const activeFields = useMemo(() => {
|
||||||
|
if (!schema || isSearching) return [];
|
||||||
|
return Object.entries(schema).filter(
|
||||||
|
([, s]) => String(s.category ?? "general") === activeCategory
|
||||||
|
);
|
||||||
|
}, [schema, activeCategory, isSearching]);
|
||||||
|
|
||||||
|
/* ---- Handlers ---- */
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!config) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.saveConfig(config);
|
||||||
|
showToast("Configuration saved", "success");
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`Failed to save: ${e}`, "error");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleYamlSave = async () => {
|
||||||
|
setYamlSaving(true);
|
||||||
|
try {
|
||||||
|
await api.saveConfigRaw(yamlText);
|
||||||
|
showToast("YAML config saved", "success");
|
||||||
|
api.getConfig().then(setConfig).catch(() => {});
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`Failed to save YAML: ${e}`, "error");
|
||||||
|
} finally {
|
||||||
|
setYamlSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (defaults) setConfig(structuredClone(defaults));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (!config) return;
|
||||||
|
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "hermes-config.json";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
try {
|
||||||
|
const imported = JSON.parse(reader.result as string);
|
||||||
|
setConfig(imported);
|
||||||
|
showToast("Config imported — review and save", "success");
|
||||||
|
} catch {
|
||||||
|
showToast("Invalid JSON file", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- Loading ---- */
|
||||||
|
if (!config || !schema) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-24">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Render field list (shared between search & normal) ---- */
|
||||||
|
const renderFields = (fields: [string, Record<string, unknown>][], showCategory = false) => {
|
||||||
|
let lastSection = "";
|
||||||
|
let lastCat = "";
|
||||||
|
return fields.map(([key, s]) => {
|
||||||
|
const parts = key.split(".");
|
||||||
|
const section = parts.length > 1 ? parts[0] : "";
|
||||||
|
const cat = String(s.category ?? "general");
|
||||||
|
const showCatBadge = showCategory && cat !== lastCat;
|
||||||
|
const showSection = !showCategory && section && section !== lastSection && section !== activeCategory;
|
||||||
|
lastSection = section;
|
||||||
|
lastCat = cat;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
{showCatBadge && (
|
||||||
|
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
|
||||||
|
<span className="text-base">{CATEGORY_ICONS[cat] || "📄"}</span>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{prettyCategoryName(cat)}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 border-t border-border" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showSection && (
|
||||||
|
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{section.replace(/_/g, " ")}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 border-t border-border" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="py-1">
|
||||||
|
<AutoField
|
||||||
|
schemaKey={key}
|
||||||
|
schema={s}
|
||||||
|
value={getNestedValue(config, key)}
|
||||||
|
onChange={(v) => setConfig(setNestedValue(config, key, v))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Toast toast={toast} />
|
||||||
|
|
||||||
|
{/* ═══════════════ Header Bar ═══════════════ */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5 rounded">
|
||||||
|
~/.hermes/config.yaml
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleExport} title="Export config as JSON" aria-label="Export config">
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()} title="Import config from JSON" aria-label="Import config">
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleReset} title="Reset to defaults" aria-label="Reset to defaults">
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="w-px h-5 bg-border mx-1" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={yamlMode ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setYamlMode(!yamlMode)}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{yamlMode ? (
|
||||||
|
<>
|
||||||
|
<FormInput className="h-3.5 w-3.5" />
|
||||||
|
Form
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Code className="h-3.5 w-3.5" />
|
||||||
|
YAML
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{yamlMode ? (
|
||||||
|
<Button size="sm" onClick={handleYamlSave} disabled={yamlSaving} className="gap-1.5">
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
{yamlSaving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={saving} className="gap-1.5">
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══════════════ YAML Mode ═══════════════ */}
|
||||||
|
{yamlMode ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="py-3 px-4">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
Raw YAML Configuration
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{yamlLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
className="flex min-h-[600px] w-full bg-transparent px-4 py-3 text-sm font-mono leading-relaxed placeholder:text-muted-foreground focus-visible:outline-none border-t border-border"
|
||||||
|
value={yamlText}
|
||||||
|
onChange={(e) => setYamlText(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
/* ═══════════════ Form Mode ═══════════════ */
|
||||||
|
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
|
||||||
|
{/* ---- Sidebar ---- */}
|
||||||
|
<div className="w-52 shrink-0">
|
||||||
|
<div className="sticky top-[72px] flex flex-col gap-1">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative mb-2">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="pl-8 h-8 text-xs"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category nav */}
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const isActive = !isSearching && activeCategory === cat;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
setActiveCategory(cat);
|
||||||
|
}}
|
||||||
|
className={`group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-primary font-medium"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm leading-none">{CATEGORY_ICONS[cat] || "📄"}</span>
|
||||||
|
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span>
|
||||||
|
<span className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}>
|
||||||
|
{categoryCounts[cat] || 0}
|
||||||
|
</span>
|
||||||
|
{isActive && (
|
||||||
|
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Content ---- */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{isSearching ? (
|
||||||
|
/* Search results */
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="py-3 px-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
Search Results
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{searchMatchedFields.length} field{searchMatchedFields.length !== 1 ? "s" : ""}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-2 px-4 pb-4">
|
||||||
|
{searchMatchedFields.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
No fields match "<span className="text-foreground">{searchQuery}</span>"
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
renderFields(searchMatchedFields, true)
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
/* Active category */
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="py-3 px-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<span className="text-base">{CATEGORY_ICONS[activeCategory] || "📄"}</span>
|
||||||
|
{prettyCategoryName(activeCategory)}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{activeFields.length} field{activeFields.length !== 1 ? "s" : ""}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-2 px-4 pb-4">
|
||||||
|
{renderFields(activeFields)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
279
web/src/pages/CronPage.tsx
Normal file
279
web/src/pages/CronPage.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { CronJob } from "@/lib/api";
|
||||||
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
import { Toast } from "@/components/Toast";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select } from "@/components/ui/select";
|
||||||
|
|
||||||
|
function formatTime(iso?: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_VARIANT: Record<string, "success" | "warning" | "destructive"> = {
|
||||||
|
enabled: "success",
|
||||||
|
paused: "warning",
|
||||||
|
error: "destructive",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CronPage() {
|
||||||
|
const [jobs, setJobs] = useState<CronJob[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { toast, showToast } = useToast();
|
||||||
|
|
||||||
|
// New job form state
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [schedule, setSchedule] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [deliver, setDeliver] = useState("local");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const loadJobs = () => {
|
||||||
|
api
|
||||||
|
.getCronJobs()
|
||||||
|
.then(setJobs)
|
||||||
|
.catch(() => showToast("Failed to load cron jobs", "error"))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadJobs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!prompt.trim() || !schedule.trim()) {
|
||||||
|
showToast("Prompt and schedule are required", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await api.createCronJob({
|
||||||
|
prompt: prompt.trim(),
|
||||||
|
schedule: schedule.trim(),
|
||||||
|
name: name.trim() || undefined,
|
||||||
|
deliver,
|
||||||
|
});
|
||||||
|
showToast("Cron job created", "success");
|
||||||
|
setPrompt("");
|
||||||
|
setSchedule("");
|
||||||
|
setName("");
|
||||||
|
setDeliver("local");
|
||||||
|
loadJobs();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`Failed to create job: ${e}`, "error");
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePauseResume = async (job: CronJob) => {
|
||||||
|
try {
|
||||||
|
if (job.status === "paused") {
|
||||||
|
await api.resumeCronJob(job.id);
|
||||||
|
showToast(`Resumed "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||||
|
} else {
|
||||||
|
await api.pauseCronJob(job.id);
|
||||||
|
showToast(`Paused "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||||
|
}
|
||||||
|
loadJobs();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`Action failed: ${e}`, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrigger = async (job: CronJob) => {
|
||||||
|
try {
|
||||||
|
await api.triggerCronJob(job.id);
|
||||||
|
showToast(`Triggered "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||||
|
loadJobs();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`Trigger failed: ${e}`, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (job: CronJob) => {
|
||||||
|
try {
|
||||||
|
await api.deleteCronJob(job.id);
|
||||||
|
showToast(`Deleted "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||||
|
loadJobs();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`Delete failed: ${e}`, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-24">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Toast toast={toast} />
|
||||||
|
|
||||||
|
{/* Create new job form */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
New Cron Job
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="cron-name">Name (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="cron-name"
|
||||||
|
placeholder="e.g. Daily summary"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="cron-prompt">Prompt</Label>
|
||||||
|
<textarea
|
||||||
|
id="cron-prompt"
|
||||||
|
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
placeholder="What should the agent do on each run?"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="cron-schedule">Schedule (cron expression)</Label>
|
||||||
|
<Input
|
||||||
|
id="cron-schedule"
|
||||||
|
placeholder="0 9 * * *"
|
||||||
|
value={schedule}
|
||||||
|
onChange={(e) => setSchedule(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="cron-deliver">Deliver to</Label>
|
||||||
|
<Select
|
||||||
|
id="cron-deliver"
|
||||||
|
value={deliver}
|
||||||
|
onChange={(e) => setDeliver(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="local">Local</option>
|
||||||
|
<option value="telegram">Telegram</option>
|
||||||
|
<option value="discord">Discord</option>
|
||||||
|
<option value="slack">Slack</option>
|
||||||
|
<option value="email">Email</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button onClick={handleCreate} disabled={creating} className="w-full">
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
{creating ? "Creating..." : "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Jobs list */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
Scheduled Jobs ({jobs.length})
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{jobs.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No cron jobs configured. Create one above.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<Card key={job.id}>
|
||||||
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-medium text-sm truncate">
|
||||||
|
{job.name || job.prompt.slice(0, 60) + (job.prompt.length > 60 ? "..." : "")}
|
||||||
|
</span>
|
||||||
|
<Badge variant={STATUS_VARIANT[job.status] ?? "secondary"}>
|
||||||
|
{job.status}
|
||||||
|
</Badge>
|
||||||
|
{job.deliver && job.deliver !== "local" && (
|
||||||
|
<Badge variant="outline">{job.deliver}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{job.name && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate mb-1">
|
||||||
|
{job.prompt.slice(0, 100)}{job.prompt.length > 100 ? "..." : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="font-mono">{job.schedule}</span>
|
||||||
|
<span>Last: {formatTime(job.last_run_at)}</span>
|
||||||
|
<span>Next: {formatTime(job.next_run_at)}</span>
|
||||||
|
</div>
|
||||||
|
{job.error && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{job.error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title={job.status === "paused" ? "Resume" : "Pause"}
|
||||||
|
aria-label={job.status === "paused" ? "Resume job" : "Pause job"}
|
||||||
|
onClick={() => handlePauseResume(job)}
|
||||||
|
>
|
||||||
|
{job.status === "paused" ? (
|
||||||
|
<Play className="h-4 w-4 text-success" />
|
||||||
|
) : (
|
||||||
|
<Pause className="h-4 w-4 text-warning" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Trigger now"
|
||||||
|
aria-label="Trigger job now"
|
||||||
|
onClick={() => handleTrigger(job)}
|
||||||
|
>
|
||||||
|
<Zap className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Delete"
|
||||||
|
aria-label="Delete job"
|
||||||
|
onClick={() => handleDelete(job)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
614
web/src/pages/EnvPage.tsx
Normal file
614
web/src/pages/EnvPage.tsx
Normal file
|
|
@ -0,0 +1,614 @@
|
||||||
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
ExternalLink,
|
||||||
|
KeyRound,
|
||||||
|
MessageSquare,
|
||||||
|
Pencil,
|
||||||
|
Save,
|
||||||
|
Settings,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
Zap,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { EnvVarInfo } from "@/lib/api";
|
||||||
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
import { Toast } from "@/components/Toast";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Provider grouping */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** Map env-var key prefixes to a human-friendly provider name + ordering. */
|
||||||
|
const PROVIDER_GROUPS: { prefix: string; name: string; priority: number }[] = [
|
||||||
|
// Nous Portal first
|
||||||
|
{ prefix: "NOUS_", name: "Nous Portal", priority: 0 },
|
||||||
|
// Then alphabetical by display name
|
||||||
|
{ prefix: "ANTHROPIC_", name: "Anthropic", priority: 1 },
|
||||||
|
{ prefix: "DASHSCOPE_", name: "DashScope (Qwen)", priority: 2 },
|
||||||
|
{ prefix: "HERMES_QWEN_", name: "DashScope (Qwen)", priority: 2 },
|
||||||
|
{ prefix: "DEEPSEEK_", name: "DeepSeek", priority: 3 },
|
||||||
|
{ prefix: "GOOGLE_", name: "Gemini", priority: 4 },
|
||||||
|
{ prefix: "GEMINI_", name: "Gemini", priority: 4 },
|
||||||
|
{ prefix: "GLM_", name: "GLM / Z.AI", priority: 5 },
|
||||||
|
{ prefix: "ZAI_", name: "GLM / Z.AI", priority: 5 },
|
||||||
|
{ prefix: "Z_AI_", name: "GLM / Z.AI", priority: 5 },
|
||||||
|
{ prefix: "HF_", name: "Hugging Face", priority: 6 },
|
||||||
|
{ prefix: "KIMI_", name: "Kimi / Moonshot", priority: 7 },
|
||||||
|
{ prefix: "MINIMAX_CN_", name: "MiniMax (China)", priority: 9 },
|
||||||
|
{ prefix: "MINIMAX_", name: "MiniMax", priority: 8 },
|
||||||
|
{ prefix: "OPENCODE_GO_", name: "OpenCode Go", priority: 10 },
|
||||||
|
{ prefix: "OPENCODE_ZEN_", name: "OpenCode Zen", priority: 11 },
|
||||||
|
{ prefix: "OPENROUTER_", name: "OpenRouter", priority: 12 },
|
||||||
|
{ prefix: "XIAOMI_", name: "Xiaomi MiMo", priority: 13 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getProviderGroup(key: string): string {
|
||||||
|
for (const g of PROVIDER_GROUPS) {
|
||||||
|
if (key.startsWith(g.prefix)) return g.name;
|
||||||
|
}
|
||||||
|
return "Other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProviderPriority(groupName: string): number {
|
||||||
|
const entry = PROVIDER_GROUPS.find((g) => g.name === groupName);
|
||||||
|
return entry?.priority ?? 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderGroup {
|
||||||
|
name: string;
|
||||||
|
priority: number;
|
||||||
|
entries: [string, EnvVarInfo][];
|
||||||
|
hasAnySet: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_META: Record<string, { label: string; icon: typeof KeyRound }> = {
|
||||||
|
provider: { label: "LLM Providers", icon: Zap },
|
||||||
|
tool: { label: "Tool API Keys", icon: KeyRound },
|
||||||
|
messaging: { label: "Messaging Platforms", icon: MessageSquare },
|
||||||
|
setting: { label: "Agent Settings", icon: Settings },
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* EnvVarRow — single key edit row */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function EnvVarRow({
|
||||||
|
varKey,
|
||||||
|
info,
|
||||||
|
edits,
|
||||||
|
setEdits,
|
||||||
|
revealed,
|
||||||
|
saving,
|
||||||
|
onSave,
|
||||||
|
onClear,
|
||||||
|
onReveal,
|
||||||
|
onCancelEdit,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
varKey: string;
|
||||||
|
info: EnvVarInfo;
|
||||||
|
edits: Record<string, string>;
|
||||||
|
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||||
|
revealed: Record<string, string>;
|
||||||
|
saving: string | null;
|
||||||
|
onSave: (key: string) => void;
|
||||||
|
onClear: (key: string) => void;
|
||||||
|
onReveal: (key: string) => void;
|
||||||
|
onCancelEdit: (key: string) => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const isEditing = edits[varKey] !== undefined;
|
||||||
|
const isRevealed = !!revealed[varKey];
|
||||||
|
const displayValue = isRevealed ? revealed[varKey] : (info.redacted_value ?? "---");
|
||||||
|
|
||||||
|
// Compact inline row for unset, non-editing keys (used inside provider groups)
|
||||||
|
if (compact && !info.is_set && !isEditing) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 py-1.5 opacity-50 hover:opacity-100 transition-opacity">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="font-mono-ui text-[0.7rem] text-muted-foreground">{varKey}</span>
|
||||||
|
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">{info.description}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{info.url && (
|
||||||
|
<a href={info.url} target="_blank" rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
|
||||||
|
Get key <ExternalLink className="h-2.5 w-2.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" className="h-6 text-[0.6rem] px-2"
|
||||||
|
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
|
||||||
|
<Pencil className="h-2.5 w-2.5" />
|
||||||
|
Set
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-compact unset row
|
||||||
|
if (!info.is_set && !isEditing) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 border border-border/50 px-4 py-2.5 opacity-60 hover:opacity-100 transition-opacity">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Label className="font-mono-ui text-[0.7rem] text-muted-foreground">{varKey}</Label>
|
||||||
|
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">{info.description}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{info.url && (
|
||||||
|
<a href={info.url} target="_blank" rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
|
||||||
|
Get key <ExternalLink className="h-2.5 w-2.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-[0.6rem]"
|
||||||
|
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
Set
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full expanded row for set keys or keys being edited
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2 border border-border p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="font-mono-ui text-[0.7rem]">{varKey}</Label>
|
||||||
|
<Badge variant={info.is_set ? "success" : "outline"}>
|
||||||
|
{info.is_set ? "Set" : "Not set"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{info.url && (
|
||||||
|
<a href={info.url} target="_blank" rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
|
||||||
|
Get key <ExternalLink className="h-2.5 w-2.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">{info.description}</p>
|
||||||
|
|
||||||
|
{info.tools.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{info.tools.map((tool) => (
|
||||||
|
<Badge key={tool} variant="secondary" className="text-[0.6rem] py-0 px-1.5">{tool}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isEditing && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`flex-1 border border-border px-3 py-2 font-mono-ui text-xs ${
|
||||||
|
isRevealed ? "bg-background text-foreground select-all" : "bg-muted/30 text-muted-foreground"
|
||||||
|
}`}>
|
||||||
|
{info.is_set ? displayValue : "---"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{info.is_set && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onReveal(varKey)}
|
||||||
|
title={isRevealed ? "Hide value" : "Show real value"}
|
||||||
|
aria-label={isRevealed ? `Hide ${varKey}` : `Reveal ${varKey}`}>
|
||||||
|
{isRevealed
|
||||||
|
? <EyeOff className="h-4 w-4" />
|
||||||
|
: <Eye className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button size="sm" variant="outline"
|
||||||
|
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
{info.is_set ? "Replace" : "Set"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{info.is_set && (
|
||||||
|
<Button size="sm" variant="ghost"
|
||||||
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => onClear(varKey)} disabled={saving === varKey}>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
{saving === varKey ? "..." : "Clear"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditing && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input autoFocus type="text" value={edits[varKey]}
|
||||||
|
onChange={(e) => setEdits((prev) => ({ ...prev, [varKey]: e.target.value }))}
|
||||||
|
placeholder={info.is_set ? `Replace current value (${info.redacted_value ?? "---"})` : "Enter value..."}
|
||||||
|
className="flex-1 font-mono-ui text-xs" />
|
||||||
|
<Button size="sm" onClick={() => onSave(varKey)}
|
||||||
|
disabled={saving === varKey || !edits[varKey]}>
|
||||||
|
<Save className="h-3 w-3" />
|
||||||
|
{saving === varKey ? "..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onCancelEdit(varKey)}>
|
||||||
|
<X className="h-3 w-3" /> Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* ProviderGroupCard — groups API key + base URL per provider */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function ProviderGroupCard({
|
||||||
|
group,
|
||||||
|
edits,
|
||||||
|
setEdits,
|
||||||
|
revealed,
|
||||||
|
saving,
|
||||||
|
onSave,
|
||||||
|
onClear,
|
||||||
|
onReveal,
|
||||||
|
onCancelEdit,
|
||||||
|
}: {
|
||||||
|
group: ProviderGroup;
|
||||||
|
edits: Record<string, string>;
|
||||||
|
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||||
|
revealed: Record<string, string>;
|
||||||
|
saving: string | null;
|
||||||
|
onSave: (key: string) => void;
|
||||||
|
onClear: (key: string) => void;
|
||||||
|
onReveal: (key: string) => void;
|
||||||
|
onCancelEdit: (key: string) => void;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
// Separate API keys from base URLs and other settings
|
||||||
|
const apiKeys = group.entries.filter(([k]) => k.endsWith("_API_KEY") || k.endsWith("_TOKEN"));
|
||||||
|
const baseUrls = group.entries.filter(([k]) => k.endsWith("_BASE_URL"));
|
||||||
|
const other = group.entries.filter(([k]) => !k.endsWith("_API_KEY") && !k.endsWith("_TOKEN") && !k.endsWith("_BASE_URL"));
|
||||||
|
const hasAnyConfigured = group.entries.some(([, info]) => info.is_set);
|
||||||
|
const configuredCount = group.entries.filter(([, info]) => info.is_set).length;
|
||||||
|
|
||||||
|
// Get a representative URL for "Get key" link
|
||||||
|
const keyUrl = apiKeys.find(([, info]) => info.url)?.[1]?.url ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-border">
|
||||||
|
{/* Header — always visible */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex w-full items-center justify-between gap-3 px-4 py-3 cursor-pointer hover:bg-primary/5 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
{expanded ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
|
||||||
|
<span className="font-semibold text-sm tracking-wide">{group.name}</span>
|
||||||
|
{hasAnyConfigured && (
|
||||||
|
<Badge variant="success" className="text-[0.6rem]">
|
||||||
|
{configuredCount} set
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{keyUrl && (
|
||||||
|
<a href={keyUrl} target="_blank" rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
Get key <ExternalLink className="h-2.5 w-2.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<span className="text-[0.65rem] text-muted-foreground/60">
|
||||||
|
{group.entries.length} key{group.entries.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded content */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="border-t border-border px-4 py-3 grid gap-2">
|
||||||
|
{/* API keys first (most important) */}
|
||||||
|
{apiKeys.map(([key, info]) => (
|
||||||
|
<EnvVarRow
|
||||||
|
key={key} varKey={key} info={info} compact
|
||||||
|
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||||
|
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Base URLs (secondary) */}
|
||||||
|
{baseUrls.map(([key, info]) => (
|
||||||
|
<EnvVarRow
|
||||||
|
key={key} varKey={key} info={info} compact
|
||||||
|
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||||
|
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Anything else */}
|
||||||
|
{other.map(([key, info]) => (
|
||||||
|
<EnvVarRow
|
||||||
|
key={key} varKey={key} info={info} compact
|
||||||
|
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||||
|
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Main page */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export default function EnvPage() {
|
||||||
|
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null);
|
||||||
|
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||||
|
const [revealed, setRevealed] = useState<Record<string, string>>({});
|
||||||
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(true); // Show all providers by default
|
||||||
|
const { toast, showToast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getEnvVars().then(setVars).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async (key: string) => {
|
||||||
|
const value = edits[key];
|
||||||
|
if (!value) return;
|
||||||
|
setSaving(key);
|
||||||
|
try {
|
||||||
|
await api.setEnvVar(key, value);
|
||||||
|
setVars((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
[key]: { ...prev[key], is_set: true, redacted_value: value.slice(0, 4) + "..." + value.slice(-4) },
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
|
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||||
|
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||||
|
showToast(`${key} saved`, "success");
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`Failed to save ${key}: ${e}`, "error");
|
||||||
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = async (key: string) => {
|
||||||
|
setSaving(key);
|
||||||
|
try {
|
||||||
|
await api.deleteEnvVar(key);
|
||||||
|
setVars((prev) =>
|
||||||
|
prev
|
||||||
|
? { ...prev, [key]: { ...prev[key], is_set: false, redacted_value: null } }
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
|
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||||
|
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||||
|
showToast(`${key} removed`, "success");
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`Failed to remove ${key}: ${e}`, "error");
|
||||||
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReveal = async (key: string) => {
|
||||||
|
if (revealed[key]) {
|
||||||
|
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await api.revealEnvVar(key);
|
||||||
|
setRevealed((prev) => ({ ...prev, [key]: resp.value }));
|
||||||
|
} catch {
|
||||||
|
showToast(`Failed to reveal ${key}`, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = (key: string) => {
|
||||||
|
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- Build provider groups ---- */
|
||||||
|
const { providerGroups, nonProviderGrouped } = useMemo(() => {
|
||||||
|
if (!vars) return { providerGroups: [], nonProviderGrouped: [] };
|
||||||
|
|
||||||
|
const providerEntries = Object.entries(vars).filter(
|
||||||
|
([, info]) => info.category === "provider" && (showAdvanced || !info.advanced),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group by provider
|
||||||
|
const groupMap = new Map<string, [string, EnvVarInfo][]>();
|
||||||
|
for (const entry of providerEntries) {
|
||||||
|
const groupName = getProviderGroup(entry[0]);
|
||||||
|
if (!groupMap.has(groupName)) groupMap.set(groupName, []);
|
||||||
|
groupMap.get(groupName)!.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: ProviderGroup[] = Array.from(groupMap.entries())
|
||||||
|
.map(([name, entries]) => ({
|
||||||
|
name,
|
||||||
|
priority: getProviderPriority(name),
|
||||||
|
entries,
|
||||||
|
hasAnySet: entries.some(([, info]) => info.is_set),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.priority - b.priority);
|
||||||
|
|
||||||
|
// Non-provider categories
|
||||||
|
const otherCategories = ["tool", "messaging", "setting"];
|
||||||
|
const nonProvider = otherCategories.map((cat) => {
|
||||||
|
const entries = Object.entries(vars).filter(
|
||||||
|
([, info]) => info.category === cat && (showAdvanced || !info.advanced),
|
||||||
|
);
|
||||||
|
const setEntries = entries.filter(([, info]) => info.is_set);
|
||||||
|
const unsetEntries = entries.filter(([, info]) => !info.is_set);
|
||||||
|
return {
|
||||||
|
...CATEGORY_META[cat],
|
||||||
|
category: cat,
|
||||||
|
setEntries,
|
||||||
|
unsetEntries,
|
||||||
|
totalEntries: entries.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { providerGroups: groups, nonProviderGrouped: nonProvider };
|
||||||
|
}, [vars, showAdvanced]);
|
||||||
|
|
||||||
|
if (!vars) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-24">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalProviders = providerGroups.length;
|
||||||
|
const configuredProviders = providerGroups.filter((g) => g.hasAnySet).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Toast toast={toast} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage API keys and secrets stored in <code>~/.hermes/.env</code>
|
||||||
|
</p>
|
||||||
|
<p className="text-[0.7rem] text-muted-foreground/70">
|
||||||
|
Changes are saved to disk immediately. Active sessions pick up new keys automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setShowAdvanced(!showAdvanced)}>
|
||||||
|
{showAdvanced ? "Hide Advanced" : "Show Advanced"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══════════════ LLM Providers (grouped) ═══════════════ */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="sticky top-14 z-10 bg-card border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">LLM Providers</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
{configuredProviders} of {totalProviders} providers configured
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="grid gap-0 p-0">
|
||||||
|
{providerGroups.map((group) => (
|
||||||
|
<ProviderGroupCard
|
||||||
|
key={group.name}
|
||||||
|
group={group}
|
||||||
|
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||||
|
onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ═══════════════ Other categories (flat) ═══════════════ */}
|
||||||
|
{nonProviderGrouped.map(({ label, icon: Icon, setEntries, unsetEntries, totalEntries, category }) => {
|
||||||
|
if (totalEntries === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={category}>
|
||||||
|
<CardHeader className="sticky top-14 z-10 bg-card border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">{label}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
{setEntries.length} of {totalEntries} configured
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="grid gap-3 pt-4">
|
||||||
|
{setEntries.map(([key, info]) => (
|
||||||
|
<EnvVarRow
|
||||||
|
key={key} varKey={key} info={info}
|
||||||
|
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||||
|
onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{unsetEntries.length > 0 && (
|
||||||
|
<CollapsibleUnset
|
||||||
|
category={category}
|
||||||
|
unsetEntries={unsetEntries}
|
||||||
|
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||||
|
onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* CollapsibleUnset — for non-provider categories */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function CollapsibleUnset({
|
||||||
|
category: _category,
|
||||||
|
unsetEntries,
|
||||||
|
edits,
|
||||||
|
setEdits,
|
||||||
|
revealed,
|
||||||
|
saving,
|
||||||
|
onSave,
|
||||||
|
onClear,
|
||||||
|
onReveal,
|
||||||
|
onCancelEdit,
|
||||||
|
}: {
|
||||||
|
category: string;
|
||||||
|
unsetEntries: [string, EnvVarInfo][];
|
||||||
|
edits: Record<string, string>;
|
||||||
|
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||||
|
revealed: Record<string, string>;
|
||||||
|
saving: string | null;
|
||||||
|
onSave: (key: string) => void;
|
||||||
|
onClear: (key: string) => void;
|
||||||
|
onReveal: (key: string) => void;
|
||||||
|
onCancelEdit: (key: string) => void;
|
||||||
|
}) {
|
||||||
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer pt-1"
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
>
|
||||||
|
{collapsed
|
||||||
|
? <ChevronRight className="h-3 w-3" />
|
||||||
|
: <ChevronDown className="h-3 w-3" />}
|
||||||
|
<span>{unsetEntries.length} not configured</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!collapsed && unsetEntries.map(([key, info]) => (
|
||||||
|
<EnvVarRow
|
||||||
|
key={key} varKey={key} info={info}
|
||||||
|
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
|
||||||
|
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
web/src/pages/LogsPage.tsx
Normal file
175
web/src/pages/LogsPage.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import { FileText, RefreshCw } from "lucide-react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
const FILES = ["agent", "errors", "gateway"] as const;
|
||||||
|
const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const;
|
||||||
|
const COMPONENTS = ["all", "gateway", "agent", "tools", "cli", "cron"] as const;
|
||||||
|
const LINE_COUNTS = [50, 100, 200, 500] as const;
|
||||||
|
|
||||||
|
function classifyLine(line: string): "error" | "warning" | "info" | "debug" {
|
||||||
|
const upper = line.toUpperCase();
|
||||||
|
if (upper.includes("ERROR") || upper.includes("CRITICAL") || upper.includes("FATAL")) return "error";
|
||||||
|
if (upper.includes("WARNING") || upper.includes("WARN")) return "warning";
|
||||||
|
if (upper.includes("DEBUG")) return "debug";
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINE_COLORS: Record<string, string> = {
|
||||||
|
error: "text-destructive",
|
||||||
|
warning: "text-warning",
|
||||||
|
info: "text-foreground",
|
||||||
|
debug: "text-muted-foreground/60",
|
||||||
|
};
|
||||||
|
|
||||||
|
function FilterBar<T extends string>({
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
options: readonly T[];
|
||||||
|
value: T;
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-muted-foreground font-medium w-20 shrink-0">{label}</span>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<Button
|
||||||
|
key={opt}
|
||||||
|
variant={value === opt ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="text-xs h-7 px-2.5"
|
||||||
|
onClick={() => onChange(opt)}
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogsPage() {
|
||||||
|
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
|
||||||
|
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
|
||||||
|
const [component, setComponent] = useState<(typeof COMPONENTS)[number]>("all");
|
||||||
|
const [lineCount, setLineCount] = useState<(typeof LINE_COUNTS)[number]>(100);
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
const [lines, setLines] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
api
|
||||||
|
.getLogs({ file, lines: lineCount, level, component })
|
||||||
|
.then((resp) => {
|
||||||
|
setLines(resp.lines);
|
||||||
|
// Auto-scroll to bottom
|
||||||
|
setTimeout(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
})
|
||||||
|
.catch((err) => setError(String(err)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [file, lineCount, level, component]);
|
||||||
|
|
||||||
|
// Initial load + refetch on filter change
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs();
|
||||||
|
}, [fetchLogs]);
|
||||||
|
|
||||||
|
// Auto-refresh polling
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoRefresh) return;
|
||||||
|
const interval = setInterval(fetchLogs, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [autoRefresh, fetchLogs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">Logs</CardTitle>
|
||||||
|
{loading && (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={autoRefresh}
|
||||||
|
onCheckedChange={setAutoRefresh}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs">Auto-refresh</Label>
|
||||||
|
{autoRefresh && (
|
||||||
|
<Badge variant="success" className="text-[10px]">
|
||||||
|
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||||
|
Live
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchLogs} className="text-xs h-7">
|
||||||
|
<RefreshCw className="h-3 w-3 mr-1" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-3 mb-4">
|
||||||
|
<FilterBar label="File" options={FILES} value={file} onChange={setFile} />
|
||||||
|
<FilterBar label="Level" options={LEVELS} value={level} onChange={setLevel} />
|
||||||
|
<FilterBar label="Component" options={COMPONENTS} value={component} onChange={setComponent} />
|
||||||
|
<FilterBar
|
||||||
|
label="Lines"
|
||||||
|
options={LINE_COUNTS.map(String) as unknown as readonly string[]}
|
||||||
|
value={String(lineCount)}
|
||||||
|
onChange={(v) => setLineCount(Number(v) as (typeof LINE_COUNTS)[number])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3 mb-4">
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="border border-border bg-background p-4 font-mono-ui text-xs leading-5 overflow-auto max-h-[600px] min-h-[200px]"
|
||||||
|
>
|
||||||
|
{lines.length === 0 && !loading && (
|
||||||
|
<p className="text-muted-foreground text-center py-8">No log lines found</p>
|
||||||
|
)}
|
||||||
|
{lines.map((line, i) => {
|
||||||
|
const cls = classifyLine(line);
|
||||||
|
return (
|
||||||
|
<div key={i} className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1 rounded`}>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
429
web/src/pages/SessionsPage.tsx
Normal file
429
web/src/pages/SessionsPage.tsx
Normal file
|
|
@ -0,0 +1,429 @@
|
||||||
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
MessageSquare,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Clock,
|
||||||
|
Terminal,
|
||||||
|
Globe,
|
||||||
|
MessageCircle,
|
||||||
|
Hash,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { SessionInfo, SessionMessage, SessionSearchResult } from "@/lib/api";
|
||||||
|
import { timeAgo } from "@/lib/utils";
|
||||||
|
import { Markdown } from "@/components/Markdown";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
const ROLE_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||||
|
user: { bg: "bg-primary/10", text: "text-primary", label: "User" },
|
||||||
|
assistant: { bg: "bg-success/10", text: "text-success", label: "Assistant" },
|
||||||
|
system: { bg: "bg-muted", text: "text-muted-foreground", label: "System" },
|
||||||
|
tool: { bg: "bg-warning/10", text: "text-warning", label: "Tool" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> = {
|
||||||
|
cli: { icon: Terminal, color: "text-primary" },
|
||||||
|
telegram: { icon: MessageCircle, color: "text-[oklch(0.65_0.15_250)]" },
|
||||||
|
discord: { icon: Hash, color: "text-[oklch(0.65_0.15_280)]" },
|
||||||
|
slack: { icon: MessageSquare, color: "text-[oklch(0.7_0.15_155)]" },
|
||||||
|
whatsapp: { icon: Globe, color: "text-success" },
|
||||||
|
cron: { icon: Clock, color: "text-warning" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Render an FTS5 snippet with highlighted matches.
|
||||||
|
* The backend wraps matches in >>> and <<< delimiters. */
|
||||||
|
function SnippetHighlight({ snippet }: { snippet: string }) {
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
const regex = />>>(.*?)<<</g;
|
||||||
|
let last = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
let i = 0;
|
||||||
|
while ((match = regex.exec(snippet)) !== null) {
|
||||||
|
if (match.index > last) {
|
||||||
|
parts.push(snippet.slice(last, match.index));
|
||||||
|
}
|
||||||
|
parts.push(
|
||||||
|
<mark key={i++} className="bg-warning/30 text-warning rounded-sm px-0.5">
|
||||||
|
{match[1]}
|
||||||
|
</mark>
|
||||||
|
);
|
||||||
|
last = regex.lastIndex;
|
||||||
|
}
|
||||||
|
if (last < snippet.length) {
|
||||||
|
parts.push(snippet.slice(last));
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-muted-foreground/80 truncate max-w-lg mt-0.5">
|
||||||
|
{parts}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name: string; arguments: string } } }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
let args = toolCall.function.arguments;
|
||||||
|
try {
|
||||||
|
args = JSON.stringify(JSON.parse(args), null, 2);
|
||||||
|
} catch {
|
||||||
|
// keep as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 rounded-md border border-warning/20 bg-warning/5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-warning cursor-pointer hover:bg-warning/10 transition-colors"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
aria-label={`${open ? "Collapse" : "Expand"} tool call ${toolCall.function.name}`}
|
||||||
|
>
|
||||||
|
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||||
|
<span className="font-mono-ui font-medium">{toolCall.function.name}</span>
|
||||||
|
<span className="text-warning/50 ml-auto">{toolCall.id}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<pre className="border-t border-warning/20 px-3 py-2 text-xs text-warning/80 overflow-x-auto whitespace-pre-wrap font-mono">
|
||||||
|
{args}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({ msg, highlight }: { msg: SessionMessage; highlight?: string }) {
|
||||||
|
const style = ROLE_STYLES[msg.role] ?? ROLE_STYLES.system;
|
||||||
|
const label = msg.tool_name ? `Tool: ${msg.tool_name}` : style.label;
|
||||||
|
|
||||||
|
// Check if any search term appears as a prefix of any word in content
|
||||||
|
const isHit = (() => {
|
||||||
|
if (!highlight || !msg.content) return false;
|
||||||
|
const content = msg.content.toLowerCase();
|
||||||
|
const terms = highlight.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
return terms.some((term) => content.includes(term));
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Split search query into terms for inline highlighting
|
||||||
|
const highlightTerms = isHit && highlight
|
||||||
|
? highlight.split(/\s+/).filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${style.bg} p-3 ${isHit ? "ring-1 ring-warning/40" : ""}`} data-search-hit={isHit || undefined}>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className={`text-xs font-semibold ${style.text}`}>{label}</span>
|
||||||
|
{isHit && (
|
||||||
|
<Badge variant="warning" className="text-[9px] py-0 px-1.5">match</Badge>
|
||||||
|
)}
|
||||||
|
{msg.timestamp && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">{timeAgo(msg.timestamp)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{msg.content && (
|
||||||
|
msg.role === "system"
|
||||||
|
? <div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">{msg.content}</div>
|
||||||
|
: <Markdown content={msg.content} highlightTerms={highlightTerms} />
|
||||||
|
)}
|
||||||
|
{msg.tool_calls && msg.tool_calls.length > 0 && (
|
||||||
|
<div className="mt-1">
|
||||||
|
{msg.tool_calls.map((tc) => (
|
||||||
|
<ToolCallBlock key={tc.id} toolCall={tc} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Message list with auto-scroll to first search hit. */
|
||||||
|
function MessageList({ messages, highlight }: { messages: SessionMessage[]; highlight?: string }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlight || !containerRef.current) return;
|
||||||
|
// Scroll to first hit after render
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const hit = containerRef.current?.querySelector("[data-search-hit]");
|
||||||
|
if (hit) {
|
||||||
|
hit.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [messages, highlight]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="flex flex-col gap-3 max-h-[600px] overflow-y-auto pr-2">
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<MessageBubble key={i} msg={msg} highlight={highlight} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionRow({
|
||||||
|
session,
|
||||||
|
snippet,
|
||||||
|
searchQuery,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
session: SessionInfo;
|
||||||
|
snippet?: string;
|
||||||
|
searchQuery?: string;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isExpanded && messages === null && !loading) {
|
||||||
|
setLoading(true);
|
||||||
|
api
|
||||||
|
.getSessionMessages(session.id)
|
||||||
|
.then((resp) => setMessages(resp.messages))
|
||||||
|
.catch((err) => setError(String(err)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
}, [isExpanded, session.id, messages, loading]);
|
||||||
|
|
||||||
|
const sourceInfo = (session.source ? SOURCE_CONFIG[session.source] : null) ?? { icon: Globe, color: "text-muted-foreground" };
|
||||||
|
const SourceIcon = sourceInfo.icon;
|
||||||
|
const hasTitle = session.title && session.title !== "Untitled";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`border overflow-hidden transition-colors ${
|
||||||
|
session.is_active
|
||||||
|
? "border-success/30 bg-success/[0.03]"
|
||||||
|
: "border-border"
|
||||||
|
}`}>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between p-3 cursor-pointer hover:bg-secondary/30 transition-colors"
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
<div className={`shrink-0 ${sourceInfo.color}`}>
|
||||||
|
<SourceIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-sm truncate pr-2 ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}>
|
||||||
|
{hasTitle ? session.title : (session.preview ? session.preview.slice(0, 60) : "Untitled session")}
|
||||||
|
</span>
|
||||||
|
{session.is_active && (
|
||||||
|
<Badge variant="success" className="text-[10px] shrink-0">
|
||||||
|
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||||
|
Live
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span className="truncate max-w-[180px]">{(session.model ?? "unknown").split("/").pop()}</span>
|
||||||
|
<span className="text-border">·</span>
|
||||||
|
<span>{session.message_count} msgs</span>
|
||||||
|
{session.tool_call_count > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-border">·</span>
|
||||||
|
<span>{session.tool_call_count} tools</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-border">·</span>
|
||||||
|
<span>{timeAgo(session.last_active)}</span>
|
||||||
|
</div>
|
||||||
|
{snippet && (
|
||||||
|
<SnippetHighlight snippet={snippet} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{session.source ?? "local"}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||||
|
aria-label="Delete session"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-border bg-background/50 p-4">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive py-4 text-center">{error}</p>
|
||||||
|
)}
|
||||||
|
{messages && messages.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center">No messages</p>
|
||||||
|
)}
|
||||||
|
{messages && messages.length > 0 && (
|
||||||
|
<MessageList messages={messages} highlight={searchQuery} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SessionsPage() {
|
||||||
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [searchResults, setSearchResults] = useState<SessionSearchResult[] | null>(null);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||||
|
|
||||||
|
const loadSessions = useCallback(() => {
|
||||||
|
api
|
||||||
|
.getSessions()
|
||||||
|
.then(setSessions)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSessions();
|
||||||
|
}, [loadSessions]);
|
||||||
|
|
||||||
|
// Debounced FTS search
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
|
||||||
|
if (!search.trim()) {
|
||||||
|
setSearchResults(null);
|
||||||
|
setSearching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearching(true);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
api
|
||||||
|
.searchSessions(search.trim())
|
||||||
|
.then((resp) => setSearchResults(resp.results))
|
||||||
|
.catch(() => setSearchResults(null))
|
||||||
|
.finally(() => setSearching(false));
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.deleteSession(id);
|
||||||
|
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||||
|
if (expandedId === id) setExpandedId(null);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build snippet map from search results (session_id → snippet)
|
||||||
|
const snippetMap = new Map<string, string>();
|
||||||
|
if (searchResults) {
|
||||||
|
for (const r of searchResults) {
|
||||||
|
snippetMap.set(r.session_id, r.snippet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When searching, filter sessions to those with FTS matches;
|
||||||
|
// when not searching, show all sessions
|
||||||
|
const filtered = searchResults
|
||||||
|
? sessions.filter((s) => snippetMap.has(s.id))
|
||||||
|
: sessions;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-24">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Header outside card for lighter feel */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-base font-semibold">Sessions</h1>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{sessions.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-64">
|
||||||
|
{searching ? (
|
||||||
|
<div className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin rounded-full border-[1.5px] border-primary border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
placeholder="Search message content..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-8 pr-7 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Clock className="h-8 w-8 mb-3 opacity-40" />
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{search ? "No sessions match your search" : "No sessions yet"}
|
||||||
|
</p>
|
||||||
|
{!search && (
|
||||||
|
<p className="text-xs mt-1 text-muted-foreground/60">Start a conversation to see it here</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{filtered.map((s) => (
|
||||||
|
<SessionRow
|
||||||
|
key={s.id}
|
||||||
|
session={s}
|
||||||
|
snippet={snippetMap.get(s.id)}
|
||||||
|
searchQuery={search || undefined}
|
||||||
|
isExpanded={expandedId === s.id}
|
||||||
|
onToggle={() =>
|
||||||
|
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
||||||
|
}
|
||||||
|
onDelete={() => handleDelete(s.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
439
web/src/pages/SkillsPage.tsx
Normal file
439
web/src/pages/SkillsPage.tsx
Normal file
|
|
@ -0,0 +1,439 @@
|
||||||
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Package,
|
||||||
|
Search,
|
||||||
|
Wrench,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Filter,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
|
||||||
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
import { Toast } from "@/components/Toast";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types & helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
interface CategoryGroup {
|
||||||
|
name: string; // display name
|
||||||
|
key: string; // raw key (or "__none__")
|
||||||
|
skills: SkillInfo[];
|
||||||
|
enabledCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
mlops: "MLOps",
|
||||||
|
"mlops/cloud": "MLOps / Cloud",
|
||||||
|
"mlops/evaluation": "MLOps / Evaluation",
|
||||||
|
"mlops/inference": "MLOps / Inference",
|
||||||
|
"mlops/models": "MLOps / Models",
|
||||||
|
"mlops/training": "MLOps / Training",
|
||||||
|
"mlops/vector-databases": "MLOps / Vector DBs",
|
||||||
|
mcp: "MCP",
|
||||||
|
"red-teaming": "Red Teaming",
|
||||||
|
ocr: "OCR",
|
||||||
|
p5js: "p5.js",
|
||||||
|
ai: "AI",
|
||||||
|
ux: "UX",
|
||||||
|
ui: "UI",
|
||||||
|
};
|
||||||
|
|
||||||
|
function prettyCategory(raw: string | null | undefined): string {
|
||||||
|
if (!raw) return "General";
|
||||||
|
if (CATEGORY_LABELS[raw]) return CATEGORY_LABELS[raw];
|
||||||
|
return raw
|
||||||
|
.split(/[-_/]/)
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export default function SkillsPage() {
|
||||||
|
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
||||||
|
const [toolsets, setToolsets] = useState<ToolsetInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||||
|
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
|
||||||
|
// Start collapsed by default
|
||||||
|
const [collapsedCategories, setCollapsedCategories] = useState<Set<string> | "all">("all");
|
||||||
|
const { toast, showToast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([api.getSkills(), api.getToolsets()])
|
||||||
|
.then(([s, t]) => {
|
||||||
|
setSkills(s);
|
||||||
|
setToolsets(t);
|
||||||
|
})
|
||||||
|
.catch(() => showToast("Failed to load skills/toolsets", "error"))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ---- Toggle skill ---- */
|
||||||
|
const handleToggleSkill = async (skill: SkillInfo) => {
|
||||||
|
setTogglingSkills((prev) => new Set(prev).add(skill.name));
|
||||||
|
try {
|
||||||
|
await api.toggleSkill(skill.name, !skill.enabled);
|
||||||
|
setSkills((prev) =>
|
||||||
|
prev.map((s) =>
|
||||||
|
s.name === skill.name ? { ...s, enabled: !s.enabled } : s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
showToast(
|
||||||
|
`${skill.name} ${skill.enabled ? "disabled" : "enabled"}`,
|
||||||
|
"success"
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
showToast(`Failed to toggle ${skill.name}`, "error");
|
||||||
|
} finally {
|
||||||
|
setTogglingSkills((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(skill.name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- Derived data ---- */
|
||||||
|
const lowerSearch = search.toLowerCase();
|
||||||
|
|
||||||
|
const filteredSkills = useMemo(() => {
|
||||||
|
return skills.filter((s) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!search ||
|
||||||
|
s.name.toLowerCase().includes(lowerSearch) ||
|
||||||
|
s.description.toLowerCase().includes(lowerSearch) ||
|
||||||
|
(s.category ?? "").toLowerCase().includes(lowerSearch);
|
||||||
|
const matchesCategory =
|
||||||
|
!activeCategory ||
|
||||||
|
(activeCategory === "__none__" ? !s.category : s.category === activeCategory);
|
||||||
|
return matchesSearch && matchesCategory;
|
||||||
|
});
|
||||||
|
}, [skills, search, lowerSearch, activeCategory]);
|
||||||
|
|
||||||
|
const categoryGroups: CategoryGroup[] = useMemo(() => {
|
||||||
|
const map = new Map<string, SkillInfo[]>();
|
||||||
|
for (const s of filteredSkills) {
|
||||||
|
const key = s.category || "__none__";
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key)!.push(s);
|
||||||
|
}
|
||||||
|
// Sort: General first, then alphabetical
|
||||||
|
const entries = [...map.entries()].sort((a, b) => {
|
||||||
|
if (a[0] === "__none__") return -1;
|
||||||
|
if (b[0] === "__none__") return 1;
|
||||||
|
return a[0].localeCompare(b[0]);
|
||||||
|
});
|
||||||
|
return entries.map(([key, list]) => ({
|
||||||
|
key,
|
||||||
|
name: prettyCategory(key === "__none__" ? null : key),
|
||||||
|
skills: list.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
enabledCount: list.filter((s) => s.enabled).length,
|
||||||
|
}));
|
||||||
|
}, [filteredSkills]);
|
||||||
|
|
||||||
|
const allCategories = useMemo(() => {
|
||||||
|
const cats = new Map<string, number>();
|
||||||
|
for (const s of skills) {
|
||||||
|
const key = s.category || "__none__";
|
||||||
|
cats.set(key, (cats.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
return [...cats.entries()]
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a[0] === "__none__") return -1;
|
||||||
|
if (b[0] === "__none__") return 1;
|
||||||
|
return a[0].localeCompare(b[0]);
|
||||||
|
})
|
||||||
|
.map(([key, count]) => ({ key, name: prettyCategory(key === "__none__" ? null : key), count }));
|
||||||
|
}, [skills]);
|
||||||
|
|
||||||
|
const enabledCount = skills.filter((s) => s.enabled).length;
|
||||||
|
|
||||||
|
const filteredToolsets = useMemo(() => {
|
||||||
|
return toolsets.filter(
|
||||||
|
(t) =>
|
||||||
|
!search ||
|
||||||
|
t.name.toLowerCase().includes(lowerSearch) ||
|
||||||
|
t.label.toLowerCase().includes(lowerSearch) ||
|
||||||
|
t.description.toLowerCase().includes(lowerSearch)
|
||||||
|
);
|
||||||
|
}, [toolsets, search, lowerSearch]);
|
||||||
|
|
||||||
|
const isCollapsed = (key: string): boolean => {
|
||||||
|
if (collapsedCategories === "all") return true;
|
||||||
|
return collapsedCategories.has(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCollapse = (key: string) => {
|
||||||
|
setCollapsedCategories((prev) => {
|
||||||
|
if (prev === "all") {
|
||||||
|
// Switching from "all collapsed" → expand just this one
|
||||||
|
const allKeys = new Set(categoryGroups.map((g) => g.key));
|
||||||
|
allKeys.delete(key);
|
||||||
|
return allKeys;
|
||||||
|
}
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- Loading ---- */
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-24">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Toast toast={toast} />
|
||||||
|
|
||||||
|
{/* ═══════════════ Header + Search ═══════════════ */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Package className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-base font-semibold">Skills</h1>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{enabledCount}/{skills.length} enabled
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══════════════ Search + Category Filter ═══════════════ */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="pl-9"
|
||||||
|
placeholder="Search skills and toolsets..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category pills */}
|
||||||
|
{allCategories.length > 1 && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Filter className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`inline-flex items-center px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
|
||||||
|
!activeCategory
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveCategory(null)}
|
||||||
|
>
|
||||||
|
All ({skills.length})
|
||||||
|
</button>
|
||||||
|
{allCategories.map(({ key, name, count }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
className={`inline-flex items-center px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
|
||||||
|
activeCategory === key
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
setActiveCategory(activeCategory === key ? null : key)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
<span className="ml-1 opacity-60">{count}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══════════════ Skills by Category ═══════════════ */}
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
|
||||||
|
{filteredSkills.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
{skills.length === 0
|
||||||
|
? "No skills found. Skills are loaded from ~/.hermes/skills/"
|
||||||
|
: "No skills match your search or filter."}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
categoryGroups.map(({ key, name, skills: catSkills, enabledCount: catEnabled }) => {
|
||||||
|
const collapsed = isCollapsed(key);
|
||||||
|
return (
|
||||||
|
<Card key={key}>
|
||||||
|
<CardHeader
|
||||||
|
className="cursor-pointer select-none py-3 px-4"
|
||||||
|
onClick={() => toggleCollapse(key)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<CardTitle className="text-sm font-medium">{name}</CardTitle>
|
||||||
|
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||||
|
{catSkills.length} skill{catSkills.length !== 1 ? "s" : ""}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={catEnabled === catSkills.length ? "success" : "outline"}
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
|
{catEnabled}/{catSkills.length} enabled
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{collapsed ? (
|
||||||
|
/* Peek: show first few skill names so collapsed isn't blank */
|
||||||
|
<div className="px-4 pb-3 flex items-center min-h-[28px]">
|
||||||
|
<p className="text-xs text-muted-foreground/60 truncate leading-normal">
|
||||||
|
{catSkills.slice(0, 4).map((s) => s.name).join(", ")}
|
||||||
|
{catSkills.length > 4 && `, +${catSkills.length - 4} more`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CardContent className="pt-0 px-4 pb-3">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{catSkills.map((skill) => (
|
||||||
|
<div
|
||||||
|
key={skill.name}
|
||||||
|
className="group flex items-start gap-3 rounded-md px-3 py-2.5 transition-colors hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<div className="pt-0.5 shrink-0">
|
||||||
|
<Switch
|
||||||
|
checked={skill.enabled}
|
||||||
|
onCheckedChange={() => handleToggleSkill(skill)}
|
||||||
|
disabled={togglingSkills.has(skill.name)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span
|
||||||
|
className={`font-mono-ui text-sm ${
|
||||||
|
skill.enabled
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{skill.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
|
||||||
|
{skill.description || "No description available."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ═══════════════ Toolsets ═══════════════ */}
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||||
|
<Wrench className="h-4 w-4" />
|
||||||
|
Toolsets ({filteredToolsets.length})
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{filteredToolsets.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No toolsets match the search.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filteredToolsets.map((ts) => {
|
||||||
|
// Strip emoji prefix from label for cleaner display
|
||||||
|
const labelText = ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || ts.name;
|
||||||
|
const emoji = ts.label.match(/^[\p{Emoji}]+/u)?.[0] || "🔧";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={ts.name} className="relative overflow-hidden">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="text-2xl shrink-0 leading-none mt-0.5">{emoji}</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-medium text-sm">{labelText}</span>
|
||||||
|
<Badge
|
||||||
|
variant={ts.enabled ? "success" : "outline"}
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
|
{ts.enabled ? "active" : "inactive"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
{ts.description}
|
||||||
|
</p>
|
||||||
|
{ts.enabled && !ts.configured && (
|
||||||
|
<p className="text-[10px] text-amber-300/80 mb-2">
|
||||||
|
Setup needed
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{ts.tools.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{ts.tools.map((tool) => (
|
||||||
|
<Badge
|
||||||
|
key={tool}
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] font-mono"
|
||||||
|
>
|
||||||
|
{tool}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ts.tools.length === 0 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground/60">
|
||||||
|
{ts.enabled ? `${ts.name} toolset` : "Disabled for CLI"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
web/src/pages/StatusPage.tsx
Normal file
303
web/src/pages/StatusPage.tsx
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
AlertTriangle,
|
||||||
|
Clock,
|
||||||
|
Cpu,
|
||||||
|
Database,
|
||||||
|
Radio,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { PlatformStatus, SessionInfo, StatusResponse } from "@/lib/api";
|
||||||
|
import { timeAgo, isoTimeAgo } from "@/lib/utils";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
const PLATFORM_STATE_BADGE: Record<string, { variant: "success" | "warning" | "destructive"; label: string }> = {
|
||||||
|
connected: { variant: "success", label: "Connected" },
|
||||||
|
disconnected: { variant: "warning", label: "Disconnected" },
|
||||||
|
fatal: { variant: "destructive", label: "Error" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const GATEWAY_STATE_DISPLAY: Record<string, { badge: "success" | "warning" | "destructive" | "outline"; label: string }> = {
|
||||||
|
running: { badge: "success", label: "Running" },
|
||||||
|
starting: { badge: "warning", label: "Starting" },
|
||||||
|
startup_failed: { badge: "destructive", label: "Failed" },
|
||||||
|
stopped: { badge: "outline", label: "Stopped" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function gatewayValue(status: StatusResponse): string {
|
||||||
|
if (status.gateway_running) return `PID ${status.gateway_pid}`;
|
||||||
|
if (status.gateway_state === "startup_failed") return "Start failed";
|
||||||
|
return "Not running";
|
||||||
|
}
|
||||||
|
|
||||||
|
function gatewayBadge(status: StatusResponse) {
|
||||||
|
const info = status.gateway_state ? GATEWAY_STATE_DISPLAY[status.gateway_state] : null;
|
||||||
|
if (info) return info;
|
||||||
|
return status.gateway_running
|
||||||
|
? { badge: "success" as const, label: "Running" }
|
||||||
|
: { badge: "outline" as const, label: "Off" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusPage() {
|
||||||
|
const [status, setStatus] = useState<StatusResponse | null>(null);
|
||||||
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = () => {
|
||||||
|
api.getStatus().then(setStatus).catch(() => {});
|
||||||
|
api.getSessions().then(setSessions).catch(() => {});
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const interval = setInterval(load, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-24">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gwBadge = gatewayBadge(status);
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
icon: Cpu,
|
||||||
|
label: "Agent",
|
||||||
|
value: `v${status.version}`,
|
||||||
|
badgeText: "Live",
|
||||||
|
badgeVariant: "success" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Radio,
|
||||||
|
label: "Gateway",
|
||||||
|
value: gatewayValue(status),
|
||||||
|
badgeText: gwBadge.label,
|
||||||
|
badgeVariant: gwBadge.badge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Activity,
|
||||||
|
label: "Active Sessions",
|
||||||
|
value: status.active_sessions > 0 ? `${status.active_sessions} running` : "None",
|
||||||
|
badgeText: status.active_sessions > 0 ? "Live" : "Off",
|
||||||
|
badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as "success" | "outline",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const platforms = Object.entries(status.gateway_platforms ?? {});
|
||||||
|
const activeSessions = sessions.filter((s) => s.is_active);
|
||||||
|
const recentSessions = sessions.filter((s) => !s.is_active).slice(0, 5);
|
||||||
|
|
||||||
|
// Collect alerts that need attention
|
||||||
|
const alerts: { message: string; detail?: string }[] = [];
|
||||||
|
if (status.gateway_state === "startup_failed") {
|
||||||
|
alerts.push({
|
||||||
|
message: "Gateway failed to start",
|
||||||
|
detail: status.gateway_exit_reason ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const failedPlatforms = platforms.filter(([, info]) => info.state === "fatal" || info.state === "disconnected");
|
||||||
|
for (const [name, info] of failedPlatforms) {
|
||||||
|
alerts.push({
|
||||||
|
message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${info.state === "fatal" ? "error" : "disconnected"}`,
|
||||||
|
detail: info.error_message ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Alert banner — breaks grid monotony for critical states */}
|
||||||
|
{alerts.length > 0 && (
|
||||||
|
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||||
|
<div className="flex flex-col gap-2 min-w-0">
|
||||||
|
{alerts.map((alert, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<p className="text-sm font-medium text-destructive">{alert.message}</p>
|
||||||
|
{alert.detail && (
|
||||||
|
<p className="text-xs text-destructive/70 mt-0.5">{alert.detail}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{items.map(({ icon: Icon, label, value, badgeText, badgeVariant }) => (
|
||||||
|
<Card key={label}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{label}</CardTitle>
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold font-display">{value}</div>
|
||||||
|
|
||||||
|
{badgeText && (
|
||||||
|
<Badge variant={badgeVariant} className="mt-2">
|
||||||
|
{badgeVariant === "success" && (
|
||||||
|
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||||
|
)}
|
||||||
|
{badgeText}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{platforms.length > 0 && (
|
||||||
|
<PlatformsCard platforms={platforms} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSessions.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Activity className="h-5 w-5 text-success" />
|
||||||
|
<CardTitle className="text-base">Active Sessions</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="grid gap-3">
|
||||||
|
{activeSessions.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className="flex items-center justify-between border border-border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm">{s.title ?? "Untitled"}</span>
|
||||||
|
|
||||||
|
<Badge variant="success" className="text-[10px]">
|
||||||
|
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||||
|
Live
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-mono-ui">{s.model ?? "unknown"}</span> · {s.message_count} msgs · {timeAgo(s.last_active)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recentSessions.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">Recent Sessions</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="grid gap-3">
|
||||||
|
{recentSessions.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className="flex items-center justify-between border border-border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="font-medium text-sm">{s.title ?? "Untitled"}</span>
|
||||||
|
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-mono-ui">{s.model ?? "unknown"}</span> · {s.message_count} msgs · {timeAgo(s.last_active)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{s.preview && (
|
||||||
|
<span className="text-xs text-muted-foreground/70 truncate max-w-md">
|
||||||
|
{s.preview}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
<Database className="mr-1 h-3 w-3" />
|
||||||
|
{s.source ?? "local"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlatformsCard({ platforms }: PlatformsCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Radio className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">Connected Platforms</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="grid gap-3">
|
||||||
|
{platforms.map(([name, info]) => {
|
||||||
|
const display = PLATFORM_STATE_BADGE[info.state] ?? {
|
||||||
|
variant: "outline" as const,
|
||||||
|
label: info.state,
|
||||||
|
};
|
||||||
|
const IconComponent = info.state === "connected" ? Wifi : info.state === "fatal" ? AlertTriangle : WifiOff;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className="flex items-center justify-between border border-border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<IconComponent className={`h-4 w-4 ${
|
||||||
|
info.state === "connected"
|
||||||
|
? "text-success"
|
||||||
|
: info.state === "fatal"
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-warning"
|
||||||
|
}`} />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-medium capitalize">{name}</span>
|
||||||
|
|
||||||
|
{info.error_message && (
|
||||||
|
<span className="text-xs text-destructive">{info.error_message}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{info.updated_at && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Last update: {isoTimeAgo(info.updated_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Badge variant={display.variant}>
|
||||||
|
{display.variant === "success" && (
|
||||||
|
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||||
|
)}
|
||||||
|
{display.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlatformsCardProps {
|
||||||
|
platforms: [string, PlatformStatus][];
|
||||||
|
}
|
||||||
34
web/tsconfig.app.json
Normal file
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue