feat: enrich LoRA sidebar with shared media
This commit is contained in:
@@ -163,13 +163,38 @@
|
||||
background: #101012;
|
||||
}
|
||||
|
||||
.lmri-preview img {
|
||||
.lmri-preview img,
|
||||
.lmri-preview video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.lmri-preview video {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.lmri-media-blurred {
|
||||
filter: blur(22px) brightness(0.55);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.lmri-media-reveal {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgb(255 255 255 / 45%);
|
||||
border-radius: 7px;
|
||||
background: rgb(10 10 12 / 82%);
|
||||
color: white;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.lmri-card-body,
|
||||
.lmri-notice {
|
||||
padding: 13px;
|
||||
@@ -279,6 +304,191 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.lmri-shared {
|
||||
margin-top: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--lmri-border);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--lmri-bg) 58%, transparent);
|
||||
}
|
||||
|
||||
.lmri-shared-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.lmri-shared-header h3,
|
||||
.lmri-shared-prompt h4 {
|
||||
margin: 0;
|
||||
color: var(--lmri-text);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.lmri-shared-source {
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
color: var(--lmri-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.lmri-shared-navigation {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.lmri-shared-nav,
|
||||
.lmri-copy-button {
|
||||
border: 1px solid var(--lmri-border);
|
||||
border-radius: 6px;
|
||||
background: var(--lmri-panel);
|
||||
color: var(--lmri-text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lmri-shared-nav {
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.lmri-shared-position {
|
||||
min-width: 29px;
|
||||
color: var(--lmri-muted);
|
||||
font-size: 9px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lmri-shared-dots {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
padding: 8px 9px 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.lmri-shared-dot {
|
||||
flex: 0 0 8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--lmri-muted) 52%, transparent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lmri-shared-dot.active {
|
||||
background: var(--lmri-accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--lmri-accent) 28%, transparent);
|
||||
}
|
||||
|
||||
.lmri-shared-dot:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--lmri-accent) 75%, white 15%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.lmri-shared-viewer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
max-height: 360px;
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
border-block: 1px solid var(--lmri-border);
|
||||
background: #101012;
|
||||
}
|
||||
|
||||
.lmri-shared-viewer img,
|
||||
.lmri-shared-viewer video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.lmri-shared-params {
|
||||
padding: 8px 9px 0;
|
||||
}
|
||||
|
||||
.lmri-shared-prompt {
|
||||
padding: 9px 10px 10px;
|
||||
}
|
||||
|
||||
.lmri-prompt-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.lmri-copy-button {
|
||||
min-width: 48px;
|
||||
padding: 3px 7px;
|
||||
color: var(--lmri-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.lmri-copy-button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.lmri-shared-prompt-text,
|
||||
.lmri-negative-prompt p {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--lmri-text);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.lmri-shared-prompt-text {
|
||||
max-height: 9em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.lmri-negative-prompt {
|
||||
padding: 0 10px 10px;
|
||||
color: var(--lmri-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.lmri-negative-prompt summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lmri-negative-prompt p {
|
||||
margin-top: 6px;
|
||||
color: var(--lmri-muted);
|
||||
}
|
||||
|
||||
.lmri-enrichment-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-top: 12px;
|
||||
color: var(--lmri-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.lmri-shared-nav:hover,
|
||||
.lmri-copy-button:hover,
|
||||
.lmri-media-reveal:hover {
|
||||
border-color: color-mix(in srgb, var(--lmri-accent) 75%, var(--lmri-border));
|
||||
}
|
||||
|
||||
.lmri-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
const WEIGHT_EXTENSION = /\.(?:safetensors|ckpt|pt|pth|bin)$/i;
|
||||
const LORA_SYNTAX = /<lora:([^:>]+)(?::[^>]*)?>/gi;
|
||||
const VIDEO_EXTENSION = /\.(?:mp4|webm|mov|m4v)$/i;
|
||||
const NSFW_LEVELS = {
|
||||
pg: 1,
|
||||
pg13: 2,
|
||||
r: 4,
|
||||
x: 8,
|
||||
xxx: 16,
|
||||
blocked: 32,
|
||||
};
|
||||
const DISABLED_VALUES = new Set([
|
||||
"",
|
||||
"none",
|
||||
@@ -77,6 +86,315 @@ export function normalizeUsageTips(value) {
|
||||
.filter((entry) => entry.value !== "");
|
||||
}
|
||||
|
||||
function decodeMediaPath(value) {
|
||||
let decoded = String(value || "");
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
const next = decodeURIComponent(decoded);
|
||||
if (next === decoded) break;
|
||||
decoded = next;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function mediaPath(value) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) return "";
|
||||
try {
|
||||
const parsed = new URL(text, "http://localhost/");
|
||||
return decodeMediaPath(parsed.searchParams.get("path") || parsed.pathname)
|
||||
.split(/[?#]/, 1)[0]
|
||||
.toLowerCase();
|
||||
} catch {
|
||||
return decodeMediaPath(text).split(/[?#]/, 1)[0].toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
export function isVideoMedia(value, declaredType = "") {
|
||||
const type = String(declaredType || "").trim().toLowerCase();
|
||||
if (type === "video" || type.startsWith("video/")) return true;
|
||||
return VIDEO_EXTENSION.test(mediaPath(value));
|
||||
}
|
||||
|
||||
function safeMediaReference(value) {
|
||||
if (typeof value !== "string" || !value.trim()) return "";
|
||||
try {
|
||||
const parsed = new URL(value.trim(), "http://localhost/");
|
||||
return !parsed.username &&
|
||||
!parsed.password &&
|
||||
(parsed.protocol === "http:" || parsed.protocol === "https:")
|
||||
? value.trim()
|
||||
: "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function firstString(...values) {
|
||||
for (const value of values) {
|
||||
const text = stringValue(value);
|
||||
if (text) return text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function objectValue(value) {
|
||||
let parsed = value;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? parsed
|
||||
: {};
|
||||
}
|
||||
|
||||
function generationMetadata(item) {
|
||||
const direct = objectValue(item?.meta);
|
||||
return { ...direct, ...objectValue(direct.meta) };
|
||||
}
|
||||
|
||||
function normalizeNsfwLevel(value) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value > 0 ? value : null;
|
||||
}
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
const normalized = value.trim().toLowerCase().replace(/[\s_-]+/g, "");
|
||||
if (normalized === "unknown") return null;
|
||||
if (normalized in NSFW_LEVELS) return NSFW_LEVELS[normalized];
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
|
||||
}
|
||||
|
||||
function optimizeMediaUrl(value, mediaType) {
|
||||
const safe = safeMediaReference(value);
|
||||
if (!safe) return "";
|
||||
try {
|
||||
const parsed = new URL(safe, "http://localhost/");
|
||||
if (
|
||||
parsed.hostname !== "civitai.com" &&
|
||||
parsed.hostname.endsWith(".civitai.com") &&
|
||||
parsed.pathname.includes("/original=true")
|
||||
) {
|
||||
const replacement =
|
||||
mediaType === "video"
|
||||
? "/transcode=true,width=450,optimized=true"
|
||||
: "/width=450,optimized=true";
|
||||
parsed.pathname = parsed.pathname.replace("/original=true", replacement);
|
||||
return parsed.href;
|
||||
}
|
||||
} catch {
|
||||
return safe;
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
function mediaIdentity(item, url) {
|
||||
const id = item?.civitai_image_id ?? item?.id;
|
||||
return id != null && String(id).trim()
|
||||
? `id:${String(id).trim()}`
|
||||
: `url:${url}`;
|
||||
}
|
||||
|
||||
function localExampleFile(item, index, exampleFiles) {
|
||||
if (!Array.isArray(exampleFiles) || !exampleFiles.length) return null;
|
||||
if (typeof item?.id === "string" && item.id) {
|
||||
const prefix = `custom_${item.id}`;
|
||||
return exampleFiles.find((file) =>
|
||||
String(file?.name || "").startsWith(prefix)
|
||||
);
|
||||
}
|
||||
return exampleFiles.find((file) => {
|
||||
const match = /(?:^|\/)image_(\d+)\./i.exec(String(file?.name || ""));
|
||||
return match && Number(match[1]) === index;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMediaItem(item, source, localFile = null) {
|
||||
if (!item || typeof item !== "object" || item.downloadFailed) return null;
|
||||
const meta = generationMetadata(item);
|
||||
const rawUrl = firstString(
|
||||
localFile?.path,
|
||||
item.preview_url,
|
||||
item.image_url,
|
||||
item.url
|
||||
);
|
||||
const mediaType = isVideoMedia(
|
||||
rawUrl,
|
||||
localFile?.is_video ? "video" : item.media_type || item.type
|
||||
)
|
||||
? "video"
|
||||
: "image";
|
||||
const url = optimizeMediaUrl(rawUrl, mediaType);
|
||||
if (!url) return null;
|
||||
|
||||
const prompt = firstString(item.prompt, meta.prompt);
|
||||
const negativePrompt = firstString(
|
||||
item.negative_prompt,
|
||||
item.negativePrompt,
|
||||
meta.negative_prompt,
|
||||
meta.negativePrompt
|
||||
);
|
||||
const sizeLabel = firstString(item.size, meta.Size, meta.size);
|
||||
const sizeMatch = /^(\d+)\s*[x×]\s*(\d+)$/i.exec(sizeLabel);
|
||||
const width = Number(item.width || sizeMatch?.[1]);
|
||||
const height = Number(item.height || sizeMatch?.[2]);
|
||||
const reactionCount = [
|
||||
item.like_count,
|
||||
item.heart_count,
|
||||
item.laugh_count,
|
||||
item.comment_count,
|
||||
].reduce((sum, value) => {
|
||||
const count = Number(value);
|
||||
return sum + (Number.isFinite(count) && count > 0 ? count : 0);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
id: mediaIdentity(item, url),
|
||||
source,
|
||||
url,
|
||||
thumbnailUrl: safeMediaReference(item.thumbnail_url),
|
||||
mediaType,
|
||||
prompt,
|
||||
negativePrompt,
|
||||
username: firstString(item.username, item.creator?.username),
|
||||
width: Number.isFinite(width) && width > 0 ? width : null,
|
||||
height: Number.isFinite(height) && height > 0 ? height : null,
|
||||
steps: item.steps ?? meta.steps ?? meta.Steps ?? null,
|
||||
sampler: item.sampler ?? meta.sampler ?? meta.Sampler ?? "",
|
||||
cfgScale:
|
||||
item.cfg_scale ?? meta.cfg_scale ?? meta.cfgScale ?? meta.CFG ?? null,
|
||||
seed: item.seed ?? meta.seed ?? meta.Seed ?? null,
|
||||
denoise: item.denoise ?? meta.denoise ?? null,
|
||||
clipSkip: meta.clip_skip ?? meta.clipSkip ?? null,
|
||||
modelName: firstString(item.model_name, meta.Model, meta.model),
|
||||
baseModel: firstString(
|
||||
item.base_model,
|
||||
item.baseModel,
|
||||
meta.base_model,
|
||||
meta.baseModel
|
||||
),
|
||||
nsfwLevel: normalizeNsfwLevel(
|
||||
item.nsfwLevel ?? item.nsfw_level ?? item.nsfw
|
||||
),
|
||||
reactionCount,
|
||||
civitaiImageId: item.civitai_image_id ?? item.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function missingMediaValue(value) {
|
||||
return value == null || value === "";
|
||||
}
|
||||
|
||||
function mergeMediaItem(primary, supplement) {
|
||||
const merged = { ...primary };
|
||||
const fillFields = [
|
||||
"thumbnailUrl",
|
||||
"prompt",
|
||||
"negativePrompt",
|
||||
"username",
|
||||
"width",
|
||||
"height",
|
||||
"steps",
|
||||
"sampler",
|
||||
"cfgScale",
|
||||
"seed",
|
||||
"denoise",
|
||||
"clipSkip",
|
||||
"modelName",
|
||||
"baseModel",
|
||||
"civitaiImageId",
|
||||
];
|
||||
for (const field of fillFields) {
|
||||
if (missingMediaValue(merged[field]) && !missingMediaValue(supplement[field])) {
|
||||
merged[field] = supplement[field];
|
||||
}
|
||||
}
|
||||
if (!String(merged.url).startsWith("/") && String(supplement.url).startsWith("/")) {
|
||||
merged.url = supplement.url;
|
||||
}
|
||||
if (supplement.mediaType === "video") merged.mediaType = "video";
|
||||
const levels = [merged.nsfwLevel, supplement.nsfwLevel].filter(
|
||||
(value) => typeof value === "number" && Number.isFinite(value)
|
||||
);
|
||||
merged.nsfwLevel = levels.length ? Math.max(...levels) : null;
|
||||
merged.reactionCount = Math.max(
|
||||
Number(merged.reactionCount) || 0,
|
||||
Number(supplement.reactionCount) || 0
|
||||
);
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function mergeCivitaiMetadata(summary, details) {
|
||||
const merged = { ...objectValue(summary) };
|
||||
for (const [key, value] of Object.entries(objectValue(details))) {
|
||||
if (value !== undefined) merged[key] = value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function normalizeSharedMedia(
|
||||
communityImages,
|
||||
civitaiMetadata,
|
||||
exampleFiles = []
|
||||
) {
|
||||
const civitai = objectValue(civitaiMetadata);
|
||||
const regularImages = Array.isArray(civitai.images) ? civitai.images : [];
|
||||
const customImages = Array.isArray(civitai.customImages)
|
||||
? civitai.customImages
|
||||
: [];
|
||||
const candidates = [
|
||||
...(Array.isArray(communityImages)
|
||||
? communityImages.map((item) => [item, "Community creation", null])
|
||||
: []),
|
||||
...regularImages.map((item, index) => [
|
||||
item,
|
||||
"Civitai example",
|
||||
localExampleFile(item, index, exampleFiles),
|
||||
]),
|
||||
...customImages.map((item, index) => [
|
||||
item,
|
||||
"Custom example",
|
||||
localExampleFile(item, regularImages.length + index, exampleFiles),
|
||||
]),
|
||||
];
|
||||
|
||||
const indexes = new Map();
|
||||
const media = [];
|
||||
for (const [item, source, localFile] of candidates) {
|
||||
const normalized = normalizeMediaItem(item, source, localFile);
|
||||
if (!normalized) continue;
|
||||
const existingIndex = indexes.get(normalized.id);
|
||||
if (existingIndex != null) {
|
||||
media[existingIndex] = mergeMediaItem(media[existingIndex], normalized);
|
||||
continue;
|
||||
}
|
||||
indexes.set(normalized.id, media.length);
|
||||
media.push(normalized);
|
||||
}
|
||||
return media;
|
||||
}
|
||||
|
||||
export function normalizeMediaSettings(value) {
|
||||
const settings = objectValue(value);
|
||||
return {
|
||||
blurMatureContent: settings.blur_mature_content !== false,
|
||||
matureBlurLevel:
|
||||
normalizeNsfwLevel(settings.mature_blur_level) ?? NSFW_LEVELS.r,
|
||||
showOnlySfw: settings.show_only_sfw === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractLoraSyntax(value) {
|
||||
if (typeof value !== "string") return [];
|
||||
const names = [];
|
||||
|
||||
Reference in New Issue
Block a user