Files

588 lines
20 KiB
JavaScript

// Model indicator bridge — manages the model indicator footer element,
// graph tracing, recognition state, and lazy-loads Svelte for the picker
// and download modals.
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { showModelModal, closeModelModal, applyTemplate, swapModelInPlace, hasCheckpointGraph, registerCatalogDownloadModal, registerDownloadModal, registerNodePackInstallModal } from "./model-bridge.js";
import "./model-settings-bridge.js"; // side-effect: registers showModelModal/closeModelModal
import { autoApplyTagSources, resetTagAutoApply } from "./tags-dropdown.js";
import { getLink } from "./slot-utils.js";
import { ensureSvelteCSS, createModuleLoader } from "./lazy-load.js";
// ── recognition state ─────────────────────────────────────────────
const _recognition = { running: false, total: 0, done: 0 };
api.addEventListener("promptchain_recognition_start", ({ detail }) => {
_recognition.running = true;
_recognition.total = detail.total;
_recognition.done = 0;
window.dispatchEvent(new CustomEvent("pcr-recognition-update"));
});
api.addEventListener("promptchain_model_recognized", ({ detail }) => {
_recognition.done = detail.done;
_recognition.total = detail.total;
window.dispatchEvent(new CustomEvent("pcr-recognition-update", { detail }));
});
api.addEventListener("promptchain_recognition_done", () => {
_recognition.running = false;
window.dispatchEvent(new CustomEvent("pcr-recognition-update"));
});
// ── graph tracing ─────────────────────────────────────────────────
// Mirrors the canonical set in main.js — UltimateSDUpscale included so the
// indicator resolves the model in upscale-only graphs (no KSampler at all).
const KSAMPLER_TYPES = new Set([
"KSampler", "KSamplerAdvanced",
"PromptChain_RegionalDetailer", "SamplerCustomAdvanced", "UltimateSDUpscale", "UltimateSDUpscaleNoUpscale",
"PromptChain_IdeogramSampler", "ClownsharKSampler_Beta",
]);
const MODEL_WIDGET_NAMES = ["ckpt_name", "unet_name", "model_name"];
function findConnectedModel(pcNode) {
const graph = app.graph;
if (!graph) return null;
const ksNode = findDownstreamKSampler(pcNode, graph);
if (!ksNode) return null;
return findUpstreamCheckpoint(ksNode, graph)?.filename ?? null;
}
function findDownstreamKSampler(node, graph) {
const visited = new Set();
const queue = [node];
while (queue.length) {
const n = queue.shift();
if (visited.has(n.id)) continue;
visited.add(n.id);
for (const output of n.outputs || []) {
if (!output.links?.length) continue;
for (const lid of output.links) {
const link = getLink(graph, lid);
if (!link) continue;
const target = graph.getNodeById(link.target_id);
if (!target) continue;
if (KSAMPLER_TYPES.has(target.comfyClass)) return target;
queue.push(target);
}
}
}
return null;
}
function findUpstreamCheckpoint(ksNode, graph) {
const visited = new Set();
const queue = [ksNode];
while (queue.length) {
const n = queue.shift();
if (visited.has(n.id)) continue;
visited.add(n.id);
for (const input of n.inputs || []) {
if (input.link == null) continue;
const link = getLink(graph, input.link);
if (!link) continue;
const source = graph.getNodeById(link.origin_id);
if (!source) continue;
for (const wName of MODEL_WIDGET_NAMES) {
const w = source.widgets?.find(w => w.name === wName);
if (w?.value) return { filename: w.value, node: source };
}
queue.push(source);
}
}
return null;
}
// ── download helpers (pure data transforms) ────────────────────────
export function resolveDownloadUrl(source) {
if (!source) return "";
if (source.provider === "huggingface") {
return `https://huggingface.co/${source.repo}/resolve/main/${source.path}`;
}
if (source.provider === "civitai") {
return source.download_url || "";
}
return source.url || "";
}
export function extractPrecisions(files) {
const precisions = new Set();
for (const f of files) {
if (f.variants) {
for (const v of f.variants) precisions.add(v.precision);
}
}
return [...precisions];
}
export function resolveFilesForPrecision(files, precision) {
const resolved = [];
for (const f of files) {
if (f.variants) {
const match = f.variants.find(v => v.precision === precision);
if (match) {
resolved.push({
label: f.label,
folder: f.folder,
filename: match.filename,
size_bytes: match.size_bytes,
source: match.source,
});
}
} else {
resolved.push(f);
}
}
return resolved;
}
export function totalSizeForPrecision(files, precision) {
return resolveFilesForPrecision(files, precision)
.reduce((sum, f) => sum + (f.size_bytes || 0), 0);
}
// ── Svelte lazy-load ──────────────────────────────────────────────
const ensureCSS = ensureSvelteCSS;
let svelteModule = null;
const loadModule = createModuleLoader(async () => {
svelteModule = await import("./svelte/promptchain-model-indicator.js");
return svelteModule;
});
// ── model picker (Svelte) ─────────────────────────────────────────
let _activePicker = null;
let _pickerContainer = null;
// showModelPicker is async (lazy module load) — without this guard a second
// trigger mid-await (double-click, or click + the pcr-open-model-picker
// event) passes the _activePicker null-check too, mounts a TWIN picker and
// orphans the first container (it's untracked, so nothing ever removes it).
let _pickerOpening = false;
function closeModelPicker() {
if (_activePicker) {
svelteModule?.destroyModelPicker(_activePicker);
_activePicker = null;
}
if (_pickerContainer) {
_pickerContainer.remove();
_pickerContainer = null;
}
}
async function showModelPicker(pcNode, anchorEl, opts = {}) {
if (_activePicker) { closeModelPicker(); return; }
if (_pickerOpening) return; // the in-flight open wins
_pickerOpening = true;
try {
await showModelPickerInner(pcNode, anchorEl, opts);
} finally {
_pickerOpening = false;
}
}
async function showModelPickerInner(pcNode, anchorEl, opts) {
closeModelModal();
ensureCSS();
const mod = await loadModule();
_pickerContainer = document.createElement("div");
document.body.appendChild(_pickerContainer);
_activePicker = mod.mountModelPicker(_pickerContainer, {
anchorEl,
focusModelFile: opts.focusModelFile || null,
recognition: _recognition,
// Offer "(Current Workflow)" in the template submenu only when an in-place swap
// is possible (the current network is a checkpoint graph).
hasCheckpointGraph: hasCheckpointGraph(pcNode),
onSwapInPlace: (filename, hash) => {
fetch(`/promptchain/models/settings/${hash}`).then(r => r.ok ? r.json() : null)
.then(settings => swapModelInPlace(pcNode, filename, settings))
.catch(() => {});
closeModelPicker();
},
onSelectModel: (model, template) => {
// Load the model's saved settings, then prefer an IN-PLACE swap when a
// compatible checkpoint graph already exists (preserves custom wiring like the
// regional couple). Only rebuild from the template when there's nothing to
// preserve or the new model isn't checkpoint-loadable.
fetch(`/promptchain/models/settings/${model.hash}`).then(r => r.ok ? r.json() : null)
.then(settings => {
if (swapModelInPlace(pcNode, model.filename, settings)) return;
if (template) {
template._presetNodes = settings?.nodes || {};
applyTemplate(pcNode, template, model.filename);
}
})
.catch(() => { if (template) applyTemplate(pcNode, template, model.filename); });
closeModelPicker();
},
onOpenSettings: (model) => {
closeModelPicker();
anchorEl.classList.add("pcr-model-indicator-open");
showModelModal(pcNode, model, anchorEl);
},
onShowDownload: (civitaiResult) => {
closeModelPicker();
showDownloadModal(civitaiResult, pcNode, anchorEl);
},
onShowCatalogDownload: (catalogEntry, initialPrecision) => {
closeModelPicker();
showCatalogDownloadModal(catalogEntry, pcNode, anchorEl, initialPrecision);
},
onClose: () => closeModelPicker(),
applyTemplate: (tpl, filename, presetNodes) => {
tpl._presetNodes = presetNodes || {};
applyTemplate(pcNode, tpl, filename);
closeModelPicker();
},
});
}
// ── download modal (Svelte) ───────────────────────────────────────
let _activeDownload = null;
let _downloadContainer = null;
function closeDownloadModal() {
if (_activeDownload) {
svelteModule?.destroyDownloadModal(_activeDownload);
_activeDownload = null;
}
if (_downloadContainer) {
_downloadContainer.remove();
_downloadContainer = null;
}
}
async function showDownloadModal(civitaiResult, pcNode, indicatorEl, opts = {}) {
closeDownloadModal();
ensureCSS();
const mod = await loadModule();
_downloadContainer = document.createElement("div");
document.body.appendChild(_downloadContainer);
_activeDownload = mod.mountDownloadModal(_downloadContainer, {
civitaiResult,
onClose: () => closeDownloadModal(),
onBeforeRestart: opts.onBeforeRestart,
onModelReady: (filename) => {
closeDownloadModal();
// Let the caller decide what "done" means. Default: re-open the
// picker so the user can click the freshly-scanned model. The
// "download newer version" flow swapped the ckpt widget during
// onBeforeRestart (atomic with the user clicking Restart) and
// skips the picker.
if (opts.onModelReady) opts.onModelReady(filename);
else showModelPicker(pcNode, indicatorEl, { focusModelFile: filename });
},
});
}
// ── catalog download modal (Svelte) ───────────────────────────────
let _activeCatalogDownload = null;
let _catalogContainer = null;
function closeCatalogDownloadModal() {
if (_activeCatalogDownload) {
svelteModule?.destroyCatalogDownloadModal(_activeCatalogDownload);
_activeCatalogDownload = null;
}
if (_catalogContainer) {
_catalogContainer.remove();
_catalogContainer = null;
}
}
async function showCatalogDownloadModal(catalogEntry, pcNode, indicatorEl, initialPrecision = null) {
closeCatalogDownloadModal();
ensureCSS();
const mod = await loadModule();
_catalogContainer = document.createElement("div");
document.body.appendChild(_catalogContainer);
_activeCatalogDownload = mod.mountCatalogDownloadModal(_catalogContainer, {
catalogEntry,
initialPrecision,
onClose: () => closeCatalogDownloadModal(),
onModelReady: (filename) => {
closeCatalogDownloadModal();
// Re-open the picker focused on the just-installed model so its template
// choice is front-and-center (single template auto-applies).
showModelPicker(pcNode, indicatorEl, { focusModelFile: filename });
},
});
}
export { showCatalogDownloadModal };
registerCatalogDownloadModal(showCatalogDownloadModal);
registerDownloadModal(showDownloadModal);
// ── node-pack install modal (Svelte) ──────────────────────────────
let _activeNodePack = null;
let _nodePackContainer = null;
function closeNodePackInstallModal() {
if (_activeNodePack) {
svelteModule?.destroyNodePackInstallModal(_activeNodePack);
_activeNodePack = null;
}
if (_nodePackContainer) {
_nodePackContainer.remove();
_nodePackContainer = null;
}
}
async function showNodePackInstallModal(injectable, onReady, onClose) {
closeNodePackInstallModal();
ensureCSS();
const mod = await loadModule();
_nodePackContainer = document.createElement("div");
document.body.appendChild(_nodePackContainer);
_activeNodePack = mod.mountNodePackInstallModal(_nodePackContainer, {
injectable,
onClose: () => { closeNodePackInstallModal(); onClose?.(); },
onReady: () => onReady?.(),
});
}
registerNodePackInstallModal(showNodePackInstallModal);
// ── shared cleanup state for onNodeRemoved (installed once) ──────
const _nodeCleanups = new Map(); // nodeId → { update, abort }
let _onNodeRemovedInstalled = false;
function installNodeRemovedHandler() {
if (_onNodeRemovedInstalled || !app.graph) return;
_onNodeRemovedInstalled = true;
const origOnNodeRemoved = app.graph.onNodeRemoved;
app.graph.onNodeRemoved = function (removedNode) {
origOnNodeRemoved?.apply(this, arguments);
const cleanup = _nodeCleanups.get(removedNode.id);
if (cleanup) {
cleanup.abort.abort();
_nodeCleanups.delete(removedNode.id);
}
// Notify surviving indicators to re-trace after topology change
for (const entry of _nodeCleanups.values()) entry.update();
};
}
// ── createModelIndicator (vanilla — tightly coupled to ComfyUI footer) ──
export function createModelIndicator(node) {
const el = document.createElement("div");
el.className = "pcr-model-indicator pcr-model-disconnected";
const label = document.createElement("span");
label.className = "pcr-model-indicator-label";
label.textContent = "No Model";
el.appendChild(label);
const arrow = document.createElement("span");
arrow.className = "pcr-model-indicator-arrow";
arrow.textContent = "▾";
el.appendChild(arrow);
let currentFilename = null;
let _modelInfo = null;
let _prevHash = null;
let _updateGeneration = 0;
let _trackedCheckpointId = null;
function update() {
const graph = app.graph;
let filename = null;
let ckptNode = null;
if (graph) {
const ksNode = findDownstreamKSampler(node, graph);
if (ksNode) {
const ckpt = findUpstreamCheckpoint(ksNode, graph);
if (ckpt) { filename = ckpt.filename; ckptNode = ckpt.node; }
}
}
currentFilename = filename;
if (ckptNode && ckptNode.id !== _trackedCheckpointId) {
_trackedCheckpointId = ckptNode.id;
const w = ckptNode.widgets?.find(w => MODEL_WIDGET_NAMES.includes(w.name));
if (w && !w._pcrHooked) {
w._pcrHooked = true;
const origCb = w.callback;
w.callback = function (...args) {
origCb?.apply(this, args);
update();
};
}
}
const gen = ++_updateGeneration;
if (filename) {
label.textContent = filename;
label.title = filename;
el.className = "pcr-model-indicator pcr-model-connected";
fetch(`/promptchain/models/identity?file=${encodeURIComponent(filename)}`)
.then(r => r.json())
.then(data => {
if (gen !== _updateGeneration) return;
if (data.error) return;
_modelInfo = data;
const hashChanged = data.hash !== _prevHash;
_prevHash = data.hash;
if (hashChanged) {
resetTagAutoApply();
autoApplyTagSources(data.hash);
}
return fetch(`/promptchain/models/settings/${data.hash}`).then(r => r.ok ? r.json() : null);
})
.then(settings => {
if (gen !== _updateGeneration) return;
if (settings) {
if (settings.display_name && settings.display_name !== filename) {
label.textContent = settings.display_name;
label.title = `${settings.display_name}\n${filename}`;
}
return;
}
el.classList.add("pcr-model-detecting");
return fetch("/promptchain/models/detect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hash: _modelInfo.hash }),
})
.then(r => r.ok ? r.json() : null)
.then(result => {
if (gen !== _updateGeneration) return;
el.classList.remove("pcr-model-detecting");
if (result?.status === "found" && result.settings?.display_name) {
label.textContent = result.settings.display_name;
label.title = `${result.settings.display_name}\n${filename}`;
window.dispatchEvent(new CustomEvent("pcr-model-detected", {
detail: { hash: _modelInfo.hash, settings: result.settings },
}));
}
});
})
.catch(() => { el.classList.remove("pcr-model-detecting"); });
} else {
label.textContent = "No Model";
label.title = "";
el.className = "pcr-model-indicator pcr-model-disconnected";
_modelInfo = null;
currentFilename = null;
if (_prevHash) {
_prevHash = null;
resetTagAutoApply();
}
}
}
el.addEventListener("click", (e) => {
e.stopPropagation();
const freshFilename = findConnectedModel(node);
if (!freshFilename) {
label.textContent = "No Model";
el.className = "pcr-model-indicator pcr-model-disconnected";
_modelInfo = null;
currentFilename = null;
closeModelModal();
showModelPicker(node, el);
return;
}
// Always re-resolve identity on click. The cached _modelInfo can
// be stale — e.g. after a download+restart the widget swap fires
// its callback while the server is offline, the identity fetch
// fails, and _modelInfo keeps pointing at the previous version's
// hash. Opening the settings modal with that stale hash shows
// the old version as active even though the node is now on the
// new file. Small latency cost (local /identity call) in exchange
// for always-correct state.
//
// Only touch the label when the filename actually changed — if it
// didn't, the existing "JANKU Trained NoobAI - v7.77" text is
// already correct, and slamming in the raw filename while the
// fetch is in flight creates a visible flicker back to the raw
// filename for ~50ms.
const filenameChanged = freshFilename !== currentFilename;
currentFilename = freshFilename;
if (filenameChanged) {
label.textContent = freshFilename;
label.title = freshFilename;
}
el.className = "pcr-model-indicator pcr-model-connected";
const gen = ++_updateGeneration;
fetch(`/promptchain/models/identity?file=${encodeURIComponent(freshFilename)}`)
.then(r => r.json())
.then(data => {
if (gen !== _updateGeneration) return null;
if (data.error) {
// Identity unknown — fall back to the picker so the user
// can recover. Leaves _modelInfo intact in case a stale
// version is still better than nothing for future clicks.
closeModelModal();
showModelPicker(node, el);
return null;
}
const hashChanged = data.hash !== _prevHash;
_modelInfo = data;
_prevHash = data.hash;
if (hashChanged) {
resetTagAutoApply();
autoApplyTagSources(data.hash);
}
return fetch(`/promptchain/models/settings/${data.hash}`).then(r => r.ok ? r.json() : null);
})
.then(settings => {
if (gen !== _updateGeneration || !_modelInfo) return;
if (settings?.display_name && settings.display_name !== freshFilename) {
label.textContent = settings.display_name;
label.title = `${settings.display_name}\n${freshFilename}`;
}
closeModelPicker();
el.classList.add("pcr-model-indicator-open");
showModelModal(node, _modelInfo, el);
})
.catch(() => {});
});
const abortController = new AbortController();
const { signal } = abortController;
window.addEventListener("pcr-open-model-picker", (e) => {
if (e.detail.nodeId === node.id) showModelPicker(node, el);
}, { signal });
window.addEventListener("pcr-model-renamed", (e) => {
if (_modelInfo && e.detail.hash === _modelInfo.hash) {
label.textContent = e.detail.name;
label.title = `${e.detail.name}\n${currentFilename}`;
}
}, { signal });
installNodeRemovedHandler();
_nodeCleanups.set(node.id, { update, abort: abortController });
requestAnimationFrame(update);
return { element: el, update, getModelInfo: () => _modelInfo };
}