Improve missing dependency detection and native panel UI

This commit is contained in:
ComfyUI_FindModels
2026-06-18 14:04:37 +08:00
parent 2fa2fc1830
commit bbd569e2ef
11 changed files with 3138 additions and 328 deletions
+2
View File
@@ -2,3 +2,5 @@ __pycache__/
*.py[cod]
.pytest_cache/
.venv/
external_model_folder.json
quark_auth.json
+25 -15
View File
@@ -9,6 +9,8 @@ candidate direct-download URLs from Civitai and Hugging Face.
- Scans explicit model-selector widgets on the live workflow after a workflow opens.
- Scans live widget values, nested custom-widget values, and serialized widget values used by dynamic custom loaders.
- Mirrors ComfyUI's workflow overview by scanning embedded model metadata and treating invalid official model-selector values as missing.
- Detects model selectors by their model-file option lists, including custom multi-LoRA widgets whose type is not `combo`.
- Ignores bypassed/disabled nodes, prioritizes live combo and asset widgets, preserves official model URLs and groups all referencing nodes.
- Recognizes checkpoints, LoRAs, VAEs, ControlNet, text encoders, diffusion models,
embeddings, and upscale models.
- Uses ComfyUI's configured model paths, including paths from `extra_model_paths.yaml`.
@@ -24,16 +26,25 @@ candidate direct-download URLs from Civitai and Hugging Face.
ComfyUI model folder.
- Shows candidate file sizes and loads successfully downloaded files into their workflow nodes.
- Shows live downloaded bytes, total size, and percentage while a model download is running.
- Supports pausing, resuming with HTTP range requests, retrying, and cancelling model downloads.
- Keeps downloads running when the panel closes and restores active download progress when it is reopened or the page reloads.
- Searches the configured Quark Netdisk libraries directly for missing model filenames.
- Ignores serialized workflow metadata, URLs, free-text fields, and generic `model`
fields on non-loader custom nodes to prevent false missing-model reports.
- Detects node types missing from ComfyUI's live node registry and matches them against
the local TE launcher's official plugin market plus ComfyUI-Manager's official node map.
- Installs or updates only exact, verified TE-market matches. Before installation it checks
workflow package metadata plus ComfyUI-Manager's official node map.
- Makes every missing-node card clickable and provides retry plus GitHub search actions.
- Excludes node types already registered by frontend-only LiteGraph extensions to avoid false missing-node reports.
- Separates missing models, missing nodes, downloads, and settings into dedicated overview-style tabs and can locate referencing nodes on the canvas.
- Can install missing-node plugins from verified mappings or an explicitly entered GitHub repository URL.
- Installs or updates only GitHub repositories selected by the user. Before installation it checks
duplicate repositories, performs a dependency dry run, blocks core runtime dependency
changes, and runs `pip check`.
- Does not move, delete, or overwrite model files.
- Can search a user-configured external model library for exact missing filenames and,
after explicit confirmation, move them into the matching registered ComfyUI model folder.
- Opens the native Windows folder selector when choosing an external model library.
- Shows the missing model size when workflow metadata, a verified source, or an exact external-library match provides it.
- Never overwrites an existing model file.
## Installation
@@ -44,10 +55,9 @@ cd ComfyUI/custom_nodes
git clone https://github.com/YOUR_NAME/ComfyUI_FindModels.git
```
Restart ComfyUI. A persistent **查找模型** button appears beside the Queue/Run
controls in the top toolbar. Hover over it to see the latest missing-model count.
The button is placed immediately before **显示图像流** when that control is available.
The panel stays closed during startup and workflow loading; open it from the toolbar.
Restart ComfyUI. A persistent **查找模型** button appears after the active-task control
and before the property-panel toggle. The panel stays closed during startup and mounts
inside ComfyUI's native resizable right-side panel when opened.
No extra Python packages are required.
@@ -58,11 +68,11 @@ No extra Python packages are required.
3. Review local replacement suggestions.
4. Click **一键加载模型** to load safe high-confidence local matches into their nodes, or apply an
individual suggestion.
5. For missing models, click **下载缺失模型**. You can download a Civitai or Hugging
5. For missing models, click **查找下载来源**. You can download a Civitai or Hugging
Face candidate or a public match from the two configured Quark Netdisk model libraries
directly into its matching configured model folder.
6. Missing nodes automatically query the TE official plugin market. Click **安装或更新插件**
only after reviewing the exact matched repository, then restart ComfyUI.
6. Missing nodes use workflow metadata and ComfyUI-Manager's official mapping. Review the
repository before installing, or enter a custom GitHub repository URL, then restart ComfyUI.
The download results are search candidates. Verify the model page, license, base model,
and filename before downloading. Some Civitai files require authentication, so their
@@ -75,9 +85,9 @@ incorrect folder.
Downloads are accepted only from approved Civitai and Hugging Face HTTPS hosts. Existing
files are never overwritten. Completed downloads clear ComfyUI's model filename cache so
the model appears in the matching node selector. Quark downloads use its public share API
without reading account cookies. Quark may refuse direct downloads for large or restricted
files; the extension reports that restriction and does not bypass it.
the model appears in the matching node selector. Quark downloads use its public share API;
an optional login Cookie can be stored locally for files that require authentication. Quark
may still refuse restricted files, in which case the extension tries a verified exact-name source.
## Matching Rules
@@ -100,8 +110,8 @@ python -m unittest discover -s tests -v
Workflow model filenames are sent to Civitai, Hugging Face, and the two configured public
Quark shares only when you explicitly click **下载缺失模型**. Regular scans remain local.
Missing node names are sent to the TE official plugin market and ComfyUI-Manager's official
node map to find verified plugin candidates.
Missing node names are sent to ComfyUI-Manager's official node map when finding verified
plugin candidates. An optional Quark Cookie is stored only in the local ignored configuration file.
## License
+766 -64
View File
File diff suppressed because it is too large Load Diff
+174 -18
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass
from difflib import SequenceMatcher
from pathlib import PurePosixPath
@@ -13,10 +14,19 @@ MODEL_EXTENSIONS = {
".gguf",
".onnx",
".pt",
".pt2",
".pth",
".pkl",
".safetensors",
".sft",
}
STANDARD_LOADER_HINTS = (
"instantid",
"ipadapter",
"ip_adapter",
"samloader",
"sam_loader",
"ultralytics",
"checkpoint",
"lora",
"vae",
@@ -24,6 +34,7 @@ STANDARD_LOADER_HINTS = (
"clip",
"textencoder",
"text_encoder",
"textencode",
"unet",
"diffusion",
"upscale",
@@ -61,10 +72,29 @@ EXPLICIT_MODEL_WIDGETS = (
"upscale_model_name",
"embedding_name",
)
MODEL_WIDGET_PATTERNS = (
re.compile(r"^(?:lora|lycoris)_?\d+$"),
re.compile(r"^(?:ckpt|checkpoint|vae|controlnet|control_net|clip|clip_vision|text_encoder|unet|diffusion_model|upscale_model)_?\d+$"),
re.compile(r"^(?:model|model_name)_\d+$"),
)
CATEGORY_HINTS = (
("diffusion_models", ("fantasytalking", "fantasyportrait")),
("upscale_models", ("rife", "frame_interpolation", "film", "upscale", "esrgan", "super_resolution", "superresolution")),
("LLM", ("llama_cpp", "llama cpp", "llama-cpp", "llm", "mmproj")),
# InstantID uses an IP-Adapter-format file, but its loader registers the
# official ComfyUI category as "instantid". Keep this before ipadapter.
("instantid", ("instantid", "instant_id", "instant id")),
("ipadapter", ("ipadapter", "ip_adapter", "ip adapter")),
("ultralytics_segm", ("ultralytics_segm", "ultralytics segm", "segm_", "segm/")),
("ultralytics_bbox", ("ultralytics_bbox", "ultralytics bbox", "bbox_", "bbox/")),
("sams", ("samloader", "sam_loader", "sam loader", "sam_model", "sam model")),
("diffusion_models", ("fantasytalking", "fantasyportrait", "infinitetalk")),
("detection", ("detection", "detector", "vitpose", "yolo")),
("frame_interpolation", ("rife", "frame_interpolation", "frame interpolation", "film")),
("audio_encoders", ("audio_encoder", "audio encoder", "wav2vec", "whisper")),
("background_removal", ("background_removal", "background removal", "rembg")),
("geometry_estimation", ("geometry_estimation", "geometry estimation", "depth_anything")),
("optical_flow", ("optical_flow", "optical flow")),
("upscale_models", ("upscale", "esrgan", "super_resolution", "superresolution")),
("controlnet", ("controlnet", "control_net", "control")),
("embeddings", ("embedding", "textual_inversion")),
("vae", ("vae",)),
@@ -76,6 +106,12 @@ CATEGORY_HINTS = (
)
CATEGORY_ALIASES = {
"LLM": ("LLM",),
"instantid": ("instantid",),
"ipadapter": ("ipadapter",),
"sams": ("sams",),
"ultralytics_bbox": ("ultralytics_bbox",),
"ultralytics_segm": ("ultralytics_segm",),
"checkpoints": ("checkpoints",),
"loras": ("loras",),
"vae": ("vae",),
@@ -85,7 +121,19 @@ CATEGORY_ALIASES = {
"diffusion_models": ("diffusion_models", "unet"),
"upscale_models": ("upscale_models",),
"embeddings": ("embeddings",),
"detection": ("detection",),
"frame_interpolation": ("frame_interpolation",),
"audio_encoders": ("audio_encoders",),
"background_removal": ("background_removal",),
"geometry_estimation": ("geometry_estimation",),
"optical_flow": ("optical_flow",),
"unknown": (
"LLM",
"instantid",
"ipadapter",
"sams",
"ultralytics_bbox",
"ultralytics_segm",
"checkpoints",
"loras",
"vae",
@@ -110,6 +158,11 @@ class Reference:
node_type: str | None = None
strict: bool = True
official_missing: bool = False
official_valid: bool = False
source_url: str | None = None
source_hash: str | None = None
source_hash_type: str | None = None
source_size: int | None = None
def as_dict(self) -> dict[str, Any]:
return {
@@ -120,6 +173,11 @@ class Reference:
"node_type": self.node_type,
"strict": self.strict,
"official_missing": self.official_missing,
"official_valid": self.official_valid,
"source_url": self.source_url,
"source_hash": self.source_hash,
"source_hash_type": self.source_hash_type,
"source_size": self.source_size,
}
@@ -131,6 +189,11 @@ def basename(value: str) -> str:
return PurePosixPath(normalize_path(value)).name
def model_name_key(value: str) -> str:
normalized = unicodedata.normalize("NFKC", basename(value)).casefold()
return "".join(char for char in normalized if char.isalnum())
def is_model_name(value: str) -> bool:
if "://" in value or value.startswith(("http:", "https:")):
return False
@@ -138,11 +201,18 @@ def is_model_name(value: str) -> bool:
return PurePosixPath(clean).suffix.lower() in MODEL_EXTENSIONS
def classify(hint: str) -> str:
def classify(hint: str, registered_categories: Iterable[str] = ()) -> str:
normalized = re.sub(r"[^a-z0-9]+", "_", hint.lower())
for category, terms in CATEGORY_HINTS:
if any(term.replace(" ", "_") in normalized for term in terms):
return category
for category in registered_categories:
category_hint = re.sub(r"[^a-z0-9]+", "_", str(category).lower()).strip("_")
if len(category_hint) >= 4 and (
category_hint in normalized
or category_hint.replace("_", "") in normalized.replace("_", "")
):
return str(category)
return "unknown"
@@ -153,7 +223,13 @@ def normalized_stem(value: str) -> str:
return "".join(token for token in tokens if token and not ignored.match(token))
def extract_references(payload: Any) -> list[Reference]:
def is_explicit_model_widget(value: Any) -> bool:
widget_hint = re.sub(r"[^a-z0-9_]+", "", str(value or "").lower())
return widget_hint in EXPLICIT_MODEL_WIDGETS or any(pattern.fullmatch(widget_hint) for pattern in MODEL_WIDGET_PATTERNS)
def extract_references(payload: Any, registered_categories: Iterable[str] = ()) -> list[Reference]:
registered_categories = tuple(registered_categories)
found: dict[tuple[str, str, str | None, str | None], Reference] = {}
def add(
@@ -164,29 +240,40 @@ def extract_references(payload: Any) -> list[Reference]:
node_type: Any = None,
model_selector: bool = False,
model_value_valid: bool | None = None,
source_url: Any = None,
source_hash: Any = None,
source_hash_type: Any = None,
source_size: Any = None,
category_override: Any = None,
) -> None:
if not is_model_name(value):
return
widget_hint = re.sub(r"[^a-z0-9_]+", "", str(widget or "").lower())
node_hint = re.sub(r"[^a-z0-9_]+", "", str(node_type or "").lower())
if node_id is not None:
explicit_widget = widget_hint in EXPLICIT_MODEL_WIDGETS
explicit_widget = is_explicit_model_widget(widget)
# Custom loaders frequently translate the widget label (for example
# "模型"), so the file extension plus the loader node type is the
# reliable signal. Non-loader custom nodes remain excluded.
loader_widget = "loader" in node_hint
if not explicit_widget and not loader_widget and not model_selector:
model_node = any(term in node_hint for term in STANDARD_LOADER_HINTS)
if not explicit_widget and not loader_widget and not model_node and not model_selector:
return
ref = Reference(
name=normalize_path(value),
category=classify(f"{hint} {value}"),
category=str(category_override) if isinstance(category_override, str) and category_override else classify(f"{hint} {value}", registered_categories),
node_id=None if node_id is None else str(node_id),
widget=None if widget is None else str(widget),
node_type=None if node_type is None else str(node_type),
strict=model_selector
or is_explicit_model_widget(widget)
or "loader" in node_hint
or any(term in node_hint for term in STANDARD_LOADER_HINTS),
official_missing=model_selector and model_value_valid is False,
official_valid=model_selector and model_value_valid is True,
source_url=source_url if isinstance(source_url, str) else None,
source_hash=source_hash if isinstance(source_hash, str) else None,
source_hash_type=source_hash_type if isinstance(source_hash_type, str) else None,
source_size=int(source_size) if isinstance(source_size, (int, float)) and source_size > 0 else None,
)
found[(ref.name.lower(), ref.category, ref.node_id, ref.widget)] = ref
@@ -203,6 +290,8 @@ def extract_references(payload: Any) -> list[Reference]:
current_id = value.get("id", node_id)
current_type = value.get("type") or value.get("class_type") or node_type
if current_id is not None and value.get("active") is False:
return
if isinstance(value.get("name"), str) and isinstance(value.get("directory"), str):
add(
value["name"],
@@ -211,6 +300,12 @@ def extract_references(payload: Any) -> list[Reference]:
None,
current_type,
True,
None,
value.get("url"),
value.get("hash"),
value.get("hash_type"),
value.get("size"),
value.get("directory"),
)
if isinstance(value.get("widgets"), list):
for widget in value["widgets"]:
@@ -229,6 +324,11 @@ def extract_references(payload: Any) -> list[Reference]:
current_type,
bool(widget.get("model_selector")),
widget.get("model_value_valid"),
widget.get("source_url"),
widget.get("source_hash"),
widget.get("source_hash_type"),
widget.get("source_size"),
widget.get("directory"),
)
elif isinstance(item, list):
for nested in item:
@@ -249,10 +349,22 @@ def extract_references(payload: Any) -> list[Reference]:
walk(payload)
references = list(found.values())
located_names = {ref.name.lower() for ref in references if ref.node_id is not None}
live_widget_refs = {
(ref.name.lower(), ref.node_id)
for ref in references
if ref.node_id is not None and ref.widget is not None
}
filtered = [
ref
for ref in references
if ref.node_id is not None or ref.name.lower() not in located_names
if (
(ref.node_id is not None or ref.name.lower() not in located_names)
and not (
ref.node_id is not None
and ref.widget is None
and (ref.name.lower(), ref.node_id) in live_widget_refs
)
)
]
deduplicated: dict[tuple[str, str | None, str | None], Reference] = {}
for ref in filtered:
@@ -263,9 +375,13 @@ def extract_references(payload: Any) -> list[Reference]:
return list(deduplicated.values())
def load_installed(get_filename_list: Callable[[str], Iterable[str]]) -> dict[str, list[str]]:
def load_installed(
get_filename_list: Callable[[str], Iterable[str]], extra_categories: Iterable[str] = ()
) -> dict[str, list[str]]:
installed: dict[str, list[str]] = {}
for category in {alias for aliases in CATEGORY_ALIASES.values() for alias in aliases}:
categories = {alias for aliases in CATEGORY_ALIASES.values() for alias in aliases}
categories.update(category for category in extra_categories if category and category != "unknown")
for category in categories:
try:
installed[category] = sorted({normalize_path(str(name)) for name in get_filename_list(category)})
except Exception:
@@ -274,10 +390,18 @@ def load_installed(get_filename_list: Callable[[str], Iterable[str]]) -> dict[st
def match_reference(reference: Reference, installed: dict[str, list[str]]) -> dict[str, Any]:
categories = CATEGORY_ALIASES.get(reference.category, CATEGORY_ALIASES["unknown"])
if reference.official_valid:
return {"status": "installed", "match": None}
categories = CATEGORY_ALIASES.get(
reference.category,
CATEGORY_ALIASES["unknown"] if reference.category == "unknown" else (reference.category,),
)
if reference.official_missing:
categories = tuple(dict.fromkeys((*categories, *CATEGORY_ALIASES["unknown"], *installed.keys())))
candidates = [(category, name) for category in categories for name in installed.get(category, [])]
wanted_path = normalize_path(reference.name).lower()
wanted_base = basename(reference.name).lower()
wanted_key = model_name_key(reference.name)
wanted_stem = normalized_stem(reference.name)
best: tuple[float, str, str, str] | None = None
@@ -288,6 +412,8 @@ def match_reference(reference: Reference, installed: dict[str, list[str]]) -> di
score, reason = 1.0, "exact_path"
elif candidate_base.lower() == wanted_base:
score, reason = 0.99, "exact_filename"
elif wanted_key and model_name_key(candidate_path) == wanted_key:
score, reason = 0.985, "equivalent_filename"
elif wanted_stem and normalized_stem(candidate_path) == wanted_stem:
score, reason = 0.96, "normalized_filename"
else:
@@ -303,10 +429,11 @@ def match_reference(reference: Reference, installed: dict[str, list[str]]) -> di
score, name, category, reason = best
status = (
"missing"
"installed"
if reason == "exact_path" or (reason in {"exact_filename", "equivalent_filename"} and not reference.official_missing)
else "missing"
if reference.official_missing
else "installed"
if reason == "exact_path"
and reason not in {"exact_filename", "equivalent_filename"}
else "adaptable"
if score >= 0.78
else "missing"
@@ -328,17 +455,41 @@ def match_reference(reference: Reference, installed: dict[str, list[str]]) -> di
}
def analyze(payload: Any, get_filename_list: Callable[[str], Iterable[str]]) -> dict[str, Any]:
references = extract_references(payload)
installed = load_installed(get_filename_list)
def analyze(
payload: Any,
get_filename_list: Callable[[str], Iterable[str]],
registered_categories: Iterable[str] = (),
) -> dict[str, Any]:
registered_categories = tuple(registered_categories)
references = extract_references(payload, registered_categories)
installed = load_installed(
get_filename_list,
(*registered_categories, *(reference.category for reference in references)),
)
results = []
for reference in references:
results.append({**reference.as_dict(), **match_reference(reference, installed)})
resolved_widgets = {
(item["node_id"], item["widget"])
for item in results
if item["status"] == "installed" and item["node_id"] is not None and item["widget"] is not None
}
resolved_models = {
(item["category"], basename(item["name"]).lower())
for item in results
if item["status"] == "installed"
}
unresolved_by_name: dict[str, dict[str, Any]] = {}
for item in results:
if item["status"] not in {"adaptable", "missing"}:
continue
if (
(item["node_id"], item["widget"]) in resolved_widgets
or (item["category"], basename(item["name"]).lower()) in resolved_models
):
continue
key = item["name"].lower()
reference = {"node_id": item["node_id"], "widget": item["widget"], "node_type": item["node_type"]}
current = unresolved_by_name.get(key)
if (
current is None
@@ -349,7 +500,12 @@ def analyze(payload: Any, get_filename_list: Callable[[str], Iterable[str]]) ->
and item["official_missing"] == current["official_missing"]
)
):
item["referencing_nodes"] = [reference] if item["node_id"] is not None else []
unresolved_by_name[key] = item
elif item["node_id"] is not None:
refs = current.setdefault("referencing_nodes", [])
if reference not in refs:
refs.append(reference)
unresolved = list(unresolved_by_name.values())
return {
"summary": {
+116 -62
View File
@@ -13,7 +13,6 @@ from typing import Any, Iterable
from urllib.parse import urlparse
TE_MARKET_URL = "https://extension-list.oystermercury.top/comfyui/custom-node-list.json"
COMFY_MANAGER_NODE_MAP_URL = "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/extension-node-map.json"
PROTECTED_DEPENDENCIES = {
"torch",
@@ -42,6 +41,49 @@ def missing_node_types(node_types: Iterable[Any], registered: Iterable[str]) ->
)
def missing_workflow_node_types(nodes: Iterable[Any], registered: Iterable[str]) -> list[str]:
return missing_node_types(
(
node.get("type")
for node in nodes
if isinstance(node, dict)
and node.get("active") is not False
and node.get("frontend_registered") is not True
),
registered,
)
def missing_workflow_node_packages(nodes: Iterable[Any], registered: Iterable[str]) -> list[dict[str, Any]]:
missing = set(missing_workflow_node_types(nodes, registered))
packages: dict[str, dict[str, Any]] = {}
for node in nodes:
if not isinstance(node, dict) or str(node.get("type") or "").strip() not in missing:
continue
node_type = str(node["type"]).strip()
package_id = str(node.get("package_id") or "").strip()
key = package_id.lower() if package_id else f"__unknown__:{node_type.lower()}"
package = packages.setdefault(
key,
{
"id": package_id or "__unknown__",
"title": package_id or "未知包",
"known": bool(package_id),
"version": str(node.get("package_version") or "").strip(),
"node_types": [],
"node_ids": [],
"count": 0,
},
)
if node_type not in package["node_types"]:
package["node_types"].append(node_type)
node_id = node.get("id")
if node_id is not None and str(node_id) not in package["node_ids"]:
package["node_ids"].append(str(node_id))
package["count"] += 1
return sorted(packages.values(), key=lambda item: (not item["known"], item["title"].lower()))
def normalize_repo_url(value: str) -> str:
url = value.strip().rstrip("/")
if url.endswith(".git"):
@@ -54,51 +96,51 @@ def allowed_repo_url(value: str) -> bool:
return parsed.scheme == "https" and parsed.hostname == "github.com" and len(parsed.path.strip("/").split("/")) >= 2
def market_candidates(
entries: Iterable[dict[str, Any]], node_type: str, node_map: dict[str, Any] | None = None
def github_fallback_candidates(
package_id: str,
node_type: str,
node_map: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
mapped_repositories = {
normalize_repo_url(repo_url)
for repo_url, value in (node_map or {}).items()
if isinstance(value, list) and value and isinstance(value[0], list) and node_type in value[0]
}
candidates = []
for entry in entries:
files = entry.get("files") or []
repo_url = next((str(item) for item in files if allowed_repo_url(str(item))), "")
if entry.get("install_type", "git-clone") != "git-clone" or not repo_url:
continue
confidence = 0.0
reason = ""
if node_type in (entry.get("preemptions") or []):
confidence, reason = 1.0, "official_node_mapping"
elif normalize_repo_url(repo_url) in mapped_repositories:
confidence, reason = 0.99, "comfy_manager_node_mapping"
else:
pattern = entry.get("nodename_pattern")
if pattern:
try:
if re.search(str(pattern), node_type):
confidence, reason = 0.96, "official_node_pattern"
except re.error:
pass
if not confidence:
continue
candidates.append(
{
"id": str(entry.get("id") or normalize_repo_url(repo_url).rsplit("/", 1)[-1]),
"title": str(entry.get("title") or entry.get("id") or repo_url),
"author": str(entry.get("author") or ""),
"description": str(entry.get("description") or ""),
"repo_url": repo_url,
"confidence": confidence,
"reason": reason,
"""Build installable candidates only from workflow or official-manager GitHub mappings."""
candidates: dict[str, dict[str, Any]] = {}
normalized_package = package_id.strip().strip("/")
if re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", normalized_package):
repo_url = f"https://github.com/{normalized_package}"
candidates[normalize_repo_url(repo_url)] = {
"id": normalized_package,
"title": normalized_package,
"author": normalized_package.split("/", 1)[0],
"description": "工作流 aux_id 提供的 GitHub 插件仓库",
"repo_url": repo_url,
"confidence": 1.0,
"reason": "workflow_package_github",
"installable": True,
"pip": [],
"apt_dependency": [],
}
for repo_url, value in (node_map or {}).items():
if (
allowed_repo_url(str(repo_url))
and isinstance(value, list)
and value
and isinstance(value[0], list)
and node_type in value[0]
):
normalized = normalize_repo_url(str(repo_url))
parts = normalized.rsplit("/", 2)
candidates[normalized] = {
"id": "/".join(parts[-2:]),
"title": parts[-1],
"author": parts[-2],
"description": "ComfyUI-Manager 官方节点映射提供的 GitHub 插件仓库",
"repo_url": str(repo_url).rstrip("/"),
"confidence": 0.99,
"reason": "comfy_manager_github",
"installable": True,
"pip": entry.get("pip") or [],
"apt_dependency": entry.get("apt_dependency") or [],
"pip": [],
"apt_dependency": [],
}
)
return sorted(candidates, key=lambda item: (-item["confidence"], item["title"].lower()))
return sorted(candidates.values(), key=lambda item: (-item["confidence"], item["title"].lower()))
def _comfy_root(folder_paths_module: Any) -> Path:
@@ -258,10 +300,14 @@ def _existing_repo(custom_nodes: Path, repo_url: str, git: str, env: dict[str, s
return None
def install_market_plugin(folder_paths_module: Any, candidate: dict[str, Any]) -> dict[str, Any]:
def install_market_plugin(
folder_paths_module: Any,
candidate: dict[str, Any],
install_dependencies: bool = True,
) -> dict[str, Any]:
repo_url = str(candidate.get("repo_url") or "")
if not candidate.get("installable") or not allowed_repo_url(repo_url):
raise RuntimeError("只允许安装 TE 官方插件市场中经过节点名精确匹配的 GitHub 插件")
raise RuntimeError("只允许安装工作流 aux_id、ComfyUI-Manager 官方映射或用户指定的 GitHub 插件")
root = _comfy_root(folder_paths_module)
custom_nodes = root / "custom_nodes"
@@ -284,28 +330,35 @@ def install_market_plugin(folder_paths_module: Any, candidate: dict[str, Any]) -
repo = temp_path
declared = candidate.get("pip") or []
conflicts = dependency_conflicts(repo) + declared_dependency_conflicts(declared)
if candidate.get("apt_dependency"):
conflicts.append("插件需要系统级 apt 依赖,Windows TE 环境无法安全自动安装")
conflicts = dependency_conflicts(repo) + declared_dependency_conflicts(declared) if install_dependencies else []
if install_dependencies and candidate.get("apt_dependency"):
conflicts.append("插件需要系统级 apt 依赖,当前 Windows 环境无法安全自动安装")
if conflicts:
raise RuntimeError("\n".join(conflicts))
requirements = _requirement_files(repo)
conflicts_before = set(
_run([python, "-m", "pip", "check"], repo, env, allow_failure=True).splitlines()
)
for requirement in requirements:
_run([python, "-m", "pip", "install", "--dry-run", "-r", str(requirement)], repo, env)
for requirement in requirements:
_run([python, "-m", "pip", "install", "-r", str(requirement)], repo, env)
if declared:
_run([python, "-m", "pip", "install", "--dry-run", *map(str, declared)], repo, env)
_run([python, "-m", "pip", "install", *map(str, declared)], repo, env)
if (repo / "install.py").is_file():
conflicts_before = set()
if install_dependencies:
conflicts_before = set(
_run([python, "-m", "pip", "check"], repo, env, allow_failure=True).splitlines()
)
for requirement in requirements:
_run([python, "-m", "pip", "install", "--dry-run", "-r", str(requirement)], repo, env)
for requirement in requirements:
_run([python, "-m", "pip", "install", "-r", str(requirement)], repo, env)
if declared:
_run([python, "-m", "pip", "install", "--dry-run", *map(str, declared)], repo, env)
_run([python, "-m", "pip", "install", *map(str, declared)], repo, env)
if install_dependencies and (repo / "install.py").is_file():
_run([python, "install.py"], repo, env)
conflicts_after = set(
_run([python, "-m", "pip", "check"], repo, env, allow_failure=True).splitlines()
conflicts_after = (
set(_run([python, "-m", "pip", "check"], repo, env, allow_failure=True).splitlines())
if install_dependencies else conflicts_before
)
new_conflicts = sorted(conflicts_after - conflicts_before)
if new_conflicts:
raise RuntimeError(
"安装依赖后发现新的环境冲突,插件未启用:\n" + "\n".join(new_conflicts)
)
if temp_path:
temp_path.replace(target)
@@ -315,7 +368,8 @@ def install_market_plugin(folder_paths_module: Any, candidate: dict[str, Any]) -
"action": action,
"title": candidate.get("title"),
"path": str(installed_path),
"dependencies": len(requirements),
"dependencies": len(requirements) if install_dependencies else 0,
"dependencies_skipped": not install_dependencies,
"existing_conflicts": sorted(conflicts_before),
"new_conflicts": new_conflicts,
"pip_check": "No new broken requirements found." if not new_conflicts else "\n".join(new_conflicts),
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "comfyui-findmodels"
version = "1.12.0"
version = "1.24.0"
description = "Find and adapt missing models in the current ComfyUI workflow"
readme = "README.md"
requires-python = ">=3.10"
+358 -7
View File
@@ -1,7 +1,10 @@
import sys
import asyncio
import tempfile
import types
import unittest
from pathlib import Path
from unittest.mock import patch
class Routes:
@@ -19,6 +22,10 @@ folder_paths.get_filename_list = lambda category: []
folder_paths.get_folder_paths = lambda category: [str(Path.cwd() / "models" / category)]
folder_paths.filename_list_cache = {}
folder_paths.models_dir = str(Path.cwd() / "models")
folder_paths.folder_names_and_paths = {
category: ([str(Path.cwd() / "models" / category)], set())
for category in ("checkpoints", "loras", "vae", "controlnet", "clip_vision", "text_encoders", "clip", "diffusion_models", "unet", "upscale_models", "embeddings", "audio_encoders")
}
sys.modules.setdefault("server", server)
sys.modules.setdefault("folder_paths", folder_paths)
sys.path.insert(0, str(Path.cwd().parent))
@@ -29,15 +36,53 @@ from ComfyUI_FindModels.find_models import (
_allowed_download_url,
_allowed_quark_download_url,
_clear_filename_cache,
_choose_external_folder,
_content_range_total,
_direct_web_fallback,
_exact_model_name,
_external_model_index,
_external_model_index_for_names,
_registered_model_extensions,
_is_model_payload,
_move_external_model,
_official_relative_model_name,
_public_download_job,
_load_quark_cookie,
_quark_download_url,
_quark_token,
_save_quark_cookie,
_safe_filename,
_size_value,
_target_directory,
scan_models,
)
class DownloadHelperTests(unittest.TestCase):
def test_normalizes_quark_file_token_shapes(self):
self.assertEqual(_quark_token(" token "), "token")
self.assertEqual(_quark_token(["token"]), "token")
self.assertEqual(_quark_token({"share_fid_token": "token"}), "token")
def test_quick_scan_includes_exact_external_library_search(self):
class Request:
async def json(self):
return {"quick": True, "nodes": []}
analyzed = {
"models": [],
"summary": {"unresolved": 0, "references": 0},
}
with (
patch("ComfyUI_FindModels.find_models.analyze", return_value=analyzed),
patch("ComfyUI_FindModels.find_models._external_candidates_index", return_value={}) as external_search,
patch("ComfyUI_FindModels.find_models._load_external_folder", return_value=None),
):
response = asyncio.run(scan_models(Request()))
external_search.assert_called_once()
self.assertIn(b'"quick": true', response.body)
def test_allows_known_model_hosts(self):
self.assertTrue(_allowed_download_url("https://civitai.com/api/download/models/1"))
self.assertTrue(_allowed_download_url("https://huggingface.co/repo/resolve/main/model.safetensors"))
@@ -60,6 +105,32 @@ class DownloadHelperTests(unittest.TestCase):
self.assertEqual(_size_value(2048, 1024), 2097152)
self.assertIsNone(_size_value(None))
def test_reads_total_size_from_content_range(self):
self.assertEqual(_content_range_total("bytes 1024-2047/8192"), 8192)
self.assertIsNone(_content_range_total(None))
def test_public_download_job_hides_resume_payload_and_temp_path(self):
public = _public_download_job({
"id": "job",
"status": "paused",
"payload": {"url": "secret"},
"temp_path": "private.part",
})
self.assertEqual(public, {"id": "job", "status": "paused"})
def test_public_download_job_reports_speed_elapsed_and_eta(self):
with patch("ComfyUI_FindModels.find_models.time.time", return_value=110):
public = _public_download_job({
"id": "job",
"status": "downloading",
"started_at": 100,
"downloaded": 1000,
"total": 5000,
})
self.assertEqual(public["elapsed"], 10)
self.assertEqual(public["speed"], 100)
self.assertEqual(public["eta"], 40)
def test_source_filename_must_match_missing_model_exactly(self):
self.assertTrue(_exact_model_name("folder/model.safetensors", "model.safetensors"))
self.assertFalse(_exact_model_name("model-v1.safetensors", "model-v2.safetensors"))
@@ -79,6 +150,19 @@ class DownloadHelperTests(unittest.TestCase):
with self.assertRaises(Exception):
_target_directory("unknown")
def test_supports_registered_dynamic_model_folder(self):
original = folder_paths.get_folder_paths
folder_paths.get_folder_paths = lambda category: (
[str(Path.cwd() / "models" / category)] if category == "audio_encoders" else []
)
try:
self.assertEqual(
_target_directory("audio_encoders"),
(Path.cwd() / "models" / "audio_encoders").resolve(),
)
finally:
folder_paths.get_folder_paths = original
def test_supports_legacy_comfyui_folder_aliases(self):
original = folder_paths.get_folder_paths
folder_paths.get_folder_paths = lambda category: [] if category == "text_encoders" else [str(Path.cwd() / "models" / category)]
@@ -94,18 +178,285 @@ class DownloadHelperTests(unittest.TestCase):
self.assertIn("vae", folder_paths.filename_list_cache)
def test_contains_requested_quark_libraries(self):
self.assertEqual(
[item["url"] for item in QUARK_MODEL_LIBRARIES],
[
"https://pan.quark.cn/s/fb913d649b18",
"https://pan.quark.cn/s/4680ac8665162",
],
)
urls = [item["url"] for item in QUARK_MODEL_LIBRARIES]
self.assertIn("https://pan.quark.cn/s/fb913d649b18", urls)
self.assertIn("https://pan.quark.cn/s/4680ac866516", urls)
def test_quark_download_uses_refreshed_singular_fid_token(self):
calls = []
async def fake_quark_json(session, method, path, *, share_id, data=None):
calls.append((method, path, data))
if "sharepage/token" in path:
return {"data": {"stoken": "fresh-stoken"}}
return {"data": [{"download_url": "https://download.uc.cn/model.safetensors"}]}
async def fake_candidates(session, name, category, library):
return [{
"name": name,
"quark": {
"share_id": library["share_id"],
"fid": "fresh-fid",
"fid_token": "fresh-token",
"filename": name,
},
}]
with patch("ComfyUI_FindModels.find_models._quark_json", side_effect=fake_quark_json), patch(
"ComfyUI_FindModels.find_models._quark_candidates", side_effect=fake_candidates
):
url = asyncio.run(_quark_download_url(None, {
"share_id": "share",
"fid": "stale-fid",
"fid_token": "stale-token",
"filename": "model.safetensors",
}))
self.assertEqual(url, "https://download.uc.cn/model.safetensors")
download_payload = calls[-1][2]
self.assertEqual(download_payload["fids"], ["fresh-fid"])
self.assertNotIn("fid_token", download_payload)
self.assertEqual(download_payload["fids_token"], ["fresh-token"])
def test_saves_and_clears_quark_cookie_locally(self):
with tempfile.TemporaryDirectory() as directory:
config = Path(directory) / "quark_auth.json"
with patch("ComfyUI_FindModels.find_models.QUARK_AUTH_CONFIG", config):
_save_quark_cookie("foo=bar")
self.assertEqual(_load_quark_cookie(), "foo=bar")
_save_quark_cookie("")
self.assertEqual(_load_quark_cookie(), "")
self.assertFalse(config.exists())
def test_direct_fallback_accepts_only_verified_exact_filename(self):
async def fake_huggingface(session, name):
return [
{"provider": "Hugging Face", "name": name, "url": "https://huggingface.co/repo/resolve/main/model.safetensors"},
{"provider": "Hugging Face", "name": "wrong.safetensors", "url": "https://huggingface.co/repo/resolve/main/wrong.safetensors"},
]
async def fake_validate(session, candidate):
return candidate
with patch("ComfyUI_FindModels.find_models._huggingface_candidates", side_effect=fake_huggingface), patch(
"ComfyUI_FindModels.find_models._civitai_candidates", return_value=[]
), patch("ComfyUI_FindModels.find_models._validate_web_candidate", side_effect=fake_validate):
result = asyncio.run(_direct_web_fallback(None, "model.safetensors"))
self.assertEqual(result["name"], "model.safetensors")
def test_contains_verified_fantasytalking_source(self):
source = KNOWN_MODEL_SOURCES["fantasytalking_fp16.safetensors"]
self.assertEqual(source["name"], "fantasytalking_fp16.safetensors")
self.assertTrue(source["url"].startswith("https://huggingface.co/Kijai/WanVideo_comfy/"))
def test_external_model_index_only_contains_model_files(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
model = root / "nested" / "wanted.safetensors"
model.parent.mkdir()
model.write_bytes(b"x" * 2048)
(root / "notes.txt").write_text("not a model", encoding="utf-8")
index = _external_model_index(root)
self.assertEqual(index["wanted.safetensors"][0]["path"], str(model.resolve()))
self.assertNotIn("notes.txt", index)
def test_external_model_index_searches_deep_registered_model_extensions(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
model = root / "archive" / "models" / "detection" / "people" / "pose.pt2"
model.parent.mkdir(parents=True)
model.write_bytes(b"x" * 2048)
index = _external_model_index(root)
self.assertEqual(index["pose.pt2"][0]["path"], str(model.resolve()))
self.assertEqual(index["pose.pt2"][0]["category_hint"], "detection")
def test_external_priority_scan_finds_exact_deep_gguf_model(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
wanted = root / "diffusion_models" / "Wan" / "Wan2.1-InfiniteTalk_Single_Q6_K.gguf"
unrelated = root / "diffusion_models" / "Wan" / "another-model.gguf"
duplicate = root / "archive" / "Wan2.1-InfiniteTalk_Single_Q6_K.gguf"
wanted.parent.mkdir(parents=True)
duplicate.parent.mkdir(parents=True)
wanted.write_bytes(b"x" * 2048)
unrelated.write_bytes(b"x" * 2048)
duplicate.write_bytes(b"x" * 2048)
index = _external_model_index_for_names(
root,
{"Wan/Wan2.1-InfiniteTalk_Single_Q6_K.gguf"},
{"Wan/Wan2.1-InfiniteTalk_Single_Q6_K.gguf": "diffusion_models"},
)
self.assertEqual(list(index), ["wan2.1-infinitetalk_single_q6_k.gguf"])
self.assertEqual(len(index["wan2.1-infinitetalk_single_q6_k.gguf"]), 1)
self.assertEqual(index["wan2.1-infinitetalk_single_q6_k.gguf"][0]["path"], str(wanted.resolve()))
self.assertEqual(index["wan2.1-infinitetalk_single_q6_k.gguf"][0]["category_hint"], "diffusion_models")
def test_external_priority_scan_rejects_unicode_punctuation_variant(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
model = root / "loras" / "Qwen" / "任务拆解二次元,.safetensors"
model.parent.mkdir(parents=True)
model.write_bytes(b"x" * 2048)
wanted = "Qwen/任务拆解二次元, .safetensors"
index = _external_model_index_for_names(root, {wanted}, {wanted: "loras"})
self.assertEqual(index, {})
def test_external_model_index_uses_custom_registered_extensions(self):
original = folder_paths.folder_names_and_paths
folder_paths.folder_names_and_paths = {
**original,
"custom_weights": ([str(Path.cwd() / "models" / "custom_weights")], {".weights"}),
}
try:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
model = root / "many" / "nested" / "custom.weights"
model.parent.mkdir(parents=True)
model.write_bytes(b"x" * 2048)
self.assertIn(".weights", _registered_model_extensions())
self.assertIn("custom.weights", _external_model_index(root))
finally:
folder_paths.folder_names_and_paths = original
def test_native_folder_selector_returns_selected_folder(self):
with tempfile.TemporaryDirectory() as directory:
completed = types.SimpleNamespace(returncode=0, stdout=f"\ufeff{directory}\n", stderr="")
with patch("ComfyUI_FindModels.find_models.subprocess.run", return_value=completed) as run:
self.assertEqual(_choose_external_folder(), Path(directory).resolve())
script = run.call_args.args[0][-1]
self.assertIn("$owner.TopMost = $true", script)
self.assertIn("$dialog.ShowDialog($owner)", script)
def test_moves_external_model_to_official_category_root(self):
with tempfile.TemporaryDirectory() as external, tempfile.TemporaryDirectory() as models:
source = Path(external) / "wanted.safetensors"
source.write_bytes(b"x" * 2048)
original = folder_paths.get_folder_paths
folder_paths.get_folder_paths = lambda category: [str(Path(models) / category)]
try:
result = _move_external_model(
str(source),
"Wan/wanted.safetensors",
"diffusion_models",
Path(external).resolve(),
)
finally:
folder_paths.get_folder_paths = original
self.assertFalse(source.exists())
self.assertTrue((Path(models) / "diffusion_models" / "wanted.safetensors").exists())
self.assertEqual(result["relative_name"], "wanted.safetensors")
def test_external_move_strips_redundant_official_folder_prefix(self):
with tempfile.TemporaryDirectory() as external, tempfile.TemporaryDirectory() as models:
source = Path(external) / "wanted.safetensors"
source.write_bytes(b"x" * 2048)
original = folder_paths.get_folder_paths
folder_paths.get_folder_paths = lambda category: [str(Path(models) / category)]
try:
result = _move_external_model(
str(source),
"models/loras/characters/wanted.safetensors",
"loras",
Path(external).resolve(),
)
finally:
folder_paths.get_folder_paths = original
expected = Path(models) / "loras" / "wanted.safetensors"
self.assertTrue(expected.exists())
self.assertEqual(result["relative_name"], "wanted.safetensors")
def test_official_relative_name_ignores_node_subfolders(self):
self.assertEqual(
_official_relative_model_name("Wan/wanted.safetensors", "diffusion_models").as_posix(),
"wanted.safetensors",
)
def test_target_directory_prefers_canonical_official_folder_over_legacy_alias(self):
original = folder_paths.get_folder_paths
folder_paths.get_folder_paths = lambda category: [
str(Path.cwd() / "models" / "unet"),
str(Path.cwd() / "models" / "diffusion_models"),
] if category == "diffusion_models" else []
try:
self.assertEqual(
_target_directory("diffusion_models"),
(Path.cwd() / "models" / "diffusion_models").resolve(),
)
finally:
folder_paths.get_folder_paths = original
def test_target_directory_supports_official_uppercase_llm_category(self):
original = folder_paths.get_folder_paths
folder_paths.get_folder_paths = lambda category: [str(Path.cwd() / "models" / "LLM")] if category == "LLM" else []
try:
self.assertEqual(
_target_directory("LLM"),
(Path.cwd() / "models" / "LLM").resolve(),
)
finally:
folder_paths.get_folder_paths = original
def test_target_directory_supports_official_instantid_category(self):
original = folder_paths.get_folder_paths
folder_paths.get_folder_paths = lambda category: [str(Path.cwd() / "models" / "instantid")] if category == "instantid" else []
try:
self.assertEqual(
_target_directory("instantid"),
(Path.cwd() / "models" / "instantid").resolve(),
)
finally:
folder_paths.get_folder_paths = original
def test_external_move_rejects_wrong_filename(self):
with tempfile.TemporaryDirectory() as external:
source = Path(external) / "wrong.safetensors"
source.write_bytes(b"x" * 2048)
with self.assertRaises(Exception):
_move_external_model(
str(source),
"wanted.safetensors",
"checkpoints",
Path(external).resolve(),
)
def test_external_move_rejects_punctuation_variant_filename(self):
with tempfile.TemporaryDirectory() as external:
source = Path(external) / "model,.safetensors"
source.write_bytes(b"x" * 2048)
with self.assertRaises(Exception):
_move_external_model(
str(source),
"model,.safetensors",
"checkpoints",
Path(external).resolve(),
)
def test_external_move_never_overwrites_existing_model(self):
with tempfile.TemporaryDirectory() as external, tempfile.TemporaryDirectory() as models:
source = Path(external) / "wanted.safetensors"
source.write_bytes(b"x" * 2048)
target = Path(models) / "checkpoints" / "wanted.safetensors"
target.parent.mkdir()
target.write_bytes(b"existing" * 256)
original = folder_paths.get_folder_paths
folder_paths.get_folder_paths = lambda category: [str(Path(models) / category)]
try:
with self.assertRaises(Exception):
_move_external_model(
str(source),
"wanted.safetensors",
"checkpoints",
Path(external).resolve(),
)
finally:
folder_paths.get_folder_paths = original
self.assertTrue(source.exists())
self.assertTrue(target.exists())
if __name__ == "__main__":
unittest.main()
+406 -16
View File
@@ -21,8 +21,16 @@ class ModelFinderTests(unittest.TestCase):
def test_classification(self):
self.assertEqual(classify("LoraLoader lora_name"), "loras")
self.assertEqual(classify("ControlNetLoader control_net_name"), "controlnet")
self.assertEqual(classify("RIFE VFI rife47.pth"), "upscale_models")
self.assertEqual(classify("RIFE VFI rife47.pth"), "frame_interpolation")
self.assertEqual(classify("Load CLIP Vision clip_vision_h.safetensors"), "clip_vision")
self.assertEqual(classify("VitPose Loader vitpose-l-wholebody.onnx"), "detection")
self.assertEqual(classify("Llama-cpp Model Loader llama_model"), "LLM")
self.assertEqual(classify("Llama-cpp Model Loader mmproj_model"), "LLM")
self.assertEqual(classify("Load InstantID Model ip-adapter.bin"), "instantid")
self.assertEqual(classify("IPAdapter Model Loader ip-adapter.bin"), "ipadapter")
self.assertEqual(classify("Load CustomWeights file.bin", ["custom_weights"]), "custom_weights")
self.assertEqual(classify("SAMLoader (Impact) sam_vit_b_01ec64.pth"), "sams")
self.assertEqual(classify("检测加载器 segm/face_yolov8n_v2.pt"), "ultralytics_segm")
def test_clip_vision_filename_classifies_translated_loader(self):
payload = {
@@ -66,16 +74,16 @@ class ModelFinderTests(unittest.TestCase):
self.assertEqual(len(result["models"]), 1)
self.assertTrue(result["models"][0]["match"]["auto_apply"])
def test_exact_filename_in_subfolder_requires_loading_into_node(self):
def test_exact_filename_in_official_category_is_installed(self):
payload = {
"nodes": [
{"id": 4, "type": "CheckpointLoaderSimple", "widgets": [{"name": "ckpt_name", "value": "model-v1.safetensors"}]},
]
}
result = analyze(payload, lambda category: FILES.get(category, []))
self.assertEqual(result["summary"]["adaptable"], 1)
self.assertEqual(result["models"][0]["match"]["reason"], "exact_filename")
self.assertTrue(result["models"][0]["match"]["auto_apply"])
self.assertEqual(result["summary"]["installed"], 1)
self.assertEqual(result["summary"]["unresolved"], 0)
self.assertEqual(result["models"], [])
def test_hides_installed_models(self):
payload = {
@@ -180,6 +188,44 @@ class ModelFinderTests(unittest.TestCase):
result = analyze(payload, lambda category: [])
self.assertEqual(result["models"][0]["category"], "diffusion_models")
def test_infinitetalk_model_uses_diffusion_models_folder(self):
payload = {
"nodes": [{
"id": 120,
"type": "Multi/InfiniteTalk Model Loader",
"widgets": [{
"name": "模型",
"value": "Wan/Wan2.1-InfiniteTalk_Single_Q6_K.gguf",
"model_selector": True,
}],
}]
}
result = analyze(payload, lambda category: [])
self.assertEqual(result["models"][0]["category"], "diffusion_models")
def test_llama_cpp_models_use_official_uppercase_llm_folder(self):
payload = {
"nodes": [{
"id": 48,
"type": "Llama-cpp Model Loader",
"widgets": [
{
"name": "llama_model",
"value": "Huihui-Qwen3-VL-8B-Instruct-abliterated.Q8_0.gguf",
"model_selector": True,
},
{
"name": "mmproj_model",
"value": "Huihui-Qwen3-VL-8B-Instruct-abliterated.mmproj-Q8_0.gguf",
"model_selector": True,
},
],
}]
}
result = analyze(payload, lambda category: [])
self.assertEqual(len(result["models"]), 2)
self.assertTrue(all(model["category"] == "LLM" for model in result["models"]))
def test_detects_missing_model_in_custom_loader_with_translated_widget(self):
payload = {
"nodes": [{
@@ -197,6 +243,22 @@ class ModelFinderTests(unittest.TestCase):
self.assertEqual(result["models"][0]["category"], "diffusion_models")
self.assertEqual(result["models"][0]["name"], "Wan/Wan14Bi2vFusioniX_fp8.safetensors")
def test_detects_model_in_textencode_cached_node(self):
payload = {
"nodes": [{
"id": 95,
"type": "WanVideo TextEncode Cached",
"widgets": [{
"name": "模型名",
"value": "umt5-xxl-enc-fp8_e4m3fn.safetensors",
"model_selector": False,
}],
}]
}
result = analyze(payload, lambda category: [])
self.assertEqual(result["summary"]["unresolved"], 1)
self.assertEqual(result["models"][0]["category"], "text_encoders")
def test_hides_installed_model_in_custom_loader_with_translated_widget(self):
payload = {
"nodes": [{
@@ -228,9 +290,8 @@ class ModelFinderTests(unittest.TestCase):
payload,
lambda category: ["Wan/Wan14Bi2vFusioniX_fp8.safetensors"] if category == "diffusion_models" else [],
)
self.assertEqual(result["summary"]["adaptable"], 1)
self.assertEqual(result["models"][0]["name"], "FusioniX/Wan14Bi2vFusioniX_fp8.safetensors")
self.assertEqual(result["models"][0]["match"]["name"], "Wan/Wan14Bi2vFusioniX_fp8.safetensors")
self.assertEqual(result["summary"]["installed"], 1)
self.assertEqual(result["models"], [])
def test_extracts_model_filename_nested_in_custom_widget_value(self):
payload = {
@@ -259,10 +320,10 @@ class ModelFinderTests(unittest.TestCase):
payload,
lambda category: ["Wan2_1_VAE_bf16.safetensors"] if category == "vae" else [],
)
self.assertEqual(result["summary"]["adaptable"], 1)
self.assertEqual(result["models"][0]["category"], "vae")
self.assertEqual(result["summary"]["installed"], 1)
self.assertEqual(result["models"], [])
def test_official_combo_missing_stays_missing_even_when_file_exists(self):
def test_official_combo_missing_hides_when_registered_path_is_already_exact(self):
payload = {
"nodes": [{
"id": 22,
@@ -279,8 +340,8 @@ class ModelFinderTests(unittest.TestCase):
payload,
lambda category: ["Wan/Wan14Bi2vFusioniX_fp8.safetensors"] if category == "diffusion_models" else [],
)
self.assertEqual(result["summary"]["missing"], 1)
self.assertTrue(result["models"][0]["official_missing"])
self.assertEqual(result["summary"]["unresolved"], 0)
self.assertEqual(result["models"], [])
def test_official_combo_valid_hides_installed_model(self):
payload = {
@@ -331,7 +392,7 @@ class ModelFinderTests(unittest.TestCase):
self.assertEqual(result["summary"]["missing"], 1)
self.assertEqual(result["models"][0]["category"], "loras")
def test_official_missing_reference_wins_over_adaptable_duplicate(self):
def test_exact_official_filename_suppresses_stale_missing_reference(self):
payload = {
"nodes": [
{
@@ -355,9 +416,338 @@ class ModelFinderTests(unittest.TestCase):
payload,
lambda category: ["Other/model.safetensors"] if category == "diffusion_models" else [],
)
self.assertEqual(result["summary"]["unresolved"], 0)
self.assertEqual(result["models"], [])
def test_official_flat_model_can_replace_invalid_legacy_prefix(self):
payload = {
"nodes": [{
"id": 90,
"type": "LoraLoaderModelOnly",
"widgets": [{
"name": "lora_name",
"value": "图图黑丝/model.safetensors",
"model_selector": True,
"model_value_valid": False,
}],
}]
}
result = analyze(
payload,
lambda category: ["model.safetensors"] if category == "loras" else [],
)
self.assertEqual(result["summary"]["unresolved"], 1)
self.assertEqual(result["models"][0]["status"], "missing")
self.assertTrue(result["models"][0]["official_missing"])
self.assertEqual(result["models"][0]["status"], "adaptable")
self.assertEqual(result["models"][0]["match"]["name"], "model.safetensors")
self.assertTrue(result["models"][0]["match"]["auto_apply"])
def test_multi_lora_invalid_paths_are_available_to_one_click_load(self):
names = [
"WanAnimate_relight_lora_fp16.safetensors",
"Wan2.2-Lightning_I2V-A14B-4steps-lora_LOW_fp16.safetensors",
"FastWan_T2V_14B_480p_lora_rank_128_bf16.safetensors",
]
payload = {
"nodes": [{
"id": 40,
"type": "WanVideo Lora Select Multi",
"widgets": [
{
"name": f"lora_{index}",
"value": f"万相lora/{name}",
"model_selector": True,
"model_value_valid": False,
}
for index, name in enumerate(names)
],
}]
}
result = analyze(payload, lambda category: names if category == "loras" else [])
self.assertEqual(result["summary"]["unresolved"], 3)
self.assertTrue(all(model["status"] == "adaptable" for model in result["models"]))
self.assertTrue(all(model["match"]["auto_apply"] for model in result["models"]))
def test_instantid_model_uses_registered_official_category(self):
payload = {
"nodes": [{
"id": 91,
"type": "Load InstantID Model",
"widgets": [{
"name": "instantid_file",
"value": "ip-adapter.bin",
"model_selector": True,
"model_value_valid": False,
}],
}]
}
model = analyze(payload, lambda category: [])["models"][0]
self.assertEqual(model["category"], "instantid")
self.assertEqual(model["name"], "ip-adapter.bin")
def test_impact_models_use_registered_official_categories(self):
payload = {
"nodes": [
{
"id": 169,
"type": "检测加载器",
"widgets": [{"name": "model_name", "value": "segm/face_yolov8n_v2.pt", "model_selector": True, "model_value_valid": False}],
},
{
"id": 170,
"type": "SAMLoader (Impact)",
"widgets": [{"name": "model_name", "value": "sam_vit_b_01ec64.pth", "model_selector": True, "model_value_valid": False}],
},
]
}
result = analyze(payload, lambda category: [], ["ultralytics_segm", "sams"])
categories = {model["name"]: model["category"] for model in result["models"]}
self.assertEqual(categories["segm/face_yolov8n_v2.pt"], "ultralytics_segm")
self.assertEqual(categories["sam_vit_b_01ec64.pth"], "sams")
def test_one_click_load_finds_local_model_even_when_node_category_is_stale(self):
payload = {
"nodes": [{
"id": 169,
"type": "检测加载器",
"widgets": [{
"name": "model_name",
"value": "segm/face_yolov8n_v2.pt",
"model_selector": True,
"model_value_valid": False,
}],
}]
}
installed = {"ultralytics_bbox": ["face_yolov8n_v2.pt"]}
model = analyze(payload, lambda category: installed.get(category, []), ["ultralytics_bbox", "ultralytics_segm"])["models"][0]
self.assertEqual(model["status"], "adaptable")
self.assertEqual(model["match"]["name"], "face_yolov8n_v2.pt")
self.assertEqual(model["match"]["category"], "ultralytics_bbox")
self.assertTrue(model["match"]["auto_apply"])
def test_one_click_load_searches_dynamic_registered_local_categories(self):
payload = {
"nodes": [{
"id": 200,
"type": "Custom Model Loader",
"widgets": [{
"name": "model_name",
"value": "old-prefix/local-model.bin",
"model_selector": True,
"model_value_valid": False,
"directory": "stale_category",
}],
}]
}
installed = {"plugin_official_models": ["real-folder/local-model.bin"]}
model = analyze(payload, lambda category: installed.get(category, []), installed.keys())["models"][0]
self.assertEqual(model["status"], "adaptable")
self.assertEqual(model["match"]["name"], "real-folder/local-model.bin")
self.assertEqual(model["match"]["category"], "plugin_official_models")
self.assertTrue(model["match"]["auto_apply"])
def test_unicode_lora_official_relative_path_matches_exactly(self):
name = "Qwen/任务拆解二次元, .safetensors"
payload = {
"nodes": [{
"id": 42,
"type": "LoraLoaderModelOnly",
"widgets": [{
"name": "lora_name",
"value": name.replace("/", "\\"),
"model_selector": True,
"model_value_valid": False,
}],
}]
}
result = analyze(payload, lambda category: [name] if category == "loras" else [])
self.assertEqual(result["models"], [])
self.assertEqual(result["summary"]["installed"], 1)
def test_unicode_lora_punctuation_variant_is_adaptable(self):
workflow_name = "Qwen/任务拆解二次元, .safetensors"
installed_name = "Qwen/任务拆解二次元,.safetensors"
payload = {
"nodes": [{
"id": 42,
"type": "LoraLoaderModelOnly",
"widgets": [{
"name": "lora_name",
"value": workflow_name,
"model_selector": True,
"model_value_valid": False,
}],
}]
}
model = analyze(payload, lambda category: [installed_name] if category == "loras" else [])["models"][0]
self.assertEqual(model["status"], "adaptable")
self.assertEqual(model["match"]["name"], installed_name)
self.assertTrue(model["match"]["auto_apply"])
def test_ignores_inactive_node_models_like_workflow_overview(self):
payload = {
"nodes": [{
"id": 40,
"type": "CheckpointLoaderSimple",
"active": False,
"widgets": [{
"name": "ckpt_name",
"value": "inactive-missing.safetensors",
"model_selector": True,
"model_value_valid": False,
}],
}]
}
result = analyze(payload, lambda category: [])
self.assertEqual(result["models"], [])
def test_preserves_embedded_download_metadata(self):
payload = {
"models": [{
"name": "downloadable.safetensors",
"directory": "checkpoints",
"url": "https://huggingface.co/example/resolve/main/downloadable.safetensors",
"hash": "abc",
"hash_type": "sha256",
"size": 123456,
}],
"nodes": [],
}
result = analyze(payload, lambda category: [])
model = result["models"][0]
self.assertEqual(model["source_url"], payload["models"][0]["url"])
self.assertEqual(model["source_hash"], "abc")
self.assertEqual(model["source_hash_type"], "sha256")
self.assertEqual(model["source_size"], 123456)
def test_live_widget_verdict_suppresses_serialized_duplicate(self):
payload = {
"nodes": [{
"id": 50,
"type": "CheckpointLoaderSimple",
"widgets": [{
"name": "ckpt_name",
"value": "valid.safetensors",
"model_selector": True,
"model_value_valid": True,
}],
"widgets_values": ["valid.safetensors"],
}]
}
result = analyze(payload, lambda category: [])
self.assertEqual(result["models"], [])
def test_adapted_installed_model_suppresses_stale_missing_metadata(self):
payload = {
"models": [{
"name": "old-folder/model.safetensors",
"directory": "diffusion_models",
}],
"nodes": [{
"id": 51,
"type": "WanVideoModelLoader",
"widgets": [{
"name": "model",
"value": "new-folder/model.safetensors",
"model_selector": True,
"model_value_valid": True,
"directory": "diffusion_models",
}],
}],
}
result = analyze(
payload,
lambda category: ["new-folder/model.safetensors"] if category == "diffusion_models" else [],
)
self.assertEqual(result["summary"]["unresolved"], 0)
self.assertEqual(result["models"], [])
def test_supports_official_sft_model_extension(self):
result = analyze(
{"models": [{"name": "missing.sft", "directory": "checkpoints"}], "nodes": []},
lambda category: [],
)
self.assertEqual(result["models"][0]["name"], "missing.sft")
def test_uses_dynamic_official_model_directory(self):
payload = {
"models": [{"name": "custom-model.bin", "directory": "audio_encoders"}],
"nodes": [],
}
installed = {"audio_encoders": ["custom-model.bin"]}
result = analyze(payload, lambda category: installed.get(category, []))
self.assertEqual(result["models"], [])
def test_live_widget_preserves_enriched_official_metadata(self):
payload = {
"nodes": [{
"id": 70,
"type": "CustomLoader",
"widgets": [{
"name": "model",
"value": "missing.safetensors",
"model_selector": True,
"model_value_valid": False,
"directory": "audio_encoders",
"source_url": "https://huggingface.co/example/resolve/main/missing.safetensors",
"source_hash": "abc",
"source_hash_type": "sha256",
}],
"widgets_values": ["missing.safetensors"],
}]
}
model = analyze(payload, lambda category: [])["models"][0]
self.assertEqual(model["category"], "audio_encoders")
self.assertEqual(model["source_url"], payload["nodes"][0]["widgets"][0]["source_url"])
def test_detects_all_missing_models_from_multi_lora_selector(self):
names = [
"万相lora/WanAnimate_relight_lora_fp16.safetensors",
"万相lora/Wan2.2-Lightning_I2V-A14B-4steps-lora_LOW_fp16.safetensors",
"万相lora/FastWan_T2V_14B_480p_lora_rank128_bf16.safetensors",
"万相lora/Wan21_PusaV1_LoRA_14B_rank512_bf16.safetensors",
"万相lora/Wan2.2-Fun-A14B-InP-low-noise-HPS2.1.safetensors",
]
payload = {
"nodes": [{
"id": 80,
"type": "WanVideo Lora Select Multi",
"widgets": [
{
"name": f"lora_{index}",
"value": name,
"model_selector": True,
"model_value_valid": False,
}
for index, name in enumerate(names)
],
}]
}
result = analyze(payload, lambda category: [])
self.assertEqual(result["summary"]["unresolved"], 5)
self.assertEqual({model["name"] for model in result["models"]}, set(names))
self.assertTrue(all(model["category"] == "loras" for model in result["models"]))
def test_detects_multi_lora_widgets_without_frontend_selector_metadata(self):
names = [
"万相lora/WanAnimate_relight_lora_fp16.safetensors",
"万相lora/Wan2.2-Lightning_I2V-A14B-4steps-lora_LOW_fp16.safetensors",
"万相lora/FastWan_T2V_14B_480p_lora_rank128_bf16.safetensors",
"万相lora/Wan21_PusaV1_LoRA_14B_rank512_bf16.safetensors",
"万相lora/Wan2.2-Fun-A14B-InP-low-noise-HPS2.1.safetensors",
]
payload = {
"nodes": [{
"id": 81,
"type": "WanVideo Lora Select Multi",
"widgets": [
{"name": f"lora_{index}", "value": name, "model_selector": False}
for index, name in enumerate(names)
],
}]
}
result = analyze(payload, lambda category: [])
self.assertEqual(result["summary"]["unresolved"], 5)
self.assertEqual({model["name"] for model in result["models"]}, set(names))
if __name__ == "__main__":
+56 -26
View File
@@ -6,28 +6,13 @@ from node_installer import (
allowed_repo_url,
declared_dependency_conflicts,
dependency_conflicts,
market_candidates,
github_fallback_candidates,
missing_node_types,
missing_workflow_node_packages,
missing_workflow_node_types,
)
MARKET = [
{
"id": "gguf",
"title": "ComfyUI-GGUF",
"files": ["https://github.com/city96/ComfyUI-GGUF"],
"install_type": "git-clone",
"preemptions": ["UnetLoaderGGUF"],
},
{
"id": "mapped",
"title": "Mapped Pack",
"files": ["https://github.com/example/mapped-pack"],
"install_type": "git-clone",
},
]
class NodeInstallerTests(unittest.TestCase):
def test_finds_only_unregistered_node_types(self):
self.assertEqual(missing_node_types(["Known", "Missing", "Missing"], ["Known"]), ["Missing"])
@@ -35,15 +20,60 @@ class NodeInstallerTests(unittest.TestCase):
def test_ignores_frontend_annotation_nodes(self):
self.assertEqual(missing_node_types(["Markdown注释", "注释+(mtb)", "Note"], []), [])
def test_matches_te_market_preemption(self):
result = market_candidates(MARKET, "UnetLoaderGGUF")
self.assertEqual(result[0]["id"], "gguf")
self.assertTrue(result[0]["installable"])
def test_ignores_nodes_registered_by_frontend_extensions(self):
nodes = [
{"type": "获取点", "frontend_registered": True, "active": True},
{"type": "ActualMissingNode", "frontend_registered": False, "active": True},
{"type": "InactiveMissingNode", "frontend_registered": False, "active": False},
]
self.assertEqual(missing_workflow_node_types(nodes, []), ["ActualMissingNode"])
def test_matches_official_manager_node_map_to_te_market(self):
node_map = {"https://github.com/example/mapped-pack": [["MappedNode"], {}]}
result = market_candidates(MARKET, "MappedNode", node_map)
self.assertEqual(result[0]["id"], "mapped")
def test_groups_missing_nodes_by_workflow_package_id(self):
nodes = [
{
"id": 230,
"type": "AudioDurationToFrames",
"package_id": "syq890610-crypto/Comfyui-Mk-tools",
"package_version": "70cd94a6",
},
{
"id": 241,
"type": "PresetSizeSelector",
"package_id": "syq890610-crypto/Comfyui-Mk-tools",
"package_version": "70cd94a6",
},
]
packages = missing_workflow_node_packages(nodes, [])
self.assertEqual(len(packages), 1)
self.assertEqual(packages[0]["id"], "syq890610-crypto/Comfyui-Mk-tools")
self.assertEqual(packages[0]["count"], 2)
self.assertEqual(packages[0]["node_ids"], ["230", "241"])
def test_groups_unmapped_missing_node_as_unknown_package(self):
packages = missing_workflow_node_packages([{"id": 1, "type": "UnknownNode"}], [])
self.assertFalse(packages[0]["known"])
self.assertEqual(packages[0]["node_types"], ["UnknownNode"])
def test_builds_github_fallback_from_workflow_package_id(self):
result = github_fallback_candidates(
"syq890610-crypto/Comfyui-Mk-tools",
"AudioDurationToFrames",
{},
)
self.assertEqual(result[0]["repo_url"], "https://github.com/syq890610-crypto/Comfyui-Mk-tools")
self.assertEqual(result[0]["reason"], "workflow_package_github")
def test_builds_github_fallback_from_manager_mapping(self):
result = github_fallback_candidates(
"",
"MappedNode",
{"https://github.com/example/mapped-pack": [["MappedNode"], {}]},
)
self.assertEqual(result[0]["repo_url"], "https://github.com/example/mapped-pack")
self.assertEqual(result[0]["reason"], "comfy_manager_github")
def test_does_not_install_from_untrusted_package_text(self):
self.assertEqual(github_fallback_candidates("not a github repository", "UnknownNode", {}), [])
def test_rejects_non_github_install_urls(self):
self.assertFalse(allowed_repo_url("https://example.com/plugin"))
+400 -36
View File
@@ -1,44 +1,408 @@
#find-models-launcher {
display: inline-flex; align-items: center; white-space: nowrap;
margin-inline: 4px; padding: 7px 10px; border: 1px solid #666; border-radius: 7px;
color: #eee; background: #292929; cursor: pointer;
}
#find-models-launcher.has-missing { border-color: #dc4c4c; color: #fff; background: #7d2929; }
#find-models-launcher.toolbar-fallback {
position: fixed; top: 8px; right: 220px; z-index: 1000;
margin-inline: 4px; padding: 7px 12px; border: 1px solid #4b5563; border-radius: 8px;
color: #e5e7eb; background: #24262b; cursor: pointer; transition: .15s ease;
}
#find-models-launcher:hover { background: #30333a; border-color: #6b7280; }
#find-models-launcher.has-missing { border-color: #ef4444; color: #fff; background: #7f1d1d; }
#find-models-launcher.toolbar-fallback { position: fixed; top: 8px; right: 220px; z-index: 1000; }
#find-models-panel {
display: none; position: fixed; top: 60px; right: 18px; z-index: 1001;
width: min(520px, calc(100vw - 36px)); max-height: calc(100vh - 100px);
overflow: auto; box-sizing: border-box; padding: 14px;
color: #eee; background: #181818; border: 1px solid #555; border-radius: 10px;
box-shadow: 0 12px 40px #0009; font: 13px/1.45 sans-serif;
display: none; position: fixed; top: 58px; right: 14px; z-index: 1001;
width: min(580px, calc(100vw - 28px)); height: min(760px, calc(100vh - 76px)); max-height: calc(100vh - 76px);
overflow: auto; box-sizing: border-box; padding: 0 14px 16px;
color: #e5e7eb; background: #151619; border: 1px solid #34363d; border-radius: 12px;
box-shadow: 0 18px 60px #000b; font: 13px/1.45 Inter, ui-sans-serif, system-ui, sans-serif;
}
#find-models-panel.open { display: block; }
.fm-header, .fm-actions, .fm-item > div:first-child { display: flex; gap: 8px; align-items: center; justify-content: space-between; }
.fm-header { margin-bottom: 12px; font-size: 16px; }
.fm-header button { font-size: 20px; }
.fm-actions { justify-content: flex-start; margin-bottom: 10px; }
.fm-summary { padding: 8px 0; color: #bbb; }
.fm-item { margin: 8px 0; padding: 10px; border-left: 4px solid #777; border-radius: 5px; background: #252525; }
.fm-item strong { overflow-wrap: anywhere; }
.fm-item span { color: #aaa; white-space: nowrap; }
.fm-adaptable { border-color: #e6a700; }
.fm-missing { border-color: #dc4c4c; }
.fm-installed { border-color: #3ca66b; }
.fm-match { margin: 7px 0; color: #ccc; overflow-wrap: anywhere; }
.fm-sources { display: grid; gap: 5px; margin-top: 7px; }
.fm-node-candidates { display: grid; gap: 5px; margin-top: 7px; }
.fm-source { display: grid; grid-template-columns: 1fr auto; gap: 6px; align-items: center; }
.fm-download-progress {
grid-column: 1 / -1; min-height: 16px; color: #8fd3a8; font-size: 12px;
.fm-header {
position: sticky; top: 0; z-index: 2; display: flex; align-items: center; justify-content: space-between;
margin: 0 -14px; padding: 13px 14px; background: #151619ee; border-bottom: 1px solid #303238;
backdrop-filter: blur(12px); font-size: 16px;
}
.fm-download-jobs {
display: grid; gap: 6px; margin: 8px 0; padding: 9px;
border: 1px solid #555; border-radius: 6px; background: #202020;
.fm-header button { padding: 2px 8px !important; border: 0 !important; background: transparent !important; font-size: 21px; }
.fm-tabs {
position: sticky; top: 51px; z-index: 2; display: grid; grid-template-columns: repeat(4, 1fr);
margin: 0 -14px; padding: 0 14px; background: #151619ee; border-bottom: 1px solid #303238;
backdrop-filter: blur(12px);
}
.fm-tabs button {
padding: 10px 5px !important; border: 0 !important; border-bottom: 2px solid transparent !important;
border-radius: 0 !important; background: transparent !important; color: #9ca3af !important;
}
.fm-tabs button.active { border-bottom-color: #ef4444 !important; color: #f9fafb !important; }
.fm-tabs button span {
display: inline-grid; place-items: center; min-width: 16px; height: 16px; margin-left: 3px;
border-radius: 999px; background: #34363d; color: #d1d5db; font-size: 10px;
}
.fm-tab-panel { display: none; min-height: 180px; }
.fm-tab-panel.active { display: block; }
.fm-model-list { display: grid !important; gap: 7px; min-height: 120px; }
.fm-model-card { display: block !important; visibility: visible !important; opacity: 1 !important; min-height: 72px; }
.fm-actions { display: flex; gap: 8px; margin: 13px 0 10px; }
.fm-external-config { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; margin: 0 0 12px; }
.fm-external-config input {
min-width: 0; padding: 7px 9px; color: #e5e7eb; background: #191a1e;
border: 1px solid #44474f; border-radius: 6px; outline: none;
}
.fm-external-config input:focus { border-color: #60a5fa; }
.fm-settings-card {
display: grid; gap: 8px; margin-top: 14px; padding: 12px;
border: 1px solid #34363d; border-radius: 9px; background: #202126;
}
.fm-settings-card > span { color: #9ca3af; font-size: 11px; }
@media (max-width: 560px) {
.fm-external-config { grid-template-columns: 1fr 1fr; }
.fm-external-config input { grid-column: 1 / -1; }
}
.fm-item-title strong {
overflow: visible; text-overflow: clip; white-space: normal; overflow-wrap: anywhere; word-break: break-all;
}
.fm-model-actions {
grid-column: 1 / -1 !important; grid-row: auto !important;
justify-content: flex-end; flex-wrap: nowrap; margin-top: 7px;
}
.fm-model-actions button { flex: 0 0 auto; }
@media (max-width: 500px) {
.fm-model-actions { justify-content: flex-start; overflow-x: auto; }
}
/* Mount inside ComfyUI's native right SplitterPanel. */
#find-models-panel.native-docked {
display: block; position: relative; inset: auto; width: 100% !important;
height: 100%; max-height: none; margin: 0; border: 0; border-radius: 0;
box-shadow: none; background: #11151b; overflow-x: hidden; overflow-y: auto;
}
#find-models-panel.native-docked .fm-resize-handle { display: none; }
#find-models-panel.native-docked .fm-header { top: 0; }
#find-models-panel.native-docked .fm-tabs { top: 60px; }
#find-models-panel.native-docked .fm-table-head { display: none; }
#find-models-panel.native-docked .fm-item,
#find-models-panel.native-docked .fm-model-card {
grid-template-columns: minmax(0, 1fr); gap: 6px;
}
#find-models-panel.native-docked .fm-item-title,
#find-models-panel.native-docked .fm-meta,
#find-models-panel.native-docked .fm-match,
#find-models-panel.native-docked .fm-external-candidates,
#find-models-panel.native-docked .fm-sources,
#find-models-panel.native-docked .fm-node-candidates,
#find-models-panel.native-docked .fm-node-types,
#find-models-panel.native-docked .fm-item-actions {
grid-column: 1 / -1 !important; grid-row: auto !important;
}
#find-models-panel.native-docked .fm-item-title strong {
display: block; width: 100%; overflow: visible; white-space: normal; word-break: break-all;
}
.fm-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; }
.fm-stat { display: grid; gap: 2px; padding: 9px 10px; background: #202227; border: 1px solid #34363d; border-radius: 8px; }
.fm-stat strong { font-size: 18px; color: #f9fafb; }
.fm-stat span { color: #9ca3af; font-size: 11px; }
.fm-section-title { display: flex; align-items: center; gap: 7px; margin: 15px 2px 7px; font-weight: 650; color: #f3f4f6; }
.fm-section-title span, .fm-badge {
padding: 2px 7px; border: 1px solid #44474f; border-radius: 999px; color: #b7bbc4; background: #26282e; font-size: 11px;
}
.fm-item { margin: 7px 0; padding: 11px; border: 1px solid #34363d; border-radius: 9px; background: #202126; }
.fm-item-title { display: flex; gap: 8px; align-items: center; }
.fm-item-title strong { flex: 1; overflow-wrap: anywhere; color: #f3f4f6; }
.fm-copy-row { display: contents; }
.fm-copy-name { width: 26px; height: 26px; padding: 0 !important; overflow: hidden; font-size: 0 !important; color: #b7bbc4 !important; background: transparent !important; border-color: transparent !important; }
.fm-copy-name::before { content: "⧉"; font-size: 16px; line-height: 24px; }
.fm-copy-name:hover { color: #fff !important; border-color: #6b7280 !important; }
.fm-quark-auth-config { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 7px; }
.fm-quark-auth-config input {
min-width: 0; padding: 7px 9px; color: #e5e7eb; background: #191a1e;
border: 1px solid #44474f; border-radius: 6px; outline: none;
}
.fm-node-title {
display: flex; width: 100%; gap: 8px; align-items: center; text-align: left;
padding: 0 !important; border: 0 !important; background: transparent !important;
}
.fm-node-title strong { flex: 1; overflow-wrap: anywhere; color: #f3f4f6; }
.fm-node-title:hover strong { color: #93c5fd; }
.fm-status-icon {
display: inline-grid; place-items: center; width: 20px; height: 20px; flex: 0 0 20px;
border-radius: 50%; color: #fff; background: #dc2626; font-weight: 700;
}
.fm-adaptable .fm-status-icon { background: #d97706; }
.fm-meta { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; margin: 7px 0; color: #9ca3af; font-size: 11px; }
.fm-missing { border-color: #5b2a2e; }
.fm-adaptable { border-color: #5b4723; }
.fm-match { margin: 8px 0; padding: 7px 8px; border-radius: 6px; color: #d1d5db; background: #18191d; overflow-wrap: anywhere; }
.fm-item-actions { display: flex; gap: 6px; margin-top: 8px; }
.fm-sources, .fm-node-candidates { display: grid; gap: 6px; margin-top: 8px; }
.fm-source { display: grid; grid-template-columns: 1fr auto; gap: 7px; align-items: center; padding: 7px; border-radius: 6px; background: #191a1e; }
.fm-source a { color: #93c5fd; overflow-wrap: anywhere; }
.fm-external-candidates { display: grid; gap: 6px; margin-top: 8px; }
.fm-external-source { border: 1px solid #36533f; background: #18231c; }
.fm-external-source span { overflow-wrap: anywhere; color: #bbf7d0; font-size: 11px; }
.fm-external-source .fm-target-warning { color: #fbbf24; }
.fm-download-progress { grid-column: 1 / -1; min-height: 14px; color: #86efac; font-size: 11px; }
.fm-download-jobs { display: grid; gap: 6px; margin: 9px 0 12px; padding: 10px; border: 1px solid #36533f; border-radius: 8px; background: #18231c; }
.fm-download-job { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; color: #86efac; }
.fm-download-job-info { display: grid; gap: 2px; min-width: 0; }
.fm-download-job-info strong { overflow-wrap: anywhere; color: #d1fae5; }
.fm-download-job-info span { font-size: 11px; }
.fm-download-controls, .fm-node-actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.fm-node-actions a { color: #93c5fd; }
.fm-node-types { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.fm-node-types span { padding: 4px 8px; border-radius: 6px; background: #171a1f; color: #c8cbd1; font-size: 12px; }
.fm-candidate-warning { color: #fbbf24; font-size: 11px; }
.fm-download-paused { border-color: #7c5e20; background: #292315; color: #fde68a; }
.fm-download-cancelled { border-color: #4b5563; background: #202126; color: #9ca3af; }
.fm-download-failed { color: #fca5a5; }
.fm-empty { display: grid; place-items: center; gap: 4px; margin: 30px 0; padding: 24px; color: #9ca3af; }
.fm-empty strong { color: #e5e7eb; font-size: 15px; }
#find-models-panel button {
padding: 6px 9px; color: #e5e7eb; background: #292b31; border: 1px solid #4b4e57; border-radius: 6px; cursor: pointer;
}
#find-models-panel button:hover { background: #363941; border-color: #666a75; }
#find-models-panel button:disabled { opacity: .55; cursor: wait; }
/* Refined information architecture */
#find-models-panel {
--fm-bg: #111318; --fm-surface: #191c23; --fm-line: #303642; --fm-muted: #929baa; --fm-text: #edf1f7;
width: min(640px, calc(100vw - 24px)); height: min(820px, calc(100vh - 68px));
top: 54px; right: 12px; padding: 0 16px 18px; color: var(--fm-text);
background: linear-gradient(180deg, #151820, var(--fm-bg) 170px); border-color: #343b48; border-radius: 16px;
box-shadow: 0 24px 80px #000c;
}
.fm-header { margin: 0 -16px; padding: 14px 16px; background: #14171eee; border-color: var(--fm-line); }
.fm-header-copy { display: grid; gap: 2px; }
.fm-header-copy span { color: var(--fm-muted); font-size: 11px; font-weight: 400; }
.fm-scan-state {
display: flex; align-items: center; gap: 8px; margin: 12px 0; padding: 8px 10px;
border: 1px solid #2d4059; border-radius: 9px; color: #bfd8f7; background: #182332;
}
.fm-scan-state [data-scan-status] { flex: 1; min-width: 0; }
.fm-scan-dot { width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: #60a5fa; box-shadow: 0 0 0 4px #60a5fa22; }
.fm-tabs {
top: 60px; gap: 6px; margin: 0 -16px; padding: 8px 16px; background: #14171eee; border-color: var(--fm-line);
}
.fm-tabs button {
display: grid; grid-template-columns: auto 1fr auto; gap: 5px; align-items: center;
padding: 8px !important; border: 1px solid transparent !important; border-radius: 9px !important;
}
.fm-tabs button.active { border-color: #454d5d !important; color: #fff !important; background: #252a34 !important; }
.fm-tabs button span { display: initial; min-width: 0; height: auto; margin: 0; border-radius: 0; background: transparent; font-size: inherit; }
.fm-tabs .fm-tab-icon { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; color: #b7c3d5; background: #303744; font-size: 10px; font-weight: 800; }
.fm-tabs button.active .fm-tab-icon { color: #fff; background: #e34d59; }
.fm-tabs button b {
display: inline-grid; place-items: center; min-width: 16px; height: 16px; border-radius: 999px;
color: #d1d5db; background: #343b48; font-size: 10px; font-weight: 650;
}
.fm-section-toolbar {
display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 14px 0 10px; padding: 11px 12px;
border: 1px solid var(--fm-line); border-radius: 10px; background: #181c23;
}
.fm-section-toolbar > div { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; }
.fm-section-toolbar > div:first-child { display: grid; gap: 2px; }
.fm-section-toolbar span { color: var(--fm-muted); font-size: 11px; }
.fm-primary { border-color: #b23d48 !important; background: #7c2730 !important; }
.fm-primary:hover { background: #96323d !important; }
.fm-summary { margin-top: 10px; }
.fm-stat, .fm-item { border-color: var(--fm-line); background: var(--fm-surface); }
.fm-item { border-radius: 11px; box-shadow: 0 6px 20px #0002; }
.fm-tab-panel { padding-bottom: 8px; }
.fm-model-list, .fm-node-list, .fm-downloads { animation: fm-fade-in .16s ease-out; }
.fm-settings-card {
border-color: var(--fm-line); border-radius: 11px; background: linear-gradient(145deg, #1d2129, #181b22);
box-shadow: 0 8px 24px #0002;
}
.fm-settings-card > strong { font-size: 14px; }
.fm-download-jobs { border-color: #31513d; border-radius: 11px; background: #17231c; }
.fm-download-job { padding: 9px; border: 1px solid #2e4938; border-radius: 8px; background: #1a2920; }
.fm-job-kind {
align-self: start; padding: 2px 6px; border-radius: 999px; color: #bbf7d0; background: #28543a; font-size: 10px;
}
.fm-job-node { color: #bfdbfe; background: #284561; }
.fm-empty {
min-height: 150px; margin: 12px 0; border: 1px dashed #353c48; border-radius: 12px;
background: linear-gradient(145deg, #171a20, #13161b);
}
#find-models-panel::-webkit-scrollbar { width: 8px; }
#find-models-panel::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 999px; background: #4a5363; background-clip: padding-box; }
@keyframes fm-fade-in { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 620px) {
.fm-tab-label { display: none !important; }
.fm-tabs button { grid-template-columns: 1fr auto; }
.fm-section-toolbar { align-items: stretch; flex-direction: column; }
}
/* Single-page workflow dependency console */
#find-models-panel {
width: min(820px, calc(100vw - 24px)); height: calc(100vh - 24px); max-height: calc(100vh - 24px);
top: 12px; right: 12px; padding: 0 16px 18px; border-radius: 4px;
background: radial-gradient(circle at 78% 0%, #1c212b 0, #11151b 36%, #0d1116 100%);
}
.fm-header { position: sticky; top: 0; min-height: 36px; z-index: 5; background: #11151bef; }
.fm-header-copy strong { font-size: 20px; letter-spacing: .2px; }
.fm-header-copy span { display: none; }
.fm-window-actions { display: flex; gap: 6px; }
.fm-window-actions button { font-size: 17px !important; }
.fm-scan-state {
position: relative; margin: 12px 0 6px; padding: 11px 13px; border-color: #29313c;
color: #aeb7c5; background: linear-gradient(90deg, #171d25, #141920);
}
.fm-scan-dot { background: #4e93ff; box-shadow: 0 0 0 4px #4e93ff18; }
.fm-tabs {
position: sticky; top: 64px; z-index: 4; grid-template-columns: repeat(4, 1fr); margin: 0 -16px 14px;
padding: 8px 16px 0; background: #10141aee; border-bottom: 1px solid #343b46;
}
.fm-tabs button {
display: flex; justify-content: center; gap: 8px; padding: 12px 7px !important;
border: 0 !important; border-bottom: 2px solid transparent !important; border-radius: 0 !important;
font-size: 14px;
}
.fm-tabs button.active, .fm-tabs button:hover {
color: #dfe5ff !important; background: transparent !important; border-bottom-color: #746dff !important;
}
.fm-tabs .fm-tab-icon { width: auto; height: auto; color: #b4becb; background: transparent; font-size: 14px; }
.fm-tabs button.active .fm-tab-icon { color: #8d87ff; background: transparent; }
.fm-tabs button b, .fm-section-toolbar em {
display: inline-grid; place-items: center; min-width: 18px; height: 18px; padding: 0 4px;
border-radius: 5px; color: #fff; background: #e4515c; font-size: 11px; font-style: normal;
}
.fm-summary {
display: flex; align-items: center; gap: 9px; margin: 8px 0 18px; padding: 0 2px;
color: #939dab; font-size: 12px;
}
.fm-summary i { width: 1px; height: 14px; margin: 0 4px; background: #3b424d; }
.fm-summary-ok { display: grid; place-items: center; width: 17px; height: 17px; border: 1px solid #3db55e; border-radius: 50%; color: #57d475; }
.fm-tab-panel, .fm-tab-panel.active {
display: block; min-height: 0; padding: 4px 0 20px; scroll-margin-top: 125px;
}
.fm-section-toolbar { margin: 0 0 10px; padding: 0; border: 0; background: transparent; }
.fm-section-toolbar > div:first-child { display: flex; align-items: baseline; gap: 8px; }
.fm-section-toolbar > div:first-child strong { font-size: 17px; }
.fm-section-toolbar > div:first-child span { display: none; }
.fm-section-toolbar button { min-height: 34px; padding: 7px 14px !important; }
.fm-primary {
border-color: #6863ff !important; background: linear-gradient(135deg, #5962f4, #765cff) !important;
box-shadow: 0 5px 18px #615cff35;
}
.fm-primary:hover { background: linear-gradient(135deg, #6870ff, #846cff) !important; }
.fm-table-head {
display: grid; grid-template-columns: minmax(190px, 1.1fr) minmax(220px, 1.5fr) minmax(180px, auto);
gap: 12px; padding: 10px 12px; color: #9aa5b4; background: #1b2028; border: 1px solid #303742; border-bottom: 0;
font-size: 11px;
}
.fm-table-foot { padding: 6px 12px; text-align: center; color: #8c96a5; background: #1a1f27; border: 1px solid #303742; border-top: 0; font-size: 11px; }
.fm-model-list, .fm-node-list { display: block !important; min-height: 0; }
.fm-item, .fm-model-card {
display: grid !important; grid-template-columns: minmax(190px, 1.1fr) minmax(220px, 1.5fr) minmax(180px, auto);
gap: 4px 12px; align-items: center; min-height: 0; margin: 0; padding: 10px 12px;
border-color: #303742; border-radius: 0; background: #141920; box-shadow: none;
}
.fm-item + .fm-item { border-top: 0; }
.fm-item-title { grid-column: 1; }
.fm-item-title .fm-status-icon { display: none; }
.fm-item-title strong { font-size: 12px; font-weight: 600; }
.fm-copy-name { width: 22px; height: 22px; }
.fm-copy-name::before { font-size: 13px; line-height: 20px; }
.fm-meta { grid-column: 2; margin: 0; }
.fm-meta span:nth-child(2) { display: none; }
.fm-item-actions { grid-column: 3; justify-content: flex-end; margin: 0; }
.fm-match, .fm-external-candidates, .fm-sources, .fm-node-candidates, .fm-node-types {
grid-column: 1 / -1; margin-top: 5px;
}
.fm-match { padding: 6px 8px; border: 1px solid #294a34; color: #85df9e; background: #16251c; font-size: 11px; }
.fm-badge { border-color: #3c4552; color: #c7cfda; background: #1d232c; }
.fm-source { border: 1px solid #303844; background: #171d25; }
.fm-external-source { border-color: #315540; background: #16231b; }
.fm-node-list .fm-item-title { grid-column: 1; }
.fm-node-list .fm-meta { grid-column: 2; }
.fm-node-list .fm-node-types { grid-column: 1 / 3; }
.fm-node-list .fm-item-actions { grid-column: 3; grid-row: 1 / 3; }
.fm-node-list .fm-node-candidates { grid-column: 1 / -1; }
.fm-download-jobs { gap: 0; padding: 0; border-color: #303742; border-radius: 0; background: #141920; }
.fm-download-job {
grid-template-columns: auto minmax(0, 1fr) auto; padding: 10px 12px; border: 0; border-bottom: 1px solid #303742;
border-radius: 0; background: #141920;
}
.fm-download-job:last-child { border-bottom: 0; }
.fm-download-job-info span { color: #9aa5b4; }
.fm-settings-card { margin-top: 8px; border-radius: 0; background: #151a21; box-shadow: none; }
.fm-external-config, .fm-quark-auth-config { margin: 0; }
.fm-empty { min-height: 80px; margin: 0; border-radius: 0; }
@media (max-width: 760px) {
#find-models-panel { width: calc(100vw - 12px); right: 6px; }
.fm-table-head { display: none; }
.fm-item, .fm-model-card { grid-template-columns: 1fr auto; }
.fm-item-title, .fm-meta, .fm-match, .fm-external-candidates, .fm-sources, .fm-node-candidates, .fm-node-types { grid-column: 1 / -1 !important; }
.fm-item-actions { grid-column: 1 / -1 !important; grid-row: auto !important; justify-content: flex-start; }
}
/* Compact four-page panel */
#find-models-panel {
width: min(590px, calc(100vw - 24px)); height: min(820px, calc(100vh - 24px));
}
.fm-tabs { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.fm-tabs button { min-width: 0; gap: 5px; padding-inline: 4px !important; font-size: 12px; }
.fm-tabs .fm-tab-icon { display: none; }
.fm-tab-panel { display: none; min-height: 360px; padding: 4px 0 16px; }
.fm-tab-panel.active { display: block; }
.fm-section-toolbar { align-items: flex-start; }
.fm-section-toolbar > div:last-child { justify-content: flex-end; }
.fm-table-head {
grid-template-columns: minmax(160px, 1fr) minmax(150px, 1fr) auto;
gap: 8px; padding-inline: 10px;
}
.fm-item, .fm-model-card {
grid-template-columns: minmax(160px, 1fr) minmax(150px, 1fr) auto;
gap: 5px 8px; padding: 9px 10px;
}
.fm-meta { gap: 5px; }
.fm-meta [data-model-size] { flex-basis: 100%; }
.fm-item-actions { flex-wrap: wrap; }
.fm-source { grid-template-columns: minmax(0, 1fr) auto; }
.fm-source a, .fm-source span { font-size: 11px; }
.fm-quark-auth-config { grid-template-columns: minmax(0, 1fr) auto auto; }
@media (max-width: 600px) {
.fm-tab-label { display: inline !important; }
.fm-tabs button { display: flex; }
.fm-section-toolbar { align-items: stretch; flex-direction: column; }
.fm-section-toolbar > div:last-child { justify-content: flex-start; }
.fm-table-head { display: none; }
.fm-item, .fm-model-card { grid-template-columns: 1fr; }
}
/* Docked, resizable workflow-overview style panel */
#find-models-panel {
top: 40px; right: 0; width: min(560px, calc(100vw - 24px));
height: calc(100vh - 40px); max-height: calc(100vh - 40px);
border-radius: 0; border-top: 0; border-right: 0;
}
.fm-resize-handle {
position: fixed; top: 40px; bottom: 0; width: 7px; margin-left: -19px;
cursor: ew-resize; touch-action: none; z-index: 10;
}
.fm-resize-handle::after {
content: ""; position: absolute; top: 0; bottom: 0; left: 3px; width: 1px; background: #3b414c;
}
.fm-resize-handle:hover::after { width: 2px; background: #7470ff; }
.fm-tabs .fm-tab-label { font-weight: 750; }
.fm-item-title { min-width: 0; flex-wrap: nowrap; }
.fm-item-title strong {
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal;
}
.fm-copy-name { flex: 0 0 22px; }
.fm-item-actions { white-space: nowrap; }
.fm-local-load { border-color: #2f7546 !important; color: #b9f6c9 !important; background: #193322 !important; }
.fm-node-install-options {
display: grid; gap: 8px; margin: 0 0 10px; padding: 10px;
border: 1px solid #303742; background: #151a21;
}
.fm-node-install-options label { display: flex; align-items: center; gap: 7px; color: #cbd2dc; }
.fm-node-install-options > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }
.fm-node-install-options input[type="url"] {
min-width: 0; padding: 7px 9px; color: #e5e7eb; background: #10141a;
border: 1px solid #444b58; border-radius: 6px; outline: none;
}
.fm-quark-links { display: flex; gap: 8px; flex-wrap: wrap; }
.fm-quark-links a { color: #93c5fd; }
@media (max-width: 560px) {
#find-models-panel { width: 100vw !important; }
.fm-resize-handle { display: none; }
}
.fm-download-job { display: flex; justify-content: space-between; gap: 10px; color: #8fd3a8; }
.fm-download-job span:first-child { overflow-wrap: anywhere; }
.fm-download-failed { color: #ff8c8c; }
.fm-sources a { color: #7fb9ff; overflow-wrap: anywhere; }
#find-models-panel button { padding: 5px 8px; color: #eee; background: #333; border: 1px solid #666; border-radius: 5px; cursor: pointer; }
+834 -83
View File
File diff suppressed because it is too large Load Diff