Improve missing dependency detection and native panel UI
This commit is contained in:
@@ -2,3 +2,5 @@ __pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
external_model_folder.json
|
||||
quark_auth.json
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+174
-18
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user