release: v0.50.102–v0.50.108 batch (code blocks, utf-8, image URLs, deletion warning, PermissionError, Docker docs, kimi-k2.5) (#755)

## Batch release: v0.50.102 – v0.50.108

Seven self-built PRs reviewed and approved by @nesquena, now consolidated into a single release branch.

### Included fixes

| Version | PR | What it fixes |
|---|---|---|
| v0.50.102 | #746 | Code blocks lose newlines when not preceded by blank line (fixes #745) |
| v0.50.103 | #743 | `encoding='utf-8'` on `write_text()` in `api/profiles.py` — Windows `.env` detection (fixes #741) |
| v0.50.104 | #735 | Agent `MEDIA:localhost:*` image URLs rewritten to `document.baseURI` — remote users get working images (fixes #642) |
| v0.50.105 | #736 | Profile deletion warning strengthened: "permanently deleted, cannot be undone" across all 6 locales (fixes #637) |
| v0.50.106 | #738 | Catch `PermissionError` in `_signing_key()` — three-container Docker UID mismatch no longer crashes all HTTP requests |
| v0.50.107 | #737 | Docs: three-container UID/GID alignment guide in README + `HERMES_UID`/`HERMES_GID` forwarded in compose (fixes #645) |
| v0.50.108 | #742 | Add `kimi-k2.5` to Kimi/Moonshot provider model list (fixes #740) |

### Testing
- **pytest**: 1510 passed, 1 warning (1 pre-existing unrelated failure excluded)
- **QA harness**: 20/20 passed (`~/WebUI/scripts/run-browser-tests.sh`)
- **Browser**: layout, slash autocomplete width, edit button, image URL rewrite, profile deletion dialog all verified

All PRs reviewed and approved by @nesquena. Ready to merge and tag **v0.50.108**.
This commit is contained in:
nesquena-hermes
2026-04-20 00:26:55 -07:00
committed by GitHub
parent aa767d28d0
commit 69570ca77c
8 changed files with 138 additions and 14 deletions

View File

@@ -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 `<img src>`. 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 `<pre>` blocks (including language-labelled blocks with `<div class="pre-header">` 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 `<br>`, 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 `<pre>` 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 `<br>`, collapsing the entire block to one line. (Fixes #745)
## [v0.50.101] — 2026-04-20

View File

@@ -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

View File

@@ -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:

View File

@@ -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"},

View File

@@ -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,

View File

@@ -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

View File

@@ -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',
},
};

View File

@@ -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 `<img class="msg-media-img" src="${esc(ref)}" alt="image" loading="lazy" onclick="this.classList.toggle('msg-media-img--full')">`;
// 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 `<a href="${esc(ref)}" target="_blank" rel="noopener">${esc(ref)}</a>`;
if(_IMAGE_EXTS.test(src.split('?')[0])){
return `<img class="msg-media-img" src="${esc(src)}" alt="image" loading="lazy" onclick="this.classList.toggle('msg-media-img--full')">`;
}
return `<a href="${esc(src)}" target="_blank" rel="noopener">${esc(src)}</a>`;
}
// Local file path
const apiUrl='api/media?path='+encodeURIComponent(ref);