Pressing r, or picking Refresh Node Definitions from the menu, reloaded every other node's model lists but not ours. The library is fetched once and cached in the browser, so a LoRA added since the tab was opened stayed unknown: its tag read red, completion never offered it, and the browser did not list it. A page reload was the only way out. ComfyUI invokes refreshComboInNodes on every extension at the end of reloadNodeDefs, which is exactly that moment. Using it drops the shared library, the base model asked of the server per checkpoint, and each node's own memo of what it had resolved - all three, or the stale one wins. Nodes register themselves in a set while they live, because walking the root graph would miss any inside a subgraph. Checked end to end against a real ComfyUI: a tag naming a file that is not there reads red, stays red when the file is added, and turns cyan on refresh, with the browser listing it too. Confirmed the same test leaves the tag red without this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
77 lines
2.7 KiB
JavaScript
77 lines
2.7 KiB
JavaScript
// Which base model a node is wired to, and which one a LoRA belongs to.
|
|
//
|
|
// Both the prompt's completion and the library browser need this, and they must
|
|
// not be able to disagree about what matches what, so it lives in one place.
|
|
|
|
/** What a LoRA is filed under: what it says it is, else where it sits.
|
|
*
|
|
* base_model comes from the .civitai.info sidecar and means the same thing in
|
|
* anybody's collection. Folder names only mean something to whoever chose them,
|
|
* so they are the fallback rather than the rule.
|
|
*/
|
|
export function groupOf(entry) {
|
|
return entry.base_model || entry.folder || "Unsorted";
|
|
}
|
|
|
|
// "Flux.2 Klein 9B" and "flux2 klein 9b" are the same base written twice.
|
|
const key = (name) => (name || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
|
|
/** Do these two name the same base model? Unknown never matches anything. */
|
|
export function sameBase(a, b) {
|
|
const left = key(a);
|
|
return left !== "" && left === key(b);
|
|
}
|
|
|
|
/** The model file feeding this node, found by walking back up the graph. */
|
|
export function connectedModelName(node) {
|
|
const seen = new Set();
|
|
const walk = (current, depth) => {
|
|
if (!current || depth > 12 || seen.has(current.id)) return null;
|
|
seen.add(current.id);
|
|
for (const widget of current.widgets || []) {
|
|
const isModel = /^(unet_name|ckpt_name|model_name)$/.test(widget.name || "");
|
|
if (isModel && typeof widget.value === "string" && widget.value) return widget.value;
|
|
}
|
|
for (let slot = 0; slot < (current.inputs || []).length; slot++) {
|
|
const found = walk(current.getInputNode?.(slot), depth + 1);
|
|
if (found !== null) return found;
|
|
}
|
|
return null;
|
|
};
|
|
return walk(node, 0);
|
|
}
|
|
|
|
// Asked of the server once per file: the answer is a sidecar read, and the
|
|
// same checkpoint stays connected for as long as anyone keeps typing.
|
|
const asked = new Map();
|
|
|
|
/** Forget the answers, so a sidecar written since is read again. */
|
|
export function forgetBases() {
|
|
asked.clear();
|
|
}
|
|
|
|
/** The base model of the file wired into this node, asked of the server. */
|
|
export async function connectedBase(node) {
|
|
const name = connectedModelName(node);
|
|
if (!name) return null;
|
|
if (asked.has(name)) return asked.get(name);
|
|
|
|
let answer = null;
|
|
try {
|
|
const info = await fetch(`/warppipe/model/base?name=${encodeURIComponent(name)}`).then((r) =>
|
|
r.json()
|
|
);
|
|
answer = info.base_model || null;
|
|
} catch {
|
|
/* fall through to the folder */
|
|
}
|
|
if (!answer) {
|
|
// No sidecar: fall back to the folder, which is right for collections filed
|
|
// that way and simply matches nothing for the rest.
|
|
const parts = name.replace(/\\/g, "/").split("/");
|
|
answer = parts.length > 1 ? parts[0] : null;
|
|
}
|
|
asked.set(name, answer);
|
|
return answer;
|
|
}
|