mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-29 08:51:27 -03:00
fix(recipes): distinguish unobtainable LoRAs in recipe status and skip them in syntax
The recipe card pill counted LoRAs deleted from the source (isDeleted) as available, showing a green 'ready 2/2' for recipes that cannot be fully reproduced. LoRAs with an unresolvable hash (hashInvalid) were counted as missing/downloadable even though downloads always fail, and recipe syntax generation emitted broken tokens for them. - Four-state status on RecipeCard pill and RecipeTab badge: ready (all in library), missing (downloadable, red, keeps the action cue), partial (unobtainable entries skipped when used, amber, fa-circle-minus), unavailable (nothing usable, gray, fa-ban) - Pill numerator is now the real in-library count; tooltips spell out missing vs unavailable (deleted from source or unresolvable hash) - get_recipe_syntax_tokens skips hashInvalid entries like deleted ones instead of emitting tokens pointing at nonexistent files - Bulk missing-download manager and recipe context menu exclude hashInvalid LoRAs, matching the modal's per-item download block - New locale keys loraStatus.missingAndUnavailable/partial/noneUsable, translated for all 9 non-en locales
This commit is contained in:
@@ -699,6 +699,20 @@ body.hide-card-version .hl-badge {
|
||||
border-color: rgba(231, 76, 60, 0.65);
|
||||
}
|
||||
|
||||
/* Partial: usable but degraded — some LoRAs are unobtainable (deleted from
|
||||
the source or unresolvable hash) and are skipped when the recipe is used.
|
||||
Amber sits between ready green and missing red. */
|
||||
.lora-count.partial {
|
||||
background: rgba(243, 156, 18, 0.35);
|
||||
border-color: rgba(243, 156, 18, 0.65);
|
||||
}
|
||||
|
||||
/* Unavailable: no usable LoRA at all — gray marks the recipe as dead. */
|
||||
.lora-count.unavailable {
|
||||
background: rgba(149, 165, 166, 0.35);
|
||||
border-color: rgba(149, 165, 166, 0.65);
|
||||
}
|
||||
|
||||
.placeholder-message {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
|
||||
@@ -1078,6 +1078,19 @@
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
/* Partial: usable but degraded — some LoRAs are unobtainable and skipped.
|
||||
Orange sits between ready green and missing amber. */
|
||||
.recipe-card__badge--partial {
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
/* Unavailable: no usable LoRA at all — red marks the recipe as dead. */
|
||||
.recipe-card__badge--unavailable {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.recipe-card__badge--empty {
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
color: #e2e8f0;
|
||||
@@ -1093,6 +1106,16 @@
|
||||
background: rgba(245, 199, 43, 0.22);
|
||||
}
|
||||
|
||||
[data-theme="light"] .recipe-card__badge--partial {
|
||||
color: #c2410c;
|
||||
background: rgba(249, 115, 22, 0.18);
|
||||
}
|
||||
|
||||
[data-theme="light"] .recipe-card__badge--unavailable {
|
||||
color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.16);
|
||||
}
|
||||
|
||||
[data-theme="light"] .recipe-card__badge--empty {
|
||||
color: rgba(71, 85, 105, 0.9);
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
|
||||
@@ -204,8 +204,9 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
const response = await fetch(`/api/lm/recipe/${recipeId}`);
|
||||
const recipe = await response.json();
|
||||
|
||||
// Get missing LoRAs
|
||||
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted);
|
||||
// Get missing LoRAs (still downloadable: not deleted from the
|
||||
// source and hash still resolvable)
|
||||
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted && !lora.hashInvalid);
|
||||
|
||||
if (missingLoras.length === 0) {
|
||||
showToast('recipes.contextMenu.downloadMissing.noMissingLoras', {}, 'info');
|
||||
|
||||
@@ -41,15 +41,35 @@ class RecipeCard {
|
||||
const loras = this.recipe.loras || [];
|
||||
const lorasCount = loras.length;
|
||||
|
||||
// Check if all LoRAs are available in the library
|
||||
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
|
||||
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
|
||||
// Count LoRAs by availability: in library, missing (still downloadable
|
||||
// from the source), or unobtainable (deleted from the source, or an
|
||||
// unresolvable hash) which is silently skipped when the recipe is used.
|
||||
const availableLorasCount = loras.filter(lora => lora.inLibrary).length;
|
||||
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted && !lora.hashInvalid).length;
|
||||
const unavailableLorasCount = lorasCount - availableLorasCount - missingLorasCount;
|
||||
|
||||
// Compact status pill: state icon + available/total fraction.
|
||||
// Icon switches by state so status never relies on color alone.
|
||||
const availableLorasCount = lorasCount - missingLorasCount;
|
||||
const loraCountStateClass = missingLorasCount > 0 ? 'missing' : (allLorasAvailable ? 'ready' : '');
|
||||
const loraCountIcon = missingLorasCount > 0 ? 'fa-exclamation-triangle' : (allLorasAvailable ? 'fa-check' : 'fa-layer-group');
|
||||
// - missing (red): something can still be downloaded, most actionable
|
||||
// - partial (amber): usable but degraded, unobtainable LoRAs are skipped
|
||||
// - unavailable (gray, ban): no usable LoRA at all
|
||||
let loraCountStateClass = '';
|
||||
let loraCountIcon = 'fa-layer-group';
|
||||
if (lorasCount > 0) {
|
||||
if (availableLorasCount === lorasCount) {
|
||||
loraCountStateClass = 'ready';
|
||||
loraCountIcon = 'fa-check';
|
||||
} else if (missingLorasCount > 0) {
|
||||
loraCountStateClass = 'missing';
|
||||
loraCountIcon = 'fa-exclamation-triangle';
|
||||
} else if (availableLorasCount > 0) {
|
||||
loraCountStateClass = 'partial';
|
||||
loraCountIcon = 'fa-circle-minus';
|
||||
} else {
|
||||
loraCountStateClass = 'unavailable';
|
||||
loraCountIcon = 'fa-ban';
|
||||
}
|
||||
}
|
||||
const loraCountLabel = lorasCount > 0 ? `${availableLorasCount}/${lorasCount}` : `${lorasCount}`;
|
||||
|
||||
// Ensure file_url exists, fallback to API URL if needed
|
||||
@@ -135,7 +155,7 @@ class RecipeCard {
|
||||
<span class="model-name">${this.recipe.title}</span>
|
||||
</div>
|
||||
${!isDuplicatesMode ? `
|
||||
<div class="lora-count ${loraCountStateClass}" title="${this.getLoraStatusTitle(lorasCount, missingLorasCount)}">
|
||||
<div class="lora-count ${loraCountStateClass}" title="${this.getLoraStatusTitle(lorasCount, availableLorasCount, missingLorasCount, unavailableLorasCount)}">
|
||||
<i class="fas ${loraCountIcon}" aria-hidden="true"></i> ${loraCountLabel}
|
||||
</div>
|
||||
` : ''}
|
||||
@@ -154,17 +174,38 @@ class RecipeCard {
|
||||
return card;
|
||||
}
|
||||
|
||||
getLoraStatusTitle(totalCount, missingCount) {
|
||||
getLoraStatusTitle(totalCount, availableCount, missingCount, unavailableCount) {
|
||||
if (totalCount === 0) {
|
||||
return translate('recipes.loraStatus.none', {}, 'No LoRAs in this recipe');
|
||||
}
|
||||
if (missingCount === 0) {
|
||||
if (availableCount === totalCount) {
|
||||
return translate('recipes.loraStatus.allAvailable', {}, 'All LoRAs available - Ready to use');
|
||||
}
|
||||
if (missingCount > 0 && unavailableCount > 0) {
|
||||
return translate(
|
||||
'recipes.loraStatus.missingAndUnavailable',
|
||||
{ missing: missingCount, unavailable: unavailableCount, total: totalCount },
|
||||
`${missingCount} of ${totalCount} LoRAs missing, ${unavailableCount} unavailable (deleted from source or unresolvable hash)`
|
||||
);
|
||||
}
|
||||
if (missingCount > 0) {
|
||||
return translate(
|
||||
'recipes.loraStatus.missing',
|
||||
{ missing: missingCount, total: totalCount },
|
||||
`${missingCount} of ${totalCount} LoRAs missing`
|
||||
);
|
||||
}
|
||||
if (availableCount > 0) {
|
||||
return translate(
|
||||
'recipes.loraStatus.partial',
|
||||
{ unavailable: unavailableCount, total: totalCount },
|
||||
`${unavailableCount} of ${totalCount} LoRAs unavailable (deleted from source or unresolvable hash) - skipped when recipe is used`
|
||||
);
|
||||
}
|
||||
return translate(
|
||||
'recipes.loraStatus.missing',
|
||||
{ missing: missingCount, total: totalCount },
|
||||
`${missingCount} of ${totalCount} LoRAs missing`
|
||||
'recipes.loraStatus.noneUsable',
|
||||
{ unavailable: unavailableCount, total: totalCount },
|
||||
`No usable LoRAs - ${unavailableCount} of ${totalCount} deleted from source or unresolvable hash`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -130,17 +130,29 @@ function renderRecipes(tabElement, recipes, options) {
|
||||
const baseModel = recipe.base_model || '';
|
||||
const loras = recipe.loras || [];
|
||||
const lorasCount = loras.length;
|
||||
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
|
||||
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
|
||||
const statusClass = lorasCount === 0 ? 'empty' : (allLorasAvailable ? 'ready' : 'missing');
|
||||
// Missing = still downloadable; unavailable = deleted from the source
|
||||
// or unresolvable hash, silently skipped when the recipe is used.
|
||||
const availableLorasCount = loras.filter(lora => lora.inLibrary).length;
|
||||
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted && !lora.hashInvalid).length;
|
||||
const unavailableLorasCount = lorasCount - availableLorasCount - missingLorasCount;
|
||||
const statusClass = lorasCount === 0 ? 'empty'
|
||||
: (availableLorasCount === lorasCount ? 'ready'
|
||||
: (missingLorasCount > 0 ? 'missing'
|
||||
: (availableLorasCount > 0 ? 'partial' : 'unavailable')));
|
||||
let statusLabel;
|
||||
|
||||
if (lorasCount === 0) {
|
||||
statusLabel = 'No linked LoRAs';
|
||||
} else if (allLorasAvailable) {
|
||||
} else if (statusClass === 'ready') {
|
||||
statusLabel = `${lorasCount} LoRA${lorasCount > 1 ? 's' : ''} ready`;
|
||||
} else if (statusClass === 'missing') {
|
||||
statusLabel = unavailableLorasCount > 0
|
||||
? `Missing ${missingLorasCount}, ${unavailableLorasCount} of ${lorasCount} unavailable`
|
||||
: `Missing ${missingLorasCount} of ${lorasCount}`;
|
||||
} else if (statusClass === 'partial') {
|
||||
statusLabel = `${unavailableLorasCount} of ${lorasCount} unavailable - skipped when used`;
|
||||
} else {
|
||||
statusLabel = `Missing ${missingLorasCount} of ${lorasCount}`;
|
||||
statusLabel = 'No usable LoRAs';
|
||||
}
|
||||
|
||||
const imageUrl = recipe.file_url ||
|
||||
@@ -207,8 +219,16 @@ function renderRecipes(tabElement, recipes, options) {
|
||||
const statusBadge = document.createElement('span');
|
||||
statusBadge.className = `recipe-card__badge recipe-card__badge--${statusClass}`;
|
||||
|
||||
// Icon switches by state so status never relies on color alone.
|
||||
const statusIcons = {
|
||||
ready: 'fa-check',
|
||||
missing: 'fa-exclamation-triangle',
|
||||
partial: 'fa-circle-minus',
|
||||
unavailable: 'fa-ban',
|
||||
empty: 'fa-layer-group',
|
||||
};
|
||||
const statusIcon = document.createElement('i');
|
||||
statusIcon.className = 'fas fa-layer-group';
|
||||
statusIcon.className = `fas ${statusIcons[statusClass] || 'fa-layer-group'}`;
|
||||
statusIcon.setAttribute('aria-hidden', 'true');
|
||||
statusBadge.appendChild(statusIcon);
|
||||
|
||||
@@ -216,7 +236,7 @@ function renderRecipes(tabElement, recipes, options) {
|
||||
statusText.textContent = statusLabel;
|
||||
statusBadge.appendChild(statusText);
|
||||
|
||||
statusBadge.title = getLoraStatusTitle(lorasCount, missingLorasCount);
|
||||
statusBadge.title = getLoraStatusTitle(lorasCount, availableLorasCount, missingLorasCount, unavailableLorasCount);
|
||||
meta.appendChild(statusBadge);
|
||||
|
||||
body.appendChild(meta);
|
||||
@@ -264,13 +284,23 @@ function renderRecipes(tabElement, recipes, options) {
|
||||
/**
|
||||
* Returns a descriptive title for the LoRA status indicator
|
||||
* @param {number} totalCount - Total number of LoRAs in recipe
|
||||
* @param {number} missingCount - Number of missing LoRAs
|
||||
* @param {number} availableCount - Number of LoRAs present in the library
|
||||
* @param {number} missingCount - Number of missing LoRAs (still downloadable)
|
||||
* @param {number} unavailableCount - Number of unobtainable LoRAs (deleted
|
||||
* from the source or unresolvable hash)
|
||||
* @returns {string} Status title text
|
||||
*/
|
||||
function getLoraStatusTitle(totalCount, missingCount) {
|
||||
function getLoraStatusTitle(totalCount, availableCount, missingCount, unavailableCount) {
|
||||
if (totalCount === 0) return "No LoRAs in this recipe";
|
||||
if (missingCount === 0) return "All LoRAs available - Ready to use";
|
||||
return `${missingCount} of ${totalCount} LoRAs missing`;
|
||||
if (availableCount === totalCount) return "All LoRAs available - Ready to use";
|
||||
if (missingCount > 0 && unavailableCount > 0) {
|
||||
return `${missingCount} of ${totalCount} LoRAs missing, ${unavailableCount} unavailable (deleted from source or unresolvable hash)`;
|
||||
}
|
||||
if (missingCount > 0) return `${missingCount} of ${totalCount} LoRAs missing`;
|
||||
if (availableCount > 0) {
|
||||
return `${unavailableCount} of ${totalCount} LoRAs unavailable (deleted from source or unresolvable hash) - skipped when recipe is used`;
|
||||
}
|
||||
return `No usable LoRAs - ${unavailableCount} of ${totalCount} deleted from source or unresolvable hash`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,8 +30,9 @@ export class BulkMissingLoraDownloadManager {
|
||||
|
||||
if (recipe.loras && Array.isArray(recipe.loras)) {
|
||||
recipe.loras.forEach(lora => {
|
||||
// Only include LoRAs not in library and not deleted
|
||||
if (!lora.inLibrary && !lora.isDeleted) {
|
||||
// Only include LoRAs not in library and still downloadable
|
||||
// (not deleted from the source, hash still resolvable)
|
||||
if (!lora.inLibrary && !lora.isDeleted && !lora.hashInvalid) {
|
||||
const uniqueKey = lora.hash || lora.id || lora.modelVersionId;
|
||||
|
||||
if (uniqueKey && !uniqueLoras.has(uniqueKey)) {
|
||||
|
||||
Reference in New Issue
Block a user