diff --git a/locales/de.json b/locales/de.json index 918840df..77ac8bb6 100644 --- a/locales/de.json +++ b/locales/de.json @@ -883,7 +883,10 @@ "loraStatus": { "none": "Keine LoRAs in diesem Rezept", "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": { "inLibrary": "In Bibliothek", diff --git a/locales/en.json b/locales/en.json index f5ad82d7..60a39c23 100644 --- a/locales/en.json +++ b/locales/en.json @@ -883,7 +883,10 @@ "loraStatus": { "none": "No LoRAs in this recipe", "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": { "inLibrary": "In Library", diff --git a/locales/es.json b/locales/es.json index 3129ec00..b7e8e83f 100644 --- a/locales/es.json +++ b/locales/es.json @@ -883,7 +883,10 @@ "loraStatus": { "none": "No hay LoRAs en esta receta", "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": { "inLibrary": "En la biblioteca", diff --git a/locales/fr.json b/locales/fr.json index fea76cb8..812a4567 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -883,7 +883,10 @@ "loraStatus": { "none": "Aucun LoRA dans cette Recipe", "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": { "inLibrary": "Dans la bibliothèque", diff --git a/locales/he.json b/locales/he.json index 260d82df..2fe44542 100644 --- a/locales/he.json +++ b/locales/he.json @@ -883,7 +883,10 @@ "loraStatus": { "none": "אין 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": { "inLibrary": "בספרייה", diff --git a/locales/ja.json b/locales/ja.json index ca08c4f5..7c05edb8 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -883,7 +883,10 @@ "loraStatus": { "none": "このレシピには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": { "inLibrary": "ライブラリ内", diff --git a/locales/ko.json b/locales/ko.json index 1003cf41..44f7d71c 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -883,7 +883,10 @@ "loraStatus": { "none": "이 레시피에는 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": { "inLibrary": "라이브러리에 있음", diff --git a/locales/ru.json b/locales/ru.json index 75d38316..4f33ba2c 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -883,7 +883,10 @@ "loraStatus": { "none": "В этом рецепте нет 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": { "inLibrary": "В библиотеке", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 62dd3376..db96a3a3 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -883,7 +883,10 @@ "loraStatus": { "none": "此配方不包含 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": { "inLibrary": "在库中", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 2653b987..732e3f74 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -883,7 +883,10 @@ "loraStatus": { "none": "此配方不含 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": { "inLibrary": "已在庫存", diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index c9acd075..54627e1d 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -3889,7 +3889,10 @@ class RecipeScanner: break 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 file_name = lora.get("file_name", "unknown-lora") folder = lora.get("folder", "") diff --git a/static/css/components/card.css b/static/css/components/card.css index e0c439f1..f5304355 100644 --- a/static/css/components/card.css +++ b/static/css/components/card.css @@ -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; diff --git a/static/css/components/lora-modal/lora-modal.css b/static/css/components/lora-modal/lora-modal.css index 51c1c60b..d5fcca7e 100644 --- a/static/css/components/lora-modal/lora-modal.css +++ b/static/css/components/lora-modal/lora-modal.css @@ -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); diff --git a/static/js/components/ContextMenu/RecipeContextMenu.js b/static/js/components/ContextMenu/RecipeContextMenu.js index a6a6ffa6..1e22a2b3 100644 --- a/static/js/components/ContextMenu/RecipeContextMenu.js +++ b/static/js/components/ContextMenu/RecipeContextMenu.js @@ -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'); diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js index 18ae172c..0c46d2c1 100644 --- a/static/js/components/RecipeCard.js +++ b/static/js/components/RecipeCard.js @@ -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 { ${this.recipe.title} ${!isDuplicatesMode ? ` -
+
${loraCountLabel}
` : ''} @@ -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` ); } diff --git a/static/js/components/shared/RecipeTab.js b/static/js/components/shared/RecipeTab.js index 1384de13..a17bfc74 100644 --- a/static/js/components/shared/RecipeTab.js +++ b/static/js/components/shared/RecipeTab.js @@ -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`; } /** diff --git a/static/js/managers/BulkMissingLoraDownloadManager.js b/static/js/managers/BulkMissingLoraDownloadManager.js index baa8359a..fdc0b281 100644 --- a/static/js/managers/BulkMissingLoraDownloadManager.js +++ b/static/js/managers/BulkMissingLoraDownloadManager.js @@ -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)) { diff --git a/tests/frontend/components/recipeCard.loraBadges.test.js b/tests/frontend/components/recipeCard.loraBadges.test.js index 45ed92fb..7a6fb119 100644 --- a/tests/frontend/components/recipeCard.loraBadges.test.js +++ b/tests/frontend/components/recipeCard.loraBadges.test.js @@ -130,14 +130,61 @@ describe('RecipeCard LoRA status pill', () => { 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([ { name: 'a', inLibrary: true }, { name: 'b', inLibrary: false, isDeleted: true }, ]); 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 () => { diff --git a/tests/services/test_recipe_scanner.py b/tests/services/test_recipe_scanner.py index def8ee42..e76c06e3 100644 --- a/tests/services/test_recipe_scanner.py +++ b/tests/services/test_recipe_scanner.py @@ -374,6 +374,34 @@ async def test_set_lora_entry_hash_invalid_persists_flag(tmp_path: Path, recipe_ 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 == [""] + + @pytest.mark.asyncio async def test_load_recipe_rewrites_missing_image_path(tmp_path: Path, recipe_scanner): scanner, _ = recipe_scanner