- Send version_name only for titles shared by several files and guard non-dict civitai values, so the cycler payload stays small. - Keep cached labels after the availability TTL expires instead of flashing file names while a refresh is in flight. - List every covered node in the setting tooltip. - Cover the payload rules and the stale-cache labels with tests.
898 lines
27 KiB
JavaScript
898 lines
27 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
import { api } from "../../scripts/api.js";
|
|
import { lmUrl } from "./base_path.js";
|
|
|
|
// Mirrors the backend resolver (get_lora_info_absolute): a ".ckpt"/".pt"
|
|
// reference resolves to the same-named .safetensors file. The scanner only
|
|
// 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;
|
|
|
|
/**
|
|
* Parse a numeric usage-tips value (accepts numbers and numeric strings).
|
|
*/
|
|
function parseUsageTipNumber(value) {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
if (typeof value === "string") {
|
|
const parsed = parseFloat(value);
|
|
if (Number.isFinite(parsed)) {
|
|
return parsed;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Parse a "x.x-y.y" range string, tolerating whitespace and negatives.
|
|
*/
|
|
function parseRangeString(value) {
|
|
if (typeof value !== "string") {
|
|
return null;
|
|
}
|
|
const match = value.trim().match(/^(-?\d+(?:\.\d+)?)\s*-\s*(-?\d+(?:\.\d+)?)$/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
return { min: parseFloat(match[1]), max: parseFloat(match[2]) };
|
|
}
|
|
|
|
/**
|
|
* Extract the recommended strength range from a usage_tips payload (JSON
|
|
* string or already-parsed object). Explicit strength_min/strength_max keys
|
|
* take precedence over the strength_range "x.x-y.y" shorthand. Returns
|
|
* { min, max, recommended } with null bounds for open-ended ranges, or null
|
|
* when no valid range is configured.
|
|
*/
|
|
export function parseStrengthRange(usageTips) {
|
|
let tips = usageTips;
|
|
if (typeof tips === "string") {
|
|
if (!tips.trim()) {
|
|
return null;
|
|
}
|
|
try {
|
|
tips = JSON.parse(tips);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
if (!tips || typeof tips !== "object") {
|
|
return null;
|
|
}
|
|
|
|
let min = parseUsageTipNumber(tips.strength_min ?? tips.strengthMin);
|
|
let max = parseUsageTipNumber(tips.strength_max ?? tips.strengthMax);
|
|
|
|
if (min === null || max === null) {
|
|
const parsed = parseRangeString(tips.strength_range ?? tips.strengthRange);
|
|
if (parsed) {
|
|
if (min === null) min = parsed.min;
|
|
if (max === null) max = parsed.max;
|
|
}
|
|
}
|
|
|
|
if (min === null && max === null) {
|
|
return null;
|
|
}
|
|
if (min !== null && max !== null && min > max) {
|
|
return null;
|
|
}
|
|
|
|
return { min, max, recommended: parseUsageTipNumber(tips.strength) };
|
|
}
|
|
|
|
/**
|
|
* Describe how a strength value violates the recommended range, or return
|
|
* null when it is inside (or no range is configured). The returned string is
|
|
* used as the input's tooltip.
|
|
*/
|
|
export function describeStrengthRangeViolation(value, range) {
|
|
if (!range) {
|
|
return null;
|
|
}
|
|
const numeric = Number(value);
|
|
if (!Number.isFinite(numeric)) {
|
|
return null;
|
|
}
|
|
const { min, max, recommended } = range;
|
|
|
|
let direction = null;
|
|
if (min !== null && numeric < min) {
|
|
direction = "Below";
|
|
} else if (max !== null && numeric > max) {
|
|
direction = "Above";
|
|
}
|
|
if (!direction) {
|
|
return null;
|
|
}
|
|
|
|
let rangeText;
|
|
if (min !== null && max !== null) {
|
|
rangeText = `${min.toFixed(2)}\u2013${max.toFixed(2)}`;
|
|
} else if (min !== null) {
|
|
rangeText = `\u2265 ${min.toFixed(2)}`;
|
|
} else {
|
|
rangeText = `\u2264 ${max.toFixed(2)}`;
|
|
}
|
|
|
|
let text = `${direction} recommended strength range (${rangeText})`;
|
|
if (recommended !== null) {
|
|
text += `; recommended: ${recommended.toFixed(2)}`;
|
|
}
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* Toggle the out-of-range visual cue on a strength input.
|
|
*/
|
|
export function applyStrengthRangeCue(inputEl, value, range) {
|
|
const message = describeStrengthRangeViolation(value, range);
|
|
inputEl.classList.toggle("lm-strength-out-of-range", message !== null);
|
|
if (message) {
|
|
inputEl.title = message;
|
|
} else {
|
|
inputEl.removeAttribute("title");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the lookup map of recommended strength ranges from cycler-list
|
|
* entries. Keyed like the availability set (normalized path and basename).
|
|
*/
|
|
export function buildStrengthRangeMap(loras) {
|
|
const map = new Map();
|
|
for (const lora of loras || []) {
|
|
const range = parseStrengthRange(lora?.usage_tips);
|
|
if (!range) {
|
|
continue;
|
|
}
|
|
const normalized = normalizeLoraNameKey(lora?.file_name);
|
|
if (!normalized) {
|
|
continue;
|
|
}
|
|
map.set(normalized, range);
|
|
const slash = normalized.lastIndexOf("/");
|
|
if (slash >= 0) {
|
|
map.set(normalized.slice(slash + 1), range);
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Build the lookup map of row labels from cycler-list entries. Keyed like the
|
|
* availability set (normalized path and basename), so a row that stores either
|
|
* form resolves to its label.
|
|
*
|
|
* The label is the model name (Civitai / sidecar title). Titles are not unique
|
|
* in practice -- a LoRA's versions often share one title, and the same title may
|
|
* cover several files -- so for every title used by more than one file the label
|
|
* gains a disambiguating prefix: "[v2] Title". Unique titles are left untouched.
|
|
* The prefix goes first because node rows truncate with an ellipsis, and a
|
|
* suffix at the end is exactly what a narrow row clips first.
|
|
*/
|
|
export function buildModelNameMap(loras) {
|
|
const entries = [];
|
|
for (const lora of loras || []) {
|
|
const modelName = typeof lora?.model_name === "string" ? lora.model_name.trim() : "";
|
|
if (!modelName) {
|
|
continue;
|
|
}
|
|
const normalized = normalizeLoraNameKey(lora?.file_name);
|
|
if (!normalized) {
|
|
continue;
|
|
}
|
|
entries.push({
|
|
normalized,
|
|
title: modelName,
|
|
versionName: typeof lora?.version_name === "string" ? lora.version_name.trim() : "",
|
|
folder: typeof lora?.folder === "string" ? lora.folder.trim() : "",
|
|
});
|
|
}
|
|
|
|
const suffixByEntry = new Map();
|
|
for (const group of groupEntriesByTitle(entries)) {
|
|
if (group.length < 2) {
|
|
continue;
|
|
}
|
|
const suffixes = resolveTitleSuffixes(group);
|
|
group.forEach((entry, index) => {
|
|
const suffix = suffixes[index];
|
|
if (suffix) {
|
|
suffixByEntry.set(entry.normalized, suffix);
|
|
}
|
|
});
|
|
}
|
|
|
|
const map = new Map();
|
|
for (const entry of entries) {
|
|
const suffix = suffixByEntry.get(entry.normalized);
|
|
const label = suffix ? `[${suffix}] ${entry.title}` : entry.title;
|
|
map.set(entry.normalized, label);
|
|
const slash = entry.normalized.lastIndexOf("/");
|
|
if (slash >= 0) {
|
|
map.set(entry.normalized.slice(slash + 1), label);
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
const TITLE_SUFFIX_MAX_LEN = 32;
|
|
const TITLE_SUFFIX_MIN_LEN = 2;
|
|
const TITLE_SUFFIX_TOKEN_SPLIT = /[\s_]+/;
|
|
const TITLE_SUFFIX_EDGE_TRIM = /^[\s_:,]+|[\s_:,]+$/g;
|
|
|
|
/**
|
|
* Group entries by their title, preserving the order they arrived in.
|
|
*/
|
|
function groupEntriesByTitle(entries) {
|
|
const groups = new Map();
|
|
for (const entry of entries) {
|
|
const group = groups.get(entry.title);
|
|
if (group) {
|
|
group.push(entry);
|
|
} else {
|
|
groups.set(entry.title, [entry]);
|
|
}
|
|
}
|
|
return groups.values();
|
|
}
|
|
|
|
/**
|
|
* Produce one distinguishing suffix per entry of a duplicate-title group.
|
|
*
|
|
* Tiers, each used only when it actually separates every member:
|
|
* 1. Civitai version names, trimmed of the text every member shares
|
|
* ("Example_v1" / "Example_v2 (variant)" -> "v1" / "v2 (variant)").
|
|
* 2. The library folder (e.g. "set" vs "archive/bulk").
|
|
* 3. The file name, which is always unique.
|
|
*/
|
|
function resolveTitleSuffixes(group) {
|
|
const versionNames = group.map((entry) => entry.versionName);
|
|
if (versionNames.every(Boolean)) {
|
|
const trimmed = trimSharedVersionTokens(versionNames);
|
|
if (trimmed) {
|
|
return trimmed;
|
|
}
|
|
}
|
|
|
|
const folders = group.map((entry) => entry.folder);
|
|
if (
|
|
folders.every(Boolean) &&
|
|
new Set(folders).size === folders.length
|
|
) {
|
|
return folders;
|
|
}
|
|
|
|
return group.map((entry) => {
|
|
const slash = entry.normalized.lastIndexOf("/");
|
|
return slash >= 0 ? entry.normalized.slice(slash + 1) : entry.normalized;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Strip the tokens every version name shares, keeping the most that can be
|
|
* removed without collapsing a member to something short or ambiguous.
|
|
*
|
|
* Removing the shared tail matters as much as the shared head: Civitai often
|
|
* repeats the release line on each version ("2.2 HIGH v0.08a (nightly)"), so
|
|
* trimming both leaves the word that actually differs ("HIGH"). Returns null
|
|
* when no trim leaves every result distinct and at least two characters long --
|
|
* identical version names cannot be separated this way.
|
|
*/
|
|
function trimSharedVersionTokens(names) {
|
|
const tokenLists = names.map((name) =>
|
|
String(name).split(TITLE_SUFFIX_TOKEN_SPLIT).filter(Boolean)
|
|
);
|
|
const prefixMax = commonPrefixLength(tokenLists);
|
|
const suffixMax = commonSuffixLength(tokenLists, prefixMax);
|
|
|
|
const candidates = [];
|
|
for (let prefix = 0; prefix <= prefixMax; prefix += 1) {
|
|
for (let suffix = 0; suffix <= suffixMax; suffix += 1) {
|
|
candidates.push([prefix, suffix]);
|
|
}
|
|
}
|
|
candidates.sort(
|
|
(a, b) => b[0] + b[1] - (a[0] + a[1]) || b[0] - a[0]
|
|
);
|
|
|
|
for (const [prefix, suffix] of candidates) {
|
|
const labels = tokenLists.map((tokens) =>
|
|
cleanSuffixLabel(tokens.slice(prefix, suffix ? tokens.length - suffix : tokens.length))
|
|
);
|
|
if (
|
|
labels.every((label) => label.length >= TITLE_SUFFIX_MIN_LEN) &&
|
|
new Set(labels).size === labels.length
|
|
) {
|
|
return labels;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Count the leading tokens shared by every list.
|
|
*/
|
|
function commonPrefixLength(tokenLists) {
|
|
if (tokenLists.length === 0) {
|
|
return 0;
|
|
}
|
|
let shared = 0;
|
|
const [first] = tokenLists;
|
|
for (let i = 0; i < first.length; i += 1) {
|
|
const token = first[i];
|
|
if (!tokenLists.every((tokens) => tokens[i] === token)) {
|
|
break;
|
|
}
|
|
shared += 1;
|
|
}
|
|
return shared;
|
|
}
|
|
|
|
/**
|
|
* Count the trailing tokens shared by every list, ignoring `drop` leading ones.
|
|
*/
|
|
function commonSuffixLength(tokenLists, drop) {
|
|
if (tokenLists.length === 0) {
|
|
return 0;
|
|
}
|
|
const tails = tokenLists.map((tokens) => tokens.slice(drop).reverse());
|
|
return commonPrefixLength(tails);
|
|
}
|
|
|
|
/**
|
|
* Join suffix tokens into a label, trimming separators and capping the length.
|
|
*/
|
|
function cleanSuffixLabel(tokens) {
|
|
const joined = tokens
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.replace(TITLE_SUFFIX_EDGE_TRIM, "")
|
|
.trim();
|
|
if (joined.length <= TITLE_SUFFIX_MAX_LEN) {
|
|
return joined;
|
|
}
|
|
return `${joined.slice(0, TITLE_SUFFIX_MAX_LEN - 1).trimEnd()}\u2026`;
|
|
}
|
|
|
|
/**
|
|
* Resolve a widget entry name against a normalized-name map built from the
|
|
* cycler list. Mirrors isLoraNameAvailable's matching: basename fallback for
|
|
* folder-qualified names, and null for absolute paths (outside the library).
|
|
*/
|
|
function lookupByLoraName(map, name) {
|
|
if (!map || map.size === 0) {
|
|
return null;
|
|
}
|
|
const normalized = String(name || "").replace(/\\/g, "/");
|
|
if (normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized)) {
|
|
return null;
|
|
}
|
|
const key = normalizeLoraNameKey(name);
|
|
if (map.has(key)) {
|
|
return map.get(key);
|
|
}
|
|
const slash = key.lastIndexOf("/");
|
|
if (slash >= 0) {
|
|
return map.get(key.slice(slash + 1)) || null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Refresh the availability cache from the cycler list: the set of known names,
|
|
* their recommended strength ranges, and their row labels.
|
|
*/
|
|
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 loras = data?.loras || [];
|
|
const paths = loras
|
|
.map((lora) => lora?.file_name)
|
|
.filter(Boolean);
|
|
const set = buildAvailableLoraSet(paths);
|
|
const ranges = buildStrengthRangeMap(loras);
|
|
const names = buildModelNameMap(loras);
|
|
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, ranges, names, 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;
|
|
}
|
|
|
|
/**
|
|
* Synchronous lookup of a LoRA's recommended strength range from the cached
|
|
* cycler-list data. Mirrors isLoraNameAvailable's matching: basename fallback
|
|
* for folder-qualified names, and null for absolute paths or while the cache
|
|
* is not loaded (no cue is shown in those cases).
|
|
*/
|
|
export function getLoraStrengthRange(name) {
|
|
if (
|
|
!availableLorasCache ||
|
|
Date.now() - availableLorasCache.at >= AVAILABLE_LORAS_TTL_MS
|
|
) {
|
|
return null;
|
|
}
|
|
return lookupByLoraName(availableLorasCache.ranges, name);
|
|
}
|
|
|
|
/**
|
|
* Synchronous label for a LoRA row on a node widget.
|
|
*
|
|
* Returns the library model name (Civitai / sidecar title) when `displayMode`
|
|
* asks for model names and the library knows this file, otherwise the stored
|
|
* entry name unchanged. The widget passes the mode it read from ComfyUI's
|
|
* settings at render time, so no cached preference is kept here. The stored name
|
|
* is what the widget value must keep — only the label changes — so callers must
|
|
* leave the LoRA name they persist untouched.
|
|
*
|
|
* The cached map is used even after its TTL expires: labels are display-only,
|
|
* and falling back to file names while a refresh is in flight would flash the
|
|
* old value under the user. A library change drops the cache entirely.
|
|
*/
|
|
export function getLoraDisplayName(name, displayMode = "file_name") {
|
|
if (displayMode !== "model_name" || !availableLorasCache) {
|
|
return name;
|
|
}
|
|
return lookupByLoraName(availableLorasCache.names, name) || name;
|
|
}
|
|
|
|
/**
|
|
* 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}${lmUrl("/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) {
|
|
if (!value) return [];
|
|
return Array.isArray(value) ? value : [];
|
|
}
|
|
|
|
// Format LoRA data
|
|
export function formatLoraValue(loras) {
|
|
return loras;
|
|
}
|
|
|
|
// Determine if clip entry should be shown - now based on expanded property or initial diff values
|
|
export function shouldShowClipEntry(loraData) {
|
|
// If expanded property exists, use that
|
|
if (loraData.hasOwnProperty('expanded')) {
|
|
return loraData.expanded;
|
|
}
|
|
// Otherwise use the legacy logic - if values differ, it should be expanded
|
|
return Number(loraData.strength) !== Number(loraData.clipStrength);
|
|
}
|
|
|
|
// Helper function to sync clipStrength with strength when collapsed
|
|
export function syncClipStrengthIfCollapsed(loraData) {
|
|
// If not expanded (collapsed), sync clipStrength with strength
|
|
if (loraData.hasOwnProperty('expanded') && !loraData.expanded) {
|
|
loraData.clipStrength = loraData.strength;
|
|
}
|
|
return loraData;
|
|
}
|
|
|
|
// Function to directly save the recipe without dialog
|
|
export async function saveRecipeDirectly({ embedWorkflow = false } = {}) {
|
|
try {
|
|
const prompt = await app.graphToPrompt();
|
|
console.log('Prompt:', prompt); // for debugging purposes
|
|
|
|
// Embedding the graph is opt-in: it is by far the largest metadata field
|
|
// and its widget values can contain sensitive data (paths, API keys). The
|
|
// UI-format graph is sent rather than the API prompt so node layout and
|
|
// groups survive — that is what "Send Workflow to ComfyUI" restores.
|
|
const requestBody = {};
|
|
if (embedWorkflow) {
|
|
if (prompt && prompt.workflow) {
|
|
requestBody.workflow = prompt.workflow;
|
|
} else {
|
|
showToast('No workflow available to embed; saving the recipe without it', 'warning');
|
|
}
|
|
}
|
|
|
|
// Show loading toast
|
|
if (app && app.extensionManager && app.extensionManager.toast) {
|
|
app.extensionManager.toast.add({
|
|
severity: 'info',
|
|
summary: embedWorkflow ? 'Saving Recipe with Workflow' : 'Saving Recipe',
|
|
detail: 'Please wait...',
|
|
life: 2000
|
|
});
|
|
}
|
|
|
|
// Send the request to the backend API
|
|
const response = await fetch(lmUrl('/api/lm/recipes/save-from-widget'), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(requestBody)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
// Show result toast
|
|
if (app && app.extensionManager && app.extensionManager.toast) {
|
|
if (result.success) {
|
|
let severity = 'success';
|
|
let summary = embedWorkflow ? 'Recipe Saved with Workflow' : 'Recipe Saved';
|
|
let detail = embedWorkflow
|
|
? 'Recipe and the current workflow have been saved'
|
|
: 'Recipe has been saved successfully';
|
|
|
|
if (embedWorkflow && result.workflow_skipped === 'too_large') {
|
|
severity = 'warn';
|
|
summary = 'Recipe Saved without Workflow';
|
|
detail = 'The workflow is too large to embed; the recipe was saved without it';
|
|
} else if (embedWorkflow && result.has_workflow !== true) {
|
|
severity = 'warn';
|
|
summary = 'Recipe Saved without Workflow';
|
|
detail = 'The workflow could not be embedded in the recipe image';
|
|
}
|
|
|
|
app.extensionManager.toast.add({ severity, summary, detail, life: 5000 });
|
|
} else {
|
|
app.extensionManager.toast.add({
|
|
severity: 'error',
|
|
summary: 'Error',
|
|
detail: result.error || 'Failed to save recipe',
|
|
life: 5000
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving recipe:', error);
|
|
|
|
// Show error toast
|
|
if (app && app.extensionManager && app.extensionManager.toast) {
|
|
app.extensionManager.toast.add({
|
|
severity: 'error',
|
|
summary: 'Error',
|
|
detail: 'Failed to save recipe: ' + (error.message || 'Unknown error'),
|
|
life: 5000
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Utility function to copy text to clipboard with fallback for older browsers
|
|
* @param {string} text - The text to copy to clipboard
|
|
* @param {string} successMessage - Optional success message to show in toast
|
|
* @returns {Promise<boolean>} - Promise that resolves to true if copy was successful
|
|
*/
|
|
export async function copyToClipboard(text, successMessage = 'Copied to clipboard') {
|
|
try {
|
|
// Modern clipboard API
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
await navigator.clipboard.writeText(text);
|
|
} else {
|
|
// Fallback for older browsers
|
|
const textarea = document.createElement('textarea');
|
|
textarea.value = text;
|
|
textarea.style.position = 'absolute';
|
|
textarea.style.left = '-99999px';
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(textarea);
|
|
}
|
|
|
|
if (successMessage) {
|
|
showToast(successMessage, 'success');
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
console.error('Copy failed:', err);
|
|
showToast('Copy failed', 'error');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show a toast notification
|
|
* @param {string} message - The message to display
|
|
* @param {string} type - The type of toast (success, error, info, warning)
|
|
*/
|
|
export function showToast(message, type = 'info') {
|
|
if (app && app.extensionManager && app.extensionManager.toast) {
|
|
app.extensionManager.toast.add({
|
|
severity: type,
|
|
summary: type.charAt(0).toUpperCase() + type.slice(1),
|
|
detail: message,
|
|
life: 3000
|
|
});
|
|
} else {
|
|
console.log(`${type.toUpperCase()}: ${message}`);
|
|
// Fallback alert for critical errors only
|
|
if (type === 'error') {
|
|
alert(message);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Move a LoRA to a new position in the array
|
|
* @param {Array} loras - Array of LoRA objects
|
|
* @param {number} fromIndex - Current index of the LoRA
|
|
* @param {number} toIndex - Target index for the LoRA
|
|
* @returns {Array} - New array with LoRA moved
|
|
*/
|
|
export function moveLoraInArray(loras, fromIndex, toIndex) {
|
|
const newLoras = [...loras];
|
|
const [removed] = newLoras.splice(fromIndex, 1);
|
|
newLoras.splice(toIndex, 0, removed);
|
|
return newLoras;
|
|
}
|
|
|
|
/**
|
|
* Move a LoRA by name to a specific position
|
|
* @param {Array} loras - Array of LoRA objects
|
|
* @param {string} loraName - Name of the LoRA to move
|
|
* @param {string} direction - 'up', 'down', 'top', 'bottom'
|
|
* @returns {Array} - New array with LoRA moved
|
|
*/
|
|
export function moveLoraByDirection(loras, loraName, direction) {
|
|
const currentIndex = loras.findIndex(l => l.name === loraName);
|
|
if (currentIndex === -1) return loras;
|
|
|
|
let newIndex;
|
|
switch (direction) {
|
|
case 'up':
|
|
newIndex = Math.max(0, currentIndex - 1);
|
|
break;
|
|
case 'down':
|
|
newIndex = Math.min(loras.length - 1, currentIndex + 1);
|
|
break;
|
|
case 'top':
|
|
newIndex = 0;
|
|
break;
|
|
case 'bottom':
|
|
newIndex = loras.length - 1;
|
|
break;
|
|
default:
|
|
return loras;
|
|
}
|
|
|
|
if (newIndex === currentIndex) return loras;
|
|
return moveLoraInArray(loras, currentIndex, newIndex);
|
|
}
|
|
|
|
/**
|
|
* Get the drop target index based on mouse position
|
|
* @param {HTMLElement} container - The container element
|
|
* @param {number} clientY - Mouse Y position
|
|
* @returns {number} - Target index for dropping
|
|
*/
|
|
export function getDropTargetIndex(container, clientY) {
|
|
const entries = container.querySelectorAll('.lm-lora-entry');
|
|
let targetIndex = entries.length;
|
|
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const rect = entries[i].getBoundingClientRect();
|
|
if (clientY < rect.top + rect.height / 2) {
|
|
targetIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return targetIndex;
|
|
}
|