diff --git a/locales/de.json b/locales/de.json index 2e829ab0..33bd219b 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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", diff --git a/locales/en.json b/locales/en.json index 4cc0efe2..b1348c12 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/es.json b/locales/es.json index 5a5a3cdb..590533c8 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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", diff --git a/locales/fr.json b/locales/fr.json index 3376a06d..505d2699 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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", diff --git a/locales/he.json b/locales/he.json index 9055f84d..d1217233 100644 --- a/locales/he.json +++ b/locales/he.json @@ -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": "צור תיקייה חדשה", diff --git a/locales/ja.json b/locales/ja.json index 57b2bae6..0c14a09d 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -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": "新しいフォルダを作成", diff --git a/locales/ko.json b/locales/ko.json index 2dfb91c0..b5767fd3 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -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": "새 폴더 만들기", diff --git a/locales/ru.json b/locales/ru.json index ff7f4f26..7c925f58 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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": "Создать новую папку", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index ed5ef14e..75971d4a 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -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": "新建文件夹", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index b3bcb42c..36d5fbb5 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -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": "建立新資料夾", diff --git a/py/routes/handlers/download_routing_handlers.py b/py/routes/handlers/download_routing_handlers.py index 20fa2086..6aa4c1f3 100644 --- a/py/routes/handlers/download_routing_handlers.py +++ b/py/routes/handlers/download_routing_handlers.py @@ -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, } ) diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index ff9730f7..aefeaf44 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -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: diff --git a/py/services/download_manager.py b/py/services/download_manager.py index d06d64af..568bda57 100644 --- a/py/services/download_manager.py +++ b/py/services/download_manager.py @@ -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( diff --git a/py/services/download_routing.py b/py/services/download_routing.py index 84fbac1f..ae84686c 100644 --- a/py/services/download_routing.py +++ b/py/services/download_routing.py @@ -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 diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 049a0b03..f624cb2d 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -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, } ) diff --git a/static/css/components/modal/download-modal.css b/static/css/components/modal/download-modal.css index 1ea968ff..da69d019 100644 --- a/static/css/components/modal/download-modal.css +++ b/static/css/components/modal/download-modal.css @@ -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; diff --git a/static/css/components/modal/settings-modal.css b/static/css/components/modal/settings-modal.css index f92c834c..8cdbdf67 100644 --- a/static/css/components/modal/settings-modal.css +++ b/static/css/components/modal/settings-modal.css @@ -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; diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index 15c710c6..9c2d10f4 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -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); diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index c70c2903..f9bb9af5 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -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(); } diff --git a/templates/components/modals/download_modal.html b/templates/components/modals/download_modal.html index 45c2073c..2174f641 100644 --- a/templates/components/modals/download_modal.html +++ b/templates/components/modals/download_modal.html @@ -91,6 +91,7 @@
+
diff --git a/tests/frontend/managers/downloadManager.routing.test.js b/tests/frontend/managers/downloadManager.routing.test.js index b035b6bd..85c18fda 100644 --- a/tests/frontend/managers/downloadManager.routing.test.js +++ b/tests/frontend/managers/downloadManager.routing.test.js @@ -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 = ` + + + + `; + 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(''); + }); +}); diff --git a/tests/frontend/managers/settingsManager.downloadRootRules.test.js b/tests/frontend/managers/settingsManager.downloadRootRules.test.js index 5a1c96c8..12847937 100644 --- a/tests/frontend/managers/settingsManager.downloadRootRules.test.js +++ b/tests/frontend/managers/settingsManager.downloadRootRules.test.js @@ -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: [], + }); + }); +}); diff --git a/tests/routes/test_download_routing_handlers.py b/tests/routes/test_download_routing_handlers.py index d26b7ced..116328a8 100644 --- a/tests/routes/test_download_routing_handlers.py +++ b/tests/routes/test_download_routing_handlers.py @@ -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 diff --git a/tests/services/test_download_manager_basic.py b/tests/services/test_download_manager_basic.py index 80c8c3fc..168f9a75 100644 --- a/tests/services/test_download_manager_basic.py +++ b/tests/services/test_download_manager_basic.py @@ -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)) diff --git a/tests/services/test_download_routing.py b/tests/services/test_download_routing.py index d363b5fd..df2b1dcf 100644 --- a/tests/services/test_download_routing.py +++ b/tests/services/test_download_routing.py @@ -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 diff --git a/tests/services/test_settings_manager.py b/tests/services/test_settings_manager.py index 873e12cd..301eb245 100644 --- a/tests/services/test_settings_manager.py +++ b/tests/services/test_settings_manager.py @@ -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