mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-06 16:36:45 -03:00
Compare commits
6 Commits
v1.0.4
...
d9ec9c512e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9ec9c512e | ||
|
|
0bcd8e09a9 | ||
|
|
fa049a28c8 | ||
|
|
89fd2b43d6 | ||
|
|
c53f44e7ef | ||
|
|
ae7bfdb517 |
@@ -56,6 +56,11 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Takkan, Ca
|
||||
|
||||
## Release Notes
|
||||
|
||||
### v1.0.5
|
||||
|
||||
* **Excluded Models Management View** - Added a new global-menu view for excluded models, with actions to restore them or delete them permanently.
|
||||
* **Fix for `401 Unauthorized` Downloads** - Fixed an issue where some `civitai.red` downloads could lose authentication during redirect and fail with `401 Unauthorized`.
|
||||
|
||||
### v1.0.4
|
||||
|
||||
* **Civitai Domain Split Support** - Added support for `civitai.com` and `civitai.red` model URLs and recipe/image URLs across import, analysis, and download flows.
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "{count} Rezepte erfolgreich repariert.",
|
||||
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
|
||||
"error": "Recipe-Reparatur fehlgeschlagen: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Ausgeschlossene Modelle verwalten"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -222,12 +225,14 @@
|
||||
"presetOverwriteConfirm": "Voreinstellung \"{name}\" existiert bereits. Überschreiben?",
|
||||
"presetNamePlaceholder": "Voreinstellungsname...",
|
||||
"baseModel": "Basis-Modell",
|
||||
"baseModelSearchPlaceholder": "Basismodelle durchsuchen...",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"modelTypes": "Modelltypen",
|
||||
"license": "Lizenz",
|
||||
"noCreditRequired": "Kein Credit erforderlich",
|
||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||
"noTags": "Keine Tags",
|
||||
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
|
||||
"clearAll": "Alle Filter löschen",
|
||||
"any": "Beliebig",
|
||||
"all": "Alle",
|
||||
@@ -680,6 +685,7 @@
|
||||
"moveToFolder": "In Ordner verschieben",
|
||||
"repairMetadata": "Metadaten reparieren",
|
||||
"excludeModel": "Modell ausschließen",
|
||||
"restoreModel": "Modell wiederherstellen",
|
||||
"deleteModel": "Modell löschen",
|
||||
"shareRecipe": "Rezept teilen",
|
||||
"viewAllLoras": "Alle LoRAs anzeigen",
|
||||
@@ -1803,6 +1809,8 @@
|
||||
"deleteFailed": "Fehler beim Löschen von {type}: {message}",
|
||||
"excludeSuccess": "{type} erfolgreich ausgeschlossen",
|
||||
"excludeFailed": "Fehler beim Ausschließen von {type}: {message}",
|
||||
"restoreSuccess": "{type} erfolgreich wiederhergestellt",
|
||||
"restoreFailed": "{type} konnte nicht wiederhergestellt werden: {message}",
|
||||
"fileNameUpdated": "Dateiname erfolgreich aktualisiert",
|
||||
"fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}",
|
||||
"previewUpdated": "Vorschau erfolgreich aktualisiert",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "Successfully repaired {count} recipes.",
|
||||
"cancelled": "Repair cancelled. {count} recipes were repaired.",
|
||||
"error": "Recipe repair failed: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Manage Excluded Models"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -222,12 +225,14 @@
|
||||
"presetOverwriteConfirm": "Preset \"{name}\" already exists. Overwrite?",
|
||||
"presetNamePlaceholder": "Preset name...",
|
||||
"baseModel": "Base Model",
|
||||
"baseModelSearchPlaceholder": "Search base models...",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"license": "License",
|
||||
"noCreditRequired": "No Credit Required",
|
||||
"allowSellingGeneratedContent": "Allow Selling",
|
||||
"noTags": "No tags",
|
||||
"noBaseModelMatches": "No base models match the current search.",
|
||||
"clearAll": "Clear All Filters",
|
||||
"any": "Any",
|
||||
"all": "All",
|
||||
@@ -680,6 +685,7 @@
|
||||
"moveToFolder": "Move to Folder",
|
||||
"repairMetadata": "Repair metadata",
|
||||
"excludeModel": "Exclude Model",
|
||||
"restoreModel": "Restore Model",
|
||||
"deleteModel": "Delete Model",
|
||||
"shareRecipe": "Share Recipe",
|
||||
"viewAllLoras": "View All LoRAs",
|
||||
@@ -1803,6 +1809,8 @@
|
||||
"deleteFailed": "Failed to delete {type}: {message}",
|
||||
"excludeSuccess": "{type} excluded successfully",
|
||||
"excludeFailed": "Failed to exclude {type}: {message}",
|
||||
"restoreSuccess": "{type} restored successfully",
|
||||
"restoreFailed": "Failed to restore {type}: {message}",
|
||||
"fileNameUpdated": "File name updated successfully",
|
||||
"fileRenameFailed": "Failed to rename file: {error}",
|
||||
"previewUpdated": "Preview updated successfully",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "Se repararon con éxito {count} recetas.",
|
||||
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
|
||||
"error": "Error al reparar recetas: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Gestionar modelos excluidos"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -222,12 +225,14 @@
|
||||
"presetOverwriteConfirm": "El preset \"{name}\" ya existe. ¿Sobrescribir?",
|
||||
"presetNamePlaceholder": "Nombre del preajuste...",
|
||||
"baseModel": "Modelo base",
|
||||
"baseModelSearchPlaceholder": "Buscar modelos base...",
|
||||
"modelTags": "Etiquetas (Top 20)",
|
||||
"modelTypes": "Tipos de modelos",
|
||||
"license": "Licencia",
|
||||
"noCreditRequired": "Sin crédito requerido",
|
||||
"allowSellingGeneratedContent": "Venta permitida",
|
||||
"noTags": "Sin etiquetas",
|
||||
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
|
||||
"clearAll": "Limpiar todos los filtros",
|
||||
"any": "Cualquiera",
|
||||
"all": "Todos",
|
||||
@@ -680,6 +685,7 @@
|
||||
"moveToFolder": "Mover a carpeta",
|
||||
"repairMetadata": "Reparar metadatos",
|
||||
"excludeModel": "Excluir modelo",
|
||||
"restoreModel": "Restaurar modelo",
|
||||
"deleteModel": "Eliminar modelo",
|
||||
"shareRecipe": "Compartir receta",
|
||||
"viewAllLoras": "Ver todos los LoRAs",
|
||||
@@ -1803,6 +1809,8 @@
|
||||
"deleteFailed": "Error al eliminar {type}: {message}",
|
||||
"excludeSuccess": "{type} excluido exitosamente",
|
||||
"excludeFailed": "Error al excluir {type}: {message}",
|
||||
"restoreSuccess": "{type} restaurado correctamente",
|
||||
"restoreFailed": "No se pudo restaurar {type}: {message}",
|
||||
"fileNameUpdated": "Nombre de archivo actualizado exitosamente",
|
||||
"fileRenameFailed": "Error al renombrar archivo: {error}",
|
||||
"previewUpdated": "Vista previa actualizada exitosamente",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "{count} recettes réparées avec succès.",
|
||||
"cancelled": "Réparation annulée. {count} recettes ont été réparées.",
|
||||
"error": "Échec de la réparation des recettes : {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Gérer les modèles exclus"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -222,12 +225,14 @@
|
||||
"presetOverwriteConfirm": "Le préréglage \"{name}\" existe déjà. Remplacer?",
|
||||
"presetNamePlaceholder": "Nom du préréglage...",
|
||||
"baseModel": "Modèle de base",
|
||||
"baseModelSearchPlaceholder": "Rechercher des modèles de base...",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"modelTypes": "Types de modèles",
|
||||
"license": "Licence",
|
||||
"noCreditRequired": "Crédit non requis",
|
||||
"allowSellingGeneratedContent": "Vente autorisée",
|
||||
"noTags": "Aucun tag",
|
||||
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
|
||||
"clearAll": "Effacer tous les filtres",
|
||||
"any": "N'importe quel",
|
||||
"all": "Tous",
|
||||
@@ -680,6 +685,7 @@
|
||||
"moveToFolder": "Déplacer vers un dossier",
|
||||
"repairMetadata": "Réparer les métadonnées",
|
||||
"excludeModel": "Exclure le modèle",
|
||||
"restoreModel": "Restaurer le modèle",
|
||||
"deleteModel": "Supprimer le modèle",
|
||||
"shareRecipe": "Partager la recipe",
|
||||
"viewAllLoras": "Voir tous les LoRAs",
|
||||
@@ -1803,6 +1809,8 @@
|
||||
"deleteFailed": "Échec de la suppression de {type} : {message}",
|
||||
"excludeSuccess": "{type} exclu avec succès",
|
||||
"excludeFailed": "Échec de l'exclusion de {type} : {message}",
|
||||
"restoreSuccess": "{type} restauré avec succès",
|
||||
"restoreFailed": "Échec de la restauration de {type} : {message}",
|
||||
"fileNameUpdated": "Nom de fichier mis à jour avec succès",
|
||||
"fileRenameFailed": "Échec du renommage du fichier : {error}",
|
||||
"previewUpdated": "Aperçu mis à jour avec succès",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "תוקנו בהצלחה {count} מתכונים.",
|
||||
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
|
||||
"error": "תיקון המתכונים נכשל: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "ניהול מודלים מוחרגים"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -222,12 +225,14 @@
|
||||
"presetOverwriteConfirm": "הפריסט \"{name}\" כבר קיים. לדרוס?",
|
||||
"presetNamePlaceholder": "שם קביעה מראש...",
|
||||
"baseModel": "מודל בסיס",
|
||||
"baseModelSearchPlaceholder": "חפש מודלי בסיס...",
|
||||
"modelTags": "תגיות (20 המובילות)",
|
||||
"modelTypes": "סוגי מודלים",
|
||||
"license": "רישיון",
|
||||
"noCreditRequired": "ללא קרדיט נדרש",
|
||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||
"noTags": "ללא תגיות",
|
||||
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
||||
"clearAll": "נקה את כל המסננים",
|
||||
"any": "כלשהו",
|
||||
"all": "כל התגים",
|
||||
@@ -680,6 +685,7 @@
|
||||
"moveToFolder": "העבר לתיקייה",
|
||||
"repairMetadata": "תיקון מטא-דאטה",
|
||||
"excludeModel": "החרג מודל",
|
||||
"restoreModel": "שחזור מודל",
|
||||
"deleteModel": "מחק מודל",
|
||||
"shareRecipe": "שתף מתכון",
|
||||
"viewAllLoras": "הצג את כל ה-LoRAs",
|
||||
@@ -1803,6 +1809,8 @@
|
||||
"deleteFailed": "מחיקת {type} נכשלה: {message}",
|
||||
"excludeSuccess": "{type} הוחרג בהצלחה",
|
||||
"excludeFailed": "החרגת {type} נכשלה: {message}",
|
||||
"restoreSuccess": "{type} שוחזר בהצלחה",
|
||||
"restoreFailed": "שחזור {type} נכשל: {message}",
|
||||
"fileNameUpdated": "שם הקובץ עודכן בהצלחה",
|
||||
"fileRenameFailed": "שינוי שם הקובץ נכשל: {error}",
|
||||
"previewUpdated": "התצוגה המקדימה עודכנה בהצלחה",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "{count} 件のレシピを正常に修復しました。",
|
||||
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
|
||||
"error": "レシピの修復に失敗しました: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "除外モデルを管理"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -222,12 +225,14 @@
|
||||
"presetOverwriteConfirm": "プリセット「{name}」は既に存在します。上書きしますか?",
|
||||
"presetNamePlaceholder": "プリセット名...",
|
||||
"baseModel": "ベースモデル",
|
||||
"baseModelSearchPlaceholder": "ベースモデルを検索...",
|
||||
"modelTags": "タグ(上位20)",
|
||||
"modelTypes": "モデルタイプ",
|
||||
"license": "ライセンス",
|
||||
"noCreditRequired": "クレジット不要",
|
||||
"allowSellingGeneratedContent": "販売許可",
|
||||
"noTags": "タグなし",
|
||||
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
||||
"clearAll": "すべてのフィルタをクリア",
|
||||
"any": "いずれか",
|
||||
"all": "すべて",
|
||||
@@ -680,6 +685,7 @@
|
||||
"moveToFolder": "フォルダに移動",
|
||||
"repairMetadata": "メタデータを修復",
|
||||
"excludeModel": "モデルを除外",
|
||||
"restoreModel": "モデルを復元",
|
||||
"deleteModel": "モデルを削除",
|
||||
"shareRecipe": "レシピを共有",
|
||||
"viewAllLoras": "すべてのLoRAを表示",
|
||||
@@ -1803,6 +1809,8 @@
|
||||
"deleteFailed": "{type}の削除に失敗しました:{message}",
|
||||
"excludeSuccess": "{type}が正常に除外されました",
|
||||
"excludeFailed": "{type}の除外に失敗しました:{message}",
|
||||
"restoreSuccess": "{type}を復元しました",
|
||||
"restoreFailed": "{type}の復元に失敗しました: {message}",
|
||||
"fileNameUpdated": "ファイル名が正常に更新されました",
|
||||
"fileRenameFailed": "ファイル名の変更に失敗しました:{error}",
|
||||
"previewUpdated": "プレビューが正常に更新されました",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
|
||||
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
|
||||
"error": "레시피 복구 실패: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "제외된 모델 관리"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -222,12 +225,14 @@
|
||||
"presetOverwriteConfirm": "프리셋 \"{name}\"이(가) 이미 존재합니다. 덮어쓰시겠습니까?",
|
||||
"presetNamePlaceholder": "프리셋 이름...",
|
||||
"baseModel": "베이스 모델",
|
||||
"baseModelSearchPlaceholder": "베이스 모델 검색...",
|
||||
"modelTags": "태그 (상위 20개)",
|
||||
"modelTypes": "모델 유형",
|
||||
"license": "라이선스",
|
||||
"noCreditRequired": "크레딧 표기 없음",
|
||||
"allowSellingGeneratedContent": "판매 허용",
|
||||
"noTags": "태그 없음",
|
||||
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
||||
"clearAll": "모든 필터 지우기",
|
||||
"any": "아무",
|
||||
"all": "모두",
|
||||
@@ -680,6 +685,7 @@
|
||||
"moveToFolder": "폴더로 이동",
|
||||
"repairMetadata": "메타데이터 복구",
|
||||
"excludeModel": "모델 제외",
|
||||
"restoreModel": "모델 복원",
|
||||
"deleteModel": "모델 삭제",
|
||||
"shareRecipe": "레시피 공유",
|
||||
"viewAllLoras": "모든 LoRA 보기",
|
||||
@@ -1803,6 +1809,8 @@
|
||||
"deleteFailed": "{type} 삭제 실패: {message}",
|
||||
"excludeSuccess": "{type}이(가) 성공적으로 제외되었습니다",
|
||||
"excludeFailed": "{type} 제외 실패: {message}",
|
||||
"restoreSuccess": "{type} 복원 완료",
|
||||
"restoreFailed": "{type} 복원 실패: {message}",
|
||||
"fileNameUpdated": "파일명이 성공적으로 업데이트되었습니다",
|
||||
"fileRenameFailed": "파일 이름 변경 실패: {error}",
|
||||
"previewUpdated": "미리보기가 성공적으로 업데이트되었습니다",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "Успешно восстановлено {count} рецептов.",
|
||||
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
|
||||
"error": "Ошибка восстановления рецептов: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Управление исключёнными моделями"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -222,12 +225,14 @@
|
||||
"presetOverwriteConfirm": "Пресет \"{name}\" уже существует. Перезаписать?",
|
||||
"presetNamePlaceholder": "Имя пресета...",
|
||||
"baseModel": "Базовая модель",
|
||||
"baseModelSearchPlaceholder": "Поиск базовых моделей...",
|
||||
"modelTags": "Теги (Топ 20)",
|
||||
"modelTypes": "Типы моделей",
|
||||
"license": "Лицензия",
|
||||
"noCreditRequired": "Без указания авторства",
|
||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||
"noTags": "Без тегов",
|
||||
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
||||
"clearAll": "Очистить все фильтры",
|
||||
"any": "Любой",
|
||||
"all": "Все",
|
||||
@@ -680,6 +685,7 @@
|
||||
"moveToFolder": "Переместить в папку",
|
||||
"repairMetadata": "Восстановить метаданные",
|
||||
"excludeModel": "Исключить модель",
|
||||
"restoreModel": "Восстановить модель",
|
||||
"deleteModel": "Удалить модель",
|
||||
"shareRecipe": "Поделиться рецептом",
|
||||
"viewAllLoras": "Посмотреть все LoRAs",
|
||||
@@ -1803,6 +1809,8 @@
|
||||
"deleteFailed": "Не удалось удалить {type}: {message}",
|
||||
"excludeSuccess": "{type} успешно исключен",
|
||||
"excludeFailed": "Не удалось исключить {type}: {message}",
|
||||
"restoreSuccess": "{type} успешно восстановлен",
|
||||
"restoreFailed": "Не удалось восстановить {type}: {message}",
|
||||
"fileNameUpdated": "Имя файла успешно обновлено",
|
||||
"fileRenameFailed": "Не удалось переименовать файл: {error}",
|
||||
"previewUpdated": "Превью успешно обновлено",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "成功修复了 {count} 个配方。",
|
||||
"cancelled": "修复已取消。已修复 {count} 个配方。",
|
||||
"error": "配方修复失败:{message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -222,12 +225,14 @@
|
||||
"presetOverwriteConfirm": "预设 \"{name}\" 已存在。是否覆盖?",
|
||||
"presetNamePlaceholder": "预设名称...",
|
||||
"baseModel": "基础模型",
|
||||
"baseModelSearchPlaceholder": "搜索基础模型...",
|
||||
"modelTags": "标签(前20)",
|
||||
"modelTypes": "模型类型",
|
||||
"license": "许可证",
|
||||
"noCreditRequired": "无需署名",
|
||||
"allowSellingGeneratedContent": "允许销售",
|
||||
"noTags": "无标签",
|
||||
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
||||
"clearAll": "清除所有筛选",
|
||||
"any": "任一",
|
||||
"all": "全部",
|
||||
@@ -680,6 +685,7 @@
|
||||
"moveToFolder": "移动到文件夹",
|
||||
"repairMetadata": "修复元数据",
|
||||
"excludeModel": "排除模型",
|
||||
"restoreModel": "恢复模型",
|
||||
"deleteModel": "删除模型",
|
||||
"shareRecipe": "分享配方",
|
||||
"viewAllLoras": "查看所有 LoRA",
|
||||
@@ -1803,6 +1809,8 @@
|
||||
"deleteFailed": "删除 {type} 失败:{message}",
|
||||
"excludeSuccess": "{type} 排除成功",
|
||||
"excludeFailed": "排除 {type} 失败:{message}",
|
||||
"restoreSuccess": "{type} 已成功恢复",
|
||||
"restoreFailed": "恢复 {type} 失败:{message}",
|
||||
"fileNameUpdated": "文件名更新成功",
|
||||
"fileRenameFailed": "重命名文件失败:{error}",
|
||||
"previewUpdated": "预览图片更新成功",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "成功修復 {count} 個配方。",
|
||||
"cancelled": "修復已取消。已修復 {count} 個配方。",
|
||||
"error": "配方修復失敗:{message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -222,12 +225,14 @@
|
||||
"presetOverwriteConfirm": "預設 \"{name}\" 已存在。是否覆蓋?",
|
||||
"presetNamePlaceholder": "預設名稱...",
|
||||
"baseModel": "基礎模型",
|
||||
"baseModelSearchPlaceholder": "搜尋基礎模型...",
|
||||
"modelTags": "標籤(前 20)",
|
||||
"modelTypes": "模型類型",
|
||||
"license": "授權",
|
||||
"noCreditRequired": "無需署名",
|
||||
"allowSellingGeneratedContent": "允許銷售",
|
||||
"noTags": "無標籤",
|
||||
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
||||
"clearAll": "清除所有篩選",
|
||||
"any": "任一",
|
||||
"all": "全部",
|
||||
@@ -680,6 +685,7 @@
|
||||
"moveToFolder": "移動到資料夾",
|
||||
"repairMetadata": "修復元數據",
|
||||
"excludeModel": "排除模型",
|
||||
"restoreModel": "還原模型",
|
||||
"deleteModel": "刪除模型",
|
||||
"shareRecipe": "分享配方",
|
||||
"viewAllLoras": "檢視全部 LoRA",
|
||||
@@ -1803,6 +1809,8 @@
|
||||
"deleteFailed": "刪除 {type} 失敗:{message}",
|
||||
"excludeSuccess": "{type} 已成功排除",
|
||||
"excludeFailed": "排除 {type} 失敗:{message}",
|
||||
"restoreSuccess": "{type} 已成功還原",
|
||||
"restoreFailed": "還原 {type} 失敗:{message}",
|
||||
"fileNameUpdated": "檔案名稱已成功更新",
|
||||
"fileRenameFailed": "重新命名檔案失敗:{error}",
|
||||
"previewUpdated": "預覽圖片已成功更新",
|
||||
|
||||
@@ -224,6 +224,42 @@ class ModelListingHandler:
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def get_excluded_models(self, request: web.Request) -> web.Response:
|
||||
start_time = time.perf_counter()
|
||||
try:
|
||||
params = self._parse_common_params(request)
|
||||
result = await self._service.get_excluded_paginated_data(**params)
|
||||
|
||||
format_start = time.perf_counter()
|
||||
formatted_result = {
|
||||
"items": [
|
||||
await self._service.format_response(item)
|
||||
for item in result["items"]
|
||||
],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"page_size": result["page_size"],
|
||||
"total_pages": result["total_pages"],
|
||||
}
|
||||
format_duration = time.perf_counter() - format_start
|
||||
|
||||
duration = time.perf_counter() - start_time
|
||||
self._logger.debug(
|
||||
"Request for %s/excluded took %.3fs (formatting: %.3fs)",
|
||||
self._service.model_type,
|
||||
duration,
|
||||
format_duration,
|
||||
)
|
||||
return web.json_response(formatted_result)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error retrieving excluded %ss: %s",
|
||||
self._service.model_type,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
def _parse_common_params(self, request: web.Request) -> Dict:
|
||||
page = int(request.query.get("page", "1"))
|
||||
page_size = min(int(request.query.get("page_size", "20")), 100)
|
||||
@@ -392,6 +428,21 @@ class ModelManagementHandler:
|
||||
self._logger.error("Error excluding model: %s", exc, exc_info=True)
|
||||
return web.Response(text=str(exc), status=500)
|
||||
|
||||
async def unexclude_model(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
file_path = data.get("file_path")
|
||||
if not file_path:
|
||||
return web.Response(text="Model path is required", status=400)
|
||||
|
||||
result = await self._lifecycle_service.unexclude_model(file_path)
|
||||
return web.json_response(result)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error restoring model: %s", exc, exc_info=True)
|
||||
return web.Response(text=str(exc), status=500)
|
||||
|
||||
async def fetch_civitai(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
@@ -859,7 +910,7 @@ class ModelQueryHandler:
|
||||
async def get_base_models(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
if limit < 1 or limit > 100:
|
||||
if limit < 0 or limit > 100:
|
||||
limit = 20
|
||||
base_models = await self._service.get_base_models(limit)
|
||||
return web.json_response({"success": True, "base_models": base_models})
|
||||
@@ -2437,8 +2488,10 @@ class ModelHandlerSet:
|
||||
return {
|
||||
"handle_models_page": self.page_view.handle,
|
||||
"get_models": self.listing.get_models,
|
||||
"get_excluded_models": self.listing.get_excluded_models,
|
||||
"delete_model": self.management.delete_model,
|
||||
"exclude_model": self.management.exclude_model,
|
||||
"unexclude_model": self.management.unexclude_model,
|
||||
"fetch_civitai": self.management.fetch_civitai,
|
||||
"fetch_all_civitai": self.civitai.fetch_all_civitai,
|
||||
"relink_civitai": self.management.relink_civitai,
|
||||
|
||||
@@ -329,6 +329,7 @@ class RecipeQueryHandler:
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
cache = await recipe_scanner.get_cached_data()
|
||||
|
||||
base_model_counts: Dict[str, int] = {}
|
||||
@@ -344,6 +345,8 @@ class RecipeQueryHandler:
|
||||
for model, count in base_model_counts.items()
|
||||
]
|
||||
sorted_models.sort(key=lambda entry: entry["count"], reverse=True)
|
||||
if limit > 0:
|
||||
sorted_models = sorted_models[:limit]
|
||||
return web.json_response({"success": True, "base_models": sorted_models})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
|
||||
|
||||
@@ -22,8 +22,10 @@ class RouteDefinition:
|
||||
|
||||
COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/list", "get_models"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/excluded", "get_excluded_models"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/delete", "delete_model"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/exclude", "exclude_model"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/unexclude", "unexclude_model"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/fetch-civitai", "fetch_civitai"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/fetch-all-civitai", "fetch_all_civitai"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"),
|
||||
|
||||
@@ -179,6 +179,57 @@ class BaseModelService(ABC):
|
||||
)
|
||||
return paginated
|
||||
|
||||
async def get_excluded_paginated_data(
|
||||
self,
|
||||
page: int,
|
||||
page_size: int,
|
||||
sort_by: str = "name",
|
||||
search: str = None,
|
||||
fuzzy_search: bool = False,
|
||||
search_options: dict = None,
|
||||
**kwargs,
|
||||
) -> Dict:
|
||||
"""Get paginated excluded model data."""
|
||||
excluded_paths = list(self.scanner.get_excluded_models())
|
||||
excluded_entries: List[Dict[str, Any]] = []
|
||||
stale_paths: List[str] = []
|
||||
|
||||
for file_path in excluded_paths:
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
stale_paths.append(file_path)
|
||||
continue
|
||||
|
||||
entry = await self._build_excluded_entry(file_path)
|
||||
if entry:
|
||||
excluded_entries.append(entry)
|
||||
else:
|
||||
stale_paths.append(file_path)
|
||||
|
||||
if stale_paths:
|
||||
current_excluded = getattr(self.scanner, "_excluded_models", None)
|
||||
if isinstance(current_excluded, list):
|
||||
stale_set = set(stale_paths)
|
||||
self.scanner._excluded_models = [
|
||||
path for path in current_excluded if path not in stale_set
|
||||
]
|
||||
persist_current_cache = getattr(self.scanner, "_persist_current_cache", None)
|
||||
if callable(persist_current_cache):
|
||||
await persist_current_cache()
|
||||
|
||||
excluded_entries = self._sort_entries(excluded_entries, sort_by)
|
||||
|
||||
if search:
|
||||
excluded_entries = await self._apply_search_filters(
|
||||
excluded_entries,
|
||||
search,
|
||||
fuzzy_search,
|
||||
search_options,
|
||||
)
|
||||
|
||||
paginated = self._paginate(excluded_entries, page, page_size)
|
||||
paginated["items"] = await self._annotate_update_flags(paginated["items"])
|
||||
return paginated
|
||||
|
||||
async def _fetch_with_usage_sort(self, sort_params):
|
||||
"""Fetch data sorted by usage count (desc/asc)."""
|
||||
cache = await self.cache_repository.get_cache()
|
||||
@@ -218,6 +269,62 @@ class BaseModelService(ABC):
|
||||
)
|
||||
return annotated
|
||||
|
||||
def _sort_entries(self, data: List[Dict[str, Any]], sort_by: str) -> List[Dict[str, Any]]:
|
||||
sort_params = self.cache_repository.parse_sort(sort_by)
|
||||
key_name = sort_params.key
|
||||
|
||||
if key_name == "date":
|
||||
key_fn = lambda item: (
|
||||
float(item.get("modified", 0.0) or 0.0),
|
||||
(item.get("model_name") or item.get("file_name") or "").lower(),
|
||||
item.get("file_path", "").lower(),
|
||||
)
|
||||
elif key_name == "size":
|
||||
key_fn = lambda item: (
|
||||
int(item.get("size", 0) or 0),
|
||||
(item.get("model_name") or item.get("file_name") or "").lower(),
|
||||
item.get("file_path", "").lower(),
|
||||
)
|
||||
elif key_name == "usage":
|
||||
key_fn = lambda item: (
|
||||
int(item.get("usage_count", 0) or 0),
|
||||
(item.get("model_name") or item.get("file_name") or "").lower(),
|
||||
item.get("file_path", "").lower(),
|
||||
)
|
||||
else:
|
||||
key_fn = lambda item: (
|
||||
(item.get("model_name") or item.get("file_name") or "").lower(),
|
||||
item.get("file_path", "").lower(),
|
||||
)
|
||||
|
||||
return sorted(data, key=key_fn, reverse=sort_params.order == "desc")
|
||||
|
||||
async def _build_excluded_entry(self, file_path: str) -> Optional[Dict[str, Any]]:
|
||||
root_path = self.scanner._find_root_for_file(file_path)
|
||||
if not root_path:
|
||||
return None
|
||||
|
||||
metadata, should_skip = await MetadataManager.load_metadata(
|
||||
file_path,
|
||||
self.metadata_class,
|
||||
)
|
||||
if should_skip:
|
||||
return None
|
||||
|
||||
if metadata is None:
|
||||
metadata = await self.scanner._create_default_metadata(file_path)
|
||||
if metadata is None:
|
||||
return None
|
||||
|
||||
metadata = self.scanner.adjust_metadata(metadata, file_path, root_path)
|
||||
folder = os.path.dirname(os.path.relpath(file_path, root_path)).replace(
|
||||
os.path.sep, "/"
|
||||
)
|
||||
entry = self.scanner._build_cache_entry(metadata, folder=folder)
|
||||
entry = self.scanner.adjust_cached_entry(entry)
|
||||
entry["exclude"] = True
|
||||
return entry
|
||||
|
||||
async def _apply_hash_filters(
|
||||
self, data: List[Dict], hash_filters: Dict
|
||||
) -> List[Dict]:
|
||||
|
||||
@@ -42,6 +42,7 @@ class CheckpointService(BaseModelService):
|
||||
"notes": checkpoint_data.get("notes", ""),
|
||||
"sub_type": sub_type,
|
||||
"favorite": checkpoint_data.get("favorite", False),
|
||||
"exclude": bool(checkpoint_data.get("exclude", False)),
|
||||
"update_available": bool(checkpoint_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
||||
|
||||
@@ -16,7 +16,7 @@ from ..utils.constants import (
|
||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||
VALID_LORA_TYPES,
|
||||
)
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
from ..utils.utils import sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
@@ -644,7 +644,9 @@ class DownloadManager:
|
||||
if mirrors:
|
||||
for mirror in mirrors:
|
||||
if mirror.get("deletedAt") is None and mirror.get("url"):
|
||||
download_urls.append(mirror["url"])
|
||||
download_urls.append(
|
||||
normalize_civitai_download_url(mirror["url"])
|
||||
)
|
||||
|
||||
# When source is 'civarchive', prioritize non-Civitai URLs
|
||||
# This avoids failed downloads from deleted Civitai models
|
||||
@@ -663,7 +665,9 @@ class DownloadManager:
|
||||
else:
|
||||
download_url = file_info.get("downloadUrl")
|
||||
if download_url:
|
||||
download_urls.append(download_url)
|
||||
download_urls.append(
|
||||
normalize_civitai_download_url(download_url)
|
||||
)
|
||||
|
||||
if not download_urls:
|
||||
return {"success": False, "error": "No mirror URL found"}
|
||||
@@ -1138,6 +1142,7 @@ class DownloadManager:
|
||||
pause_control.update_stall_timeout(downloader.stall_timeout)
|
||||
last_error = None
|
||||
for download_url in download_urls:
|
||||
download_url = normalize_civitai_download_url(download_url)
|
||||
use_auth = download_url.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||
download_kwargs = {
|
||||
"progress_callback": lambda progress, snapshot=None: (
|
||||
|
||||
@@ -138,7 +138,7 @@ class Downloader:
|
||||
self.chunk_size = (
|
||||
16 * 1024 * 1024
|
||||
) # 16MB chunks to balance I/O reduction and memory usage
|
||||
self.max_retries = 5
|
||||
self.max_retries = self._resolve_max_retries()
|
||||
self.base_delay = 2.0 # Base delay for exponential backoff
|
||||
self.session_timeout = 300 # 5 minutes
|
||||
self.stall_timeout = self._resolve_stall_timeout()
|
||||
@@ -192,6 +192,18 @@ class Downloader:
|
||||
|
||||
return max(30.0, timeout_value)
|
||||
|
||||
def _resolve_max_retries(self) -> int:
|
||||
"""Determine max retry count from environment while preserving defaults."""
|
||||
default_retries = 5
|
||||
raw_value = os.environ.get("COMFYUI_DOWNLOAD_MAX_RETRIES")
|
||||
|
||||
try:
|
||||
retries = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
retries = default_retries
|
||||
|
||||
return max(0, retries)
|
||||
|
||||
def _should_refresh_session(self) -> bool:
|
||||
"""Check if session should be refreshed"""
|
||||
if self._session is None:
|
||||
@@ -334,6 +346,7 @@ class Downloader:
|
||||
logger.info(f"Resuming download from offset {resume_offset} bytes")
|
||||
|
||||
total_size = 0
|
||||
range_redirect_retry_urls: set[str] = set()
|
||||
|
||||
while retry_count <= self.max_retries:
|
||||
try:
|
||||
@@ -372,6 +385,23 @@ class Downloader:
|
||||
if response.status == 200:
|
||||
# Full content response
|
||||
if resume_offset > 0:
|
||||
redirected_url = str(response.url)
|
||||
if (
|
||||
allow_resume
|
||||
and response.history
|
||||
and redirected_url
|
||||
and redirected_url != url
|
||||
and redirected_url not in range_redirect_retry_urls
|
||||
):
|
||||
range_redirect_retry_urls.add(redirected_url)
|
||||
logger.info(
|
||||
"Range request was not honored after redirect; retrying final URL directly: %s",
|
||||
redirected_url,
|
||||
)
|
||||
url = redirected_url
|
||||
response.release()
|
||||
continue
|
||||
|
||||
# Server doesn't support ranges, restart from beginning
|
||||
logger.warning(
|
||||
"Server doesn't support range requests, restarting download"
|
||||
@@ -571,37 +601,53 @@ class Downloader:
|
||||
expected_size = total_size if total_size > 0 else None
|
||||
|
||||
integrity_error: Optional[str] = None
|
||||
resumable_incomplete = False
|
||||
if final_size <= 0:
|
||||
integrity_error = "Downloaded file is empty"
|
||||
elif expected_size is not None and final_size != expected_size:
|
||||
integrity_error = f"File size mismatch. Expected: {expected_size}, Got: {final_size}"
|
||||
resumable_incomplete = (
|
||||
allow_resume
|
||||
and part_path != save_path
|
||||
and final_size > 0
|
||||
and final_size < expected_size
|
||||
)
|
||||
|
||||
if integrity_error is not None:
|
||||
logger.error(
|
||||
log_fn = logger.warning if resumable_incomplete else logger.error
|
||||
log_fn(
|
||||
"Download integrity check failed for %s: %s",
|
||||
save_path,
|
||||
integrity_error,
|
||||
)
|
||||
|
||||
# Remove the corrupted payload so future attempts start fresh
|
||||
if os.path.exists(part_path):
|
||||
try:
|
||||
os.remove(part_path)
|
||||
except OSError as remove_error:
|
||||
logger.warning(
|
||||
"Failed to delete corrupted download %s: %s",
|
||||
part_path,
|
||||
remove_error,
|
||||
)
|
||||
if part_path != save_path and os.path.exists(save_path):
|
||||
try:
|
||||
os.remove(save_path)
|
||||
except OSError as remove_error:
|
||||
logger.warning(
|
||||
"Failed to delete target file %s after integrity error: %s",
|
||||
save_path,
|
||||
remove_error,
|
||||
)
|
||||
if resumable_incomplete:
|
||||
logger.info(
|
||||
"Preserving incomplete download for resume: %s (%s/%s bytes)",
|
||||
part_path,
|
||||
final_size,
|
||||
expected_size,
|
||||
)
|
||||
else:
|
||||
# Remove corrupted payloads that cannot be safely resumed.
|
||||
if os.path.exists(part_path):
|
||||
try:
|
||||
os.remove(part_path)
|
||||
except OSError as remove_error:
|
||||
logger.warning(
|
||||
"Failed to delete corrupted download %s: %s",
|
||||
part_path,
|
||||
remove_error,
|
||||
)
|
||||
if part_path != save_path and os.path.exists(save_path):
|
||||
try:
|
||||
os.remove(save_path)
|
||||
except OSError as remove_error:
|
||||
logger.warning(
|
||||
"Failed to delete target file %s after integrity error: %s",
|
||||
save_path,
|
||||
remove_error,
|
||||
)
|
||||
|
||||
retry_count += 1
|
||||
if retry_count <= self.max_retries:
|
||||
@@ -611,8 +657,16 @@ class Downloader:
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
resume_offset = 0
|
||||
total_size = 0
|
||||
if resumable_incomplete and os.path.exists(part_path):
|
||||
resume_offset = os.path.getsize(part_path)
|
||||
total_size = expected_size or 0
|
||||
logger.info(
|
||||
"Will resume incomplete download from byte %s",
|
||||
resume_offset,
|
||||
)
|
||||
else:
|
||||
resume_offset = 0
|
||||
total_size = 0
|
||||
await self._create_session()
|
||||
continue
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ class EmbeddingService(BaseModelService):
|
||||
"notes": embedding_data.get("notes", ""),
|
||||
"sub_type": sub_type,
|
||||
"favorite": embedding_data.get("favorite", False),
|
||||
"exclude": bool(embedding_data.get("exclude", False)),
|
||||
"update_available": bool(embedding_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
|
||||
|
||||
@@ -48,6 +48,7 @@ class LoraService(BaseModelService):
|
||||
"usage_tips": lora_data.get("usage_tips", ""),
|
||||
"notes": lora_data.get("notes", ""),
|
||||
"favorite": lora_data.get("favorite", False),
|
||||
"exclude": bool(lora_data.get("exclude", False)),
|
||||
"update_available": bool(lora_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(
|
||||
lora_data.get("skip_metadata_refresh", False)
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Opti
|
||||
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -207,11 +208,56 @@ class ModelLifecycleService:
|
||||
|
||||
excluded = getattr(self._scanner, "_excluded_models", None)
|
||||
if isinstance(excluded, list):
|
||||
excluded.append(file_path)
|
||||
if file_path not in excluded:
|
||||
excluded.append(file_path)
|
||||
|
||||
persist_current_cache = getattr(self._scanner, "_persist_current_cache", None)
|
||||
if callable(persist_current_cache):
|
||||
await persist_current_cache()
|
||||
|
||||
message = f"Model {os.path.basename(file_path)} excluded"
|
||||
return {"success": True, "message": message}
|
||||
|
||||
async def unexclude_model(self, file_path: str) -> Dict[str, object]:
|
||||
"""Restore a previously excluded model to the active cache."""
|
||||
|
||||
if not file_path:
|
||||
raise ValueError("Model path is required")
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise ValueError("Model file does not exist")
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_payload = await self._metadata_loader(metadata_path)
|
||||
metadata_payload["exclude"] = False
|
||||
|
||||
await self._metadata_manager.save_metadata(file_path, metadata_payload)
|
||||
|
||||
metadata, should_skip = await MetadataManager.load_metadata(
|
||||
file_path,
|
||||
self._scanner.model_class,
|
||||
)
|
||||
if should_skip:
|
||||
metadata = None
|
||||
if metadata is None:
|
||||
metadata = metadata_payload
|
||||
|
||||
excluded = getattr(self._scanner, "_excluded_models", None)
|
||||
if isinstance(excluded, list):
|
||||
self._scanner._excluded_models = [
|
||||
path for path in excluded if path != file_path
|
||||
]
|
||||
|
||||
await self._scanner.update_single_model_cache(
|
||||
file_path,
|
||||
file_path,
|
||||
metadata,
|
||||
recalculate_type=True,
|
||||
)
|
||||
|
||||
message = f"Model {os.path.basename(file_path)} restored"
|
||||
return {"success": True, "message": message}
|
||||
|
||||
async def bulk_delete_models(self, file_paths: Iterable[str]) -> Dict[str, object]:
|
||||
"""Delete a collection of models via the scanner bulk operation."""
|
||||
|
||||
|
||||
@@ -1535,7 +1535,7 @@ class ModelScanner:
|
||||
return sorted_tags[:limit]
|
||||
|
||||
async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]:
|
||||
"""Get base models sorted by frequency"""
|
||||
"""Get base models sorted by count. If limit is 0, return all."""
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
base_model_counts = {}
|
||||
@@ -1546,7 +1546,9 @@ class ModelScanner:
|
||||
|
||||
sorted_models = [{'name': model, 'count': count} for model, count in base_model_counts.items()]
|
||||
sorted_models.sort(key=lambda x: x['count'], reverse=True)
|
||||
|
||||
|
||||
if limit == 0:
|
||||
return sorted_models
|
||||
return sorted_models[:limit]
|
||||
|
||||
async def get_model_info_by_name(self, name):
|
||||
|
||||
@@ -119,6 +119,24 @@ def extract_civitai_image_id(url: str | None) -> str | None:
|
||||
return path_match.group(1)
|
||||
|
||||
|
||||
def normalize_civitai_download_url(url: str | None) -> str | None:
|
||||
"""Rewrite Civitai download URLs to the canonical authenticated host."""
|
||||
|
||||
if not url:
|
||||
return url
|
||||
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return url
|
||||
|
||||
hostname = parsed.hostname.lower() if parsed.hostname else None
|
||||
if hostname != "civitai.red" or not parsed.path.startswith("/api/download/"):
|
||||
return url
|
||||
|
||||
return urlunparse(parsed._replace(netloc="civitai.com"))
|
||||
|
||||
|
||||
def extract_civitai_page_host(url: str | None) -> str | None:
|
||||
"""Extract the supported Civitai page host from a URL."""
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.0.4"
|
||||
version = "1.0.5"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -243,3 +243,58 @@
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.excluded-view-banner {
|
||||
margin-bottom: var(--space-2);
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08),
|
||||
var(--card-bg)
|
||||
);
|
||||
}
|
||||
|
||||
.excluded-view-banner__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.excluded-view-banner__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.excluded-view-banner__back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.excluded-view-banner__back:hover {
|
||||
border-color: var(--lora-accent);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.excluded-view-banner__content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.excluded-view-banner__back {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,3 +680,22 @@
|
||||
margin-left: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.excluded-model {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.model-excluded-badge {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in oklab, var(--warning-color, #d97706) 85%, white 15%);
|
||||
color: white;
|
||||
font-size: 0.65rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
top: 50px; /* Position below header */
|
||||
width: 320px;
|
||||
width: 366px;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-base);
|
||||
@@ -197,6 +197,31 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-search-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filter-search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 76, 175, 80), 0.15);
|
||||
}
|
||||
|
||||
.filter-empty-state {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.filter-section h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
@@ -733,4 +758,4 @@
|
||||
right: 20px;
|
||||
top: 160px; /* Adjusted for mobile layout */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +56,10 @@ export function getApiEndpoints(modelType) {
|
||||
return {
|
||||
// Base CRUD operations
|
||||
list: `/api/lm/${modelType}/list`,
|
||||
excluded: `/api/lm/${modelType}/excluded`,
|
||||
delete: `/api/lm/${modelType}/delete`,
|
||||
exclude: `/api/lm/${modelType}/exclude`,
|
||||
unexclude: `/api/lm/${modelType}/unexclude`,
|
||||
rename: `/api/lm/${modelType}/rename`,
|
||||
save: `/api/lm/${modelType}/save-metadata`,
|
||||
cancelTask: `/api/lm/${modelType}/cancel-task`,
|
||||
|
||||
@@ -51,6 +51,7 @@ export class BaseModelApiClient {
|
||||
async fetchModelsPage(page = 1, pageSize = null) {
|
||||
const pageState = this.getPageState();
|
||||
const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize;
|
||||
const isExcludedView = pageState.viewMode === 'excluded';
|
||||
|
||||
try {
|
||||
const params = this._buildQueryParams({
|
||||
@@ -71,7 +72,10 @@ export class BaseModelApiClient {
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.apiConfig.endpoints.list}?${params}`);
|
||||
const endpoint = isExcludedView
|
||||
? this.apiConfig.endpoints.excluded
|
||||
: this.apiConfig.endpoints.list;
|
||||
const response = await fetch(`${endpoint}?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`);
|
||||
}
|
||||
@@ -84,7 +88,7 @@ export class BaseModelApiClient {
|
||||
totalPages: data.total_pages,
|
||||
currentPage: page,
|
||||
hasMore: page < data.total_pages,
|
||||
folders: data.folders
|
||||
folders: data.folders || []
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
@@ -212,6 +216,50 @@ export class BaseModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async unexcludeModel(filePath) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading(`Restoring ${this.apiConfig.config.singularName}...`);
|
||||
|
||||
const response = await fetch(this.apiConfig.endpoints.unexclude, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_path: filePath })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to restore ${this.apiConfig.config.singularName}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (state.virtualScroller) {
|
||||
state.virtualScroller.removeItemByFilePath(filePath);
|
||||
}
|
||||
showToast(
|
||||
'toast.api.restoreSuccess',
|
||||
{ type: this.apiConfig.config.displayName },
|
||||
'success',
|
||||
`Restored ${this.apiConfig.config.displayName}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error(data.error || `Failed to restore ${this.apiConfig.config.singularName}`);
|
||||
} catch (error) {
|
||||
console.error(`Error restoring ${this.apiConfig.config.singularName}:`, error);
|
||||
showToast(
|
||||
'toast.api.restoreFailed',
|
||||
{ type: this.apiConfig.config.singularName, message: error.message },
|
||||
'error',
|
||||
`Failed to restore ${this.apiConfig.config.singularName}: ${error.message}`
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
async renameModelFile(filePath, newFileName) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`);
|
||||
@@ -883,20 +931,21 @@ export class BaseModelApiClient {
|
||||
|
||||
_buildQueryParams(baseParams, pageState) {
|
||||
const params = new URLSearchParams(baseParams);
|
||||
const isExcludedView = pageState.viewMode === 'excluded';
|
||||
|
||||
if (pageState.activeFolder !== null) {
|
||||
if (!isExcludedView && pageState.activeFolder !== null) {
|
||||
params.append('folder', pageState.activeFolder);
|
||||
}
|
||||
|
||||
if (pageState.showFavoritesOnly) {
|
||||
if (!isExcludedView && pageState.showFavoritesOnly) {
|
||||
params.append('favorites_only', 'true');
|
||||
}
|
||||
|
||||
if (pageState.showUpdateAvailableOnly) {
|
||||
if (!isExcludedView && pageState.showUpdateAvailableOnly) {
|
||||
params.append('update_available_only', 'true');
|
||||
}
|
||||
|
||||
if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) {
|
||||
if (!isExcludedView && this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) {
|
||||
params.append('first_letter', pageState.activeLetterFilter);
|
||||
}
|
||||
|
||||
@@ -918,7 +967,7 @@ export class BaseModelApiClient {
|
||||
|
||||
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
|
||||
|
||||
if (pageState.filters) {
|
||||
if (!isExcludedView && pageState.filters) {
|
||||
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
|
||||
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
|
||||
if (state === 'include') {
|
||||
@@ -981,7 +1030,9 @@ export class BaseModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
this._addModelSpecificParams(params, pageState);
|
||||
if (!isExcludedView) {
|
||||
this._addModelSpecificParams(params, pageState);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
||||
|
||||
showMenu(x, y, card) {
|
||||
super.showMenu(x, y, card);
|
||||
this.updateExcludeMenuItem();
|
||||
|
||||
// Update the "Move to other root" label based on current model type
|
||||
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
|
||||
@@ -83,6 +84,9 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
||||
case 'exclude':
|
||||
showExcludeModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'restore':
|
||||
this.restoreExcludedModel(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ export class EmbeddingContextMenu extends BaseContextMenu {
|
||||
async saveModelMetadata(filePath, data) {
|
||||
return getModelApiClient().saveModelMetadata(filePath, data);
|
||||
}
|
||||
|
||||
showMenu(x, y, card) {
|
||||
super.showMenu(x, y, card);
|
||||
this.updateExcludeMenuItem();
|
||||
}
|
||||
|
||||
handleMenuAction(action) {
|
||||
// First try to handle with common actions
|
||||
@@ -56,6 +61,9 @@ export class EmbeddingContextMenu extends BaseContextMenu {
|
||||
case 'exclude':
|
||||
showExcludeModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'restore':
|
||||
this.restoreExcludedModel(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
const licenseRefreshItem = this.menu.querySelector('[data-action="fetch-missing-licenses"]');
|
||||
const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
|
||||
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
|
||||
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
|
||||
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
|
||||
|
||||
if (isRecipesPage) {
|
||||
@@ -29,12 +30,14 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
licenseRefreshItem?.classList.add('hidden');
|
||||
downloadExamplesItem?.classList.add('hidden');
|
||||
cleanupExamplesItem?.classList.add('hidden');
|
||||
excludedModelsItem?.classList.add('hidden');
|
||||
repairRecipesItem?.classList.remove('hidden');
|
||||
} else {
|
||||
modelUpdateItem?.classList.remove('hidden');
|
||||
licenseRefreshItem?.classList.remove('hidden');
|
||||
downloadExamplesItem?.classList.remove('hidden');
|
||||
cleanupExamplesItem?.classList.remove('hidden');
|
||||
excludedModelsItem?.classList.remove('hidden');
|
||||
repairRecipesItem?.classList.add('hidden');
|
||||
}
|
||||
|
||||
@@ -68,12 +71,21 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
console.error('Failed to repair recipes:', error);
|
||||
});
|
||||
break;
|
||||
case 'manage-excluded-models':
|
||||
this.manageExcludedModels();
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unhandled global context menu action: ${action}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
manageExcludedModels() {
|
||||
window.pageControls?.enterExcludedView?.().catch((error) => {
|
||||
console.error('Failed to open excluded models view:', error);
|
||||
});
|
||||
}
|
||||
|
||||
async downloadExampleImages(menuItem) {
|
||||
const downloadPath = state?.global?.settings?.example_images_path;
|
||||
if (!downloadPath) {
|
||||
|
||||
@@ -20,6 +20,11 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
return getModelApiClient().saveModelMetadata(filePath, data);
|
||||
}
|
||||
|
||||
showMenu(x, y, card) {
|
||||
super.showMenu(x, y, card);
|
||||
this.updateExcludeMenuItem();
|
||||
}
|
||||
|
||||
handleMenuAction(action, menuItem) {
|
||||
// First try to handle with common actions
|
||||
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
||||
@@ -61,6 +66,9 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
case 'exclude':
|
||||
showExcludeModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'restore':
|
||||
this.restoreExcludedModel(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,39 @@ import { extractCivitaiModelUrlParts } from '../../utils/civitaiUtils.js';
|
||||
|
||||
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
|
||||
export const ModelContextMenuMixin = {
|
||||
isExcludedView() {
|
||||
return state?.pages?.[state.currentPageType]?.viewMode === 'excluded';
|
||||
},
|
||||
|
||||
updateExcludeMenuItem() {
|
||||
const excludeItem = this.menu?.querySelector('[data-action="exclude"], [data-action="restore"]');
|
||||
if (!excludeItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExcludedView = this.isExcludedView();
|
||||
excludeItem.dataset.action = isExcludedView ? 'restore' : 'exclude';
|
||||
excludeItem.innerHTML = isExcludedView
|
||||
? `<i class="fas fa-undo"></i> <span>${translate('loras.contextMenu.restoreModel', {}, 'Restore model')}</span>`
|
||||
: `<i class="fas fa-eye-slash"></i> <span>${translate('loras.contextMenu.excludeModel', {}, 'Exclude model')}</span>`;
|
||||
},
|
||||
|
||||
async restoreExcludedModel(filePath) {
|
||||
const restored = await getModelApiClient().unexcludeModel(filePath);
|
||||
if (!restored) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.pageControls?.exitExcludedView) {
|
||||
await window.pageControls.exitExcludedView();
|
||||
} else {
|
||||
const resetFn = this.resetAndReload || resetAndReload;
|
||||
if (typeof resetFn === 'function') {
|
||||
await resetFn(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// NSFW Selector methods
|
||||
initNSFWSelector() {
|
||||
if (this._nsfwSelectorInitialized) {
|
||||
|
||||
@@ -38,8 +38,12 @@ export class PageControls {
|
||||
|
||||
// Initialize favorites filter button state
|
||||
this.initFavoritesFilter();
|
||||
|
||||
this.initExcludedViewControls();
|
||||
this.syncExcludedViewState();
|
||||
|
||||
console.log(`PageControls initialized for ${pageType} page`);
|
||||
window.pageControls = this;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +60,19 @@ export class PageControls {
|
||||
|
||||
// Load sort preference
|
||||
this.loadSortPreference();
|
||||
|
||||
if (!this.pageState.viewMode) {
|
||||
this.pageState.viewMode = 'active';
|
||||
}
|
||||
if (!this.pageState.excludedViewState) {
|
||||
this.pageState.excludedViewState = {
|
||||
sortBy: 'name:asc',
|
||||
search: '',
|
||||
};
|
||||
}
|
||||
if (!this.pageState.filters?.search) {
|
||||
this.pageState.filters.search = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +133,15 @@ export class PageControls {
|
||||
// Page-specific event listeners
|
||||
this.initPageSpecificListeners();
|
||||
}
|
||||
|
||||
initExcludedViewControls() {
|
||||
const backButton = document.getElementById('excludedViewBackBtn');
|
||||
if (backButton) {
|
||||
backButton.addEventListener('click', async () => {
|
||||
await this.exitExcludedView();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize dropdown functionality
|
||||
@@ -334,6 +360,13 @@ export class PageControls {
|
||||
* @param {string} sortValue - The sort value to save
|
||||
*/
|
||||
saveSortPreference(sortValue) {
|
||||
if (this.pageState.viewMode === 'excluded') {
|
||||
this.pageState.excludedViewState = {
|
||||
...(this.pageState.excludedViewState || {}),
|
||||
sortBy: sortValue,
|
||||
};
|
||||
return;
|
||||
}
|
||||
setStorageItem(`${this.pageType}_sort`, sortValue);
|
||||
}
|
||||
|
||||
@@ -473,6 +506,8 @@ export class PageControls {
|
||||
// Update app state
|
||||
this.pageState.showFavoritesOnly = showFavoritesOnly;
|
||||
}
|
||||
|
||||
this.updateActionButtonStates();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -489,12 +524,17 @@ export class PageControls {
|
||||
if (updateFilterBtn) {
|
||||
updateFilterBtn.classList.toggle('active', showUpdatesOnly);
|
||||
}
|
||||
|
||||
this.updateActionButtonStates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorites-only filter and reload models
|
||||
*/
|
||||
async toggleFavoritesOnly() {
|
||||
if (this.pageState.viewMode === 'excluded') {
|
||||
return;
|
||||
}
|
||||
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
||||
|
||||
// Toggle the filter state in storage
|
||||
@@ -521,6 +561,9 @@ export class PageControls {
|
||||
* Toggle update-available-only filter and reload models
|
||||
*/
|
||||
async toggleUpdateAvailableOnly() {
|
||||
if (this.pageState.viewMode === 'excluded') {
|
||||
return;
|
||||
}
|
||||
const updateFilterBtn = document.getElementById('updateFilterBtn');
|
||||
const storageKey = `show_update_available_only_${this.pageType}`;
|
||||
const newState = !this.pageState.showUpdateAvailableOnly;
|
||||
@@ -535,6 +578,234 @@ export class PageControls {
|
||||
|
||||
await this.resetAndReload(true);
|
||||
}
|
||||
|
||||
cloneFilters(filters = this.pageState.filters) {
|
||||
return JSON.parse(JSON.stringify(filters || {}));
|
||||
}
|
||||
|
||||
buildExcludedFilters(search = '') {
|
||||
return {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: [],
|
||||
search,
|
||||
tagLogic: 'any',
|
||||
};
|
||||
}
|
||||
|
||||
applyFilterState(filters) {
|
||||
this.pageState.filters = filters;
|
||||
|
||||
if (window.filterManager) {
|
||||
window.filterManager.filters = window.filterManager.initializeFilters(filters);
|
||||
window.filterManager.updateActiveFiltersCount();
|
||||
if (typeof window.filterManager.updateSelections === 'function') {
|
||||
window.filterManager.updateSelections();
|
||||
}
|
||||
window.filterManager.closeFilterPanel();
|
||||
}
|
||||
}
|
||||
|
||||
updateActionButtonStates() {
|
||||
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
||||
if (favoriteFilterBtn) {
|
||||
favoriteFilterBtn.classList.toggle('active', Boolean(this.pageState.showFavoritesOnly));
|
||||
}
|
||||
|
||||
const updateFilterBtn = document.getElementById('updateFilterBtn');
|
||||
if (updateFilterBtn) {
|
||||
updateFilterBtn.classList.toggle('active', Boolean(this.pageState.showUpdateAvailableOnly));
|
||||
}
|
||||
}
|
||||
|
||||
syncExcludedViewState() {
|
||||
const isExcludedView = this.pageState.viewMode === 'excluded';
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const excludedBanner = document.getElementById('excludedViewBanner');
|
||||
const filterButton = document.getElementById('filterButton');
|
||||
const breadcrumbContainer = document.getElementById('breadcrumbContainer');
|
||||
const duplicatesBanner = document.getElementById('duplicatesBanner');
|
||||
const alphabetBarContainer = document.querySelector('.alphabet-bar-container');
|
||||
const hiddenSelectors = [
|
||||
'[data-action="fetch"]',
|
||||
'[data-action="download"]',
|
||||
'[data-action="bulk"]',
|
||||
'[data-action="find-duplicates"]',
|
||||
'#favoriteFilterBtn',
|
||||
'.update-filter-group',
|
||||
];
|
||||
const customFilterIndicator = document.getElementById('customFilterIndicator');
|
||||
|
||||
document.body.classList.toggle('excluded-view-active', isExcludedView);
|
||||
excludedBanner?.classList.toggle('hidden', !isExcludedView);
|
||||
breadcrumbContainer?.classList.toggle('hidden', isExcludedView);
|
||||
alphabetBarContainer?.classList.toggle('hidden', isExcludedView);
|
||||
|
||||
if (duplicatesBanner && isExcludedView) {
|
||||
duplicatesBanner.style.display = 'none';
|
||||
}
|
||||
|
||||
hiddenSelectors.forEach((selector) => {
|
||||
document.querySelectorAll(selector).forEach((element) => {
|
||||
element.classList.toggle('hidden', isExcludedView);
|
||||
});
|
||||
});
|
||||
|
||||
if (customFilterIndicator && isExcludedView) {
|
||||
customFilterIndicator.classList.add('hidden');
|
||||
}
|
||||
|
||||
if (filterButton) {
|
||||
filterButton.disabled = isExcludedView;
|
||||
filterButton.classList.toggle('hidden', isExcludedView);
|
||||
}
|
||||
|
||||
const activeFiltersCount = document.getElementById('activeFiltersCount');
|
||||
if (activeFiltersCount && isExcludedView) {
|
||||
activeFiltersCount.style.display = 'none';
|
||||
}
|
||||
|
||||
if (sortSelect) {
|
||||
sortSelect.value = this.pageState.sortBy;
|
||||
}
|
||||
if (searchInput) {
|
||||
searchInput.value = this.pageState.filters?.search || '';
|
||||
}
|
||||
|
||||
this.updateActionButtonStates();
|
||||
|
||||
if (this.sidebarManager) {
|
||||
const shouldShowSidebar = !isExcludedView && state?.global?.settings?.show_folder_sidebar !== false;
|
||||
this.sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {
|
||||
console.error('Failed to update sidebar visibility:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
suspendInteractiveModes() {
|
||||
const snapshot = {
|
||||
bulkMode: Boolean(state.bulkMode),
|
||||
duplicatesMode: Boolean(this.pageState.duplicatesMode),
|
||||
};
|
||||
|
||||
if (snapshot.bulkMode && window.bulkManager?.toggleBulkMode) {
|
||||
window.bulkManager.toggleBulkMode();
|
||||
}
|
||||
|
||||
if (snapshot.duplicatesMode && window.modelDuplicatesManager?.exitDuplicateMode) {
|
||||
window.modelDuplicatesManager.exitDuplicateMode();
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async restoreInteractiveModes(snapshot = {}) {
|
||||
if (snapshot.bulkMode && !state.bulkMode && window.bulkManager?.toggleBulkMode) {
|
||||
window.bulkManager.toggleBulkMode();
|
||||
}
|
||||
|
||||
if (!snapshot.duplicatesMode || this.pageState.duplicatesMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duplicatesManager = window.modelDuplicatesManager;
|
||||
if (!duplicatesManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof duplicatesManager.enterDuplicateMode === 'function' &&
|
||||
Array.isArray(duplicatesManager.duplicateGroups) &&
|
||||
duplicatesManager.duplicateGroups.length > 0) {
|
||||
duplicatesManager.enterDuplicateMode();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof duplicatesManager.findDuplicates === 'function') {
|
||||
await duplicatesManager.findDuplicates();
|
||||
}
|
||||
}
|
||||
|
||||
syncCustomFilterIndicator() {
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
if (!indicator) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.pageState.viewMode === 'excluded') {
|
||||
indicator.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this.checkCustomFilters === 'function') {
|
||||
this.checkCustomFilters();
|
||||
}
|
||||
}
|
||||
|
||||
async enterExcludedView() {
|
||||
if (this.pageState.viewMode === 'excluded') {
|
||||
return;
|
||||
}
|
||||
|
||||
const interactionSnapshot = this.suspendInteractiveModes();
|
||||
|
||||
this.pageState.activeViewSnapshot = {
|
||||
sortBy: this.pageState.sortBy,
|
||||
activeFolder: this.pageState.activeFolder,
|
||||
activeLetterFilter: this.pageState.activeLetterFilter ?? null,
|
||||
showFavoritesOnly: this.pageState.showFavoritesOnly,
|
||||
showUpdateAvailableOnly: this.pageState.showUpdateAvailableOnly,
|
||||
bulkMode: interactionSnapshot.bulkMode,
|
||||
duplicatesMode: interactionSnapshot.duplicatesMode,
|
||||
filters: this.cloneFilters(),
|
||||
};
|
||||
|
||||
const excludedState = this.pageState.excludedViewState || {
|
||||
sortBy: 'name:asc',
|
||||
search: '',
|
||||
};
|
||||
|
||||
this.pageState.viewMode = 'excluded';
|
||||
this.pageState.sortBy = excludedState.sortBy || 'name:asc';
|
||||
this.pageState.currentPage = 1;
|
||||
this.pageState.activeFolder = null;
|
||||
this.pageState.activeLetterFilter = null;
|
||||
this.pageState.showFavoritesOnly = false;
|
||||
this.pageState.showUpdateAvailableOnly = false;
|
||||
|
||||
this.applyFilterState(this.buildExcludedFilters(excludedState.search || ''));
|
||||
this.syncExcludedViewState();
|
||||
await this.resetAndReload(false);
|
||||
}
|
||||
|
||||
async exitExcludedView() {
|
||||
if (this.pageState.viewMode !== 'excluded') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pageState.excludedViewState = {
|
||||
...(this.pageState.excludedViewState || {}),
|
||||
sortBy: this.pageState.sortBy,
|
||||
search: this.pageState.filters?.search || '',
|
||||
};
|
||||
|
||||
const snapshot = this.pageState.activeViewSnapshot || {};
|
||||
this.pageState.viewMode = 'active';
|
||||
this.pageState.sortBy = snapshot.sortBy || this.convertLegacySortFormat(getStorageItem(`${this.pageType}_sort`) || 'name:asc');
|
||||
this.pageState.currentPage = 1;
|
||||
this.pageState.activeFolder = snapshot.activeFolder ?? getStorageItem(`${this.pageType}_activeFolder`);
|
||||
this.pageState.activeLetterFilter = snapshot.activeLetterFilter ?? null;
|
||||
this.pageState.showFavoritesOnly = Boolean(snapshot.showFavoritesOnly);
|
||||
this.pageState.showUpdateAvailableOnly = Boolean(snapshot.showUpdateAvailableOnly);
|
||||
this.applyFilterState(snapshot.filters || this.buildExcludedFilters(''));
|
||||
this.pageState.activeViewSnapshot = null;
|
||||
|
||||
this.syncExcludedViewState();
|
||||
await this.resetAndReload(true);
|
||||
this.syncCustomFilterIndicator();
|
||||
await this.restoreInteractiveModes(snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find duplicate models
|
||||
|
||||
@@ -433,10 +433,11 @@ export function createModelCard(model, modelType) {
|
||||
card.dataset.usage_count = String(model.usage_count);
|
||||
card.dataset.notes = model.notes || '';
|
||||
card.dataset.base_model = model.base_model || 'Unknown';
|
||||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||||
const hasUpdateAvailable = Boolean(model.update_available);
|
||||
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
||||
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
|
||||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||||
card.dataset.exclude = model.exclude ? 'true' : 'false';
|
||||
const hasUpdateAvailable = Boolean(model.update_available);
|
||||
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
||||
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
|
||||
|
||||
// To only show usage_count when sorting by usage.
|
||||
const pageState = getCurrentPageState();
|
||||
@@ -487,6 +488,9 @@ export function createModelCard(model, modelType) {
|
||||
if (model.skip_metadata_refresh) {
|
||||
card.classList.add('skip-refresh');
|
||||
}
|
||||
if (model.exclude) {
|
||||
card.classList.add('excluded-model');
|
||||
}
|
||||
|
||||
// Apply selection state if in bulk mode and this card is in the selected set (LoRA only)
|
||||
if (modelType === MODEL_TYPES.LORA && state.bulkMode && state.selectedLoras.has(model.file_path)) {
|
||||
@@ -619,6 +623,11 @@ export function createModelCard(model, modelType) {
|
||||
<i class="fas fa-ban"></i>
|
||||
</span>
|
||||
` : ''}
|
||||
${model.exclude ? `
|
||||
<span class="model-excluded-badge" title="${translate('globalContextMenu.manageExcludedModels.label', {}, 'Excluded Models')}">
|
||||
<i class="fas fa-eye-slash"></i>
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
${actionIcons}
|
||||
|
||||
@@ -240,9 +240,7 @@ export class BulkManager {
|
||||
*/
|
||||
handleGlobalKeyboard(e) {
|
||||
// Skip if modal is open (handled by event manager conditions)
|
||||
// Skip if search input is focused
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchInput && document.activeElement === searchInput) {
|
||||
if (this.isEditingTextInputContext(e.target)) {
|
||||
return false; // Don't handle, allow default behavior
|
||||
}
|
||||
|
||||
@@ -266,6 +264,26 @@ export class BulkManager {
|
||||
return false; // Continue with other handlers
|
||||
}
|
||||
|
||||
isEditingTextInputContext(target) {
|
||||
const activeElement = document.activeElement;
|
||||
const candidate = target instanceof Element ? target : activeElement;
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tagName = candidate.tagName?.toLowerCase();
|
||||
if (
|
||||
candidate.isContentEditable
|
||||
|| tagName === 'input'
|
||||
|| tagName === 'textarea'
|
||||
|| tagName === 'select'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(candidate.closest?.('#filterPanel'));
|
||||
}
|
||||
|
||||
toggleBulkMode() {
|
||||
state.bulkMode = !state.bulkMode;
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ export class FilterManager {
|
||||
this.filterPanel = document.getElementById('filterPanel');
|
||||
this.filterButton = document.getElementById('filterButton');
|
||||
this.activeFiltersCount = document.getElementById('activeFiltersCount');
|
||||
this.baseModelSearchInput = document.getElementById('baseModelSearchInput');
|
||||
this.baseModelOptions = [];
|
||||
this.tagsLoaded = false;
|
||||
|
||||
// Initialize preset manager
|
||||
@@ -49,6 +51,8 @@ export class FilterManager {
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.initializeFilterSearchInputs();
|
||||
|
||||
// Create base model filter tags if they exist
|
||||
if (document.getElementById('baseModelTags')) {
|
||||
this.createBaseModelTags();
|
||||
@@ -110,6 +114,18 @@ export class FilterManager {
|
||||
this.updateTagLogicToggleUI();
|
||||
}
|
||||
|
||||
initializeFilterSearchInputs() {
|
||||
if (this.baseModelSearchInput) {
|
||||
this.baseModelSearchInput.addEventListener('input', () => {
|
||||
this.renderBaseModelTags();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getNormalizedSearchQuery(input) {
|
||||
return (input?.value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
updateTagLogicToggleUI() {
|
||||
const toggleContainer = document.getElementById('tagLogicToggle');
|
||||
if (!toggleContainer) return;
|
||||
@@ -164,11 +180,6 @@ export class FilterManager {
|
||||
|
||||
tagsContainer.innerHTML = '';
|
||||
|
||||
if (!tags.length) {
|
||||
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect existing tag names from the API response
|
||||
const existingTagNames = new Set(tags.map(t => t.tag));
|
||||
|
||||
@@ -186,6 +197,11 @@ export class FilterManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (!tags.length) {
|
||||
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tags.forEach(tag => {
|
||||
const tagEl = document.createElement('div');
|
||||
tagEl.className = 'filter-tag tag-filter';
|
||||
@@ -212,7 +228,6 @@ export class FilterManager {
|
||||
await this.applyFilters(false);
|
||||
});
|
||||
|
||||
this.applyTagElementState(tagEl, (this.filters.tags && this.filters.tags[tagName]) || 'none');
|
||||
tagsContainer.appendChild(tagEl);
|
||||
});
|
||||
|
||||
@@ -235,8 +250,8 @@ export class FilterManager {
|
||||
await this.applyFilters(false);
|
||||
});
|
||||
|
||||
this.applyTagElementState(noTagsEl, (this.filters.tags && this.filters.tags[noTagsKey]) || 'none');
|
||||
tagsContainer.appendChild(noTagsEl);
|
||||
this.updateTagSelections();
|
||||
}
|
||||
|
||||
initializeLicenseFilters() {
|
||||
@@ -323,44 +338,15 @@ export class FilterManager {
|
||||
if (!baseModelTagsContainer) return;
|
||||
|
||||
// Set the API endpoint based on current page
|
||||
const apiEndpoint = `/api/lm/${this.currentPage}/base-models`;
|
||||
const apiEndpoint = `/api/lm/${this.currentPage}/base-models?limit=0`;
|
||||
|
||||
// Fetch base models
|
||||
fetch(apiEndpoint)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.base_models) {
|
||||
baseModelTagsContainer.innerHTML = '';
|
||||
|
||||
data.base_models.forEach(model => {
|
||||
const tag = document.createElement('div');
|
||||
tag.className = `filter-tag base-model-tag`;
|
||||
tag.dataset.baseModel = model.name;
|
||||
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
|
||||
|
||||
// Add click handler to toggle selection and automatically apply
|
||||
tag.addEventListener('click', async () => {
|
||||
tag.classList.toggle('active');
|
||||
|
||||
if (tag.classList.contains('active')) {
|
||||
if (!this.filters.baseModel.includes(model.name)) {
|
||||
this.filters.baseModel.push(model.name);
|
||||
}
|
||||
} else {
|
||||
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
|
||||
}
|
||||
|
||||
this.updateActiveFiltersCount();
|
||||
|
||||
// Auto-apply filter when tag is clicked
|
||||
await this.applyFilters(false);
|
||||
});
|
||||
|
||||
baseModelTagsContainer.appendChild(tag);
|
||||
});
|
||||
|
||||
// Update selections based on stored filters
|
||||
this.updateTagSelections();
|
||||
this.baseModelOptions = data.base_models;
|
||||
this.renderBaseModelTags();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -369,6 +355,57 @@ export class FilterManager {
|
||||
});
|
||||
}
|
||||
|
||||
renderBaseModelTags() {
|
||||
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
||||
const emptyState = document.getElementById('baseModelEmptyState');
|
||||
if (!baseModelTagsContainer) return;
|
||||
|
||||
baseModelTagsContainer.innerHTML = '';
|
||||
|
||||
if (!this.baseModelOptions.length) {
|
||||
baseModelTagsContainer.innerHTML = '<div class="no-tags">No base models available</div>';
|
||||
if (emptyState) {
|
||||
emptyState.hidden = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const query = this.getNormalizedSearchQuery(this.baseModelSearchInput);
|
||||
const filteredModels = query
|
||||
? this.baseModelOptions.filter(model => model.name.toLowerCase().includes(query))
|
||||
: this.baseModelOptions;
|
||||
|
||||
filteredModels.forEach(model => {
|
||||
const tag = document.createElement('div');
|
||||
tag.className = 'filter-tag base-model-tag';
|
||||
tag.dataset.baseModel = model.name;
|
||||
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
|
||||
|
||||
tag.addEventListener('click', async () => {
|
||||
tag.classList.toggle('active');
|
||||
|
||||
if (tag.classList.contains('active')) {
|
||||
if (!this.filters.baseModel.includes(model.name)) {
|
||||
this.filters.baseModel.push(model.name);
|
||||
}
|
||||
} else {
|
||||
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
|
||||
}
|
||||
|
||||
this.updateActiveFiltersCount();
|
||||
await this.applyFilters(false);
|
||||
});
|
||||
|
||||
baseModelTagsContainer.appendChild(tag);
|
||||
});
|
||||
|
||||
if (emptyState) {
|
||||
emptyState.hidden = filteredModels.length > 0;
|
||||
}
|
||||
|
||||
this.updateTagSelections();
|
||||
}
|
||||
|
||||
async createModelTypeTags() {
|
||||
const modelTypeContainer = document.getElementById('modelTypeTags');
|
||||
if (!modelTypeContainer) return;
|
||||
@@ -453,6 +490,7 @@ export class FilterManager {
|
||||
|
||||
this.filterPanel.classList.remove('hidden');
|
||||
this.filterButton.classList.add('active');
|
||||
this.baseModelSearchInput?.focus();
|
||||
|
||||
// Load tags if they haven't been loaded yet
|
||||
if (!this.tagsLoaded) {
|
||||
|
||||
@@ -90,7 +90,9 @@ export const state = {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: []
|
||||
modelTypes: [],
|
||||
search: '',
|
||||
tagLogic: 'any',
|
||||
},
|
||||
bulkMode: false,
|
||||
selectedLoras: new Set(),
|
||||
@@ -98,6 +100,12 @@ export const state = {
|
||||
showFavoritesOnly: false,
|
||||
showUpdateAvailableOnly: false,
|
||||
duplicatesMode: false,
|
||||
viewMode: 'active',
|
||||
excludedViewState: {
|
||||
sortBy: 'name:asc',
|
||||
search: '',
|
||||
},
|
||||
activeViewSnapshot: null,
|
||||
},
|
||||
|
||||
recipes: {
|
||||
@@ -147,7 +155,9 @@ export const state = {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: []
|
||||
modelTypes: [],
|
||||
search: '',
|
||||
tagLogic: 'any',
|
||||
},
|
||||
modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model'
|
||||
bulkMode: false,
|
||||
@@ -156,6 +166,12 @@ export const state = {
|
||||
showFavoritesOnly: false,
|
||||
showUpdateAvailableOnly: false,
|
||||
duplicatesMode: false,
|
||||
viewMode: 'active',
|
||||
excludedViewState: {
|
||||
sortBy: 'name:asc',
|
||||
search: '',
|
||||
},
|
||||
activeViewSnapshot: null,
|
||||
},
|
||||
|
||||
[MODEL_TYPES.EMBEDDING]: {
|
||||
@@ -178,7 +194,9 @@ export const state = {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: []
|
||||
modelTypes: [],
|
||||
search: '',
|
||||
tagLogic: 'any',
|
||||
},
|
||||
bulkMode: false,
|
||||
selectedModels: new Set(),
|
||||
@@ -186,6 +204,12 @@ export const state = {
|
||||
showFavoritesOnly: false,
|
||||
showUpdateAvailableOnly: false,
|
||||
duplicatesMode: false,
|
||||
viewMode: 'active',
|
||||
excludedViewState: {
|
||||
sortBy: 'name:asc',
|
||||
search: '',
|
||||
},
|
||||
activeViewSnapshot: null,
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export function initializeEventManagement() {
|
||||
setupPageUnloadCleanup();
|
||||
|
||||
// Register global event handlers that need coordination
|
||||
registerGlobalEventHandlers();
|
||||
registerContextMenuEvents();
|
||||
registerGlobalClickHandlers();
|
||||
|
||||
@@ -148,6 +149,10 @@ function registerGlobalClickHandlers() {
|
||||
* Register common application-wide event handlers
|
||||
*/
|
||||
export function registerGlobalEventHandlers() {
|
||||
eventManager.removeHandler('keydown', 'global-escape');
|
||||
eventManager.removeHandler('focusin', 'global-focus');
|
||||
eventManager.removeHandler('click', 'global-analytics');
|
||||
|
||||
// Escape key handler for closing modals/panels
|
||||
eventManager.addHandler('keydown', 'global-escape', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
@@ -156,6 +161,14 @@ export function registerGlobalEventHandlers() {
|
||||
modalManager.closeCurrentModal();
|
||||
return true; // Stop propagation
|
||||
}
|
||||
|
||||
if (
|
||||
window.filterManager?.filterPanel
|
||||
&& !window.filterManager.filterPanel.classList.contains('hidden')
|
||||
) {
|
||||
window.filterManager.closeFilterPanel();
|
||||
return true; // Stop propagation
|
||||
}
|
||||
|
||||
// Check if node selector is active and close it
|
||||
if (eventManager.getState('nodeSelectorActive')) {
|
||||
|
||||
@@ -111,6 +111,9 @@
|
||||
<div class="context-menu-item" data-action="cleanup-example-images-folders">
|
||||
<i class="fas fa-trash-restore"></i> <span>{{ t('globalContextMenu.cleanupExampleImages.label') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="manage-excluded-models">
|
||||
<i class="fas fa-eye-slash"></i> <span>{{ t('globalContextMenu.manageExcludedModels.label', default='Manage Excluded Models') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="repair-recipes">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
|
||||
</div>
|
||||
@@ -136,4 +139,4 @@
|
||||
|
||||
<div id="nodeSelector" class="node-selector">
|
||||
<!-- Dynamic node list will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
<div class="controls">
|
||||
<div id="excludedViewBanner" class="excluded-view-banner hidden">
|
||||
<div class="excluded-view-banner__content">
|
||||
<div class="excluded-view-banner__title">
|
||||
<i class="fas fa-eye-slash"></i>
|
||||
<span>{{ t('globalContextMenu.manageExcludedModels.label', default='Excluded Models') }}</span>
|
||||
</div>
|
||||
<button id="excludedViewBackBtn" class="excluded-view-banner__back">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span>{{ t('common.actions.back', default='Back') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="action-buttons">
|
||||
<div title="{{ t('loras.controls.sort.title') }}" class="control-group">
|
||||
|
||||
@@ -145,9 +145,22 @@
|
||||
|
||||
<div class="filter-section">
|
||||
<h4>{{ t('header.filter.baseModel') }}</h4>
|
||||
<input
|
||||
type="text"
|
||||
id="baseModelSearchInput"
|
||||
class="filter-search-input"
|
||||
placeholder="{{ t('header.filter.baseModelSearchPlaceholder') }}"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
>
|
||||
<div class="filter-tags" id="baseModelTags">
|
||||
<!-- Tags will be dynamically inserted here -->
|
||||
</div>
|
||||
<div id="baseModelEmptyState" class="filter-empty-state" hidden>
|
||||
{{ t('header.filter.noBaseModelMatches') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<div class="filter-section-header">
|
||||
@@ -188,4 +201,4 @@
|
||||
{{ t('header.filter.clearAll') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2102,6 +2102,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="context-menu-item" data-action="check-model-updates"></div>
|
||||
<div class="context-menu-item" data-action="fetch-missing-licenses"></div>
|
||||
<div class="context-menu-item" data-action="cleanup-example-images-folders"></div>
|
||||
<div class="context-menu-item" data-action="manage-excluded-models"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -2120,6 +2121,9 @@ describe('Interaction-level regression coverage', () => {
|
||||
startProgressUpdates: vi.fn(),
|
||||
updateDownloadButtonText: vi.fn(),
|
||||
};
|
||||
window.pageControls = {
|
||||
enterExcludedView: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
global.fetch = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
@@ -2224,5 +2228,10 @@ describe('Interaction-level regression coverage', () => {
|
||||
);
|
||||
expect(loadingManagerStub.showSimpleLoading).toHaveBeenNthCalledWith(2, 'Refreshing license metadata for LoRAs...');
|
||||
expect(fetchMissingItem.classList.contains('disabled')).toBe(false);
|
||||
|
||||
menu.showMenu(560, 600);
|
||||
const excludedItem = document.querySelector('[data-action="manage-excluded-models"]');
|
||||
excludedItem.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
expect(window.pageControls.enterExcludedView).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,6 +86,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.bulkManager;
|
||||
delete window.modelDuplicatesManager;
|
||||
delete global.fetch;
|
||||
vi.useRealTimers();
|
||||
@@ -109,11 +110,16 @@ function renderControlsDom(pageKey) {
|
||||
<div class="search-option-tag active" data-option="filename"></div>
|
||||
</div>
|
||||
<div id="filterPanel" class="filter-panel hidden">
|
||||
<input id="baseModelSearchInput" />
|
||||
<div id="baseModelTags" class="filter-tags"></div>
|
||||
<div id="baseModelEmptyState" hidden></div>
|
||||
<div id="modelTagsFilter" class="filter-tags"></div>
|
||||
<button class="clear-filter"></button>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div id="excludedViewBanner" class="excluded-view-banner hidden">
|
||||
<button id="excludedViewBackBtn">Back</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="action-buttons">
|
||||
<div class="control-group">
|
||||
@@ -172,6 +178,9 @@ function renderControlsDom(pageKey) {
|
||||
<i class="fas fa-times-circle clear-filter"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div id="breadcrumbContainer"></div>
|
||||
<div id="duplicatesBanner" style="display: none;"></div>
|
||||
<div class="alphabet-bar-container"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -279,6 +288,8 @@ describe('FilterManager tag and base model filters', () => {
|
||||
|
||||
const manager = new FilterManager({ page: pageKey });
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(`/api/lm/${pageKey}/base-models?limit=0`);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const chip = document.querySelector('[data-base-model="SDXL"]');
|
||||
expect(chip).not.toBeNull();
|
||||
@@ -304,6 +315,167 @@ describe('FilterManager tag and base model filters', () => {
|
||||
expect(getCurrentPageState().filters.baseModel).toEqual([]);
|
||||
expect(baseModelChip.classList.contains('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('filters base model chips locally without changing selected state', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
base_models: [
|
||||
{ name: 'SDXL', count: 2 },
|
||||
{ name: 'LTXV 2.3', count: 1 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
renderControlsDom('loras');
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
stateModule.initPageState('loras');
|
||||
const { getCurrentPageState } = stateModule;
|
||||
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
|
||||
|
||||
new FilterManager({ page: 'loras' });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[data-base-model="LTXV 2.3"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
const searchInput = document.getElementById('baseModelSearchInput');
|
||||
const ltxvChip = document.querySelector('[data-base-model="LTXV 2.3"]');
|
||||
ltxvChip.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
await vi.waitFor(() => expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledTimes(1));
|
||||
expect(getCurrentPageState().filters.baseModel).toEqual(['LTXV 2.3']);
|
||||
|
||||
loadMoreWithVirtualScrollMock.mockClear();
|
||||
searchInput.value = 'sdx';
|
||||
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
expect(document.querySelector('[data-base-model="SDXL"]')).not.toBeNull();
|
||||
expect(document.querySelector('[data-base-model="LTXV 2.3"]')).toBeNull();
|
||||
expect(document.getElementById('baseModelEmptyState').hidden).toBe(true);
|
||||
expect(getCurrentPageState().filters.baseModel).toEqual(['LTXV 2.3']);
|
||||
|
||||
searchInput.value = 'zzz';
|
||||
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
expect(document.getElementById('baseModelEmptyState').hidden).toBe(false);
|
||||
|
||||
searchInput.value = 'ltx';
|
||||
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const restoredChip = document.querySelector('[data-base-model="LTXV 2.3"]');
|
||||
expect(restoredChip).not.toBeNull();
|
||||
expect(restoredChip.classList.contains('active')).toBe(true);
|
||||
});
|
||||
|
||||
it('disables browser autocomplete helpers for the base model search input', async () => {
|
||||
renderControlsDom('loras');
|
||||
|
||||
const searchInput = document.getElementById('baseModelSearchInput');
|
||||
|
||||
searchInput.setAttribute('autocomplete', 'off');
|
||||
searchInput.setAttribute('autocorrect', 'off');
|
||||
searchInput.setAttribute('autocapitalize', 'none');
|
||||
searchInput.setAttribute('spellcheck', 'false');
|
||||
|
||||
expect(searchInput.getAttribute('autocomplete')).toBe('off');
|
||||
expect(searchInput.getAttribute('autocorrect')).toBe('off');
|
||||
expect(searchInput.getAttribute('autocapitalize')).toBe('none');
|
||||
expect(searchInput.getAttribute('spellcheck')).toBe('false');
|
||||
});
|
||||
|
||||
it('focuses the base model search input when opening the filter panel', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
base_models: [{ name: 'SDXL', count: 2 }],
|
||||
}),
|
||||
});
|
||||
|
||||
renderControlsDom('loras');
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
stateModule.initPageState('loras');
|
||||
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
|
||||
|
||||
const manager = new FilterManager({ page: 'loras' });
|
||||
const searchInput = document.getElementById('baseModelSearchInput');
|
||||
|
||||
expect(document.activeElement).not.toBe(searchInput);
|
||||
|
||||
manager.toggleFilterPanel();
|
||||
|
||||
expect(document.activeElement).toBe(searchInput);
|
||||
});
|
||||
|
||||
it('does not let base model search trigger bulk shortcuts', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
base_models: [{ name: 'SDXL', count: 2 }],
|
||||
}),
|
||||
});
|
||||
|
||||
renderControlsDom('loras');
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
stateModule.initPageState('loras');
|
||||
const { BulkManager } = await import('../../../static/js/managers/BulkManager.js');
|
||||
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
|
||||
|
||||
const filterManager = new FilterManager({ page: 'loras' });
|
||||
const bulkManager = new BulkManager();
|
||||
const searchInput = document.getElementById('baseModelSearchInput');
|
||||
window.filterManager = filterManager;
|
||||
|
||||
searchInput.focus();
|
||||
|
||||
const bulkEvent = new KeyboardEvent('keydown', {
|
||||
key: 'b',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
Object.defineProperty(bulkEvent, 'target', { value: searchInput });
|
||||
expect(bulkManager.handleGlobalKeyboard(bulkEvent)).toBe(false);
|
||||
|
||||
const selectAllEvent = new KeyboardEvent('keydown', {
|
||||
key: 'a',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
Object.defineProperty(selectAllEvent, 'target', { value: searchInput });
|
||||
expect(bulkManager.handleGlobalKeyboard(selectAllEvent)).toBe(false);
|
||||
});
|
||||
|
||||
it('closes the filter panel on Escape', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
base_models: [{ name: 'SDXL', count: 2 }],
|
||||
}),
|
||||
});
|
||||
|
||||
renderControlsDom('loras');
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
stateModule.initPageState('loras');
|
||||
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
|
||||
const { eventManager } = await import('../../../static/js/utils/EventManager.js');
|
||||
const { initializeEventManagement } = await import('../../../static/js/utils/eventManagementInit.js');
|
||||
|
||||
eventManager.cleanup();
|
||||
initializeEventManagement();
|
||||
|
||||
const manager = new FilterManager({ page: 'loras' });
|
||||
window.filterManager = manager;
|
||||
manager.toggleFilterPanel();
|
||||
expect(manager.filterPanel.classList.contains('hidden')).toBe(false);
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
|
||||
expect(manager.filterPanel.classList.contains('hidden')).toBe(true);
|
||||
eventManager.cleanup();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('PageControls favorites, sorting, and duplicates scenarios', () => {
|
||||
@@ -576,4 +748,93 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
|
||||
duplicateButton.click();
|
||||
expect(toggleDuplicateMode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['loras', 'LorasControls'],
|
||||
['checkpoints', 'CheckpointsControls'],
|
||||
['embeddings', 'EmbeddingsControls'],
|
||||
])('switches %s page into excluded mode and restores state', async (pageKey, exportName) => {
|
||||
renderControlsDom(pageKey);
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
stateModule.initPageState(pageKey);
|
||||
const pageState = stateModule.getCurrentPageState();
|
||||
pageState.filters.search = 'active-search';
|
||||
pageState.showFavoritesOnly = true;
|
||||
pageState.showUpdateAvailableOnly = true;
|
||||
|
||||
const controlsModule = await import('../../../static/js/components/controls/index.js');
|
||||
const ControlsClass = controlsModule[exportName];
|
||||
const controls = new ControlsClass();
|
||||
|
||||
await controls.enterExcludedView();
|
||||
|
||||
expect(pageState.viewMode).toBe('excluded');
|
||||
expect(pageState.filters.search).toBe('');
|
||||
expect(resetAndReloadMock).toHaveBeenLastCalledWith(false);
|
||||
expect(document.getElementById('excludedViewBanner').classList.contains('hidden')).toBe(false);
|
||||
expect(document.querySelector('[data-action="fetch"]').classList.contains('hidden')).toBe(true);
|
||||
expect(document.getElementById('filterButton').disabled).toBe(true);
|
||||
|
||||
pageState.filters.search = 'excluded-search';
|
||||
await controls.exitExcludedView();
|
||||
|
||||
expect(pageState.viewMode).toBe('active');
|
||||
expect(pageState.filters.search).toBe('active-search');
|
||||
expect(pageState.excludedViewState.search).toBe('excluded-search');
|
||||
expect(resetAndReloadMock).toHaveBeenLastCalledWith(true);
|
||||
expect(document.getElementById('excludedViewBanner').classList.contains('hidden')).toBe(true);
|
||||
expect(document.querySelector('[data-action="fetch"]').classList.contains('hidden')).toBe(false);
|
||||
expect(document.getElementById('filterButton').disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('suspends bulk and duplicate modes for excluded view and restores custom filter banner on exit', async () => {
|
||||
renderControlsDom('loras');
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
stateModule.initPageState('loras');
|
||||
const pageState = stateModule.getCurrentPageState();
|
||||
stateModule.state.bulkMode = true;
|
||||
pageState.duplicatesMode = true;
|
||||
|
||||
sessionStorage.setItem('lora_manager_recipe_to_lora_filterLoraHash', 'hash-1');
|
||||
sessionStorage.setItem('lora_manager_filterRecipeName', 'Recipe Filter');
|
||||
|
||||
const { LorasControls } = await import('../../../static/js/components/controls/LorasControls.js');
|
||||
|
||||
const toggleBulkMode = vi.fn(() => {
|
||||
stateModule.state.bulkMode = !stateModule.state.bulkMode;
|
||||
});
|
||||
const exitDuplicateMode = vi.fn(() => {
|
||||
pageState.duplicatesMode = false;
|
||||
});
|
||||
const enterDuplicateMode = vi.fn(() => {
|
||||
pageState.duplicatesMode = true;
|
||||
});
|
||||
|
||||
window.bulkManager = { toggleBulkMode };
|
||||
window.modelDuplicatesManager = {
|
||||
duplicateGroups: [{ hash: 'dup-1', models: [{ file_path: 'a' }, { file_path: 'b' }] }],
|
||||
exitDuplicateMode,
|
||||
enterDuplicateMode,
|
||||
};
|
||||
|
||||
const controls = new LorasControls();
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
expect(indicator.classList.contains('hidden')).toBe(false);
|
||||
|
||||
await controls.enterExcludedView();
|
||||
|
||||
expect(toggleBulkMode).toHaveBeenCalledTimes(1);
|
||||
expect(exitDuplicateMode).toHaveBeenCalledTimes(1);
|
||||
expect(stateModule.state.bulkMode).toBe(false);
|
||||
expect(pageState.duplicatesMode).toBe(false);
|
||||
expect(indicator.classList.contains('hidden')).toBe(true);
|
||||
|
||||
await controls.exitExcludedView();
|
||||
|
||||
expect(indicator.classList.contains('hidden')).toBe(false);
|
||||
expect(toggleBulkMode).toHaveBeenCalledTimes(2);
|
||||
expect(enterDuplicateMode).toHaveBeenCalledTimes(1);
|
||||
expect(stateModule.state.bulkMode).toBe(true);
|
||||
expect(pageState.duplicatesMode).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
38
tests/routes/test_model_query_handler.py
Normal file
38
tests/routes/test_model_query_handler.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import json
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from py.routes.handlers.model_handlers import ModelQueryHandler
|
||||
|
||||
|
||||
class DummyService:
|
||||
def __init__(self):
|
||||
self.received_limit = None
|
||||
|
||||
async def get_base_models(self, limit):
|
||||
self.received_limit = limit
|
||||
return [{"name": "SDXL", "count": 2}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_query_handler_accepts_limit_zero_for_base_models():
|
||||
service = DummyService()
|
||||
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||
|
||||
response = await handler.get_base_models(SimpleNamespace(query={"limit": "0"}))
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert payload["success"] is True
|
||||
assert service.received_limit == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_query_handler_rejects_negative_limit_for_base_models():
|
||||
service = DummyService()
|
||||
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||
|
||||
await handler.get_base_models(SimpleNamespace(query={"limit": "-1"}))
|
||||
|
||||
assert service.received_limit == 20
|
||||
44
tests/routes/test_recipe_query_handler.py
Normal file
44
tests/routes/test_recipe_query_handler.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import json
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from py.routes.handlers.recipe_handlers import RecipeQueryHandler
|
||||
|
||||
|
||||
async def _noop():
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recipe_query_handler_base_models_limit_zero_returns_all():
|
||||
cache = SimpleNamespace(
|
||||
raw_data=[
|
||||
{"base_model": "SDXL"},
|
||||
{"base_model": "LTXV 2.3"},
|
||||
{"base_model": "SDXL"},
|
||||
]
|
||||
)
|
||||
scanner = SimpleNamespace(get_cached_data=lambda: None)
|
||||
|
||||
async def get_cached_data():
|
||||
return cache
|
||||
|
||||
scanner.get_cached_data = get_cached_data
|
||||
|
||||
handler = RecipeQueryHandler(
|
||||
ensure_dependencies_ready=_noop,
|
||||
recipe_scanner_getter=lambda: scanner,
|
||||
format_recipe_file_url=lambda value: value,
|
||||
logger=logging.getLogger(__name__),
|
||||
)
|
||||
|
||||
response = await handler.get_base_models(SimpleNamespace(query={"limit": "0"}))
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert payload["success"] is True
|
||||
assert payload["base_models"] == [
|
||||
{"name": "SDXL", "count": 2},
|
||||
{"name": "LTXV 2.3", "count": 1},
|
||||
]
|
||||
@@ -369,8 +369,8 @@ async def test_execute_download_uses_auth_for_red_civitai_downloads(monkeypatch,
|
||||
)
|
||||
|
||||
assert result == {"success": True}
|
||||
assert recorded_use_auth == [("https://civitai.red/api/download/models/119514", True)]
|
||||
assert "https://civitai.red/api/download/".startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||
assert recorded_use_auth == [("https://civitai.com/api/download/models/119514", True)]
|
||||
assert "https://civitai.com/api/download/".startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -30,10 +30,21 @@ class FakeStream:
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status, headers, chunks):
|
||||
def __init__(
|
||||
self,
|
||||
status,
|
||||
headers,
|
||||
chunks,
|
||||
*,
|
||||
url="https://example.com/file",
|
||||
history=None,
|
||||
):
|
||||
self.status = status
|
||||
self.headers = headers
|
||||
self.content = FakeStream(chunks)
|
||||
self.url = url
|
||||
self.history = history or []
|
||||
self.released = False
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
@@ -41,14 +52,25 @@ class FakeResponse:
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def release(self):
|
||||
self.released = True
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, responses):
|
||||
self._responses = list(responses)
|
||||
self._get_calls = 0
|
||||
self.requests = []
|
||||
|
||||
def get(self, url, headers=None, allow_redirects=True, proxy=None): # noqa: D401 - signature mirrors aiohttp
|
||||
del url, headers, allow_redirects, proxy
|
||||
self.requests.append(
|
||||
{
|
||||
"url": url,
|
||||
"headers": headers or {},
|
||||
"allow_redirects": allow_redirects,
|
||||
"proxy": proxy,
|
||||
}
|
||||
)
|
||||
response_factory = self._responses[self._get_calls]
|
||||
self._get_calls += 1
|
||||
return response_factory()
|
||||
@@ -75,7 +97,7 @@ def _build_downloader(responses, *, max_retries=0):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_fails_when_size_mismatch(tmp_path):
|
||||
async def test_download_file_preserves_incomplete_part_when_size_mismatch(tmp_path):
|
||||
target_path = tmp_path / "model" / "file.bin"
|
||||
target_path.parent.mkdir()
|
||||
|
||||
@@ -94,7 +116,7 @@ async def test_download_file_fails_when_size_mismatch(tmp_path):
|
||||
assert success is False
|
||||
assert "mismatch" in message.lower()
|
||||
assert not target_path.exists()
|
||||
assert not Path(str(target_path) + ".part").exists()
|
||||
assert Path(str(target_path) + ".part").read_bytes() == b"abc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -136,7 +158,9 @@ async def test_download_file_succeeds_when_sizes_match(tmp_path):
|
||||
|
||||
downloader = _build_downloader(responses)
|
||||
|
||||
success, result_path = await downloader.download_file("https://example.com/file", str(target_path))
|
||||
success, result_path = await downloader.download_file(
|
||||
"https://example.com/file", str(target_path)
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert Path(result_path).read_bytes() == payload
|
||||
@@ -166,9 +190,77 @@ async def test_download_file_recovers_from_stall(tmp_path):
|
||||
downloader = _build_downloader(responses, max_retries=1)
|
||||
downloader.stall_timeout = 0.05
|
||||
|
||||
success, result_path = await downloader.download_file("https://example.com/file", str(target_path))
|
||||
success, result_path = await downloader.download_file(
|
||||
"https://example.com/file", str(target_path)
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert Path(result_path).read_bytes() == payload
|
||||
assert downloader._session._get_calls == 2
|
||||
assert not Path(str(target_path) + ".part").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_resumes_after_incomplete_integrity_check(tmp_path):
|
||||
target_path = tmp_path / "model" / "file.bin"
|
||||
target_path.parent.mkdir()
|
||||
|
||||
responses = [
|
||||
lambda: FakeResponse(
|
||||
status=200,
|
||||
headers={"content-length": "6"},
|
||||
chunks=[b"abc"],
|
||||
),
|
||||
lambda: FakeResponse(
|
||||
status=206,
|
||||
headers={"content-length": "3", "Content-Range": "bytes 3-5/6"},
|
||||
chunks=[b"def"],
|
||||
),
|
||||
]
|
||||
|
||||
downloader = _build_downloader(responses, max_retries=1)
|
||||
|
||||
success, result_path = await downloader.download_file("https://example.com/file", str(target_path))
|
||||
|
||||
assert success is True
|
||||
assert Path(result_path).read_bytes() == b"abcdef"
|
||||
assert downloader._session._get_calls == 2
|
||||
assert downloader._session.requests[1]["headers"]["Range"] == "bytes=3-"
|
||||
assert not Path(str(target_path) + ".part").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_retries_redirected_url_when_range_not_honored(tmp_path):
|
||||
target_path = tmp_path / "model" / "file.bin"
|
||||
target_path.parent.mkdir()
|
||||
Path(str(target_path) + ".part").write_bytes(b"abc")
|
||||
|
||||
redirected_url = "https://download.example.com/file.bin"
|
||||
first_response = FakeResponse(
|
||||
status=200,
|
||||
headers={"content-length": "6"},
|
||||
chunks=[],
|
||||
url=redirected_url,
|
||||
history=[object()],
|
||||
)
|
||||
|
||||
responses = [
|
||||
lambda: first_response,
|
||||
lambda: FakeResponse(
|
||||
status=206,
|
||||
headers={"content-length": "3", "Content-Range": "bytes 3-5/6"},
|
||||
chunks=[b"def"],
|
||||
url=redirected_url,
|
||||
),
|
||||
]
|
||||
|
||||
downloader = _build_downloader(responses, max_retries=0)
|
||||
|
||||
success, result_path = await downloader.download_file("https://example.com/file", str(target_path))
|
||||
|
||||
assert success is True
|
||||
assert Path(result_path).read_bytes() == b"abcdef"
|
||||
assert first_response.released is True
|
||||
assert downloader._session.requests[0]["headers"]["Range"] == "bytes=3-"
|
||||
assert downloader._session.requests[1]["url"] == redirected_url
|
||||
assert downloader._session.requests[1]["headers"]["Range"] == "bytes=3-"
|
||||
|
||||
@@ -5,6 +5,7 @@ import pytest
|
||||
|
||||
from py.services.model_lifecycle_service import ModelLifecycleService
|
||||
from py.utils.metadata_manager import MetadataManager
|
||||
from py.utils.models import LoraMetadata
|
||||
|
||||
|
||||
class DummyCache:
|
||||
@@ -445,6 +446,63 @@ async def test_exclude_model_empty_path_raises_error():
|
||||
await service.exclude_model("")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unexclude_model_restores_cache_entry(tmp_path: Path):
|
||||
"""Verify unexclude_model clears exclude metadata and restores cache entry."""
|
||||
model_path = tmp_path / "restored_model.safetensors"
|
||||
model_path.write_bytes(b"content")
|
||||
|
||||
metadata_payload = {
|
||||
"file_name": "restored_model",
|
||||
"model_name": "restored_model",
|
||||
"file_path": str(model_path),
|
||||
"sha256": "abc123",
|
||||
"exclude": True,
|
||||
"tags": ["tag1"],
|
||||
}
|
||||
metadata_path = tmp_path / "restored_model.metadata.json"
|
||||
metadata_path.write_text(json.dumps(metadata_payload))
|
||||
|
||||
class RestoreScanner:
|
||||
def __init__(self):
|
||||
self.model_type = "lora"
|
||||
self.model_class = LoraMetadata
|
||||
self._excluded_models = [str(model_path)]
|
||||
self.updated = []
|
||||
|
||||
async def update_single_model_cache(self, old_path, new_path, metadata, recalculate_type=False):
|
||||
exclude_value = metadata.get("exclude") if isinstance(metadata, dict) else metadata.exclude
|
||||
self.updated.append((old_path, new_path, exclude_value, recalculate_type))
|
||||
|
||||
saved_metadata = []
|
||||
|
||||
class SavingMetadataManager:
|
||||
async def save_metadata(self, path: str, metadata: dict):
|
||||
saved_metadata.append((path, metadata.copy()))
|
||||
await MetadataManager.save_metadata(path, metadata)
|
||||
|
||||
async def metadata_loader(path: str):
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
scanner = RestoreScanner()
|
||||
service = ModelLifecycleService(
|
||||
scanner=scanner,
|
||||
metadata_manager=SavingMetadataManager(),
|
||||
metadata_loader=metadata_loader,
|
||||
)
|
||||
|
||||
result = await service.unexclude_model(str(model_path))
|
||||
|
||||
assert result["success"] is True
|
||||
assert "restored" in result["message"].lower()
|
||||
assert scanner._excluded_models == []
|
||||
assert saved_metadata[0][1]["exclude"] is False
|
||||
assert scanner.updated == [
|
||||
(str(model_path), str(model_path), False, True)
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for bulk_delete_models functionality
|
||||
# =============================================================================
|
||||
|
||||
52
tests/services/test_model_scanner_base_models.py
Normal file
52
tests/services/test_model_scanner_base_models.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from py.services.model_scanner import ModelScanner
|
||||
|
||||
|
||||
class DummyScanner:
|
||||
def __init__(self, raw_data):
|
||||
self._cache = SimpleNamespace(raw_data=raw_data)
|
||||
|
||||
async def get_cached_data(self):
|
||||
return self._cache
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_base_models_limit_zero_returns_all_sorted():
|
||||
scanner = DummyScanner(
|
||||
[
|
||||
{"base_model": "SDXL"},
|
||||
{"base_model": "LTXV 2.3"},
|
||||
{"base_model": "SDXL"},
|
||||
{"base_model": ""},
|
||||
{},
|
||||
]
|
||||
)
|
||||
|
||||
result = await ModelScanner.get_base_models(scanner, limit=0)
|
||||
|
||||
assert result == [
|
||||
{"name": "SDXL", "count": 2},
|
||||
{"name": "LTXV 2.3", "count": 1},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_base_models_positive_limit_still_truncates():
|
||||
scanner = DummyScanner(
|
||||
[
|
||||
{"base_model": "SDXL"},
|
||||
{"base_model": "LTXV 2.3"},
|
||||
{"base_model": "Flux.1 D"},
|
||||
{"base_model": "SDXL"},
|
||||
]
|
||||
)
|
||||
|
||||
result = await ModelScanner.get_base_models(scanner, limit=2)
|
||||
|
||||
assert result == [
|
||||
{"name": "SDXL", "count": 2},
|
||||
{"name": "LTXV 2.3", "count": 1},
|
||||
]
|
||||
@@ -3,6 +3,7 @@ from py.utils.civitai_utils import (
|
||||
extract_civitai_image_id,
|
||||
extract_civitai_model_url_parts,
|
||||
is_supported_civitai_page_host,
|
||||
normalize_civitai_download_url,
|
||||
resolve_license_info,
|
||||
resolve_license_payload,
|
||||
)
|
||||
@@ -122,3 +123,24 @@ def test_extract_civitai_image_id_supports_red():
|
||||
|
||||
def test_extract_civitai_image_id_rejects_non_civitai_host():
|
||||
assert extract_civitai_image_id("https://example.com/images/126920345") is None
|
||||
|
||||
|
||||
def test_normalize_civitai_download_url_rewrites_red_to_com():
|
||||
url = "https://civitai.red/api/download/models/2786889?type=Model&format=SafeTensor"
|
||||
|
||||
assert (
|
||||
normalize_civitai_download_url(url)
|
||||
== "https://civitai.com/api/download/models/2786889?type=Model&format=SafeTensor"
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_civitai_download_url_keeps_non_download_red_urls():
|
||||
url = "https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777"
|
||||
|
||||
assert normalize_civitai_download_url(url) == url
|
||||
|
||||
|
||||
def test_normalize_civitai_download_url_keeps_existing_com_urls():
|
||||
url = "https://civitai.com/api/download/models/2786889?type=Model&format=SafeTensor"
|
||||
|
||||
assert normalize_civitai_download_url(url) == url
|
||||
|
||||
Reference in New Issue
Block a user