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:
+4
-1
@@ -883,7 +883,10 @@
|
|||||||
"loraStatus": {
|
"loraStatus": {
|
||||||
"none": "Keine LoRAs in diesem Rezept",
|
"none": "Keine LoRAs in diesem Rezept",
|
||||||
"allAvailable": "Alle LoRAs verfügbar - Einsatzbereit",
|
"allAvailable": "Alle LoRAs verfügbar - Einsatzbereit",
|
||||||
"missing": "{missing} von {total} LoRAs fehlen"
|
"missing": "{missing} von {total} LoRAs fehlen",
|
||||||
|
"missingAndUnavailable": "{missing} von {total} LoRAs fehlen, {unavailable} nicht verfügbar (von der Quelle gelöscht oder Hash nicht auflösbar)",
|
||||||
|
"partial": "{unavailable} von {total} LoRAs nicht verfügbar (von der Quelle gelöscht oder Hash nicht auflösbar) - werden bei Verwendung des Rezepts übersprungen",
|
||||||
|
"noneUsable": "Keine verwendbaren LoRAs - {unavailable} von {total} von der Quelle gelöscht oder Hash nicht auflösbar"
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"inLibrary": "In Bibliothek",
|
"inLibrary": "In Bibliothek",
|
||||||
|
|||||||
+4
-1
@@ -883,7 +883,10 @@
|
|||||||
"loraStatus": {
|
"loraStatus": {
|
||||||
"none": "No LoRAs in this recipe",
|
"none": "No LoRAs in this recipe",
|
||||||
"allAvailable": "All LoRAs available - Ready to use",
|
"allAvailable": "All LoRAs available - Ready to use",
|
||||||
"missing": "{missing} of {total} LoRAs missing"
|
"missing": "{missing} of {total} LoRAs missing",
|
||||||
|
"missingAndUnavailable": "{missing} of {total} LoRAs missing, {unavailable} unavailable (deleted from source or unresolvable hash)",
|
||||||
|
"partial": "{unavailable} of {total} LoRAs unavailable (deleted from source or unresolvable hash) - skipped when recipe is used",
|
||||||
|
"noneUsable": "No usable LoRAs - {unavailable} of {total} deleted from source or unresolvable hash"
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"inLibrary": "In Library",
|
"inLibrary": "In Library",
|
||||||
|
|||||||
+4
-1
@@ -883,7 +883,10 @@
|
|||||||
"loraStatus": {
|
"loraStatus": {
|
||||||
"none": "No hay LoRAs en esta receta",
|
"none": "No hay LoRAs en esta receta",
|
||||||
"allAvailable": "Todos los LoRAs disponibles - Listo para usar",
|
"allAvailable": "Todos los LoRAs disponibles - Listo para usar",
|
||||||
"missing": "Faltan {missing} de {total} LoRAs"
|
"missing": "Faltan {missing} de {total} LoRAs",
|
||||||
|
"missingAndUnavailable": "Faltan {missing} de {total} LoRAs, {unavailable} no disponibles (eliminados de la fuente o hash no resoluble)",
|
||||||
|
"partial": "{unavailable} de {total} LoRAs no disponibles (eliminados de la fuente o hash no resoluble) - se omiten al usar la receta",
|
||||||
|
"noneUsable": "No hay LoRAs utilizables - {unavailable} de {total} eliminados de la fuente o hash no resoluble"
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"inLibrary": "En la biblioteca",
|
"inLibrary": "En la biblioteca",
|
||||||
|
|||||||
+4
-1
@@ -883,7 +883,10 @@
|
|||||||
"loraStatus": {
|
"loraStatus": {
|
||||||
"none": "Aucun LoRA dans cette Recipe",
|
"none": "Aucun LoRA dans cette Recipe",
|
||||||
"allAvailable": "Tous les LoRAs sont disponibles - Prêt à l'emploi",
|
"allAvailable": "Tous les LoRAs sont disponibles - Prêt à l'emploi",
|
||||||
"missing": "{missing} LoRA(s) manquant(s) sur {total}"
|
"missing": "{missing} LoRA(s) manquant(s) sur {total}",
|
||||||
|
"missingAndUnavailable": "{missing} LoRA(s) manquant(s) sur {total}, {unavailable} indisponible(s) (supprimé(s) de la source ou hash irrésolvable)",
|
||||||
|
"partial": "{unavailable} LoRA(s) indisponible(s) sur {total} (supprimé(s) de la source ou hash irrésolvable) - ignoré(s) lors de l'utilisation de la Recipe",
|
||||||
|
"noneUsable": "Aucun LoRA utilisable - {unavailable} sur {total} supprimé(s) de la source ou hash irrésolvable"
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"inLibrary": "Dans la bibliothèque",
|
"inLibrary": "Dans la bibliothèque",
|
||||||
|
|||||||
+4
-1
@@ -883,7 +883,10 @@
|
|||||||
"loraStatus": {
|
"loraStatus": {
|
||||||
"none": "אין LoRAs במתכון הזה",
|
"none": "אין LoRAs במתכון הזה",
|
||||||
"allAvailable": "כל ה-LoRAs זמינים - מוכן לשימוש",
|
"allAvailable": "כל ה-LoRAs זמינים - מוכן לשימוש",
|
||||||
"missing": "{missing} מתוך {total} LoRAs חסרים"
|
"missing": "{missing} מתוך {total} LoRAs חסרים",
|
||||||
|
"missingAndUnavailable": "{missing} מתוך {total} LoRAs חסרים, {unavailable} לא זמינים (נמחקו מהמקור או hash שלא ניתן לפתור)",
|
||||||
|
"partial": "{unavailable} מתוך {total} LoRAs לא זמינים (נמחקו מהמקור או hash שלא ניתן לפתור) - ידולגו בעת שימוש במתכון",
|
||||||
|
"noneUsable": "אין LoRAs שמישים - {unavailable} מתוך {total} נמחקו מהמקור או hash שלא ניתן לפתור"
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"inLibrary": "בספרייה",
|
"inLibrary": "בספרייה",
|
||||||
|
|||||||
+4
-1
@@ -883,7 +883,10 @@
|
|||||||
"loraStatus": {
|
"loraStatus": {
|
||||||
"none": "このレシピにはLoRAがありません",
|
"none": "このレシピにはLoRAがありません",
|
||||||
"allAvailable": "すべてのLoRAが利用可能 - 使用可能",
|
"allAvailable": "すべてのLoRAが利用可能 - 使用可能",
|
||||||
"missing": "{total} 件中 {missing} 件のLoRAが不足"
|
"missing": "{total} 件中 {missing} 件のLoRAが不足",
|
||||||
|
"missingAndUnavailable": "{total} 件中 {missing} 件のLoRAが不足、{unavailable} 件は利用不可(ソースから削除済みかハッシュを解決できません)",
|
||||||
|
"partial": "{total} 件中 {unavailable} 件のLoRAが利用不可(ソースから削除済みかハッシュを解決できません)- レシピ使用時はスキップされます",
|
||||||
|
"noneUsable": "使用可能なLoRAがありません - {total} 件中 {unavailable} 件がソースから削除済みかハッシュを解決できません"
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"inLibrary": "ライブラリ内",
|
"inLibrary": "ライブラリ内",
|
||||||
|
|||||||
+4
-1
@@ -883,7 +883,10 @@
|
|||||||
"loraStatus": {
|
"loraStatus": {
|
||||||
"none": "이 레시피에는 LoRA가 없습니다",
|
"none": "이 레시피에는 LoRA가 없습니다",
|
||||||
"allAvailable": "모든 LoRA 사용 가능 - 바로 사용 가능",
|
"allAvailable": "모든 LoRA 사용 가능 - 바로 사용 가능",
|
||||||
"missing": "총 {total}개 중 {missing}개 LoRA 누락"
|
"missing": "총 {total}개 중 {missing}개 LoRA 누락",
|
||||||
|
"missingAndUnavailable": "총 {total}개 중 {missing}개 LoRA 누락, {unavailable}개 사용 불가(소스에서 삭제되었거나 해시를 확인할 수 없음)",
|
||||||
|
"partial": "총 {total}개 중 {unavailable}개 LoRA 사용 불가(소스에서 삭제되었거나 해시를 확인할 수 없음) - 레시피 사용 시 건너뜁니다",
|
||||||
|
"noneUsable": "사용 가능한 LoRA가 없습니다 - 총 {total}개 중 {unavailable}개가 소스에서 삭제되었거나 해시를 확인할 수 없습니다"
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"inLibrary": "라이브러리에 있음",
|
"inLibrary": "라이브러리에 있음",
|
||||||
|
|||||||
+4
-1
@@ -883,7 +883,10 @@
|
|||||||
"loraStatus": {
|
"loraStatus": {
|
||||||
"none": "В этом рецепте нет LoRAs",
|
"none": "В этом рецепте нет LoRAs",
|
||||||
"allAvailable": "Все LoRAs доступны - Готово к использованию",
|
"allAvailable": "Все LoRAs доступны - Готово к использованию",
|
||||||
"missing": "Отсутствует {missing} из {total} LoRAs"
|
"missing": "Отсутствует {missing} из {total} LoRAs",
|
||||||
|
"missingAndUnavailable": "Отсутствует {missing} из {total} LoRAs, {unavailable} недоступны (удалены из источника или хеш не разрешается)",
|
||||||
|
"partial": "{unavailable} из {total} LoRAs недоступны (удалены из источника или хеш не разрешается) - будут пропущены при использовании рецепта",
|
||||||
|
"noneUsable": "Нет доступных LoRAs - {unavailable} из {total} удалены из источника или хеш не разрешается"
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"inLibrary": "В библиотеке",
|
"inLibrary": "В библиотеке",
|
||||||
|
|||||||
+4
-1
@@ -883,7 +883,10 @@
|
|||||||
"loraStatus": {
|
"loraStatus": {
|
||||||
"none": "此配方不包含 LoRA",
|
"none": "此配方不包含 LoRA",
|
||||||
"allAvailable": "所有 LoRA 均已就绪 - 可直接使用",
|
"allAvailable": "所有 LoRA 均已就绪 - 可直接使用",
|
||||||
"missing": "{total} 个 LoRA 中缺失 {missing} 个"
|
"missing": "{total} 个 LoRA 中缺失 {missing} 个",
|
||||||
|
"missingAndUnavailable": "{total} 个 LoRA 中缺失 {missing} 个,{unavailable} 个不可用(已从源站删除或哈希无法解析)",
|
||||||
|
"partial": "{total} 个 LoRA 中 {unavailable} 个不可用(已从源站删除或哈希无法解析)- 使用配方时将被跳过",
|
||||||
|
"noneUsable": "没有可用的 LoRA - {total} 个中 {unavailable} 个已从源站删除或哈希无法解析"
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"inLibrary": "在库中",
|
"inLibrary": "在库中",
|
||||||
|
|||||||
+4
-1
@@ -883,7 +883,10 @@
|
|||||||
"loraStatus": {
|
"loraStatus": {
|
||||||
"none": "此配方不含 LoRA",
|
"none": "此配方不含 LoRA",
|
||||||
"allAvailable": "所有 LoRA 皆已就緒 - 可直接使用",
|
"allAvailable": "所有 LoRA 皆已就緒 - 可直接使用",
|
||||||
"missing": "{total} 個 LoRA 中缺少 {missing} 個"
|
"missing": "{total} 個 LoRA 中缺少 {missing} 個",
|
||||||
|
"missingAndUnavailable": "{total} 個 LoRA 中缺少 {missing} 個,{unavailable} 個不可用(已從來源刪除或雜湊無法解析)",
|
||||||
|
"partial": "{total} 個 LoRA 中 {unavailable} 個不可用(已從來源刪除或雜湊無法解析)- 使用配方時將被略過",
|
||||||
|
"noneUsable": "沒有可用的 LoRA - {total} 個中 {unavailable} 個已從來源刪除或雜湊無法解析"
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"inLibrary": "已在庫存",
|
"inLibrary": "已在庫存",
|
||||||
|
|||||||
@@ -3889,7 +3889,10 @@ class RecipeScanner:
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not file_name:
|
if not file_name:
|
||||||
if lora.get("isDeleted", False):
|
# LoRAs deleted from the source or with an unresolvable hash
|
||||||
|
# cannot be downloaded; skip them instead of emitting a token
|
||||||
|
# pointing at a file that does not exist locally.
|
||||||
|
if lora.get("isDeleted", False) or lora.get("hashInvalid", False):
|
||||||
continue
|
continue
|
||||||
file_name = lora.get("file_name", "unknown-lora")
|
file_name = lora.get("file_name", "unknown-lora")
|
||||||
folder = lora.get("folder", "")
|
folder = lora.get("folder", "")
|
||||||
|
|||||||
@@ -699,6 +699,20 @@ body.hide-card-version .hl-badge {
|
|||||||
border-color: rgba(231, 76, 60, 0.65);
|
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 {
|
.placeholder-message {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -1078,6 +1078,19 @@
|
|||||||
color: #facc15;
|
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 {
|
.recipe-card__badge--empty {
|
||||||
background: rgba(148, 163, 184, 0.18);
|
background: rgba(148, 163, 184, 0.18);
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
@@ -1093,6 +1106,16 @@
|
|||||||
background: rgba(245, 199, 43, 0.22);
|
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 {
|
[data-theme="light"] .recipe-card__badge--empty {
|
||||||
color: rgba(71, 85, 105, 0.9);
|
color: rgba(71, 85, 105, 0.9);
|
||||||
background: rgba(148, 163, 184, 0.2);
|
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 response = await fetch(`/api/lm/recipe/${recipeId}`);
|
||||||
const recipe = await response.json();
|
const recipe = await response.json();
|
||||||
|
|
||||||
// Get missing LoRAs
|
// Get missing LoRAs (still downloadable: not deleted from the
|
||||||
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted);
|
// source and hash still resolvable)
|
||||||
|
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted && !lora.hashInvalid);
|
||||||
|
|
||||||
if (missingLoras.length === 0) {
|
if (missingLoras.length === 0) {
|
||||||
showToast('recipes.contextMenu.downloadMissing.noMissingLoras', {}, 'info');
|
showToast('recipes.contextMenu.downloadMissing.noMissingLoras', {}, 'info');
|
||||||
|
|||||||
@@ -41,15 +41,35 @@ class RecipeCard {
|
|||||||
const loras = this.recipe.loras || [];
|
const loras = this.recipe.loras || [];
|
||||||
const lorasCount = loras.length;
|
const lorasCount = loras.length;
|
||||||
|
|
||||||
// Check if all LoRAs are available in the library
|
// Count LoRAs by availability: in library, missing (still downloadable
|
||||||
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
|
// from the source), or unobtainable (deleted from the source, or an
|
||||||
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
|
// 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.
|
// Compact status pill: state icon + available/total fraction.
|
||||||
// Icon switches by state so status never relies on color alone.
|
// Icon switches by state so status never relies on color alone.
|
||||||
const availableLorasCount = lorasCount - missingLorasCount;
|
// - missing (red): something can still be downloaded, most actionable
|
||||||
const loraCountStateClass = missingLorasCount > 0 ? 'missing' : (allLorasAvailable ? 'ready' : '');
|
// - partial (amber): usable but degraded, unobtainable LoRAs are skipped
|
||||||
const loraCountIcon = missingLorasCount > 0 ? 'fa-exclamation-triangle' : (allLorasAvailable ? 'fa-check' : 'fa-layer-group');
|
// - 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}`;
|
const loraCountLabel = lorasCount > 0 ? `${availableLorasCount}/${lorasCount}` : `${lorasCount}`;
|
||||||
|
|
||||||
// Ensure file_url exists, fallback to API URL if needed
|
// Ensure file_url exists, fallback to API URL if needed
|
||||||
@@ -135,7 +155,7 @@ class RecipeCard {
|
|||||||
<span class="model-name">${this.recipe.title}</span>
|
<span class="model-name">${this.recipe.title}</span>
|
||||||
</div>
|
</div>
|
||||||
${!isDuplicatesMode ? `
|
${!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}
|
<i class="fas ${loraCountIcon}" aria-hidden="true"></i> ${loraCountLabel}
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
@@ -154,17 +174,38 @@ class RecipeCard {
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
getLoraStatusTitle(totalCount, missingCount) {
|
getLoraStatusTitle(totalCount, availableCount, missingCount, unavailableCount) {
|
||||||
if (totalCount === 0) {
|
if (totalCount === 0) {
|
||||||
return translate('recipes.loraStatus.none', {}, 'No LoRAs in this recipe');
|
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');
|
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(
|
return translate(
|
||||||
'recipes.loraStatus.missing',
|
'recipes.loraStatus.noneUsable',
|
||||||
{ missing: missingCount, total: totalCount },
|
{ unavailable: unavailableCount, total: totalCount },
|
||||||
`${missingCount} of ${totalCount} LoRAs missing`
|
`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 baseModel = recipe.base_model || '';
|
||||||
const loras = recipe.loras || [];
|
const loras = recipe.loras || [];
|
||||||
const lorasCount = loras.length;
|
const lorasCount = loras.length;
|
||||||
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
|
// Missing = still downloadable; unavailable = deleted from the source
|
||||||
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
|
// or unresolvable hash, silently skipped when the recipe is used.
|
||||||
const statusClass = lorasCount === 0 ? 'empty' : (allLorasAvailable ? 'ready' : 'missing');
|
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;
|
let statusLabel;
|
||||||
|
|
||||||
if (lorasCount === 0) {
|
if (lorasCount === 0) {
|
||||||
statusLabel = 'No linked LoRAs';
|
statusLabel = 'No linked LoRAs';
|
||||||
} else if (allLorasAvailable) {
|
} else if (statusClass === 'ready') {
|
||||||
statusLabel = `${lorasCount} LoRA${lorasCount > 1 ? 's' : ''} 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 {
|
} else {
|
||||||
statusLabel = `Missing ${missingLorasCount} of ${lorasCount}`;
|
statusLabel = 'No usable LoRAs';
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageUrl = recipe.file_url ||
|
const imageUrl = recipe.file_url ||
|
||||||
@@ -207,8 +219,16 @@ function renderRecipes(tabElement, recipes, options) {
|
|||||||
const statusBadge = document.createElement('span');
|
const statusBadge = document.createElement('span');
|
||||||
statusBadge.className = `recipe-card__badge recipe-card__badge--${statusClass}`;
|
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');
|
const statusIcon = document.createElement('i');
|
||||||
statusIcon.className = 'fas fa-layer-group';
|
statusIcon.className = `fas ${statusIcons[statusClass] || 'fa-layer-group'}`;
|
||||||
statusIcon.setAttribute('aria-hidden', 'true');
|
statusIcon.setAttribute('aria-hidden', 'true');
|
||||||
statusBadge.appendChild(statusIcon);
|
statusBadge.appendChild(statusIcon);
|
||||||
|
|
||||||
@@ -216,7 +236,7 @@ function renderRecipes(tabElement, recipes, options) {
|
|||||||
statusText.textContent = statusLabel;
|
statusText.textContent = statusLabel;
|
||||||
statusBadge.appendChild(statusText);
|
statusBadge.appendChild(statusText);
|
||||||
|
|
||||||
statusBadge.title = getLoraStatusTitle(lorasCount, missingLorasCount);
|
statusBadge.title = getLoraStatusTitle(lorasCount, availableLorasCount, missingLorasCount, unavailableLorasCount);
|
||||||
meta.appendChild(statusBadge);
|
meta.appendChild(statusBadge);
|
||||||
|
|
||||||
body.appendChild(meta);
|
body.appendChild(meta);
|
||||||
@@ -264,13 +284,23 @@ function renderRecipes(tabElement, recipes, options) {
|
|||||||
/**
|
/**
|
||||||
* Returns a descriptive title for the LoRA status indicator
|
* Returns a descriptive title for the LoRA status indicator
|
||||||
* @param {number} totalCount - Total number of LoRAs in recipe
|
* @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
|
* @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 (totalCount === 0) return "No LoRAs in this recipe";
|
||||||
if (missingCount === 0) return "All LoRAs available - Ready to use";
|
if (availableCount === totalCount) return "All LoRAs available - Ready to use";
|
||||||
return `${missingCount} of ${totalCount} LoRAs missing`;
|
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)) {
|
if (recipe.loras && Array.isArray(recipe.loras)) {
|
||||||
recipe.loras.forEach(lora => {
|
recipe.loras.forEach(lora => {
|
||||||
// Only include LoRAs not in library and not deleted
|
// Only include LoRAs not in library and still downloadable
|
||||||
if (!lora.inLibrary && !lora.isDeleted) {
|
// (not deleted from the source, hash still resolvable)
|
||||||
|
if (!lora.inLibrary && !lora.isDeleted && !lora.hashInvalid) {
|
||||||
const uniqueKey = lora.hash || lora.id || lora.modelVersionId;
|
const uniqueKey = lora.hash || lora.id || lora.modelVersionId;
|
||||||
|
|
||||||
if (uniqueKey && !uniqueLoras.has(uniqueKey)) {
|
if (uniqueKey && !uniqueLoras.has(uniqueKey)) {
|
||||||
|
|||||||
@@ -130,14 +130,61 @@ describe('RecipeCard LoRA status pill', () => {
|
|||||||
expect(card.element.querySelector('.lora-count.missing')).toBeNull();
|
expect(card.element.querySelector('.lora-count.missing')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not count deleted LoRAs as missing', async () => {
|
it('marks deleted-from-source LoRAs as partial instead of ready', async () => {
|
||||||
const card = await createCard([
|
const card = await createCard([
|
||||||
{ name: 'a', inLibrary: true },
|
{ name: 'a', inLibrary: true },
|
||||||
{ name: 'b', inLibrary: false, isDeleted: true },
|
{ name: 'b', inLibrary: false, isDeleted: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(card.element.querySelector('.lora-count.missing')).toBeNull();
|
expect(card.element.querySelector('.lora-count.missing')).toBeNull();
|
||||||
expect(card.element.querySelector('.lora-count.ready')).not.toBeNull();
|
expect(card.element.querySelector('.lora-count.ready')).toBeNull();
|
||||||
|
|
||||||
|
const pill = card.element.querySelector('.lora-count.partial');
|
||||||
|
expect(pill).not.toBeNull();
|
||||||
|
expect(pill.querySelector('.fa-circle-minus')).not.toBeNull();
|
||||||
|
expect(pill.textContent).toContain('1/2');
|
||||||
|
expect(pill.title).toBe('1 of 2 LoRAs unavailable (deleted from source or unresolvable hash) - skipped when recipe is used');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an unresolvable hash as unobtainable, not missing', async () => {
|
||||||
|
const card = await createCard([
|
||||||
|
{ name: 'a', inLibrary: true },
|
||||||
|
{ name: 'b', inLibrary: false, hashInvalid: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(card.element.querySelector('.lora-count.missing')).toBeNull();
|
||||||
|
|
||||||
|
const pill = card.element.querySelector('.lora-count.partial');
|
||||||
|
expect(pill).not.toBeNull();
|
||||||
|
expect(pill.textContent).toContain('1/2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows 0/n unavailable when every LoRA is deleted and none is in the library', async () => {
|
||||||
|
const card = await createCard([
|
||||||
|
{ name: 'a', inLibrary: false, isDeleted: true },
|
||||||
|
{ name: 'b', inLibrary: false, isDeleted: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pill = card.element.querySelector('.lora-count.unavailable');
|
||||||
|
expect(pill).not.toBeNull();
|
||||||
|
expect(pill.querySelector('.fa-ban')).not.toBeNull();
|
||||||
|
expect(pill.textContent).toContain('0/2');
|
||||||
|
expect(pill.title).toBe('No usable LoRAs - 2 of 2 deleted from source or unresolvable hash');
|
||||||
|
expect(card.element.querySelector('.lora-count.ready')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the actionable missing state when LoRAs are both missing and deleted', async () => {
|
||||||
|
const card = await createCard([
|
||||||
|
{ name: 'a', inLibrary: true },
|
||||||
|
{ name: 'b', inLibrary: false },
|
||||||
|
{ name: 'c', inLibrary: false, isDeleted: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pill = card.element.querySelector('.lora-count.missing');
|
||||||
|
expect(pill).not.toBeNull();
|
||||||
|
expect(pill.textContent).toContain('1/3');
|
||||||
|
expect(pill.title).toBe('1 of 3 LoRAs missing, 1 unavailable (deleted from source or unresolvable hash)');
|
||||||
|
expect(card.element.querySelector('.lora-count.partial')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a neutral layers icon with a bare 0 when the recipe has no LoRAs', async () => {
|
it('shows a neutral layers icon with a bare 0 when the recipe has no LoRAs', async () => {
|
||||||
|
|||||||
@@ -374,6 +374,34 @@ async def test_set_lora_entry_hash_invalid_persists_flag(tmp_path: Path, recipe_
|
|||||||
assert cleared_lora["hashInvalid"] is False
|
assert cleared_lora["hashInvalid"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_recipe_syntax_tokens_skips_unobtainable_loras(tmp_path: Path, recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||||
|
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
recipe_id = "syntax-skip-1"
|
||||||
|
recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
recipe_data = {
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(tmp_path / "image.png"),
|
||||||
|
"title": "Syntax skip",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"loras": [
|
||||||
|
{"file_name": "usable_lora", "strength": 0.8, "hash": ""},
|
||||||
|
{"file_name": "deleted_lora", "strength": 1.0, "hash": "", "isDeleted": True},
|
||||||
|
{"file_name": "invalid_hash_lora", "strength": 1.0, "hash": "", "hashInvalid": True},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
recipe_path.write_text(json.dumps(recipe_data))
|
||||||
|
await scanner.add_recipe(dict(recipe_data))
|
||||||
|
|
||||||
|
tokens = await scanner.get_recipe_syntax_tokens(recipe_id)
|
||||||
|
|
||||||
|
assert tokens == ["<lora:usable_lora:0.8>"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_load_recipe_rewrites_missing_image_path(tmp_path: Path, recipe_scanner):
|
async def test_load_recipe_rewrites_missing_image_path(tmp_path: Path, recipe_scanner):
|
||||||
scanner, _ = recipe_scanner
|
scanner, _ = recipe_scanner
|
||||||
|
|||||||
Reference in New Issue
Block a user