diff --git a/locales/de.json b/locales/de.json index fd34ccec..297f3a6a 100644 --- a/locales/de.json +++ b/locales/de.json @@ -495,6 +495,25 @@ "saveFailed": "Ausgeschlossene Basismodelle konnten nicht gespeichert werden: {message}" } }, + "downloadRootRules": { + "title": "[TODO: Translate] Download Root Rules", + "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", + "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "[TODO: Translate] Add rule", + "removeRule": "[TODO: Translate] Remove rule", + "moveUp": "[TODO: Translate] Move up", + "moveDown": "[TODO: Translate] Move down", + "noRoots": "[TODO: Translate] No roots configured for this model type.", + "types": { + "loras": "[TODO: Translate] LoRA", + "checkpoints": "[TODO: Translate] Checkpoint", + "unet": "[TODO: Translate] Diffusion Model", + "embeddings": "[TODO: Translate] Embedding" + }, + "validation": { + "saveFailed": "[TODO: Translate] Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "Bereits heruntergeladene Modellversionen überspringen", "help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe." diff --git a/locales/en.json b/locales/en.json index 0db5e294..92ed45ef 100644 --- a/locales/en.json +++ b/locales/en.json @@ -495,6 +495,25 @@ "saveFailed": "Unable to save excluded base models: {message}" } }, + "downloadRootRules": { + "title": "Download Root Rules", + "help": "Route downloads to a different root folder based on the model's base model.", + "description": "Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "Add rule", + "removeRule": "Remove rule", + "moveUp": "Move up", + "moveDown": "Move down", + "noRoots": "No roots configured for this model type.", + "types": { + "loras": "LoRA", + "checkpoints": "Checkpoint", + "unet": "Diffusion Model", + "embeddings": "Embedding" + }, + "validation": { + "saveFailed": "Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "Skip previously downloaded model versions", "help": "When enabled, versions downloaded before will be skipped." diff --git a/locales/es.json b/locales/es.json index b76ff5d9..ea062cdb 100644 --- a/locales/es.json +++ b/locales/es.json @@ -495,6 +495,25 @@ "saveFailed": "No se pudieron guardar los modelos base excluidos: {message}" } }, + "downloadRootRules": { + "title": "[TODO: Translate] Download Root Rules", + "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", + "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "[TODO: Translate] Add rule", + "removeRule": "[TODO: Translate] Remove rule", + "moveUp": "[TODO: Translate] Move up", + "moveDown": "[TODO: Translate] Move down", + "noRoots": "[TODO: Translate] No roots configured for this model type.", + "types": { + "loras": "[TODO: Translate] LoRA", + "checkpoints": "[TODO: Translate] Checkpoint", + "unet": "[TODO: Translate] Diffusion Model", + "embeddings": "[TODO: Translate] Embedding" + }, + "validation": { + "saveFailed": "[TODO: Translate] Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "Omitir versiones de modelos previamente descargadas", "help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga." diff --git a/locales/fr.json b/locales/fr.json index 3842415d..dcdbb5f4 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -495,6 +495,25 @@ "saveFailed": "Impossible d’enregistrer les modèles de base exclus : {message}" } }, + "downloadRootRules": { + "title": "[TODO: Translate] Download Root Rules", + "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", + "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "[TODO: Translate] Add rule", + "removeRule": "[TODO: Translate] Remove rule", + "moveUp": "[TODO: Translate] Move up", + "moveDown": "[TODO: Translate] Move down", + "noRoots": "[TODO: Translate] No roots configured for this model type.", + "types": { + "loras": "[TODO: Translate] LoRA", + "checkpoints": "[TODO: Translate] Checkpoint", + "unet": "[TODO: Translate] Diffusion Model", + "embeddings": "[TODO: Translate] Embedding" + }, + "validation": { + "saveFailed": "[TODO: Translate] Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "Ignorer les versions de modèles précédemment téléchargées", "help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement." diff --git a/locales/he.json b/locales/he.json index aa752516..90e9faba 100644 --- a/locales/he.json +++ b/locales/he.json @@ -495,6 +495,25 @@ "saveFailed": "לא ניתן לשמור את מודלי הבסיס המוחרגים: {message}" } }, + "downloadRootRules": { + "title": "[TODO: Translate] Download Root Rules", + "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", + "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "[TODO: Translate] Add rule", + "removeRule": "[TODO: Translate] Remove rule", + "moveUp": "[TODO: Translate] Move up", + "moveDown": "[TODO: Translate] Move down", + "noRoots": "[TODO: Translate] No roots configured for this model type.", + "types": { + "loras": "[TODO: Translate] LoRA", + "checkpoints": "[TODO: Translate] Checkpoint", + "unet": "[TODO: Translate] Diffusion Model", + "embeddings": "[TODO: Translate] Embedding" + }, + "validation": { + "saveFailed": "[TODO: Translate] Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "דלג על גרסאות מודלים שהורדו בעבר", "help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה." diff --git a/locales/ja.json b/locales/ja.json index 806345e9..342bfff9 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -495,6 +495,25 @@ "saveFailed": "除外するベースモデルを保存できませんでした: {message}" } }, + "downloadRootRules": { + "title": "[TODO: Translate] Download Root Rules", + "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", + "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "[TODO: Translate] Add rule", + "removeRule": "[TODO: Translate] Remove rule", + "moveUp": "[TODO: Translate] Move up", + "moveDown": "[TODO: Translate] Move down", + "noRoots": "[TODO: Translate] No roots configured for this model type.", + "types": { + "loras": "[TODO: Translate] LoRA", + "checkpoints": "[TODO: Translate] Checkpoint", + "unet": "[TODO: Translate] Diffusion Model", + "embeddings": "[TODO: Translate] Embedding" + }, + "validation": { + "saveFailed": "[TODO: Translate] Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "以前にダウンロードしたモデルバージョンをスキップ", "help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。" diff --git a/locales/ko.json b/locales/ko.json index 944faf2a..a6d5a691 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -495,6 +495,25 @@ "saveFailed": "제외된 베이스 모델을 저장할 수 없습니다: {message}" } }, + "downloadRootRules": { + "title": "[TODO: Translate] Download Root Rules", + "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", + "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "[TODO: Translate] Add rule", + "removeRule": "[TODO: Translate] Remove rule", + "moveUp": "[TODO: Translate] Move up", + "moveDown": "[TODO: Translate] Move down", + "noRoots": "[TODO: Translate] No roots configured for this model type.", + "types": { + "loras": "[TODO: Translate] LoRA", + "checkpoints": "[TODO: Translate] Checkpoint", + "unet": "[TODO: Translate] Diffusion Model", + "embeddings": "[TODO: Translate] Embedding" + }, + "validation": { + "saveFailed": "[TODO: Translate] Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "이전에 다운로드한 모델 버전 건너뛰기", "help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다." diff --git a/locales/ru.json b/locales/ru.json index 8eaf2477..b867796b 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -495,6 +495,25 @@ "saveFailed": "Не удалось сохранить исключённые базовые модели: {message}" } }, + "downloadRootRules": { + "title": "[TODO: Translate] Download Root Rules", + "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", + "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "[TODO: Translate] Add rule", + "removeRule": "[TODO: Translate] Remove rule", + "moveUp": "[TODO: Translate] Move up", + "moveDown": "[TODO: Translate] Move down", + "noRoots": "[TODO: Translate] No roots configured for this model type.", + "types": { + "loras": "[TODO: Translate] LoRA", + "checkpoints": "[TODO: Translate] Checkpoint", + "unet": "[TODO: Translate] Diffusion Model", + "embeddings": "[TODO: Translate] Embedding" + }, + "validation": { + "saveFailed": "[TODO: Translate] Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "Пропускать ранее загруженные версии моделей", "help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки." diff --git a/locales/zh-CN.json b/locales/zh-CN.json index dbea1cf1..05ee6285 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -495,6 +495,25 @@ "saveFailed": "无法保存已排除的基础模型:{message}" } }, + "downloadRootRules": { + "title": "[TODO: Translate] Download Root Rules", + "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", + "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "[TODO: Translate] Add rule", + "removeRule": "[TODO: Translate] Remove rule", + "moveUp": "[TODO: Translate] Move up", + "moveDown": "[TODO: Translate] Move down", + "noRoots": "[TODO: Translate] No roots configured for this model type.", + "types": { + "loras": "[TODO: Translate] LoRA", + "checkpoints": "[TODO: Translate] Checkpoint", + "unet": "[TODO: Translate] Diffusion Model", + "embeddings": "[TODO: Translate] Embedding" + }, + "validation": { + "saveFailed": "[TODO: Translate] Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "跳过已下载的模型版本", "help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。" diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 043c2cf7..2269bf51 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -495,6 +495,25 @@ "saveFailed": "無法儲存已排除的基礎模型:{message}" } }, + "downloadRootRules": { + "title": "[TODO: Translate] Download Root Rules", + "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", + "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", + "addRule": "[TODO: Translate] Add rule", + "removeRule": "[TODO: Translate] Remove rule", + "moveUp": "[TODO: Translate] Move up", + "moveDown": "[TODO: Translate] Move down", + "noRoots": "[TODO: Translate] No roots configured for this model type.", + "types": { + "loras": "[TODO: Translate] LoRA", + "checkpoints": "[TODO: Translate] Checkpoint", + "unet": "[TODO: Translate] Diffusion Model", + "embeddings": "[TODO: Translate] Embedding" + }, + "validation": { + "saveFailed": "[TODO: Translate] Unable to save download root rules: {message}" + } + }, "skipPreviouslyDownloadedModelVersions": { "label": "跳過已下載的模型版本", "help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。" diff --git a/py/routes/handlers/download_routing_handlers.py b/py/routes/handlers/download_routing_handlers.py index 8107afc5..20fa2086 100644 --- a/py/routes/handlers/download_routing_handlers.py +++ b/py/routes/handlers/download_routing_handlers.py @@ -8,7 +8,10 @@ import logging from aiohttp import web from ...services.download_routing import ( + MODEL_TYPE_TO_DEFAULT_ROOT_SETTING, + MODEL_TYPE_TO_ROOT_RULE_KEY, is_diffusion_model_download, + resolve_download_root, resolve_other_download_sub_type, ) from ...utils.constants import VALID_OTHER_CIVITAI_TYPES @@ -103,18 +106,35 @@ class DownloadRoutingHandler: from ...services.settings_manager import get_settings_manager + settings = get_settings_manager() is_diffusion = is_diffusion_model_download( model_type, file_types=(str(t) for t in file_types), base_model=base_model, - unknown_base_model_default=get_settings_manager().get( + unknown_base_model_default=settings.get( "unknown_base_model_routing", "diffusion_model" ), ) + root_kind = "unet" if is_diffusion else model_type + + # Apply download_root_rules against the final root kind so the + # location-step preview matches where the download will land. + resolved_root = "" + rule_key = MODEL_TYPE_TO_ROOT_RULE_KEY.get(root_kind.lower()) + default_root_setting = MODEL_TYPE_TO_DEFAULT_ROOT_SETTING.get(root_kind.lower()) + if rule_key and default_root_setting: + default_root = settings.get(default_root_setting) or "" + if isinstance(default_root, str) and default_root: + resolved_root = resolve_download_root( + settings.get_download_root_rules().get(rule_key), + base_model, + default_root, + ) return web.json_response( { "success": True, "is_diffusion_model": is_diffusion, - "root_kind": "unet" if is_diffusion else model_type, + "root_kind": root_kind, + "resolved_root": resolved_root, } ) diff --git a/py/services/download_manager.py b/py/services/download_manager.py index dc3d1a5a..d06d64af 100644 --- a/py/services/download_manager.py +++ b/py/services/download_manager.py @@ -47,7 +47,11 @@ from ..utils.exif_utils import ExifUtils from ..utils.metadata_manager import MetadataManager from ..utils.sidecar_paths import get_metadata_path, get_preview_dir from .service_registry import ServiceRegistry -from .download_routing import is_diffusion_model_download, resolve_other_download_sub_type +from .download_routing import ( + is_diffusion_model_download, + resolve_download_root, + resolve_other_download_sub_type, +) from .settings_manager import get_settings_manager from .metadata_service import get_default_metadata_provider, get_metadata_provider from .downloader import get_downloader, DownloadProgress, DownloadStreamControl @@ -1494,6 +1498,23 @@ class DownloadManager: self._restored_persisted_downloads = True + @staticmethod + def _resolve_rule_routed_root( + rule_key: str, + base_model: str, + default_root: str, + ) -> str: + """Apply download_root_rules for the final model type's folder key. + + ``rule_key`` is a folder_paths key ("loras"/"checkpoints"/"unet"/ + "embeddings") — a checkpoint download routed to the unet roots + consults the "unet" rules. + """ + if not isinstance(base_model, str) or not base_model: + return default_root + rules = get_settings_manager().get_download_root_rules().get(rule_key) + return resolve_download_root(rules, base_model, default_root) + async def _resolve_download_target_path( self, save_dir: str, @@ -1904,15 +1925,19 @@ class DownloadManager: if is_diffusion_model: default_path = settings_manager.get("default_unet_root") error_msg = "Default unet root path not set in settings" + rule_key = "unet" else: default_path = settings_manager.get("default_checkpoint_root") error_msg = "Default checkpoint root path not set in settings" + rule_key = "checkpoints" if not default_path: return { "success": False, "error": error_msg, } - save_dir = default_path + save_dir = self._resolve_rule_routed_root( + rule_key, base_model_value, default_path + ) elif model_type == "lora": default_path = settings_manager.get("default_lora_root") if not default_path: @@ -1920,7 +1945,9 @@ class DownloadManager: "success": False, "error": "Default lora root path not set in settings", } - save_dir = default_path + save_dir = self._resolve_rule_routed_root( + "loras", base_model_value, default_path + ) elif model_type == "embedding": default_path = settings_manager.get("default_embedding_root") if not default_path: @@ -1928,7 +1955,9 @@ class DownloadManager: "success": False, "error": "Default embedding root path not set in settings", } - save_dir = default_path + save_dir = self._resolve_rule_routed_root( + "embeddings", base_model_value, default_path + ) elif model_type == "other": other_sub_type = resolve_other_download_sub_type( model_type_from_info, diff --git a/py/services/download_routing.py b/py/services/download_routing.py index 175ca9a4..84fbac1f 100644 --- a/py/services/download_routing.py +++ b/py/services/download_routing.py @@ -10,7 +10,7 @@ two can never disagree. from __future__ import annotations import logging -from typing import Iterable, Optional +from typing import Iterable, Mapping, Optional, Sequence from ..utils.constants import ( CHECKPOINT_BASE_MODELS, @@ -30,6 +30,60 @@ DIFFUSION_FILE_TYPES = frozenset({"UNet", "Diffusion Model"}) ROUTING_DIFFUSION_MODEL = "diffusion_model" ROUTING_CHECKPOINT = "checkpoint" +# Download-time model type -> download_root_rules folder key. The rule set is +# keyed by folder_paths keys, so a checkpoint download that resolves to the +# unet roots consults the "unet" rules, not the "checkpoints" ones. +MODEL_TYPE_TO_ROOT_RULE_KEY = { + "lora": "loras", + "checkpoint": "checkpoints", + "unet": "unet", + "embedding": "embeddings", +} + +# Download-time model type -> the setting holding its default download root. +MODEL_TYPE_TO_DEFAULT_ROOT_SETTING = { + "lora": "default_lora_root", + "checkpoint": "default_checkpoint_root", + "unet": "default_unet_root", + "embedding": "default_embedding_root", +} + + +def resolve_download_root( + rules: Optional[Sequence[Mapping[str, object]]], + base_model: str, + default_root: str, +) -> str: + """Return the root from the first matching rule, else ``default_root``. + + Rules are ordered; the first rule whose ``match`` list contains + ``base_model`` wins. Only ``match_type == "base_model"`` rules are + supported (tag matching is a P1 concern). + """ + if not rules or not base_model: + return default_root + + for rule in rules: + if not isinstance(rule, Mapping): + continue + if rule.get("match_type") != "base_model": + continue + match = rule.get("match") + if not isinstance(match, Sequence) or isinstance(match, (str, bytes)): + continue + if base_model not in match: + continue + root = rule.get("root") + if isinstance(root, str) and root.strip(): + logger.info( + "Download root rule matched base model '%s', routing to %s", + base_model, + root, + ) + return root + + return default_root + def is_diffusion_model_download( model_type: str, diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 21b71a12..049a0b03 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -30,6 +30,7 @@ from ..utils.constants import ( DEFAULT_ENABLED_OTHER_SUB_TYPES, DEFAULT_HASH_CHUNK_SIZE_MB, DEFAULT_PRIORITY_TAG_CONFIG, + DOWNLOAD_ROOT_RULE_FOLDER_KEYS, OTHER_SUB_TYPE_FOLDER_KEYS, SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS, VALID_OTHER_SUB_TYPES, @@ -140,6 +141,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "metadata_refresh_skip_paths": [], "skip_previously_downloaded_model_versions": False, "download_skip_base_models": [], + # Ordered per-type rules routing downloads to a non-default root by base + # model; the first matching rule wins, unmatched downloads use default_*_root. + "download_root_rules": {key: [] for key in DOWNLOAD_ROOT_RULE_FOLDER_KEYS}, # Routing target for checkpoint downloads whose baseModel is neither a # known diffusion model nor a known full checkpoint (CHECKPOINT_BASE_MODELS). "unknown_base_model_routing": "diffusion_model", @@ -436,6 +440,19 @@ class SettingsManager: self.settings["download_skip_base_models"] = [] inserted_defaults = True + if "download_root_rules" in self.settings: + normalized_root_rules = self.normalize_download_root_rules( + self.settings.get("download_root_rules") + ) + if normalized_root_rules != self.settings.get("download_root_rules"): + self.settings["download_root_rules"] = normalized_root_rules + updated_existing = True + else: + self.settings["download_root_rules"] = { + key: [] for key in DOWNLOAD_ROOT_RULE_FOLDER_KEYS + } + inserted_defaults = True + if "skip_previously_downloaded_model_versions" not in self.settings: self.settings["skip_previously_downloaded_model_versions"] = False inserted_defaults = True @@ -1505,6 +1522,96 @@ class SettingsManager: self._save_settings() return base_models + def normalize_download_root_rules( + self, value: Any + ) -> Dict[str, List[Dict[str, Any]]]: + """Normalize a ``download_root_rules`` payload. + + Fault-tolerant like the other settings normalizers: unknown type keys, + malformed entries, unknown match types and out-of-whitelist base model + values are dropped silently; a rule whose root is not one of the + configured roots for its type (folder_paths + extra_folder_paths) is + dropped with a warning. + """ + normalized: Dict[str, List[Dict[str, Any]]] = { + key: [] for key in DOWNLOAD_ROOT_RULE_FOLDER_KEYS + } + if not isinstance(value, Mapping): + return normalized + + for key, raw_rules in value.items(): + if key not in DOWNLOAD_ROOT_RULE_FOLDER_KEYS: + continue + if not isinstance(raw_rules, Sequence) or isinstance( + raw_rules, (str, bytes, bytearray) + ): + continue + allowed_roots = self._get_allowed_roots(key) + for raw_rule in raw_rules: + if not isinstance(raw_rule, Mapping): + continue + match_type = raw_rule.get("match_type") + if match_type != "base_model": + logger.warning( + "Dropping download root rule for %s: unsupported match_type %r", + key, + match_type, + ) + continue + match = raw_rule.get("match") + if not isinstance(match, Sequence) or isinstance( + match, (str, bytes, bytearray) + ): + continue + matched_models: List[str] = [] + seen = set() + for candidate in match: + if not isinstance(candidate, str): + continue + token = candidate.strip() + if not token or token not in SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS: + continue + if token in seen: + continue + seen.add(token) + matched_models.append(token) + if not matched_models: + logger.warning( + "Dropping download root rule for %s: no valid base models", + key, + ) + continue + root = raw_rule.get("root") + if not isinstance(root, str) or not root.strip(): + continue + root = root.strip() + if _normalize_root_identity(root) not in allowed_roots: + logger.warning( + "Dropping download root rule for %s: root %s is not a " + "configured root for this model type", + key, + root, + ) + continue + normalized[key].append( + { + "match_type": "base_model", + "match": matched_models, + "root": root, + } + ) + + return normalized + + def get_download_root_rules(self) -> Dict[str, List[Dict[str, Any]]]: + rules = self.normalize_download_root_rules( + self.settings.get("download_root_rules") + ) + if rules != self.settings.get("download_root_rules"): + self.settings["download_root_rules"] = rules + self._save_settings() + return rules + def get_skip_previously_downloaded_model_versions(self) -> bool: value = self.settings.get("skip_previously_downloaded_model_versions", False) if isinstance(value, bool): @@ -1880,6 +1987,8 @@ class SettingsManager: value = self.normalize_metadata_refresh_skip_paths(value) elif key == "download_skip_base_models": value = self.normalize_download_skip_base_models(value) + elif key == "download_root_rules": + value = self.normalize_download_root_rules(value) elif key == "mature_blur_level": value = self.normalize_mature_blur_level(value) elif key == "default_other_roots": diff --git a/py/utils/constants.py b/py/utils/constants.py index 9a9341ca..1cb0f4c7 100644 --- a/py/utils/constants.py +++ b/py/utils/constants.py @@ -516,3 +516,8 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset( "Wan Video 2.7", ] ) + +# Model-type keys that download_root_rules understands. These mirror the +# folder_paths keys ("loras"/"checkpoints"/"unet"/"embeddings") so a rule's +# root can be validated against the roots configured for that type. +DOWNLOAD_ROOT_RULE_FOLDER_KEYS = ("loras", "checkpoints", "unet", "embeddings") diff --git a/static/css/components/modal/settings-modal.css b/static/css/components/modal/settings-modal.css index 5b979d43..15b27f11 100644 --- a/static/css/components/modal/settings-modal.css +++ b/static/css/components/modal/settings-modal.css @@ -620,6 +620,76 @@ opacity: 0.75; } +.download-root-rule-group { + margin-bottom: var(--space-3); +} + +.download-root-rule-group:last-child { + margin-bottom: 0; +} + +.download-root-rule-group-header { + font-weight: 600; + font-size: 0.95em; + margin-bottom: var(--space-1); +} + +.download-root-rule-toggle { + width: 100%; + min-width: 0; +} + +.download-root-rule-summary { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.download-root-rule-root { + width: 100%; + min-width: 0; +} + +.download-root-rule-actions { + display: flex; + gap: 4px; + align-items: center; +} + +.rule-move-btn { + width: 32px; + height: 32px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background: transparent; + color: var(--text-color); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition-base); +} + +.rule-move-btn:hover:not(:disabled) { + border-color: var(--lora-accent); + color: var(--lora-accent); +} + +.rule-move-btn:disabled { + opacity: 0.35; + cursor: default; +} + +.download-root-rules-hint { + font-size: 0.9em; + opacity: 0.6; + padding: 4px 0; +} + +.download-root-rule-panel { + margin-top: var(--space-1); +} + .priority-tags-input:focus { border-color: var(--lora-accent); outline: none; diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index 08c0b31d..15c710c6 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -37,6 +37,9 @@ export class DownloadManager { // Manual checkpoint/diffusion root override on the location step: // null follows the auto classification, true/false is user-picked. this._routingOverride = null; + // Root resolved by download_root_rules for the current version + // (null = no rule matched; the default root is preselected). + this._resolvedRoot = null; // Multi-file selection state: selectedFile stays the first selected // file for backward compatibility with single-file flows (#1058). @@ -981,8 +984,10 @@ export class DownloadManager { async proceedToLocationContent() { try { + this._resolvedRoot = null; this._isDiffusionModel = await this._resolveIsDiffusionModel(); this._otherSubType = await this._resolveOtherSubType(); + this._resolvedRoot = await this._resolveRuleRoot(); // Each location-step entry starts from the auto classification; // the user can override it via the checkpoint/diffusion toggle. this._routingOverride = null; @@ -1073,9 +1078,14 @@ export class DownloadManager { } console.log('Default root:', defaultRoot); console.log('Available roots:', rootsData.roots); - if (defaultRoot && rootsData.roots.includes(defaultRoot)) { - console.log(`Setting default root: ${defaultRoot}`); - modelRoot.value = defaultRoot; + // A download_root_rules hit preselects the rule's root so the + // location-step preview matches where the download will land. + const preferredRoot = (this._resolvedRoot && rootsData.roots.includes(this._resolvedRoot)) + ? this._resolvedRoot + : defaultRoot; + if (preferredRoot && rootsData.roots.includes(preferredRoot)) { + console.log(`Setting default root: ${preferredRoot}`); + modelRoot.value = preferredRoot; } document.getElementById('modelRootLabel').textContent = @@ -1172,6 +1182,10 @@ export class DownloadManager { throw new Error(`routing endpoint returned ${response.status}`); } const data = await response.json(); + if (typeof data.resolved_root === 'string' && data.resolved_root) { + // Root a download_root_rules rule would send this download to. + this._resolvedRoot = data.resolved_root; + } if (typeof data.is_diffusion_model === 'boolean') { return data.is_diffusion_model; } @@ -1182,6 +1196,50 @@ export class DownloadManager { return localFileTypeCheck; } + /** + * Resolve the root a download_root_rules rule routes this download to, + * so the location step preselects the same root the backend will use. + * Returns null when no rule matches or the endpoint is unavailable. + */ + async _resolveRuleRoot() { + // The checkpoints page already fetched the routing decision + // (including the rule-resolved root) in _resolveIsDiffusionModel. + if (this.apiClient.modelType === 'checkpoints') { + return this._resolvedRoot ?? null; + } + + // Other-page downloads route by sub_type, not by base model rules; + // without version metadata (e.g. Hugging Face downloads) there is + // no base model to match a rule against. + if (this.apiClient.modelType === 'other' + || (!this.selectedFile && !this.currentVersion)) { + return null; + } + + try { + const fileTypes = this.selectedFile + ? [this.selectedFile.type] + : (this.currentVersion?.files || []).map(f => f.type); + const response = await fetch(DOWNLOAD_ENDPOINTS.routing, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model_type: this.apiClient.modelType.replace(/s$/, ''), + base_model: this.currentVersion?.baseModel || '', + file_types: fileTypes, + }), + }); + if (!response.ok) { + throw new Error(`routing endpoint returned ${response.status}`); + } + const data = await response.json(); + return (typeof data.resolved_root === 'string' && data.resolved_root) || null; + } catch (error) { + console.warn('[download] root rule resolution unavailable:', error); + return null; + } + } + /** * Resolve which other-page sub_type (vae/upscaler/text_encoder/ * clip_vision/controlnet) this download routes to. The backend owns the diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index a0c9baa1..c58a1a41 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -46,6 +46,19 @@ const FILENAME_TEMPLATE_MODEL_TYPES = { embedding: 'embeddings', }; +// Model-type keys understood by the download_root_rules setting; mirrors +// DOWNLOAD_ROOT_RULE_FOLDER_KEYS in py/utils/constants.py. +const DOWNLOAD_ROOT_RULE_TYPES = ['loras', 'checkpoints', 'unet', 'embeddings']; + +// Root-list endpoints per rule type; same data source as the default root +// dropdowns in the Library settings section. +const DOWNLOAD_ROOT_RULE_ROOT_ENDPOINTS = { + loras: '/api/lm/loras/roots', + checkpoints: '/api/lm/checkpoints/checkpoints_roots', + unet: '/api/lm/checkpoints/unet_roots', + embeddings: '/api/lm/embeddings/roots', +}; + export class SettingsManager { constructor() { this.initialized = false; @@ -56,6 +69,10 @@ export class SettingsManager { this.registeredStartupBannerIds = new Set(); this.modelPathsSectionInitialized = false; this.modelPathsDirty = false; + this.downloadRootRuleRoots = null; + this.downloadRootRuleDrafts = {}; + this.downloadRootRuleOpenPanels = new Set(); + this.downloadRootRuleSearches = {}; // Add initialization to sync with modal state this.currentPage = document.body.dataset.page || 'loras'; @@ -203,6 +220,10 @@ export class SettingsManager { backendSettings?.download_skip_base_models ?? defaults.download_skip_base_models ); + merged.download_root_rules = this.normalizeDownloadRootRules( + backendSettings?.download_root_rules ?? defaults.download_root_rules + ); + merged.mature_blur_level = this.normalizeMatureBlurLevel( backendSettings?.mature_blur_level ?? defaults.mature_blur_level ); @@ -254,6 +275,41 @@ export class SettingsManager { return this.normalizePatternList(value).filter(model => allowed.has(model)); } + normalizeDownloadRootRules(value) { + const normalized = { loras: [], checkpoints: [], unet: [], embeddings: [] }; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return normalized; + } + DOWNLOAD_ROOT_RULE_TYPES.forEach((typeKey) => { + const rawRules = value[typeKey]; + if (!Array.isArray(rawRules)) { + return; + } + rawRules.forEach((rule) => { + if (!rule || typeof rule !== 'object') { + return; + } + const match = Array.isArray(rule.match) + ? [...new Set(rule.match + .filter((model) => typeof model === 'string' && model.trim()) + .map((model) => model.trim()))] + : []; + const root = typeof rule.root === 'string' ? rule.root.trim() : ''; + if (!match.length || !root) { + return; + } + normalized[typeKey].push({ match_type: 'base_model', match, root }); + }); + }); + return normalized; + } + + getDownloadRootRules() { + const normalized = this.normalizeDownloadRootRules(state.global.settings.download_root_rules); + state.global.settings.download_root_rules = normalized; + return normalized; + } + registerStartupMessages(messages = []) { if (!Array.isArray(messages) || messages.length === 0) { return; @@ -1248,6 +1304,13 @@ export class SettingsManager { // Load base model path mappings this.loadBaseModelMappings(); + // Load download root rules (async: fetches per-type root lists) + await this.loadDownloadRootRules(); + const downloadRootRulesError = document.getElementById('downloadRootRulesError'); + if (downloadRootRulesError) { + downloadRootRulesError.textContent = ''; + } + // Load library options await this.loadLibraries(); @@ -4369,6 +4432,402 @@ export class SettingsManager { } } + // ── Download Root Rules ───────────────────────────────────────────── + // Ordered per-type rules routing downloads to a non-default root by base + // model. Edits mutate state.global.settings.download_root_rules and are + // persisted immediately via saveSetting; the backend normalizes on save. + + async loadDownloadRootRules() { + const container = document.getElementById('downloadRootRulesContainer'); + if (!container) { + return; + } + await this.fetchDownloadRootRuleRoots(); + this.renderDownloadRootRules(); + } + + async fetchDownloadRootRuleRoots() { + const roots = { loras: [], checkpoints: [], unet: [], embeddings: [] }; + await Promise.all( + Object.entries(DOWNLOAD_ROOT_RULE_ROOT_ENDPOINTS).map(async ([typeKey, url]) => { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + if (Array.isArray(data.roots)) { + roots[typeKey] = data.roots; + } + } catch (error) { + console.warn(`Failed to load ${typeKey} roots for download root rules:`, error); + } + }) + ); + this.downloadRootRuleRoots = roots; + } + + renderDownloadRootRules() { + const container = document.getElementById('downloadRootRulesContainer'); + if (!container) { + return; + } + container.innerHTML = ''; + DOWNLOAD_ROOT_RULE_TYPES.forEach((typeKey) => { + container.appendChild(this.buildDownloadRootRuleGroup(typeKey)); + }); + } + + buildDownloadRootRuleGroup(typeKey) { + const group = document.createElement('div'); + group.className = 'download-root-rule-group'; + + const header = document.createElement('div'); + header.className = 'download-root-rule-group-header'; + header.textContent = translate(`settings.downloadRootRules.types.${typeKey}`, {}, typeKey); + group.appendChild(header); + + const roots = this.downloadRootRuleRoots?.[typeKey] || []; + if (!roots.length) { + const hint = document.createElement('div'); + hint.className = 'download-root-rules-hint'; + hint.textContent = translate('settings.downloadRootRules.noRoots', {}, 'No roots configured for this model type.'); + group.appendChild(hint); + return group; + } + + const rules = this.getDownloadRootRules()[typeKey]; + rules.forEach((rule, index) => { + group.appendChild(this.buildDownloadRootRuleRow(typeKey, rule, index, false, roots)); + }); + + const draft = this.downloadRootRuleDrafts[typeKey]; + if (draft) { + group.appendChild(this.buildDownloadRootRuleRow(typeKey, draft, rules.length, true, roots)); + } + + const addBtn = document.createElement('button'); + addBtn.type = 'button'; + addBtn.className = 'add-mapping-btn'; + addBtn.disabled = !!draft; + addBtn.innerHTML = `${translate('settings.downloadRootRules.addRule', {}, 'Add rule')}`; + addBtn.addEventListener('click', () => this.addDownloadRootRule(typeKey)); + group.appendChild(addBtn); + + return group; + } + + buildDownloadRootRuleRow(typeKey, rule, index, isDraft, roots) { + const row = document.createElement('div'); + row.className = 'mapping-row download-root-rule-row'; + row.dataset.ruleType = typeKey; + row.dataset.ruleIndex = String(index); + + const panelKey = `${typeKey}:${index}`; + const isOpen = this.downloadRootRuleOpenPanels.has(panelKey); + + const controls = document.createElement('div'); + controls.className = 'mapping-controls download-root-rule-controls'; + + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'secondary-btn base-model-skip-toggle download-root-rule-toggle'; + toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); + const summary = document.createElement('span'); + summary.className = 'download-root-rule-summary'; + this.renderDownloadRootRuleSummary(summary, rule.match); + const toggleLabel = document.createElement('span'); + toggleLabel.className = 'base-model-skip-toggle-label'; + toggleLabel.textContent = isOpen + ? translate('settings.downloadSkipBaseModels.actions.collapse', {}, 'Collapse') + : translate('settings.downloadSkipBaseModels.actions.edit', {}, 'Edit'); + toggle.appendChild(summary); + toggle.appendChild(toggleLabel); + toggle.addEventListener('click', () => this.toggleDownloadRootRulePanel(typeKey, index)); + controls.appendChild(toggle); + + const rootSelect = document.createElement('select'); + rootSelect.className = 'base-model-select download-root-rule-root'; + roots.forEach((root) => { + const option = document.createElement('option'); + option.value = root; + option.textContent = root; + rootSelect.appendChild(option); + }); + if (roots.includes(rule.root)) { + rootSelect.value = rule.root; + } + rootSelect.addEventListener('change', () => { + this.updateDownloadRootRuleRoot(typeKey, index, rootSelect.value, isDraft); + }); + controls.appendChild(rootSelect); + + const actions = document.createElement('div'); + actions.className = 'download-root-rule-actions'; + if (!isDraft) { + const ruleCount = this.getDownloadRootRules()[typeKey].length; + const upBtn = document.createElement('button'); + upBtn.type = 'button'; + upBtn.className = 'rule-move-btn'; + upBtn.disabled = index === 0; + upBtn.title = translate('settings.downloadRootRules.moveUp', {}, 'Move up'); + upBtn.innerHTML = ''; + upBtn.addEventListener('click', () => this.moveDownloadRootRule(typeKey, index, -1)); + const downBtn = document.createElement('button'); + downBtn.type = 'button'; + downBtn.className = 'rule-move-btn'; + downBtn.disabled = index === ruleCount - 1; + downBtn.title = translate('settings.downloadRootRules.moveDown', {}, 'Move down'); + downBtn.innerHTML = ''; + downBtn.addEventListener('click', () => this.moveDownloadRootRule(typeKey, index, 1)); + actions.appendChild(upBtn); + actions.appendChild(downBtn); + } + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'remove-mapping-btn'; + removeBtn.title = translate('settings.downloadRootRules.removeRule', {}, 'Remove rule'); + removeBtn.innerHTML = ''; + removeBtn.addEventListener('click', () => this.removeDownloadRootRule(typeKey, index, isDraft)); + actions.appendChild(removeBtn); + controls.appendChild(actions); + row.appendChild(controls); + + const panel = document.createElement('div'); + panel.className = 'base-model-skip-panel download-root-rule-panel'; + panel.hidden = !isOpen; + + const toolbar = document.createElement('div'); + toolbar.className = 'base-model-skip-toolbar'; + const search = document.createElement('input'); + search.type = 'text'; + search.className = 'base-model-skip-search'; + search.placeholder = translate('settings.downloadSkipBaseModels.searchPlaceholder', {}, 'Filter base models...'); + search.value = this.downloadRootRuleSearches[panelKey] || ''; + toolbar.appendChild(search); + panel.appendChild(toolbar); + + const list = document.createElement('div'); + list.className = 'base-model-skip-list'; + panel.appendChild(list); + search.addEventListener('input', () => { + this.downloadRootRuleSearches[panelKey] = search.value; + this.renderDownloadRootRuleBaseModelList(list, typeKey, index, isDraft, summary); + }); + this.renderDownloadRootRuleBaseModelList(list, typeKey, index, isDraft, summary); + + row.appendChild(panel); + return row; + } + + renderDownloadRootRuleSummary(summaryEl, match) { + if (!match.length) { + summaryEl.textContent = translate('settings.downloadSkipBaseModels.summary.none', {}, 'None selected'); + return; + } + if (match.length <= 2) { + summaryEl.textContent = match.join(', '); + return; + } + summaryEl.textContent = translate( + 'settings.downloadSkipBaseModels.summary.count', + { count: match.length }, + `${match.length} selected` + ); + } + + renderDownloadRootRuleBaseModelList(listEl, typeKey, index, isDraft, summaryEl) { + const rule = isDraft + ? this.downloadRootRuleDrafts[typeKey] + : this.getDownloadRootRules()[typeKey][index]; + if (!rule) { + return; + } + const selected = new Set(rule.match); + const query = (this.downloadRootRuleSearches[`${typeKey}:${index}`] || '').trim().toLowerCase(); + const options = this.getAvailableDownloadSkipBaseModels(); + const filtered = query + ? options.filter((baseModel) => baseModel.toLowerCase().includes(query)) + : options; + + listEl.innerHTML = ''; + filtered.forEach((baseModel) => { + const label = document.createElement('label'); + label.className = 'base-model-skip-option'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.value = baseModel; + input.checked = selected.has(baseModel); + input.addEventListener('change', () => { + this.toggleDownloadRootRuleBaseModel(typeKey, index, baseModel, input.checked, isDraft, summaryEl); + }); + const text = document.createElement('span'); + text.textContent = baseModel; + label.appendChild(input); + label.appendChild(text); + listEl.appendChild(label); + }); + } + + toggleDownloadRootRulePanel(typeKey, index) { + const panelKey = `${typeKey}:${index}`; + const opening = !this.downloadRootRuleOpenPanels.has(panelKey); + if (opening) { + this.downloadRootRuleOpenPanels.add(panelKey); + } else { + this.downloadRootRuleOpenPanels.delete(panelKey); + } + this.renderDownloadRootRules(); + if (opening) { + document + .querySelector(`#downloadRootRulesContainer .download-root-rule-row[data-rule-type="${typeKey}"][data-rule-index="${index}"] .base-model-skip-search`) + ?.focus(); + } + } + + addDownloadRootRule(typeKey) { + if (this.downloadRootRuleDrafts[typeKey]) { + return; + } + const roots = this.downloadRootRuleRoots?.[typeKey] || []; + this.downloadRootRuleDrafts[typeKey] = { + match_type: 'base_model', + match: [], + root: roots[0] || '', + }; + const index = this.getDownloadRootRules()[typeKey].length; + this.downloadRootRuleOpenPanels.add(`${typeKey}:${index}`); + this.renderDownloadRootRules(); + } + + removeDownloadRootRule(typeKey, index, isDraft) { + if (isDraft) { + delete this.downloadRootRuleDrafts[typeKey]; + } else { + const rules = this.getDownloadRootRules(); + rules[typeKey].splice(index, 1); + } + // Row indices shift after removal; open panels track positions, so + // close them all rather than reopening the wrong rule. + this.downloadRootRuleOpenPanels.clear(); + this.downloadRootRuleSearches = {}; + this.renderDownloadRootRules(); + if (!isDraft) { + this.persistDownloadRootRules(); + } + } + + moveDownloadRootRule(typeKey, index, direction) { + const rules = this.getDownloadRootRules(); + const list = rules[typeKey]; + const target = index + direction; + if (target < 0 || target >= list.length) { + return; + } + [list[index], list[target]] = [list[target], list[index]]; + this.downloadRootRuleOpenPanels.clear(); + this.downloadRootRuleSearches = {}; + this.renderDownloadRootRules(); + this.persistDownloadRootRules(); + } + + updateDownloadRootRuleRoot(typeKey, index, root, isDraft) { + if (isDraft) { + const draft = this.downloadRootRuleDrafts[typeKey]; + if (draft) { + draft.root = root; + } + return; + } + const rules = this.getDownloadRootRules(); + const rule = rules[typeKey][index]; + if (!rule || rule.root === root) { + return; + } + rule.root = root; + this.persistDownloadRootRules(); + } + + toggleDownloadRootRuleBaseModel(typeKey, index, baseModel, checked, isDraft, summaryEl) { + if (isDraft) { + const draft = this.downloadRootRuleDrafts[typeKey]; + if (!draft) { + return; + } + draft.match = checked + ? [...draft.match, baseModel] + : draft.match.filter((model) => model !== baseModel); + if (!draft.match.length) { + if (summaryEl) { + this.renderDownloadRootRuleSummary(summaryEl, draft.match); + } + return; + } + // First base model on a draft promotes it to a persisted rule. + const rules = this.getDownloadRootRules(); + rules[typeKey].push({ + match_type: 'base_model', + match: [...draft.match], + root: draft.root, + }); + delete this.downloadRootRuleDrafts[typeKey]; + this.downloadRootRuleOpenPanels.delete(`${typeKey}:${index}`); + this.downloadRootRuleOpenPanels.add(`${typeKey}:${rules[typeKey].length - 1}`); + this.renderDownloadRootRules(); + this.persistDownloadRootRules(); + return; + } + + const rules = this.getDownloadRootRules(); + const rule = rules[typeKey][index]; + if (!rule) { + return; + } + rule.match = checked + ? [...rule.match, baseModel] + : rule.match.filter((model) => model !== baseModel); + if (!rule.match.length) { + // A rule without base models can never match; drop it (the + // backend normalization would discard it anyway). + rules[typeKey].splice(index, 1); + this.downloadRootRuleOpenPanels.clear(); + this.downloadRootRuleSearches = {}; + this.renderDownloadRootRules(); + this.persistDownloadRootRules(); + return; + } + if (summaryEl) { + this.renderDownloadRootRuleSummary(summaryEl, rule.match); + } + this.persistDownloadRootRules(); + } + + async persistDownloadRootRules() { + const payload = this.getDownloadRootRules(); + const errorElement = document.getElementById('downloadRootRulesError'); + try { + if (errorElement) { + errorElement.textContent = ''; + } + await this.saveSetting('download_root_rules', payload); + showToast( + 'toast.settings.settingsUpdated', + { setting: translate('settings.downloadRootRules.title', {}, 'Download Root Rules') }, + 'success' + ); + } catch (error) { + console.error('Failed to save download root rules:', error); + if (errorElement) { + errorElement.textContent = translate( + 'settings.downloadRootRules.validation.saveFailed', + { message: error.message }, + `Unable to save download root rules: ${error.message}` + ); + } + showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error'); + } + } + async saveMetadataRefreshSkipPaths() { const input = document.getElementById('metadataRefreshSkipPaths'); const errorElement = document.getElementById('metadataRefreshSkipPathsError'); diff --git a/static/js/state/index.js b/static/js/state/index.js index d7e4f593..a9c8cede 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -64,6 +64,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({ metadata_refresh_skip_paths: [], skip_previously_downloaded_model_versions: false, download_skip_base_models: [], + download_root_rules: { loras: [], checkpoints: [], unet: [], embeddings: [] }, unknown_base_model_routing: 'diffusion_model', backup_auto_enabled: true, backup_retention_count: 5, @@ -87,6 +88,7 @@ export function createDefaultSettings() { download_filename_templates: { ...DEFAULT_FILENAME_TEMPLATES }, priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, default_other_roots: {}, + download_root_rules: { loras: [], checkpoints: [], unet: [], embeddings: [] }, enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'], // Standalone-only fields populated by GET /api/lm/settings; in plugin // mode the backend omits folder_paths/folder_path_schema and these diff --git a/templates/components/modals/settings/organization.html b/templates/components/modals/settings/organization.html index 41d74bc0..7edeb8d1 100644 --- a/templates/components/modals/settings/organization.html +++ b/templates/components/modals/settings/organization.html @@ -114,6 +114,22 @@ +