diff --git a/README.md b/README.md index f65d42b..4243ed7 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,6 @@ 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. - Searches the configured Quark Netdisk libraries directly for missing model filenames. -- Audits existing model locations and reports only files whose expected official folder - can be determined clearly. The audit never moves files. -- Leaves ambiguous checkpoint/diffusion model files in place instead of guessing their - architecture from names such as `flux`, `wan`, or `z_image`. - Ignores serialized workflow metadata, URLs, free-text fields, and generic `model` fields on non-loader custom nodes to prevent false missing-model reports. - Does not move, delete, or overwrite model files. @@ -53,7 +49,7 @@ 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. @@ -61,9 +57,10 @@ The download results are search candidates. Verify the model page, license, base and filename before downloading. Some Civitai files require authentication, so their direct URL may redirect to a sign-in page. -Only sources whose filename exactly matches the missing workflow filename and whose -download endpoint is currently usable are shown. Similar filenames and restricted Quark -files are excluded. +Only sources whose filename exactly matches the missing workflow filename are shown. +Matching Quark files are shown as download buttons instead of fallback search links. +The download endpoint rejects unknown model categories rather than placing files in an +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 @@ -91,7 +88,7 @@ python -m unittest discover -s tests -v ## Privacy 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. +Quark shares only when you explicitly click **下载缺失模型**. Regular scans remain local. ## License diff --git a/find_models.py b/find_models.py index 4386319..9266e2c 100644 --- a/find_models.py +++ b/find_models.py @@ -55,19 +55,6 @@ DOWNLOAD_CATEGORY_ALIASES = { "upscale_models": ("upscale_models",), "embeddings": ("embeddings",), } -OFFICIAL_MODEL_FOLDERS = { - "checkpoints": "checkpoints", - "loras": "loras", - "vae": "vae", - "controlnet": "controlnet", - "clip_vision": "clip_vision", - "text_encoders": "text_encoders", - "diffusion_models": "diffusion_models", - "upscale_models": "upscale_models", - "embeddings": "embeddings", -} - - def _safe_query(name: str) -> str: return re.sub(r"[_\-.]+", " ", basename(name).rsplit(".", 1)[0]).strip() @@ -118,7 +105,10 @@ def _is_model_payload(path: Path) -> bool: def _target_directory(category: str) -> Path: - for candidate in DOWNLOAD_CATEGORY_ALIASES.get(category, (category,)): + aliases = DOWNLOAD_CATEGORY_ALIASES.get(category) + if not aliases: + raise web.HTTPBadRequest(text=f"无法确定模型对应的 ComfyUI 文件夹: {category}") + for candidate in aliases: try: paths = folder_paths.get_folder_paths(candidate) except Exception: @@ -137,54 +127,6 @@ def _clear_filename_cache(category: str) -> None: cache.pop(candidate, None) -def _comfy_model_root() -> Path: - base = getattr(folder_paths, "models_dir", None) - if not base: - checkpoint_paths = folder_paths.get_folder_paths("checkpoints") - if not checkpoint_paths: - raise web.HTTPBadRequest(text="无法确定 ComfyUI models 目录") - base = Path(checkpoint_paths[0]).parent - return Path(base).resolve() - - -def _classify_existing_file(path: Path) -> str | None: - text = path.as_posix().lower() - name = path.name.lower() - if any(term in text for term in ("lora", "lycoris")): - return "loras" - if "controlnet" in text or "control_net" in text: - return "controlnet" - if "clip_vision" in text: - return "clip_vision" - if any(term in text for term in ("text_encoder", "/clip/", "/t5")): - return "text_encoders" - if "embedding" in text: - return "embeddings" - if any(term in text for term in ("upscale", "esrgan", "rife", "frame_interpolation")): - return "upscale_models" - if "/vae" in text or name.startswith("vae") or "_vae" in name: - return "vae" - if any(term in text for term in ("diffusion_model", "/unet/")): - return "diffusion_models" - return None - - -def _audit_model_locations() -> list[dict[str, str]]: - root = _comfy_model_root() - issues = [] - for source in root.rglob("*"): - if not source.is_file() or source.suffix.lower() not in MODEL_EXTENSIONS: - continue - category = _classify_existing_file(source) - if not category: - continue - target_dir = (root / OFFICIAL_MODEL_FOLDERS[category]).resolve() - if source.parent == target_dir or target_dir in source.parents: - continue - issues.append({"expected": category, "path": str(source)}) - return issues - - 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: @@ -387,20 +329,10 @@ async def find_sources(request: web.Request) -> web.Response: *(_validate_web_candidate(session, candidate) for candidate in exact_web), return_exceptions=True, ) - checked_quark = await asyncio.gather( - *(_validate_quark_candidate(session, candidate) for candidate in exact_quark), - return_exceptions=True, - ) candidates = [ - candidate for candidate in [*checked_web, *checked_quark] if isinstance(candidate, dict) + candidate for candidate in [*checked_web, *exact_quark] if isinstance(candidate, dict) ] - return web.json_response({"name": name, "candidates": candidates[:12], "quark_libraries": QUARK_MODEL_LIBRARIES}) - - -@PromptServer.instance.routes.get("/findmodels/audit") -async def audit_models(request: web.Request) -> web.Response: - items = _audit_model_locations() - return web.json_response({"count": len(items), "items": items}) + return web.json_response({"name": name, "candidates": candidates[:12]}) async def _quark_download_url(session: aiohttp.ClientSession, payload: dict[str, Any]) -> str: @@ -429,17 +361,7 @@ async def _quark_download_url(session: aiohttp.ClientSession, payload: dict[str, if _allowed_quark_download_url(url): return url detail = errors[0] if errors else "需要登录或文件超过公开下载大小限制" - raise web.HTTPBadGateway(text=f"夸克无法公开下载:{detail}") - - -async def _validate_quark_candidate( - session: aiohttp.ClientSession, candidate: dict[str, Any] -) -> dict[str, Any] | None: - try: - await _quark_download_url(session, candidate["quark"]) - return candidate - except web.HTTPException: - return None + raise web.HTTPBadGateway(text=f"夸克拒绝直链下载:{detail}") @PromptServer.instance.routes.post("/findmodels/download") diff --git a/model_finder.py b/model_finder.py index 4604cee..4a16855 100644 --- a/model_finder.py +++ b/model_finder.py @@ -121,7 +121,7 @@ class Reference: def normalize_path(value: str) -> str: - return value.replace("\\", "/").strip() + return value.replace("\\", "/").strip().lstrip("/") def basename(value: str) -> str: diff --git a/pyproject.toml b/pyproject.toml index ea5229d..c70f61b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "comfyui-findmodels" -version = "1.7.0" +version = "1.8.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 135f9da..7130ad0 100644 --- a/tests/test_download_helpers.py +++ b/tests/test_download_helpers.py @@ -33,8 +33,6 @@ from ComfyUI_FindModels.find_models import ( _safe_filename, _size_value, _target_directory, - _classify_existing_file, - _audit_model_locations, ) @@ -76,6 +74,10 @@ class DownloadHelperTests(unittest.TestCase): def test_uses_matching_comfyui_model_folder(self): self.assertEqual(_target_directory("loras"), (Path.cwd() / "models" / "loras").resolve()) + def test_rejects_unknown_download_folder(self): + with self.assertRaises(Exception): + _target_directory("unknown") + 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)] @@ -99,29 +101,5 @@ class DownloadHelperTests(unittest.TestCase): ], ) - def test_classifies_files_for_official_folders(self): - self.assertEqual(_classify_existing_file(Path("misc/loras/hero.safetensors")), "loras") - self.assertEqual(_classify_existing_file(Path("misc/rife47.pth")), "upscale_models") - self.assertEqual(_classify_existing_file(Path("misc/vae_model.safetensors")), "vae") - self.assertIsNone(_classify_existing_file(Path("checkpoints/z_image_turbo_bf16.safetensors"))) - self.assertIsNone(_classify_existing_file(Path("misc/unknown_model.safetensors"))) - - def test_audit_reports_only_clearly_misplaced_files(self): - root = Path(folder_paths.models_dir) - misplaced = root / "checkpoints" / "hero_lora.safetensors" - correct = root / "loras" / "people" / "correct.safetensors" - misplaced.parent.mkdir(parents=True, exist_ok=True) - correct.parent.mkdir(parents=True, exist_ok=True) - misplaced.write_bytes(b"x") - correct.write_bytes(b"x") - try: - issues = _audit_model_locations() - self.assertTrue(any(item["path"] == str(misplaced.resolve()) for item in issues)) - self.assertFalse(any(item["path"] == str(correct.resolve()) for item in issues)) - finally: - misplaced.unlink() - correct.unlink() - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_model_finder.py b/tests/test_model_finder.py index 2e8609b..8297cff 100644 --- a/tests/test_model_finder.py +++ b/tests/test_model_finder.py @@ -27,6 +27,12 @@ class ModelFinderTests(unittest.TestCase): def test_normalized_stem_removes_common_precision_suffix(self): self.assertEqual(normalized_stem("Hero_v2_fp16.safetensors"), "hero") + def test_removes_leading_path_separators(self): + refs = extract_references( + {"nodes": [{"id": 6, "type": "VAELoader", "widgets": [{"name": "vae_name", "value": "\\/folder/model.safetensors"}]}]} + ) + self.assertEqual(refs[0].name, "folder/model.safetensors") + def test_extracts_widget_reference_with_location(self): refs = extract_references( {"nodes": [{"id": 7, "type": "LoraLoader", "widgets": [{"name": "lora_name", "value": "Hero.safetensors"}]}]} diff --git a/web/find_models.js b/web/find_models.js index dd47b12..1014965 100644 --- a/web/find_models.js +++ b/web/find_models.js @@ -47,12 +47,6 @@ async function post(path, body) { return response.json(); } -async function get(path) { - const response = await api.fetchApi(path); - if (!response.ok) throw new Error(await response.text()); - return response.json(); -} - function findTargetWidget(node, model) { const widgets = node?.widgets || []; return widgets.find((item) => item.name === model.widget) @@ -112,7 +106,6 @@ function ensurePanel() {
-
尚未扫描
`; document.body.appendChild(panel); @@ -133,31 +126,11 @@ function ensurePanel() { button.disabled = false; if (count) window.setTimeout(() => scan(false), 100); }; - panel.querySelector('[data-action="audit"]').onclick = async () => { - const button = panel.querySelector('[data-action="audit"]'); - button.disabled = true; - try { - const audit = await get("/findmodels/audit"); - if (!audit.count) { - panel.querySelector(".fm-summary").textContent = "未发现能够明确判断为放错位置的模型。"; - return; - } - const preview = audit.items.slice(0, 50).map((item) => - `${item.expected}: ${item.path}`, - ).join("\n"); - window.alert(`发现 ${audit.count} 个能够明确判断的位置问题。\n本功能只检查,不移动文件。\n\n${preview}`); - panel.querySelector(".fm-summary").textContent = `发现 ${audit.count} 个明确的位置问题,未移动任何文件。`; - } catch (error) { - panel.querySelector(".fm-summary").textContent = `检查失败:${error.message}`; - } finally { - button.disabled = false; - } - }; panel.addEventListener("click", async (event) => { const sourceButton = event.target.closest("[data-source]"); if (sourceButton) { sourceButton.disabled = true; - sourceButton.textContent = "查询中…"; + sourceButton.textContent = "正在获取下载项…"; const model = lastResult?.models?.find((item) => item.name === sourceButton.dataset.source); const target = sourceButton.closest(".fm-item").querySelector(".fm-sources"); try { @@ -166,15 +139,12 @@ function ensurePanel() { category: model.category, }); const links = data.candidates.map((item) => sourceHtml(item, model)).join(""); - const quarkFallback = data.quark_libraries.map((item) => - `${escapeHtml(item.name)}备用查找`, - ).join(""); - target.innerHTML = `${links || "未找到文件名完全一致且可直接下载的模型"}${quarkFallback}`; + target.innerHTML = links || "未找到文件名完全一致的可下载模型"; } catch (error) { - target.textContent = `查询失败:${error.message}`; + target.textContent = `获取下载项失败:${error.message}`; } finally { sourceButton.disabled = false; - sourceButton.textContent = "查找下载来源"; + sourceButton.textContent = "下载缺失模型"; } return; } @@ -219,7 +189,7 @@ function render(result, quiet) {
${escapeHtml(model.name)}${escapeHtml(model.category)} · ${escapeHtml(model.status)}
${model.match ? `
本地候选:${escapeHtml(model.match.name)} (${Math.round(model.match.confidence * 100)}%) ${model.match.auto_apply ? `` : ""}
` : ""} - ${model.status === "missing" ? `
` : ""} + ${model.status === "missing" ? `
` : ""}
`).join("") || "

当前工作流中未识别到模型引用。

"; panel.querySelectorAll("[data-apply]").forEach((button) => { button.onclick = async () => {