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
+2
View File
@@ -50,6 +50,7 @@ from ...services.errors import RateLimitError, ResourceNotFoundError
from ...utils.civitai_utils import resolve_license_payload
from ...utils.file_utils import calculate_sha256
from ...utils.metadata_manager import MetadataManager
from ...utils.url_utils import relative_root_prefix
LICENSE_FIELDS = (
"allowNoCredit",
@@ -204,6 +205,7 @@ class ModelPageView:
"version": self._get_app_version(),
"provider_presets_json": json.dumps(PROVIDER_PRESETS),
"provider_models_json": "{}",
"rel_prefix": relative_root_prefix(request.path),
}
if not is_initializing:
+3
View File
@@ -36,6 +36,7 @@ from ...utils.constants import NSFW_LEVELS
from ...utils.directory_browser import WINDOWS_DRIVES_TOKEN, browse_directory
from ...utils.exif_utils import ExifUtils
from ...utils.recipe_open_stats import RecipeOpenStats
from ...utils.url_utils import relative_root_prefix
from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher
from ...services.websocket_manager import ws_manager as default_ws_manager
@@ -215,6 +216,7 @@ class RecipePageView:
settings=self._settings,
request=request,
t=self._server_i18n.get_translation,
rel_prefix=relative_root_prefix(request.path),
)
except Exception as cache_error: # pragma: no cover - logging path
self._logger.error("Error loading recipe cache data: %s", cache_error)
@@ -223,6 +225,7 @@ class RecipePageView:
settings=self._settings,
request=request,
t=self._server_i18n.get_translation,
rel_prefix=relative_root_prefix(request.path),
)
return web.Response(text=rendered, content_type="text/html")
except Exception as exc: # pragma: no cover - logging path
+2
View File
@@ -13,6 +13,7 @@ from ..services.server_i18n import server_i18n
from ..services.service_registry import ServiceRegistry
from ..services.model_query import normalize_sub_type, resolve_sub_type
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES
from ..utils.url_utils import relative_root_prefix
from ..utils.usage_stats import UsageStats
logger = logging.getLogger(__name__)
@@ -106,6 +107,7 @@ class StatsRoutes:
settings=settings_manager,
request=request,
t=server_i18n.get_translation,
rel_prefix=relative_root_prefix(request.path),
)
return web.Response(
+18
View File
@@ -0,0 +1,18 @@
"""Helpers for generating URLs that survive reverse-proxy subpath mounts."""
from __future__ import annotations
def relative_root_prefix(request_path: str) -> str:
"""Return the relative prefix ("", "../", ...) that takes a manager page
back to the mount root.
Templates reference assets and pages with relative URLs (e.g.
``{{ rel_prefix }}loras_static/...``) so the browser keeps whatever
subpath a reverse proxy (llama-swap, SwarmUI, ...) mounted ComfyUI under.
The backend only ever sees the stripped path, so the depth of the page
route is all that matters: "/loras" -> "", "/loras/recipes" -> "../".
"""
segments = [segment for segment in request_path.split("/") if segment]
return "../" * max(len(segments) - 1, 0)
@@ -9,6 +9,7 @@ import { moveManager } from '../../managers/MoveManager.js';
import { rematchModalManager } from '../../managers/RematchModalManager.js';
import { showRematchSummary } from '../RematchSummaryModal.js';
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
import { withBasePath } from '../../utils/basePath.js';
export class RecipeContextMenu extends BaseContextMenu {
constructor() {
@@ -180,7 +181,7 @@ export class RecipeContextMenu extends BaseContextMenu {
setSessionItem('filterRecipeName', recipe.title);
// Navigate to the LoRAs page
window.location.href = '/loras';
window.location.href = withBasePath('/loras');
} else {
showToast('recipes.contextMenu.viewLoras.noLorasFound', {}, 'info');
}
+3 -2
View File
@@ -7,6 +7,7 @@ import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { fetchRecipeDetails, updateRecipeMetadata, sendRecipeWorkflow, extractRecipeId } from '../api/recipeApi.js';
import { downloadManager } from '../managers/DownloadManager.js';
import { withBasePath } from '../utils/basePath.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
import { openMediaViewer } from './shared/MediaViewer.js';
import { showRecipeDeleteConfirmation } from './RecipeCard.js';
@@ -3231,7 +3232,7 @@ class RecipeModal {
setSessionItem('filterCheckpointRecipeName', this.currentRecipe.title);
}
window.location.href = '/checkpoints';
window.location.href = withBasePath('/checkpoints');
}
_getCheckpointHash(checkpoint) {
@@ -3281,7 +3282,7 @@ class RecipeModal {
}
// Navigate to the LoRAs page
window.location.href = '/loras';
window.location.href = withBasePath('/loras');
}
// Only in-library LoRA items are row-navigable: the row opens the local
+3 -2
View File
@@ -3,6 +3,7 @@
*/
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { withBasePath } from '../../utils/basePath.js';
/**
* Loads recipes that use the specified model and renders them in the tab.
@@ -356,7 +357,7 @@ function navigateToRecipesPage({ modelKind, displayName, modelHash }) {
}
// Directly navigate to recipes page
window.location.href = '/loras/recipes';
window.location.href = withBasePath('/loras/recipes');
}
/**
@@ -378,7 +379,7 @@ function navigateToRecipeDetails(recipeId) {
setSessionItem('viewRecipeId', recipeId);
// Directly navigate to recipes page
window.location.href = '/loras/recipes';
window.location.href = withBasePath('/loras/recipes');
}
function getRecipesEndpoint(modelKind) {
+16
View File
@@ -0,0 +1,16 @@
/**
* Base-path helpers for the manager pages.
*
* `window.LM_BASE_PATH` is set by the inline bootstrap in
* templates/components/base_path_bootstrap.html: it is the reverse-proxy
* subpath the manager is served under (e.g. "/comfyui"), or "" for normal
* and standalone deployments.
*/
export function getBasePath() {
return window.LM_BASE_PATH || '';
}
export function withBasePath(path) {
return `${getBasePath()}${path}`;
}
+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 %}
+21
View File
@@ -0,0 +1,21 @@
import { describe, it, expect, afterEach } from 'vitest';
import { getBasePath, withBasePath } from '../../../static/js/utils/basePath.js';
describe('static/js/utils/basePath.js', () => {
afterEach(() => {
delete window.LM_BASE_PATH;
});
it('returns empty base path when bootstrap did not set one', () => {
expect(getBasePath()).toBe('');
expect(withBasePath('/loras')).toBe('/loras');
});
it('prepends the detected base path', () => {
window.LM_BASE_PATH = '/comfyui';
expect(getBasePath()).toBe('/comfyui');
expect(withBasePath('/loras')).toBe('/comfyui/loras');
expect(withBasePath('/loras/recipes')).toBe('/comfyui/loras/recipes');
});
});
@@ -0,0 +1,217 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const extractBootstrapScript = () => {
const html = readFileSync(
resolve(repoRoot, 'templates/components/base_path_bootstrap.html'),
'utf8',
);
const match = html.match(/<script>([\s\S]*?)<\/script>/);
if (!match) {
throw new Error('bootstrap <script> block not found');
}
return match[1];
};
const PATCHED_PROTOS = () => [
[Element.prototype, ['innerHTML', 'outerHTML']],
[HTMLImageElement.prototype, ['src']],
[HTMLMediaElement.prototype, ['src']],
[HTMLSourceElement.prototype, ['src']],
[HTMLVideoElement.prototype, ['poster']],
[HTMLAnchorElement.prototype, ['href']],
[HTMLScriptElement.prototype, ['src']],
[HTMLLinkElement.prototype, ['href']],
];
describe('base_path_bootstrap.html', () => {
let savedGlobals;
let savedDescriptors;
let savedMethods;
beforeEach(() => {
savedGlobals = {
fetch: window.fetch,
WebSocket: window.WebSocket,
};
savedDescriptors = PATCHED_PROTOS().flatMap(([proto, props]) =>
props.map((prop) => [proto, prop, Object.getOwnPropertyDescriptor(proto, prop)]),
);
savedMethods = {
xhrOpen: XMLHttpRequest.prototype.open,
insertAdjacentHTML: Element.prototype.insertAdjacentHTML,
setAttribute: Element.prototype.setAttribute,
};
});
afterEach(() => {
window.fetch = savedGlobals.fetch;
window.WebSocket = savedGlobals.WebSocket;
for (const [proto, prop, desc] of savedDescriptors) {
if (desc) {
Object.defineProperty(proto, prop, desc);
}
}
XMLHttpRequest.prototype.open = savedMethods.xhrOpen;
Element.prototype.insertAdjacentHTML = savedMethods.insertAdjacentHTML;
Element.prototype.setAttribute = savedMethods.setAttribute;
delete window.LM_BASE_PATH;
delete window.lmWithBasePath;
window.history.replaceState({}, '', '/');
});
const runBootstrap = (pathname) => {
window.history.replaceState({}, '', pathname);
(0, eval)(extractBootstrapScript());
};
it('detects no prefix for root-mounted pages and patches nothing', () => {
const nativeFetch = window.fetch;
runBootstrap('/loras');
expect(window.LM_BASE_PATH).toBe('');
expect(window.fetch).toBe(nativeFetch);
runBootstrap('/');
expect(window.LM_BASE_PATH).toBe('');
});
it.each([
['/comfyui/loras', '/comfyui'],
['/comfyui/loras/', '/comfyui'],
['/comfyui/loras/recipes', '/comfyui'],
['/comfyui/checkpoints', '/comfyui'],
['/comfyui/embeddings', '/comfyui'],
['/comfyui/other', '/comfyui'],
['/comfyui/statistics', '/comfyui'],
['/ComfyBackendDirect/loras', '/ComfyBackendDirect'],
['/proxy/nested/loras', '/proxy/nested'],
])('detects prefix for %s', (pathname, expected) => {
runBootstrap(pathname);
expect(window.LM_BASE_PATH).toBe(expected);
});
it('does not mistake similar paths for manager pages', () => {
runBootstrap('/comfyui/lorasgallery');
expect(window.LM_BASE_PATH).toBe('');
runBootstrap('/comfyui/foo-loras');
expect(window.LM_BASE_PATH).toBe('');
});
it('prefixes root-absolute fetch URLs only', async () => {
const fetchSpy = vi.fn().mockResolvedValue({ ok: true });
window.fetch = fetchSpy;
runBootstrap('/comfyui/loras');
await window.fetch('/api/lm/loras/list');
await window.fetch('/loras_static/images/no-preview.png');
await window.fetch('https://civitai.com/api/v1/models');
await window.fetch('//cdn.example.com/x.js');
await window.fetch('relative/path');
expect(fetchSpy.mock.calls.map((call) => call[0])).toEqual([
'/comfyui/api/lm/loras/list',
'/comfyui/loras_static/images/no-preview.png',
'https://civitai.com/api/v1/models',
'//cdn.example.com/x.js',
'relative/path',
]);
});
it('prefixes same-origin absolute fetch URLs', async () => {
const fetchSpy = vi.fn().mockResolvedValue({ ok: true });
window.fetch = fetchSpy;
runBootstrap('/comfyui/loras');
const absolute = `${window.location.origin}/api/lm/init-status`;
await window.fetch(absolute);
expect(fetchSpy).toHaveBeenCalledWith(
`${window.location.origin}/comfyui/api/lm/init-status`,
undefined,
);
});
it('prefixes fetch() called with a URL object', async () => {
const fetchSpy = vi.fn().mockResolvedValue({ ok: true });
window.fetch = fetchSpy;
runBootstrap('/comfyui/loras');
await window.fetch(new URL('/api/lm/base-models', window.location.origin));
expect(fetchSpy).toHaveBeenCalledWith(
`${window.location.origin}/comfyui/api/lm/base-models`,
undefined,
);
await window.fetch(new URL('https://civitai.com/api/v1/models'));
expect(fetchSpy).toHaveBeenLastCalledWith('https://civitai.com/api/v1/models', undefined);
});
it('prefixes WebSocket URLs', () => {
const constructed = [];
class FakeWebSocket {
constructor(url, protocols) {
constructed.push([url, protocols]);
}
}
FakeWebSocket.CONNECTING = 0;
FakeWebSocket.OPEN = 1;
FakeWebSocket.CLOSING = 2;
FakeWebSocket.CLOSED = 3;
window.WebSocket = FakeWebSocket;
runBootstrap('/comfyui/loras');
new window.WebSocket('/ws/fetch-progress');
new window.WebSocket('wss://other.example.com/socket', ['a']);
expect(constructed).toEqual([
['/comfyui/ws/fetch-progress', undefined],
['wss://other.example.com/socket', ['a']],
]);
});
it('rewrites root-absolute URLs inside innerHTML markup', () => {
runBootstrap('/comfyui/loras');
const container = document.createElement('div');
container.innerHTML = `<img src="/api/lm/previews?path=x" onerror="this.src='/loras_static/images/no-preview.png'">`
+ `<a href="/api/lm/download-model/1">dl</a>`
+ `<video poster="/loras_static/p.png"><source src="/example_images_static/a/b.mp4"></video>`;
const html = container.innerHTML;
expect(html).toContain('src="/comfyui/api/lm/previews?path=x"');
expect(html).toContain("this.src='/comfyui/loras_static/images/no-preview.png'");
expect(html).toContain('href="/comfyui/api/lm/download-model/1"');
expect(html).toContain('poster="/comfyui/loras_static/p.png"');
expect(html).toContain('src="/comfyui/example_images_static/a/b.mp4"');
});
it('does not double-prefix markup that already carries the prefix', () => {
runBootstrap('/comfyui/loras');
const container = document.createElement('div');
container.innerHTML = '<img src="/api/lm/previews?path=x">';
const once = container.innerHTML;
container.innerHTML = once;
expect(container.innerHTML).toBe(once);
expect(once).not.toContain('/comfyui/comfyui/');
});
it('prefixes direct DOM URL assignments', () => {
runBootstrap('/comfyui/loras');
const img = document.createElement('img');
img.src = '/loras_static/images/no-preview.png';
expect(img.getAttribute('src')).toBe('/comfyui/loras_static/images/no-preview.png');
const anchor = document.createElement('a');
anchor.setAttribute('href', '/api/lm/download-model/1');
expect(anchor.getAttribute('href')).toBe('/comfyui/api/lm/download-model/1');
const video = document.createElement('video');
video.poster = '/loras_static/p.png';
expect(video.getAttribute('poster')).toBe('/comfyui/loras_static/p.png');
});
});
@@ -0,0 +1,28 @@
import { describe, it, expect, afterEach } from 'vitest';
import { getComfyUIBasePath, lmUrl } from '../../../web/comfyui/base_path.js';
describe('web/comfyui/base_path.js', () => {
afterEach(() => {
window.history.replaceState({}, '', '/');
});
it.each([
['/', ''],
['/comfyui/', '/comfyui'],
['/comfyui', '/comfyui'],
['/ComfyBackendDirect/', '/ComfyBackendDirect'],
])('maps %s to base path %s', (pathname, expected) => {
window.history.replaceState({}, '', pathname);
expect(getComfyUIBasePath()).toBe(expected);
});
it('builds prefixed URLs', () => {
window.history.replaceState({}, '', '/comfyui/');
expect(lmUrl('/api/lm/version-info')).toBe('/comfyui/api/lm/version-info');
expect(lmUrl('/loras')).toBe('/comfyui/loras');
window.history.replaceState({}, '', '/');
expect(lmUrl('/api/lm/version-info')).toBe('/api/lm/version-info');
});
});
+2 -2
View File
@@ -55,10 +55,10 @@ async def test_model_page_view_reads_version_per_request():
)
view._get_app_version = lambda: "1.0.2-old"
first = await view.handle(SimpleNamespace()) # pyright: ignore[reportArgumentType]
first = await view.handle(SimpleNamespace(path="/loras")) # pyright: ignore[reportArgumentType]
view._get_app_version = lambda: "1.0.2-new"
second = await view.handle(SimpleNamespace()) # pyright: ignore[reportArgumentType]
second = await view.handle(SimpleNamespace(path="/loras")) # pyright: ignore[reportArgumentType]
assert first.text == "1.0.2-old"
assert second.text == "1.0.2-new"
+23
View File
@@ -0,0 +1,23 @@
"""Tests for py/utils/url_utils.py relative_root_prefix."""
from __future__ import annotations
import pytest
from py.utils.url_utils import relative_root_prefix
@pytest.mark.parametrize(
("request_path", "expected"),
[
("/", ""),
("/loras", ""),
("/checkpoints", ""),
("/statistics", ""),
("/loras/", ""),
("/loras/recipes", "../"),
("/loras/recipes/", "../"),
],
)
def test_relative_root_prefix(request_path: str, expected: str) -> None:
assert relative_root_prefix(request_path) == expected
@@ -58,6 +58,7 @@
import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
import ModalWrapper from '../lora-pool/modals/ModalWrapper.vue'
import type { LoraItem } from '../../composables/types'
import { lmApiUrl } from '@/utils/basePath'
interface LoraListItem {
index: number
@@ -131,7 +132,7 @@ const selectLora = (index: number) => {
// in the Vue widgets build, so we need to use the full path with /api prefix
const customPreviewUrlResolver = async (modelName: string) => {
const response = await fetch(
`/api/lm/loras/preview-url?name=${encodeURIComponent(modelName)}&license_flags=true`
lmApiUrl(`/api/lm/loras/preview-url?name=${encodeURIComponent(modelName)}&license_flags=true`)
)
if (!response.ok) {
throw new Error('Failed to fetch preview URL')
@@ -35,6 +35,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { LoraEntry } from '../../composables/types'
import { lmApiUrl } from '@/utils/basePath'
const props = defineProps<{
loras: LoraEntry[]
@@ -48,7 +49,7 @@ const previewUrls = ref<Record<string, string>>({})
// Fetch preview URL for a lora using API
const fetchPreviewUrl = async (loraName: string) => {
try {
const response = await fetch(`/api/lm/loras/preview-url?name=${encodeURIComponent(loraName)}`)
const response = await fetch(lmApiUrl(`/api/lm/loras/preview-url?name=${encodeURIComponent(loraName)}`))
if (response.ok) {
const data = await response.json()
@@ -1,5 +1,6 @@
import { ref, watch, computed } from 'vue'
import type { ComponentWidget, CyclerConfig, LoraPoolConfig } from './types'
import { lmApiUrl } from '@/utils/basePath'
export interface CyclerLoraItem {
file_name: string
@@ -173,7 +174,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
requestBody.pool_config = poolConfig.filters
}
const response = await fetch('/api/lm/loras/cycler-list', {
const response = await fetch(lmApiUrl('/api/lm/loras/cycler-list'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -1,12 +1,13 @@
import { ref } from 'vue'
import type { BaseModelOption, TagOption, FolderTreeNode, LoraItem } from './types'
import { lmApiUrl } from '@/utils/basePath'
export function useLoraPoolApi() {
const isLoading = ref(false)
const fetchBaseModels = async (limit = 50): Promise<BaseModelOption[]> => {
try {
const response = await fetch(`/api/lm/loras/base-models?limit=${limit}`)
const response = await fetch(lmApiUrl(`/api/lm/loras/base-models?limit=${limit}`))
const data = await response.json()
return data.base_models || []
} catch (error) {
@@ -17,7 +18,7 @@ export function useLoraPoolApi() {
const fetchTags = async (limit = 0): Promise<TagOption[]> => {
try {
const response = await fetch(`/api/lm/loras/top-tags?limit=${limit}`)
const response = await fetch(lmApiUrl(`/api/lm/loras/top-tags?limit=${limit}`))
const data = await response.json()
return data.tags || []
} catch (error) {
@@ -28,7 +29,7 @@ export function useLoraPoolApi() {
const fetchFolderTree = async (): Promise<FolderTreeNode[]> => {
try {
const response = await fetch('/api/lm/loras/unified-folder-tree')
const response = await fetch(lmApiUrl('/api/lm/loras/unified-folder-tree'))
const data = await response.json()
return transformFolderTree(data.tree || {})
} catch (error) {
@@ -102,7 +103,7 @@ export function useLoraPoolApi() {
urlParams.set('name_pattern_use_regex', String(params.namePatternsUseRegex))
}
const response = await fetch(`/api/lm/loras/list?${urlParams}`)
const response = await fetch(lmApiUrl(`/api/lm/loras/list?${urlParams}`))
const data = await response.json()
return {
@@ -1,5 +1,6 @@
import { ref, computed, watch } from 'vue'
import type { ComponentWidget, RandomizerConfig, LoraEntry } from './types'
import { lmApiUrl } from '@/utils/basePath'
export function useLoraRandomizerState(widget: ComponentWidget<RandomizerConfig>) {
// Flag to prevent infinite loops during config restoration
@@ -160,7 +161,7 @@ export function useLoraRandomizerState(widget: ComponentWidget<RandomizerConfig>
}
// Call API endpoint
const response = await fetch('/api/lm/loras/random-sample', {
const response = await fetch(lmApiUrl('/api/lm/loras/random-sample'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+8
View File
@@ -0,0 +1,8 @@
export function getLmBasePath(): string {
const { pathname } = window.location;
return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
}
export function lmApiUrl(path: string): string {
return `${getLmBasePath()}${path}`;
}
+46
View File
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, it } from 'vitest'
import { getLmBasePath, lmApiUrl } from '@/utils/basePath'
const originalPathname = window.location.pathname
function setPathname(pathname: string) {
window.history.replaceState(null, '', pathname)
}
afterEach(() => {
setPathname(originalPathname)
})
describe('getLmBasePath', () => {
it('returns an empty string when ComfyUI is served at the root', () => {
setPathname('/')
expect(getLmBasePath()).toBe('')
})
it('strips the trailing slash from a subpath prefix', () => {
setPathname('/comfyui/')
expect(getLmBasePath()).toBe('/comfyui')
})
it('keeps a prefix without a trailing slash as-is', () => {
setPathname('/ComfyBackendDirect')
expect(getLmBasePath()).toBe('/ComfyBackendDirect')
})
})
describe('lmApiUrl', () => {
it('leaves root-absolute paths unchanged at the root', () => {
setPathname('/')
expect(lmApiUrl('/api/lm/loras/list')).toBe('/api/lm/loras/list')
})
it('prepends the subpath prefix to root-absolute paths', () => {
setPathname('/comfyui/')
expect(lmApiUrl('/api/lm/loras/list')).toBe('/comfyui/api/lm/loras/list')
})
})
+19
View File
@@ -0,0 +1,19 @@
/**
* Base-path helpers for code running inside the ComfyUI page.
*
* When ComfyUI is served under a URL subpath by a reverse proxy (e.g.
* llama-swap serves it at "/comfyui/", SwarmUI at "/ComfyBackendDirect/"),
* the ComfyUI SPA always sits at the root of that prefix, so the prefix is
* the current pathname without the trailing slash the same convention the
* ComfyUI frontend uses for its own api_base. Proxies strip the prefix and
* forward no headers, so the page URL is the only place to learn it.
*/
export function getComfyUIBasePath() {
const { pathname } = window.location;
return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
}
export function lmUrl(path) {
return `${getComfyUIBasePath()}${path}`;
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { lmUrl } from "./base_path.js";
// Mirrors the backend resolver (get_lora_info_absolute): a ".ckpt"/".pt"
// reference resolves to the same-named .safetensors file. The scanner only
@@ -394,7 +395,7 @@ function connectLibraryChangeSocket() {
const protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
let ws;
try {
ws = new WebSocket(`${protocol}${window.location.host}/ws/fetch-progress`);
ws = new WebSocket(`${protocol}${window.location.host}${lmUrl("/ws/fetch-progress")}`);
} catch (error) {
return;
}
@@ -474,7 +475,7 @@ export async function saveRecipeDirectly() {
}
// Send the request to the backend API
const response = await fetch('/api/lm/recipes/save-from-widget', {
const response = await fetch(lmUrl('/api/lm/recipes/save-from-widget'), {
method: 'POST'
});
+2 -1
View File
@@ -1,7 +1,8 @@
import { api } from "../../scripts/api.js";
import { ensureLmStyles } from "./lm_styles_loader.js";
import { lmUrl } from "./base_path.js";
const LICENSE_ICON_PATH = "/loras_static/images/tabler/";
const LICENSE_ICON_PATH = lmUrl("/loras_static/images/tabler/");
const LICENSE_FLAG_BITS = {
allowNoCredit: 1 << 0,
allowOnImages: 1 << 1,
+3 -2
View File
@@ -1,4 +1,5 @@
import { app } from "../../scripts/app.js";
import { lmUrl } from "./base_path.js";
// ============================================================================
// Setting IDs and Defaults
@@ -55,7 +56,7 @@ const loadWorkflowOptions = async () => {
return;
}
try {
const response = await fetch("/api/lm/example-workflows");
const response = await fetch(lmUrl("/api/lm/example-workflows"));
const data = await response.json();
if (data.success && data.workflows) {
workflowOptionsFull = data.workflows;
@@ -81,7 +82,7 @@ const loadTemplateWorkflow = async (templateName) => {
const workflow = workflowOptionsFull.find((w) => w.label === templateName);
if (workflow && workflow.value) {
const workflowResponse = await fetch(
`/api/lm/example-workflows/${encodeURIComponent(workflow.value)}`
lmUrl(`/api/lm/example-workflows/${encodeURIComponent(workflow.value)}`)
);
const workflowData = await workflowResponse.json();
if (workflowData.success && workflowData.workflow) {
+4 -3
View File
@@ -1,4 +1,5 @@
import { app } from "../../scripts/app.js";
import { lmUrl } from "./base_path.js";
const BUTTON_TOOLTIP = "Launch LoRA Manager (Shift+Click opens in new window)";
const LORA_MANAGER_PATH = "/loras";
@@ -9,7 +10,7 @@ const BUTTON_GROUP_CLASS = "lora-manager-top-menu-group";
const MIN_VERSION_FOR_ACTION_BAR = [1, 33, 9];
const openLoraManager = (event) => {
const url = `${window.location.origin}${LORA_MANAGER_PATH}`;
const url = `${window.location.origin}${lmUrl(LORA_MANAGER_PATH)}`;
if (event.shiftKey) {
window.open(url, "_blank", NEW_WINDOW_FEATURES);
@@ -29,7 +30,7 @@ const getComfyUIFrontendVersion = async () => {
}
try {
const response = await fetch("/system_stats");
const response = await fetch(lmUrl("/system_stats"));
const data = await response.json();
if (data?.system?.comfyui_frontend_version) {
@@ -80,7 +81,7 @@ const supportsActionBarButtons = async () => {
const fetchVersionInfo = async () => {
try {
const response = await fetch("/api/lm/version-info");
const response = await fetch(lmUrl("/api/lm/version-info"));
const data = await response.json();
if (data.success) {
+2 -1
View File
@@ -1,6 +1,7 @@
// ComfyUI extension to track model usage statistics
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { lmUrl } from "./base_path.js";
import { showToast } from "./utils.js";
import { getAutoPathCorrectionPreference, getUsageStatisticsPreference } from "./settings.js";
@@ -40,7 +41,7 @@ app.registerExtension({
async updateUsageStats(promptId) {
try {
// Call backend endpoint with the prompt_id
const response = await fetch(`/api/lm/update-usage-stats`, {
const response = await fetch(lmUrl(`/api/lm/update-usage-stats`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+2 -1
View File
@@ -1,6 +1,7 @@
export const CONVERTED_TYPE = 'converted-widget';
import { app } from "../../scripts/app.js";
import { AutoComplete } from "./autocomplete.js";
import { lmUrl } from "./base_path.js";
const ROOT_GRAPH_ID = "root";
@@ -408,7 +409,7 @@ export function updateConnectedTriggerWords(node, loraNames) {
return;
}
fetch("/api/lm/loras/get_trigger_words", {
fetch(lmUrl("/api/lm/loras/get_trigger_words"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
+53 -39
View File
@@ -1105,7 +1105,7 @@ to { transform: rotate(360deg);
box-sizing: border-box;
}
.last-used-preview[data-v-b940502e] {
.last-used-preview[data-v-7db61bc9] {
position: absolute;
bottom: 100%;
right: 0;
@@ -1113,7 +1113,7 @@ to { transform: rotate(360deg);
z-index: 100;
width: 280px;
}
.last-used-preview__content[data-v-b940502e] {
.last-used-preview__content[data-v-7db61bc9] {
background: var(--comfy-menu-bg, #1a1a1a);
border: 1px solid var(--border-color, #444);
border-radius: 6px;
@@ -1123,7 +1123,7 @@ to { transform: rotate(360deg);
flex-direction: column;
gap: 4px;
}
.last-used-preview__item[data-v-b940502e] {
.last-used-preview__item[data-v-7db61bc9] {
display: flex;
align-items: center;
gap: 8px;
@@ -1131,7 +1131,7 @@ to { transform: rotate(360deg);
background: var(--comfy-input-bg, #333);
border-radius: 6px;
}
.last-used-preview__thumb[data-v-b940502e] {
.last-used-preview__thumb[data-v-7db61bc9] {
width: 28px;
height: 28px;
object-fit: cover;
@@ -1139,37 +1139,37 @@ to { transform: rotate(360deg);
flex-shrink: 0;
background: rgba(0, 0, 0, 0.2);
}
.last-used-preview__thumb--placeholder[data-v-b940502e] {
.last-used-preview__thumb--placeholder[data-v-7db61bc9] {
display: flex;
align-items: center;
justify-content: center;
color: var(--fg-color, #fff);
opacity: 0.2;
}
.last-used-preview__thumb--placeholder svg[data-v-b940502e] {
.last-used-preview__thumb--placeholder svg[data-v-7db61bc9] {
width: 14px;
height: 14px;
}
.last-used-preview__info[data-v-b940502e] {
.last-used-preview__info[data-v-7db61bc9] {
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.last-used-preview__name[data-v-b940502e] {
.last-used-preview__name[data-v-7db61bc9] {
font-size: 11px;
color: var(--fg-color, #fff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.last-used-preview__strength[data-v-b940502e] {
.last-used-preview__strength[data-v-7db61bc9] {
font-size: 10px;
color: var(--fg-color, #fff);
opacity: 0.5;
}
.last-used-preview__more[data-v-b940502e] {
.last-used-preview__more[data-v-7db61bc9] {
font-size: 11px;
color: var(--fg-color, #fff);
opacity: 0.5;
@@ -1957,10 +1957,10 @@ to { transform: rotate(360deg);
opacity: 1;
}
.search-container[data-v-83f6f852] {
.search-container[data-v-ed10780a] {
position: relative;
}
.search-icon[data-v-83f6f852] {
.search-icon[data-v-ed10780a] {
position: absolute;
left: 10px;
top: 50%;
@@ -1970,7 +1970,7 @@ to { transform: rotate(360deg);
color: var(--fg-color, #fff);
opacity: 0.5;
}
.search-input[data-v-83f6f852] {
.search-input[data-v-ed10780a] {
width: 100%;
padding: 8px 32px;
background: var(--comfy-input-bg, #333);
@@ -1981,14 +1981,14 @@ to { transform: rotate(360deg);
outline: none;
box-sizing: border-box;
}
.search-input[data-v-83f6f852]:focus {
.search-input[data-v-ed10780a]:focus {
border-color: rgba(66, 153, 225, 0.6);
}
.search-input[data-v-83f6f852]::placeholder {
.search-input[data-v-ed10780a]::placeholder {
color: var(--fg-color, #fff);
opacity: 0.4;
}
.clear-button[data-v-83f6f852] {
.clear-button[data-v-ed10780a] {
position: absolute;
right: 8px;
top: 50%;
@@ -2005,22 +2005,22 @@ to { transform: rotate(360deg);
opacity: 0.5;
transition: opacity 0.15s;
}
.clear-button[data-v-83f6f852]:hover {
.clear-button[data-v-ed10780a]:hover {
opacity: 0.8;
}
.clear-button svg[data-v-83f6f852] {
.clear-button svg[data-v-ed10780a] {
width: 12px;
height: 12px;
color: var(--fg-color, #fff);
}
.lora-list[data-v-83f6f852] {
.lora-list[data-v-ed10780a] {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 400px;
overflow-y: auto;
}
.lora-item[data-v-83f6f852] {
.lora-item[data-v-ed10780a] {
display: flex;
align-items: center;
gap: 12px;
@@ -2030,14 +2030,14 @@ to { transform: rotate(360deg);
transition: all 0.15s;
border-left: 3px solid transparent;
}
.lora-item[data-v-83f6f852]:hover {
.lora-item[data-v-ed10780a]:hover {
background: rgba(66, 153, 225, 0.15);
}
.lora-item.active[data-v-83f6f852] {
.lora-item.active[data-v-ed10780a] {
background: rgba(66, 153, 225, 0.25);
border-left-color: rgba(66, 153, 225, 0.8);
}
.lora-index[data-v-83f6f852] {
.lora-index[data-v-ed10780a] {
font-family: 'SF Mono', 'Roboto Mono', monospace;
font-size: 12px;
color: rgba(226, 232, 240, 0.5);
@@ -2045,7 +2045,7 @@ to { transform: rotate(360deg);
text-align: right;
font-variant-numeric: tabular-nums;
}
.lora-name[data-v-83f6f852] {
.lora-name[data-v-ed10780a] {
flex: 1;
font-size: 13px;
color: var(--fg-color, #fff);
@@ -2053,7 +2053,7 @@ to { transform: rotate(360deg);
text-overflow: ellipsis;
white-space: nowrap;
}
.current-badge[data-v-83f6f852] {
.current-badge[data-v-ed10780a] {
font-size: 11px;
padding: 2px 8px;
background: rgba(66, 153, 225, 0.3);
@@ -2062,14 +2062,14 @@ to { transform: rotate(360deg);
color: rgba(191, 219, 254, 1);
font-weight: 500;
}
.lora-item.no-lora-item .lora-name[data-v-83f6f852] {
.lora-item.no-lora-item .lora-name[data-v-ed10780a] {
font-style: italic;
color: rgba(226, 232, 240, 0.6);
}
.lora-item.no-lora-item:hover .lora-name[data-v-83f6f852] {
.lora-item.no-lora-item:hover .lora-name[data-v-ed10780a] {
color: rgba(226, 232, 240, 0.8);
}
.no-results[data-v-83f6f852] {
.no-results[data-v-ed10780a] {
padding: 32px 20px;
text-align: center;
color: var(--fg-color, #fff);
@@ -12262,11 +12262,18 @@ const _sfc_main$c = /* @__PURE__ */ defineComponent({
}
});
const FoldersModal = /* @__PURE__ */ _export_sfc(_sfc_main$c, [["__scopeId", "data-v-046dcbf4"]]);
function getLmBasePath() {
const { pathname } = window.location;
return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
}
function lmApiUrl(path) {
return `${getLmBasePath()}${path}`;
}
function useLoraPoolApi() {
const isLoading = ref(false);
const fetchBaseModels = async (limit = 50) => {
try {
const response = await fetch(`/api/lm/loras/base-models?limit=${limit}`);
const response = await fetch(lmApiUrl(`/api/lm/loras/base-models?limit=${limit}`));
const data = await response.json();
return data.base_models || [];
} catch (error) {
@@ -12276,7 +12283,7 @@ function useLoraPoolApi() {
};
const fetchTags = async (limit = 0) => {
try {
const response = await fetch(`/api/lm/loras/top-tags?limit=${limit}`);
const response = await fetch(lmApiUrl(`/api/lm/loras/top-tags?limit=${limit}`));
const data = await response.json();
return data.tags || [];
} catch (error) {
@@ -12286,7 +12293,7 @@ function useLoraPoolApi() {
};
const fetchFolderTree = async () => {
try {
const response = await fetch("/api/lm/loras/unified-folder-tree");
const response = await fetch(lmApiUrl("/api/lm/loras/unified-folder-tree"));
const data = await response.json();
return transformFolderTree(data.tree || {});
} catch (error) {
@@ -12334,7 +12341,7 @@ function useLoraPoolApi() {
if (params.namePatternsUseRegex !== void 0) {
urlParams.set("name_pattern_use_regex", String(params.namePatternsUseRegex));
}
const response = await fetch(`/api/lm/loras/list?${urlParams}`);
const response = await fetch(lmApiUrl(`/api/lm/loras/list?${urlParams}`));
const data = await response.json();
return {
items: data.items || [],
@@ -12682,7 +12689,7 @@ const _sfc_main$a = /* @__PURE__ */ defineComponent({
const previewUrls = ref({});
const fetchPreviewUrl = async (loraName) => {
try {
const response = await fetch(`/api/lm/loras/preview-url?name=${encodeURIComponent(loraName)}`);
const response = await fetch(lmApiUrl(`/api/lm/loras/preview-url?name=${encodeURIComponent(loraName)}`));
if (response.ok) {
const data = await response.json();
if (data.preview_url) {
@@ -12732,7 +12739,7 @@ const _sfc_main$a = /* @__PURE__ */ defineComponent({
};
}
});
const LastUsedPreview = /* @__PURE__ */ _export_sfc(_sfc_main$a, [["__scopeId", "data-v-b940502e"]]);
const LastUsedPreview = /* @__PURE__ */ _export_sfc(_sfc_main$a, [["__scopeId", "data-v-7db61bc9"]]);
const _hoisted_1$7 = { class: "slider-handle__value" };
const _sfc_main$9 = /* @__PURE__ */ defineComponent({
__name: "SingleSlider",
@@ -13528,7 +13535,7 @@ function useLoraRandomizerState(widget) {
if (poolConfig) {
requestBody.pool_config = poolConfig.filters || {};
}
const response = await fetch("/api/lm/loras/random-sample", {
const response = await fetch(lmApiUrl("/api/lm/loras/random-sample"), {
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -14238,7 +14245,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
};
const customPreviewUrlResolver = async (modelName) => {
const response = await fetch(
`/api/lm/loras/preview-url?name=${encodeURIComponent(modelName)}&license_flags=true`
lmApiUrl(`/api/lm/loras/preview-url?name=${encodeURIComponent(modelName)}&license_flags=true`)
);
if (!response.ok) {
throw new Error("Failed to fetch preview URL");
@@ -14364,7 +14371,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
};
}
});
const LoraListModal = /* @__PURE__ */ _export_sfc(_sfc_main$4, [["__scopeId", "data-v-83f6f852"]]);
const LoraListModal = /* @__PURE__ */ _export_sfc(_sfc_main$4, [["__scopeId", "data-v-ed10780a"]]);
function useLoraCyclerState(widget) {
let isRestoring = false;
const currentIndex = ref(1);
@@ -14491,7 +14498,7 @@ function useLoraCyclerState(widget) {
if (poolConfig == null ? void 0 : poolConfig.filters) {
requestBody.pool_config = poolConfig.filters;
}
const response = await fetch("/api/lm/loras/cycler-list", {
const response = await fetch(lmApiUrl("/api/lm/loras/cycler-list"), {
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -15898,6 +15905,13 @@ function stripAutocompleteMetadataFromPromptResult(result) {
}
return result;
}
function getComfyUIBasePath() {
const { pathname } = window.location;
return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
}
function lmUrl(path) {
return `${getComfyUIBasePath()}${path}`;
}
const ROOT_GRAPH_ID = "root";
const LORA_PROVIDER_NODE_TYPES = [
"Lora Stacker (LoraManager)",
@@ -16055,7 +16069,7 @@ function updateConnectedTriggerWords(node, loraNames) {
if (nodeIds.length === 0) {
return;
}
fetch("/api/lm/loras/get_trigger_words", {
fetch(lmUrl("/api/lm/loras/get_trigger_words"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -1,5 +1,6 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { lmUrl } from "./base_path.js";
import { getAllGraphNodes, getNodeReference, getNodeFromGraph, getChildGraphs, chainCallback, getLinkFromGraph } from "./utils.js";
import { ensureLmStyles } from "./lm_styles_loader.js";
@@ -396,7 +397,7 @@ app.registerExtension({
}
this._lastFingerprint = fingerprint;
const response = await fetch("/api/lm/register-nodes", {
const response = await fetch(lmUrl("/api/lm/register-nodes"), {
method: "POST",
headers: {
"Content-Type": "application/json",