feat(download): add configurable base model download exclusions

This commit is contained in:
Will Miao
2026-03-26 23:06:14 +08:00
parent 5b065b47d4
commit a5191414cc
22 changed files with 988 additions and 4 deletions

View File

@@ -323,6 +323,24 @@
"saveFailed": "Übersprungene Pfade konnten nicht gespeichert werden: {message}"
}
},
"downloadSkipBaseModels": {
"label": "Downloads für Basismodelle überspringen",
"help": "Gilt für alle Download-Abläufe. Hier können nur unterstützte Basismodelle ausgewählt werden.",
"searchPlaceholder": "Basismodelle filtern...",
"empty": "Keine Basismodelle entsprechen der aktuellen Suche.",
"summary": {
"none": "Nichts ausgewählt",
"count": "{count} ausgewählt"
},
"actions": {
"edit": "Bearbeiten",
"collapse": "Einklappen",
"clear": "Löschen"
},
"validation": {
"saveFailed": "Ausgeschlossene Basismodelle konnten nicht gespeichert werden: {message}"
}
},
"layoutSettings": {
"displayDensity": "Anzeige-Dichte",
"displayDensityOptions": {
@@ -1467,6 +1485,7 @@
"pleaseSelectVersion": "Bitte wählen Sie eine Version aus",
"versionExists": "Diese Version existiert bereits in Ihrer Bibliothek",
"downloadCompleted": "Download erfolgreich abgeschlossen",
"downloadSkippedByBaseModel": "Download übersprungen, weil das Basismodell {baseModel} ausgeschlossen ist",
"autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen",
"autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen",
"autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}",

View File

@@ -323,6 +323,24 @@
"saveFailed": "Unable to save skip paths: {message}"
}
},
"downloadSkipBaseModels": {
"label": "Skip downloads for base models",
"help": "When a model version uses one of these base models, LoRA Manager will skip the download before any file transfer starts. Applies to all download flows. Only supported base models can be selected here.",
"searchPlaceholder": "Filter base models...",
"empty": "No base models match the current search.",
"summary": {
"none": "None selected",
"count": "{count} selected"
},
"actions": {
"edit": "Edit",
"collapse": "Collapse",
"clear": "Clear"
},
"validation": {
"saveFailed": "Unable to save excluded base models: {message}"
}
},
"layoutSettings": {
"displayDensity": "Display Density",
"displayDensityOptions": {
@@ -1467,6 +1485,7 @@
"pleaseSelectVersion": "Please select a version",
"versionExists": "This version already exists in your library",
"downloadCompleted": "Download completed successfully",
"downloadSkippedByBaseModel": "Skipped download because base model {baseModel} is excluded",
"autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}",
"autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models",
"autoOrganizeFailed": "Auto-organize failed: {error}",

View File

@@ -323,6 +323,24 @@
"saveFailed": "No se pudieron guardar las rutas a omitir: {message}"
}
},
"downloadSkipBaseModels": {
"label": "Omitir descargas para modelos base",
"help": "Se aplica a todos los flujos de descarga. Aquí solo se pueden seleccionar modelos base compatibles.",
"searchPlaceholder": "Filtrar modelos base...",
"empty": "Ningún modelo base coincide con la búsqueda actual.",
"summary": {
"none": "Ninguno seleccionado",
"count": "{count} seleccionados"
},
"actions": {
"edit": "Editar",
"collapse": "Contraer",
"clear": "Limpiar"
},
"validation": {
"saveFailed": "No se pudieron guardar los modelos base excluidos: {message}"
}
},
"layoutSettings": {
"displayDensity": "Densidad de visualización",
"displayDensityOptions": {
@@ -1467,6 +1485,7 @@
"pleaseSelectVersion": "Por favor selecciona una versión",
"versionExists": "Esta versión ya existe en tu biblioteca",
"downloadCompleted": "Descarga completada exitosamente",
"downloadSkippedByBaseModel": "Descarga omitida porque el modelo base {baseModel} está excluido",
"autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}",
"autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos",
"autoOrganizeFailed": "Auto-organización fallida: {error}",

View File

@@ -323,6 +323,24 @@
"saveFailed": "Impossible d'enregistrer les chemins à ignorer : {message}"
}
},
"downloadSkipBaseModels": {
"label": "Ignorer les téléchargements pour certains modèles de base",
"help": "Sapplique à tous les flux de téléchargement. Seuls les modèles de base pris en charge peuvent être sélectionnés ici.",
"searchPlaceholder": "Filtrer les modèles de base...",
"empty": "Aucun modèle de base ne correspond à la recherche actuelle.",
"summary": {
"none": "Aucune sélection",
"count": "{count} sélectionnés"
},
"actions": {
"edit": "Modifier",
"collapse": "Réduire",
"clear": "Effacer"
},
"validation": {
"saveFailed": "Impossible denregistrer les modèles de base exclus : {message}"
}
},
"layoutSettings": {
"displayDensity": "Densité d'affichage",
"displayDensityOptions": {
@@ -1467,6 +1485,7 @@
"pleaseSelectVersion": "Veuillez sélectionner une version",
"versionExists": "Cette version existe déjà dans votre bibliothèque",
"downloadCompleted": "Téléchargement terminé avec succès",
"downloadSkippedByBaseModel": "Téléchargement ignoré, car le modèle de base {baseModel} est exclu",
"autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}",
"autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles",
"autoOrganizeFailed": "Échec de l'auto-organisation : {error}",

View File

@@ -323,6 +323,24 @@
"saveFailed": "לא ניתן לשמור נתיבי דילוג: {message}"
}
},
"downloadSkipBaseModels": {
"label": "דלג על הורדות עבור מודלי בסיס",
"help": "חל על כל תהליכי ההורדה. ניתן לבחור כאן רק מודלי בסיס נתמכים.",
"searchPlaceholder": "סנן מודלי בסיס...",
"empty": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
"summary": {
"none": "לא נבחר דבר",
"count": "{count} נבחרו"
},
"actions": {
"edit": "עריכה",
"collapse": "כווץ",
"clear": "נקה"
},
"validation": {
"saveFailed": "לא ניתן לשמור את מודלי הבסיס המוחרגים: {message}"
}
},
"layoutSettings": {
"displayDensity": "צפיפות תצוגה",
"displayDensityOptions": {
@@ -1467,6 +1485,7 @@
"pleaseSelectVersion": "אנא בחר גרסה",
"versionExists": "גרסה זו כבר קיימת בספרייה שלך",
"downloadCompleted": "ההורדה הושלמה בהצלחה",
"downloadSkippedByBaseModel": "ההורדה דולגה כי מודל הבסיס {baseModel} מוחרג",
"autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}",
"autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים",
"autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}",

View File

@@ -323,6 +323,24 @@
"saveFailed": "スキップパスの保存に失敗しました:{message}"
}
},
"downloadSkipBaseModels": {
"label": "ベースモデルのダウンロードをスキップ",
"help": "すべてのダウンロードフローに適用されます。ここでは対応しているベースモデルのみ選択できます。",
"searchPlaceholder": "ベースモデルを絞り込む...",
"empty": "現在の検索に一致するベースモデルはありません。",
"summary": {
"none": "未選択",
"count": "{count} 件を選択"
},
"actions": {
"edit": "編集",
"collapse": "折りたたむ",
"clear": "クリア"
},
"validation": {
"saveFailed": "除外するベースモデルを保存できませんでした: {message}"
}
},
"layoutSettings": {
"displayDensity": "表示密度",
"displayDensityOptions": {
@@ -1467,6 +1485,7 @@
"pleaseSelectVersion": "バージョンを選択してください",
"versionExists": "このバージョンは既にライブラリに存在します",
"downloadCompleted": "ダウンロードが正常に完了しました",
"downloadSkippedByBaseModel": "ベースモデル {baseModel} が除外されているため、ダウンロードをスキップしました",
"autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました",
"autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗",
"autoOrganizeFailed": "自動整理に失敗しました:{error}",

View File

@@ -323,6 +323,24 @@
"saveFailed": "건너뛰기 경로를 저장할 수 없습니다: {message}"
}
},
"downloadSkipBaseModels": {
"label": "기본 모델 다운로드 건너뛰기",
"help": "모든 다운로드 흐름에 적용됩니다. 여기서는 지원되는 기본 모델만 선택할 수 있습니다.",
"searchPlaceholder": "기본 모델 필터링...",
"empty": "현재 검색과 일치하는 기본 모델이 없습니다.",
"summary": {
"none": "선택 없음",
"count": "{count}개 선택됨"
},
"actions": {
"edit": "편집",
"collapse": "접기",
"clear": "지우기"
},
"validation": {
"saveFailed": "제외된 기본 모델을 저장할 수 없습니다: {message}"
}
},
"layoutSettings": {
"displayDensity": "표시 밀도",
"displayDensityOptions": {
@@ -1467,6 +1485,7 @@
"pleaseSelectVersion": "버전을 선택해주세요",
"versionExists": "이 버전은 이미 라이브러리에 있습니다",
"downloadCompleted": "다운로드가 성공적으로 완료되었습니다",
"downloadSkippedByBaseModel": "기본 모델 {baseModel}이(가) 제외되어 다운로드를 건너뛰었습니다",
"autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다",
"autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패",
"autoOrganizeFailed": "자동 정리 실패: {error}",

View File

@@ -323,6 +323,24 @@
"saveFailed": "Не удалось сохранить пути для пропуска: {message}"
}
},
"downloadSkipBaseModels": {
"label": "Пропускать загрузки для базовых моделей",
"help": "Применяется ко всем сценариям загрузки. Здесь можно выбрать только поддерживаемые базовые модели.",
"searchPlaceholder": "Фильтровать базовые модели...",
"empty": "Нет базовых моделей, соответствующих текущему поиску.",
"summary": {
"none": "Ничего не выбрано",
"count": "Выбрано: {count}"
},
"actions": {
"edit": "Изменить",
"collapse": "Свернуть",
"clear": "Очистить"
},
"validation": {
"saveFailed": "Не удалось сохранить исключённые базовые модели: {message}"
}
},
"layoutSettings": {
"displayDensity": "Плотность отображения",
"displayDensityOptions": {
@@ -1467,6 +1485,7 @@
"pleaseSelectVersion": "Пожалуйста, выберите версию",
"versionExists": "Эта версия уже существует в вашей библиотеке",
"downloadCompleted": "Загрузка успешно завершена",
"downloadSkippedByBaseModel": "Загрузка пропущена, потому что базовая модель {baseModel} исключена",
"autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}",
"autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей",
"autoOrganizeFailed": "Ошибка автоматической организации: {error}",

View File

@@ -323,6 +323,24 @@
"saveFailed": "无法保存跳过路径:{message}"
}
},
"downloadSkipBaseModels": {
"label": "跳过这些基础模型的下载",
"help": "适用于所有下载流程。这里只能选择受支持的基础模型。",
"searchPlaceholder": "筛选基础模型...",
"empty": "没有与当前搜索匹配的基础模型。",
"summary": {
"none": "未选择",
"count": "已选择 {count} 项"
},
"actions": {
"edit": "编辑",
"collapse": "收起",
"clear": "清空"
},
"validation": {
"saveFailed": "无法保存已排除的基础模型:{message}"
}
},
"layoutSettings": {
"displayDensity": "显示密度",
"displayDensityOptions": {
@@ -1467,6 +1485,7 @@
"pleaseSelectVersion": "请选择版本",
"versionExists": "该版本已存在于你的库中",
"downloadCompleted": "下载成功完成",
"downloadSkippedByBaseModel": "由于基础模型 {baseModel} 已被排除,已跳过下载",
"autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}",
"autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型",
"autoOrganizeFailed": "自动整理失败:{error}",

View File

@@ -323,6 +323,24 @@
"saveFailed": "無法儲存跳過路徑:{message}"
}
},
"downloadSkipBaseModels": {
"label": "跳過這些基礎模型的下載",
"help": "適用於所有下載流程。這裡只能選擇受支援的基礎模型。",
"searchPlaceholder": "篩選基礎模型...",
"empty": "沒有符合目前搜尋條件的基礎模型。",
"summary": {
"none": "未選擇",
"count": "已選擇 {count} 項"
},
"actions": {
"edit": "編輯",
"collapse": "收起",
"clear": "清空"
},
"validation": {
"saveFailed": "無法儲存已排除的基礎模型:{message}"
}
},
"layoutSettings": {
"displayDensity": "顯示密度",
"displayDensityOptions": {
@@ -1467,6 +1485,7 @@
"pleaseSelectVersion": "請選擇一個版本",
"versionExists": "此版本已存在於您的庫中",
"downloadCompleted": "下載成功完成",
"downloadSkippedByBaseModel": "由於基礎模型 {baseModel} 已被排除,已跳過下載",
"autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理",
"autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型",
"autoOrganizeFailed": "自動整理失敗:{error}",

View File

@@ -13,6 +13,7 @@ from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
from ..utils.constants import (
CARD_PREVIEW_WIDTH,
DIFFUSION_MODEL_BASE_MODELS,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
VALID_LORA_TYPES,
)
from ..utils.civitai_utils import rewrite_preview_url
@@ -228,7 +229,9 @@ class DownloadManager:
# Update status based on result
if task_id in self._active_downloads:
self._active_downloads[task_id]["status"] = (
"completed" if result["success"] else "failed"
result.get("status", "completed")
if result["success"]
else "failed"
)
if not result["success"]:
self._active_downloads[task_id]["error"] = result.get(
@@ -352,10 +355,54 @@ class DownloadManager:
"error": f'Model type "{model_type_from_info}" is not supported for download',
}
excluded_base_models = get_settings_manager().get_download_skip_base_models()
base_model_value = version_info.get("baseModel", "")
if (
isinstance(base_model_value, str)
and base_model_value in SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS
and base_model_value in excluded_base_models
):
file_name = ""
files = version_info.get("files")
if isinstance(files, list):
primary_file = next(
(
file_info
for file_info in files
if isinstance(file_info, dict) and file_info.get("primary")
),
None,
)
selected_file = primary_file
if selected_file is None:
selected_file = next(
(file_info for file_info in files if isinstance(file_info, dict)),
None,
)
if isinstance(selected_file, dict):
raw_file_name = selected_file.get("name", "")
if isinstance(raw_file_name, str):
file_name = raw_file_name.strip()
message = (
f"Skipped download for '{file_name or version_info.get('name') or f'model_version:{model_version_id or model_id}'}' "
f"because base model '{base_model_value}' is excluded in settings"
)
logger.info(message)
return {
"success": True,
"skipped": True,
"status": "skipped",
"reason": "base_model_excluded",
"message": message,
"base_model": base_model_value,
"file_name": file_name,
"download_id": download_id,
}
# Check if this checkpoint should be treated as a diffusion model based on baseModel
is_diffusion_model = False
if model_type == "checkpoint":
base_model_value = version_info.get("baseModel", "")
if base_model_value in DIFFUSION_MODEL_BASE_MODELS:
is_diffusion_model = True
logger.info(

View File

@@ -11,7 +11,11 @@ from typing import Any, Awaitable, Dict, Iterable, List, Mapping, Optional, Sequ
from platformdirs import user_config_dir
from ..utils.constants import DEFAULT_HASH_CHUNK_SIZE_MB, DEFAULT_PRIORITY_TAG_CONFIG
from ..utils.constants import (
DEFAULT_HASH_CHUNK_SIZE_MB,
DEFAULT_PRIORITY_TAG_CONFIG,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
)
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
from ..utils.settings_paths import APP_NAME, ensure_settings_file, get_legacy_settings_path
from ..utils.tag_priorities import (
@@ -73,6 +77,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"update_flag_strategy": "same_base",
"auto_organize_exclusions": [],
"metadata_refresh_skip_paths": [],
"download_skip_base_models": [],
}
@@ -276,6 +281,21 @@ class SettingsManager:
self.settings["metadata_refresh_skip_paths"] = []
inserted_defaults = True
if "download_skip_base_models" in self.settings:
normalized_skip_base_models = self.normalize_download_skip_base_models(
self.settings.get("download_skip_base_models")
)
if normalized_skip_base_models != self.settings.get(
"download_skip_base_models"
):
self.settings["download_skip_base_models"] = (
normalized_skip_base_models
)
updated_existing = True
else:
self.settings["download_skip_base_models"] = []
inserted_defaults = True
had_mature_level = "mature_blur_level" in self.settings
raw_mature_level = self.settings.get("mature_blur_level")
normalized_mature_level = self.normalize_mature_blur_level(raw_mature_level)
@@ -964,6 +984,45 @@ class SettingsManager:
self._save_settings()
return skip_paths
def normalize_download_skip_base_models(self, value: Any) -> List[str]:
if value is None:
return []
if isinstance(value, str):
candidates: Iterable[str] = (
value.replace("\n", ",").replace(";", ",").split(",")
)
elif isinstance(value, Sequence) and not isinstance(
value, (bytes, bytearray, str)
):
candidates = value
else:
return []
base_models: List[str] = []
seen = set()
for raw in candidates:
if not isinstance(raw, str):
continue
token = raw.strip()
if not token or token not in SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS:
continue
if token in seen:
continue
seen.add(token)
base_models.append(token)
return base_models
def get_download_skip_base_models(self) -> List[str]:
base_models = self.normalize_download_skip_base_models(
self.settings.get("download_skip_base_models")
)
if base_models != self.settings.get("download_skip_base_models"):
self.settings["download_skip_base_models"] = base_models
self._save_settings()
return base_models
def get_extra_folder_paths(self) -> Dict[str, List[str]]:
"""Get extra folder paths for the active library.
@@ -1032,6 +1091,8 @@ class SettingsManager:
value = self.normalize_auto_organize_exclusions(value)
elif key == "metadata_refresh_skip_paths":
value = self.normalize_metadata_refresh_skip_paths(value)
elif key == "download_skip_base_models":
value = self.normalize_download_skip_base_models(value)
elif key == "mature_blur_level":
value = self.normalize_mature_blur_level(value)
self.settings[key] = value

View File

@@ -113,3 +113,59 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
"Qwen",
]
)
# Supported baseModel values for download exclusion settings.
# Keep this aligned with static/js/utils/constants.js, excluding the generic "Other" value.
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
[
"SD 1.4",
"SD 1.5",
"SD 1.5 LCM",
"SD 1.5 Hyper",
"SD 2.0",
"SD 2.1",
"SD 3",
"SD 3.5",
"SD 3.5 Medium",
"SD 3.5 Large",
"SD 3.5 Large Turbo",
"SDXL 1.0",
"SDXL Lightning",
"SDXL Hyper",
"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",
"AuraFlow",
"Chroma",
"PixArt a",
"PixArt E",
"Hunyuan 1",
"Lumina",
"Kolors",
"NoobAI",
"Illustrious",
"Pony",
"HiDream",
"Qwen",
"ZImageTurbo",
"ZImageBase",
"SVD",
"LTXV",
"LTXV2",
"Wan Video",
"Wan Video 1.3B t2v",
"Wan Video 14B t2v",
"Wan Video 14B i2v 480p",
"Wan Video 14B i2v 720p",
"Wan Video 2.2 TI2V-5B",
"Wan Video 2.2 T2V-A14B",
"Wan Video 2.2 I2V-A14B",
"Hunyuan Video",
]
)

View File

@@ -430,6 +430,88 @@
box-sizing: border-box;
}
.base-model-skip-toggle {
min-width: 220px;
justify-content: space-between;
gap: 10px;
}
.base-model-skip-toggle-label {
opacity: 0.75;
white-space: nowrap;
}
.base-model-skip-panel {
margin-top: var(--space-2);
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background-color: var(--lora-surface);
}
.base-model-skip-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.base-model-skip-search {
flex: 1;
min-width: 0;
padding: 8px 10px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--settings-bg);
color: var(--text-color);
}
.base-model-skip-search:focus {
border-color: var(--lora-accent);
outline: none;
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
}
.base-model-skip-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
max-height: 220px;
overflow-y: auto;
}
.base-model-skip-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background-color: var(--settings-bg);
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.base-model-skip-option:hover {
border-color: var(--lora-accent);
background-color: rgba(var(--lora-accent-rgb, 79, 70, 229), 0.05);
}
.base-model-skip-option input {
margin: 0;
}
.base-model-skip-option span {
font-size: 0.9em;
line-height: 1.25;
}
.base-model-skip-empty {
padding: 8px 0 0;
font-size: 0.9em;
opacity: 0.75;
}
.priority-tags-input:focus {
border-color: var(--lora-accent);
outline: none;

View File

@@ -492,7 +492,7 @@ export class DownloadManager {
console.error('WebSocket error:', error);
};
await this.apiClient.downloadModel(
const response = await this.apiClient.downloadModel(
modelId,
versionId,
modelRoot,
@@ -502,6 +502,16 @@ export class DownloadManager {
source
);
if (response?.skipped) {
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
updateProgress(100, 0, displayName);
showToast('toast.loras.downloadSkippedByBaseModel', { baseModel: response.base_model || 'Unknown' }, 'warning');
if (closeModal) {
modalManager.closeModal('downloadModal');
}
return true;
}
showToast('toast.loras.downloadCompleted', {}, 'success');
if (closeModal) {

View File

@@ -139,6 +139,10 @@ export class SettingsManager {
backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths
);
merged.download_skip_base_models = this.normalizeDownloadSkipBaseModels(
backendSettings?.download_skip_base_models ?? defaults.download_skip_base_models
);
merged.mature_blur_level = this.normalizeMatureBlurLevel(
backendSettings?.mature_blur_level ?? defaults.mature_blur_level
);
@@ -179,6 +183,15 @@ export class SettingsManager {
return [];
}
getAvailableDownloadSkipBaseModels() {
return MAPPABLE_BASE_MODELS.filter(model => model !== 'Other');
}
normalizeDownloadSkipBaseModels(value) {
const allowed = new Set(this.getAvailableDownloadSkipBaseModels());
return this.normalizePatternList(value).filter(model => allowed.has(model));
}
registerStartupMessages(messages = []) {
if (!Array.isArray(messages) || messages.length === 0) {
return;
@@ -379,6 +392,36 @@ export class SettingsManager {
});
}
const downloadSkipBaseModelsContainer = document.getElementById('downloadSkipBaseModelsContainer');
if (downloadSkipBaseModelsContainer) {
downloadSkipBaseModelsContainer.addEventListener('change', (event) => {
if (event.target instanceof HTMLInputElement && event.target.name === 'downloadSkipBaseModel') {
this.saveDownloadSkipBaseModels();
}
});
}
const downloadSkipBaseModelsToggle = document.getElementById('downloadSkipBaseModelsToggle');
if (downloadSkipBaseModelsToggle) {
downloadSkipBaseModelsToggle.addEventListener('click', () => {
this.toggleDownloadSkipBaseModelsPanel();
});
}
const downloadSkipBaseModelsSearch = document.getElementById('downloadSkipBaseModelsSearch');
if (downloadSkipBaseModelsSearch) {
downloadSkipBaseModelsSearch.addEventListener('input', () => {
this.renderDownloadSkipBaseModels();
});
}
const downloadSkipBaseModelsClear = document.getElementById('downloadSkipBaseModelsClear');
if (downloadSkipBaseModelsClear) {
downloadSkipBaseModelsClear.addEventListener('click', () => {
this.clearDownloadSkipBaseModels();
});
}
this.setupPriorityTagInputs();
this.initializeNavigation();
this.initializeSearch();
@@ -730,6 +773,13 @@ export class SettingsManager {
metadataRefreshSkipPathsError.textContent = '';
}
this.renderDownloadSkipBaseModels();
const downloadSkipBaseModelsError = document.getElementById('downloadSkipBaseModelsError');
if (downloadSkipBaseModelsError) {
downloadSkipBaseModelsError.textContent = '';
}
this.setDownloadSkipBaseModelsPanelOpen(false);
// Set video autoplay on hover setting
const autoplayOnHoverCheckbox = document.getElementById('autoplayOnHover');
if (autoplayOnHoverCheckbox) {
@@ -2170,6 +2220,190 @@ export class SettingsManager {
}
}
renderDownloadSkipBaseModels() {
const container = document.getElementById('downloadSkipBaseModelsContainer');
const searchInput = document.getElementById('downloadSkipBaseModelsSearch');
const emptyState = document.getElementById('downloadSkipBaseModelsEmpty');
if (!container) {
return;
}
const selectedValues = this.normalizeDownloadSkipBaseModels(
state.global.settings.download_skip_base_models
);
const selected = new Set(selectedValues);
const options = this.getAvailableDownloadSkipBaseModels();
const query = (searchInput?.value || '').trim().toLowerCase();
const filteredOptions = query
? options.filter((baseModel) => baseModel.toLowerCase().includes(query))
: options;
container.innerHTML = filteredOptions.map((baseModel) => `
<label class="base-model-skip-option">
<input
type="checkbox"
name="downloadSkipBaseModel"
value="${baseModel}"
${selected.has(baseModel) ? 'checked' : ''}
>
<span>${baseModel}</span>
</label>
`).join('');
if (emptyState) {
emptyState.hidden = filteredOptions.length > 0;
}
this.renderDownloadSkipBaseModelsSummary(selectedValues);
}
renderDownloadSkipBaseModelsSummary(selectedValues = null) {
const summaryElement = document.getElementById('downloadSkipBaseModelsSummary');
if (!summaryElement) {
return;
}
const values = Array.isArray(selectedValues)
? selectedValues
: this.normalizeDownloadSkipBaseModels(state.global.settings.download_skip_base_models);
if (values.length === 0) {
summaryElement.textContent = translate(
'settings.downloadSkipBaseModels.summary.none',
{},
'None selected'
);
return;
}
if (values.length <= 2) {
summaryElement.textContent = values.join(', ');
return;
}
summaryElement.textContent = translate(
'settings.downloadSkipBaseModels.summary.count',
{ count: values.length },
`${values.length} selected`
);
}
setDownloadSkipBaseModelsPanelOpen(isOpen) {
const panel = document.getElementById('downloadSkipBaseModelsPanel');
const toggle = document.getElementById('downloadSkipBaseModelsToggle');
const toggleLabel = toggle?.querySelector('.base-model-skip-toggle-label');
if (!panel || !toggle) {
return;
}
panel.hidden = !isOpen;
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
if (toggleLabel) {
toggleLabel.textContent = isOpen
? translate('settings.downloadSkipBaseModels.actions.collapse', {}, 'Collapse')
: translate('settings.downloadSkipBaseModels.actions.edit', {}, 'Edit');
}
if (isOpen) {
const searchInput = document.getElementById('downloadSkipBaseModelsSearch');
searchInput?.focus();
}
}
toggleDownloadSkipBaseModelsPanel() {
const panel = document.getElementById('downloadSkipBaseModelsPanel');
if (!panel) {
return;
}
this.setDownloadSkipBaseModelsPanelOpen(panel.hidden);
}
async saveDownloadSkipBaseModels() {
const container = document.getElementById('downloadSkipBaseModelsContainer');
const errorElement = document.getElementById('downloadSkipBaseModelsError');
if (!container) return;
const selected = Array.from(
container.querySelectorAll('input[name="downloadSkipBaseModel"]:checked')
).map((input) => input.value);
const normalized = this.normalizeDownloadSkipBaseModels(selected);
const current = this.normalizeDownloadSkipBaseModels(state.global.settings.download_skip_base_models);
if (normalized.join('|') === current.join('|')) {
if (errorElement) {
errorElement.textContent = '';
}
return;
}
try {
if (errorElement) {
errorElement.textContent = '';
}
await this.saveSetting('download_skip_base_models', normalized);
this.renderDownloadSkipBaseModels();
showToast(
'toast.settings.settingsUpdated',
{ setting: translate('settings.downloadSkipBaseModels.label') },
'success'
);
} catch (error) {
console.error('Failed to save download skip base models:', error);
if (errorElement) {
errorElement.textContent = translate(
'settings.downloadSkipBaseModels.validation.saveFailed',
{ message: error.message },
`Unable to save excluded base models: ${error.message}`
);
}
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
async clearDownloadSkipBaseModels() {
const searchInput = document.getElementById('downloadSkipBaseModelsSearch');
if (searchInput) {
searchInput.value = '';
}
const current = this.normalizeDownloadSkipBaseModels(
state.global.settings.download_skip_base_models
);
if (current.length === 0) {
this.renderDownloadSkipBaseModels();
return;
}
try {
const errorElement = document.getElementById('downloadSkipBaseModelsError');
if (errorElement) {
errorElement.textContent = '';
}
await this.saveSetting('download_skip_base_models', []);
this.renderDownloadSkipBaseModels();
showToast(
'toast.settings.settingsUpdated',
{ setting: translate('settings.downloadSkipBaseModels.label') },
'success'
);
} catch (error) {
const errorElement = document.getElementById('downloadSkipBaseModelsError');
console.error('Failed to clear download skip base models:', error);
if (errorElement) {
errorElement.textContent = translate(
'settings.downloadSkipBaseModels.validation.saveFailed',
{ message: error.message },
`Unable to save excluded base models: ${error.message}`
);
}
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
async saveMetadataRefreshSkipPaths() {
const input = document.getElementById('metadataRefreshSkipPaths');
const errorElement = document.getElementById('metadataRefreshSkipPathsError');

View File

@@ -38,6 +38,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
hide_early_access_updates: false,
auto_organize_exclusions: [],
metadata_refresh_skip_paths: [],
download_skip_base_models: [],
});
export function createDefaultSettings() {

View File

@@ -743,6 +743,46 @@
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="downloadSkipBaseModelsToggle">
{{ t('settings.downloadSkipBaseModels.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadSkipBaseModels.help') }}"></i>
</label>
</div>
<div class="setting-control">
<button
type="button"
id="downloadSkipBaseModelsToggle"
class="secondary-btn base-model-skip-toggle"
aria-expanded="false"
>
<span id="downloadSkipBaseModelsSummary">{{ t('settings.downloadSkipBaseModels.summary.none') }}</span>
<span class="base-model-skip-toggle-label">{{ t('settings.downloadSkipBaseModels.actions.edit') }}</span>
</button>
</div>
</div>
<div id="downloadSkipBaseModelsPanel" class="base-model-skip-panel" hidden>
<div class="base-model-skip-toolbar">
<input
type="text"
id="downloadSkipBaseModelsSearch"
class="base-model-skip-search"
placeholder="{{ t('settings.downloadSkipBaseModels.searchPlaceholder') }}"
/>
<button type="button" class="text-btn base-model-skip-clear" id="downloadSkipBaseModelsClear">
{{ t('settings.downloadSkipBaseModels.actions.clear') }}
</button>
</div>
<div id="downloadSkipBaseModelsContainer" class="base-model-skip-list"></div>
<div id="downloadSkipBaseModelsEmpty" class="base-model-skip-empty" hidden>
{{ t('settings.downloadSkipBaseModels.empty') }}
</div>
</div>
<div class="settings-input-error-message" id="downloadSkipBaseModelsError"></div>
</div>
<!-- Priority Tags -->
<div class="setting-item priority-tags-item">
<div class="setting-row priority-tags-header-row">

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: {
closeModal: vi.fn(),
},
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
}));
vi.mock('../../../static/js/state/index.js', () => {
const settings = {};
return {
state: {
global: {
settings,
},
},
createDefaultSettings: () => ({
language: 'en',
download_skip_base_models: [],
}),
};
});
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
resetAndReload: vi.fn(),
}));
vi.mock('../../../static/js/utils/constants.js', () => ({
DOWNLOAD_PATH_TEMPLATES: {},
DEFAULT_PATH_TEMPLATES: {},
MAPPABLE_BASE_MODELS: ['Flux.1 D', 'Pony', 'SDXL 1.0', 'Other'],
PATH_TEMPLATE_PLACEHOLDERS: {},
DEFAULT_PRIORITY_TAG_CONFIG: {
lora: 'character, style',
checkpoint: 'base, guide',
embedding: 'hint',
},
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: (key, params, fallback) => {
if (key === 'settings.downloadSkipBaseModels.summary.none') {
return 'None selected';
}
if (key === 'settings.downloadSkipBaseModels.summary.count') {
return `${params?.count ?? 0} selected`;
}
return fallback ?? '';
},
}));
vi.mock('../../../static/js/i18n/index.js', () => ({
i18n: {
getCurrentLocale: () => 'en',
setLanguage: vi.fn().mockResolvedValue(),
},
}));
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
configureModelCardVideo: vi.fn(),
}));
vi.mock('../../../static/js/managers/BannerService.js', () => ({
bannerService: {
registerBanner: vi.fn(),
},
}));
vi.mock('../../../static/js/components/SidebarManager.js', () => ({
sidebarManager: {
setSidebarEnabled: vi.fn().mockResolvedValue(),
},
}));
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
import { state } from '../../../static/js/state/index.js';
const createManager = () => {
const initSettingsSpy = vi
.spyOn(SettingsManager.prototype, 'initializeSettings')
.mockResolvedValue();
const initializeSpy = vi
.spyOn(SettingsManager.prototype, 'initialize')
.mockImplementation(() => {});
const manager = new SettingsManager();
initSettingsSpy.mockRestore();
initializeSpy.mockRestore();
return manager;
};
const appendDownloadSkipUi = () => {
document.body.innerHTML = `
<button id="downloadSkipBaseModelsToggle" aria-expanded="false">
<span id="downloadSkipBaseModelsSummary"></span>
<span class="base-model-skip-toggle-label"></span>
</button>
<div id="downloadSkipBaseModelsPanel" hidden>
<input id="downloadSkipBaseModelsSearch" />
<button id="downloadSkipBaseModelsClear" type="button">Clear</button>
<div id="downloadSkipBaseModelsContainer"></div>
<div id="downloadSkipBaseModelsEmpty" hidden></div>
</div>
<div id="downloadSkipBaseModelsError"></div>
`;
};
describe('SettingsManager download skip base models UI', () => {
beforeEach(() => {
document.body.innerHTML = '';
vi.clearAllMocks();
state.global.settings = {
download_skip_base_models: [],
};
});
it('renders a compact summary for selected base models', () => {
appendDownloadSkipUi();
state.global.settings.download_skip_base_models = ['Flux.1 D', 'Pony'];
const manager = createManager();
manager.renderDownloadSkipBaseModels();
expect(document.getElementById('downloadSkipBaseModelsSummary').textContent).toBe('Flux.1 D, Pony');
expect(document.querySelectorAll('#downloadSkipBaseModelsContainer input')).toHaveLength(3);
});
it('filters the list using the search input and shows an empty state', () => {
appendDownloadSkipUi();
state.global.settings.download_skip_base_models = ['Flux.1 D'];
const manager = createManager();
const searchInput = document.getElementById('downloadSkipBaseModelsSearch');
searchInput.value = 'pony';
manager.renderDownloadSkipBaseModels();
expect(document.querySelectorAll('#downloadSkipBaseModelsContainer input')).toHaveLength(1);
expect(document.querySelector('#downloadSkipBaseModelsContainer input').value).toBe('Pony');
searchInput.value = 'zzz';
manager.renderDownloadSkipBaseModels();
expect(document.querySelectorAll('#downloadSkipBaseModelsContainer input')).toHaveLength(0);
expect(document.getElementById('downloadSkipBaseModelsEmpty').hidden).toBe(false);
});
});

View File

@@ -719,3 +719,42 @@ def test_auto_organize_conflict_when_running(mock_service):
await client.close()
asyncio.run(scenario())
def test_download_model_returns_skipped_success(mock_service, download_manager_stub):
async def scenario():
download_manager_stub.last_progress_snapshot = None
async def fake_download(**kwargs):
download_manager_stub.calls.append(kwargs)
return {
"success": True,
"skipped": True,
"status": "skipped",
"reason": "base_model_excluded",
"message": "Skipped by settings",
"base_model": "SDXL 1.0",
"file_name": "demo.safetensors",
}
download_manager_stub.download_from_civitai = fake_download
client = await create_test_client(mock_service)
try:
response = await client.post(
"/api/lm/download-model",
json={"model_version_id": 123},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["skipped"] is True
assert payload["reason"] == "base_model_excluded"
assert payload["base_model"] == "SDXL 1.0"
assert payload["file_name"] == "demo.safetensors"
finally:
await client.close()
asyncio.run(scenario())

View File

@@ -38,6 +38,7 @@ def isolate_settings(monkeypatch, tmp_path):
"embedding": "{base_model}/{first_tag}",
},
"base_model_path_mappings": {"BaseModel": "MappedModel"},
"download_skip_base_models": [],
}
)
monkeypatch.setattr(manager, "settings", default_settings)
@@ -443,3 +444,49 @@ def test_distribute_preview_to_entries_keeps_existing_file(tmp_path):
assert targets[0] == str(existing_preview)
assert Path(targets[1]).read_bytes() == b"preview"
@pytest.mark.asyncio
async def test_download_skips_excluded_base_model(monkeypatch, scanners, metadata_provider):
manager = DownloadManager()
get_settings_manager().settings["download_skip_base_models"] = ["SDXL 1.0"]
metadata_provider.get_model_version = AsyncMock(
return_value={
"id": 42,
"model": {"type": "LoRA", "tags": ["fantasy"]},
"baseModel": "SDXL 1.0",
"creator": {"username": "Author"},
"files": [
{
"type": "Model",
"primary": True,
"downloadUrl": "https://example.invalid/file.safetensors",
"name": "file.safetensors",
}
],
}
)
execute_download = AsyncMock()
monkeypatch.setattr(
DownloadManager, "_execute_download", execute_download, raising=False
)
result = await manager.download_from_civitai(
model_version_id=99,
use_default_paths=True,
progress_callback=None,
source=None,
)
assert result["success"] is True
assert result["skipped"] is True
assert result["status"] == "skipped"
assert result["reason"] == "base_model_excluded"
assert result["base_model"] == "SDXL 1.0"
assert result["file_name"] == "file.safetensors"
assert "file.safetensors" in result["message"]
execute_download.assert_not_called()
assert manager._active_downloads[result["download_id"]]["status"] == "skipped"

View File

@@ -605,3 +605,28 @@ def test_delete_library_switches_active(manager, tmp_path):
manager.delete_library("other")
assert manager.get_active_library_name() == "default"
def test_download_skip_base_models_are_normalized(manager):
manager.settings["download_skip_base_models"] = [
"SDXL 1.0",
"Invalid",
"SDXL 1.0",
"Pony",
"Other",
]
result = manager.get_download_skip_base_models()
assert result == ["SDXL 1.0", "Pony"]
assert manager.settings["download_skip_base_models"] == ["SDXL 1.0", "Pony"]
def test_setting_download_skip_base_models_normalizes_string_input(manager):
manager.set(
"download_skip_base_models",
"SDXL 1.0, Pony; Invalid\nSDXL 1.0"
)
assert manager.get("download_skip_base_models") == ["SDXL 1.0", "Pony"]