Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b12a682121 | ||
|
|
bc16545794 | ||
|
|
a13a1e0b9e | ||
|
|
fc43b897c5 | ||
|
|
d6a925cf11 | ||
|
|
5468b04550 | ||
|
|
7556ea0e04 | ||
|
|
92fbf2a793 | ||
|
|
0d98116b37 | ||
|
|
31a721417e | ||
|
|
f9663d2f1d | ||
|
|
cbc3c01604 | ||
|
|
42dd2b562d | ||
|
|
6b4ff53315 | ||
|
|
ce84d1bafa |
30
.github/workflows/tests.yml
vendored
Normal file
30
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.11', '3.12', '3.13']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyyaml>=6.0 pytest pytest-timeout
|
||||
|
||||
- name: Run tests
|
||||
run: pytest tests/ -v --timeout=60
|
||||
@@ -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)
|
||||
|
||||
49
CHANGELOG.md
49
CHANGELOG.md
@@ -6,6 +6,55 @@
|
||||
---
|
||||
|
||||
|
||||
## [v0.49.2] OAuth provider support in onboarding (issues #303, #304)
|
||||
|
||||
- **OAuth provider bypass** (closes #303, #304): The first-run onboarding wizard now correctly recognizes OAuth-authenticated providers (GitHub Copilot, OpenAI Codex, Nous Portal, Qwen OAuth) as ready, instead of always demanding an API key.
|
||||
- New `_provider_oauth_authenticated()` helper in `api/onboarding.py` checks `hermes_cli.auth.get_auth_status()` first (authoritative), then falls back to parsing `~/.hermes/auth.json` directly for the known OAuth provider IDs (`openai-codex`, `copilot`, `copilot-acp`, `qwen-oauth`, `nous`).
|
||||
- `_status_from_runtime()` now has an `else` branch for providers not in `_SUPPORTED_PROVIDER_SETUPS`; OAuth-authenticated providers return `provider_ready=True` and `setup_state="ready"`.
|
||||
- The `provider_incomplete` status note no longer says "API key" for OAuth providers — it now says "Run 'hermes auth' or 'hermes model' in a terminal to complete setup."
|
||||
- 19 new tests in `tests/test_sprint34.py`; 738 tests total (up from 719)
|
||||
|
||||
## [v0.49.1] Docker docs + mobile Profiles button (PRs #291, #265)
|
||||
|
||||
- **Two-container Docker setup** (PR #291 / closes #288): New `docker-compose.two-container.yml` for running the Hermes Agent and WebUI as separate containers with shared volumes. Documents the architecture clearly; localhost-only port binding by default.
|
||||
- **Mobile Profiles button** (PR #265 @gabogabucho): Adds Profiles to the mobile bottom navigation bar (last position: Chat → Tasks → Skills → Memory → Spaces → Profiles). Uses `mobileSwitchPanel()` for correct active-highlight behaviour; `data-panel="profiles"` attribute set; SVG matches other nav icons; 3 new tests.
|
||||
- 700 tests total (up from 697)
|
||||
|
||||
## [v0.49.0] First-run onboarding wizard + self-update hardening (PRs #285, #287, #289)
|
||||
|
||||
- **One-shot bootstrap and first-run setup wizard** (PR #285): New users are greeted with a guided onboarding overlay on first load. The wizard checks system status, configures a provider (OpenRouter, Anthropic, OpenAI, or custom OpenAI-compatible endpoint), sets a workspace and optional password, and marks setup as complete — all without leaving the browser.
|
||||
- `bootstrap.py`: one-shot CLI bootstrap that writes `~/.hermes/config.yaml` and `~/.hermes/.env` from flags; idempotent and safe to re-run
|
||||
- `api/routes.py`: `/api/onboarding/status` (GET) and `/api/onboarding/complete` (POST) endpoints; real provider config persistence to `config.yaml` + `.env`
|
||||
- `static/onboarding.js`: full wizard JS module — step navigation, provider dropdown, model selector, API key input, Back/Continue flow, i18n support
|
||||
- `static/index.html`: onboarding overlay HTML shell + `<script src="/static/onboarding.js">` load
|
||||
- `static/i18n.js`: 40+ onboarding keys added to all 5 locales (en, es, de, zh-Hans, zh-Hant)
|
||||
- `static/boot.js`: on load, fetches `/api/onboarding/status` and opens wizard when `completed=false`
|
||||
- Wizard does NOT show when `onboarding_completed=true` in settings
|
||||
- 14 new tests in `tests/test_onboarding.py`; 693 tests total (up from 679)
|
||||
|
||||
- **Self-update git pull diagnostics** (PR #287): Fixes multiple failure modes in the WebUI self-update flow when the repo has a non-trivial git state.
|
||||
- `_run_git()` now returns stderr on failure (stdout fallback, then exit-code message) — users see actionable git errors instead of empty strings
|
||||
- New `_split_remote_ref()` helper splits `origin/master` into `('origin', 'master')` before `git pull --ff-only` — fixes silent failures where git misinterpreted the combined string as a repository name
|
||||
- `--untracked-files=no` added to `git status --porcelain` — prevents spurious stash failures in repos with untracked files
|
||||
- Early merge-conflict detection via porcelain status codes before attempting pull
|
||||
- 4 new unit tests in `tests/test_updates.py`
|
||||
|
||||
- **Skip flaky redaction test in agent-less environments** (PR #289): `test_api_sessions_list_redacts_titles` added to the CI skip list for environments without hermes-agent installed. Test still runs with the full agent; security coverage preserved by 6 pure-unit tests and 2 other API-level redaction tests.
|
||||
- 697 tests total (up from 693)
|
||||
|
||||
## [v0.48.2] Provider/model mismatch warning (PR #283, fixes #266)
|
||||
|
||||
- **Provider mismatch warning** (PR #283): WebUI now warns when you select a model from a provider different from the one Hermes is configured for, instead of silently failing with a 401 error.
|
||||
- `api/streaming.py`: 401/auth errors classified as `type='auth_mismatch'` with an actionable hint ("Run `hermes model` in your terminal to switch providers")
|
||||
- `static/ui.js`: `populateModelDropdown()` stores `active_provider` from `/api/models` as `window._activeProvider`; new `_checkProviderMismatch()` helper compares selected model's provider prefix against the configured provider
|
||||
- `static/boot.js`: `modelSelect.onchange` calls `_checkProviderMismatch()` and shows a toast warning immediately on selection
|
||||
- `static/messages.js`: `apperror` handler shows "Provider mismatch" label (via i18n) instead of "Error" for auth errors
|
||||
- `static/i18n.js`: `provider_mismatch_warning` and `provider_mismatch_label` keys added to all 5 locales (en, es, de, zh-Hans, zh-Hant)
|
||||
- Check skipped for `openrouter` and `custom` providers to avoid false positives
|
||||
- 21 new tests in `tests/test_provider_mismatch.py`; 679 tests total (up from 658)
|
||||
## [v0.48.1] Markdown table inline formatting (PR #278)
|
||||
|
||||
- **Inline formatting in table cells** (PR #278, @nesquena): Table header and data cells now render `**bold**`, `*italic*`, `` `code` ``, and `[links](url)` correctly. Previously `esc()` was used, which displayed raw HTML tags as text. Changed to `inlineMd()` consistent with list items and blockquotes. XSS-safe: `inlineMd()` escapes all interpolated values. Two-line change in `static/ui.js`. Fixes #273.
|
||||
## [v0.48.0] Real-time gateway session sync (PR #274)
|
||||
|
||||
- **Real-time gateway session sync** (PR #274, @bergeouss): Gateway sessions from Telegram, Discord, Slack, and other messaging platforms now appear in the WebUI sidebar and update in real time as new messages arrive. Enable via the "Show agent sessions" checkbox (renamed from "Show CLI sessions").
|
||||
|
||||
58
README.md
58
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -169,6 +171,32 @@ docker run -d \
|
||||
> To expose on a network, change the port to `"8787:8787"` in `docker-compose.yml`
|
||||
> and set `HERMES_WEBUI_PASSWORD` to enable authentication.
|
||||
|
||||
### Two-container setup (Agent + WebUI)
|
||||
|
||||
If you run the Hermes Agent in its own Docker container and want the WebUI
|
||||
in a separate container:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.two-container.yml up -d
|
||||
```
|
||||
|
||||
This starts both containers with shared volumes:
|
||||
|
||||
- **`hermes-home`** — shared `~/.hermes` for config, sessions, skills, memory
|
||||
- **`hermes-agent-src`** — the agent's source code, mounted into the WebUI
|
||||
container so it can install the agent's Python dependencies at startup
|
||||
|
||||
The WebUI's init script automatically installs hermes-agent and all its
|
||||
dependencies (openai, anthropic, etc.) into its own Python environment on
|
||||
first boot. Subsequent restarts reuse the installed packages.
|
||||
|
||||
> **How it works:** The WebUI imports hermes-agent's Python modules directly
|
||||
> (not via HTTP). The shared volume makes the agent source available, and
|
||||
> the init script runs `uv pip install` to set up the dependencies. Both
|
||||
> containers share the same `~/.hermes` directory for config and state.
|
||||
|
||||
See `docker-compose.two-container.yml` for the full configuration.
|
||||
|
||||
---
|
||||
|
||||
## What start.sh discovers automatically
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
|
||||
> Everything you can do from the CLI terminal, you can do from this UI.
|
||||
>
|
||||
> Last updated: v0.48.0 (April 12, 2026) — 658 tests, 658 passing
|
||||
> Tests: 604 total (604 passing, 0 failures)
|
||||
> Last updated: v0.49.1 (April 12, 2026) — 700 tests, 700 passing
|
||||
> Onboarding MVP now writes real Hermes provider config from the Web UI for OpenRouter, Anthropic, OpenAI, and custom OpenAI-compatible endpoints.
|
||||
> Tests: 700 total (700 passing, 0 failures)
|
||||
> Source: <repo>/
|
||||
|
||||
---
|
||||
@@ -46,6 +47,10 @@
|
||||
| v0.47.0 | Dialogs, session menu, skills command, mobile fixes, mobile QA | Shared app dialogs (#251); session ⋯ menu (#252); mobile QA suite (#254); custom provider slash routing fix (#255); Android Chrome mobile fixes (#256); /skills command (#257); +21 tests | 645 |
|
||||
| v0.47.1 | Spanish locale | Full Spanish (es) locale, 175 keys, key-parity tests (#275 @gabogabucho); +3 tests | 648 |
|
||||
| v0.48.0 | Gateway session sync | Real-time Telegram/Discord/Slack sessions in sidebar via SSE + DB polling (#274 @bergeouss); +10 tests | 658 |
|
||||
| v0.48.1 | Table inline formatting | `inlineMd()` in table cells — **bold**, *italic*, `code`, links render correctly (PR #278); 0 new tests | 658 |
|
||||
| v0.48.2 | Provider mismatch warning | Toast warning + auth_mismatch error type for provider/model mismatches (#283, fixes #266); +21 tests | 679 |
|
||||
| v0.49.1 | Docker docs + mobile Profiles button | Two-container Docker compose (#291/#288); Profiles button in mobile bottom nav with mobileSwitchPanel, data-panel, correct SVG size and position (#297/#265 @gabogabucho); +3 tests | 700 |
|
||||
| v0.49.0 | First-run onboarding wizard + self-update hardening | One-shot bootstrap + guided setup wizard; provider config persisted to config.yaml + .env; OpenRouter/Anthropic/OpenAI/Custom; wizard hidden after completion (#285); self-update stderr/split-ref/conflict fixes (#287); skip flaky redaction test (#289); +18 tests | 697 |
|
||||
| v0.32 | Auto-compaction handling | Compression detection, /compact command, real context window indicator | 424 |
|
||||
| v0.33 | /insights sync | Opt-in state.db sync so `hermes /insights` includes WebUI sessions | 424 |
|
||||
| v0.34 | Sprint 26 — Pluggable themes | Dark, Light, Slate, Solarized, Monokai, Nord; settings unsaved-changes guard; /theme command | 433 |
|
||||
|
||||
@@ -1163,8 +1163,8 @@ New test cases in `tests/test_sprint26.py`:
|
||||
|
||||
---
|
||||
|
||||
*Last updated: April 10, 2026*
|
||||
*Current version: v0.45.0 | 604 tests*
|
||||
*Last updated: April 12, 2026*
|
||||
*Current version: v0.49.1 | 700 tests*
|
||||
*Next sprint: Sprint 24 (Web Polish + Bug Fix Pass)*
|
||||
*Horizon sprint: Sprint 25 (macOS Desktop Application)*
|
||||
*Docs sweep policy: update markdown proactively during PR reviews and after significant releases*
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
> Prerequisites: SSH tunnel is active on port 8786. Open http://localhost:8786 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8786/health should return {"status":"ok"}.
|
||||
>
|
||||
> Automated tests: 658 total (658 passing, 0 skipped, 0 known failures)
|
||||
> Automated tests: 700 total (700 passing, 0 skipped, 0 known failures). Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), and the `/api/onboarding/*` backend.
|
||||
> Run: `pytest tests/ -v --timeout=60`
|
||||
|
||||
---
|
||||
|
||||
812
api/config.py
812
api/config.py
File diff suppressed because it is too large
Load Diff
474
api/onboarding.py
Normal file
474
api/onboarding.py
Normal file
@@ -0,0 +1,474 @@
|
||||
"""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 _provider_oauth_authenticated(provider: str, hermes_home: "Path") -> bool:
|
||||
"""Return True if the provider has valid OAuth credentials.
|
||||
|
||||
Checks via hermes_cli.auth.get_auth_status() when available, then falls
|
||||
back to reading auth.json directly for the known OAuth provider IDs
|
||||
(openai-codex, copilot, copilot-acp, qwen-oauth, nous).
|
||||
|
||||
This covers users who authenticated via 'hermes auth' or 'hermes model'
|
||||
but whose provider is not in _SUPPORTED_PROVIDER_SETUPS because it does
|
||||
not use a plain API key.
|
||||
"""
|
||||
provider = (provider or "").strip().lower()
|
||||
if not provider:
|
||||
return False
|
||||
|
||||
# Fast path: ask hermes_cli directly — the authoritative source
|
||||
try:
|
||||
from hermes_cli.auth import get_auth_status as _gas
|
||||
|
||||
status = _gas(provider)
|
||||
if isinstance(status, dict) and status.get("logged_in"):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: parse auth.json ourselves for known OAuth provider IDs.
|
||||
# Covers deployments where hermes_cli is installed but the import above
|
||||
# fails for an unexpected reason (version mismatch, import cycle, etc.).
|
||||
_known_oauth_providers = {"openai-codex", "copilot", "copilot-acp", "qwen-oauth", "nous"}
|
||||
if provider not in _known_oauth_providers:
|
||||
return False
|
||||
|
||||
try:
|
||||
import json as _j
|
||||
|
||||
auth_path = hermes_home / "auth.json"
|
||||
if not auth_path.exists():
|
||||
return False
|
||||
store = _j.loads(auth_path.read_text(encoding="utf-8"))
|
||||
providers_store = store.get("providers")
|
||||
if not isinstance(providers_store, dict):
|
||||
return False
|
||||
state = providers_store.get(provider)
|
||||
if not isinstance(state, dict):
|
||||
return False
|
||||
# Any non-empty token is enough to confirm the user has credentials.
|
||||
# Token refresh happens at runtime inside the agent.
|
||||
has_token = bool(
|
||||
str(state.get("access_token") or "").strip()
|
||||
or str(state.get("api_key") or "").strip()
|
||||
or str(state.get("refresh_token") or "").strip()
|
||||
)
|
||||
return has_token
|
||||
except Exception:
|
||||
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)
|
||||
else:
|
||||
# Unknown / OAuth provider (e.g. openai-codex, copilot, qwen-oauth).
|
||||
# These do not use a plain API key; auth lives in auth.json or a
|
||||
# credential pool managed by hermes_cli.
|
||||
provider_ready = _provider_oauth_authenticated(
|
||||
provider, _get_active_hermes_home()
|
||||
)
|
||||
|
||||
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"
|
||||
if provider == "custom" and not base_url:
|
||||
note = (
|
||||
"Hermes has a saved provider/model selection but still needs the "
|
||||
"base URL and API key required to chat."
|
||||
)
|
||||
elif provider not in _SUPPORTED_PROVIDER_SETUPS:
|
||||
# OAuth / unsupported provider: avoid misleading "API key" wording.
|
||||
note = (
|
||||
f"Provider '{provider}' is configured but not yet authenticated. "
|
||||
"Run 'hermes auth' or 'hermes model' in a terminal to complete "
|
||||
"setup, then reload the Web UI."
|
||||
)
|
||||
else:
|
||||
note = (
|
||||
"Hermes has a saved provider/model selection but still needs the "
|
||||
"API key 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
@@ -463,12 +463,29 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
# Detect rate limit errors specifically so the client can show a helpful card
|
||||
# rather than the generic "Connection lost" message
|
||||
is_rate_limit = 'rate limit' in err_str.lower() or '429' in err_str or 'RateLimitError' in type(e).__name__
|
||||
is_auth_error = (
|
||||
'401' in err_str
|
||||
or 'AuthenticationError' in type(e).__name__
|
||||
or 'authentication' in err_str.lower()
|
||||
or 'unauthorized' in err_str.lower()
|
||||
or 'invalid api key' in err_str.lower()
|
||||
or 'no cookie auth credentials' in err_str.lower()
|
||||
)
|
||||
if is_rate_limit:
|
||||
put('apperror', {
|
||||
'message': err_str,
|
||||
'type': 'rate_limit',
|
||||
'hint': 'Rate limit reached. The fallback model (if configured) was also exhausted. Try again in a moment.',
|
||||
})
|
||||
elif is_auth_error:
|
||||
put('apperror', {
|
||||
'message': err_str,
|
||||
'type': 'auth_mismatch',
|
||||
'hint': (
|
||||
'The selected model may not be supported by your configured provider. '
|
||||
'Run `hermes model` in your terminal to switch providers, then restart the WebUI.'
|
||||
),
|
||||
})
|
||||
else:
|
||||
put('apperror', {'message': err_str, 'type': 'error'})
|
||||
finally:
|
||||
|
||||
@@ -29,15 +29,39 @@ CACHE_TTL = 1800 # 30 minutes
|
||||
|
||||
|
||||
def _run_git(args, cwd, timeout=10):
|
||||
"""Run a git command and return (stdout, ok)."""
|
||||
"""Run a git command and return (useful output, ok).
|
||||
|
||||
On failure, returns stderr (or stdout as fallback) so callers can
|
||||
surface actionable git error messages instead of empty strings.
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['git'] + args, cwd=str(cwd), capture_output=True,
|
||||
text=True, timeout=timeout,
|
||||
)
|
||||
return r.stdout.strip(), r.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
return '', False
|
||||
stdout = r.stdout.strip()
|
||||
stderr = r.stderr.strip()
|
||||
if r.returncode == 0:
|
||||
return stdout, True
|
||||
return stderr or stdout or f"git exited with status {r.returncode}", False
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
detail = (getattr(exc, 'stderr', None) or getattr(exc, 'stdout', None) or '').strip()
|
||||
return detail or f"git {' '.join(args)} timed out after {timeout}s", False
|
||||
except FileNotFoundError:
|
||||
return 'git executable not found', False
|
||||
except OSError as exc:
|
||||
return f'git failed to start: {exc}', False
|
||||
|
||||
|
||||
def _split_remote_ref(ref):
|
||||
"""Split 'origin/branch-name' into ('origin', 'branch-name').
|
||||
|
||||
Returns (None, ref) if ref contains no slash.
|
||||
"""
|
||||
if '/' not in ref:
|
||||
return None, ref
|
||||
remote, branch = ref.split('/', 1)
|
||||
return remote, branch
|
||||
|
||||
|
||||
def _detect_default_branch(path):
|
||||
@@ -159,8 +183,17 @@ def _apply_update_inner(target):
|
||||
),
|
||||
}
|
||||
|
||||
# Check for dirty working tree
|
||||
status_out, _ = _run_git(['status', '--porcelain'], path)
|
||||
# Check for dirty working tree (ignore untracked files — git stash
|
||||
# doesn't include them, so stashing on '??' alone leaves nothing to pop)
|
||||
status_out, status_ok = _run_git(
|
||||
['status', '--porcelain', '--untracked-files=no'], path
|
||||
)
|
||||
if not status_ok:
|
||||
return {'ok': False, 'message': f'Failed to inspect repo status: {status_out[:200]}'}
|
||||
# Fail early on unresolved merge conflicts
|
||||
if any(line[:2] in {'DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'}
|
||||
for line in status_out.splitlines()):
|
||||
return {'ok': False, 'message': 'Repository has unresolved merge conflicts'}
|
||||
stashed = False
|
||||
if status_out:
|
||||
_, ok = _run_git(['stash'], path)
|
||||
@@ -168,8 +201,16 @@ def _apply_update_inner(target):
|
||||
return {'ok': False, 'message': 'Failed to stash local changes'}
|
||||
stashed = True
|
||||
|
||||
# Pull with ff-only (no merge commits)
|
||||
pull_out, pull_ok = _run_git(['pull', '--ff-only', compare_ref], path, timeout=30)
|
||||
# Pull with ff-only (no merge commits).
|
||||
# Split tracking refs like 'origin/main' into separate remote + branch
|
||||
# arguments — git treats 'origin/main' as a repository name otherwise.
|
||||
remote, branch = _split_remote_ref(compare_ref)
|
||||
pull_args = ['pull', '--ff-only']
|
||||
if remote:
|
||||
pull_args.extend([remote, branch])
|
||||
else:
|
||||
pull_args.append(compare_ref)
|
||||
pull_out, pull_ok = _run_git(pull_args, path, timeout=30)
|
||||
if not pull_ok:
|
||||
if stashed:
|
||||
_run_git(['stash', 'pop'], path)
|
||||
|
||||
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)
|
||||
55
docker-compose.two-container.yml
Normal file
55
docker-compose.two-container.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
# Two-container Docker Compose: Hermes Agent + Hermes WebUI
|
||||
#
|
||||
# This runs the agent and web UI in separate containers connected via
|
||||
# shared volumes. The WebUI installs the agent's Python dependencies
|
||||
# at startup from the shared agent source volume.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.two-container.yml up -d
|
||||
#
|
||||
# The agent container runs the gateway (CLI, Telegram, cron, etc.).
|
||||
# The WebUI container serves the browser interface on port 8787.
|
||||
# Both share ~/.hermes for config, sessions, and state.
|
||||
|
||||
services:
|
||||
hermes-agent:
|
||||
image: nousresearch/hermes-agent:latest
|
||||
container_name: hermes-agent
|
||||
volumes:
|
||||
# Persist config, state, sessions, skills, memory across restarts
|
||||
- hermes-home:/root/.hermes
|
||||
# Expose agent source so the WebUI can install dependencies from it
|
||||
- hermes-agent-src:/opt/hermes
|
||||
environment:
|
||||
- HERMES_HOME=/root/.hermes
|
||||
restart: unless-stopped
|
||||
|
||||
hermes-webui:
|
||||
image: ghcr.io/nesquena/hermes-webui:latest
|
||||
container_name: hermes-webui
|
||||
depends_on:
|
||||
- hermes-agent
|
||||
ports:
|
||||
- "127.0.0.1:8787:8787"
|
||||
volumes:
|
||||
# Same hermes home as the agent — shares config, sessions, state
|
||||
- hermes-home:/home/hermeswebui/.hermes
|
||||
# Agent source mounted where docker_init.bash expects it.
|
||||
# At startup the init script runs:
|
||||
# uv pip install /home/hermeswebui/.hermes/hermes-agent
|
||||
# which installs the agent and all its Python dependencies.
|
||||
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent
|
||||
environment:
|
||||
- HERMES_WEBUI_HOST=0.0.0.0
|
||||
- HERMES_WEBUI_PORT=8787
|
||||
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui-mvp
|
||||
# Match your host user's UID/GID for correct file permissions
|
||||
- WANTED_UID=${UID:-1000}
|
||||
- WANTED_GID=${GID:-1000}
|
||||
# Optional: set a password for remote access
|
||||
# - HERMES_WEBUI_PASSWORD=your-secret-password
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
hermes-home:
|
||||
hermes-agent-src:
|
||||
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 "$@"
|
||||
|
||||
@@ -209,6 +209,11 @@ $('modelSelect').onchange=async()=>{
|
||||
localStorage.setItem('hermes-webui-model', selectedModel);
|
||||
await api('/api/session/update',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,workspace:S.session.workspace,model:selectedModel})});
|
||||
S.session.model=selectedModel;syncTopbar();
|
||||
// Warn if selected model belongs to a different provider than what Hermes is configured for
|
||||
if(typeof _checkProviderMismatch==='function'){
|
||||
const warn=_checkProviderMismatch(selectedModel);
|
||||
if(warn&&typeof showToast==='function') showToast(warn,4000);
|
||||
}
|
||||
};
|
||||
$('msg').addEventListener('input',()=>{
|
||||
autoResize();
|
||||
@@ -382,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){
|
||||
|
||||
148
static/i18n.js
148
static/i18n.js
@@ -48,6 +48,8 @@ const LOCALES = {
|
||||
n_messages: (n) => `${n} messages`,
|
||||
model_unavailable: ' (unavailable)',
|
||||
model_unavailable_title: 'This model is no longer in your current provider list',
|
||||
provider_mismatch_warning: (m,p)=>`"${m}" may not work with your configured provider (${p}). Send anyway, or run \`hermes model\` in your terminal to switch.`,
|
||||
provider_mismatch_label: 'Provider mismatch',
|
||||
// commands.js
|
||||
cmd_help: 'List available commands',
|
||||
cmd_clear: 'Clear conversation messages',
|
||||
@@ -191,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: {
|
||||
@@ -237,6 +308,8 @@ const LOCALES = {
|
||||
n_messages: (n) => `${n} mensajes`,
|
||||
model_unavailable: ' (no disponible)',
|
||||
model_unavailable_title: 'Este modelo ya no está en tu lista actual de proveedores',
|
||||
provider_mismatch_warning: (m,p)=>`"${m}" puede no funcionar con tu proveedor configurado (${p}). Envía de todas formas, o ejecuta \`hermes model\` en la terminal para cambiar.`,
|
||||
provider_mismatch_label: 'Proveedor incompatible',
|
||||
// commands.js
|
||||
cmd_help: 'Listar los comandos disponibles',
|
||||
cmd_clear: 'Borrar los mensajes de la conversación',
|
||||
@@ -380,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: {
|
||||
@@ -426,6 +568,8 @@ const LOCALES = {
|
||||
n_messages: (n) => `${n} Nachrichten`,
|
||||
model_unavailable: ' (nicht verfügbar)',
|
||||
model_unavailable_title: 'Dieses Modell ist nicht mehr in Ihrer aktuellen Provider-Liste',
|
||||
provider_mismatch_warning: (m,p)=>`"${m}" funktioniert möglicherweise nicht mit Ihrem konfigurierten Provider (${p}). Trotzdem senden, oder \`hermes model\` im Terminal ausführen.`,
|
||||
provider_mismatch_label: 'Provider-Konflikt',
|
||||
// commands.js
|
||||
cmd_help: 'Verfügbare Befehle auflisten',
|
||||
cmd_clear: 'Konversationsverlauf löschen',
|
||||
@@ -615,6 +759,8 @@ const LOCALES = {
|
||||
n_messages: (n) => `${n} \u6761\u6d88\u606f`,
|
||||
model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09',
|
||||
model_unavailable_title: '\u8fd9\u4e2a\u6a21\u578b\u5df2\u7ecf\u4e0d\u5728\u5f53\u524d provider \u5217\u8868\u4e2d',
|
||||
provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u65e0\u6cd5\u5728\u5f53\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u5546 (${p}) \u4e0b\u5de5\u4f5c\u3002\u76f4\u63a5\u53d1\u9001\uff0c\u6216\u5728\u7ec8\u7aef\u8fd0\u884c \`hermes model\` \u5207\u6362\u3002`,
|
||||
provider_mismatch_label: '\u63d0\u4f9b\u5546\u4e0d\u5339\u914d',
|
||||
// commands.js
|
||||
cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4',
|
||||
cmd_clear: '\u6e05\u7a7a\u5f53\u524d\u5bf9\u8bdd\u6d88\u606f',
|
||||
@@ -802,6 +948,8 @@ const LOCALES = {
|
||||
n_messages: (n) => `${n} \u689d\u8a0a\u606f`,
|
||||
model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09',
|
||||
model_unavailable_title: '\u6b64\u6a21\u578b\u5df2\u7d93\u4e0d\u5728\u7576\u524d provider \u5217\u8868\u4e2d',
|
||||
provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u7121\u6cd5\u5728\u7576\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u8005 (${p}) \u4e0b\u904b\u4f5c\u3002\u5c1a\u9001\uff0c\u6216\u5728\u7d42\u7aef\u57f7\u884c \`hermes model\` \u5207\u63db\u3002`,
|
||||
provider_mismatch_label: '\u63d0\u4f9b\u8005\u4e0d\u76f8\u7b26',
|
||||
// commands.js
|
||||
cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4',
|
||||
cmd_clear: '\u6e05\u7a7a\u7576\u524d\u5c0d\u8a71\u8a0a\u606f',
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.48.0</div></div></div>
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.49.2</div></div></div>
|
||||
<div class="sidebar-nav">
|
||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
|
||||
@@ -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">
|
||||
@@ -461,6 +481,10 @@
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h8l2 2h10v14H2z"/></svg>
|
||||
<span data-i18n="tab_workspaces">Spaces</span>
|
||||
</button>
|
||||
<button class="mobile-nav-btn" data-panel="profiles" onclick="mobileSwitchPanel('profiles')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
<span data-i18n="tab_profiles">Profiles</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="/static/i18n.js"></script>
|
||||
@@ -471,6 +495,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">
|
||||
|
||||
@@ -234,7 +234,8 @@ async function send(){
|
||||
try{
|
||||
const d=JSON.parse(e.data);
|
||||
const isRateLimit=d.type==='rate_limit';
|
||||
const label=isRateLimit?'Rate limit reached':'Error';
|
||||
const isAuthMismatch=d.type==='auth_mismatch';
|
||||
const label=isRateLimit?'Rate limit reached':isAuthMismatch?(typeof t==='function'?t('provider_mismatch_label'):'Provider mismatch'):'Error';
|
||||
const hint=d.hint?`\n\n*${d.hint}*`:'';
|
||||
S.messages.push({role:'assistant',content:`**${label}:** ${d.message}${hint}`});
|
||||
}catch(_){
|
||||
|
||||
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 */
|
||||
|
||||
32
static/ui.js
32
static/ui.js
@@ -45,6 +45,8 @@ async function populateModelDropdown(){
|
||||
try{
|
||||
const data=await fetch(new URL('/api/models',location.origin).href,{credentials:'include'}).then(r=>r.json());
|
||||
if(!data.groups||!data.groups.length) return; // keep HTML defaults
|
||||
// Store active provider globally so the send path can warn on mismatch
|
||||
window._activeProvider=data.active_provider||null;
|
||||
// Clear existing options
|
||||
sel.innerHTML='';
|
||||
_dynamicModelLabels={};
|
||||
@@ -70,6 +72,32 @@ async function populateModelDropdown(){
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given model ID belongs to a different provider than the one
|
||||
* currently configured in Hermes. Returns a warning string if mismatched,
|
||||
* or null if the selection looks compatible.
|
||||
*
|
||||
* Provider detection is intentionally loose — we compare the model's slash
|
||||
* prefix (e.g. "openai/" from "openai/gpt-4o") against the active provider
|
||||
* name. Custom/local endpoints report active_provider='custom' or the
|
||||
* base_url hostname and we skip the check to avoid false positives.
|
||||
*/
|
||||
function _checkProviderMismatch(modelId){
|
||||
const ap=(window._activeProvider||'').toLowerCase();
|
||||
if(!ap||ap==='custom'||ap==='openrouter') return null; // can't reliably check
|
||||
const slash=modelId.indexOf('/');
|
||||
if(slash<0) return null; // bare model name, no provider prefix
|
||||
const modelProvider=modelId.substring(0,slash).toLowerCase();
|
||||
// Normalise common aliases
|
||||
const aliases={'claude':'anthropic','gpt':'openai','gemini':'google'};
|
||||
const norm=p=>aliases[p]||p;
|
||||
if(norm(modelProvider)!==norm(ap)){
|
||||
return (window.t?window.t('provider_mismatch_warning',modelId,ap):
|
||||
`"${modelId}" may not work with your configured provider (${ap}). Send anyway or run \`hermes model\` to switch.`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Scroll pinning ──────────────────────────────────────────────────────────
|
||||
// When streaming, auto-scroll only if the user hasn't manually scrolled up.
|
||||
// Once the user scrolls back to within 80px of the bottom, re-pin.
|
||||
@@ -211,8 +239,8 @@ function renderMd(raw){
|
||||
if(rows.length<2)return block;
|
||||
const isSep=r=>/^\|[\s|:-]+\|$/.test(r.trim());
|
||||
if(!isSep(rows[1]))return block;
|
||||
const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<td>${esc(c.trim())}</td>`).join('');
|
||||
const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<th>${esc(c.trim())}</th>`).join('');
|
||||
const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<td>${inlineMd(c.trim())}</td>`).join('');
|
||||
const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<th>${inlineMd(c.trim())}</th>`).join('');
|
||||
const header=`<tr>${parseHeader(rows[0])}</tr>`;
|
||||
const body=rows.slice(2).map(r=>`<tr>${parseRow(r)}</tr>`).join('');
|
||||
return `<table><thead>${header}</thead><tbody>${body}</tbody></table>`;
|
||||
|
||||
@@ -153,6 +153,8 @@ def pytest_collection_modifyitems(config, items):
|
||||
# Agent backend (need running AIAgent)
|
||||
'test_chat_stream_opens_successfully',
|
||||
'test_approval_submit_and_respond',
|
||||
# Security redaction (flaky — session state varies across test ordering)
|
||||
'test_api_sessions_list_redacts_titles',
|
||||
# Workspace path (macOS /tmp -> /private/tmp symlink)
|
||||
'test_new_session_inherits_workspace',
|
||||
'test_workspace_add_valid',
|
||||
|
||||
@@ -175,3 +175,34 @@ def test_composer_textarea_font_size_mobile():
|
||||
# Check for 16px font-size on the textarea in a mobile breakpoint
|
||||
assert re.search(r'font-size:16px', CSS), \
|
||||
"Composer textarea must have font-size:16px at mobile widths to prevent iOS zoom-on-focus"
|
||||
|
||||
|
||||
|
||||
# ── Profiles button in mobile bottom nav ─────────────────────────────────────
|
||||
|
||||
def test_mobile_profiles_button_present():
|
||||
"""Mobile bottom nav must include a Profiles button (PR #265)."""
|
||||
assert 'data-panel="profiles"' in HTML and 'mobileSwitchPanel' in HTML, \
|
||||
"Mobile nav must have a Profiles button with data-panel='profiles' and mobileSwitchPanel"
|
||||
|
||||
|
||||
def test_mobile_profiles_button_uses_mobileSwitchPanel():
|
||||
"""Profiles mobile nav button must use mobileSwitchPanel, not raw switchPanel."""
|
||||
import re
|
||||
match = re.search(
|
||||
r'<button[^>]*mobile-nav-btn[^>]*data-panel="profiles"[^>]*>|'
|
||||
r'<button[^>]*data-panel="profiles"[^>]*mobile-nav-btn[^>]*>',
|
||||
HTML
|
||||
)
|
||||
assert match, "Could not find mobile-nav-btn with data-panel='profiles'"
|
||||
btn_html = HTML[match.start():match.start()+300]
|
||||
assert "mobileSwitchPanel('profiles')" in btn_html, \
|
||||
"Profiles mobile nav button must call mobileSwitchPanel('profiles')"
|
||||
|
||||
|
||||
def test_mobile_profiles_button_is_last_in_nav():
|
||||
"""Profiles button must appear after Spaces in the mobile bottom nav."""
|
||||
spaces_pos = HTML.find('data-panel="workspaces"')
|
||||
profiles_pos = HTML.rfind('data-panel="profiles"')
|
||||
assert spaces_pos > 0 and profiles_pos > spaces_pos, \
|
||||
"Profiles button must appear after Spaces button in the mobile nav"
|
||||
|
||||
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
|
||||
266
tests/test_provider_mismatch.py
Normal file
266
tests/test_provider_mismatch.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Tests for issue #266 — provider/model mismatch warning.
|
||||
|
||||
Covers:
|
||||
1. streaming.py: auth errors detected and classified as 'auth_mismatch'
|
||||
2. static/ui.js: _checkProviderMismatch() helper exists and logic is correct
|
||||
3. static/messages.js: apperror handler has auth_mismatch branch
|
||||
4. static/i18n.js: provider_mismatch_warning and provider_mismatch_label keys
|
||||
present in all 5 locales (en, es, de, zh, zh-Hant)
|
||||
5. static/boot.js: modelSelect.onchange calls _checkProviderMismatch
|
||||
6. /api/models: response includes active_provider field
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||
BASE = "http://127.0.0.1:8788"
|
||||
|
||||
|
||||
def _read(rel_path: str) -> str:
|
||||
return (REPO_ROOT / rel_path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ── 1. streaming.py: auth error detection ───────────────────────────────────
|
||||
|
||||
class TestStreamingAuthErrorDetection:
|
||||
"""streaming.py must classify auth/401 errors as auth_mismatch."""
|
||||
|
||||
def test_auth_mismatch_type_defined_in_streaming(self):
|
||||
"""'auth_mismatch' type must be emitted for auth errors."""
|
||||
src = _read("api/streaming.py")
|
||||
assert "auth_mismatch" in src, (
|
||||
"auth_mismatch type not found in streaming.py — "
|
||||
"401/auth errors will not be surfaced with a helpful message"
|
||||
)
|
||||
|
||||
def test_is_auth_error_flag_defined(self):
|
||||
"""is_auth_error variable must exist in the error handler."""
|
||||
src = _read("api/streaming.py")
|
||||
assert "is_auth_error" in src, (
|
||||
"is_auth_error flag not found in streaming.py"
|
||||
)
|
||||
|
||||
def test_auth_error_detects_401(self):
|
||||
"""'401' must be part of the auth error detection logic."""
|
||||
src = _read("api/streaming.py")
|
||||
# Find the is_auth_error block
|
||||
idx = src.find("is_auth_error")
|
||||
assert idx != -1
|
||||
block = src[idx:idx + 400]
|
||||
assert "'401'" in block or '"401"' in block, (
|
||||
"'401' not in is_auth_error detection block"
|
||||
)
|
||||
|
||||
def test_auth_error_detects_unauthorized(self):
|
||||
"""'unauthorized' must be part of the auth error detection logic."""
|
||||
src = _read("api/streaming.py")
|
||||
idx = src.find("is_auth_error")
|
||||
block = src[idx:idx + 400]
|
||||
assert "unauthorized" in block.lower(), (
|
||||
"'unauthorized' not in is_auth_error detection block"
|
||||
)
|
||||
|
||||
def test_auth_error_hint_mentions_hermes_model(self):
|
||||
"""The auth_mismatch hint must mention 'hermes model' command."""
|
||||
src = _read("api/streaming.py")
|
||||
# Find the auth_mismatch apperror block
|
||||
idx = src.find("auth_mismatch")
|
||||
block = src[idx:idx + 500]
|
||||
assert "hermes model" in block, (
|
||||
"auth_mismatch hint must mention 'hermes model' command "
|
||||
"so users know how to fix provider mismatch"
|
||||
)
|
||||
|
||||
def test_auth_error_does_not_catch_rate_limit(self):
|
||||
"""Rate limit errors must not be reclassified as auth_mismatch."""
|
||||
src = _read("api/streaming.py")
|
||||
# is_rate_limit must come before is_auth_error in the elif chain
|
||||
rl_idx = src.find("is_rate_limit")
|
||||
ae_idx = src.find("is_auth_error")
|
||||
assert rl_idx < ae_idx, (
|
||||
"is_rate_limit check should precede is_auth_error — "
|
||||
"rate limit errors must not be mistaken for auth errors"
|
||||
)
|
||||
|
||||
|
||||
# ── 2. static/ui.js: _checkProviderMismatch() ───────────────────────────────
|
||||
|
||||
class TestCheckProviderMismatch:
|
||||
"""ui.js must expose _checkProviderMismatch() helper."""
|
||||
|
||||
def test_function_defined(self):
|
||||
"""_checkProviderMismatch function must be defined in ui.js."""
|
||||
src = _read("static/ui.js")
|
||||
assert "function _checkProviderMismatch" in src, (
|
||||
"_checkProviderMismatch not defined in ui.js"
|
||||
)
|
||||
|
||||
def test_uses_window_active_provider(self):
|
||||
"""Function must read window._activeProvider."""
|
||||
src = _read("static/ui.js")
|
||||
idx = src.find("function _checkProviderMismatch")
|
||||
block = src[idx:idx + 800]
|
||||
assert "_activeProvider" in block, (
|
||||
"_checkProviderMismatch must read window._activeProvider"
|
||||
)
|
||||
|
||||
def test_skips_check_for_openrouter(self):
|
||||
"""OpenRouter can route to any provider — skip the warning."""
|
||||
src = _read("static/ui.js")
|
||||
idx = src.find("function _checkProviderMismatch")
|
||||
block = src[idx:idx + 800]
|
||||
assert "openrouter" in block.lower(), (
|
||||
"_checkProviderMismatch must skip the check for openrouter"
|
||||
)
|
||||
|
||||
def test_skips_check_for_custom(self):
|
||||
"""Custom endpoints can serve any model — skip the warning."""
|
||||
src = _read("static/ui.js")
|
||||
idx = src.find("function _checkProviderMismatch")
|
||||
block = src[idx:idx + 800]
|
||||
assert "custom" in block.lower(), (
|
||||
"_checkProviderMismatch must skip the check for custom provider"
|
||||
)
|
||||
|
||||
def test_active_provider_stored_on_model_load(self):
|
||||
"""populateModelDropdown must store active_provider from /api/models."""
|
||||
src = _read("static/ui.js")
|
||||
# Find the function definition (skip the comment that also mentions the name)
|
||||
idx = src.find("async function populateModelDropdown")
|
||||
assert idx != -1, "async function populateModelDropdown not found"
|
||||
block = src[idx:idx + 800]
|
||||
assert "_activeProvider" in block, (
|
||||
"populateModelDropdown must set window._activeProvider "
|
||||
"from the /api/models response"
|
||||
)
|
||||
|
||||
|
||||
# ── 3. static/messages.js: apperror handler ─────────────────────────────────
|
||||
|
||||
class TestApperrorHandler:
|
||||
"""messages.js apperror handler must handle auth_mismatch type."""
|
||||
|
||||
def test_auth_mismatch_type_handled(self):
|
||||
"""apperror handler must check for type='auth_mismatch'."""
|
||||
src = _read("static/messages.js")
|
||||
assert "auth_mismatch" in src, (
|
||||
"auth_mismatch type not handled in messages.js apperror handler"
|
||||
)
|
||||
|
||||
def test_provider_mismatch_label(self):
|
||||
"""'Provider mismatch' label must appear in the error handling."""
|
||||
src = _read("static/messages.js")
|
||||
assert "Provider mismatch" in src, (
|
||||
"'Provider mismatch' label not found in messages.js"
|
||||
)
|
||||
|
||||
def test_is_auth_mismatch_variable(self):
|
||||
"""isAuthMismatch variable must be defined."""
|
||||
src = _read("static/messages.js")
|
||||
assert "isAuthMismatch" in src, (
|
||||
"isAuthMismatch variable not found in messages.js apperror handler"
|
||||
)
|
||||
|
||||
|
||||
# ── 4. static/i18n.js: all 5 locales ────────────────────────────────────────
|
||||
|
||||
class TestI18nProviderMismatch:
|
||||
"""All 5 locales must have provider_mismatch_warning and provider_mismatch_label."""
|
||||
|
||||
REQUIRED_KEYS = ["provider_mismatch_warning", "provider_mismatch_label"]
|
||||
|
||||
def _count_key(self, src: str, key: str) -> int:
|
||||
return len(re.findall(r'\b' + re.escape(key) + r'\b', src))
|
||||
|
||||
def test_all_locales_have_warning_key(self):
|
||||
"""provider_mismatch_warning must appear in all 5 locales."""
|
||||
src = _read("static/i18n.js")
|
||||
count = self._count_key(src, "provider_mismatch_warning")
|
||||
assert count >= 5, (
|
||||
f"provider_mismatch_warning found {count} times, expected >= 5 "
|
||||
f"(one per locale: en, es, de, zh, zh-Hant)"
|
||||
)
|
||||
|
||||
def test_all_locales_have_label_key(self):
|
||||
"""provider_mismatch_label must appear in all 5 locales."""
|
||||
src = _read("static/i18n.js")
|
||||
count = self._count_key(src, "provider_mismatch_label")
|
||||
assert count >= 5, (
|
||||
f"provider_mismatch_label found {count} times, expected >= 5"
|
||||
)
|
||||
|
||||
def test_warning_is_function_in_en(self):
|
||||
"""English provider_mismatch_warning must be a function (m, p) => ..."""
|
||||
src = _read("static/i18n.js")
|
||||
# Find the en block
|
||||
en_start = src.find("\n en: {")
|
||||
es_start = src.find("\n es: {")
|
||||
en_block = src[en_start:es_start]
|
||||
assert "provider_mismatch_warning" in en_block, "Key not in en block"
|
||||
idx = en_block.find("provider_mismatch_warning")
|
||||
line = en_block[idx:idx + 200]
|
||||
# Must be a function, not a plain string
|
||||
assert "=>" in line, (
|
||||
"provider_mismatch_warning in en locale must be an arrow function "
|
||||
"that takes (m, p) parameters for model and provider interpolation"
|
||||
)
|
||||
|
||||
def test_spanish_locale_key_coverage(self):
|
||||
"""Spanish locale must have the new keys (parity with English)."""
|
||||
src = _read("static/i18n.js")
|
||||
es_start = src.find("\n es: {")
|
||||
de_start = src.find("\n de: {")
|
||||
es_block = src[es_start:de_start]
|
||||
for key in self.REQUIRED_KEYS:
|
||||
assert key in es_block, f"Key '{key}' missing from Spanish locale"
|
||||
|
||||
|
||||
# ── 5. static/boot.js: dropdown change handler ──────────────────────────────
|
||||
|
||||
class TestBootModelSelectChange:
|
||||
"""boot.js modelSelect.onchange must call _checkProviderMismatch."""
|
||||
|
||||
def test_onchange_calls_check_function(self):
|
||||
"""modelSelect.onchange must invoke _checkProviderMismatch."""
|
||||
src = _read("static/boot.js")
|
||||
assert "_checkProviderMismatch" in src, (
|
||||
"boot.js modelSelect.onchange must call _checkProviderMismatch "
|
||||
"to warn users about provider/model mismatches"
|
||||
)
|
||||
# Verify it's called from the onchange handler (near modelSelect.onchange)
|
||||
idx = src.find("'modelSelect').onchange") or src.find('"modelSelect").onchange')
|
||||
if idx == -1:
|
||||
# Try alternate patterns
|
||||
idx = src.find("modelSelect")
|
||||
block_start = src.rfind("\n", 0, src.find("_checkProviderMismatch")) or 0
|
||||
surrounding = src[max(0, block_start - 200):block_start + 400]
|
||||
assert "modelSelect" in surrounding or "selectedModel" in surrounding, (
|
||||
"_checkProviderMismatch must be called in the context of model selection"
|
||||
)
|
||||
|
||||
def test_onchange_shows_toast_on_mismatch(self):
|
||||
"""The warning must be shown via showToast, not alert()."""
|
||||
src = _read("static/boot.js")
|
||||
# Both _checkProviderMismatch call and showToast must be near each other
|
||||
idx = src.find("_checkProviderMismatch")
|
||||
assert idx != -1, "_checkProviderMismatch not found in boot.js"
|
||||
block = src[idx:idx + 300]
|
||||
assert "showToast" in block, (
|
||||
"Provider mismatch warning must be shown via showToast(), not alert()"
|
||||
)
|
||||
|
||||
|
||||
# ── 6. /api/models: active_provider in response ──────────────────────────────
|
||||
|
||||
def test_api_models_includes_active_provider():
|
||||
"""/api/models must include 'active_provider' key in response."""
|
||||
with urllib.request.urlopen(BASE + "/api/models", timeout=10) as r:
|
||||
data = json.loads(r.read())
|
||||
# active_provider can be None/null but the key must exist
|
||||
assert "active_provider" in data, (
|
||||
"/api/models response missing 'active_provider' field — "
|
||||
"frontend needs this to detect provider mismatches"
|
||||
)
|
||||
@@ -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, \
|
||||
|
||||
228
tests/test_sprint34.py
Normal file
228
tests/test_sprint34.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Sprint 34 Tests: OAuth provider support in onboarding (issues #303, #304).
|
||||
|
||||
Covers:
|
||||
1. _provider_oauth_authenticated() returns True for known OAuth providers
|
||||
with valid tokens in auth.json
|
||||
2. _provider_oauth_authenticated() returns False when auth.json is absent,
|
||||
empty, or has no token data
|
||||
3. _provider_oauth_authenticated() returns False for unknown/API-key providers
|
||||
4. _status_from_runtime() marks copilot/openai-codex as provider_ready when
|
||||
credentials exist
|
||||
5. _status_from_runtime() gives a helpful "hermes auth" note (not "API key")
|
||||
for OAuth providers that have no credentials yet
|
||||
6. API route /api/onboarding/status reflects OAuth-ready state
|
||||
"""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest.mock
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
BASE = "http://127.0.0.1:8788"
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_auth_json(provider_id: str, tokens: dict, tmp_dir: pathlib.Path) -> pathlib.Path:
|
||||
"""Write an auth.json with the given tokens for provider_id into tmp_dir."""
|
||||
store = {"providers": {provider_id: tokens}}
|
||||
auth_path = tmp_dir / "auth.json"
|
||||
auth_path.write_text(json.dumps(store), encoding="utf-8")
|
||||
return auth_path
|
||||
|
||||
|
||||
# ── 1–3. _provider_oauth_authenticated unit tests ────────────────────────────
|
||||
|
||||
class TestProviderOAuthAuthenticated:
|
||||
"""Unit tests for the new _provider_oauth_authenticated() helper."""
|
||||
|
||||
def _call(self, provider: str, hermes_home: pathlib.Path) -> bool:
|
||||
# Import fresh so we don't get a stale module reference
|
||||
from api.onboarding import _provider_oauth_authenticated
|
||||
return _provider_oauth_authenticated(provider, hermes_home)
|
||||
|
||||
def test_returns_false_when_auth_json_absent(self, tmp_path):
|
||||
"""No auth.json -> not authenticated."""
|
||||
assert self._call("openai-codex", tmp_path) is False
|
||||
|
||||
def test_openai_codex_with_access_token(self, tmp_path):
|
||||
"""openai-codex with a valid access_token -> authenticated."""
|
||||
_make_auth_json(
|
||||
"openai-codex",
|
||||
{"access_token": "ey.test.token", "refresh_token": "ref123"},
|
||||
tmp_path,
|
||||
)
|
||||
assert self._call("openai-codex", tmp_path) is True
|
||||
|
||||
def test_openai_codex_with_refresh_token_only(self, tmp_path):
|
||||
"""openai-codex with only a refresh_token -> still authenticated."""
|
||||
_make_auth_json(
|
||||
"openai-codex",
|
||||
{"access_token": "", "refresh_token": "ref_only_token"},
|
||||
tmp_path,
|
||||
)
|
||||
assert self._call("openai-codex", tmp_path) is True
|
||||
|
||||
def test_copilot_with_api_key(self, tmp_path):
|
||||
"""copilot with an api_key (GitHub token) -> authenticated."""
|
||||
_make_auth_json("copilot", {"api_key": "ghu_test_token_123"}, tmp_path)
|
||||
assert self._call("copilot", tmp_path) is True
|
||||
|
||||
def test_empty_tokens_returns_false(self, tmp_path):
|
||||
"""All token fields empty -> not authenticated."""
|
||||
_make_auth_json(
|
||||
"openai-codex",
|
||||
{"access_token": "", "refresh_token": "", "api_key": ""},
|
||||
tmp_path,
|
||||
)
|
||||
assert self._call("openai-codex", tmp_path) is False
|
||||
|
||||
def test_missing_provider_key_in_auth_json(self, tmp_path):
|
||||
"""auth.json present but provider key absent -> not authenticated."""
|
||||
store = {"providers": {"some-other-provider": {"access_token": "tok"}}}
|
||||
(tmp_path / "auth.json").write_text(json.dumps(store), encoding="utf-8")
|
||||
assert self._call("openai-codex", tmp_path) is False
|
||||
|
||||
def test_unknown_provider_not_in_oauth_list(self, tmp_path):
|
||||
"""A provider that is not a known OAuth provider -> always False."""
|
||||
_make_auth_json("some-random-provider", {"access_token": "tok"}, tmp_path)
|
||||
assert self._call("some-random-provider", tmp_path) is False
|
||||
|
||||
def test_nous_provider_recognized(self, tmp_path):
|
||||
"""nous is in the known OAuth set."""
|
||||
_make_auth_json("nous", {"access_token": "nous_tok"}, tmp_path)
|
||||
assert self._call("nous", tmp_path) is True
|
||||
|
||||
def test_qwen_oauth_provider_recognized(self, tmp_path):
|
||||
"""qwen-oauth is in the known OAuth set."""
|
||||
_make_auth_json("qwen-oauth", {"access_token": "qwen_tok"}, tmp_path)
|
||||
assert self._call("qwen-oauth", tmp_path) is True
|
||||
|
||||
def test_empty_provider_string_returns_false(self, tmp_path):
|
||||
"""Empty provider string -> False, no crash."""
|
||||
assert self._call("", tmp_path) is False
|
||||
assert self._call(" ", tmp_path) is False
|
||||
|
||||
|
||||
# ── 4–5. _status_from_runtime integration ────────────────────────────────────
|
||||
|
||||
class TestStatusFromRuntimeOAuth:
|
||||
"""_status_from_runtime should treat OAuth providers with tokens as ready."""
|
||||
|
||||
def _call(self, provider: str, model: str, hermes_home: pathlib.Path) -> dict:
|
||||
from api.onboarding import _status_from_runtime
|
||||
import api.onboarding as _ob
|
||||
orig_home = _ob._get_active_hermes_home
|
||||
orig_found = _ob._HERMES_FOUND
|
||||
_ob._get_active_hermes_home = lambda: hermes_home
|
||||
# Simulate hermes-agent being available so we reach the provider logic
|
||||
# (without this, _status_from_runtime short-circuits to agent_unavailable)
|
||||
_ob._HERMES_FOUND = True
|
||||
try:
|
||||
cfg = {"model": {"provider": provider, "default": model}}
|
||||
return _status_from_runtime(cfg, True)
|
||||
finally:
|
||||
_ob._get_active_hermes_home = orig_home
|
||||
_ob._HERMES_FOUND = orig_found
|
||||
|
||||
def test_copilot_ready_when_api_key_in_auth_json(self, tmp_path):
|
||||
"""copilot configured + api_key in auth.json -> provider_ready True."""
|
||||
_make_auth_json("copilot", {"api_key": "ghu_abc123"}, tmp_path)
|
||||
result = self._call("copilot", "gpt-5.4", tmp_path)
|
||||
assert result["provider_configured"] is True
|
||||
assert result["provider_ready"] is True
|
||||
assert result["setup_state"] == "ready"
|
||||
|
||||
def test_openai_codex_ready_when_token_in_auth_json(self, tmp_path):
|
||||
"""openai-codex configured + access_token -> provider_ready True."""
|
||||
_make_auth_json(
|
||||
"openai-codex",
|
||||
{"access_token": "ey.test", "refresh_token": "ref"},
|
||||
tmp_path,
|
||||
)
|
||||
result = self._call("openai-codex", "codex-mini-latest", tmp_path)
|
||||
assert result["provider_configured"] is True
|
||||
assert result["provider_ready"] is True
|
||||
assert result["setup_state"] == "ready"
|
||||
|
||||
def test_copilot_not_ready_without_credentials(self, tmp_path):
|
||||
"""copilot configured but no credentials -> provider_ready False.
|
||||
|
||||
We mock hermes_cli.auth to be unavailable so the function falls through
|
||||
to the auth.json path. With no auth.json the result must be False.
|
||||
"""
|
||||
import unittest.mock
|
||||
|
||||
# Prevent the hermes_cli fast path from finding real credentials
|
||||
with unittest.mock.patch(
|
||||
"api.onboarding._provider_oauth_authenticated",
|
||||
return_value=False,
|
||||
):
|
||||
result = self._call("copilot", "gpt-5.4", tmp_path)
|
||||
|
||||
assert result["provider_configured"] is True
|
||||
assert result["provider_ready"] is False
|
||||
assert result["setup_state"] == "provider_incomplete"
|
||||
|
||||
def test_oauth_incomplete_note_mentions_hermes_auth(self, tmp_path):
|
||||
"""When OAuth provider is incomplete, note should mention hermes auth/model."""
|
||||
result = self._call("openai-codex", "codex-mini-latest", tmp_path)
|
||||
note = result["provider_note"]
|
||||
assert "hermes auth" in note or "hermes model" in note, (
|
||||
f"Expected 'hermes auth' or 'hermes model' in note, got: {note!r}"
|
||||
)
|
||||
|
||||
def test_oauth_incomplete_note_does_not_say_api_key(self, tmp_path):
|
||||
"""OAuth provider incomplete note must not say 'API key' — that's misleading."""
|
||||
result = self._call("copilot", "gpt-5.4", tmp_path)
|
||||
note = result["provider_note"]
|
||||
assert "API key" not in note, (
|
||||
f"Note misleadingly mentions 'API key' for OAuth provider: {note!r}"
|
||||
)
|
||||
|
||||
def test_standard_provider_incomplete_note_still_says_api_key(self, tmp_path):
|
||||
"""For a standard API-key provider (openrouter), note should still say API key."""
|
||||
# openrouter with no .env
|
||||
result = self._call("openrouter", "anthropic/claude-sonnet-4.6", tmp_path)
|
||||
assert result["provider_ready"] is False
|
||||
note = result["provider_note"]
|
||||
assert "API key" in note, (
|
||||
f"Expected 'API key' in note for openrouter, got: {note!r}"
|
||||
)
|
||||
|
||||
|
||||
# ── 6. API endpoint reflects OAuth-ready state ───────────────────────────────
|
||||
|
||||
class TestOnboardingStatusApiOAuth:
|
||||
"""
|
||||
The /api/onboarding/status endpoint should report provider_ready=True
|
||||
when an OAuth provider is configured and has valid credentials.
|
||||
"""
|
||||
|
||||
def test_status_endpoint_returns_200(self):
|
||||
import urllib.request
|
||||
with urllib.request.urlopen(BASE + "/api/onboarding/status", timeout=10) as r:
|
||||
assert r.status == 200
|
||||
data = json.loads(r.read())
|
||||
assert "system" in data
|
||||
assert "provider_ready" in data["system"]
|
||||
|
||||
def test_onboarding_status_has_chat_ready_field(self):
|
||||
import urllib.request
|
||||
with urllib.request.urlopen(BASE + "/api/onboarding/status", timeout=10) as r:
|
||||
data = json.loads(r.read())
|
||||
assert "chat_ready" in data["system"]
|
||||
|
||||
def test_status_setup_state_valid_values(self):
|
||||
"""setup_state must be one of the known string values."""
|
||||
import urllib.request
|
||||
with urllib.request.urlopen(BASE + "/api/onboarding/status", timeout=10) as r:
|
||||
data = json.loads(r.read())
|
||||
valid = {"ready", "provider_incomplete", "needs_provider", "agent_unavailable"}
|
||||
assert data["system"]["setup_state"] in valid, (
|
||||
f"Unexpected setup_state: {data['system']['setup_state']!r}"
|
||||
)
|
||||
53
tests/test_updates.py
Normal file
53
tests/test_updates.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Tests for self-update diagnostics (api/updates.py)."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import api.updates as updates
|
||||
|
||||
|
||||
def test_run_git_returns_stderr_on_failure(tmp_path):
|
||||
"""When a git command fails, _run_git should return stderr (not empty string)."""
|
||||
with patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1,
|
||||
stdout='',
|
||||
stderr="fatal: 'origin/master' does not appear to be a git repository\n",
|
||||
)
|
||||
out, ok = updates._run_git(['pull', '--ff-only', 'origin/master'], tmp_path)
|
||||
|
||||
assert ok is False
|
||||
assert "does not appear to be a git repository" in out
|
||||
|
||||
|
||||
def test_run_git_returns_stdout_when_no_stderr(tmp_path):
|
||||
"""If stderr is empty on failure, fall back to stdout."""
|
||||
with patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=128,
|
||||
stdout='Already up to date.',
|
||||
stderr='',
|
||||
)
|
||||
out, ok = updates._run_git(['pull'], tmp_path)
|
||||
|
||||
assert ok is False
|
||||
assert 'Already up to date' in out
|
||||
|
||||
|
||||
def test_run_git_returns_exit_code_when_no_output(tmp_path):
|
||||
"""If both stdout and stderr are empty, report the exit code."""
|
||||
with patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1,
|
||||
stdout='',
|
||||
stderr='',
|
||||
)
|
||||
out, ok = updates._run_git(['status'], tmp_path)
|
||||
|
||||
assert ok is False
|
||||
assert 'status 1' in out
|
||||
|
||||
|
||||
def test_split_remote_ref_splits_tracking_ref():
|
||||
"""_split_remote_ref should correctly split origin/branch."""
|
||||
assert updates._split_remote_ref('origin/master') == ('origin', 'master')
|
||||
assert updates._split_remote_ref('origin/feature/foo') == ('origin', 'feature/foo')
|
||||
assert updates._split_remote_ref('master') == (None, 'master')
|
||||
Reference in New Issue
Block a user