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:
Will Miao
2026-10-09 22:32:25 +08:00
parent 198232f3a4
commit c372a688de
26 changed files with 1024 additions and 74 deletions
+12
View File
@@ -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",
+12
View File
@@ -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",
+12
View File
@@ -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",
+12
View File
@@ -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",
+12
View File
@@ -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": "צור תיקייה חדשה",
+12
View File
@@ -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": "新しいフォルダを作成",
+12
View File
@@ -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": "새 폴더 만들기",
+12
View File
@@ -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": "Создать новую папку",
+12
View File
@@ -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": "新建文件夹",
+12
View File
@@ -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,
}
)
+5
View File
@@ -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:
+16 -7
View File
@@ -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(
+68 -24
View File
@@ -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
+23 -14
View File
@@ -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;
+49
View File
@@ -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);
+186 -25
View File
@@ -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))
+56
View File
@@ -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
+74
View File
@@ -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