mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
Compare commits
10 Commits
90be5799e4
...
6f5c444ec5
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f5c444ec5 | |||
| 20f66a4fe1 | |||
| 879745da53 | |||
| 3afec0a0be | |||
| 06c270a6e1 | |||
| 87e93636dc | |||
| 074d1f2e51 | |||
| 40f922b0e8 | |||
| a7214b6cff | |||
| 8ca66e72eb |
+16
-10
@@ -260,7 +260,11 @@
|
||||
"any": "Beliebig",
|
||||
"all": "Alle",
|
||||
"tagLogicAny": "Jedes Tag abgleichen (ODER)",
|
||||
"tagLogicAll": "Alle Tags abgleichen (UND)"
|
||||
"tagLogicAll": "Alle Tags abgleichen (UND)",
|
||||
"loraAvailability": "LoRA-Verfügbarkeit",
|
||||
"availabilityReady": "Einsatzbereit",
|
||||
"availabilityMissing": "Mit fehlenden LoRAs",
|
||||
"availabilityDeleted": "Mit gelöschten LoRAs"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Theme wechseln",
|
||||
@@ -855,7 +859,8 @@
|
||||
"title": "LoRA-Rezepte",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Send to ComfyUI",
|
||||
"sendRecipe": "Send to ComfyUI"
|
||||
"sendRecipe": "Send to ComfyUI",
|
||||
"deleteRecipeWithShortcut": "Rezept löschen (Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "Rezeptnavigation",
|
||||
@@ -863,10 +868,10 @@
|
||||
"nextWithShortcut": "Nächstes Rezept (→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
|
||||
"sent": "[TODO: Translate] Workflow sent to ComfyUI",
|
||||
"sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI",
|
||||
"noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
|
||||
"sendWorkflow": "Workflow an ComfyUI senden",
|
||||
"sent": "Workflow an ComfyUI gesendet",
|
||||
"sendFailed": "Fehler beim Senden des Workflows an ComfyUI",
|
||||
"noWorkflow": "Kein eingebetteter Workflow in diesem Rezept gefunden"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1440,7 +1445,8 @@
|
||||
"openFileLocation": "Dateispeicherort öffnen",
|
||||
"sendToWorkflow": "An ComfyUI senden",
|
||||
"sendToWorkflowText": "An ComfyUI senden",
|
||||
"copyHash": "Hash kopieren"
|
||||
"copyHash": "Hash kopieren",
|
||||
"deleteModelWithShortcut": "Modell löschen (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "Dateispeicherort erfolgreich geöffnet",
|
||||
@@ -2048,9 +2054,9 @@
|
||||
"reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen",
|
||||
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
|
||||
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest.",
|
||||
"workflowSent": "[TODO: Translate] Workflow sent to ComfyUI",
|
||||
"workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
|
||||
"workflowSent": "Workflow an ComfyUI gesendet",
|
||||
"workflowSendFailed": "Fehler beim Senden des Workflows an ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "Kein eingebetteter Workflow in diesem Rezept gefunden"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "Keine Modelle ausgewählt",
|
||||
|
||||
+9
-3
@@ -260,7 +260,11 @@
|
||||
"any": "Any",
|
||||
"all": "All",
|
||||
"tagLogicAny": "Match any tag (OR)",
|
||||
"tagLogicAll": "Match all tags (AND)"
|
||||
"tagLogicAll": "Match all tags (AND)",
|
||||
"loraAvailability": "Lora Availability",
|
||||
"availabilityReady": "Ready to use",
|
||||
"availabilityMissing": "Has missing",
|
||||
"availabilityDeleted": "Has deleted"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Toggle theme",
|
||||
@@ -855,7 +859,8 @@
|
||||
"title": "LoRA Recipes",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Send to ComfyUI",
|
||||
"sendRecipe": "Send to ComfyUI"
|
||||
"sendRecipe": "Send to ComfyUI",
|
||||
"deleteRecipeWithShortcut": "Delete recipe (Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "Recipe navigation",
|
||||
@@ -1440,7 +1445,8 @@
|
||||
"openFileLocation": "Open File Location",
|
||||
"sendToWorkflow": "Send to ComfyUI",
|
||||
"sendToWorkflowText": "Send to ComfyUI",
|
||||
"copyHash": "Copy hash"
|
||||
"copyHash": "Copy hash",
|
||||
"deleteModelWithShortcut": "Delete model (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "File location opened successfully",
|
||||
|
||||
+16
-10
@@ -260,7 +260,11 @@
|
||||
"any": "Cualquiera",
|
||||
"all": "Todos",
|
||||
"tagLogicAny": "Coincidir con cualquier etiqueta (O)",
|
||||
"tagLogicAll": "Coincidir con todas las etiquetas (Y)"
|
||||
"tagLogicAll": "Coincidir con todas las etiquetas (Y)",
|
||||
"loraAvailability": "Disponibilidad de LoRAs",
|
||||
"availabilityReady": "Listos para usar",
|
||||
"availabilityMissing": "Con LoRAs faltantes",
|
||||
"availabilityDeleted": "Con LoRAs eliminados"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Cambiar tema",
|
||||
@@ -855,7 +859,8 @@
|
||||
"title": "Recetas de LoRA",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Enviar a ComfyUI",
|
||||
"sendRecipe": "Enviar a ComfyUI"
|
||||
"sendRecipe": "Enviar a ComfyUI",
|
||||
"deleteRecipeWithShortcut": "Eliminar receta (Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "Navegación de recetas",
|
||||
@@ -863,10 +868,10 @@
|
||||
"nextWithShortcut": "Siguiente receta (→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
|
||||
"sent": "[TODO: Translate] Workflow sent to ComfyUI",
|
||||
"sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI",
|
||||
"noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
|
||||
"sendWorkflow": "Enviar workflow a ComfyUI",
|
||||
"sent": "Workflow enviado a ComfyUI",
|
||||
"sendFailed": "Error al enviar el workflow a ComfyUI",
|
||||
"noWorkflow": "No se encontró ningún workflow integrado en esta receta"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1440,7 +1445,8 @@
|
||||
"openFileLocation": "Abrir ubicación del archivo",
|
||||
"sendToWorkflow": "Enviar a ComfyUI",
|
||||
"sendToWorkflowText": "Enviar a ComfyUI",
|
||||
"copyHash": "Copiar hash"
|
||||
"copyHash": "Copiar hash",
|
||||
"deleteModelWithShortcut": "Eliminar modelo (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "Ubicación del archivo abierta exitosamente",
|
||||
@@ -2048,9 +2054,9 @@
|
||||
"reimportBulkFailed": "Error al reimportar algunas recetas",
|
||||
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
|
||||
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración.",
|
||||
"workflowSent": "[TODO: Translate] Workflow sent to ComfyUI",
|
||||
"workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
|
||||
"workflowSent": "Workflow enviado a ComfyUI",
|
||||
"workflowSendFailed": "Error al enviar el workflow a ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "No se encontró ningún workflow integrado en esta receta"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "No hay modelos seleccionados",
|
||||
|
||||
+16
-10
@@ -260,7 +260,11 @@
|
||||
"any": "N'importe quel",
|
||||
"all": "Tous",
|
||||
"tagLogicAny": "Correspondre à n'importe quel tag (OU)",
|
||||
"tagLogicAll": "Correspondre à tous les tags (ET)"
|
||||
"tagLogicAll": "Correspondre à tous les tags (ET)",
|
||||
"loraAvailability": "Disponibilité des LoRAs",
|
||||
"availabilityReady": "Prêts à l'emploi",
|
||||
"availabilityMissing": "Avec LoRAs manquants",
|
||||
"availabilityDeleted": "Avec LoRAs supprimés"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Basculer le thème",
|
||||
@@ -855,7 +859,8 @@
|
||||
"title": "LoRA Recipes",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Envoyer vers ComfyUI",
|
||||
"sendRecipe": "Envoyer vers ComfyUI"
|
||||
"sendRecipe": "Envoyer vers ComfyUI",
|
||||
"deleteRecipeWithShortcut": "Supprimer la recette (Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "Navigation des recettes",
|
||||
@@ -863,10 +868,10 @@
|
||||
"nextWithShortcut": "Recette suivante (→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
|
||||
"sent": "[TODO: Translate] Workflow sent to ComfyUI",
|
||||
"sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI",
|
||||
"noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
|
||||
"sendWorkflow": "Envoyer le workflow vers ComfyUI",
|
||||
"sent": "Workflow envoyé vers ComfyUI",
|
||||
"sendFailed": "Échec de l'envoi du workflow vers ComfyUI",
|
||||
"noWorkflow": "Aucun workflow intégré trouvé dans cette recette"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1440,7 +1445,8 @@
|
||||
"openFileLocation": "Ouvrir l'emplacement du fichier",
|
||||
"sendToWorkflow": "Envoyer vers ComfyUI",
|
||||
"sendToWorkflowText": "Envoyer vers ComfyUI",
|
||||
"copyHash": "Copier le hash"
|
||||
"copyHash": "Copier le hash",
|
||||
"deleteModelWithShortcut": "Supprimer le modèle (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "Emplacement du fichier ouvert avec succès",
|
||||
@@ -2048,9 +2054,9 @@
|
||||
"reimportBulkFailed": "Échec du ré-import de certaines recettes",
|
||||
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
|
||||
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres.",
|
||||
"workflowSent": "[TODO: Translate] Workflow sent to ComfyUI",
|
||||
"workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
|
||||
"workflowSent": "Workflow envoyé vers ComfyUI",
|
||||
"workflowSendFailed": "Échec de l'envoi du workflow vers ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "Aucun workflow intégré trouvé dans cette recette"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "Aucun modèle sélectionné",
|
||||
|
||||
+16
-10
@@ -260,7 +260,11 @@
|
||||
"any": "כלשהו",
|
||||
"all": "כל התגים",
|
||||
"tagLogicAny": "התאם כל תג (או)",
|
||||
"tagLogicAll": "התאם את כל התגים (וגם)"
|
||||
"tagLogicAll": "התאם את כל התגים (וגם)",
|
||||
"loraAvailability": "זמינות LoRA",
|
||||
"availabilityReady": "מוכנים לשימוש",
|
||||
"availabilityMissing": "עם LoRAs חסרים",
|
||||
"availabilityDeleted": "עם LoRAs שנמחקו"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "החלף ערכת נושא",
|
||||
@@ -855,7 +859,8 @@
|
||||
"title": "מתכוני LoRA",
|
||||
"actions": {
|
||||
"sendCheckpoint": "שלח ל-ComfyUI",
|
||||
"sendRecipe": "שלח ל-ComfyUI"
|
||||
"sendRecipe": "שלח ל-ComfyUI",
|
||||
"deleteRecipeWithShortcut": "מחק מתכון (Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "ניווט מתכונים",
|
||||
@@ -863,10 +868,10 @@
|
||||
"nextWithShortcut": "המתכון הבא (→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
|
||||
"sent": "[TODO: Translate] Workflow sent to ComfyUI",
|
||||
"sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI",
|
||||
"noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
|
||||
"sendWorkflow": "שלח workflow ל-ComfyUI",
|
||||
"sent": "ה-workflow נשלח ל-ComfyUI",
|
||||
"sendFailed": "שליחת ה-workflow ל-ComfyUI נכשלה",
|
||||
"noWorkflow": "לא נמצא workflow מוטבע במתכון זה"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1440,7 +1445,8 @@
|
||||
"openFileLocation": "פתח מיקום קובץ",
|
||||
"sendToWorkflow": "שלח ל-ComfyUI",
|
||||
"sendToWorkflowText": "שלח ל-ComfyUI",
|
||||
"copyHash": "העתק האש"
|
||||
"copyHash": "העתק האש",
|
||||
"deleteModelWithShortcut": "מחק מודל (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "מיקום הקובץ נפתח בהצלחה",
|
||||
@@ -2048,9 +2054,9 @@
|
||||
"reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל",
|
||||
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
|
||||
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות.",
|
||||
"workflowSent": "[TODO: Translate] Workflow sent to ComfyUI",
|
||||
"workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
|
||||
"workflowSent": "ה-workflow נשלח ל-ComfyUI",
|
||||
"workflowSendFailed": "שליחת ה-workflow ל-ComfyUI נכשלה: {error}",
|
||||
"workflowNoWorkflow": "לא נמצא workflow מוטבע במתכון זה"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "לא נבחרו מודלים",
|
||||
|
||||
+16
-10
@@ -260,7 +260,11 @@
|
||||
"any": "いずれか",
|
||||
"all": "すべて",
|
||||
"tagLogicAny": "いずれかのタグに一致 (OR)",
|
||||
"tagLogicAll": "すべてのタグに一致 (AND)"
|
||||
"tagLogicAll": "すべてのタグに一致 (AND)",
|
||||
"loraAvailability": "LoRA の利用状況",
|
||||
"availabilityReady": "使用可能",
|
||||
"availabilityMissing": "不足 LoRA あり",
|
||||
"availabilityDeleted": "削除済み LoRA あり"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "テーマの切り替え",
|
||||
@@ -855,7 +859,8 @@
|
||||
"title": "LoRAレシピ",
|
||||
"actions": {
|
||||
"sendCheckpoint": "ComfyUIへ送信",
|
||||
"sendRecipe": "ComfyUIへ送信"
|
||||
"sendRecipe": "ComfyUIへ送信",
|
||||
"deleteRecipeWithShortcut": "レシピを削除(Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "レシピナビゲーション",
|
||||
@@ -863,10 +868,10 @@
|
||||
"nextWithShortcut": "次のレシピ(→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
|
||||
"sent": "[TODO: Translate] Workflow sent to ComfyUI",
|
||||
"sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI",
|
||||
"noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
|
||||
"sendWorkflow": "ワークフローをComfyUIへ送信",
|
||||
"sent": "ワークフローをComfyUIへ送信しました",
|
||||
"sendFailed": "ワークフローをComfyUIへ送信できませんでした",
|
||||
"noWorkflow": "このレシピに埋め込まれたワークフローが見つかりません"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1440,7 +1445,8 @@
|
||||
"openFileLocation": "ファイルの場所を開く",
|
||||
"sendToWorkflow": "ComfyUI に送信",
|
||||
"sendToWorkflowText": "ComfyUI に送信",
|
||||
"copyHash": "ハッシュをコピー"
|
||||
"copyHash": "ハッシュをコピー",
|
||||
"deleteModelWithShortcut": "モデルを削除(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "ファイルの場所を正常に開きました",
|
||||
@@ -2048,9 +2054,9 @@
|
||||
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
|
||||
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
|
||||
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。",
|
||||
"workflowSent": "[TODO: Translate] Workflow sent to ComfyUI",
|
||||
"workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
|
||||
"workflowSent": "ワークフローをComfyUIへ送信しました",
|
||||
"workflowSendFailed": "ワークフローをComfyUIへ送信できませんでした: {error}",
|
||||
"workflowNoWorkflow": "このレシピに埋め込まれたワークフローが見つかりません"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "モデルが選択されていません",
|
||||
|
||||
+16
-10
@@ -260,7 +260,11 @@
|
||||
"any": "아무",
|
||||
"all": "모두",
|
||||
"tagLogicAny": "모든 태그 일치 (OR)",
|
||||
"tagLogicAll": "모든 태그 일치 (AND)"
|
||||
"tagLogicAll": "모든 태그 일치 (AND)",
|
||||
"loraAvailability": "LoRA 가용성",
|
||||
"availabilityReady": "바로 사용 가능",
|
||||
"availabilityMissing": "누락된 LoRA 있음",
|
||||
"availabilityDeleted": "삭제된 LoRA 있음"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "테마 토글",
|
||||
@@ -855,7 +859,8 @@
|
||||
"title": "LoRA 레시피",
|
||||
"actions": {
|
||||
"sendCheckpoint": "ComfyUI로 보내기",
|
||||
"sendRecipe": "ComfyUI로 보내기"
|
||||
"sendRecipe": "ComfyUI로 보내기",
|
||||
"deleteRecipeWithShortcut": "레시피 삭제(Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "레시피 탐색",
|
||||
@@ -863,10 +868,10 @@
|
||||
"nextWithShortcut": "다음 레시피(→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
|
||||
"sent": "[TODO: Translate] Workflow sent to ComfyUI",
|
||||
"sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI",
|
||||
"noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
|
||||
"sendWorkflow": "워크플로를 ComfyUI로 보내기",
|
||||
"sent": "워크플로를 ComfyUI로 보냈습니다",
|
||||
"sendFailed": "워크플로를 ComfyUI로 보내지 못했습니다",
|
||||
"noWorkflow": "이 레시피에서 임베드된 워크플로를 찾을 수 없습니다"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1440,7 +1445,8 @@
|
||||
"openFileLocation": "파일 위치 열기",
|
||||
"sendToWorkflow": "ComfyUI로 보내기",
|
||||
"sendToWorkflowText": "ComfyUI로 보내기",
|
||||
"copyHash": "해시 복사"
|
||||
"copyHash": "해시 복사",
|
||||
"deleteModelWithShortcut": "모델 삭제(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "파일 위치가 성공적으로 열렸습니다",
|
||||
@@ -2048,9 +2054,9 @@
|
||||
"reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다",
|
||||
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
|
||||
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요.",
|
||||
"workflowSent": "[TODO: Translate] Workflow sent to ComfyUI",
|
||||
"workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
|
||||
"workflowSent": "워크플로를 ComfyUI로 보냈습니다",
|
||||
"workflowSendFailed": "워크플로를 ComfyUI로 보내지 못했습니다: {error}",
|
||||
"workflowNoWorkflow": "이 레시피에서 임베드된 워크플로를 찾을 수 없습니다"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "선택된 모델이 없습니다",
|
||||
|
||||
+16
-10
@@ -260,7 +260,11 @@
|
||||
"any": "Любой",
|
||||
"all": "Все",
|
||||
"tagLogicAny": "Совпадение с любым тегом (ИЛИ)",
|
||||
"tagLogicAll": "Совпадение со всеми тегами (И)"
|
||||
"tagLogicAll": "Совпадение со всеми тегами (И)",
|
||||
"loraAvailability": "Доступность LoRAs",
|
||||
"availabilityReady": "Готовы к использованию",
|
||||
"availabilityMissing": "Есть отсутствующие",
|
||||
"availabilityDeleted": "Есть удалённые"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Переключить тему",
|
||||
@@ -855,7 +859,8 @@
|
||||
"title": "Рецепты LoRA",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Отправить в ComfyUI",
|
||||
"sendRecipe": "Отправить в ComfyUI"
|
||||
"sendRecipe": "Отправить в ComfyUI",
|
||||
"deleteRecipeWithShortcut": "Удалить рецепт (Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "Навигация по рецептам",
|
||||
@@ -863,10 +868,10 @@
|
||||
"nextWithShortcut": "Следующий рецепт (→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
|
||||
"sent": "[TODO: Translate] Workflow sent to ComfyUI",
|
||||
"sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI",
|
||||
"noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
|
||||
"sendWorkflow": "Отправить workflow в ComfyUI",
|
||||
"sent": "Workflow отправлен в ComfyUI",
|
||||
"sendFailed": "Не удалось отправить workflow в ComfyUI",
|
||||
"noWorkflow": "В этом рецепте не найден встроенный workflow"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1440,7 +1445,8 @@
|
||||
"openFileLocation": "Открыть расположение файла",
|
||||
"sendToWorkflow": "Отправить в ComfyUI",
|
||||
"sendToWorkflowText": "Отправить в ComfyUI",
|
||||
"copyHash": "Копировать хэш"
|
||||
"copyHash": "Копировать хэш",
|
||||
"deleteModelWithShortcut": "Удалить модель (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "Расположение файла успешно открыто",
|
||||
@@ -2048,9 +2054,9 @@
|
||||
"reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты",
|
||||
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
|
||||
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках.",
|
||||
"workflowSent": "[TODO: Translate] Workflow sent to ComfyUI",
|
||||
"workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
|
||||
"workflowSent": "Workflow отправлен в ComfyUI",
|
||||
"workflowSendFailed": "Не удалось отправить workflow в ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "В этом рецепте не найден встроенный workflow"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "Модели не выбраны",
|
||||
|
||||
+16
-10
@@ -260,7 +260,11 @@
|
||||
"any": "任一",
|
||||
"all": "全部",
|
||||
"tagLogicAny": "匹配任一标签 (或)",
|
||||
"tagLogicAll": "匹配所有标签 (与)"
|
||||
"tagLogicAll": "匹配所有标签 (与)",
|
||||
"loraAvailability": "LoRA 可用性",
|
||||
"availabilityReady": "可直接使用",
|
||||
"availabilityMissing": "包含缺失 LoRA",
|
||||
"availabilityDeleted": "包含已删除 LoRA"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "切换主题",
|
||||
@@ -855,7 +859,8 @@
|
||||
"title": "LoRA 配方",
|
||||
"actions": {
|
||||
"sendCheckpoint": "发送到 ComfyUI",
|
||||
"sendRecipe": "发送到 ComfyUI"
|
||||
"sendRecipe": "发送到 ComfyUI",
|
||||
"deleteRecipeWithShortcut": "删除配方(Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "配方导航",
|
||||
@@ -863,10 +868,10 @@
|
||||
"nextWithShortcut": "下一个配方(→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
|
||||
"sent": "[TODO: Translate] Workflow sent to ComfyUI",
|
||||
"sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI",
|
||||
"noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
|
||||
"sendWorkflow": "发送工作流到 ComfyUI",
|
||||
"sent": "工作流已发送到 ComfyUI",
|
||||
"sendFailed": "发送工作流到 ComfyUI 失败",
|
||||
"noWorkflow": "此配方中未找到内嵌工作流"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1440,7 +1445,8 @@
|
||||
"openFileLocation": "打开文件位置",
|
||||
"sendToWorkflow": "发送到 ComfyUI",
|
||||
"sendToWorkflowText": "发送到 ComfyUI",
|
||||
"copyHash": "复制哈希值"
|
||||
"copyHash": "复制哈希值",
|
||||
"deleteModelWithShortcut": "删除模型(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "文件位置已成功打开",
|
||||
@@ -2048,9 +2054,9 @@
|
||||
"reimportBulkFailed": "重新导入某些配方失败",
|
||||
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。",
|
||||
"workflowSent": "[TODO: Translate] Workflow sent to ComfyUI",
|
||||
"workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
|
||||
"workflowSent": "工作流已发送到 ComfyUI",
|
||||
"workflowSendFailed": "发送工作流到 ComfyUI 失败: {error}",
|
||||
"workflowNoWorkflow": "此配方中未找到内嵌工作流"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "未选中模型",
|
||||
|
||||
+16
-10
@@ -260,7 +260,11 @@
|
||||
"any": "任一",
|
||||
"all": "全部",
|
||||
"tagLogicAny": "符合任一票籤 (或)",
|
||||
"tagLogicAll": "符合所有標籤 (與)"
|
||||
"tagLogicAll": "符合所有標籤 (與)",
|
||||
"loraAvailability": "LoRA 可用性",
|
||||
"availabilityReady": "可直接使用",
|
||||
"availabilityMissing": "包含缺少的 LoRA",
|
||||
"availabilityDeleted": "包含已刪除的 LoRA"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "切換主題",
|
||||
@@ -855,7 +859,8 @@
|
||||
"title": "LoRA 配方",
|
||||
"actions": {
|
||||
"sendCheckpoint": "傳送到 ComfyUI",
|
||||
"sendRecipe": "傳送到 ComfyUI"
|
||||
"sendRecipe": "傳送到 ComfyUI",
|
||||
"deleteRecipeWithShortcut": "刪除配方(Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "配方導覽",
|
||||
@@ -863,10 +868,10 @@
|
||||
"nextWithShortcut": "下一個配方(→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
|
||||
"sent": "[TODO: Translate] Workflow sent to ComfyUI",
|
||||
"sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI",
|
||||
"noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
|
||||
"sendWorkflow": "傳送工作流到 ComfyUI",
|
||||
"sent": "工作流已傳送到 ComfyUI",
|
||||
"sendFailed": "傳送工作流到 ComfyUI 失敗",
|
||||
"noWorkflow": "此配方中未找到內嵌工作流"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -1440,7 +1445,8 @@
|
||||
"openFileLocation": "開啟檔案位置",
|
||||
"sendToWorkflow": "傳送到 ComfyUI",
|
||||
"sendToWorkflowText": "傳送到 ComfyUI",
|
||||
"copyHash": "複製雜湊值"
|
||||
"copyHash": "複製雜湊值",
|
||||
"deleteModelWithShortcut": "刪除模型(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "檔案位置已成功開啟",
|
||||
@@ -2048,9 +2054,9 @@
|
||||
"reimportBulkFailed": "重新匯入某些配方失敗",
|
||||
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。",
|
||||
"workflowSent": "[TODO: Translate] Workflow sent to ComfyUI",
|
||||
"workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
|
||||
"workflowSent": "工作流已傳送到 ComfyUI",
|
||||
"workflowSendFailed": "傳送工作流到 ComfyUI 失敗: {error}",
|
||||
"workflowNoWorkflow": "此配方中未找到內嵌工作流"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "未選擇模型",
|
||||
|
||||
@@ -649,9 +649,60 @@ class NodeRegistry:
|
||||
|
||||
|
||||
class HealthCheckHandler:
|
||||
def __init__(
|
||||
self,
|
||||
scanner_getters: Mapping[str, Callable[[], Awaitable[Any]]] | None = None,
|
||||
) -> None:
|
||||
self._scanner_getters = scanner_getters or {
|
||||
"lora": ServiceRegistry.get_lora_scanner,
|
||||
"checkpoint": ServiceRegistry.get_checkpoint_scanner,
|
||||
"embedding": ServiceRegistry.get_embedding_scanner,
|
||||
"recipe": ServiceRegistry.get_recipe_scanner,
|
||||
}
|
||||
|
||||
async def health_check(self, request: web.Request) -> web.Response:
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
async def get_init_status(self, request: web.Request) -> web.Response:
|
||||
"""Report aggregate scanner initialization status.
|
||||
|
||||
Used by the initialization page's polling fallback when the
|
||||
/ws/init-progress WebSocket is unavailable. Omits pageType so every
|
||||
page accepts the update and only reloads once all scanners are done.
|
||||
"""
|
||||
pending: list[str] = []
|
||||
for name, getter in self._scanner_getters.items():
|
||||
try:
|
||||
scanner = await getter()
|
||||
except Exception:
|
||||
pending.append(name)
|
||||
continue
|
||||
cache_ready = getattr(scanner, "_cache", None) is not None
|
||||
is_initializing = getattr(scanner, "is_initializing", None)
|
||||
busy = (
|
||||
is_initializing()
|
||||
if callable(is_initializing)
|
||||
else bool(getattr(scanner, "_is_initializing", False))
|
||||
)
|
||||
if busy or not cache_ready:
|
||||
pending.append(name)
|
||||
|
||||
if pending:
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "initializing",
|
||||
"stage": "processing",
|
||||
"details": "Initializing: " + ", ".join(pending),
|
||||
}
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "complete",
|
||||
"progress": 100,
|
||||
"details": "Initialization complete",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SupportersHandler:
|
||||
"""Handler for supporters data."""
|
||||
@@ -3859,6 +3910,7 @@ class MiscHandlerSet:
|
||||
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
||||
return {
|
||||
"health_check": self.health.health_check,
|
||||
"get_init_status": self.health.get_init_status,
|
||||
"get_settings": self.settings.get_settings,
|
||||
"update_settings": self.settings.update_settings,
|
||||
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
|
||||
|
||||
@@ -176,11 +176,19 @@ class RecipePageView:
|
||||
user_language = self._settings.get("language", "en")
|
||||
self._server_i18n.set_locale(user_language)
|
||||
|
||||
# While the initial scan is running, show the initialization
|
||||
# screen (same as the model pages) instead of an empty grid; the
|
||||
# page reloads itself when the scanner broadcasts completion.
|
||||
is_initializing = (
|
||||
recipe_scanner._cache is None or recipe_scanner.is_initializing()
|
||||
)
|
||||
|
||||
try:
|
||||
await recipe_scanner.get_cached_data(force_refresh=False)
|
||||
if not is_initializing:
|
||||
await recipe_scanner.get_cached_data(force_refresh=False)
|
||||
rendered = self._template_env.get_template(self._template_name).render(
|
||||
recipes=[],
|
||||
is_initializing=False,
|
||||
is_initializing=is_initializing,
|
||||
settings=self._settings,
|
||||
request=request,
|
||||
t=self._server_i18n.get_translation,
|
||||
@@ -266,6 +274,14 @@ class RecipeListingHandler:
|
||||
if tag_filters:
|
||||
filters["tags"] = tag_filters
|
||||
|
||||
lora_availability = {
|
||||
status.strip()
|
||||
for status in request.query.get("lora_availability", "").split(",")
|
||||
if status.strip() in ("ready", "missing", "deleted")
|
||||
}
|
||||
if lora_availability:
|
||||
filters["lora_availability"] = lora_availability
|
||||
|
||||
lora_hash = request.query.get("lora_hash")
|
||||
checkpoint_hash = request.query.get("checkpoint_hash")
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
|
||||
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
|
||||
RouteDefinition("GET", "/api/lm/health-check", "health_check"),
|
||||
RouteDefinition("GET", "/api/lm/init-status", "get_init_status"),
|
||||
RouteDefinition("GET", "/api/lm/supporters", "get_supporters"),
|
||||
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
|
||||
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"),
|
||||
|
||||
+142
-39
@@ -19,6 +19,7 @@ from ..utils.recipe_open_stats import RecipeOpenStats
|
||||
from .model_scanner import WEIGHT_FILE_EXTENSIONS
|
||||
from .recipe_cache import RecipeCache
|
||||
from .recipes.errors import RecipeNotFoundError, RecipePersistenceError
|
||||
from .websocket_manager import ws_manager
|
||||
from natsort import natsorted
|
||||
import sys
|
||||
import re
|
||||
@@ -38,6 +39,9 @@ logger = logging.getLogger(__name__)
|
||||
# explicitly to "diffusion_model" (mirrors Oracle R2-F1).
|
||||
_CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"}
|
||||
|
||||
# Valid LoRA availability statuses for the recipe listing filter.
|
||||
_VALID_LORA_AVAILABILITY_STATUSES = frozenset({"ready", "missing", "deleted"})
|
||||
|
||||
|
||||
class RecipeScanner:
|
||||
"""Service for scanning and managing recipe images"""
|
||||
@@ -481,6 +485,10 @@ class RecipeScanner:
|
||||
return str(value)
|
||||
return "unknown"
|
||||
|
||||
def is_initializing(self) -> bool:
|
||||
"""Check if the scanner is currently initializing"""
|
||||
return self._is_initializing
|
||||
|
||||
def on_library_changed(self) -> None:
|
||||
"""Reset cached state when the active library changes."""
|
||||
|
||||
@@ -1404,7 +1412,20 @@ class RecipeScanner:
|
||||
|
||||
async def initialize_in_background(self) -> None:
|
||||
"""Initialize cache in background using thread pool"""
|
||||
# Mark as initializing before any await so concurrent callers can
|
||||
# wait on this task instead of observing the placeholder empty cache
|
||||
# (the LoRA scanner wait below can take a while at startup).
|
||||
self._is_initializing = True
|
||||
self._initialization_task = asyncio.current_task()
|
||||
try:
|
||||
await ws_manager.broadcast_init_progress({
|
||||
'stage': 'loading_cache',
|
||||
'progress': 0,
|
||||
'details': 'Loading recipe cache...',
|
||||
'scanner_type': 'recipe',
|
||||
'pageType': 'recipes',
|
||||
})
|
||||
|
||||
await self._wait_for_lora_scanner()
|
||||
|
||||
# Set initial empty cache to avoid None reference errors
|
||||
@@ -1417,39 +1438,61 @@ class RecipeScanner:
|
||||
folder_tree={},
|
||||
)
|
||||
|
||||
# Mark as initializing to prevent concurrent initializations
|
||||
self._is_initializing = True
|
||||
self._initialization_task = asyncio.current_task()
|
||||
# Start timer
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Start timer
|
||||
start_time = time.time()
|
||||
# Use thread pool to execute CPU-intensive operations
|
||||
loop = asyncio.get_event_loop()
|
||||
cache = await loop.run_in_executor(
|
||||
None, # Use default thread pool
|
||||
self._initialize_recipe_cache_sync, # Run synchronous version in thread
|
||||
)
|
||||
if cache is not None:
|
||||
self._cache = cache
|
||||
|
||||
# Use thread pool to execute CPU-intensive operations
|
||||
loop = asyncio.get_event_loop()
|
||||
cache = await loop.run_in_executor(
|
||||
None, # Use default thread pool
|
||||
self._initialize_recipe_cache_sync, # Run synchronous version in thread
|
||||
)
|
||||
if cache is not None:
|
||||
self._cache = cache
|
||||
|
||||
# Calculate elapsed time and log it
|
||||
elapsed_time = time.time() - start_time
|
||||
recipe_count = (
|
||||
len(cache.raw_data) if cache and hasattr(cache, "raw_data") else 0
|
||||
)
|
||||
logger.info(
|
||||
f"Recipe cache initialized in {elapsed_time:.2f} seconds. Found {recipe_count} recipes"
|
||||
)
|
||||
self._schedule_post_scan_enrichment()
|
||||
# Schedule FTS index build in background (non-blocking)
|
||||
self._schedule_fts_index_build()
|
||||
finally:
|
||||
# Mark initialization as complete regardless of outcome
|
||||
self._is_initializing = False
|
||||
# Calculate elapsed time and log it
|
||||
elapsed_time = time.time() - start_time
|
||||
recipe_count = (
|
||||
len(cache.raw_data) if cache and hasattr(cache, "raw_data") else 0
|
||||
)
|
||||
logger.info(
|
||||
f"Recipe cache initialized in {elapsed_time:.2f} seconds. Found {recipe_count} recipes"
|
||||
)
|
||||
await ws_manager.broadcast_init_progress({
|
||||
'stage': 'finalizing',
|
||||
'progress': 100,
|
||||
'status': 'complete',
|
||||
'details': f'Found {recipe_count} recipes.',
|
||||
'scanner_type': 'recipe',
|
||||
'pageType': 'recipes',
|
||||
})
|
||||
self._schedule_post_scan_enrichment()
|
||||
# Schedule FTS index build in background (non-blocking)
|
||||
self._schedule_fts_index_build()
|
||||
except Exception as e:
|
||||
logger.error(f"Recipe Scanner: Error initializing cache in background: {e}")
|
||||
# Ensure the cache is never None so the page stops showing the
|
||||
# initialization screen, and let waiting clients reload into the
|
||||
# regular (possibly empty) view instead of stalling.
|
||||
if self._cache is None:
|
||||
self._cache = RecipeCache(
|
||||
raw_data=[],
|
||||
sorted_by_name=[],
|
||||
sorted_by_date=[],
|
||||
folders=[],
|
||||
folder_tree={},
|
||||
)
|
||||
await ws_manager.broadcast_init_progress({
|
||||
'stage': 'finalizing',
|
||||
'progress': 100,
|
||||
'status': 'complete',
|
||||
'details': 'Recipe cache initialization failed.',
|
||||
'scanner_type': 'recipe',
|
||||
'pageType': 'recipes',
|
||||
})
|
||||
finally:
|
||||
# Mark initialization as complete regardless of outcome
|
||||
self._is_initializing = False
|
||||
|
||||
def _initialize_recipe_cache_sync(self):
|
||||
"""Synchronous version of recipe cache initialization for thread pool execution.
|
||||
@@ -2254,21 +2297,28 @@ class RecipeScanner:
|
||||
|
||||
async def get_cached_data(self, force_refresh: bool = False) -> RecipeCache:
|
||||
"""Get cached recipe data, refresh if needed"""
|
||||
# If a background initialization is in progress, wait for it to
|
||||
# complete so callers never observe the placeholder empty cache.
|
||||
initialization_task = self._initialization_task
|
||||
if (
|
||||
self._is_initializing
|
||||
and not force_refresh
|
||||
and initialization_task is not None
|
||||
and initialization_task is not asyncio.current_task()
|
||||
and not initialization_task.done()
|
||||
):
|
||||
try:
|
||||
await initialization_task
|
||||
except Exception:
|
||||
# Initialization failures are logged by the task itself; fall
|
||||
# through and return whatever cache state we have.
|
||||
pass
|
||||
|
||||
# If cache is already initialized and no refresh is needed, return it immediately
|
||||
if self._cache is not None and not force_refresh:
|
||||
self._update_folder_metadata()
|
||||
return cast(RecipeCache, self._cache)
|
||||
|
||||
# If another initialization is already in progress, wait for it to complete
|
||||
if self._is_initializing and not force_refresh:
|
||||
return self._cache or RecipeCache(
|
||||
raw_data=[],
|
||||
sorted_by_name=[],
|
||||
sorted_by_date=[],
|
||||
folders=[],
|
||||
folder_tree={},
|
||||
)
|
||||
|
||||
# If force refresh is requested, re-scan in a thread pool to avoid
|
||||
# blocking the event loop (which is shared with ComfyUI).
|
||||
if force_refresh:
|
||||
@@ -2947,6 +2997,43 @@ class RecipeScanner:
|
||||
|
||||
return lora
|
||||
|
||||
def _compute_availability_statuses(self, recipe: Dict[str, Any]) -> Set[str]:
|
||||
"""Compute the LoRA availability status set for a recipe.
|
||||
|
||||
Returns ``{"ready"}`` when every non-excluded LoRA resolves to the
|
||||
local library (recipes without LoRAs count as ready); otherwise a
|
||||
subset of ``{"missing", "deleted"}``. Uses the same inLibrary
|
||||
resolution as ``_enrich_lora_entry`` (hash index with modelVersionId
|
||||
fallback) but performs only in-memory lookups.
|
||||
"""
|
||||
|
||||
statuses: Set[str] = set()
|
||||
for lora in recipe.get("loras") or []:
|
||||
if not isinstance(lora, dict) or lora.get("exclude"):
|
||||
continue
|
||||
|
||||
in_library = False
|
||||
if self._lora_scanner:
|
||||
hash_value = (lora.get("hash") or "").lower()
|
||||
if hash_value:
|
||||
in_library = self._lora_scanner.has_hash(hash_value)
|
||||
elif lora.get("modelVersionId") is not None:
|
||||
in_library = (
|
||||
self._get_lora_from_version_index(lora.get("modelVersionId"))
|
||||
is not None
|
||||
)
|
||||
|
||||
if in_library:
|
||||
continue
|
||||
if lora.get("isDeleted"):
|
||||
statuses.add("deleted")
|
||||
else:
|
||||
statuses.add("missing")
|
||||
|
||||
if not statuses:
|
||||
statuses.add("ready")
|
||||
return statuses
|
||||
|
||||
def _normalize_preview_url(self, preview_url: Optional[str]) -> Optional[str]:
|
||||
"""Return a preview URL that is reachable from the browser."""
|
||||
|
||||
@@ -3214,6 +3301,22 @@ class RecipeScanner:
|
||||
if not matches_exclude(item.get("tags"))
|
||||
]
|
||||
|
||||
# Filter by LoRA availability status
|
||||
availability = filters.get("lora_availability")
|
||||
if availability:
|
||||
selected = {
|
||||
status
|
||||
for status in availability
|
||||
if status in _VALID_LORA_AVAILABILITY_STATUSES
|
||||
}
|
||||
# Selecting every status (or none) means no filtering.
|
||||
if 0 < len(selected) < len(_VALID_LORA_AVAILABILITY_STATUSES):
|
||||
filtered_data = [
|
||||
item
|
||||
for item in filtered_data
|
||||
if self._compute_availability_statuses(item) & selected
|
||||
]
|
||||
|
||||
# Apply sorting if not already handled by pre-sorted cache
|
||||
if ":" in sort_by or sort_field in ("loras_count", "random", "opened"):
|
||||
field, order = (sort_by.split(":") + ["desc"])[:2]
|
||||
|
||||
@@ -68,6 +68,39 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Destructive modal action: ghost icon button right-anchored by its own auto
|
||||
margin, revealing the danger color only on hover/focus. Shared by the model
|
||||
modal and the recipe modal. */
|
||||
.modal-delete-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-delete-btn:hover,
|
||||
.modal-delete-btn:focus-visible {
|
||||
color: var(--lora-error);
|
||||
border-color: var(--lora-error);
|
||||
background: oklch(from var(--lora-error) l c h / 0.08);
|
||||
}
|
||||
|
||||
.modal-delete-btn i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* When license icons directly precede the delete button, they carry the auto
|
||||
margin instead, so the [license][delete] cluster stays right-anchored as
|
||||
one group with the delete button flush at the right edge and no split gap. */
|
||||
.modal-header-actions .license-restrictions {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -76,6 +109,11 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.modal-header-actions .license-restrictions + .modal-delete-btn,
|
||||
.modal-header-actions .license-permissions + .modal-delete-btn {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.license-restrictions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -174,6 +174,11 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add LoRA availability filter (no statuses selected = no filtering)
|
||||
if (pageState.filters?.loraAvailability && pageState.filters.loraAvailability.length > 0) {
|
||||
params.append('lora_availability', pageState.filters.loraAvailability.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch recipes
|
||||
|
||||
+132
-115
@@ -339,124 +339,11 @@ class RecipeCard {
|
||||
}
|
||||
|
||||
showDeleteConfirmation() {
|
||||
try {
|
||||
// Get recipe ID
|
||||
const recipeId = this.recipe.id;
|
||||
const filePath = this.recipe.file_path;
|
||||
if (!recipeId) {
|
||||
showToast('toast.recipes.cannotDelete', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create delete modal content
|
||||
const previewUrl = this.recipe.file_url || '/loras_static/images/no-preview.png';
|
||||
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
|
||||
|
||||
const deleteModalContent = `
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2>Delete Recipe</h2>
|
||||
<p class="delete-message">Are you sure you want to delete this recipe?</p>
|
||||
<div class="delete-model-info">
|
||||
<div class="delete-preview">
|
||||
${isVideo ?
|
||||
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
|
||||
`<img src="${previewUrl}" alt="${this.recipe.title}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||||
}
|
||||
</div>
|
||||
<div class="delete-info">
|
||||
<h3>${this.recipe.title}</h3>
|
||||
<p>${translate('modals.deleteRecipe.recoverableWarning')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="delete-note">Note: Deleting this recipe will not affect the LoRA files used in it.</p>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" onclick="closeDeleteModal()">Cancel</button>
|
||||
<button class="delete-btn" onclick="confirmDelete()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show the modal with custom content and setup callbacks
|
||||
modalManager.showModal('deleteModal', deleteModalContent, () => {
|
||||
// This is the onClose callback
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
deleteBtn.textContent = 'Delete';
|
||||
deleteBtn.disabled = false;
|
||||
});
|
||||
|
||||
// Set up the delete and cancel buttons with proper event handlers
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const cancelBtn = deleteModal.querySelector('.cancel-btn');
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
|
||||
// Store recipe ID in the modal for the delete confirmation handler
|
||||
deleteModal.dataset.recipeId = recipeId;
|
||||
deleteModal.dataset.filePath = filePath;
|
||||
|
||||
// Update button event handlers
|
||||
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
|
||||
deleteBtn.onclick = () => this.confirmDeleteRecipe();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error showing delete confirmation:', error);
|
||||
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
|
||||
}
|
||||
showRecipeDeleteConfirmation(this.recipe);
|
||||
}
|
||||
|
||||
confirmDeleteRecipe() {
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const recipeId = deleteModal.dataset.recipeId;
|
||||
|
||||
if (!recipeId) {
|
||||
showToast('toast.recipes.cannotDelete', {}, 'error');
|
||||
modalManager.closeModal('deleteModal');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
const originalText = deleteBtn.textContent;
|
||||
deleteBtn.textContent = 'Deleting...';
|
||||
deleteBtn.disabled = true;
|
||||
|
||||
// Call API to delete the recipe
|
||||
fetch(`/api/lm/recipe/${recipeId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete recipe');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.batch_id) {
|
||||
// Staged delete: offer undo instead of the plain success toast
|
||||
const batchId = data.batch_id;
|
||||
showActionToast('toast.undo.deleted', { name: this.recipe.title }, 'success', {
|
||||
actionText: translate('toast.undo.action'),
|
||||
onAction: () => handleUndoDelete(batchId, () => window.recipeManager.loadRecipes(true)),
|
||||
});
|
||||
} else {
|
||||
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
|
||||
}
|
||||
|
||||
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
|
||||
|
||||
modalManager.closeModal('deleteModal');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting recipe:', error);
|
||||
showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
|
||||
|
||||
// Reset button state
|
||||
deleteBtn.textContent = originalText;
|
||||
deleteBtn.disabled = false;
|
||||
});
|
||||
confirmRecipeDelete(this.recipe);
|
||||
}
|
||||
|
||||
shareRecipe() {
|
||||
@@ -507,4 +394,134 @@ class RecipeCard {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the delete confirmation modal for a recipe. Shared by RecipeCard and
|
||||
* RecipeModal so the flow stays identical regardless of where it starts.
|
||||
* @param {Object} recipe - The recipe to delete
|
||||
*/
|
||||
export function showRecipeDeleteConfirmation(recipe) {
|
||||
try {
|
||||
// Get recipe ID
|
||||
const recipeId = recipe.id;
|
||||
const filePath = recipe.file_path;
|
||||
if (!recipeId) {
|
||||
showToast('toast.recipes.cannotDelete', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create delete modal content
|
||||
const previewUrl = recipe.file_url || '/loras_static/images/no-preview.png';
|
||||
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
|
||||
|
||||
const deleteModalContent = `
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2>Delete Recipe</h2>
|
||||
<p class="delete-message">Are you sure you want to delete this recipe?</p>
|
||||
<div class="delete-model-info">
|
||||
<div class="delete-preview">
|
||||
${isVideo ?
|
||||
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
|
||||
`<img src="${previewUrl}" alt="${recipe.title}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||||
}
|
||||
</div>
|
||||
<div class="delete-info">
|
||||
<h3>${recipe.title}</h3>
|
||||
<p>${translate('modals.deleteRecipe.recoverableWarning')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="delete-note">Note: Deleting this recipe will not affect the LoRA files used in it.</p>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" onclick="closeDeleteModal()">Cancel</button>
|
||||
<button class="delete-btn" onclick="confirmDelete()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show the modal with custom content and setup callbacks
|
||||
modalManager.showModal('deleteModal', deleteModalContent, () => {
|
||||
// This is the onClose callback
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
deleteBtn.textContent = 'Delete';
|
||||
deleteBtn.disabled = false;
|
||||
});
|
||||
|
||||
// Set up the delete and cancel buttons with proper event handlers
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const cancelBtn = deleteModal.querySelector('.cancel-btn');
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
|
||||
// Store recipe ID in the modal for the delete confirmation handler
|
||||
deleteModal.dataset.recipeId = recipeId;
|
||||
deleteModal.dataset.filePath = filePath;
|
||||
|
||||
// Update button event handlers
|
||||
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
|
||||
deleteBtn.onclick = () => confirmRecipeDelete(recipe);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error showing delete confirmation:', error);
|
||||
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the recipe deletion after the user confirms in the delete modal.
|
||||
* @param {Object} recipe - The recipe being deleted (used for toast messaging)
|
||||
*/
|
||||
function confirmRecipeDelete(recipe) {
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const recipeId = deleteModal.dataset.recipeId;
|
||||
|
||||
if (!recipeId) {
|
||||
showToast('toast.recipes.cannotDelete', {}, 'error');
|
||||
modalManager.closeModal('deleteModal');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
const originalText = deleteBtn.textContent;
|
||||
deleteBtn.textContent = 'Deleting...';
|
||||
deleteBtn.disabled = true;
|
||||
|
||||
// Call API to delete the recipe
|
||||
fetch(`/api/lm/recipe/${recipeId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete recipe');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.batch_id) {
|
||||
// Staged delete: offer undo instead of the plain success toast
|
||||
const batchId = data.batch_id;
|
||||
showActionToast('toast.undo.deleted', { name: recipe.title }, 'success', {
|
||||
actionText: translate('toast.undo.action'),
|
||||
onAction: () => handleUndoDelete(batchId, () => window.recipeManager.loadRecipes(true)),
|
||||
});
|
||||
} else {
|
||||
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
|
||||
}
|
||||
|
||||
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
|
||||
|
||||
modalManager.closeModal('deleteModal');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting recipe:', error);
|
||||
showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
|
||||
|
||||
// Reset button state
|
||||
deleteBtn.textContent = originalText;
|
||||
deleteBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
export { RecipeCard };
|
||||
|
||||
@@ -8,6 +8,7 @@ import { fetchRecipeDetails, updateRecipeMetadata, sendRecipeWorkflow } from '..
|
||||
import { downloadManager } from '../managers/DownloadManager.js';
|
||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
import { openMediaViewer } from './shared/MediaViewer.js';
|
||||
import { showRecipeDeleteConfirmation } from './RecipeCard.js';
|
||||
import { renderCompactTags, setupTagTooltip } from './shared/utils.js';
|
||||
import { setupTagEditMode } from './shared/ModelTags.js';
|
||||
|
||||
@@ -123,6 +124,7 @@ class RecipeModal {
|
||||
this.setupStripLoraToggle();
|
||||
this.setupPromptEditors();
|
||||
this.setupNavigationControls();
|
||||
this.setupDeleteControl();
|
||||
// Set up tooltip positioning handlers after DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
this.setupTooltipPositioning();
|
||||
@@ -180,6 +182,18 @@ class RecipeModal {
|
||||
this.updateNavigationControls();
|
||||
}
|
||||
|
||||
setupDeleteControl() {
|
||||
const deleteBtn = document.getElementById('deleteRecipeBtn');
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', () => this.handleDeleteRecipe());
|
||||
}
|
||||
}
|
||||
|
||||
handleDeleteRecipe() {
|
||||
if (!this.currentRecipe) return;
|
||||
showRecipeDeleteConfirmation(this.currentRecipe);
|
||||
}
|
||||
|
||||
shouldIgnoreNavigationKey(event) {
|
||||
const target = event.target;
|
||||
if (!target) return false;
|
||||
@@ -230,6 +244,9 @@ class RecipeModal {
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
this.handleDirectionalNavigation('next');
|
||||
} else if (event.key === 'Delete') {
|
||||
event.preventDefault();
|
||||
this.handleDeleteRecipe();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -624,6 +641,10 @@ class RecipeModal {
|
||||
|
||||
actionsContainer.querySelectorAll('.recipe-source-url-btn').forEach(btn => btn.remove());
|
||||
|
||||
// Keep the delete button as the last (rightmost) header action;
|
||||
// insertBefore with null falls back to appendChild if it is missing.
|
||||
const deleteBtn = document.getElementById('deleteRecipeBtn');
|
||||
|
||||
if (this.currentRecipe?.has_workflow === true) {
|
||||
const workflowBtn = document.createElement('button');
|
||||
workflowBtn.className = 'recipe-source-url-btn';
|
||||
@@ -633,7 +654,7 @@ class RecipeModal {
|
||||
workflowBtn.addEventListener('click', () => {
|
||||
this.sendWorkflowToComfyUI();
|
||||
});
|
||||
actionsContainer.appendChild(workflowBtn);
|
||||
actionsContainer.insertBefore(workflowBtn, deleteBtn);
|
||||
}
|
||||
|
||||
const sourcePath = this.currentRecipe?.source_path || '';
|
||||
@@ -646,7 +667,7 @@ class RecipeModal {
|
||||
btn.addEventListener('click', () => {
|
||||
window.open(sourcePath, '_blank');
|
||||
});
|
||||
actionsContainer.appendChild(btn);
|
||||
actionsContainer.insertBefore(btn, deleteBtn);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export class SidebarManager {
|
||||
this.pageControls = null;
|
||||
this.pageType = null;
|
||||
this.treeData = {};
|
||||
this.folderTreeLoaded = false;
|
||||
this.selectedPath = '';
|
||||
this.expandedNodes = new Set();
|
||||
this.apiClient = null;
|
||||
@@ -1171,13 +1172,32 @@ export class SidebarManager {
|
||||
const response = await this.apiClient.fetchModelFolders();
|
||||
this.foldersList = response.folders || [];
|
||||
}
|
||||
this.folderTreeLoaded = true;
|
||||
this.renderFolderDisplay();
|
||||
} catch (error) {
|
||||
this.folderTreeLoaded = false;
|
||||
console.error('Failed to load folder data:', error);
|
||||
this.renderEmptyState();
|
||||
}
|
||||
}
|
||||
|
||||
folderExistsInTree(path) {
|
||||
if (!path) return true;
|
||||
|
||||
if (this.displayMode === 'tree') {
|
||||
let node = this.treeData;
|
||||
for (const segment of path.split('/')) {
|
||||
if (!node || typeof node !== 'object' || !(segment in node)) {
|
||||
return false;
|
||||
}
|
||||
node = node[segment];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.foldersList.includes(path);
|
||||
}
|
||||
|
||||
renderFolderDisplay() {
|
||||
if (this.displayMode === 'tree') {
|
||||
this.renderTree();
|
||||
@@ -1809,7 +1829,31 @@ export class SidebarManager {
|
||||
restoreSelectedFolder() {
|
||||
const activeFolder = getStorageItem(`${this.pageType}_activeFolder`);
|
||||
if (activeFolder && typeof activeFolder === 'string') {
|
||||
this.selectedPath = activeFolder;
|
||||
// Fall back to the root when the persisted folder no longer
|
||||
// exists in the freshly loaded tree (e.g. it was moved or
|
||||
// deleted); otherwise the grid stays empty with a phantom
|
||||
// breadcrumb. Skip validation when the tree failed to load so a
|
||||
// transient API error doesn't wipe the saved location.
|
||||
if (this.folderTreeLoaded && !this.folderExistsInTree(activeFolder)) {
|
||||
console.warn(`Persisted folder "${activeFolder}" not found in folder tree, falling back to root`);
|
||||
this.selectedPath = '';
|
||||
if (this.pageControls?.pageState) {
|
||||
this.pageControls.pageState.activeFolder = '';
|
||||
}
|
||||
setStorageItem(`${this.pageType}_activeFolder`, '');
|
||||
// When the reset happens after initialization (e.g. via
|
||||
// refresh() after a drag move emptied the folder), reload the
|
||||
// listing so the grid shows the root contents instead of
|
||||
// staying empty. Skipped during initialize() — the first load
|
||||
// picks up the cleared filter on its own.
|
||||
if (this.isInitialized && typeof this.pageControls?.resetAndReload === 'function') {
|
||||
this.pageControls.resetAndReload().catch((error) => {
|
||||
console.error('Failed to reload after resetting folder selection:', error);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.selectedPath = activeFolder;
|
||||
}
|
||||
this.updateTreeSelection();
|
||||
this.updateBreadcrumbs();
|
||||
this.updateSidebarHeader();
|
||||
|
||||
@@ -52,7 +52,11 @@ class InitializationManager {
|
||||
detectPageType() {
|
||||
// Get the current page type from URL or data attribute
|
||||
const path = window.location.pathname;
|
||||
if (path.includes('/checkpoints')) {
|
||||
// The recipes page lives at /loras/recipes, so it must be matched
|
||||
// before the generic '/loras' check.
|
||||
if (path.includes('/recipes')) {
|
||||
this.pageType = 'recipes';
|
||||
} else if (path.includes('/checkpoints')) {
|
||||
this.pageType = 'checkpoints';
|
||||
} else if (path.includes('/loras')) {
|
||||
this.pageType = 'loras';
|
||||
@@ -216,7 +220,8 @@ class InitializationManager {
|
||||
const scannerTypeToPageType = {
|
||||
'lora': 'loras',
|
||||
'checkpoint': 'checkpoints',
|
||||
'embedding': 'embeddings'
|
||||
'embedding': 'embeddings',
|
||||
'recipe': 'recipes'
|
||||
};
|
||||
|
||||
if (scannerTypeToPageType[data.scanner_type] !== this.pageType) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { parsePresets, renderPresetTags } from './PresetTags.js';
|
||||
import { initVersionsTab } from './ModelVersionsTab.js';
|
||||
import { loadRecipesForModel } from './RecipeTab.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { showDeleteModal } from '../../utils/modalUtils.js';
|
||||
import { state } from '../../state/index.js';
|
||||
|
||||
function getModalFilePath(fallback = '') {
|
||||
@@ -444,6 +445,17 @@ export async function showModelModal(model, modelType) {
|
||||
if (licenseIcons) {
|
||||
headerActionItems.push(indentMarkup(licenseIcons.trim(), 20));
|
||||
}
|
||||
|
||||
// Destructive action stays last (rightmost). The license icons' auto
|
||||
// margin right-anchors the [license][delete] cluster as one group.
|
||||
const deleteModelTitle = translate('modals.model.actions.deleteModelWithShortcut', {}, 'Delete model (Del)');
|
||||
const deleteModelButton = `
|
||||
<button class="modal-delete-btn" data-action="delete-model" title="${deleteModelTitle}" aria-label="${deleteModelTitle}">
|
||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
`.trim();
|
||||
headerActionItems.push(indentMarkup(deleteModelButton, 20));
|
||||
|
||||
const headerActionsMarkup = headerActionItems.length
|
||||
? [
|
||||
' <div class="modal-header-actions">',
|
||||
@@ -944,6 +956,9 @@ function setupEventHandlers(filePath, modelType) {
|
||||
case 'send-to-workflow':
|
||||
handleSendToWorkflow(target, modelType);
|
||||
break;
|
||||
case 'delete-model':
|
||||
handleDeleteModel();
|
||||
break;
|
||||
case 'copy-hash':
|
||||
if (target.dataset.hash) {
|
||||
copyToClipboard(target.dataset.hash, 'Hash copied to clipboard');
|
||||
@@ -1218,12 +1233,26 @@ function setupNavigationShortcuts(modelType) {
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
handleDirectionalNavigation('next', navigationModelType);
|
||||
} else if (event.key === 'Delete') {
|
||||
event.preventDefault();
|
||||
handleDeleteModel();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', navigationKeyHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the shared delete confirmation for the model currently shown in the
|
||||
* modal. Showing the delete modal replaces this modal (ModalManager only
|
||||
* keeps one modal open), which also unregisters these shortcuts.
|
||||
*/
|
||||
function handleDeleteModel() {
|
||||
const filePath = getModalFilePath();
|
||||
if (!filePath) return;
|
||||
showDeleteModal(filePath);
|
||||
}
|
||||
|
||||
async function handleDirectionalNavigation(direction, modelType) {
|
||||
if (navigationInProgress) return;
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { FilterPresetManager, EMPTY_WILDCARD_MARKER } from './FilterPresetManager.js';
|
||||
|
||||
// LoRA availability statuses available on the recipes page. No statuses
|
||||
// selected (the default) means no filtering.
|
||||
const LORA_AVAILABILITY_STATUSES = ['ready', 'missing', 'deleted'];
|
||||
|
||||
export class FilterManager {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
@@ -74,6 +78,11 @@ export class FilterManager {
|
||||
this.initializeLicenseFilters();
|
||||
}
|
||||
|
||||
// Add click handlers for LoRA availability tags (recipes page only)
|
||||
if (this.shouldShowLoraAvailabilityFilter()) {
|
||||
this.initializeLoraAvailabilityFilters();
|
||||
}
|
||||
|
||||
// Initialize tag logic toggle
|
||||
this.initializeTagLogicToggle();
|
||||
|
||||
@@ -421,6 +430,42 @@ export class FilterManager {
|
||||
});
|
||||
}
|
||||
|
||||
initializeLoraAvailabilityFilters() {
|
||||
const availabilityTags = document.querySelectorAll('.lora-availability-tag');
|
||||
availabilityTags.forEach(tag => {
|
||||
tag.addEventListener('click', async () => {
|
||||
const status = tag.dataset.availability;
|
||||
const selected = this.filters.loraAvailability || [];
|
||||
|
||||
if (selected.includes(status)) {
|
||||
this.filters.loraAvailability = selected.filter(value => value !== status);
|
||||
tag.classList.remove('active');
|
||||
} else {
|
||||
this.filters.loraAvailability = [...selected, status];
|
||||
tag.classList.add('active');
|
||||
}
|
||||
|
||||
this.updateActiveFiltersCount();
|
||||
await this.applyFilters(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Update selections based on stored filters
|
||||
this.updateLoraAvailabilitySelections();
|
||||
}
|
||||
|
||||
updateLoraAvailabilitySelections() {
|
||||
const availabilityTags = document.querySelectorAll('.lora-availability-tag');
|
||||
const selected = this.filters.loraAvailability || [];
|
||||
availabilityTags.forEach(tag => {
|
||||
if (selected.includes(tag.dataset.availability)) {
|
||||
tag.classList.add('active');
|
||||
} else {
|
||||
tag.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createBaseModelTags() {
|
||||
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
||||
if (!baseModelTagsContainer) return;
|
||||
@@ -681,6 +726,11 @@ export class FilterManager {
|
||||
}
|
||||
this.updateModelTypeSelections();
|
||||
|
||||
// Update LoRA availability tags if visible on this page
|
||||
if (this.shouldShowLoraAvailabilityFilter()) {
|
||||
this.updateLoraAvailabilitySelections();
|
||||
}
|
||||
|
||||
const autoTagEls = document.querySelectorAll('.auto-tag-filter');
|
||||
autoTagEls.forEach(el => {
|
||||
const tag = el.dataset.autoTag;
|
||||
@@ -708,7 +758,9 @@ export class FilterManager {
|
||||
const modelTypeFilterCount = this.filters.modelTypes.length;
|
||||
// Exclude EMPTY_WILDCARD_MARKER from base model count
|
||||
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
|
||||
const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount;
|
||||
// Active when at least one availability status is deselected
|
||||
const loraAvailabilityCount = this.filters.loraAvailability?.length ?? 0;
|
||||
const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount + loraAvailabilityCount;
|
||||
|
||||
if (this.activeFiltersCount) {
|
||||
if (totalActiveFilters > 0) {
|
||||
@@ -805,6 +857,7 @@ export class FilterManager {
|
||||
autoTags: {},
|
||||
license: {},
|
||||
modelTypes: [],
|
||||
loraAvailability: [],
|
||||
tagLogic: 'any'
|
||||
});
|
||||
|
||||
@@ -891,12 +944,14 @@ export class FilterManager {
|
||||
const modelTypeCount = this.filters.modelTypes.length;
|
||||
// Exclude EMPTY_WILDCARD_MARKER from base model count
|
||||
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
|
||||
const loraAvailabilityCount = this.filters.loraAvailability?.length ?? 0;
|
||||
return (
|
||||
baseModelCount > 0 ||
|
||||
tagCount > 0 ||
|
||||
autoTagCount > 0 ||
|
||||
licenseCount > 0 ||
|
||||
modelTypeCount > 0
|
||||
modelTypeCount > 0 ||
|
||||
loraAvailabilityCount > 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -909,6 +964,7 @@ export class FilterManager {
|
||||
autoTags: this.normalizeTagFilters(source.autoTags),
|
||||
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
|
||||
modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
|
||||
loraAvailability: this.normalizeLoraAvailabilityFilters(source.loraAvailability),
|
||||
tagLogic: source.tagLogic || 'any'
|
||||
};
|
||||
}
|
||||
@@ -917,6 +973,33 @@ export class FilterManager {
|
||||
return this.currentPage !== 'recipes';
|
||||
}
|
||||
|
||||
shouldShowLoraAvailabilityFilter() {
|
||||
return this.currentPage === 'recipes';
|
||||
}
|
||||
|
||||
normalizeLoraAvailabilityFilters(loraAvailability) {
|
||||
// Default to no statuses selected (= no filtering)
|
||||
if (!Array.isArray(loraAvailability)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
return loraAvailability.reduce((acc, status) => {
|
||||
if (typeof status !== 'string') {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const normalized = status.trim().toLowerCase();
|
||||
if (!LORA_AVAILABILITY_STATUSES.includes(normalized) || seen.has(normalized)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
seen.add(normalized);
|
||||
acc.push(normalized);
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
normalizeTagFilters(tagFilters) {
|
||||
if (!tagFilters) {
|
||||
return {};
|
||||
@@ -994,6 +1077,7 @@ export class FilterManager {
|
||||
autoTags: { ...(this.filters.autoTags || {}) },
|
||||
license: { ...(this.filters.license || {}) },
|
||||
modelTypes: [...(this.filters.modelTypes || [])],
|
||||
loraAvailability: [...(this.filters.loraAvailability || [])],
|
||||
tagLogic: this.filters.tagLogic || 'any',
|
||||
search: pageState?.filters?.search ?? ''
|
||||
};
|
||||
|
||||
@@ -148,6 +148,7 @@ export const state = {
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: [],
|
||||
loraAvailability: [],
|
||||
search: ''
|
||||
},
|
||||
pageSize: 20,
|
||||
|
||||
@@ -264,6 +264,22 @@
|
||||
{{ t('header.filter.noTagMatches') }}
|
||||
</div>
|
||||
</div>
|
||||
{% if current_page == 'recipes' %}
|
||||
<div class="filter-section">
|
||||
<h4>{{ t('header.filter.loraAvailability') }}</h4>
|
||||
<div class="filter-tags" id="loraAvailabilityTags">
|
||||
<div class="filter-tag lora-availability-tag" data-availability="ready">
|
||||
{{ t('header.filter.availabilityReady') }}
|
||||
</div>
|
||||
<div class="filter-tag lora-availability-tag" data-availability="missing">
|
||||
{{ t('header.filter.availabilityMissing') }}
|
||||
</div>
|
||||
<div class="filter-tag lora-availability-tag" data-availability="deleted">
|
||||
{{ t('header.filter.availabilityDeleted') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if current_page == 'loras' or current_page == 'checkpoints' %}
|
||||
<div class="filter-section">
|
||||
<h4>{{ t('header.filter.modelTypes') }}</h4>
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i> <span>{{ t('recipes.actions.sendRecipe') }}</span>
|
||||
</button>
|
||||
<button class="modal-delete-btn" id="deleteRecipeBtn" title="{{ t('recipes.actions.deleteRecipeWithShortcut') }}" aria-label="{{ t('recipes.actions.deleteRecipeWithShortcut') }}">
|
||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Recipe Tags Container (rendered by renderCompactTags) -->
|
||||
<div id="recipeTagsContainer"></div>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const getCurrentPageStateMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
|
||||
RecipeCard: vi.fn(() => ({ element: document.createElement('div') })),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
state: {
|
||||
loadingManager: {
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
},
|
||||
},
|
||||
getCurrentPageState: getCurrentPageStateMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||
captureScrollPosition: vi.fn(),
|
||||
restoreScrollPosition: vi.fn(),
|
||||
recreateVirtualScroll: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchRecipesPage } from '../../../static/js/api/recipeApi.js';
|
||||
|
||||
function makePageState(loraAvailability) {
|
||||
return {
|
||||
pageSize: 50,
|
||||
currentPage: 1,
|
||||
hasMore: true,
|
||||
isLoading: false,
|
||||
sortBy: 'date:desc',
|
||||
showFavoritesOnly: false,
|
||||
activeFolder: null,
|
||||
searchOptions: { recursive: true },
|
||||
customFilter: { active: false },
|
||||
filters: { loraAvailability },
|
||||
};
|
||||
}
|
||||
|
||||
describe('fetchRecipesPage lora_availability param', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ items: [], total: 0, total_pages: 0 }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
it('appends lora_availability when a subset of statuses is selected', async () => {
|
||||
getCurrentPageStateMock.mockReturnValue(makePageState(['missing', 'deleted']));
|
||||
|
||||
await fetchRecipesPage(1, 50);
|
||||
|
||||
const url = global.fetch.mock.calls[0][0];
|
||||
const params = new URL(url, 'http://localhost').searchParams;
|
||||
expect(params.get('lora_availability')).toBe('missing,deleted');
|
||||
});
|
||||
|
||||
it('appends lora_availability when all statuses are selected (backend treats it as show-all)', async () => {
|
||||
getCurrentPageStateMock.mockReturnValue(
|
||||
makePageState(['ready', 'missing', 'deleted'])
|
||||
);
|
||||
|
||||
await fetchRecipesPage(1, 50);
|
||||
|
||||
const url = global.fetch.mock.calls[0][0];
|
||||
const params = new URL(url, 'http://localhost').searchParams;
|
||||
expect(params.get('lora_availability')).toBe('ready,missing,deleted');
|
||||
});
|
||||
|
||||
it('omits lora_availability when no statuses are selected', async () => {
|
||||
getCurrentPageStateMock.mockReturnValue(makePageState([]));
|
||||
|
||||
await fetchRecipesPage(1, 50);
|
||||
|
||||
const url = global.fetch.mock.calls[0][0];
|
||||
const params = new URL(url, 'http://localhost').searchParams;
|
||||
expect(params.get('lora_availability')).toBeNull();
|
||||
});
|
||||
|
||||
it('omits lora_availability when the filter is absent', async () => {
|
||||
getCurrentPageStateMock.mockReturnValue(makePageState(undefined));
|
||||
|
||||
await fetchRecipesPage(1, 50);
|
||||
|
||||
const url = global.fetch.mock.calls[0][0];
|
||||
const params = new URL(url, 'http://localhost').searchParams;
|
||||
expect(params.get('lora_availability')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2032,4 +2032,122 @@ describe('AutoComplete widget interactions', () => {
|
||||
expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle');
|
||||
expect(calledUrl).toContain('recursive=true');
|
||||
});
|
||||
|
||||
describe('discoverability hints', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const typeSlashCommand = async () => {
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/';
|
||||
input.selectionStart = 1;
|
||||
document.body.append(input);
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', { showPreview: false, minChars: 1 });
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return autoComplete;
|
||||
};
|
||||
|
||||
it('shows the current autocomplete state below the slash command list', async () => {
|
||||
const autoComplete = await typeSlashCommand();
|
||||
|
||||
const footer = autoComplete.dropdown.querySelector('.lm-autocomplete-command-footer');
|
||||
expect(footer).not.toBeNull();
|
||||
expect(footer.textContent).toContain('/noautocomplete to disable');
|
||||
});
|
||||
|
||||
it('shows how to re-enable autocomplete in the footer when it is off', async () => {
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const autoComplete = await typeSlashCommand();
|
||||
|
||||
const footer = autoComplete.dropdown.querySelector('.lm-autocomplete-command-footer');
|
||||
expect(footer).not.toBeNull();
|
||||
expect(footer.textContent).toContain('/autocomplete to enable');
|
||||
});
|
||||
|
||||
it('stays silent when typing with tag autocomplete disabled', async () => {
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||
return false;
|
||||
}
|
||||
if (key === 'loramanager.autocomplete_accept_key') {
|
||||
return 'both';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'hello';
|
||||
input.selectionStart = 5;
|
||||
document.body.append(input);
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('hello');
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', { showPreview: false, minChars: 1 });
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
expect(autoComplete.isVisible).toBe(false);
|
||||
expect(fetchApiMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a dismissible first-run hint on tag suggestions and remembers dismissal', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
words: [{ tag_name: '1girl', category: 4, post_count: 500000 }],
|
||||
}),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('1gi');
|
||||
|
||||
const triggerSearch = async () => {
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '1gi';
|
||||
input.selectionStart = 3;
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
return autoComplete;
|
||||
};
|
||||
|
||||
const autoComplete = await triggerSearch();
|
||||
|
||||
const hint = autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint');
|
||||
expect(hint).not.toBeNull();
|
||||
expect(hint.textContent).toContain('/noautocomplete');
|
||||
|
||||
hint.querySelector('button').click();
|
||||
|
||||
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
|
||||
expect(localStorage.getItem('lm:autocomplete-disable-tip-dismissed')).toBe('1');
|
||||
|
||||
// A fresh instance no longer shows the hint once dismissed
|
||||
const autoComplete2 = await triggerSearch();
|
||||
expect(autoComplete2.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
SIDEBAR_MANAGER_MODULE,
|
||||
STORAGE_HELPERS_MODULE,
|
||||
MODEL_API_FACTORY_MODULE,
|
||||
I18N_MODULE,
|
||||
BULK_MANAGER_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
UPDATE_CHECK_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
|
||||
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||
MODEL_API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
UPDATE_CHECK_MODULE: new URL('../../../static/js/utils/updateCheckHelpers.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(MODEL_API_FACTORY_MODULE, () => ({ getModelApiClient: vi.fn() }));
|
||||
vi.mock(I18N_MODULE, () => ({ translate: (key, _args, fallback) => fallback || key }));
|
||||
vi.mock(BULK_MANAGER_MODULE, () => ({ bulkManager: {} }));
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn() }));
|
||||
vi.mock(UPDATE_CHECK_MODULE, () => ({ performFolderUpdateCheck: vi.fn() }));
|
||||
|
||||
const { SidebarManager } = await import(SIDEBAR_MANAGER_MODULE);
|
||||
const { setStorageItem, getStorageItem } = await import(STORAGE_HELPERS_MODULE);
|
||||
|
||||
function createManager({
|
||||
treeData = {},
|
||||
foldersList = [],
|
||||
displayMode = 'tree',
|
||||
folderTreeLoaded = true,
|
||||
isInitialized = true,
|
||||
persistedFolder = 'Civitai/_Missing',
|
||||
} = {}) {
|
||||
const manager = new SidebarManager();
|
||||
manager.pageType = 'recipes';
|
||||
manager.displayMode = displayMode;
|
||||
manager.treeData = treeData;
|
||||
manager.foldersList = foldersList;
|
||||
manager.folderTreeLoaded = folderTreeLoaded;
|
||||
manager.isInitialized = isInitialized;
|
||||
manager.updateTreeSelection = vi.fn();
|
||||
manager.updateBreadcrumbs = vi.fn();
|
||||
manager.updateSidebarHeader = vi.fn();
|
||||
|
||||
const resetAndReload = vi.fn().mockResolvedValue(undefined);
|
||||
manager.pageControls = {
|
||||
pageState: { activeFolder: persistedFolder },
|
||||
resetAndReload,
|
||||
};
|
||||
|
||||
if (persistedFolder !== null) {
|
||||
setStorageItem('recipes_activeFolder', persistedFolder);
|
||||
}
|
||||
|
||||
return { manager, resetAndReload };
|
||||
}
|
||||
|
||||
describe('SidebarManager.restoreSelectedFolder', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('falls back to root and reloads when the persisted folder is missing', () => {
|
||||
const { manager, resetAndReload } = createManager({
|
||||
treeData: { Civitai: {} },
|
||||
});
|
||||
|
||||
manager.restoreSelectedFolder();
|
||||
|
||||
expect(manager.selectedPath).toBe('');
|
||||
expect(manager.pageControls.pageState.activeFolder).toBe('');
|
||||
expect(getStorageItem('recipes_activeFolder')).toBe('');
|
||||
expect(resetAndReload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the persisted folder when it exists in the tree', () => {
|
||||
const { manager, resetAndReload } = createManager({
|
||||
treeData: { Civitai: { _Missing: {} } },
|
||||
});
|
||||
|
||||
manager.restoreSelectedFolder();
|
||||
|
||||
expect(manager.selectedPath).toBe('Civitai/_Missing');
|
||||
expect(manager.pageControls.pageState.activeFolder).toBe('Civitai/_Missing');
|
||||
expect(resetAndReload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets without reloading during initial initialization', () => {
|
||||
const { manager, resetAndReload } = createManager({
|
||||
treeData: { Civitai: {} },
|
||||
isInitialized: false,
|
||||
});
|
||||
|
||||
manager.restoreSelectedFolder();
|
||||
|
||||
expect(manager.selectedPath).toBe('');
|
||||
expect(resetAndReload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the persisted folder when the tree failed to load', () => {
|
||||
const { manager, resetAndReload } = createManager({
|
||||
treeData: {},
|
||||
folderTreeLoaded: false,
|
||||
});
|
||||
|
||||
manager.restoreSelectedFolder();
|
||||
|
||||
expect(manager.selectedPath).toBe('Civitai/_Missing');
|
||||
expect(resetAndReload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates against the folder list in list display mode', () => {
|
||||
const { manager, resetAndReload } = createManager({
|
||||
displayMode: 'list',
|
||||
foldersList: ['Civitai'],
|
||||
});
|
||||
|
||||
manager.restoreSelectedFolder();
|
||||
|
||||
expect(manager.selectedPath).toBe('');
|
||||
expect(resetAndReload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
getCurrentPageState: vi.fn(() => ({
|
||||
filters: {},
|
||||
})),
|
||||
state: {
|
||||
currentPageType: 'recipes',
|
||||
loadingManager: {
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
updatePanelPositions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
getModelApiClient: vi.fn(() => ({
|
||||
loadMoreWithVirtualScroll: vi.fn().mockResolvedValue(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||
getStorageItem: vi.fn(),
|
||||
setStorageItem: vi.fn(),
|
||||
removeStorageItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: vi.fn((key, _params, fallback) => fallback || key),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/managers/FilterPresetManager.js', () => ({
|
||||
FilterPresetManager: vi.fn().mockImplementation(() => ({
|
||||
renderPresets: vi.fn(),
|
||||
saveActivePreset: vi.fn(),
|
||||
restoreActivePreset: vi.fn(),
|
||||
updateAddButtonState: vi.fn(),
|
||||
hasEmptyWildcardResult: vi.fn(() => false),
|
||||
})),
|
||||
EMPTY_WILDCARD_MARKER: '__EMPTY_WILDCARD_RESULT__',
|
||||
}));
|
||||
|
||||
import { FilterManager } from '../../../static/js/managers/FilterManager.js';
|
||||
import { getStorageItem } from '../../../static/js/utils/storageHelpers.js';
|
||||
|
||||
const ALL_STATUSES = ['ready', 'missing', 'deleted'];
|
||||
|
||||
describe('FilterManager - LoRA Availability', () => {
|
||||
let manager;
|
||||
let mockFilterPanel;
|
||||
let mockActiveFiltersCount;
|
||||
|
||||
function createAvailabilityTags() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'loraAvailabilityTags';
|
||||
ALL_STATUSES.forEach(status => {
|
||||
const tag = document.createElement('div');
|
||||
tag.className = 'filter-tag lora-availability-tag';
|
||||
tag.dataset.availability = status;
|
||||
container.appendChild(tag);
|
||||
});
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getStorageItem.mockReturnValue(undefined);
|
||||
document.body.innerHTML = '';
|
||||
|
||||
mockFilterPanel = document.createElement('div');
|
||||
mockFilterPanel.id = 'filterPanel';
|
||||
mockFilterPanel.classList.add('hidden');
|
||||
document.body.appendChild(mockFilterPanel);
|
||||
|
||||
mockActiveFiltersCount = document.createElement('span');
|
||||
createAvailabilityTags();
|
||||
|
||||
const originalGetElementById = document.getElementById;
|
||||
document.getElementById = vi.fn((id) => {
|
||||
if (id === 'filterPanel') return mockFilterPanel;
|
||||
if (id === 'filterButton') return document.createElement('button');
|
||||
if (id === 'activeFiltersCount') return mockActiveFiltersCount;
|
||||
if (id === 'baseModelTags') return document.createElement('div');
|
||||
if (id === 'modelTypeTags') return document.createElement('div');
|
||||
return originalGetElementById.call(document, id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeFilters', () => {
|
||||
it('should default to no statuses selected on the recipes page', () => {
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
expect(manager.filters.loraAvailability).toEqual([]);
|
||||
});
|
||||
|
||||
it('should restore a saved selection from storage', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
loraAvailability: ['missing'],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
expect(manager.filters.loraAvailability).toEqual(['missing']);
|
||||
});
|
||||
|
||||
it('should drop invalid stored values', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
loraAvailability: ['missing', 'bogus', 'missing'],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
expect(manager.filters.loraAvailability).toEqual(['missing']);
|
||||
});
|
||||
|
||||
it('should default to no statuses when the stored value is not an array', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
loraAvailability: 'missing',
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
expect(manager.filters.loraAvailability).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasActiveFilters', () => {
|
||||
it('should be inactive when no statuses are selected', () => {
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
expect(manager.hasActiveFilters()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be active when at least one status is selected', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
loraAvailability: ['ready'],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
expect(manager.hasActiveFilters()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateActiveFiltersCount', () => {
|
||||
it('should count selected statuses', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
loraAvailability: ['missing', 'deleted'],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
expect(mockActiveFiltersCount.textContent).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chip interaction', () => {
|
||||
it('should select a status when its chip is clicked', async () => {
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
const readyTag = document.querySelector('[data-availability="ready"]');
|
||||
expect(readyTag.classList.contains('active')).toBe(false);
|
||||
|
||||
readyTag.click();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(manager.filters.loraAvailability).toEqual(['ready']);
|
||||
expect(readyTag.classList.contains('active')).toBe(true);
|
||||
});
|
||||
|
||||
it('should deselect a selected status when its chip is clicked again', async () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
loraAvailability: ['ready'],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
const readyTag = document.querySelector('[data-availability="ready"]');
|
||||
// Restored state should mark the chip active
|
||||
expect(readyTag.classList.contains('active')).toBe(true);
|
||||
|
||||
readyTag.click();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(manager.filters.loraAvailability).toEqual([]);
|
||||
expect(readyTag.classList.contains('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('should mark all chips active when a stored all-statuses array is restored', () => {
|
||||
// Legacy stored value: all statuses selected. Under positive
|
||||
// selection semantics the backend treats this as show-all.
|
||||
getStorageItem.mockReturnValue({
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
loraAvailability: [...ALL_STATUSES],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
expect(manager.filters.loraAvailability).toEqual(ALL_STATUSES);
|
||||
document.querySelectorAll('.lora-availability-tag').forEach(tag => {
|
||||
expect(tag.classList.contains('active')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloneFilters', () => {
|
||||
it('should include loraAvailability in cloned filters', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
loraAvailability: ['deleted'],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
const cloned = manager.cloneFilters();
|
||||
|
||||
expect(cloned.loraAvailability).toEqual(['deleted']);
|
||||
});
|
||||
|
||||
it('should clone an empty selection as an empty array', () => {
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
const cloned = manager.cloneFilters();
|
||||
|
||||
expect(cloned.loraAvailability).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearFilters', () => {
|
||||
it('should reset loraAvailability to no statuses selected', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
loraAvailability: ['deleted'],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
expect(manager.filters.loraAvailability).toEqual(['deleted']);
|
||||
|
||||
manager.clearFilters();
|
||||
|
||||
expect(manager.filters.loraAvailability).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ from py.routes.handlers.misc_handlers import (
|
||||
BackupHandler,
|
||||
DoctorHandler,
|
||||
FileSystemHandler,
|
||||
HealthCheckHandler,
|
||||
LoraCodeHandler,
|
||||
ModelLibraryHandler,
|
||||
NodeRegistry,
|
||||
@@ -2010,3 +2011,54 @@ async def test_resolve_filename_conflicts_handles_scanner_error_gracefully():
|
||||
|
||||
assert payload["success"] is True
|
||||
assert payload["count"] == 0
|
||||
|
||||
|
||||
async def test_get_init_status_reports_complete_when_all_scanners_ready():
|
||||
async def ready_scanner():
|
||||
return SimpleNamespace(_cache=object(), is_initializing=lambda: False)
|
||||
|
||||
handler = HealthCheckHandler(
|
||||
scanner_getters={
|
||||
"lora": ready_scanner,
|
||||
"recipe": ready_scanner,
|
||||
}
|
||||
)
|
||||
|
||||
response = await handler.get_init_status(FakeRequest(method="GET")) # pyright: ignore[reportArgumentType]
|
||||
payload = _json_payload(response)
|
||||
|
||||
assert payload["status"] == "complete"
|
||||
assert payload["progress"] == 100
|
||||
assert "pageType" not in payload
|
||||
|
||||
|
||||
async def test_get_init_status_reports_pending_scanners():
|
||||
async def ready_scanner():
|
||||
return SimpleNamespace(_cache=object(), is_initializing=lambda: False)
|
||||
|
||||
async def initializing_scanner():
|
||||
return SimpleNamespace(_cache=object(), is_initializing=lambda: True)
|
||||
|
||||
async def no_cache_scanner():
|
||||
return SimpleNamespace(_cache=None, is_initializing=lambda: False)
|
||||
|
||||
async def failing_scanner():
|
||||
raise RuntimeError("scanner unavailable")
|
||||
|
||||
handler = HealthCheckHandler(
|
||||
scanner_getters={
|
||||
"lora": ready_scanner,
|
||||
"checkpoint": initializing_scanner,
|
||||
"embedding": no_cache_scanner,
|
||||
"recipe": failing_scanner,
|
||||
}
|
||||
)
|
||||
|
||||
response = await handler.get_init_status(FakeRequest(method="GET")) # pyright: ignore[reportArgumentType]
|
||||
payload = _json_payload(response)
|
||||
|
||||
assert payload["status"] == "initializing"
|
||||
assert "checkpoint" in payload["details"]
|
||||
assert "embedding" in payload["details"]
|
||||
assert "recipe" in payload["details"]
|
||||
assert "lora" not in payload["details"]
|
||||
|
||||
@@ -632,6 +632,43 @@ async def test_list_recipes_passes_checkpoint_hash_filter(
|
||||
assert harness.scanner.last_paginated_params["checkpoint_hash"] == "ckpt123"
|
||||
|
||||
|
||||
async def test_list_recipes_passes_lora_availability_filter(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.get(
|
||||
"/api/lm/recipes?lora_availability=missing,deleted"
|
||||
)
|
||||
payload = await response.json()
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["items"] == []
|
||||
assert harness.scanner.last_paginated_params is not None
|
||||
filters = harness.scanner.last_paginated_params["filters"]
|
||||
assert filters["lora_availability"] == {"missing", "deleted"}
|
||||
|
||||
|
||||
async def test_list_recipes_ignores_invalid_lora_availability_values(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
# Valid values are kept, invalid ones dropped
|
||||
response = await harness.client.get(
|
||||
"/api/lm/recipes?lora_availability=bogus,ready"
|
||||
)
|
||||
assert response.status == 200
|
||||
assert harness.scanner.last_paginated_params is not None
|
||||
filters = harness.scanner.last_paginated_params["filters"]
|
||||
assert filters["lora_availability"] == {"ready"}
|
||||
|
||||
# No valid values at all -> no availability filter
|
||||
response = await harness.client.get("/api/lm/recipes?lora_availability=bogus")
|
||||
assert response.status == 200
|
||||
assert harness.scanner.last_paginated_params is not None
|
||||
filters = harness.scanner.last_paginated_params["filters"]
|
||||
assert "lora_availability" not in filters
|
||||
|
||||
|
||||
async def test_get_recipes_for_checkpoint(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
harness.scanner.checkpoint_lookup["abc123"] = [
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Tests for the LoRA availability filter on the recipe listing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from py.config import config
|
||||
from py.services.recipe_scanner import RecipeScanner
|
||||
|
||||
|
||||
class StubLoraScanner:
|
||||
"""In-memory lora scanner double exposing the hash/version indexes."""
|
||||
|
||||
def __init__(self, hashes=(), version_index=None):
|
||||
self._hashes = {value.lower() for value in hashes}
|
||||
self._cache = SimpleNamespace(raw_data=[], version_index=version_index or {})
|
||||
|
||||
def has_hash(self, hash_value):
|
||||
return hash_value.lower() in self._hashes
|
||||
|
||||
def get_preview_url_by_hash(self, hash_value):
|
||||
return None
|
||||
|
||||
def get_path_by_hash(self, hash_value):
|
||||
return None
|
||||
|
||||
async def get_cached_data(self):
|
||||
return self._cache
|
||||
|
||||
async def refresh_cache(self, force=False):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recipe_scanner(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
|
||||
lora_scanner = StubLoraScanner(
|
||||
hashes={"aaa111"},
|
||||
version_index={42: {"file_path": "/loras/from-version.safetensors"}},
|
||||
)
|
||||
scanner = RecipeScanner(lora_scanner=lora_scanner) # pyright: ignore[reportArgumentType]
|
||||
|
||||
recipes = [
|
||||
# All loras in library (hash matching is case-insensitive) -> ready
|
||||
{"id": "r1", "title": "Ready", "loras": [{"hash": "AAA111"}]},
|
||||
# One lora not in library -> missing
|
||||
{
|
||||
"id": "r2",
|
||||
"title": "Missing",
|
||||
"loras": [{"hash": "aaa111"}, {"hash": "bbb222"}],
|
||||
},
|
||||
# Lora deleted on Civitai and not in library -> deleted
|
||||
{
|
||||
"id": "r3",
|
||||
"title": "Deleted",
|
||||
"loras": [{"hash": "ccc333", "isDeleted": True}],
|
||||
},
|
||||
# Missing + deleted -> both statuses
|
||||
{
|
||||
"id": "r4",
|
||||
"title": "Mixed",
|
||||
"loras": [{"hash": "bbb222"}, {"hash": "ccc333", "isDeleted": True}],
|
||||
},
|
||||
# No loras at all -> ready
|
||||
{"id": "r5", "title": "Empty", "loras": []},
|
||||
# Excluded lora is ignored -> ready
|
||||
{
|
||||
"id": "r6",
|
||||
"title": "Excluded",
|
||||
"loras": [{"hash": "bbb222", "exclude": True}],
|
||||
},
|
||||
# modelVersionId resolves via the version index -> ready
|
||||
{"id": "r7", "title": "VersionFallback", "loras": [{"modelVersionId": 42}]},
|
||||
]
|
||||
|
||||
scanner._cache = SimpleNamespace(
|
||||
raw_data=recipes,
|
||||
sorted_by_date=recipes,
|
||||
sorted_by_name=recipes,
|
||||
)
|
||||
return scanner
|
||||
|
||||
|
||||
async def _fetch_ids(scanner, filters=None):
|
||||
result = await scanner.get_paginated_data(page=1, page_size=50, filters=filters)
|
||||
return {item["id"] for item in result["items"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_availability_filter_ready_only(recipe_scanner):
|
||||
ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"ready"}})
|
||||
assert ids == {"r1", "r5", "r6", "r7"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_availability_filter_missing_only(recipe_scanner):
|
||||
ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"missing"}})
|
||||
assert ids == {"r2", "r4"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_availability_filter_deleted_only(recipe_scanner):
|
||||
ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"deleted"}})
|
||||
assert ids == {"r3", "r4"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_availability_filter_missing_and_deleted(recipe_scanner):
|
||||
ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"missing", "deleted"}})
|
||||
assert ids == {"r2", "r3", "r4"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_availability_filter_all_statuses_disables_filtering(recipe_scanner):
|
||||
ids = await _fetch_ids(
|
||||
recipe_scanner, {"lora_availability": {"ready", "missing", "deleted"}}
|
||||
)
|
||||
assert ids == {"r1", "r2", "r3", "r4", "r5", "r6", "r7"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_availability_filter_absent_or_invalid_disables_filtering(recipe_scanner):
|
||||
assert await _fetch_ids(recipe_scanner) == {"r1", "r2", "r3", "r4", "r5", "r6", "r7"}
|
||||
ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"bogus"}})
|
||||
assert ids == {"r1", "r2", "r3", "r4", "r5", "r6", "r7"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_availability_filter_counts_and_pagination(recipe_scanner):
|
||||
# Filtering happens before pagination, so totals reflect the filtered set.
|
||||
result = await recipe_scanner.get_paginated_data(
|
||||
page=1, page_size=1, filters={"lora_availability": {"ready"}}
|
||||
)
|
||||
assert result["total"] == 4
|
||||
assert result["total_pages"] == 4
|
||||
assert len(result["items"]) == 1
|
||||
+148
-20
@@ -17,9 +17,13 @@ import {
|
||||
getLoraActiveFiltersAutocompletePreference,
|
||||
getPromptTagAutocompletePreference,
|
||||
getTagSpaceReplacementPreference,
|
||||
setLoraManagerSettingValue,
|
||||
} from "./settings.js";
|
||||
import { showToast } from "./utils.js";
|
||||
|
||||
// localStorage key for the one-time "how to disable" hint in the dropdown
|
||||
const FIRST_RUN_HINT_DISMISSED_KEY = 'lm:autocomplete-disable-tip-dismissed';
|
||||
|
||||
// Command definitions for category filtering
|
||||
const TAG_COMMANDS = {
|
||||
'/character': { categories: [4, 11], label: 'Character' },
|
||||
@@ -37,14 +41,14 @@ const TAG_COMMANDS = {
|
||||
type: 'toggle_setting',
|
||||
settingId: 'loramanager.prompt_tag_autocomplete',
|
||||
value: true,
|
||||
label: 'Autocomplete: ON',
|
||||
label: 'Turn autocomplete ON',
|
||||
condition: () => !getPromptTagAutocompletePreference()
|
||||
},
|
||||
'/noautocomplete': {
|
||||
type: 'toggle_setting',
|
||||
settingId: 'loramanager.prompt_tag_autocomplete',
|
||||
value: false,
|
||||
label: 'Autocomplete: OFF',
|
||||
label: 'Turn autocomplete OFF',
|
||||
condition: () => getPromptTagAutocompletePreference()
|
||||
},
|
||||
};
|
||||
@@ -55,7 +59,7 @@ const LORAS_COMMANDS = {
|
||||
type: 'toggle_setting',
|
||||
settingId: 'loramanager.lora_active_filters_autocomplete',
|
||||
value: true,
|
||||
label: 'Active Filters: ON',
|
||||
label: 'Turn active filters search ON',
|
||||
feedbackSummary: 'Active Filters Search: ON',
|
||||
feedbackDetail: 'LoRA autocomplete now searches within the active filters of the LoRA Manager page.',
|
||||
condition: () => !getLoraActiveFiltersAutocompletePreference()
|
||||
@@ -64,7 +68,7 @@ const LORAS_COMMANDS = {
|
||||
type: 'toggle_setting',
|
||||
settingId: 'loramanager.lora_active_filters_autocomplete',
|
||||
value: false,
|
||||
label: 'Active Filters: OFF',
|
||||
label: 'Turn active filters search OFF',
|
||||
feedbackSummary: 'Active Filters Search: OFF',
|
||||
feedbackDetail: 'LoRA autocomplete searches the full library again.',
|
||||
condition: () => getLoraActiveFiltersAutocompletePreference()
|
||||
@@ -72,8 +76,7 @@ const LORAS_COMMANDS = {
|
||||
};
|
||||
|
||||
// Category display information
|
||||
const CATEGORY_INFO = {
|
||||
0: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' },
|
||||
const CATEGORY_INFO = { 0: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' },
|
||||
1: { bg: 'rgba(255, 138, 139, 0.2)', text: '#ffc3c3', label: 'Artist' },
|
||||
3: { bg: 'rgba(199, 151, 255, 0.2)', text: '#ddc9fb', label: 'Copyright' },
|
||||
4: { bg: 'rgba(53, 198, 74, 0.2)', text: '#93e49a', label: 'Character' },
|
||||
@@ -471,6 +474,10 @@ class AutoComplete {
|
||||
this.searchType = null;
|
||||
this.suppressAutocompleteOnce = false;
|
||||
|
||||
// Discoverability hints state
|
||||
this.commandListFooter = null; // State hint shown below the slash command list
|
||||
this.firstRunHint = null; // One-time "how to disable" bar inside the dropdown
|
||||
|
||||
// Virtual scrolling state
|
||||
this.virtualScrollOffset = 0;
|
||||
this.hasMoreItems = true;
|
||||
@@ -829,7 +836,9 @@ class AutoComplete {
|
||||
searchTerm = rawSearchTerm;
|
||||
this.searchType = 'custom_words';
|
||||
} else {
|
||||
// No command and setting disabled - no autocomplete for direct typing
|
||||
// No command and setting disabled - no autocomplete for direct typing.
|
||||
// Re-enable discovery is covered by the command-list footer,
|
||||
// the node context menu and the settings tooltip.
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
@@ -1707,13 +1716,132 @@ class AutoComplete {
|
||||
this.selectItem(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// State hint below the command list (e.g. how to toggle autocomplete)
|
||||
this._renderCommandListFooter();
|
||||
|
||||
// Update virtual scroll height for virtual scrolling mode
|
||||
if (this.contentContainer) {
|
||||
this.updateVirtualScrollHeight();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a state hint below the slash command list so the autocomplete
|
||||
* toggle commands explain themselves. Only applies to prompt nodes.
|
||||
*/
|
||||
_renderCommandListFooter() {
|
||||
this._removeCommandListFooter();
|
||||
|
||||
if (this.modelType !== 'prompt') {
|
||||
return;
|
||||
}
|
||||
|
||||
const enabled = getPromptTagAutocompletePreference();
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'lm-autocomplete-command-footer';
|
||||
footer.textContent = enabled
|
||||
? 'Tag autocomplete is ON — /noautocomplete to disable'
|
||||
: 'Tag autocomplete is OFF — /autocomplete to enable';
|
||||
footer.style.cssText = `
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: rgba(226, 232, 240, 0.5);
|
||||
border-top: 1px solid rgba(226, 232, 240, 0.1);
|
||||
white-space: nowrap;
|
||||
`;
|
||||
// Keep focus in the textarea when the hint is clicked
|
||||
footer.addEventListener('mousedown', (e) => e.preventDefault());
|
||||
|
||||
this.dropdown.appendChild(footer);
|
||||
this.commandListFooter = footer;
|
||||
}
|
||||
|
||||
_removeCommandListFooter() {
|
||||
if (this.commandListFooter) {
|
||||
this.commandListFooter.remove();
|
||||
this.commandListFooter = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a one-time, dismissible hint inside the dropdown telling users how
|
||||
* to disable tag autocomplete. Dismissal is persisted in localStorage.
|
||||
*/
|
||||
_maybeShowFirstRunHint() {
|
||||
if (this.firstRunHint) {
|
||||
return;
|
||||
}
|
||||
if (this.modelType !== 'prompt'
|
||||
|| this.showingCommands
|
||||
|| this.searchType !== 'custom_words'
|
||||
|| this.activeCommand) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dismissed = false;
|
||||
try {
|
||||
dismissed = localStorage.getItem(FIRST_RUN_HINT_DISMISSED_KEY) === '1';
|
||||
} catch (e) {
|
||||
// localStorage unavailable - fall through and show the hint
|
||||
}
|
||||
if (dismissed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'lm-autocomplete-first-run-hint';
|
||||
hint.style.cssText = `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: rgba(226, 232, 240, 0.6);
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
|
||||
`;
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.textContent = 'Tip: type /noautocomplete to turn off these suggestions';
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.title = 'Dismiss';
|
||||
closeBtn.style.cssText = `
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(226, 232, 240, 0.5);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
`;
|
||||
closeBtn.addEventListener('click', () => {
|
||||
try {
|
||||
localStorage.setItem(FIRST_RUN_HINT_DISMISSED_KEY, '1');
|
||||
} catch (e) {
|
||||
}
|
||||
this._removeFirstRunHint();
|
||||
});
|
||||
|
||||
hint.appendChild(text);
|
||||
hint.appendChild(closeBtn);
|
||||
// Keep focus in the textarea when interacting with the hint
|
||||
hint.addEventListener('mousedown', (e) => e.preventDefault());
|
||||
|
||||
this.dropdown.insertBefore(hint, this.dropdown.firstChild);
|
||||
this.firstRunHint = hint;
|
||||
}
|
||||
|
||||
_removeFirstRunHint() {
|
||||
if (this.firstRunHint) {
|
||||
this.firstRunHint.remove();
|
||||
this.firstRunHint = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a command into the input
|
||||
* @param {string} command - The command to insert (e.g., "/character")
|
||||
@@ -1745,6 +1873,9 @@ class AutoComplete {
|
||||
this.selectedIndex = -1;
|
||||
this.hasManualSelection = false;
|
||||
|
||||
// Command-list state hints do not belong to regular search results
|
||||
this._removeCommandListFooter();
|
||||
|
||||
// Reset virtual scroll state
|
||||
this.virtualScrollOffset = 0;
|
||||
this.currentPage = 0;
|
||||
@@ -2447,6 +2578,7 @@ class AutoComplete {
|
||||
return;
|
||||
}
|
||||
|
||||
this._maybeShowFirstRunHint();
|
||||
// For virtual scrolling, render items first so positionAtCursor can measure width correctly
|
||||
if (this.options.enableVirtualScroll && this.contentContainer) {
|
||||
this.dropdown.style.display = 'block';
|
||||
@@ -2515,6 +2647,10 @@ class AutoComplete {
|
||||
this.selectedIndex = -1;
|
||||
this.hasManualSelection = false;
|
||||
this.showingCommands = false;
|
||||
|
||||
// Remove discoverability hints attached to the dropdown
|
||||
this._removeCommandListFooter();
|
||||
this._removeFirstRunHint();
|
||||
|
||||
// Clear items to prevent stale data from being displayed
|
||||
// when autocomplete is shown again
|
||||
@@ -2854,20 +2990,12 @@ class AutoComplete {
|
||||
const { settingId, value } = command;
|
||||
|
||||
try {
|
||||
// Use ComfyUI's setting API to update global setting
|
||||
const settingManager = app?.extensionManager?.setting;
|
||||
if (settingManager && typeof settingManager.set === 'function') {
|
||||
await settingManager.set(settingId, value);
|
||||
const success = await setLoraManagerSettingValue(settingId, value);
|
||||
if (success) {
|
||||
this._showToggleFeedback(command, value);
|
||||
this._clearCurrentToken();
|
||||
} else {
|
||||
// Fallback: use legacy settings API
|
||||
const setting = app.ui.settings.settingsById?.[settingId];
|
||||
if (setting) {
|
||||
app.ui.settings.setSettingValue(settingId, value);
|
||||
this._showToggleFeedback(command, value);
|
||||
this._clearCurrentToken();
|
||||
}
|
||||
throw new Error('settings API unavailable');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Lora Manager] Failed to toggle setting:', error);
|
||||
@@ -2893,7 +3021,7 @@ class AutoComplete {
|
||||
summary: command.feedbackSummary || (enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled'),
|
||||
detail: command.feedbackDetail || (enabled
|
||||
? 'Tag autocomplete is now ON. Type to see suggestions.'
|
||||
: 'Tag autocomplete is now OFF. Use /autocomplete to re-enable.'),
|
||||
: 'Tag autocomplete is now OFF. Use /autocomplete or the node right-click menu to re-enable.'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import {
|
||||
PROMPT_TAG_AUTOCOMPLETE_SETTING_ID,
|
||||
getPromptTagAutocompletePreference,
|
||||
setLoraManagerSettingValue,
|
||||
} from "./settings.js";
|
||||
import { showToast } from "./utils.js";
|
||||
|
||||
/**
|
||||
* Extension for PromptLM node to support dynamic trigger_words inputs.
|
||||
@@ -93,6 +99,48 @@ app.registerExtension({
|
||||
|
||||
return onConnectionsChange?.apply?.(this, arguments);
|
||||
};
|
||||
|
||||
// Expose the tag autocomplete toggle in the node's right-click menu so
|
||||
// users can discover the switch where the behavior actually happens,
|
||||
// instead of only via slash commands or the global settings dialog.
|
||||
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function(_, options) {
|
||||
getExtraMenuOptions?.apply?.(this, arguments);
|
||||
|
||||
options.push(null);
|
||||
|
||||
const autocompleteEnabled = getPromptTagAutocompletePreference();
|
||||
options.push({
|
||||
content: autocompleteEnabled
|
||||
? "Tag Autocomplete: ON (/noautocomplete to disable)"
|
||||
: "Tag Autocomplete: OFF (/autocomplete to enable)",
|
||||
callback: async () => {
|
||||
const newValue = !autocompleteEnabled;
|
||||
try {
|
||||
const success = await setLoraManagerSettingValue(PROMPT_TAG_AUTOCOMPLETE_SETTING_ID, newValue);
|
||||
if (!success) {
|
||||
throw new Error("settings API unavailable");
|
||||
}
|
||||
showToast({
|
||||
severity: newValue ? 'success' : 'secondary',
|
||||
summary: newValue ? 'Autocomplete Enabled' : 'Autocomplete Disabled',
|
||||
detail: newValue
|
||||
? 'Tag autocomplete is now ON. Type to see suggestions.'
|
||||
: 'Tag autocomplete is now OFF. Type /autocomplete in the prompt field to re-enable.',
|
||||
life: 3000
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Lora Manager] Failed to toggle setting:', error);
|
||||
showToast({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to toggle autocomplete setting',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
},
|
||||
|
||||
nodeCreated(node, app) {
|
||||
|
||||
+23
-1
@@ -172,6 +172,26 @@ const getPromptTagAutocompletePreference = (() => {
|
||||
};
|
||||
})();
|
||||
|
||||
/**
|
||||
* Persist a LoRA Manager setting through ComfyUI's setting API.
|
||||
* Returns true when the setting was written successfully.
|
||||
*/
|
||||
const setLoraManagerSettingValue = async (settingId, value) => {
|
||||
const settingManager = app?.extensionManager?.setting;
|
||||
if (settingManager && typeof settingManager.set === "function") {
|
||||
await settingManager.set(settingId, value);
|
||||
return true;
|
||||
}
|
||||
|
||||
const setting = app?.ui?.settings?.settingsById?.[settingId];
|
||||
if (setting) {
|
||||
app.ui.settings.setSettingValue(settingId, value);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getAutocompleteAppendCommaPreference = (() => {
|
||||
let settingsUnavailableLogged = false;
|
||||
|
||||
@@ -422,7 +442,7 @@ app.registerExtension({
|
||||
name: "Enable Tag Autocomplete in Prompt Nodes",
|
||||
type: "boolean",
|
||||
defaultValue: PROMPT_TAG_AUTOCOMPLETE_DEFAULT,
|
||||
tooltip: "When enabled, typing will trigger tag autocomplete suggestions. Commands (e.g., /character, /artist) always work regardless of this setting.",
|
||||
tooltip: "When enabled, typing in a Prompt (LoraManager) node triggers tag autocomplete suggestions. You can also toggle it by typing /autocomplete or /noautocomplete in the node, or from the node's right-click menu. Slash commands (e.g., /character, /artist) always work regardless of this setting.",
|
||||
category: ["LoRA Manager", "Autocomplete", "Prompt"],
|
||||
},
|
||||
{
|
||||
@@ -576,6 +596,7 @@ app.registerExtension({
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
PROMPT_TAG_AUTOCOMPLETE_SETTING_ID,
|
||||
getWheelSensitivity,
|
||||
getAutoPathCorrectionPreference,
|
||||
getAutocompleteAppendCommaPreference,
|
||||
@@ -587,4 +608,5 @@ export {
|
||||
getNewTabTemplatePreference,
|
||||
getStrengthStepPreference,
|
||||
getLoraActiveFiltersAutocompletePreference,
|
||||
setLoraManagerSettingValue,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user