diff --git a/.gitignore b/.gitignore
index 93c42f6..23a2b16 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,5 @@ __pycache__/
*.py[cod]
.pytest_cache/
.venv/
+external_model_folder.json
+quark_auth.json
diff --git a/README.md b/README.md
index dbd38f0..7e4c74b 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/find_models.py b/find_models.py
index 429f2d3..6ba28da 100644
--- a/find_models.py
+++ b/find_models.py
@@ -1,8 +1,10 @@
from __future__ import annotations
import asyncio
+import json
import os
import re
+import shutil
import subprocess
import tempfile
import time
@@ -18,13 +20,14 @@ from aiohttp import web
import folder_paths
from server import PromptServer
-from .model_finder import MODEL_EXTENSIONS, analyze, basename, normalized_stem
+from .model_finder import MODEL_EXTENSIONS, analyze, basename, model_name_key, normalize_path, normalized_stem
from .node_installer import (
COMFY_MANAGER_NODE_MAP_URL,
- TE_MARKET_URL,
+ allowed_repo_url,
+ github_fallback_candidates,
install_market_plugin,
- market_candidates,
- missing_node_types,
+ missing_workflow_node_packages,
+ missing_workflow_node_types,
)
@@ -41,10 +44,22 @@ ALLOWED_DOWNLOAD_HOSTS = {
}
QUARK_MODEL_LIBRARIES = (
{"name": "夸克模型库 1", "share_id": "fb913d649b18", "url": "https://pan.quark.cn/s/fb913d649b18"},
- {"name": "夸克模型库 2", "share_id": "4680ac8665162", "url": "https://pan.quark.cn/s/4680ac8665162"},
+ {"name": "夸克模型库 2", "share_id": "4680ac866516", "url": "https://pan.quark.cn/s/4680ac866516"},
+ {"name": "夸克模型库 2(兼容)", "share_id": "4680ac8665162", "url": "https://pan.quark.cn/s/4680ac8665162"},
)
QUARK_API = "https://drive-pc.quark.cn/1/clouddrive"
+QUARK_USER_AGENT = (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 "
+ "Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch"
+)
QUARK_CATEGORY_FOLDERS = {
+ "LLM": {"LLM", "llm"},
+ "instantid": {"instantid"},
+ "ipadapter": {"ipadapter", "ip_adapter"},
+ "sams": {"sams"},
+ "ultralytics_bbox": {"ultralytics_bbox", "bbox"},
+ "ultralytics_segm": {"ultralytics_segm", "segm"},
"checkpoints": {"checkpoints"},
"loras": {"loras", "lora"},
"vae": {"vae"},
@@ -56,6 +71,12 @@ QUARK_CATEGORY_FOLDERS = {
"embeddings": {"embeddings"},
}
DOWNLOAD_CATEGORY_ALIASES = {
+ "LLM": ("LLM",),
+ "instantid": ("instantid",),
+ "ipadapter": ("ipadapter",),
+ "sams": ("sams",),
+ "ultralytics_bbox": ("ultralytics_bbox",),
+ "ultralytics_segm": ("ultralytics_segm",),
"checkpoints": ("checkpoints",),
"loras": ("loras",),
"vae": ("vae",),
@@ -65,8 +86,28 @@ DOWNLOAD_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",),
}
KNOWN_MODEL_SOURCES = {
+ "qwen_3_06b_base.safetensors": {
+ "provider": "Hugging Face · circlestone-labs/Anima",
+ "name": "qwen_3_06b_base.safetensors",
+ "url": "https://huggingface.co/circlestone-labs/Anima/resolve/main/split_files/text_encoders/qwen_3_06b_base.safetensors?download=true",
+ "size": 1192135096,
+ "confidence": 1.0,
+ },
+ "anima-base-v1.0.safetensors": {
+ "provider": "Hugging Face · circlestone-labs/Anima",
+ "name": "anima-base-v1.0.safetensors",
+ "url": "https://huggingface.co/circlestone-labs/Anima/resolve/main/split_files/diffusion_models/anima-base-v1.0.safetensors?download=true",
+ "size": 4182218328,
+ "confidence": 1.0,
+ },
"fantasytalking_fp16.safetensors": {
"provider": "Hugging Face · Kijai/WanVideo_comfy",
"name": "fantasytalking_fp16.safetensors",
@@ -77,6 +118,11 @@ KNOWN_MODEL_SOURCES = {
}
DOWNLOAD_JOBS: dict[str, dict[str, Any]] = {}
DOWNLOAD_TASKS: dict[str, asyncio.Task[Any]] = {}
+EXTERNAL_FOLDER_CONFIG = Path(__file__).with_name("external_model_folder.json")
+QUARK_AUTH_CONFIG = Path(__file__).with_name("quark_auth.json")
+EXTERNAL_INDEX_CACHE: dict[str, list[dict[str, Any]]] = {}
+EXTERNAL_INDEX_ROOT: str | None = None
+EXTERNAL_INDEX_TASK: asyncio.Task[Any] | None = None
def _safe_query(name: str) -> str:
@@ -87,6 +133,10 @@ def _exact_model_name(wanted: str, candidate: str) -> bool:
return basename(wanted).lower() == basename(candidate).lower()
+def _equivalent_model_name(wanted: str, candidate: str) -> bool:
+ return _exact_model_name(wanted, candidate) or model_name_key(wanted) == model_name_key(candidate)
+
+
def _is_https_url(value: Any) -> bool:
return isinstance(value, str) and value.startswith("https://")
@@ -110,6 +160,35 @@ def _allowed_quark_download_url(value: Any) -> bool:
return host.endswith((".quark.cn", ".uc.cn", ".alicdn.com", ".aliyuncs.com"))
+def _load_quark_cookie() -> str:
+ try:
+ data = json.loads(QUARK_AUTH_CONFIG.read_text(encoding="utf-8"))
+ return str(data.get("cookie", "")).strip()
+ except (OSError, ValueError, TypeError, AttributeError):
+ return ""
+
+
+def _quark_token(value: Any) -> str:
+ if isinstance(value, str):
+ return value.strip()
+ if isinstance(value, list):
+ return next((_quark_token(item) for item in value if _quark_token(item)), "")
+ if isinstance(value, dict):
+ for key in ("share_fid_token", "fid_token", "token"):
+ token = _quark_token(value.get(key))
+ if token:
+ return token
+ return ""
+
+
+def _save_quark_cookie(cookie: str) -> None:
+ value = str(cookie or "").strip()
+ if value:
+ QUARK_AUTH_CONFIG.write_text(json.dumps({"cookie": value}, ensure_ascii=False), encoding="utf-8")
+ elif QUARK_AUTH_CONFIG.exists():
+ QUARK_AUTH_CONFIG.unlink()
+
+
def _safe_filename(value: str) -> str:
name = basename(value).strip()
if not name or not name.lower().endswith(tuple(MODEL_EXTENSIONS)):
@@ -117,6 +196,24 @@ def _safe_filename(value: str) -> str:
return name
+def _safe_relative_model_name(value: str) -> Path:
+ normalized = str(value).replace("\\", "/").strip().lstrip("/")
+ parts = [part for part in normalized.split("/") if part]
+ if not parts or any(part in {".", ".."} for part in parts):
+ raise web.HTTPBadRequest(text="Invalid model path")
+ _safe_filename(parts[-1])
+ return Path(*parts)
+
+
+def _official_relative_model_name(value: str, category: str) -> Path:
+ normalized = normalize_path(value)
+ if category == "ultralytics_bbox" and normalized.lower().startswith("bbox/"):
+ normalized = normalized.split("/", 1)[1]
+ elif category == "ultralytics_segm" and normalized.lower().startswith("segm/"):
+ normalized = normalized.split("/", 1)[1]
+ return Path(_safe_filename(normalized))
+
+
def _size_value(value: Any, multiplier: int = 1) -> int | None:
try:
size = int(float(value) * multiplier)
@@ -125,6 +222,11 @@ def _size_value(value: Any, multiplier: int = 1) -> int | None:
return None
+def _content_range_total(value: Any) -> int | None:
+ match = re.search(r"/(\d+)$", str(value or ""))
+ return _size_value(match.group(1)) if match else None
+
+
def _is_model_payload(path: Path) -> bool:
if path.stat().st_size < 1024:
return False
@@ -134,21 +236,84 @@ def _is_model_payload(path: Path) -> bool:
def _target_directory(category: str) -> Path:
- aliases = DOWNLOAD_CATEGORY_ALIASES.get(category)
- if not aliases:
- raise web.HTTPBadRequest(text=f"无法确定模型对应的 ComfyUI 文件夹: {category}")
+ aliases = DOWNLOAD_CATEGORY_ALIASES.get(category, (category,))
+ registered = getattr(folder_paths, "folder_names_and_paths", None)
+ if category not in DOWNLOAD_CATEGORY_ALIASES and isinstance(registered, dict) and category not in registered:
+ raise web.HTTPBadRequest(text=f"No registered model folder for category: {category}")
for candidate in aliases:
try:
paths = folder_paths.get_folder_paths(candidate)
except Exception:
continue
if paths:
- target = Path(paths[0]).resolve()
+ resolved = [Path(path).resolve() for path in paths]
+ target = next(
+ (path for path in resolved if path.name.lower() == category.lower()),
+ next((path for path in resolved if path.name.lower() == candidate.lower()), resolved[0]),
+ )
target.mkdir(parents=True, exist_ok=True)
return target
raise web.HTTPBadRequest(text=f"No configured model folder for category: {category}")
+def _registered_model_extensions() -> set[str]:
+ extensions = {extension.lower() for extension in MODEL_EXTENSIONS}
+ registered = getattr(folder_paths, "folder_names_and_paths", None)
+ if not isinstance(registered, dict):
+ return extensions
+ for _, value in registered.items():
+ if not isinstance(value, (tuple, list)) or len(value) < 2:
+ continue
+ accepted = value[1]
+ if not isinstance(accepted, (set, tuple, list)):
+ continue
+ extensions.update(
+ str(extension).lower()
+ for extension in accepted
+ if isinstance(extension, str) and extension.startswith(".")
+ )
+ return extensions
+
+
+def _registered_category_roots() -> list[tuple[str, Path]]:
+ roots: list[tuple[str, Path]] = []
+ registered = getattr(folder_paths, "folder_names_and_paths", None)
+ if not isinstance(registered, dict):
+ return roots
+ for category, value in registered.items():
+ if not isinstance(value, (tuple, list)) or not value:
+ continue
+ paths = value[0]
+ if not isinstance(paths, (tuple, list)):
+ continue
+ for path in paths:
+ try:
+ roots.append((str(category), Path(path).resolve()))
+ except (OSError, TypeError, ValueError):
+ continue
+ return roots
+
+
+def _external_category_hint(path: Path, root: Path) -> str | None:
+ official_names: dict[str, str] = {
+ category.lower(): category for category in DOWNLOAD_CATEGORY_ALIASES
+ }
+ registered = getattr(folder_paths, "folder_names_and_paths", None)
+ if isinstance(registered, dict):
+ official_names.update({str(category).lower(): str(category) for category in registered})
+ for category, official_root in _registered_category_roots():
+ official_names.setdefault(official_root.name.lower(), category)
+ try:
+ parts = path.resolve().relative_to(root.resolve()).parts[:-1]
+ except (OSError, ValueError):
+ return None
+ for part in reversed(parts):
+ category = official_names.get(part.lower())
+ if category:
+ return category
+ return None
+
+
def _clear_filename_cache(category: str) -> None:
cache = getattr(folder_paths, "filename_list_cache", None)
if isinstance(cache, dict):
@@ -156,6 +321,225 @@ def _clear_filename_cache(category: str) -> None:
cache.pop(candidate, None)
+def _load_external_folder() -> Path | None:
+ try:
+ data = json.loads(EXTERNAL_FOLDER_CONFIG.read_text(encoding="utf-8"))
+ path = Path(str(data.get("path", ""))).expanduser().resolve()
+ return path if path.is_dir() else None
+ except (OSError, ValueError, TypeError):
+ return None
+
+
+def _save_external_folder(path: Path) -> None:
+ global EXTERNAL_INDEX_CACHE, EXTERNAL_INDEX_ROOT
+ EXTERNAL_FOLDER_CONFIG.write_text(
+ json.dumps({"path": str(path)}, ensure_ascii=False, indent=2),
+ encoding="utf-8",
+ )
+ EXTERNAL_INDEX_CACHE = {}
+ EXTERNAL_INDEX_ROOT = None
+
+
+def _choose_external_folder() -> Path | None:
+ if os.name != "nt":
+ raise RuntimeError("Native folder selection is currently supported on Windows only")
+ script = (
+ "Add-Type -AssemblyName System.Windows.Forms; "
+ "$owner = New-Object System.Windows.Forms.Form; "
+ "$owner.TopMost = $true; "
+ "$owner.ShowInTaskbar = $false; "
+ "$owner.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterScreen; "
+ "$owner.Size = New-Object System.Drawing.Size(1, 1); "
+ "$owner.Opacity = 0; "
+ "$owner.Show(); "
+ "$owner.Activate(); "
+ "$owner.BringToFront(); "
+ "$dialog = New-Object System.Windows.Forms.FolderBrowserDialog; "
+ "$dialog.Description = '选择外部模型库文件夹'; "
+ "$dialog.ShowNewFolderButton = $true; "
+ "try { "
+ "if ($dialog.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) "
+ "{ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $dialog.SelectedPath } "
+ "} finally { $dialog.Dispose(); $owner.Close(); $owner.Dispose(); }"
+ )
+ completed = subprocess.run(
+ ["powershell.exe", "-NoProfile", "-STA", "-Command", script],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=300,
+ check=False,
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
+ )
+ selected = completed.stdout.strip().lstrip("\ufeff")
+ if completed.returncode != 0:
+ raise RuntimeError(completed.stderr.strip() or "Unable to open the folder selector")
+ if not selected:
+ return None
+ path = Path(selected).resolve()
+ if not path.is_dir():
+ raise RuntimeError("Selected external model folder does not exist")
+ return path
+
+
+def _external_model_index(root: Path | None) -> dict[str, list[dict[str, Any]]]:
+ index: dict[str, list[dict[str, Any]]] = {}
+ if root is None or not root.is_dir():
+ return index
+ extensions = _registered_model_extensions()
+ for directory, _, files in os.walk(root, onerror=lambda _: None):
+ for filename in files:
+ if Path(filename).suffix.lower() not in extensions:
+ continue
+ path = (Path(directory) / filename).resolve()
+ try:
+ size = path.stat().st_size
+ except OSError:
+ continue
+ index.setdefault(filename.lower(), []).append(
+ {
+ "name": filename,
+ "path": str(path),
+ "size": size,
+ "category_hint": _external_category_hint(path, root),
+ }
+ )
+ return index
+
+
+def _external_model_index_for_names(
+ root: Path | None,
+ wanted_names: set[str],
+ category_hints: dict[str, str] | None = None,
+) -> dict[str, list[dict[str, Any]]]:
+ """Quickly find only the exact model filenames required by the current workflow."""
+ index: dict[str, list[dict[str, Any]]] = {}
+ wanted = {basename(name).lower() for name in wanted_names if basename(name)}
+ if root is None or not root.is_dir() or not wanted:
+ return index
+ extensions = _registered_model_extensions()
+ remaining = set(wanted)
+
+ def scan(search_root: Path, names: set[str]) -> None:
+ for directory, _, files in os.walk(search_root, onerror=lambda _: None):
+ for filename in files:
+ key = filename.lower()
+ if key not in names or Path(filename).suffix.lower() not in extensions:
+ continue
+ path = (Path(directory) / filename).resolve()
+ try:
+ size = path.stat().st_size
+ except OSError:
+ continue
+ index.setdefault(key, []).append(
+ {
+ "name": filename,
+ "path": str(path),
+ "size": size,
+ "category_hint": _external_category_hint(path, root),
+ }
+ )
+
+ normalized_hints = {
+ basename(name).lower(): str(category)
+ for name, category in (category_hints or {}).items()
+ if basename(name) and category in DOWNLOAD_CATEGORY_ALIASES
+ }
+ for category in dict.fromkeys(normalized_hints.get(name) for name in wanted):
+ if not category:
+ continue
+ category_names = {name for name in remaining if normalized_hints.get(name) == category}
+ category_roots = []
+ for alias in DOWNLOAD_CATEGORY_ALIASES[category]:
+ category_roots.extend((root / alias, root / "models" / alias, root / "ComfyUI" / "models" / alias))
+ for category_root in dict.fromkeys(category_roots):
+ if category_root.is_dir() and category_names:
+ scan(category_root, category_names)
+ category_names.difference_update(index)
+ remaining.difference_update(index)
+ if not remaining:
+ return index
+
+ if remaining:
+ scan(root, remaining)
+ return index
+
+
+async def _refresh_external_index(root: Path) -> None:
+ global EXTERNAL_INDEX_CACHE, EXTERNAL_INDEX_ROOT, EXTERNAL_INDEX_TASK
+ try:
+ EXTERNAL_INDEX_CACHE = await asyncio.to_thread(_external_model_index, root)
+ EXTERNAL_INDEX_ROOT = str(root)
+ finally:
+ EXTERNAL_INDEX_TASK = None
+
+
+def _cached_external_model_index(root: Path | None) -> dict[str, list[dict[str, Any]]]:
+ global EXTERNAL_INDEX_TASK
+ if root is None:
+ return {}
+ if EXTERNAL_INDEX_ROOT != str(root) and EXTERNAL_INDEX_TASK is None:
+ EXTERNAL_INDEX_TASK = asyncio.create_task(_refresh_external_index(root))
+ return EXTERNAL_INDEX_CACHE if EXTERNAL_INDEX_ROOT == str(root) else {}
+
+
+async def _external_candidates_index(
+ root: Path | None,
+ wanted_names: set[str],
+ category_hints: dict[str, str] | None = None,
+) -> dict[str, list[dict[str, Any]]]:
+ """Return exact external matches immediately, while refreshing the complete cache in the background."""
+ global EXTERNAL_INDEX_TASK
+ if root is None:
+ return {}
+ if EXTERNAL_INDEX_ROOT == str(root):
+ return EXTERNAL_INDEX_CACHE
+ exact_index = await asyncio.to_thread(_external_model_index_for_names, root, wanted_names, category_hints)
+ if EXTERNAL_INDEX_TASK is None:
+ EXTERNAL_INDEX_TASK = asyncio.create_task(_refresh_external_index(root))
+ return exact_index
+
+
+def _move_external_model(source_value: str, wanted_name: str, category: str, root: Path | None) -> dict[str, Any]:
+ global EXTERNAL_INDEX_CACHE, EXTERNAL_INDEX_ROOT
+ if root is None or not root.is_dir():
+ raise web.HTTPBadRequest(text="External model folder is not configured")
+ source = Path(source_value).expanduser().resolve()
+ try:
+ source.relative_to(root)
+ except ValueError as error:
+ raise web.HTTPBadRequest(text="Source file is outside the configured external model folder") from error
+ if not source.is_file() or basename(wanted_name).casefold() != source.name.casefold():
+ raise web.HTTPBadRequest(text="External model file no longer matches the missing model")
+ if not _is_model_payload(source):
+ raise web.HTTPBadRequest(text="External file is not a valid model payload")
+
+ target_dir = _target_directory(category)
+ relative_name = _official_relative_model_name(source.name, category)
+ target = (target_dir / relative_name).resolve()
+ try:
+ target.relative_to(target_dir)
+ except ValueError as error:
+ raise web.HTTPBadRequest(text="Invalid target model path") from error
+ if target.exists():
+ raise web.HTTPConflict(text=f"Model already exists: {target}")
+ target.parent.mkdir(parents=True, exist_ok=True)
+ size = source.stat().st_size
+ shutil.move(str(source), str(target))
+ EXTERNAL_INDEX_CACHE = {}
+ EXTERNAL_INDEX_ROOT = None
+ _clear_filename_cache(category)
+ return {
+ "moved": True,
+ "filename": target.name,
+ "relative_name": relative_name.as_posix(),
+ "category": category,
+ "path": str(target),
+ "size": size,
+ }
+
+
async def _get_json(session: aiohttp.ClientSession, url: str) -> Any:
try:
async with session.get(url, headers={"User-Agent": "ComfyUI_FindModels/1.0"}) as response:
@@ -259,7 +643,16 @@ async def _huggingface_candidates(session: aiohttp.ClientSession, name: str) ->
async def _quark_json(
session: aiohttp.ClientSession, method: str, path: str, *, share_id: str, data: Any = None
) -> Any:
- headers = {"User-Agent": "Mozilla/5.0 ComfyUI_FindModels/1.3.0", "Referer": f"https://pan.quark.cn/s/{share_id}"}
+ headers = {
+ "Accept": "application/json, text/plain, */*",
+ "Content-Type": "application/json;charset=UTF-8",
+ "Origin": "https://pan.quark.cn",
+ "Referer": f"https://pan.quark.cn/s/{share_id}",
+ "User-Agent": QUARK_USER_AGENT,
+ }
+ cookie = _load_quark_cookie()
+ if cookie:
+ headers["Cookie"] = cookie
url = f"{QUARK_API}{path}"
try:
async with session.request(method, url, json=data, headers=headers) as response:
@@ -316,12 +709,14 @@ async def _quark_candidates(
{
"provider": library["name"],
"name": item_name,
+ "url": library["url"],
"confidence": round(confidence, 3),
"size": _size_value(item.get("size")),
"quark": {
"share_id": share_id,
"fid": item.get("fid"),
- "fid_token": item.get("share_fid_token"),
+ "fid_token": _quark_token(item.get("share_fid_token")),
+ "filename": item_name,
},
}
)
@@ -331,31 +726,122 @@ async def _quark_candidates(
@PromptServer.instance.routes.post("/findmodels/scan")
async def scan_models(request: web.Request) -> web.Response:
payload = await request.json()
- result = analyze(payload, folder_paths.get_filename_list)
+ quick = bool(payload.get("quick"))
+ registered_categories = getattr(folder_paths, "folder_names_and_paths", {})
+ result = analyze(
+ payload,
+ folder_paths.get_filename_list,
+ registered_categories.keys() if isinstance(registered_categories, dict) else (),
+ )
+ external_root = _load_external_folder()
+ external_index = await _external_candidates_index(
+ external_root,
+ {str(model.get("name") or "") for model in result["models"]},
+ {
+ str(model.get("name") or ""): str(model.get("category") or "")
+ for model in result["models"]
+ },
+ )
+ for model in result["models"]:
+ candidates = external_index.get(basename(model["name"]).lower(), [])
+ candidates.sort(
+ key=lambda item: (
+ item.get("category_hint") != model.get("category"),
+ len(Path(item["path"]).parts),
+ )
+ )
+ active_job = next(
+ (
+ job
+ for job in DOWNLOAD_JOBS.values()
+ if basename(str(job.get("original") or job.get("filename") or "")).lower()
+ == basename(model["name"]).lower()
+ and job.get("status") in {"queued", "downloading", "completed"}
+ ),
+ None,
+ )
+ model["external_candidates"] = candidates
+ model["size"] = (
+ model.get("source_size")
+ or (candidates[0]["size"] if candidates else None)
+ or (KNOWN_MODEL_SOURCES.get(basename(model["name"]).lower()) or {}).get("size")
+ or ((active_job or {}).get("total"))
+ )
+ result["external_folder"] = str(external_root) if external_root else ""
try:
import nodes
registered = nodes.NODE_CLASS_MAPPINGS.keys()
except (ImportError, AttributeError):
registered = ()
- result["missing_nodes"] = missing_node_types(
- [node.get("type") for node in payload.get("nodes", []) if isinstance(node, dict)],
- registered,
- )
+ result["missing_nodes"] = missing_workflow_node_types(payload.get("nodes", []), registered)
+ result["missing_node_packages"] = missing_workflow_node_packages(payload.get("nodes", []), registered)
+ result["missing_node_references"] = {
+ node_type: [
+ str(node.get("id"))
+ for node in payload.get("nodes", [])
+ if isinstance(node, dict)
+ and node.get("type") == node_type
+ and node.get("id") is not None
+ ]
+ for node_type in result["missing_nodes"]
+ }
result["missing_node_candidates"] = {}
- if result["missing_nodes"]:
- async with aiohttp.ClientSession(timeout=SOURCE_TIMEOUT, trust_env=True) as session:
- entries, node_map = await asyncio.gather(_te_market_entries(session), _official_node_map(session))
- result["missing_node_candidates"] = {
- node_type: market_candidates(entries, node_type, node_map)[:8]
- for node_type in result["missing_nodes"]
- }
+ result["quick"] = quick
return web.json_response(result)
-async def _te_market_entries(session: aiohttp.ClientSession) -> list[dict[str, Any]]:
- data = await _get_json(session, TE_MARKET_URL)
- return (data or {}).get("custom_nodes", [])
+@PromptServer.instance.routes.get("/findmodels/external-folder")
+async def get_external_folder(request: web.Request) -> web.Response:
+ path = _load_external_folder()
+ return web.json_response({"path": str(path) if path else ""})
+
+
+@PromptServer.instance.routes.post("/findmodels/external-folder")
+async def set_external_folder(request: web.Request) -> web.Response:
+ payload = await request.json()
+ path = Path(str(payload.get("path", ""))).expanduser().resolve()
+ if not path.is_dir():
+ raise web.HTTPBadRequest(text="External model folder does not exist")
+ await asyncio.to_thread(_save_external_folder, path)
+ return web.json_response({"path": str(path)})
+
+
+@PromptServer.instance.routes.post("/findmodels/external-folder/select")
+async def select_external_folder(request: web.Request) -> web.Response:
+ try:
+ path = await asyncio.to_thread(_choose_external_folder)
+ except (OSError, RuntimeError, subprocess.SubprocessError) as error:
+ raise web.HTTPBadRequest(text=str(error)) from error
+ if path is None:
+ return web.json_response({"path": "", "cancelled": True})
+ await asyncio.to_thread(_save_external_folder, path)
+ return web.json_response({"path": str(path), "cancelled": False})
+
+
+@PromptServer.instance.routes.get("/findmodels/quark-auth")
+async def get_quark_auth(request: web.Request) -> web.Response:
+ return web.json_response({"configured": bool(_load_quark_cookie())})
+
+
+@PromptServer.instance.routes.post("/findmodels/quark-auth")
+async def set_quark_auth(request: web.Request) -> web.Response:
+ payload = await request.json()
+ await asyncio.to_thread(_save_quark_cookie, str(payload.get("cookie", "")))
+ return web.json_response({"configured": bool(_load_quark_cookie())})
+
+
+@PromptServer.instance.routes.post("/findmodels/external-move")
+async def move_external_model(request: web.Request) -> web.Response:
+ payload = await request.json()
+ result = await asyncio.to_thread(
+ _move_external_model,
+ str(payload.get("source", "")),
+ str(payload.get("name", "")),
+ str(payload.get("category", "")),
+ _load_external_folder(),
+ )
+ return web.json_response(result)
async def _official_node_map(session: aiohttp.ClientSession) -> dict[str, Any]:
@@ -367,16 +853,19 @@ async def _official_node_map(session: aiohttp.ClientSession) -> dict[str, Any]:
async def find_node_candidates(request: web.Request) -> web.Response:
payload = await request.json()
node_type = str(payload.get("node_type", "")).strip()
- if not node_type:
- raise web.HTTPBadRequest(text="Missing node type")
+ package_id = str(payload.get("package_id", "")).strip()
+ if not node_type and not package_id:
+ raise web.HTTPBadRequest(text="Missing node type or package id")
async with aiohttp.ClientSession(timeout=SOURCE_TIMEOUT, trust_env=True) as session:
- entries, node_map = await asyncio.gather(_te_market_entries(session), _official_node_map(session))
- candidates = market_candidates(entries, node_type, node_map)
+ node_map = await _official_node_map(session)
+ candidates = github_fallback_candidates(package_id, node_type, node_map)
+ search_term = package_id or node_type
return web.json_response(
{
"node_type": node_type,
+ "package_id": package_id,
"candidates": candidates[:8],
- "github_search_url": f"https://github.com/search?q={quote(f'{node_type} ComfyUI')}&type=repositories",
+ "github_search_url": f"https://github.com/search?q={quote(f'{search_term} ComfyUI')}&type=repositories",
}
)
@@ -385,17 +874,35 @@ async def find_node_candidates(request: web.Request) -> web.Response:
async def install_node_plugin(request: web.Request) -> web.Response:
payload = await request.json()
node_type = str(payload.get("node_type", "")).strip()
+ package_id = str(payload.get("package_id", "")).strip()
plugin_id = str(payload.get("plugin_id", "")).strip()
- if not node_type or not plugin_id:
- raise web.HTTPBadRequest(text="Missing node type or plugin id")
- async with aiohttp.ClientSession(timeout=SOURCE_TIMEOUT, trust_env=True) as session:
- entries, node_map = await asyncio.gather(_te_market_entries(session), _official_node_map(session))
- candidates = market_candidates(entries, node_type, node_map)
- candidate = next((item for item in candidates if item["id"] == plugin_id), None)
+ custom_url = str(payload.get("repo_url", "")).strip().rstrip("/")
+ install_dependencies = bool(payload.get("install_dependencies", True))
+ if (not node_type and not package_id) or (not plugin_id and not custom_url):
+ raise web.HTTPBadRequest(text="Missing node type, package id, or plugin id")
+ if custom_url:
+ if not allowed_repo_url(custom_url):
+ raise web.HTTPBadRequest(text="自定义地址必须是完整的 GitHub 仓库 HTTPS 链接")
+ repo_id = "/".join(urlparse(custom_url).path.strip("/").split("/")[:2])
+ candidate = {
+ "id": repo_id,
+ "title": repo_id,
+ "repo_url": custom_url,
+ "installable": True,
+ "pip": [],
+ "apt_dependency": [],
+ }
+ else:
+ async with aiohttp.ClientSession(timeout=SOURCE_TIMEOUT, trust_env=True) as session:
+ node_map = await _official_node_map(session)
+ candidates = github_fallback_candidates(package_id, node_type, node_map)
+ candidate = next((item for item in candidates if item["id"] == plugin_id), None)
if not candidate:
- raise web.HTTPBadRequest(text="插件不在 TE 官方市场的精确节点匹配结果中")
+ raise web.HTTPBadRequest(text="插件不在工作流 aux_id 或 ComfyUI-Manager 官方映射的可信结果中")
try:
- result = await asyncio.to_thread(install_market_plugin, folder_paths, candidate)
+ result = await asyncio.to_thread(
+ install_market_plugin, folder_paths, candidate, install_dependencies
+ )
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
raise web.HTTPBadRequest(text=str(error)) from error
return web.json_response(result)
@@ -427,14 +934,58 @@ async def find_sources(request: web.Request) -> web.Response:
*(_validate_web_candidate(session, candidate) for candidate in exact_web),
return_exceptions=True,
)
- candidates = [
+ exact_candidates = [
candidate for candidate in [*verified_known, *checked_web, *exact_quark] if isinstance(candidate, dict)
]
- return web.json_response({"name": name, "candidates": candidates[:12]})
+ for candidate in exact_candidates:
+ candidate["exact"] = True
+ candidate["downloadable"] = True
+ provider_rank = {"Hugging Face": 0, "Civitai": 1, "夸克模型库 1": 2, "夸克模型库 2": 3}
+ exact_candidates.sort(
+ key=lambda candidate: (
+ -float(candidate.get("confidence") or 0),
+ provider_rank.get(str(candidate.get("provider") or ""), -1),
+ )
+ )
+ similar_candidates = sorted(
+ [
+ {**candidate, "exact": False, "downloadable": False}
+ for candidate in [*civitai, *huggingface, *quark]
+ if not _exact_model_name(name, candidate.get("name", ""))
+ and float(candidate.get("confidence") or 0) >= 0.62
+ ],
+ key=lambda candidate: (
+ -float(candidate.get("confidence") or 0),
+ provider_rank.get(str(candidate.get("provider") or ""), 9),
+ ),
+ )
+ candidates = [*exact_candidates, *similar_candidates][:12]
+ size = next((_size_value(candidate.get("size")) for candidate in candidates if candidate.get("size")), None)
+ return web.json_response({
+ "name": name,
+ "size": size,
+ "candidates": candidates,
+ "exact_count": len(exact_candidates),
+ "search_urls": {
+ "huggingface": f"https://huggingface.co/models?search={quote(_safe_query(name))}",
+ "civitai": f"https://civitai.com/search/models?sortBy=models_v9&query={quote(_safe_query(name))}",
+ },
+ })
async def _quark_download_url(session: aiohttp.ClientSession, payload: dict[str, Any]) -> str:
share_id = str(payload.get("share_id", ""))
+ filename = basename(str(payload.get("filename", "")))
+ if filename:
+ refreshed = await _quark_candidates(
+ session,
+ filename,
+ "unknown",
+ {"share_id": share_id, "name": "Quark", "url": f"https://pan.quark.cn/s/{share_id}"},
+ )
+ exact = next((item for item in refreshed if _exact_model_name(filename, item.get("name", ""))), None)
+ if exact:
+ payload = exact["quark"]
token_data = await _quark_json(
session,
"POST",
@@ -443,25 +994,63 @@ async def _quark_download_url(session: aiohttp.ClientSession, payload: dict[str,
data={"pwd_id": share_id, "passcode": ""},
)
stoken = ((token_data or {}).get("data") or {}).get("stoken")
+ fid = str(payload.get("fid") or "").strip()
+ fid_token = _quark_token(payload.get("fid_token") or payload.get("fids_token"))
+ if not stoken:
+ raise web.HTTPBadGateway(text="夸克分享 token 获取失败,分享链接可能失效或需要重新登录")
+ if not fid or not fid_token:
+ raise web.HTTPBadGateway(text="夸克文件 token 缺失,正在使用的分享文件信息已经失效")
request_data = {
- "fids": [payload.get("fid")],
+ "fids": [fid],
"pwd_id": share_id,
"stoken": stoken,
- "fids_token": [payload.get("fid_token")],
+ "fids_token": [fid_token],
}
errors = []
- for endpoint in ("/file/download?pr=ucpro&fr=pc", "/file/share/download?pr=ucpro&fr=pc"):
+ for endpoint in (
+ "/file/download?pr=ucpro&fr=pc&uc_param_str=",
+ "/file/share/download?pr=ucpro&fr=pc&uc_param_str=",
+ ):
download = await _quark_json(session, "POST", endpoint, share_id=share_id, data=request_data)
- if download and download.get("message"):
- errors.append(str(download["message"]))
+ if download and (download.get("message") or download.get("code")):
+ errors.append(f"{download.get('code', '')}: {download.get('message', '')}".strip(": "))
entries = (download or {}).get("data") or []
url = entries[0].get("download_url") if entries else None
if _allowed_quark_download_url(url):
return url
detail = errors[0] if errors else "需要登录或文件超过公开下载大小限制"
+ if detail.startswith("31001"):
+ detail = "夸克要求登录。请在设置中保存已登录 pan.quark.cn 的 Cookie 后重试"
+ elif detail.startswith("23018") or "token" in detail.lower():
+ detail = "夸克文件 token 校验失败,插件已重新读取分享目录但服务端仍拒绝"
+ if "download file size limit" in detail.lower():
+ detail = "夸克限制该大文件通过公开分享直链下载,需要在夸克客户端登录并转存后下载"
raise web.HTTPBadGateway(text=f"夸克拒绝直链下载:{detail}")
+async def _direct_web_fallback(session: aiohttp.ClientSession, filename: str) -> dict[str, Any] | None:
+ """Find a verified, exact-name direct URL when a cloud-share download fails."""
+ known = KNOWN_MODEL_SOURCES.get(basename(filename).lower())
+ if known:
+ checked = await _validate_web_candidate(session, dict(known))
+ if checked:
+ return checked
+ civitai, huggingface = await asyncio.gather(
+ _civitai_candidates(session, filename),
+ _huggingface_candidates(session, filename),
+ )
+ exact = [
+ candidate
+ for candidate in [*huggingface, *civitai]
+ if _exact_model_name(filename, candidate.get("name", ""))
+ ]
+ checked = await asyncio.gather(
+ *(_validate_web_candidate(session, candidate) for candidate in exact),
+ return_exceptions=True,
+ )
+ return next((candidate for candidate in checked if isinstance(candidate, dict)), None)
+
+
async def _download_model_payload(payload: dict[str, Any], progress: dict[str, Any] | None = None) -> dict[str, Any]:
url = str(payload.get("url", "")).strip()
quark = payload.get("quark")
@@ -479,43 +1068,78 @@ async def _download_model_payload(payload: dict[str, Any], progress: dict[str, A
raise web.HTTPConflict(text=f"Model already exists: {target}")
temp_path: Path | None = None
+ persistent_partial = progress is not None
try:
if progress is not None:
+ temp_path = Path(progress.get("temp_path") or (target_dir / f".{filename}.{progress['id']}.part"))
+ progress["temp_path"] = str(temp_path)
+ downloaded = temp_path.stat().st_size if temp_path.exists() else 0
progress.update(
status="downloading",
filename=filename,
category=category,
- downloaded=0,
- total=expected_size,
+ downloaded=downloaded,
+ total=progress.get("total") or expected_size,
+ error=None,
updated_at=time.time(),
)
- fd, temp_name = tempfile.mkstemp(prefix=f".{filename}.", suffix=".part", dir=target_dir)
- os.close(fd)
- temp_path = Path(temp_name)
+ else:
+ fd, temp_name = tempfile.mkstemp(prefix=f".{filename}.", suffix=".part", dir=target_dir)
+ os.close(fd)
+ temp_path = Path(temp_name)
+ downloaded = 0
async with aiohttp.ClientSession(timeout=DOWNLOAD_TIMEOUT, trust_env=True) as session:
if isinstance(quark, dict):
- url = await _quark_download_url(session, quark)
+ quark.setdefault("filename", filename)
+ try:
+ url = await _quark_download_url(session, quark)
+ except web.HTTPException as quark_error:
+ fallback = await _direct_web_fallback(session, filename)
+ if not fallback:
+ raise web.HTTPBadGateway(
+ text=f"{quark_error.text};未找到文件名完全一致且可验证的其他直链"
+ ) from quark_error
+ url = str(fallback["url"])
+ expected_size = expected_size or _size_value(fallback.get("size"))
+ if progress is not None:
+ progress.update(provider=fallback.get("provider"), fallback_from="Quark", updated_at=time.time())
+ headers = {"User-Agent": "Mozilla/5.0 ComfyUI_FindModels/1.14.1", "Referer": url}
+ if isinstance(quark, dict):
+ headers.update({
+ "Origin": "https://pan.quark.cn",
+ "Referer": f"https://pan.quark.cn/s/{quark.get('share_id', '')}",
+ "User-Agent": QUARK_USER_AGENT,
+ })
+ if _load_quark_cookie():
+ headers["Cookie"] = _load_quark_cookie()
+ if downloaded:
+ headers["Range"] = f"bytes={downloaded}-"
async with session.get(
url,
- headers={"User-Agent": "Mozilla/5.0 ComfyUI_FindModels/1.4.0", "Referer": url},
+ headers=headers,
allow_redirects=True,
) as response:
allowed = _allowed_download_url(str(response.url)) or _allowed_quark_download_url(str(response.url))
- if response.status != 200 or not allowed:
+ if response.status not in {200, 206} or not allowed:
raise web.HTTPBadGateway(text=f"Download failed with HTTP {response.status}")
content_type = response.headers.get("Content-Type", "").lower()
if "text/html" in content_type or "application/json" in content_type:
raise web.HTTPBadGateway(text="下载地址返回了网页或错误信息,而不是模型文件")
+ if downloaded and response.status != 206:
+ downloaded = 0
response_size = _size_value(
response.headers.get("X-Linked-Size")
or response.headers.get("X-File-Size")
or response.headers.get("Content-Length")
)
- total = expected_size or response_size
- downloaded = 0
+ total = (
+ expected_size
+ or _content_range_total(response.headers.get("Content-Range"))
+ or ((downloaded + response_size) if downloaded and response_size else response_size)
+ )
if progress is not None:
progress.update(total=total, updated_at=time.time())
- with temp_path.open("wb") as output:
+ with temp_path.open("ab" if downloaded else "wb") as output:
async for chunk in response.content.iter_chunked(1024 * 1024):
output.write(chunk)
downloaded += len(chunk)
@@ -534,7 +1158,7 @@ async def _download_model_payload(payload: dict[str, Any], progress: dict[str, A
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as error:
raise web.HTTPBadGateway(text=f"Download failed: {error}") from error
finally:
- if temp_path and temp_path.exists():
+ if temp_path and temp_path.exists() and not persistent_partial:
temp_path.unlink()
return {
@@ -550,8 +1174,12 @@ async def _download_model_payload(payload: dict[str, Any], progress: dict[str, A
async def _run_download_job(job_id: str, payload: dict[str, Any]) -> None:
progress = DOWNLOAD_JOBS[job_id]
try:
+ progress.setdefault("started_at", time.time())
result = await _download_model_payload(payload, progress)
progress.update(status="completed", result=result, downloaded=result["size"], total=result["size"])
+ except asyncio.CancelledError:
+ if progress.get("status") != "cancelled":
+ progress.update(status="paused")
except Exception as error:
progress.update(status="failed", error=getattr(error, "text", None) or str(error))
finally:
@@ -562,10 +1190,34 @@ async def _run_download_job(job_id: str, payload: dict[str, Any]) -> None:
def _purge_download_jobs() -> None:
cutoff = time.time() - 3600
for job_id, job in list(DOWNLOAD_JOBS.items()):
- if job.get("status") in {"completed", "failed"} and job.get("updated_at", 0) < cutoff:
+ if job.get("status") in {"completed", "failed", "cancelled"} and job.get("updated_at", 0) < cutoff:
DOWNLOAD_JOBS.pop(job_id, None)
+async def _cancel_download_task(job_id: str) -> None:
+ task = DOWNLOAD_TASKS.get(job_id)
+ if task and not task.done():
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+
+
+def _public_download_job(job: dict[str, Any]) -> dict[str, Any]:
+ public = {key: value for key, value in job.items() if key not in {"payload", "temp_path"}}
+ started_at = _size_value(job.get("started_at"))
+ downloaded = _size_value(job.get("downloaded")) or 0
+ total = _size_value(job.get("total"))
+ if started_at:
+ elapsed = max(0.0, time.time() - started_at)
+ speed = downloaded / elapsed if elapsed > 0 and downloaded > 0 else 0
+ public["elapsed"] = round(elapsed, 1)
+ public["speed"] = round(speed, 1)
+ public["eta"] = round((total - downloaded) / speed, 1) if total and speed > 0 and total > downloaded else None
+ return public
+
+
@PromptServer.instance.routes.post("/findmodels/download/start")
async def start_download_model(request: web.Request) -> web.Response:
payload = await request.json()
@@ -582,12 +1234,12 @@ async def start_download_model(request: web.Request) -> web.Response:
for job in DOWNLOAD_JOBS.values()
if job.get("filename", "").lower() == filename.lower()
and job.get("category") == category
- and job.get("status") in {"queued", "downloading"}
+ and job.get("status") in {"queued", "downloading", "paused", "failed"}
),
None,
)
if existing:
- return web.json_response(existing)
+ return web.json_response(_public_download_job(existing))
job_id = uuid.uuid4().hex
DOWNLOAD_JOBS[job_id] = {
"id": job_id,
@@ -601,16 +1253,17 @@ async def start_download_model(request: web.Request) -> web.Response:
"total": _size_value(payload.get("size")),
"created_at": time.time(),
"updated_at": time.time(),
+ "payload": dict(payload),
}
DOWNLOAD_TASKS[job_id] = asyncio.create_task(_run_download_job(job_id, payload))
- return web.json_response(DOWNLOAD_JOBS[job_id])
+ return web.json_response(_public_download_job(DOWNLOAD_JOBS[job_id]))
@PromptServer.instance.routes.get("/findmodels/download/jobs")
async def download_model_jobs(request: web.Request) -> web.Response:
_purge_download_jobs()
jobs = sorted(DOWNLOAD_JOBS.values(), key=lambda job: job.get("created_at", 0), reverse=True)
- return web.json_response({"jobs": jobs})
+ return web.json_response({"jobs": [_public_download_job(job) for job in jobs]})
@PromptServer.instance.routes.post("/findmodels/download/progress")
@@ -620,7 +1273,56 @@ async def download_model_progress(request: web.Request) -> web.Response:
job = DOWNLOAD_JOBS.get(job_id)
if not job:
raise web.HTTPNotFound(text="Download task not found")
- return web.json_response(job)
+ return web.json_response(_public_download_job(job))
+
+
+@PromptServer.instance.routes.post("/findmodels/download/pause")
+async def pause_download_model(request: web.Request) -> web.Response:
+ job_id = str((await request.json()).get("job_id", "")).strip()
+ job = DOWNLOAD_JOBS.get(job_id)
+ if not job:
+ raise web.HTTPNotFound(text="Download task not found")
+ if job.get("status") not in {"queued", "downloading"}:
+ raise web.HTTPConflict(text="Only an active download can be paused")
+ job.update(status="pausing", updated_at=time.time())
+ await _cancel_download_task(job_id)
+ job.update(status="paused", updated_at=time.time())
+ return web.json_response(_public_download_job(job))
+
+
+@PromptServer.instance.routes.post("/findmodels/download/resume")
+async def resume_download_model(request: web.Request) -> web.Response:
+ job_id = str((await request.json()).get("job_id", "")).strip()
+ job = DOWNLOAD_JOBS.get(job_id)
+ if not job:
+ raise web.HTTPNotFound(text="Download task not found")
+ if job.get("status") not in {"paused", "failed"}:
+ raise web.HTTPConflict(text="Only a paused or failed download can be resumed")
+ payload = job.get("payload")
+ if not isinstance(payload, dict):
+ raise web.HTTPConflict(text="Download task cannot be resumed")
+ job.update(status="queued", error=None, updated_at=time.time())
+ DOWNLOAD_TASKS[job_id] = asyncio.create_task(_run_download_job(job_id, payload))
+ return web.json_response(_public_download_job(job))
+
+
+@PromptServer.instance.routes.post("/findmodels/download/cancel")
+async def cancel_download_model(request: web.Request) -> web.Response:
+ job_id = str((await request.json()).get("job_id", "")).strip()
+ job = DOWNLOAD_JOBS.get(job_id)
+ if not job:
+ raise web.HTTPNotFound(text="Download task not found")
+ if job.get("status") == "completed":
+ raise web.HTTPConflict(text="A completed download cannot be cancelled")
+ job.update(status="cancelled", updated_at=time.time())
+ await _cancel_download_task(job_id)
+ temp_value = str(job.get("temp_path", "")).strip()
+ if temp_value:
+ temp_path = Path(temp_value)
+ if temp_path.is_file():
+ temp_path.unlink()
+ job.update(downloaded=0, updated_at=time.time())
+ return web.json_response(_public_download_job(job))
@PromptServer.instance.routes.post("/findmodels/download")
diff --git a/model_finder.py b/model_finder.py
index ee8eb4a..6163dec 100644
--- a/model_finder.py
+++ b/model_finder.py
@@ -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": {
diff --git a/node_installer.py b/node_installer.py
index ff989e6..43e0464 100644
--- a/node_installer.py
+++ b/node_installer.py
@@ -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),
diff --git a/pyproject.toml b/pyproject.toml
index cafae6d..47026ad 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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"
diff --git a/tests/test_download_helpers.py b/tests/test_download_helpers.py
index 939ca7f..02dfa55 100644
--- a/tests/test_download_helpers.py
+++ b/tests/test_download_helpers.py
@@ -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()
diff --git a/tests/test_model_finder.py b/tests/test_model_finder.py
index 402bc2f..a29f0a8 100644
--- a/tests/test_model_finder.py
+++ b/tests/test_model_finder.py
@@ -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__":
diff --git a/tests/test_node_installer.py b/tests/test_node_installer.py
index 990a7cd..8852fba 100644
--- a/tests/test_node_installer.py
+++ b/tests/test_node_installer.py
@@ -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"))
diff --git a/web/find_models.css b/web/find_models.css
index d6470d3..5630ad1 100644
--- a/web/find_models.css
+++ b/web/find_models.css
@@ -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; }
diff --git a/web/find_models.js b/web/find_models.js
index e1b47f5..ff41957 100644
--- a/web/find_models.js
+++ b/web/find_models.js
@@ -5,7 +5,28 @@ const EXTENSION = "ComfyUI.FindModels";
let lastResult = null;
let toolbarObserver = null;
let downloadMonitor = null;
+let workflowMonitor = null;
+let activeTab = "models";
+let panelUserOpened = false;
+let lastWorkflowSignature = "";
+let scanRequestId = 0;
+let scanTimer = null;
+let enrichTimer = null;
+let observedWorkflowSignature = "";
+let nativeDockState = null;
+const resolvedNodePackages = new Set();
+const nodeActivities = new Map();
const downloadStatuses = new Map();
+const confirmedModelSelections = new Map();
+const resolvedModels = new Set();
+
+function normalizedModelValue(value) {
+ return typeof value === "string" ? value.replaceAll("\\", "/").replace(/^\/+/, "") : "";
+}
+
+function modelSelectionKey(nodeId, widgetName) {
+ return `${String(nodeId)}:${String(widgetName || "")}`;
+}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
@@ -13,6 +34,21 @@ function escapeHtml(value) {
})[char]);
}
+async function copyText(value) {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(String(value));
+ return;
+ }
+ const input = document.createElement("textarea");
+ input.value = String(value);
+ input.style.position = "fixed";
+ input.style.opacity = "0";
+ document.body.appendChild(input);
+ input.select();
+ document.execCommand("copy");
+ input.remove();
+}
+
function formatSize(value) {
const size = Number(value);
if (!Number.isFinite(size) || size <= 0) return "大小未知";
@@ -31,9 +67,66 @@ function statusText(status) {
function downloadProgressText(job) {
const downloaded = formatSize(job.downloaded);
const total = Number(job.total);
- if (!Number.isFinite(total) || total <= 0) return `已下载 ${downloaded}`;
+ const details = [
+ job.speed ? `速度 ${formatSize(job.speed)}/s` : "",
+ job.elapsed ? `已耗时 ${formatDuration(job.elapsed)}` : "",
+ job.eta ? `预计剩余 ${formatDuration(job.eta)}` : "",
+ ].filter(Boolean).join(" · ");
+ if (!Number.isFinite(total) || total <= 0) return `已下载 ${downloaded}${details ? ` · ${details}` : ""}`;
const percent = Math.min(100, Math.round((Number(job.downloaded || 0) / total) * 100));
- return `已下载 ${downloaded} / ${formatSize(total)} · ${percent}%`;
+ return `已下载 ${downloaded} / ${formatSize(total)} · ${percent}%${details ? ` · ${details}` : ""}`;
+}
+
+function formatDuration(value) {
+ const seconds = Math.max(0, Math.round(Number(value) || 0));
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const rest = seconds % 60;
+ return hours ? `${hours}小时${minutes}分` : minutes ? `${minutes}分${rest}秒` : `${rest}秒`;
+}
+
+function downloadStatusText(job) {
+ if (job.status === "failed") return `失败:${job.error || "未知错误"}`;
+ if (job.status === "paused") return `已暂停 · ${downloadProgressText(job)}`;
+ if (job.status === "pausing") return `正在暂停 · ${downloadProgressText(job)}`;
+ if (job.status === "cancelled") return "已取消";
+ if (job.status === "completed") return `下载完成 · ${formatSize(job.total)}`;
+ if (job.status === "queued") return `等待下载 · ${downloadProgressText(job)}`;
+ return downloadProgressText(job);
+}
+
+function downloadControlsHtml(job) {
+ const pause = ["queued", "downloading"].includes(job.status)
+ ? `` : "";
+ const resume = ["paused", "failed"].includes(job.status)
+ ? `` : "";
+ const cancel = !["completed", "cancelled"].includes(job.status)
+ ? `` : "";
+ return pause + resume + cancel;
+}
+
+function isExplicitModelWidget(name) {
+ const normalized = String(name || "").toLowerCase().replace(/[^a-z0-9_]+/g, "");
+ const exact = new Set([
+ "ckpt_name", "checkpoint_name", "lora_name", "vae_name", "control_net_name",
+ "controlnet_name", "clip_name", "clip_vision", "clip_vision_name", "text_encoder_name",
+ "unet_name", "diffusion_model", "diffusion_model_name", "upscale_model",
+ "upscale_model_name", "embedding_name",
+ ]);
+ return exact.has(normalized)
+ || /^(?:lora|lycoris)_?\d+$/.test(normalized)
+ || /^(?:ckpt|checkpoint|vae|controlnet|control_net|clip|clip_vision|text_encoder|unet|diffusion_model|upscale_model)_?\d+$/.test(normalized)
+ || /^(?:model|model_name)_\d+$/.test(normalized);
+}
+
+function isModelNodeType(type) {
+ const normalized = String(type || "").toLowerCase().replace(/[^a-z0-9_]+/g, "");
+ return [
+ "loader", "model", "instantid", "instant_id", "ipadapter", "ip_adapter",
+ "samloader", "sam_loader", "ultralytics", "detector",
+ "checkpoint", "lora", "vae", "controlnet", "clip", "textencode",
+ "text_encoder", "unet", "diffusion", "upscale", "embedding",
+ ].some((hint) => normalized.includes(hint));
}
function workflowSnapshot() {
@@ -43,20 +136,36 @@ function workflowSnapshot() {
} catch (error) {
console.debug("[ComfyUI_FindModels] Unable to serialize graph", error);
}
- return {
- models: serializedGraph?.models || [],
- nodes: app.graph?._nodes?.map((node) => {
+ const nodes = app.graph?._nodes?.map((node) => {
let serialized = null;
try {
serialized = node.serialize?.();
} catch (error) {
console.debug("[ComfyUI_FindModels] Unable to serialize node", node.type, error);
}
+ const widgetsValues = serialized?.widgets_values || [];
+ const selectedValues = new Set([
+ ...(Array.isArray(widgetsValues) ? widgetsValues : Object.values(widgetsValues)),
+ ...(node.widgets || []).map((widget) => widget.value),
+ ].filter((value) => typeof value === "string"));
+ const properties = serialized?.properties || node.properties || {};
+ const packageId = [properties.aux_id, properties.cnr_id, properties.package_id]
+ .find((value) => typeof value === "string" && value.trim());
+ const packageVersion = [properties.ver, properties.version]
+ .find((value) => typeof value === "string" && value.trim());
return {
id: node.id,
type: node.type,
- models: node.properties?.models || [],
- widgets_values: serialized?.widgets_values || [],
+ package_id: packageId || null,
+ package_version: packageVersion || null,
+ active: ![2, 4].includes(node.mode),
+ frontend_registered: Boolean(
+ globalThis.LiteGraph?.registered_node_types?.[node.type]
+ || node.constructor?.type === node.type
+ || node.constructor?.nodeData?.name === node.type
+ ),
+ models: (node.properties?.models || []).filter((model) => selectedValues.has(model.name)),
+ widgets_values: widgetsValues,
widgets: (node.widgets || []).flatMap((widget) => {
let rawValues = widget.options?.values;
if (typeof rawValues === "function") {
@@ -67,30 +176,98 @@ function workflowSnapshot() {
}
}
const values = Array.isArray(rawValues) ? rawValues : [];
- const isModelSelector = values.some((value) =>
- typeof value === "string" && /\.(bin|ckpt|gguf|onnx|pt|pth|safetensors)$/i.test(value),
+ const hasModelOptions = values.some((value) =>
+ typeof value === "string" && /\.(bin|ckpt|gguf|onnx|pt|pth|safetensors|sft)$/i.test(value)
);
- const normalizedValue = typeof widget.value === "string"
- ? widget.value.replaceAll("\\", "/")
- : null;
- const modelValueValid = isModelSelector && normalizedValue !== null
+ const isAsset = widget.type === "asset";
+ const isModelSelector = isAsset || hasModelOptions || isExplicitModelWidget(widget.name)
+ || (isModelNodeType(node.type) && typeof widget.value === "string"
+ && /\.(bin|ckpt|gguf|onnx|pt|pt2|pth|pkl|safetensors|sft)$/i.test(widget.value));
+ const normalizedValue = normalizedModelValue(widget.value) || null;
+ const selectionKey = modelSelectionKey(node.id, widget.name);
+ const confirmedValue = confirmedModelSelections.get(selectionKey);
+ if (confirmedValue && confirmedValue !== normalizedValue) {
+ confirmedModelSelections.delete(selectionKey);
+ }
+ const modelValueValid = confirmedValue === normalizedValue && normalizedValue !== null
+ ? true
+ : hasModelOptions && normalizedValue !== null
? values.some((value) =>
- typeof value === "string" && value.replaceAll("\\", "/") === normalizedValue
+ typeof value === "string" && normalizedModelValue(value) === normalizedValue
)
: null;
+ const modelMetadata = (node.properties?.models || []).find((model) =>
+ typeof widget.value === "string" && model.name === widget.value
+ );
return [{
name: widget.name,
type: widget.type,
value: widget.value,
model_selector: isModelSelector,
model_value_valid: modelValueValid,
+ asset_selector: isAsset,
+ directory: modelMetadata?.directory,
+ source_url: modelMetadata?.url,
+ source_hash: modelMetadata?.hash,
+ source_hash_type: modelMetadata?.hash_type,
+ source_size: modelMetadata?.size,
}];
}),
};
- }) || [],
+ }) || [];
+ const referencedNames = new Set(nodes.filter((node) => node.active).flatMap((node) => [
+ ...(Array.isArray(node.widgets_values) ? node.widgets_values : Object.values(node.widgets_values || {})),
+ ...node.models.map((model) => model.name),
+ ]).filter((value) => typeof value === "string"));
+ return {
+ models: (serializedGraph?.models || []).filter((model) => referencedNames.has(model.name)),
+ nodes,
};
}
+function workflowSignature(snapshot) {
+ return JSON.stringify((snapshot.nodes || []).map((node) => [
+ node.id,
+ node.type,
+ node.active,
+ node.widgets_values,
+ ]));
+}
+
+function removeResolvedModel(name) {
+ if (!lastResult?.models) return;
+ const normalized = normalizedModelValue(name);
+ resolvedModels.add(normalized);
+ lastResult.models = lastResult.models.filter((model) => normalizedModelValue(model.name) !== normalized);
+ lastResult.summary = { ...lastResult.summary, unresolved: lastResult.models.length };
+ render(lastResult, true);
+ observedWorkflowSignature = workflowSignature(workflowSnapshot());
+}
+
+function scheduleScan(delay = 0, quick = true) {
+ window.clearTimeout(scanTimer);
+ scanTimer = window.setTimeout(() => scan(true, { quick }), delay);
+}
+
+function scheduleEnrichedScan(delay = 180) {
+ window.clearTimeout(enrichTimer);
+ enrichTimer = window.setTimeout(() => scan(true, { quick: false }), delay);
+}
+
+function startWorkflowMonitor() {
+ window.clearInterval(workflowMonitor);
+ observedWorkflowSignature = workflowSignature(workflowSnapshot());
+ workflowMonitor = window.setInterval(() => {
+ const signature = workflowSignature(workflowSnapshot());
+ if (signature === observedWorkflowSignature) return;
+ observedWorkflowSignature = signature;
+ resolvedModels.clear();
+ resolvedNodePackages.clear();
+ scanRequestId += 1;
+ scheduleScan(0, true);
+ }, 250);
+}
+
async function post(path, body) {
const response = await api.fetchApi(path, {
method: "POST",
@@ -107,13 +284,22 @@ async function get(path) {
return response.json();
}
-function downloadJobsHtml(jobs) {
- if (!jobs.length) return "";
- return `
当前工作流中未发现缺失节点或模型。
"; +