feat(onboarding): add one-shot bootstrap and first-run setup wizard (#285)
Adds a bootstrap launcher and a blocking first-run onboarding wizard that guides new users through minimum Hermes setup from the browser UI. Supported provider flows: OpenRouter, Anthropic, OpenAI, custom OpenAI-compatible. OAuth/terminal-first flows remain via 'hermes model'. Security hardening applied during review: - /api/onboarding/setup restricted to loopback when auth disabled - Newline injection guard in _write_env_file - esc() on setup.unsupported_note in onboarding.js - Test isolation fix (send_key instead of bot_name in contamination test) - Skip markers for PyYAML-dependent tests in agent-less environments Tests: 693 passed (up from 679) Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: gabogabucho <gabogabucho@gmail.com>
This commit is contained in:
@@ -28,7 +28,8 @@ This makes the code easy to modify from a terminal or by an agent.
|
||||
<repo>/
|
||||
server.py Thin routing shell + HTTP Handler + auth middleware. ~81 lines.
|
||||
Delegates all route handling to api/routes.py.
|
||||
start.sh Discovery script: finds agent dir, Python, starts server.
|
||||
bootstrap.py One-shot launcher: optional agent install, deps, health wait, browser open.
|
||||
start.sh Thin wrapper around bootstrap.py for shell-based startup.
|
||||
Dockerfile python:3.12-slim container image (~23 lines)
|
||||
docker-compose.yml Compose config with named volume and optional auth (~22 lines)
|
||||
.dockerignore Excludes .git, tests/, .env* from Docker builds
|
||||
@@ -39,6 +40,7 @@ This makes the code easy to modify from a terminal or by an agent.
|
||||
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve(), security headers (~71 lines)
|
||||
models.py Session model + CRUD, per-session profile tracking (~137 lines)
|
||||
profiles.py Profile state management, hermes_cli wrapper (~246 lines)
|
||||
onboarding.py First-run onboarding status, real provider config writes, and readiness detection.
|
||||
routes.py All GET + POST route handlers (~1180 lines)
|
||||
startup.py Startup helpers: auto_install_agent_deps() (~50 lines)
|
||||
streaming.py SSE engine, run_agent, cancel, HERMES_HOME save/restore (~236 lines)
|
||||
@@ -53,6 +55,7 @@ This makes the code easy to modify from a terminal or by an agent.
|
||||
messages.js send(), SSE event handlers, approval, transcript (~297 lines)
|
||||
panels.js Cron, skills, memory, workspace, profiles, todo, settings (~974 lines)
|
||||
commands.js Slash command registry, parser, autocomplete dropdown (~156 lines)
|
||||
onboarding.js First-run wizard overlay, provider setup flow, and settings/workspace orchestration.
|
||||
boot.js Event wiring, mobile nav, voice input, boot IIFE (~338 lines)
|
||||
tests/
|
||||
conftest.py Isolated test server (port 8788, separate HERMES_HOME) (~240 lines)
|
||||
|
||||
32
README.md
32
README.md
@@ -92,29 +92,31 @@ ecosystem. See [HERMES.md](HERMES.md) for the full side-by-side.
|
||||
|
||||
## Quick start
|
||||
|
||||
First, you need to install and configure [Hermes Agent](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) on your computer or server. This includes the following steps to complete:
|
||||
|
||||
* [ ] Running the `curl` command to download and setup Hermes
|
||||
* [ ] Configure your [LLM provider](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart#2-set-up-a-provider) with `hermes model`
|
||||
* [ ] Configure yout [messaging gateways](https://hermes-agent.nousresearch.com/docs/user-guide/messaging/) with `hermes gateway setup`
|
||||
* [ ] Can start chatting with hermes on command-line with `hermes`
|
||||
* [ ] Optional: [Configure your extended memory provider](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory-providers)
|
||||
* [ ] Optional: [Configure your tools](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools)
|
||||
|
||||
Once installed, you can now setup the web UI with:
|
||||
Run the repo bootstrap:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nesquena/hermes-webui.git hermes-webui
|
||||
cd hermes-webui
|
||||
python3 bootstrap.py
|
||||
```
|
||||
|
||||
Or keep using the shell launcher:
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
That is it! The script will:
|
||||
The bootstrap will:
|
||||
|
||||
1. Locate your Hermes agent directory automatically.
|
||||
2. Find (or create) a Python environment with the required dependencies.
|
||||
3. Start the web server.
|
||||
4. Print the URL (and SSH tunnel command if you are on a remote machine).
|
||||
1. Detect Hermes Agent and, if missing, attempt the official installer (`curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`).
|
||||
2. Find or create a Python environment with the WebUI dependencies.
|
||||
3. Start the web server and wait for `/health`.
|
||||
4. Open the browser unless you pass `--no-browser`.
|
||||
5. Drop you into a first-run onboarding wizard inside the WebUI.
|
||||
|
||||
> Native Windows is not supported for this bootstrap yet. Use Linux, macOS, or WSL2.
|
||||
|
||||
If provider setup is still incomplete after install, the onboarding wizard will point you to finish it with `hermes model` instead of trying to replicate the full CLI setup in-browser.
|
||||
|
||||
---
|
||||
|
||||
|
||||
812
api/config.py
812
api/config.py
File diff suppressed because it is too large
Load Diff
401
api/onboarding.py
Normal file
401
api/onboarding.py
Normal file
@@ -0,0 +1,401 @@
|
||||
"""Hermes Web UI -- first-run onboarding helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from api.auth import is_auth_enabled
|
||||
from api.config import (
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_WORKSPACE,
|
||||
_FALLBACK_MODELS,
|
||||
_HERMES_FOUND,
|
||||
_PROVIDER_DISPLAY,
|
||||
_PROVIDER_MODELS,
|
||||
_get_config_path,
|
||||
get_available_models,
|
||||
get_config,
|
||||
load_settings,
|
||||
reload_config,
|
||||
save_settings,
|
||||
verify_hermes_imports,
|
||||
)
|
||||
from api.workspace import get_last_workspace, load_workspaces
|
||||
|
||||
|
||||
_SUPPORTED_PROVIDER_SETUPS = {
|
||||
"openrouter": {
|
||||
"label": "OpenRouter",
|
||||
"env_var": "OPENROUTER_API_KEY",
|
||||
"default_model": "anthropic/claude-sonnet-4.6",
|
||||
"requires_base_url": False,
|
||||
"models": [
|
||||
{"id": model["id"], "label": model["label"]} for model in _FALLBACK_MODELS
|
||||
],
|
||||
},
|
||||
"anthropic": {
|
||||
"label": "Anthropic",
|
||||
"env_var": "ANTHROPIC_API_KEY",
|
||||
"default_model": "claude-sonnet-4.6",
|
||||
"requires_base_url": False,
|
||||
"models": list(_PROVIDER_MODELS.get("anthropic", [])),
|
||||
},
|
||||
"openai": {
|
||||
"label": "OpenAI",
|
||||
"env_var": "OPENAI_API_KEY",
|
||||
"default_model": "gpt-4o",
|
||||
"default_base_url": "https://api.openai.com/v1",
|
||||
"requires_base_url": False,
|
||||
"models": list(_PROVIDER_MODELS.get("openai", [])),
|
||||
},
|
||||
"custom": {
|
||||
"label": "Custom OpenAI-compatible",
|
||||
"env_var": "OPENAI_API_KEY",
|
||||
"default_model": "gpt-4o-mini",
|
||||
"requires_base_url": True,
|
||||
"models": [],
|
||||
},
|
||||
}
|
||||
|
||||
_UNSUPPORTED_PROVIDER_NOTE = (
|
||||
"OAuth and advanced provider flows such as Nous Portal, OpenAI Codex, and GitHub "
|
||||
"Copilot are still terminal-first. Use `hermes model` for those flows."
|
||||
)
|
||||
|
||||
|
||||
def _get_active_hermes_home() -> Path:
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
|
||||
return get_active_hermes_home()
|
||||
except ImportError:
|
||||
return Path.home() / ".hermes"
|
||||
|
||||
|
||||
def _load_env_file(env_path: Path) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
if not env_path.exists():
|
||||
return values
|
||||
try:
|
||||
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip().strip('"').strip("'")
|
||||
except Exception:
|
||||
return {}
|
||||
return values
|
||||
|
||||
|
||||
def _write_env_file(env_path: Path, updates: dict[str, str]) -> None:
|
||||
current = _load_env_file(env_path)
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
current.pop(key, None)
|
||||
os.environ.pop(key, None)
|
||||
continue
|
||||
clean = str(value).strip()
|
||||
if not clean:
|
||||
continue
|
||||
# Reject embedded newlines/carriage returns to prevent .env injection
|
||||
if "\n" in clean or "\r" in clean:
|
||||
raise ValueError("API key must not contain newline characters.")
|
||||
current[key] = clean
|
||||
os.environ[key] = clean
|
||||
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = [f"{key}={current[key]}" for key in sorted(current)]
|
||||
env_path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
|
||||
|
||||
|
||||
def _load_yaml_config(config_path: Path) -> dict:
|
||||
try:
|
||||
import yaml as _yaml
|
||||
except ImportError:
|
||||
return {}
|
||||
|
||||
if not config_path.exists():
|
||||
return {}
|
||||
try:
|
||||
loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
return loaded if isinstance(loaded, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_yaml_config(config_path: Path, config: dict) -> None:
|
||||
try:
|
||||
import yaml as _yaml
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("PyYAML is required to write Hermes config.yaml") from exc
|
||||
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(
|
||||
_yaml.safe_dump(config, sort_keys=False, allow_unicode=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_model_for_provider(provider: str, model: str) -> str:
|
||||
clean = (model or "").strip()
|
||||
if not clean:
|
||||
return ""
|
||||
if provider in {"anthropic", "openai"} and clean.startswith(provider + "/"):
|
||||
return clean.split("/", 1)[1]
|
||||
return clean
|
||||
|
||||
|
||||
def _normalize_base_url(base_url: str) -> str:
|
||||
return (base_url or "").strip().rstrip("/")
|
||||
|
||||
|
||||
def _extract_current_provider(cfg: dict) -> str:
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
if provider:
|
||||
return provider
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_current_model(cfg: dict) -> str:
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, str):
|
||||
return model_cfg.strip()
|
||||
if isinstance(model_cfg, dict):
|
||||
return str(model_cfg.get("default") or "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_current_base_url(cfg: dict) -> str:
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
return _normalize_base_url(str(model_cfg.get("base_url") or ""))
|
||||
return ""
|
||||
|
||||
|
||||
def _provider_api_key_present(
|
||||
provider: str, cfg: dict, env_values: dict[str, str]
|
||||
) -> bool:
|
||||
provider = (provider or "").strip().lower()
|
||||
if not provider:
|
||||
return False
|
||||
|
||||
env_var = _SUPPORTED_PROVIDER_SETUPS.get(provider, {}).get("env_var")
|
||||
if env_var and env_values.get(env_var):
|
||||
return True
|
||||
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict) and str(model_cfg.get("api_key") or "").strip():
|
||||
return True
|
||||
|
||||
providers_cfg = cfg.get("providers", {})
|
||||
if isinstance(providers_cfg, dict):
|
||||
provider_cfg = providers_cfg.get(provider, {})
|
||||
if (
|
||||
isinstance(provider_cfg, dict)
|
||||
and str(provider_cfg.get("api_key") or "").strip()
|
||||
):
|
||||
return True
|
||||
if provider == "custom":
|
||||
custom_cfg = providers_cfg.get("custom", {})
|
||||
if (
|
||||
isinstance(custom_cfg, dict)
|
||||
and str(custom_cfg.get("api_key") or "").strip()
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _status_from_runtime(cfg: dict, imports_ok: bool) -> dict:
|
||||
provider = _extract_current_provider(cfg)
|
||||
model = _extract_current_model(cfg)
|
||||
base_url = _extract_current_base_url(cfg)
|
||||
env_values = _load_env_file(_get_active_hermes_home() / ".env")
|
||||
|
||||
provider_configured = bool(provider and model)
|
||||
provider_ready = False
|
||||
|
||||
if provider_configured:
|
||||
if provider == "custom":
|
||||
provider_ready = bool(
|
||||
base_url and _provider_api_key_present(provider, cfg, env_values)
|
||||
)
|
||||
elif provider in _SUPPORTED_PROVIDER_SETUPS:
|
||||
provider_ready = _provider_api_key_present(provider, cfg, env_values)
|
||||
|
||||
chat_ready = bool(_HERMES_FOUND and imports_ok and provider_ready)
|
||||
|
||||
if not _HERMES_FOUND or not imports_ok:
|
||||
state = "agent_unavailable"
|
||||
note = (
|
||||
"Hermes is not fully importable from the Web UI yet. Finish bootstrap or fix the "
|
||||
"agent install before provider setup will work."
|
||||
)
|
||||
elif chat_ready:
|
||||
state = "ready"
|
||||
provider_name = _PROVIDER_DISPLAY.get(
|
||||
provider, provider.title() if provider else "Hermes"
|
||||
)
|
||||
note = f"Hermes is minimally configured and ready to chat via {provider_name}."
|
||||
elif provider_configured:
|
||||
state = "provider_incomplete"
|
||||
missing = (
|
||||
"base URL and API key"
|
||||
if provider == "custom" and not base_url
|
||||
else "API key"
|
||||
)
|
||||
note = (
|
||||
f"Hermes has a saved provider/model selection but still needs the {missing} "
|
||||
"required to chat."
|
||||
)
|
||||
else:
|
||||
state = "needs_provider"
|
||||
note = "Hermes is installed, but you still need to choose a provider and save working credentials."
|
||||
|
||||
return {
|
||||
"provider_configured": provider_configured,
|
||||
"provider_ready": provider_ready,
|
||||
"chat_ready": chat_ready,
|
||||
"setup_state": state,
|
||||
"provider_note": note,
|
||||
"current_provider": provider or None,
|
||||
"current_model": model or None,
|
||||
"current_base_url": base_url or None,
|
||||
"env_path": str(_get_active_hermes_home() / ".env"),
|
||||
}
|
||||
|
||||
|
||||
def _build_setup_catalog(cfg: dict) -> dict:
|
||||
current_provider = _extract_current_provider(cfg) or "openrouter"
|
||||
current_model = _extract_current_model(cfg)
|
||||
current_base_url = _extract_current_base_url(cfg)
|
||||
|
||||
providers = []
|
||||
for provider_id, meta in _SUPPORTED_PROVIDER_SETUPS.items():
|
||||
providers.append(
|
||||
{
|
||||
"id": provider_id,
|
||||
"label": meta["label"],
|
||||
"env_var": meta["env_var"],
|
||||
"default_model": meta["default_model"],
|
||||
"default_base_url": meta.get("default_base_url") or "",
|
||||
"requires_base_url": bool(meta.get("requires_base_url")),
|
||||
"models": list(meta.get("models", [])),
|
||||
"quick": provider_id == "openrouter",
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"providers": providers,
|
||||
"unsupported_note": _UNSUPPORTED_PROVIDER_NOTE,
|
||||
"current": {
|
||||
"provider": current_provider,
|
||||
"model": current_model
|
||||
or _SUPPORTED_PROVIDER_SETUPS[current_provider]["default_model"],
|
||||
"base_url": current_base_url,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_onboarding_status() -> dict:
|
||||
settings = load_settings()
|
||||
cfg = get_config()
|
||||
imports_ok, missing, errors = verify_hermes_imports()
|
||||
runtime = _status_from_runtime(cfg, imports_ok)
|
||||
workspaces = load_workspaces()
|
||||
last_workspace = get_last_workspace()
|
||||
available_models = get_available_models()
|
||||
|
||||
return {
|
||||
"completed": bool(settings.get("onboarding_completed")),
|
||||
"settings": {
|
||||
"default_model": settings.get("default_model") or DEFAULT_MODEL,
|
||||
"default_workspace": settings.get("default_workspace")
|
||||
or str(DEFAULT_WORKSPACE),
|
||||
"password_enabled": is_auth_enabled(),
|
||||
"bot_name": settings.get("bot_name") or "Hermes",
|
||||
},
|
||||
"system": {
|
||||
"hermes_found": bool(_HERMES_FOUND),
|
||||
"imports_ok": bool(imports_ok),
|
||||
"missing_modules": missing,
|
||||
"import_errors": errors,
|
||||
"config_path": str(_get_config_path()),
|
||||
"config_exists": Path(_get_config_path()).exists(),
|
||||
**runtime,
|
||||
},
|
||||
"setup": _build_setup_catalog(cfg),
|
||||
"workspaces": {
|
||||
"items": workspaces,
|
||||
"last": last_workspace,
|
||||
},
|
||||
"models": available_models,
|
||||
}
|
||||
|
||||
|
||||
def apply_onboarding_setup(body: dict) -> dict:
|
||||
provider = str(body.get("provider") or "").strip().lower()
|
||||
model = str(body.get("model") or "").strip()
|
||||
api_key = str(body.get("api_key") or "").strip()
|
||||
base_url = _normalize_base_url(str(body.get("base_url") or ""))
|
||||
|
||||
if provider not in _SUPPORTED_PROVIDER_SETUPS:
|
||||
raise ValueError("Unsupported provider for WebUI onboarding.")
|
||||
if not model:
|
||||
raise ValueError("model is required")
|
||||
|
||||
provider_meta = _SUPPORTED_PROVIDER_SETUPS[provider]
|
||||
if provider_meta.get("requires_base_url"):
|
||||
if not base_url:
|
||||
raise ValueError("base_url is required for custom endpoints")
|
||||
parsed = urlparse(base_url)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise ValueError("base_url must start with http:// or https://")
|
||||
|
||||
cfg = _load_yaml_config(_get_config_path())
|
||||
env_path = _get_active_hermes_home() / ".env"
|
||||
env_values = _load_env_file(env_path)
|
||||
|
||||
if not api_key and not _provider_api_key_present(provider, cfg, env_values):
|
||||
raise ValueError(f"{provider_meta['env_var']} is required")
|
||||
|
||||
model_cfg = cfg.get("model", {})
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
|
||||
model_cfg["provider"] = provider
|
||||
model_cfg["default"] = _normalize_model_for_provider(provider, model)
|
||||
|
||||
if provider == "custom":
|
||||
model_cfg["base_url"] = base_url
|
||||
elif provider == "openai":
|
||||
model_cfg["base_url"] = (
|
||||
provider_meta.get("default_base_url") or "https://api.openai.com/v1"
|
||||
)
|
||||
else:
|
||||
model_cfg.pop("base_url", None)
|
||||
|
||||
cfg["model"] = model_cfg
|
||||
_save_yaml_config(_get_config_path(), cfg)
|
||||
|
||||
if api_key:
|
||||
_write_env_file(env_path, {provider_meta["env_var"]: api_key})
|
||||
|
||||
try:
|
||||
from api.profiles import _reload_dotenv
|
||||
|
||||
_reload_dotenv(_get_active_hermes_home())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
reload_config()
|
||||
return get_onboarding_status()
|
||||
|
||||
|
||||
def complete_onboarding() -> dict:
|
||||
save_settings({"onboarding_completed": True})
|
||||
return get_onboarding_status()
|
||||
1794
api/routes.py
1794
api/routes.py
File diff suppressed because it is too large
Load Diff
227
bootstrap.py
Normal file
227
bootstrap.py
Normal file
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-shot bootstrap launcher for Hermes Web UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import venv
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
INSTALLER_URL = "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh"
|
||||
REPO_ROOT = Path(__file__).resolve().parent
|
||||
DEFAULT_HOST = os.getenv("HERMES_WEBUI_HOST", "127.0.0.1")
|
||||
DEFAULT_PORT = int(os.getenv("HERMES_WEBUI_PORT", "8787"))
|
||||
|
||||
|
||||
def info(msg: str) -> None:
|
||||
print(f"[bootstrap] {msg}", flush=True)
|
||||
|
||||
|
||||
def is_wsl() -> bool:
|
||||
if platform.system() != "Linux":
|
||||
return False
|
||||
release = platform.release().lower()
|
||||
return (
|
||||
"microsoft" in release or "wsl" in release or bool(os.getenv("WSL_DISTRO_NAME"))
|
||||
)
|
||||
|
||||
|
||||
def ensure_supported_platform() -> None:
|
||||
if platform.system() == "Windows" and not is_wsl():
|
||||
raise RuntimeError(
|
||||
"Native Windows is not supported for this bootstrap yet. "
|
||||
"Please run it from Linux, macOS, or inside WSL2."
|
||||
)
|
||||
|
||||
|
||||
def discover_agent_dir() -> Path | None:
|
||||
home = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser()
|
||||
candidates = [
|
||||
os.getenv("HERMES_WEBUI_AGENT_DIR", ""),
|
||||
str(home / "hermes-agent"),
|
||||
str(REPO_ROOT.parent / "hermes-agent"),
|
||||
str(Path.home() / ".hermes" / "hermes-agent"),
|
||||
str(Path.home() / "hermes-agent"),
|
||||
]
|
||||
for raw in candidates:
|
||||
if not raw:
|
||||
continue
|
||||
candidate = Path(raw).expanduser().resolve()
|
||||
if candidate.exists() and (candidate / "run_agent.py").exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def discover_launcher_python(agent_dir: Path | None) -> str:
|
||||
env_python = os.getenv("HERMES_WEBUI_PYTHON")
|
||||
if env_python:
|
||||
return env_python
|
||||
if agent_dir:
|
||||
for rel in ("venv/bin/python", "venv/Scripts/python.exe"):
|
||||
candidate = agent_dir / rel
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
for rel in (".venv/bin/python", ".venv/Scripts/python.exe"):
|
||||
candidate = REPO_ROOT / rel
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
return shutil.which("python3") or shutil.which("python") or sys.executable
|
||||
|
||||
|
||||
def ensure_python_has_webui_deps(python_exe: str) -> str:
|
||||
check = subprocess.run(
|
||||
[python_exe, "-c", "import yaml"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if check.returncode == 0:
|
||||
return python_exe
|
||||
|
||||
venv_dir = REPO_ROOT / ".venv"
|
||||
venv_python = venv_dir / (
|
||||
"Scripts/python.exe" if platform.system() == "Windows" else "bin/python"
|
||||
)
|
||||
if not venv_python.exists():
|
||||
info(f"Creating local virtualenv at {venv_dir}")
|
||||
venv.EnvBuilder(with_pip=True).create(venv_dir)
|
||||
|
||||
info("Installing WebUI dependencies into local virtualenv")
|
||||
subprocess.run(
|
||||
[str(venv_python), "-m", "pip", "install", "--quiet", "--upgrade", "pip"],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
str(venv_python),
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--quiet",
|
||||
"-r",
|
||||
str(REPO_ROOT / "requirements.txt"),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
return str(venv_python)
|
||||
|
||||
|
||||
def hermes_command_exists() -> bool:
|
||||
return shutil.which("hermes") is not None
|
||||
|
||||
|
||||
def install_hermes_agent() -> None:
|
||||
info(f"Hermes Agent not found. Attempting install via {INSTALLER_URL}")
|
||||
subprocess.run(
|
||||
["/bin/bash", "-lc", f"curl -fsSL {INSTALLER_URL} | bash"], check=True
|
||||
)
|
||||
|
||||
|
||||
def wait_for_health(url: str, timeout: float = 25.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=2) as response:
|
||||
if b'"status": "ok"' in response.read():
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(0.4)
|
||||
return False
|
||||
|
||||
|
||||
def open_browser(url: str) -> None:
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception as exc:
|
||||
info(f"Could not open browser automatically: {exc}")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Bootstrap Hermes Web UI onboarding.")
|
||||
parser.add_argument("port", nargs="?", type=int, default=DEFAULT_PORT)
|
||||
parser.add_argument("--host", default=DEFAULT_HOST)
|
||||
parser.add_argument(
|
||||
"--no-browser",
|
||||
action="store_true",
|
||||
help="Do not open a browser tab automatically.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-agent-install",
|
||||
action="store_true",
|
||||
help="Fail instead of attempting the official Hermes installer.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
ensure_supported_platform()
|
||||
|
||||
agent_dir = discover_agent_dir()
|
||||
if not agent_dir and not hermes_command_exists():
|
||||
if args.skip_agent_install:
|
||||
raise RuntimeError(
|
||||
"Hermes Agent was not found and auto-install was disabled."
|
||||
)
|
||||
install_hermes_agent()
|
||||
agent_dir = discover_agent_dir()
|
||||
|
||||
python_exe = ensure_python_has_webui_deps(discover_launcher_python(agent_dir))
|
||||
state_dir = Path(
|
||||
os.getenv("HERMES_WEBUI_STATE_DIR", str(Path.home() / ".hermes" / "webui"))
|
||||
).expanduser()
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = state_dir / f"bootstrap-{args.port}.log"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["HERMES_WEBUI_HOST"] = args.host
|
||||
env["HERMES_WEBUI_PORT"] = str(args.port)
|
||||
env.setdefault("HERMES_WEBUI_STATE_DIR", str(state_dir))
|
||||
if agent_dir:
|
||||
env["HERMES_WEBUI_AGENT_DIR"] = str(agent_dir)
|
||||
|
||||
info(f"Starting Hermes Web UI on http://{args.host}:{args.port}")
|
||||
with log_path.open("ab") as log_file:
|
||||
proc = subprocess.Popen(
|
||||
[python_exe, str(REPO_ROOT / "server.py")],
|
||||
cwd=str(agent_dir or REPO_ROOT),
|
||||
env=env,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
health_url = f"http://{args.host}:{args.port}/health"
|
||||
if not wait_for_health(health_url):
|
||||
raise RuntimeError(
|
||||
f"Web UI did not become healthy at {health_url}. "
|
||||
f"Check the log at {log_path}. Server PID: {proc.pid}"
|
||||
)
|
||||
|
||||
app_url = (
|
||||
f"http://localhost:{args.port}"
|
||||
if args.host in ("127.0.0.1", "localhost")
|
||||
else f"http://{args.host}:{args.port}"
|
||||
)
|
||||
info(f"Web UI is ready: {app_url}")
|
||||
info(f"Log file: {log_path}")
|
||||
if not args.no_browser:
|
||||
open_browser(app_url)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc:
|
||||
print(f"[bootstrap] ERROR: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
265
start.sh
265
start.sh
@@ -1,260 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# Hermes Web UI -- portable bootstrap
|
||||
# Usage: ./start.sh [port]
|
||||
#
|
||||
# One-command startup. Discovers your Hermes install, sets up
|
||||
# a local virtualenv if needed, installs dependencies, then
|
||||
# launches the server and prints everything you need to know.
|
||||
#
|
||||
# Override any step with environment variables:
|
||||
# HERMES_WEBUI_AGENT_DIR path to hermes-agent checkout
|
||||
# HERMES_WEBUI_PYTHON python executable to use
|
||||
# HERMES_WEBUI_PORT port to listen on (default: 8787)
|
||||
# HERMES_WEBUI_HOST bind address (default: 127.0.0.1)
|
||||
# HERMES_HOME override ~/.hermes base
|
||||
# HERMES_WEBUI_STATE_DIR override state directory
|
||||
# ============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Load .env if present (machine-local overrides, not committed) ─────────────
|
||||
_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [[ -f "${_SCRIPT_DIR}/.env" ]]; then
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source "${_SCRIPT_DIR}/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# ── Colours ──────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
ok() { echo -e "${GREEN}[ok]${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!!]${RESET} $*"; }
|
||||
die() { echo -e "${RED}[XX]${RESET} $*" >&2; exit 1; }
|
||||
info() { echo -e "${CYAN}[--]${RESET} $*"; }
|
||||
hdr() { echo -e "\n${BOLD}$*${RESET}"; }
|
||||
|
||||
# ── Resolve repo root (the directory this script lives in) ───────────────────
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
info "Repo root: ${REPO_ROOT}"
|
||||
|
||||
# ── Port ─────────────────────────────────────────────────────────────────────
|
||||
PORT="${1:-${HERMES_WEBUI_PORT:-8787}}"
|
||||
export HERMES_WEBUI_PORT="${PORT}"
|
||||
|
||||
# ── Python discovery ─────────────────────────────────────────────────────────
|
||||
hdr "Discovering Python..."
|
||||
|
||||
_find_python() {
|
||||
# 1. Explicit env var
|
||||
if [[ -n "${HERMES_WEBUI_PYTHON:-}" ]]; then
|
||||
echo "${HERMES_WEBUI_PYTHON}"; return
|
||||
fi
|
||||
|
||||
# 2. Agent venv (discovered below -- call again after agent dir found)
|
||||
# (handled after agent dir discovery)
|
||||
|
||||
# 3. Local .venv in repo
|
||||
if [[ -x "${REPO_ROOT}/.venv/bin/python" ]]; then
|
||||
echo "${REPO_ROOT}/.venv/bin/python"; return
|
||||
fi
|
||||
|
||||
# 4. System python3
|
||||
if command -v python3 &>/dev/null; then
|
||||
echo "$(command -v python3)"; return
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
PYTHON="$(_find_python)"
|
||||
|
||||
# ── Hermes agent discovery ────────────────────────────────────────────────────
|
||||
hdr "Discovering Hermes agent..."
|
||||
|
||||
HERMES_HOME="${HERMES_HOME:-${HOME}/.hermes}"
|
||||
AGENT_DIR=""
|
||||
|
||||
_find_agent() {
|
||||
local candidates=(
|
||||
"${HERMES_WEBUI_AGENT_DIR:-}"
|
||||
"${HERMES_HOME}/hermes-agent"
|
||||
"${REPO_ROOT}/../hermes-agent"
|
||||
"${HOME}/.hermes/hermes-agent"
|
||||
"${HOME}/hermes-agent"
|
||||
)
|
||||
|
||||
for d in "${candidates[@]}"; do
|
||||
[[ -z "$d" ]] && continue
|
||||
d="$(cd "${d}" 2>/dev/null && pwd || true)"
|
||||
if [[ -n "$d" && -f "${d}/run_agent.py" ]]; then
|
||||
echo "$d"; return
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
|
||||
AGENT_DIR="$(_find_agent)"
|
||||
|
||||
if [[ -n "${AGENT_DIR}" ]]; then
|
||||
ok "Hermes agent: ${AGENT_DIR}"
|
||||
export HERMES_WEBUI_AGENT_DIR="${AGENT_DIR}"
|
||||
|
||||
# Now that we have agent dir, prefer its venv if we don't already have a python
|
||||
if [[ -z "${HERMES_WEBUI_PYTHON:-}" && -x "${AGENT_DIR}/venv/bin/python" ]]; then
|
||||
PYTHON="${AGENT_DIR}/venv/bin/python"
|
||||
fi
|
||||
else
|
||||
warn "Hermes agent not found. Agent features will not work."
|
||||
warn "Fix with: export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent"
|
||||
if [[ -f "${REPO_ROOT}/.env" ]]; then
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source "${REPO_ROOT}/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [[ -n "${PYTHON}" ]]; then
|
||||
ok "Python: ${PYTHON} ($(${PYTHON} --version 2>&1))"
|
||||
else
|
||||
warn "No Python found. Attempting to install..."
|
||||
if command -v apt-get &>/dev/null; then
|
||||
sudo apt-get install -y python3 python3-venv python3-pip
|
||||
elif command -v brew &>/dev/null; then
|
||||
brew install python3
|
||||
else
|
||||
die "Could not find or install Python. Please install Python 3.8+ and re-run."
|
||||
fi
|
||||
PYTHON="${HERMES_WEBUI_PYTHON:-}"
|
||||
if [[ -z "${PYTHON}" ]]; then
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON="$(command -v python3)"
|
||||
ok "Python installed: ${PYTHON}"
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
PYTHON="$(command -v python)"
|
||||
else
|
||||
echo "[XX] Python 3 is required to run bootstrap.py" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Minimum Python version check ─────────────────────────────────────────────
|
||||
PY_VER="$(${PYTHON} -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
|
||||
PY_MAJOR="$(echo "${PY_VER}" | cut -d. -f1)"
|
||||
PY_MINOR="$(echo "${PY_VER}" | cut -d. -f2)"
|
||||
if [[ "${PY_MAJOR}" -lt 3 || ( "${PY_MAJOR}" -eq 3 && "${PY_MINOR}" -lt 8 ) ]]; then
|
||||
die "Python 3.8+ required. Found: ${PY_VER}"
|
||||
fi
|
||||
|
||||
# ── Dependency check / local venv setup ──────────────────────────────────────
|
||||
hdr "Checking dependencies..."
|
||||
|
||||
VENV_NEEDED=false
|
||||
VENV_PATH="${REPO_ROOT}/.venv"
|
||||
|
||||
# If the chosen python is already the agent venv, its deps are already installed.
|
||||
# If it is a system python, check if we can import the webui deps, create a local
|
||||
# .venv if not.
|
||||
_check_deps() {
|
||||
"${PYTHON}" -c "import yaml" 2>/dev/null
|
||||
}
|
||||
|
||||
if ! _check_deps; then
|
||||
info "PyYAML not found in ${PYTHON}. Creating local .venv..."
|
||||
|
||||
if [[ ! -d "${VENV_PATH}" ]]; then
|
||||
"${PYTHON}" -m venv "${VENV_PATH}" || die "Failed to create virtualenv at ${VENV_PATH}"
|
||||
fi
|
||||
|
||||
VENV_PY="${VENV_PATH}/bin/python"
|
||||
"${VENV_PY}" -m pip install --quiet --upgrade pip
|
||||
|
||||
if [[ -f "${REPO_ROOT}/requirements.txt" ]]; then
|
||||
info "Installing from requirements.txt..."
|
||||
"${VENV_PY}" -m pip install --quiet -r "${REPO_ROOT}/requirements.txt"
|
||||
else
|
||||
info "Installing minimal deps (pyyaml)..."
|
||||
"${VENV_PY}" -m pip install --quiet pyyaml
|
||||
fi
|
||||
|
||||
PYTHON="${VENV_PY}"
|
||||
ok "Local venv ready: ${VENV_PATH}"
|
||||
else
|
||||
ok "Dependencies satisfied."
|
||||
fi
|
||||
|
||||
# ── Kill any stale instance on the same port ─────────────────────────────────
|
||||
hdr "Checking for existing instances..."
|
||||
|
||||
EXISTING=$(lsof -ti tcp:"${PORT}" 2>/dev/null || true)
|
||||
if [[ -n "${EXISTING}" ]]; then
|
||||
warn "Killing existing process on port ${PORT} (PID ${EXISTING})"
|
||||
kill "${EXISTING}" 2>/dev/null || true
|
||||
sleep 0.5
|
||||
fi
|
||||
|
||||
# Also kill any server.py process from this repo
|
||||
pkill -f "${REPO_ROOT}/server.py" 2>/dev/null || true
|
||||
|
||||
# ── Set up working directory for Hermes imports ───────────────────────────────
|
||||
# server.py / api/config.py inject agent dir into sys.path at import time,
|
||||
# but we also cd into the agent dir so relative imports in run_agent work.
|
||||
if [[ -n "${AGENT_DIR}" ]]; then
|
||||
WORKDIR="${AGENT_DIR}"
|
||||
else
|
||||
WORKDIR="${REPO_ROOT}"
|
||||
fi
|
||||
|
||||
# ── Launch ───────────────────────────────────────────────────────────────────
|
||||
hdr "Starting Hermes Web UI..."
|
||||
|
||||
LOG="/tmp/hermes-webui-${PORT}.log"
|
||||
export HERMES_WEBUI_HOST="${HERMES_WEBUI_HOST:-127.0.0.1}"
|
||||
export HERMES_WEBUI_STATE_DIR="${HERMES_WEBUI_STATE_DIR:-${HERMES_HOME}/webui}"
|
||||
|
||||
nohup "${PYTHON}" "${REPO_ROOT}/server.py" \
|
||||
> "${LOG}" 2>&1 &
|
||||
PID=$!
|
||||
|
||||
echo -e "\n${CYAN} PID ${PID} starting...${RESET}"
|
||||
sleep 1.5
|
||||
|
||||
# ── Health check ─────────────────────────────────────────────────────────────
|
||||
HEALTH_URL="http://${HERMES_WEBUI_HOST:-127.0.0.1}:${PORT}/health"
|
||||
MAX_WAIT=15
|
||||
ELAPSED=0
|
||||
while [[ $ELAPSED -lt $MAX_WAIT ]]; do
|
||||
if curl -sf "${HEALTH_URL}" | grep -q '"status"' 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
ELAPSED=$((ELAPSED + 1))
|
||||
done
|
||||
|
||||
if ! curl -sf "${HEALTH_URL}" | grep -q '"status"' 2>/dev/null; then
|
||||
warn "Health check did not pass within ${MAX_WAIT}s. Check log:"
|
||||
tail -20 "${LOG}"
|
||||
echo ""
|
||||
warn "Server may still be starting. Try: curl ${HEALTH_URL}"
|
||||
else
|
||||
ok "Server is healthy."
|
||||
fi
|
||||
|
||||
# ── Print access instructions ─────────────────────────────────────────────────
|
||||
BIND_HOST="${HERMES_WEBUI_HOST:-127.0.0.1}"
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}========================================${RESET}"
|
||||
echo -e "${GREEN} Hermes Web UI is running${RESET}"
|
||||
echo -e "${BOLD}========================================${RESET}"
|
||||
echo ""
|
||||
|
||||
if [[ "${BIND_HOST}" == "127.0.0.1" || "${BIND_HOST}" == "localhost" ]]; then
|
||||
# Server is bound to loopback -- detect if we are on a remote machine
|
||||
# by checking if $SSH_CLIENT or $SSH_TTY is set
|
||||
if [[ -n "${SSH_CLIENT:-}" || -n "${SSH_TTY:-}" ]]; then
|
||||
SERVER_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || echo "<your-server-ip>")"
|
||||
echo -e " You are on a remote machine. To access from your local browser:"
|
||||
echo ""
|
||||
echo -e " ${CYAN}ssh -N -L ${PORT}:127.0.0.1:${PORT} \$(whoami)@${SERVER_IP}${RESET}"
|
||||
echo ""
|
||||
echo -e " Then open: ${BOLD}http://localhost:${PORT}${RESET}"
|
||||
else
|
||||
echo -e " Open: ${BOLD}http://localhost:${PORT}${RESET}"
|
||||
fi
|
||||
else
|
||||
echo -e " Open: ${BOLD}http://${BIND_HOST}:${PORT}${RESET}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e " Log: ${LOG}"
|
||||
echo -e " PID: ${PID}"
|
||||
echo ""
|
||||
exec "${PYTHON}" "${REPO_ROOT}/bootstrap.py" --no-browser "$@"
|
||||
|
||||
@@ -387,6 +387,7 @@ function applyBotName(){
|
||||
}
|
||||
// Pre-load workspace list so sidebar name is correct from first render
|
||||
await loadWorkspaceList();
|
||||
await loadOnboardingWizard();
|
||||
_initResizePanels();
|
||||
const saved=localStorage.getItem('hermes-webui-session');
|
||||
if(saved){
|
||||
|
||||
138
static/i18n.js
138
static/i18n.js
@@ -193,6 +193,75 @@ const LOCALES = {
|
||||
suggest_files: 'What files are in this workspace?',
|
||||
suggest_schedule: "What's on my schedule today?",
|
||||
suggest_plan: 'Help me plan a small project.',
|
||||
// onboarding
|
||||
onboarding_badge: 'FIRST RUN',
|
||||
onboarding_title: 'Welcome to Hermes Web UI',
|
||||
onboarding_lead: 'A quick guided setup will verify Hermes, save a real provider configuration, choose a workspace and model, and optionally protect the app with a password.',
|
||||
onboarding_back: 'Back',
|
||||
onboarding_continue: 'Continue',
|
||||
onboarding_open: 'Open Hermes',
|
||||
onboarding_step_system_title: 'System check',
|
||||
onboarding_step_system_desc: 'Verify Hermes Agent and config visibility.',
|
||||
onboarding_step_setup_title: 'Provider setup',
|
||||
onboarding_step_setup_desc: 'Save the minimum Hermes provider config.',
|
||||
onboarding_step_workspace_title: 'Workspace + model',
|
||||
onboarding_step_workspace_desc: 'Pick defaults for new sessions and chat.',
|
||||
onboarding_step_password_title: 'Optional password',
|
||||
onboarding_step_password_desc: 'Protect the Web UI before sharing it.',
|
||||
onboarding_step_finish_title: 'Finish',
|
||||
onboarding_step_finish_desc: 'Review and enter the app.',
|
||||
onboarding_notice_system_ready: 'Hermes Agent looks reachable from the Web UI.',
|
||||
onboarding_notice_system_unavailable: 'Hermes Agent is not fully available yet. Bootstrap can install it, but provider setup may still require a terminal.',
|
||||
onboarding_check_agent: 'Hermes Agent',
|
||||
onboarding_check_agent_ready: 'Detected and importable',
|
||||
onboarding_check_agent_missing: 'Missing or partially importable',
|
||||
onboarding_check_password: 'Password',
|
||||
onboarding_check_password_enabled: 'Already enabled',
|
||||
onboarding_check_password_disabled: 'Not enabled yet',
|
||||
onboarding_check_provider: 'Provider config',
|
||||
onboarding_check_provider_ready: 'Ready to chat',
|
||||
onboarding_check_provider_partial: 'Saved but incomplete',
|
||||
onboarding_check_provider_pending: 'Needs verification',
|
||||
onboarding_config_file: 'Config file:',
|
||||
onboarding_env_file: '.env file:',
|
||||
onboarding_unknown: 'Unknown',
|
||||
onboarding_current_provider: 'Current setup:',
|
||||
onboarding_missing_imports: 'Missing imports:',
|
||||
onboarding_notice_setup_required: 'Choose a simple provider path here. Advanced OAuth flows still belong in the Hermes CLI for now.',
|
||||
onboarding_notice_setup_already_ready: 'A working Hermes provider setup is already detected. You can keep it or replace it here.',
|
||||
onboarding_notice_workspace: 'These values reuse the same settings APIs as the normal app.',
|
||||
onboarding_workspace_label: 'Workspace',
|
||||
onboarding_workspace_or_path: 'Or enter a workspace path',
|
||||
onboarding_workspace_placeholder: '/home/you/workspace',
|
||||
onboarding_provider_label: 'Setup mode',
|
||||
onboarding_quick_setup_badge: 'quick setup',
|
||||
onboarding_api_key_label: 'API key',
|
||||
onboarding_api_key_placeholder: 'Leave blank to keep an existing saved key',
|
||||
onboarding_api_key_help_prefix: 'Saved as a secret in your Hermes .env file using',
|
||||
onboarding_base_url_label: 'Base URL',
|
||||
onboarding_base_url_placeholder: 'https://your-endpoint.example/v1',
|
||||
onboarding_base_url_help: 'Use this for OpenAI-compatible routers, self-hosted servers, LiteLLM, Ollama, LM Studio, vLLM, or similar endpoints.',
|
||||
onboarding_model_label: 'Default model',
|
||||
onboarding_workspace_help: 'Pick the model Hermes should use for new chats after setup completes.',
|
||||
onboarding_custom_model_placeholder: 'your-model-name',
|
||||
onboarding_custom_model_help: 'For custom endpoints, enter the exact model ID your server expects.',
|
||||
onboarding_notice_password_enabled: 'A password is already configured. Enter a new one only if you want to replace it.',
|
||||
onboarding_notice_password_recommended: 'Optional but recommended if you will expose the UI beyond localhost.',
|
||||
onboarding_password_label: 'Password (optional)',
|
||||
onboarding_password_placeholder: 'Leave blank to skip',
|
||||
onboarding_password_help: 'Passwords are stored through the existing settings API and hashed server-side.',
|
||||
onboarding_notice_finish: 'You can reopen Settings later to change any of this.',
|
||||
onboarding_not_set: 'Not set',
|
||||
onboarding_password_will_enable: 'Will be enabled',
|
||||
onboarding_password_skipped: 'Skipped for now',
|
||||
onboarding_finish_help: 'Finishing stores <code>onboarding_completed</code> in settings and drops you into the normal app.',
|
||||
onboarding_error_choose_workspace: 'Choose a workspace before continuing.',
|
||||
onboarding_error_choose_model: 'Choose a model before continuing.',
|
||||
onboarding_error_provider_required: 'Choose a setup mode before continuing.',
|
||||
onboarding_error_base_url_required: 'Base URL is required for custom endpoints.',
|
||||
onboarding_error_workspace_required: 'Workspace is required.',
|
||||
onboarding_error_model_required: 'Model is required.',
|
||||
onboarding_complete: 'Onboarding complete',
|
||||
},
|
||||
|
||||
es: {
|
||||
@@ -384,6 +453,75 @@ const LOCALES = {
|
||||
suggest_files: '¿Qué archivos hay en este espacio de trabajo?',
|
||||
suggest_schedule: '¿Qué tengo hoy en mi agenda?',
|
||||
suggest_plan: 'Ayúdame a planificar un proyecto pequeño.',
|
||||
// onboarding
|
||||
onboarding_badge: 'PRIMER USO',
|
||||
onboarding_title: 'Bienvenido a Hermes Web UI',
|
||||
onboarding_lead: 'Una guía rápida verificará Hermes, guardará una configuración real del proveedor, elegirá un espacio de trabajo y un modelo, y opcionalmente protegerá la app con una contraseña.',
|
||||
onboarding_back: 'Atrás',
|
||||
onboarding_continue: 'Continuar',
|
||||
onboarding_open: 'Abrir Hermes',
|
||||
onboarding_step_system_title: 'Comprobación del sistema',
|
||||
onboarding_step_system_desc: 'Verifica Hermes Agent y la visibilidad de la configuración.',
|
||||
onboarding_step_setup_title: 'Configuración del proveedor',
|
||||
onboarding_step_setup_desc: 'Guarda la configuración mínima real de Hermes.',
|
||||
onboarding_step_workspace_title: 'Espacio de trabajo + modelo',
|
||||
onboarding_step_workspace_desc: 'Elige los valores predeterminados para nuevas sesiones y chats.',
|
||||
onboarding_step_password_title: 'Contraseña opcional',
|
||||
onboarding_step_password_desc: 'Protege la Web UI antes de compartirla.',
|
||||
onboarding_step_finish_title: 'Finalizar',
|
||||
onboarding_step_finish_desc: 'Revisa todo y entra en la app.',
|
||||
onboarding_notice_system_ready: 'Parece que Hermes Agent está accesible desde la Web UI.',
|
||||
onboarding_notice_system_unavailable: 'Hermes Agent todavía no está totalmente disponible. Bootstrap puede instalarlo, pero la configuración del proveedor quizá aún requiera una terminal.',
|
||||
onboarding_check_agent: 'Hermes Agent',
|
||||
onboarding_check_agent_ready: 'Detectado e importable',
|
||||
onboarding_check_agent_missing: 'Falta o solo es parcialmente importable',
|
||||
onboarding_check_password: 'Contraseña',
|
||||
onboarding_check_password_enabled: 'Ya está activada',
|
||||
onboarding_check_password_disabled: 'Todavía no está activada',
|
||||
onboarding_check_provider: 'Configuración del proveedor',
|
||||
onboarding_check_provider_ready: 'Listo para chatear',
|
||||
onboarding_check_provider_partial: 'Guardado pero incompleto',
|
||||
onboarding_check_provider_pending: 'Necesita verificación',
|
||||
onboarding_config_file: 'Archivo de configuración:',
|
||||
onboarding_env_file: 'Archivo .env:',
|
||||
onboarding_unknown: 'Desconocido',
|
||||
onboarding_current_provider: 'Configuración actual:',
|
||||
onboarding_missing_imports: 'Importaciones faltantes:',
|
||||
onboarding_notice_setup_required: 'Elige aquí una ruta simple de proveedor. Los flujos OAuth avanzados siguen siendo del CLI de Hermes por ahora.',
|
||||
onboarding_notice_setup_already_ready: 'Ya se detectó una configuración funcional del proveedor de Hermes. Puedes conservarla o reemplazarla aquí.',
|
||||
onboarding_notice_workspace: 'Estos valores reutilizan las mismas APIs de configuración que la app normal.',
|
||||
onboarding_workspace_label: 'Espacio de trabajo',
|
||||
onboarding_workspace_or_path: 'O introduce la ruta de un espacio de trabajo',
|
||||
onboarding_workspace_placeholder: '/home/you/workspace',
|
||||
onboarding_provider_label: 'Modo de configuración',
|
||||
onboarding_quick_setup_badge: 'configuración rápida',
|
||||
onboarding_api_key_label: 'API key',
|
||||
onboarding_api_key_placeholder: 'Déjala en blanco para conservar una key ya guardada',
|
||||
onboarding_api_key_help_prefix: 'Se guarda como secreto en tu archivo .env de Hermes usando',
|
||||
onboarding_base_url_label: 'Base URL',
|
||||
onboarding_base_url_placeholder: 'https://tu-endpoint.example/v1',
|
||||
onboarding_base_url_help: 'Úsalo para routers OpenAI-compatible, servidores autoalojados, LiteLLM, Ollama, LM Studio, vLLM o endpoints parecidos.',
|
||||
onboarding_model_label: 'Modelo predeterminado',
|
||||
onboarding_workspace_help: 'Elige el modelo que Hermes debe usar para nuevos chats cuando termine la configuración.',
|
||||
onboarding_custom_model_placeholder: 'tu-modelo',
|
||||
onboarding_custom_model_help: 'Para endpoints personalizados, introduce el identificador exacto del modelo que espera tu servidor.',
|
||||
onboarding_notice_password_enabled: 'Ya hay una contraseña configurada. Introduce una nueva solo si quieres reemplazarla.',
|
||||
onboarding_notice_password_recommended: 'Es opcional, pero recomendable si vas a exponer la UI más allá de localhost.',
|
||||
onboarding_password_label: 'Contraseña (opcional)',
|
||||
onboarding_password_placeholder: 'Déjala en blanco para omitirla',
|
||||
onboarding_password_help: 'Las contraseñas se guardan mediante la API de configuración existente y se hashean en el servidor.',
|
||||
onboarding_notice_finish: 'Puedes volver a abrir Configuración más tarde para cambiar cualquiera de estos valores.',
|
||||
onboarding_not_set: 'Sin definir',
|
||||
onboarding_password_will_enable: 'Se activará',
|
||||
onboarding_password_skipped: 'Se omitirá por ahora',
|
||||
onboarding_finish_help: 'Al finalizar se guarda <code>onboarding_completed</code> en la configuración y entras en la app normal.',
|
||||
onboarding_error_choose_workspace: 'Elige un espacio de trabajo antes de continuar.',
|
||||
onboarding_error_choose_model: 'Elige un modelo antes de continuar.',
|
||||
onboarding_error_provider_required: 'Elige un modo de configuración antes de continuar.',
|
||||
onboarding_error_base_url_required: 'La base URL es obligatoria para endpoints personalizados.',
|
||||
onboarding_error_workspace_required: 'El espacio de trabajo es obligatorio.',
|
||||
onboarding_error_model_required: 'El modelo es obligatorio.',
|
||||
onboarding_complete: 'Onboarding completado',
|
||||
},
|
||||
|
||||
de: {
|
||||
|
||||
@@ -347,6 +347,26 @@
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="onboarding-overlay" id="onboardingOverlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="onboardingTitle">
|
||||
<div class="onboarding-card">
|
||||
<div class="onboarding-shell">
|
||||
<div class="onboarding-sidebar">
|
||||
<div class="onboarding-badge" data-i18n="onboarding_badge">FIRST RUN</div>
|
||||
<h2 id="onboardingTitle" data-i18n="onboarding_title">Welcome to Hermes Web UI</h2>
|
||||
<p id="onboardingLead" data-i18n="onboarding_lead">A quick guided setup will check your Hermes install, choose a workspace and model, and optionally protect the app with a password.</p>
|
||||
<div class="onboarding-steps" id="onboardingSteps"></div>
|
||||
</div>
|
||||
<div class="onboarding-main">
|
||||
<div class="onboarding-status" id="onboardingNotice"></div>
|
||||
<div class="onboarding-body" id="onboardingBody"></div>
|
||||
<div class="onboarding-actions">
|
||||
<button class="sm-btn" id="onboardingBackBtn" onclick="prevOnboardingStep()" style="display:none" data-i18n="onboarding_back">Back</button>
|
||||
<button class="sm-btn" id="onboardingNextBtn" onclick="nextOnboardingStep()" style="font-weight:700;color:var(--blue);border-color:rgba(124,185,255,.32)" data-i18n="onboarding_continue">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-overlay" id="settingsOverlay" style="display:none">
|
||||
<div class="settings-panel">
|
||||
<div class="settings-header">
|
||||
@@ -471,6 +491,7 @@
|
||||
<script src="/static/commands.js"></script>
|
||||
<script src="/static/messages.js"></script>
|
||||
<script src="/static/panels.js"></script>
|
||||
<script src="/static/onboarding.js"></script>
|
||||
<script src="/static/boot.js"></script>
|
||||
<div class="app-dialog-overlay" id="appDialogOverlay" style="display:none" aria-hidden="true">
|
||||
<div class="app-dialog" id="appDialog" role="dialog" aria-modal="true" aria-labelledby="appDialogTitle" aria-describedby="appDialogDesc">
|
||||
|
||||
306
static/onboarding.js
Normal file
306
static/onboarding.js
Normal file
@@ -0,0 +1,306 @@
|
||||
const ONBOARDING={status:null,step:0,steps:['system','setup','workspace','password','finish'],form:{provider:'openrouter',workspace:'',model:'',password:'',apiKey:'',baseUrl:''},active:false};
|
||||
|
||||
function _getOnboardingSetupProviders(){
|
||||
return (((ONBOARDING.status||{}).setup||{}).providers)||[];
|
||||
}
|
||||
|
||||
function _getOnboardingSetupProvider(id){
|
||||
return _getOnboardingSetupProviders().find(p=>p.id===id)||null;
|
||||
}
|
||||
|
||||
function _getOnboardingCurrentSetup(){
|
||||
return (((ONBOARDING.status||{}).setup||{}).current)||{};
|
||||
}
|
||||
|
||||
function _onboardingStepMeta(key){
|
||||
return ({
|
||||
system:{title:t('onboarding_step_system_title'),desc:t('onboarding_step_system_desc')},
|
||||
setup:{title:t('onboarding_step_setup_title'),desc:t('onboarding_step_setup_desc')},
|
||||
workspace:{title:t('onboarding_step_workspace_title'),desc:t('onboarding_step_workspace_desc')},
|
||||
password:{title:t('onboarding_step_password_title'),desc:t('onboarding_step_password_desc')},
|
||||
finish:{title:t('onboarding_step_finish_title'),desc:t('onboarding_step_finish_desc')}
|
||||
})[key];
|
||||
}
|
||||
|
||||
function _renderOnboardingSteps(){
|
||||
const wrap=$('onboardingSteps');
|
||||
if(!wrap)return;
|
||||
wrap.innerHTML='';
|
||||
ONBOARDING.steps.forEach((key,idx)=>{
|
||||
const meta=_onboardingStepMeta(key);
|
||||
const item=document.createElement('div');
|
||||
item.className='onboarding-step'+(idx===ONBOARDING.step?' active':idx<ONBOARDING.step?' done':'');
|
||||
item.innerHTML=`<div class="onboarding-step-index">${idx+1}</div><div><div class="onboarding-step-title">${meta.title}</div><div class="onboarding-step-desc">${meta.desc}</div></div>`;
|
||||
wrap.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function _setOnboardingNotice(msg,kind='info'){
|
||||
const el=$('onboardingNotice');
|
||||
if(!el)return;
|
||||
if(!msg){el.style.display='none';el.textContent='';el.className='onboarding-status';return;}
|
||||
el.style.display='block';
|
||||
el.className='onboarding-status '+kind;
|
||||
el.textContent=msg;
|
||||
}
|
||||
|
||||
function _getOnboardingWorkspaceChoices(){
|
||||
const items=((ONBOARDING.status||{}).workspaces||{}).items||[];
|
||||
return items.length?items:[{name:'Home',path:ONBOARDING.form.workspace||''}];
|
||||
}
|
||||
|
||||
function _getOnboardingProviderModelChoices(){
|
||||
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider);
|
||||
return provider?(provider.models||[]):[];
|
||||
}
|
||||
|
||||
function _getOnboardingSelectedModel(){
|
||||
return ONBOARDING.form.model||'';
|
||||
}
|
||||
|
||||
function _renderOnboardingModelField(){
|
||||
const choices=_getOnboardingProviderModelChoices();
|
||||
if(ONBOARDING.form.provider==='custom'){
|
||||
return `<label class="onboarding-field"><span>${t('onboarding_model_label')}</span><input id="onboardingModelInput" value="${esc(_getOnboardingSelectedModel())}" placeholder="${t('onboarding_custom_model_placeholder')}" oninput="ONBOARDING.form.model=this.value"></label><p class="onboarding-copy">${t('onboarding_custom_model_help')}</p>`;
|
||||
}
|
||||
const options=choices.map(m=>`<option value="${esc(m.id)}">${esc(m.label)}</option>`).join('');
|
||||
return `<label class="onboarding-field"><span>${t('onboarding_model_label')}</span><select id="onboardingModelSelect" onchange="ONBOARDING.form.model=this.value">${options}</select></label><p class="onboarding-copy">${t('onboarding_workspace_help')}</p>`;
|
||||
}
|
||||
|
||||
function _providerStatusLabel(system){
|
||||
if(system.chat_ready) return t('onboarding_check_provider_ready');
|
||||
if(system.provider_configured) return t('onboarding_check_provider_partial');
|
||||
return t('onboarding_check_provider_pending');
|
||||
}
|
||||
|
||||
function _renderOnboardingBody(){
|
||||
const body=$('onboardingBody');
|
||||
if(!body||!ONBOARDING.status)return;
|
||||
const key=ONBOARDING.steps[ONBOARDING.step];
|
||||
const system=ONBOARDING.status.system||{};
|
||||
const settings=ONBOARDING.status.settings||{};
|
||||
const setup=ONBOARDING.status.setup||{};
|
||||
const nextBtn=$('onboardingNextBtn');
|
||||
const backBtn=$('onboardingBackBtn');
|
||||
if(backBtn) backBtn.style.display=ONBOARDING.step>0?'':'none';
|
||||
if(nextBtn) nextBtn.textContent=key==='finish'?t('onboarding_open'):t('onboarding_continue');
|
||||
|
||||
if(key==='system'){
|
||||
const hermesOk=system.hermes_found&&system.imports_ok;
|
||||
const setupOk=!!system.chat_ready;
|
||||
_setOnboardingNotice(system.provider_note|| (setupOk?t('onboarding_notice_system_ready'):t('onboarding_notice_system_unavailable')),setupOk?'success':(hermesOk?'info':'warn'));
|
||||
body.innerHTML=`
|
||||
<div class="onboarding-panel-grid">
|
||||
<div class="onboarding-check ${hermesOk?'ok':'warn'}"><strong>${t('onboarding_check_agent')}</strong><span>${hermesOk?t('onboarding_check_agent_ready'):t('onboarding_check_agent_missing')}</span></div>
|
||||
<div class="onboarding-check ${(setupOk?'ok':system.provider_configured?'warn':'muted')}"><strong>${t('onboarding_check_provider')}</strong><span>${_providerStatusLabel(system)}</span></div>
|
||||
<div class="onboarding-check ${(settings.password_enabled?'ok':'muted')}"><strong>${t('onboarding_check_password')}</strong><span>${settings.password_enabled?t('onboarding_check_password_enabled'):t('onboarding_check_password_disabled')}</span></div>
|
||||
</div>
|
||||
<div class="onboarding-copy">
|
||||
<p><strong>${t('onboarding_config_file')}</strong> ${esc(system.config_path||t('onboarding_unknown'))}</p>
|
||||
<p><strong>${t('onboarding_env_file')}</strong> ${esc(system.env_path||t('onboarding_unknown'))}</p>
|
||||
<p>${esc(system.provider_note||'')}</p>
|
||||
${system.current_provider?`<p><strong>${t('onboarding_current_provider')}</strong> ${esc(system.current_provider)}${system.current_model?` — ${esc(system.current_model)}`:''}</p>`:''}
|
||||
${system.current_base_url?`<p><strong>${t('onboarding_base_url_label')}</strong> ${esc(system.current_base_url)}</p>`:''}
|
||||
${system.missing_modules&&system.missing_modules.length?`<p><strong>${t('onboarding_missing_imports')}</strong> ${esc(system.missing_modules.join(', '))}</p>`:''}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if(key==='setup'){
|
||||
const providers=_getOnboardingSetupProviders();
|
||||
const options=providers.map(p=>`<option value="${esc(p.id)}">${esc(p.label)}${p.quick?' — '+esc(t('onboarding_quick_setup_badge')):''}</option>`).join('');
|
||||
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider)||providers[0]||null;
|
||||
const showBaseUrl=provider&&provider.requires_base_url;
|
||||
const keyHelp=provider?`${t('onboarding_api_key_help_prefix')} ${esc(provider.env_var)}.`:'';
|
||||
_setOnboardingNotice(system.chat_ready?t('onboarding_notice_setup_already_ready'):t('onboarding_notice_setup_required'),system.chat_ready?'success':'info');
|
||||
body.innerHTML=`
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_provider_label')}</span>
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
|
||||
</label>
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_api_key_label')}</span>
|
||||
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
|
||||
</label>
|
||||
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
|
||||
<p class="onboarding-copy">${keyHelp}</p>
|
||||
${showBaseUrl?`<p class="onboarding-copy">${t('onboarding_base_url_help')}</p>`:''}
|
||||
<p class="onboarding-copy">${esc(setup.unsupported_note||'')||''}</p>`;
|
||||
const providerSel=$('onboardingProviderSelect');
|
||||
if(providerSel) providerSel.value=ONBOARDING.form.provider;
|
||||
return;
|
||||
}
|
||||
|
||||
if(key==='workspace'){
|
||||
const workspaceOptions=_getOnboardingWorkspaceChoices().map(ws=>`<option value="${esc(ws.path)}">${esc(ws.name||ws.path)} — ${esc(ws.path)}</option>`).join('');
|
||||
_setOnboardingNotice(t('onboarding_notice_workspace'), 'info');
|
||||
body.innerHTML=`
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_workspace_label')}</span>
|
||||
<select id="onboardingWorkspaceSelect" onchange="syncOnboardingWorkspaceSelect(this.value)">${workspaceOptions}</select>
|
||||
</label>
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_workspace_or_path')}</span>
|
||||
<input id="onboardingWorkspaceInput" value="${esc(ONBOARDING.form.workspace||'')}" placeholder="${t('onboarding_workspace_placeholder')}" oninput="ONBOARDING.form.workspace=this.value">
|
||||
</label>
|
||||
${_renderOnboardingModelField()}`;
|
||||
const wsSel=$('onboardingWorkspaceSelect');
|
||||
if(wsSel && ONBOARDING.form.workspace) wsSel.value=ONBOARDING.form.workspace;
|
||||
const modelSel=$('onboardingModelSelect');
|
||||
if(modelSel && ONBOARDING.form.model) modelSel.value=ONBOARDING.form.model;
|
||||
return;
|
||||
}
|
||||
|
||||
if(key==='password'){
|
||||
_setOnboardingNotice(settings.password_enabled?t('onboarding_notice_password_enabled'):t('onboarding_notice_password_recommended'), settings.password_enabled?'success':'info');
|
||||
body.innerHTML=`
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_password_label')}</span>
|
||||
<input id="onboardingPasswordInput" type="password" value="${esc(ONBOARDING.form.password||'')}" placeholder="${t('onboarding_password_placeholder')}" oninput="ONBOARDING.form.password=this.value">
|
||||
</label>
|
||||
<p class="onboarding-copy">${t('onboarding_password_help')}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider);
|
||||
_setOnboardingNotice(t('onboarding_notice_finish'), 'success');
|
||||
body.innerHTML=`
|
||||
<div class="onboarding-summary">
|
||||
<div><strong>${t('onboarding_provider_label')}</strong><span>${esc((provider&&provider.label)||ONBOARDING.form.provider||t('onboarding_not_set'))}</span></div>
|
||||
<div><strong>${t('onboarding_model_label')}</strong><span>${esc(_getOnboardingSelectedModel()||t('onboarding_not_set'))}</span></div>
|
||||
<div><strong>${t('onboarding_workspace_label')}</strong><span>${esc(ONBOARDING.form.workspace||t('onboarding_not_set'))}</span></div>
|
||||
<div><strong>${t('onboarding_check_password')}</strong><span>${ONBOARDING.form.password?t('onboarding_password_will_enable'):t('onboarding_password_skipped')}</span></div>
|
||||
</div>
|
||||
${ONBOARDING.form.baseUrl?`<p class="onboarding-copy"><strong>${t('onboarding_base_url_label')}</strong> ${esc(ONBOARDING.form.baseUrl)}</p>`:''}
|
||||
<p class="onboarding-copy">${t('onboarding_finish_help')}</p>`;
|
||||
}
|
||||
|
||||
function syncOnboardingWorkspaceSelect(value){
|
||||
ONBOARDING.form.workspace=value;
|
||||
const input=$('onboardingWorkspaceInput');
|
||||
if(input) input.value=value;
|
||||
}
|
||||
|
||||
function syncOnboardingProvider(value){
|
||||
const provider=_getOnboardingSetupProvider(value);
|
||||
ONBOARDING.form.provider=value;
|
||||
if(provider){
|
||||
if(!ONBOARDING.form.model || !_getOnboardingProviderModelChoices().some(m=>m.id===ONBOARDING.form.model) || value==='custom'){
|
||||
ONBOARDING.form.model=provider.default_model||'';
|
||||
}
|
||||
if(provider.requires_base_url){
|
||||
ONBOARDING.form.baseUrl=ONBOARDING.form.baseUrl||provider.default_base_url||'';
|
||||
}else{
|
||||
ONBOARDING.form.baseUrl=provider.default_base_url||'';
|
||||
}
|
||||
}
|
||||
_renderOnboardingBody();
|
||||
}
|
||||
|
||||
async function loadOnboardingWizard(){
|
||||
try{
|
||||
const status=await api('/api/onboarding/status');
|
||||
ONBOARDING.status=status;
|
||||
const current=((status.setup||{}).current)||{};
|
||||
ONBOARDING.form.provider=current.provider||'openrouter';
|
||||
ONBOARDING.form.workspace=(status.workspaces&&status.workspaces.last)||status.settings.default_workspace||'';
|
||||
ONBOARDING.form.model=status.settings.default_model||current.model||'openai/gpt-5.4-mini';
|
||||
ONBOARDING.form.password='';
|
||||
ONBOARDING.form.apiKey='';
|
||||
ONBOARDING.form.baseUrl=current.base_url||'';
|
||||
ONBOARDING.active=!status.completed;
|
||||
if(!ONBOARDING.active) return false;
|
||||
$('onboardingOverlay').style.display='flex';
|
||||
_renderOnboardingSteps();
|
||||
_renderOnboardingBody();
|
||||
return true;
|
||||
}catch(e){
|
||||
console.warn('onboarding status failed',e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function prevOnboardingStep(){
|
||||
if(ONBOARDING.step===0)return;
|
||||
ONBOARDING.step--;
|
||||
_renderOnboardingSteps();
|
||||
_renderOnboardingBody();
|
||||
}
|
||||
|
||||
async function _saveOnboardingProviderSetup(){
|
||||
const provider=(ONBOARDING.form.provider||'').trim();
|
||||
const model=(ONBOARDING.form.model||'').trim();
|
||||
const apiKey=(ONBOARDING.form.apiKey||'').trim();
|
||||
const baseUrl=(ONBOARDING.form.baseUrl||'').trim();
|
||||
const current=_getOnboardingCurrentSetup();
|
||||
const isUnchanged=current.provider===provider&&((current.model||'')===model)&&((current.base_url||'')===baseUrl);
|
||||
if(isUnchanged && !apiKey && (ONBOARDING.status.system||{}).chat_ready) return;
|
||||
const body={provider,model};
|
||||
if(apiKey) body.api_key=apiKey;
|
||||
if(baseUrl) body.base_url=baseUrl;
|
||||
const status=await api('/api/onboarding/setup',{method:'POST',body:JSON.stringify(body)});
|
||||
ONBOARDING.status=status;
|
||||
}
|
||||
|
||||
async function _saveOnboardingDefaults(){
|
||||
const workspace=(ONBOARDING.form.workspace||'').trim();
|
||||
const model=(ONBOARDING.form.model||'').trim();
|
||||
const password=(ONBOARDING.form.password||'').trim();
|
||||
if(!workspace) throw new Error(t('onboarding_error_choose_workspace'));
|
||||
if(!model) throw new Error(t('onboarding_error_choose_model'));
|
||||
const known=_getOnboardingWorkspaceChoices().some(ws=>ws.path===workspace);
|
||||
if(!known){
|
||||
await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path:workspace})});
|
||||
}
|
||||
const body={default_workspace:workspace,default_model:model};
|
||||
if(password) body._set_password=password;
|
||||
await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
||||
localStorage.setItem('hermes-webui-model',model);
|
||||
if($('modelSelect')) _applyModelToDropdown(model,$('modelSelect'));
|
||||
}
|
||||
|
||||
async function _finishOnboarding(){
|
||||
await _saveOnboardingProviderSetup();
|
||||
await _saveOnboardingDefaults();
|
||||
const done=await api('/api/onboarding/complete',{method:'POST',body:'{}'});
|
||||
ONBOARDING.status=done;
|
||||
ONBOARDING.active=false;
|
||||
$('onboardingOverlay').style.display='none';
|
||||
showToast(t('onboarding_complete'));
|
||||
await loadWorkspaceList();
|
||||
if(typeof renderSessionList==='function') await renderSessionList();
|
||||
if(!S.session && typeof newSession==='function'){
|
||||
await newSession(true);
|
||||
await renderSessionList();
|
||||
}
|
||||
}
|
||||
|
||||
async function nextOnboardingStep(){
|
||||
try{
|
||||
if(ONBOARDING.steps[ONBOARDING.step]==='setup'){
|
||||
ONBOARDING.form.provider=(($('onboardingProviderSelect')||{}).value||ONBOARDING.form.provider||'').trim();
|
||||
ONBOARDING.form.apiKey=(($('onboardingApiKeyInput')||{}).value||'').trim();
|
||||
ONBOARDING.form.baseUrl=(($('onboardingBaseUrlInput')||{}).value||ONBOARDING.form.baseUrl||'').trim();
|
||||
if(!ONBOARDING.form.provider) throw new Error(t('onboarding_error_provider_required'));
|
||||
if(ONBOARDING.form.provider==='custom' && !ONBOARDING.form.baseUrl) throw new Error(t('onboarding_error_base_url_required'));
|
||||
}
|
||||
if(ONBOARDING.steps[ONBOARDING.step]==='workspace'){
|
||||
ONBOARDING.form.workspace=(($('onboardingWorkspaceInput')||{}).value||ONBOARDING.form.workspace||'').trim();
|
||||
ONBOARDING.form.model=(($('onboardingModelInput')||{}).value||($('onboardingModelSelect')||{}).value||ONBOARDING.form.model||'').trim();
|
||||
if(!ONBOARDING.form.workspace) throw new Error(t('onboarding_error_workspace_required'));
|
||||
if(!ONBOARDING.form.model) throw new Error(t('onboarding_error_model_required'));
|
||||
}
|
||||
if(ONBOARDING.steps[ONBOARDING.step]==='password'){
|
||||
ONBOARDING.form.password=(($('onboardingPasswordInput')||{}).value||'').trim();
|
||||
}
|
||||
if(ONBOARDING.step===ONBOARDING.steps.length-1){
|
||||
await _finishOnboarding();
|
||||
return;
|
||||
}
|
||||
ONBOARDING.step++;
|
||||
_renderOnboardingSteps();
|
||||
_renderOnboardingBody();
|
||||
}catch(e){
|
||||
_setOnboardingNotice(e.message||String(e),'warn');
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,44 @@
|
||||
.app-dialog-btn:focus-visible,.app-dialog-close:focus-visible{outline:2px solid rgba(124,185,255,.85);outline-offset:2px;}
|
||||
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--surface);backdrop-filter:blur(12px);border:1px solid rgba(124,185,255,0.25);color:var(--text);font-size:13px;padding:10px 20px;border-radius:12px;pointer-events:none;opacity:0;transition:opacity .2s,transform .2s;z-index:100;box-shadow:0 4px 20px rgba(0,0,0,.3);letter-spacing:.01em;}
|
||||
.toast.show{opacity:1;transform:translateX(-50%) translateY(-2px);}
|
||||
.onboarding-overlay{position:fixed;inset:0;z-index:1050;background:rgba(7,12,19,.78);backdrop-filter:blur(8px);display:none;align-items:center;justify-content:center;padding:24px;}
|
||||
.onboarding-card{width:min(980px,100%);max-height:min(760px,94vh);overflow:auto;border:1px solid rgba(124,185,255,.16);border-radius:24px;background:linear-gradient(180deg,rgba(20,30,44,.98),rgba(11,17,27,.98));box-shadow:0 24px 80px rgba(0,0,0,.45);}
|
||||
.onboarding-shell{display:grid;grid-template-columns:minmax(240px,300px) minmax(0,1fr);}
|
||||
.onboarding-sidebar{padding:28px 24px;border-right:1px solid var(--border);background:linear-gradient(180deg,rgba(124,185,255,.08),rgba(124,185,255,.02));}
|
||||
.onboarding-sidebar h2{font-size:26px;line-height:1.15;margin-top:10px;margin-bottom:12px;letter-spacing:-.03em;}
|
||||
.onboarding-badge{display:inline-flex;padding:4px 10px;border-radius:999px;font-size:10px;font-weight:800;letter-spacing:.12em;background:rgba(124,185,255,.14);color:var(--blue);}
|
||||
.onboarding-sidebar p{font-size:13px;color:var(--muted);line-height:1.7;}
|
||||
.onboarding-steps{display:flex;flex-direction:column;gap:10px;margin-top:24px;}
|
||||
.onboarding-step{display:flex;gap:12px;align-items:flex-start;padding:10px 12px;border-radius:14px;border:1px solid transparent;background:rgba(255,255,255,.02);}
|
||||
.onboarding-step.active{border-color:rgba(124,185,255,.25);background:rgba(124,185,255,.08);}
|
||||
.onboarding-step.done{background:rgba(201,168,76,.08);}
|
||||
.onboarding-step-index{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;background:rgba(255,255,255,.08);color:var(--text);flex-shrink:0;}
|
||||
.onboarding-step.done .onboarding-step-index{background:rgba(201,168,76,.16);color:var(--gold);}
|
||||
.onboarding-step.active .onboarding-step-index{background:rgba(124,185,255,.18);color:var(--blue);}
|
||||
.onboarding-step-title{font-size:13px;font-weight:700;color:var(--text);}
|
||||
.onboarding-step-desc{font-size:11px;color:var(--muted);margin-top:2px;line-height:1.5;}
|
||||
.onboarding-main{padding:28px 28px 24px;display:flex;flex-direction:column;gap:18px;min-width:0;}
|
||||
.onboarding-status{display:none;padding:12px 14px;border-radius:12px;font-size:13px;line-height:1.6;border:1px solid var(--border2);background:rgba(255,255,255,.04);}
|
||||
.onboarding-status.info{color:var(--text);}
|
||||
.onboarding-status.success{color:var(--blue);border-color:rgba(124,185,255,.3);background:rgba(124,185,255,.08);}
|
||||
.onboarding-status.warn{color:var(--gold);border-color:rgba(201,168,76,.28);background:rgba(201,168,76,.08);}
|
||||
.onboarding-body{display:flex;flex-direction:column;gap:16px;}
|
||||
.onboarding-panel-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;}
|
||||
.onboarding-check{padding:14px;border-radius:14px;border:1px solid var(--border);background:rgba(255,255,255,.03);display:flex;flex-direction:column;gap:5px;}
|
||||
.onboarding-check strong{font-size:13px;color:var(--text);}
|
||||
.onboarding-check span{font-size:12px;color:var(--muted);line-height:1.5;}
|
||||
.onboarding-check.ok{border-color:rgba(124,185,255,.28);background:rgba(124,185,255,.08);}
|
||||
.onboarding-check.warn{border-color:rgba(201,168,76,.25);background:rgba(201,168,76,.08);}
|
||||
.onboarding-field{display:flex;flex-direction:column;gap:6px;}
|
||||
.onboarding-field span{font-size:12px;font-weight:700;color:var(--text);}
|
||||
.onboarding-field input,.onboarding-field select{margin-bottom:0;padding:10px 12px;border-radius:10px;font-size:13px;background:var(--input-bg);border:1px solid var(--border2);color:var(--text);}
|
||||
.onboarding-copy{font-size:12px;color:var(--muted);line-height:1.7;}
|
||||
.onboarding-summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;}
|
||||
.onboarding-summary div{padding:14px;border-radius:14px;background:rgba(255,255,255,.03);border:1px solid var(--border);display:flex;flex-direction:column;gap:5px;}
|
||||
.onboarding-summary strong{font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);}
|
||||
.onboarding-summary span{font-size:13px;color:var(--text);word-break:break-word;}
|
||||
.onboarding-actions{display:flex;justify-content:space-between;gap:10px;margin-top:auto;}
|
||||
.onboarding-actions .sm-btn{padding:10px 16px;}
|
||||
.reconnect-banner{display:none;background:var(--surface);border:1px solid rgba(201,168,76,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--gold);display:none;align-items:center;justify-content:space-between;gap:12px;}
|
||||
.reconnect-banner.visible{display:flex;}
|
||||
.reconnect-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(201,168,76,0.15);border:1px solid rgba(201,168,76,0.4);color:var(--gold);cursor:pointer;}
|
||||
@@ -508,6 +546,12 @@
|
||||
.tool-card{margin-left:0!important;font-size:12px;}
|
||||
/* Settings modal */
|
||||
.settings-panel{width:95vw;max-width:95vw;min-height:min(580px,88vh);max-height:92vh;}
|
||||
.onboarding-overlay{padding:12px;}
|
||||
.onboarding-shell{grid-template-columns:1fr;}
|
||||
.onboarding-sidebar{border-right:none;border-bottom:1px solid var(--border);padding:22px 18px;}
|
||||
.onboarding-main{padding:20px 18px 18px;}
|
||||
.onboarding-actions{flex-direction:column-reverse;}
|
||||
.onboarding-actions .sm-btn{width:100%;min-height:44px;}
|
||||
/* Login page responsive */
|
||||
.card{width:90vw;max-width:320px;padding:28px 24px;}
|
||||
/* Workspace panel mobile close button */
|
||||
|
||||
244
tests/test_onboarding_mvp.py
Normal file
244
tests/test_onboarding_mvp.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""Onboarding MVP tests — first-run wizard and provider config persistence.
|
||||
|
||||
Tests that call /api/onboarding/setup require PyYAML in the test server's
|
||||
Python environment (the agent venv). They are skipped when hermes-agent is
|
||||
not installed, since the server falls back to system Python which typically
|
||||
lacks pyyaml.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
BASE = "http://127.0.0.1:8788"
|
||||
|
||||
# Check if pyyaml is available — onboarding setup tests need it on the server
|
||||
try:
|
||||
import yaml as _yaml
|
||||
_HAS_YAML = True
|
||||
except ImportError:
|
||||
_HAS_YAML = False
|
||||
_needs_yaml = pytest.mark.skipif(not _HAS_YAML, reason="PyYAML not installed — onboarding setup tests require it")
|
||||
|
||||
|
||||
def get(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
|
||||
|
||||
def post(path, body=None):
|
||||
req = urllib.request.Request(
|
||||
BASE + path,
|
||||
data=json.dumps(body or {}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read()), e.code
|
||||
|
||||
|
||||
def _server_hermes_home() -> pathlib.Path:
|
||||
"""Get the hermes home path the test server is actually using.
|
||||
|
||||
Using the server's own /api/onboarding/status response is more robust than
|
||||
reading TEST_STATE_DIR from conftest, which can get the wrong path when
|
||||
conftest is imported multiple times under different HERMES_HOME environments
|
||||
(api.config resets HERMES_HOME at module import time via init_profile_state).
|
||||
"""
|
||||
data, _ = get("/api/onboarding/status")
|
||||
env_path = data.get("system", {}).get("env_path", "")
|
||||
if env_path:
|
||||
return pathlib.Path(env_path).parent
|
||||
# Fallback
|
||||
hermes_home = pathlib.Path.home() / ".hermes"
|
||||
return hermes_home / "webui-mvp-test"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_hermes_config_files():
|
||||
hermes_home = _server_hermes_home()
|
||||
for rel in ("config.yaml", ".env"):
|
||||
(hermes_home / rel).unlink(missing_ok=True)
|
||||
yield
|
||||
for rel in ("config.yaml", ".env"):
|
||||
(hermes_home / rel).unlink(missing_ok=True)
|
||||
|
||||
|
||||
|
||||
def test_onboarding_status_defaults_incomplete():
|
||||
data, status = get("/api/onboarding/status")
|
||||
assert status == 200
|
||||
assert data["completed"] is False
|
||||
assert data["settings"]["password_enabled"] is False
|
||||
assert data["system"]["provider_configured"] is False
|
||||
assert data["system"]["chat_ready"] is False
|
||||
assert data["system"]["setup_state"] in {"needs_provider", "agent_unavailable"}
|
||||
assert "provider_note" in data["system"]
|
||||
assert isinstance(data["workspaces"]["items"], list)
|
||||
assert data["setup"]["providers"]
|
||||
|
||||
|
||||
@_needs_yaml
|
||||
def test_onboarding_setup_openrouter_writes_real_config_and_env():
|
||||
data, status = post(
|
||||
"/api/onboarding/setup",
|
||||
{
|
||||
"provider": "openrouter",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "sk-or-test",
|
||||
},
|
||||
)
|
||||
assert status == 200
|
||||
assert data["system"]["provider_configured"] is True
|
||||
assert data["system"]["provider_ready"] is True
|
||||
if data["system"]["imports_ok"] and data["system"]["hermes_found"]:
|
||||
assert data["system"]["chat_ready"] is True
|
||||
assert data["system"]["setup_state"] == "ready"
|
||||
else:
|
||||
assert data["system"]["chat_ready"] is False
|
||||
assert data["system"]["setup_state"] == "agent_unavailable"
|
||||
|
||||
cfg_text = (_server_hermes_home() / "config.yaml").read_text(encoding="utf-8")
|
||||
env_text = (_server_hermes_home() / ".env").read_text(encoding="utf-8")
|
||||
assert "provider: openrouter" in cfg_text
|
||||
assert "default: anthropic/claude-sonnet-4.6" in cfg_text
|
||||
assert "OPENROUTER_API_KEY=sk-or-test" in env_text
|
||||
|
||||
|
||||
@_needs_yaml
|
||||
def test_onboarding_setup_custom_endpoint_writes_runtime_files():
|
||||
data, status = post(
|
||||
"/api/onboarding/setup",
|
||||
{
|
||||
"provider": "custom",
|
||||
"model": "google/gemma-3-27b-it",
|
||||
"base_url": "http://localhost:4000/v1",
|
||||
"api_key": "sk-custom-test",
|
||||
},
|
||||
)
|
||||
assert status == 200
|
||||
assert data["system"]["provider_configured"] is True
|
||||
assert data["system"]["provider_ready"] is True
|
||||
if data["system"]["imports_ok"] and data["system"]["hermes_found"]:
|
||||
assert data["system"]["chat_ready"] is True
|
||||
assert data["system"]["setup_state"] == "ready"
|
||||
else:
|
||||
assert data["system"]["chat_ready"] is False
|
||||
assert data["system"]["setup_state"] == "agent_unavailable"
|
||||
assert data["system"]["current_provider"] == "custom"
|
||||
assert data["system"]["current_base_url"] == "http://localhost:4000/v1"
|
||||
|
||||
cfg_text = (_server_hermes_home() / "config.yaml").read_text(encoding="utf-8")
|
||||
env_text = (_server_hermes_home() / ".env").read_text(encoding="utf-8")
|
||||
assert "provider: custom" in cfg_text
|
||||
assert "default: google/gemma-3-27b-it" in cfg_text
|
||||
assert "base_url: http://localhost:4000/v1" in cfg_text
|
||||
assert "OPENAI_API_KEY=sk-custom-test" in env_text
|
||||
|
||||
|
||||
@_needs_yaml
|
||||
def test_onboarding_setup_detects_incomplete_saved_provider():
|
||||
status, code = post(
|
||||
"/api/onboarding/setup",
|
||||
{
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4.6",
|
||||
"api_key": "sk-ant-test",
|
||||
},
|
||||
)
|
||||
assert code == 200
|
||||
|
||||
(_server_hermes_home() / ".env").unlink(missing_ok=True)
|
||||
data, status_code = get("/api/onboarding/status")
|
||||
assert status_code == 200
|
||||
assert data["system"]["provider_configured"] is True
|
||||
assert data["system"]["provider_ready"] is False
|
||||
assert data["system"]["chat_ready"] is False
|
||||
assert data["system"]["setup_state"] in {"provider_incomplete", "agent_unavailable"}
|
||||
|
||||
|
||||
@_needs_yaml
|
||||
def test_onboarding_setup_rejects_missing_custom_base_url():
|
||||
data, status = post(
|
||||
"/api/onboarding/setup",
|
||||
{
|
||||
"provider": "custom",
|
||||
"model": "qwen2.5-coder",
|
||||
"api_key": "sk-test",
|
||||
},
|
||||
)
|
||||
assert status == 400
|
||||
assert "base_url is required" in data["error"]
|
||||
|
||||
|
||||
def test_onboarding_complete_persists_flag():
|
||||
data, status = post("/api/onboarding/complete", {})
|
||||
assert status == 200
|
||||
assert data["completed"] is True
|
||||
|
||||
settings = json.loads(
|
||||
(_server_hermes_home() / "settings.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert settings["onboarding_completed"] is True
|
||||
|
||||
data2, status2 = get("/api/onboarding/status")
|
||||
assert status2 == 200
|
||||
assert data2["completed"] is True
|
||||
|
||||
|
||||
def test_onboarding_complete_preserves_other_settings():
|
||||
"""Completing onboarding must not overwrite other user settings."""
|
||||
# Use send_key (a safe enum setting) to verify settings preservation
|
||||
# without contaminating bot_name or theme checks in other test files.
|
||||
# Use GET /api/settings (not onboarding status) to check preservation
|
||||
# since the onboarding status only returns a subset of settings fields.
|
||||
try:
|
||||
saved, s1 = post("/api/settings", {"send_key": "ctrl+enter"})
|
||||
assert s1 == 200
|
||||
assert saved["send_key"] == "ctrl+enter"
|
||||
|
||||
_, s2 = post("/api/onboarding/complete", {})
|
||||
assert s2 == 200
|
||||
|
||||
# Verify the non-onboarding setting survived the completion call
|
||||
current_settings, s3 = get("/api/settings")
|
||||
assert s3 == 200
|
||||
assert current_settings["send_key"] == "ctrl+enter"
|
||||
finally:
|
||||
# Always restore default send_key to avoid contaminating other tests
|
||||
post("/api/settings", {"send_key": "enter"})
|
||||
|
||||
def test_onboarding_already_completed_status():
|
||||
"""After marking onboarding complete, status must reflect completed=True
|
||||
so the wizard does not re-appear for returning users."""
|
||||
done, status = post("/api/onboarding/complete", {})
|
||||
assert status == 200
|
||||
assert done["completed"] is True
|
||||
|
||||
data, status2 = get("/api/onboarding/status")
|
||||
assert status2 == 200
|
||||
assert data["completed"] is True
|
||||
|
||||
# Reset so test doesn't contaminate others
|
||||
post("/api/settings", {"onboarding_completed": False})
|
||||
|
||||
|
||||
@_needs_yaml
|
||||
def test_onboarding_setup_rejects_api_key_with_newline():
|
||||
"""API keys containing embedded newlines must be rejected to prevent .env injection."""
|
||||
injected_key = "sk-bad" + chr(10) + "OTHER_KEY=injected"
|
||||
data, status = post(
|
||||
"/api/onboarding/setup",
|
||||
{
|
||||
"provider": "openrouter",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": injected_key,
|
||||
},
|
||||
)
|
||||
assert status == 400
|
||||
assert "newline" in data["error"].lower()
|
||||
58
tests/test_onboarding_static.py
Normal file
58
tests/test_onboarding_static.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import pathlib
|
||||
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
|
||||
|
||||
def read(path):
|
||||
return (REPO / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_index_contains_onboarding_overlay_markup():
|
||||
html = read("static/index.html")
|
||||
assert 'id="onboardingOverlay"' in html
|
||||
assert 'id="onboardingBody"' in html
|
||||
assert 'id="onboardingNextBtn"' in html
|
||||
assert 'src="/static/onboarding.js"' in html
|
||||
|
||||
|
||||
def test_onboarding_css_rules_exist():
|
||||
css = read("static/style.css")
|
||||
for selector in (
|
||||
".onboarding-overlay",
|
||||
".onboarding-card",
|
||||
".onboarding-step",
|
||||
".onboarding-status.warn",
|
||||
):
|
||||
assert selector in css
|
||||
|
||||
|
||||
def test_onboarding_js_exposes_bootstrap_hooks():
|
||||
js = read("static/onboarding.js")
|
||||
assert "async function loadOnboardingWizard()" in js
|
||||
assert "async function nextOnboardingStep()" in js
|
||||
assert "api('/api/onboarding/status')" in js
|
||||
assert "api('/api/onboarding/setup'" in js
|
||||
assert "api('/api/onboarding/complete'" in js
|
||||
|
||||
|
||||
def test_onboarding_uses_i18n_helpers():
|
||||
html = read("static/index.html")
|
||||
js = read("static/onboarding.js")
|
||||
i18n = read("static/i18n.js")
|
||||
assert 'data-i18n="onboarding_title"' in html
|
||||
assert 'data-i18n="onboarding_continue"' in html
|
||||
assert "t('onboarding_step_system_title')" in js
|
||||
assert "t('onboarding_step_setup_title')" in js
|
||||
assert "t('onboarding_complete')" in js
|
||||
assert "onboarding_title: 'Welcome to Hermes Web UI'" in i18n
|
||||
assert "onboarding_title: 'Bienvenido a Hermes Web UI'" in i18n
|
||||
|
||||
|
||||
def test_bootstrap_script_contains_official_installer_and_windows_guard():
|
||||
src = read("bootstrap.py")
|
||||
assert (
|
||||
"https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh"
|
||||
in src
|
||||
)
|
||||
assert "Native Windows is not supported" in src
|
||||
@@ -286,7 +286,11 @@ def test_server_delete_invalidates_index(cleanup_test_sessions):
|
||||
routes_src = (REPO_ROOT / "api" / "routes.py").read_text() if (REPO_ROOT / "api" / "routes.py").exists() else ""
|
||||
# Find the delete handler in either file
|
||||
for label, text in [("server.py", src), ("api/routes.py", routes_src)]:
|
||||
delete_idx = text.find("if parsed.path == '/api/session/delete':")
|
||||
# Accept both single-quote and double-quote style (formatting varies by contributor)
|
||||
delete_idx = max(
|
||||
text.find("if parsed.path == '/api/session/delete':"),
|
||||
text.find('if parsed.path == "/api/session/delete":'),
|
||||
)
|
||||
if delete_idx >= 0:
|
||||
delete_block = text[delete_idx:delete_idx+600]
|
||||
assert "SESSION_INDEX_FILE" in delete_block, \
|
||||
|
||||
Reference in New Issue
Block a user