diff --git a/CHANGELOG.md b/CHANGELOG.md index 3101ea1..6bd2f21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Hermes Web UI -- Changelog +## [v0.50.67] — 2026-04-16 + +### Added +- **Subpath mount support** — Hermes WebUI can now be served behind a reverse proxy at any subpath (e.g. `/hermes-webui/` via Tailscale Serve, nginx, or Caddy). A dynamic `` is injected as the first script in ``, and all client-side URL references are converted from absolute to relative. The server-side route handlers are unchanged. No configuration needed — works transparently for both root (`/`) and subpath deployments. (PR #588 by @vcavichini) + ## [v0.50.66] — 2026-04-16 ### Fixed diff --git a/static/boot.js b/static/boot.js index ac6c4a8..13b2557 100644 --- a/static/boot.js +++ b/static/boot.js @@ -2,7 +2,7 @@ async function cancelStream(){ const streamId = S.activeStreamId; if(!streamId) return; try{ - await fetch(new URL(`/api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{credentials:'include'}); + await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,location.href).href,{credentials:'include'}); }catch(e){/* cancel request failed — cleanup below still runs */} // Clear status unconditionally after the cancel request completes. // The SSE cancel event may also fire, but if the connection is already @@ -226,7 +226,7 @@ $('btnAttach').onclick=()=>$('fileInput').click(); form.append('file',new File([blob],`voice-input.${ext}`,{type:blob.type||`audio/${ext}`})); setComposerStatus('Transcribing…'); try{ - const res=await fetch('/api/transcribe',{method:'POST',body:form}); + const res=await fetch('api/transcribe',{method:'POST',body:form}); const data=await res.json().catch(()=>({})); if(!res.ok) throw new Error(data.error||'Transcription failed'); _commitTranscript(data.transcript||''); diff --git a/static/index.html b/static/index.html index e3282ae..7d4712d 100644 --- a/static/index.html +++ b/static/index.html @@ -4,8 +4,10 @@ Hermes + + - + @@ -553,7 +555,7 @@
System
Instance version and access controls.
- v0.50.66 + v0.50.67
@@ -587,15 +589,15 @@
- - - - - - - - - - + + + + + + + + + + diff --git a/static/login.js b/static/login.js index 7ea5eb4..9345e45 100644 --- a/static/login.js +++ b/static/login.js @@ -26,7 +26,7 @@ document.addEventListener('DOMContentLoaded', function () { var pw = input.value; hideErr(); try { - var res = await fetch('/api/auth/login', { + var res = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }), @@ -35,7 +35,7 @@ document.addEventListener('DOMContentLoaded', function () { var data = {}; try { data = await res.json(); } catch (_) {} if (res.ok && data.ok) { - window.location.href = '/'; + window.location.href = './'; } else { showErr(data.error || invalidPw); } diff --git a/static/messages.js b/static/messages.js index fcdf71f..a9604ea 100644 --- a/static/messages.js +++ b/static/messages.js @@ -533,7 +533,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`); if(st.active){ setComposerStatus('Reconnected'); - _wireSSE(new EventSource(new URL(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{withCredentials:true})); + _wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.href).href,{withCredentials:true})); return; } }catch(_){} @@ -633,7 +633,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } }catch(_){} } - _wireSSE(new EventSource(new URL(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{withCredentials:true})); + _wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.href).href,{withCredentials:true})); })(); } diff --git a/static/panels.js b/static/panels.js index 201ad6e..849983f 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1347,7 +1347,7 @@ async function saveSettings(andClose){ async function signOut(){ try{ await api('/api/auth/logout',{method:'POST',body:'{}'}); - window.location.href='/login'; + window.location.href='login'; }catch(e){ showToast(t('sign_out_failed')+e.message); } diff --git a/static/sessions.js b/static/sessions.js index 2635dce..ff509e0 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -344,7 +344,7 @@ function startGatewaySSE(){ stopGatewaySSE(); if(!window._showCliSessions) return; try{ - _gatewaySSE = new EventSource('/api/sessions/gateway/stream'); + _gatewaySSE = new EventSource('api/sessions/gateway/stream'); _gatewaySSE.addEventListener('sessions_changed', (ev) => { try{ const data = JSON.parse(ev.data); diff --git a/static/ui.js b/static/ui.js index e6342df..2be7178 100644 --- a/static/ui.js +++ b/static/ui.js @@ -65,7 +65,7 @@ async function populateModelDropdown(){ const sel=$('modelSelect'); if(!sel) return; try{ - const data=await fetch(new URL('/api/models',location.origin).href,{credentials:'include'}).then(r=>r.json()); + const data=await fetch(new URL('api/models',location.href).href,{credentials:'include'}).then(r=>r.json()); if(!data.groups||!data.groups.length) return; // keep HTML defaults // Store active provider globally so the send path can warn on mismatch window._activeProvider=data.active_provider||null; @@ -108,7 +108,7 @@ async function _fetchLiveModels(provider, sel){ // All providers now supported via agent's provider_model_ids() — no exclusions needed if(_liveModelCache[provider]) return; // already fetched this session try{ - const url=new URL('/api/models/live',location.origin); + const url=new URL('api/models/live',location.href); url.searchParams.set('provider',provider); const data=await fetch(url.href,{credentials:'include'}).then(r=>r.json()); if(!data.models||!data.models.length) return; @@ -582,7 +582,7 @@ function renderMd(raw){ return `${esc(ref)}`; } // Local file path - const apiUrl='/api/media?path='+encodeURIComponent(ref); + const apiUrl='api/media?path='+encodeURIComponent(ref); if(_IMAGE_EXTS.test(ref)){ return `${esc(ref.split('/').pop())}`; } @@ -1854,7 +1854,7 @@ async function uploadPendingFiles(){ const f=S.pendingFiles[i];const fd=new FormData(); fd.append('session_id',S.session.session_id);fd.append('file',f,f.name); try{ - const res=await fetch(new URL('/api/upload',location.origin).href,{method:'POST',credentials:'include',body:fd}); + const res=await fetch(new URL('api/upload',location.href).href,{method:'POST',credentials:'include',body:fd}); if(!res.ok){const err=await res.text();throw new Error(err);} const data=await res.json(); if(data.error)throw new Error(data.error); diff --git a/static/workspace.js b/static/workspace.js index 05fb745..c21a2ef 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -1,5 +1,7 @@ async function api(path,opts={}){ - const url=new URL(path,location.origin); + // Strip leading slash so URL resolves relative to location.href (supports subpath mounts) + const rel = path.startsWith('/') ? path.slice(1) : path; + const url=new URL(rel,location.href); const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts}); if(!res.ok){ const text=await res.text(); @@ -204,7 +206,7 @@ async function openFile(path){ if(IMAGE_EXTS.has(ext)){ // Image: load via raw endpoint, show as showPreview('image'); - const url=`/api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`; + const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`; $('previewImg').alt=path; $('previewImg').src=url; $('previewImg').onerror=()=>setStatus(t('image_load_failed')); @@ -238,7 +240,7 @@ async function openFile(path){ function downloadFile(path){ if(!S.session)return; // Trigger browser download via the raw file endpoint with content-disposition attachment - const url=`/api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`; + const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`; const filename=path.split('/').pop(); const a=document.createElement('a'); a.href=url;a.download=filename; diff --git a/tests/test_media_inline.py b/tests/test_media_inline.py index 032216e..30e353f 100644 --- a/tests/test_media_inline.py +++ b/tests/test_media_inline.py @@ -47,8 +47,8 @@ class TestMediaRenderMdStash(unittest.TestCase): "restore pass must produce download link for non-image files") def test_media_api_url_pattern(self): - self.assertIn("/api/media?path=", UI_JS, - "renderMd must build /api/media?path=... URL for local files") + self.assertIn("api/media?path=", UI_JS, + "renderMd must build api/media?path=... URL for local files") def test_media_stash_uses_null_byte_token(self): self.assertIn("\\x00D", UI_JS, diff --git a/tests/test_onboarding_static.py b/tests/test_onboarding_static.py index ed372e1..f61f3a9 100644 --- a/tests/test_onboarding_static.py +++ b/tests/test_onboarding_static.py @@ -13,7 +13,7 @@ def test_index_contains_onboarding_overlay_markup(): assert 'id="onboardingOverlay"' in html assert 'id="onboardingBody"' in html assert 'id="onboardingNextBtn"' in html - assert 'src="/static/onboarding.js"' in html + assert 'src="static/onboarding.js"' in html def test_onboarding_css_rules_exist(): diff --git a/tests/test_sprint10.py b/tests/test_sprint10.py index a976d3f..1861fbc 100644 --- a/tests/test_sprint10.py +++ b/tests/test_sprint10.py @@ -94,7 +94,7 @@ def test_cancel_button_in_html(cleanup_test_sessions): def test_cancel_function_in_boot_js(cleanup_test_sessions): src, _ = get_text("/static/boot.js") assert "async function cancelStream(" in src - assert "/api/chat/cancel" in src + assert "api/chat/cancel" in src # ── Cron history ─────────────────────────────────────────────────────────── diff --git a/tests/test_sprint20.py b/tests/test_sprint20.py index b523447..3988515 100644 --- a/tests/test_sprint20.py +++ b/tests/test_sprint20.py @@ -329,7 +329,7 @@ def test_boot_js_browser_unsupported_guard_uses_fallback_capabilities(): def test_boot_js_media_recorder_fallback_posts_to_transcribe_api(): """Desktop fallback must send recorded audio to /api/transcribe for transcription.""" js, _ = get_text("/static/boot.js") - assert '/api/transcribe' in js + assert 'api/transcribe' in js assert 'fetch(' in js diff --git a/tests/test_sprint9.py b/tests/test_sprint9.py index d2ed7d9..7871197 100644 --- a/tests/test_sprint9.py +++ b/tests/test_sprint9.py @@ -67,20 +67,20 @@ def test_boot_js_served(cleanup_test_sessions): def test_app_js_no_longer_referenced_in_html(cleanup_test_sessions): """index.html must not reference the old monolithic app.js.""" html = get_text("/") - assert 'src="/static/app.js"' not in html + assert 'src="static/app.js"' not in html # All 6 modules must be present for module in ["ui.js", "workspace.js", "sessions.js", "messages.js", "panels.js", "boot.js"]: - assert f'src="/static/{module}"' in html, f"Missing {module} in index.html" + assert f'src="static/{module}"' in html, f"Missing {module} in index.html" def test_module_load_order_correct(cleanup_test_sessions): """ui.js must appear before sessions.js which must appear before boot.js.""" html = get_text("/") - ui_pos = html.find('src="/static/ui.js"') - ws_pos = html.find('src="/static/workspace.js"') - sess_pos = html.find('src="/static/sessions.js"') - msg_pos = html.find('src="/static/messages.js"') - panels_pos = html.find('src="/static/panels.js"') - boot_pos = html.find('src="/static/boot.js"') + ui_pos = html.find('src="static/ui.js"') + ws_pos = html.find('src="static/workspace.js"') + sess_pos = html.find('src="static/sessions.js"') + msg_pos = html.find('src="static/messages.js"') + panels_pos = html.find('src="static/panels.js"') + boot_pos = html.find('src="static/boot.js"') assert ui_pos < ws_pos < sess_pos < msg_pos < panels_pos < boot_pos def test_no_duplicate_function_definitions(cleanup_test_sessions):