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:
Will Miao
2026-09-19 05:49:01 +08:00
parent 3b9e8efb3d
commit c8c84bfc54
7 changed files with 584 additions and 8 deletions
+12
View File
@@ -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;
+4
View File
@@ -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" };
});
+6 -1
View File
@@ -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));
}
});
});
+188 -2
View File
@@ -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