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 @@