UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

354 lines • 15 kB
// Missing-model resolution: figure out which model files a workflow wants but // this ComfyUI doesn't have, then find installable candidates for them. // // WHY THIS EXISTS: `list_packs` action:"extract_deps" / action:"install_deps" // resolve the custom NODE PACKS a workflow needs — they never touch the missing // MODEL side of the same problem. So "open this Template and make it runnable" // took two manual hops: the agent had to infer which checkpoint/VAE/LoRA was // missing, then find it itself. Reported in the help channel (seanmcmagic). // // DETECTION: we do NOT hardcode widget names. ComfyUI publishes every model // selector as a COMBO whose options are the server's ACTUAL filenames: // // CheckpointLoaderSimple.input.required.ckpt_name = [["a.safetensors", …], {…}] // // So a workflow value that is a model-looking filename and is NOT in its combo's // option list is missing — which covers every model type, including ones from // custom packs we've never heard of, with no per-node mapping to maintain. // // SIZE IS NOT VRAM: weights are the floor, not the ceiling (activations, latents // and context sit on top), so `fitVerdict` deliberately reserves headroom rather // than pretending file size == requirement. /** Weight-file extensions. Used to tell a model value from an ordinary enum. */ const MODEL_EXTS = new Set([".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf", ".sft", ".onnx"]); /** Widget name → models/ subdirectory. Only used to say WHERE a download should * land; detection never depends on it, so an unknown widget degrades to * `undefined` rather than dropping the missing model. */ const DIR_BY_WIDGET = { ckpt_name: "checkpoints", lora_name: "loras", vae_name: "vae", control_net_name: "controlnet", controlnet_name: "controlnet", unet_name: "diffusion_models", model_name: "upscale_models", clip_name: "text_encoders", clip_name1: "text_encoders", clip_name2: "text_encoders", style_model_name: "style_models", embedding_name: "embeddings", gligen_name: "gligen", hypernetwork_name: "hypernetworks", ipadapter_file: "ipadapter", }; /** DonutLoRAStack / LoRA Stacker V2 / similar: `lora_name_1`, `lora_name_2`, … */ const NUMBERED_LORA_WIDGET = /^lora_name_\d+$/i; export function directoryForWidget(widget, classType) { // CLIPVisionLoader shares `clip_name` with text-encoder loaders, but its // object-info combo is sourced from ComfyUI's `clip_vision` directory. if (classType === "CLIPVisionLoader" && widget === "clip_name") return "clip_vision"; if (Object.prototype.hasOwnProperty.call(DIR_BY_WIDGET, widget)) return DIR_BY_WIDGET[widget]; if (NUMBERED_LORA_WIDGET.test(widget)) return DIR_BY_WIDGET.lora_name; return undefined; } /** Strip directory + extension, lowercase — the comparable identity of a model. */ export function fileStem(name) { const base = name.split(/[/\\]/).pop() ?? name; return base.replace(/\.[^.]+$/, "").toLowerCase(); } function hasModelExt(name) { const base = name.split(/[/\\]/).pop() ?? name; const dot = base.lastIndexOf("."); if (dot < 0) return false; return MODEL_EXTS.has(base.slice(dot).toLowerCase()); } /** * Read the precision / quantisation out of a weight filename. * * This is what makes a fuzzy match set useful instead of opaque: the same model * ships as fp16 / fp8 / several GGUF quants at wildly different sizes, and on a * constrained GPU the quantised one is the whole answer. * * GGUF is checked FIRST: a name can contain both "gguf" and a dtype-looking * token, and the container is what decides whether it loads at all. */ export function classifyPrecision(filename) { const n = filename.toLowerCase(); if (/\.gguf$|[-_.]gguf\b/.test(n)) { // Q4_K_M, Q6_K, Q8_0, IQ3_XXS … const m = n.match(/\b(iq|q)(\d+)(_[a-z0-9]+)*\b/); return { precision: "gguf", quant: m ? m[0].toUpperCase() : undefined, requiresPack: "ComfyUI-GGUF", }; } if (/\bnf4\b/.test(n)) return { precision: "nf4" }; // fp8_e4m3fn / fp8_e5m2 / float8 if (/\bfp8\b|\bfloat8\b|e4m3|e5m2/.test(n)) return { precision: "fp8" }; if (/\bbf16\b|\bbfloat16\b/.test(n)) return { precision: "bf16" }; if (/\bfp16\b|\bfloat16\b|\bhalf\b/.test(n)) return { precision: "fp16" }; if (/\bfp32\b|\bfloat32\b|\bfull\b/.test(n)) return { precision: "fp32" }; return { precision: "unknown" }; } /** * Can this file plausibly run on a card with `vramBytes` of VRAM? * * Weights are the floor, not the ceiling — activations/latents/context also live * in VRAM — so we reserve headroom instead of claiming a 15.9 GB file "fits" a * 16 GB card. Anything above 80% of VRAM is reported as `tight`, not `fits`. */ export function fitVerdict(sizeBytes, vramBytes) { if (!sizeBytes || !vramBytes || sizeBytes <= 0 || vramBytes <= 0) return "unknown"; const ratio = sizeBytes / vramBytes; if (ratio > 1) return "too_big"; if (ratio > 0.8) return "tight"; return "fits"; } /** How well does `candidate` answer the request for `wanted`? */ export function matchQuality(wanted, candidate) { const w = (wanted.split(/[/\\]/).pop() ?? wanted).toLowerCase(); const c = (candidate.split(/[/\\]/).pop() ?? candidate).toLowerCase(); if (w === c) return "exact"; if (fileStem(wanted) === fileStem(candidate)) return "stem"; return "fuzzy"; } const MATCH_RANK = { exact: 0, stem: 1, fuzzy: 2 }; const FIT_RANK = { fits: 0, tight: 1, unknown: 2, too_big: 3 }; /** * Order candidates the way a human would pick: closest name first, then what * actually fits the card, then larger (higher-quality) files before smaller. * Stable — equal candidates keep their input order. */ export function rankCandidates(wanted, candidates) { return candidates .map((c, i) => ({ ...c, match: matchQuality(wanted, c.filename), _i: i })) .sort((a, b) => { const m = MATCH_RANK[a.match] - MATCH_RANK[b.match]; if (m !== 0) return m; const f = FIT_RANK[a.fit ?? "unknown"] - FIT_RANK[b.fit ?? "unknown"]; if (f !== 0) return f; const s = (b.size_bytes ?? 0) - (a.size_bytes ?? 0); if (s !== 0) return s; return a._i - b._i; }) .map(({ _i, ...rest }) => rest); } /** * The option list for an input spec, in either schema form, or null when this * pass has no authority over it. * * ComfyUI serialises a V1 combo as `[[...allowed], {cfg}]` and a V3 one as * `["COMBO", {options: [...], ...}]` (`add_to_dict_v1` writes * `(io_type, as_dict())`). Both are the live inventory `panel_get_errors` * already reads. The V1-only parser skipped every V3 custom-node combo — so * DonutLoRAStack `lora_name_N` values absent from /object_info were reported * as "No missing models" (#2068). A MULTISELECT combo is refused: its stored * value is a selection of several options, so membership in the list is the * wrong question. */ function comboOptions(spec) { if (!Array.isArray(spec) || spec.length === 0) return null; const cfg = spec[1] && typeof spec[1] === "object" && !Array.isArray(spec[1]) ? spec[1] : null; if (cfg?.multiselect) return null; const type = spec[0]; if (Array.isArray(type)) return type.filter((v) => typeof v === "string"); if (typeof type !== "string" || !/COMBO/i.test(type) || /DYNAMIC/i.test(type)) return null; const opts = cfg?.options; if (!Array.isArray(opts)) return null; const strings = opts.filter((v) => typeof v === "string"); return strings.length === opts.length ? strings : null; } /** * Find every model a workflow references that this server does not have. * * A value counts as a missing model when its input is a COMBO (so the server * published the set it actually has) and the value is a model-looking filename * absent from that set. Connections (`[nodeId, slot]` arrays) and non-model * enums are ignored. * * `directory` is a best-effort hint for where a download should land; it is * never required, so an unrecognised widget still reports its missing model. */ export function findMissingModels(workflow, objectInfo) { const missing = []; const seen = new Set(); for (const [nodeId, rawNode] of Object.entries(workflow)) { if (!rawNode || typeof rawNode !== "object") continue; const node = rawNode; const classType = node.class_type; if (!classType || !node.inputs) continue; const def = objectInfo[classType]; if (!def?.input) continue; const specs = { ...(def.input.required ?? {}), ...(def.input.optional ?? {}) }; for (const [widget, value] of Object.entries(node.inputs)) { // A wired connection, not a widget value. if (Array.isArray(value)) continue; if (typeof value !== "string" || value.length === 0) continue; const options = comboOptions(specs[widget]); if (!options) continue; // not a combo → not a model selector if (options.includes(value)) continue; // present on this server // Only claim "missing MODEL" when it looks like a weight file — otherwise // it's an ordinary enum drift and not ours to fix. if (!hasModelExt(value) && !options.some(hasModelExt)) continue; const key = `${value.toLowerCase()}|${widget}`; if (seen.has(key)) continue; seen.add(key); missing.push({ node_id: nodeId, node_type: classType, widget, name: value, directory: directoryForWidget(widget, classType), }); } } return missing; } /** models/ subdir → CivitAI `types` filter, so a LoRA hunt can't return checkpoints. */ const CIVITAI_TYPE_BY_DIR = { checkpoints: ["Checkpoint"], loras: ["LORA", "LoCon"], vae: ["VAE"], controlnet: ["Controlnet"], embeddings: ["TextualInversion"], upscale_models: ["Upscaler"], }; const MB = 1024 * 1024; /** * Find installable candidates for ONE missing model, annotated with precision, * size and whether it fits this GPU, best first — AND which providers failed * to answer at all. * * Deliberately does NOT auto-pick: the same filename ships across quants and * base models, so we return a ranked, annotated list and let the caller decide. * Network failures degrade to fewer candidates rather than failing the lookup — * a partial answer beats none when one provider is down — but the failures are * REPORTED, so an empty list is never read as "nothing exists" when the search * itself failed. */ export async function resolveCandidatesDetailed(missing, deps, opts = {}) { const limit = opts.limit ?? 8; const query = fileStem(missing.name); // unknown-ok: undefined means "VRAM unknown" and is carried as such — it never // becomes a zero that would filter every candidate out. Compare the CivitAI call // immediately below, which records its failure in failedProviders for the same reason. const vram = await deps.vramBytes().catch(() => undefined); const out = []; const failedProviders = []; const civitaiTypes = missing.directory ? CIVITAI_TYPE_BY_DIR[missing.directory] : undefined; const hits = await deps.searchCivitai(query, civitaiTypes).catch(() => { failedProviders.push("CivitAI"); return []; }); for (const h of hits) { const size = typeof h.size_mb === "number" ? Math.round(h.size_mb * MB) : undefined; const p = classifyPrecision(h.name); out.push({ filename: h.name, source: "civitai", civitai_model_id: h.model_id, civitai_version_id: h.version_id, size_bytes: size, precision: p.precision, quant: p.quant, requires_pack: p.requiresPack, base_model: h.base_model, fit: fitVerdict(size, vram), }); } // Expand generously: the QUANTISED repo (e.g. city96/FLUX.1-dev-gguf) is // routinely ranked below the official one, and on a constrained GPU it is the // only candidate that actually helps — slicing too tightly drops exactly the // answer the user needs. const repos = await deps.searchHf(query).catch(() => { failedProviders.push("HuggingFace"); return []; }); for (const repo of repos.slice(0, 6)) { const files = await deps.hfRepoFiles(repo.id).catch(() => { failedProviders.push(`HuggingFace repo ${repo.id}`); return []; }); for (const f of files) { const p = classifyPrecision(f.filename); out.push({ filename: f.filename, source: "huggingface", url: `https://huggingface.co/${repo.id}/resolve/main/${f.filename}`, size_bytes: f.size_bytes, precision: p.precision, quant: p.quant, requires_pack: p.requiresPack, fit: fitVerdict(f.size_bytes, vram), }); } } return { candidates: rankCandidates(missing.name, dedupeCandidates(out)).slice(0, limit), failedProviders, }; } /** The candidate list alone — see `resolveCandidatesDetailed` for the contract. */ export async function resolveCandidates(missing, deps, opts = {}) { return (await resolveCandidatesDetailed(missing, deps, opts)).candidates; } /** * Collapse the same file offered by several repos/providers into one row. * * Popular weights are mirrored widely, so an un-deduped list wastes most of its * slots showing `flux1-dev.safetensors` three times instead of the fp8 and GGUF * variants that are the actual reason to look. Keyed on filename + precision + * quant so genuinely different builds stay distinct; the entry that knows its * size wins, since size drives the fit verdict. */ export function dedupeCandidates(candidates) { const best = new Map(); for (const c of candidates) { const key = `${(c.filename.split(/[/\\]/).pop() ?? c.filename).toLowerCase()}|${c.precision}|${c.quant ?? ""}`; const prev = best.get(key); if (!prev) { best.set(key, c); continue; } const prevKnows = typeof prev.size_bytes === "number" && prev.size_bytes > 0; const nextKnows = typeof c.size_bytes === "number" && c.size_bytes > 0; if (!prevKnows && nextKnows) best.set(key, c); } return [...best.values()]; } //# sourceMappingURL=missing-models.js.map