Compare commits

...

19 Commits

Author SHA1 Message Date
Will Miao
54bcdfab38 fix(test): add folder_path param to DummyUpdateService to match updated interface 2026-06-02 19:02:18 +08:00
Will Miao
2e7532eecc feat(update): add per-folder update check via sidebar context menu (#944) 2026-06-02 18:34:01 +08:00
Will Miao
7e5e3b1ec7 feat(download): support multi-precision file selection for CivitAI model downloads (#956) 2026-06-02 15:41:42 +08:00
Will Miao
df67bd396a fix(recipe): re-export syncChanges and add show mock to fix test 2026-06-02 11:02:20 +08:00
Will Miao
dd5d9cfcb2 fix(recipe): align refresh split button behavior with models page
- refreshRecipes() now accepts fullRebuild param and passes it to scan endpoint
- Use consistent toast.api.refreshComplete / toast.api.refreshFailed keys
- Use loadingManager.show() with progress bar (matching models page style)
- Both Refresh and Rebuild Cache now hit the real /api/lm/recipes/scan endpoint
- Add sidebarManager.refresh() after recipe scan completes
- Backend scan_recipes handler reads full_rebuild query param
2026-06-02 09:50:59 +08:00
Will Miao
d9fd60bec1 fix(recipe): use VirtualScroller pageSize in reload helpers to prevent pagination offset gap 2026-06-02 08:43:30 +08:00
Will Miao
b633b22779 fix(recipe): prevent empty grid by removing preserveScroll from refresh triggers
Bug: when scrolling down on recipes page, any operation with
preserveScroll: true would fetch only page 1 data then restore
scroll position to beyond the loaded items, leaving the grid empty.

Fix:
- Remove preserveScroll: true from all 7 must-refresh trigger
  paths (filter, search, sort, import, settings reload, sync,
  rebuild cache, sidebar folder nav)
- Replace full list refresh with updateSingleItem() for repair
  and bulk missing-LoRA download operations
- Update tests to match new scroll-free behavior
2026-06-02 08:15:29 +08:00
Will Miao
1ffa543160 fix(recipe): set dataset.favorite on recipe cards for correct bulk favorite menu 2026-06-02 07:06:58 +08:00
Will Miao
cdc940586e fix(civarchive): infer metadata.format from extension and prioritize safetensors in file list 2026-06-01 22:07:55 +08:00
Will Miao
ccf1c6f2ae fix(recipe): resolve base_model from parser and prevent empty checkpoint save on CivitAI import
- Apply CivitaiApiMetadataParser's base_model result to metadata in
  _do_import_remote_recipe and _do_import_from_url (was previously discarded)
- Extract baseModel from raw civitai_info before populate_checkpoint_from_civitai
  so it's not lost when the type check rejects non-checkpoint model versions
- Only format and save checkpoint entry when it has real data (modelId, versionId,
  name, or version), preventing empty {'type': 'checkpoint'} stubs
2026-06-01 17:58:08 +08:00
Will Miao
bfe7b5e1c7 fix(constants): add missing diffusion model base models (Flux, DiT, video, etc.) 2026-05-31 17:12:09 +08:00
Will Miao
85c020cd12 fix(update): preserve wildcards, backups dirs during ZIP upgrade, add log rotation
- Add wildcards and backups to skip_files in all three ZIP upgrade
  skip locations: _clean_plugin_folder, copy loop, .tracking generation
- Remove logs from skip_files (logs are transient and rotate automatically)
- Add _prune_old_logs() to session_logging.py: keeps only the 3 newest
  session log files, deletes older ones on each standalone startup
2026-05-31 15:56:56 +08:00
Will Miao
1b202f8ec7 fix(autocomplete): escape parentheses in prompt tag insertion (#951) 2026-05-31 15:40:19 +08:00
Will Miao
d02a0611d3 fix(update): close SQLite connection and protect cache dir during ZIP update
On Windows, shutil.rmtree() fails when deleting a directory that contains
an open SQLite database file. The ZIP update path in _download_and_replace_zip()
calls _clean_plugin_folder() which tries to delete the cache/ directory,
but downloaded_versions.sqlite is held open by DownloadedVersionHistoryService.

Fix:
- Add close() method to DownloadedVersionHistoryService to release
  the persistent SQLite connection
- Call close() before _clean_plugin_folder() in the ZIP update flow
- Add 'cache' to the skip_files list so the runtime cache directory is
  never deleted during plugin updates
2026-05-31 15:06:15 +08:00
pixelpaws
92166a161a Update Portable Package link to version 1.0.10 2026-05-31 10:08:28 +08:00
Will Miao
b509f27cb7 chore(release): bump version to v1.0.10 2026-05-31 09:39:26 +08:00
Will Miao
5c2ef48917 fix(aria2): apply certifi CA bundle to aria2c via --ca-certificate
When certifi is available, pass its CA bundle path as --ca-certificate
to the aria2c subprocess so that aria2 downloads use the same
certificate store as Python aiohttp downloads. Graceful fallback when
certifi is not installed.
2026-05-30 21:47:13 +08:00
Will Miao
ad2bd82c67 fix(downloader): use certifi CA bundle as SSL fallback and log SSL error diagnostics
- Prefer certifi's CA bundle in aiohttp SSL context with graceful
  fallback to system default when certifi is unavailable
- Add is_ssl_cert_verify_error() helper for SSL cert failure detection
- Log actionable error message (pip install --upgrade certifi /
  pip install pip-system-certs) when SSL certificate verification fails
- Apply same diagnostic logging to aria2 redirect resolution path
2026-05-30 21:28:18 +08:00
willmiao
17ba350153 docs: auto-update supporters list in README 2026-05-28 13:47:09 +00:00
44 changed files with 1019 additions and 114 deletions

File diff suppressed because one or more lines are too long

View File

@@ -963,6 +963,13 @@
"empty": { "empty": {
"noFolders": "Keine Ordner gefunden", "noFolders": "Keine Ordner gefunden",
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen" "dragHint": "Elemente hierher ziehen, um Ordner zu erstellen"
},
"folderUpdateCheck": {
"label": "Auf Updates in diesem Ordner prüfen",
"loading": "Prüfe {type}-Updates in diesem Ordner...",
"success": "{count} Update(s) für {type}s in diesem Ordner gefunden",
"none": "Alle {type}s in diesem Ordner sind aktuell",
"error": "Fehler beim Prüfen des Ordners auf {type}-Updates: {message}"
} }
}, },
"statistics": { "statistics": {
@@ -1031,6 +1038,11 @@
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.", "downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
"alreadyInLibrary": "Bereits in Bibliothek", "alreadyInLibrary": "Bereits in Bibliothek",
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]", "autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
"fileSelection": {
"title": "Dateiformat auswählen",
"files": "Dateien",
"select": "Datei auswählen"
},
"errors": { "errors": {
"invalidUrl": "Ungültiges Civitai URL-Format", "invalidUrl": "Ungültiges Civitai URL-Format",
"noVersions": "Keine Versionen für dieses Modell verfügbar" "noVersions": "Keine Versionen für dieses Modell verfügbar"

View File

@@ -963,6 +963,13 @@
"empty": { "empty": {
"noFolders": "No folders found", "noFolders": "No folders found",
"dragHint": "Drag items here to create folders" "dragHint": "Drag items here to create folders"
},
"folderUpdateCheck": {
"label": "Check for updates in this folder",
"loading": "Checking {type} updates for this folder...",
"success": "Found {count} update(s) for {type}s in this folder",
"none": "All {type}s in this folder are up to date",
"error": "Failed to check folder for {type} updates: {message}"
} }
}, },
"statistics": { "statistics": {
@@ -1031,6 +1038,11 @@
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.", "downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
"alreadyInLibrary": "Already in Library", "alreadyInLibrary": "Already in Library",
"autoOrganizedPath": "[Auto-organized by path template]", "autoOrganizedPath": "[Auto-organized by path template]",
"fileSelection": {
"title": "Select File Format",
"files": "files",
"select": "Select File"
},
"errors": { "errors": {
"invalidUrl": "Invalid Civitai URL format", "invalidUrl": "Invalid Civitai URL format",
"noVersions": "No versions available for this model" "noVersions": "No versions available for this model"

View File

@@ -963,6 +963,13 @@
"empty": { "empty": {
"noFolders": "No se encontraron carpetas", "noFolders": "No se encontraron carpetas",
"dragHint": "Arrastra elementos aquí para crear carpetas" "dragHint": "Arrastra elementos aquí para crear carpetas"
},
"folderUpdateCheck": {
"label": "Buscar actualizaciones en esta carpeta",
"loading": "Buscando actualizaciones de {type} en esta carpeta...",
"success": "Se encontraron {count} actualización(es) para {type}s en esta carpeta",
"none": "Todos los {type}s en esta carpeta están actualizados",
"error": "Error al buscar actualizaciones de {type} en la carpeta: {message}"
} }
}, },
"statistics": { "statistics": {
@@ -1031,6 +1038,11 @@
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.", "downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
"alreadyInLibrary": "Ya en la biblioteca", "alreadyInLibrary": "Ya en la biblioteca",
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]", "autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
"fileSelection": {
"title": "Seleccionar formato de archivo",
"files": "archivos",
"select": "Seleccionar archivo"
},
"errors": { "errors": {
"invalidUrl": "Formato de URL de Civitai inválido", "invalidUrl": "Formato de URL de Civitai inválido",
"noVersions": "No hay versiones disponibles para este modelo" "noVersions": "No hay versiones disponibles para este modelo"

View File

@@ -963,6 +963,13 @@
"empty": { "empty": {
"noFolders": "Aucun dossier trouvé", "noFolders": "Aucun dossier trouvé",
"dragHint": "Faites glisser des éléments ici pour créer des dossiers" "dragHint": "Faites glisser des éléments ici pour créer des dossiers"
},
"folderUpdateCheck": {
"label": "Vérifier les mises à jour dans ce dossier",
"loading": "Vérification des mises à jour {type} dans ce dossier...",
"success": "{count} mise(s) à jour trouvée(s) pour les {type}s dans ce dossier",
"none": "Tous les {type}s dans ce dossier sont à jour",
"error": "Échec de la vérification des mises à jour {type} dans ce dossier : {message}"
} }
}, },
"statistics": { "statistics": {
@@ -1031,6 +1038,11 @@
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.", "downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
"alreadyInLibrary": "Déjà dans la bibliothèque", "alreadyInLibrary": "Déjà dans la bibliothèque",
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]", "autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
"fileSelection": {
"title": "Choisir le format de fichier",
"files": "fichiers",
"select": "Choisir le fichier"
},
"errors": { "errors": {
"invalidUrl": "Format d'URL Civitai invalide", "invalidUrl": "Format d'URL Civitai invalide",
"noVersions": "Aucune version disponible pour ce modèle" "noVersions": "Aucune version disponible pour ce modèle"

View File

@@ -963,6 +963,13 @@
"empty": { "empty": {
"noFolders": "לא נמצאו תיקיות", "noFolders": "לא נמצאו תיקיות",
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות" "dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
},
"folderUpdateCheck": {
"label": "בדוק עדכונים בתיקייה זו",
"loading": "בודק עדכוני {type} בתיקייה זו...",
"success": "נמצאו {count} עדכון/ים עבור {type}s בתיקייה זו",
"none": "כל ה-{type}s בתיקייה זו מעודכנים",
"error": "נכשל בבדיקת עדכוני {type} בתיקייה: {message}"
} }
}, },
"statistics": { "statistics": {
@@ -1031,6 +1038,11 @@
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.", "downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
"alreadyInLibrary": "כבר בספרייה", "alreadyInLibrary": "כבר בספרייה",
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]", "autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
"fileSelection": {
"title": "בחר פורמט קובץ",
"files": "קבצים",
"select": "בחר קובץ"
},
"errors": { "errors": {
"invalidUrl": "פורמט URL של Civitai לא חוקי", "invalidUrl": "פורמט URL של Civitai לא חוקי",
"noVersions": "אין גרסאות זמינות למודל זה" "noVersions": "אין גרסאות זמינות למודל זה"

View File

@@ -963,6 +963,13 @@
"empty": { "empty": {
"noFolders": "フォルダが見つかりません", "noFolders": "フォルダが見つかりません",
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します" "dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
},
"folderUpdateCheck": {
"label": "このフォルダのアップデートを確認",
"loading": "このフォルダの{type}アップデートを確認中...",
"success": "このフォルダの{type}sに{count}件のアップデートが見つかりました",
"none": "このフォルダのすべての{type}sは最新です",
"error": "フォルダの{type}アップデート確認に失敗しました: {message}"
} }
}, },
"statistics": { "statistics": {
@@ -1031,6 +1038,11 @@
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。", "downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
"alreadyInLibrary": "既にライブラリ内", "alreadyInLibrary": "既にライブラリ内",
"autoOrganizedPath": "[パステンプレートによる自動整理]", "autoOrganizedPath": "[パステンプレートによる自動整理]",
"fileSelection": {
"title": "ファイル形式を選択",
"files": "ファイル",
"select": "ファイルを選択"
},
"errors": { "errors": {
"invalidUrl": "無効なCivitai URL形式", "invalidUrl": "無効なCivitai URL形式",
"noVersions": "このモデルの利用可能なバージョンがありません" "noVersions": "このモデルの利用可能なバージョンがありません"

View File

@@ -963,6 +963,13 @@
"empty": { "empty": {
"noFolders": "폴더를 찾을 수 없습니다", "noFolders": "폴더를 찾을 수 없습니다",
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다" "dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
},
"folderUpdateCheck": {
"label": "이 폴더의 업데이트 확인",
"loading": "이 폴더의 {type} 업데이트를 확인하는 중...",
"success": "이 폴더에서 {type}s에 대한 {count}개 업데이트를 찾았습니다",
"none": "이 폴더의 모든 {type}s가 최신 상태입니다",
"error": "폴더의 {type} 업데이트 확인 실패: {message}"
} }
}, },
"statistics": { "statistics": {
@@ -1031,6 +1038,11 @@
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.", "downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
"alreadyInLibrary": "이미 라이브러리에 있음", "alreadyInLibrary": "이미 라이브러리에 있음",
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]", "autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
"fileSelection": {
"title": "파일 형식 선택",
"files": "개 파일",
"select": "파일 선택"
},
"errors": { "errors": {
"invalidUrl": "잘못된 Civitai URL 형식", "invalidUrl": "잘못된 Civitai URL 형식",
"noVersions": "이 모델에 사용 가능한 버전이 없습니다" "noVersions": "이 모델에 사용 가능한 버전이 없습니다"

View File

@@ -963,6 +963,13 @@
"empty": { "empty": {
"noFolders": "Папки не найдены", "noFolders": "Папки не найдены",
"dragHint": "Перетащите элементы сюда, чтобы создать папки" "dragHint": "Перетащите элементы сюда, чтобы создать папки"
},
"folderUpdateCheck": {
"label": "Проверить обновления в этой папке",
"loading": "Проверка обновлений {type} в этой папке...",
"success": "Найдено {count} обновление(й) для {type}s в этой папке",
"none": "Все {type}s в этой папке актуальны",
"error": "Не удалось проверить папку на наличие обновлений {type}: {message}"
} }
}, },
"statistics": { "statistics": {
@@ -1031,6 +1038,11 @@
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.", "downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
"alreadyInLibrary": "Уже в библиотеке", "alreadyInLibrary": "Уже в библиотеке",
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]", "autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
"fileSelection": {
"title": "Выбрать формат файла",
"files": "файлов",
"select": "Выбрать файл"
},
"errors": { "errors": {
"invalidUrl": "Неверный формат URL Civitai", "invalidUrl": "Неверный формат URL Civitai",
"noVersions": "Нет доступных версий для этой модели" "noVersions": "Нет доступных версий для этой модели"

View File

@@ -963,6 +963,13 @@
"empty": { "empty": {
"noFolders": "未找到文件夹", "noFolders": "未找到文件夹",
"dragHint": "拖拽项目到此处以创建文件夹" "dragHint": "拖拽项目到此处以创建文件夹"
},
"folderUpdateCheck": {
"label": "检查此文件夹的更新",
"loading": "正在检查此文件夹中的{type}更新...",
"success": "在此文件夹中找到 {count} 个{type}更新",
"none": "此文件夹中的所有{type}都是最新版本",
"error": "检查文件夹{type}更新失败: {message}"
} }
}, },
"statistics": { "statistics": {
@@ -1031,6 +1038,11 @@
"downloadedTooltip": "之前已下载,但当前不在你的库中。", "downloadedTooltip": "之前已下载,但当前不在你的库中。",
"alreadyInLibrary": "已存在于库中", "alreadyInLibrary": "已存在于库中",
"autoOrganizedPath": "【已按路径模板自动整理】", "autoOrganizedPath": "【已按路径模板自动整理】",
"fileSelection": {
"title": "选择文件格式",
"files": "个文件",
"select": "选择文件"
},
"errors": { "errors": {
"invalidUrl": "无效的 Civitai URL 格式", "invalidUrl": "无效的 Civitai URL 格式",
"noVersions": "此模型没有可用版本" "noVersions": "此模型没有可用版本"

View File

@@ -963,6 +963,13 @@
"empty": { "empty": {
"noFolders": "未找到資料夾", "noFolders": "未找到資料夾",
"dragHint": "將項目拖到此處以建立資料夾" "dragHint": "將項目拖到此處以建立資料夾"
},
"folderUpdateCheck": {
"label": "檢查此資料夾的更新",
"loading": "正在檢查此資料夾中的{type}更新...",
"success": "在此資料夾中找到 {count} 個{type}更新",
"none": "此資料夾中的所有{type}都是最新版本",
"error": "檢查資料夾{type}更新失敗: {message}"
} }
}, },
"statistics": { "statistics": {
@@ -1031,6 +1038,11 @@
"downloadedTooltip": "先前已下載,但目前不在你的庫中。", "downloadedTooltip": "先前已下載,但目前不在你的庫中。",
"alreadyInLibrary": "已在庫存", "alreadyInLibrary": "已在庫存",
"autoOrganizedPath": "[依路徑範本自動整理]", "autoOrganizedPath": "[依路徑範本自動整理]",
"fileSelection": {
"title": "選擇檔案格式",
"files": "個檔案",
"select": "選擇檔案"
},
"errors": { "errors": {
"invalidUrl": "Civitai 網址格式無效", "invalidUrl": "Civitai 網址格式無效",
"noVersions": "此模型無可用版本" "noVersions": "此模型無可用版本"

View File

@@ -190,25 +190,40 @@ class RecipeEnricher:
existing_cp = recipe.get("checkpoint") existing_cp = recipe.get("checkpoint")
if existing_cp is None: if existing_cp is None:
existing_cp = {} existing_cp = {}
# Extract baseModel from raw civitai_info before populate_checkpoint_from_civitai
# (populate may reject non-checkpoint types and lose this data)
base_model_from_civitai: str = ""
if isinstance(civitai_info, dict):
base_model_from_civitai = civitai_info.get("baseModel", "") or ""
elif isinstance(civitai_info, tuple) and len(civitai_info) > 0 and isinstance(civitai_info[0], dict):
base_model_from_civitai = civitai_info[0].get("baseModel", "") or ""
checkpoint_data = await RecipeMetadataParser.populate_checkpoint_from_civitai(existing_cp, civitai_info) checkpoint_data = await RecipeMetadataParser.populate_checkpoint_from_civitai(existing_cp, civitai_info)
# 1. First, resolve base_model using full data before we format it away
# 1. Resolve base_model from checkpoint_data first, then fall back to raw civitai_info
current_base_model = recipe.get("base_model") current_base_model = recipe.get("base_model")
resolved_base_model = checkpoint_data.get("baseModel") resolved_base_model = checkpoint_data.get("baseModel") or base_model_from_civitai
if resolved_base_model: if resolved_base_model:
# Update if empty OR if it matches our generic prefix but is less specific
is_generic = not current_base_model or current_base_model.lower() in ["flux", "sdxl", "sd15"] is_generic = not current_base_model or current_base_model.lower() in ["flux", "sdxl", "sd15"]
if is_generic and resolved_base_model != current_base_model: if is_generic and resolved_base_model != current_base_model:
recipe["base_model"] = resolved_base_model recipe["base_model"] = resolved_base_model
# 2. Format according to requirements: type, modelId, modelVersionId, modelName, modelVersionName # 2. Only format and save checkpoint if it has real data (not just type after type rejection)
has_checkpoint_data = any([
checkpoint_data.get("modelId"),
checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
checkpoint_data.get("name"),
checkpoint_data.get("version"),
])
if has_checkpoint_data:
formatted_checkpoint = { formatted_checkpoint = {
"type": "checkpoint", "type": "checkpoint",
"modelId": checkpoint_data.get("modelId"), "modelId": checkpoint_data.get("modelId"),
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"), "modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
"modelName": checkpoint_data.get("name"), # In base.py, 'name' is populated from civitai_data['model']['name'] "modelName": checkpoint_data.get("name"),
"modelVersionName": checkpoint_data.get("version") # In base.py, 'version' is populated from civitai_data['name'] "modelVersionName": checkpoint_data.get("version"),
} }
# Remove None values
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None} recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None}
return True return True

View File

@@ -1960,6 +1960,10 @@ class ModelUpdateHandler:
if target_model_ids: if target_model_ids:
target_model_ids = sorted(set(target_model_ids)) target_model_ids = sorted(set(target_model_ids))
folder_path: Optional[str] = payload.get("folder_path")
if folder_path is not None and not isinstance(folder_path, str):
folder_path = None
provider = await self._get_civitai_provider() provider = await self._get_civitai_provider()
if provider is None: if provider is None:
return web.json_response( return web.json_response(
@@ -1974,6 +1978,7 @@ class ModelUpdateHandler:
provider, provider,
force_refresh=force_refresh, force_refresh=force_refresh,
target_model_ids=target_model_ids or None, target_model_ids=target_model_ids or None,
folder_path=folder_path,
) )
if self._service.scanner.is_cancelled(): if self._service.scanner.is_cancelled():
return web.json_response( return web.json_response(

View File

@@ -461,7 +461,11 @@ class RecipeQueryHandler:
if recipe_scanner is None: if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable") raise RuntimeError("Recipe scanner unavailable")
self._logger.info("Manually triggering recipe cache rebuild") full_rebuild = request.query.get("full_rebuild", "true").lower() == "true"
self._logger.info(
"Manually triggering recipe cache %s",
"full rebuild" if full_rebuild else "refresh",
)
await recipe_scanner.get_cached_data(force_refresh=True) await recipe_scanner.get_cached_data(force_refresh=True)
return web.json_response( return web.json_response(
{"success": True, "message": "Recipe cache refreshed successfully"} {"success": True, "message": "Recipe cache refreshed successfully"}
@@ -975,6 +979,9 @@ class RecipeManagementHandler:
civitai_model = civitai_parsed.get("model") civitai_model = civitai_parsed.get("model")
if civitai_model and not metadata.get("checkpoint"): if civitai_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = civitai_model metadata["checkpoint"] = civitai_model
civitai_base_model = civitai_parsed.get("base_model")
if civitai_base_model and not metadata.get("base_model"):
metadata["base_model"] = civitai_base_model
elif parsed_embedded: elif parsed_embedded:
parsed_loras = parsed_embedded.get("loras") parsed_loras = parsed_embedded.get("loras")
if parsed_loras and not metadata.get("loras"): if parsed_loras and not metadata.get("loras"):
@@ -982,6 +989,8 @@ class RecipeManagementHandler:
parsed_model = parsed_embedded.get("model") parsed_model = parsed_embedded.get("model")
if parsed_model and not metadata.get("checkpoint"): if parsed_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = parsed_model metadata["checkpoint"] = parsed_model
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
metadata["base_model"] = parsed_embedded["base_model"]
civitai_client = self._civitai_client_getter() civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe( await RecipeEnricher.enrich_recipe(
@@ -1489,13 +1498,16 @@ class RecipeManagementHandler:
if not image_url: if not image_url:
raise RecipeValidationError("Missing required field: image_url") raise RecipeValidationError("Missing required field: image_url")
force = request.query.get("force", "false").lower() == "true"
image_id = extract_civitai_image_id(image_url) image_id = extract_civitai_image_id(image_url)
if not image_id: if not image_id:
raise RecipeValidationError( raise RecipeValidationError(
"Could not extract Civitai image ID from URL" "Could not extract Civitai image ID from URL"
) )
# Check for duplicate (fast, before acquiring semaphore) # Check for duplicate (fast, before acquiring semaphore), unless force
if not force:
cache = await recipe_scanner.get_cached_data() cache = await recipe_scanner.get_cached_data()
for recipe in getattr(cache, "raw_data", []): for recipe in getattr(cache, "raw_data", []):
source = recipe.get("source_path") source = recipe.get("source_path")
@@ -1613,6 +1625,9 @@ class RecipeManagementHandler:
civitai_model = civitai_parsed.get("model") civitai_model = civitai_parsed.get("model")
if civitai_model and not metadata.get("checkpoint"): if civitai_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = civitai_model metadata["checkpoint"] = civitai_model
civitai_base_model = civitai_parsed.get("base_model")
if civitai_base_model and not metadata.get("base_model"):
metadata["base_model"] = civitai_base_model
elif parsed_embedded: elif parsed_embedded:
parsed_loras = parsed_embedded.get("loras") parsed_loras = parsed_embedded.get("loras")
if parsed_loras and not metadata.get("loras"): if parsed_loras and not metadata.get("loras"):
@@ -1620,6 +1635,8 @@ class RecipeManagementHandler:
parsed_model = parsed_embedded.get("model") parsed_model = parsed_embedded.get("model")
if parsed_model and not metadata.get("checkpoint"): if parsed_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = parsed_model metadata["checkpoint"] = parsed_model
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
metadata["base_model"] = parsed_embedded["base_model"]
civitai_client = self._civitai_client_getter() civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe( await RecipeEnricher.enrich_recipe(

View File

@@ -11,6 +11,7 @@ from typing import Dict, List
from ..utils.settings_paths import ensure_settings_file from ..utils.settings_paths import ensure_settings_file
from ..services.downloader import get_downloader from ..services.downloader import get_downloader
from ..services.service_registry import ServiceRegistry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -212,8 +213,19 @@ class UpdateRoutes:
zip_path = tmp_zip_path zip_path = tmp_zip_path
# Skip both settings.json, civitai and model cache folder # Close the downloaded-versions SQLite connection before cleaning,
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache']) # so that shutil.rmtree() does not fail on Windows (the process
# cannot delete a file with an outstanding open handle).
try:
history_svc = ServiceRegistry._services.get("downloaded_version_history_service")
if history_svc is not None:
history_svc.close()
logger.info("Closed downloaded-version history database connection")
except Exception:
logger.debug("Could not close downloaded-version history database", exc_info=True)
# Skip settings.json, civitai, model cache and runtime cache folders
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache', 'cache', 'wildcards', 'backups'])
# Extract ZIP to temp dir # Extract ZIP to temp dir
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
@@ -222,16 +234,17 @@ class UpdateRoutes:
# Find extracted folder (GitHub ZIP contains a root folder) # Find extracted folder (GitHub ZIP contains a root folder)
extracted_root = next(os.scandir(tmp_dir)).path extracted_root = next(os.scandir(tmp_dir)).path
# Copy files, skipping settings.json and civitai folder # Copy files, skipping user data that should be preserved
skip_items = {'settings.json', 'civitai', 'wildcards', 'backups'}
for item in os.listdir(extracted_root): for item in os.listdir(extracted_root):
if item == 'settings.json' or item == 'civitai': if item in skip_items:
continue continue
src = os.path.join(extracted_root, item) src = os.path.join(extracted_root, item)
dst = os.path.join(plugin_root, item) dst = os.path.join(plugin_root, item)
if os.path.isdir(src): if os.path.isdir(src):
if os.path.exists(dst): if os.path.exists(dst):
shutil.rmtree(dst) shutil.rmtree(dst)
shutil.copytree(src, dst, ignore=shutil.ignore_patterns('settings.json', 'civitai')) shutil.copytree(src, dst, ignore=shutil.ignore_patterns(*skip_items))
else: else:
shutil.copy2(src, dst) shutil.copy2(src, dst)
@@ -239,15 +252,17 @@ class UpdateRoutes:
# for ComfyUI Manager to work properly # for ComfyUI Manager to work properly
tracking_info_file = os.path.join(plugin_root, '.tracking') tracking_info_file = os.path.join(plugin_root, '.tracking')
tracking_files = [] tracking_files = []
skip_tracked = {'civitai', 'wildcards', 'backups'}
for root, dirs, files in os.walk(extracted_root): for root, dirs, files in os.walk(extracted_root):
# Skip civitai folder and its contents # Skip user data directories and their contents
rel_root = os.path.relpath(root, extracted_root) rel_root = os.path.relpath(root, extracted_root)
if rel_root == 'civitai' or rel_root.startswith('civitai' + os.sep): top_dir = rel_root.split(os.sep)[0] if rel_root != '.' else ''
if top_dir in skip_tracked:
continue continue
for file in files: for file in files:
rel_path = os.path.relpath(os.path.join(root, file), extracted_root) rel_path = os.path.relpath(os.path.join(root, file), extracted_root)
# Skip settings.json and any file under civitai # Skip settings.json and any file under user data dirs
if rel_path == 'settings.json' or rel_path.startswith('civitai' + os.sep): if rel_path == 'settings.json' or rel_path.split(os.sep)[0] in skip_tracked:
continue continue
tracking_files.append(rel_path.replace("\\", "/")) tracking_files.append(rel_path.replace("\\", "/"))
with open(tracking_info_file, "w", encoding='utf-8') as file: with open(tracking_info_file, "w", encoding='utf-8') as file:

View File

@@ -14,12 +14,30 @@ from typing import Any, Dict, Optional, Tuple
import aiohttp import aiohttp
from .downloader import DownloadProgress, get_downloader from .downloader import DownloadProgress, get_downloader, is_ssl_cert_verify_error
from .aria2_transfer_state import Aria2TransferStateStore from .aria2_transfer_state import Aria2TransferStateStore
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _try_certifi_ca_path() -> str | None:
"""Return the certifi CA bundle path if available, else None."""
try:
import certifi # type: ignore[import-untyped]
path = certifi.where()
if os.path.isfile(path):
logger.debug(
"aria2 --ca-certificate: using certifi CA bundle at %s", path
)
return path
except ImportError:
pass
logger.debug("aria2 --ca-certificate: certifi not available")
return None
CIVITAI_DOWNLOAD_URL_PREFIXES = ( CIVITAI_DOWNLOAD_URL_PREFIXES = (
"https://civitai.com/api/download/", "https://civitai.com/api/download/",
"https://civitai.red/api/download/", "https://civitai.red/api/download/",
@@ -391,6 +409,15 @@ class Aria2Downloader:
f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}" f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}"
) )
except aiohttp.ClientError as exc: except aiohttp.ClientError as exc:
if is_ssl_cert_verify_error(exc):
logger.error(
"SSL certificate verification failed during Civitai redirect "
"resolution for %s. This is usually caused by an outdated CA "
"certificate bundle. Recommended fixes:\n"
" 1. pip install --upgrade certifi\n"
" 2. pip install pip-system-certs",
url,
)
raise Aria2Error( raise Aria2Error(
f"Failed to resolve authenticated Civitai redirect: {exc}" f"Failed to resolve authenticated Civitai redirect: {exc}"
) from exc ) from exc
@@ -414,6 +441,11 @@ class Aria2Downloader:
f"--rpc-listen-port={self._rpc_port}", f"--rpc-listen-port={self._rpc_port}",
f"--rpc-secret={self._rpc_secret}", f"--rpc-secret={self._rpc_secret}",
"--check-certificate=true", "--check-certificate=true",
# Point aria2 at certifi's CA bundle when available so it uses
# the same certificate store as Python downloads.
*((
f"--ca-certificate={ca_cert}",
) if (ca_cert := _try_certifi_ca_path()) else ()),
"--allow-overwrite=true", "--allow-overwrite=true",
"--auto-file-renaming=false", "--auto-file-renaming=false",
"--file-allocation=none", "--file-allocation=none",

View File

@@ -186,6 +186,22 @@ class CivArchiveClient:
if "metadata" in file_data: if "metadata" in file_data:
transformed["metadata"] = file_data["metadata"] transformed["metadata"] = file_data["metadata"]
# Infer metadata.format from filename extension
name = transformed.get("name")
if name and isinstance(name, str):
lower_name = name.lower()
if lower_name.endswith(".safetensors"):
inferred_format = "SafeTensor"
elif lower_name.endswith(".ckpt"):
inferred_format = "PickleTensor"
else:
inferred_format = None
if inferred_format:
if "metadata" not in transformed:
transformed["metadata"] = {}
if isinstance(transformed["metadata"], dict):
transformed["metadata"].setdefault("format", inferred_format)
if file_data.get("modelVersionId") is not None: if file_data.get("modelVersionId") is not None:
transformed["modelVersionId"] = file_data.get("modelVersionId") transformed["modelVersionId"] = file_data.get("modelVersionId")
elif file_data.get("model_version_id") is not None: elif file_data.get("model_version_id") is not None:
@@ -213,6 +229,20 @@ class CivArchiveClient:
for file_data in candidates: for file_data in candidates:
if isinstance(file_data, dict): if isinstance(file_data, dict):
transformed_files.append(self._transform_file_entry(file_data)) transformed_files.append(self._transform_file_entry(file_data))
# Sort: .safetensors first, .ckpt second, others last
# so the backend fallback (no file_params) prefers safetensors
def _sort_key(f: Dict) -> int:
fname = f.get("name") or ""
if isinstance(fname, str):
lower = fname.lower()
if lower.endswith(".safetensors"):
return 0
elif lower.endswith(".ckpt"):
return 1
return 2
transformed_files.sort(key=_sort_key)
return transformed_files return transformed_files
def _transform_version( def _transform_version(

View File

@@ -96,6 +96,21 @@ class DownloadedVersionHistoryService:
def get_database_path(self) -> str: def get_database_path(self) -> str:
return self._db_path return self._db_path
def close(self) -> None:
"""Close the persistent SQLite connection, if open.
This is called before plugin update operations to release the
database file lock on Windows, allowing ``shutil.rmtree()`` to
succeed when the cache resides inside the plugin directory.
"""
if self._conn is not None:
try:
self._conn.close()
except Exception:
pass
finally:
self._conn = None
def _get_active_library_name(self) -> str | None: def _get_active_library_name(self) -> str | None:
try: try:
value = self._settings.get_active_library_name() value = self._settings.get_active_library_name()

View File

@@ -13,6 +13,7 @@ This module provides a centralized download service with:
import os import os
import logging import logging
import asyncio import asyncio
import ssl
import aiohttp import aiohttp
from collections import deque from collections import deque
from dataclasses import dataclass from dataclasses import dataclass
@@ -31,6 +32,20 @@ from .errors import RateLimitError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def is_ssl_cert_verify_error(exc: BaseException) -> bool:
"""Check if an exception represents an SSL certificate verification failure.
Matches ``ssl.SSLCertVerificationError``, ``aiohttp.ClientConnectorCertificateError``
(which wraps the former), and falls back to the standard OpenSSL error text.
"""
if isinstance(exc, ssl.SSLCertVerificationError):
return True
cert_error = getattr(exc, "certificate_error", None)
if isinstance(cert_error, ssl.SSLCertVerificationError):
return True
return "CERTIFICATE_VERIFY_FAILED" in str(exc)
@dataclass(frozen=True) @dataclass(frozen=True)
class DownloadProgress: class DownloadProgress:
"""Snapshot of a download transfer at a moment in time.""" """Snapshot of a download transfer at a moment in time."""
@@ -265,9 +280,22 @@ class Downloader:
logger.debug( logger.debug(
"Proxy mode: system-level proxy (trust_env) will be used if configured in environment." "Proxy mode: system-level proxy (trust_env) will be used if configured in environment."
) )
# Build SSL context: prefer certifi's CA bundle for broader
# CA coverage across different Python environments (especially
# embedded/compatibility Python builds).
try:
import certifi # type: ignore[import-untyped]
ca_path = certifi.where()
ssl_context = ssl.create_default_context(cafile=ca_path)
logger.debug("SSL: using certifi CA bundle at %s", ca_path)
except (ImportError, FileNotFoundError, ValueError, OSError):
ssl_context = ssl.create_default_context()
logger.debug("SSL: certifi unavailable; using system default CA bundle")
# Optimize TCP connection parameters # Optimize TCP connection parameters
connector = aiohttp.TCPConnector( connector = aiohttp.TCPConnector(
ssl=True, ssl=ssl_context,
limit=8, # Concurrent connections limit=8, # Concurrent connections
ttl_dns_cache=300, # DNS cache timeout ttl_dns_cache=300, # DNS cache timeout
force_close=False, # Keep connections for reuse force_close=False, # Keep connections for reuse
@@ -736,6 +764,17 @@ class Downloader:
DownloadRestartRequested, DownloadRestartRequested,
) as e: ) as e:
retry_count += 1 retry_count += 1
if is_ssl_cert_verify_error(e):
logger.error(
"SSL certificate verification failed when connecting to %s. "
"This is usually caused by an outdated CA certificate bundle "
"in the Python environment. Recommended fixes:\n"
" 1. pip install --upgrade certifi\n"
" 2. pip install pip-system-certs",
url,
)
logger.warning( logger.warning(
f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}" f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}"
) )

View File

@@ -689,6 +689,7 @@ class ModelUpdateService:
*, *,
force_refresh: bool = False, force_refresh: bool = False,
target_model_ids: Optional[Sequence[int]] = None, target_model_ids: Optional[Sequence[int]] = None,
folder_path: Optional[str] = None,
) -> Dict[int, ModelUpdateRecord]: ) -> Dict[int, ModelUpdateRecord]:
"""Refresh update information for every model present in the cache.""" """Refresh update information for every model present in the cache."""
scanner.reset_cancellation() scanner.reset_cancellation()
@@ -703,6 +704,7 @@ class ModelUpdateService:
local_versions = await self._collect_local_versions( local_versions = await self._collect_local_versions(
scanner, scanner,
target_model_ids=target_filter, target_model_ids=target_filter,
folder_path=folder_path,
) )
total_models = len(local_versions) total_models = len(local_versions)
if total_models == 0: if total_models == 0:
@@ -1276,6 +1278,7 @@ class ModelUpdateService:
scanner, scanner,
*, *,
target_model_ids: Optional[Sequence[int]] = None, target_model_ids: Optional[Sequence[int]] = None,
folder_path: Optional[str] = None,
) -> Dict[int, List[int]]: ) -> Dict[int, List[int]]:
cache = await scanner.get_cached_data() cache = await scanner.get_cached_data()
mapping: Dict[int, set[int]] = {} mapping: Dict[int, set[int]] = {}
@@ -1288,7 +1291,19 @@ class ModelUpdateService:
if not target_set: if not target_set:
return {} return {}
normalized_folder = None
if folder_path is not None:
normalized_folder = folder_path.replace("\\", "/").strip("/")
for item in cache.raw_data: for item in cache.raw_data:
# Apply folder filter first (cheapest check)
if normalized_folder is not None:
if not isinstance(item, dict):
continue
item_folder = (item.get("folder") or "").replace("\\", "/").strip("/")
if item_folder != normalized_folder and not item_folder.startswith(normalized_folder + "/"):
continue
civitai = item.get("civitai") if isinstance(item, dict) else None civitai = item.get("civitai") if isinstance(item, dict) else None
if not isinstance(civitai, dict): if not isinstance(civitai, dict):
continue continue

View File

@@ -101,8 +101,34 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
DIFFUSION_MODEL_BASE_MODELS = frozenset( DIFFUSION_MODEL_BASE_MODELS = frozenset(
[ [
"Anima", "Anima",
"ZImageTurbo", # Flux series — DiT architecture, loaded via UNETLoader in ComfyUI
"ZImageBase", "Flux.1 D",
"Flux.1 S",
"Flux.1 Krea",
"Flux.1 Kontext",
"Flux.2 D",
"Flux.2 Klein 9B",
"Flux.2 Klein 9B-base",
"Flux.2 Klein 4B",
"Flux.2 Klein 4B-base",
# Non-UNet / DiT image diffusion models
"AuraFlow",
"Chroma",
"HiDream",
"Hunyuan 1",
"Kolors",
"Lumina",
"PixArt a",
"PixArt E",
# Video diffusion models
"CogVideoX",
"Hunyuan Video",
"LTXV",
"LTXV2",
"LTXV 2.3",
"Mochi",
"SVD",
"Wan Video",
"Wan Video 1.3B t2v", "Wan Video 1.3B t2v",
"Wan Video 14B t2v", "Wan Video 14B t2v",
"Wan Video 14B i2v 480p", "Wan Video 14B i2v 480p",
@@ -112,9 +138,13 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
"Wan Video 2.2 T2V-A14B", "Wan Video 2.2 T2V-A14B",
"Wan Video 2.5 T2V", "Wan Video 2.5 T2V",
"Wan Video 2.5 I2V", "Wan Video 2.5 I2V",
"CogVideoX", # Other diffusion models
"Mochi", "Ernie",
"Ernie Turbo",
"Nucleus",
"Qwen", "Qwen",
"ZImageBase",
"ZImageTurbo",
] ]
) )

View File

@@ -64,6 +64,27 @@ def _build_log_file_path(settings_file: str | None, started_at: datetime) -> str
return os.path.join(log_dir, f"standalone-session-{timestamp}.log") return os.path.join(log_dir, f"standalone-session-{timestamp}.log")
_KEEP_LOG_COUNT = 3
def _prune_old_logs(log_dir: str) -> None:
"""Remove older session log files, keeping only the ``_KEEP_LOG_COUNT`` newest."""
try:
files = [
os.path.join(log_dir, name)
for name in os.listdir(log_dir)
if name.startswith("standalone-session-") and name.endswith(".log")
]
except OSError:
return
files.sort(key=os.path.getmtime, reverse=True)
for path in files[_KEEP_LOG_COUNT:]:
try:
os.remove(path)
except OSError:
pass
def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSessionLogState: def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSessionLogState:
global _session_state global _session_state
@@ -90,6 +111,7 @@ def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSes
file_handler.set_name(_FILE_HANDLER_NAME) file_handler.set_name(_FILE_HANDLER_NAME)
file_handler.setFormatter(formatter) file_handler.setFormatter(formatter)
root_logger.addHandler(file_handler) root_logger.addHandler(file_handler)
_prune_old_logs(os.path.dirname(log_file_path))
_session_state = StandaloneSessionLogState( _session_state = StandaloneSessionLogState(
started_at=started_at, started_at=started_at,

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.0.9" version = "1.0.10"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

@@ -503,3 +503,169 @@
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
} }
/* File Count Badge on Version Items */
.file-select-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
background: oklch(var(--lora-accent) / 0.18);
color: var(--lora-accent);
font-size: inherit;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid oklch(var(--lora-accent) / 0.35);
user-select: none;
box-shadow: 0 1px 2px oklch(var(--lora-accent) / 0.1);
}
.file-select-badge:hover {
background: oklch(var(--lora-accent) / 0.3);
border-color: var(--lora-accent);
transform: scale(1.05);
box-shadow: 0 2px 6px oklch(var(--lora-accent) / 0.2);
}
.file-select-badge:active {
transform: scale(0.98);
}
.file-select-badge i {
font-size: 0.9em;
}
.file-select-badge .badge-arrow {
margin-left: 2px;
font-size: 0.65em;
opacity: 0.7;
}
/* File Selection Step */
.file-selection-header {
margin-bottom: var(--space-3);
}
.file-selection-header h3 {
margin: 0 0 4px 0;
font-size: 1.1em;
color: var(--text-color);
}
.file-selection-version-name {
font-size: 0.9em;
color: var(--text-color);
opacity: 0.7;
}
.file-selection-list {
max-height: 360px;
overflow-y: auto;
margin: var(--space-2) 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.file-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.2s ease;
background: var(--bg-color);
}
.file-option:hover {
border-color: var(--lora-accent);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.file-option.selected {
border: 2px solid var(--lora-accent);
background: oklch(var(--lora-accent) / 0.05);
}
.file-option-radio {
flex-shrink: 0;
}
.file-option-radio input[type="radio"] {
width: 16px;
height: 16px;
accent-color: var(--lora-accent);
cursor: pointer;
}
.file-option-info {
flex: 1;
min-width: 0;
}
.file-option-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 4px;
}
.file-tag {
display: inline-block;
padding: 2px 7px;
border-radius: 4px;
font-size: 0.8em;
font-weight: 500;
line-height: 1.4;
}
.file-tag.format {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
}
.file-tag.fp {
background: oklch(0.6 0.15 250 / 0.1);
color: oklch(0.55 0.15 250);
}
.file-tag.size {
background: oklch(0.55 0.1 160 / 0.1);
color: oklch(0.5 0.12 160);
}
.file-option-name {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.file-option-size {
font-size: 0.9em;
color: var(--text-color);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* Dark theme adjustments */
[data-theme="dark"] .file-option {
background: var(--lora-surface);
}
[data-theme="dark"] .file-tag.fp {
background: oklch(0.55 0.12 250 / 0.15);
color: oklch(0.7 0.12 250);
}
[data-theme="dark"] .file-tag.size {
background: oklch(0.5 0.08 160 / 0.15);
color: oklch(0.65 0.08 160);
}

View File

@@ -745,3 +745,8 @@
.sidebar-tree-container { .sidebar-tree-container {
position: relative; position: relative;
} }
/* Folder context menu - positioned relative to sidebar */
#sidebarFolderContextMenu {
z-index: var(--z-modal, 1002);
}

View File

@@ -766,6 +766,49 @@ export class BaseModelApiClient {
} }
} }
async refreshUpdatesForFolder(folderPath, { force = false } = {}) {
if (!folderPath) {
throw new Error('No folder path provided');
}
try {
state.loadingManager.show('Checking for updates...', 0);
state.loadingManager.showCancelButton(() => this.cancelTask());
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
folder_path: folderPath,
force
})
});
let payload = {};
try {
payload = await response.json();
} catch (error) {
console.warn('Unable to parse refresh updates response as JSON', error);
}
if (!response.ok || payload?.success !== true) {
if (payload?.status === 'cancelled') {
showToast('toast.api.operationCancelled', {}, 'info');
return null;
}
const message = payload?.error || response.statusText || 'Failed to refresh updates';
throw new Error(message);
}
return payload;
} catch (error) {
console.error('Error refreshing updates for folder:', error);
throw error;
} finally {
state.loadingManager.hide();
}
}
async fetchCivitaiVersions(modelId, source = null) { async fetchCivitaiVersions(modelId, source = null) {
try { try {
let requestUrl = `${this.apiConfig.endpoints.civitaiVersions}/${modelId}`; let requestUrl = `${this.apiConfig.endpoints.civitaiVersions}/${modelId}`;
@@ -909,7 +952,7 @@ export class BaseModelApiClient {
} }
} }
async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId, source = null) { async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId, source = null, fileParams = null) {
try { try {
const response = await fetch(DOWNLOAD_ENDPOINTS.download, { const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
method: 'POST', method: 'POST',
@@ -921,7 +964,8 @@ export class BaseModelApiClient {
relative_path: relativePath, relative_path: relativePath,
use_default_paths: useDefaultPaths, use_default_paths: useDefaultPaths,
download_id: downloadId, download_id: downloadId,
...(source ? { source } : {}) ...(source ? { source } : {}),
...(fileParams ? { file_params: fileParams } : {})
}) })
}); });

View File

@@ -197,8 +197,8 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
// Reset page counter // Reset page counter
pageState.currentPage = 1; pageState.currentPage = 1;
// Fetch the first page const pageSize = state.virtualScroller?.pageSize || pageState.pageSize || 100;
const result = await fetchPageFunction(1, pageState.pageSize || 50); const result = await fetchPageFunction(1, pageSize);
// Update the virtual scroller // Update the virtual scroller
state.virtualScroller.refreshWithData( state.virtualScroller.refreshWithData(
@@ -251,8 +251,8 @@ export async function loadMoreWithVirtualScroll(options = {}) {
pageState.currentPage = 1; pageState.currentPage = 1;
} }
// Fetch the first page of data const pageSize = state.virtualScroller?.pageSize || pageState.pageSize || 100;
const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50); const result = await fetchPageFunction(pageState.currentPage, pageSize);
// Update virtual scroller with the new data // Update virtual scroller with the new data
state.virtualScroller.refreshWithData( state.virtualScroller.refreshWithData(
@@ -294,47 +294,41 @@ export async function resetAndReload(updateFolders = false, options = {}) {
} }
/** /**
* Sync changes - quick refresh without rebuilding cache (similar to models page) * Refreshes the recipe list by triggering a backend scan, then reloading.
* @param {boolean} fullRebuild - If true, fully rebuild the cache; if false, incremental scan
*/ */
export async function syncChanges() { export async function syncChanges() {
try { return refreshRecipes(false);
state.loadingManager.showSimpleLoading('Syncing changes...');
// Simply reload the recipes without rebuilding cache
await resetAndReload(false, { preserveScroll: true });
showToast('toast.recipes.syncComplete', {}, 'success');
} catch (error) {
console.error('Error syncing recipes:', error);
showToast('toast.recipes.syncFailed', { message: error.message }, 'error');
} finally {
state.loadingManager.hide();
state.loadingManager.restoreProgressBar();
}
} }
/** export async function refreshRecipes(fullRebuild = true) {
* Refreshes the recipe list by first rebuilding the cache and then loading recipes const actionLabel = fullRebuild ? 'Rebuilding recipe cache' : 'Refreshing recipes';
*/ const actionToast = fullRebuild ? 'Full rebuild' : 'Refresh';
export async function refreshRecipes() {
try {
state.loadingManager.showSimpleLoading('Refreshing recipes...');
// Call the API endpoint to rebuild the recipe cache try {
const response = await fetch(RECIPE_ENDPOINTS.scan); state.loadingManager.show(`${actionLabel}...`, 0);
const url = new URL(RECIPE_ENDPOINTS.scan, window.location.origin);
url.searchParams.append('full_rebuild', fullRebuild);
const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
const data = await response.json(); throw new Error(`Failed to refresh recipe cache: ${response.status} ${response.statusText}`);
throw new Error(data.error || 'Failed to refresh recipe cache');
} }
// After successful cache rebuild, reload the recipes const data = await response.json();
await resetAndReload(false, { preserveScroll: true }); if (data.status === 'cancelled') {
showToast('toast.api.operationCancelled', {}, 'info');
return;
}
showToast('toast.recipes.refreshComplete', {}, 'success'); await resetAndReload(false);
showToast('toast.api.refreshComplete', { action: actionToast }, 'success');
} catch (error) { } catch (error) {
console.error('Error refreshing recipes:', error); console.error('Error refreshing recipes:', error);
showToast('toast.recipes.refreshFailed', { message: error.message }, 'error'); showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: 'recipe' }, 'error');
} finally { } finally {
state.loadingManager.hide(); state.loadingManager.hide();
state.loadingManager.restoreProgressBar(); state.loadingManager.restoreProgressBar();

View File

@@ -306,8 +306,14 @@ export class RecipeContextMenu extends BaseContextMenu {
if (result.success) { if (result.success) {
if (result.repaired > 0) { if (result.repaired > 0) {
showToast('recipes.contextMenu.repair.success', {}, 'success'); showToast('recipes.contextMenu.repair.success', {}, 'success');
// Refresh the current card or reload const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
this.resetAndReload(); if (detailResponse.ok) {
const updatedRecipe = await detailResponse.json();
const filePath = this.currentCard?.dataset?.filepath;
if (filePath && state.virtualScroller) {
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
}
}
} else { } else {
showToast('recipes.contextMenu.repair.skipped', {}, 'info'); showToast('recipes.contextMenu.repair.skipped', {}, 'info');
} }

View File

@@ -28,6 +28,7 @@ class RecipeCard {
card.dataset.created = this.recipe.created_date; card.dataset.created = this.recipe.created_date;
card.dataset.id = this.recipe.id || ''; card.dataset.id = this.recipe.id || '';
card.dataset.folder = this.recipe.folder || ''; card.dataset.folder = this.recipe.folder || '';
card.dataset.favorite = this.recipe.favorite ? 'true' : 'false';
// Get base model with fallback // Get base model with fallback
const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown'; const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown';
@@ -161,6 +162,7 @@ class RecipeCard {
// Update early to provide instant feedback and avoid race conditions with re-renders // Update early to provide instant feedback and avoid race conditions with re-renders
this.recipe.favorite = newFavoriteState; this.recipe.favorite = newFavoriteState;
card.dataset.favorite = newFavoriteState ? 'true' : 'false';
// Function to update icon state // Function to update icon state
const updateIconUI = (icon, state) => { const updateIconUI = (icon, state) => {

View File

@@ -7,6 +7,7 @@ import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { bulkManager } from '../managers/BulkManager.js'; import { bulkManager } from '../managers/BulkManager.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
import { escapeHtml, escapeAttribute } from './shared/utils.js'; import { escapeHtml, escapeAttribute } from './shared/utils.js';
export class SidebarManager { export class SidebarManager {
@@ -41,6 +42,7 @@ export class SidebarManager {
// Bind methods // Bind methods
this.handleTreeClick = this.handleTreeClick.bind(this); this.handleTreeClick = this.handleTreeClick.bind(this);
this.handleTreeContextMenu = this.handleTreeContextMenu.bind(this);
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this); this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
this.handleDocumentClick = this.handleDocumentClick.bind(this); this.handleDocumentClick = this.handleDocumentClick.bind(this);
this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this); this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this);
@@ -185,6 +187,8 @@ export class SidebarManager {
} }
if (folderTree) { if (folderTree) {
folderTree.removeEventListener('click', this.handleTreeClick); folderTree.removeEventListener('click', this.handleTreeClick);
folderTree.removeEventListener('contextmenu', this.handleTreeContextMenu);
folderTree.removeEventListener('dragover', this.handleFolderDragOver);
} }
if (sidebarBreadcrumbNav) { if (sidebarBreadcrumbNav) {
sidebarBreadcrumbNav.removeEventListener('click', this.handleBreadcrumbClick); sidebarBreadcrumbNav.removeEventListener('click', this.handleBreadcrumbClick);
@@ -977,6 +981,7 @@ export class SidebarManager {
const folderTree = document.getElementById('sidebarFolderTree'); const folderTree = document.getElementById('sidebarFolderTree');
if (folderTree) { if (folderTree) {
folderTree.addEventListener('click', this.handleTreeClick); folderTree.addEventListener('click', this.handleTreeClick);
folderTree.addEventListener('contextmenu', this.handleTreeContextMenu);
} }
// Breadcrumb click handler // Breadcrumb click handler
@@ -1027,6 +1032,19 @@ export class SidebarManager {
if (displayModeToggleBtn) { if (displayModeToggleBtn) {
displayModeToggleBtn.addEventListener('click', this.handleDisplayModeToggle); displayModeToggleBtn.addEventListener('click', this.handleDisplayModeToggle);
} }
// Sidebar folder context menu click handler
const sidebarFolderMenu = document.getElementById('sidebarFolderContextMenu');
if (sidebarFolderMenu) {
sidebarFolderMenu.addEventListener('click', (e) => {
const item = e.target.closest('.context-menu-item');
if (!item) return;
const action = item.dataset.action;
if (action) {
this.handleFolderContextMenuAction(action);
}
});
}
} }
handleDocumentClick(event) { handleDocumentClick(event) {
@@ -1398,6 +1416,82 @@ export class SidebarManager {
} }
} }
handleTreeContextMenu(event) {
const nodeContent = event.target.closest('.sidebar-tree-node, .sidebar-folder-item');
if (!nodeContent) return;
event.preventDefault();
event.stopPropagation();
const path = nodeContent.dataset.path;
if (path === undefined || path === null || path === '') return;
this._showFolderContextMenu(event.clientX, event.clientY, path);
}
_showFolderContextMenu(x, y, path) {
this._closeFolderContextMenu();
const menu = document.getElementById('sidebarFolderContextMenu');
if (!menu) return;
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.style.display = 'block';
menu.dataset.folderPath = path;
this._folderContextOpen = true;
// Close on next click outside
this._folderContextCloseHandler = (e) => {
if (!menu.contains(e.target)) {
this._closeFolderContextMenu();
}
};
setTimeout(() => {
document.addEventListener('click', this._folderContextCloseHandler);
}, 0);
}
_closeFolderContextMenu() {
const menu = document.getElementById('sidebarFolderContextMenu');
if (menu) {
menu.style.display = 'none';
delete menu.dataset.folderPath;
}
if (this._folderContextCloseHandler) {
document.removeEventListener('click', this._folderContextCloseHandler);
this._folderContextCloseHandler = null;
}
this._folderContextOpen = false;
}
handleFolderContextMenuAction(action) {
const menu = document.getElementById('sidebarFolderContextMenu');
if (!menu) return;
const path = menu.dataset.folderPath;
this._closeFolderContextMenu();
if (!path) return;
this._performFolderAction(action, path);
}
async _performFolderAction(action, path) {
switch (action) {
case 'check-folder-updates':
try {
await performFolderUpdateCheck(path);
} catch (error) {
console.error('Folder update check failed:', error);
}
break;
default:
console.warn('Unknown folder action:', action);
}
}
handleBreadcrumbClick(event) { handleBreadcrumbClick(event) {
const breadcrumbItem = event.target.closest('.sidebar-breadcrumb-item'); const breadcrumbItem = event.target.closest('.sidebar-breadcrumb-item');
const dropdownItem = event.target.closest('.breadcrumb-dropdown-item'); const dropdownItem = event.target.closest('.breadcrumb-dropdown-item');

View File

@@ -432,7 +432,7 @@ export class BatchImportManager {
// Refresh recipes list to show newly imported recipes // Refresh recipes list to show newly imported recipes
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') { if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
window.recipeManager.loadRecipes({ preserveScroll: true }); window.recipeManager.loadRecipes(true);
} }
// Show results step // Show results step

View File

@@ -309,9 +309,22 @@ export class BulkMissingLoraDownloadManager {
}, 'warning'); }, 'warning');
} }
// Refresh the recipes list to update LoRA status // Update each affected recipe card with fresh data (LoRA inLibrary flags changed)
if (window.recipeManager) { if (state.virtualScroller) {
window.recipeManager.loadRecipes({ preserveScroll: true }); const { extractRecipeId } = await import('../api/recipeApi.js');
for (const recipe of this.pendingRecipes) {
const recipeId = extractRecipeId(recipe.file_path);
if (!recipeId) continue;
try {
const detailRes = await fetch(`/api/lm/recipe/${encodeURIComponent(recipeId)}`);
if (detailRes.ok) {
const updated = await detailRes.json();
state.virtualScroller.updateSingleItem(recipe.file_path, updated);
}
} catch (e) {
console.warn('Failed to update recipe card after LoRA download:', e);
}
}
} }
} }

View File

@@ -33,6 +33,8 @@ export class DownloadManager {
this.handleStartDownload = this.startDownload.bind(this); this.handleStartDownload = this.startDownload.bind(this);
this.handleBackToUrl = this.backToUrl.bind(this); this.handleBackToUrl = this.backToUrl.bind(this);
this.handleBackToVersions = this.backToVersions.bind(this); this.handleBackToVersions = this.backToVersions.bind(this);
this.handleBackToVersionFromFiles = this.backToVersionFromFiles.bind(this);
this.handleConfirmFileSelection = this.confirmFileSelection.bind(this);
this.handleCloseModal = this.closeModal.bind(this); this.handleCloseModal = this.closeModal.bind(this);
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this); this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
} }
@@ -80,6 +82,10 @@ export class DownloadManager {
document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions); document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions);
document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal); document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal);
// File selection step buttons
document.getElementById('backToVersionFromFilesBtn').addEventListener('click', this.handleBackToVersionFromFiles);
document.getElementById('confirmFileSelection').addEventListener('click', this.handleConfirmFileSelection);
// Default path toggle handler // Default path toggle handler
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath); document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
} }
@@ -129,6 +135,7 @@ export class DownloadManager {
this.modelId = null; this.modelId = null;
this.modelVersionId = null; this.modelVersionId = null;
this.source = null; this.source = null;
this.selectedFile = null;
this.selectedFolder = ''; this.selectedFolder = '';
@@ -247,9 +254,12 @@ export class DownloadManager {
const firstImage = version.images?.find(img => !img.url.endsWith('.mp4')); const firstImage = version.images?.find(img => !img.url.endsWith('.mp4'));
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png'; const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
// Count model-type files per version
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
const primaryFile = modelFiles.find(f => f.primary) || modelFiles[0] || {};
const fileSize = version.modelSizeKB ? const fileSize = version.modelSizeKB ?
(version.modelSizeKB / 1024).toFixed(2) : (version.modelSizeKB / 1024).toFixed(2) :
(version.files[0]?.sizeKB / 1024).toFixed(2); ((primaryFile.sizeKB || 0) / 1024).toFixed(2);
const existsLocally = version.existsLocally; const existsLocally = version.existsLocally;
const hasBeenDownloaded = version.hasBeenDownloaded && !existsLocally; const hasBeenDownloaded = version.hasBeenDownloaded && !existsLocally;
@@ -282,6 +292,12 @@ export class DownloadManager {
</div>`; </div>`;
} }
const fileBadge = modelFiles.length > 1 && !existsLocally
? `<span class="file-select-badge" data-version-id="${version.id}">
<i class="fas fa-th-list"></i> ${modelFiles.length} ${translate('modals.download.fileSelection.files')} <i class="fas fa-chevron-right badge-arrow"></i>
</span>`
: '';
return ` return `
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''} <div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}
${existsLocally ? 'exists-locally' : ''} ${existsLocally ? 'exists-locally' : ''}
@@ -302,14 +318,23 @@ export class DownloadManager {
<div class="version-meta"> <div class="version-meta">
<span><i class="fas fa-calendar"></i> ${new Date(version.createdAt).toLocaleDateString()}</span> <span><i class="fas fa-calendar"></i> ${new Date(version.createdAt).toLocaleDateString()}</span>
<span><i class="fas fa-file-archive"></i> ${fileSize} MB</span> <span><i class="fas fa-file-archive"></i> ${fileSize} MB</span>
${fileBadge}
</div> </div>
</div> </div>
</div> </div>
`; `;
}).join(''); }).join('');
// Add click handlers for version selection // Add click handlers for version selection and file badge
versionList.addEventListener('click', (event) => { versionList.addEventListener('click', (event) => {
const badge = event.target.closest('.file-select-badge');
if (badge) {
event.stopPropagation();
const versionId = badge.dataset.versionId;
this.selectVersion(versionId);
this.showFileSelectionStep(versionId);
return;
}
const versionItem = event.target.closest('.version-item'); const versionItem = event.target.closest('.version-item');
if (versionItem) { if (versionItem) {
this.selectVersion(versionItem.dataset.versionId); this.selectVersion(versionItem.dataset.versionId);
@@ -352,6 +377,80 @@ export class DownloadManager {
} }
} }
showFileSelectionStep(versionId) {
const version = this.versions.find(v => v.id.toString() === versionId.toString());
if (!version) return;
this.currentVersion = version;
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
document.getElementById('versionStep').style.display = 'none';
document.getElementById('fileSelectionStep').style.display = 'block';
const nameEl = document.getElementById('fileSelectionVersionName');
if (nameEl) {
nameEl.textContent = `${version.name} · ${version.baseModel || ''}`;
}
const container = document.getElementById('fileSelectionList');
container.innerHTML = modelFiles.map(file => {
const meta = file.metadata || {};
const sizeGB = file.sizeKB ? (file.sizeKB / (1024 * 1024)).toFixed(2) : '--';
const isSelected = this.selectedFile?.id === file.id;
const tags = [];
if (meta.size) tags.push(`<span class="file-tag size">${meta.size}</span>`);
if (meta.format) tags.push(`<span class="file-tag format">${meta.format}</span>`);
if (meta.fp) tags.push(`<span class="file-tag fp">${meta.fp}</span>`);
const fileName = file.name || '';
return `
<div class="file-option ${isSelected ? 'selected' : ''}" data-file-id="${file.id}">
<div class="file-option-radio">
<input type="radio" name="fileSelection" value="${file.id}" ${isSelected ? 'checked' : ''}>
</div>
<div class="file-option-info">
<div class="file-option-tags">
${tags.join(' ')}
</div>
<div class="file-option-name">${fileName}</div>
</div>
<div class="file-option-size">${sizeGB} GB</div>
</div>
`;
}).join('');
container.querySelectorAll('.file-option').forEach(el => {
el.addEventListener('click', () => {
container.querySelectorAll('.file-option').forEach(o => o.classList.remove('selected'));
el.classList.add('selected');
const radio = el.querySelector('input[type="radio"]');
if (radio) radio.checked = true;
});
});
}
confirmFileSelection() {
const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked');
if (!selectedRadio) return;
const version = this.currentVersion;
if (!version) return;
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
document.getElementById('fileSelectionStep').style.display = 'none';
document.getElementById('locationStep').style.display = 'block';
this.proceedToLocationContent();
}
backToVersionFromFiles() {
document.getElementById('fileSelectionStep').style.display = 'none';
document.getElementById('versionStep').style.display = 'block';
}
async proceedToLocation() { async proceedToLocation() {
if (!this.currentVersion) { if (!this.currentVersion) {
showToast('toast.loras.pleaseSelectVersion', {}, 'error'); showToast('toast.loras.pleaseSelectVersion', {}, 'error');
@@ -366,6 +465,10 @@ export class DownloadManager {
document.getElementById('versionStep').style.display = 'none'; document.getElementById('versionStep').style.display = 'none';
document.getElementById('locationStep').style.display = 'block'; document.getElementById('locationStep').style.display = 'block';
await this.proceedToLocationContent();
}
async proceedToLocationContent() {
try { try {
// Fetch model roots // Fetch model roots
@@ -450,6 +553,7 @@ export class DownloadManager {
targetFolder = '', targetFolder = '',
useDefaultPaths = false, useDefaultPaths = false,
source = null, source = null,
fileParams = null,
closeModal = false, closeModal = false,
}) { }) {
const config = this.apiClient?.apiConfig?.config; const config = this.apiClient?.apiConfig?.config;
@@ -513,7 +617,8 @@ export class DownloadManager {
targetFolder, targetFolder,
useDefaultPaths, useDefaultPaths,
downloadId, downloadId,
source source,
fileParams
); );
if (response?.skipped) { if (response?.skipped) {
@@ -632,6 +737,13 @@ export class DownloadManager {
} else { } else {
targetFolder = this.folderTreeManager.getSelectedPath(); targetFolder = this.folderTreeManager.getSelectedPath();
} }
const fileParams = this.selectedFile ? {
type: 'Model',
format: this.selectedFile.metadata?.format || 'SafeTensor',
size: this.selectedFile.metadata?.size || 'full',
fp: this.selectedFile.metadata?.fp,
} : null;
return this.executeDownloadWithProgress({ return this.executeDownloadWithProgress({
modelId: this.modelId, modelId: this.modelId,
versionId: this.currentVersion.id, versionId: this.currentVersion.id,
@@ -640,6 +752,7 @@ export class DownloadManager {
targetFolder, targetFolder,
useDefaultPaths, useDefaultPaths,
source: this.source, source: this.source,
fileParams,
closeModal: true, closeModal: true,
}); });
} }

View File

@@ -662,7 +662,7 @@ export class FilterManager {
// Call the appropriate manager's load method based on page type // Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) { if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes({ preserveScroll: true }); await window.recipeManager.loadRecipes(true);
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') { } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
// For models page, reset the page and reload // For models page, reset the page and reload
await getModelApiClient().loadMoreWithVirtualScroll(true, false); await getModelApiClient().loadMoreWithVirtualScroll(true, false);
@@ -746,7 +746,7 @@ export class FilterManager {
// Reload data using the appropriate method for the current page // Reload data using the appropriate method for the current page
if (this.currentPage === 'recipes' && window.recipeManager) { if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes({ preserveScroll: true }); await window.recipeManager.loadRecipes(true);
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') { } else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
await getModelApiClient().loadMoreWithVirtualScroll(true, true); await getModelApiClient().loadMoreWithVirtualScroll(true, true);
} }

View File

@@ -301,7 +301,7 @@ export class SearchManager {
// Call the appropriate manager's load method based on page type // Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) { if (this.currentPage === 'recipes' && window.recipeManager) {
window.recipeManager.loadRecipes({ preserveScroll: true }); window.recipeManager.loadRecipes(true);
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') { } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
// For models page, reset the page and reload // For models page, reset the page and reload
getModelApiClient().loadMoreWithVirtualScroll(true, false); getModelApiClient().loadMoreWithVirtualScroll(true, false);

View File

@@ -2876,7 +2876,7 @@ export class SettingsManager {
await resetAndReload(false); await resetAndReload(false);
} else if (this.currentPage === 'recipes') { } else if (this.currentPage === 'recipes') {
// Reload the recipes without updating folders // Reload the recipes without updating folders
await window.recipeManager.loadRecipes({ preserveScroll: true }); await window.recipeManager.loadRecipes(true);
} else if (this.currentPage === 'checkpoints') { } else if (this.currentPage === 'checkpoints') {
// Reload the checkpoints without updating folders // Reload the checkpoints without updating folders
await resetAndReload(false); await resetAndReload(false);

View File

@@ -122,7 +122,7 @@ export class DownloadManager {
modalManager.closeModal('importModal'); modalManager.closeModal('importModal');
// Refresh the recipe // Refresh the recipe
window.recipeManager.loadRecipes({ preserveScroll: true }); window.recipeManager.loadRecipes(true);
} catch (error) { } catch (error) {
console.error('Error:', error); console.error('Error:', error);

View File

@@ -8,7 +8,7 @@ import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
import { RecipeContextMenu } from './components/ContextMenu/index.js'; import { RecipeContextMenu } from './components/ContextMenu/index.js';
import { DuplicatesManager } from './components/DuplicatesManager.js'; import { DuplicatesManager } from './components/DuplicatesManager.js';
import { refreshVirtualScroll } from './utils/infiniteScroll.js'; import { refreshVirtualScroll } from './utils/infiniteScroll.js';
import { refreshRecipes, syncChanges, RecipeSidebarApiClient } from './api/recipeApi.js'; import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
import { sidebarManager } from './components/SidebarManager.js'; import { sidebarManager } from './components/SidebarManager.js';
class RecipePageControls { class RecipePageControls {
@@ -19,16 +19,13 @@ class RecipePageControls {
} }
async resetAndReload() { async resetAndReload() {
await refreshVirtualScroll({ preserveScroll: true }); await refreshVirtualScroll();
} }
async refreshModels(fullRebuild = false) { async refreshModels(fullRebuild = false) {
if (fullRebuild) { await refreshRecipes(fullRebuild);
await refreshRecipes();
return;
}
await syncChanges(); await sidebarManager.refresh();
} }
getSidebarApiClient() { getSidebarApiClient() {

View File

@@ -100,6 +100,90 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
return { status, displayName, records, error }; return { status, displayName, records, error };
} }
/**
* Perform a model update check scoped to a specific folder.
* @param {string} folderPath - The relative folder path to check.
* @param {Object} [options]
* @param {Function} [options.onComplete] - Callback invoked after the request settles.
* @returns {Promise<{status: string, records: Array, error: Error | null}>}
*/
export async function performFolderUpdateCheck(folderPath, { onComplete } = {}) {
const modelType = getCurrentModelType();
const apiConfig = getCompleteApiConfig(modelType);
const apiClient = getModelApiClient(modelType);
const displayName = apiConfig?.config?.displayName ?? 'Model';
if (!apiConfig?.endpoints?.refreshUpdates) {
console.warn('Refresh updates endpoint not configured for model type:', modelType);
onComplete?.({ status: 'unsupported', records: [], error: null });
return { status: 'unsupported', records: [], error: null };
}
const loadingMessage = translate(
'sidebar.folderUpdateCheck.loading',
{ type: displayName },
`Checking ${displayName} updates for this folder...`
);
state.loadingManager?.showSimpleLoading?.(loadingMessage);
state.loadingManager?.showCancelButton?.(() => apiClient.cancelTask());
let status = 'success';
let records = [];
let error = null;
try {
const response = await fetch(apiConfig.endpoints.refreshUpdates, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_path: folderPath, force: false })
});
let payload = {};
try {
payload = await response.json();
} catch {
payload = {};
}
if (!response.ok || payload.success !== true) {
if (payload?.status === 'cancelled') {
showToast('toast.api.operationCancelled', {}, 'info');
return { status: 'cancelled', records: [], error: null };
}
const errorMessage = payload?.error || response.statusText || 'Unknown error';
throw new Error(errorMessage);
}
records = Array.isArray(payload.records) ? payload.records : [];
if (records.length > 0) {
showToast('sidebar.folderUpdateCheck.success', { count: records.length, type: displayName }, 'success');
} else {
showToast('sidebar.folderUpdateCheck.none', { type: displayName }, 'info');
}
await resetAndReload(false);
} catch (err) {
status = 'error';
error = err instanceof Error ? err : new Error(String(err));
console.error('Error checking folder model updates:', error);
showToast(
'sidebar.folderUpdateCheck.error',
{ message: error?.message ?? 'Unknown error', type: displayName },
'error'
);
} finally {
state.loadingManager?.hide?.();
if (typeof state.loadingManager?.restoreProgressBar === 'function') {
state.loadingManager.restoreProgressBar();
}
onComplete?.({ status, records, error });
}
return { status, records, error };
}
function getTypePlural(displayName) { function getTypePlural(displayName) {
if (!displayName) { if (!displayName) {
return 'models'; return 'models';

View File

@@ -150,6 +150,13 @@
</div> </div>
</div> </div>
<!-- Sidebar Folder Context Menu -->
<div id="sidebarFolderContextMenu" class="context-menu">
<div class="context-menu-item" data-action="check-folder-updates">
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span>
</div>
</div>
<div id="nsfwLevelSelector" class="nsfw-level-selector"> <div id="nsfwLevelSelector" class="nsfw-level-selector">
<div class="nsfw-level-header"> <div class="nsfw-level-header">
<h3>{{ t('modals.contentRating.title') }}</h3> <h3>{{ t('modals.contentRating.title') }}</h3>

View File

@@ -29,6 +29,21 @@
</div> </div>
</div> </div>
<!-- Step 2.5: File Selection (optional - only when version has multiple model files) -->
<div class="download-step" id="fileSelectionStep" style="display: none;">
<div class="file-selection-header">
<h3 id="fileSelectionTitle">{{ t('modals.download.fileSelection.title') }}</h3>
<div class="file-selection-version-name" id="fileSelectionVersionName"></div>
</div>
<div class="file-selection-list" id="fileSelectionList">
<!-- File options will be rendered here dynamically -->
</div>
<div class="modal-actions">
<button class="secondary-btn" id="backToVersionFromFilesBtn">{{ t('common.actions.back') }}</button>
<button class="primary-btn" id="confirmFileSelection">{{ t('modals.download.fileSelection.select') }}</button>
</div>
</div>
<!-- Step 3: Location Selection --> <!-- Step 3: Location Selection -->
<div class="download-step" id="locationStep" style="display: none;"> <div class="download-step" id="locationStep" style="display: none;">
<div class="location-selection"> <div class="location-selection">

View File

@@ -3,6 +3,7 @@ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.hoisted(() => vi.fn()); const showToastMock = vi.hoisted(() => vi.fn());
const loadingManagerMock = vi.hoisted(() => ({ const loadingManagerMock = vi.hoisted(() => ({
showSimpleLoading: vi.fn(), showSimpleLoading: vi.fn(),
show: vi.fn(),
hide: vi.fn(), hide: vi.fn(),
restoreProgressBar: vi.fn(), restoreProgressBar: vi.fn(),
})); }));
@@ -177,9 +178,7 @@ describe('RecipeSidebarApiClient bulk operations', () => {
); );
}); });
it('preserves scroll position for recipe reloads when requested', async () => { it('reloads recipes without preserving scroll', async () => {
const scrollSnapshot = { scrollContainer: { scrollTop: 480 }, scrollTop: 480 };
captureScrollPositionMock.mockReturnValue(scrollSnapshot);
global.fetch.mockResolvedValue({ global.fetch.mockResolvedValue({
ok: true, ok: true,
json: async () => ({ json: async () => ({
@@ -189,18 +188,18 @@ describe('RecipeSidebarApiClient bulk operations', () => {
}), }),
}); });
await resetAndReload(false, { preserveScroll: true }); await resetAndReload(false);
expect(captureScrollPositionMock).toHaveBeenCalledTimes(1); expect(captureScrollPositionMock).not.toHaveBeenCalled();
expect(virtualScrollerMock.refreshWithData).toHaveBeenCalledWith( expect(virtualScrollerMock.refreshWithData).toHaveBeenCalledWith(
[{ id: 'recipe-1' }], [{ id: 'recipe-1' }],
1, 1,
false false
); );
expect(restoreScrollPositionMock).toHaveBeenCalledWith(scrollSnapshot); expect(restoreScrollPositionMock).not.toHaveBeenCalled();
}); });
it('uses scroll-preserving reloads for syncChanges', async () => { it('uses scroll-free reloads for syncChanges', async () => {
global.fetch.mockResolvedValue({ global.fetch.mockResolvedValue({
ok: true, ok: true,
json: async () => ({ json: async () => ({
@@ -212,8 +211,8 @@ describe('RecipeSidebarApiClient bulk operations', () => {
await syncChanges(); await syncChanges();
expect(captureScrollPositionMock).toHaveBeenCalledTimes(1); expect(captureScrollPositionMock).not.toHaveBeenCalled();
expect(restoreScrollPositionMock).toHaveBeenCalledTimes(1); expect(restoreScrollPositionMock).not.toHaveBeenCalled();
expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1); expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1);
}); });
}); });

View File

@@ -46,6 +46,7 @@ class DummyUpdateService:
*, *,
force_refresh=False, force_refresh=False,
target_model_ids=None, target_model_ids=None,
folder_path=None,
): ):
self.calls.append( self.calls.append(
{ {
@@ -54,6 +55,7 @@ class DummyUpdateService:
"provider": provider, "provider": provider,
"force_refresh": force_refresh, "force_refresh": force_refresh,
"target_model_ids": target_model_ids, "target_model_ids": target_model_ids,
"folder_path": folder_path,
} }
) )
return self.records return self.records

View File

@@ -183,6 +183,13 @@ function parseSearchTokens(term = '') {
return { include, exclude }; return { include, exclude };
} }
function escapePromptParentheses(text) {
// In ComfyUI's CLIP text encoder, bare parentheses are weight adjustment syntax.
// Tags containing literal parentheses must be escaped with backslash to prevent
// them from being interpreted as weight modifiers. e.g. "foo (bar)" → "foo \(bar\)"
return text.replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
function formatAutocompleteInsertion(text = '') { function formatAutocompleteInsertion(text = '') {
const trimmed = typeof text === 'string' ? text.trim() : ''; const trimmed = typeof text === 'string' ? text.trim() : '';
if (!trimmed) { if (!trimmed) {
@@ -253,7 +260,7 @@ function createDefaultBehavior(modelType) {
if (!trimmed) { if (!trimmed) {
return ''; return '';
} }
return formatAutocompleteInsertion(trimmed); return formatAutocompleteInsertion(escapePromptParentheses(trimmed));
}, },
}; };
} }
@@ -352,7 +359,7 @@ const MODEL_BEHAVIORS = {
custom_words: { custom_words: {
enablePreview: false, enablePreview: false,
async getInsertText(_instance, relativePath) { async getInsertText(_instance, relativePath) {
return formatAutocompleteInsertion(relativePath); return formatAutocompleteInsertion(escapePromptParentheses(relativePath));
}, },
}, },
prompt: { prompt: {
@@ -399,6 +406,8 @@ const MODEL_BEHAVIORS = {
tagText = tagText.replace(/_/g, ' '); tagText = tagText.replace(/_/g, ' ');
} }
tagText = escapePromptParentheses(tagText);
return formatAutocompleteInsertion(tagText); return formatAutocompleteInsertion(tagText);
} }
}, },