Make Quark matches directly downloadable

This commit is contained in:
ComfyUI_FindModels
2026-06-12 18:05:13 +08:00
parent 534c05328e
commit e73a78d892
7 changed files with 30 additions and 157 deletions
+6 -9
View File
@@ -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
+7 -85
View File
@@ -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")
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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"
+4 -26
View File
@@ -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()
+6
View File
@@ -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"}]}]}
+5 -35
View File
@@ -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() {
<div class="fm-actions">
<button data-action="scan">扫描当前工作流</button>
<button data-action="adapt">一键加载模型</button>
<button data-action="audit">检查模型位置</button>
</div>
<div class="fm-summary">尚未扫描</div><div class="fm-list"></div>`;
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) =>
`<a href="${escapeHtml(item.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(item.name)}备用查找</a>`,
).join("");
target.innerHTML = `${links || "<span>未找到文件名完全一致且可直接下载的模型</span>"}${quarkFallback}`;
target.innerHTML = links || "<span>未找到文件名完全一致的可下载模型</span>";
} 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) {
<article class="fm-item fm-${escapeHtml(model.status)}">
<div><strong>${escapeHtml(model.name)}</strong><span>${escapeHtml(model.category)} · ${escapeHtml(model.status)}</span></div>
${model.match ? `<div class="fm-match">本地候选:${escapeHtml(model.match.name)} (${Math.round(model.match.confidence * 100)}%) ${model.match.auto_apply ? `<button data-apply="${escapeHtml(model.node_id)}:${escapeHtml(model.widget)}">加载</button>` : ""}</div>` : ""}
${model.status === "missing" ? `<button data-source="${escapeHtml(model.name)}">查找下载来源</button><div class="fm-sources"></div>` : ""}
${model.status === "missing" ? `<button data-source="${escapeHtml(model.name)}">下载缺失模型</button><div class="fm-sources"></div>` : ""}
</article>`).join("") || "<p>当前工作流中未识别到模型引用。</p>";
panel.querySelectorAll("[data-apply]").forEach((button) => {
button.onclick = async () => {