Compare commits

...

7 Commits

Author SHA1 Message Date
Will Miao 9d85c2a44a fix(ui): prevent tags widget from auto-resizing in Vue mode when tags change 2026-07-13 14:55:40 +08:00
Will Miao 03dd047e62 fix(download): return 200 instead of 500 when user cancels download 2026-07-13 11:47:48 +08:00
Will Miao 86b547c1e0 fix(locales): add missing downloadStopped key to toast.downloads section 2026-07-13 11:35:48 +08:00
Will Miao bab9752c8b fix(download): close modal before progress overlay and fix downloadId ReferenceError on cancel 2026-07-13 11:29:47 +08:00
Will Miao 774cc1be86 fix(download): use file ID for exact match, add debug logging for multi-file selection (#1023)
- Frontend: send file.id in file_params, use null instead of hardcoded defaults
- Backend: priority matching (ID exact → primary → lenient metadata)
- Lenient metadata: only compare fields present on both sides (fixes GGUF size mismatch)
- Add debug logs at key points: entry, file_params received, match result, anomaly signals
2026-07-13 11:15:03 +08:00
Will Miao 234b73c8a2 feat(ui): add cancel button to download progress modal 2026-07-13 09:40:53 +08:00
Will Miao abd06c48f4 fix(settings): reject checkpoints↔unet path overlap in extra folder paths with inline error UI
Changes:
- Backend: _validate_folder_paths() now checks checkpoints↔unet overlap
  within the same library using os.path.realpath() for symlink resolution
- Backend: set() calls _validate_folder_paths() for both folder_paths and
  extra_folder_paths before writing
- Backend: extracted _normalize_path_set() helper to eliminate duplicated
  normalization logic
- Frontend: inline error display with red border + error message below the
  conflicting input, no save triggered
- Frontend: path normalization (strip trailing slash, lowercase) in pre-check
  to reduce false negatives vs backend realpath
- Frontend: asymmetric error UX — message only on the user-edited side,
  red border on the pre-existing conflict side
- CSS: has-error styles with hardcoded rgba fallback for older browsers
- i18n: checkpointUnetOverlap + checkpointUnetOverlapInline keys added to
  all 10 locale files
2026-07-13 08:22:40 +08:00
21 changed files with 512 additions and 81 deletions
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "Zusätzliche Ordnerpfade aktualisiert. Neustart erforderlich, um Änderungen anzuwenden.", "saveSuccess": "Zusätzliche Ordnerpfade aktualisiert. Neustart erforderlich, um Änderungen anzuwenden.",
"saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}", "saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}",
"validation": { "validation": {
"duplicatePath": "Dieser Pfad ist bereits konfiguriert" "duplicatePath": "Dieser Pfad ist bereits konfiguriert",
"checkpointUnetOverlap": "Derselbe Pfad kann nicht für Checkpoints und Diffusionsmodelle verwendet werden: {paths}",
"checkpointUnetOverlapInline": "Dieser Pfad wird bereits für einen anderen Modelltyp verwendet. Bitte verwenden Sie separate Ordner für Checkpoints und Diffusionsmodelle."
} }
}, },
"priorityTags": { "priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "Download wird vorbereitet...", "preparing": "Download wird vorbereitet...",
"downloadedPreview": "Vorschaubild heruntergeladen", "downloadedPreview": "Vorschaubild heruntergeladen",
"downloadingFile": "{type}-Datei wird heruntergeladen", "downloadingFile": "{type}-Datei wird heruntergeladen",
"finalizing": "Download wird abgeschlossen..." "finalizing": "Download wird abgeschlossen...",
"cancelling": "Download wird abgebrochen...",
"cancelled": "Download abgebrochen"
}, },
"progress": { "progress": {
"currentFile": "Aktuelle Datei:", "currentFile": "Aktuelle Datei:",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "Beispielbilder {action} abgeschlossen", "imagesCompleted": "Beispielbilder {action} abgeschlossen",
"imagesFailed": "Beispielbilder {action} fehlgeschlagen", "imagesFailed": "Beispielbilder {action} fehlgeschlagen",
"loadError": "Fehler beim Laden der Downloads: {message}", "loadError": "Fehler beim Laden der Downloads: {message}",
"downloadError": "Download-Fehler: {message}" "downloadError": "Download-Fehler: {message}",
"downloadStopped": "Download abgebrochen"
}, },
"import": { "import": {
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums", "folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "Extra folder paths updated. Restart required to apply changes.", "saveSuccess": "Extra folder paths updated. Restart required to apply changes.",
"saveError": "Failed to update extra folder paths: {message}", "saveError": "Failed to update extra folder paths: {message}",
"validation": { "validation": {
"duplicatePath": "This path is already configured" "duplicatePath": "This path is already configured",
"checkpointUnetOverlap": "Cannot use the same path for both checkpoints and diffusion models: {paths}",
"checkpointUnetOverlapInline": "This path is also used for a different model type. Use separate folders for checkpoints and diffusion models."
} }
}, },
"priorityTags": { "priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "Preparing download...", "preparing": "Preparing download...",
"downloadedPreview": "Downloaded preview image", "downloadedPreview": "Downloaded preview image",
"downloadingFile": "Downloading {type} file", "downloadingFile": "Downloading {type} file",
"finalizing": "Finalizing download..." "finalizing": "Finalizing download...",
"cancelling": "Cancelling download...",
"cancelled": "Download cancelled"
}, },
"progress": { "progress": {
"currentFile": "Current file:", "currentFile": "Current file:",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "Example images {action} completed", "imagesCompleted": "Example images {action} completed",
"imagesFailed": "Example images {action} failed", "imagesFailed": "Example images {action} failed",
"loadError": "Error loading downloads: {message}", "loadError": "Error loading downloads: {message}",
"downloadError": "Download error: {message}" "downloadError": "Download error: {message}",
"downloadStopped": "Download cancelled"
}, },
"import": { "import": {
"folderTreeFailed": "Failed to load folder tree", "folderTreeFailed": "Failed to load folder tree",
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "Rutas de carpetas adicionales actualizadas. Se requiere reinicio para aplicar los cambios.", "saveSuccess": "Rutas de carpetas adicionales actualizadas. Se requiere reinicio para aplicar los cambios.",
"saveError": "Error al actualizar las rutas de carpetas adicionales: {message}", "saveError": "Error al actualizar las rutas de carpetas adicionales: {message}",
"validation": { "validation": {
"duplicatePath": "Esta ruta ya está configurada" "duplicatePath": "Esta ruta ya está configurada",
"checkpointUnetOverlap": "No se puede usar la misma ruta para checkpoints y modelos de difusión: {paths}",
"checkpointUnetOverlapInline": "Esta ruta ya se usa para otro tipo de modelo. Use carpetas separadas para checkpoints y modelos de difusión."
} }
}, },
"priorityTags": { "priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "Preparando descarga...", "preparing": "Preparando descarga...",
"downloadedPreview": "Imagen de vista previa descargada", "downloadedPreview": "Imagen de vista previa descargada",
"downloadingFile": "Descargando archivo de {type}", "downloadingFile": "Descargando archivo de {type}",
"finalizing": "Finalizando descarga..." "finalizing": "Finalizando descarga...",
"cancelling": "Cancelando descarga...",
"cancelled": "Descarga cancelada"
}, },
"progress": { "progress": {
"currentFile": "Archivo actual:", "currentFile": "Archivo actual:",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "Imágenes de ejemplo {action} completadas", "imagesCompleted": "Imágenes de ejemplo {action} completadas",
"imagesFailed": "Imágenes de ejemplo {action} fallidas", "imagesFailed": "Imágenes de ejemplo {action} fallidas",
"loadError": "Error al cargar descargas: {message}", "loadError": "Error al cargar descargas: {message}",
"downloadError": "Error de descarga: {message}" "downloadError": "Error de descarga: {message}",
"downloadStopped": "Descarga cancelada"
}, },
"import": { "import": {
"folderTreeFailed": "Error al cargar árbol de carpetas", "folderTreeFailed": "Error al cargar árbol de carpetas",
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "Chemins de dossiers supplémentaires mis à jour. Redémarrage requis pour appliquer les changements.", "saveSuccess": "Chemins de dossiers supplémentaires mis à jour. Redémarrage requis pour appliquer les changements.",
"saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}", "saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}",
"validation": { "validation": {
"duplicatePath": "Ce chemin est déjà configuré" "duplicatePath": "Ce chemin est déjà configuré",
"checkpointUnetOverlap": "Impossible d'utiliser le même chemin pour les checkpoints et les modèles de diffusion : {paths}",
"checkpointUnetOverlapInline": "Ce chemin est déjà utilisé pour un autre type de modèle. Utilisez des dossiers séparés pour les checkpoints et les modèles de diffusion."
} }
}, },
"priorityTags": { "priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "Préparation du téléchargement...", "preparing": "Préparation du téléchargement...",
"downloadedPreview": "Image d'aperçu téléchargée", "downloadedPreview": "Image d'aperçu téléchargée",
"downloadingFile": "Téléchargement du fichier {type}", "downloadingFile": "Téléchargement du fichier {type}",
"finalizing": "Finalisation du téléchargement..." "finalizing": "Finalisation du téléchargement...",
"cancelling": "Annulation du téléchargement...",
"cancelled": "Téléchargement annulé"
}, },
"progress": { "progress": {
"currentFile": "Fichier actuel :", "currentFile": "Fichier actuel :",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "Images d'exemple {action} terminées", "imagesCompleted": "Images d'exemple {action} terminées",
"imagesFailed": "Images d'exemple {action} échouées", "imagesFailed": "Images d'exemple {action} échouées",
"loadError": "Erreur lors du chargement des téléchargements : {message}", "loadError": "Erreur lors du chargement des téléchargements : {message}",
"downloadError": "Erreur de téléchargement : {message}" "downloadError": "Erreur de téléchargement : {message}",
"downloadStopped": "Téléchargement annulé"
}, },
"import": { "import": {
"folderTreeFailed": "Échec du chargement de l'arborescence des dossiers", "folderTreeFailed": "Échec du chargement de l'arborescence des dossiers",
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "נתיבי תיקיות נוספים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.", "saveSuccess": "נתיבי תיקיות נוספים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}", "saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
"validation": { "validation": {
"duplicatePath": "נתיב זה כבר מוגדר" "duplicatePath": "נתיב זה כבר מוגדר",
"checkpointUnetOverlap": "לא ניתן להשתמש באותו נתיב עבור checkpoints ומודלי דיפוזיה: {paths}",
"checkpointUnetOverlapInline": "הנתיב הזה כבר נמצא בשימוש עבור סוג מודל אחר. יש להשתמש בתיקיות נפרדות עבור checkpoints ומודלי דיפוזיה."
} }
}, },
"priorityTags": { "priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "מכין הורדה...", "preparing": "מכין הורדה...",
"downloadedPreview": "תמונת תצוגה מקדימה הורדה", "downloadedPreview": "תמונת תצוגה מקדימה הורדה",
"downloadingFile": "מוריד קובץ {type}", "downloadingFile": "מוריד קובץ {type}",
"finalizing": "מסיים הורדה..." "finalizing": "מסיים הורדה...",
"cancelling": "מבטל הורדה...",
"cancelled": "ההורדה בוטלה"
}, },
"progress": { "progress": {
"currentFile": "הקובץ הנוכחי:", "currentFile": "הקובץ הנוכחי:",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "{action} תמונות הדוגמה הושלם", "imagesCompleted": "{action} תמונות הדוגמה הושלם",
"imagesFailed": "{action} תמונות הדוגמה נכשל", "imagesFailed": "{action} תמונות הדוגמה נכשל",
"loadError": "שגיאה בטעינת הורדות: {message}", "loadError": "שגיאה בטעינת הורדות: {message}",
"downloadError": "שגיאת הורדה: {message}" "downloadError": "שגיאת הורדה: {message}",
"downloadStopped": "ההורדה בוטלה"
}, },
"import": { "import": {
"folderTreeFailed": "טעינת עץ התיקיות נכשלה", "folderTreeFailed": "טעינת עץ התיקיות נכשלה",
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "追加フォルダーパスを更新しました。変更を適用するには再起動が必要です。", "saveSuccess": "追加フォルダーパスを更新しました。変更を適用するには再起動が必要です。",
"saveError": "追加フォルダーパスの更新に失敗しました: {message}", "saveError": "追加フォルダーパスの更新に失敗しました: {message}",
"validation": { "validation": {
"duplicatePath": "このパスはすでに設定されています" "duplicatePath": "このパスはすでに設定されています",
"checkpointUnetOverlap": "checkpoints と diffusion models に同じパスは使用できません:{paths}",
"checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。checkpoints と diffusion models には別々のフォルダを使用してください。"
} }
}, },
"priorityTags": { "priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "ダウンロードを準備中...", "preparing": "ダウンロードを準備中...",
"downloadedPreview": "プレビュー画像をダウンロードしました", "downloadedPreview": "プレビュー画像をダウンロードしました",
"downloadingFile": "{type}ファイルをダウンロード中", "downloadingFile": "{type}ファイルをダウンロード中",
"finalizing": "ダウンロードを完了中..." "finalizing": "ダウンロードを完了中...",
"cancelling": "ダウンロードをキャンセル中...",
"cancelled": "ダウンロードをキャンセルしました"
}, },
"progress": { "progress": {
"currentFile": "現在のファイル:", "currentFile": "現在のファイル:",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "例画像 {action} が完了しました", "imagesCompleted": "例画像 {action} が完了しました",
"imagesFailed": "例画像 {action} が失敗しました", "imagesFailed": "例画像 {action} が失敗しました",
"loadError": "ダウンロード読み込みエラー:{message}", "loadError": "ダウンロード読み込みエラー:{message}",
"downloadError": "ダウンロードエラー:{message}" "downloadError": "ダウンロードエラー:{message}",
"downloadStopped": "ダウンロードをキャンセルしました"
}, },
"import": { "import": {
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました", "folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.", "saveSuccess": "추가 폴다 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
"saveError": "추가 폴다 경로 업데이트 실패: {message}", "saveError": "추가 폴다 경로 업데이트 실패: {message}",
"validation": { "validation": {
"duplicatePath": "이 경로는 이미 구성되어 있습니다" "duplicatePath": "이 경로는 이미 구성되어 있습니다",
"checkpointUnetOverlap": "checkpoints와 diffusion models에 동일한 경로를 사용할 수 없습니다: {paths}",
"checkpointUnetOverlapInline": "이 경로는 다른 모델 유형에 이미 사용 중입니다. checkpoints와 diffusion models에 별도의 폴더를 사용하세요."
} }
}, },
"priorityTags": { "priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "다운로드 준비 중...", "preparing": "다운로드 준비 중...",
"downloadedPreview": "미리보기 이미지 다운로드됨", "downloadedPreview": "미리보기 이미지 다운로드됨",
"downloadingFile": "{type} 파일 다운로드 중", "downloadingFile": "{type} 파일 다운로드 중",
"finalizing": "다운로드 완료 중..." "finalizing": "다운로드 완료 중...",
"cancelling": "다운로드 취소 중...",
"cancelled": "다운로드가 취소되었습니다"
}, },
"progress": { "progress": {
"currentFile": "현재 파일:", "currentFile": "현재 파일:",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "예시 이미지 {action}이(가) 완료되었습니다", "imagesCompleted": "예시 이미지 {action}이(가) 완료되었습니다",
"imagesFailed": "예시 이미지 {action}이(가) 실패했습니다", "imagesFailed": "예시 이미지 {action}이(가) 실패했습니다",
"loadError": "다운로드 로딩 오류: {message}", "loadError": "다운로드 로딩 오류: {message}",
"downloadError": "다운로드 오류: {message}" "downloadError": "다운로드 오류: {message}",
"downloadStopped": "다운로드가 취소되었습니다"
}, },
"import": { "import": {
"folderTreeFailed": "폴더 트리 로딩 실패", "folderTreeFailed": "폴더 트리 로딩 실패",
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "Дополнительные пути к папкам обновлены. Требуется перезапуск для применения изменений.", "saveSuccess": "Дополнительные пути к папкам обновлены. Требуется перезапуск для применения изменений.",
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}", "saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
"validation": { "validation": {
"duplicatePath": "Этот путь уже настроен" "duplicatePath": "Этот путь уже настроен",
"checkpointUnetOverlap": "Нельзя использовать один и тот же путь для checkpoints и diffusion models: {paths}",
"checkpointUnetOverlapInline": "Этот путь уже используется для другого типа модели. Используйте отдельные папки для checkpoints и diffusion models."
} }
}, },
"priorityTags": { "priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "Подготовка загрузки...", "preparing": "Подготовка загрузки...",
"downloadedPreview": "Превью изображение загружено", "downloadedPreview": "Превью изображение загружено",
"downloadingFile": "Загрузка файла {type}", "downloadingFile": "Загрузка файла {type}",
"finalizing": "Завершение загрузки..." "finalizing": "Завершение загрузки...",
"cancelling": "Отмена загрузки...",
"cancelled": "Загрузка отменена"
}, },
"progress": { "progress": {
"currentFile": "Текущий файл:", "currentFile": "Текущий файл:",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "Примеры изображений {action} завершены", "imagesCompleted": "Примеры изображений {action} завершены",
"imagesFailed": "Примеры изображений {action} не удались", "imagesFailed": "Примеры изображений {action} не удались",
"loadError": "Ошибка загрузки downloads: {message}", "loadError": "Ошибка загрузки downloads: {message}",
"downloadError": "Ошибка загрузки: {message}" "downloadError": "Ошибка загрузки: {message}",
"downloadStopped": "Загрузка отменена"
}, },
"import": { "import": {
"folderTreeFailed": "Не удалось загрузить дерево папок", "folderTreeFailed": "Не удалось загрузить дерево папок",
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "额外文件夹路径已更新,需要重启才能生效。", "saveSuccess": "额外文件夹路径已更新,需要重启才能生效。",
"saveError": "更新额外文件夹路径失败:{message}", "saveError": "更新额外文件夹路径失败:{message}",
"validation": { "validation": {
"duplicatePath": "此路径已配置" "duplicatePath": "此路径已配置",
"checkpointUnetOverlap": "checkpoints 和 diffusion models 不能使用相同的路径:{paths}",
"checkpointUnetOverlapInline": "此路径已被用于另一种模型类型。请为 checkpoints 和 diffusion models 使用不同的文件夹。"
} }
}, },
"priorityTags": { "priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "正在准备下载...", "preparing": "正在准备下载...",
"downloadedPreview": "预览图片已下载", "downloadedPreview": "预览图片已下载",
"downloadingFile": "正在下载 {type} 文件", "downloadingFile": "正在下载 {type} 文件",
"finalizing": "正在完成下载..." "finalizing": "正在完成下载...",
"cancelling": "取消下载中...",
"cancelled": "下载已取消"
}, },
"progress": { "progress": {
"currentFile": "当前文件:", "currentFile": "当前文件:",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "示例图片{action}完成", "imagesCompleted": "示例图片{action}完成",
"imagesFailed": "示例图片{action}失败", "imagesFailed": "示例图片{action}失败",
"loadError": "加载下载项出错:{message}", "loadError": "加载下载项出错:{message}",
"downloadError": "下载错误:{message}" "downloadError": "下载错误:{message}",
"downloadStopped": "下载已取消"
}, },
"import": { "import": {
"folderTreeFailed": "加载文件夹树失败", "folderTreeFailed": "加载文件夹树失败",
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "額外資料夾路徑已更新,需要重啟才能生效。", "saveSuccess": "額外資料夾路徑已更新,需要重啟才能生效。",
"saveError": "更新額外資料夾路徑失敗:{message}", "saveError": "更新額外資料夾路徑失敗:{message}",
"validation": { "validation": {
"duplicatePath": "此路徑已設定" "duplicatePath": "此路徑已設定",
"checkpointUnetOverlap": "checkpoints 和 diffusion models 不能使用相同的路徑:{paths}",
"checkpointUnetOverlapInline": "此路徑已被用於另一種模型類型。請為 checkpoints 和 diffusion models 使用不同的資料夾。"
} }
}, },
"priorityTags": { "priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "準備下載中...", "preparing": "準備下載中...",
"downloadedPreview": "已下載預覽圖片", "downloadedPreview": "已下載預覽圖片",
"downloadingFile": "正在下載 {type} 檔案", "downloadingFile": "正在下載 {type} 檔案",
"finalizing": "完成下載中..." "finalizing": "完成下載中...",
"cancelling": "取消下載中...",
"cancelled": "下載已取消"
}, },
"progress": { "progress": {
"currentFile": "目前檔案:", "currentFile": "目前檔案:",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "範例圖片{action}完成", "imagesCompleted": "範例圖片{action}完成",
"imagesFailed": "範例圖片{action}失敗", "imagesFailed": "範例圖片{action}失敗",
"loadError": "載入下載時發生錯誤:{message}", "loadError": "載入下載時發生錯誤:{message}",
"downloadError": "下載錯誤:{message}" "downloadError": "下載錯誤:{message}",
"downloadStopped": "下載已取消"
}, },
"import": { "import": {
"folderTreeFailed": "載入資料夾樹狀結構失敗", "folderTreeFailed": "載入資料夾樹狀結構失敗",
+62 -14
View File
@@ -230,6 +230,12 @@ class DownloadManager:
Returns: Returns:
Dict with download result Dict with download result
""" """
logger.debug(
"[download] download_from_civitai called: model_id=%s, model_version_id=%s, "
"source=%s, file_params=%s",
model_id, model_version_id, source, file_params,
)
# Validate that at least one identifier is provided # Validate that at least one identifier is provided
if not model_id and not model_version_id: if not model_id and not model_version_id:
return { return {
@@ -250,6 +256,7 @@ class DownloadManager:
"source": source, "source": source,
"file_params": copy.deepcopy(file_params) if file_params is not None else None, "file_params": copy.deepcopy(file_params) if file_params is not None else None,
"progress": 0, "progress": 0,
"status": "queued", "status": "queued",
"transfer_backend": self._get_model_download_backend(), "transfer_backend": self._get_model_download_backend(),
"bytes_downloaded": 0, "bytes_downloaded": 0,
@@ -289,8 +296,8 @@ class DownloadManager:
return result return result
except asyncio.CancelledError: except asyncio.CancelledError:
return { return {
"success": False, "success": True,
"error": "Download was cancelled", "cancelled": True,
"download_id": task_id, "download_id": task_id,
} }
finally: finally:
@@ -1421,14 +1428,35 @@ class DownloadManager:
# If file_params is provided, try to find matching file # If file_params is provided, try to find matching file
if file_params and model_version_id: if file_params and model_version_id:
target_file_id = file_params.get("id")
target_type = file_params.get("type", "Model") target_type = file_params.get("type", "Model")
target_format = file_params.get("format", "SafeTensor") target_format = file_params.get("format")
target_size = file_params.get("size", "full") target_size = file_params.get("size")
target_fp = file_params.get("fp") target_fp = file_params.get("fp")
is_primary = file_params.get("isPrimary", False) is_primary = file_params.get("isPrimary", False)
if is_primary: logger.debug(
# Find primary file "[download] file_params received: id=%s, type=%s, format=%s, size=%s, fp=%s, isPrimary=%s, "
"model_version_id=%s, total_files=%d",
target_file_id, target_type, target_format, target_size, target_fp, is_primary,
model_version_id, len(files),
)
if target_file_id:
target_id_str = str(target_file_id)
for f in files:
f_id = f.get("id")
if str(f_id) == target_id_str:
file_info = f
logger.debug(
"[download] MATCH by ID: id=%s name='%s'",
f_id, f.get("name"),
)
break
if not file_info:
logger.debug("[download] No file found with id=%s", target_file_id)
elif is_primary:
file_info = next( file_info = next(
( (
f f
@@ -1439,28 +1467,41 @@ class DownloadManager:
None, None,
) )
else: else:
# Match by metadata # Lenient metadata match: only compare fields present on both sides
for f in files: for f in files:
f_type = f.get("type", "") f_type = f.get("type", "")
f_meta = f.get("metadata", {})
# Check type match
if f_type != target_type: if f_type != target_type:
continue continue
# Check metadata match f_meta = f.get("metadata", {})
if f_meta.get("format") != target_format: f_format = f_meta.get("format") or f.get("format")
f_size = f_meta.get("size") or f.get("size")
f_fp = f_meta.get("fp") or f.get("fp")
if target_format and f_format != target_format:
continue continue
if f_meta.get("size") != target_size: if target_size and f_size and f_size != target_size:
continue continue
if target_fp and f_meta.get("fp") != target_fp: if target_fp and f_fp and f_fp != target_fp:
continue continue
file_info = f file_info = f
break break
if not file_info:
logger.debug(
"[download] No match found via file_params — falling back to primary file lookup",
)
elif not file_params:
logger.debug(
"[download] No file_params provided (null/None) — will use primary file lookup. "
"model_version_id=%s, total_files=%d",
model_version_id, len(files),
)
# Fallback to primary file if no match found # Fallback to primary file if no match found
if not file_info: if not file_info:
logger.debug("[download] Looking for primary file as fallback")
file_info = next( file_info = next(
( (
f f
@@ -1469,6 +1510,13 @@ class DownloadManager:
), ),
None, None,
) )
if file_info:
logger.debug(
"[download] Fallback primary file selected: id=%s, name=%s",
file_info.get("id"), file_info.get("name"),
)
else:
logger.debug("[download] No primary file found in fallback lookup")
if not file_info: if not file_info:
return {"success": False, "error": "No suitable file found in metadata"} return {"success": False, "error": "No suitable file found in metadata"}
+46 -1
View File
@@ -630,12 +630,37 @@ class SettingsManager:
return False return False
@staticmethod
def _normalize_path_set(paths: Iterable[str]) -> set[str]:
"""Normalize an iterable of paths for set-based overlap comparison.
Resolves symlinks via ``os.path.realpath`` when the path exists on disk,
then applies ``os.path.normcase`` + ``os.path.normpath`` for consistent
cross-platform comparison. Non-string / empty entries are skipped.
"""
result: set[str] = set()
for p in paths:
if not isinstance(p, str):
continue
stripped = p.strip()
if not stripped:
continue
if os.path.exists(stripped):
stripped = os.path.normpath(os.path.realpath(stripped))
result.add(os.path.normcase(stripped))
return result
def _validate_folder_paths( def _validate_folder_paths(
self, self,
library_name: str, library_name: str,
folder_paths: Mapping[str, Iterable[str]], folder_paths: Mapping[str, Iterable[str]],
) -> None: ) -> None:
"""Ensure folder paths do not overlap with other libraries.""" """Ensure folder paths do not overlap with other libraries.
Also detects checkpoints ↔ unet path overlap within the same library
(including via symlink resolution), which is a configuration error since
these model types must use separate physical folders.
"""
libraries = self.settings.get("libraries", {}) libraries = self.settings.get("libraries", {})
normalized_new: Dict[str, Dict[str, str]] = {} normalized_new: Dict[str, Dict[str, str]] = {}
for key, values in folder_paths.items(): for key, values in folder_paths.items():
@@ -673,6 +698,22 @@ class SettingsManager:
f"Folder path(s) {collisions} already assigned to library '{other_name}'" f"Folder path(s) {collisions} already assigned to library '{other_name}'"
) )
# Checkpoints ↔ unet overlap within the same library
ckpt_paths = folder_paths.get("checkpoints", []) or []
unet_paths = folder_paths.get("unet", []) or []
if ckpt_paths and unet_paths:
ckpt_real = self._normalize_path_set(ckpt_paths)
unet_real = self._normalize_path_set(unet_paths)
overlap = ckpt_real & unet_real
if overlap:
collisions = ", ".join(sorted(overlap))
raise ValueError(
f"Path(s) {collisions} are configured for both "
f"'checkpoints' and 'unet' (diffusion models). "
f"These model types must use separate physical folders. "
f"Please remove one of the conflicting entries."
)
def _update_active_library_entry( def _update_active_library_entry(
self, self,
*, *,
@@ -1547,8 +1588,12 @@ class SettingsManager:
portable_switch_pending = True portable_switch_pending = True
self._prepare_portable_switch(value) self._prepare_portable_switch(value)
if key == "folder_paths" and isinstance(value, Mapping): if key == "folder_paths" and isinstance(value, Mapping):
active_name = self.get_active_library_name()
self._validate_folder_paths(active_name, value)
self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type] self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type]
elif key == "extra_folder_paths" and isinstance(value, Mapping): elif key == "extra_folder_paths" and isinstance(value, Mapping):
active_name = self.get_active_library_name()
self._validate_folder_paths(active_name, value)
self._update_active_library_entry(extra_folder_paths=value) # type: ignore[arg-type] self._update_active_library_entry(extra_folder_paths=value) # type: ignore[arg-type]
elif key == "default_lora_root": elif key == "default_lora_root":
self._update_active_library_entry(default_lora_root=str(value)) self._update_active_library_entry(default_lora_root=str(value))
@@ -1562,6 +1562,29 @@ input:checked + .toggle-slider:before {
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1); box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
} }
.extra-folder-path-row .path-controls .extra-folder-path-input.has-error {
border-color: var(--lora-error);
background-color: rgba(220, 53, 69, 0.08);
background-color: rgba(from var(--lora-error) r g b / 0.08);
}
.extra-folder-path-row .path-controls .extra-folder-path-input.has-error:focus {
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.15);
box-shadow: 0 0 0 2px rgba(from var(--lora-error) r g b / 0.15);
}
.extra-folder-path-error {
color: var(--lora-error);
font-size: 0.8em;
margin-top: 4px;
line-height: 1.4;
display: none;
}
.extra-folder-path-error.visible {
display: block;
}
.extra-folder-path-row .path-controls .remove-path-btn { .extra-folder-path-row .path-controls .remove-path-btn {
width: 32px; width: 32px;
height: 32px; height: 32px;
+12
View File
@@ -112,6 +112,18 @@ export class BaseModelApiClient {
} }
} }
async cancelDownload(downloadId) {
try {
const response = await fetch(
`${DOWNLOAD_ENDPOINTS.cancelGet}?download_id=${encodeURIComponent(downloadId)}`
);
return await response.json();
} catch (error) {
console.error('Error cancelling download:', error);
return { success: false, error: error.message };
}
}
async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) { async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) {
const pageState = this.getPageState(); const pageState = this.getPageState();
@@ -196,6 +196,17 @@ export class BulkMissingLoraDownloadManager {
let completedDownloads = 0; let completedDownloads = 0;
let failedDownloads = 0; let failedDownloads = 0;
let currentLoraProgress = 0; let currentLoraProgress = 0;
let cancelled = false;
loadingManager.showCancelButton(async () => {
if (cancelled) return;
cancelled = true;
try {
await this.loraApiClient.cancelDownload(batchDownloadId);
} catch (e) {
console.error('Cancel request failed:', e);
}
});
// Set up WebSocket message handler // Set up WebSocket message handler
ws.onmessage = (event) => { ws.onmessage = (event) => {
@@ -207,6 +218,11 @@ export class BulkMissingLoraDownloadManager {
return; return;
} }
if (data.status === 'cancelled') {
cancelled = true;
return;
}
// Process progress updates // Process progress updates
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) { if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
currentLoraProgress = data.progress; currentLoraProgress = data.progress;
@@ -249,6 +265,8 @@ export class BulkMissingLoraDownloadManager {
// Download each LoRA sequentially // Download each LoRA sequentially
for (let i = 0; i < lorasToDownload.length; i++) { for (let i = 0; i < lorasToDownload.length; i++) {
if (cancelled) break;
const lora = lorasToDownload[i]; const lora = lorasToDownload[i];
currentLoraProgress = 0; currentLoraProgress = 0;
@@ -275,11 +293,13 @@ export class BulkMissingLoraDownloadManager {
modelId, modelId,
versionId, versionId,
loraRoot, loraRoot,
'', // Empty relative path, use default paths '',
useDefaultPaths, useDefaultPaths,
batchDownloadId batchDownloadId
); );
if (cancelled) break;
if (!response.success) { if (!response.success) {
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`); console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
failedDownloads++; failedDownloads++;
@@ -288,8 +308,10 @@ export class BulkMissingLoraDownloadManager {
updateProgress(100, completedDownloads, ''); updateProgress(100, completedDownloads, '');
} }
} catch (error) { } catch (error) {
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error); if (!cancelled) {
failedDownloads++; console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
failedDownloads++;
}
} }
} }
@@ -300,7 +322,10 @@ export class BulkMissingLoraDownloadManager {
loadingManager.hide(); loadingManager.hide();
// Show completion message // Show completion message
if (failedDownloads === 0) { if (cancelled) {
showToast('toast.downloads.downloadStopped', {}, 'info',
`Download cancelled. ${completedDownloads} item(s) completed.`);
} else if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success'); showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else { } else {
showToast('toast.loras.downloadPartialSuccess', { showToast('toast.loras.downloadPartialSuccess', {
+123 -23
View File
@@ -728,14 +728,23 @@ export class DownloadManager {
confirmFileSelection() { confirmFileSelection() {
const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked'); const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked');
if (!selectedRadio) return; if (!selectedRadio) {
console.warn('[download] confirmFileSelection: no radio button checked');
return;
}
const version = this.currentVersion; const version = this.currentVersion;
if (!version) return; if (!version) {
console.warn('[download] confirmFileSelection: no currentVersion set');
return;
}
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model'); const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value); this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
console.log('[download] confirmFileSelection: selected file id=%s, name="%s", type="%s", metadata=%o',
this.selectedFile?.id, this.selectedFile?.name, this.selectedFile?.type, this.selectedFile?.metadata);
document.getElementById('fileSelectionStep').style.display = 'none'; document.getElementById('fileSelectionStep').style.display = 'none';
document.getElementById('locationStep').style.display = 'block'; document.getElementById('locationStep').style.display = 'block';
this.proceedToLocationContent(); this.proceedToLocationContent();
@@ -872,16 +881,26 @@ export class DownloadManager {
const displayName = versionName || `#${versionId}`; const displayName = versionName || `#${versionId}`;
let ws = null; let ws = null;
let updateProgress = () => { }; let updateProgress = () => { };
let cancelled = false;
const downloadId = Date.now().toString();
try { try {
this.loadingManager.restoreProgressBar(); this.loadingManager.restoreProgressBar();
updateProgress = this.loadingManager.showDownloadProgress(1); updateProgress = this.loadingManager.showDownloadProgress(1);
updateProgress(0, 0, displayName); updateProgress(0, 0, displayName);
const downloadId = Date.now().toString();
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`); ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
this.loadingManager.showCancelButton(async () => {
if (cancelled) return;
cancelled = true;
try {
await this.apiClient.cancelDownload(downloadId);
} catch (e) {
console.error('Cancel request failed:', e);
}
});
ws.onmessage = event => { ws.onmessage = event => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
@@ -890,6 +909,12 @@ export class DownloadManager {
return; return;
} }
if (data.status === 'cancelled') {
cancelled = true;
this.loadingManager.setStatus(translate('modals.download.status.cancelled', {}, 'Download cancelled'));
return;
}
if (data.status === 'progress' && data.download_id === downloadId) { if (data.status === 'progress' && data.download_id === downloadId) {
const metrics = { const metrics = {
bytesDownloaded: data.bytes_downloaded, bytesDownloaded: data.bytes_downloaded,
@@ -928,6 +953,10 @@ export class DownloadManager {
fileParams fileParams
); );
if (cancelled) {
return false;
}
if (response?.skipped) { if (response?.skipped) {
this.loadingManager.setStatus(translate('modals.download.status.finalizing')); this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
updateProgress(100, 0, displayName); updateProgress(100, 0, displayName);
@@ -968,8 +997,12 @@ export class DownloadManager {
return true; return true;
} catch (error) { } catch (error) {
console.error('Failed to download model version:', error); if (cancelled) {
showToast('toast.downloads.downloadError', { message: error?.message }, 'error'); console.log('Download cancelled by user:', downloadId);
} else {
console.error('Failed to download model version:', error);
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
}
return false; return false;
} finally { } finally {
try { try {
@@ -989,16 +1022,33 @@ export class DownloadManager {
const totalFiles = this.hfSelectedFiles.length; const totalFiles = this.hfSelectedFiles.length;
const updateProgress = this.loadingManager.showDownloadProgress(totalFiles); const updateProgress = this.loadingManager.showDownloadProgress(totalFiles);
let cancelled = false;
let currentDownloadId = null;
this.loadingManager.showCancelButton(async () => {
if (cancelled) return;
cancelled = true;
if (currentDownloadId) {
try {
await this.apiClient.cancelDownload(currentDownloadId);
} catch (e) {
console.error('Cancel request failed:', e);
}
}
});
try { try {
let completedDownloads = 0; let completedDownloads = 0;
for (let i = 0; i < totalFiles; i++) { for (let i = 0; i < totalFiles; i++) {
if (cancelled) break;
const filename = this.hfSelectedFiles[i]; const filename = this.hfSelectedFiles[i];
updateProgress(0, completedDownloads, filename); updateProgress(0, completedDownloads, filename);
this.loadingManager.setStatus(`Downloading ${filename}...`); this.loadingManager.setStatus(`Downloading ${filename}...`);
const downloadId = Date.now().toString() + '_' + i; currentDownloadId = Date.now().toString() + '_' + i;
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`); const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${currentDownloadId}`);
try { try {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
@@ -1006,12 +1056,13 @@ export class DownloadManager {
ws.onerror = reject; ws.onerror = reject;
}); });
// Capture completed count at WS creation time so progress
// updates arriving after completedDownloads increments still
// show the correct "N / total" position.
const snapshotCompleted = completedDownloads; const snapshotCompleted = completedDownloads;
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.status === 'cancelled') {
cancelled = true;
return;
}
if (data.status === 'progress') { if (data.status === 'progress') {
const metrics = { const metrics = {
bytesDownloaded: data.bytes_downloaded, bytesDownloaded: data.bytes_downloaded,
@@ -1029,9 +1080,11 @@ export class DownloadManager {
modelRoot, modelRoot,
relativePath: targetFolder, relativePath: targetFolder,
useDefaultPaths, useDefaultPaths,
download_id: downloadId, download_id: currentDownloadId,
}); });
if (cancelled) break;
if (response?.success) { if (response?.success) {
completedDownloads++; completedDownloads++;
updateProgress(100, completedDownloads, filename); updateProgress(100, completedDownloads, filename);
@@ -1041,13 +1094,19 @@ export class DownloadManager {
} }
} }
showToast('toast.loras.downloadCompleted', {}, 'success'); if (cancelled) {
// Reload page data — model is already in scanner cache via backend showToast('toast.downloads.downloadStopped', {}, 'info',
`Download cancelled. ${completedDownloads} item(s) completed.`);
} else {
showToast('toast.loras.downloadCompleted', {}, 'success');
}
await resetAndReload(true); await resetAndReload(true);
return true; return true;
} catch (error) { } catch (error) {
console.error('Failed to download HF model:', error); if (!cancelled) {
showToast('toast.downloads.downloadError', { message: error?.message }, 'error'); console.error('Failed to download HF model:', error);
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
}
return false; return false;
} finally { } finally {
this.loadingManager.hide(); this.loadingManager.hide();
@@ -1426,12 +1485,23 @@ export class DownloadManager {
} }
const fileParams = this.selectedFile ? { const fileParams = this.selectedFile ? {
id: this.selectedFile.id,
type: this.selectedFile.type || 'Model', type: this.selectedFile.type || 'Model',
format: this.selectedFile.metadata?.format || 'SafeTensor', format: this.selectedFile.metadata?.format || null,
size: this.selectedFile.metadata?.size || 'full', size: this.selectedFile.metadata?.size || null,
fp: this.selectedFile.metadata?.fp, fp: this.selectedFile.metadata?.fp || null,
} : null; } : null;
if (fileParams) {
console.log('[download] startDownload (single): fileParams built from selectedFile — id=%s, type=%s, format=%s, size=%s, fp=%s',
fileParams.id, fileParams.type, fileParams.format, fileParams.size, fileParams.fp);
} else {
console.log('[download] startDownload (single): this.selectedFile is null — no file selection, will download primary/default file. version=%s has %d files',
this.currentVersion?.id, (this.currentVersion?.files || []).length);
}
modalManager.closeModal('downloadModal');
return this.executeDownloadWithProgress({ return this.executeDownloadWithProgress({
modelId: this.modelId, modelId: this.modelId,
versionId: this.currentVersion.id, versionId: this.currentVersion.id,
@@ -1470,11 +1540,27 @@ export class DownloadManager {
let completedDownloads = 0; let completedDownloads = 0;
let failedDownloads = 0; let failedDownloads = 0;
let cancelled = false;
loadingManager.showCancelButton(async () => {
if (cancelled) return;
cancelled = true;
try {
await this.apiClient.cancelDownload(batchDownloadId);
} catch (e) {
console.error('Cancel request failed:', e);
}
});
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.type === 'download_id') return; if (data.type === 'download_id') return;
if (data.status === 'cancelled') {
cancelled = true;
return;
}
if (data.status === 'progress' && data.download_id?.startsWith(batchDownloadId)) { if (data.status === 'progress' && data.download_id?.startsWith(batchDownloadId)) {
const current = downloadItems[completedDownloads + failedDownloads]; const current = downloadItems[completedDownloads + failedDownloads];
const name = current?.selectedVersion?.name || current?.displayName || current?.filename || `#${completedDownloads + failedDownloads + 1}`; const name = current?.selectedVersion?.name || current?.displayName || current?.filename || `#${completedDownloads + failedDownloads + 1}`;
@@ -1493,6 +1579,8 @@ export class DownloadManager {
}); });
for (let i = 0; i < downloadItems.length; i++) { for (let i = 0; i < downloadItems.length; i++) {
if (cancelled) break;
const item = downloadItems[i]; const item = downloadItems[i];
const name = item.displayName || item.filename || (item.selectedVersion?.name || `Model #${item.modelId}`); const name = item.displayName || item.filename || (item.selectedVersion?.name || `Model #${item.modelId}`);
const isHf = item.source === 'huggingface'; const isHf = item.source === 'huggingface';
@@ -1503,7 +1591,6 @@ export class DownloadManager {
try { try {
let response; let response;
if (isHf) { if (isHf) {
// Per-file WebSocket for real-time progress
const downloadId = Date.now().toString() + '_hf_' + i; const downloadId = Date.now().toString() + '_hf_' + i;
const wsHf = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`); const wsHf = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
try { try {
@@ -1537,6 +1624,8 @@ export class DownloadManager {
wsHf.close(); wsHf.close();
} }
} else { } else {
console.log('[download] batch download: fileParams NOT passed for modelId=%s, versionId=%s — backend will use primary file',
item.modelId, item.selectedVersion?.id);
response = await this.apiClient.downloadModel( response = await this.apiClient.downloadModel(
item.modelId, item.modelId,
item.selectedVersion.id, item.selectedVersion.id,
@@ -1548,6 +1637,8 @@ export class DownloadManager {
); );
} }
if (cancelled) break;
if (!response.success) { if (!response.success) {
failedDownloads++; failedDownloads++;
} else { } else {
@@ -1555,15 +1646,20 @@ export class DownloadManager {
updateProgress(100, completedDownloads, ''); updateProgress(100, completedDownloads, '');
} }
} catch (err) { } catch (err) {
console.error(`Failed to download ${name}:`, err); if (!cancelled) {
failedDownloads++; console.error(`Failed to download ${name}:`, err);
failedDownloads++;
}
} }
} }
ws.close(); ws.close();
loadingManager.hide(); loadingManager.hide();
if (failedDownloads === 0) { if (cancelled) {
showToast('toast.downloads.downloadStopped', {}, 'info',
`Download cancelled. ${completedDownloads} item(s) completed.`);
} else if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success'); showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else { } else {
showToast('toast.loras.downloadPartialSuccess', { showToast('toast.loras.downloadPartialSuccess', {
@@ -1581,6 +1677,10 @@ export class DownloadManager {
modelRoot = '', modelRoot = '',
targetFolder = '' targetFolder = ''
} = {}) { } = {}) {
console.warn('[download] downloadVersionWithDefaults: NO fileParams will be sent — backend will always use primary file. '
+ 'modelType=%s, modelId=%s, versionId=%s, versionName="%s"',
modelType, modelId, versionId, versionName);
try { try {
this.apiClient = getModelApiClient(modelType); this.apiClient = getModelApiClient(modelType);
} catch (error) { } catch (error) {
+4
View File
@@ -281,6 +281,10 @@ export class LoadingManager {
// Initialize transfer stats with empty data // Initialize transfer stats with empty data
updateTransferStats(); updateTransferStats();
if (this.cancelButton) {
this.loadingContent.appendChild(this.cancelButton);
}
// Return update function // Return update function
return (currentProgress, currentIndex = 0, currentName = '', metrics = {}) => { return (currentProgress, currentIndex = 0, currentName = '', metrics = {}) => {
// Update current item progress // Update current item progress
+85 -1
View File
@@ -1693,13 +1693,15 @@ export class SettingsManager {
<input type="text" class="extra-folder-path-input" <input type="text" class="extra-folder-path-input"
placeholder="${translate('settings.extraFolderPaths.pathPlaceholder', {}, '/path/to/models')}" value="${path}" placeholder="${translate('settings.extraFolderPaths.pathPlaceholder', {}, '/path/to/models')}" value="${path}"
onblur="settingsManager.updateExtraFolderPaths('${modelType}')" onblur="settingsManager.updateExtraFolderPaths('${modelType}')"
onfocus="settingsManager.clearExtraFolderPathError(this)"
onkeydown="if(event.key === 'Enter') { this.blur(); }" /> onkeydown="if(event.key === 'Enter') { this.blur(); }" />
<button type="button" class="remove-path-btn" <button type="button" class="remove-path-btn"
onclick="this.parentElement.parentElement.remove(); settingsManager.updateExtraFolderPaths('${modelType}')" onclick="settingsManager.removeExtraFolderPathRow(this, '${modelType}')"
title="${translate('common.actions.delete', {}, 'Delete')}"> title="${translate('common.actions.delete', {}, 'Delete')}">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
</div> </div>
<div class="extra-folder-path-error"></div>
`; `;
container.appendChild(row); container.appendChild(row);
@@ -1713,7 +1715,63 @@ export class SettingsManager {
} }
} }
clearExtraFolderPathError(input) {
input.classList.remove('has-error');
const row = input.closest('.extra-folder-path-row');
if (row) {
const errEl = row.querySelector('.extra-folder-path-error');
if (errEl) {
errEl.classList.remove('visible');
errEl.textContent = '';
}
}
}
_clearAllExtraFolderPathErrors() {
document.querySelectorAll('.extra-folder-path-input.has-error').forEach((input) => {
input.classList.remove('has-error');
});
document.querySelectorAll('.extra-folder-path-error.visible').forEach((el) => {
el.classList.remove('visible');
el.textContent = '';
});
}
_markExtraFolderPathsError(modelType, overlappingPaths, showMessage = false) {
const container = document.getElementById(`extraFolderPaths-${modelType}`);
if (!container) return;
const inputs = container.querySelectorAll('.extra-folder-path-input');
inputs.forEach((input) => {
const val = input.value.trim();
if (val && overlappingPaths.includes(val)) {
input.classList.add('has-error');
if (showMessage) {
const row = input.closest('.extra-folder-path-row');
if (row) {
const errEl = row.querySelector('.extra-folder-path-error');
if (errEl) {
errEl.textContent = translate('settings.extraFolderPaths.validation.checkpointUnetOverlapInline', {}, 'This path is also used for a different model type. Use separate folders for checkpoints and diffusion models.');
errEl.classList.add('visible');
}
}
}
}
});
}
removeExtraFolderPathRow(btn, modelType) {
const row = btn.closest('.extra-folder-path-row');
if (row) {
row.remove();
this.updateExtraFolderPaths(modelType);
}
}
async updateExtraFolderPaths(changedModelType) { async updateExtraFolderPaths(changedModelType) {
// Clear previous errors
this._clearAllExtraFolderPathErrors();
const extraFolderPaths = {}; const extraFolderPaths = {};
// Collect paths for all model types // Collect paths for all model types
@@ -1734,6 +1792,32 @@ export class SettingsManager {
extraFolderPaths[modelType] = paths; extraFolderPaths[modelType] = paths;
}); });
// Client-side pre-check: checkpoints and unet must not share the same path.
// Normalise paths to reduce false negatives vs the backend's realpath + normcase.
const normalise = (p) => p.replace(/[/\\]+$/, '').toLowerCase();
const ckptSet = new Set((extraFolderPaths.checkpoints || []).map(normalise));
const unetSet = new Set((extraFolderPaths.unet || []).map(normalise));
const ckptOverlap = (extraFolderPaths.checkpoints || []).filter(p => p && unetSet.has(normalise(p)));
const unetOverlap = (extraFolderPaths.unet || []).filter(p => p && ckptSet.has(normalise(p)));
const hasOverlap = ckptOverlap.length > 0 || unetOverlap.length > 0;
if (hasOverlap) {
// Error message only on the side the user just edited.
// The other side gets red border only (passive conflict indicator).
if (changedModelType === 'checkpoints') {
this._markExtraFolderPathsError('checkpoints', ckptOverlap, true);
this._markExtraFolderPathsError('unet', unetOverlap, false);
} else if (changedModelType === 'unet') {
this._markExtraFolderPathsError('unet', unetOverlap, true);
this._markExtraFolderPathsError('checkpoints', ckptOverlap, false);
} else {
// Pre-existing conflict from direct config edit — mark both without messages
this._markExtraFolderPathsError('checkpoints', ckptOverlap, false);
this._markExtraFolderPathsError('unet', unetOverlap, false);
}
return;
}
// Check if paths have actually changed // Check if paths have actually changed
const currentPaths = state.global.settings.extra_folder_paths || {}; const currentPaths = state.global.settings.extra_folder_paths || {};
const pathsChanged = JSON.stringify(currentPaths) !== JSON.stringify(extraFolderPaths); const pathsChanged = JSON.stringify(currentPaths) !== JSON.stringify(extraFolderPaths);
+29 -8
View File
@@ -168,6 +168,18 @@ export class DownloadManager {
let failedDownloads = 0; let failedDownloads = 0;
let accessFailures = 0; let accessFailures = 0;
let currentLoraProgress = 0; let currentLoraProgress = 0;
let cancelled = false;
this.importManager.loadingManager.showCancelButton(async () => {
if (cancelled) return;
cancelled = true;
try {
const loraClient = getModelApiClient(MODEL_TYPES.LORA);
await loraClient.cancelDownload(batchDownloadId);
} catch (e) {
console.error('Cancel request failed:', e);
}
});
// Set up progress tracking for current download // Set up progress tracking for current download
ws.onmessage = (event) => { ws.onmessage = (event) => {
@@ -179,6 +191,11 @@ export class DownloadManager {
return; return;
} }
if (data.status === 'cancelled') {
cancelled = true;
return;
}
// Process progress updates for our current active download // Process progress updates for our current active download
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) { if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
// Update current LoRA progress // Update current LoRA progress
@@ -221,6 +238,8 @@ export class DownloadManager {
const useDefaultPaths = getStorageItem('use_default_path_loras', false); const useDefaultPaths = getStorageItem('use_default_path_loras', false);
for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) { for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) {
if (cancelled) break;
const lora = this.importManager.downloadableLoRAs[i]; const lora = this.importManager.downloadableLoRAs[i];
// Reset current LoRA progress for new download // Reset current LoRA progress for new download
@@ -241,15 +260,13 @@ export class DownloadManager {
batchDownloadId batchDownloadId
); );
if (cancelled) break;
if (!response.success) { if (!response.success) {
console.error(`Failed to download LoRA ${lora.name}: ${response.error}`); console.error(`Failed to download LoRA ${lora.name}: ${response.error}`);
failedDownloads++; failedDownloads++;
// Continue with next download
} else { } else {
completedDownloads++; completedDownloads++;
// Update progress to show completion of current LoRA
updateProgress(100, completedDownloads, ''); updateProgress(100, completedDownloads, '');
if (completedDownloads + failedDownloads < this.importManager.downloadableLoRAs.length) { if (completedDownloads + failedDownloads < this.importManager.downloadableLoRAs.length) {
@@ -259,9 +276,10 @@ export class DownloadManager {
} }
} }
} catch (downloadError) { } catch (downloadError) {
console.error(`Error downloading LoRA ${lora.name}:`, downloadError); if (!cancelled) {
failedDownloads++; console.error(`Error downloading LoRA ${lora.name}:`, downloadError);
// Continue with next download failedDownloads++;
}
} }
} }
@@ -269,7 +287,10 @@ export class DownloadManager {
ws.close(); ws.close();
// Show appropriate completion message based on results // Show appropriate completion message based on results
if (failedDownloads === 0) { if (cancelled) {
showToast('toast.downloads.downloadStopped', {}, 'info',
`Download cancelled. ${completedDownloads} item(s) completed.`);
} else if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success'); showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else { } else {
if (accessFailures > 0) { if (accessFailures > 0) {
+8
View File
@@ -133,6 +133,14 @@
outline: none; outline: none;
} }
/* Vue node mode: prevent content from pushing node size via ResizeObserver.
Same technique as .lm-loras-container.lm-vue-node above. */
.comfy-tags-container.lm-vue-node {
height: 100%;
min-height: var(--comfy-widget-min-height, 150px);
contain: layout size;
}
.lm-lora-empty-state { .lm-lora-empty-state {
text-align: center; text-align: center;
padding: 20px 0; padding: 20px 0;
+11
View File
@@ -1,6 +1,7 @@
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
import { forwardMiddleMouseToCanvas, forwardWheelToCanvas } from "./utils.js"; import { forwardMiddleMouseToCanvas, forwardWheelToCanvas } from "./utils.js";
import { copyToClipboard } from "./loras_widget_utils.js"; import { copyToClipboard } from "./loras_widget_utils.js";
import { ensureLmStyles } from "./lm_styles_loader.js";
const MIN_HEIGHT = 150; const MIN_HEIGHT = 150;
const GROUP_EDITOR_ID = "lm-trigger-group-editor"; const GROUP_EDITOR_ID = "lm-trigger-group-editor";
@@ -696,6 +697,16 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
outline: "none", outline: "none",
}); });
// Set a fixed minimum height so the node has a reasonable starting size.
// Adding or removing tags does NOT change the node size — the container
// scrolls when content exceeds the allocated space.
ensureLmStyles();
container.style.setProperty("--comfy-widget-min-height", `${MIN_HEIGHT}px`);
if (typeof LiteGraph !== "undefined" && LiteGraph.vueNodesMode) {
container.classList.add("lm-vue-node");
}
const initialTagsData = opts?.defaultVal || []; const initialTagsData = opts?.defaultVal || [];
function renderSimpleTag(tagData, index, widget, showStrengthInfo) { function renderSimpleTag(tagData, index, widget, showStrengthInfo) {