diff --git a/locales/de.json b/locales/de.json index 12aed4e5..8729ccf1 100644 --- a/locales/de.json +++ b/locales/de.json @@ -575,6 +575,7 @@ "skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen", "resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen", "deleteAll": "Alle Modelle löschen", + "downloadMissingLoras": "Fehlende LoRAs herunterladen", "clear": "Auswahl löschen", "skipMetadataRefreshCount": "Überspringen({count} Modelle)", "resumeMetadataRefreshCount": "Fortsetzen({count} Modelle)", @@ -983,6 +984,14 @@ "save": "Basis-Modell aktualisieren", "cancel": "Abbrechen" }, + "bulkDownloadMissingLoras": { + "title": "Fehlende LoRAs herunterladen", + "message": "{uniqueCount} einzigartige fehlende LoRAs gefunden (von insgesamt {totalCount} in ausgewählten Rezepten).", + "previewTitle": "Zu herunterladende LoRAs:", + "moreItems": "...und {count} weitere", + "note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.", + "downloadButton": "{count} LoRA(s) herunterladen" + }, "exampleAccess": { "title": "Lokale Beispielbilder", "message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:", @@ -1507,7 +1516,10 @@ "batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoDirectory": "Please enter a directory path", "batchImportBrowseFailed": "Failed to browse directory: {message}", - "batchImportDirectorySelected": "Directory selected: {path}" + "batchImportDirectorySelected": "Directory selected: {path}", + "noRecipesSelected": "Keine Rezepte ausgewählt", + "noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden", + "noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest." }, "models": { "noModelsSelected": "Keine Modelle ausgewählt", diff --git a/locales/en.json b/locales/en.json index 0a1e83d2..8f3dbcf8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -575,6 +575,7 @@ "skipMetadataRefresh": "Skip Metadata Refresh for Selected", "resumeMetadataRefresh": "Resume Metadata Refresh for Selected", "deleteAll": "Delete Selected Models", + "downloadMissingLoras": "Download Missing LoRAs", "clear": "Clear Selection", "skipMetadataRefreshCount": "Skip ({count} models)", "resumeMetadataRefreshCount": "Resume ({count} models)", @@ -983,6 +984,14 @@ "save": "Update Base Model", "cancel": "Cancel" }, + "bulkDownloadMissingLoras": { + "title": "Download Missing LoRAs", + "message": "Found {uniqueCount} unique missing LoRAs (from {totalCount} total across selected recipes).", + "previewTitle": "LoRAs to download:", + "moreItems": "...and {count} more", + "note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.", + "downloadButton": "Download {count} LoRA(s)" + }, "exampleAccess": { "title": "Local Example Images", "message": "No local example images found for this model. View options:", @@ -1507,7 +1516,10 @@ "batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoDirectory": "Please enter a directory path", "batchImportBrowseFailed": "Failed to browse directory: {message}", - "batchImportDirectorySelected": "Directory selected: {path}" + "batchImportDirectorySelected": "Directory selected: {path}", + "noRecipesSelected": "No recipes selected", + "noMissingLorasInSelection": "No missing LoRAs found in selected recipes", + "noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings." }, "models": { "noModelsSelected": "No models selected", diff --git a/locales/es.json b/locales/es.json index 09d8f516..2ac8dd11 100644 --- a/locales/es.json +++ b/locales/es.json @@ -575,6 +575,7 @@ "skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados", "resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados", "deleteAll": "Eliminar todos los modelos", + "downloadMissingLoras": "Descargar LoRAs faltantes", "clear": "Limpiar selección", "skipMetadataRefreshCount": "Omitir({count} modelos)", "resumeMetadataRefreshCount": "Reanudar({count} modelos)", @@ -983,6 +984,14 @@ "save": "Actualizar modelo base", "cancel": "Cancelar" }, + "bulkDownloadMissingLoras": { + "title": "Descargar LoRAs faltantes", + "message": "Se encontraron {uniqueCount} LoRAs faltantes únicos (de {totalCount} en total entre las recetas seleccionadas).", + "previewTitle": "LoRAs para descargar:", + "moreItems": "...y {count} más", + "note": "Los archivos se descargarán usando las plantillas de ruta predeterminadas. Esto puede tomar un tiempo dependiendo del número de LoRAs.", + "downloadButton": "Descargar {count} LoRA(s)" + }, "exampleAccess": { "title": "Imágenes de ejemplo locales", "message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:", @@ -1507,7 +1516,10 @@ "batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoDirectory": "Please enter a directory path", "batchImportBrowseFailed": "Failed to browse directory: {message}", - "batchImportDirectorySelected": "Directory selected: {path}" + "batchImportDirectorySelected": "Directory selected: {path}", + "noRecipesSelected": "No se han seleccionado recetas", + "noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas", + "noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración." }, "models": { "noModelsSelected": "No hay modelos seleccionados", diff --git a/locales/fr.json b/locales/fr.json index 7984452b..4c401755 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -575,6 +575,7 @@ "skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection", "resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection", "deleteAll": "Supprimer tous les modèles", + "downloadMissingLoras": "Télécharger les LoRAs manquants", "clear": "Effacer la sélection", "skipMetadataRefreshCount": "Ignorer({count} modèles)", "resumeMetadataRefreshCount": "Reprendre({count} modèles)", @@ -983,6 +984,14 @@ "save": "Mettre à jour le modèle de base", "cancel": "Annuler" }, + "bulkDownloadMissingLoras": { + "title": "Télécharger les LoRAs manquants", + "message": "{uniqueCount} LoRAs manquants uniques trouvés (sur un total de {totalCount} dans les recettes sélectionnées).", + "previewTitle": "LoRAs à télécharger :", + "moreItems": "...et {count} de plus", + "note": "Les fichiers seront téléchargés en utilisant les modèles de chemins par défaut. Cela peut prendre un certain temps selon le nombre de LoRAs.", + "downloadButton": "Télécharger {count} LoRA(s)" + }, "exampleAccess": { "title": "Images d'exemple locales", "message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :", @@ -1507,7 +1516,10 @@ "batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoDirectory": "Please enter a directory path", "batchImportBrowseFailed": "Failed to browse directory: {message}", - "batchImportDirectorySelected": "Directory selected: {path}" + "batchImportDirectorySelected": "Directory selected: {path}", + "noRecipesSelected": "Aucune recette sélectionnée", + "noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées", + "noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres." }, "models": { "noModelsSelected": "Aucun modèle sélectionné", diff --git a/locales/he.json b/locales/he.json index ff645faa..b0d3e03c 100644 --- a/locales/he.json +++ b/locales/he.json @@ -575,6 +575,7 @@ "skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים", "resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים", "deleteAll": "מחק את כל המודלים", + "downloadMissingLoras": "הורדת LoRAs חסרים", "clear": "נקה בחירה", "skipMetadataRefreshCount": "דילוג({count} מודלים)", "resumeMetadataRefreshCount": "המשך({count} מודלים)", @@ -983,6 +984,14 @@ "save": "עדכן מודל בסיס", "cancel": "ביטול" }, + "bulkDownloadMissingLoras": { + "title": "הורדת LoRAs חסרים", + "message": "נמצאו {uniqueCount} LoRAs חסרים ייחודיים (מתוך {totalCount} בסך הכל במתכונים שנבחרו).", + "previewTitle": "LoRAs להורדה:", + "moreItems": "...ועוד {count}", + "note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.", + "downloadButton": "הורד {count} LoRA(s)" + }, "exampleAccess": { "title": "תמונות דוגמה מקומיות", "message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:", @@ -1507,7 +1516,10 @@ "batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoDirectory": "Please enter a directory path", "batchImportBrowseFailed": "Failed to browse directory: {message}", - "batchImportDirectorySelected": "Directory selected: {path}" + "batchImportDirectorySelected": "Directory selected: {path}", + "noRecipesSelected": "לא נבחרו מתכונים", + "noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו", + "noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות." }, "models": { "noModelsSelected": "לא נבחרו מודלים", diff --git a/locales/ja.json b/locales/ja.json index 035805e7..d8fdbb40 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -575,6 +575,7 @@ "skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ", "resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開", "deleteAll": "すべてのモデルを削除", + "downloadMissingLoras": "不足している LoRA をダウンロード", "clear": "選択をクリア", "skipMetadataRefreshCount": "スキップ({count}モデル)", "resumeMetadataRefreshCount": "再開({count}モデル)", @@ -983,6 +984,14 @@ "save": "ベースモデルを更新", "cancel": "キャンセル" }, + "bulkDownloadMissingLoras": { + "title": "不足している LoRA をダウンロード", + "message": "選択したレシピから合計 {totalCount} 個中 {uniqueCount} 個のユニークな不足している LoRA が見つかりました。", + "previewTitle": "ダウンロードする LoRA:", + "moreItems": "...あと {count} 個", + "note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。", + "downloadButton": "{count} 個の LoRA をダウンロード" + }, "exampleAccess": { "title": "ローカル例画像", "message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:", @@ -1507,7 +1516,10 @@ "batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoDirectory": "Please enter a directory path", "batchImportBrowseFailed": "Failed to browse directory: {message}", - "batchImportDirectorySelected": "Directory selected: {path}" + "batchImportDirectorySelected": "Directory selected: {path}", + "noRecipesSelected": "レシピが選択されていません", + "noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした", + "noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。" }, "models": { "noModelsSelected": "モデルが選択されていません", diff --git a/locales/ko.json b/locales/ko.json index 177ff18a..2f28182e 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -575,6 +575,7 @@ "skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기", "resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개", "deleteAll": "모든 모델 삭제", + "downloadMissingLoras": "누락된 LoRA 다운로드", "clear": "선택 지우기", "skipMetadataRefreshCount": "건너뛰기({count}개 모델)", "resumeMetadataRefreshCount": "재개({count}개 모델)", @@ -983,6 +984,14 @@ "save": "베이스 모델 업데이트", "cancel": "취소" }, + "bulkDownloadMissingLoras": { + "title": "누락된 LoRA 다운로드", + "message": "선택한 레시피에서 총 {totalCount}개 중 {uniqueCount}개의 고유한 누락된 LoRA를 찾았습니다.", + "previewTitle": "다운로드할 LoRA:", + "moreItems": "...그리고 {count}개 더", + "note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.", + "downloadButton": "{count}개 LoRA 다운로드" + }, "exampleAccess": { "title": "로컬 예시 이미지", "message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:", @@ -1507,7 +1516,10 @@ "batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoDirectory": "Please enter a directory path", "batchImportBrowseFailed": "Failed to browse directory: {message}", - "batchImportDirectorySelected": "Directory selected: {path}" + "batchImportDirectorySelected": "Directory selected: {path}", + "noRecipesSelected": "선택한 레시피가 없습니다", + "noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다", + "noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요." }, "models": { "noModelsSelected": "선택된 모델이 없습니다", diff --git a/locales/ru.json b/locales/ru.json index 309bad14..eeb2ceb3 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -575,6 +575,7 @@ "skipMetadataRefresh": "Пропустить обновление метаданных для выбранных", "resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных", "deleteAll": "Удалить все модели", + "downloadMissingLoras": "Скачать отсутствующие LoRAs", "clear": "Очистить выбор", "skipMetadataRefreshCount": "Пропустить({count} моделей)", "resumeMetadataRefreshCount": "Возобновить({count} моделей)", @@ -983,6 +984,14 @@ "save": "Обновить базовую модель", "cancel": "Отмена" }, + "bulkDownloadMissingLoras": { + "title": "Скачать отсутствующие LoRAs", + "message": "Найдено {uniqueCount} уникальных отсутствующих LoRAs (из {totalCount} всего в выбранных рецептах).", + "previewTitle": "LoRAs для скачивания:", + "moreItems": "...и еще {count}", + "note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.", + "downloadButton": "Скачать {count} LoRA(s)" + }, "exampleAccess": { "title": "Локальные примеры изображений", "message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:", @@ -1507,7 +1516,10 @@ "batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoDirectory": "Please enter a directory path", "batchImportBrowseFailed": "Failed to browse directory: {message}", - "batchImportDirectorySelected": "Directory selected: {path}" + "batchImportDirectorySelected": "Directory selected: {path}", + "noRecipesSelected": "Рецепты не выбраны", + "noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs", + "noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках." }, "models": { "noModelsSelected": "Модели не выбраны", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index e6fbd385..eab2fea4 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -575,6 +575,7 @@ "skipMetadataRefresh": "跳过所选模型的元数据刷新", "resumeMetadataRefresh": "恢复所选模型的元数据刷新", "deleteAll": "删除选中模型", + "downloadMissingLoras": "下载缺失的 LoRAs", "clear": "清除选择", "skipMetadataRefreshCount": "跳过({count} 个模型)", "resumeMetadataRefreshCount": "恢复({count} 个模型)", @@ -983,6 +984,14 @@ "save": "更新基础模型", "cancel": "取消" }, + "bulkDownloadMissingLoras": { + "title": "下载缺失的 LoRAs", + "message": "发现 {uniqueCount} 个独特的缺失 LoRAs(从选定配方中的 {totalCount} 个总数)。", + "previewTitle": "要下载的 LoRAs:", + "moreItems": "...还有 {count} 个", + "note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。", + "downloadButton": "下载 {count} 个 LoRA(s)" + }, "exampleAccess": { "title": "本地示例图片", "message": "未找到此模型的本地示例图片。可选操作:", @@ -1507,7 +1516,10 @@ "batchImportNoUrls": "请输入至少一个 URL 或文件路径", "batchImportNoDirectory": "请输入目录路径", "batchImportBrowseFailed": "浏览目录失败:{message}", - "batchImportDirectorySelected": "已选择目录:{path}" + "batchImportDirectorySelected": "已选择目录:{path}", + "noRecipesSelected": "未选择任何配方", + "noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs", + "noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。" }, "models": { "noModelsSelected": "未选中模型", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 1918def3..de9de6fc 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -575,6 +575,7 @@ "skipMetadataRefresh": "跳過所選模型的元數據更新", "resumeMetadataRefresh": "恢復所選模型的元數據更新", "deleteAll": "刪除全部模型", + "downloadMissingLoras": "下載缺失的 LoRAs", "clear": "清除選取", "skipMetadataRefreshCount": "跳過({count} 個模型)", "resumeMetadataRefreshCount": "恢復({count} 個模型)", @@ -983,6 +984,14 @@ "save": "更新基礎模型", "cancel": "取消" }, + "bulkDownloadMissingLoras": { + "title": "下載缺失的 LoRAs", + "message": "發現 {uniqueCount} 個獨特的缺失 LoRAs(從選取食譜中的 {totalCount} 個總數)。", + "previewTitle": "要下載的 LoRAs:", + "moreItems": "...還有 {count} 個", + "note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。", + "downloadButton": "下載 {count} 個 LoRA(s)" + }, "exampleAccess": { "title": "本機範例圖片", "message": "此模型未找到本機範例圖片。可選擇:", @@ -1507,7 +1516,10 @@ "batchImportNoUrls": "請輸入至少一個 URL 或檔案路徑", "batchImportNoDirectory": "請輸入目錄路徑", "batchImportBrowseFailed": "瀏覽目錄失敗:{message}", - "batchImportDirectorySelected": "已選擇目錄:{path}" + "batchImportDirectorySelected": "已選擇目錄:{path}", + "noRecipesSelected": "未選取任何食譜", + "noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs", + "noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。" }, "models": { "noModelsSelected": "未選擇模型", diff --git a/static/css/components/modal/_base.css b/static/css/components/modal/_base.css index eeadb450..18efbca0 100644 --- a/static/css/components/modal/_base.css +++ b/static/css/components/modal/_base.css @@ -151,7 +151,8 @@ body.modal-open { [data-theme="dark"] .changelog-section, [data-theme="dark"] .update-info, [data-theme="dark"] .info-item, -[data-theme="dark"] .path-preview { +[data-theme="dark"] .path-preview, +[data-theme="dark"] #bulkDownloadMissingLorasModal .bulk-download-loras-preview { background: rgba(255, 255, 255, 0.03); border: 1px solid var(--lora-border); } @@ -349,3 +350,87 @@ button:disabled, margin-top: var(--space-1); text-align: center; } + +/* Bulk Download Missing LoRAs Modal */ +#bulkDownloadMissingLorasModal .modal-body { + padding: var(--space-3); +} + +#bulkDownloadMissingLorasModal .confirmation-message { + color: var(--text-color); + margin-bottom: var(--space-3); + font-size: 1em; + line-height: 1.5; +} + +#bulkDownloadMissingLorasModal .bulk-download-loras-preview { + background: rgba(0, 0, 0, 0.03); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: var(--border-radius-sm); + padding: var(--space-3); + margin-bottom: var(--space-3); +} + +#bulkDownloadMissingLorasModal .preview-title { + font-weight: 600; + margin-bottom: var(--space-2); + color: var(--text-color); + font-size: 0.95em; +} + +#bulkDownloadMissingLorasModal .bulk-download-loras-list { + list-style: none; + padding: 0; + margin: 0; +} + +#bulkDownloadMissingLorasModal .bulk-download-loras-list li { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-1) 0; + border-bottom: 1px solid var(--border-color); + font-size: 0.9em; +} + +#bulkDownloadMissingLorasModal .bulk-download-loras-list li:last-child { + border-bottom: none; +} + +#bulkDownloadMissingLorasModal .bulk-download-loras-list li.more-items { + font-style: italic; + opacity: 0.7; + text-align: center; + justify-content: center; + padding: var(--space-2) 0; +} + +#bulkDownloadMissingLorasModal .lora-name { + font-weight: 500; + color: var(--text-color); + flex: 1; +} + +#bulkDownloadMissingLorasModal .lora-version { + font-size: 0.85em; + opacity: 0.7; + margin-left: var(--space-1); + color: var(--text-muted); +} + +#bulkDownloadMissingLorasModal .confirmation-note { + display: flex; + align-items: flex-start; + gap: var(--space-2); + padding: var(--space-2); + background: rgba(59, 130, 246, 0.1); + border-radius: var(--border-radius-sm); + font-size: 0.9em; + color: var(--text-color); +} + +#bulkDownloadMissingLorasModal .confirmation-note i { + color: var(--lora-accent); + margin-top: 2px; + flex-shrink: 0; +} diff --git a/static/js/components/ContextMenu/BulkContextMenu.js b/static/js/components/ContextMenu/BulkContextMenu.js index 2349cc2b..84995051 100644 --- a/static/js/components/ContextMenu/BulkContextMenu.js +++ b/static/js/components/ContextMenu/BulkContextMenu.js @@ -2,6 +2,8 @@ import { BaseContextMenu } from './BaseContextMenu.js'; import { state } from '../../state/index.js'; import { bulkManager } from '../../managers/BulkManager.js'; import { updateElementText, translate } from '../../utils/i18nHelpers.js'; +import { bulkMissingLoraDownloadManager } from '../../managers/BulkMissingLoraDownloadManager.js'; +import { showToast } from '../../utils/uiHelpers.js'; export class BulkContextMenu extends BaseContextMenu { constructor() { @@ -37,6 +39,7 @@ export class BulkContextMenu extends BaseContextMenu { const moveAllItem = this.menu.querySelector('[data-action="move-all"]'); const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]'); const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]'); + const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]'); if (sendToWorkflowAppendItem) { sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none'; @@ -71,6 +74,10 @@ export class BulkContextMenu extends BaseContextMenu { if (setContentRatingItem) { setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none'; } + if (downloadMissingLorasItem) { + // Only show for recipes page + downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none'; + } const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]'); const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]'); @@ -178,6 +185,9 @@ export class BulkContextMenu extends BaseContextMenu { case 'delete-all': bulkManager.showBulkDeleteModal(); break; + case 'download-missing-loras': + this.handleDownloadMissingLoras(); + break; case 'clear': bulkManager.clearSelection(); break; @@ -185,4 +195,39 @@ export class BulkContextMenu extends BaseContextMenu { console.warn(`Unknown bulk action: ${action}`); } } + + /** + * Handle downloading missing LoRAs for selected recipes + */ + async handleDownloadMissingLoras() { + if (state.selectedModels.size === 0) { + return; + } + + // Get selected recipes from the virtual scroller + const selectedRecipes = []; + state.selectedModels.forEach(filePath => { + const card = document.querySelector(`.model-card[data-filepath="${CSS.escape(filePath)}"]`); + if (card && card.recipeData) { + selectedRecipes.push(card.recipeData); + } + }); + + if (selectedRecipes.length === 0) { + // Try to get recipes from virtual scroller state + const items = state.virtualScroller?.items || []; + items.forEach(recipe => { + if (recipe.file_path && state.selectedModels.has(recipe.file_path)) { + selectedRecipes.push(recipe); + } + }); + } + + if (selectedRecipes.length === 0) { + showToast('toast.recipes.noRecipesSelected', {}, 'warning'); + return; + } + + await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes); + } } diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index b8bcdf12..a3b5807e 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -1299,7 +1299,6 @@ class RecipeModal { // New method to navigate to the LoRAs page navigateToLorasPage(specificLoraIndex = null) { - debugger; // Close the current modal modalManager.closeModal('recipeModal'); diff --git a/static/js/managers/BulkMissingLoraDownloadManager.js b/static/js/managers/BulkMissingLoraDownloadManager.js new file mode 100644 index 00000000..9fbedfee --- /dev/null +++ b/static/js/managers/BulkMissingLoraDownloadManager.js @@ -0,0 +1,357 @@ +import { showToast } from '../utils/uiHelpers.js'; +import { translate } from '../utils/i18nHelpers.js'; +import { getModelApiClient } from '../api/modelApiFactory.js'; +import { MODEL_TYPES } from '../api/apiConfig.js'; +import { state } from '../state/index.js'; +import { modalManager } from './ModalManager.js'; + +/** + * Manager for downloading missing LoRAs for selected recipes in bulk + */ +export class BulkMissingLoraDownloadManager { + constructor() { + this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA); + this.pendingLoras = []; + this.pendingRecipes = []; + } + + /** + * Collect missing LoRAs from selected recipes with deduplication + * @param {Array} selectedRecipes - Array of selected recipe objects + * @returns {Object} - Object containing unique missing LoRAs and statistics + */ + collectMissingLoras(selectedRecipes) { + const uniqueLoras = new Map(); // key: hash or modelVersionId, value: lora object + const missingLorasByRecipe = new Map(); + let totalMissingCount = 0; + + selectedRecipes.forEach(recipe => { + const missingLoras = []; + + if (recipe.loras && Array.isArray(recipe.loras)) { + recipe.loras.forEach(lora => { + // Only include LoRAs not in library and not deleted + if (!lora.inLibrary && !lora.isDeleted) { + const uniqueKey = lora.hash || lora.id || lora.modelVersionId; + + if (uniqueKey && !uniqueLoras.has(uniqueKey)) { + // Store the LoRA info + uniqueLoras.set(uniqueKey, { + ...lora, + modelId: lora.modelId || lora.model_id, + id: lora.id || lora.modelVersionId, + }); + } + + missingLoras.push(lora); + totalMissingCount++; + } + }); + } + + if (missingLoras.length > 0) { + missingLorasByRecipe.set(recipe.id || recipe.file_path, { + recipe, + missingLoras + }); + } + }); + + return { + uniqueLoras: Array.from(uniqueLoras.values()), + uniqueCount: uniqueLoras.size, + totalMissingCount, + missingLorasByRecipe + }; + } + + /** + * Show confirmation modal for downloading missing LoRAs + * @param {Object} stats - Statistics about missing LoRAs + * @returns {Promise} - Whether user confirmed + */ + async showConfirmationModal(stats) { + const { uniqueCount, totalMissingCount, uniqueLoras } = stats; + + if (uniqueCount === 0) { + showToast('toast.recipes.noMissingLoras', {}, 'info'); + return false; + } + + // Store pending data for confirmation + this.pendingLoras = uniqueLoras; + + // Update modal content + const messageEl = document.getElementById('bulkDownloadMissingLorasMessage'); + const listEl = document.getElementById('bulkDownloadMissingLorasList'); + const confirmBtn = document.getElementById('bulkDownloadMissingLorasConfirmBtn'); + + if (messageEl) { + messageEl.textContent = translate('modals.bulkDownloadMissingLoras.message', { + uniqueCount, + totalCount: totalMissingCount + }, `Found ${uniqueCount} unique missing LoRAs (from ${totalMissingCount} total across selected recipes).`); + } + + if (listEl) { + listEl.innerHTML = uniqueLoras.slice(0, 10).map(lora => ` +
  • + ${lora.name || lora.file_name || 'Unknown'} + ${lora.version ? `${lora.version}` : ''} +
  • + `).join('') + + (uniqueLoras.length > 10 ? ` +
  • ${translate('modals.bulkDownloadMissingLoras.moreItems', { count: uniqueLoras.length - 10 }, `...and ${uniqueLoras.length - 10} more`)}
  • + ` : ''); + } + + if (confirmBtn) { + confirmBtn.innerHTML = ` + + ${translate('modals.bulkDownloadMissingLoras.downloadButton', { count: uniqueCount }, `Download ${uniqueCount} LoRA(s)`)} + `; + } + + // Show modal + modalManager.showModal('bulkDownloadMissingLorasModal'); + + // Return a promise that will be resolved when user confirms or cancels + return new Promise((resolve) => { + this.confirmResolve = resolve; + }); + } + + /** + * Called when user confirms download in modal + */ + async confirmDownload() { + modalManager.closeModal('bulkDownloadMissingLorasModal'); + + if (this.confirmResolve) { + this.confirmResolve(true); + this.confirmResolve = null; + } + + // Execute download + await this.executeDownload(this.pendingLoras); + this.pendingLoras = []; + } + + /** + * Download missing LoRAs for selected recipes + * @param {Array} selectedRecipes - Array of selected recipe objects + */ + async downloadMissingLoras(selectedRecipes) { + if (!selectedRecipes || selectedRecipes.length === 0) { + showToast('toast.recipes.noRecipesSelected', {}, 'warning'); + return; + } + + // Store selected recipes + this.pendingRecipes = selectedRecipes; + + // Collect missing LoRAs with deduplication + const stats = this.collectMissingLoras(selectedRecipes); + + if (stats.uniqueCount === 0) { + showToast('toast.recipes.noMissingLorasInSelection', {}, 'info'); + return; + } + + // Show confirmation modal + const confirmed = await this.showConfirmationModal(stats); + if (!confirmed) { + return; + } + } + + /** + * Execute the download process + * @param {Array} lorasToDownload - Array of unique LoRAs to download + */ + async executeDownload(lorasToDownload) { + const totalLoras = lorasToDownload.length; + + // Get LoRA root directory + const loraRoot = await this.getLoraRoot(); + if (!loraRoot) { + showToast('toast.recipes.noLoraRootConfigured', {}, 'error'); + return; + } + + // Generate batch download ID + const batchDownloadId = Date.now().toString(); + + // Use default paths + const useDefaultPaths = true; + + // Set up WebSocket for progress updates + const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; + const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`); + + // Show download progress UI + const loadingManager = state.loadingManager; + const updateProgress = loadingManager.showDownloadProgress(totalLoras); + + let completedDownloads = 0; + let failedDownloads = 0; + let currentLoraProgress = 0; + + // Set up WebSocket message handler + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + // Handle download ID confirmation + if (data.type === 'download_id') { + console.log(`Connected to batch download progress with ID: ${data.download_id}`); + return; + } + + // Process progress updates + if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) { + currentLoraProgress = data.progress; + + const currentLora = lorasToDownload[completedDownloads + failedDownloads]; + const loraName = currentLora ? (currentLora.name || currentLora.file_name || 'Unknown') : ''; + + const metrics = { + bytesDownloaded: data.bytes_downloaded, + totalBytes: data.total_bytes, + bytesPerSecond: data.bytes_per_second + }; + + updateProgress(currentLoraProgress, completedDownloads, loraName, metrics); + + // Update status message + if (currentLoraProgress < 3) { + loadingManager.setStatus( + translate('recipes.controls.import.startingDownload', + { current: completedDownloads + failedDownloads + 1, total: totalLoras }, + `Starting download for LoRA ${completedDownloads + failedDownloads + 1}/${totalLoras}` + ) + ); + } else if (currentLoraProgress > 3 && currentLoraProgress < 100) { + loadingManager.setStatus( + translate('recipes.controls.import.downloadingLoras', {}, `Downloading LoRAs...`) + ); + } + } + }; + + // Wait for WebSocket to connect + await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = (error) => { + console.error('WebSocket error:', error); + reject(error); + }; + }); + + // Download each LoRA sequentially + for (let i = 0; i < lorasToDownload.length; i++) { + const lora = lorasToDownload[i]; + + currentLoraProgress = 0; + + loadingManager.setStatus( + translate('recipes.controls.import.startingDownload', + { current: i + 1, total: totalLoras }, + `Starting download for LoRA ${i + 1}/${totalLoras}` + ) + ); + updateProgress(0, completedDownloads, lora.name || lora.file_name || 'Unknown'); + + try { + const modelId = lora.modelId || lora.model_id; + const versionId = lora.id || lora.modelVersionId; + + if (!modelId && !versionId) { + console.warn(`Skipping LoRA without model/version ID:`, lora); + failedDownloads++; + continue; + } + + const response = await this.loraApiClient.downloadModel( + modelId, + versionId, + loraRoot, + '', // Empty relative path, use default paths + useDefaultPaths, + batchDownloadId + ); + + if (!response.success) { + console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`); + failedDownloads++; + } else { + completedDownloads++; + updateProgress(100, completedDownloads, ''); + } + } catch (error) { + console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error); + failedDownloads++; + } + } + + // Close WebSocket + ws.close(); + + // Hide loading UI + loadingManager.hide(); + + // Show completion message + if (failedDownloads === 0) { + showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success'); + } else { + showToast('toast.loras.downloadPartialSuccess', { + completed: completedDownloads, + total: totalLoras + }, 'warning'); + } + + // Refresh the recipes list to update LoRA status + if (window.recipeManager) { + window.recipeManager.loadRecipes(); + } + } + + /** + * Get LoRA root directory from API + * @returns {Promise} - LoRA root directory or null + */ + async getLoraRoot() { + try { + // Fetch available LoRA roots from API + const rootsData = await this.loraApiClient.fetchModelRoots(); + + if (!rootsData || !rootsData.roots || rootsData.roots.length === 0) { + console.error('No LoRA roots available'); + return null; + } + + // Try to get default root from settings + const defaultRootKey = 'default_lora_root'; + const defaultRoot = state.global?.settings?.[defaultRootKey]; + + // If default root is set and exists in available roots, use it + if (defaultRoot && rootsData.roots.includes(defaultRoot)) { + return defaultRoot; + } + + // Otherwise, return the first available root + return rootsData.roots[0]; + + } catch (error) { + console.error('Error getting LoRA root:', error); + return null; + } + } +} + +// Export singleton instance +export const bulkMissingLoraDownloadManager = new BulkMissingLoraDownloadManager(); + +// Make available globally for HTML onclick handlers +if (typeof window !== 'undefined') { + window.bulkMissingLoraDownloadManager = bulkMissingLoraDownloadManager; +} diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index ea9cdb17..8de0c563 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -291,6 +291,19 @@ export class ModalManager { }); } + // Register bulkDownloadMissingLorasModal + const bulkDownloadMissingLorasModal = document.getElementById('bulkDownloadMissingLorasModal'); + if (bulkDownloadMissingLorasModal) { + this.registerModal('bulkDownloadMissingLorasModal', { + element: bulkDownloadMissingLorasModal, + onClose: () => { + this.getModal('bulkDownloadMissingLorasModal').element.style.display = 'none'; + document.body.classList.remove('modal-open'); + }, + closeOnOutsideClick: true + }); + } + document.addEventListener('keydown', this.boundHandleEscape); this.initialized = true; } diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index d2651f8a..66fffd5b 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -87,6 +87,9 @@ {{ t('loras.bulkOperations.resumeMetadataRefresh') }}
    +
    + {{ t('loras.bulkOperations.downloadMissingLoras') }} +
    {{ t('loras.bulkOperations.moveAll') }}
    diff --git a/templates/components/modals/confirm_modals.html b/templates/components/modals/confirm_modals.html index 4636b87b..6c4fedea 100644 --- a/templates/components/modals/confirm_modals.html +++ b/templates/components/modals/confirm_modals.html @@ -80,4 +80,32 @@ + + + + \ No newline at end of file