Files
ComfyUI-LM-Remote/web/comfyui/lora_manager_sidebar.js
T

1260 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import {
buildExternalLinks,
cleanLoraName,
closeActiveSidebarTab,
extractLoraNames,
getActiveSidebarTabId,
getSelectedGraphNodes,
isVideoMedia,
loraSearchTerm,
matchModelItems,
mergeCivitaiMetadata,
normalizeLoraIdentifier,
normalizeMediaSettings,
normalizeSharedMedia,
normalizeUsageTips,
} from "./lora_manager_sidebar_utils.js";
import { openRemoteConfigDialog } from "./remote_config_dialog.js";
const TAB_ID = "lm-remote-lora-info";
const COMMAND_ID = "LMRemote.OpenLoraInfo";
const CONFIG_COMMAND_ID = "LMRemote.Configure";
const CONFIG_SETTING_ID = "LMRemote.Connection.Configure";
const AUTO_OPEN_SETTING = "LMRemote.LoraInfo.AutoOpen";
const STYLE_ID = "lm-remote-lora-info-style";
const NODE_SELECTION_HOOK = Symbol.for("lmRemote.loraInfo.nodeSelectionHook");
const CANVAS_SELECTION_HOOK = Symbol.for("lmRemote.loraInfo.canvasSelectionHook");
const MAX_SHARED_MEDIA = 40;
const MATURE_MEDIA_LEVEL = 4;
const SIDEBAR_CLOSE_DELAY_MS = 80;
let sidebarRoot = null;
let selectedNode = null;
let selectedNames = [];
let activeName = "";
let selectionSignature = "";
let lookupState = { status: "idle" };
let lookupGeneration = 0;
let lookupController = null;
let monitorTimer = null;
let sidebarCloseTimer = null;
function createElement(tag, className, text) {
const element = document.createElement(tag);
if (className) element.className = className;
if (text != null) element.textContent = String(text);
return element;
}
function ensureStyles() {
if (document.getElementById(STYLE_ID)) return;
const link = document.createElement("link");
link.id = STYLE_ID;
link.rel = "stylesheet";
link.href = new URL("./lora_manager_sidebar.css", import.meta.url).href;
document.head.appendChild(link);
}
function openConfiguration() {
ensureStyles();
openRemoteConfigDialog({
onSaved: () => {
if (activeName) lookupActiveName();
},
});
}
function configureSettingControl() {
const button = createElement(
"button",
"lmrc-settings-button",
"Configure LM Remote…"
);
button.type = "button";
button.addEventListener("click", openConfiguration);
return button;
}
function selectedNodeLabel() {
if (!selectedNode) return "";
return (
selectedNode.title ||
selectedNode.comfyClass ||
selectedNode.type ||
`Node ${selectedNode.id ?? ""}`
);
}
function makeExternalLink(label, url, extraClass = "") {
const link = createElement(
"a",
`lmri-button lmri-link ${extraClass}`.trim(),
label
);
link.href = url;
link.target = "_blank";
link.rel = "noopener noreferrer";
return link;
}
function managerSearchUrl(name) {
return `/loras?search=${encodeURIComponent(loraSearchTerm(name) || name)}`;
}
function safePreviewUrl(value) {
if (!value) return "";
try {
const parsed = new URL(String(value), window.location.origin);
if (
!parsed.username &&
!parsed.password &&
(parsed.protocol === "http:" || parsed.protocol === "https:")
) {
return parsed.href;
}
} catch {
return "";
}
return "";
}
function createMediaElement(url, mediaType, alt) {
const safeUrl = safePreviewUrl(url);
if (!safeUrl) return null;
if (mediaType === "video") {
const video = document.createElement("video");
video.src = safeUrl;
video.controls = true;
video.muted = true;
video.loop = true;
video.playsInline = true;
video.preload = "metadata";
video.referrerPolicy = "no-referrer";
video.setAttribute("playsinline", "");
video.setAttribute("aria-label", alt);
return video;
}
const image = document.createElement("img");
image.src = safeUrl;
image.alt = alt;
image.loading = "lazy";
image.referrerPolicy = "no-referrer";
return image;
}
function addMatureMediaGate(
container,
media,
level,
{ gateUnknown = false, settings = null } = {}
) {
const mediaSettings = settings || normalizeMediaSettings();
if (!mediaSettings.blurMatureContent) return;
const numericLevel = Number(level);
const hasKnownLevel =
level != null && Number.isFinite(numericLevel) && numericLevel > 0;
if (
(hasKnownLevel && numericLevel < mediaSettings.matureBlurLevel) ||
(!hasKnownLevel && !gateUnknown)
) {
return;
}
media.classList.add("lmri-media-blurred");
const previousTabIndex = media.getAttribute("tabindex");
const hadControls = media instanceof HTMLVideoElement && media.controls;
if (media instanceof HTMLVideoElement) {
media.pause();
media.controls = false;
}
media.tabIndex = -1;
media.setAttribute("aria-hidden", "true");
const reveal = createElement(
"button",
"lmri-media-reveal",
hasKnownLevel ? "Show mature preview" : "Show unrated preview"
);
reveal.type = "button";
reveal.addEventListener("click", () => {
media.classList.remove("lmri-media-blurred");
media.removeAttribute("aria-hidden");
if (previousTabIndex == null) media.removeAttribute("tabindex");
else media.setAttribute("tabindex", previousTabIndex);
if (media instanceof HTMLVideoElement && hadControls) media.controls = true;
reveal.remove();
});
container.appendChild(reveal);
}
async function copyText(value) {
const text = String(value || "");
if (!text) return false;
try {
await navigator.clipboard?.writeText(text);
if (navigator.clipboard?.writeText) return true;
} catch {
// Fall through to the legacy copy path for non-secure ComfyUI origins.
}
const textarea = document.createElement("textarea");
const previousFocus = document.activeElement;
textarea.value = text;
textarea.readOnly = true;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, text.length);
try {
return document.execCommand?.("copy") === true;
} catch {
return false;
} finally {
textarea.remove();
previousFocus?.focus?.();
}
}
function createCopyButton(text) {
const button = createElement("button", "lmri-copy-button", "Copy");
button.type = "button";
button.title = "Copy shared prompt";
button.setAttribute("aria-live", "polite");
button.addEventListener("click", async () => {
button.disabled = true;
const copied = await copyText(text);
button.textContent = copied ? "Copied" : "Copy failed";
button.disabled = false;
button.focus();
window.setTimeout(() => {
if (button.isConnected) button.textContent = "Copy";
}, 1800);
});
return button;
}
function toDisplayList(value) {
if (Array.isArray(value)) {
return value.map((item) => String(item).trim()).filter(Boolean);
}
if (value && typeof value === "object") {
return Object.entries(value)
.filter(([, enabled]) => Boolean(enabled))
.map(([name]) => name);
}
return [];
}
function appendPills(container, values, className = "") {
const unique = Array.from(new Set(values.filter(Boolean)));
if (!unique.length) return;
const pills = createElement("div", `lmri-pills ${className}`.trim());
unique.forEach((value) => {
pills.appendChild(createElement("span", "lmri-pill", value));
});
container.appendChild(pills);
}
function appendMetaRow(container, label, value) {
if (value == null || value === "") return;
const row = createElement("div", "lmri-meta-row");
row.append(
createElement("span", "lmri-meta-label", label),
createElement("span", "lmri-meta-value", value)
);
container.appendChild(row);
}
function formatFileSize(value) {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes <= 0) return "";
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return `${size.toFixed(unit > 1 ? 1 : 0)} ${units[unit]}`;
}
function appendSearchActions(container, query, model = null) {
const links = buildExternalLinks(query, model);
const actions = createElement("div", "lmri-actions");
actions.append(
makeExternalLink("Civitai", links.civitai, "lmri-civitai"),
makeExternalLink("Civitai Red", links.civitaiRed, "lmri-civitai-red"),
makeExternalLink("CivArchive", links.civArchive, "lmri-archive")
);
container.appendChild(actions);
}
function renderEmpty(content) {
const empty = createElement("div", "lmri-empty");
empty.append(
createElement("i", "pi pi-info-circle lmri-empty-icon"),
createElement("h3", "", "Select a LoRA loader"),
createElement(
"p",
"",
"Select any node with a LoRA name, LoRA stack, or <lora:name:strength> value."
)
);
content.appendChild(empty);
}
function renderLoading(content) {
const loading = createElement("div", "lmri-state");
loading.append(
createElement("i", "pi pi-spin pi-spinner"),
createElement("span", "", `Looking up ${activeName}…`)
);
content.appendChild(loading);
}
function renderMissing(content) {
const panel = createElement("section", "lmri-notice");
panel.append(
createElement("h3", "", "No LoRA Manager card found"),
createElement(
"p",
"",
`“${activeName}” is selected, but it is not indexed by the remote LoRA Manager.`
)
);
panel.appendChild(
makeExternalLink(
"Search LoRA Manager",
managerSearchUrl(activeName),
"lmri-manager"
)
);
appendSearchActions(panel, activeName);
content.appendChild(panel);
}
function renderError(content) {
const panel = createElement("section", "lmri-notice lmri-error");
panel.append(
createElement("h3", "", "LoRA Manager unavailable"),
createElement(
"p",
"",
lookupState.message || "The remote Manager did not answer this lookup."
)
);
const retry = createElement("button", "lmri-button", "Try again");
retry.type = "button";
retry.addEventListener("click", () => lookupActiveName());
const configure = createElement("button", "lmri-button", "Configure");
configure.type = "button";
configure.addEventListener("click", openConfiguration);
const actions = createElement("div", "lmri-inline-actions");
actions.append(retry, configure);
panel.appendChild(actions);
appendSearchActions(panel, activeName);
content.appendChild(panel);
}
async function useResolvedCandidate(model) {
const generation = ++lookupGeneration;
lookupController?.abort();
lookupController = new AbortController();
lookupState = {
status: "found",
model,
enrichment: "loading",
mediaIndex: 0,
};
renderSidebar();
try {
const enriched = await safelyEnrichManagerCard(
model,
lookupController.signal
);
if (generation !== lookupGeneration) return;
lookupState = {
status: "found",
model: enriched,
enrichment: "ready",
mediaIndex: 0,
};
refreshEnrichmentSlot();
} catch (error) {
if (error?.name !== "AbortError" && generation === lookupGeneration) {
lookupState = { ...lookupState, enrichment: "ready" };
renderSidebar();
}
}
}
function renderAmbiguous(content) {
const panel = createElement("section", "lmri-notice");
panel.append(
createElement("h3", "", "Choose the matching LoRA"),
createElement(
"p",
"",
"More than one Manager card has this filename. Pick the folder used by the node."
)
);
const candidates = createElement("div", "lmri-candidates");
(lookupState.candidates || []).forEach((model) => {
const button = createElement("button", "lmri-candidate");
button.type = "button";
const title = model.model_name || model.file_name || "Unnamed LoRA";
const path = [model.folder, model.file_name].filter(Boolean).join("/");
button.append(
createElement("strong", "", title),
createElement("span", "", path || model.file_path || "")
);
button.addEventListener("click", () => {
void useResolvedCandidate(model);
});
candidates.appendChild(button);
});
panel.appendChild(candidates);
appendSearchActions(panel, activeName);
content.appendChild(panel);
}
function sharedParameterLabels(media) {
const values = [];
if (media.width && media.height) values.push(`${media.width}×${media.height}`);
if (media.steps != null && media.steps !== "") {
values.push(`${media.steps} steps`);
}
if (media.sampler) values.push(String(media.sampler));
if (media.cfgScale != null && media.cfgScale !== "") {
values.push(`CFG ${media.cfgScale}`);
}
if (media.seed != null && media.seed !== "") {
values.push(`Seed ${media.seed}`);
}
if (media.denoise != null && media.denoise !== "") {
values.push(`Denoise ${media.denoise}`);
}
if (media.clipSkip != null && media.clipSkip !== "") {
values.push(`Clip skip ${media.clipSkip}`);
}
if (media.modelName) values.push(`Model ${media.modelName}`);
if (media.baseModel) values.push(`Base ${media.baseModel}`);
if (media.reactionCount > 0) values.push(`♥ ${media.reactionCount}`);
return values;
}
function createSharedMediaSection(model) {
const items = Array.isArray(model.sharedMedia) ? model.sharedMedia : [];
if (!items.length) return null;
const requestedIndex = Number(lookupState.mediaIndex);
const index = Number.isInteger(requestedIndex)
? Math.min(Math.max(requestedIndex, 0), items.length - 1)
: 0;
const media = items[index];
const section = createElement("section", "lmri-shared");
const header = createElement("div", "lmri-shared-header");
const heading = createElement("div", "");
heading.append(
createElement("h3", "", "Shared examples"),
createElement(
"span",
"lmri-shared-source",
[media.source, media.username ? `by ${media.username}` : ""]
.filter(Boolean)
.join(" · ")
)
);
header.appendChild(heading);
if (items.length > 1) {
const navigation = createElement("div", "lmri-shared-navigation");
const previous = createElement("button", "lmri-shared-nav", "");
previous.type = "button";
previous.title = "Previous shared example";
previous.setAttribute("aria-label", previous.title);
const position = createElement(
"span",
"lmri-shared-position",
`${index + 1}/${items.length}`
);
position.setAttribute("role", "status");
position.setAttribute("aria-live", "polite");
const next = createElement("button", "lmri-shared-nav", "");
next.type = "button";
next.title = "Next shared example";
next.setAttribute("aria-label", next.title);
previous.dataset.direction = "previous";
next.dataset.direction = "next";
const replaceAt = (nextIndex, focusSelector) => {
lookupState = { ...lookupState, mediaIndex: nextIndex };
const replacement = createSharedMediaSection(model);
if (!replacement) return;
section.replaceWith(replacement);
replacement.querySelector(focusSelector)?.focus();
};
previous.addEventListener("click", () => {
replaceAt(
(index - 1 + items.length) % items.length,
'[data-direction="previous"]'
);
});
next.addEventListener("click", () => {
replaceAt((index + 1) % items.length, '[data-direction="next"]');
});
navigation.append(previous, position, next);
header.appendChild(navigation);
}
section.appendChild(header);
const viewer = createElement("div", "lmri-shared-viewer");
if (media.width && media.height) {
const aspect = media.width / media.height;
if (aspect >= 0.4 && aspect <= 2.5) {
viewer.style.aspectRatio = `${media.width} / ${media.height}`;
}
}
const mediaElement = createMediaElement(
media.url,
media.mediaType,
`${media.source} for ${model.model_name || activeName}`
);
if (mediaElement) {
mediaElement.addEventListener("error", () => viewer.remove());
viewer.appendChild(mediaElement);
addMatureMediaGate(viewer, mediaElement, media.nsfwLevel, {
gateUnknown: media.source === "Community creation",
settings: model.mediaSettings,
});
section.appendChild(viewer);
}
if (items.length > 2) {
const dots = createElement("div", "lmri-shared-dots");
items.forEach((item, itemIndex) => {
const dot = createElement(
"button",
itemIndex === index ? "lmri-shared-dot active" : "lmri-shared-dot"
);
dot.type = "button";
dot.title = `${item.source} ${itemIndex + 1}`;
dot.setAttribute("aria-label", `Show shared example ${itemIndex + 1}`);
dot.dataset.mediaIndex = String(itemIndex);
if (itemIndex === index) dot.setAttribute("aria-current", "true");
dot.addEventListener("click", () => {
lookupState = { ...lookupState, mediaIndex: itemIndex };
const replacement = createSharedMediaSection(model);
if (!replacement) return;
section.replaceWith(replacement);
replacement
.querySelector(`[data-media-index="${itemIndex}"]`)
?.focus();
});
dots.appendChild(dot);
});
section.appendChild(dots);
}
const parameters = sharedParameterLabels(media);
if (parameters.length) appendPills(section, parameters, "lmri-shared-params");
if (media.prompt) {
const prompt = createElement("section", "lmri-shared-prompt");
const promptHeader = createElement("div", "lmri-prompt-header");
promptHeader.append(
createElement("h4", "", "Shared prompt"),
createCopyButton(media.prompt)
);
prompt.append(
promptHeader,
createElement("p", "lmri-shared-prompt-text", media.prompt)
);
section.appendChild(prompt);
}
if (media.negativePrompt) {
const negative = createElement("details", "lmri-negative-prompt");
negative.append(
createElement("summary", "", "Negative prompt"),
createElement("p", "", media.negativePrompt)
);
section.appendChild(negative);
}
return section;
}
function populateEnrichmentSlot(container, model) {
container.replaceChildren();
const sharedMedia = createSharedMediaSection(model);
if (sharedMedia) {
container.appendChild(sharedMedia);
return;
}
if (lookupState.enrichment === "loading") {
const loading = createElement("div", "lmri-enrichment-state");
loading.setAttribute("role", "status");
loading.setAttribute("aria-live", "polite");
loading.append(
createElement("i", "pi pi-spin pi-spinner"),
createElement("span", "", "Loading shared media…")
);
container.appendChild(loading);
}
}
function refreshEnrichmentSlot() {
const slot = sidebarRoot?.querySelector(".lmri-enrichment-slot");
if (slot && lookupState.status === "found") {
populateEnrichmentSlot(slot, lookupState.model);
}
}
function renderModelCard(content, model) {
const card = createElement("article", "lmri-card");
const previewUrl = safePreviewUrl(model.preview_url);
if (previewUrl) {
const preview = createElement("div", "lmri-preview");
const media = createMediaElement(
previewUrl,
isVideoMedia(model.preview_url) ? "video" : "image",
`Preview for ${model.model_name || activeName}`
);
if (media) {
media.addEventListener("error", () => preview.remove());
preview.appendChild(media);
card.appendChild(preview);
}
}
const body = createElement("div", "lmri-card-body");
const heading = createElement("div", "lmri-card-heading");
const titleGroup = createElement("div", "");
titleGroup.append(
createElement("h2", "", model.model_name || model.file_name || activeName),
createElement(
"p",
"lmri-file-name",
[model.folder, model.file_name].filter(Boolean).join("/") ||
model.file_path ||
activeName
)
);
heading.appendChild(titleGroup);
const flags = createElement("div", "lmri-flags");
if (model.favorite) flags.appendChild(createElement("span", "", "★"));
if (model.update_available) {
flags.appendChild(createElement("span", "lmri-update", "Update"));
}
if (flags.childNodes.length) heading.appendChild(flags);
body.appendChild(heading);
const metadata = createElement("div", "lmri-metadata");
appendMetaRow(metadata, "Base model", model.base_model);
appendMetaRow(metadata, "Type", model.sub_type);
appendMetaRow(metadata, "Version", model.civitai?.name);
appendMetaRow(metadata, "Size", formatFileSize(model.file_size));
appendMetaRow(
metadata,
"Used",
Number.isFinite(Number(model.usage_count))
? `${Number(model.usage_count)} times`
: ""
);
if (model.sha256) {
const hash = String(model.sha256);
appendMetaRow(metadata, "SHA256", hash.length > 16 ? `${hash.slice(0, 16)}…` : hash);
metadata.lastElementChild?.querySelector(".lmri-meta-value")?.setAttribute(
"title",
hash
);
}
body.appendChild(metadata);
const trainedWords = toDisplayList(model.civitai?.trainedWords);
if (trainedWords.length) {
body.appendChild(createElement("h3", "lmri-section-title", "Trigger words"));
appendPills(body, trainedWords, "lmri-triggers");
}
const tags = [
...toDisplayList(model.tags),
...toDisplayList(model.auto_tags),
];
if (tags.length) {
body.appendChild(createElement("h3", "lmri-section-title", "Tags"));
appendPills(body, tags);
}
const usageTips = normalizeUsageTips(model.usage_tips);
if (usageTips.length) {
const section = createElement("section", "lmri-copy");
section.appendChild(createElement("h3", "", "Usage tips"));
const values = createElement("div", "lmri-metadata lmri-usage-tips");
usageTips.forEach((tip) => appendMetaRow(values, tip.label, tip.value));
section.appendChild(values);
body.appendChild(section);
}
if (model.notes) {
const section = createElement("section", "lmri-copy");
section.append(
createElement("h3", "", "Manager notes"),
createElement("p", "", model.notes)
);
body.appendChild(section);
}
const enrichment = createElement("div", "lmri-enrichment-slot");
populateEnrichmentSlot(enrichment, model);
body.appendChild(enrichment);
const actions = createElement("div", "lmri-actions lmri-primary-actions");
actions.appendChild(
makeExternalLink(
"Open Manager card",
managerSearchUrl(model.file_name || activeName),
"lmri-manager"
)
);
body.appendChild(actions);
appendSearchActions(body, activeName, model);
card.appendChild(body);
content.appendChild(card);
}
function renderSidebar() {
if (!sidebarRoot) return;
sidebarRoot.replaceChildren();
const header = createElement("header", "lmri-header");
const heading = createElement("div", "");
heading.append(
createElement("h1", "", "LoRA Info"),
createElement("p", "", selectedNodeLabel() || "Selected node")
);
const refresh = createElement("button", "lmri-icon-button");
refresh.type = "button";
refresh.title = "Refresh LoRA Manager card";
refresh.setAttribute("aria-label", refresh.title);
refresh.appendChild(createElement("i", "pi pi-refresh"));
refresh.disabled = !activeName || lookupState.status === "loading";
refresh.addEventListener("click", () => lookupActiveName());
const configure = createElement("button", "lmri-icon-button");
configure.type = "button";
configure.title = "Configure LM Remote";
configure.setAttribute("aria-label", configure.title);
configure.appendChild(createElement("i", "pi pi-cog"));
configure.addEventListener("click", openConfiguration);
const headerActions = createElement("div", "lmri-header-actions");
headerActions.append(configure, refresh);
header.append(heading, headerActions);
sidebarRoot.appendChild(header);
if (selectedNames.length > 1) {
const selector = createElement("div", "lmri-name-selector");
selectedNames.forEach((name) => {
const button = createElement(
"button",
normalizeLoraIdentifier(name) === normalizeLoraIdentifier(activeName)
? "lmri-name active"
: "lmri-name",
loraSearchTerm(name) || name
);
button.type = "button";
button.title = name;
button.addEventListener("click", () => {
if (normalizeLoraIdentifier(name) === normalizeLoraIdentifier(activeName)) {
return;
}
activeName = name;
lookupActiveName();
});
selector.appendChild(button);
});
sidebarRoot.appendChild(selector);
}
const content = createElement("main", "lmri-content");
sidebarRoot.appendChild(content);
if (!activeName) {
renderEmpty(content);
} else if (lookupState.status === "loading") {
renderLoading(content);
} else if (lookupState.status === "found") {
renderModelCard(content, lookupState.model);
} else if (lookupState.status === "ambiguous") {
renderAmbiguous(content);
} else if (lookupState.status === "missing") {
renderMissing(content);
} else if (lookupState.status === "error") {
renderError(content);
} else {
renderLoading(content);
}
}
async function responseError(response) {
try {
const payload = await response.json();
return payload.error || `Request failed with HTTP ${response.status}`;
} catch {
return `Request failed with HTTP ${response.status}`;
}
}
async function fallbackListLookup(name, signal) {
const term = loraSearchTerm(name);
const params = new URLSearchParams({
page: "1",
page_size: "100",
search: term,
fuzzy_search: "true",
});
const response = await api.fetchApi(`/api/lm/loras/list?${params}`, {
signal,
});
if (!response.ok) throw new Error(await responseError(response));
const payload = await response.json();
const result = matchModelItems(name, payload.items);
return {
success: true,
query: name,
...result,
};
}
async function resolveManagerCard(name, signal) {
const response = await api.fetchApi(
`/api/lm/loras/resolve?name=${encodeURIComponent(name)}`,
{ signal }
);
if (response.status === 404) {
return fallbackListLookup(name, signal);
}
if (!response.ok) throw new Error(await responseError(response));
const result = await response.json();
if (result?.success && !result.found && !result.ambiguous) {
try {
return await fallbackListLookup(name, signal);
} catch (error) {
if (error?.name === "AbortError") throw error;
}
}
return result;
}
async function fetchCivitaiMetadata(model, signal) {
const filePath = String(model?.file_path || "").trim();
if (!filePath) return null;
try {
const params = new URLSearchParams({ file_path: filePath });
const response = await api.fetchApi(`/api/lm/loras/metadata?${params}`, {
signal,
});
if (!response.ok) return null;
const payload = await response.json();
return payload?.success && payload.metadata ? payload.metadata : null;
} catch (error) {
if (error?.name === "AbortError") throw error;
return null;
}
}
function communityImagesForHash(payload, hash) {
const groups = payload?.images;
if (!groups || typeof groups !== "object" || Array.isArray(groups)) return [];
const normalizedHash = String(hash || "").toLowerCase();
for (const [key, images] of Object.entries(groups)) {
if (key.toLowerCase() === normalizedHash && Array.isArray(images)) {
return images;
}
}
return [];
}
async function fetchCommunityImages(model, signal) {
const hash = String(model?.sha256 || "").trim();
if (!hash) return [];
try {
const response = await api.fetchApi(
"/api/lm/community-images/by-hashes",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hashes: [hash] }),
signal,
}
);
if (!response.ok) return [];
const payload = await response.json();
return payload?.success ? communityImagesForHash(payload, hash) : [];
} catch (error) {
if (error?.name === "AbortError") throw error;
return [];
}
}
async function fetchExampleFiles(model, signal) {
const hash = String(model?.sha256 || "").trim();
if (!hash) return [];
try {
const params = new URLSearchParams({ model_hash: hash });
const response = await api.fetchApi(
`/api/lm/example-image-files?${params}`,
{ signal }
);
if (!response.ok) return [];
const payload = await response.json();
return payload?.success && Array.isArray(payload.files) ? payload.files : [];
} catch (error) {
if (error?.name === "AbortError") throw error;
return [];
}
}
async function fetchManagerSettings(signal) {
try {
const response = await api.fetchApi("/api/lm/settings", { signal });
if (!response.ok) return null;
const payload = await response.json();
return payload?.success ? payload.settings : null;
} catch (error) {
if (error?.name === "AbortError") throw error;
return null;
}
}
async function enrichManagerCard(model, signal) {
const [details, communityImages, exampleFiles, rawSettings] = await Promise.all([
fetchCivitaiMetadata(model, signal),
fetchCommunityImages(model, signal),
fetchExampleFiles(model, signal),
fetchManagerSettings(signal),
]);
const civitai = mergeCivitaiMetadata(model?.civitai, details);
const mediaSettings = normalizeMediaSettings(rawSettings);
let sharedMedia = normalizeSharedMedia(
communityImages,
civitai,
exampleFiles
);
if (mediaSettings.showOnlySfw) {
sharedMedia = sharedMedia.filter(
(media) =>
typeof media.nsfwLevel === "number" &&
media.nsfwLevel < MATURE_MEDIA_LEVEL
);
}
return {
...model,
civitai,
mediaSettings,
sharedMedia: sharedMedia.slice(0, MAX_SHARED_MEDIA),
};
}
async function safelyEnrichManagerCard(model, signal) {
try {
return await enrichManagerCard(model, signal);
} catch (error) {
if (error?.name === "AbortError") throw error;
return {
...model,
mediaSettings: normalizeMediaSettings(),
sharedMedia: [],
};
}
}
async function lookupActiveName() {
const name = cleanLoraName(activeName);
if (!name) return;
const generation = ++lookupGeneration;
lookupController?.abort();
lookupController = new AbortController();
lookupState = { status: "loading" };
renderSidebar();
try {
const result = await resolveManagerCard(name, lookupController.signal);
if (generation !== lookupGeneration) return;
if (!result?.success) {
throw new Error(result?.error || "The Manager lookup failed.");
}
if (result.found && result.model) {
lookupState = {
status: "found",
model: result.model,
enrichment: "loading",
mediaIndex: 0,
};
renderSidebar();
const enriched = await safelyEnrichManagerCard(
result.model,
lookupController.signal
);
if (generation !== lookupGeneration) return;
lookupState = {
status: "found",
model: enriched,
enrichment: "ready",
mediaIndex: 0,
};
refreshEnrichmentSlot();
return;
} else if (result.ambiguous && result.candidates?.length) {
lookupState = {
status: "ambiguous",
candidates: result.candidates,
};
} else {
lookupState = { status: "missing" };
}
} catch (error) {
if (error?.name === "AbortError" || generation !== lookupGeneration) return;
lookupState = {
status: "error",
message: error instanceof Error ? error.message : String(error),
};
}
renderSidebar();
}
function autoOpenEnabled() {
return app.extensionManager?.setting?.get?.(AUTO_OPEN_SETTING) !== false;
}
function openSidebarTab() {
const manager = app.extensionManager;
if (!manager) return false;
const sidebar = manager.sidebarTab || manager;
if (getActiveSidebarTabId(manager) === TAB_ID) return true;
if (typeof manager.setActiveSidebarTab === "function") {
manager.setActiveSidebarTab(TAB_ID);
if (getActiveSidebarTabId(manager) === TAB_ID) return true;
}
if (sidebar && "activeSidebarTabId" in sidebar) {
try {
const current = sidebar.activeSidebarTabId;
if (current && typeof current === "object" && "value" in current) {
current.value = TAB_ID;
} else {
sidebar.activeSidebarTabId = TAB_ID;
}
} catch {
// Some frontend versions expose a readonly store property.
}
if (getActiveSidebarTabId(manager) === TAB_ID) return true;
}
if (typeof sidebar?.toggleSidebarTab === "function") {
sidebar.toggleSidebarTab(TAB_ID);
return true;
}
if (typeof manager.toggleSidebarTab === "function") {
manager.toggleSidebarTab(TAB_ID);
return true;
}
if (typeof manager.command?.execute === "function") {
manager.command.execute(`Workspace.ToggleSidebarTab.${TAB_ID}`);
return true;
}
return false;
}
function cancelSidebarClose() {
if (sidebarCloseTimer == null) return;
window.clearTimeout(sidebarCloseTimer);
sidebarCloseTimer = null;
}
function scheduleSidebarClose() {
cancelSidebarClose();
sidebarCloseTimer = window.setTimeout(() => {
sidebarCloseTimer = null;
const nodes = getSelectedGraphNodes(app.canvas);
const node = nodes.length === 1 ? nodes[0] : null;
if (node && extractLoraNames(node).length) return;
closeActiveSidebarTab(app.extensionManager, TAB_ID);
}, SIDEBAR_CLOSE_DELAY_MS);
}
function updateSelection({ autoOpen = false, force = false } = {}) {
const nodes = getSelectedGraphNodes(app.canvas);
const node = nodes.length === 1 ? nodes[0] : null;
const names = node ? extractLoraNames(node) : [];
const shouldCloseSidebar = selectedNames.length > 0 && names.length === 0;
if (names.length) cancelSidebarClose();
const signature = `${node?.id ?? ""}|${names
.map(normalizeLoraIdentifier)
.join("|")}`;
if (!force && signature === selectionSignature) {
if (autoOpen && names.length && autoOpenEnabled()) openSidebarTab();
return;
}
selectionSignature = signature;
selectedNode = node;
selectedNames = names;
const currentStillExists = names.some(
(name) =>
normalizeLoraIdentifier(name) === normalizeLoraIdentifier(activeName)
);
activeName = currentStillExists ? activeName : names[0] || "";
lookupGeneration += 1;
lookupController?.abort();
lookupState = { status: activeName ? "loading" : "idle" };
renderSidebar();
if (activeName) {
if (autoOpen && autoOpenEnabled()) openSidebarTab();
lookupActiveName();
} else if (shouldCloseSidebar) {
scheduleSidebarClose();
}
}
function chainCanvasSelection() {
const canvas = app.canvas;
if (!canvas || canvas[CANVAS_SELECTION_HOOK]) return;
canvas[CANVAS_SELECTION_HOOK] = true;
const previous = canvas.onSelectionChange;
canvas.onSelectionChange = function (...args) {
const result =
typeof previous === "function" ? previous.apply(this, args) : undefined;
queueMicrotask(() => updateSelection({ autoOpen: true }));
return result;
};
}
function chainNodeSelection(nodeType) {
const prototype = nodeType?.prototype;
if (!prototype || prototype[NODE_SELECTION_HOOK]) return;
prototype[NODE_SELECTION_HOOK] = true;
const previous = prototype.onSelected;
prototype.onSelected = function (...args) {
const result =
typeof previous === "function" ? previous.apply(this, args) : undefined;
queueMicrotask(() => updateSelection({ autoOpen: true }));
return result;
};
}
function registerSidebarTab() {
const manager = app.extensionManager;
const sidebar = manager?.sidebarTab;
const tabs =
sidebar?.sidebarTabs?.value ??
sidebar?.sidebarTabs ??
manager?.getSidebarTabs?.() ??
[];
if (Array.isArray(tabs) && tabs.some((tab) => tab.id === TAB_ID)) return;
const specification = {
id: TAB_ID,
icon: "pi pi-id-card",
title: "LoRA Info",
tooltip: "LoRA Manager card and Civitai links for the selected loader",
type: "custom",
render(container) {
ensureStyles();
container.style.height = "100%";
container.style.minHeight = "0";
sidebarRoot = createElement("div", "lmri-root");
container.replaceChildren(sidebarRoot);
renderSidebar();
updateSelection({ force: true });
},
destroy() {
cancelSidebarClose();
lookupGeneration += 1;
lookupController?.abort();
sidebarRoot?.remove();
sidebarRoot = null;
lookupState = { status: "idle" };
},
};
if (typeof manager?.registerSidebarTab === "function") {
manager.registerSidebarTab(specification);
} else if (typeof sidebar?.registerSidebarTab === "function") {
sidebar.registerSidebarTab(specification);
} else {
console.error(
"[LM-Remote] This ComfyUI frontend does not support custom sidebar tabs."
);
}
}
app.registerExtension({
name: "LoraManager.RemoteLoraInfoSidebar",
settings: [
{
id: CONFIG_SETTING_ID,
name: "Remote LoRA Manager",
type: configureSettingControl,
defaultValue: "",
category: ["LM Remote", "Connection", "Configure"],
tooltip: "Set the remote URL, timeout, and path mappings.",
},
{
id: AUTO_OPEN_SETTING,
name: "Open LoRA Info when selecting a LoRA loader",
type: "boolean",
defaultValue: true,
category: ["LM Remote", "LoRA Info", "Auto-open"],
},
],
commands: [
{
id: CONFIG_COMMAND_ID,
label: "Configure LM Remote",
icon: "pi pi-cog",
function: openConfiguration,
},
{
id: COMMAND_ID,
label: "Open LoRA Info",
icon: "pi pi-id-card",
function: () => {
updateSelection({ force: true });
openSidebarTab();
},
},
],
getSelectionToolboxCommands(selectedItem) {
return extractLoraNames(selectedItem).length ? [COMMAND_ID] : [];
},
beforeRegisterNodeDef(nodeType) {
chainNodeSelection(nodeType);
},
setup() {
ensureStyles();
registerSidebarTab();
chainCanvasSelection();
updateSelection();
if (monitorTimer == null) {
monitorTimer = window.setInterval(() => {
chainCanvasSelection();
updateSelection();
}, 500);
}
},
});