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
+41 -11
View File
@@ -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`;
}
/**