mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(loras): warn when widget strength leaves the usage-tips range
The cycler-list payload now carries usage_tips, and the LORAS widget parses strength_min/strength_max/strength_range into a cached lookup. Strength inputs (model and clip) turn amber with an explanatory tooltip when dragged, typed, or stepped outside the recommended range. Related: https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1090
This commit is contained in:
@@ -309,6 +309,18 @@
|
||||
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.3);
|
||||
}
|
||||
|
||||
/* Advisory cue: strength is outside the usage-tips recommended range.
|
||||
Amber to distinguish it from the red "missing LoRA" error state. */
|
||||
.lm-lora-strength-input.lm-strength-out-of-range {
|
||||
border-color: rgba(245, 158, 11, 0.75);
|
||||
color: rgba(252, 211, 77, 0.95);
|
||||
}
|
||||
|
||||
.lm-lora-strength-input.lm-strength-out-of-range:focus {
|
||||
border-color: rgba(245, 158, 11, 0.9);
|
||||
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.lm-lora-strength-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
syncClipStrengthIfCollapsed,
|
||||
getAvailableLoras,
|
||||
getAvailableLorasSync,
|
||||
getLoraStrengthRange,
|
||||
applyStrengthRangeCue,
|
||||
isLoraNameAvailable,
|
||||
onLibraryChanged
|
||||
} from "./loras_widget_utils.js";
|
||||
@@ -521,6 +523,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
strengthEl.classList.add("lm-lora-strength-input");
|
||||
strengthEl.type = "text";
|
||||
strengthEl.value = typeof strength === 'number' ? strength.toFixed(2) : Number(strength).toFixed(2);
|
||||
applyStrengthRangeCue(strengthEl, strength, getLoraStrengthRange(name));
|
||||
strengthEl.addEventListener('pointerdown', () => {
|
||||
pendingFocusTarget = { name, type: "strength" };
|
||||
});
|
||||
@@ -650,6 +653,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
clipStrengthEl.classList.add("lm-lora-strength-input", "lm-lora-clip-strength-input");
|
||||
clipStrengthEl.type = "text";
|
||||
clipStrengthEl.value = typeof clipStrength === 'number' ? clipStrength.toFixed(2) : Number(clipStrength).toFixed(2);
|
||||
applyStrengthRangeCue(clipStrengthEl, clipStrength, getLoraStrengthRange(name));
|
||||
clipStrengthEl.addEventListener('pointerdown', () => {
|
||||
pendingFocusTarget = { name, type: "clip" };
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { createMenuItem, createDropIndicator } from "./loras_widget_components.js";
|
||||
import { parseLoraValue, formatLoraValue, syncClipStrengthIfCollapsed, saveRecipeDirectly, copyToClipboard, showToast, moveLoraByDirection, getDropTargetIndex } from "./loras_widget_utils.js";
|
||||
import { parseLoraValue, formatLoraValue, syncClipStrengthIfCollapsed, saveRecipeDirectly, copyToClipboard, showToast, moveLoraByDirection, getDropTargetIndex, getLoraStrengthRange, applyStrengthRangeCue } from "./loras_widget_utils.js";
|
||||
|
||||
// Function to handle strength adjustment via dragging
|
||||
export function handleStrengthDrag(name, initialStrength, initialX, event, widget, isClipStrength = false, updateWidget = true) {
|
||||
@@ -194,6 +194,7 @@ export function initDrag(
|
||||
const strengthInput = currentDragElement.querySelector('.lm-lora-strength-input');
|
||||
if (strengthInput && typeof newStrength === 'number') {
|
||||
strengthInput.value = newStrength.toFixed(2);
|
||||
applyStrengthRangeCue(strengthInput, newStrength, getLoraStrengthRange(name));
|
||||
}
|
||||
|
||||
// Prevent showing the preview tooltip during drag
|
||||
@@ -335,6 +336,10 @@ export function initHeaderDrag(headerEl, widget, renderFunction) {
|
||||
if (lorasData[index]) {
|
||||
input.value = lorasData[index].strength.toFixed(2);
|
||||
}
|
||||
const entryEl = input.closest('[data-lora-name]');
|
||||
if (entryEl) {
|
||||
applyStrengthRangeCue(input, input.value, getLoraStrengthRange(entryEl.dataset.loraName));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -88,6 +88,158 @@ 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;
|
||||
}
|
||||
|
||||
async function refreshAvailableLoras() {
|
||||
const generation = availabilityGeneration;
|
||||
try {
|
||||
@@ -100,16 +252,18 @@ async function refreshAvailableLoras() {
|
||||
return null;
|
||||
}
|
||||
const data = await response.json();
|
||||
const paths = (data?.loras || [])
|
||||
const loras = data?.loras || [];
|
||||
const paths = loras
|
||||
.map((lora) => lora?.file_name)
|
||||
.filter(Boolean);
|
||||
const set = buildAvailableLoraSet(paths);
|
||||
const ranges = buildStrengthRangeMap(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, at: Date.now() };
|
||||
availableLorasCache = { set, ranges, at: Date.now() };
|
||||
return set;
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch available LoRAs:", error);
|
||||
@@ -151,6 +305,38 @@ export function getAvailableLorasSync() {
|
||||
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;
|
||||
}
|
||||
const ranges = availableLorasCache.ranges;
|
||||
if (!ranges || ranges.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 (ranges.has(key)) {
|
||||
return ranges.get(key);
|
||||
}
|
||||
const slash = key.lastIndexOf("/");
|
||||
if (slash >= 0) {
|
||||
return ranges.get(key.slice(slash + 1)) || null;
|
||||
}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user