feat: support subpath mount via reverse proxy — v0.50.67 (PR #588 by @vcavichini)
Squash-merges feature from PR #588 by @vcavichini. Dynamic <base href> injection + api() helper slash-stripping enables deploying hermes-webui behind a reverse proxy at any subpath without configuration. Also fixes pre-existing bug: api/upload was using location.origin instead of location.href (closes #596). Co-authored-by: vcavichini <vcavichini@users.noreply.github.com>
This commit is contained in:
@@ -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 `<base href>` is injected as the first script in `<head>`, 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
|
||||
|
||||
@@ -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||'');
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Hermes</title>
|
||||
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash) -->
|
||||
<script>(function(){var p=location.pathname.endsWith('/')?location.pathname:(location.pathname.replace(/\/[^\/]*$/,'/')||'/');document.write('<base href="'+location.origin+p+'">');})()</script>
|
||||
<script>(function(){var t=localStorage.getItem('hermes-theme');if(t==='system'){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';}if(t&&t!=='dark')document.documentElement.dataset.theme=t;})()</script>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="static/style.css">
|
||||
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" integrity="sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" crossorigin="anonymous">
|
||||
<!-- Prism.js syntax highlighting (loaded async, non-blocking) -->
|
||||
@@ -553,7 +555,7 @@
|
||||
<div class="settings-section-title">System</div>
|
||||
<div class="settings-section-meta">Instance version and access controls.</div>
|
||||
</div>
|
||||
<span class="settings-version-badge">v0.50.66</span>
|
||||
<span class="settings-version-badge">v0.50.67</span>
|
||||
</div>
|
||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||
@@ -587,15 +589,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="/static/i18n.js"></script>
|
||||
<script src="/static/icons.js"></script>
|
||||
<script src="/static/ui.js"></script>
|
||||
<script src="/static/workspace.js"></script>
|
||||
<script src="/static/sessions.js"></script>
|
||||
<script src="/static/commands.js"></script>
|
||||
<script src="/static/messages.js"></script>
|
||||
<script src="/static/panels.js"></script>
|
||||
<script src="/static/onboarding.js"></script>
|
||||
<script src="/static/boot.js"></script>
|
||||
<script src="static/i18n.js"></script>
|
||||
<script src="static/icons.js"></script>
|
||||
<script src="static/ui.js"></script>
|
||||
<script src="static/workspace.js"></script>
|
||||
<script src="static/sessions.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/messages.js"></script>
|
||||
<script src="static/panels.js"></script>
|
||||
<script src="static/onboarding.js"></script>
|
||||
<script src="static/boot.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}));
|
||||
})();
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 `<a href="${esc(ref)}" target="_blank" rel="noopener">${esc(ref)}</a>`;
|
||||
}
|
||||
// Local file path
|
||||
const apiUrl='/api/media?path='+encodeURIComponent(ref);
|
||||
const apiUrl='api/media?path='+encodeURIComponent(ref);
|
||||
if(_IMAGE_EXTS.test(ref)){
|
||||
return `<img class="msg-media-img" src="${esc(apiUrl)}" alt="${esc(ref.split('/').pop())}" loading="lazy" onclick="this.classList.toggle('msg-media-img--full')">`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <img>
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user