feat: support reverse-proxy URL subpaths (llama-swap, SwarmUI) (#1122)

This commit is contained in:
Will Miao
2026-09-24 08:04:00 +08:00
parent 755e1a5bca
commit 2f9bd3ee7d
42 changed files with 731 additions and 103 deletions
+10 -9
View File
@@ -2,19 +2,20 @@
<html>
<head>
{% include 'components/base_path_bootstrap.html' %}
<title>{% block title %}{{ t('header.appTitle') }}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/loras_static/css/style.css?v={{ version }}">
<link rel="stylesheet" href="/loras_static/css/onboarding.css?v={{ version }}">
<link rel="stylesheet" href="/loras_static/vendor/flag-icons/flag-icons.min.css">
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/css/style.css?v={{ version }}">
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/css/onboarding.css?v={{ version }}">
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/vendor/flag-icons/flag-icons.min.css">
{% block page_css %}{% endblock %}
<link rel="stylesheet" href="/loras_static/vendor/font-awesome/css/all.min.css"
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/vendor/font-awesome/css/all.min.css"
crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="icon" type="image/png" sizes="32x32" href="/loras_static/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/loras_static/images/favicon-16x16.png">
<link rel="manifest" href="/loras_static/images/site.webmanifest">
<link rel="icon" type="image/png" sizes="32x32" href="{{ rel_prefix }}loras_static/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="{{ rel_prefix }}loras_static/images/favicon-16x16.png">
<link rel="manifest" href="{{ rel_prefix }}loras_static/images/site.webmanifest">
<link rel="preload" as="font" type="font/woff2" href="/loras_static/vendor/font-awesome/webfonts/fa-solid-900.woff2" crossorigin>
<link rel="preload" as="font" type="font/woff2" href="{{ rel_prefix }}loras_static/vendor/font-awesome/webfonts/fa-solid-900.woff2" crossorigin>
<!-- 添加性能监控 -->
<script>
@@ -102,7 +103,7 @@
{% if is_initializing %}
<!-- Load initialization JavaScript -->
<script type="module" src="/loras_static/js/components/initialization.js?v={{ version }}"></script>
<script type="module" src="{{ rel_prefix }}loras_static/js/components/initialization.js?v={{ version }}"></script>
{% else %}
{% block main_script %}{% endblock %}
{% endif %}
+1 -1
View File
@@ -75,5 +75,5 @@
{% endblock %}
{% block main_script %}
<script type="module" src="/loras_static/js/checkpoints.js?v={{ version }}"></script>
<script type="module" src="{{ rel_prefix }}loras_static/js/checkpoints.js?v={{ version }}"></script>
{% endblock %}
@@ -0,0 +1,195 @@
{#
Base-path bootstrap. Must be the FIRST element in <head>: it detects the
reverse-proxy subpath (e.g. "/comfyui" when served via llama-swap, or
"/ComfyBackendDirect" via SwarmUI) from the current page URL and, only when
a prefix exists, patches fetch/XHR/WebSocket/innerHTML/DOM URL setters so
that root-absolute URLs ("/api/lm/...", "/loras_static/...") generated
anywhere in the frontend or in backend JSON payloads get the prefix
prepended. When no prefix is detected (normal or standalone deployment)
nothing is patched and behavior is byte-identical to before.
#}
<script>
(function () {
'use strict';
// Manager page routes as the backend sees them (proxies strip the
// prefix, so the page path always ends with one of these suffixes).
// Longest first so "/loras/recipes" wins over "/loras".
var KNOWN_PAGES = ['/loras/recipes', '/loras', '/checkpoints', '/embeddings', '/other', '/statistics'];
var path = window.location.pathname.replace(/\/+$/, '') || '/';
var prefix = '';
for (var i = 0; i < KNOWN_PAGES.length; i++) {
var page = KNOWN_PAGES[i];
if (path === page || (path.length > page.length && path.endsWith(page))) {
prefix = path.slice(0, path.length - page.length);
break;
}
}
window.LM_BASE_PATH = prefix;
if (!prefix) {
return;
}
var prefixBody = prefix.slice(1);
function prefixPath(p) {
if (p.charAt(0) !== '/' || p.charAt(1) === '/') {
return p;
}
// Already prefixed (e.g. markup re-serialized from the DOM).
if (p === prefix || p.indexOf(prefix + '/') === 0) {
return p;
}
return prefix + p;
}
function prefixUrl(url) {
if (typeof url !== 'string') {
return url;
}
if (url.charAt(0) === '/') {
return prefixPath(url);
}
// Absolute same-origin URL (e.g. from a Request object).
if (url.indexOf('http') === 0) {
try {
var u = new URL(url);
if (u.origin === window.location.origin) {
var prefixed = prefixPath(u.pathname);
if (prefixed !== u.pathname) {
u.pathname = prefixed;
return u.toString();
}
}
} catch (e) { /* not a parseable URL: leave untouched */ }
}
return url;
}
window.lmWithBasePath = prefixUrl;
// fetch()
if (window.fetch) {
var nativeFetch = window.fetch;
window.fetch = function (input, init) {
if (typeof input === 'string') {
input = prefixUrl(input);
} else if (typeof URL !== 'undefined' && input instanceof URL) {
// fetch(new URL('/api/...', location.origin)) bypasses the
// string check — coerce so same-origin URLs get prefixed.
input = prefixUrl(input.href);
} else if (typeof Request !== 'undefined' && input instanceof Request) {
var requestUrl = prefixUrl(input.url);
if (requestUrl !== input.url) {
input = new Request(requestUrl, input);
}
}
return nativeFetch.call(this, input, init);
};
}
// XMLHttpRequest
if (window.XMLHttpRequest) {
var nativeOpen = window.XMLHttpRequest.prototype.open;
window.XMLHttpRequest.prototype.open = function (method, url) {
arguments[1] = prefixUrl(typeof url === 'string' ? url : String(url));
return nativeOpen.apply(this, arguments);
};
}
// WebSocket
if (window.WebSocket) {
var NativeWebSocket = window.WebSocket;
var PatchedWebSocket = function (url, protocols) {
if (url && typeof url !== 'string' && url.href) {
url = url.href;
}
return protocols === undefined
? new NativeWebSocket(prefixUrl(url))
: new NativeWebSocket(prefixUrl(url), protocols);
};
PatchedWebSocket.prototype = NativeWebSocket.prototype;
PatchedWebSocket.CONNECTING = NativeWebSocket.CONNECTING;
PatchedWebSocket.OPEN = NativeWebSocket.OPEN;
PatchedWebSocket.CLOSING = NativeWebSocket.CLOSING;
PatchedWebSocket.CLOSED = NativeWebSocket.CLOSED;
window.WebSocket = PatchedWebSocket;
}
// Markup injected via innerHTML/outerHTML/insertAdjacentHTML carries
// root-absolute URLs from backend JSON (preview URLs, placeholders) and
// inline handlers (onerror="this.src='/...'"): rewrite the attributes.
var ATTR_URL_RE = /((?:src|href|poster)\s*=\s*["'])(\/)(?!\/)/g;
function rewriteMarkup(html) {
if (typeof html !== 'string' || html.indexOf('/') === -1) {
return html;
}
return html.replace(ATTR_URL_RE, function (match, head, slash, offset, whole) {
var rest = whole.slice(offset + match.length);
if (rest === prefixBody || rest.indexOf(prefixBody + '/') === 0) {
return match;
}
return head + prefix + '/';
});
}
function patchMarkupProp(proto, prop) {
var desc = Object.getOwnPropertyDescriptor(proto, prop);
if (!desc || !desc.set) {
return;
}
Object.defineProperty(proto, prop, {
configurable: true,
enumerable: desc.enumerable,
get: desc.get,
set: function (value) { desc.set.call(this, rewriteMarkup(value)); },
});
}
if (window.Element) {
patchMarkupProp(window.Element.prototype, 'innerHTML');
patchMarkupProp(window.Element.prototype, 'outerHTML');
var nativeInsertAdjacentHTML = window.Element.prototype.insertAdjacentHTML;
if (nativeInsertAdjacentHTML) {
window.Element.prototype.insertAdjacentHTML = function (position, html) {
return nativeInsertAdjacentHTML.call(this, position, rewriteMarkup(html));
};
}
// Direct DOM assignments: img.src = model.preview_url, anchor.href, ...
var nativeSetAttribute = window.Element.prototype.setAttribute;
window.Element.prototype.setAttribute = function (name, value) {
if (typeof value === 'string' && /^(src|href|poster)$/i.test(name)) {
value = prefixUrl(value);
}
return nativeSetAttribute.call(this, name, value);
};
}
function patchUrlProp(proto, prop) {
if (!proto) {
return;
}
var desc = Object.getOwnPropertyDescriptor(proto, prop);
if (!desc || !desc.set) {
return;
}
Object.defineProperty(proto, prop, {
configurable: true,
enumerable: desc.enumerable,
get: desc.get,
set: function (value) { desc.set.call(this, prefixUrl(value)); },
});
}
patchUrlProp(window.HTMLImageElement && window.HTMLImageElement.prototype, 'src');
patchUrlProp(window.HTMLMediaElement && window.HTMLMediaElement.prototype, 'src');
patchUrlProp(window.HTMLSourceElement && window.HTMLSourceElement.prototype, 'src');
patchUrlProp(window.HTMLVideoElement && window.HTMLVideoElement.prototype, 'poster');
patchUrlProp(window.HTMLAnchorElement && window.HTMLAnchorElement.prototype, 'href');
patchUrlProp(window.HTMLScriptElement && window.HTMLScriptElement.prototype, 'src');
patchUrlProp(window.HTMLLinkElement && window.HTMLLinkElement.prototype, 'href');
})();
</script>
+8 -8
View File
@@ -3,8 +3,8 @@
<!-- Left section: Logo + Navigation -->
<div class="header-left">
<div class="header-branding">
<a href="/loras" class="logo-link">
<img src="/loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo">
<a href="{{ rel_prefix }}loras" class="logo-link">
<img src="{{ rel_prefix }}loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo">
<span class="app-title">{{ t('header.appTitle') }}</span>
</a>
</div>
@@ -23,26 +23,26 @@
{% set current_page = 'loras' %}
{% endif %}
<nav class="main-nav">
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
<a href="{{ rel_prefix }}loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
</a>
<a href="/loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}"
<a href="{{ rel_prefix }}loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}"
id="recipesNavItem">
<i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span>
</a>
<a href="/checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}"
<a href="{{ rel_prefix }}checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}"
id="checkpointsNavItem">
<i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span>
</a>
<a href="/embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}"
<a href="{{ rel_prefix }}embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}"
id="embeddingsNavItem">
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
</a>
<a href="/other" class="nav-item{% if current_path.startswith('/other') %} active{% endif %}{% if not settings.get('enable_other_models') %} nav-item--hidden{% endif %}"
<a href="{{ rel_prefix }}other" class="nav-item{% if current_path.startswith('/other') %} active{% endif %}{% if not settings.get('enable_other_models') %} nav-item--hidden{% endif %}"
id="otherNavItem">
<i class="fas fa-shapes"></i> <span>{{ t('header.navigation.other') }}</span>
</a>
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
<a href="{{ rel_prefix }}statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
id="statisticsNavItem">
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
</a>
+5 -5
View File
@@ -29,7 +29,7 @@
<div class="tip-carousel" id="tipCarousel">
<div class="tip-item active">
<div class="tip-image">
<img src="/loras_static/images/tips/civitai-api.png" alt="{{ t('initialization.tips.civitai.alt') }}"
<img src="{{ rel_prefix }}loras_static/images/tips/civitai-api.png" alt="{{ t('initialization.tips.civitai.alt') }}"
onerror="this.src='/loras_static/images/no-preview.png'">
</div>
<div class="tip-text">
@@ -39,7 +39,7 @@
</div>
<div class="tip-item">
<div class="tip-image">
<img src="/loras_static/images/tips/civitai-download.png" alt="{{ t('initialization.tips.download.alt') }}"
<img src="{{ rel_prefix }}loras_static/images/tips/civitai-download.png" alt="{{ t('initialization.tips.download.alt') }}"
onerror="this.src='/loras_static/images/no-preview.png'">
</div>
<div class="tip-text">
@@ -49,7 +49,7 @@
</div>
<div class="tip-item">
<div class="tip-image">
<img src="/loras_static/images/tips/recipes.png" alt="{{ t('initialization.tips.recipes.alt') }}"
<img src="{{ rel_prefix }}loras_static/images/tips/recipes.png" alt="{{ t('initialization.tips.recipes.alt') }}"
onerror="this.src='/loras_static/images/no-preview.png'">
</div>
<div class="tip-text">
@@ -59,7 +59,7 @@
</div>
<div class="tip-item">
<div class="tip-image">
<img src="/loras_static/images/tips/filter.png" alt="{{ t('initialization.tips.filter.alt') }}"
<img src="{{ rel_prefix }}loras_static/images/tips/filter.png" alt="{{ t('initialization.tips.filter.alt') }}"
onerror="this.src='/loras_static/images/no-preview.png'">
</div>
<div class="tip-text">
@@ -69,7 +69,7 @@
</div>
<div class="tip-item">
<div class="tip-image">
<img src="/loras_static/images/tips/search.webp" alt="{{ t('initialization.tips.search.alt') }}"
<img src="{{ rel_prefix }}loras_static/images/tips/search.webp" alt="{{ t('initialization.tips.search.alt') }}"
onerror="this.src='/loras_static/images/no-preview.png'">
</div>
<div class="tip-text">
+2 -2
View File
@@ -19,7 +19,7 @@
<h3>{{ t('help.gettingStarted.title') }}</h3>
<div class="video-container">
<div class="video-thumbnail" data-video-id="hvKw31YpE-U">
<img src="/loras_static/images/video-thumbnails/getting-started.jpg" alt="Getting Started with LoRA Manager">
<img src="{{ rel_prefix }}loras_static/images/video-thumbnails/getting-started.jpg" alt="Getting Started with LoRA Manager">
<div class="video-play-overlay">
<a href="https://www.youtube.com/watch?v=hvKw31YpE-U" target="_blank" class="external-link-btn">
<i class="fas fa-external-link-alt"></i>
@@ -62,7 +62,7 @@
<div class="video-item">
<div class="video-container">
<div class="video-thumbnail" data-video-id="videoseries?list=PLU2fMdHNl8ohz1u7Ke3ooOuMbU5Y4sgoj">
<img src="/loras_static/images/video-thumbnails/updates-playlist.jpg" alt="LoRA Manager Updates Playlist">
<img src="{{ rel_prefix }}loras_static/images/video-thumbnails/updates-playlist.jpg" alt="LoRA Manager Updates Playlist">
<div class="video-play-overlay">
<a href="https://www.youtube.com/playlist?list=PLU2fMdHNl8ohz1u7Ke3ooOuMbU5Y4sgoj" target="_blank" class="external-link-btn">
<i class="fas fa-external-link-alt"></i>
@@ -83,7 +83,7 @@
<i class="fas fa-chevron-down toggle-icon"></i>
</button>
<div class="qrcode-container" id="qrCodeContainer">
<img src="/loras_static/images/wechat-qr.webp" alt="WeChat Pay QR Code" class="qrcode-image">
<img src="{{ rel_prefix }}loras_static/images/wechat-qr.webp" alt="WeChat Pay QR Code" class="qrcode-image">
</div>
</div>
+1 -1
View File
@@ -71,5 +71,5 @@
{% endblock %}
{% block main_script %}
<script type="module" src="/loras_static/js/embeddings.js?v={{ version }}"></script>
<script type="module" src="{{ rel_prefix }}loras_static/js/embeddings.js?v={{ version }}"></script>
{% endblock %}
+1 -1
View File
@@ -27,6 +27,6 @@
{% block main_script %}
{% if not is_initializing %}
<script type="module" src="/loras_static/js/loras.js?v={{ version }}"></script>
<script type="module" src="{{ rel_prefix }}loras_static/js/loras.js?v={{ version }}"></script>
{% endif %}
{% endblock %}
+2 -2
View File
@@ -167,8 +167,8 @@
{% block main_script %}
{% if other_disabled or other_no_paths %}
<script type="module" src="/loras_static/js/other_disabled.js?v={{ version }}"></script>
<script type="module" src="{{ rel_prefix }}loras_static/js/other_disabled.js?v={{ version }}"></script>
{% else %}
<script type="module" src="/loras_static/js/other.js?v={{ version }}"></script>
<script type="module" src="{{ rel_prefix }}loras_static/js/other.js?v={{ version }}"></script>
{% endif %}
{% endblock %}
+5 -5
View File
@@ -4,10 +4,10 @@
{% block page_id %}recipes{% endblock %}
{% block page_css %}
<link rel="stylesheet" href="/loras_static/css/components/card.css?v={{ version }}">
<link rel="stylesheet" href="/loras_static/css/components/recipe-modal.css?v={{ version }}">
<link rel="stylesheet" href="/loras_static/css/components/import-modal.css?v={{ version }}">
<link rel="stylesheet" href="/loras_static/css/components/batch-import-modal.css?v={{ version }}">
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/css/components/card.css?v={{ version }}">
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/css/components/recipe-modal.css?v={{ version }}">
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/css/components/import-modal.css?v={{ version }}">
<link rel="stylesheet" href="{{ rel_prefix }}loras_static/css/components/batch-import-modal.css?v={{ version }}">
{% endblock %}
{% block additional_components %}
@@ -113,5 +113,5 @@
{% endblock %}
{% block main_script %}
<script type="module" src="/loras_static/js/recipes.js?v={{ version }}"></script>
<script type="module" src="{{ rel_prefix }}loras_static/js/recipes.js?v={{ version }}"></script>
{% endblock %}
+2 -2
View File
@@ -5,7 +5,7 @@
{% block head_scripts %}
<!-- Add Chart.js for statistics page -->
<script src="/loras_static/vendor/chart.js/chart.umd.js"></script>
<script src="{{ rel_prefix }}loras_static/vendor/chart.js/chart.umd.js"></script>
{% endblock %}
{% block init_title %}{{ t('initialization.statistics.title') }}{% endblock %}
@@ -192,6 +192,6 @@
{% block main_script %}
{% if not is_initializing %}
<script type="module" src="/loras_static/js/statistics.js?v={{ version }}"></script>
<script type="module" src="{{ rel_prefix }}loras_static/js/statistics.js?v={{ version }}"></script>
{% endif %}
{% endblock %}