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:
+132
-77
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">×</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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user