feat: add configurable download path system with templates and mappings

Implements a new download path configuration UI and backend logic:
- Add Subfolder fill mode: Suggested (existing), Template, Manual
- Add Base Model Path Mappings to override detected base-model folders
- Add Default Root settings to control root directory per category
- Add Detect From Existing Folders button that infers templates and mappings from local model folders
- Add backend /model_resolver/root-directories and /model_resolver/path-template-suggestions routes
- Refactor resolver settings into core/settings.py for shared frontend/backend use
- Extract path template inference logic into core/path_templates.py
- Update download panel, context menu, and loading flow to use new path settings
This commit is contained in:
Dariusz L
2026-06-19 00:09:58 +02:00
parent 9399f52033
commit 47d286620f
14 changed files with 2030 additions and 95 deletions
+132 -77
View File
@@ -78,7 +78,15 @@ class ModelResolverExtension:
apply_resolution,
search_local_matches,
)
from .core.path_templates import infer_download_path_templates
from .core.scanner import get_model_files, invalidate_model_files_cache
from .core.settings import (
bool_setting as resolver_bool_setting,
get_default_root_for_category,
load_settings as load_resolver_settings,
resolve_download_subfolder,
save_settings as save_resolver_settings,
)
except ImportError as e:
self.logger.error(f"Model Resolver: Could not import core modules: {e}")
return False
@@ -1756,6 +1764,18 @@ class ModelResolverExtension:
category = normalize_download_category(category)
subfolder = data.get("subfolder", "")
base_directory = data.get("base_directory", "")
path_metadata = data.get("path_metadata", {})
if not isinstance(path_metadata, dict):
path_metadata = {}
settings = load_resolver_settings()
if not base_directory:
base_directory = get_default_root_for_category(category, settings)
subfolder = resolve_download_subfolder(
category,
subfolder,
path_metadata,
settings,
)
if not url:
return web.json_response(
@@ -1914,6 +1934,75 @@ class ModelResolverExtension:
)
return web.json_response({"error": str(e)}, status=500)
@routes.get("/model_resolver/root-directories")
async def get_root_directories(request):
"""Get configured ComfyUI root directories for path settings."""
try:
import os
import folder_paths
categories = [
"loras",
"checkpoints",
"diffusion_models",
"embeddings",
"text_encoders",
"vae",
"upscale_models",
]
roots = {}
for cat in categories:
folder_key = normalize_download_category(cat)
candidate_keys = [folder_key]
if folder_key == "diffusion_models":
candidate_keys.append("unet")
elif folder_key == "text_encoders":
candidate_keys.append("clip")
paths = []
for candidate_key in candidate_keys:
paths.extend(folder_paths.get_folder_paths(candidate_key) or [])
normalized_paths = []
seen = set()
for path in paths:
if not path:
continue
path_abs = os.path.abspath(path)
path_key = os.path.normcase(path_abs)
if path_key in seen:
continue
seen.add(path_key)
normalized_paths.append(path_abs)
roots[folder_key] = normalized_paths
return web.json_response(roots)
except Exception as e:
self.logger.error(
f"Model Resolver root directories error: {e}", exc_info=True
)
return web.json_response({"error": str(e)}, status=500)
@routes.get("/model_resolver/path-template-suggestions")
async def get_path_template_suggestions(request):
"""Infer path template presets from existing local model folders."""
try:
from .core.sources.popular import get_base_models_config
force_rescan = request.query.get("force") == "1"
models = await asyncio.to_thread(get_model_files, force_rescan)
base_models_config = get_base_models_config()
suggestions = await asyncio.to_thread(
infer_download_path_templates,
models,
base_models_config,
)
return web.json_response(suggestions)
except Exception as e:
self.logger.error(
f"Model Resolver path template suggestions error: {e}",
exc_info=True,
)
return web.json_response({"error": str(e)}, status=500)
@routes.get("/model_resolver/capabilities")
async def get_capabilities(request):
"""Get optional source capabilities available in this install."""
@@ -1946,7 +2035,15 @@ class ModelResolverExtension:
return web.json_response([])
known_categories = set(folder_paths.folder_names_and_paths.keys())
if category not in known_categories:
folder_keys = [category]
if category == "diffusion_models":
folder_keys.append("unet")
elif category == "text_encoders":
folder_keys.append("clip")
available_folder_keys = [
folder_key for folder_key in folder_keys if folder_key in known_categories
]
if not available_folder_keys:
self.logger.debug(
f"Model Resolver: skipping subfolder lookup for unknown category '{raw_category}' -> '{category}'"
)
@@ -1974,11 +2071,18 @@ class ModelResolverExtension:
"base_directory": base_dir,
}
base_dirs = [
base_dir
for base_dir in (folder_paths.get_folder_paths(category) or [])
if base_dir and os.path.isdir(base_dir)
]
base_dirs = []
seen_base_dirs = set()
for folder_key in available_folder_keys:
for base_dir in folder_paths.get_folder_paths(folder_key) or []:
if not base_dir or not os.path.isdir(base_dir):
continue
base_abs = os.path.abspath(base_dir)
base_key = os.path.normcase(base_abs)
if base_key in seen_base_dirs:
continue
seen_base_dirs.add(base_key)
base_dirs.append(base_abs)
def find_base_dir(full_path):
if not full_path:
@@ -1996,24 +2100,25 @@ class ModelResolverExtension:
continue
return ""
filenames = folder_paths.get_filename_list(category) or []
for rel_path in filenames:
if not isinstance(rel_path, str):
continue
base_dir = ""
try:
base_dir = find_base_dir(
folder_paths.get_full_path(category, rel_path)
)
except Exception:
for folder_key in available_folder_keys:
filenames = folder_paths.get_filename_list(folder_key) or []
for rel_path in filenames:
if not isinstance(rel_path, str):
continue
base_dir = ""
parts = [p for p in rel_path.replace("/", "\\").split("\\") if p]
if len(parts) <= 1:
continue
current = ""
for part in parts[:-1]:
current = f"{current}\\{part}" if current else part
add_subfolder(current, base_dir)
try:
base_dir = find_base_dir(
folder_paths.get_full_path(folder_key, rel_path)
)
except Exception:
base_dir = ""
parts = [p for p in rel_path.replace("/", "\\").split("\\") if p]
if len(parts) <= 1:
continue
current = ""
for part in parts[:-1]:
current = f"{current}\\{part}" if current else part
add_subfolder(current, base_dir)
for base_dir in base_dirs:
for root, dirs, _files in os.walk(base_dir):
@@ -2065,47 +2170,6 @@ class ModelResolverExtension:
# ==================== SETTINGS (server-side persistence) ====================
import os as _os
import json as _json
_SETTINGS_FILE = _os.path.join(
_os.path.dirname(_os.path.abspath(__file__)),
"model_resolver_settings.json",
)
# Keys that contain sensitive credentials – stored in the file but
# never logged.
_CREDENTIAL_KEYS = {
"civitai_key",
"civitai_session_token",
"hf_token",
"brave_search_api_key",
}
def _load_settings() -> dict:
try:
if _os.path.isfile(_SETTINGS_FILE):
with open(_SETTINGS_FILE, "r", encoding="utf-8") as f:
data = _json.load(f)
if isinstance(data, dict):
return data
except Exception as exc:
self.logger.warning(f"Model Resolver: could not read settings file: {exc}")
return {}
def _bool_setting(value, default: bool = True) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return bool(value)
def _log_level_setting(value, default: str = BACKEND_DEFAULT_LOG_LEVEL) -> LogLevel:
normalized = str(value or default or "INFO").strip().upper()
if hasattr(LogLevel, normalized):
@@ -2114,27 +2178,18 @@ class ModelResolverExtension:
return getattr(LogLevel, fallback, LogLevel.INFO)
def _apply_backend_logging_settings(settings: dict) -> None:
enabled = _bool_setting(settings.get("backend_logs_enabled"), True)
enabled = resolver_bool_setting(settings.get("backend_logs_enabled"), True)
level = _log_level_setting(settings.get("backend_log_level"))
backend_log_controller.set_enabled(enabled)
backend_log_controller.set_global_level(level)
def _save_settings(payload: dict) -> dict:
# Merge with existing settings so we never lose keys not sent
# by the current request.
current = _load_settings()
current.update({k: v for k, v in payload.items() if k})
with open(_SETTINGS_FILE, "w", encoding="utf-8") as f:
_json.dump(current, f, indent=2, ensure_ascii=False)
return current
_apply_backend_logging_settings(_load_settings())
_apply_backend_logging_settings(load_resolver_settings())
@routes.get("/model_resolver/settings")
async def get_settings_route(request):
"""Return persisted settings (API keys, preferences)."""
try:
data = await asyncio.to_thread(_load_settings)
data = await asyncio.to_thread(load_resolver_settings)
return web.json_response(data)
except Exception as e:
self.logger.error(f"Model Resolver settings GET error: {e}", exc_info=True)
@@ -2147,7 +2202,7 @@ class ModelResolverExtension:
payload = await request.json()
if not isinstance(payload, dict):
return web.json_response({"error": "Expected JSON object"}, status=400)
settings = await asyncio.to_thread(_save_settings, payload)
settings = await asyncio.to_thread(save_resolver_settings, payload)
_apply_backend_logging_settings(settings)
return web.json_response({"success": True})
except Exception as e:
+14 -1
View File
@@ -118,6 +118,11 @@ def get_download_directory(category: str, preferred_base_directory: str = "") ->
return None
folder_key = normalize_download_category(category)
folder_keys = [folder_key]
if folder_key == "diffusion_models":
folder_keys.append("unet")
elif folder_key == "text_encoders":
folder_keys.append("clip")
def _normalize(path_value: str) -> str:
return os.path.normcase(os.path.abspath(path_value))
@@ -176,7 +181,15 @@ def get_download_directory(category: str, preferred_base_directory: str = "") ->
return paths[0]
try:
paths = folder_paths.get_folder_paths(folder_key)
paths = []
seen_paths = set()
for candidate_key in folder_keys:
for path in folder_paths.get_folder_paths(candidate_key) or []:
path_key = _normalize(path)
if path_key in seen_paths:
continue
seen_paths.add(path_key)
paths.append(path)
if paths:
if preferred_base_directory:
preferred_normalized = _normalize(preferred_base_directory)
+291
View File
@@ -0,0 +1,291 @@
"""Infer download path templates from existing local model folders."""
from __future__ import annotations
import re
from collections import Counter, defaultdict
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
from .settings import (
normalize_download_category,
normalize_relative_subfolder,
sanitize_folder_name,
)
TEMPLATE_CATEGORIES = (
"loras",
"checkpoints",
"embeddings",
"diffusion_models",
"text_encoders",
"controlnet",
"vae",
"upscale_models",
)
TAG_FOLDER_TOKENS = {
"concept",
"concepts",
"style",
"styles",
"character",
"characters",
"clothing",
"clothes",
"pose",
"poses",
"object",
"objects",
"vehicle",
"vehicles",
"artist",
"artists",
"celebrity",
"celebrities",
}
def _normalize_token(value: Any) -> str:
return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())
def _split_relative_folder_segments(relative_path: Any) -> List[str]:
parts = [
part.strip()
for part in str(relative_path or "").replace("\\", "/").split("/")
if part.strip()
]
return parts[:-1] if len(parts) > 1 else []
def _get_base_models(base_models_config: Any) -> Iterable[Mapping[str, Any]]:
if isinstance(base_models_config, Mapping):
base_models = base_models_config.get("base_models", [])
else:
base_models = base_models_config
if isinstance(base_models, Sequence) and not isinstance(base_models, (str, bytes)):
for item in base_models:
if isinstance(item, Mapping):
yield item
def _build_base_model_alias_index(base_models_config: Any) -> Dict[str, Dict[str, str]]:
index: Dict[str, Dict[str, str]] = {}
for item in _get_base_models(base_models_config):
name = str(item.get("name") or "").strip()
if not name:
continue
aliases = item.get("aliases") or []
values = [name, *aliases] if isinstance(aliases, list) else [name]
for value in values:
token = _normalize_token(value)
if token and token not in index:
index[token] = {"name": name, "alias": str(value or "")}
return index
def _match_base_model_segment(
segment: str,
base_alias_index: Mapping[str, Dict[str, str]],
) -> Optional[Dict[str, str]]:
token = _normalize_token(segment)
if not token:
return None
if token in base_alias_index:
return dict(base_alias_index[token])
for alias_token, match in sorted(
base_alias_index.items(),
key=lambda item: len(item[0]),
reverse=True,
):
if len(alias_token) < 4:
continue
if token.startswith(alias_token) or alias_token in token:
return dict(match)
return None
def _join_mapping_segments(segments: Sequence[str]) -> str:
return normalize_relative_subfolder("/".join(str(segment or "") for segment in segments))
def _match_base_model_path(
segments: Sequence[str],
base_alias_index: Mapping[str, Dict[str, str]],
) -> Optional[Dict[str, Any]]:
"""Find the deepest recognized base-model segment in a folder prefix."""
best_match: Optional[Dict[str, Any]] = None
for index, segment in enumerate(segments):
match = _match_base_model_segment(segment, base_alias_index)
if not match:
continue
best_match = {
**match,
"segment": segment,
"index": index,
"path": _join_mapping_segments(segments[: index + 1]),
}
return best_match
def _is_tag_like_segment(segment: str) -> bool:
return _normalize_token(segment) in TAG_FOLDER_TOKENS
def _reason_for_template(template: str, confidence: float) -> str:
percent = round(confidence * 100)
if template == "{base_model}/{first_tag}":
return f"{percent}% foldered models use a base-model folder with a second-level grouping."
if template == "{base_model}":
return f"{percent}% foldered models use a recognized base-model folder path."
if template == "{first_tag}":
return f"{percent}% foldered models start with a tag-like folder."
if template == "":
return f"{percent}% models are stored directly in the root folder."
return f"{percent}% confidence."
def _infer_category_template(records: List[Dict[str, Any]]) -> Dict[str, Any]:
total = len(records)
if total == 0:
return {
"template": "",
"confidence": 0.0,
"apply": False,
"model_count": 0,
"foldered_count": 0,
"reason": "No local models found for this category.",
"samples": [],
}
foldered_count = sum(1 for record in records if record["segments"])
flat_count = total - foldered_count
foldered_denominator = max(foldered_count, 1)
flat_ratio = flat_count / total
base_first_count = sum(1 for record in records if record["base_model"])
base_flat_count = sum(
1
for record in records
if record["base_model"] and record.get("segments_after_base", 0) == 0
)
base_grouped_count = sum(
1
for record in records
if record["base_model"] and record.get("segments_after_base", 0) >= 1
)
tag_first_count = sum(1 for record in records if record["tag_like_first"])
base_first_ratio = base_first_count / foldered_denominator
base_flat_ratio = base_flat_count / foldered_denominator
base_grouped_ratio = base_grouped_count / foldered_denominator
tag_first_ratio = tag_first_count / foldered_denominator
template = ""
confidence = flat_ratio
if flat_ratio >= 0.65:
template = ""
confidence = flat_ratio
elif base_grouped_ratio >= 0.45 and base_first_ratio >= 0.5:
template = "{base_model}/{first_tag}"
confidence = min(1.0, (base_grouped_ratio * 0.75) + (base_first_ratio * 0.25))
elif base_first_ratio >= 0.45:
template = "{base_model}"
confidence = max(base_first_ratio, base_flat_ratio)
elif tag_first_ratio >= 0.45:
template = "{first_tag}"
confidence = tag_first_ratio
apply = total >= 2 and confidence >= 0.45
samples = [record["relative_path"] for record in records if record["segments"]][:5]
return {
"template": template,
"confidence": round(confidence, 3),
"apply": apply,
"model_count": total,
"foldered_count": foldered_count,
"flat_count": flat_count,
"base_model_count": base_first_count,
"base_model_folder_count": base_flat_count,
"base_model_tag_count": base_grouped_count,
"tag_like_count": tag_first_count,
"reason": _reason_for_template(template, confidence),
"samples": samples,
}
def infer_download_path_templates(
models: Iterable[Mapping[str, Any]],
base_models_config: Any,
) -> Dict[str, Any]:
"""Return template and base-model mapping suggestions for local model folders."""
base_alias_index = _build_base_model_alias_index(base_models_config)
grouped: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
mapping_votes: Dict[str, Counter] = defaultdict(Counter)
seen = set()
for model in models or []:
category = normalize_download_category(str(model.get("category") or ""))
if category not in TEMPLATE_CATEGORIES:
continue
relative_path = str(model.get("relative_path") or model.get("filename") or "")
model_key = (
category,
str(model.get("path") or ""),
relative_path,
)
if model_key in seen:
continue
seen.add(model_key)
segments = _split_relative_folder_segments(relative_path)
base_match = _match_base_model_path(segments, base_alias_index) if segments else None
segments_after_base = (
len(segments) - int(base_match.get("index", 0)) - 1
if base_match
else 0
)
tag_like_first = _is_tag_like_segment(segments[0]) if segments else False
grouped[category].append(
{
"relative_path": relative_path,
"segments": segments,
"base_model": base_match,
"base_model_path": base_match.get("path", "") if base_match else "",
"segments_after_base": segments_after_base,
"tag_like_first": tag_like_first,
}
)
if base_match and segments:
base_name = base_match.get("name") or ""
base_path = str(base_match.get("path") or "")
if base_name and base_path and sanitize_folder_name(base_name, base_name) != base_path:
mapping_votes[base_name][base_path] += 1
categories = {
category: _infer_category_template(grouped.get(category, []))
for category in TEMPLATE_CATEGORIES
}
templates = {
category: suggestion["template"]
for category, suggestion in categories.items()
if suggestion.get("apply")
}
base_model_path_mappings = {
base_name: votes.most_common(1)[0][0]
for base_name, votes in mapping_votes.items()
if votes
}
return {
"templates": templates,
"base_model_path_mappings": base_model_path_mappings,
"categories": categories,
"total_models": sum(item.get("model_count", 0) for item in categories.values()),
}
+412
View File
@@ -0,0 +1,412 @@
"""Shared settings helpers for Model Resolver."""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any, Dict, Iterable, Mapping, Optional
SETTINGS_FILE = Path(__file__).resolve().parents[1] / "model_resolver_settings.json"
DOWNLOAD_PATH_MODES = {"suggested", "template", "manual"}
CATEGORY_MAP = {
"checkpoint": "checkpoints",
"checkpoints": "checkpoints",
"lora": "loras",
"loras": "loras",
"embedding": "embeddings",
"embeddings": "embeddings",
"diffusion_model": "diffusion_models",
"diffusion_models": "diffusion_models",
"unet": "diffusion_models",
"clip": "text_encoders",
"clips": "text_encoders",
"text_encoder": "text_encoders",
"text_encoders": "text_encoders",
"controlnet": "controlnet",
"control_net": "controlnet",
"vae": "vae",
"upscaler": "upscale_models",
"upscale_model": "upscale_models",
"upscale_models": "upscale_models",
}
DEFAULT_DOWNLOAD_PATH_TEMPLATES: Dict[str, str] = {
"loras": "{base_model}/{first_tag}",
"checkpoints": "{base_model}",
"embeddings": "{base_model}",
"diffusion_models": "{base_model}",
"text_encoders": "",
"controlnet": "{base_model}",
"vae": "",
"upscale_models": "",
}
DEFAULT_ROOT_KEYS = {
"loras": "default_lora_root",
"checkpoints": "default_checkpoint_root",
"diffusion_models": "default_unet_root",
"embeddings": "default_embedding_root",
"text_encoders": "default_text_encoder_root",
"vae": "default_vae_root",
"upscale_models": "default_upscale_model_root",
}
TEMPLATE_KEY_ALIASES = {
"loras": ("loras", "lora"),
"checkpoints": ("checkpoints", "checkpoint"),
"embeddings": ("embeddings", "embedding"),
"diffusion_models": ("diffusion_models", "diffusion_model", "unet"),
"text_encoders": ("text_encoders", "text_encoder", "clip", "clips"),
"controlnet": ("controlnet", "control_net"),
"vae": ("vae",),
"upscale_models": ("upscale_models", "upscale_model", "upscaler"),
}
PRIORITY_TAGS = (
"concept",
"style",
"character",
"clothing",
"pose",
"object",
"vehicle",
"artist",
"celebrity",
)
_INVALID_SEGMENT_CHARS_RE = re.compile(r'[<>:"|?*\x00-\x1f]+')
_UNKNOWN_PLACEHOLDER_RE = re.compile(r"\{[^{}]+\}")
def bool_setting(value: Any, default: bool = True) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return bool(value)
def normalize_download_category(category: str) -> str:
token = (
str(category or "")
.strip()
.lower()
.replace("\\", "_")
.replace("/", "_")
.replace("-", "_")
.replace(" ", "_")
)
while "__" in token:
token = token.replace("__", "_")
token = token.strip("_")
return CATEGORY_MAP.get(token, token or "checkpoints")
def sanitize_folder_name(value: Any, fallback: str = "") -> str:
text = str(value or "").strip()
if not text:
text = fallback
text = text.replace("/", "_").replace("\\", "_")
text = _INVALID_SEGMENT_CHARS_RE.sub("_", text)
text = re.sub(r"\s+", " ", text).strip(" .")
if text in {"", ".", ".."}:
text = fallback
return text.strip(" .")
def normalize_relative_subfolder(path_value: Any) -> str:
text = str(path_value or "").replace("\\", "/").strip()
if not text:
return ""
parts = []
for raw_part in text.split("/"):
part = sanitize_folder_name(raw_part)
if part and part not in {".", ".."}:
parts.append(part)
return "/".join(parts)
def normalize_download_path_template(template: Any) -> str:
text = str(template or "").replace("\\", "/").strip()
if not text:
return ""
parts = []
for raw_part in text.split("/"):
part = raw_part.strip()
if not part or part in {".", ".."}:
continue
parts.append(part)
return "/".join(parts)
def _read_settings_file() -> Dict[str, Any]:
try:
if SETTINGS_FILE.is_file():
with SETTINGS_FILE.open("r", encoding="utf-8") as handle:
data = json.load(handle)
if isinstance(data, dict):
return data
except Exception:
return {}
return {}
def _coerce_dict(value: Any) -> Dict[str, Any]:
if isinstance(value, dict):
return dict(value)
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return {}
if isinstance(parsed, dict):
return dict(parsed)
return {}
def normalize_download_path_templates(value: Any) -> Dict[str, str]:
raw_templates = _coerce_dict(value)
normalized = dict(DEFAULT_DOWNLOAD_PATH_TEMPLATES)
for raw_key, raw_template in raw_templates.items():
key = normalize_download_category(str(raw_key))
normalized[key] = normalize_download_path_template(raw_template)
return normalized
def normalize_base_model_path_mappings(value: Any) -> Dict[str, str]:
raw_mappings = _coerce_dict(value)
mappings: Dict[str, str] = {}
for raw_key, raw_value in raw_mappings.items():
key = str(raw_key or "").strip()
value = str(raw_value or "").strip()
if key and value:
mappings[key] = value
return mappings
def normalize_download_path_mode(value: Any, auto_fill_subfolder: Any = None) -> str:
mode = str(value or "").strip().lower()
if mode in DOWNLOAD_PATH_MODES:
return mode
if auto_fill_subfolder is False:
return "manual"
return "suggested"
def normalize_settings(settings: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
data = dict(settings or {})
data["download_path_mode"] = normalize_download_path_mode(
data.get("download_path_mode"), data.get("auto_fill_subfolder")
)
data["download_path_templates"] = normalize_download_path_templates(
data.get("download_path_templates")
)
data["base_model_path_mappings"] = normalize_base_model_path_mappings(
data.get("base_model_path_mappings")
)
for key in DEFAULT_ROOT_KEYS.values():
data[key] = str(data.get(key) or "").strip()
return data
def load_settings() -> Dict[str, Any]:
return normalize_settings(_read_settings_file())
def save_settings(payload: Mapping[str, Any]) -> Dict[str, Any]:
current = _read_settings_file()
current.update({str(key): value for key, value in payload.items() if key})
normalized = normalize_settings(current)
SETTINGS_FILE.write_text(
json.dumps(normalized, indent=2, ensure_ascii=False),
encoding="utf-8",
)
return normalized
def _template_for_category(settings: Mapping[str, Any], category: str) -> str:
templates = normalize_download_path_templates(settings.get("download_path_templates"))
category_key = normalize_download_category(category)
aliases = TEMPLATE_KEY_ALIASES.get(category_key, (category_key,))
for alias in aliases:
if alias in templates:
return templates.get(alias, "")
return templates.get(category_key, "")
def _listify_tags(value: Any) -> Iterable[str]:
if isinstance(value, (list, tuple, set)):
for item in value:
text = str(item or "").strip()
if text:
yield text
return
if isinstance(value, str):
for item in re.split(r"[,;]+", value):
text = item.strip()
if text:
yield text
def _normalize_tag(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())
def _resolve_base_model_mapping(mappings: Mapping[str, str], base_model: Any) -> str:
base_text = str(base_model or "")
if base_text in mappings:
return mappings[base_text]
base_token = _normalize_tag(base_text)
if not base_token:
return base_text
normalized_mappings = {
_normalize_tag(key): value
for key, value in mappings.items()
if _normalize_tag(key)
}
if base_token in normalized_mappings:
return normalized_mappings[base_token]
for key_token, value in sorted(
normalized_mappings.items(),
key=lambda item: len(item[0]),
reverse=True,
):
if len(key_token) < 4:
continue
if base_token.startswith(key_token) or key_token in base_token or base_token in key_token:
return value
return base_text
def _first_tag(tags: Any) -> str:
tag_list = list(_listify_tags(tags))
if not tag_list:
return "no tags"
normalized_tags = {tag: _normalize_tag(tag) for tag in tag_list}
for priority in PRIORITY_TAGS:
priority_token = _normalize_tag(priority)
for tag, normalized in normalized_tags.items():
if normalized == priority_token:
return tag
return tag_list[0]
def _creator_name(metadata: Mapping[str, Any]) -> str:
creator = metadata.get("creator")
if isinstance(creator, Mapping):
name = creator.get("username") or creator.get("name")
if name:
return str(name)
if isinstance(creator, str) and creator.strip():
return creator
for key in ("author", "username", "creator_username", "creator_name"):
value = metadata.get(key)
if value:
return str(value)
repo_id = metadata.get("repo_id") or metadata.get("repo")
if isinstance(repo_id, str) and "/" in repo_id:
return repo_id.split("/", 1)[0]
return "Anonymous"
def _filename_stem(metadata: Mapping[str, Any]) -> str:
filename = str(metadata.get("filename") or metadata.get("file_name") or "").strip()
if not filename:
return ""
return Path(filename).stem
def calculate_template_subfolder(
category: str,
metadata: Optional[Mapping[str, Any]] = None,
settings: Optional[Mapping[str, Any]] = None,
) -> str:
active_settings = normalize_settings(settings if settings is not None else load_settings())
template = _template_for_category(active_settings, category)
if not template:
return ""
model_metadata = dict(metadata or {})
base_model = (
model_metadata.get("base_model")
or model_metadata.get("baseModel")
or model_metadata.get("base_model_context")
or "Unknown Base Model"
)
mappings = normalize_base_model_path_mappings(
active_settings.get("base_model_path_mappings")
)
mapped_base_model = _resolve_base_model_mapping(mappings, base_model)
model_name = (
model_metadata.get("model_name")
or model_metadata.get("name")
or _filename_stem(model_metadata)
or "Model"
)
version_name = (
model_metadata.get("version_name")
or model_metadata.get("versionName")
or model_metadata.get("version")
or ""
)
replacements = {
"{base_model}": (
normalize_relative_subfolder(mapped_base_model)
or sanitize_folder_name(mapped_base_model, "Unknown Base Model")
),
"{author}": sanitize_folder_name(_creator_name(model_metadata), "Anonymous"),
"{first_tag}": sanitize_folder_name(
_first_tag(model_metadata.get("tags")), "no tags"
),
"{model_name}": sanitize_folder_name(model_name, "Model"),
"{version_name}": sanitize_folder_name(version_name, ""),
}
formatted = template
for token, replacement in replacements.items():
formatted = formatted.replace(token, replacement)
formatted = _UNKNOWN_PLACEHOLDER_RE.sub("", formatted)
return normalize_relative_subfolder(formatted)
def resolve_download_subfolder(
category: str,
requested_subfolder: Any = "",
metadata: Optional[Mapping[str, Any]] = None,
settings: Optional[Mapping[str, Any]] = None,
) -> str:
requested = str(requested_subfolder or "").strip()
if requested:
return requested
active_settings = normalize_settings(settings if settings is not None else load_settings())
if active_settings.get("download_path_mode") != "template":
return ""
return calculate_template_subfolder(category, metadata, active_settings)
def get_default_root_for_category(
category: str,
settings: Optional[Mapping[str, Any]] = None,
) -> str:
active_settings = normalize_settings(settings if settings is not None else load_settings())
key = DEFAULT_ROOT_KEYS.get(normalize_download_category(category))
if not key:
return ""
return str(active_settings.get(key) or "").strip()
File diff suppressed because one or more lines are too long
+197
View File
@@ -4759,6 +4759,197 @@
border-color: rgba(78,161,255,0.45);
box-shadow: none;
}
.mr-options-textarea {
min-height: 150px;
resize: vertical;
line-height: 1.45;
}
.mr-options-template-list {
display: grid;
gap: 12px;
}
.mr-options-subsection-head {
display: flex;
align-items: center;
gap: 8px;
margin: 18px 0 2px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255,255,255,0.12);
}
.mr-options-subsection-head:first-of-type {
margin-top: 8px;
}
.mr-options-subsection-head-actions {
justify-content: space-between;
gap: 12px;
}
.mr-options-subsection-title-wrap {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.mr-options-subsection-title {
margin: 0;
color: var(--mr-text);
font-size: 17px;
font-weight: 700;
line-height: 1.2;
}
.mr-options-placeholder-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin: 12px 0 4px;
color: var(--mr-text-muted);
font-size: 12px;
font-weight: 600;
}
.mr-options-template-detect-status {
min-height: 28px;
display: flex;
align-items: center;
padding: 7px 10px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 4px;
background: rgba(255, 255, 255, 0.025);
color: var(--mr-text-muted);
font-size: 12px;
line-height: 1.35;
}
.mr-options-template-detect-status.is-pending {
color: #cfe8ff;
border-color: rgba(78, 161, 255, 0.28);
background: rgba(78, 161, 255, 0.08);
}
.mr-options-template-detect-status.is-valid {
color: #c9f6d0;
border-color: rgba(114, 211, 138, 0.28);
background: rgba(114, 211, 138, 0.08);
}
.mr-options-template-detect-status.is-invalid {
color: #ffd8a8;
border-color: rgba(255, 184, 77, 0.28);
background: rgba(255, 184, 77, 0.08);
}
.mr-options-mapping-section {
display: grid;
gap: 10px;
}
.mr-options-mapping-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.mr-options-mapping-list {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 6px;
background: rgba(15, 23, 42, 0.20);
}
.mr-options-mapping-row {
display: grid;
grid-template-columns: minmax(150px, 1fr) minmax(150px, 1fr) 40px;
align-items: center;
gap: 8px;
}
.mr-options-mapping-row .mr-options-input {
height: 32px;
min-height: 32px;
padding: 0 10px;
}
.mr-options-mapping-remove {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 0, 0, 0.82);
border-radius: 5px;
background: rgba(255, 0, 0, 0.08);
color: #ff3b3b;
cursor: pointer;
font-size: 16px;
font-weight: 800;
line-height: 1;
}
.mr-options-mapping-remove:hover {
background: rgba(255, 0, 0, 0.18);
color: #ff6b6b;
}
.mr-options-placeholder-badge {
display: inline-flex;
align-items: center;
min-height: 20px;
padding: 3px 7px;
border-radius: 4px;
background: rgba(0, 122, 204, 0.9);
color: #eaf6ff;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.mr-options-template-row {
display: grid;
gap: 8px;
padding: 12px 0;
border-top: 1px solid rgba(255,255,255,0.1);
}
.mr-options-template-main {
display: grid;
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
align-items: center;
gap: 12px;
}
.mr-options-template-custom.is-hidden {
display: none;
}
.mr-options-template-preview {
min-height: 32px;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 4px;
background: rgba(15, 23, 42, 0.36);
color: rgba(226, 232, 240, 0.72);
font-size: 12px;
line-height: 1.4;
word-break: break-word;
}
.mr-options-template-preview span {
color: rgba(226, 232, 240, 0.62);
font-weight: 600;
}
.mr-options-template-preview code {
color: #00a8ff;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 12px;
white-space: normal;
word-break: break-word;
}
@media (max-width: 820px) {
.mr-options-template-main {
grid-template-columns: 1fr;
}
.mr-options-subsection-head-actions {
align-items: flex-start;
flex-direction: column;
}
.mr-options-mapping-head {
align-items: flex-start;
flex-direction: column;
}
.mr-options-mapping-row {
grid-template-columns: 1fr 1fr 36px;
}
}
.mr-options-visibility-btn,
#model-resolver-modal .mr-options-visibility-btn {
width: 42px;
@@ -4925,6 +5116,9 @@
width: 100%;
min-width: 0;
}
.mr-options-path-mode-row {
grid-template-columns: minmax(150px, 1fr) minmax(220px, 280px);
}
.mr-options-actions {
display: flex;
flex-direction: column;
@@ -4986,6 +5180,9 @@
grid-template-columns: minmax(120px, 1fr) minmax(64px, 96px);
gap: 8px;
}
.mr-options-path-mode-row {
grid-template-columns: minmax(120px, 1fr) minmax(220px, 260px);
}
.mr-options-toggle-row {
gap: 10px;
}
@@ -1114,6 +1114,7 @@ export const resolveDownloadMethods = {
category: category,
subfolder: subfolder,
base_directory: baseDirectory,
path_metadata: this.getDownloadPathMetadata(missing, source),
hf_token: tokens.hf_token,
civitai_key: tokens.civitai_key
})
@@ -1751,7 +1752,15 @@ export const resolveDownloadMethods = {
const url = btn.dataset.url;
const filename = btn.dataset.filename;
const category = btn.dataset.category;
this.downloadFromSearch(missing, url, filename, category, btn);
let pathMetadata = null;
try {
pathMetadata = btn.dataset.pathMetadata
? JSON.parse(decodeURIComponent(btn.dataset.pathMetadata))
: null;
} catch (_error) {
pathMetadata = null;
}
this.downloadFromSearch(missing, url, filename, category, btn, pathMetadata);
});
});
@@ -1933,6 +1942,9 @@ export const resolveDownloadMethods = {
const rowKeys = new Set();
const addRow = (row) => {
if (!row) return;
if (!row.pathMetadata) {
row.pathMetadata = this.getDownloadPathMetadata(missing, row.detailsContext || row);
}
const rowKey = `${row.sourceKey}:${row.downloadUrl || row.openUrl || `${row.model}:${row.filename}`}`;
if (rowKeys.has(rowKey)) return;
rowKeys.add(rowKey);
@@ -2108,7 +2120,7 @@ export const resolveDownloadMethods = {
/**
* Download from search results
*/
async downloadFromSearch(missing, url, filename, category, btn) {
async downloadFromSearch(missing, url, filename, category, btn, pathMetadata = null) {
const progressId = this.getDownloadProgressElementId(missing);
const progressDiv = this.contentElement?.querySelector(`#${progressId}`);
const tokens = this.getStoredTokens();
@@ -2161,6 +2173,10 @@ export const resolveDownloadMethods = {
category: targetSelection.category,
subfolder: targetSelection.subfolder,
base_directory: targetSelection.baseDirectory || '',
path_metadata: pathMetadata || this.getDownloadPathMetadata(missing, {
filename,
category: targetSelection.category
}),
hf_token: tokens.hf_token,
civitai_key: tokens.civitai_key
})
+1
View File
@@ -21,6 +21,7 @@ export class ResolverManagerDialog extends ComfyDialog {
this.missingModels = [];
this.allModels = null; // list of all available models for dropdown
this.downloadDirectories = null;
this.downloadRootDirectories = null;
this.capabilities = null;
this.baseModels = null;
this.downloadSubfolders = new Map();
+358 -3
View File
@@ -62,6 +62,20 @@ export const downloadTargetMethods = {
}
},
async ensureDownloadRootDirectoriesLoaded() {
if (this.downloadRootDirectories) return this.downloadRootDirectories;
try {
const resp = await api.fetchApi('/model_resolver/root-directories');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
this.downloadRootDirectories = data && typeof data === 'object' ? data : {};
} catch (e) {
console.warn('Model Resolver: could not load root directories', e);
this.downloadRootDirectories = {};
}
return this.downloadRootDirectories;
},
async ensureBaseModelsLoaded() {
if (this.baseModels) return;
try {
@@ -215,9 +229,28 @@ export const downloadTargetMethods = {
return tokenNames[category] || category || 'unknown';
},
getDefaultDownloadCategoryKeys() {
return [
'checkpoints',
'loras',
'diffusion_models',
'text_encoders',
'vae',
'embeddings',
'upscale_models',
'controlnet',
'clip_vision',
'ipadapter',
'sams'
];
},
getDownloadCategoryOptions(defaultCategory = 'checkpoints') {
const directories = this.downloadDirectories || {};
const keys = Object.keys(directories);
const keys = [
...Object.keys(directories),
...this.getDefaultDownloadCategoryKeys()
];
const preferred = this.normalizeDownloadCategory(defaultCategory || 'checkpoints');
const ordered = [
preferred,
@@ -272,7 +305,9 @@ export const downloadTargetMethods = {
getDownloadTargetBaseDirectory(category = '') {
const normalizedCategory = this.normalizeDownloadCategory(category);
return this.downloadDirectories?.[normalizedCategory] || '';
return this.getDefaultRootForCategory(normalizedCategory)
|| this.downloadDirectories?.[normalizedCategory]
|| '';
},
joinLocalPath(basePath = '', relativePath = '') {
@@ -339,7 +374,297 @@ export const downloadTargetMethods = {
);
},
normalizeDownloadPathMode(value = '') {
const mode = String(value || '').trim().toLowerCase();
return ['suggested', 'template', 'manual'].includes(mode) ? mode : 'suggested';
},
getDefaultDownloadPathTemplates() {
return {
loras: '{base_model}/{first_tag}',
checkpoints: '{base_model}',
embeddings: '{base_model}',
diffusion_models: '{base_model}',
text_encoders: '',
controlnet: '{base_model}',
vae: '',
upscale_models: ''
};
},
getDownloadPathTemplateCategoryDefinitions() {
return [
{ key: 'loras', label: 'LoRAs' },
{ key: 'checkpoints', label: 'Checkpoints' },
{ key: 'embeddings', label: 'Embeddings' },
{ key: 'diffusion_models', label: 'Diffusion models' },
{ key: 'text_encoders', label: 'Text encoders' },
{ key: 'controlnet', label: 'ControlNet' },
{ key: 'vae', label: 'VAE' },
{ key: 'upscale_models', label: 'Upscale models' }
];
},
getDefaultRootCategoryDefinitions() {
return [
{ key: 'loras', label: 'LoRA root', settingKey: 'default_lora_root', storageKey: 'ModelResolver.defaultLoraRoot' },
{ key: 'checkpoints', label: 'Checkpoint root', settingKey: 'default_checkpoint_root', storageKey: 'ModelResolver.defaultCheckpointRoot' },
{ key: 'diffusion_models', label: 'Diffusion model root', settingKey: 'default_unet_root', storageKey: 'ModelResolver.defaultUnetRoot' },
{ key: 'embeddings', label: 'Embedding root', settingKey: 'default_embedding_root', storageKey: 'ModelResolver.defaultEmbeddingRoot' },
{ key: 'text_encoders', label: 'Text encoder root', settingKey: 'default_text_encoder_root', storageKey: 'ModelResolver.defaultTextEncoderRoot' },
{ key: 'vae', label: 'VAE root', settingKey: 'default_vae_root', storageKey: 'ModelResolver.defaultVaeRoot' },
{ key: 'upscale_models', label: 'Upscale model root', settingKey: 'default_upscale_model_root', storageKey: 'ModelResolver.defaultUpscaleModelRoot' }
];
},
getDownloadPathTemplatePresetDefinitions() {
return [
{ value: '', label: 'Flat folder' },
{ value: '{base_model}', label: 'By base model' },
{ value: '{author}', label: 'By author' },
{ value: '{first_tag}', label: 'By first tag' },
{ value: '{base_model}/{first_tag}', label: 'Base model / first tag' },
{ value: '{base_model}/{author}', label: 'Base model / author' },
{ value: '{author}/{first_tag}', label: 'Author / first tag' },
{ value: '{base_model}/{author}/{first_tag}', label: 'Base model / author / first tag' },
{ value: '{base_model}/{model_name}', label: 'Base model / model name' },
{ value: '{base_model}/{model_name}/{version_name}', label: 'Base model / model / version' }
];
},
parseJsonObjectSetting(value, fallback = {}) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return { ...value };
}
if (typeof value !== 'string' || !value.trim()) {
return { ...fallback };
}
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? { ...parsed }
: { ...fallback };
} catch (_error) {
return { ...fallback };
}
},
normalizeDownloadPathTemplate(template = '') {
return String(template || '')
.replace(/\\/g, '/')
.split('/')
.map(part => part.trim())
.filter(part => part && part !== '.' && part !== '..')
.join('/');
},
getDownloadPathMode() {
return this.normalizeDownloadPathMode(localStorage.getItem('ModelResolver.downloadPathMode') || 'suggested');
},
getDownloadPathTemplates() {
const defaults = this.getDefaultDownloadPathTemplates();
const parsed = this.parseJsonObjectSetting(
localStorage.getItem('ModelResolver.downloadPathTemplates'),
defaults
);
const templates = { ...defaults };
Object.entries(parsed).forEach(([key, value]) => {
templates[this.normalizeDownloadCategory(key)] = this.normalizeDownloadPathTemplate(value);
});
return templates;
},
getBaseModelPathMappings() {
const parsed = this.parseJsonObjectSetting(localStorage.getItem('ModelResolver.baseModelPathMappings'), {});
return Object.entries(parsed).reduce((acc, [key, value]) => {
const source = String(key || '').trim();
const target = String(value || '').trim();
if (source && target) acc[source] = target;
return acc;
}, {});
},
normalizeBaseModelMappingKey(value = '') {
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
},
resolveBaseModelPathMapping(baseModel = '', mappings = this.getBaseModelPathMappings()) {
const text = String(baseModel || '');
if (Object.prototype.hasOwnProperty.call(mappings, text)) {
return mappings[text];
}
const token = this.normalizeBaseModelMappingKey(text);
if (!token) return text;
const normalizedEntries = Object.entries(mappings)
.map(([key, value]) => ({
key,
token: this.normalizeBaseModelMappingKey(key),
value
}))
.filter(entry => entry.token);
const exact = normalizedEntries.find(entry => entry.token === token);
if (exact) return exact.value;
const partial = normalizedEntries
.sort((a, b) => b.token.length - a.token.length)
.find(entry => (
entry.token.length >= 4 &&
(token.startsWith(entry.token) || token.includes(entry.token) || entry.token.includes(token))
));
return partial ? partial.value : text;
},
getDefaultRootSettings() {
return this.getDefaultRootCategoryDefinitions().reduce((acc, item) => {
acc[item.settingKey] = localStorage.getItem(item.storageKey) || '';
return acc;
}, {});
},
getDefaultRootForCategory(category = '') {
const normalizedCategory = this.normalizeDownloadCategory(category);
const definition = this.getDefaultRootCategoryDefinitions()
.find(item => this.normalizeDownloadCategory(item.key) === normalizedCategory);
return definition ? (localStorage.getItem(definition.storageKey) || '') : '';
},
formatBaseModelMappingsForInput(mappings = {}) {
return Object.entries(mappings || {})
.map(([key, value]) => `${key}=${value}`)
.join('\n');
},
parseBaseModelMappingsInput(value = '') {
const mappings = {};
String(value || '').split(/\r?\n/).forEach(line => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return;
const separator = trimmed.includes('=>') ? '=>' : '=';
const index = trimmed.indexOf(separator);
if (index <= 0) return;
const key = trimmed.slice(0, index).trim();
const mapped = trimmed.slice(index + separator.length).trim();
if (key && mapped) mappings[key] = mapped;
});
return mappings;
},
sanitizeDownloadPathSegment(value = '', fallback = '') {
let text = String(value || '').trim() || fallback;
text = text
.replace(/[\\/]+/g, '_')
.replace(/[<>:"|?*\x00-\x1f]+/g, '_')
.replace(/\s+/g, ' ')
.replace(/^[\s.]+|[\s.]+$/g, '');
if (!text || text === '.' || text === '..') {
text = fallback;
}
return String(text || '').replace(/^[\s.]+|[\s.]+$/g, '');
},
sanitizeDownloadPathValue(value = '', fallback = '') {
return this.normalizeTemplateSubfolder(value)
|| this.sanitizeDownloadPathSegment(value, fallback);
},
normalizeTemplateSubfolder(value = '') {
return String(value || '')
.replace(/\\/g, '/')
.split('/')
.map(part => this.sanitizeDownloadPathSegment(part))
.filter(part => part && part !== '.' && part !== '..')
.join('/');
},
getPriorityDownloadTag(tags = []) {
const list = Array.isArray(tags)
? tags.map(tag => String(tag || '').trim()).filter(Boolean)
: String(tags || '').split(/[,;]+/).map(tag => tag.trim()).filter(Boolean);
if (!list.length) return 'no tags';
const priorityTags = ['concept', 'style', 'character', 'clothing', 'pose', 'object', 'vehicle', 'artist', 'celebrity'];
const normalize = (value) => String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
for (const priority of priorityTags) {
const match = list.find(tag => normalize(tag) === normalize(priority));
if (match) return match;
}
return list[0];
},
getDownloadPathMetadata(missing = {}, source = {}) {
const sourceData = source && typeof source === 'object' ? source : {};
const searchSuggestion = this.getCachedSearchSuggestionData(missing);
const merged = {
...(missing?.civitai_info || {}),
...(missing?.civitai_search_result || {}),
...(missing?.download_source || {}),
...(searchSuggestion || {}),
...sourceData
};
const repoId = merged.repo_id || merged.repo || '';
const filename = merged.downloadFilename
|| merged.filename
|| merged.file_name
|| missing.original_path?.split('/').pop()?.split('\\').pop()
|| '';
const modelName = merged.model_name || merged.model || merged.name || filename.replace(/\.[^.]+$/, '') || '';
const creator = merged.creator
|| (merged.creator_username ? { username: merged.creator_username } : null)
|| (merged.username ? { username: merged.username } : null)
|| null;
const author = merged.author
|| merged.creator_username
|| merged.username
|| (repoId && String(repoId).includes('/') ? String(repoId).split('/')[0] : '');
return {
filename,
name: modelName,
model_name: modelName,
version_name: merged.version_name || merged.versionName || merged.version || '',
base_model: merged.base_model || merged.baseModel || '',
tags: Array.isArray(merged.tags) ? merged.tags : [],
creator,
author,
repo_id: repoId,
category: merged.category || missing.category || ''
};
},
calculateDownloadPathTemplateSubfolder(category = '', metadata = {}) {
const templates = this.getDownloadPathTemplates();
const normalizedCategory = this.normalizeDownloadCategory(category);
const template = templates[normalizedCategory] || '';
if (!template) return '';
const mappings = this.getBaseModelPathMappings();
const baseModel = metadata.base_model || metadata.baseModel || 'Unknown Base Model';
const mappedBaseModel = this.resolveBaseModelPathMapping(baseModel, mappings);
const creator = metadata.creator && typeof metadata.creator === 'object'
? (metadata.creator.username || metadata.creator.name || '')
: (typeof metadata.creator === 'string' ? metadata.creator : '');
const author = metadata.author
|| creator
|| (metadata.repo_id && String(metadata.repo_id).includes('/') ? String(metadata.repo_id).split('/')[0] : '')
|| 'Anonymous';
const replacements = {
'{base_model}': this.sanitizeDownloadPathValue(mappedBaseModel, 'Unknown Base Model'),
'{author}': this.sanitizeDownloadPathSegment(author, 'Anonymous'),
'{first_tag}': this.sanitizeDownloadPathSegment(this.getPriorityDownloadTag(metadata.tags), 'no tags'),
'{model_name}': this.sanitizeDownloadPathSegment(metadata.model_name || metadata.name || metadata.filename?.replace(/\.[^.]+$/, '') || 'Model', 'Model'),
'{version_name}': this.sanitizeDownloadPathSegment(metadata.version_name || metadata.versionName || metadata.version || '', '')
};
let formatted = template;
Object.entries(replacements).forEach(([token, value]) => {
formatted = formatted.split(token).join(value);
});
formatted = formatted.replace(/\{[^{}]+\}/g, '');
return this.normalizeTemplateSubfolder(formatted);
},
isAutoFillSubfolderEnabled() {
if (this.getDownloadPathMode() === 'manual') return false;
return localStorage.getItem('ModelResolver.autoFillSubfolder') !== 'false';
},
@@ -570,6 +895,20 @@ export const downloadTargetMethods = {
},
getSuggestedDownloadSubfolder(missing, category, folders = []) {
const mode = this.getDownloadPathMode();
if (mode === 'manual') {
return null;
}
if (mode === 'template') {
const value = this.calculateDownloadPathTemplateSubfolder(
category,
this.getDownloadPathMetadata(missing, { category })
);
return value
? { value, label: `${value} (template)`, baseDirectory: '' }
: null;
}
const folderEntries = this.getFolderSuggestionEntries(folders);
return this.getSuggestedLoraSubfolder(missing, category, folderEntries)
|| this.getSuggestedExistingSubfolderByModelName(missing, folderEntries);
@@ -914,7 +1253,11 @@ export const downloadTargetMethods = {
frontend_log_level: storedFrontendLogLevel || DEFAULT_FRONTEND_LOG_LEVEL,
backend_log_level: storedBackendLogLevel || 'DEBUG',
civitai_candidate_limit,
search_source_enabled
search_source_enabled,
download_path_mode: this.getDownloadPathMode(),
download_path_templates: this.getDownloadPathTemplates(),
base_model_path_mappings: this.getBaseModelPathMappings(),
...this.getDefaultRootSettings()
};
},
@@ -970,6 +1313,17 @@ export const downloadTargetMethods = {
localStorage.setItem('ModelResolver.autoFillBaseModel', data.auto_fill_base_model ? 'true' : 'false');
if (data.auto_fill_subfolder !== undefined)
localStorage.setItem('ModelResolver.autoFillSubfolder', data.auto_fill_subfolder ? 'true' : 'false');
if (data.download_path_mode !== undefined)
localStorage.setItem('ModelResolver.downloadPathMode', this.normalizeDownloadPathMode(data.download_path_mode));
if (data.download_path_templates !== undefined)
localStorage.setItem('ModelResolver.downloadPathTemplates', JSON.stringify(data.download_path_templates || {}));
if (data.base_model_path_mappings !== undefined)
localStorage.setItem('ModelResolver.baseModelPathMappings', JSON.stringify(data.base_model_path_mappings || {}));
this.getDefaultRootCategoryDefinitions().forEach((item) => {
if (data[item.settingKey] !== undefined) {
localStorage.setItem(item.storageKey, String(data[item.settingKey] || ''));
}
});
if (data.civitai_candidate_limit !== undefined)
localStorage.setItem('ModelResolver.civitaiCandidateLimit', `${data.civitai_candidate_limit}`);
if (data.frontend_logs_enabled !== undefined)
@@ -1043,6 +1397,7 @@ export const downloadTargetMethods = {
this.cachedLoadedModelsSignature = null;
this.allModels = null;
this.downloadDirectories = null;
this.downloadRootDirectories = null;
this.capabilities = null;
this.baseModels = null;
this.downloadSubfolders.clear();
+12 -1
View File
@@ -1141,6 +1141,13 @@ export const searchPanelMethods = {
category: downloadSource.directory || downloadSource.category || missing.category || 'checkpoints',
openUrl: modelUrl,
searchedAt: this.getSearchResultTimestamp(downloadSource),
pathMetadata: this.getDownloadPathMetadata(missing, {
...downloadSource,
filename: downloadFilename,
model: modelName,
version: modelParts.version,
category: downloadSource.directory || downloadSource.category || missing.category || 'checkpoints'
}),
detailsContext: ['civitai', 'civarchive'].includes(String(source).toLowerCase())
? {
...downloadSource,
@@ -1238,6 +1245,9 @@ export const searchPanelMethods = {
const detailsData = detailsContext
? encodeURIComponent(JSON.stringify(detailsContext))
: '';
const pathMetadata = row.pathMetadata
? encodeURIComponent(JSON.stringify(row.pathMetadata))
: '';
let actions = '';
if (detailsContext) {
@@ -1256,7 +1266,8 @@ export const searchPanelMethods = {
aria-label="Download ${this.escapeHtml(downloadFilename)}"
data-url="${this.escapeHtml(downloadUrl)}"
data-filename="${this.escapeHtml(downloadFilename)}"
data-category="${this.escapeHtml(category)}">${getSvgIcon('download')}</button>
data-category="${this.escapeHtml(category)}"
data-path-metadata="${this.escapeHtml(pathMetadata)}">${getSvgIcon('download')}</button>
`;
}
if (openUrl) {
@@ -157,6 +157,7 @@ export const lifecycleGraphMethods = {
this.cachedAnalysisData
) {
if (shouldRenderMissingModels()) {
await this.ensureDownloadDirectoriesLoaded();
this.displayMissingModels(this.contentElement, this.cachedAnalysisData);
this.reconnectActiveDownloads();
}
@@ -197,6 +198,7 @@ export const lifecycleGraphMethods = {
this.saveAnalysisCacheForActiveWorkflow();
}
if (shouldRenderMissingModels()) {
await this.ensureDownloadDirectoriesLoaded();
this.displayMissingModels(this.contentElement, data);
// Reconnect any active downloads to their new progress divs
@@ -700,6 +700,22 @@ export const missingBrowserMethods = {
return;
}
if (!this.downloadDirectories) {
const renderToken = `download-dirs-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
this._downloadDirectoriesRenderToken = renderToken;
container.innerHTML = this.renderStatusMessage('Loading download folders...', 'info');
this.ensureDownloadDirectoriesLoaded?.().then(() => {
if (
this._downloadDirectoriesRenderToken === renderToken &&
this.activeTab === 'missing' &&
container.isConnected
) {
this.displayMissingModels(container, data);
}
});
return;
}
// Sort missing models: those with 100% confidence matches first, then others
const sortedMissingModels = [...missingModels].sort((a, b) => {
const aMatches = a.matches || [];
+8 -5
View File
@@ -26,18 +26,21 @@ export const modelInfoMethods = {
const canShowMore = this.canShowSourceDetails(model);
const isDownloadTableContext = model?.context_scope === 'download_table';
const isDownloadFolderContext = model?.context_scope === 'download_folder';
const isDownloadRootContext = model?.context_scope === 'download_root';
const isFolderOnlyContext = isDownloadFolderContext || isDownloadRootContext;
const hasLocalPath = Boolean(model?.folder_path || model?.download_directory || model?.directory || model?.path || model?.resolved_path);
const showOpenFolder = !isDownloadTableContext && hasLocalPath;
this.setContextMenuItemVisible('showInfo', !isDownloadTableContext && !isDownloadFolderContext);
this.setContextMenuItemVisible('showInfo', !isDownloadTableContext && !isFolderOnlyContext);
this.setContextMenuItemVisible('showMore', canShowMore);
this.setContextMenuItemVisible('civitai', !isDownloadTableContext && !isDownloadFolderContext);
this.setContextMenuItemVisible('civitai', !isDownloadTableContext && !isFolderOnlyContext);
this.setContextMenuItemVisible('openFolder', showOpenFolder);
this.setContextMenuDividerVisible('source', (!isDownloadTableContext && !isDownloadFolderContext) || canShowMore);
this.setContextMenuDividerVisible('folder', showOpenFolder && !isDownloadFolderContext);
this.setContextMenuDividerVisible('source', (!isDownloadTableContext && !isFolderOnlyContext) || canShowMore);
this.setContextMenuDividerVisible('folder', showOpenFolder && !isFolderOnlyContext);
const openFolderLabel = this.contextMenu.querySelector('.mr-context-menu-action-open-folder span:last-child');
if (openFolderLabel) {
openFolderLabel.textContent = isDownloadFolderContext ? 'Open Download Folder' : 'Open Containing Folder';
openFolderLabel.textContent = model?.open_folder_label
|| (isDownloadRootContext ? 'Open Root Folder' : isDownloadFolderContext ? 'Open Download Folder' : 'Open Containing Folder');
}
// Position the menu
+568 -5
View File
@@ -8,6 +8,86 @@ export const optionsMethods = {
this.contentElement.style.overflowY = 'hidden';
const tokens = this.getStoredTokens();
const pathTemplateCategories = this.getDownloadPathTemplateCategoryDefinitions();
const defaultRootCategories = this.getDefaultRootCategoryDefinitions();
const pathTemplatePresets = this.getDownloadPathTemplatePresetDefinitions();
const pathTemplatePresetValues = new Set(pathTemplatePresets.map(preset => preset.value));
const renderPathModeOptions = (selectedValue) => {
const selected = this.normalizeDownloadPathMode(selectedValue);
const options = [
{
value: 'suggested',
label: 'Suggest existing folders',
tooltip: 'Uses current Model Resolver behavior: it fills an existing matching folder when possible.'
},
{
value: 'template',
label: 'Use path templates',
tooltip: 'Creates subfolders from template placeholders such as base model, author, tags, and model name.'
},
{
value: 'manual',
label: 'Manual only',
tooltip: 'Leaves the subfolder field empty until you type or pick a folder.'
}
];
return options
.map(option => `<option value="${option.value}" ${option.value === selected ? 'selected' : ''} title="${this.escapeHtml(option.tooltip)}">${this.escapeHtml(option.label)}</option>`)
.join('');
};
const renderTemplatePlaceholderBadges = () => ['{base_model}', '{author}', '{first_tag}', '{model_name}', '{version_name}']
.map(placeholder => `<span class="mr-options-placeholder-badge">${this.escapeHtml(placeholder)}</span>`)
.join('');
const renderPathTemplateRows = () => pathTemplateCategories
.map(category => {
const template = tokens.download_path_templates?.[category.key] ?? this.getDefaultDownloadPathTemplates()[category.key] ?? '';
const normalizedTemplate = this.normalizeDownloadPathTemplate(template);
const isPreset = pathTemplatePresetValues.has(normalizedTemplate);
const selectedValue = isPreset ? normalizedTemplate : 'custom';
const options = [
...pathTemplatePresets.map(preset => (
`<option value="${this.escapeHtml(preset.value)}" ${preset.value === selectedValue ? 'selected' : ''}>${this.escapeHtml(preset.label)}</option>`
)),
`<option value="custom" ${selectedValue === 'custom' ? 'selected' : ''}>Custom template</option>`
].join('');
const preview = this.calculateDownloadPathTemplateSubfolder(category.key, {
base_model: 'Flux.1 D',
tags: ['style', 'character'],
author: 'example_author',
model_name: 'Example Model',
version_name: 'v1'
}) || '(flat folder)';
return `
<div class="mr-options-template-row" data-template-category="${this.escapeHtml(category.key)}">
<div class="mr-options-template-main">
<label class="mr-options-label" for="mr-options-template-${this.escapeHtml(category.key)}">${this.escapeHtml(category.label)}</label>
<select id="mr-options-template-${this.escapeHtml(category.key)}" class="mr-options-input mr-options-template-preset" data-template-category="${this.escapeHtml(category.key)}">
${options}
</select>
</div>
<input class="mr-options-input mr-options-template-custom ${selectedValue === 'custom' ? '' : 'is-hidden'}" data-template-category="${this.escapeHtml(category.key)}" type="text" value="${this.escapeHtml(normalizedTemplate)}" placeholder="{base_model}/{author}/{first_tag}">
<div class="mr-options-template-preview" data-template-preview="${this.escapeHtml(category.key)}"><span>Preview:</span> <code>${this.escapeHtml(preview)}</code></div>
</div>
`;
})
.join('');
const renderDefaultRootRows = () => defaultRootCategories
.map(category => {
const value = tokens[category.settingKey] || '';
return `
<div class="mr-options-template-row" data-root-category="${this.escapeHtml(category.key)}">
<div class="mr-options-template-main">
<label class="mr-options-label" for="mr-options-root-${this.escapeHtml(category.key)}">${this.escapeHtml(category.label)}</label>
<select id="mr-options-root-${this.escapeHtml(category.key)}" class="mr-options-input mr-options-default-root" data-root-category="${this.escapeHtml(category.key)}" data-setting-key="${this.escapeHtml(category.settingKey)}" data-storage-key="${this.escapeHtml(category.storageKey)}" data-current-value="${this.escapeHtml(value)}">
<option value="">Auto</option>
${value ? `<option value="${this.escapeHtml(value)}" selected>${this.escapeHtml(value)}</option>` : ''}
</select>
</div>
</div>
`;
})
.join('');
const sourceDefaultsRows = this.getSearchSourceDefinitions()
.filter(def => this.isSourceAvailable(def.source))
.map(def => {
@@ -59,12 +139,19 @@ export const optionsMethods = {
</span>
<span class="mr-options-nav-meta">01</span>
</button>
<button type="button" class="mr-options-nav-btn" data-target="mr-options-section-paths">
<span class="mr-options-nav-main">
<span class="mr-options-nav-icon" aria-hidden="true">${getSvgIcon('folderOpen')}</span>
<span>Paths</span>
</span>
<span class="mr-options-nav-meta">02</span>
</button>
<button type="button" class="mr-options-nav-btn" data-target="mr-options-section-local-db">
<span class="mr-options-nav-main">
<span class="mr-options-nav-icon" aria-hidden="true">${getSvgIcon('database')}</span>
<span>Local Database</span>
</span>
<span class="mr-options-nav-meta">02</span>
<span class="mr-options-nav-meta">03</span>
</button>
</div>
</div>
@@ -76,7 +163,7 @@ export const optionsMethods = {
<span class="mr-options-nav-icon" aria-hidden="true">${getSvgIcon('search')}</span>
<span>Defaults</span>
</span>
<span class="mr-options-nav-meta">03</span>
<span class="mr-options-nav-meta">04</span>
</button>
</div>
</div>
@@ -88,14 +175,14 @@ export const optionsMethods = {
<span class="mr-options-nav-icon mr-options-provider-icon mr-options-provider-icon-civitai" aria-hidden="true">${getSvgIcon('civitai')}</span>
<span>CivitAI</span>
</span>
<span class="mr-options-nav-meta">04</span>
<span class="mr-options-nav-meta">05</span>
</button>
<button type="button" class="mr-options-nav-btn" data-target="mr-options-section-hf">
<span class="mr-options-nav-main">
<span class="mr-options-nav-icon mr-options-provider-icon mr-options-provider-icon-huggingface" aria-hidden="true">${getSvgIcon('huggingface')}</span>
<span>HuggingFace</span>
</span>
<span class="mr-options-nav-meta">05</span>
<span class="mr-options-nav-meta">06</span>
</button>
</div>
</div>
@@ -107,7 +194,7 @@ export const optionsMethods = {
<span class="mr-options-nav-icon" aria-hidden="true">${getSvgIcon('wrench')}</span>
<span>Maintenance</span>
</span>
<span class="mr-options-nav-meta">06</span>
<span class="mr-options-nav-meta">07</span>
</button>
</div>
</div>
@@ -129,6 +216,61 @@ export const optionsMethods = {
</div>
</div>
</section>
<section id="mr-options-section-paths" class="mr-options-card mr-options-section is-hidden">
<div class="mr-options-section-head">
<h4 class="mr-options-section-title">Download Paths</h4>
</div>
<div class="mr-options-grid">
<div class="mr-options-panel">
<div class="mr-options-stack">
<div class="mr-options-number-row mr-options-path-mode-row">
<div class="mr-options-number-copy">
<span class="mr-options-label">Subfolder fill mode <span class="mr-tooltip-badge" data-tooltip="Controls how Model Resolver fills the Subfolder field before a download. Manual edits in the download panel still win.">?</span></span>
</div>
<select id="mr-options-download-path-mode" class="mr-options-input">
${renderPathModeOptions(tokens.download_path_mode)}
</select>
</div>
<div class="mr-options-subsection-head">
<h5 class="mr-options-subsection-title">Default Roots</h5>
<span class="mr-tooltip-badge" data-tooltip="Choose the root directory used by downloads for each model type. Auto keeps Model Resolver's existing folder choice.">?</span>
</div>
<div class="mr-options-template-list">
${renderDefaultRootRows()}
</div>
<div class="mr-options-subsection-head mr-options-subsection-head-actions">
<div class="mr-options-subsection-title-wrap">
<h5 class="mr-options-subsection-title">Download Path Templates</h5>
<span class="mr-tooltip-badge" data-tooltip="Templates create subfolders under the selected root. Empty template means a flat folder.">?</span>
</div>
<button id="mr-options-detect-templates" type="button" class="mr-btn mr-btn-secondary mr-btn-sm">Detect from existing folders</button>
</div>
<div id="mr-options-template-detect-status" class="mr-options-template-detect-status">Scan your existing model folders and apply the most likely template presets.</div>
<div class="mr-options-placeholder-row">
<span>Available placeholders:</span>
${renderTemplatePlaceholderBadges()}
</div>
<div class="mr-options-template-list">
${renderPathTemplateRows()}
</div>
</div>
</div>
<div class="mr-options-panel">
<div class="mr-options-stack">
<div class="mr-options-mapping-section">
<div class="mr-options-mapping-head">
<label class="mr-options-label">Base Model Path Mappings <span class="mr-tooltip-badge" data-tooltip="Optional. Map a detected base model to a folder path, for example: Pony -> SDXL/Pony. These are applied to {base_model} and may include subfolders.">?</span></label>
<button id="mr-options-add-base-model-mapping" type="button" class="mr-btn mr-btn-primary mr-btn-sm">+ Add Mapping</button>
</div>
<div id="mr-options-base-model-mappings" class="mr-options-mapping-list"></div>
</div>
<div class="mr-options-db-message">
These settings only affect new downloads. The per-download Folder and Subfolder controls remain available for one-off overrides.
</div>
</div>
</div>
</div>
</section>
<section id="mr-options-section-search" class="mr-options-card mr-options-section is-hidden">
<div class="mr-options-section-head">
<h4 class="mr-options-section-title">Search Defaults</h4>
@@ -465,6 +607,14 @@ export const optionsMethods = {
const hfUseBraveFallbackInput = this.contentElement.querySelector('#mr-options-hf-use-brave-fallback');
const autoFillBaseModelInput = this.contentElement.querySelector('#mr-options-auto-fill-base-model');
const autoFillSubfolderInput = this.contentElement.querySelector('#mr-options-auto-fill-subfolder');
const downloadPathModeInput = this.contentElement.querySelector('#mr-options-download-path-mode');
const defaultRootSelectInputs = Array.from(this.contentElement.querySelectorAll('.mr-options-default-root'));
const templatePresetInputs = Array.from(this.contentElement.querySelectorAll('.mr-options-template-preset'));
const templateCustomInputs = Array.from(this.contentElement.querySelectorAll('.mr-options-template-custom'));
const detectTemplatesBtn = this.contentElement.querySelector('#mr-options-detect-templates');
const templateDetectStatus = this.contentElement.querySelector('#mr-options-template-detect-status');
const baseModelMappingsContainer = this.contentElement.querySelector('#mr-options-base-model-mappings');
const addBaseModelMappingBtn = this.contentElement.querySelector('#mr-options-add-base-model-mapping');
const frontendLogsEnabledInput = this.contentElement.querySelector('#mr-options-frontend-logs-enabled');
const backendLogsEnabledInput = this.contentElement.querySelector('#mr-options-backend-logs-enabled');
const frontendLogLevelInput = this.contentElement.querySelector('#mr-options-frontend-log-level');
@@ -510,6 +660,10 @@ export const optionsMethods = {
hfUseBraveFallbackInput,
autoFillBaseModelInput,
autoFillSubfolderInput,
downloadPathModeInput,
...defaultRootSelectInputs,
...templatePresetInputs,
...templateCustomInputs,
frontendLogsEnabledInput,
backendLogsEnabledInput,
frontendLogLevelInput,
@@ -893,6 +1047,373 @@ export const optionsMethods = {
bindVisibilityToggle(civitaiSessionInput, civitaiSessionToggle);
bindVisibilityToggle(hfInput, hfToggle);
bindVisibilityToggle(braveInput, braveToggle);
const pathPreviewMetadata = {
base_model: 'Flux.1 D',
tags: ['style', 'character'],
author: 'example_author',
model_name: 'Example Model',
version_name: 'v1'
};
const getTemplateInputValue = (categoryKey) => {
const select = templatePresetInputs.find(input => input.dataset.templateCategory === categoryKey);
const custom = templateCustomInputs.find(input => input.dataset.templateCategory === categoryKey);
if (!select) return '';
return select.value === 'custom' ? (custom?.value || '') : select.value;
};
const getBaseModelMappingChoices = () => {
const names = [];
const seen = new Set();
const addName = (value) => {
const name = String(value || '').trim();
if (!name || seen.has(name)) return;
seen.add(name);
names.push(name);
};
const baseModelsList = this.baseModels?.base_models;
if (Array.isArray(baseModelsList)) {
baseModelsList.forEach(model => addName(model?.name));
}
Object.keys(tokens.base_model_path_mappings || {}).forEach(addName);
return names.sort((a, b) => a.localeCompare(b));
};
const getBaseModelMappingRows = () => (
baseModelMappingsContainer
? Array.from(baseModelMappingsContainer.querySelectorAll('.mr-options-mapping-row'))
: []
);
const collectBaseModelPathMappings = () => {
if (!baseModelMappingsContainer) {
return { ...(tokens.base_model_path_mappings || {}) };
}
return getBaseModelMappingRows().reduce((acc, row) => {
const baseModel = row.querySelector('.mr-options-mapping-base')?.value?.trim() || '';
const pathValue = row.querySelector('.mr-options-mapping-path')?.value?.trim() || '';
if (baseModel && pathValue) {
acc[baseModel] = pathValue;
}
return acc;
}, {});
};
const renderBaseModelMappingOptions = (currentValue = '', currentRow = null) => {
const usedValues = new Set(
getBaseModelMappingRows()
.filter(row => row !== currentRow)
.map(row => row.querySelector('.mr-options-mapping-base')?.value?.trim() || '')
.filter(Boolean)
);
const options = [
`<option value="">Select Base Model</option>`
];
const choices = getBaseModelMappingChoices();
if (currentValue && !choices.includes(currentValue)) {
options.push(`<option value="${this.escapeHtml(currentValue)}" selected>${this.escapeHtml(currentValue)}</option>`);
}
choices.forEach((name) => {
if (usedValues.has(name) && name !== currentValue) return;
options.push(`<option value="${this.escapeHtml(name)}" ${name === currentValue ? 'selected' : ''}>${this.escapeHtml(name)}</option>`);
});
return options.join('');
};
const refreshBaseModelMappingOptions = () => {
getBaseModelMappingRows().forEach((row) => {
const select = row.querySelector('.mr-options-mapping-base');
if (!select) return;
const currentValue = select.value;
select.innerHTML = renderBaseModelMappingOptions(currentValue, row);
select.value = currentValue;
});
};
const addBaseModelMappingRow = (baseModel = '', pathValue = '') => {
if (!baseModelMappingsContainer) return;
const row = document.createElement('div');
row.className = 'mr-options-mapping-row';
row.innerHTML = `
<select class="mr-options-input mr-options-mapping-base">
${renderBaseModelMappingOptions(baseModel, row)}
</select>
<input class="mr-options-input mr-options-mapping-path" type="text" placeholder="Custom path (e.g., SDXL/Pony)" value="${this.escapeHtml(pathValue)}">
<button type="button" class="mr-options-mapping-remove" aria-label="Remove mapping" data-tooltip="Remove mapping">&times;</button>
`;
const baseSelect = row.querySelector('.mr-options-mapping-base');
const pathInput = row.querySelector('.mr-options-mapping-path');
const removeBtn = row.querySelector('.mr-options-mapping-remove');
const markChanged = () => {
setStatus('You have unsaved changes.', 'is-dirty');
refreshBaseModelMappingOptions();
syncAllTemplateControls();
};
baseSelect?.addEventListener('change', markChanged);
pathInput?.addEventListener('input', markChanged);
pathInput?.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
pathInput.blur();
}
});
removeBtn?.addEventListener('click', () => {
row.remove();
if (!getBaseModelMappingRows().length) {
addBaseModelMappingRow('', '');
}
markChanged();
});
baseModelMappingsContainer.appendChild(row);
refreshBaseModelMappingOptions();
this.bindTooltips(row);
};
const renderBaseModelMappingRows = (mappings = {}) => {
if (!baseModelMappingsContainer) return;
baseModelMappingsContainer.innerHTML = '';
const entries = Object.entries(mappings || {});
if (!entries.length) {
addBaseModelMappingRow('', '');
return;
}
entries.forEach(([baseModel, pathValue]) => {
addBaseModelMappingRow(baseModel, pathValue);
});
};
const renderTemplatePreview = (template) => {
const mappings = collectBaseModelPathMappings();
const mappedBase = this.resolveBaseModelPathMapping(
pathPreviewMetadata.base_model,
mappings
);
const replacements = {
'{base_model}': this.sanitizeDownloadPathValue(mappedBase, 'Unknown Base Model'),
'{author}': this.sanitizeDownloadPathSegment(pathPreviewMetadata.author, 'Anonymous'),
'{first_tag}': this.sanitizeDownloadPathSegment(this.getPriorityDownloadTag(pathPreviewMetadata.tags), 'no tags'),
'{model_name}': this.sanitizeDownloadPathSegment(pathPreviewMetadata.model_name, 'Model'),
'{version_name}': this.sanitizeDownloadPathSegment(pathPreviewMetadata.version_name, '')
};
let formatted = this.normalizeDownloadPathTemplate(template);
Object.entries(replacements).forEach(([token, value]) => {
formatted = formatted.split(token).join(value);
});
formatted = formatted.replace(/\{[^{}]+\}/g, '');
return this.normalizeTemplateSubfolder(formatted) || '(flat folder)';
};
const syncTemplateControl = (categoryKey) => {
const select = templatePresetInputs.find(input => input.dataset.templateCategory === categoryKey);
const custom = templateCustomInputs.find(input => input.dataset.templateCategory === categoryKey);
const preview = this.contentElement.querySelector(`[data-template-preview="${categoryKey}"]`);
const useCustom = select?.value === 'custom';
if (custom) {
custom.classList.toggle('is-hidden', !useCustom);
}
if (preview) {
preview.innerHTML = `<span>Preview:</span> <code>${this.escapeHtml(renderTemplatePreview(getTemplateInputValue(categoryKey)))}</code>`;
}
};
const syncAllTemplateControls = () => {
pathTemplateCategories.forEach(category => syncTemplateControl(category.key));
};
const getDefaultRootContextPath = (select) => {
if (!select) return '';
const category = this.normalizeDownloadCategory(select.dataset.rootCategory || '');
const selectedValue = String(select.value || '').trim();
if (selectedValue) return selectedValue;
const directDownloadRoot = this.downloadDirectories?.[category] || '';
if (directDownloadRoot) return directDownloadRoot;
const rootOptions = this.downloadRootDirectories?.[category];
return Array.isArray(rootOptions) && rootOptions.length ? rootOptions[0] : '';
};
const getDefaultRootContextModel = (select) => {
const path = getDefaultRootContextPath(select);
if (!path) return null;
const category = this.normalizeDownloadCategory(select.dataset.rootCategory || '');
const label = select.closest('.mr-options-template-row')?.querySelector('.mr-options-label')?.textContent?.trim()
|| this.getCategoryDisplayName(category);
return {
context_scope: 'download_root',
name: label,
path,
resolved_path: path,
folder_path: path,
download_directory: path,
category,
open_folder_label: 'Open Root Folder'
};
};
const syncDefaultRootContextTarget = (select) => {
if (!select) return;
const contextModel = getDefaultRootContextModel(select);
const tooltip = contextModel
? `Right-click to open ${contextModel.name}`
: 'No root folder available';
this.setDownloadFolderContextTarget(select, contextModel, tooltip);
this.setDownloadFolderContextTarget(select.closest('.mr-options-template-row'), contextModel, tooltip);
};
const setTemplateControlValue = (categoryKey, template) => {
const select = templatePresetInputs.find(input => input.dataset.templateCategory === categoryKey);
const custom = templateCustomInputs.find(input => input.dataset.templateCategory === categoryKey);
if (!select) return false;
const normalizedTemplate = this.normalizeDownloadPathTemplate(template);
select.value = pathTemplatePresetValues.has(normalizedTemplate)
? normalizedTemplate
: 'custom';
if (custom) {
custom.value = normalizedTemplate;
}
syncTemplateControl(categoryKey);
return true;
};
const setTemplateDetectStatus = (text, mode = '') => {
if (!templateDetectStatus) return;
templateDetectStatus.textContent = text;
templateDetectStatus.classList.remove('is-valid', 'is-invalid', 'is-pending');
if (mode) templateDetectStatus.classList.add(mode);
};
const applyDetectedPathTemplates = async () => {
if (!detectTemplatesBtn) return;
detectTemplatesBtn.disabled = true;
setTemplateDetectStatus('Scanning existing model folders...', 'is-pending');
try {
const resp = await api.fetchApi('/model_resolver/path-template-suggestions?force=1');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const categories = data?.categories && typeof data.categories === 'object'
? data.categories
: {};
let appliedCount = 0;
let inspectedCount = 0;
pathTemplateCategories.forEach((category) => {
const suggestion = categories[category.key];
if (!suggestion) return;
if (Number(suggestion.model_count || 0) > 0) {
inspectedCount += 1;
}
if (suggestion.apply && setTemplateControlValue(category.key, suggestion.template || '')) {
appliedCount += 1;
}
});
let mappingCount = 0;
const detectedMappings = data?.base_model_path_mappings || {};
if (
baseModelMappingsContainer &&
detectedMappings &&
typeof detectedMappings === 'object' &&
!Array.isArray(detectedMappings)
) {
const currentMappings = collectBaseModelPathMappings();
Object.entries(detectedMappings).forEach(([source, target]) => {
const sourceKey = String(source || '').trim();
const targetValue = String(target || '').trim();
if (sourceKey && targetValue && !currentMappings[sourceKey]) {
currentMappings[sourceKey] = targetValue;
mappingCount += 1;
}
});
if (mappingCount > 0) {
renderBaseModelMappingRows(currentMappings);
}
}
if (appliedCount > 0 || mappingCount > 0) {
if (appliedCount > 0 && downloadPathModeInput) {
downloadPathModeInput.value = 'template';
}
syncAllTemplateControls();
setStatus('You have unsaved changes.', 'is-dirty');
const mappingText = mappingCount
? ` Added ${mappingCount} base-model mapping${mappingCount === 1 ? '' : 's'}.`
: '';
setTemplateDetectStatus(
appliedCount > 0
? `Detected ${appliedCount} template preset${appliedCount === 1 ? '' : 's'} from ${inspectedCount} model type${inspectedCount === 1 ? '' : 's'}.${mappingText}`
: `Detected ${mappingCount} base-model mapping${mappingCount === 1 ? '' : 's'} from existing folders.`,
'is-valid'
);
this.showNotification('Path templates detected. Review and save options.', 'success');
} else {
setTemplateDetectStatus(
'No confident folder pattern found. Existing template settings were not changed.',
'is-invalid'
);
this.showNotification('No confident path template pattern found.', 'info');
}
} catch (error) {
console.error('Model Resolver: path template detection error:', error);
setTemplateDetectStatus(error.message || 'Template detection failed.', 'is-invalid');
this.showNotification('Path template detection failed', 'error');
} finally {
detectTemplatesBtn.disabled = false;
}
};
templatePresetInputs.forEach(input => {
input.addEventListener('change', () => syncTemplateControl(input.dataset.templateCategory));
});
templateCustomInputs.forEach(input => {
input.addEventListener('input', () => syncTemplateControl(input.dataset.templateCategory));
});
renderBaseModelMappingRows(tokens.base_model_path_mappings || {});
if (typeof this.ensureBaseModelsLoaded === 'function') {
this.ensureBaseModelsLoaded()
.then(() => {
refreshBaseModelMappingOptions();
})
.catch((error) => {
console.warn('Model Resolver: could not load base models for mapping options', error);
});
}
if (addBaseModelMappingBtn) {
addBaseModelMappingBtn.addEventListener('click', () => {
addBaseModelMappingRow('', '');
setStatus('You have unsaved changes.', 'is-dirty');
});
}
syncAllTemplateControls();
const populateDefaultRootSelects = async () => {
const roots = await this.ensureDownloadRootDirectoriesLoaded();
await this.ensureDownloadDirectoriesLoaded();
defaultRootSelectInputs.forEach((select) => {
const category = this.normalizeDownloadCategory(select.dataset.rootCategory || '');
const currentValue = select.value || select.dataset.currentValue || '';
const categoryRoots = Array.isArray(roots?.[category]) ? roots[category] : [];
const options = [
{ value: '', label: 'Auto' },
...categoryRoots.map(path => ({ value: path, label: path }))
];
if (currentValue && !options.some(option => option.value === currentValue)) {
options.push({ value: currentValue, label: `${currentValue} (not currently detected)` });
}
select.innerHTML = options
.map(option => `<option value="${this.escapeHtml(option.value)}" ${option.value === currentValue ? 'selected' : ''}>${this.escapeHtml(option.label)}</option>`)
.join('');
syncDefaultRootContextTarget(select);
});
};
populateDefaultRootSelects();
setStatus('Saved only on this machine.');
setVisibleSection('mr-options-section-sources');
@@ -903,6 +1424,12 @@ export const optionsMethods = {
});
});
defaultRootSelectInputs.forEach((select) => {
select.addEventListener('change', () => {
syncDefaultRootContextTarget(select);
});
});
this.bindTooltips(this.contentElement);
navButtons.forEach((btn) => {
@@ -955,6 +1482,12 @@ export const optionsMethods = {
});
}
if (detectTemplatesBtn) {
detectTemplatesBtn.addEventListener('click', () => {
applyDetectedPathTemplates();
});
}
if (civitaiSessionCheckBtn) {
civitaiSessionCheckBtn.addEventListener('click', () => {
checkCredential({
@@ -1030,6 +1563,21 @@ export const optionsMethods = {
sourceEnabled[key] = input.checked;
}
});
const downloadPathTemplates = {};
pathTemplateCategories.forEach(category => {
downloadPathTemplates[category.key] = this.normalizeDownloadPathTemplate(
getTemplateInputValue(category.key)
);
});
const baseModelPathMappings = collectBaseModelPathMappings();
const downloadPathMode = this.normalizeDownloadPathMode(downloadPathModeInput?.value || tokens.download_path_mode);
const defaultRootSettings = {};
defaultRootSelectInputs.forEach((select) => {
const settingKey = select.dataset.settingKey;
if (settingKey) {
defaultRootSettings[settingKey] = select.value || '';
}
});
const newSettings = {
civitai_key: civitaiInput?.value || '',
@@ -1043,6 +1591,10 @@ export const optionsMethods = {
hf_use_brave_fallback: Boolean(hfUseBraveFallbackInput?.checked),
auto_fill_base_model: Boolean(autoFillBaseModelInput?.checked),
auto_fill_subfolder: Boolean(autoFillSubfolderInput?.checked),
download_path_mode: downloadPathMode,
download_path_templates: downloadPathTemplates,
base_model_path_mappings: baseModelPathMappings,
...defaultRootSettings,
frontend_logs_enabled: Boolean(frontendLogsEnabledInput?.checked),
backend_logs_enabled: Boolean(backendLogsEnabledInput?.checked),
frontend_log_level: normalizeLogLevel(frontendLogLevelInput?.value || tokens.frontend_log_level),
@@ -1063,6 +1615,15 @@ export const optionsMethods = {
localStorage.setItem('ModelResolver.hfUseBraveFallback', newSettings.hf_use_brave_fallback ? 'true' : 'false');
localStorage.setItem('ModelResolver.autoFillBaseModel', newSettings.auto_fill_base_model ? 'true' : 'false');
localStorage.setItem('ModelResolver.autoFillSubfolder', newSettings.auto_fill_subfolder ? 'true' : 'false');
localStorage.setItem('ModelResolver.downloadPathMode', newSettings.download_path_mode);
localStorage.setItem('ModelResolver.downloadPathTemplates', JSON.stringify(newSettings.download_path_templates));
localStorage.setItem('ModelResolver.baseModelPathMappings', JSON.stringify(newSettings.base_model_path_mappings));
defaultRootSelectInputs.forEach((select) => {
const storageKey = select.dataset.storageKey;
if (storageKey) {
localStorage.setItem(storageKey, select.value || '');
}
});
localStorage.setItem('ModelResolver.frontendLogsEnabled', newSettings.frontend_logs_enabled ? 'true' : 'false');
localStorage.setItem('ModelResolver.backendLogsEnabled', newSettings.backend_logs_enabled ? 'true' : 'false');
localStorage.setItem('ModelResolver.frontendLogLevel', newSettings.frontend_log_level);
@@ -1091,6 +1652,8 @@ export const optionsMethods = {
this.showNotification('Options saved locally only', 'warning');
}
this.downloadRootDirectories = null;
this.downloadSubfolders?.clear();
await this.clearSearchCaches();
});
}