mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-13 04:42:13 -03:00
feat(download): add download root rules routing downloads by base model
Add a download_root_rules setting: ordered per-type rules
({match_type: base_model, match: [...], root: path}) that replace the
default download root when 'use default paths' is on. The first matching
rule wins; unmatched downloads fall back to default_*_root. Rules are
consulted under the final download type, so a checkpoint routed to the
unet roots uses the unet rule set.
- settings_manager: normalize/get/set with root validation against the
configured roots (folder_paths + extra_folder_paths); invalid rules
are dropped with a warning
- download_manager: apply the resolved root in the use_default_paths
branches for lora/checkpoint(unet)/embedding downloads
- download routing handler: expose resolved_root so the download
confirmation modal preselects the rule-routed root
- settings UI: per-type rule editor in the Organization section with
base-model multi-select panels, root dropdowns, reorder and remove
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
|
||||
|
||||
@@ -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はそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
|
||||
|
||||
@@ -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는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
|
||||
|
||||
@@ -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 будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
|
||||
|
||||
@@ -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 将跳过下载该模型版本。适用于所有下载流程。"
|
||||
|
||||
@@ -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 將跳過下載該模型版本。適用於所有下載流程。"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = `<i class="fas fa-plus"></i><span>${translate('settings.downloadRootRules.addRule', {}, 'Add rule')}</span>`;
|
||||
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 = '<i class="fas fa-arrow-up"></i>';
|
||||
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 = '<i class="fas fa-arrow-down"></i>';
|
||||
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 = '<i class="fas fa-times"></i>';
|
||||
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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -114,6 +114,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label>
|
||||
{{ t('settings.downloadRootRules.title') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadRootRules.help') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-help">{{ t('settings.downloadRootRules.description') }}</div>
|
||||
<div class="mappings-container">
|
||||
<div id="downloadRootRulesContainer"></div>
|
||||
</div>
|
||||
<div class="settings-input-error-message" id="downloadRootRulesError"></div>
|
||||
</div>
|
||||
|
||||
{{ sm.setting_toggle('skipPreviouslyDownloadedModelVersions', 'skip_previously_downloaded_model_versions', 'settings.skipPreviouslyDownloadedModelVersions.label', 'settings.skipPreviouslyDownloadedModelVersions.help') }}
|
||||
|
||||
<div class="setting-item">
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
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_root_rules: { loras: [], checkpoints: [], unet: [], embeddings: [] },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
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: {},
|
||||
FILENAME_TEMPLATE_PLACEHOLDERS: [],
|
||||
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
|
||||
DEFAULT_PRIORITY_TAG_CONFIG: {
|
||||
lora: 'character, style',
|
||||
checkpoint: 'base, guide',
|
||||
embedding: 'hint',
|
||||
},
|
||||
getMappableBaseModelsDynamic: () => ['Flux.1 D', 'Pony', 'SDXL 1.0', 'Other'],
|
||||
}));
|
||||
|
||||
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(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
|
||||
import { state } from '../../../static/js/state/index.js';
|
||||
|
||||
const ROOTS = {
|
||||
loras: ['/models/loras-main', '/models/lora-archive'],
|
||||
checkpoints: ['/models/checkpoints'],
|
||||
unet: ['/models/unet-main', '/models/unet-archive'],
|
||||
embeddings: [],
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||
manager.downloadRootRuleRoots = ROOTS;
|
||||
|
||||
return manager;
|
||||
};
|
||||
|
||||
const appendDownloadRootRulesUi = () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="downloadRootRulesContainer"></div>
|
||||
<div id="downloadRootRulesError"></div>
|
||||
`;
|
||||
};
|
||||
|
||||
const groupRows = (typeKey) =>
|
||||
document.querySelectorAll(
|
||||
`#downloadRootRulesContainer .download-root-rule-row[data-rule-type="${typeKey}"]`
|
||||
);
|
||||
|
||||
describe('SettingsManager download root rules', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.clearAllMocks();
|
||||
state.global.settings = {
|
||||
download_root_rules: { loras: [], checkpoints: [], unet: [], embeddings: [] },
|
||||
};
|
||||
});
|
||||
|
||||
it('normalizes malformed rules out of the setting', () => {
|
||||
const manager = createManager();
|
||||
|
||||
const normalized = manager.normalizeDownloadRootRules({
|
||||
loras: [
|
||||
{ match_type: 'base_model', match: ['Flux.1 D'], root: '/models/lora-archive' },
|
||||
{ match_type: 'base_model', match: [], root: '/models/lora-archive' },
|
||||
{ match_type: 'base_model', match: ['Pony'], root: '' },
|
||||
null,
|
||||
'not-a-rule',
|
||||
],
|
||||
embeddings: 'oops',
|
||||
unknownType: [{ match_type: 'base_model', match: ['Pony'], root: '/x' }],
|
||||
});
|
||||
|
||||
expect(normalized).toEqual({
|
||||
loras: [{ match_type: 'base_model', match: ['Flux.1 D'], root: '/models/lora-archive' }],
|
||||
checkpoints: [],
|
||||
unet: [],
|
||||
embeddings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('renders one group per type and a hint when no roots are configured', () => {
|
||||
appendDownloadRootRulesUi();
|
||||
state.global.settings.download_root_rules = {
|
||||
loras: [{ match_type: 'base_model', match: ['Flux.1 D', 'Pony'], root: '/models/lora-archive' }],
|
||||
checkpoints: [],
|
||||
unet: [],
|
||||
embeddings: [],
|
||||
};
|
||||
const manager = createManager();
|
||||
|
||||
manager.renderDownloadRootRules();
|
||||
|
||||
expect(document.querySelectorAll('.download-root-rule-group')).toHaveLength(4);
|
||||
expect(groupRows('loras')).toHaveLength(1);
|
||||
expect(groupRows('loras')[0].querySelector('.download-root-rule-summary').textContent)
|
||||
.toBe('Flux.1 D, Pony');
|
||||
expect(groupRows('loras')[0].querySelector('select.download-root-rule-root').value)
|
||||
.toBe('/models/lora-archive');
|
||||
// embeddings has no roots configured
|
||||
const embeddingsGroup = document.querySelectorAll('.download-root-rule-group')[3];
|
||||
expect(embeddingsGroup.querySelector('.download-root-rules-hint')).not.toBeNull();
|
||||
expect(embeddingsGroup.querySelector('.add-mapping-btn')).toBeNull();
|
||||
});
|
||||
|
||||
it('add rule creates a draft and the first base model selection promotes and persists it', async () => {
|
||||
appendDownloadRootRulesUi();
|
||||
const manager = createManager();
|
||||
manager.renderDownloadRootRules();
|
||||
|
||||
manager.addDownloadRootRule('loras');
|
||||
|
||||
expect(manager.downloadRootRuleDrafts.loras).toEqual({
|
||||
match_type: 'base_model',
|
||||
match: [],
|
||||
root: '/models/loras-main',
|
||||
});
|
||||
expect(groupRows('loras')).toHaveLength(1);
|
||||
// A second add while a draft exists is a no-op
|
||||
manager.addDownloadRootRule('loras');
|
||||
expect(groupRows('loras')).toHaveLength(1);
|
||||
|
||||
// The draft row panel is open; tick a base model checkbox
|
||||
const checkbox = groupRows('loras')[0].querySelector(
|
||||
'.download-root-rule-panel input[value="Flux.1 D"]'
|
||||
);
|
||||
checkbox.checked = true;
|
||||
checkbox.dispatchEvent(new Event('change'));
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.saveSetting).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(manager.downloadRootRuleDrafts.loras).toBeUndefined();
|
||||
expect(manager.saveSetting).toHaveBeenCalledWith('download_root_rules', {
|
||||
loras: [{ match_type: 'base_model', match: ['Flux.1 D'], root: '/models/loras-main' }],
|
||||
checkpoints: [],
|
||||
unet: [],
|
||||
embeddings: [],
|
||||
});
|
||||
expect(state.global.settings.download_root_rules.loras).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removing a rule persists the updated list', async () => {
|
||||
appendDownloadRootRulesUi();
|
||||
state.global.settings.download_root_rules = {
|
||||
loras: [
|
||||
{ match_type: 'base_model', match: ['Flux.1 D'], root: '/models/lora-archive' },
|
||||
{ match_type: 'base_model', match: ['Pony'], root: '/models/loras-main' },
|
||||
],
|
||||
checkpoints: [],
|
||||
unet: [],
|
||||
embeddings: [],
|
||||
};
|
||||
const manager = createManager();
|
||||
manager.renderDownloadRootRules();
|
||||
|
||||
manager.removeDownloadRootRule('loras', 0, false);
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.saveSetting).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(manager.saveSetting).toHaveBeenCalledWith('download_root_rules', {
|
||||
loras: [{ match_type: 'base_model', match: ['Pony'], root: '/models/loras-main' }],
|
||||
checkpoints: [],
|
||||
unet: [],
|
||||
embeddings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('move down swaps rule order and persists', async () => {
|
||||
appendDownloadRootRulesUi();
|
||||
state.global.settings.download_root_rules = {
|
||||
loras: [
|
||||
{ match_type: 'base_model', match: ['Flux.1 D'], root: '/models/lora-archive' },
|
||||
{ match_type: 'base_model', match: ['Pony'], root: '/models/loras-main' },
|
||||
],
|
||||
checkpoints: [],
|
||||
unet: [],
|
||||
embeddings: [],
|
||||
};
|
||||
const manager = createManager();
|
||||
manager.renderDownloadRootRules();
|
||||
|
||||
manager.moveDownloadRootRule('loras', 0, 1);
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.saveSetting).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(manager.saveSetting).toHaveBeenCalledWith('download_root_rules', {
|
||||
loras: [
|
||||
{ match_type: 'base_model', match: ['Pony'], root: '/models/loras-main' },
|
||||
{ match_type: 'base_model', match: ['Flux.1 D'], root: '/models/lora-archive' },
|
||||
],
|
||||
checkpoints: [],
|
||||
unet: [],
|
||||
embeddings: [],
|
||||
});
|
||||
|
||||
// Out-of-range moves are no-ops
|
||||
manager.saveSetting.mockClear();
|
||||
manager.moveDownloadRootRule('loras', 1, 1);
|
||||
expect(manager.saveSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unchecking the last base model of a persisted rule removes the rule', async () => {
|
||||
appendDownloadRootRulesUi();
|
||||
state.global.settings.download_root_rules = {
|
||||
loras: [{ match_type: 'base_model', match: ['Flux.1 D'], root: '/models/lora-archive' }],
|
||||
checkpoints: [],
|
||||
unet: [],
|
||||
embeddings: [],
|
||||
};
|
||||
const manager = createManager();
|
||||
manager.renderDownloadRootRules();
|
||||
|
||||
const row = groupRows('loras')[0];
|
||||
const checkbox = row.querySelector('.download-root-rule-panel input[value="Flux.1 D"]');
|
||||
expect(checkbox.checked).toBe(true);
|
||||
checkbox.checked = false;
|
||||
checkbox.dispatchEvent(new Event('change'));
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.saveSetting).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(manager.saveSetting).toHaveBeenCalledWith('download_root_rules', {
|
||||
loras: [],
|
||||
checkpoints: [],
|
||||
unet: [],
|
||||
embeddings: [],
|
||||
});
|
||||
expect(groupRows('loras')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('loadDownloadRootRules fetches roots for every type and renders', async () => {
|
||||
appendDownloadRootRulesUi();
|
||||
const fetchMock = vi.fn().mockImplementation(async (url) => ({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
if (url.includes('loras/roots')) return { roots: ROOTS.loras };
|
||||
if (url.includes('checkpoints_roots')) return { roots: ROOTS.checkpoints };
|
||||
if (url.includes('unet_roots')) return { roots: ROOTS.unet };
|
||||
if (url.includes('embeddings/roots')) return { roots: [] };
|
||||
return {};
|
||||
},
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
const manager = createManager();
|
||||
manager.downloadRootRuleRoots = null;
|
||||
|
||||
await manager.loadDownloadRootRules();
|
||||
|
||||
expect(manager.downloadRootRuleRoots).toEqual(ROOTS);
|
||||
expect(document.querySelectorAll('.download-root-rule-group')).toHaveLength(4);
|
||||
expect(document.getElementById('downloadRootRulesError').textContent).toBe('');
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,14 @@ async def test_diffusion_base_model_routes_to_unet():
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
assert response.status == 200
|
||||
assert payload == {"success": True, "is_diffusion_model": True, "root_kind": "unet"}
|
||||
assert payload == {
|
||||
"success": True,
|
||||
"is_diffusion_model": True,
|
||||
"root_kind": "unet",
|
||||
# No rule matches "Anima", so the resolved root falls back to the
|
||||
# auto-configured default unet root.
|
||||
"resolved_root": get_settings_manager().get("default_unet_root"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -245,3 +252,57 @@ async def test_other_routing_disabled_for_switched_off_sub_type():
|
||||
assert payload["disabled"] is True
|
||||
assert payload["reason"] == "other_sub_type_disabled"
|
||||
assert payload["requested_sub_type"] == "upscaler"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolved_root_falls_back_to_default_root():
|
||||
manager = get_settings_manager()
|
||||
manager.settings["default_lora_root"] = "/loras-main"
|
||||
|
||||
handler = DownloadRoutingHandler()
|
||||
response = await handler.get_download_routing(
|
||||
FakeRequest({"model_type": "lora", "base_model": "Pony", "file_types": []})
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
assert payload["resolved_root"] == "/loras-main"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolved_root_applies_download_root_rules():
|
||||
manager = get_settings_manager()
|
||||
manager.settings["folder_paths"] = {"loras": ["/loras-main", "/lora-archive"]}
|
||||
manager.settings["default_lora_root"] = "/loras-main"
|
||||
manager.settings["download_root_rules"] = {
|
||||
"loras": [
|
||||
{"match_type": "base_model", "match": ["Flux.1 D"], "root": "/lora-archive"}
|
||||
]
|
||||
}
|
||||
|
||||
handler = DownloadRoutingHandler()
|
||||
response = await handler.get_download_routing(
|
||||
FakeRequest({"model_type": "lora", "base_model": "Flux.1 D", "file_types": []})
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
assert payload["resolved_root"] == "/lora-archive"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolved_root_applies_unet_rules_after_diffusion_routing():
|
||||
manager = get_settings_manager()
|
||||
manager.settings["folder_paths"] = {"unet": ["/unet-main", "/unet-archive"]}
|
||||
manager.settings["default_unet_root"] = "/unet-main"
|
||||
manager.settings["download_root_rules"] = {
|
||||
"unet": [
|
||||
{"match_type": "base_model", "match": ["Flux.1 D"], "root": "/unet-archive"}
|
||||
]
|
||||
}
|
||||
|
||||
handler = DownloadRoutingHandler()
|
||||
response = await handler.get_download_routing(
|
||||
FakeRequest(
|
||||
{"model_type": "checkpoint", "base_model": "Flux.1 D", "file_types": ["Model"]}
|
||||
)
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
assert payload["root_kind"] == "unet"
|
||||
assert payload["resolved_root"] == "/unet-archive"
|
||||
|
||||
@@ -2362,3 +2362,182 @@ async def test_fetch_raw_file_name_edge_cases():
|
||||
# Provider exceptions degrade to None.
|
||||
provider.get_version_file_mini = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
assert await manager._fetch_raw_file_name(provider, 1, 2) is None
|
||||
|
||||
|
||||
def _capture_execute_download(monkeypatch, captured):
|
||||
async def fake_execute_download(self, **kwargs):
|
||||
captured.update(
|
||||
{
|
||||
"save_dir": kwargs["save_dir"],
|
||||
"relative_path": kwargs["relative_path"],
|
||||
"model_type": kwargs["model_type"],
|
||||
}
|
||||
)
|
||||
return {"success": True}
|
||||
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_execute_download", fake_execute_download, raising=False
|
||||
)
|
||||
|
||||
|
||||
def _lora_payload(base_model):
|
||||
return {
|
||||
"id": 1234,
|
||||
"model": {"type": "LORA", "tags": ["fantasy"]},
|
||||
"baseModel": base_model,
|
||||
"creator": {"username": "Author"},
|
||||
"files": [
|
||||
{
|
||||
"type": "Model",
|
||||
"primary": True,
|
||||
"downloadUrl": "https://example.invalid/file.safetensors",
|
||||
"name": "file.safetensors",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_root_rule_routes_lora_to_rule_root(
|
||||
monkeypatch, scanners, metadata_provider, tmp_path
|
||||
):
|
||||
"""A matching download_root_rules rule replaces the default lora root."""
|
||||
manager = DownloadManager()
|
||||
|
||||
default_root = tmp_path / "loras-main"
|
||||
archive_root = tmp_path / "lora-archive"
|
||||
settings = get_settings_manager()
|
||||
settings.settings["folder_paths"] = {
|
||||
"loras": [str(default_root), str(archive_root)]
|
||||
}
|
||||
settings.settings["default_lora_root"] = str(default_root)
|
||||
settings.settings["download_root_rules"] = {
|
||||
"loras": [
|
||||
{
|
||||
"match_type": "base_model",
|
||||
"match": ["Flux.1 D"],
|
||||
"root": str(archive_root),
|
||||
}
|
||||
]
|
||||
}
|
||||
metadata_provider.payload = _lora_payload("Flux.1 D")
|
||||
|
||||
captured = {}
|
||||
_capture_execute_download(monkeypatch, captured)
|
||||
|
||||
result = await manager.download_from_civitai(
|
||||
model_version_id=99,
|
||||
save_dir=str(tmp_path),
|
||||
use_default_paths=True,
|
||||
progress_callback=None,
|
||||
source=None,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured["model_type"] == "lora"
|
||||
assert captured["save_dir"] == str(archive_root / "Flux.1 D" / "fantasy")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_root_rule_falls_back_to_default_root(
|
||||
monkeypatch, scanners, metadata_provider, tmp_path
|
||||
):
|
||||
"""A non-matching base model keeps the default root."""
|
||||
manager = DownloadManager()
|
||||
|
||||
default_root = tmp_path / "loras-main"
|
||||
archive_root = tmp_path / "lora-archive"
|
||||
settings = get_settings_manager()
|
||||
settings.settings["folder_paths"] = {
|
||||
"loras": [str(default_root), str(archive_root)]
|
||||
}
|
||||
settings.settings["default_lora_root"] = str(default_root)
|
||||
settings.settings["download_root_rules"] = {
|
||||
"loras": [
|
||||
{
|
||||
"match_type": "base_model",
|
||||
"match": ["Flux.1 D"],
|
||||
"root": str(archive_root),
|
||||
}
|
||||
]
|
||||
}
|
||||
metadata_provider.payload = _lora_payload("Pony")
|
||||
|
||||
captured = {}
|
||||
_capture_execute_download(monkeypatch, captured)
|
||||
|
||||
result = await manager.download_from_civitai(
|
||||
model_version_id=99,
|
||||
save_dir=str(tmp_path),
|
||||
use_default_paths=True,
|
||||
progress_callback=None,
|
||||
source=None,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert str(captured["save_dir"]).startswith(str(default_root))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_root_rule_applies_to_final_unet_type(
|
||||
monkeypatch, scanners, metadata_provider, tmp_path
|
||||
):
|
||||
"""A checkpoint download routed to unet consults the unet rules, not
|
||||
the checkpoints rules."""
|
||||
manager = DownloadManager()
|
||||
|
||||
unet_default = tmp_path / "unet-main"
|
||||
unet_archive = tmp_path / "unet-archive"
|
||||
checkpoint_root = tmp_path / "checkpoints"
|
||||
settings = get_settings_manager()
|
||||
settings.settings["folder_paths"] = {
|
||||
"checkpoints": [str(checkpoint_root)],
|
||||
"unet": [str(unet_default), str(unet_archive)],
|
||||
}
|
||||
settings.settings["default_checkpoint_root"] = str(checkpoint_root)
|
||||
settings.settings["default_unet_root"] = str(unet_default)
|
||||
settings.settings["download_root_rules"] = {
|
||||
"checkpoints": [
|
||||
{
|
||||
"match_type": "base_model",
|
||||
"match": ["Flux.1 D"],
|
||||
"root": str(checkpoint_root),
|
||||
}
|
||||
],
|
||||
"unet": [
|
||||
{
|
||||
"match_type": "base_model",
|
||||
"match": ["Flux.1 D"],
|
||||
"root": str(unet_archive),
|
||||
}
|
||||
],
|
||||
}
|
||||
metadata_provider.payload = {
|
||||
"id": 1234,
|
||||
"model": {"type": "Checkpoint", "tags": ["fantasy"]},
|
||||
"baseModel": "Flux.1 D",
|
||||
"creator": {"username": "Author"},
|
||||
"files": [
|
||||
{
|
||||
"type": "Model",
|
||||
"primary": True,
|
||||
"downloadUrl": "https://example.invalid/file.safetensors",
|
||||
"name": "file.safetensors",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
captured = {}
|
||||
_capture_execute_download(monkeypatch, captured)
|
||||
|
||||
result = await manager.download_from_civitai(
|
||||
model_version_id=99,
|
||||
save_dir=str(tmp_path),
|
||||
use_default_paths=True,
|
||||
progress_callback=None,
|
||||
source=None,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured["model_type"] == "checkpoint"
|
||||
assert captured["save_dir"] == str(unet_archive / "Flux.1 D" / "fantasy")
|
||||
|
||||
@@ -205,3 +205,69 @@ class TestResolveOtherDownloadSubType:
|
||||
|
||||
def test_model_type_matching_is_case_insensitive(self):
|
||||
assert resolve_other_download_sub_type("vAe") == "vae"
|
||||
|
||||
|
||||
from py.services.download_routing import resolve_download_root
|
||||
|
||||
|
||||
def test_resolve_download_root_returns_default_without_rules():
|
||||
assert resolve_download_root(None, "Flux.1 D", "/default") == "/default"
|
||||
assert resolve_download_root([], "Flux.1 D", "/default") == "/default"
|
||||
|
||||
|
||||
def test_resolve_download_root_returns_default_without_base_model():
|
||||
rules = [{"match_type": "base_model", "match": ["Flux.1 D"], "root": "/nas"}]
|
||||
assert resolve_download_root(rules, "", "/default") == "/default"
|
||||
|
||||
|
||||
def test_resolve_download_root_first_match_wins():
|
||||
rules = [
|
||||
{"match_type": "base_model", "match": ["Flux.1 D", "Wan Video"], "root": "/nas"},
|
||||
{"match_type": "base_model", "match": ["Flux.1 D"], "root": "/other"},
|
||||
]
|
||||
assert resolve_download_root(rules, "Flux.1 D", "/default") == "/nas"
|
||||
assert resolve_download_root(rules, "Wan Video", "/default") == "/nas"
|
||||
|
||||
|
||||
def test_resolve_download_root_falls_back_when_no_rule_matches():
|
||||
rules = [{"match_type": "base_model", "match": ["Pony"], "root": "/nas"}]
|
||||
assert resolve_download_root(rules, "Flux.1 D", "/default") == "/default"
|
||||
|
||||
|
||||
def test_resolve_download_root_skips_malformed_rules():
|
||||
rules = [
|
||||
{"match_type": "tags", "match": ["Flux.1 D"], "root": "/nas"},
|
||||
{"match_type": "base_model", "match": "Flux.1 D", "root": "/nas"},
|
||||
"not-a-dict",
|
||||
{"match_type": "base_model", "match": ["Flux.1 D"], "root": ""},
|
||||
{"match_type": "base_model", "match": ["Flux.1 D"], "root": "/nas"},
|
||||
]
|
||||
assert resolve_download_root(rules, "Flux.1 D", "/default") == "/nas"
|
||||
|
||||
|
||||
def test_resolve_download_root_rule_lists_are_per_type():
|
||||
"""Checkpoint and unet rule sets are consulted independently."""
|
||||
rules_by_type = {
|
||||
"checkpoints": [
|
||||
{"match_type": "base_model", "match": ["SDXL 1.0"], "root": "/ckpt-nas"}
|
||||
],
|
||||
"unet": [
|
||||
{"match_type": "base_model", "match": ["Flux.1 D"], "root": "/unet-nas"}
|
||||
],
|
||||
}
|
||||
assert (
|
||||
resolve_download_root(rules_by_type["checkpoints"], "SDXL 1.0", "/default")
|
||||
== "/ckpt-nas"
|
||||
)
|
||||
assert (
|
||||
resolve_download_root(rules_by_type["unet"], "SDXL 1.0", "/default")
|
||||
== "/default"
|
||||
)
|
||||
assert (
|
||||
resolve_download_root(rules_by_type["unet"], "Flux.1 D", "/default")
|
||||
== "/unet-nas"
|
||||
)
|
||||
assert (
|
||||
resolve_download_root(rules_by_type["checkpoints"], "Flux.1 D", "/default")
|
||||
== "/default"
|
||||
)
|
||||
|
||||
@@ -1566,3 +1566,196 @@ def test_migrate_sanitizes_legacy_libraries_includes_other_roots(tmp_path, monke
|
||||
|
||||
payload = manager.get_libraries()["legacy"]
|
||||
assert payload["default_other_roots"] == {}
|
||||
|
||||
|
||||
def _configure_lora_roots(manager, *roots):
|
||||
manager.settings["folder_paths"] = {"loras": [str(root) for root in roots]}
|
||||
|
||||
|
||||
def test_download_root_rules_defaults_to_empty_per_type(manager):
|
||||
assert manager.get_download_root_rules() == {
|
||||
"loras": [],
|
||||
"checkpoints": [],
|
||||
"unet": [],
|
||||
"embeddings": [],
|
||||
}
|
||||
|
||||
|
||||
def test_download_root_rules_normalize_keeps_valid_rules_in_order(manager):
|
||||
_configure_lora_roots(manager, "/loras-main", "/loras-archive")
|
||||
manager.settings["download_root_rules"] = {
|
||||
"loras": [
|
||||
{
|
||||
"match_type": "base_model",
|
||||
"match": ["Flux.1 D", "Wan Video"],
|
||||
"root": "/loras-archive",
|
||||
},
|
||||
{"match_type": "base_model", "match": ["Pony"], "root": "/loras-main"},
|
||||
]
|
||||
}
|
||||
|
||||
result = manager.get_download_root_rules()
|
||||
|
||||
assert result["loras"] == [
|
||||
{
|
||||
"match_type": "base_model",
|
||||
"match": ["Flux.1 D", "Wan Video"],
|
||||
"root": "/loras-archive",
|
||||
},
|
||||
{"match_type": "base_model", "match": ["Pony"], "root": "/loras-main"},
|
||||
]
|
||||
assert manager.settings["download_root_rules"]["loras"] == result["loras"]
|
||||
|
||||
|
||||
def test_download_root_rules_normalize_drops_invalid_match_values(manager):
|
||||
_configure_lora_roots(manager, "/loras-archive")
|
||||
manager.settings["download_root_rules"] = {
|
||||
"loras": [
|
||||
{
|
||||
"match_type": "base_model",
|
||||
"match": ["Flux.1 D", "Not A Model", "Flux.1 D", 42, ""],
|
||||
"root": "/loras-archive",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = manager.get_download_root_rules()
|
||||
|
||||
assert result["loras"] == [
|
||||
{"match_type": "base_model", "match": ["Flux.1 D"], "root": "/loras-archive"}
|
||||
]
|
||||
|
||||
|
||||
def test_download_root_rules_normalize_drops_rule_with_unknown_root(
|
||||
manager, caplog
|
||||
):
|
||||
_configure_lora_roots(manager, "/loras-main")
|
||||
manager.settings["download_root_rules"] = {
|
||||
"loras": [
|
||||
{
|
||||
"match_type": "base_model",
|
||||
"match": ["Flux.1 D"],
|
||||
"root": "/not-configured",
|
||||
},
|
||||
{"match_type": "base_model", "match": ["Pony"], "root": "/loras-main"},
|
||||
]
|
||||
}
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = manager.get_download_root_rules()
|
||||
|
||||
assert result["loras"] == [
|
||||
{"match_type": "base_model", "match": ["Pony"], "root": "/loras-main"}
|
||||
]
|
||||
assert "/not-configured" in caplog.text
|
||||
|
||||
|
||||
def test_download_root_rules_root_matches_with_slash_normalization(manager):
|
||||
_configure_lora_roots(manager, "/loras-archive/")
|
||||
manager.settings["download_root_rules"] = {
|
||||
"loras": [
|
||||
{"match_type": "base_model", "match": ["Flux.1 D"], "root": "/loras-archive"}
|
||||
]
|
||||
}
|
||||
|
||||
result = manager.get_download_root_rules()
|
||||
|
||||
assert result["loras"][0]["root"] == "/loras-archive"
|
||||
|
||||
|
||||
def test_download_root_rules_accepts_extra_folder_paths_roots(manager):
|
||||
_configure_lora_roots(manager, "/loras-main")
|
||||
manager.settings["extra_folder_paths"] = {"loras": ["/loras-extra"]}
|
||||
manager.settings["download_root_rules"] = {
|
||||
"loras": [
|
||||
{"match_type": "base_model", "match": ["Flux.1 D"], "root": "/loras-extra"}
|
||||
]
|
||||
}
|
||||
|
||||
result = manager.get_download_root_rules()
|
||||
|
||||
assert result["loras"][0]["root"] == "/loras-extra"
|
||||
|
||||
|
||||
def test_download_root_rules_normalize_drops_unknown_match_type(manager, caplog):
|
||||
_configure_lora_roots(manager, "/loras-archive")
|
||||
manager.settings["download_root_rules"] = {
|
||||
"loras": [
|
||||
{"match_type": "tags", "match": ["Flux.1 D"], "root": "/loras-archive"}
|
||||
]
|
||||
}
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = manager.get_download_root_rules()
|
||||
|
||||
assert result["loras"] == []
|
||||
assert "tags" in caplog.text
|
||||
|
||||
|
||||
def test_download_root_rules_normalize_drops_malformed_input(manager):
|
||||
_configure_lora_roots(manager, "/loras-archive")
|
||||
|
||||
assert manager.normalize_download_root_rules(None) == {
|
||||
"loras": [],
|
||||
"checkpoints": [],
|
||||
"unet": [],
|
||||
"embeddings": [],
|
||||
}
|
||||
assert manager.normalize_download_root_rules("not-a-dict")["loras"] == []
|
||||
assert manager.normalize_download_root_rules({"loras": "not-a-list"})[
|
||||
"loras"
|
||||
] == []
|
||||
assert manager.normalize_download_root_rules({"loras": ["not-a-dict", 42]})[
|
||||
"loras"
|
||||
] == []
|
||||
# Unknown type keys are dropped; known keys survive.
|
||||
result = manager.normalize_download_root_rules(
|
||||
{
|
||||
"videos": [
|
||||
{"match_type": "base_model", "match": ["Pony"], "root": "/x"}
|
||||
],
|
||||
"loras": [
|
||||
{
|
||||
"match_type": "base_model",
|
||||
"match": ["Flux.1 D"],
|
||||
"root": "/loras-archive",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
assert "videos" not in result
|
||||
assert len(result["loras"]) == 1
|
||||
|
||||
|
||||
def test_download_root_rules_normalize_drops_rule_with_empty_match(manager):
|
||||
_configure_lora_roots(manager, "/loras-archive")
|
||||
manager.settings["download_root_rules"] = {
|
||||
"loras": [
|
||||
{"match_type": "base_model", "match": ["Not A Model"], "root": "/loras-archive"}
|
||||
]
|
||||
}
|
||||
|
||||
result = manager.get_download_root_rules()
|
||||
|
||||
assert result["loras"] == []
|
||||
|
||||
|
||||
def test_set_download_root_rules_normalizes(manager):
|
||||
_configure_lora_roots(manager, "/loras-main", "/loras-archive")
|
||||
|
||||
manager.set(
|
||||
"download_root_rules",
|
||||
{
|
||||
"loras": [
|
||||
{
|
||||
"match_type": "base_model",
|
||||
"match": ["Flux.1 D", "Invalid"],
|
||||
"root": "/loras-archive",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert manager.get("download_root_rules")["loras"] == [
|
||||
{"match_type": "base_model", "match": ["Flux.1 D"], "root": "/loras-archive"}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user