diff --git a/.env.example b/.env.example index c5381d2..938f023 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,6 @@ # Path to your Hermes config.yaml (for toolsets and model config) # HERMES_CONFIG_PATH=~/.hermes/config.yaml + +# Display name for the assistant in the UI (default: Hermes) +# HERMES_WEBUI_BOT_NAME=Hermes diff --git a/api/config.py b/api/config.py index ba6b5de..bb79483 100644 --- a/api/config.py +++ b/api/config.py @@ -680,6 +680,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) + 'bot_name': os.getenv('HERMES_WEBUI_BOT_NAME', 'Hermes'), # display name for the assistant 'password_hash': None, # SHA-256 hash; None = auth disabled } diff --git a/api/routes.py b/api/routes.py index f50ef63..dd05eec 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2,6 +2,7 @@ Hermes Web UI -- Route handlers for GET and POST endpoints. Extracted from server.py (Sprint 11) so server.py is a thin shell. """ +import html as _html import json import os import queue @@ -56,7 +57,7 @@ except ImportError: # ── Login page (self-contained, no external deps) ──────────────────────────── _LOGIN_PAGE_HTML = ''' -Hermes — Sign in +{{BOT_NAME}} — Sign in
- -

Hermes

+ +

{{BOT_NAME}}

Enter your password to continue

