diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ac87686..9085c075 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,42 @@
# Hermes Web UI -- Changelog
+## [v0.50.108] — 2026-04-20
+
+### Fixed
+- **Kimi K2.5 added to Kimi/Moonshot provider model list** — `kimi-k2.5` was present in `hermes_cli` but missing from the WebUI's `api/config.py` kimi-coding provider, making it unavailable in the model selector. (Fixes #740)
+
+## [v0.50.107] — 2026-04-20
+
+### Added
+- **Three-container UID/GID alignment guide in README** — new subsection explains why UIDs must match across containers sharing a bind-mounted volume, documents the variable name asymmetry (`HERMES_UID`/`HERMES_GID` for the agent image vs `WANTED_UID`/`WANTED_GID` for the WebUI image), gives the recommended `.env` setup for standard Linux and NAS/Unraid deployments, provides the one-time `chown` fix for existing installs, and notes that the dashboard volume must be read-write. (Fixes #645)
+
+### Fixed
+- **`HERMES_UID`/`HERMES_GID` forwarded to agent and dashboard containers** — `docker-compose.three-container.yml` now declares `HERMES_UID=${HERMES_UID:-10000}` and `HERMES_GID=${HERMES_GID:-10000}` in the environment blocks for `hermes-agent` and `hermes-dashboard`, making the documented `.env` recipe functional.
+
+## [v0.50.106] — 2026-04-20
+
+### Fixed
+- **`PermissionError` in auth signing key no longer crashes every HTTP request** — `key_file.exists()` in `api/auth.py`'s `_signing_key()` was called outside the try/except block. In three-container bind-mount setups where the agent container initialises the state directory under a different UID, `pathlib.Path.exists()` raises `PermissionError`, which escaped up through `is_auth_enabled()` → `check_auth()` and crashed every HTTP request with HTTP 500. The `exists()` call is now inside the try block so `PermissionError` is caught and falls back to an in-memory key. (PR #625)
+
+## [v0.50.105] — 2026-04-20
+
+### Fixed
+- **Profile deletion warning now leads with destructive impact** — the confirmation dialog now reads: "All sessions, config, skills, and memory for this profile will be permanently deleted. This cannot be undone." Updated across all 6 supported locales. (Fixes #637)
+
+## [v0.50.104] — 2026-04-20
+
+### Fixed
+- **Agent image URLs rewritten to actual server base** — when an agent emits a `MEDIA:http://localhost:8787/...` URL, the WebUI now rewrites the `localhost`/`127.0.0.1` host to the page's `document.baseURI` before inserting it as an ``. Fixes broken images for remote users (VPN, Docker, deployed servers) and preserves subpath mounts (e.g. `/hermes/`). (Fixes #642)
+
+## [v0.50.103] — 2026-04-20
+
+### Fixed
+- **Windows `.env` encoding fix** — `write_text()` calls in `api/profiles.py` were missing `encoding='utf-8'`, causing failures on Windows systems with non-UTF-8 locale encodings. All file I/O in `api/` now explicitly specifies `encoding='utf-8'`. (Fixes #741)
+
## [v0.50.102] — 2026-04-20
### Fixed
-- **Code blocks no longer lose newlines when not preceded by a blank line** — `renderMd()` in `static/ui.js` now stashes `
` blocks (including language-labelled blocks with `` wrappers), mermaid diagrams, and katex blocks before the paragraph-splitting pass, then restores them. Previously, if a fenced code block was not separated from surrounding text by a double newline, all `\n` inside it were replaced with `
`, causing Prism.js to collapse the entire block to one line and misidentify everything after a `//` comment as a comment. (Fixes #745, reported by @qqxpee) +- **Code blocks no longer lose newlines when not preceded by a blank line** — `renderMd()` now stashes `` blocks (including language-labelled wrappers), mermaid diagrams, and katex blocks before the paragraph-splitting pass, then restores them. Previously, if a fenced code block was not separated from surrounding text by a blank line, all `\n` inside it were replaced with `
`, collapsing the entire block to one line. (Fixes #745) ## [v0.50.101] — 2026-04-20 diff --git a/README.md b/README.md index 6d2be908..08b23c7e 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,75 @@ first boot. Subsequent restarts reuse the installed packages. See `docker-compose.two-container.yml` for the full configuration. +### Running alongside hermes-dashboard (three-container setup) + +To run the Hermes Agent, Hermes Dashboard, and the WebUI together on a +shared volume, use the three-container Compose file: + +```bash +docker compose -f docker-compose.three-container.yml up -d +``` + +This brings up: +- **`hermes-agent`** — gateway API on port 8642 +- **`hermes-dashboard`** — monitoring UI on port 9119 +- **`hermes-webui`** — browser chat interface on port 8787 + +All three services share the same `hermes-home` named volume so config, +sessions, skills, and memory are consistent across all surfaces. + +#### Why UIDs must match + +The `hermes-home` volume is a bind-mount in practice — all three containers +write to the same filesystem tree under `~/.hermes`. If the containers run +as different UIDs, whichever container creates a file first becomes its +owner, and the others hit `PermissionError` on subsequent writes. + +The fix is to make all containers run as **your host user's UID and GID**. + +#### Variable name asymmetry + +> ⚠️ **The two image families use different environment variable names** for +> the UID/GID setting: +> +> | Image | Variable | +> |---|---| +> | `nousresearch/hermes-agent` (agent + dashboard) | `HERMES_UID` / `HERMES_GID` | +> | `ghcr.io/nesquena/hermes-webui` | `WANTED_UID` / `WANTED_GID` | +> +> You must set **both pairs** when using a `.env` file. + +#### Recommended setup + +For a standard Linux user (UID ≥ 1000): + +```bash +# Create a .env file with your host UID/GID +echo "UID=$(id -u)" >> .env +echo "GID=$(id -g)" >> .env +# hermes-agent / hermes-dashboard +echo "HERMES_UID=$(id -u)" >> .env +echo "HERMES_GID=$(id -g)" >> .env +``` + +For NAS/Unraid deployments where a fixed service account is preferred, use +`10000:10000` (or your NAS service UID) instead of `$(id -u)`. + +If you get `PermissionError` on an **existing** `~/.hermes` directory, run +the one-time ownership fix: + +```bash +chown -R $(id -u):$(id -g) ~/.hermes +``` + +#### Volume mount mode + +The dashboard container needs **read-write** access to the shared volume +(it writes session logs and dashboard state). Do **not** add `:ro` to the +`hermes-home` volume in `hermes-dashboard`'s `volumes:` entry. + +See `docker-compose.three-container.yml` for the full reference configuration. + --- ## What start.sh discovers automatically diff --git a/api/auth.py b/api/auth.py index 53a43b29..15e419df 100644 --- a/api/auth.py +++ b/api/auth.py @@ -51,13 +51,13 @@ def _record_login_attempt(ip: str) -> None: def _signing_key(): """Return a random signing key, generating and persisting one on first call.""" key_file = STATE_DIR / '.signing_key' - if key_file.exists(): - try: + try: + if key_file.exists(): raw = key_file.read_bytes() if len(raw) >= 32: return raw[:32] - except Exception: - logger.debug("Failed to read signing key from file, generating new key") + except Exception: + logger.debug("Failed to read or access signing key file, using in-memory key") # Generate a new random key key = secrets.token_bytes(32) try: diff --git a/api/config.py b/api/config.py index 464e3dd1..b91996ec 100644 --- a/api/config.py +++ b/api/config.py @@ -533,6 +533,7 @@ _PROVIDER_MODELS = { {"id": "moonshot-v1-32k", "label": "Moonshot v1 32k"}, {"id": "moonshot-v1-128k", "label": "Moonshot v1 128k"}, {"id": "kimi-latest", "label": "Kimi Latest"}, + {"id": "kimi-k2.5", "label": "Kimi K2.5"}, ], "minimax": [ {"id": "MiniMax-M2.7", "label": "MiniMax M2.7"}, diff --git a/api/profiles.py b/api/profiles.py index bc40414e..26b0864e 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -208,7 +208,7 @@ def switch_profile(name: str) -> dict: # Write sticky default for CLI consistency try: ap_file = _DEFAULT_HERMES_HOME / 'active_profile' - ap_file.write_text(name if name != 'default' else '') + ap_file.write_text(name if name != 'default' else '', encoding='utf-8') except Exception: logger.debug("Failed to write active profile file") @@ -357,7 +357,7 @@ def _write_endpoint_to_config(profile_dir: Path, base_url: str = None, api_key: if api_key: model_section['api_key'] = api_key cfg['model'] = model_section - config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True)) + config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8') def create_profile_api(name: str, clone_from: str = None, diff --git a/docker-compose.three-container.yml b/docker-compose.three-container.yml index 34e1aed2..8afa3a3e 100644 --- a/docker-compose.three-container.yml +++ b/docker-compose.three-container.yml @@ -28,6 +28,8 @@ services: - hermes-agent-src:/opt/hermes environment: - HERMES_HOME=/root/.hermes + - HERMES_UID=${HERMES_UID:-10000} + - HERMES_GID=${HERMES_GID:-10000} restart: unless-stopped deploy: resources: @@ -47,6 +49,8 @@ services: - hermes-home:/root/.hermes environment: - HERMES_HOME=/root/.hermes + - HERMES_UID=${HERMES_UID:-10000} + - HERMES_GID=${HERMES_GID:-10000} # Dashboard connects to the gateway for health/session data - GATEWAY_HEALTH_URL=http://hermes-agent:8642 depends_on: @@ -90,6 +94,12 @@ services: # echo "UID=$(id -u)" >> .env && echo "GID=$(id -g)" >> .env - WANTED_UID=${UID:-1000} - WANTED_GID=${GID:-1000} + # NOTE: When using bind-mount volumes shared across containers, ALL containers + # that write to the same host directory must run as the same UID/GID. + # If hermes-agent initialises the state dir as root (UID 0), hermes-webui + # will get a PermissionError accessing those paths — including a crash on every + # HTTP request if the auth signing-key file is unreadable. Either set WANTED_UID + # to match the agent container's UID, or use a named Docker volume (preferred). # Optional: set a password for remote access # - HERMES_WEBUI_PASSWORD=your-secret-password restart: unless-stopped diff --git a/static/i18n.js b/static/i18n.js index f1e2bc60..898588b4 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -464,7 +464,7 @@ const LOCALES = { profile_base_url_rule: 'Base URL must start with http:// or https://', profile_created: (name) => `Profile created: ${name}`, profile_delete_confirm_title: (name) => `Delete profile "${name}"?`, - profile_delete_confirm_message: 'This removes all config, skills, memory, and sessions for this profile.', + profile_delete_confirm_message: 'All sessions, config, skills, and memory for this profile will be permanently deleted. This cannot be undone.', profile_deleted: (name) => `Profile deleted: ${name}`, active_conversation_none: 'No active conversation selected.', active_conversation_meta: (title, count) => `${title} · ${count} message${count === 1 ? '' : 's'}`, @@ -914,7 +914,7 @@ const LOCALES = { profile_base_url_rule: 'Базовый URL должен начинаться с http:// или https://', profile_created: (name) => `Профиль создан: ${name}`, profile_delete_confirm_title: (name) => `Удалить профиль «${name}»?`, - profile_delete_confirm_message: 'Это удалит всю конфигурацию, навыки, память и сеансы этого профиля.', + profile_delete_confirm_message: 'Все сеансы, конфигурация, навыки и память этого профиля будут удалены безвозвратно. Это действие невозможно отменить.', profile_deleted: (name) => `Профиль удалён: ${name}`, active_conversation_none: 'Активная беседа не выбрана.', active_conversation_meta: (title, count) => { @@ -1348,7 +1348,7 @@ const LOCALES = { profile_base_url_rule: 'Base URL must start with http:// or https://', profile_created: (name) => `Profile created: ${name}`, profile_delete_confirm_title: (name) => `Delete profile "${name}"?`, - profile_delete_confirm_message: 'This removes all config, skills, memory, and sessions for this profile.', + profile_delete_confirm_message: 'Todas las sesiones, configuración, habilidades y memoria de este perfil se eliminarán de forma permanente. Esta acción no se puede deshacer.', profile_deleted: (name) => `Profile deleted: ${name}`, active_conversation_none: 'No active conversation selected.', active_conversation_meta: (title, count) => `${title} · ${count} message${count === 1 ? '' : 's'}`, @@ -1581,6 +1581,7 @@ const LOCALES = { onboarding_password_will_replace: 'Wird ersetzt', onboarding_password_keep_existing: 'Aktuelles Passwort beibehalten', onboarding_password_remains_disabled: 'Bleibt deaktiviert', + profile_delete_confirm_message: 'Alle Sitzungen, Konfigurationen, Fähigkeiten und Erinnerungen dieses Profils werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.', }, zh: { @@ -1992,7 +1993,7 @@ const LOCALES = { profile_base_url_rule: 'Base URL 必须以 http:// 或 https:// 开头', profile_created: (name) => `配置档已创建:${name}`, profile_delete_confirm_title: (name) => `删除配置档“${name}”?`, - profile_delete_confirm_message: '这将删除该配置档的所有配置、技能、记忆和会话。', + profile_delete_confirm_message: '该配置档的所有会话、配置、技能和记忆将被永久删除。此操作无法撤销。', profile_deleted: (name) => `配置档已删除:${name}`, active_conversation_none: '当前未选择活动会话。', active_conversation_meta: (title, count) => `${title} · ${count} 条消息`, @@ -2235,6 +2236,7 @@ const LOCALES = { // ui.js workspace_desc: '\u8acb\u9078\u64c7\u5de5\u4f5c\u5340\uff0c\u6216\u8f09\u5165\u65b0\u540d\u7a31\u5beb\u4e00\u500b', tab_profiles: '\u914d\u7f6e', + profile_delete_confirm_message: '\u6b64\u914d\u7f6e\u6a94\u7684\u6240\u6709\u6703\u8a71\u3001\u8a2d\u5b9a\u3001\u6280\u80fd\u548c\u8a18\u61b6\u5c07\u88ab\u6c38\u4e45\u522a\u9664\u3002\u6b64\u64cd\u4f5c\u7121\u6cd5\u64a4\u92b7\u3002', }, }; diff --git a/static/ui.js b/static/ui.js index 69c94348..e33123f6 100644 --- a/static/ui.js +++ b/static/ui.js @@ -663,10 +663,19 @@ function renderMd(raw){ const ref=media_stash[+i]; // HTTP(S) URL if(/^https?:\/\//i.test(ref)){ - if(_IMAGE_EXTS.test(ref.split('?')[0])){ - return ``; + // Rewrite localhost/127.0.0.1 to the actual server base URL so remote + // users (VPN, Docker, deployed) can load agent-generated images (#642). + // Strip the trailing slash from document.baseURI so the URL's own path + // joins cleanly — this preserves any subpath mount (e.g. /hermes/). + let src=ref; + if(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i.test(src)){ + const base=document.baseURI.replace(/\/$/,''); + src=src.replace(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i,base); } - return `${esc(ref)}`; + if(_IMAGE_EXTS.test(src.split('?')[0])){ + return `
`; + } + return `${esc(src)}`; } // Local file path const apiUrl='api/media?path='+encodeURIComponent(ref);