Compare commits

...

10 Commits

Author SHA1 Message Date
Will Miao 6f5c444ec5 feat(recipes): add lora availability filter to recipe filter panel 2026-08-24 17:00:02 +08:00
Will Miao 20f66a4fe1 fix(ui): reload listing when an invalid folder selection falls back to root
After a drag move empties the selected folder, refresh() resets the
stale activeFolder to root but the grid kept showing the old filtered
(empty) view until a manual reload. Trigger resetAndReload when the
fallback happens post-initialization; the initial page load is untouched
because it picks up the cleared filter on its own.
2026-08-24 14:17:06 +08:00
Will Miao 879745da53 fix(init): add missing /api/lm/init-status endpoint used by polling fallback
initialization.js falls back to polling /api/lm/init-status when the
/ws/init-progress WebSocket cannot be established, but no route ever
registered that path — each poll 404'd and the page never reloaded after
the scan completed. Report the aggregate status of all four scanners and
omit pageType so every initialization page accepts the update.
2026-08-24 14:17:06 +08:00
Will Miao 3afec0a0be fix(ui): fall back to folder root when persisted active folder no longer exists
restoreSelectedFolder trusted localStorage blindly: a stale activeFolder
(moved/deleted, or saved while the tree was still empty) left the grid
filtered to a nonexistent folder with a phantom breadcrumb and no way to
recover short of clicking the root breadcrumb. Validate the persisted
path against the freshly loaded tree and reset to root when it is gone;
skip validation when the tree load failed so transient errors don't wipe
the saved location.
2026-08-24 14:17:06 +08:00
Will Miao 06c270a6e1 fix(recipes): show initialization screen and auto-reload during first scan
The recipes page always rendered with is_initializing=False, so a cold
start displayed an empty grid that never updated until a manual refresh.
Mirror the model pages: gate render_page on the scanner state, broadcast
init progress from RecipeScanner (including a completion message, and a
failure fallback so the page never stalls), and teach initialization.js
to detect the /loras/recipes page before the generic /loras match.
2026-08-24 14:17:06 +08:00
Will Miao 87e93636dc fix(recipes): wait for in-flight cache initialization instead of returning empty cache
get_cached_data() claimed to wait for a running initialization but
actually returned the placeholder empty cache, so API requests during
startup saw zero recipes. The initializing flag was also set only after
the LoRA scanner wait, leaving an unguarded window. Mark initialization
before the first await and have callers await the in-flight task.
2026-08-24 14:17:06 +08:00
Will Miao 074d1f2e51 feat(ui): improve tag autocomplete toggle discoverability in prompt nodes
- Add Tag Autocomplete ON/OFF entry to the Prompt (LoraManager) node
  right-click menu, cross-referencing the slash commands
- Show the current autocomplete state (/autocomplete or /noautocomplete
  hint) below the slash command list
- Show a one-time dismissible tip in the suggestion dropdown on first use
- Clarify toggle command labels (Turn autocomplete ON/OFF) and cross-link
  all three entry points in the settings tooltip
- Share the setting write path via setLoraManagerSettingValue()
2026-08-24 12:21:31 +08:00
Will Miao 40f922b0e8 fix(ui): right-anchor license icons and delete button as one group in model modal 2026-08-24 11:39:17 +08:00
Will Miao a7214b6cff fix(i18n): translate remaining workflow-related UI strings 2026-08-24 09:31:17 +08:00
Will Miao 8ca66e72eb feat(ui): add delete button and Del shortcut to model and recipe modals 2026-08-24 09:27:00 +08:00
35 changed files with 1808 additions and 277 deletions
+16 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": "未選擇模型",
+52
View File
@@ -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,
+18 -2
View File
@@ -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")
+1
View File
@@ -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
View File
@@ -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;
+5
View File
@@ -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
View File
@@ -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 };
+23 -2
View File
@@ -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);
}
}
+45 -1
View File
@@ -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();
+7 -2
View File
@@ -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) {
+29
View File
@@ -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;
+86 -2
View File
@@ -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 ?? ''
};
+1
View File
@@ -148,6 +148,7 @@ export const state = {
tags: {},
license: {},
modelTypes: [],
loraAvailability: [],
search: ''
},
pageSize: 20,
+16
View File
@@ -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>
+3
View File
@@ -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([]);
});
});
});
+52
View File
@@ -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"]
+37
View File
@@ -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
View File
@@ -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
});
}
+48
View File
@@ -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
View File
@@ -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,
};