bool: content_type='text/html; charset=utf-8') if parsed.path == '/login': - return t(handler, _LOGIN_PAGE_HTML, content_type='text/html; charset=utf-8') + _bn = _html.escape(load_settings().get('bot_name') or 'Hermes') + _page = _LOGIN_PAGE_HTML.replace('{{BOT_NAME}}', _bn).replace('{{BOT_NAME_INITIAL}}', _bn[0].upper()) + return t(handler, _page, content_type='text/html; charset=utf-8') if parsed.path == '/api/auth/status': from api.auth import is_auth_enabled, parse_cookie, verify_session @@ -523,6 +526,8 @@ def handle_post(handler, parsed) -> bool: # ── Settings (POST) ── if parsed.path == '/api/settings': + if 'bot_name' in body: + body['bot_name'] = (str(body['bot_name']) or '').strip() or 'Hermes' saved = save_settings(body) saved.pop('password_hash', None) # never expose hash to client return j(handler, saved) diff --git a/static/boot.js b/static/boot.js index 4e74ff8..e6d6a5e 100644 --- a/static/boot.js +++ b/static/boot.js @@ -306,10 +306,23 @@ document.querySelectorAll('.suggestion').forEach(btn=>{ }; })(); +function applyBotName(){ + const name=window._botName||'Hermes'; + document.title=name; + const sidebarH1=document.querySelector('.sidebar-header h1'); + if(sidebarH1) sidebarH1.textContent=name; + const logo=document.querySelector('.sidebar-header .logo'); + if(logo) logo.textContent=name.charAt(0).toUpperCase(); + const topbarTitle=$('topbarTitle'); + if(topbarTitle && (!S.session)) topbarTitle.textContent=name; + const msg=$('msg'); + if(msg) msg.placeholder='Message '+name+'\u2026'; +} + (async()=>{ // Load send key preference let _bootSettings={}; - try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;_bootSettings={check_for_updates:false};} + try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;window._botName=s.bot_name||'Hermes';const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);applyBotName();}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;window._botName='Hermes';_bootSettings={check_for_updates:false};} // Non-blocking update check (fire-and-forget, once per tab session) // ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards) const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1'; diff --git a/static/index.html b/static/index.html index c55d7e3..5157d0a 100644 --- a/static/index.html +++ b/static/index.html @@ -372,6 +372,11 @@
Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.
+
+ +
Display name for the assistant throughout the UI. Defaults to Hermes.
+ +
Enter a new password to set or change it. Leave blank to keep current setting.
diff --git a/static/messages.js b/static/messages.js index 257994e..284ca5b 100644 --- a/static/messages.js +++ b/static/messages.js @@ -93,8 +93,9 @@ async function send(){ assistantRow=document.createElement('div');assistantRow.className='msg-row'; assistantBody=document.createElement('div');assistantBody.className='msg-body'; const role=document.createElement('div');role.className='msg-role assistant'; - const icon=document.createElement('div');icon.className='role-icon assistant';icon.textContent='H'; - const lbl=document.createElement('span');lbl.style.fontSize='12px';lbl.textContent='Hermes'; + const _bn=window._botName||'Hermes'; + const icon=document.createElement('div');icon.className='role-icon assistant';icon.textContent=_bn.charAt(0).toUpperCase(); + const lbl=document.createElement('span');lbl.style.fontSize='12px';lbl.textContent=_bn; role.appendChild(icon);role.appendChild(lbl); assistantRow.appendChild(role);assistantRow.appendChild(assistantBody); $('msgInner').appendChild(assistantRow); diff --git a/static/panels.js b/static/panels.js index bb3ea9a..3866366 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1009,6 +1009,9 @@ async function loadSettingsPanel(){ if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_markSettingsDirty,{once:false});} const updateCb=$('settingsCheckUpdates'); if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_markSettingsDirty,{once:false});} + // Bot name + const botNameField=$('settingsBotName'); + if(botNameField){botNameField.value=settings.bot_name||'Hermes';botNameField.addEventListener('input',_markSettingsDirty,{once:false});} // Password field: always blank (we don't send hash back) const pwField=$('settingsPassword'); if(pwField){pwField.value='';pwField.addEventListener('input',_markSettingsDirty,{once:false});} @@ -1042,6 +1045,8 @@ async function saveSettings(andClose){ body.show_cli_sessions=showCliSessions; body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked; body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked; + const botName=(($('settingsBotName')||{}).value||'').trim(); + body.bot_name=botName||'Hermes'; // Password: only act if the field has content; blank = leave auth unchanged if(pw && pw.trim()){ try{ @@ -1060,6 +1065,8 @@ async function saveSettings(andClose){ window._sendKey=sendKey||'enter'; window._showTokenUsage=showTokenUsage; window._showCliSessions=showCliSessions; + window._botName=body.bot_name; + if(typeof applyBotName==='function') applyBotName(); _settingsDirty=false; _settingsThemeOnOpen=theme; const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none'; renderMessages(); diff --git a/static/sessions.js b/static/sessions.js index e655745..ed0c0f9 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -45,7 +45,7 @@ async function loadSession(sid){ if(tc&&tc.name) appendLiveToolCard(tc); } syncTopbar();await loadDir('.');renderMessages();appendThinking(); - setBusy(true);setStatus('Hermes is thinking\u2026'); + setBusy(true);setStatus((window._botName||'Hermes')+' is thinking\u2026'); startApprovalPolling(sid); }else{ MSG_QUEUE.length=0;updateQueueBadge(); // clear queue for the viewed session @@ -429,7 +429,7 @@ async function deleteSession(sid){ if(remaining.sessions&&remaining.sessions.length){ await loadSession(remaining.sessions[0].session_id); }else{ - $('topbarTitle').textContent='Hermes'; + $('topbarTitle').textContent=window._botName||'Hermes'; $('topbarMeta').textContent='Start a new conversation'; $('msgInner').innerHTML=''; $('emptyState').style.display=''; diff --git a/static/ui.js b/static/ui.js index fe78f19..352357c 100644 --- a/static/ui.js +++ b/static/ui.js @@ -237,7 +237,7 @@ function setStatus(t){ txt.textContent=t; bar.style.display=''; // Show dismiss X only for static/error messages, not transient busy ones - const transient = t.endsWith('…') || t === 'Hermes is thinking…'; + const transient = t.endsWith('…') || t === (window._botName||'Hermes')+' is thinking\u2026'; if(dismiss)dismiss.style.display=(!transient && !S.busy)?'inline':'none'; } } @@ -402,7 +402,7 @@ async function checkInflightOnBoot(sid) { function syncTopbar(){ if(!S.session){ - document.title='Hermes'; + document.title=window._botName||'Hermes'; // Show default workspace name even without a session const sidebarName=$('sidebarWsName'); if(sidebarName && sidebarName.textContent==='Workspace'){ @@ -412,7 +412,7 @@ function syncTopbar(){ } const sessionTitle=S.session.title||'Untitled'; $('topbarTitle').textContent=sessionTitle; - document.title=sessionTitle+' \u2014 Hermes'; + document.title=sessionTitle+' \u2014 '+(window._botName||'Hermes'); const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool'); $('topbarMeta').textContent=`${vis.length} messages`; // If a profile switch just happened, apply its model rather than the session's stale value. @@ -505,7 +505,8 @@ function renderMessages(){ const retryBtn = isLastAssistant ? `` : ''; const tsVal=m._ts||m.timestamp; const tsTitle=tsVal?new Date(tsVal*1000).toLocaleString():''; - row.innerHTML=`
${isUser?'Y':'H'}
${isUser?'You':'Hermes'}${tsTitle?`${new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}`:''}${editBtn}${retryBtn}
${filesHtml}
${bodyHtml}
`; + const _bn=window._botName||'Hermes'; + row.innerHTML=`
${isUser?'Y':esc(_bn.charAt(0).toUpperCase())}
${isUser?'You':esc(_bn)}${tsTitle?`${new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}`:''}${editBtn}${retryBtn}
${filesHtml}
${bodyHtml}
`; row.dataset.rawText = String(content).trim(); inner.appendChild(row); } diff --git a/tests/test_sprint27.py b/tests/test_sprint27.py new file mode 100644 index 0000000..bd5386e --- /dev/null +++ b/tests/test_sprint27.py @@ -0,0 +1,136 @@ +""" +Sprint 27 Tests: configurable assistant display name (bot_name). +Tests cover settings API round-trip, empty/missing input defaults, +login page rendering, and server-side sanitization. +""" +import json +import urllib.error +import urllib.request + +BASE = "http://127.0.0.1:8788" + + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + + +def get_raw(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return r.read().decode(), r.status + + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, + 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 + + +# ── Default value ───────────────────────────────────────────────────────── + +def test_settings_default_bot_name(): + """GET /api/settings should return bot_name defaulting to 'Hermes'.""" + d, status = get("/api/settings") + assert status == 200 + assert "bot_name" in d + assert d["bot_name"] == "Hermes" + + +# ── Round-trip ──────────────────────────────────────────────────────────── + +def test_settings_set_bot_name(): + """POST /api/settings with bot_name should persist and round-trip.""" + try: + d, status = post("/api/settings", {"bot_name": "TestBot"}) + assert status == 200 + assert d.get("bot_name") == "TestBot" + d2, _ = get("/api/settings") + assert d2.get("bot_name") == "TestBot" + finally: + post("/api/settings", {"bot_name": "Hermes"}) + + +def test_settings_bot_name_special_chars(): + """bot_name with safe special characters should persist correctly.""" + try: + d, status = post("/api/settings", {"bot_name": "My Assistant 2.0"}) + assert status == 200 + d2, _ = get("/api/settings") + assert d2.get("bot_name") == "My Assistant 2.0" + finally: + post("/api/settings", {"bot_name": "Hermes"}) + + +# ── Server-side sanitization ────────────────────────────────────────────── + +def test_settings_empty_bot_name_defaults_to_hermes(): + """Posting an empty bot_name should default to 'Hermes' server-side.""" + try: + d, status = post("/api/settings", {"bot_name": ""}) + assert status == 200 + assert d.get("bot_name") == "Hermes" + d2, _ = get("/api/settings") + assert d2.get("bot_name") == "Hermes" + finally: + post("/api/settings", {"bot_name": "Hermes"}) + + +def test_settings_whitespace_bot_name_defaults_to_hermes(): + """Posting a whitespace-only bot_name should default to 'Hermes'.""" + try: + d, status = post("/api/settings", {"bot_name": " "}) + assert status == 200 + assert d.get("bot_name") == "Hermes" + finally: + post("/api/settings", {"bot_name": "Hermes"}) + + +# ── Login page rendering ────────────────────────────────────────────────── + +def test_login_page_shows_default_bot_name(): + """GET /login should contain 'Hermes' in title and h1 when default.""" + html, status = get_raw("/login") + assert status == 200 + assert "Hermes" in html + assert "<h1>Hermes</h1>" in html + + +def test_login_page_shows_custom_bot_name(): + """GET /login should reflect the configured bot_name.""" + try: + post("/api/settings", {"bot_name": "Aria"}) + html, status = get_raw("/login") + assert status == 200 + assert "<title>Aria" in html + assert "<h1>Aria</h1>" in html + finally: + post("/api/settings", {"bot_name": "Hermes"}) + + +def test_login_page_empty_name_does_not_crash(): + """Login page must not 500 even if somehow bot_name is empty in settings.""" + # Force an empty value by patching settings file directly — skipped here + # because the server-side guard in POST /api/settings prevents storing empty. + # Instead, verify that /login returns 200 reliably. + html, status = get_raw("/login") + assert status == 200 + assert "Sign in" in html + + +def test_login_page_xss_escaped(): + """bot_name with HTML special chars should be escaped in the login page.""" + try: + post("/api/settings", {"bot_name": "<script>alert(1)</script>"}) + html, status = get_raw("/login") + assert status == 200 + # Raw tag must not appear unescaped + assert "<script>alert(1)</script>" not in html + # Escaped form should appear + assert "<script>" in html + finally: + post("/api/settings", {"bot_name": "Hermes"})