feat: enrich LoRA sidebar with shared media

This commit is contained in:
2026-08-24 23:01:28 +02:00
parent 5bb3b3a349
commit 203885fc4e
7 changed files with 1371 additions and 20 deletions
+509 -13
View File
@@ -6,9 +6,13 @@ import {
cleanLoraName,
extractLoraNames,
getSelectedGraphNodes,
isVideoMedia,
loraSearchTerm,
matchModelItems,
mergeCivitaiMetadata,
normalizeLoraIdentifier,
normalizeMediaSettings,
normalizeSharedMedia,
normalizeUsageTips,
} from "./lora_manager_sidebar_utils.js";
import { openRemoteConfigDialog } from "./remote_config_dialog.js";
@@ -21,6 +25,8 @@ 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;
let sidebarRoot = null;
let selectedNode = null;
@@ -98,7 +104,11 @@ function safePreviewUrl(value) {
if (!value) return "";
try {
const parsed = new URL(String(value), window.location.origin);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
if (
!parsed.username &&
!parsed.password &&
(parsed.protocol === "http:" || parsed.protocol === "https:")
) {
return parsed.href;
}
} catch {
@@ -107,6 +117,124 @@ function safePreviewUrl(value) {
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);
@@ -230,11 +358,36 @@ function renderError(content) {
content.appendChild(panel);
}
function useResolvedCandidate(model) {
lookupGeneration += 1;
async function useResolvedCandidate(model) {
const generation = ++lookupGeneration;
lookupController?.abort();
lookupState = { status: "found", model };
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) {
@@ -258,7 +411,9 @@ function renderAmbiguous(content) {
createElement("strong", "", title),
createElement("span", "", path || model.file_path || "")
);
button.addEventListener("click", () => useResolvedCandidate(model));
button.addEventListener("click", () => {
void useResolvedCandidate(model);
});
candidates.appendChild(button);
});
panel.appendChild(candidates);
@@ -266,18 +421,213 @@ function renderAmbiguous(content) {
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 image = document.createElement("img");
image.src = previewUrl;
image.alt = `Preview for ${model.model_name || activeName}`;
image.loading = "lazy";
image.addEventListener("error", () => preview.remove());
preview.appendChild(image);
card.appendChild(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");
@@ -358,6 +708,10 @@ function renderModelCard(content, model) {
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(
@@ -493,6 +847,129 @@ async function resolveManagerCard(name, signal) {
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;
@@ -511,7 +988,26 @@ async function lookupActiveName() {
throw new Error(result?.error || "The Manager lookup failed.");
}
if (result.found && result.model) {
lookupState = { status: "found", model: 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",