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 `
模型下载任务${jobs.map((job) => ` +function downloadJobsHtml(jobs, nodeJobs = [...nodeActivities.values()]) { + if (!jobs.length && !nodeJobs.length) { + return '
暂无下载任务模型下载与节点安装进度会统一显示在这里
'; + } + const modelJobs = jobs.map((job) => `
- ${escapeHtml(job.filename)} - ${escapeHtml(job.status === "failed" ? `失败:${job.error || "未知错误"}` : downloadProgressText(job))} -
`).join("")}
`; + 模型 +
${escapeHtml(job.filename)}${escapeHtml(downloadStatusText(job))}
+
${downloadControlsHtml(job)}
+ `).join(""); + const pluginJobs = nodeJobs.map((job) => ` +
+ 节点 +
${escapeHtml(job.title)}${escapeHtml(job.message)}
+
`).join(""); + return `
${modelJobs}${pluginJobs}
`; } async function refreshDownloadJobs() { @@ -121,17 +307,27 @@ async function refreshDownloadJobs() { try { const { jobs = [] } = await get("/findmodels/download/jobs"); panel.querySelector(".fm-downloads").innerHTML = downloadJobsHtml(jobs); + panel.querySelector('[data-tab-count="downloads"]').textContent = + jobs.filter((job) => !["completed", "cancelled"].includes(job.status)).length + + [...nodeActivities.values()].filter((job) => job.status === "downloading").length; + const sectionCount = panel.querySelector('[data-section-count="downloads"]'); + if (sectionCount) sectionCount.textContent = + jobs.filter((job) => !["completed", "cancelled"].includes(job.status)).length + + [...nodeActivities.values()].filter((job) => job.status === "downloading").length; for (const job of jobs) { const previous = downloadStatuses.get(job.id); downloadStatuses.set(job.id, job.status); if (job.status === "completed" && previous && previous !== "completed") { - await applyMatch({ + const model = lastResult?.models?.find((item) => item.name === job.original); + await applyMatchEverywhere(model || { node_id: job.node_id, widget: job.widget, name: job.original, match: { name: job.result?.relative_name }, }); - window.setTimeout(() => scan(true), 300); + removeResolvedModel(job.original); + scheduleScan(0, true); + scheduleEnrichedScan(); } } } catch (error) { @@ -162,6 +358,9 @@ async function applyMatch(model) { const previous = widget.value; try { node.graph?.beforeChange?.(node); + if (Array.isArray(widget.options?.values) && !widget.options.values.includes(model.match.name)) { + widget.options.values.push(model.match.name); + } widget.value = model.match.name; if (typeof widget.callback === "function") { await Promise.resolve(widget.callback(widget.value, app.canvas, node, [0, 0], {})); @@ -171,7 +370,14 @@ async function applyMatch(model) { node.setDirtyCanvas?.(true, true); app.graph?.setDirtyCanvas?.(true, true); app.canvas?.setDirty?.(true, true); - return widget.value === model.match.name; + const applied = normalizedModelValue(widget.value) === normalizedModelValue(model.match.name); + if (applied) { + confirmedModelSelections.set( + modelSelectionKey(node.id, widget.name), + normalizedModelValue(widget.value), + ); + } + return applied; } catch (error) { widget.value = previous; node.graph?.afterChange?.(node); @@ -180,30 +386,212 @@ async function applyMatch(model) { } } +async function applyMatchEverywhere(model) { + const references = model.referencing_nodes?.length + ? model.referencing_nodes + : [{ node_id: model.node_id, widget: model.widget, node_type: model.node_type }]; + let applied = 0; + for (const reference of references) { + if (await applyMatch({ ...model, ...reference })) applied += 1; + } + return applied === references.length; +} + +function locateNode(nodeId) { + const numericId = Number(nodeId); + const node = app.graph?.getNodeById?.(Number.isNaN(numericId) ? nodeId : numericId) + || app.graph?._nodes?.find((item) => String(item.id) === String(nodeId)); + if (!node) return false; + app.canvas?.selectNode?.(node); + app.canvas?.centerOnNode?.(node); + node.setDirtyCanvas?.(true, true); + return true; +} + function sourceHtml(item, model) { const quark = item.quark ? escapeHtml(JSON.stringify(item.quark)) : ""; + const downloadButton = item.downloadable !== false + ? `` + : '相似候选,请打开核对'; return `
${item.url ? `` : ""} ${escapeHtml(item.provider)} · ${escapeHtml(item.name)} · ${formatSize(item.size)} · ${Math.round(item.confidence * 100)}% ${item.url ? "" : ""} - + ${downloadButton}
`; } -function nodeCandidatesHtml(nodeType, candidates) { +function officialSourceHtml(model) { + if (!model.source_url) return ""; + return sourceHtml({ + provider: "工作流内嵌来源", + name: model.name, + url: model.source_url, + size: model.size, + confidence: 1, + }, model); +} + +function externalCandidatesHtml(model) { + const candidates = model.external_candidates || []; + if (!candidates.length) return ""; + return `
${candidates.map((item) => ` +
+ 外部模型库 · ${escapeHtml(item.path)} · ${formatSize(item.size)} + ${model.category === "unknown" ? '工作流未提供模型目录,不能自动剪切' : ``} +
`).join("")}
`; +} + +function referenceText(model) { + const count = model.referencing_nodes?.length || (model.node_id ? 1 : 0); + return count ? `${count} 个引用节点` : "工作流模型元数据"; +} + +function modelCardHtml(model) { + try { + const confidence = Number(model.match?.confidence); + return ` +
+
${model.status === "missing" ? "!" : "↻"}${escapeHtml(model.name)}
+
${escapeHtml(model.category || "unknown")}${escapeHtml(model.official_missing ? "工作流总览判定缺失" : statusText(model.status))}${referenceText(model)}大小:${formatSize(model.size)}
+ ${model.match ? `
本地精确候选:${escapeHtml(model.match.name)} (${Number.isFinite(confidence) ? Math.round(confidence * 100) : 0}%)
` : ""} + ${externalCandidatesHtml(model)} +
${officialSourceHtml(model)}
+
+ ${model.node_id ? `` : ""} + ${model.match?.auto_apply ? `` : ''} + +
+
`; + } catch (error) { + console.error("[ComfyUI_FindModels] Failed to render missing model card", model, error); + return ` +
+
!${escapeHtml(model?.name || "未知缺失模型")}
+
${escapeHtml(model?.category || "unknown")}模型详情渲染异常,但模型仍然缺失
+
`; + } +} + +function nodeCandidatesHtml(nodeType, candidates, githubSearchUrl = "", packageId = "") { const links = candidates.map((item) => `
${escapeHtml(item.title)} · ${escapeHtml(item.author)} · ${Math.round(item.confidence * 100)}% - +
`).join(""); - return links || "TE 官方市场没有精确匹配。"; + const searchTerm = packageId || nodeType; + const searchUrl = githubSearchUrl || `https://github.com/search?q=${encodeURIComponent(`${searchTerm} ComfyUI`)}&type=repositories`; + return `${links || "官方映射暂未找到精确插件,可使用工作流提供的 GitHub 仓库或自定义链接。"} +
+ + GitHub 搜索 +
`; +} + +function setActiveTab(panel, tab) { + activeTab = tab; + panel.querySelectorAll("[data-tab]").forEach((button) => + button.classList.toggle("active", button.dataset.tab === tab) + ); + panel.querySelectorAll("[data-tab-panel]").forEach((content) => + content.classList.toggle("active", content.dataset.tabPanel === tab) + ); + panel.scrollTop = 0; +} + +function installDependenciesEnabled(panel) { + return panel.querySelector("[data-install-dependencies]")?.checked !== false; +} + +async function waitForPropertiesPanel() { + for (let attempt = 0; attempt < 40; attempt += 1) { + const properties = document.querySelector("[data-testid='properties-panel']"); + if (properties) return properties; + await new Promise((resolve) => window.setTimeout(resolve, 25)); + } + return null; +} + +async function openDockedPanel(panel) { + if (nativeDockState && panel.classList.contains("native-docked")) return true; + let properties = document.querySelector("[data-testid='properties-panel']"); + const wasOpen = Boolean(properties); + let openedByUs = false; + if (!properties) { + const toggle = findPropertyPanelButton(); + if (toggle) { + toggle.click(); + openedByUs = true; + properties = await waitForPropertiesPanel(); + } + } + if (!properties?.parentElement) { + panel.classList.remove("native-docked"); + panel.classList.add("open"); + return false; + } + const host = properties.parentElement; + nativeDockState = { + properties, + host, + openedByUs, + wasOpen, + previousDisplay: properties.style.display, + }; + properties.style.display = "none"; + properties.setAttribute("aria-hidden", "true"); + host.appendChild(panel); + panel.classList.add("native-docked", "open"); + return true; +} + +function closeDockedPanel(panel) { + panel.classList.remove("open", "native-docked"); + document.body.appendChild(panel); + const state = nativeDockState; + nativeDockState = null; + if (!state) return; + state.properties.style.display = state.previousDisplay; + state.properties.removeAttribute("aria-hidden"); + if (state.openedByUs && !state.wasOpen) { + window.requestAnimationFrame(() => findPropertyPanelButton()?.click()); + } +} + +function enablePanelResize(panel) { + const handle = panel.querySelector(".fm-resize-handle"); + if (!handle || handle.dataset.ready) return; + handle.dataset.ready = "true"; + handle.addEventListener("pointerdown", (event) => { + event.preventDefault(); + handle.setPointerCapture(event.pointerId); + const startX = event.clientX; + const startWidth = panel.getBoundingClientRect().width; + const move = (current) => { + if (panel.classList.contains("native-docked")) return; + const width = Math.max(420, Math.min(window.innerWidth - 24, startWidth + startX - current.clientX)); + panel.style.width = `${width}px`; + localStorage.setItem("findmodels.panelWidth", String(width)); + }; + const up = () => { + handle.removeEventListener("pointermove", move); + handle.removeEventListener("pointerup", up); + }; + handle.addEventListener("pointermove", move); + handle.addEventListener("pointerup", up); + }); } function ensurePanel() { @@ -212,15 +600,193 @@ function ensurePanel() { panel = document.createElement("section"); panel.id = "find-models-panel"; panel.innerHTML = ` -
查找模型
-
- - +
+
+
查找缺失模型和节点当前工作流缺失依赖管理
+
-
尚未扫描
`; +
等待扫描当前工作流
+ +
尚未扫描
+
+
缺失模型 0仅显示尚未加载的模型,解决后立即移出
+
+
+
+
缺失节点 0仅显示当前工作流尚未安装的节点包
+
+ +
+
+
+
+
+
下载任务 0模型下载与节点安装任务统一显示
+
+
+
+
设置配置外部模型库与夸克网盘下载能力
+
外部模型库指定本机外部模型目录,只精确匹配当前工作流缺失的模型文件名。 +
+ + +
+
+
`; document.body.appendChild(panel); - panel.querySelector('[data-action="close"]').onclick = () => panel.classList.remove("open"); - panel.querySelector('[data-action="scan"]').onclick = () => scan(false); + const savedWidth = Number(localStorage.getItem("findmodels.panelWidth")); + if (Number.isFinite(savedWidth) && savedWidth >= 420) panel.style.width = `${savedWidth}px`; + enablePanelResize(panel); + panel.querySelector('[data-tab-panel="settings"]')?.insertAdjacentHTML("beforeend", ` +
夸克链接查找下载来源时后台精确搜索以下模型库;公开分享大文件受限时会优先回退到可验证的快速直链。 + +
+ + + +
+ 正在检查夸克登录态... +
`); + get("/findmodels/quark-auth").then((result) => { + const status = panel.querySelector("[data-quark-auth-status]"); + if (status) status.textContent = result.configured ? "已保存夸克登录态,可重试直链下载。" : "未保存夸克登录态,公开分享大文件可能被夸克限制。"; + }).catch(() => {}); + panel.querySelectorAll("[data-tab]").forEach((button) => { + button.onclick = () => setActiveTab(panel, button.dataset.tab); + }); + setActiveTab(panel, activeTab); + panel.querySelector('[data-action="close"]').onclick = () => { + panelUserOpened = false; + closeDockedPanel(panel); + }; + panel.querySelectorAll('[data-action="scan"]').forEach((button) => { + button.onclick = () => scheduleScan(0, true); + }); + panel.querySelector('[data-action="refresh-downloads"]').onclick = () => refreshDownloadJobs(); + panel.querySelector('[data-action="install-missing-nodes"]').onclick = async () => { + const button = panel.querySelector('[data-action="install-missing-nodes"]'); + button.disabled = true; + let installed = 0; + const failures = []; + const packages = lastResult?.missing_node_packages || []; + for (const nodePackage of packages) { + const nodeType = nodePackage.node_types?.[0] || ""; + const packageId = nodePackage.known ? nodePackage.id : ""; + const activityId = packageId || nodeType; + nodeActivities.set(activityId, { title: nodePackage.title, status: "downloading", message: "正在查找可信插件来源…" }); + await refreshDownloadJobs(); + button.textContent = `正在查找 ${nodePackage.title}…`; + try { + const found = await post("/findnodes/candidates", { node_type: nodeType, package_id: packageId }); + const candidate = (found.candidates || []).find((item) => [ + "workflow_package_id", + "workflow_package_github", + "official_node_mapping", + "comfy_manager_node_mapping", + "comfy_manager_github", + ].includes(item.reason)); + if (!candidate) throw new Error("没有找到由工作流 aux_id 或 ComfyUI-Manager 官方映射确认的 GitHub 仓库"); + button.textContent = `正在安装 ${nodePackage.title}…`; + nodeActivities.set(activityId, { title: nodePackage.title, status: "downloading", message: "正在检查依赖并安装…" }); + await refreshDownloadJobs(); + await post("/findnodes/install", { + node_type: nodeType, + package_id: packageId, + plugin_id: candidate.id, + install_dependencies: installDependenciesEnabled(panel), + }); + resolvedNodePackages.add(activityId); + nodeActivities.set(activityId, { title: nodePackage.title, status: "completed", message: "安装完成,重启 ComfyUI 后生效" }); + installed += 1; + } catch (error) { + nodeActivities.set(activityId, { title: nodePackage.title, status: "failed", message: `安装失败:${error.message}` }); + failures.push(`${nodePackage.title}: ${error.message}`); + } + await refreshDownloadJobs(); + } + button.disabled = false; + button.textContent = "安装缺失节点"; + panel.querySelector(".fm-summary").textContent = + `已安装或更新 ${installed} 个精确匹配插件。${failures.length ? `失败 ${failures.length} 个:${failures.join(";")}` : "请重启 ComfyUI。"}` + if (installed) { + render(lastResult, true); + scheduleScan(0, true); + } + }; + panel.querySelector('[data-action="select-external-folder"]').onclick = async () => { + const button = panel.querySelector('[data-action="select-external-folder"]'); + const input = panel.querySelector("[data-external-folder]"); + button.disabled = true; + button.textContent = "等待选择…"; + try { + const result = await post("/findmodels/external-folder/select", {}); + if (!result.cancelled) { + input.value = result.path; + scheduleScan(0, true); + scheduleEnrichedScan(); + } + } catch (error) { + panel.querySelector(".fm-summary").textContent = `选择外部模型库失败:${error.message}`; + } finally { + button.disabled = false; + button.textContent = "选择文件夹"; + } + }; + panel.querySelector('[data-action="install-custom-github"]').onclick = async () => { + const button = panel.querySelector('[data-action="install-custom-github"]'); + const input = panel.querySelector("[data-custom-github]"); + const nodePackage = lastResult?.missing_node_packages?.[0]; + const nodeType = nodePackage?.node_types?.[0] || "CustomNode"; + if (!input.value.trim()) { + panel.querySelector(".fm-summary").textContent = "请输入完整的 GitHub 插件仓库链接。"; + return; + } + button.disabled = true; + button.textContent = "正在安装…"; + try { + const result = await post("/findnodes/install", { + node_type: nodeType, + package_id: nodePackage?.known ? nodePackage.id : "custom-github", + repo_url: input.value.trim(), + install_dependencies: installDependenciesEnabled(panel), + }); + input.value = ""; + panel.querySelector(".fm-summary").textContent = `${result.title} 已安装,请重启 ComfyUI。`; + await refreshDownloadJobs(); + } catch (error) { + panel.querySelector(".fm-summary").textContent = `GitHub 插件安装失败:${error.message}`; + } finally { + button.disabled = false; + button.textContent = "链接安装插件"; + } + }; + panel.querySelector('[data-action="save-quark-cookie"]').onclick = async () => { + const input = panel.querySelector("[data-quark-cookie]"); + const status = panel.querySelector("[data-quark-auth-status]"); + try { + const result = await post("/findmodels/quark-auth", { cookie: input.value }); + input.value = ""; + status.textContent = result.configured ? "已保存夸克登录态,可重试直链下载。" : "Cookie 为空,未保存登录态。"; + } catch (error) { + status.textContent = `保存夸克登录态失败:${error.message}`; + } + }; + panel.querySelector('[data-action="clear-quark-cookie"]').onclick = async () => { + const input = panel.querySelector("[data-quark-cookie]"); + const status = panel.querySelector("[data-quark-auth-status]"); + try { + await post("/findmodels/quark-auth", { cookie: "" }); + input.value = ""; + status.textContent = "已清除夸克登录态。"; + } catch (error) { + status.textContent = `清除夸克登录态失败:${error.message}`; + } + }; panel.querySelector('[data-action="adapt"]').onclick = async () => { const button = panel.querySelector('[data-action="adapt"]'); button.disabled = true; @@ -228,15 +794,114 @@ function ensurePanel() { let failed = 0; for (const model of lastResult?.models || []) { if (!model.match?.auto_apply) continue; - if (await applyMatch(model)) count += 1; + if (await applyMatchEverywhere(model)) count += 1; else failed += 1; } panel.querySelector(".fm-summary").textContent = `已加载 ${count} 个模型${failed ? `,${failed} 个加载失败` : ""}。请检查节点后保存工作流。`; button.disabled = false; - if (count) window.setTimeout(() => scan(false), 100); + if (count) { + for (const model of [...(lastResult?.models || [])]) { + if (model.match?.auto_apply) removeResolvedModel(model.name); + } + scheduleScan(0, true); + } }; panel.addEventListener("click", async (event) => { + const copyButton = event.target.closest("[data-copy-model-name]"); + if (copyButton) { + try { + await copyText(copyButton.dataset.copyModelName); + const original = copyButton.textContent; + copyButton.textContent = "已复制"; + window.setTimeout(() => { copyButton.textContent = original; }, 1200); + } catch (error) { + panel.querySelector(".fm-summary").textContent = `复制模型名称失败:${error.message}`; + } + return; + } + + const locateButton = event.target.closest("[data-locate-node]"); + if (locateButton) { + if (!locateNode(locateButton.dataset.locateNode)) { + panel.querySelector(".fm-summary").textContent = "无法定位节点,节点可能已被删除。"; + } + return; + } + + const controlButton = event.target.closest("[data-download-control]"); + if (controlButton) { + controlButton.disabled = true; + try { + await post(`/findmodels/download/${controlButton.dataset.downloadControl}`, { + job_id: controlButton.dataset.jobId, + }); + await refreshDownloadJobs(); + } catch (error) { + panel.querySelector(".fm-summary").textContent = `下载任务操作失败:${error.message}`; + controlButton.disabled = false; + } + return; + } + + const nodeRefreshButton = event.target.closest("[data-node-refresh]"); + if (nodeRefreshButton) { + nodeRefreshButton.disabled = true; + nodeRefreshButton.textContent = "正在查找…"; + const target = nodeRefreshButton.closest(".fm-item").querySelector(".fm-node-candidates"); + try { + const data = await post("/findnodes/candidates", { + node_type: nodeRefreshButton.dataset.nodeRefresh, + package_id: nodeRefreshButton.dataset.packageId, + }); + target.innerHTML = nodeCandidatesHtml( + data.node_type, + data.candidates || [], + data.github_search_url, + data.package_id, + ); + } catch (error) { + nodeRefreshButton.disabled = false; + nodeRefreshButton.textContent = "重新查找插件"; + panel.querySelector(".fm-summary").textContent = `查找缺失节点插件失败:${error.message}`; + } + return; + } + + const moveButton = event.target.closest("[data-external-move]"); + if (moveButton) { + moveButton.disabled = true; + moveButton.textContent = "正在剪切…"; + try { + const moved = await post("/findmodels/external-move", { + source: moveButton.dataset.externalMove, + name: moveButton.dataset.name, + category: moveButton.dataset.category, + }); + const model = (lastResult?.models || []).find((item) => item.name === moveButton.dataset.name); + const references = model?.referencing_nodes?.length + ? model.referencing_nodes + : [{ node_id: moveButton.dataset.nodeId, widget: moveButton.dataset.widget }]; + for (const reference of references) { + await applyMatch({ + ...reference, + name: moveButton.dataset.name, + match: { name: moved.relative_name }, + }); + } + removeResolvedModel(moveButton.dataset.name); + panel.querySelector(".fm-summary").textContent = + `已剪切 ${moved.relative_name} 到 ${moved.category} 模型目录。`; + scheduleScan(0, true); + scheduleEnrichedScan(); + } catch (error) { + moveButton.disabled = false; + moveButton.textContent = "剪切失败,重试"; + panel.querySelector(".fm-summary").textContent = `剪切模型失败:${error.message}`; + } + return; + } + const installButton = event.target.closest("[data-node-install]"); if (installButton) { installButton.disabled = true; @@ -244,13 +909,25 @@ function ensurePanel() { try { const result = await post("/findnodes/install", { node_type: installButton.dataset.nodeType, + package_id: installButton.dataset.packageId, plugin_id: installButton.dataset.nodeInstall, + install_dependencies: installDependenciesEnabled(panel), + }); + const activityId = installButton.dataset.packageId || installButton.dataset.nodeType; + resolvedNodePackages.add(activityId); + nodeActivities.set(activityId, { + title: result.title, + status: "completed", + message: "安装完成,重启 ComfyUI 后生效", }); installButton.textContent = result.action === "updated" ? "更新完成,需重启" : "安装完成,需重启"; panel.querySelector(".fm-summary").textContent = `${result.title} 已${result.action === "updated" ? "更新" : "安装"};` + `${result.new_conflicts?.length ? `发现 ${result.new_conflicts.length} 个新增依赖冲突,请查看终端。` : "未发现新增依赖冲突。"}` + "请重启 ComfyUI。"; + await refreshDownloadJobs(); + render(lastResult, true); + scheduleScan(0, true); } catch (error) { installButton.disabled = false; installButton.textContent = "安装失败,重试"; @@ -270,13 +947,23 @@ function ensurePanel() { name: sourceButton.dataset.source, category: model.category, }); + if (data.size) { + model.size = data.size; + const sizeLabel = sourceButton.closest(".fm-item").querySelector("[data-model-size]"); + if (sizeLabel) sizeLabel.textContent = `大小:${formatSize(data.size)}`; + } const links = data.candidates.map((item) => sourceHtml(item, model)).join(""); - target.innerHTML = links || "未找到文件名完全一致的可下载模型"; + const searches = data.search_urls + ? `
Hugging Face 搜索Civitai 搜索
` + : ""; + target.innerHTML = links + ? `${data.exact_count ? "" : '未找到完全同名文件,以下为高置信相似候选,需人工核对。'}${links}${searches}` + : `未找到完全同名或高置信候选。${searches}`; } catch (error) { target.textContent = `获取下载项失败:${error.message}`; } finally { sourceButton.disabled = false; - sourceButton.textContent = "下载缺失模型"; + sourceButton.textContent = "查找下载来源"; } return; } @@ -310,43 +997,82 @@ function ensurePanel() { function render(result, quiet) { const panel = ensurePanel(); + result.models = (result.models || []).filter((model) => !resolvedModels.has(normalizedModelValue(model.name))); + result.summary = { ...result.summary, unresolved: result.models.length }; lastResult = result; const summary = result.summary; const missingNodes = result.missing_nodes || []; + const missingNodePackages = (result.missing_node_packages || []).filter((nodePackage) => { + const nodeType = nodePackage.node_types?.[0] || ""; + return !resolvedNodePackages.has(nodePackage.id) && !resolvedNodePackages.has(nodeType); + }); const missingNodeCandidates = result.missing_node_candidates || {}; - updateToolbarButton(summary.unresolved + missingNodes.length); - panel.querySelector(".fm-summary").textContent = - `缺失节点:${missingNodes.length};未加载模型:${summary.unresolved}`; - const nodeHtml = missingNodes.map((nodeType) => ` + updateToolbarButton(summary.unresolved + missingNodePackages.length); + panel.querySelector('[data-tab-count="models"]').textContent = summary.unresolved; + panel.querySelector('[data-tab-count="nodes"]').textContent = missingNodePackages.length; + panel.querySelector('[data-section-count="models"]').textContent = summary.unresolved; + panel.querySelector('[data-section-count="nodes"]').textContent = missingNodePackages.length; + const scanStatus = panel.querySelector("[data-scan-status]"); + if (scanStatus) { + scanStatus.textContent = result.quick + ? "已快速识别,正在补充外部模型库候选…" + : "当前工作流扫描完成"; + } + const externalInput = panel.querySelector("[data-external-folder]"); + if (externalInput && document.activeElement !== externalInput) { + externalInput.value = result.external_folder || ""; + } + panel.querySelector(".fm-summary").innerHTML = + `✓当前页面仅显示尚未解决的依赖项`; + const nodeHtml = missingNodePackages.map((nodePackage) => { + const nodeType = nodePackage.node_types?.[0] || ""; + const nodeIds = nodePackage.node_ids || []; + return `
-
${escapeHtml(nodeType)}缺失节点
-
${nodeCandidatesHtml(nodeType, missingNodeCandidates[nodeType] || [])}
-
`).join(""); - panel.querySelector(".fm-list").innerHTML = nodeHtml + result.models.map((model) => ` -
-
${escapeHtml(model.name)}${escapeHtml(model.category)} · ${escapeHtml(model.official_missing ? "官方判定缺失" : statusText(model.status))}
- ${model.match ? `
本地候选:${escapeHtml(model.match.name)} (${Math.round(model.match.confidence * 100)}%) ${model.match.auto_apply ? `` : ""}
` : ""} - ${model.status === "missing" ? `
` : ""} -
`).join("") || "

当前工作流中未发现缺失节点或模型。

"; +
!${escapeHtml(nodePackage.title)}${nodePackage.known ? "缺失节点包" : "未知包"}
+
${nodePackage.count} 个节点${nodePackage.version ? `${escapeHtml(nodePackage.version)}` : ""}
+
${(nodePackage.node_types || []).map((item) => `${escapeHtml(item)}`).join("")}
+ ${nodeIds[0] ? `
` : ""} +
${nodeCandidatesHtml(nodeType, missingNodeCandidates[nodeType] || [], "", nodePackage.known ? nodePackage.id : "")}
+ `; + }).join(""); + const modelsHtml = (result.models || []).map(modelCardHtml).join(""); + panel.querySelector(".fm-model-list").innerHTML = modelsHtml + ? `
文件名类型 / 引用 / 本地候选操作
${modelsHtml}
共 ${result.models.length} 项
` + : '
未发现缺失模型当前工作流模型已就绪
'; + panel.querySelector(".fm-node-list").innerHTML = nodeHtml + ? `
包名(节点集)引用 / 来源操作
${nodeHtml}
共 ${missingNodePackages.length} 项
` + : '
未发现缺失节点当前工作流节点均已注册
'; panel.querySelectorAll("[data-apply]").forEach((button) => { button.onclick = async () => { const model = result.models.find((item) => `${item.node_id}:${item.widget}` === button.dataset.apply); - if (model && await applyMatch(model)) window.setTimeout(() => scan(false), 100); + if (model && await applyMatchEverywhere(model)) { + removeResolvedModel(model.name); + scheduleScan(0, true); + } }; }); - if (!quiet) panel.classList.add("open"); } -async function scan(quiet = false) { +async function scan(quiet = false, { quick = false } = {}) { const panel = ensurePanel(); + const snapshot = workflowSnapshot(); + const signature = workflowSignature(snapshot); + const requestId = ++scanRequestId; updateToolbarButton(null, "扫描中"); - panel.querySelector(".fm-summary").textContent = "正在扫描…"; + const scanStatus = panel.querySelector("[data-scan-status]"); + if (scanStatus) scanStatus.textContent = quick ? "正在快速识别当前工作流…" : "正在补充外部模型库候选…"; + if (!quiet && !lastResult) panel.querySelector(".fm-summary").textContent = "正在扫描…"; try { - render(await post("/findmodels/scan", workflowSnapshot()), quiet); + const result = await post("/findmodels/scan", { ...snapshot, quick }); + if (requestId !== scanRequestId || signature !== workflowSignature(workflowSnapshot())) return; + lastWorkflowSignature = signature; + render(result, quiet); + if (quick) scheduleEnrichedScan(); } catch (error) { + if (requestId !== scanRequestId) return; updateToolbarButton(null, "扫描失败"); - if (!quiet) panel.classList.add("open"); - panel.querySelector(".fm-summary").textContent = `扫描失败:${error.message}`; + if (scanStatus) scanStatus.textContent = `扫描失败:${error.message}`; } } @@ -371,27 +1097,33 @@ function findRunButton() { return selectors.map((selector) => document.querySelector(selector)).find(Boolean); } -function findImageStreamButton() { +function findPropertyPanelButton() { const selectors = [ - "[data-testid='image-feed-button']", - "[data-testid='image-stream-button']", - "button[aria-label='显示图像流']", - "button[title='显示图像流']", - "button[aria-label*='图像流']", - "button[title*='图像流']", - "button[aria-label*='image feed']", - "button[title*='image feed']", + "button[aria-label='开关属性面板']", + "button[title='开关属性面板']", + "button[aria-label*='属性面板']", + "button[title*='属性面板']", + "button[aria-label*='Toggle property panel']", + "button[title*='Toggle property panel']", ]; const direct = selectors.map((selector) => document.querySelector(selector)).find(Boolean); if (direct) return direct; return [...document.querySelectorAll("button")].find((button) => - ["显示图像流", "Show image feed", "Show image stream"].includes(button.textContent?.trim()), + ["开关属性面板", "Toggle property panel"].includes(button.textContent?.trim()), + ); +} + +function findActiveTasksButton() { + return [...document.querySelectorAll("button")].find((button) => + /(?:\d+\s*个活动任务|\d+\s*active tasks?)/i.test(button.textContent?.trim() || ""), ); } function findTopToolbar() { - const imageStreamButton = findImageStreamButton(); - if (imageStreamButton?.parentElement) return imageStreamButton.parentElement; + const propertyButton = findPropertyPanelButton(); + if (propertyButton?.parentElement) return propertyButton.parentElement; + const activeTasksButton = findActiveTasksButton(); + if (activeTasksButton?.parentElement) return activeTasksButton.parentElement; const runButton = findRunButton(); if (runButton?.parentElement) return runButton.parentElement; return document.querySelector( @@ -401,7 +1133,8 @@ function findTopToolbar() { function mountToolbarButton() { let existing = document.getElementById("find-models-launcher"); - const imageStreamButton = findImageStreamButton(); + const propertyButton = findPropertyPanelButton(); + const activeTasksButton = findActiveTasksButton(); const runButton = findRunButton(); const toolbar = findTopToolbar(); @@ -411,18 +1144,30 @@ function mountToolbarButton() { existing.type = "button"; existing.textContent = "查找模型"; existing.title = "扫描当前工作流中的缺失模型"; - existing.onclick = () => scan(false); document.body.appendChild(existing); } + existing.onclick = async () => { + panelUserOpened = true; + const panel = ensurePanel(); + if (panel.classList.contains("open")) { + scan(true, { quick: true }); + return; + } + await openDockedPanel(panel); + scan(true, { quick: true }); + }; if (toolbar) { existing.classList.remove("toolbar-fallback"); if (existing.parentElement !== toolbar) { - if (imageStreamButton) imageStreamButton.insertAdjacentElement("beforebegin", existing); + if (propertyButton) propertyButton.insertAdjacentElement("beforebegin", existing); + else if (activeTasksButton) activeTasksButton.insertAdjacentElement("afterend", existing); else if (runButton) runButton.insertAdjacentElement("afterend", existing); else toolbar.appendChild(existing); - } else if (imageStreamButton && existing.nextElementSibling !== imageStreamButton) { - imageStreamButton.insertAdjacentElement("beforebegin", existing); + } else if (propertyButton && existing.nextElementSibling !== propertyButton) { + propertyButton.insertAdjacentElement("beforebegin", existing); + } else if (!propertyButton && activeTasksButton && activeTasksButton.nextElementSibling !== existing) { + activeTasksButton.insertAdjacentElement("afterend", existing); } return true; } @@ -447,12 +1192,18 @@ app.registerExtension({ style.href = new URL("./find_models.css", import.meta.url).href; document.head.appendChild(style); ensurePanel(); + panelUserOpened = false; + ensurePanel().classList.remove("open"); watchTopToolbar(); startDownloadMonitor(); - window.setTimeout(() => scan(true), 1200); + startWorkflowMonitor(); + scheduleScan(0, true); }, async afterConfigureGraph() { - window.clearTimeout(window.__findModelsTimer); - window.__findModelsTimer = window.setTimeout(() => scan(true), 700); + scanRequestId += 1; + lastWorkflowSignature = ""; + resolvedModels.clear(); + resolvedNodePackages.clear(); + scheduleScan(0, true); }, });