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:
Will Miao
2026-08-29 16:41:48 +08:00
parent ebe3df7d22
commit c972c755fc
19 changed files with 258 additions and 40 deletions
+53 -12
View File
@@ -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`
);
}