login-module-patch: sync to v0.50.36-local.1
This commit is contained in:
committed by
Nathan Esquenazi
parent
8d1c257ea8
commit
8b857d9efc
@@ -7,6 +7,11 @@
|
|||||||
>
|
>
|
||||||
> Keep this document updated as architecture changes are made.
|
> Keep this document updated as architecture changes are made.
|
||||||
|
|
||||||
|
> Current shipped build: `v0.50.36-local.1` (April 14, 2026).
|
||||||
|
> Baseline: upstream `nesquena/hermes-webui` `v0.50.36`.
|
||||||
|
> Intentional local delta: first-time password enablement from Settings immediately issues a `hermes_session` cookie so the current browser remains signed in. The previous `Assistant Reply Language` customization has been removed, and legacy `assistant_language` settings are filtered out on load/save.
|
||||||
|
> Automated coverage: 1059 passing tests.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Overview and Purpose
|
## 1. Overview and Purpose
|
||||||
@@ -23,6 +28,11 @@ frontend framework. The Python server is split into a routing shell (server.py)
|
|||||||
business logic modules (api/). The frontend is seven vanilla JS modules loaded from static/.
|
business logic modules (api/). The frontend is seven vanilla JS modules loaded from static/.
|
||||||
This makes the code easy to modify from a terminal or by an agent.
|
This makes the code easy to modify from a terminal or by an agent.
|
||||||
|
|
||||||
|
For the current local build, the codebase is intentionally as close to upstream as possible:
|
||||||
|
the app now tracks upstream `v0.50.36`, keeps the password-session continuity patch in the
|
||||||
|
settings/onboarding flow, and does not carry forward the prior reply-language preference
|
||||||
|
feature.
|
||||||
|
|
||||||
Hermes-level chrome is intentionally consolidated: the sidebar has no dedicated brand header.
|
Hermes-level chrome is intentionally consolidated: the sidebar has no dedicated brand header.
|
||||||
Instead, the footer exposes a single "Hermes WebUI" launch button that opens one tabbed
|
Instead, the footer exposes a single "Hermes WebUI" launch button that opens one tabbed
|
||||||
control-center modal for global preferences, conversation import/export, and clear-conversation
|
control-center modal for global preferences, conversation import/export, and clear-conversation
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
|
> 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.
|
> Everything you can do from the CLI terminal, you can do from this UI.
|
||||||
>
|
>
|
||||||
> Last updated: v0.50.21 (April 13, 2026) — 961 tests, 961 passing
|
> Last updated: v0.50.36-local.1 (April 14, 2026) — 1059 tests, 1059 passing
|
||||||
> Full production-ready: onboarding wizard, multi-profile support, KaTeX math rendering,
|
> Full production-ready: upstream v0.50.36 synced locally, with only the first-password session continuity patch retained.
|
||||||
> live reasoning cards with localStorage reload recovery, CSRF reverse proxy fixes, Docker improvements.
|
> Local delta: enabling password from Settings keeps the current browser signed in; the former Assistant Reply Language enhancement has been removed.
|
||||||
> Tests: 961 total (961 passing, 0 failures)
|
> Tests: 1059 total (1059 passing, 0 failures)
|
||||||
> Source: <repo>/
|
> Source: <repo>/
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -74,6 +74,7 @@
|
|||||||
| v0.50.16–v0.50.17 | CSRF reverse proxy + Docker uv pre-install | Scheme-aware CSRF port normalization for non-standard ports (@lx3133584), Docker uv pre-installed at build time as root (fixes air-gapped startup, @mmartial-pattern) | 900 |
|
| v0.50.16–v0.50.17 | CSRF reverse proxy + Docker uv pre-install | Scheme-aware CSRF port normalization for non-standard ports (@lx3133584), Docker uv pre-installed at build time as root (fixes air-gapped startup, @mmartial-pattern) | 900 |
|
||||||
| v0.50.18–v0.50.19 | Workspace fallback + Unicode filenames | Cascading workspace path recovery (@Jordan-SkyLF), Unicode Content-Disposition headers with RFC 5987 filename* (@shaoxianbilly), silent auth error surfacing, stale model cleanup | 924 |
|
| v0.50.18–v0.50.19 | Workspace fallback + Unicode filenames | Cascading workspace path recovery (@Jordan-SkyLF), Unicode Content-Disposition headers with RFC 5987 filename* (@shaoxianbilly), silent auth error surfacing, stale model cleanup | 924 |
|
||||||
| v0.50.20–v0.50.21 | Silent errors + live model fetching + durable streaming recovery | apperror on empty agent response, /api/models/live endpoint with SSRF guard, live reasoning cards, tool_complete SSE events, SESSION_QUEUES, localStorage reload recovery (@Jordan-SkyLF) | 961 |
|
| v0.50.20–v0.50.21 | Silent errors + live model fetching + durable streaming recovery | apperror on empty agent response, /api/models/live endpoint with SSRF guard, live reasoning cards, tool_complete SSE events, SESSION_QUEUES, localStorage reload recovery (@Jordan-SkyLF) | 961 |
|
||||||
|
| v0.50.22–v0.50.36-local.1 | Upstream sync + minimal local patch retention | Synced to upstream `v0.50.36`; retained first-password session continuity in Settings/onboarding; removed local Assistant Reply Language enhancement; added legacy settings cleanup regression coverage | 1059 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1126,6 +1126,7 @@ _SETTINGS_DEFAULTS = {
|
|||||||
"bubble_layout": False, # right-aligned user / left-aligned assistant chat bubbles
|
"bubble_layout": False, # right-aligned user / left-aligned assistant chat bubbles
|
||||||
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
|
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
|
||||||
}
|
}
|
||||||
|
_SETTINGS_LEGACY_DROP_KEYS = {"assistant_language"}
|
||||||
|
|
||||||
|
|
||||||
def load_settings() -> dict:
|
def load_settings() -> dict:
|
||||||
@@ -1135,7 +1136,13 @@ def load_settings() -> dict:
|
|||||||
try:
|
try:
|
||||||
stored = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
|
stored = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
|
||||||
if isinstance(stored, dict):
|
if isinstance(stored, dict):
|
||||||
settings.update(stored)
|
settings.update(
|
||||||
|
{
|
||||||
|
k: v
|
||||||
|
for k, v in stored.items()
|
||||||
|
if k not in _SETTINGS_LEGACY_DROP_KEYS
|
||||||
|
}
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to load settings from %s", SETTINGS_FILE)
|
logger.debug("Failed to load settings from %s", SETTINGS_FILE)
|
||||||
return settings
|
return settings
|
||||||
|
|||||||
@@ -987,12 +987,57 @@ def handle_post(handler, parsed) -> bool:
|
|||||||
|
|
||||||
# ── Settings (POST) ──
|
# ── Settings (POST) ──
|
||||||
if parsed.path == "/api/settings":
|
if parsed.path == "/api/settings":
|
||||||
|
from api.auth import (
|
||||||
|
create_session,
|
||||||
|
is_auth_enabled,
|
||||||
|
parse_cookie,
|
||||||
|
set_auth_cookie,
|
||||||
|
verify_session,
|
||||||
|
)
|
||||||
|
|
||||||
if "bot_name" in body:
|
if "bot_name" in body:
|
||||||
body["bot_name"] = (str(body["bot_name"]) or "").strip() or "Hermes"
|
body["bot_name"] = (str(body["bot_name"]) or "").strip() or "Hermes"
|
||||||
|
|
||||||
|
auth_enabled_before = is_auth_enabled()
|
||||||
|
current_cookie = parse_cookie(handler)
|
||||||
|
logged_in_before = bool(current_cookie and verify_session(current_cookie))
|
||||||
|
requested_password = bool(
|
||||||
|
isinstance(body.get("_set_password"), str)
|
||||||
|
and body.get("_set_password", "").strip()
|
||||||
|
)
|
||||||
|
|
||||||
saved = save_settings(body)
|
saved = save_settings(body)
|
||||||
saved.pop("password_hash", None) # never expose hash to client
|
saved.pop("password_hash", None) # never expose hash to client
|
||||||
|
|
||||||
|
auth_enabled_after = is_auth_enabled()
|
||||||
|
auth_just_enabled = bool(
|
||||||
|
requested_password and auth_enabled_after and not auth_enabled_before
|
||||||
|
)
|
||||||
|
logged_in_after = logged_in_before
|
||||||
|
new_cookie = None
|
||||||
|
|
||||||
|
if auth_just_enabled and not logged_in_before:
|
||||||
|
new_cookie = create_session()
|
||||||
|
logged_in_after = True
|
||||||
|
|
||||||
|
saved["auth_enabled"] = auth_enabled_after
|
||||||
|
saved["logged_in"] = logged_in_after
|
||||||
|
saved["auth_just_enabled"] = auth_just_enabled
|
||||||
|
|
||||||
|
if not new_cookie:
|
||||||
return j(handler, saved)
|
return j(handler, saved)
|
||||||
|
|
||||||
|
response_body = json.dumps(saved, ensure_ascii=False, indent=2).encode("utf-8")
|
||||||
|
handler.send_response(200)
|
||||||
|
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
handler.send_header("Content-Length", str(len(response_body)))
|
||||||
|
handler.send_header("Cache-Control", "no-store")
|
||||||
|
set_auth_cookie(handler, new_cookie)
|
||||||
|
_security_headers(handler)
|
||||||
|
handler.end_headers()
|
||||||
|
handler.wfile.write(response_body)
|
||||||
|
return True
|
||||||
|
|
||||||
if parsed.path == "/api/onboarding/setup":
|
if parsed.path == "/api/onboarding/setup":
|
||||||
# Writing API keys to disk - restrict to local/private networks unless auth is active.
|
# Writing API keys to disk - restrict to local/private networks unless auth is active.
|
||||||
# In Docker, requests arrive from the bridge network (172.x.x.x), not 127.0.0.1,
|
# In Docker, requests arrive from the bridge network (172.x.x.x), not 127.0.0.1,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class QuietHTTPServer(ThreadingHTTPServer):
|
|||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion
|
timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion
|
||||||
server_version = 'HermesWebUI/0.2'
|
server_version = 'HermesWebUI/0.50.36-local.1'
|
||||||
def log_message(self, fmt, *args): pass # suppress default Apache-style log
|
def log_message(self, fmt, *args): pass # suppress default Apache-style log
|
||||||
|
|
||||||
def log_request(self, code: str='-', size: str='-') -> None:
|
def log_request(self, code: str='-', size: str='-') -> None:
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ const LOCALES = {
|
|||||||
settings_saved: 'Settings saved',
|
settings_saved: 'Settings saved',
|
||||||
settings_save_failed: 'Save failed: ',
|
settings_save_failed: 'Save failed: ',
|
||||||
settings_load_failed: 'Failed to load settings: ',
|
settings_load_failed: 'Failed to load settings: ',
|
||||||
settings_saved_pw: 'Settings saved (password set \u2014 login now required)',
|
settings_saved_pw: 'Settings saved — password protection enabled and this browser stays signed in',
|
||||||
|
settings_saved_pw_updated: 'Settings saved — password updated',
|
||||||
// login page (used server-side via /api/i18n/login endpoint)
|
// login page (used server-side via /api/i18n/login endpoint)
|
||||||
login_title: 'Sign in',
|
login_title: 'Sign in',
|
||||||
login_subtitle: 'Enter your password to continue',
|
login_subtitle: 'Enter your password to continue',
|
||||||
@@ -281,6 +282,9 @@ const LOCALES = {
|
|||||||
onboarding_notice_finish: 'You can reopen Settings later to change any of this.',
|
onboarding_notice_finish: 'You can reopen Settings later to change any of this.',
|
||||||
onboarding_not_set: 'Not set',
|
onboarding_not_set: 'Not set',
|
||||||
onboarding_password_will_enable: 'Will be enabled',
|
onboarding_password_will_enable: 'Will be enabled',
|
||||||
|
onboarding_password_will_replace: 'Will be replaced',
|
||||||
|
onboarding_password_keep_existing: 'Keep current password',
|
||||||
|
onboarding_password_remains_disabled: 'Will remain disabled',
|
||||||
onboarding_password_skipped: 'Skipped for now',
|
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_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_workspace: 'Choose a workspace before continuing.',
|
||||||
@@ -534,7 +538,8 @@ const LOCALES = {
|
|||||||
settings_saved: 'Configuración guardada',
|
settings_saved: 'Configuración guardada',
|
||||||
settings_save_failed: 'Error al guardar: ',
|
settings_save_failed: 'Error al guardar: ',
|
||||||
settings_load_failed: 'Error al cargar la configuración: ',
|
settings_load_failed: 'Error al cargar la configuración: ',
|
||||||
settings_saved_pw: 'Configuración guardada (contraseña establecida — ahora se requiere iniciar sesión)',
|
settings_saved_pw: 'Configuración guardada — la contraseña queda activada y este navegador sigue autenticado',
|
||||||
|
settings_saved_pw_updated: 'Configuración guardada — contraseña actualizada',
|
||||||
// login page (used server-side via /api/i18n/login endpoint)
|
// login page (used server-side via /api/i18n/login endpoint)
|
||||||
login_title: 'Iniciar sesión',
|
login_title: 'Iniciar sesión',
|
||||||
login_subtitle: 'Introduce tu contraseña para continuar',
|
login_subtitle: 'Introduce tu contraseña para continuar',
|
||||||
@@ -675,6 +680,9 @@ const LOCALES = {
|
|||||||
onboarding_notice_finish: 'Puedes volver a abrir Configuración más tarde para cambiar cualquiera de estos valores.',
|
onboarding_notice_finish: 'Puedes volver a abrir Configuración más tarde para cambiar cualquiera de estos valores.',
|
||||||
onboarding_not_set: 'Sin definir',
|
onboarding_not_set: 'Sin definir',
|
||||||
onboarding_password_will_enable: 'Se activará',
|
onboarding_password_will_enable: 'Se activará',
|
||||||
|
onboarding_password_will_replace: 'Se reemplazará',
|
||||||
|
onboarding_password_keep_existing: 'Mantener la contraseña actual',
|
||||||
|
onboarding_password_remains_disabled: 'Seguirá desactivada',
|
||||||
onboarding_password_skipped: 'Se omitirá por ahora',
|
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_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_workspace: 'Elige un espacio de trabajo antes de continuar.',
|
||||||
@@ -935,7 +943,8 @@ const LOCALES = {
|
|||||||
settings_saved: 'Einstellungen gespeichert',
|
settings_saved: 'Einstellungen gespeichert',
|
||||||
settings_save_failed: 'Speichern fehlgeschlagen: ',
|
settings_save_failed: 'Speichern fehlgeschlagen: ',
|
||||||
settings_load_failed: 'Laden der Einstellungen fehlgeschlagen: ',
|
settings_load_failed: 'Laden der Einstellungen fehlgeschlagen: ',
|
||||||
settings_saved_pw: 'Einstellungen gespeichert (Passwort gesetzt \u2014 Login jetzt erforderlich)',
|
settings_saved_pw: 'Einstellungen gespeichert — Passwortschutz aktiviert und dieser Browser bleibt angemeldet',
|
||||||
|
settings_saved_pw_updated: 'Einstellungen gespeichert — Passwort aktualisiert',
|
||||||
// login page
|
// login page
|
||||||
login_title: 'Anmelden',
|
login_title: 'Anmelden',
|
||||||
login_subtitle: 'Geben Sie Ihr Passwort ein, um fortzufahren',
|
login_subtitle: 'Geben Sie Ihr Passwort ein, um fortzufahren',
|
||||||
@@ -997,6 +1006,10 @@ const LOCALES = {
|
|||||||
suggest_files: 'Welche Dateien sind in diesem Workspace?',
|
suggest_files: 'Welche Dateien sind in diesem Workspace?',
|
||||||
suggest_schedule: 'Was steht heute auf meinem Plan?',
|
suggest_schedule: 'Was steht heute auf meinem Plan?',
|
||||||
suggest_plan: 'Hilf mir, ein kleines Projekt zu planen.',
|
suggest_plan: 'Hilf mir, ein kleines Projekt zu planen.',
|
||||||
|
onboarding_password_will_enable: 'Wird aktiviert',
|
||||||
|
onboarding_password_will_replace: 'Wird ersetzt',
|
||||||
|
onboarding_password_keep_existing: 'Aktuelles Passwort beibehalten',
|
||||||
|
onboarding_password_remains_disabled: 'Bleibt deaktiviert',
|
||||||
},
|
},
|
||||||
|
|
||||||
zh: {
|
zh: {
|
||||||
@@ -1135,7 +1148,8 @@ const LOCALES = {
|
|||||||
settings_saved: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58',
|
settings_saved: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58',
|
||||||
settings_save_failed: '\u4fdd\u5b58\u5931\u8d25\uff1a',
|
settings_save_failed: '\u4fdd\u5b58\u5931\u8d25\uff1a',
|
||||||
settings_load_failed: '\u8bbe\u7f6e\u52a0\u8f7d\u5931\u8d25\uff1a',
|
settings_load_failed: '\u8bbe\u7f6e\u52a0\u8f7d\u5931\u8d25\uff1a',
|
||||||
settings_saved_pw: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58\uff08\u5bc6\u7801\u5df2\u8bbe\u7f6e\u2014\u73b0\u5728\u9700\u8981\u767b\u5f55\uff09',
|
settings_saved_pw: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58\uff0c\u5df2\u542f\u7528\u5bc6\u7801\u4fdd\u62a4\uff0c\u5f53\u524d\u6d4f\u89c8\u5668\u4f1a\u4fdd\u6301\u767b\u5f55',
|
||||||
|
settings_saved_pw_updated: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58\uff0c\u5bc6\u7801\u5df2\u66f4\u65b0',
|
||||||
// login page
|
// login page
|
||||||
login_title: '\u767b\u5f55',
|
login_title: '\u767b\u5f55',
|
||||||
login_subtitle: '\u8f93\u5165\u5bc6\u7801\u7ee7\u7eed\u4f7f\u7528',
|
login_subtitle: '\u8f93\u5165\u5bc6\u7801\u7ee7\u7eed\u4f7f\u7528',
|
||||||
@@ -1519,7 +1533,8 @@ const LOCALES = {
|
|||||||
settings_saved: '\u8a2d\u5b9a\u5df2\u5132\u5b58',
|
settings_saved: '\u8a2d\u5b9a\u5df2\u5132\u5b58',
|
||||||
settings_save_failed: '\u5132\u5b58\u5931\u6557\uff1a',
|
settings_save_failed: '\u5132\u5b58\u5931\u6557\uff1a',
|
||||||
settings_load_failed: '\u8a2d\u5b9a\u52a0\u8f09\u5931\u6557\uff1a',
|
settings_load_failed: '\u8a2d\u5b9a\u52a0\u8f09\u5931\u6557\uff1a',
|
||||||
settings_saved_pw: '\u8a2d\u5b9a\u5df2\u5132\u5b58\uff08\u5bc6\u78bc\u5df2\u8a2d\u5b9a\u2014\u73fe\u5728\u9700\u8981\u767b\u5f55\uff09',
|
settings_saved_pw: '\u8a2d\u5b9a\u5df2\u5132\u5b58\uff0c\u5bc6\u78bc\u4fdd\u8b77\u5df2\u555f\u7528\uff0c\u7576\u524d\u700f\u89bd\u5668\u6703\u4fdd\u6301\u767b\u5165',
|
||||||
|
settings_saved_pw_updated: '\u8a2d\u5b9a\u5df2\u5132\u5b58\uff0c\u5bc6\u78bc\u5df2\u66f4\u65b0',
|
||||||
// login page
|
// login page
|
||||||
login_title: '\u767b\u5f55',
|
login_title: '\u767b\u5f55',
|
||||||
login_subtitle: '\u8f38\u5165\u5bc6\u78bc\u7e7c\u7e8c\u4f7f\u7528',
|
login_subtitle: '\u8f38\u5165\u5bc6\u78bc\u7e7c\u7e8c\u4f7f\u7528',
|
||||||
@@ -1577,6 +1592,27 @@ const LOCALES = {
|
|||||||
settings_desc_check_updates: '\u7576\u6709\u66f4\u65b0\u7684 WebUI \u6216\u52a9\u624b\u7248\u672c\u6642\u986f\u793a\u6a19\u8a18\u3002\u5c07\u5728\u5f8c\u81ea\u6b63\u5e38\u57f7\u884c Git-Fetch\u3002',
|
settings_desc_check_updates: '\u7576\u6709\u66f4\u65b0\u7684 WebUI \u6216\u52a9\u624b\u7248\u672c\u6642\u986f\u793a\u6a19\u8a18\u3002\u5c07\u5728\u5f8c\u81ea\u6b63\u5e38\u57f7\u884c Git-Fetch\u3002',
|
||||||
settings_desc_bot_name: '\u52a9\u624b\u5728 UI \u4e2d\u7684\u986f\u793a\u540d\u7a31\u3002\u9810\u8a2d\u70b8\u7528\u6539\u3002',
|
settings_desc_bot_name: '\u52a9\u624b\u5728 UI \u4e2d\u7684\u986f\u793a\u540d\u7a31\u3002\u9810\u8a2d\u70b8\u7528\u6539\u3002',
|
||||||
settings_desc_password: '\u8a2d\u5b9a WebUI \u767b\u5165\u5bc6\u78bc\u3002\u5047\u5982\u5df2\u8a2d\u7f6e\uff0c\u6bcf\u6b21\u52a0\u8f09\u90fd\u9700\u8981\u767b\u5165\u3002',
|
settings_desc_password: '\u8a2d\u5b9a WebUI \u767b\u5165\u5bc6\u78bc\u3002\u5047\u5982\u5df2\u8a2d\u7f6e\uff0c\u6bcf\u6b21\u52a0\u8f09\u90fd\u9700\u8981\u767b\u5165\u3002',
|
||||||
|
onboarding_password_will_enable: '\u5c07\u6703\u555f\u7528',
|
||||||
|
onboarding_password_will_replace: '\u5c07\u6703\u53d6\u4ee3',
|
||||||
|
onboarding_password_keep_existing: '\u4fdd\u7559\u76ee\u524d\u5bc6\u78bc',
|
||||||
|
onboarding_password_remains_disabled: '\u6703\u7e7c\u7e8c\u4fdd\u6301\u95dc\u9589',
|
||||||
|
settings_label_sound: '\u901a\u77e5\u8072\u97f3',
|
||||||
|
// boot.js
|
||||||
|
cancelling: '\u6b63\u5728\u53d6\u6d88...',
|
||||||
|
cancel_failed: '\u53d6\u6d88\u5931\u6557\uff1a',
|
||||||
|
mic_denied: '\u9ea6\u514b\u98a8\u8a2a\u554f\u88ab\u62d2\u7d75\uff0c\u8acb\u6aa2\u67e5\u700f\u89bd\u5668\u6b0a\u9650\u3002',
|
||||||
|
mic_no_speech: '\u6c92\u6709\u6aa2\u6e2c\u5230\u8a71\u97f3\uff0c\u8acb\u518d\u5617\u4e00\u6b21\u3002',
|
||||||
|
mic_network: '\u8a71\u97f3\u8b58\u5225\u76ee\u524d\u4e0d\u53ef\u7528\u3002',
|
||||||
|
mic_error: '\u8a71\u97f3\u8f38\u5165\u51fa\u932f\uff1a',
|
||||||
|
session_imported: '\u6703\u8a71\u5df2\u5c0e\u5165',
|
||||||
|
import_failed: '\u5c0e\u5165\u5931\u6557\uff1a',
|
||||||
|
import_invalid_json: 'JSON \u7121\u6548',
|
||||||
|
image_pasted: '\u5df2\u7c98\u8cbc\u5716\u7247\uff1a',
|
||||||
|
// messages.js
|
||||||
|
edit_message: '\u7de8\u8f2f\u8a0a\u606f',
|
||||||
|
regenerate: '\u91cd\u65b0\u751f\u6210\u56de\u8986',
|
||||||
|
copy: '\u8907\u88fd',
|
||||||
|
copied: '\u5df2\u8907\u88fd',
|
||||||
// ui.js
|
// ui.js
|
||||||
workspace_desc: '\u8acb\u9078\u64c7\u5de5\u4f5c\u5340\uff0c\u6216\u8f09\u5165\u65b0\u540d\u7a31\u5beb\u4e00\u500b',
|
workspace_desc: '\u8acb\u9078\u64c7\u5de5\u4f5c\u5340\uff0c\u6216\u8f09\u5165\u65b0\u540d\u7a31\u5beb\u4e00\u500b',
|
||||||
tab_profiles: '\u914d\u7f6e',
|
tab_profiles: '\u914d\u7f6e',
|
||||||
|
|||||||
@@ -224,12 +224,19 @@ function _renderOnboardingBody(){
|
|||||||
<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_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_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_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><strong>${t('onboarding_check_password')}</strong><span>${t(_getOnboardingPasswordSummaryKey(settings))}</span></div>
|
||||||
</div>
|
</div>
|
||||||
${ONBOARDING.form.baseUrl?`<p class="onboarding-copy"><strong>${t('onboarding_base_url_label')}</strong> ${esc(ONBOARDING.form.baseUrl)}</p>`:''}
|
${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>`;
|
<p class="onboarding-copy">${t('onboarding_finish_help')}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _getOnboardingPasswordSummaryKey(settings){
|
||||||
|
const hasExistingPassword=!!(settings&&settings.password_enabled);
|
||||||
|
const hasNewPassword=!!((ONBOARDING.form.password||'').trim());
|
||||||
|
if(hasNewPassword) return hasExistingPassword?'onboarding_password_will_replace':'onboarding_password_will_enable';
|
||||||
|
return hasExistingPassword?'onboarding_password_keep_existing':'onboarding_password_remains_disabled';
|
||||||
|
}
|
||||||
|
|
||||||
function syncOnboardingWorkspaceSelect(value){
|
function syncOnboardingWorkspaceSelect(value){
|
||||||
ONBOARDING.form.workspace=value;
|
ONBOARDING.form.workspace=value;
|
||||||
const input=$('onboardingWorkspaceInput');
|
const input=$('onboardingWorkspaceInput');
|
||||||
@@ -309,7 +316,10 @@ async function _saveOnboardingDefaults(){
|
|||||||
}
|
}
|
||||||
const body={default_workspace:workspace,default_model:model};
|
const body={default_workspace:workspace,default_model:model};
|
||||||
if(password) body._set_password=password;
|
if(password) body._set_password=password;
|
||||||
await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
||||||
|
if(ONBOARDING.status){
|
||||||
|
ONBOARDING.status.settings={...(ONBOARDING.status.settings||{}),password_enabled:!!saved.auth_enabled};
|
||||||
|
}
|
||||||
localStorage.setItem('hermes-webui-model',model);
|
localStorage.setItem('hermes-webui-model',model);
|
||||||
if($('modelSelect')) _applyModelToDropdown(model,$('modelSelect'));
|
if($('modelSelect')) _applyModelToDropdown(model,$('modelSelect'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1245,11 +1245,7 @@ async function loadSettingsPanel(){
|
|||||||
// Show auth buttons only when auth is active
|
// Show auth buttons only when auth is active
|
||||||
try{
|
try{
|
||||||
const authStatus=await api('/api/auth/status');
|
const authStatus=await api('/api/auth/status');
|
||||||
const active=authStatus.auth_enabled;
|
_setSettingsAuthButtonsVisible(!!authStatus.auth_enabled);
|
||||||
const signOutBtn=$('btnSignOut');
|
|
||||||
if(signOutBtn) signOutBtn.style.display=active?'':'none';
|
|
||||||
const disableBtn=$('btnDisableAuth');
|
|
||||||
if(disableBtn) disableBtn.style.display=active?'':'none';
|
|
||||||
}catch(e){}
|
}catch(e){}
|
||||||
_syncHermesPanelSessionActions();
|
_syncHermesPanelSessionActions();
|
||||||
switchSettingsSection(_settingsSection);
|
switchSettingsSection(_settingsSection);
|
||||||
@@ -1258,6 +1254,39 @@ async function loadSettingsPanel(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _setSettingsAuthButtonsVisible(active){
|
||||||
|
const signOutBtn=$('btnSignOut');
|
||||||
|
if(signOutBtn) signOutBtn.style.display=active?'':'none';
|
||||||
|
const disableBtn=$('btnDisableAuth');
|
||||||
|
if(disableBtn) disableBtn.style.display=active?'':'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _applySavedSettingsUi(saved, body, opts){
|
||||||
|
const {sendKey,showTokenUsage,showCliSessions,theme,language}=opts;
|
||||||
|
window._sendKey=sendKey||'enter';
|
||||||
|
window._showTokenUsage=showTokenUsage;
|
||||||
|
window._showCliSessions=showCliSessions;
|
||||||
|
window._soundEnabled=body.sound_enabled;
|
||||||
|
window._notificationsEnabled=body.notifications_enabled;
|
||||||
|
window._botName=body.bot_name||'Hermes';
|
||||||
|
document.body.classList.toggle('bubble-layout', !!body.bubble_layout);
|
||||||
|
if(typeof applyBotName==='function') applyBotName();
|
||||||
|
if(typeof setLocale==='function') setLocale(language);
|
||||||
|
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
|
||||||
|
if(typeof startGatewaySSE==='function'){
|
||||||
|
if(showCliSessions) startGatewaySSE();
|
||||||
|
else if(typeof stopGatewaySSE==='function') stopGatewaySSE();
|
||||||
|
}
|
||||||
|
_setSettingsAuthButtonsVisible(!!saved.auth_enabled);
|
||||||
|
_settingsDirty=false;
|
||||||
|
_settingsThemeOnOpen=theme;
|
||||||
|
const bar=$('settingsUnsavedBar');
|
||||||
|
if(bar) bar.style.display='none';
|
||||||
|
renderMessages();
|
||||||
|
if(typeof syncTopbar==='function') syncTopbar();
|
||||||
|
if(typeof renderSessionList==='function') renderSessionList();
|
||||||
|
}
|
||||||
|
|
||||||
async function saveSettings(andClose){
|
async function saveSettings(andClose){
|
||||||
const model=($('settingsModel')||{}).value;
|
const model=($('settingsModel')||{}).value;
|
||||||
const sendKey=($('settingsSendKey')||{}).value;
|
const sendKey=($('settingsSendKey')||{}).value;
|
||||||
@@ -1285,37 +1314,16 @@ async function saveSettings(andClose){
|
|||||||
// Password: only act if the field has content; blank = leave auth unchanged
|
// Password: only act if the field has content; blank = leave auth unchanged
|
||||||
if(pw && pw.trim()){
|
if(pw && pw.trim()){
|
||||||
try{
|
try{
|
||||||
await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})});
|
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})});
|
||||||
window._sendKey=sendKey||'enter';
|
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,language});
|
||||||
window._showTokenUsage=showTokenUsage;
|
showToast(t(saved.auth_just_enabled?'settings_saved_pw':'settings_saved_pw_updated'));
|
||||||
window._soundEnabled=body.sound_enabled;
|
|
||||||
window._notificationsEnabled=body.notifications_enabled;
|
|
||||||
if(typeof setLocale==='function') setLocale(language);
|
|
||||||
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
|
|
||||||
showToast(t('settings_saved_pw'));
|
|
||||||
_settingsDirty=false; _settingsThemeOnOpen=theme;
|
|
||||||
_hideSettingsPanel();
|
_hideSettingsPanel();
|
||||||
return;
|
return;
|
||||||
}catch(e){showToast(t('settings_save_failed')+e.message);return;}
|
}catch(e){showToast(t('settings_save_failed')+e.message);return;}
|
||||||
}
|
}
|
||||||
try{
|
try{
|
||||||
await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
||||||
window._sendKey=sendKey||'enter';
|
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,language});
|
||||||
window._showTokenUsage=showTokenUsage;
|
|
||||||
window._showCliSessions=showCliSessions;
|
|
||||||
window._soundEnabled=body.sound_enabled;
|
|
||||||
window._notificationsEnabled=body.notifications_enabled;
|
|
||||||
window._botName=body.bot_name;
|
|
||||||
if(typeof applyBotName==='function') applyBotName();
|
|
||||||
if(typeof setLocale==='function') setLocale(language);
|
|
||||||
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
|
|
||||||
// Restart gateway SSE when agent session setting changes
|
|
||||||
if(typeof startGatewaySSE==='function'){if(showCliSessions)startGatewaySSE();else if(typeof stopGatewaySSE==='function')stopGatewaySSE();}
|
|
||||||
_settingsDirty=false; _settingsThemeOnOpen=theme;
|
|
||||||
const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none';
|
|
||||||
renderMessages();
|
|
||||||
if(typeof syncTopbar==='function') syncTopbar();
|
|
||||||
if(typeof renderSessionList==='function') renderSessionList();
|
|
||||||
showToast(t('settings_saved'));
|
showToast(t('settings_saved'));
|
||||||
_hideSettingsPanel();
|
_hideSettingsPanel();
|
||||||
}catch(e){
|
}catch(e){
|
||||||
|
|||||||
137
tests/test_sprint45.py
Normal file
137
tests/test_sprint45.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""
|
||||||
|
Sprint 45 Tests: v0.50.36 upstream sync with minimal local patch retention.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- First password enablement via POST /api/settings keeps the current browser logged in
|
||||||
|
- The returned auth metadata is present and onboarding can continue with the issued cookie
|
||||||
|
- Legacy assistant_language is no longer exposed and is removed on the next save
|
||||||
|
- The local reply-language UI/runtime enhancement is gone from the synced codebase
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:8788"
|
||||||
|
REPO = pathlib.Path(__file__).parent.parent
|
||||||
|
SETTINGS_FILE = pathlib.Path.home() / ".hermes" / "webui-mvp-test" / "settings.json"
|
||||||
|
|
||||||
|
|
||||||
|
def get(path, headers=None):
|
||||||
|
req = urllib.request.Request(BASE + path, headers=headers or {})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
return json.loads(r.read()), r.status, dict(r.headers)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return json.loads(e.read()), e.code, dict(e.headers)
|
||||||
|
|
||||||
|
|
||||||
|
def post(path, body=None, headers=None):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + path,
|
||||||
|
data=json.dumps(body or {}).encode(),
|
||||||
|
headers={"Content-Type": "application/json", **(headers or {})},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
return json.loads(r.read()), r.status, dict(r.headers)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return json.loads(e.read()), e.code, dict(e.headers)
|
||||||
|
|
||||||
|
|
||||||
|
def read(path):
|
||||||
|
return (REPO / path).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_settings_file():
|
||||||
|
if SETTINGS_FILE.exists():
|
||||||
|
return SETTINGS_FILE.read_text(encoding="utf-8")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_settings_file(original_text):
|
||||||
|
if original_text is None:
|
||||||
|
SETTINGS_FILE.unlink(missing_ok=True)
|
||||||
|
return
|
||||||
|
SETTINGS_FILE.write_text(original_text, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_password_enablement_returns_cookie_and_keeps_browser_logged_in():
|
||||||
|
original_settings = _snapshot_settings_file()
|
||||||
|
try:
|
||||||
|
saved, status, headers = post("/api/settings", {"_set_password": "sprint45-secret"})
|
||||||
|
assert status == 200
|
||||||
|
assert saved["auth_enabled"] is True
|
||||||
|
assert saved["logged_in"] is True
|
||||||
|
assert saved["auth_just_enabled"] is True
|
||||||
|
|
||||||
|
set_cookie = headers.get("Set-Cookie", "")
|
||||||
|
assert "hermes_session=" in set_cookie
|
||||||
|
cookie_header = set_cookie.split(";", 1)[0]
|
||||||
|
|
||||||
|
auth, auth_status, _ = get("/api/auth/status", headers={"Cookie": cookie_header})
|
||||||
|
assert auth_status == 200
|
||||||
|
assert auth["auth_enabled"] is True
|
||||||
|
assert auth["logged_in"] is True
|
||||||
|
|
||||||
|
done, done_status, _ = post(
|
||||||
|
"/api/onboarding/complete",
|
||||||
|
{},
|
||||||
|
headers={"Cookie": cookie_header},
|
||||||
|
)
|
||||||
|
assert done_status == 200
|
||||||
|
assert done["completed"] is True
|
||||||
|
finally:
|
||||||
|
_restore_settings_file(original_settings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_assistant_language_is_hidden_and_removed_on_next_save():
|
||||||
|
original_settings = _snapshot_settings_file()
|
||||||
|
try:
|
||||||
|
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
SETTINGS_FILE.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"assistant_language": "zh",
|
||||||
|
"send_key": "enter",
|
||||||
|
"onboarding_completed": False,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
loaded, status, _ = get("/api/settings")
|
||||||
|
assert status == 200
|
||||||
|
assert "assistant_language" not in loaded
|
||||||
|
|
||||||
|
saved, save_status, _ = post("/api/settings", {"send_key": "ctrl+enter"})
|
||||||
|
assert save_status == 200
|
||||||
|
assert "assistant_language" not in saved
|
||||||
|
assert saved["send_key"] == "ctrl+enter"
|
||||||
|
|
||||||
|
persisted = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
|
||||||
|
assert "assistant_language" not in persisted
|
||||||
|
finally:
|
||||||
|
_restore_settings_file(original_settings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reply_language_customization_ui_and_runtime_are_removed():
|
||||||
|
index_html = read("static/index.html")
|
||||||
|
panels_js = read("static/panels.js")
|
||||||
|
streaming_py = read("api/streaming.py")
|
||||||
|
|
||||||
|
assert "settingsAssistantLanguage" not in index_html
|
||||||
|
assert "assistant_language" not in panels_js
|
||||||
|
assert "settingsAssistantLanguage" not in panels_js
|
||||||
|
assert "assistant_language" not in streaming_py
|
||||||
|
assert "Default reply language:" not in streaming_py
|
||||||
|
|
||||||
|
|
||||||
|
def test_synced_version_strings_show_local_patch_version():
|
||||||
|
index_html = read("static/index.html")
|
||||||
|
server_py = read("server.py")
|
||||||
|
|
||||||
|
assert "v0.50.36-local.1" in index_html
|
||||||
|
assert "HermesWebUI/0.50.36-local.1" in server_py
|
||||||
Reference in New Issue
Block a user