mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-11 08:20:14 -03:00
feat(nodes): flag missing local models at queue and load time (#1057)
This commit is contained in:
@@ -247,6 +247,25 @@
|
||||
border-left: 3px solid rgba(245, 158, 11, 0.6) !important;
|
||||
}
|
||||
|
||||
.lm-lora-entry[data-missing="true"] {
|
||||
background-color: rgba(220, 38, 38, 0.16);
|
||||
border: 1px solid rgba(220, 38, 38, 0.5);
|
||||
}
|
||||
|
||||
.lm-lora-entry[data-missing="true"] .lm-lora-name {
|
||||
color: rgba(252, 165, 165, 0.95);
|
||||
}
|
||||
|
||||
.lm-lora-clip-entry[data-missing="true"] {
|
||||
background-color: rgba(220, 38, 38, 0.12);
|
||||
border: 1px solid rgba(220, 38, 38, 0.4);
|
||||
border-left: 2px solid rgba(220, 38, 38, 0.6);
|
||||
}
|
||||
|
||||
.lm-lora-clip-entry[data-missing="true"] .lm-lora-name {
|
||||
color: rgba(252, 165, 165, 0.95);
|
||||
}
|
||||
|
||||
.lm-lora-name {
|
||||
margin-left: 4px;
|
||||
flex: 1;
|
||||
|
||||
@@ -3,7 +3,11 @@ import {
|
||||
parseLoraValue,
|
||||
formatLoraValue,
|
||||
shouldShowClipEntry,
|
||||
syncClipStrengthIfCollapsed
|
||||
syncClipStrengthIfCollapsed,
|
||||
getAvailableLoras,
|
||||
getAvailableLorasSync,
|
||||
isLoraNameAvailable,
|
||||
onLibraryChanged
|
||||
} from "./loras_widget_utils.js";
|
||||
import { initDrag, createContextMenu, initHeaderDrag, initReorderDrag, handleKeyboardNavigation } from "./loras_widget_events.js";
|
||||
import { forwardMiddleMouseToCanvas, forwardWheelToCanvas, enableListWheelScroll } from "./utils.js";
|
||||
@@ -140,6 +144,48 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
emitSelectionChange(buildSelectionPayload(loraName));
|
||||
}
|
||||
};
|
||||
|
||||
// Mirror ComfyUI's setNodeHasErrors: has_errors is not an auto-tracked
|
||||
// litegraph property, so the node:property:changed event must be fired
|
||||
// manually for the Vue renderer to pick up the error state.
|
||||
//
|
||||
// The flag is applied asynchronously (setTimeout 0): applying it during
|
||||
// LGraphNode.configure makes ComfyUI's errorNodeWidgets.onConfigure create
|
||||
// a fallback UNKNOWN widget for every widgets_values entry, because it
|
||||
// treats has_errors as "node definition missing".
|
||||
let pendingErrorFlag = null;
|
||||
let errorFlagTimer = null;
|
||||
|
||||
const flushErrorFlag = () => {
|
||||
errorFlagTimer = null;
|
||||
const hasMissing = pendingErrorFlag;
|
||||
pendingErrorFlag = null;
|
||||
if (typeof hasMissing !== 'boolean') {
|
||||
return;
|
||||
}
|
||||
const oldValue = node.has_errors === true;
|
||||
if (oldValue === hasMissing) {
|
||||
return;
|
||||
}
|
||||
node.has_errors = hasMissing;
|
||||
if (node.graph) {
|
||||
node.graph.trigger('node:property:changed', {
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'has_errors',
|
||||
oldValue,
|
||||
newValue: hasMissing
|
||||
});
|
||||
node.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
};
|
||||
|
||||
const updateNodeErrorFlag = (hasMissing) => {
|
||||
pendingErrorFlag = hasMissing;
|
||||
if (errorFlagTimer === null) {
|
||||
errorFlagTimer = setTimeout(flushErrorFlag, 0);
|
||||
}
|
||||
};
|
||||
|
||||
// Add keyboard event listener to container
|
||||
container.addEventListener('keydown', (e) => {
|
||||
@@ -220,6 +266,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
emptyMessage.textContent = "No LoRAs added";
|
||||
emptyMessage.className = "lm-lora-empty-state";
|
||||
container.appendChild(emptyMessage);
|
||||
updateNodeErrorFlag(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -267,8 +314,21 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
initHeaderDrag(header, widget, renderLoras);
|
||||
|
||||
// Render each lora entry
|
||||
const availableSet = getAvailableLorasSync();
|
||||
if (!availableSet) {
|
||||
// Availability data missing (workflow switch without node recreation,
|
||||
// cache expiry): fetch it and re-render once it lands so missing cues
|
||||
// and the node flag always resolve. Only re-render on success to avoid
|
||||
// retry loops on failure.
|
||||
getAvailableLoras().then((set) => {
|
||||
if (set && !widget.__dragActive && container.isConnected) {
|
||||
renderLoras(widget.value, widget);
|
||||
}
|
||||
});
|
||||
}
|
||||
lorasData.forEach((loraData) => {
|
||||
const { name, strength, clipStrength, active } = loraData;
|
||||
const missing = !isLoraNameAvailable(name, availableSet);
|
||||
|
||||
// Determine expansion state using our helper function
|
||||
const isExpanded = shouldShowClipEntry(loraData);
|
||||
@@ -283,6 +343,10 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
loraEl.dataset.active = active ? "true" : "false";
|
||||
loraEl.dataset.locked = (loraData.locked || false) ? "true" : "false";
|
||||
|
||||
if (missing) {
|
||||
loraEl.setAttribute("data-missing", "true");
|
||||
}
|
||||
|
||||
// Add click handler for selection
|
||||
loraEl.addEventListener('click', (e) => {
|
||||
// Skip if clicking on interactive elements
|
||||
@@ -374,6 +438,9 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
const nameEl = document.createElement("div");
|
||||
nameEl.textContent = name;
|
||||
nameEl.className = "lm-lora-name";
|
||||
if (missing) {
|
||||
nameEl.title = "LoRA not found in local library";
|
||||
}
|
||||
|
||||
// Move preview tooltip events to nameEl instead of loraEl
|
||||
let previewTimer = null; // Timer for delayed preview
|
||||
@@ -387,7 +454,8 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
|
||||
nameEl.addEventListener('mouseenter', (e) => {
|
||||
e.stopPropagation();
|
||||
if (shouldSuppressPreview()) {
|
||||
// Missing LoRAs have no preview data — skip the placeholder tooltip.
|
||||
if (missing || shouldSuppressPreview()) {
|
||||
return;
|
||||
}
|
||||
previewTimer = setTimeout(async () => {
|
||||
@@ -544,10 +612,17 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
clipEl.dataset.loraName = name;
|
||||
clipEl.dataset.active = active ? "true" : "false";
|
||||
|
||||
if (missing) {
|
||||
clipEl.setAttribute("data-missing", "true");
|
||||
}
|
||||
|
||||
// Create clip name display
|
||||
const clipNameEl = document.createElement("div");
|
||||
clipNameEl.textContent = "[clip] " + name;
|
||||
clipNameEl.className = "lm-lora-name";
|
||||
if (missing) {
|
||||
clipNameEl.title = "LoRA not found in local library";
|
||||
}
|
||||
|
||||
// Create clip strength control
|
||||
const clipStrengthControl = document.createElement("div");
|
||||
@@ -667,6 +742,16 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
updateEntrySelection(entry, entryLoraName === selectedLora);
|
||||
});
|
||||
|
||||
// Flag the node when any active entry references a LoRA missing locally.
|
||||
// Skipped while the availability set is not loaded (null) to avoid
|
||||
// clearing or setting the flag based on incomplete information.
|
||||
const hasMissingActive = availableSet
|
||||
? lorasData.some(
|
||||
(lora) => lora.active && !isLoraNameAvailable(lora.name, availableSet)
|
||||
)
|
||||
: null;
|
||||
updateNodeErrorFlag(hasMissingActive);
|
||||
|
||||
const selectionExists = selectedLora
|
||||
? currentLorasData.some((lora) => lora.name === selectedLora)
|
||||
: false;
|
||||
@@ -767,7 +852,30 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
|
||||
widget.callback = callback;
|
||||
|
||||
// Invalidate the availability cache and re-render when the local library
|
||||
// changes (e.g. a LoRA is deleted from the Lora Manager UI) so missing
|
||||
// cues and the node error flag update without waiting for the TTL.
|
||||
const unsubscribeLibraryChange = onLibraryChanged(() => {
|
||||
if (!widget.__dragActive && container.isConnected) {
|
||||
renderLoras(widget.value, widget);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch the local library and re-render once available so missing entries
|
||||
// get their visual cue and the node error flag as soon as the data lands.
|
||||
getAvailableLoras().then(() => {
|
||||
if (!widget.__dragActive && container.isConnected) {
|
||||
renderLoras(widget.value, widget);
|
||||
}
|
||||
});
|
||||
|
||||
widget.onRemove = () => {
|
||||
unsubscribeLibraryChange();
|
||||
if (errorFlagTimer !== null) {
|
||||
clearTimeout(errorFlagTimer);
|
||||
errorFlagTimer = null;
|
||||
pendingErrorFlag = null;
|
||||
}
|
||||
while (container.firstChild) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,246 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
// Mirrors the backend resolver (get_lora_info_absolute): a ".ckpt"/".pt"
|
||||
// reference resolves to the same-named .safetensors file. The scanner only
|
||||
// indexes .safetensors, but keeping these here lets legacy references match.
|
||||
const LORA_FILE_EXTENSIONS = [".safetensors", ".ckpt", ".pt", ".bin"];
|
||||
|
||||
/**
|
||||
* Strip a known LoRA model extension from a name (case-insensitive).
|
||||
*
|
||||
* The two sides of the availability check differ:
|
||||
* - The collection side (cycler-list `file_name`) is stored extension-free
|
||||
* (scanner convention), so stripping is a no-op there.
|
||||
* - The widget entry side comes from the autocomplete path, which returns
|
||||
* on-disk relative paths WITH the extension (e.g.
|
||||
* "Illustrious/lazyhand.safetensors"), so stripping is required to match.
|
||||
*/
|
||||
export function stripLoraExtension(name) {
|
||||
const lowered = String(name || "").toLowerCase();
|
||||
for (const ext of LORA_FILE_EXTENSIONS) {
|
||||
if (lowered.endsWith(ext)) {
|
||||
return name.slice(0, -ext.length);
|
||||
}
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a LoRA name for availability lookup: forward slashes and no
|
||||
* extension, mirroring the backend matching in get_lora_info_absolute.
|
||||
*/
|
||||
export function normalizeLoraNameKey(name) {
|
||||
return stripLoraExtension(String(name || "").replace(/\\/g, "/"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the lookup set of available LoRA names from relative paths like
|
||||
* "folder/lora.safetensors". Both the full path and the bare basename are
|
||||
* registered (extension stripped), matching how users can reference LoRAs.
|
||||
*/
|
||||
export function buildAvailableLoraSet(relativePaths) {
|
||||
const set = new Set();
|
||||
for (const p of relativePaths || []) {
|
||||
const normalized = normalizeLoraNameKey(p);
|
||||
if (!normalized) continue;
|
||||
set.add(normalized);
|
||||
const slash = normalized.lastIndexOf("/");
|
||||
if (slash >= 0) {
|
||||
set.add(normalized.slice(slash + 1));
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a widget entry name is available locally.
|
||||
*
|
||||
* When the availability set is not loaded yet (null), every name is treated
|
||||
* as available so entries are never falsely flagged while the fetch is
|
||||
* pending. Absolute paths outside the library cannot be verified
|
||||
* client-side and are treated as available. A folder-qualified name that
|
||||
* does not match a stored path falls back to its basename, mirroring the
|
||||
* backend resolver (get_lora_info_absolute's basename fallback and the
|
||||
* legacy syntax format).
|
||||
*/
|
||||
export function isLoraNameAvailable(name, availableSet) {
|
||||
if (!availableSet) {
|
||||
return true;
|
||||
}
|
||||
const normalized = String(name || "").replace(/\\/g, "/");
|
||||
if (normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
const key = normalizeLoraNameKey(name);
|
||||
if (availableSet.has(key)) {
|
||||
return true;
|
||||
}
|
||||
const slash = key.lastIndexOf("/");
|
||||
if (slash >= 0) {
|
||||
return availableSet.has(key.slice(slash + 1));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const AVAILABLE_LORAS_TTL_MS = 60000;
|
||||
let availableLorasCache = null;
|
||||
let availableLorasPromise = null;
|
||||
let availabilityGeneration = 0;
|
||||
|
||||
async function refreshAvailableLoras() {
|
||||
const generation = availabilityGeneration;
|
||||
try {
|
||||
const response = await api.fetchApi("/lm/loras/cycler-list", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
if (!response || !response.ok) {
|
||||
return null;
|
||||
}
|
||||
const data = await response.json();
|
||||
const paths = (data?.loras || [])
|
||||
.map((lora) => lora?.file_name)
|
||||
.filter(Boolean);
|
||||
const set = buildAvailableLoraSet(paths);
|
||||
if (generation !== availabilityGeneration) {
|
||||
// Stale response: the cache was invalidated while this fetch was in
|
||||
// flight, do not repopulate it with pre-change data.
|
||||
return null;
|
||||
}
|
||||
availableLorasCache = { set, at: Date.now() };
|
||||
return set;
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch available LoRAs:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the set of available LoRA names, cached with a TTL. Concurrent
|
||||
* callers share a single in-flight request. Resolves to null on failure.
|
||||
*/
|
||||
export function getAvailableLoras() {
|
||||
connectLibraryChangeSocket();
|
||||
if (
|
||||
availableLorasCache &&
|
||||
Date.now() - availableLorasCache.at < AVAILABLE_LORAS_TTL_MS
|
||||
) {
|
||||
return Promise.resolve(availableLorasCache.set);
|
||||
}
|
||||
if (!availableLorasPromise) {
|
||||
availableLorasPromise = refreshAvailableLoras().finally(() => {
|
||||
availableLorasPromise = null;
|
||||
});
|
||||
}
|
||||
return availableLorasPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous snapshot of the cached availability set, or null when the
|
||||
* cache is not loaded (or expired).
|
||||
*/
|
||||
export function getAvailableLorasSync() {
|
||||
if (
|
||||
availableLorasCache &&
|
||||
Date.now() - availableLorasCache.at < AVAILABLE_LORAS_TTL_MS
|
||||
) {
|
||||
return availableLorasCache.set;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached availability data (used by tests and by callers that need
|
||||
* a forced refresh of the local library state). In-flight fetches started
|
||||
* before the reset are invalidated via the generation counter.
|
||||
*/
|
||||
export function resetAvailableLorasCache() {
|
||||
availabilityGeneration += 1;
|
||||
availableLorasCache = null;
|
||||
availableLorasPromise = null;
|
||||
}
|
||||
|
||||
// The Lora Manager UI and the ComfyUI graph page are separate pages; the
|
||||
// backend broadcasts "models_changed" over its WebSocket when the local
|
||||
// library changes (delete/rename/move/scan), so the graph page can
|
||||
// invalidate its availability cache immediately instead of waiting for the
|
||||
// TTL to expire.
|
||||
const libraryChangeListeners = new Set();
|
||||
|
||||
/**
|
||||
* Register a callback fired whenever the local model library changes.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
export function onLibraryChanged(callback) {
|
||||
libraryChangeListeners.add(callback);
|
||||
return () => {
|
||||
libraryChangeListeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a library-change WebSocket message. Exported for testability.
|
||||
*/
|
||||
export function handleLibraryChangeMessage(data) {
|
||||
if (!data || data.type !== "models_changed") {
|
||||
return;
|
||||
}
|
||||
resetAvailableLorasCache();
|
||||
for (const listener of libraryChangeListeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch (error) {
|
||||
console.warn("Library change listener failed:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const LIBRARY_WS_RECONNECT_MS = 30000;
|
||||
let libraryWs = null;
|
||||
let libraryWsRetryTimer = null;
|
||||
|
||||
function connectLibraryChangeSocket() {
|
||||
if (libraryWs || typeof WebSocket === "undefined") {
|
||||
return;
|
||||
}
|
||||
const protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket(`${protocol}${window.location.host}/ws/fetch-progress`);
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
libraryWs = ws;
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
handleLibraryChangeMessage(JSON.parse(event.data));
|
||||
} catch (error) {
|
||||
// Non-JSON messages from other broadcasters are ignored.
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
libraryWs = null;
|
||||
if (libraryWsRetryTimer === null) {
|
||||
libraryWsRetryTimer = setTimeout(() => {
|
||||
libraryWsRetryTimer = null;
|
||||
connectLibraryChangeSocket();
|
||||
}, LIBRARY_WS_RECONNECT_MS);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the library-change WebSocket is connected (idempotent). Called on
|
||||
* the first availability fetch; safe in environments without WebSocket.
|
||||
*/
|
||||
export function ensureLibraryChangeSocket() {
|
||||
connectLibraryChangeSocket();
|
||||
}
|
||||
|
||||
// Parse LoRA entries from value
|
||||
export function parseLoraValue(value) {
|
||||
|
||||
Reference in New Issue
Block a user