feat: add base model filtering across search sources, API routes, and frontend UI

This commit is contained in:
Dariusz L
2026-06-06 19:43:43 +02:00
parent 57ac022fc4
commit ab59cd231f
16 changed files with 1544 additions and 33 deletions
+46
View File
@@ -138,6 +138,52 @@ class ModelResolverExtension:
)
download_available = False
# ==================== BASE MODELS CONFIG ROUTE ====================
@routes.get("/model_resolver/base-models")
async def get_base_models(request):
"""Return the base models config from metadata/base-models.json.
Returns {base_models: [{name, aliases}]} so the frontend
dropdown can populate correctly via baseModels.base_models.
"""
try:
from .core.sources.popular import get_base_models_config
data = get_base_models_config()
return web.json_response(data)
except Exception as e:
self.logger.error(
f"Model Resolver base-models error: {e}", exc_info=True
)
return web.json_response({"error": str(e)}, status=500)
@routes.get("/model_resolver/base-models/status")
async def get_base_models_status_route(request):
"""Get local and optional remote base models status."""
try:
check_remote = request.query.get("check_remote") == "1"
from .core.sources.popular import get_base_models_status
status = await asyncio.to_thread(get_base_models_status, check_remote)
return web.json_response(status)
except Exception as e:
self.logger.error(
f"Model Resolver base-models status error: {e}", exc_info=True
)
return web.json_response({"error": str(e)}, status=500)
@routes.post("/model_resolver/base-models/update")
async def update_base_models_route(request):
"""Update base models list from CivitAI."""
try:
from .core.sources.popular import update_base_models_from_remote
status = await asyncio.to_thread(update_base_models_from_remote)
return web.json_response(status)
except Exception as e:
self.logger.error(
f"Model Resolver base-models update error: {e}", exc_info=True
)
return web.json_response({"error": str(e)}, status=500)
# ==================== ANALYZE ROUTES ====================
@routes.post("/model_resolver/analyze")
+49 -4
View File
@@ -486,6 +486,34 @@ def _calculate_confidence(
return round(best * 100, 1)
def _normalize_base_model(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())
def _base_model_matches(candidate: str, preferred: Optional[str]) -> bool:
preferred_norm = _normalize_base_model(preferred or "")
if not preferred_norm:
return True
candidate_norm = _normalize_base_model(candidate or "")
if not candidate_norm:
return False
from .popular import load_base_model_aliases
aliases = load_base_model_aliases()
preferred_tokens = aliases.get(preferred_norm, [preferred_norm])
return any(
token and (token in candidate_norm or candidate_norm in token)
for token in preferred_tokens
)
def _base_model_score(candidate: str, preferred: Optional[str]) -> float:
if not preferred:
return 0.0
return 1000.0 if _base_model_matches(candidate, preferred) else -1000.0
def _build_result_from_payload(
payload: Dict[str, Any],
query: str = "",
@@ -751,6 +779,7 @@ def search_civarchive(
def search_civarchive_for_file(
filename: str,
model_type: Optional[str] = None,
base_model_context: Optional[str] = None,
exact_only: bool = False,
limit: int = DEFAULT_CIVARCHIVE_CANDIDATE_LIMIT,
) -> Optional[Dict[str, Any]]:
@@ -762,8 +791,9 @@ def search_civarchive_for_file(
return None
limit = max(1, min(int(limit), MAX_CIVARCHIVE_CANDIDATE_LIMIT))
base_model_key = _normalize_base_model(base_model_context or "")
cache_key = (
f"file::{normalized_filename.lower()}::{model_type or ''}::{exact_only}::{limit}"
f"file::{normalized_filename.lower()}::{model_type or ''}::{base_model_key}::{exact_only}::{limit}"
)
if cache_key in _search_cache:
return _search_cache[cache_key]
@@ -780,6 +810,7 @@ def search_civarchive_for_file(
best_match = None
best_confidence = 0.0
best_rank = -9999.0
seen = set()
try:
@@ -810,19 +841,29 @@ def search_civarchive_for_file(
resolved["confidence"] = confidence
resolved["match_type"] = "exact" if confidence == 100.0 else "similar"
base_model_matches = _base_model_matches(
resolved.get("base_model"), base_model_context
)
rank = confidence + _base_model_score(
resolved.get("base_model"), base_model_context
)
if confidence > best_confidence:
if rank > best_rank:
best_rank = rank
best_confidence = confidence
best_match = resolved
if confidence == 100.0:
if confidence == 100.0 and base_model_matches:
_search_cache[cache_key] = best_match
return best_match
if len(seen) >= limit:
break
if best_match:
if best_match and (
not base_model_context
or _base_model_matches(best_match.get("base_model"), base_model_context)
):
break
except CivArchiveSearchError:
raise
@@ -832,6 +873,10 @@ def search_civarchive_for_file(
if best_match and best_confidence < 40:
best_match = None
if best_match and base_model_context and not _base_model_matches(
best_match.get("base_model"), base_model_context
):
best_match = None
_search_cache[cache_key] = best_match
return best_match
+48 -6
View File
@@ -261,6 +261,34 @@ def _calculate_filename_confidence(target_filename: str, candidate_filename: str
return round(max(similarity, similarity_no_ext) * 100, 1)
def _normalize_base_model(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())
def _base_model_matches(candidate: str, preferred: Optional[str]) -> bool:
preferred_norm = _normalize_base_model(preferred or "")
if not preferred_norm:
return True
candidate_norm = _normalize_base_model(candidate or "")
if not candidate_norm:
return False
from .popular import load_base_model_aliases
aliases = load_base_model_aliases()
preferred_tokens = aliases.get(preferred_norm, [preferred_norm])
return any(
token and (token in candidate_norm or candidate_norm in token)
for token in preferred_tokens
)
def _base_model_score(candidate: str, preferred: Optional[str]) -> float:
if not preferred:
return 0.0
return 1000.0 if _base_model_matches(candidate, preferred) else -1000.0
def _find_matching_file_in_versions(
versions: List[Dict[str, Any]],
filename: str,
@@ -502,6 +530,7 @@ def _find_civitai_file_in_model(
api_key: Optional[str] = None,
exact_only: bool = False,
preferred_version_id: Optional[int] = None,
base_model_context: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Load one CivitAI model and search all its versions for the requested file."""
filename_lower = filename.lower()
@@ -554,7 +583,9 @@ def _find_civitai_file_in_model(
if preferred_version_id is not None:
resolved = resolve_urn(model_id, preferred_version_id, api_key)
if resolved:
if resolved_version_matches(resolved):
if resolved_version_matches(resolved) and _base_model_matches(
resolved.get("base_model"), base_model_context
):
return build_result_from_resolved_version(resolved, preferred_version_id)
best_resolved_result = None
@@ -589,17 +620,22 @@ def _find_civitai_file_in_model(
resolved = resolve_urn(model_id, version_id, api_key)
if resolved:
if resolved_version_matches(resolved):
if resolved_version_matches(resolved) and _base_model_matches(
resolved.get("base_model"), base_model_context
):
return build_result_from_resolved_version(resolved, version_id)
if not exact_only:
expected_filename = resolved.get("expected_filename", "")
confidence = _calculate_filename_confidence(filename, expected_filename)
ranking_score = confidence + _base_model_score(
resolved.get("base_model"), base_model_context
)
log_debug(
f"CivitAI resolved version confidence: model_id={model_id}, version_id={version_id}, expected_filename={expected_filename}, confidence={confidence}"
)
if confidence > best_resolved_confidence:
best_resolved_confidence = confidence
if ranking_score > best_resolved_confidence:
best_resolved_confidence = ranking_score
best_resolved_result = build_result_from_resolved_version(
resolved, version_id
)
@@ -639,7 +675,10 @@ def _find_civitai_file_in_model(
log_info(
f"CivitAI version-list probable match: model_id={model_id}, version_id={result.get('version_id')}, filename={result.get('filename')}, confidence={result['confidence']}"
)
return result
if _base_model_matches(result.get("base_model"), base_model_context):
return result
if not base_model_context:
return result
return None
@@ -716,6 +755,7 @@ def search_civitai_for_file(
api_key: Optional[str] = None,
exact_only: bool = False,
model_type: Optional[str] = None,
base_model_context: Optional[str] = None,
session_token: Optional[str] = None,
candidate_limit: int = DEFAULT_CIVITAI_CANDIDATE_LIMIT,
use_trpc_search: bool = True,
@@ -739,9 +779,10 @@ def search_civitai_for_file(
candidate_limit = max(1, min(int(candidate_limit), MAX_CIVITAI_CANDIDATE_LIMIT))
session_key = "session" if session_token else "anon"
model_type_key = str(model_type or "").lower()
base_model_key = _normalize_base_model(base_model_context or "")
methods_key = f"trpc{int(bool(use_trpc_search))}_html{int(bool(use_html_fallback))}"
cache_key = (
f"civit_{filename}_exact{exact_only}_type{model_type_key}_session{session_key}_limit{candidate_limit}_{methods_key}"
f"civit_{filename}_exact{exact_only}_type{model_type_key}_base{base_model_key}_session{session_key}_limit{candidate_limit}_{methods_key}"
)
if cache_key in _search_cache:
log_debug(f"CivitAI search cache hit for {filename} (exact_only={exact_only})")
@@ -798,6 +839,7 @@ def search_civitai_for_file(
api_key=api_key,
exact_only=exact_only,
preferred_version_id=version_id,
base_model_context=base_model_context,
)
if result:
confidence = float(result.get("confidence") or 0.0)
+70 -11
View File
@@ -1,4 +1,4 @@
"""
"""
LoRA Manager Archive Source Module
Search archived CivitAI metadata stored by comfyui-lora-manager.
@@ -153,6 +153,34 @@ def _calculate_confidence(
return round(best * 100, 1)
def _normalize_base_model(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())
def _base_model_matches(candidate: str, preferred: Optional[str]) -> bool:
preferred_norm = _normalize_base_model(preferred or "")
if not preferred_norm:
return True
candidate_norm = _normalize_base_model(candidate or "")
if not candidate_norm:
return False
from .popular import load_base_model_aliases
aliases = load_base_model_aliases()
preferred_tokens = aliases.get(preferred_norm, [preferred_norm])
return any(
token and (token in candidate_norm or candidate_norm in token)
for token in preferred_tokens
)
def _base_model_score(candidate: str, preferred: Optional[str]) -> float:
if not preferred:
return 0.0
return 1000.0 if _base_model_matches(candidate, preferred) else -1000.0
def _extract_search_tokens(query: str) -> Dict[str, List[str]]:
"""Split filename/model query into useful content/version tokens."""
base_query = os.path.splitext(os.path.basename(query or ""))[0]
@@ -513,7 +541,10 @@ def _build_result_from_row(
def search_lora_manager_archive(
query: str, model_type: Optional[str] = None, limit: int = 10
query: str,
model_type: Optional[str] = None,
base_model_context: Optional[str] = None,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""
Search the local comfyui-lora-manager CivitAI archive by model name.
@@ -531,7 +562,10 @@ def search_lora_manager_archive(
return []
normalized_type = _normalize_model_type(model_type)
cache_key = f"search::{normalized_query.lower()}::{normalized_type}::{limit}"
base_model_key = _normalize_base_model(base_model_context or "")
cache_key = (
f"search::{normalized_query.lower()}::{normalized_type}::{base_model_key}::{limit}"
)
if cache_key in _search_cache:
return list(_search_cache[cache_key])
@@ -562,22 +596,27 @@ def search_lora_manager_archive(
)
if confidence < 40:
continue
scored_candidates.append((confidence, row))
rank = confidence + _base_model_score(
row["base_model"], base_model_context
)
scored_candidates.append((rank, confidence, row))
scored_candidates.sort(
key=lambda item: (
item[0],
1 if item[1]["version_name"] else 0,
item[1],
1 if item[2]["version_name"] else 0,
),
reverse=True,
)
selected_version_ids = [
row["version_id"] for _, row in scored_candidates[: max(limit * 3, limit)]
row["version_id"]
for _, _, row in scored_candidates[: max(limit * 3, limit)]
]
full_rows = _load_full_rows_for_versions(conn, selected_version_ids)
results = []
for _, candidate_row in scored_candidates:
for _, _, candidate_row in scored_candidates:
version_id = candidate_row["version_id"]
full_row = full_rows.get(version_id)
if not full_row:
@@ -585,10 +624,15 @@ def search_lora_manager_archive(
result = _build_result_from_row(conn, full_row, normalized_query)
if not result or result["confidence"] < 40:
continue
if base_model_context and not _base_model_matches(
result.get("base_model"), base_model_context
):
continue
results.append(result)
results.sort(
key=lambda item: (
_base_model_score(item.get("base_model"), base_model_context),
item.get("confidence", 0),
1 if item.get("match_type") == "exact" else 0,
),
@@ -611,6 +655,7 @@ def search_lora_manager_archive(
def search_lora_manager_archive_for_file(
filename: str,
model_type: Optional[str] = None,
base_model_context: Optional[str] = None,
exact_only: bool = False,
limit: int = 10,
) -> Optional[Dict[str, Any]]:
@@ -621,29 +666,43 @@ def search_lora_manager_archive_for_file(
if not search_query:
return None
base_model_key = _normalize_base_model(base_model_context or "")
cache_key = (
f"file::{filename.lower()}::{_normalize_model_type(model_type)}::{exact_only}::{limit}"
f"file::{filename.lower()}::{_normalize_model_type(model_type)}::{base_model_key}::{exact_only}::{limit}"
)
if cache_key in _search_cache:
return _search_cache[cache_key]
candidates = search_lora_manager_archive(
search_query, model_type=model_type, limit=limit
search_query,
model_type=model_type,
base_model_context=base_model_context,
limit=limit,
)
best_match = None
best_confidence = 0.0
best_rank = -9999.0
for candidate in candidates:
candidate_filename = candidate.get("filename", "")
confidence = _calculate_confidence(filename, candidate.get("name", ""), candidate.get("version_name", ""), candidate_filename)
if exact_only and confidence < 100.0:
continue
if confidence > best_confidence:
base_model_matches = _base_model_matches(
candidate.get("base_model"), base_model_context
)
if base_model_context and not base_model_matches:
continue
rank = confidence + _base_model_score(
candidate.get("base_model"), base_model_context
)
if rank > best_rank:
best_rank = rank
best_confidence = confidence
best_match = dict(candidate)
best_match["confidence"] = confidence
best_match["match_type"] = "exact" if confidence == 100.0 else "similar"
if confidence == 100.0:
if confidence == 100.0 and base_model_matches:
break
if best_match is None and exact_only:
+305 -1
View File
@@ -6,6 +6,10 @@ Curated list of common models with known download URLs.
import os
import json
import re
import shutil
import tempfile
from datetime import datetime, timezone
from typing import Dict, Any, Optional, List
from ..log_system.log_funcs import (
@@ -22,10 +26,12 @@ METADATA_DIR = os.path.join(
)
POPULAR_MODELS_FILE = os.path.join(METADATA_DIR, "popular-models.json")
MODEL_ALIASES_FILE = os.path.join(METADATA_DIR, "model-aliases.json")
BASE_MODELS_FILE = os.path.join(METADATA_DIR, "base-models.json")
# Cache for loaded data
_popular_models_cache: Optional[Dict] = None
_model_aliases_cache: Optional[Dict] = None
_base_models_aliases_cache: Optional[Dict[str, List[str]]] = None
def _load_popular_models() -> Dict[str, Any]:
@@ -68,6 +74,71 @@ def _load_model_aliases() -> Dict[str, List[str]]:
return _model_aliases_cache
def _normalize_base_model(value: str) -> str:
import re
return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())
def load_base_model_aliases() -> Dict[str, List[str]]:
"""Load and normalize base model aliases from base-models.json."""
global _base_models_aliases_cache
if _base_models_aliases_cache is not None:
return _base_models_aliases_cache
aliases_dict = {}
try:
if os.path.exists(BASE_MODELS_FILE):
with open(BASE_MODELS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
base_models = data.get("base_models", [])
for model in base_models:
name = model.get("name", "")
normalized_name = _normalize_base_model(name)
if not normalized_name:
continue
aliases = model.get("aliases", [])
# Normalize each alias token and deduplicate
normalized_tokens = []
seen_tokens = set()
for alias in aliases:
if not alias:
continue
normalized_alias = _normalize_base_model(alias)
if normalized_alias and normalized_alias not in seen_tokens:
seen_tokens.add(normalized_alias)
normalized_tokens.append(normalized_alias)
# Ensure normalized name itself is in preferred tokens if not already
if normalized_name not in seen_tokens:
normalized_tokens.append(normalized_name)
aliases_dict[normalized_name] = normalized_tokens
except Exception as e:
log_error(f"Error loading base model aliases: {e}")
# Fallback to hardcoded defaults if file is missing/corrupted
if not aliases_dict:
aliases_dict = {
"zimage": ["zimage", "zimageturbo"],
"pony": ["pony", "ponyxl"],
"illustrious": ["illustrious", "illustriousxl"],
"sdxl10": ["sdxl", "sdxl10", "sdxl100", "sdxl1"],
"sd15": ["sd15", "sd1", "stablediffusion15"],
"flux1d": ["flux", "flux1", "flux1d", "fluxdev"],
"flux1s": ["flux1s", "fluxschnell"],
"qwenimage": ["qwenimage"],
"hunyuan1": ["hunyuan", "hunyuan1"],
"wanvideo": ["wan", "wanvideo"],
"noobai": ["noobai"],
"hidream": ["hidream"],
}
_base_models_aliases_cache = aliases_dict
return _base_models_aliases_cache
def get_popular_model_url(filename: str) -> Optional[Dict[str, Any]]:
"""
Look up a model filename in the popular models database.
@@ -136,8 +207,241 @@ def get_all_popular_models() -> Dict[str, Any]:
def reload_databases():
"""Force reload of all databases."""
global _popular_models_cache, _model_aliases_cache
global _popular_models_cache, _model_aliases_cache, _base_models_aliases_cache
_popular_models_cache = None
_model_aliases_cache = None
_base_models_aliases_cache = None
_load_popular_models()
_load_model_aliases()
load_base_model_aliases()
BASE_MODELS_META_FILE = os.path.join(METADATA_DIR, "base-models.meta.json")
def get_base_models_config() -> Dict[str, Any]:
"""Return the raw base-models config (base_models array) for the frontend dropdown."""
return _read_base_models_file()
def _read_base_models_file() -> Dict[str, Any]:
try:
if os.path.exists(BASE_MODELS_FILE):
with open(BASE_MODELS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
log_warn(f"Error reading base-models.json: {e}")
return {"base_models": []}
def _read_base_models_meta() -> Dict[str, Any]:
try:
if os.path.exists(BASE_MODELS_META_FILE):
with open(BASE_MODELS_META_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
log_warn(f"Error reading base-models.meta.json: {e}")
return {}
def generate_aliases(name: str) -> List[str]:
aliases = {name.lower()}
# Replace dots, dashes, underscores with spaces, then normalize spaces
normalized = re.sub(r"[\._\-]+", " ", name.lower())
normalized = re.sub(r"\s+", " ", normalized).strip()
aliases.add(normalized)
# Remove all spaces/dashes/dots/underscores
collapsed = re.sub(r"[\s\._\-]+", "", name.lower())
aliases.add(collapsed)
# If there are dots/dashes/underscores, add specific clean replacements
if "." in name:
aliases.add(name.lower().replace(".", ""))
aliases.add(name.lower().replace(".", " "))
if "-" in name:
aliases.add(name.lower().replace("-", ""))
aliases.add(name.lower().replace("-", " "))
if "_":
aliases.add(name.lower().replace("_", ""))
aliases.add(name.lower().replace("_", " "))
# Extract first word if it has multiple words
words = name.lower().split()
if len(words) > 1:
first_word = words[0]
if len(first_word) > 2 and first_word not in {"stable", "flux", "pony", "sdxl"}:
aliases.add(first_word)
return sorted(list({a for a in aliases if len(a) > 1}))
def get_base_models_status(check_remote: bool = False) -> Dict[str, Any]:
"""Return local base-models metadata and optionally compare with CivitAI."""
local_data = _read_base_models_file()
base_models = local_data.get("base_models", [])
meta = _read_base_models_meta()
local_count = len(base_models)
local_updated_at = meta.get("updated_at") or ""
status = {
"local_count": local_count,
"local_updated_at": local_updated_at,
"remote_checked_at": meta.get("last_checked_at") or "",
"update_available": False,
}
if check_remote:
try:
import requests
# Fetch from CivitAI API
response = requests.get("https://civitai.com/api/v1/enums", timeout=15)
if response.status_code == 200:
enums = response.json()
remote_models = enums.get("BaseModel", [])
# Check if there are new models by comparing normalized names and aliases
all_existing_normalized = set()
for m in base_models:
all_existing_normalized.add(_normalize_base_model(m.get("name", "")))
for alias in m.get("aliases", []):
all_existing_normalized.add(_normalize_base_model(alias))
new_models_found = False
for remote_name in remote_models:
norm_remote = _normalize_base_model(remote_name)
if norm_remote and norm_remote not in all_existing_normalized:
new_models_found = True
break
status.update({
"remote_checked_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
"update_available": new_models_found
})
except Exception as e:
log_warn(f"Error checking remote base models: {e}")
return status
def update_base_models_from_remote() -> Dict[str, Any]:
"""Fetch live BaseModel enums from CivitAI and merge into base-models.json."""
import requests
response = requests.get("https://civitai.com/api/v1/enums", timeout=15)
if response.status_code != 200:
raise ValueError(f"CivitAI enums endpoint returned HTTP {response.status_code}")
enums = response.json()
remote_names = enums.get("BaseModel", [])
if not remote_names or not isinstance(remote_names, list):
raise ValueError("CivitAI enums did not return a valid BaseModel list")
local_data = _read_base_models_file()
base_models = local_data.get("base_models", [])
# 1. Build a set of all normalized aliases currently in base-models.json
all_known_normalized = set()
for m in base_models:
for alias in m.get("aliases", []):
all_known_normalized.add(_normalize_base_model(alias))
all_known_normalized.add(_normalize_base_model(m.get("name", "")))
updated_models = list(base_models)
new_added_count = 0
for name in remote_names:
norm_name = _normalize_base_model(name)
if not norm_name:
continue
# If the normalized name is already a known alias or name, skip it
if norm_name in all_known_normalized:
continue
# Generate candidate aliases
candidates = generate_aliases(name)
# Filter out aliases that are already registered/known
filtered_aliases = []
for alias in candidates:
norm_alias = _normalize_base_model(alias)
if norm_alias not in all_known_normalized:
filtered_aliases.append(alias)
all_known_normalized.add(norm_alias)
# If we have valid aliases, add the new base model entry
if filtered_aliases:
if name.lower() not in [a.lower() for a in filtered_aliases]:
filtered_aliases.insert(0, name)
all_known_normalized.add(norm_name)
updated_models.append({
"name": name,
"aliases": filtered_aliases
})
new_added_count += 1
# Write updated models to base-models.json atomically
os.makedirs(METADATA_DIR, exist_ok=True)
# Backup existing
if os.path.exists(BASE_MODELS_FILE):
shutil.copy2(BASE_MODELS_FILE, f"{BASE_MODELS_FILE}.bak")
# Write atomically
fd, tmp_path = tempfile.mkstemp(
prefix=f".{os.path.basename(BASE_MODELS_FILE)}.",
suffix=".tmp",
dir=os.path.dirname(BASE_MODELS_FILE),
text=True,
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump({"base_models": updated_models}, f, indent=2, ensure_ascii=False)
f.write("\n")
os.replace(tmp_path, BASE_MODELS_FILE)
except Exception:
try:
os.remove(tmp_path)
except OSError:
pass
raise
# Write meta file
now_str = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
meta = {
"updated_at": now_str,
"last_checked_at": now_str,
"local_count": len(updated_models),
"new_models_added": new_added_count
}
fd_meta, tmp_meta_path = tempfile.mkstemp(
prefix=f".{os.path.basename(BASE_MODELS_META_FILE)}.",
suffix=".tmp",
dir=os.path.dirname(BASE_MODELS_META_FILE),
text=True,
)
try:
with os.fdopen(fd_meta, "w", encoding="utf-8") as f:
json.dump(meta, f, indent=2, ensure_ascii=False)
f.write("\n")
os.replace(tmp_meta_path, BASE_MODELS_META_FILE)
except Exception:
try:
os.remove(tmp_meta_path)
except OSError:
pass
raise
# Force reload database in-memory cache
reload_databases()
return {
"local_count": len(updated_models),
"local_updated_at": now_str,
"new_models_added": new_added_count,
"updated": True
}
+627
View File
@@ -0,0 +1,627 @@
{
"base_models": [
{
"name": "Z-Image",
"aliases": [
"zimage",
"z image",
"z-image",
"z_image",
"zImageTurbo",
"z image turbo"
]
},
{
"name": "Pony",
"aliases": [
"pony",
"ponyxl",
"pony diffusion",
"pony realism"
]
},
{
"name": "Illustrious",
"aliases": [
"illustrious",
"illustriousxl",
"illustrious xl"
]
},
{
"name": "SDXL 1.0",
"aliases": [
"sdxl",
"sdxl10",
"sdxl 1.0",
"stable diffusion xl",
"sdxl100",
"sdxl1"
]
},
{
"name": "SD 1.5",
"aliases": [
"sd15",
"sd 1.5",
"sd1.5",
"stable diffusion 1.5",
"sd1",
"stablediffusion15"
]
},
{
"name": "Flux.1 D",
"aliases": [
"flux",
"flux1",
"flux.1",
"flux dev",
"flux.1 d",
"flux1d",
"fluxdev"
]
},
{
"name": "Flux.1 S",
"aliases": [
"flux schnell",
"flux.1 s",
"flux1s",
"fluxschnell"
]
},
{
"name": "Qwen Image",
"aliases": [
"qwen image",
"qwenimage",
"qwen-image"
]
},
{
"name": "Hunyuan 1",
"aliases": [
"hunyuan",
"hunyuan1"
]
},
{
"name": "WAN Video",
"aliases": [
"wan",
"wan video",
"wanvideo"
]
},
{
"name": "NoobAI",
"aliases": [
"noobai",
"noob ai"
]
},
{
"name": "HiDream",
"aliases": [
"hidream",
"hi dream"
]
},
{
"name": "Anima",
"aliases": [
"anima"
]
},
{
"name": "AuraFlow",
"aliases": [
"auraflow"
]
},
{
"name": "Chroma",
"aliases": [
"chroma"
]
},
{
"name": "CogVideoX",
"aliases": [
"cogvideox"
]
},
{
"name": "Ernie",
"aliases": [
"ernie"
]
},
{
"name": "Flux.1 Krea",
"aliases": [
"Flux.1 Krea",
"flux 1 krea"
]
},
{
"name": "Flux.1 Kontext",
"aliases": [
"Flux.1 Kontext",
"flux 1 kontext"
]
},
{
"name": "Flux.2 D",
"aliases": [
"Flux.2 D",
"flux 2 d",
"flux.2"
]
},
{
"name": "Flux.2 Klein 9B",
"aliases": [
"Flux.2 Klein 9B",
"flux 2 klein 9b"
]
},
{
"name": "Flux.2 Klein 9B-base",
"aliases": [
"Flux.2 Klein 9B-base",
"flux 2 klein 9b base"
]
},
{
"name": "Flux.2 Klein 4B",
"aliases": [
"Flux.2 Klein 4B",
"flux 2 klein 4b"
]
},
{
"name": "Flux.2 Klein 4B-base",
"aliases": [
"Flux.2 Klein 4B-base",
"flux 2 klein 4b base"
]
},
{
"name": "Grok",
"aliases": [
"grok"
]
},
{
"name": "HappyHorse",
"aliases": [
"happyhorse"
]
},
{
"name": "HiDream-O1",
"aliases": [
"HiDream-O1",
"hidream o1"
]
},
{
"name": "Hunyuan Video",
"aliases": [
"hunyuan video"
]
},
{
"name": "Imagen4",
"aliases": [
"imagen4"
]
},
{
"name": "Kolors",
"aliases": [
"kolors"
]
},
{
"name": "Krea 2",
"aliases": [
"krea",
"krea 2"
]
},
{
"name": "LTXV",
"aliases": [
"ltxv"
]
},
{
"name": "LTXV2",
"aliases": [
"ltxv2"
]
},
{
"name": "LTXV 2.3",
"aliases": [
"LTXV 2.3",
"ltxv 2 3"
]
},
{
"name": "Lens",
"aliases": [
"lens"
]
},
{
"name": "Lumina",
"aliases": [
"lumina"
]
},
{
"name": "MAI",
"aliases": [
"mai"
]
},
{
"name": "Mochi",
"aliases": [
"mochi"
]
},
{
"name": "Nano Banana",
"aliases": [
"nano",
"nano banana"
]
},
{
"name": "ODOR",
"aliases": [
"odor"
]
},
{
"name": "OpenAI",
"aliases": [
"openai"
]
},
{
"name": "Upscaler",
"aliases": [
"upscaler"
]
},
{
"name": "Other",
"aliases": [
"other"
]
},
{
"name": "PixArt a",
"aliases": [
"pixart",
"pixart a"
]
},
{
"name": "PixArt E",
"aliases": [
"pixart e"
]
},
{
"name": "Playground v2",
"aliases": [
"playground",
"playground v2"
]
},
{
"name": "Pony V7",
"aliases": [
"pony v7"
]
},
{
"name": "Qwen",
"aliases": [
"qwen"
]
},
{
"name": "Qwen 2",
"aliases": [
"qwen 2"
]
},
{
"name": "Stable Cascade",
"aliases": [
"stable cascade"
]
},
{
"name": "SD 1.4",
"aliases": [
"SD 1.4",
"sd 1 4"
]
},
{
"name": "SD 1.5 LCM",
"aliases": [
"SD 1.5 LCM",
"sd 1 5 lcm"
]
},
{
"name": "SD 1.5 Hyper",
"aliases": [
"SD 1.5 Hyper",
"sd 1 5 hyper"
]
},
{
"name": "SD 2.0",
"aliases": [
"SD 2.0",
"sd 2 0"
]
},
{
"name": "SD 2.0 768",
"aliases": [
"SD 2.0 768",
"sd 2 0 768"
]
},
{
"name": "SD 2.1",
"aliases": [
"SD 2.1",
"sd 2 1"
]
},
{
"name": "SD 2.1 768",
"aliases": [
"SD 2.1 768",
"sd 2 1 768"
]
},
{
"name": "SD 2.1 Unclip",
"aliases": [
"SD 2.1 Unclip",
"sd 2 1 unclip"
]
},
{
"name": "SD 3",
"aliases": [
"sd 3"
]
},
{
"name": "SD 3.5",
"aliases": [
"SD 3.5",
"sd 3 5"
]
},
{
"name": "SD 3.5 Large",
"aliases": [
"SD 3.5 Large",
"sd 3 5 large"
]
},
{
"name": "SD 3.5 Large Turbo",
"aliases": [
"SD 3.5 Large Turbo",
"sd 3 5 large turbo"
]
},
{
"name": "SD 3.5 Medium",
"aliases": [
"SD 3.5 Medium",
"sd 3 5 medium"
]
},
{
"name": "SDXL 0.9",
"aliases": [
"SDXL 0.9",
"sdxl 0 9"
]
},
{
"name": "SDXL 1.0 LCM",
"aliases": [
"SDXL 1.0 LCM",
"sdxl 1 0 lcm"
]
},
{
"name": "SDXL Lightning",
"aliases": [
"sdxl lightning"
]
},
{
"name": "SDXL Hyper",
"aliases": [
"sdxl hyper"
]
},
{
"name": "SDXL Turbo",
"aliases": [
"sdxl turbo"
]
},
{
"name": "SDXL Distilled",
"aliases": [
"sdxl distilled"
]
},
{
"name": "Seedream",
"aliases": [
"seedream"
]
},
{
"name": "SVD",
"aliases": [
"svd"
]
},
{
"name": "SVD XT",
"aliases": [
"svd xt"
]
},
{
"name": "Sora 2",
"aliases": [
"sora",
"sora 2"
]
},
{
"name": "Veo 3",
"aliases": [
"veo",
"veo 3"
]
},
{
"name": "Wan Video 1.3B t2v",
"aliases": [
"Wan Video 1.3B t2v",
"wan video 1 3b t2v"
]
},
{
"name": "Wan Video 14B t2v",
"aliases": [
"wan video 14b t2v"
]
},
{
"name": "Wan Video 14B i2v 480p",
"aliases": [
"wan video 14b i2v 480p"
]
},
{
"name": "Wan Video 14B i2v 720p",
"aliases": [
"wan video 14b i2v 720p"
]
},
{
"name": "Wan Video 2.2 TI2V-5B",
"aliases": [
"Wan Video 2.2 TI2V-5B",
"wan video 2 2 ti2v 5b"
]
},
{
"name": "Wan Video 2.2 I2V-A14B",
"aliases": [
"Wan Video 2.2 I2V-A14B",
"wan video 2 2 i2v a14b"
]
},
{
"name": "Wan Video 2.2 T2V-A14B",
"aliases": [
"Wan Video 2.2 T2V-A14B",
"wan video 2 2 t2v a14b"
]
},
{
"name": "Wan Video 2.5 T2V",
"aliases": [
"Wan Video 2.5 T2V",
"wan video 2 5 t2v"
]
},
{
"name": "Wan Video 2.5 I2V",
"aliases": [
"Wan Video 2.5 I2V",
"wan video 2 5 i2v"
]
},
{
"name": "Wan Image 2.7",
"aliases": [
"Wan Image 2.7",
"wan image 2 7"
]
},
{
"name": "Wan Video 2.7",
"aliases": [
"Wan Video 2.7",
"wan video 2 7"
]
},
{
"name": "ZImageBase",
"aliases": [
"zimagebase"
]
},
{
"name": "Vidu Q1",
"aliases": [
"vidu",
"vidu q1"
]
},
{
"name": "Hailuo by MiniMax",
"aliases": [
"hailuo",
"hailuo by minimax"
]
},
{
"name": "Kling",
"aliases": [
"kling"
]
},
{
"name": "Seedance",
"aliases": [
"seedance"
]
},
{
"name": "ACE Audio",
"aliases": [
"ace",
"ace audio"
]
}
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"updated_at": "2026-06-06T17:28:12+00:00",
"last_checked_at": "2026-06-06T17:28:12+00:00",
"local_count": 91,
"new_models_added": 79
}
File diff suppressed because one or more lines are too long
+11 -5
View File
@@ -1822,7 +1822,7 @@
}
.mr-search-source-bar {
display: grid;
grid-template-columns: auto minmax(178px, 1fr);
grid-template-columns: auto minmax(150px, 0.85fr) minmax(150px, 1fr);
align-items: center;
gap: 10px;
margin-bottom: 0;
@@ -1863,7 +1863,8 @@
text-transform: uppercase;
white-space: nowrap;
}
.mr-search-source-select {
.mr-search-source-select,
.mr-search-base-select {
width: 100%;
height: var(--mr-download-control-height);
min-height: var(--mr-download-control-height);
@@ -1878,16 +1879,19 @@
appearance: none;
box-sizing: border-box;
}
.mr-missing-browser .mr-search-source-select {
.mr-missing-browser .mr-search-source-select,
.mr-missing-browser .mr-search-base-select {
background: var(--mr-missing-control-bg, rgba(255,255,255,0.035));
border-color: var(--mr-missing-control-border, var(--mr-missing-line-strong, rgba(255,255,255,0.12)));
border-radius: var(--mr-missing-control-radius, 7px);
}
.mr-missing-browser .mr-search-source-select:focus {
.mr-missing-browser .mr-search-source-select:focus,
.mr-missing-browser .mr-search-base-select:focus {
border-color: var(--mr-missing-control-focus-border, rgba(114,211,138,0.4));
box-shadow: 0 0 0 2px var(--mr-missing-control-focus-ring, rgba(114,211,138,0.12));
}
.mr-search-source-select:focus {
.mr-search-source-select:focus,
.mr-search-base-select:focus {
border-color: rgba(114,211,138,0.4);
box-shadow: 0 0 0 3px rgba(114,211,138,0.12);
}
@@ -4362,6 +4366,7 @@
}
#model-resolver-modal .mr-missing-detail-pane .mr-combo-input,
#model-resolver-modal .mr-missing-detail-pane .mr-search-source-select,
#model-resolver-modal .mr-missing-detail-pane .mr-search-base-select,
#model-resolver-modal .mr-missing-detail-pane .mr-download-target-input,
#model-resolver-modal .mr-missing-detail-pane .mr-download-target-select {
min-height: var(--mr-missing-field-height, 38px);
@@ -4374,6 +4379,7 @@
}
#model-resolver-modal .mr-missing-detail-pane .mr-combo-input:focus,
#model-resolver-modal .mr-missing-detail-pane .mr-search-source-select:focus,
#model-resolver-modal .mr-missing-detail-pane .mr-search-base-select:focus,
#model-resolver-modal .mr-missing-detail-pane .mr-download-target-input:focus,
#model-resolver-modal .mr-missing-detail-pane .mr-download-target-select:focus {
background: var(--mr-missing-control-bg-hover, rgba(255,255,255,0.045));
@@ -846,6 +846,7 @@ export const resolveDownloadMethods = {
}
const selectedSourceLabel = this.getSearchSourceLabel(selectedSource);
const sourceIds = this.getSearchSourcesForSelection(selectedSource, missing);
const baseModelContext = this.getSearchBaseModelContext(missing);
// For URNs, use the resolved file/model name for searching instead of the URN itself
// and pass the URN type as category (CivitAI expects specific type names)
@@ -907,6 +908,7 @@ export const resolveDownloadMethods = {
state.lastAttemptSources = sourceIds;
state.lastAttemptFound = null;
state.lastAttemptError = null;
state.lastAttemptBaseModelContext = baseModelContext;
state.sourceProgress = {};
for (const source of sourceIds) {
this.setSourceProgress(state, source, {
@@ -944,6 +946,7 @@ export const resolveDownloadMethods = {
filename,
category,
is_urn: isUrn,
base_model_context: baseModelContext,
civitai_session_token: tokens.civitai_session_token,
civitai_candidate_limit: tokens.civitai_candidate_limit,
civitai_use_trpc_search: tokens.civitai_use_trpc_search,
@@ -991,6 +994,9 @@ export const resolveDownloadMethods = {
if (!this.isBackgroundSearchRunActive(workflowKey, missingSearchKey, searchRunId)) {
return { source, stale: true };
}
if (baseModelContext !== this.getSearchBaseModelContext(missing)) {
return { source, stale: true };
}
const responseSources = Array.isArray(data.searched_sources) && data.searched_sources.length
? data.searched_sources
@@ -1300,6 +1306,53 @@ export const resolveDownloadMethods = {
this.syncSearchSourceUi(missing, container);
}
const baseSelect = container.querySelector(`#search-base-select-${missing.node_id}-${missing.widget_index}`);
const baseList = container.querySelector(`#search-base-list-${missing.node_id}-${missing.widget_index}`);
if (baseSelect && baseList && baseSelect.dataset.mlSearchBaseBound !== 'true') {
baseSelect.dataset.mlSearchBaseBound = 'true';
const renderBaseOptions = () => {
const options = this.getKnownBaseModelOptions();
baseList.innerHTML = options
.map(option => {
const label = option.value === 'auto'
? this.getSearchBaseModelLabel('auto')
: option.label;
return `<div class="mr-download-target-option" data-value="${encodeURIComponent(option.value)}" data-label="${encodeURIComponent(label)}">${this.escapeHtml(label)}</div>`;
})
.join('');
baseList.style.display = 'block';
baseList.querySelectorAll('.mr-download-target-option').forEach(optionEl => {
optionEl.addEventListener('mousedown', (event) => {
event.preventDefault();
const value = decodeURIComponent(optionEl.dataset.value || '');
const label = decodeURIComponent(optionEl.dataset.label || optionEl.dataset.value || '');
this.setDropdownValue(baseSelect, value, label);
this.setSearchBaseModel(missing, value, container);
baseList.style.display = 'none';
});
});
};
const hideBaseList = () => {
setTimeout(() => {
baseList.style.display = 'none';
}, 150);
};
this.enableWheelScrollChaining(baseList);
baseSelect.addEventListener('focus', () => renderBaseOptions());
baseSelect.addEventListener('click', () => renderBaseOptions());
baseSelect.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
event.preventDefault();
renderBaseOptions();
}
});
baseSelect.addEventListener('blur', hideBaseList);
this.syncSearchSourceUi(missing, container);
}
this.wireDownloadTargetAutocomplete(container, missing);
},
+5 -4
View File
@@ -1,4 +1,4 @@
import { $el, ComfyDialog } from "../../../../scripts/ui.js";
import { $el, ComfyDialog } from "../../../../scripts/ui.js";
import { dialogShellMethods } from "./shell/dialog_shell_methods.js";
import { lifecycleGraphMethods } from "./shell/lifecycle_graph_methods.js";
import { workflowStateMethods } from "./shell/workflow_state_methods.js";
@@ -21,6 +21,7 @@ export class ResolverManagerDialog extends ComfyDialog {
this.allModels = null; // list of all available models for dropdown
this.downloadDirectories = null;
this.capabilities = null;
this.baseModels = null;
this.downloadSubfolders = new Map();
this.downloadTargetSelections = new Map(); // missing model key -> user/suggested download target
this.pendingResolutions = [];
@@ -85,21 +86,21 @@ export class ResolverManagerDialog extends ComfyDialog {
$el("div.mr-context-menu-item", {
onclick: () => this.handleContextMenuAction('showInfo')
}, [
$el("span.mr-context-menu-item-icon", { textContent: "ℹ" }),
$el("span.mr-context-menu-item-icon", { textContent: "?" }),
$el("span", { textContent: "Show Info" })
]),
$el("div.mr-context-menu-divider"),
$el("div.mr-context-menu-item", {
onclick: () => this.handleContextMenuAction('civitai')
}, [
$el("span.mr-context-menu-item-icon", { textContent: "🌐" }),
$el("span.mr-context-menu-item-icon", { textContent: "??" }),
$el("span", { textContent: "Open in CivitAI" })
]),
$el("div.mr-context-menu-divider"),
$el("div.mr-context-menu-item", {
onclick: () => this.handleContextMenuAction('openFolder')
}, [
$el("span.mr-context-menu-item-icon", { textContent: "📁" }),
$el("span.mr-context-menu-item-icon", { textContent: "??" }),
$el("span", { textContent: "Open Containing Folder" })
])
]);
@@ -50,7 +50,21 @@ export const downloadTargetMethods = {
}
},
async ensureBaseModelsLoaded() {
if (this.baseModels) return;
try {
const resp = await api.fetchApi('/model_resolver/base-models');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
this.baseModels = data && typeof data === 'object' ? data : {};
} catch (e) {
console.warn('Model Resolver: could not load base-models config', e);
this.baseModels = {};
}
},
isSourceAvailable(source) {
if (!source || ['all', 'local', 'huggingface', 'civitai', 'civarchive'].includes(source)) {
return true;
}
@@ -675,11 +689,13 @@ export const downloadTargetMethods = {
this.allModels = null;
this.downloadDirectories = null;
this.capabilities = null;
this.baseModels = null;
this.downloadSubfolders.clear();
this.downloadTargetSelections?.clear();
this._analysisProgressToken = null;
await this.ensureCapabilitiesLoaded();
await this.ensureBaseModelsLoaded();
this.refreshMissingListStats?.();
this.updateBatchFooterButtons?.();
this.updateDownloadAllButtonState?.();
+166 -1
View File
@@ -1,4 +1,4 @@
import { app } from "../../../../../scripts/app.js";
import { app } from "../../../../../scripts/app.js";
import { api } from "../../../../../scripts/api.js";
import { $el } from "../../../../../scripts/ui.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
@@ -29,6 +29,7 @@ export const searchPanelMethods = {
createEmptySearchState() {
return {
selectedSource: 'all',
selectedBaseModel: 'auto',
results: {
popular: null,
model_list: null,
@@ -38,6 +39,7 @@ export const searchPanelMethods = {
lora_manager_archive: null
},
lastAttemptSources: [],
lastAttemptBaseModelContext: '',
lastAttemptFound: null,
lastAttemptError: null,
sourceProgress: {},
@@ -45,6 +47,119 @@ export const searchPanelMethods = {
};
},
getKnownBaseModelOptions() {
const baseModelsList = this.baseModels?.base_models;
if (Array.isArray(baseModelsList) && baseModelsList.length > 0) {
return [
{ value: 'auto', label: 'Auto' },
{ value: 'none', label: 'Any model' },
...baseModelsList.map(m => ({ value: m.name, label: m.name }))
];
}
// Fallback to hardcoded list
return [
{ value: 'auto', label: 'Auto' },
{ value: 'none', label: 'Any model' },
{ value: 'Z-Image', label: 'Z-Image' },
{ value: 'Pony', label: 'Pony' },
{ value: 'Illustrious', label: 'Illustrious' },
{ value: 'SDXL 1.0', label: 'SDXL 1.0' },
{ value: 'SD 1.5', label: 'SD 1.5' },
{ value: 'Flux.1 D', label: 'Flux.1 D' },
{ value: 'Flux.1 S', label: 'Flux.1 S' },
{ value: 'Qwen Image', label: 'Qwen Image' },
{ value: 'Hunyuan 1', label: 'Hunyuan 1' },
{ value: 'WAN Video', label: 'WAN Video' },
{ value: 'NoobAI', label: 'NoobAI' },
{ value: 'HiDream', label: 'HiDream' }
];
},
normalizeBaseModelToken(value = '') {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '');
},
getBaseModelAliases() {
const baseModelsList = this.baseModels?.base_models;
if (Array.isArray(baseModelsList) && baseModelsList.length > 0) {
return baseModelsList.map(m => ({ value: m.name, aliases: m.aliases || [] }));
}
// Fallback to hardcoded list
return [
{ value: 'Z-Image', aliases: ['zimage', 'z image', 'z-image', 'z_image', 'zImageTurbo', 'z image turbo'] },
{ value: 'Pony', aliases: ['pony', 'ponyxl', 'pony diffusion', 'pony realism'] },
{ value: 'Illustrious', aliases: ['illustrious', 'illustriousxl', 'illustrious xl'] },
{ value: 'SDXL 1.0', aliases: ['sdxl', 'sdxl10', 'sdxl 1.0', 'stable diffusion xl'] },
{ value: 'SD 1.5', aliases: ['sd15', 'sd 1.5', 'sd1.5', 'stable diffusion 1.5'] },
{ value: 'Flux.1 D', aliases: ['flux', 'flux1', 'flux.1', 'flux dev', 'flux.1 d', 'flux1d'] },
{ value: 'Flux.1 S', aliases: ['flux schnell', 'flux.1 s', 'flux1s'] },
{ value: 'Qwen Image', aliases: ['qwen image', 'qwenimage', 'qwen-image'] },
{ value: 'Hunyuan 1', aliases: ['hunyuan', 'hunyuan1'] },
{ value: 'WAN Video', aliases: ['wan', 'wan video', 'wanvideo'] },
{ value: 'NoobAI', aliases: ['noobai', 'noob ai'] },
{ value: 'HiDream', aliases: ['hidream', 'hi dream'] }
];
},
getWorkflowModelReferenceText() {
const workflow = this.getCurrentWorkflow?.();
if (!workflow) return '';
const values = [];
const visit = (value, key = '') => {
if (typeof value === 'string') {
if (/\.(safetensors|ckpt|pt|pth|bin|gguf|onnx)\b/i.test(value) || /model|checkpoint|unet|diffusion/i.test(key)) {
values.push(value);
}
return;
}
if (Array.isArray(value)) {
value.forEach(item => visit(item, key));
return;
}
if (value && typeof value === 'object') {
Object.entries(value).forEach(([childKey, childValue]) => visit(childValue, childKey));
}
};
visit(workflow);
return values.join(' ');
},
getDominantWorkflowBaseModel() {
const text = this.getWorkflowModelReferenceText();
if (!text) return '';
const normalizedText = this.normalizeBaseModelToken(text);
let best = null;
for (const entry of this.getBaseModelAliases()) {
const score = entry.aliases.reduce((count, alias) => {
const token = this.normalizeBaseModelToken(alias);
return token && normalizedText.includes(token) ? count + 1 : count;
}, 0);
if (score > 0 && (!best || score > best.score)) {
best = { value: entry.value, score };
}
}
return best?.value || '';
},
getSearchBaseModelLabel(value = 'auto') {
if (value === 'auto') {
const detected = this.getDominantWorkflowBaseModel();
return detected ? `Auto (${detected})` : 'Auto';
}
const option = this.getKnownBaseModelOptions().find(item => item.value === value);
return option?.label || value || 'Auto';
},
getSearchBaseModelContext(missing = {}) {
const state = this.getSearchState(missing);
const selected = state.selectedBaseModel || 'auto';
if (selected === 'none') return '';
if (selected === 'auto') return this.getDominantWorkflowBaseModel();
return selected;
},
getBackgroundSearchJobKey(workflowKey, missingSearchKey) {
return `${workflowKey || 'workflow'}\n${missingSearchKey || 'missing'}`;
},
@@ -636,6 +751,15 @@ export const searchPanelMethods = {
}
this.setDropdownValue(selectEl, state.selectedSource, this.getSearchSourceLabel(state.selectedSource));
}
const baseEl = container.querySelector(`#search-base-select-${missing.node_id}-${missing.widget_index}`);
if (baseEl) {
const options = this.getKnownBaseModelOptions();
if (!options.some(option => option.value === state.selectedBaseModel)) {
state.selectedBaseModel = 'auto';
}
this.setDropdownValue(baseEl, state.selectedBaseModel, this.getSearchBaseModelLabel(state.selectedBaseModel));
}
},
/**
@@ -648,6 +772,37 @@ export const searchPanelMethods = {
this.syncSearchSourceUi(missing, container);
},
setSearchBaseModel(missing, baseModel, container) {
const state = this.getSearchState(missing);
const nextBaseModel = baseModel || 'auto';
const changed = (state.selectedBaseModel || 'auto') !== nextBaseModel;
if (changed) {
this.clearSearchProgressTimers(state.activeSearchRunId);
state.results = this.createEmptySearchState().results;
state.lastAttemptSources = [];
state.lastAttemptBaseModelContext = '';
state.lastAttemptFound = null;
state.lastAttemptError = null;
state.sourceProgress = {};
state.activeSearchRunId = null;
const resultsDiv = container?.querySelector?.(`#search-results-${missing.node_id}-${missing.widget_index}`);
if (resultsDiv) {
resultsDiv.classList.remove('mr-is-visible');
resultsDiv.classList.add('mr-is-hidden');
resultsDiv.innerHTML = '';
}
const searchBtn = container?.querySelector?.(`#search-${missing.node_id}-${missing.widget_index}`);
if (searchBtn) {
searchBtn.disabled = false;
searchBtn.innerHTML = `${this.getSearchIconHtml()} ${this.hasSearchResultsForMissing(missing) ? 'Search Again' : 'Search'}`;
}
}
state.selectedBaseModel = nextBaseModel;
this.persistSearchStateForActiveWorkflow();
this.syncSearchSourceUi(missing, container);
this.refreshSearchUiForMissing?.(missing, state);
},
getDropdownValue(el) {
return el?.dataset?.value || el?.value || '';
},
@@ -985,8 +1140,11 @@ export const searchPanelMethods = {
const searchSourcesId = `search-sources-${missing.node_id}-${missing.widget_index}`;
const searchSourceSelectId = `search-source-select-${missing.node_id}-${missing.widget_index}`;
const searchSourceListId = `search-source-list-${missing.node_id}-${missing.widget_index}`;
const searchBaseSelectId = `search-base-select-${missing.node_id}-${missing.widget_index}`;
const searchBaseListId = `search-base-list-${missing.node_id}-${missing.widget_index}`;
const state = this.getSearchState(missing);
const selectedSource = state.selectedSource || 'all';
const selectedBaseModel = state.selectedBaseModel || 'auto';
const buttonText = options.buttonText
|| (this.hasSearchResultsForMissing(missing) ? 'Search Again' : 'Search');
@@ -1001,6 +1159,13 @@ export const searchPanelMethods = {
html += `<div id="${searchSourceListId}" class="mr-download-target-list mr-search-source-list"></div>`;
html += `</div>`;
html += `</div>`;
html += `<div class="mr-search-source-picker mr-search-base-picker">`;
html += `<label class="mr-search-source-picker-label" for="${searchBaseSelectId}">Model</label>`;
html += `<div class="mr-download-target-wrap">`;
html += `<input id="${searchBaseSelectId}" class="mr-download-target-input mr-search-base-select" type="text" readonly autocomplete="off" data-value="${this.escapeHtml(selectedBaseModel)}" value="${this.escapeHtml(this.getSearchBaseModelLabel(selectedBaseModel))}">`;
html += `<div id="${searchBaseListId}" class="mr-download-target-list mr-search-base-list"></div>`;
html += `</div>`;
html += `</div>`;
html += `</div>`;
return html;
},
@@ -32,6 +32,7 @@ export const lifecycleGraphMethods = {
// Ensure all models are loaded for dropdown
await this.ensureCapabilitiesLoaded();
await this.ensureBaseModelsLoaded();
await this.ensureAllModelsLoaded();
await this.ensureDownloadDirectoriesLoaded();
@@ -186,10 +186,12 @@ export const workflowStateMethods = {
}
clone.selectedSource = clone.selectedSource || 'all';
clone.selectedBaseModel = clone.selectedBaseModel || 'auto';
clone.results = this.mergeSearchResults({}, clone.results || {});
clone.lastAttemptSources = Array.isArray(clone.lastAttemptSources)
? clone.lastAttemptSources
: [];
clone.lastAttemptBaseModelContext = clone.lastAttemptBaseModelContext || '';
clone.lastAttemptFound = clone.lastAttemptFound ?? null;
clone.lastAttemptError = clone.lastAttemptError || null;
clone.sourceProgress = clone.sourceProgress || {};
@@ -229,6 +231,7 @@ export const workflowStateMethods = {
isPersistableSearchState(state = {}) {
if ((state?.selectedSource || 'all') !== 'all') return true;
if ((state?.selectedBaseModel || 'auto') !== 'auto') return true;
if (state?.lastAttemptError) return true;
if (this.hasSearchResults(state?.results || {})) return true;
+137
View File
@@ -136,6 +136,35 @@ export const optionsMethods = {
</div>
</div>
</div>
<div class="mr-options-section-head" style="margin-top: 24px;">
<h4 class="mr-options-section-title">Base Models Support</h4>
</div>
<div class="mr-options-grid">
<div class="mr-options-panel">
<div class="mr-options-stack">
<div class="mr-options-db-summary">
<div class="mr-options-db-row">
<span>Base Models</span>
<strong id="mr-options-base-models-count">Loading...</strong>
</div>
<div class="mr-options-db-row">
<span>Last update</span>
<strong id="mr-options-base-models-updated">Loading...</strong>
</div>
<div class="mr-options-db-row">
<span>Status</span>
<strong id="mr-options-base-models-state">Checking local file...</strong>
</div>
</div>
<div id="mr-options-base-models-message" class="mr-options-db-message">Base Models list maps new CivitAI models to local categories.</div>
<div class="mr-options-db-actions">
<button id="mr-options-base-models-check" type="button" class="mr-btn mr-btn-secondary">${getSvgIcon('refreshCw')} Check latest</button>
<button id="mr-options-base-models-update" type="button" class="mr-btn mr-btn-primary">${getSvgIcon('download')} Update Base Models</button>
<a class="mr-options-inline-link" href="https://civitai.com/api/v1/enums" target="_blank" rel="noopener noreferrer">Source</a>
</div>
</div>
</div>
</div>
</section>
<section id="mr-options-section-civitai" class="mr-options-card mr-options-section">
<div class="mr-options-section-head">
@@ -353,6 +382,12 @@ export const optionsMethods = {
const modelListMessageEl = this.contentElement.querySelector('#mr-options-model-list-message');
const modelListCheckBtn = this.contentElement.querySelector('#mr-options-model-list-check');
const modelListUpdateBtn = this.contentElement.querySelector('#mr-options-model-list-update');
const baseModelsCountEl = this.contentElement.querySelector('#mr-options-base-models-count');
const baseModelsUpdatedEl = this.contentElement.querySelector('#mr-options-base-models-updated');
const baseModelsStateEl = this.contentElement.querySelector('#mr-options-base-models-state');
const baseModelsMessageEl = this.contentElement.querySelector('#mr-options-base-models-message');
const baseModelsCheckBtn = this.contentElement.querySelector('#mr-options-base-models-check');
const baseModelsUpdateBtn = this.contentElement.querySelector('#mr-options-base-models-update');
const hfIndexCountEl = this.contentElement.querySelector('#mr-options-hf-index-count');
const hfIndexUpdatedEl = this.contentElement.querySelector('#mr-options-hf-index-updated');
const hfIndexStateEl = this.contentElement.querySelector('#mr-options-hf-index-state');
@@ -397,6 +432,11 @@ export const optionsMethods = {
if (modelListUpdateBtn) modelListUpdateBtn.disabled = busy;
};
const setBaseModelsBusy = (busy) => {
if (baseModelsCheckBtn) baseModelsCheckBtn.disabled = busy;
if (baseModelsUpdateBtn) baseModelsUpdateBtn.disabled = busy;
};
const setHfIndexBusy = (busy) => {
if (hfIndexRefreshBtn) hfIndexRefreshBtn.disabled = busy;
};
@@ -525,6 +565,89 @@ export const optionsMethods = {
}
};
const renderBaseModelsStatus = (data = {}, checkedRemote = false) => {
if (baseModelsCountEl) {
baseModelsCountEl.textContent = Number.isFinite(Number(data.local_count))
? Number(data.local_count).toLocaleString()
: '-';
}
if (baseModelsUpdatedEl) {
baseModelsUpdatedEl.textContent = formatOptionDate(data.local_updated_at);
}
if (baseModelsStateEl) {
if (checkedRemote && data.update_available) {
baseModelsStateEl.textContent = 'Update available';
} else if (checkedRemote) {
baseModelsStateEl.textContent = 'Up to date';
} else {
baseModelsStateEl.textContent = data.local_updated_at ? 'Tracked' : 'Bundled';
}
}
if (baseModelsMessageEl) {
if (checkedRemote && data.update_available) {
baseModelsMessageEl.textContent = `New base models are available from CivitAI.`;
} else if (checkedRemote) {
baseModelsMessageEl.textContent = `Base Models list is current.`;
} else {
baseModelsMessageEl.textContent = `Base Models list maps new CivitAI models to local categories.`;
}
}
};
const loadBaseModelsStatus = async (checkRemote = false) => {
try {
setBaseModelsBusy(true);
if (baseModelsStateEl) {
baseModelsStateEl.textContent = checkRemote ? 'Checking CivitAI...' : 'Loading...';
}
const response = await api.fetchApi(`/model_resolver/base-models/status${checkRemote ? '?check_remote=1' : ''}`);
if (!response.ok) {
throw new Error(`Status failed: ${response.status}`);
}
const data = await response.json();
renderBaseModelsStatus(data, checkRemote);
return data;
} catch (error) {
console.error('Model Resolver: base-models status error:', error);
if (baseModelsStateEl) baseModelsStateEl.textContent = 'Error';
if (baseModelsMessageEl) baseModelsMessageEl.textContent = error.message || 'Failed to read Base Models status.';
return null;
} finally {
setBaseModelsBusy(false);
}
};
const updateBaseModels = async () => {
try {
setBaseModelsBusy(true);
if (baseModelsStateEl) baseModelsStateEl.textContent = 'Updating...';
if (baseModelsMessageEl) baseModelsMessageEl.textContent = 'Downloading latest base models from CivitAI...';
const response = await api.fetchApi('/model_resolver/base-models/update', {
method: 'POST'
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `Update failed: ${response.status}`);
}
await this.clearSearchCaches();
// Invalidate the in-memory cache so the base model dropdown
// reloads fresh data from the server next time it is opened.
this.baseModels = null;
await this.ensureBaseModelsLoaded();
renderBaseModelsStatus(data, false);
if (baseModelsStateEl) baseModelsStateEl.textContent = 'Updated';
if (baseModelsMessageEl) baseModelsMessageEl.textContent = `Base Models updated. ${Number(data.local_count || 0).toLocaleString()} base models loaded.`;
this.showNotification('Base Models updated', 'success');
} catch (error) {
console.error('Model Resolver: base-models update error:', error);
if (baseModelsStateEl) baseModelsStateEl.textContent = 'Error';
if (baseModelsMessageEl) baseModelsMessageEl.textContent = error.message || 'Failed to update Base Models.';
this.showNotification('Base Models update failed', 'error');
} finally {
setBaseModelsBusy(false);
}
};
const renderHfIndexStatus = (data = {}) => {
if (hfIndexCountEl) {
hfIndexCountEl.textContent = Number.isFinite(Number(data.file_count))
@@ -683,6 +806,7 @@ export const optionsMethods = {
setVisibleSection(targetId);
if (targetId === 'mr-options-section-local-db') {
loadModelListStatus(false);
loadBaseModelsStatus(false);
} else if (targetId === 'mr-options-section-hf') {
loadHfIndexStatus();
}
@@ -701,6 +825,18 @@ export const optionsMethods = {
});
}
if (baseModelsCheckBtn) {
baseModelsCheckBtn.addEventListener('click', () => {
loadBaseModelsStatus(true);
});
}
if (baseModelsUpdateBtn) {
baseModelsUpdateBtn.addEventListener('click', () => {
updateBaseModels();
});
}
if (hfIndexRefreshBtn) {
hfIndexRefreshBtn.addEventListener('click', () => {
refreshHfIndex();
@@ -766,6 +902,7 @@ export const optionsMethods = {
}
loadModelListStatus(false);
loadBaseModelsStatus(false);
loadHfIndexStatus();
if (saveBtn) {