mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-13 04:42:13 -03:00
feat(download): support tag-based rules and show matched rule in download modal
Download root rules now accept match_type "tag": the rule hits when any of its free-text tags equals one of the model's CivitAI tags (trimmed, case-insensitive), so e.g. all "character" models can be routed to a NAS root regardless of base model. Rules stay ordered — first match wins across mixed base_model/tag rules. - settings normalize keeps tag entries as free text (case-insensitive dedupe, no base-model whitelist); resolve_download_root gained a tags parameter and a match_download_root_rule helper - the download flow passes the version's model tags into rule resolution - the routing endpoint accepts tags and reports the matched rule; the versions endpoint ships model-level tags with each version so the location step can preview tag matches - the settings rule editor gained a per-rule match-type dropdown; tag rules edit a comma-separated list (committed on blur/Enter) instead of the base model checkbox panel - the download confirmation modal shows "Routed by rule: ..." next to the preselected root
This commit is contained in:
@@ -506,6 +506,14 @@
|
||||
"moveUp": "[TODO: Translate] Move up",
|
||||
"moveDown": "[TODO: Translate] Move down",
|
||||
"noRoots": "[TODO: Translate] No roots configured for this model type.",
|
||||
"matchTypes": {
|
||||
"baseModel": "[TODO: Translate] Base model",
|
||||
"tag": "[TODO: Translate] Tag"
|
||||
},
|
||||
"tagPlaceholder": "[TODO: Translate] character, style, realistic",
|
||||
"summary": {
|
||||
"tags": "[TODO: Translate] tags: {tags}"
|
||||
},
|
||||
"types": {
|
||||
"loras": "[TODO: Translate] LoRA",
|
||||
"checkpoints": "[TODO: Translate] Checkpoint",
|
||||
@@ -1646,6 +1654,10 @@
|
||||
"label": "Zieltyp:",
|
||||
"tooltip": "Automatisch aus den Modell-Metadaten erkannt. Wechseln Sie, wenn die Erkennung falsch war; ein Wechsel deaktiviert „Standardpfad verwenden“ für diesen Download."
|
||||
},
|
||||
"routedByRule": {
|
||||
"baseModel": "[TODO: Translate] Routed by rule: base model \"{value}\"",
|
||||
"tag": "[TODO: Translate] Routed by rule: tag \"{value}\""
|
||||
},
|
||||
"targetFolderPath": "Zielordnerpfad:",
|
||||
"browseFolders": "Ordner durchsuchen:",
|
||||
"createNewFolder": "Neuen Ordner erstellen",
|
||||
|
||||
@@ -506,6 +506,14 @@
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"noRoots": "No roots configured for this model type.",
|
||||
"matchTypes": {
|
||||
"baseModel": "Base model",
|
||||
"tag": "Tag"
|
||||
},
|
||||
"tagPlaceholder": "character, style, realistic",
|
||||
"summary": {
|
||||
"tags": "tags: {tags}"
|
||||
},
|
||||
"types": {
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Checkpoint",
|
||||
@@ -1646,6 +1654,10 @@
|
||||
"label": "Destination type:",
|
||||
"tooltip": "Auto-detected from the model metadata. Switch if it was misdetected; switching turns off \"Use Default Path\" for this download."
|
||||
},
|
||||
"routedByRule": {
|
||||
"baseModel": "Routed by rule: base model \"{value}\"",
|
||||
"tag": "Routed by rule: tag \"{value}\""
|
||||
},
|
||||
"targetFolderPath": "Target Folder Path:",
|
||||
"browseFolders": "Browse Folders:",
|
||||
"createNewFolder": "Create new folder",
|
||||
|
||||
@@ -506,6 +506,14 @@
|
||||
"moveUp": "[TODO: Translate] Move up",
|
||||
"moveDown": "[TODO: Translate] Move down",
|
||||
"noRoots": "[TODO: Translate] No roots configured for this model type.",
|
||||
"matchTypes": {
|
||||
"baseModel": "[TODO: Translate] Base model",
|
||||
"tag": "[TODO: Translate] Tag"
|
||||
},
|
||||
"tagPlaceholder": "[TODO: Translate] character, style, realistic",
|
||||
"summary": {
|
||||
"tags": "[TODO: Translate] tags: {tags}"
|
||||
},
|
||||
"types": {
|
||||
"loras": "[TODO: Translate] LoRA",
|
||||
"checkpoints": "[TODO: Translate] Checkpoint",
|
||||
@@ -1646,6 +1654,10 @@
|
||||
"label": "Tipo de destino:",
|
||||
"tooltip": "Detectado automáticamente a partir de los metadatos del modelo. Cámbialo si la detección fue incorrecta; cambiar desactiva «Usar ruta predeterminada» para esta descarga."
|
||||
},
|
||||
"routedByRule": {
|
||||
"baseModel": "[TODO: Translate] Routed by rule: base model \"{value}\"",
|
||||
"tag": "[TODO: Translate] Routed by rule: tag \"{value}\""
|
||||
},
|
||||
"targetFolderPath": "Ruta de carpeta de destino:",
|
||||
"browseFolders": "Explorar carpetas:",
|
||||
"createNewFolder": "Crear nueva carpeta",
|
||||
|
||||
@@ -506,6 +506,14 @@
|
||||
"moveUp": "[TODO: Translate] Move up",
|
||||
"moveDown": "[TODO: Translate] Move down",
|
||||
"noRoots": "[TODO: Translate] No roots configured for this model type.",
|
||||
"matchTypes": {
|
||||
"baseModel": "[TODO: Translate] Base model",
|
||||
"tag": "[TODO: Translate] Tag"
|
||||
},
|
||||
"tagPlaceholder": "[TODO: Translate] character, style, realistic",
|
||||
"summary": {
|
||||
"tags": "[TODO: Translate] tags: {tags}"
|
||||
},
|
||||
"types": {
|
||||
"loras": "[TODO: Translate] LoRA",
|
||||
"checkpoints": "[TODO: Translate] Checkpoint",
|
||||
@@ -1646,6 +1654,10 @@
|
||||
"label": "Type de destination :",
|
||||
"tooltip": "Détecté automatiquement à partir des métadonnées du modèle. Changez si la détection est incorrecte ; changer désactive « Utiliser le chemin par défaut » pour ce téléchargement."
|
||||
},
|
||||
"routedByRule": {
|
||||
"baseModel": "[TODO: Translate] Routed by rule: base model \"{value}\"",
|
||||
"tag": "[TODO: Translate] Routed by rule: tag \"{value}\""
|
||||
},
|
||||
"targetFolderPath": "Chemin du dossier cible :",
|
||||
"browseFolders": "Parcourir les dossiers :",
|
||||
"createNewFolder": "Créer un nouveau dossier",
|
||||
|
||||
@@ -506,6 +506,14 @@
|
||||
"moveUp": "[TODO: Translate] Move up",
|
||||
"moveDown": "[TODO: Translate] Move down",
|
||||
"noRoots": "[TODO: Translate] No roots configured for this model type.",
|
||||
"matchTypes": {
|
||||
"baseModel": "[TODO: Translate] Base model",
|
||||
"tag": "[TODO: Translate] Tag"
|
||||
},
|
||||
"tagPlaceholder": "[TODO: Translate] character, style, realistic",
|
||||
"summary": {
|
||||
"tags": "[TODO: Translate] tags: {tags}"
|
||||
},
|
||||
"types": {
|
||||
"loras": "[TODO: Translate] LoRA",
|
||||
"checkpoints": "[TODO: Translate] Checkpoint",
|
||||
@@ -1646,6 +1654,10 @@
|
||||
"label": "סוג יעד:",
|
||||
"tooltip": "זוהה אוטומטית ממטא-נתוני המודל. החלף אם הזיהוי שגוי; החלפה מכבה את «השתמש בנתיב ברירת מחדל» עבור הורדה זו."
|
||||
},
|
||||
"routedByRule": {
|
||||
"baseModel": "[TODO: Translate] Routed by rule: base model \"{value}\"",
|
||||
"tag": "[TODO: Translate] Routed by rule: tag \"{value}\""
|
||||
},
|
||||
"targetFolderPath": "נתיב תיקיית יעד:",
|
||||
"browseFolders": "דפדף בתיקיות:",
|
||||
"createNewFolder": "צור תיקייה חדשה",
|
||||
|
||||
@@ -506,6 +506,14 @@
|
||||
"moveUp": "[TODO: Translate] Move up",
|
||||
"moveDown": "[TODO: Translate] Move down",
|
||||
"noRoots": "[TODO: Translate] No roots configured for this model type.",
|
||||
"matchTypes": {
|
||||
"baseModel": "[TODO: Translate] Base model",
|
||||
"tag": "[TODO: Translate] Tag"
|
||||
},
|
||||
"tagPlaceholder": "[TODO: Translate] character, style, realistic",
|
||||
"summary": {
|
||||
"tags": "[TODO: Translate] tags: {tags}"
|
||||
},
|
||||
"types": {
|
||||
"loras": "[TODO: Translate] LoRA",
|
||||
"checkpoints": "[TODO: Translate] Checkpoint",
|
||||
@@ -1646,6 +1654,10 @@
|
||||
"label": "保存先タイプ:",
|
||||
"tooltip": "モデルのメタデータから自動検出されます。誤検出の場合は切り替えてください。切り替えると、このダウンロードでは「デフォルトパスを使用」がオフになります。"
|
||||
},
|
||||
"routedByRule": {
|
||||
"baseModel": "[TODO: Translate] Routed by rule: base model \"{value}\"",
|
||||
"tag": "[TODO: Translate] Routed by rule: tag \"{value}\""
|
||||
},
|
||||
"targetFolderPath": "ターゲットフォルダパス:",
|
||||
"browseFolders": "フォルダを参照:",
|
||||
"createNewFolder": "新しいフォルダを作成",
|
||||
|
||||
@@ -506,6 +506,14 @@
|
||||
"moveUp": "[TODO: Translate] Move up",
|
||||
"moveDown": "[TODO: Translate] Move down",
|
||||
"noRoots": "[TODO: Translate] No roots configured for this model type.",
|
||||
"matchTypes": {
|
||||
"baseModel": "[TODO: Translate] Base model",
|
||||
"tag": "[TODO: Translate] Tag"
|
||||
},
|
||||
"tagPlaceholder": "[TODO: Translate] character, style, realistic",
|
||||
"summary": {
|
||||
"tags": "[TODO: Translate] tags: {tags}"
|
||||
},
|
||||
"types": {
|
||||
"loras": "[TODO: Translate] LoRA",
|
||||
"checkpoints": "[TODO: Translate] Checkpoint",
|
||||
@@ -1646,6 +1654,10 @@
|
||||
"label": "대상 유형:",
|
||||
"tooltip": "모델 메타데이터에서 자동으로 감지됩니다. 잘못 감지된 경우 전환하세요. 전환하면 이 다운로드에서 '기본 경로 사용'이 꺼집니다."
|
||||
},
|
||||
"routedByRule": {
|
||||
"baseModel": "[TODO: Translate] Routed by rule: base model \"{value}\"",
|
||||
"tag": "[TODO: Translate] Routed by rule: tag \"{value}\""
|
||||
},
|
||||
"targetFolderPath": "대상 폴더 경로:",
|
||||
"browseFolders": "폴더 탐색:",
|
||||
"createNewFolder": "새 폴더 만들기",
|
||||
|
||||
@@ -506,6 +506,14 @@
|
||||
"moveUp": "[TODO: Translate] Move up",
|
||||
"moveDown": "[TODO: Translate] Move down",
|
||||
"noRoots": "[TODO: Translate] No roots configured for this model type.",
|
||||
"matchTypes": {
|
||||
"baseModel": "[TODO: Translate] Base model",
|
||||
"tag": "[TODO: Translate] Tag"
|
||||
},
|
||||
"tagPlaceholder": "[TODO: Translate] character, style, realistic",
|
||||
"summary": {
|
||||
"tags": "[TODO: Translate] tags: {tags}"
|
||||
},
|
||||
"types": {
|
||||
"loras": "[TODO: Translate] LoRA",
|
||||
"checkpoints": "[TODO: Translate] Checkpoint",
|
||||
@@ -1646,6 +1654,10 @@
|
||||
"label": "Тип назначения:",
|
||||
"tooltip": "Определяется автоматически из метаданных модели. Переключите, если определение неверно; переключение отключает «Использовать путь по умолчанию» для этой загрузки."
|
||||
},
|
||||
"routedByRule": {
|
||||
"baseModel": "[TODO: Translate] Routed by rule: base model \"{value}\"",
|
||||
"tag": "[TODO: Translate] Routed by rule: tag \"{value}\""
|
||||
},
|
||||
"targetFolderPath": "Путь к целевой папке:",
|
||||
"browseFolders": "Обзор папок:",
|
||||
"createNewFolder": "Создать новую папку",
|
||||
|
||||
@@ -506,6 +506,14 @@
|
||||
"moveUp": "[TODO: Translate] Move up",
|
||||
"moveDown": "[TODO: Translate] Move down",
|
||||
"noRoots": "[TODO: Translate] No roots configured for this model type.",
|
||||
"matchTypes": {
|
||||
"baseModel": "[TODO: Translate] Base model",
|
||||
"tag": "[TODO: Translate] Tag"
|
||||
},
|
||||
"tagPlaceholder": "[TODO: Translate] character, style, realistic",
|
||||
"summary": {
|
||||
"tags": "[TODO: Translate] tags: {tags}"
|
||||
},
|
||||
"types": {
|
||||
"loras": "[TODO: Translate] LoRA",
|
||||
"checkpoints": "[TODO: Translate] Checkpoint",
|
||||
@@ -1646,6 +1654,10 @@
|
||||
"label": "目标类型:",
|
||||
"tooltip": "根据模型元数据自动检测。如果检测结果有误,请切换;切换会为本次下载关闭“使用默认路径”。"
|
||||
},
|
||||
"routedByRule": {
|
||||
"baseModel": "[TODO: Translate] Routed by rule: base model \"{value}\"",
|
||||
"tag": "[TODO: Translate] Routed by rule: tag \"{value}\""
|
||||
},
|
||||
"targetFolderPath": "目标文件夹路径:",
|
||||
"browseFolders": "浏览文件夹:",
|
||||
"createNewFolder": "新建文件夹",
|
||||
|
||||
@@ -506,6 +506,14 @@
|
||||
"moveUp": "[TODO: Translate] Move up",
|
||||
"moveDown": "[TODO: Translate] Move down",
|
||||
"noRoots": "[TODO: Translate] No roots configured for this model type.",
|
||||
"matchTypes": {
|
||||
"baseModel": "[TODO: Translate] Base model",
|
||||
"tag": "[TODO: Translate] Tag"
|
||||
},
|
||||
"tagPlaceholder": "[TODO: Translate] character, style, realistic",
|
||||
"summary": {
|
||||
"tags": "[TODO: Translate] tags: {tags}"
|
||||
},
|
||||
"types": {
|
||||
"loras": "[TODO: Translate] LoRA",
|
||||
"checkpoints": "[TODO: Translate] Checkpoint",
|
||||
@@ -1646,6 +1654,10 @@
|
||||
"label": "目標類型:",
|
||||
"tooltip": "根據模型中繼資料自動偵測。如果偵測結果有誤,請切換;切換會為本次下載關閉「使用預設路徑」。"
|
||||
},
|
||||
"routedByRule": {
|
||||
"baseModel": "[TODO: Translate] Routed by rule: base model \"{value}\"",
|
||||
"tag": "[TODO: Translate] Routed by rule: tag \"{value}\""
|
||||
},
|
||||
"targetFolderPath": "目標資料夾路徑:",
|
||||
"browseFolders": "瀏覽資料夾:",
|
||||
"createNewFolder": "建立新資料夾",
|
||||
|
||||
@@ -11,7 +11,7 @@ from ...services.download_routing import (
|
||||
MODEL_TYPE_TO_DEFAULT_ROOT_SETTING,
|
||||
MODEL_TYPE_TO_ROOT_RULE_KEY,
|
||||
is_diffusion_model_download,
|
||||
resolve_download_root,
|
||||
match_download_root_rule,
|
||||
resolve_other_download_sub_type,
|
||||
)
|
||||
from ...utils.constants import VALID_OTHER_CIVITAI_TYPES
|
||||
@@ -38,6 +38,7 @@ class DownloadRoutingHandler:
|
||||
model_type = payload.get("model_type", "")
|
||||
base_model = payload.get("base_model") or ""
|
||||
file_types = payload.get("file_types") or []
|
||||
tags = payload.get("tags") or []
|
||||
selected_file_type = payload.get("selected_file_type")
|
||||
|
||||
if not isinstance(model_type, str) or not model_type:
|
||||
@@ -52,6 +53,8 @@ class DownloadRoutingHandler:
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
if not isinstance(tags, list):
|
||||
tags = []
|
||||
if selected_file_type is not None and not isinstance(selected_file_type, str):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "selected_file_type must be a string"},
|
||||
@@ -120,21 +123,31 @@ class DownloadRoutingHandler:
|
||||
# Apply download_root_rules against the final root kind so the
|
||||
# location-step preview matches where the download will land.
|
||||
resolved_root = ""
|
||||
matched_rule = None
|
||||
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(
|
||||
rule = match_download_root_rule(
|
||||
settings.get_download_root_rules().get(rule_key),
|
||||
base_model,
|
||||
default_root,
|
||||
tags,
|
||||
)
|
||||
if rule is not None:
|
||||
resolved_root = rule["root"]
|
||||
matched_rule = {
|
||||
"match_type": rule.get("match_type"),
|
||||
"match": list(rule.get("match") or []),
|
||||
}
|
||||
else:
|
||||
resolved_root = default_root
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"is_diffusion_model": is_diffusion,
|
||||
"root_kind": root_kind,
|
||||
"resolved_root": resolved_root,
|
||||
"matched_rule": matched_rule,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2391,7 +2391,12 @@ class ModelCivitaiHandler:
|
||||
exc,
|
||||
)
|
||||
|
||||
# Model-level tags travel with each version so the download
|
||||
# location step can preview download_root_rules tag matches.
|
||||
model_tags = response.get("tags") or []
|
||||
|
||||
for version in versions:
|
||||
version["modelTags"] = model_tags
|
||||
version_id = None
|
||||
version_id_raw = version.get("id")
|
||||
if version_id_raw is not None:
|
||||
|
||||
@@ -1503,17 +1503,17 @@ class DownloadManager:
|
||||
rule_key: str,
|
||||
base_model: str,
|
||||
default_root: str,
|
||||
tags=None,
|
||||
) -> 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.
|
||||
consults the "unet" rules. ``tags`` are the model's CivitAI tags,
|
||||
used by rules with ``match_type == "tag"``.
|
||||
"""
|
||||
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)
|
||||
return resolve_download_root(rules, base_model, default_root, tags=tags)
|
||||
|
||||
async def _resolve_download_target_path(
|
||||
self,
|
||||
@@ -1792,6 +1792,15 @@ class DownloadManager:
|
||||
|
||||
excluded_base_models = get_settings_manager().get_download_skip_base_models()
|
||||
base_model_value = version_info.get("baseModel", "")
|
||||
# Model-level CivitAI tags, used by download_root_rules with
|
||||
# match_type "tag" (same source as _calculate_relative_path's
|
||||
# {first_tag} placeholder).
|
||||
model_info = version_info.get("model")
|
||||
model_tags = (
|
||||
model_info.get("tags") or []
|
||||
if isinstance(model_info, dict)
|
||||
else []
|
||||
)
|
||||
if (
|
||||
isinstance(base_model_value, str)
|
||||
and base_model_value in SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS
|
||||
@@ -1936,7 +1945,7 @@ class DownloadManager:
|
||||
"error": error_msg,
|
||||
}
|
||||
save_dir = self._resolve_rule_routed_root(
|
||||
rule_key, base_model_value, default_path
|
||||
rule_key, base_model_value, default_path, tags=model_tags
|
||||
)
|
||||
elif model_type == "lora":
|
||||
default_path = settings_manager.get("default_lora_root")
|
||||
@@ -1946,7 +1955,7 @@ class DownloadManager:
|
||||
"error": "Default lora root path not set in settings",
|
||||
}
|
||||
save_dir = self._resolve_rule_routed_root(
|
||||
"loras", base_model_value, default_path
|
||||
"loras", base_model_value, default_path, tags=model_tags
|
||||
)
|
||||
elif model_type == "embedding":
|
||||
default_path = settings_manager.get("default_embedding_root")
|
||||
@@ -1956,7 +1965,7 @@ class DownloadManager:
|
||||
"error": "Default embedding root path not set in settings",
|
||||
}
|
||||
save_dir = self._resolve_rule_routed_root(
|
||||
"embeddings", base_model_value, default_path
|
||||
"embeddings", base_model_value, default_path, tags=model_tags
|
||||
)
|
||||
elif model_type == "other":
|
||||
other_sub_type = resolve_other_download_sub_type(
|
||||
|
||||
@@ -48,39 +48,83 @@ MODEL_TYPE_TO_DEFAULT_ROOT_SETTING = {
|
||||
"embedding": "default_embedding_root",
|
||||
}
|
||||
|
||||
# match_type values understood by download_root_rules.
|
||||
RULE_MATCH_TYPE_BASE_MODEL = "base_model"
|
||||
RULE_MATCH_TYPE_TAG = "tag"
|
||||
|
||||
|
||||
def _normalize_tag_set(tags: Optional[Iterable[object]]) -> frozenset:
|
||||
"""Case-folded, trimmed tag set for rule matching (never None)."""
|
||||
if not tags:
|
||||
return frozenset()
|
||||
return frozenset(
|
||||
tag.strip().casefold()
|
||||
for tag in tags
|
||||
if isinstance(tag, str) and tag.strip()
|
||||
)
|
||||
|
||||
|
||||
def match_download_root_rule(
|
||||
rules: Optional[Sequence[Mapping[str, object]]],
|
||||
base_model: str,
|
||||
tags: Optional[Iterable[object]] = None,
|
||||
) -> Optional[Mapping[str, object]]:
|
||||
"""Return the first rule matching this download, else ``None``.
|
||||
|
||||
Rules are ordered; the first match wins. ``base_model`` rules hit when
|
||||
their ``match`` list contains ``base_model`` (exact match); ``tag`` rules
|
||||
hit when any ``match`` entry equals one of the model's tags (trimmed,
|
||||
case-insensitive). A rule with an empty/invalid root never matches.
|
||||
"""
|
||||
if not rules:
|
||||
return None
|
||||
|
||||
model_tags = _normalize_tag_set(tags)
|
||||
|
||||
for rule in rules:
|
||||
if not isinstance(rule, Mapping):
|
||||
continue
|
||||
match = rule.get("match")
|
||||
if not isinstance(match, Sequence) or isinstance(match, (str, bytes)):
|
||||
continue
|
||||
match_type = rule.get("match_type")
|
||||
if match_type == RULE_MATCH_TYPE_BASE_MODEL:
|
||||
if not base_model or base_model not in match:
|
||||
continue
|
||||
elif match_type == RULE_MATCH_TYPE_TAG:
|
||||
if not model_tags or not any(
|
||||
isinstance(entry, str) and entry.strip().casefold() in model_tags
|
||||
for entry in match
|
||||
):
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
root = rule.get("root")
|
||||
if isinstance(root, str) and root.strip():
|
||||
return rule
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_download_root(
|
||||
rules: Optional[Sequence[Mapping[str, object]]],
|
||||
base_model: str,
|
||||
default_root: str,
|
||||
tags: Optional[Iterable[object]] = None,
|
||||
) -> 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).
|
||||
See :func:`match_download_root_rule` for the matching semantics.
|
||||
"""
|
||||
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
|
||||
rule = match_download_root_rule(rules, base_model, tags)
|
||||
if rule is not None:
|
||||
root = rule["root"]
|
||||
logger.info(
|
||||
"Download root rule matched (%s), routing to %s",
|
||||
rule.get("match_type"),
|
||||
root,
|
||||
)
|
||||
return root
|
||||
|
||||
return default_root
|
||||
|
||||
|
||||
@@ -1528,10 +1528,12 @@ class SettingsManager:
|
||||
"""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.
|
||||
malformed entries and unknown match types are dropped silently;
|
||||
``base_model`` rules keep only whitelisted base model values while
|
||||
``tag`` rules accept any free-text tag (trimmed, case-insensitively
|
||||
deduplicated); 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
|
||||
@@ -1551,7 +1553,7 @@ class SettingsManager:
|
||||
if not isinstance(raw_rule, Mapping):
|
||||
continue
|
||||
match_type = raw_rule.get("match_type")
|
||||
if match_type != "base_model":
|
||||
if match_type not in ("base_model", "tag"):
|
||||
logger.warning(
|
||||
"Dropping download root rule for %s: unsupported match_type %r",
|
||||
key,
|
||||
@@ -1563,22 +1565,29 @@ class SettingsManager:
|
||||
match, (str, bytes, bytearray)
|
||||
):
|
||||
continue
|
||||
matched_models: List[str] = []
|
||||
matched_values: 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:
|
||||
if not token:
|
||||
continue
|
||||
if token in seen:
|
||||
if match_type == "base_model":
|
||||
if token not in SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS:
|
||||
continue
|
||||
dedupe_key = token
|
||||
else:
|
||||
dedupe_key = token.casefold()
|
||||
if dedupe_key in seen:
|
||||
continue
|
||||
seen.add(token)
|
||||
matched_models.append(token)
|
||||
if not matched_models:
|
||||
seen.add(dedupe_key)
|
||||
matched_values.append(token)
|
||||
if not matched_values:
|
||||
logger.warning(
|
||||
"Dropping download root rule for %s: no valid base models",
|
||||
"Dropping download root rule for %s: no valid %s matches",
|
||||
key,
|
||||
match_type,
|
||||
)
|
||||
continue
|
||||
root = raw_rule.get("root")
|
||||
@@ -1595,8 +1604,8 @@ class SettingsManager:
|
||||
continue
|
||||
normalized[key].append(
|
||||
{
|
||||
"match_type": "base_model",
|
||||
"match": matched_models,
|
||||
"match_type": match_type,
|
||||
"match": matched_values,
|
||||
"root": root,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1096,6 +1096,12 @@
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.download-rule-match-hint {
|
||||
margin-top: var(--space-1);
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.routing-override-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
|
||||
@@ -695,6 +695,39 @@
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.download-root-rule-match-type {
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.download-root-rule-match-type:focus {
|
||||
border-color: var(--lora-accent);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
|
||||
}
|
||||
|
||||
.download-root-rule-tag-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--settings-bg);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.download-root-rule-tag-input:focus {
|
||||
border-color: var(--lora-accent);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
|
||||
}
|
||||
|
||||
.priority-tags-input:focus {
|
||||
border-color: var(--lora-accent);
|
||||
outline: none;
|
||||
|
||||
@@ -40,6 +40,9 @@ export class DownloadManager {
|
||||
// Root resolved by download_root_rules for the current version
|
||||
// (null = no rule matched; the default root is preselected).
|
||||
this._resolvedRoot = null;
|
||||
// The download_root_rules rule that matched, for the location-step
|
||||
// "routed by rule" hint ({match_type, match} or null).
|
||||
this._matchedRule = null;
|
||||
|
||||
// Multi-file selection state: selectedFile stays the first selected
|
||||
// file for backward compatibility with single-file flows (#1058).
|
||||
@@ -985,6 +988,7 @@ export class DownloadManager {
|
||||
|
||||
try {
|
||||
this._resolvedRoot = null;
|
||||
this._matchedRule = null;
|
||||
this._isDiffusionModel = await this._resolveIsDiffusionModel();
|
||||
this._otherSubType = await this._resolveOtherSubType();
|
||||
this._resolvedRoot = await this._resolveRuleRoot();
|
||||
@@ -1090,6 +1094,47 @@ export class DownloadManager {
|
||||
|
||||
document.getElementById('modelRootLabel').textContent =
|
||||
translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
|
||||
|
||||
this._renderRuleMatchHint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show which download_root_rules rule routed this download to the
|
||||
* preselected root (or hide the hint when none matched). The user can
|
||||
* still override the root manually; the hint only explains the
|
||||
* preselection.
|
||||
*/
|
||||
_renderRuleMatchHint() {
|
||||
const hint = document.getElementById('downloadRuleMatchHint');
|
||||
if (!hint) return;
|
||||
|
||||
const rule = this._matchedRule;
|
||||
if (!rule || (rule.match_type !== 'base_model' && rule.match_type !== 'tag')) {
|
||||
hint.hidden = true;
|
||||
hint.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const match = Array.isArray(rule.match) ? rule.match : [];
|
||||
let value;
|
||||
let i18nKey;
|
||||
let fallback;
|
||||
if (rule.match_type === 'tag') {
|
||||
const modelTags = (this.currentVersion?.modelTags || [])
|
||||
.map((tag) => String(tag).trim().toLowerCase());
|
||||
value = match.find((entry) => modelTags.includes(String(entry).trim().toLowerCase()))
|
||||
|| match[0]
|
||||
|| '';
|
||||
i18nKey = 'modals.download.routedByRule.tag';
|
||||
fallback = `Routed by rule: tag "${value}"`;
|
||||
} else {
|
||||
value = this.currentVersion?.baseModel || match[0] || '';
|
||||
i18nKey = 'modals.download.routedByRule.baseModel';
|
||||
fallback = `Routed by rule: base model "${value}"`;
|
||||
}
|
||||
|
||||
hint.textContent = translate(i18nKey, { value }, fallback);
|
||||
hint.hidden = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1176,6 +1221,7 @@ export class DownloadManager {
|
||||
model_type: 'checkpoint',
|
||||
base_model: this.currentVersion?.baseModel || '',
|
||||
file_types: fileTypes,
|
||||
tags: this.currentVersion?.modelTags || [],
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -1186,6 +1232,7 @@ export class DownloadManager {
|
||||
// Root a download_root_rules rule would send this download to.
|
||||
this._resolvedRoot = data.resolved_root;
|
||||
}
|
||||
this._matchedRule = data.matched_rule || null;
|
||||
if (typeof data.is_diffusion_model === 'boolean') {
|
||||
return data.is_diffusion_model;
|
||||
}
|
||||
@@ -1227,12 +1274,14 @@ export class DownloadManager {
|
||||
model_type: this.apiClient.modelType.replace(/s$/, ''),
|
||||
base_model: this.currentVersion?.baseModel || '',
|
||||
file_types: fileTypes,
|
||||
tags: this.currentVersion?.modelTags || [],
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`routing endpoint returned ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this._matchedRule = data.matched_rule || null;
|
||||
return (typeof data.resolved_root === 'string' && data.resolved_root) || null;
|
||||
} catch (error) {
|
||||
console.warn('[download] root rule resolution unavailable:', error);
|
||||
|
||||
@@ -303,16 +303,33 @@ export class SettingsManager {
|
||||
if (!rule || typeof rule !== 'object') {
|
||||
return;
|
||||
}
|
||||
const matchType = rule.match_type === 'tag' ? 'tag'
|
||||
: rule.match_type === 'base_model' ? 'base_model'
|
||||
: null;
|
||||
if (!matchType) {
|
||||
return;
|
||||
}
|
||||
const seen = new Set();
|
||||
const match = Array.isArray(rule.match)
|
||||
? [...new Set(rule.match
|
||||
.filter((model) => typeof model === 'string' && model.trim())
|
||||
.map((model) => model.trim()))]
|
||||
? rule.match
|
||||
.filter((entry) => typeof entry === 'string' && entry.trim())
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => {
|
||||
// Tags dedupe case-insensitively; base models are
|
||||
// whitelist values with stable casing.
|
||||
const dedupeKey = matchType === 'tag' ? entry.toLowerCase() : entry;
|
||||
if (seen.has(dedupeKey)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(dedupeKey);
|
||||
return true;
|
||||
})
|
||||
: [];
|
||||
const root = typeof rule.root === 'string' ? rule.root.trim() : '';
|
||||
if (!match.length || !root) {
|
||||
return;
|
||||
}
|
||||
normalized[typeKey].push({ match_type: 'base_model', match, root });
|
||||
normalized[typeKey].push({ match_type: matchType, match, root });
|
||||
});
|
||||
});
|
||||
return normalized;
|
||||
@@ -4579,13 +4596,30 @@ export class SettingsManager {
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'mapping-controls download-root-rule-controls';
|
||||
|
||||
const matchTypeSelect = document.createElement('select');
|
||||
matchTypeSelect.className = 'download-root-rule-match-type';
|
||||
[
|
||||
['base_model', 'settings.downloadRootRules.matchTypes.baseModel', 'Base model'],
|
||||
['tag', 'settings.downloadRootRules.matchTypes.tag', 'Tag'],
|
||||
].forEach(([value, i18nKey, fallback]) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = value;
|
||||
option.textContent = translate(i18nKey, {}, fallback);
|
||||
matchTypeSelect.appendChild(option);
|
||||
});
|
||||
matchTypeSelect.value = rule.match_type === 'tag' ? 'tag' : 'base_model';
|
||||
matchTypeSelect.addEventListener('change', () => {
|
||||
this.setDownloadRootRuleMatchType(typeKey, index, matchTypeSelect.value, isDraft);
|
||||
});
|
||||
controls.appendChild(matchTypeSelect);
|
||||
|
||||
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);
|
||||
this.renderDownloadRootRuleSummary(summary, rule);
|
||||
const toggleLabel = document.createElement('span');
|
||||
toggleLabel.className = 'base-model-skip-toggle-label';
|
||||
toggleLabel.textContent = isOpen
|
||||
@@ -4650,30 +4684,157 @@ export class SettingsManager {
|
||||
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);
|
||||
if (rule.match_type === 'tag') {
|
||||
// Tag rules edit a free-text comma-separated list instead of the
|
||||
// base model checkbox list. Commits on blur / Enter.
|
||||
const tagInput = document.createElement('input');
|
||||
tagInput.type = 'text';
|
||||
tagInput.className = 'download-root-rule-tag-input';
|
||||
tagInput.placeholder = translate(
|
||||
'settings.downloadRootRules.tagPlaceholder', {}, 'character, style, realistic'
|
||||
);
|
||||
tagInput.value = rule.match.join(', ');
|
||||
tagInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
tagInput.blur();
|
||||
}
|
||||
});
|
||||
tagInput.addEventListener('blur', () => {
|
||||
this.commitDownloadRootRuleTags(typeKey, index, tagInput.value, isDraft);
|
||||
});
|
||||
panel.appendChild(tagInput);
|
||||
} else {
|
||||
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;
|
||||
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);
|
||||
});
|
||||
this.renderDownloadRootRuleBaseModelList(list, typeKey, index, isDraft, summary);
|
||||
}
|
||||
|
||||
row.appendChild(panel);
|
||||
return row;
|
||||
}
|
||||
|
||||
renderDownloadRootRuleSummary(summaryEl, match) {
|
||||
parseDownloadRootRuleTags(rawValue) {
|
||||
const seen = new Set();
|
||||
return String(rawValue || '')
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => {
|
||||
if (!part) {
|
||||
return false;
|
||||
}
|
||||
const dedupeKey = part.toLowerCase();
|
||||
if (seen.has(dedupeKey)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(dedupeKey);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
setDownloadRootRuleMatchType(typeKey, index, matchType, isDraft) {
|
||||
if (matchType !== 'base_model' && matchType !== 'tag') {
|
||||
return;
|
||||
}
|
||||
if (isDraft) {
|
||||
const draft = this.downloadRootRuleDrafts[typeKey];
|
||||
if (!draft || draft.match_type === matchType) {
|
||||
return;
|
||||
}
|
||||
draft.match_type = matchType;
|
||||
this.renderDownloadRootRules();
|
||||
return;
|
||||
}
|
||||
const rules = this.getDownloadRootRules();
|
||||
const rule = rules[typeKey][index];
|
||||
if (!rule || rule.match_type === matchType) {
|
||||
return;
|
||||
}
|
||||
// Keep the match entries when switching: tags are free text, and
|
||||
// non-whitelist leftovers in a base_model rule are filtered by the
|
||||
// backend normalization on save.
|
||||
rule.match_type = matchType;
|
||||
this.downloadRootRuleOpenPanels.clear();
|
||||
this.downloadRootRuleSearches = {};
|
||||
this.renderDownloadRootRules();
|
||||
this.persistDownloadRootRules();
|
||||
}
|
||||
|
||||
commitDownloadRootRuleTags(typeKey, index, rawValue, isDraft) {
|
||||
const tags = this.parseDownloadRootRuleTags(rawValue);
|
||||
if (isDraft) {
|
||||
const draft = this.downloadRootRuleDrafts[typeKey];
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
if (!tags.length) {
|
||||
// Nothing committed yet; keep the draft for later editing.
|
||||
return;
|
||||
}
|
||||
// First committed tag list promotes the draft to a persisted
|
||||
// rule (mirrors the first base model checkbox selection).
|
||||
const rules = this.getDownloadRootRules();
|
||||
rules[typeKey].push({
|
||||
match_type: 'tag',
|
||||
match: tags,
|
||||
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;
|
||||
}
|
||||
if (!tags.length) {
|
||||
// A rule without matches can never hit; drop it (same as
|
||||
// unchecking the last base model of a rule).
|
||||
rules[typeKey].splice(index, 1);
|
||||
this.downloadRootRuleOpenPanels.clear();
|
||||
this.downloadRootRuleSearches = {};
|
||||
this.renderDownloadRootRules();
|
||||
this.persistDownloadRootRules();
|
||||
return;
|
||||
}
|
||||
if (JSON.stringify(rule.match) === JSON.stringify(tags)) {
|
||||
return;
|
||||
}
|
||||
rule.match = tags;
|
||||
this.renderDownloadRootRules();
|
||||
this.persistDownloadRootRules();
|
||||
}
|
||||
|
||||
renderDownloadRootRuleSummary(summaryEl, rule) {
|
||||
const match = Array.isArray(rule?.match) ? rule.match : [];
|
||||
if (rule?.match_type === 'tag' && match.length) {
|
||||
summaryEl.textContent = translate(
|
||||
'settings.downloadRootRules.summary.tags',
|
||||
{ tags: match.join(', ') },
|
||||
`tags: ${match.join(', ')}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!match.length) {
|
||||
summaryEl.textContent = translate('settings.downloadSkipBaseModels.summary.none', {}, 'None selected');
|
||||
return;
|
||||
@@ -4733,7 +4894,7 @@ export class SettingsManager {
|
||||
this.renderDownloadRootRules();
|
||||
if (opening) {
|
||||
document
|
||||
.querySelector(`#downloadRootRulesContainer .download-root-rule-row[data-rule-type="${typeKey}"][data-rule-index="${index}"] .base-model-skip-search`)
|
||||
.querySelector(`#downloadRootRulesContainer .download-root-rule-row[data-rule-type="${typeKey}"][data-rule-index="${index}"] .base-model-skip-search, #downloadRootRulesContainer .download-root-rule-row[data-rule-type="${typeKey}"][data-rule-index="${index}"] .download-root-rule-tag-input`)
|
||||
?.focus();
|
||||
}
|
||||
}
|
||||
@@ -4812,7 +4973,7 @@ export class SettingsManager {
|
||||
: draft.match.filter((model) => model !== baseModel);
|
||||
if (!draft.match.length) {
|
||||
if (summaryEl) {
|
||||
this.renderDownloadRootRuleSummary(summaryEl, draft.match);
|
||||
this.renderDownloadRootRuleSummary(summaryEl, draft);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -4850,7 +5011,7 @@ export class SettingsManager {
|
||||
return;
|
||||
}
|
||||
if (summaryEl) {
|
||||
this.renderDownloadRootRuleSummary(summaryEl, rule.match);
|
||||
this.renderDownloadRootRuleSummary(summaryEl, rule);
|
||||
}
|
||||
this.persistDownloadRootRules();
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
<div class="input-group">
|
||||
<label for="modelRoot" id="modelRootLabel">{{ t('modals.download.selectModelRoot') }}</label>
|
||||
<select id="modelRoot"></select>
|
||||
<div class="download-rule-match-hint" id="downloadRuleMatchHint" hidden></div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Path Selection (hidden when using default path) -->
|
||||
|
||||
@@ -107,6 +107,7 @@ describe('DownloadManager._resolveIsDiffusionModel', () => {
|
||||
model_type: 'checkpoint',
|
||||
base_model: 'Anima',
|
||||
file_types: ['Model'],
|
||||
tags: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -197,7 +198,6 @@ describe('DownloadManager._resolveOtherSubType', () => {
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('sends selected_file_type plus all version file types when a file is selected', async () => {
|
||||
manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'Model' }, { type: 'VAE' }] };
|
||||
manager.selectedFile = { type: 'VAE' };
|
||||
@@ -365,3 +365,91 @@ describe('DownloadManager.proceedToLocationContent (other page)', () => {
|
||||
expect(document.getElementById('modelRoot').value).toBe('/models/upscale');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('DownloadManager matched-rule hint', () => {
|
||||
let manager;
|
||||
let fetchMock;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<select id="modelRoot"></select>
|
||||
<label id="modelRootLabel"></label>
|
||||
<div class="download-rule-match-hint" id="downloadRuleMatchHint" hidden></div>
|
||||
`;
|
||||
state.global.settings = {};
|
||||
|
||||
manager = new DownloadManager();
|
||||
manager.apiClient = { modelType: 'loras' };
|
||||
manager.selectedFile = null;
|
||||
manager.selectedFiles = [];
|
||||
manager.currentVersion = null;
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('sends model tags to the routing endpoint and captures the matched rule', async () => {
|
||||
manager.currentVersion = {
|
||||
baseModel: 'Pony',
|
||||
files: [{ type: 'Model' }],
|
||||
modelTags: ['style', 'character'],
|
||||
};
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
root_kind: 'lora',
|
||||
resolved_root: '/lora-archive',
|
||||
matched_rule: { match_type: 'tag', match: ['character'] },
|
||||
}),
|
||||
});
|
||||
|
||||
const resolved = await manager._resolveRuleRoot();
|
||||
|
||||
expect(resolved).toBe('/lora-archive');
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body).tags).toEqual(['style', 'character']);
|
||||
expect(manager._matchedRule).toEqual({ match_type: 'tag', match: ['character'] });
|
||||
});
|
||||
|
||||
it('shows the matched tag rule next to the root selector', () => {
|
||||
manager.currentVersion = {
|
||||
baseModel: 'Pony',
|
||||
modelTags: ['style', 'character'],
|
||||
};
|
||||
manager._matchedRule = { match_type: 'tag', match: ['Character', 'style'] };
|
||||
|
||||
manager._renderRuleMatchHint();
|
||||
|
||||
const hint = document.getElementById('downloadRuleMatchHint');
|
||||
expect(hint.hidden).toBe(false);
|
||||
// Both entries hit; the rule's own order decides which one is shown.
|
||||
expect(hint.textContent).toBe('Routed by rule: tag "Character"');
|
||||
});
|
||||
|
||||
it('shows the matched base model rule', () => {
|
||||
manager.currentVersion = { baseModel: 'Flux.1 D', modelTags: [] };
|
||||
manager._matchedRule = { match_type: 'base_model', match: ['Flux.1 D'] };
|
||||
|
||||
manager._renderRuleMatchHint();
|
||||
|
||||
const hint = document.getElementById('downloadRuleMatchHint');
|
||||
expect(hint.hidden).toBe(false);
|
||||
expect(hint.textContent).toBe('Routed by rule: base model "Flux.1 D"');
|
||||
});
|
||||
|
||||
it('hides the hint when no rule matched', () => {
|
||||
manager.currentVersion = { baseModel: 'Pony', modelTags: [] };
|
||||
manager._matchedRule = null;
|
||||
|
||||
manager._renderRuleMatchHint();
|
||||
|
||||
const hint = document.getElementById('downloadRuleMatchHint');
|
||||
expect(hint.hidden).toBe(true);
|
||||
expect(hint.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -429,3 +429,162 @@ describe('SettingsManager download root rules default-root cue', () => {
|
||||
.toBe('Default: /models/lora-archive — unmatched downloads go here');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('SettingsManager download root rules tag matching', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.clearAllMocks();
|
||||
state.global.settings = {
|
||||
download_root_rules: { loras: [], checkpoints: [], unet: [], embeddings: [] },
|
||||
};
|
||||
});
|
||||
|
||||
it('normalizes tag rules as free text with case-insensitive dedupe', () => {
|
||||
const manager = createManager();
|
||||
|
||||
const normalized = manager.normalizeDownloadRootRules({
|
||||
loras: [
|
||||
{ match_type: 'tag', match: [' character ', 'Style', 'CHARACTER'], root: '/models/lora-archive' },
|
||||
{ match_type: 'tag', match: [], root: '/models/lora-archive' },
|
||||
{ match_type: 'unknown', match: ['x'], root: '/models/lora-archive' },
|
||||
{ match_type: 'tag', match: ['character'], root: '' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(normalized.loras).toEqual([
|
||||
{ match_type: 'tag', match: ['character', 'Style'], root: '/models/lora-archive' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders a tag rule with the match-type select, summary and tag input', () => {
|
||||
appendDownloadRootRulesUi();
|
||||
state.global.settings.download_root_rules = {
|
||||
loras: [{ match_type: 'tag', match: ['character', 'style'], root: '/models/lora-archive' }],
|
||||
checkpoints: [],
|
||||
unet: [],
|
||||
embeddings: [],
|
||||
};
|
||||
const manager = createManager();
|
||||
|
||||
manager.renderDownloadRootRules();
|
||||
|
||||
const row = groupRows('loras')[0];
|
||||
expect(row.querySelector('.download-root-rule-match-type').value).toBe('tag');
|
||||
expect(row.querySelector('.download-root-rule-summary').textContent)
|
||||
.toBe('tags: character, style');
|
||||
expect(row.querySelector('.download-root-rule-tag-input').value)
|
||||
.toBe('character, style');
|
||||
// No base model checkbox list for tag rules
|
||||
expect(row.querySelector('.base-model-skip-list')).toBeNull();
|
||||
});
|
||||
|
||||
it('switching a persisted rule to tag persists immediately and keeps the match list', 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 select = groupRows('loras')[0].querySelector('.download-root-rule-match-type');
|
||||
select.value = 'tag';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.saveSetting).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(manager.saveSetting).toHaveBeenCalledWith('download_root_rules', {
|
||||
loras: [{ match_type: 'tag', match: ['Flux.1 D'], root: '/models/lora-archive' }],
|
||||
checkpoints: [],
|
||||
unet: [],
|
||||
embeddings: [],
|
||||
});
|
||||
expect(groupRows('loras')[0].querySelector('.download-root-rule-tag-input')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('a draft switched to tag promotes on the first committed tag input', async () => {
|
||||
appendDownloadRootRulesUi();
|
||||
const manager = createManager();
|
||||
manager.renderDownloadRootRules();
|
||||
|
||||
manager.addDownloadRootRule('loras');
|
||||
const select = groupRows('loras')[0].querySelector('.download-root-rule-match-type');
|
||||
select.value = 'tag';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
|
||||
// Still a draft: nothing persisted yet
|
||||
expect(manager.saveSetting).not.toHaveBeenCalled();
|
||||
|
||||
const tagInput = groupRows('loras')[0].querySelector('.download-root-rule-tag-input');
|
||||
tagInput.value = 'character, Style, CHARACTER';
|
||||
tagInput.dispatchEvent(new Event('blur'));
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.saveSetting).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(manager.downloadRootRuleDrafts.loras).toBeUndefined();
|
||||
expect(manager.saveSetting).toHaveBeenCalledWith('download_root_rules', {
|
||||
loras: [{ match_type: 'tag', match: ['character', 'Style'], root: '/models/loras-main' }],
|
||||
checkpoints: [],
|
||||
unet: [],
|
||||
embeddings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('committing an empty tag list on a persisted rule removes the rule', async () => {
|
||||
appendDownloadRootRulesUi();
|
||||
state.global.settings.download_root_rules = {
|
||||
loras: [{ match_type: 'tag', match: ['character'], root: '/models/lora-archive' }],
|
||||
checkpoints: [],
|
||||
unet: [],
|
||||
embeddings: [],
|
||||
};
|
||||
const manager = createManager();
|
||||
manager.renderDownloadRootRules();
|
||||
|
||||
const tagInput = groupRows('loras')[0].querySelector('.download-root-rule-tag-input');
|
||||
tagInput.value = ' , ';
|
||||
tagInput.dispatchEvent(new Event('blur'));
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.saveSetting).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(manager.saveSetting).toHaveBeenCalledWith('download_root_rules', {
|
||||
loras: [],
|
||||
checkpoints: [],
|
||||
unet: [],
|
||||
embeddings: [],
|
||||
});
|
||||
expect(groupRows('loras')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('editing tags on a persisted rule parses, dedupes and persists', async () => {
|
||||
appendDownloadRootRulesUi();
|
||||
state.global.settings.download_root_rules = {
|
||||
loras: [{ match_type: 'tag', match: ['character'], root: '/models/lora-archive' }],
|
||||
checkpoints: [],
|
||||
unet: [],
|
||||
embeddings: [],
|
||||
};
|
||||
const manager = createManager();
|
||||
manager.renderDownloadRootRules();
|
||||
|
||||
const tagInput = groupRows('loras')[0].querySelector('.download-root-rule-tag-input');
|
||||
tagInput.value = 'realistic, style, REALISTIC';
|
||||
tagInput.dispatchEvent(new Event('blur'));
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.saveSetting).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(manager.saveSetting).toHaveBeenCalledWith('download_root_rules', {
|
||||
loras: [{ match_type: 'tag', match: ['realistic', 'style'], root: '/models/lora-archive' }],
|
||||
checkpoints: [],
|
||||
unet: [],
|
||||
embeddings: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ async def test_diffusion_base_model_routes_to_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"),
|
||||
"matched_rule": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -306,3 +307,81 @@ async def test_resolved_root_applies_unet_rules_after_diffusion_routing():
|
||||
payload = json.loads(response.text)
|
||||
assert payload["root_kind"] == "unet"
|
||||
assert payload["resolved_root"] == "/unet-archive"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matched_rule_is_null_when_no_rule_matches():
|
||||
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["matched_rule"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matched_rule_reports_base_model_hit():
|
||||
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"
|
||||
assert payload["matched_rule"] == {
|
||||
"match_type": "base_model",
|
||||
"match": ["Flux.1 D"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matched_rule_reports_tag_hit_from_payload_tags():
|
||||
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": "tag", "match": ["character"], "root": "/lora-archive"}
|
||||
]
|
||||
}
|
||||
|
||||
handler = DownloadRoutingHandler()
|
||||
response = await handler.get_download_routing(
|
||||
FakeRequest(
|
||||
{
|
||||
"model_type": "lora",
|
||||
"base_model": "Pony",
|
||||
"file_types": [],
|
||||
"tags": ["Style", "Character"],
|
||||
}
|
||||
)
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
assert payload["resolved_root"] == "/lora-archive"
|
||||
assert payload["matched_rule"] == {"match_type": "tag", "match": ["character"]}
|
||||
|
||||
# Without a matching tag the same rule does not fire.
|
||||
response = await handler.get_download_routing(
|
||||
FakeRequest(
|
||||
{
|
||||
"model_type": "lora",
|
||||
"base_model": "Pony",
|
||||
"file_types": [],
|
||||
"tags": ["style"],
|
||||
}
|
||||
)
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
assert payload["resolved_root"] == "/loras-main"
|
||||
assert payload["matched_rule"] is None
|
||||
|
||||
@@ -2541,3 +2541,47 @@ async def test_download_root_rule_applies_to_final_unet_type(
|
||||
assert result["success"] is True
|
||||
assert captured["model_type"] == "checkpoint"
|
||||
assert captured["save_dir"] == str(unet_archive / "Flux.1 D" / "fantasy")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_root_rule_tag_match_routes_to_rule_root(
|
||||
monkeypatch, scanners, metadata_provider, tmp_path
|
||||
):
|
||||
"""A tag rule hits on the model's CivitAI tags (case-insensitive) even
|
||||
when no base_model rule applies."""
|
||||
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": "tag",
|
||||
"match": ["Character"],
|
||||
"root": str(archive_root),
|
||||
}
|
||||
]
|
||||
}
|
||||
payload = _lora_payload("Pony")
|
||||
payload["model"]["tags"] = ["style", "character"]
|
||||
metadata_provider.payload = payload
|
||||
|
||||
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 str(captured["save_dir"]).startswith(str(archive_root))
|
||||
|
||||
@@ -271,3 +271,59 @@ def test_resolve_download_root_rule_lists_are_per_type():
|
||||
resolve_download_root(rules_by_type["checkpoints"], "Flux.1 D", "/default")
|
||||
== "/default"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_download_root_tag_rule_matches_model_tags():
|
||||
rules = [{"match_type": "tag", "match": ["character"], "root": "/nas"}]
|
||||
assert (
|
||||
resolve_download_root(rules, "Flux.1 D", "/default", tags=["character", "style"])
|
||||
== "/nas"
|
||||
)
|
||||
assert (
|
||||
resolve_download_root(rules, "Flux.1 D", "/default", tags=["style"])
|
||||
== "/default"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_download_root_tag_match_is_case_insensitive_and_trimmed():
|
||||
rules = [{"match_type": "tag", "match": [" Character "], "root": "/nas"}]
|
||||
assert (
|
||||
resolve_download_root(rules, "", "/default", tags=["CHARACTER"])
|
||||
== "/nas"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_download_root_tag_rule_matches_without_base_model():
|
||||
"""Tag rules hit even when the model has no usable baseModel value."""
|
||||
rules = [{"match_type": "tag", "match": ["character"], "root": "/nas"}]
|
||||
assert resolve_download_root(rules, "", "/default", tags=["character"]) == "/nas"
|
||||
|
||||
|
||||
def test_resolve_download_root_first_match_wins_across_match_types():
|
||||
"""A tag rule listed before a base_model rule takes priority."""
|
||||
rules = [
|
||||
{"match_type": "tag", "match": ["character"], "root": "/nas"},
|
||||
{"match_type": "base_model", "match": ["Flux.1 D"], "root": "/main"},
|
||||
]
|
||||
assert (
|
||||
resolve_download_root(rules, "Flux.1 D", "/default", tags=["character"])
|
||||
== "/nas"
|
||||
)
|
||||
# Without the tag, the base_model rule still applies.
|
||||
assert (
|
||||
resolve_download_root(rules, "Flux.1 D", "/default", tags=["style"])
|
||||
== "/main"
|
||||
)
|
||||
|
||||
|
||||
def test_match_download_root_rule_returns_the_matching_rule():
|
||||
from py.services.download_routing import match_download_root_rule
|
||||
|
||||
rules = [
|
||||
{"match_type": "tag", "match": ["character"], "root": "/nas"},
|
||||
{"match_type": "base_model", "match": ["Pony"], "root": "/main"},
|
||||
]
|
||||
rule = match_download_root_rule(rules, "Pony", ["character"])
|
||||
assert rule["match_type"] == "tag"
|
||||
assert match_download_root_rule(rules, "Pony", ["style"])["match_type"] == "base_model"
|
||||
assert match_download_root_rule(rules, "SDXL 1.0", ["style"]) is None
|
||||
|
||||
@@ -1759,3 +1759,77 @@ def test_set_download_root_rules_normalizes(manager):
|
||||
assert manager.get("download_root_rules")["loras"] == [
|
||||
{"match_type": "base_model", "match": ["Flux.1 D"], "root": "/loras-archive"}
|
||||
]
|
||||
|
||||
|
||||
def test_download_root_rules_normalize_keeps_tag_rules_with_free_text(manager):
|
||||
"""Tag rules accept arbitrary free-text tags (no base model whitelist),
|
||||
trimmed and deduplicated case-insensitively."""
|
||||
_configure_lora_roots(manager, "/loras-archive")
|
||||
manager.settings["download_root_rules"] = {
|
||||
"loras": [
|
||||
{
|
||||
"match_type": "tag",
|
||||
"match": [" character ", "Style", "STYLE", 42, ""],
|
||||
"root": "/loras-archive",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = manager.get_download_root_rules()
|
||||
|
||||
assert result["loras"] == [
|
||||
{
|
||||
"match_type": "tag",
|
||||
"match": ["character", "Style"],
|
||||
"root": "/loras-archive",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_download_root_rules_normalize_drops_tag_rule_with_empty_match(manager, caplog):
|
||||
_configure_lora_roots(manager, "/loras-archive")
|
||||
manager.settings["download_root_rules"] = {
|
||||
"loras": [
|
||||
{"match_type": "tag", "match": ["", " "], "root": "/loras-archive"}
|
||||
]
|
||||
}
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = manager.get_download_root_rules()
|
||||
|
||||
assert result["loras"] == []
|
||||
assert "tag" in caplog.text
|
||||
|
||||
|
||||
def test_download_root_rules_normalize_preserves_mixed_rule_order(manager):
|
||||
"""base_model and tag rules can be interleaved; order is significant."""
|
||||
_configure_lora_roots(manager, "/nas", "/main")
|
||||
manager.settings["download_root_rules"] = {
|
||||
"loras": [
|
||||
{"match_type": "tag", "match": ["character"], "root": "/nas"},
|
||||
{"match_type": "base_model", "match": ["Pony"], "root": "/main"},
|
||||
]
|
||||
}
|
||||
|
||||
result = manager.get_download_root_rules()
|
||||
|
||||
assert [rule["match_type"] for rule in result["loras"]] == [
|
||||
"tag",
|
||||
"base_model",
|
||||
]
|
||||
assert [rule["root"] for rule in result["loras"]] == ["/nas", "/main"]
|
||||
|
||||
|
||||
def test_download_root_rules_tag_rules_still_validate_root(manager, caplog):
|
||||
_configure_lora_roots(manager, "/loras-main")
|
||||
manager.settings["download_root_rules"] = {
|
||||
"loras": [
|
||||
{"match_type": "tag", "match": ["character"], "root": "/not-configured"}
|
||||
]
|
||||
}
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = manager.get_download_root_rules()
|
||||
|
||||
assert result["loras"] == []
|
||||
assert "/not-configured" in caplog.text
|
||||
|
||||
Reference in New Issue
Block a user