From b979b4c443595424d0ef06d10e45f49d98081629 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Wed, 8 Apr 2026 14:55:03 +0000 Subject: [PATCH 1/2] feat: pluggable i18n with English/Chinese language switcher in Settings Introduces a locale bundle system that makes UI language switchable at runtime and trivially extensible to any future language. Architecture: - static/i18n.js: LOCALES object with 'en' and 'zh' bundles, t(key) helper with English fallback, setLocale()/loadLocale() for persistence via localStorage. Adding a new language = adding one object. - api/config.py: 'language' setting (default 'en'), BCP-47 validation - api/routes.py: _LOGIN_LOCALE dict for server-rendered login page; template placeholders substituted at request time from saved setting - static/index.html: loads i18n.js first (before other scripts); adds Language dropdown to Settings panel, auto-populated from LOCALES Wiring: - boot.js: applies server-persisted locale at startup (after /api/settings fetch); speech recognition lang follows _locale._speech - panels.js: populates Language dropdown from LOCALES on settings open; saves + applies locale on Save Settings - All JS files: hardcoded user-facing strings replaced with t() calls Coverage: - test_sprint20.py: relaxed recognition.lang assertion to accept dynamic locale-driven assignment (behavior unchanged for English default) - 499/499 tests pass Closes #177 (incorporates Chinese translations as a proper locale bundle rather than hardcoded strings, so English default is fully preserved) --- api/config.py | 6 + api/routes.py | 50 +++++-- static/boot.js | 22 ++-- static/commands.js | 74 +++++------ static/i18n.js | 289 +++++++++++++++++++++++++++++++++++++++++ static/index.html | 5 + static/panels.js | 29 ++++- static/ui.js | 70 +++++----- static/workspace.js | 16 +-- tests/test_sprint20.py | 9 +- 10 files changed, 464 insertions(+), 106 deletions(-) create mode 100644 static/i18n.js diff --git a/api/config.py b/api/config.py index f9db9ca..86c9777 100644 --- a/api/config.py +++ b/api/config.py @@ -777,6 +777,7 @@ _SETTINGS_DEFAULTS = { 'sync_to_insights': False, # mirror WebUI token usage to state.db for /insights 'check_for_updates': True, # check if webui/agent repos are behind upstream 'theme': 'dark', # active UI theme name (no enum gate -- allows custom themes) + 'language': 'en', # UI locale code; must match a key in static/i18n.js LOCALES 'bot_name': os.getenv('HERMES_WEBUI_BOT_NAME', 'Hermes'), # display name for the assistant 'sound_enabled': False, # play notification sound when assistant finishes 'notifications_enabled': False, # browser notification when tab is in background @@ -800,6 +801,8 @@ _SETTINGS_ENUM_VALUES = { 'send_key': {'enter', 'ctrl+enter'}, } _SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights', 'check_for_updates', 'sound_enabled', 'notifications_enabled'} +# Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr') +_SETTINGS_LANG_RE = __import__('re').compile(r'^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$') def save_settings(settings: dict) -> dict: """Save settings to disk. Returns the merged settings. Ignores unknown keys.""" @@ -818,6 +821,9 @@ def save_settings(settings: dict) -> dict: # Validate enum-constrained keys if k in _SETTINGS_ENUM_VALUES and v not in _SETTINGS_ENUM_VALUES[k]: continue + # Validate language codes (BCP-47-like: 'en', 'zh', 'fr', 'zh-CN') + if k == 'language' and (not isinstance(v, str) or not _SETTINGS_LANG_RE.match(v)): + continue # Coerce bool keys if k in _SETTINGS_BOOL_KEYS: v = bool(v) diff --git a/api/routes.py b/api/routes.py index ff2ed9e..6cc1cf5 100644 --- a/api/routes.py +++ b/api/routes.py @@ -72,10 +72,28 @@ except ImportError: _permanent_approved = set() +# ── Login page locale strings ───────────────────────────────────────────────── +# Add entries here to support more languages on the login page. +# The key must match the 'language' setting value (from static/i18n.js LOCALES). +_LOGIN_LOCALE = { + 'en': { + 'lang': 'en', 'title': 'Sign in', + 'subtitle': 'Enter your password to continue', + 'placeholder': 'Password', 'btn': 'Sign in', + 'invalid_pw': 'Invalid password', 'conn_failed': 'Connection failed', + }, + 'zh': { + 'lang': 'zh-CN', 'title': '\u767b\u5f55', + 'subtitle': '\u8f93\u5165\u5bc6\u7801\u7ee7\u7eed\u4f7f\u7528', + 'placeholder': '\u5bc6\u7801', 'btn': '\u767b\u5f55', + 'invalid_pw': '\u5bc6\u7801\u9519\u8bef', 'conn_failed': '\u8fde\u63a5\u5931\u8d25', + }, +} + # ── Login page (self-contained, no external deps) ──────────────────────────── _LOGIN_PAGE_HTML = ''' - -{{BOT_NAME}} — Sign in + +{{BOT_NAME}} — {{LOGIN_TITLE}}