1331 lines
60 KiB
JavaScript
1331 lines
60 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
||
import { api } from "../../scripts/api.js";
|
||
|
||
const EXTENSION = "ComfyUI.FindModels";
|
||
let lastResult = null;
|
||
let toolbarObserver = null;
|
||
let downloadMonitor = null;
|
||
let workflowMonitor = null;
|
||
let activeTab = "models";
|
||
let scanRequestId = 0;
|
||
let scanTimer = null;
|
||
let enrichTimer = null;
|
||
let observedWorkflowSignature = "";
|
||
let nativeDockState = null;
|
||
const resolvedNodePackages = new Set();
|
||
const nodeActivities = new Map();
|
||
const downloadStatuses = new Map();
|
||
const confirmedModelSelections = new Map();
|
||
|
||
function normalizedModelValue(value) {
|
||
return typeof value === "string" ? value.replaceAll("\\", "/").replace(/^\/+/, "") : "";
|
||
}
|
||
|
||
function collectModelValues(value, output = []) {
|
||
if (typeof value === "string") {
|
||
if (/\.(bin|ckpt|gguf|onnx|pt|pt2|pth|pkl|safetensors|sft)$/i.test(value)) {
|
||
output.push(value);
|
||
}
|
||
return output;
|
||
}
|
||
if (Array.isArray(value)) {
|
||
value.forEach((item) => collectModelValues(item, output));
|
||
return output;
|
||
}
|
||
if (value && typeof value === "object") {
|
||
Object.values(value).forEach((item) => collectModelValues(item, output));
|
||
}
|
||
return output;
|
||
}
|
||
|
||
function modelSelectionKey(nodeId, widgetName) {
|
||
return `${String(nodeId)}:${String(widgetName || "")}`;
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
|
||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||
})[char]);
|
||
}
|
||
|
||
async function copyText(value) {
|
||
if (navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(String(value));
|
||
return;
|
||
}
|
||
const input = document.createElement("textarea");
|
||
input.value = String(value);
|
||
input.style.position = "fixed";
|
||
input.style.opacity = "0";
|
||
document.body.appendChild(input);
|
||
input.select();
|
||
document.execCommand("copy");
|
||
input.remove();
|
||
}
|
||
|
||
function formatSize(value) {
|
||
const size = Number(value);
|
||
if (!Number.isFinite(size) || size <= 0) return "大小未知";
|
||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||
const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
|
||
return `${(size / (1024 ** index)).toFixed(index > 1 ? 2 : 0)} ${units[index]}`;
|
||
}
|
||
|
||
function statusText(status) {
|
||
return {
|
||
adaptable: "路径不一致,可一键加载",
|
||
missing: "缺失",
|
||
}[status] || status;
|
||
}
|
||
|
||
function downloadProgressText(job) {
|
||
const downloaded = formatSize(job.downloaded);
|
||
const total = Number(job.total);
|
||
const details = [
|
||
job.speed ? `速度 ${formatSize(job.speed)}/s` : "",
|
||
job.elapsed ? `已耗时 ${formatDuration(job.elapsed)}` : "",
|
||
job.eta ? `预计剩余 ${formatDuration(job.eta)}` : "",
|
||
].filter(Boolean).join(" · ");
|
||
if (!Number.isFinite(total) || total <= 0) return `已下载 ${downloaded}${details ? ` · ${details}` : ""}`;
|
||
const percent = Math.min(100, Math.round((Number(job.downloaded || 0) / total) * 100));
|
||
return `已下载 ${downloaded} / ${formatSize(total)} · ${percent}%${details ? ` · ${details}` : ""}`;
|
||
}
|
||
|
||
function formatDuration(value) {
|
||
const seconds = Math.max(0, Math.round(Number(value) || 0));
|
||
const hours = Math.floor(seconds / 3600);
|
||
const minutes = Math.floor((seconds % 3600) / 60);
|
||
const rest = seconds % 60;
|
||
return hours ? `${hours}小时${minutes}分` : minutes ? `${minutes}分${rest}秒` : `${rest}秒`;
|
||
}
|
||
|
||
function downloadStatusText(job) {
|
||
if (job.status === "failed") return `失败:${job.error || "未知错误"}`;
|
||
if (job.status === "paused") return `已暂停 · ${downloadProgressText(job)}`;
|
||
if (job.status === "pausing") return `正在暂停 · ${downloadProgressText(job)}`;
|
||
if (job.status === "cancelled") return "已取消";
|
||
if (job.status === "completed") return `下载完成 · ${formatSize(job.total)}`;
|
||
if (job.status === "queued") return `等待下载 · ${downloadProgressText(job)}`;
|
||
return downloadProgressText(job);
|
||
}
|
||
|
||
function downloadControlsHtml(job) {
|
||
const pause = ["queued", "downloading"].includes(job.status)
|
||
? `<button data-download-control="pause" data-job-id="${escapeHtml(job.id)}">暂停</button>` : "";
|
||
const resume = ["paused", "failed"].includes(job.status)
|
||
? `<button data-download-control="resume" data-job-id="${escapeHtml(job.id)}">${job.status === "failed" ? "重试" : "继续"}</button>` : "";
|
||
const cancel = !["completed", "cancelled"].includes(job.status)
|
||
? `<button data-download-control="cancel" data-job-id="${escapeHtml(job.id)}">取消</button>` : "";
|
||
return pause + resume + cancel;
|
||
}
|
||
|
||
function isExplicitModelWidget(name) {
|
||
const normalized = String(name || "").toLowerCase().replace(/[^a-z0-9_]+/g, "");
|
||
const exact = new Set([
|
||
"ckpt_name", "checkpoint_name", "lora_name", "vae_name", "control_net_name",
|
||
"controlnet_name", "clip_name", "clip_vision", "clip_vision_name", "text_encoder_name",
|
||
"unet_name", "diffusion_model", "diffusion_model_name", "upscale_model",
|
||
"upscale_model_name", "embedding_name", "lora", "loras", "lycoris", "lycorises",
|
||
]);
|
||
return exact.has(normalized)
|
||
|| /^(?:lora|lycoris)_?\d+$/.test(normalized)
|
||
|| /^(?:ckpt|checkpoint|vae|controlnet|control_net|clip|clip_vision|text_encoder|unet|diffusion_model|upscale_model)_?\d+$/.test(normalized)
|
||
|| /^(?:model|model_name)_\d+$/.test(normalized);
|
||
}
|
||
|
||
function isModelNodeType(type) {
|
||
const normalized = String(type || "").toLowerCase().replace(/[^a-z0-9_]+/g, "");
|
||
return [
|
||
"loader", "model", "instantid", "instant_id", "ipadapter", "ip_adapter",
|
||
"samloader", "sam_loader", "ultralytics", "detector",
|
||
"checkpoint", "lora", "vae", "controlnet", "clip", "textencode",
|
||
"text_encoder", "unet", "diffusion", "upscale", "embedding",
|
||
].some((hint) => normalized.includes(hint));
|
||
}
|
||
|
||
function workflowSnapshot() {
|
||
let serializedGraph = null;
|
||
try {
|
||
serializedGraph = app.graph?.serialize?.();
|
||
} catch (error) {
|
||
console.debug("[ComfyUI_FindModels] Unable to serialize graph", error);
|
||
}
|
||
const nodes = app.graph?._nodes?.map((node) => {
|
||
let serialized = null;
|
||
try {
|
||
serialized = node.serialize?.();
|
||
} catch (error) {
|
||
console.debug("[ComfyUI_FindModels] Unable to serialize node", node.type, error);
|
||
}
|
||
const widgetsValues = serialized?.widgets_values || [];
|
||
const selectedValues = new Set([
|
||
...collectModelValues(widgetsValues),
|
||
...(node.widgets || []).flatMap((widget) => collectModelValues(widget.value)),
|
||
].filter((value) => typeof value === "string"));
|
||
const properties = serialized?.properties || node.properties || {};
|
||
const packageId = [properties.aux_id, properties.cnr_id, properties.package_id]
|
||
.find((value) => typeof value === "string" && value.trim());
|
||
const packageVersion = [properties.ver, properties.version]
|
||
.find((value) => typeof value === "string" && value.trim());
|
||
return {
|
||
id: node.id,
|
||
type: node.type,
|
||
package_id: packageId || null,
|
||
package_version: packageVersion || null,
|
||
active: ![2, 4].includes(node.mode),
|
||
frontend_registered: Boolean(
|
||
globalThis.LiteGraph?.registered_node_types?.[node.type]
|
||
|| node.constructor?.type === node.type
|
||
|| node.constructor?.nodeData?.name === node.type
|
||
),
|
||
models: (node.properties?.models || []).filter((model) => selectedValues.has(model.name)),
|
||
widgets_values: widgetsValues,
|
||
widgets: (node.widgets || []).flatMap((widget) => {
|
||
let rawValues = widget.options?.values;
|
||
if (typeof rawValues === "function") {
|
||
try {
|
||
rawValues = rawValues();
|
||
} catch (error) {
|
||
console.debug("[ComfyUI_FindModels] Unable to inspect combo values", widget.name, error);
|
||
}
|
||
}
|
||
const values = Array.isArray(rawValues) ? rawValues : [];
|
||
const hasModelOptions = values.some((value) =>
|
||
typeof value === "string" && /\.(bin|ckpt|gguf|onnx|pt|pth|safetensors|sft)$/i.test(value)
|
||
);
|
||
const isAsset = widget.type === "asset";
|
||
const nestedModelValues = collectModelValues(widget.value);
|
||
const isModelSelector = isAsset || hasModelOptions || isExplicitModelWidget(widget.name)
|
||
|| (isModelNodeType(node.type) && nestedModelValues.length > 0);
|
||
const normalizedValue = normalizedModelValue(widget.value) || null;
|
||
const normalizedNestedValues = nestedModelValues
|
||
.map((value) => normalizedModelValue(value))
|
||
.filter(Boolean);
|
||
const selectionKey = modelSelectionKey(node.id, widget.name);
|
||
const confirmedValue = confirmedModelSelections.get(selectionKey);
|
||
if (
|
||
confirmedValue
|
||
&& confirmedValue !== normalizedValue
|
||
&& !normalizedNestedValues.includes(confirmedValue)
|
||
) {
|
||
confirmedModelSelections.delete(selectionKey);
|
||
}
|
||
const optionValueValid = hasModelOptions && (
|
||
(normalizedValue !== null && values.some((value) =>
|
||
typeof value === "string" && normalizedModelValue(value) === normalizedValue
|
||
))
|
||
|| normalizedNestedValues.some((nestedValue) => values.some((value) =>
|
||
typeof value === "string" && normalizedModelValue(value) === nestedValue
|
||
))
|
||
);
|
||
const modelValueValid = (
|
||
confirmedValue
|
||
&& (confirmedValue === normalizedValue || normalizedNestedValues.includes(confirmedValue))
|
||
)
|
||
? true
|
||
: optionValueValid
|
||
? true
|
||
: null;
|
||
const modelMetadata = (node.properties?.models || []).find((model) =>
|
||
typeof widget.value === "string" && model.name === widget.value
|
||
);
|
||
return [{
|
||
name: widget.name,
|
||
type: widget.type,
|
||
value: widget.value,
|
||
model_selector: isModelSelector,
|
||
model_value_valid: modelValueValid,
|
||
asset_selector: isAsset,
|
||
directory: modelMetadata?.directory
|
||
|| widget.options?.directory
|
||
|| widget.options?.folder
|
||
|| widget.options?.model_folder,
|
||
source_url: modelMetadata?.url,
|
||
source_hash: modelMetadata?.hash,
|
||
source_hash_type: modelMetadata?.hash_type,
|
||
source_size: modelMetadata?.size,
|
||
}];
|
||
}),
|
||
};
|
||
}) || [];
|
||
return {
|
||
models: serializedGraph?.models || [],
|
||
nodes,
|
||
};
|
||
}
|
||
|
||
function workflowSignature(snapshot) {
|
||
return JSON.stringify({
|
||
nodes: (snapshot.nodes || []).map((node) => [
|
||
node.id,
|
||
node.type,
|
||
node.active,
|
||
node.widgets_values,
|
||
]),
|
||
models: (snapshot.models || []).map((model) => [
|
||
model.name,
|
||
model.directory,
|
||
model.url,
|
||
model.hash,
|
||
model.size,
|
||
]),
|
||
});
|
||
}
|
||
|
||
function removeResolvedModel(name) {
|
||
if (!lastResult?.models) return;
|
||
const normalized = normalizedModelValue(name);
|
||
lastResult.models = lastResult.models.filter((model) => normalizedModelValue(model.name) !== normalized);
|
||
lastResult.summary = { ...lastResult.summary, unresolved: lastResult.models.length };
|
||
render(lastResult);
|
||
observedWorkflowSignature = workflowSignature(workflowSnapshot());
|
||
}
|
||
|
||
function scheduleScan(delay = 0, quick = true) {
|
||
window.clearTimeout(scanTimer);
|
||
scanTimer = window.setTimeout(() => scan(true, { quick }), delay);
|
||
}
|
||
|
||
function scheduleEnrichedScan(delay = 180) {
|
||
window.clearTimeout(enrichTimer);
|
||
enrichTimer = window.setTimeout(() => scan(true, { quick: false }), delay);
|
||
}
|
||
|
||
function startWorkflowMonitor() {
|
||
window.clearInterval(workflowMonitor);
|
||
observedWorkflowSignature = workflowSignature(workflowSnapshot());
|
||
workflowMonitor = window.setInterval(() => {
|
||
const signature = workflowSignature(workflowSnapshot());
|
||
if (signature === observedWorkflowSignature) return;
|
||
observedWorkflowSignature = signature;
|
||
resolvedNodePackages.clear();
|
||
scanRequestId += 1;
|
||
scheduleScan(0, true);
|
||
}, 250);
|
||
}
|
||
|
||
async function post(path, body) {
|
||
const response = await api.fetchApi(path, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!response.ok) throw new Error(await response.text());
|
||
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 downloadJobsHtml(jobs, nodeJobs = [...nodeActivities.values()]) {
|
||
if (!jobs.length && !nodeJobs.length) {
|
||
return '<div class="fm-empty"><strong>暂无下载任务</strong><span>模型下载与节点安装进度会统一显示在这里</span></div>';
|
||
}
|
||
const modelJobs = jobs.map((job) => `
|
||
<div class="fm-download-job fm-download-${escapeHtml(job.status)}">
|
||
<span class="fm-job-kind">模型</span>
|
||
<div class="fm-download-job-info"><strong>${escapeHtml(job.filename)}</strong><span>${escapeHtml(downloadStatusText(job))}</span></div>
|
||
<div class="fm-download-controls">${downloadControlsHtml(job)}</div>
|
||
</div>`).join("");
|
||
const pluginJobs = nodeJobs.map((job) => `
|
||
<div class="fm-download-job fm-download-${escapeHtml(job.status)}">
|
||
<span class="fm-job-kind fm-job-node">节点</span>
|
||
<div class="fm-download-job-info"><strong>${escapeHtml(job.title)}</strong><span>${escapeHtml(job.message)}</span>
|
||
<div class="fm-install-progress ${job.status === "downloading" ? "active" : ""}"><i style="width:${Math.max(0, Math.min(100, Number(job.progress) || 0))}%"></i></div>
|
||
</div>
|
||
</div>`).join("");
|
||
return `<section class="fm-download-jobs">${modelJobs}${pluginJobs}</section>`;
|
||
}
|
||
|
||
async function refreshDownloadJobs() {
|
||
const panel = ensurePanel();
|
||
try {
|
||
const { jobs = [] } = await get("/findmodels/download/jobs");
|
||
panel.querySelector(".fm-downloads").innerHTML = downloadJobsHtml(jobs);
|
||
panel.querySelector('[data-tab-count="downloads"]').textContent =
|
||
jobs.filter((job) => !["completed", "cancelled"].includes(job.status)).length
|
||
+ [...nodeActivities.values()].filter((job) => job.status === "downloading").length;
|
||
const sectionCount = panel.querySelector('[data-section-count="downloads"]');
|
||
if (sectionCount) sectionCount.textContent =
|
||
jobs.filter((job) => !["completed", "cancelled"].includes(job.status)).length
|
||
+ [...nodeActivities.values()].filter((job) => job.status === "downloading").length;
|
||
for (const job of jobs) {
|
||
const previous = downloadStatuses.get(job.id);
|
||
downloadStatuses.set(job.id, job.status);
|
||
if (job.status === "completed" && previous && previous !== "completed") {
|
||
const model = lastResult?.models?.find((item) => item.name === job.original);
|
||
await applyMatchEverywhere(model || {
|
||
node_id: job.node_id,
|
||
widget: job.widget,
|
||
name: job.original,
|
||
match: { name: job.result?.relative_name },
|
||
});
|
||
removeResolvedModel(job.original);
|
||
scheduleScan(0, true);
|
||
scheduleEnrichedScan();
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn("[ComfyUI_FindModels] Unable to refresh download jobs", error);
|
||
}
|
||
}
|
||
|
||
function startDownloadMonitor() {
|
||
window.clearInterval(downloadMonitor);
|
||
refreshDownloadJobs();
|
||
downloadMonitor = window.setInterval(refreshDownloadJobs, 750);
|
||
}
|
||
|
||
function findTargetWidget(node, model) {
|
||
const widgets = node?.widgets || [];
|
||
return widgets.find((item) => item.name === model.widget)
|
||
|| widgets.find((item) => item.value === model.name)
|
||
|| widgets.find((item) => typeof item.value === "string" && item.value.replaceAll("\\", "/") === model.name)
|
||
|| widgets.find((item) => modelValueContains(item.value, model.name));
|
||
}
|
||
|
||
function modelValueContains(value, target) {
|
||
if (typeof value === "string") {
|
||
return modelValueMatches(value, target);
|
||
}
|
||
if (Array.isArray(value)) {
|
||
return value.some((item) => modelValueContains(item, target));
|
||
}
|
||
if (value && typeof value === "object") {
|
||
return Object.values(value).some((item) => modelValueContains(item, target));
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function modelValueMatches(value, target) {
|
||
const normalizedValue = normalizedModelValue(value);
|
||
const normalizedTarget = normalizedModelValue(target);
|
||
if (!normalizedValue || !normalizedTarget) return false;
|
||
return normalizedValue === normalizedTarget
|
||
|| normalizedValue.split("/").pop()?.toLowerCase() === normalizedTarget.split("/").pop()?.toLowerCase();
|
||
}
|
||
|
||
function replaceModelValue(value, target, replacement) {
|
||
if (typeof value === "string") {
|
||
return modelValueMatches(value, target)
|
||
? { value: replacement, changed: true }
|
||
: { value, changed: false };
|
||
}
|
||
if (Array.isArray(value)) {
|
||
let changed = false;
|
||
const next = value.map((item) => {
|
||
const result = replaceModelValue(item, target, replacement);
|
||
changed = changed || result.changed;
|
||
return result.value;
|
||
});
|
||
return { value: changed ? next : value, changed };
|
||
}
|
||
if (value && typeof value === "object") {
|
||
let changed = false;
|
||
const next = {};
|
||
for (const [key, item] of Object.entries(value)) {
|
||
const result = replaceModelValue(item, target, replacement);
|
||
changed = changed || result.changed;
|
||
next[key] = result.value;
|
||
}
|
||
return { value: changed ? next : value, changed };
|
||
}
|
||
return { value, changed: false };
|
||
}
|
||
|
||
async function applyMatch(model) {
|
||
const numericId = Number(model.node_id);
|
||
const node = app.graph?.getNodeById?.(Number.isNaN(numericId) ? model.node_id : numericId)
|
||
|| app.graph?._nodes?.find((item) => String(item.id) === String(model.node_id));
|
||
const widget = findTargetWidget(node, model);
|
||
if (!node || !widget || !model.match?.name) return false;
|
||
|
||
const previous = widget.value;
|
||
try {
|
||
node.graph?.beforeChange?.(node);
|
||
if (Array.isArray(widget.options?.values) && !widget.options.values.includes(model.match.name)) {
|
||
widget.options.values.push(model.match.name);
|
||
}
|
||
const nested = replaceModelValue(widget.value, model.name, model.match.name);
|
||
widget.value = nested.changed ? nested.value : model.match.name;
|
||
if (typeof widget.callback === "function") {
|
||
await Promise.resolve(widget.callback(widget.value, app.canvas, node, [0, 0], {}));
|
||
}
|
||
node.onWidgetChanged?.(widget.name, widget.value, previous, widget);
|
||
node.graph?.afterChange?.(node);
|
||
node.setDirtyCanvas?.(true, true);
|
||
app.graph?.setDirtyCanvas?.(true, true);
|
||
app.canvas?.setDirty?.(true, true);
|
||
const applied = normalizedModelValue(widget.value) === normalizedModelValue(model.match.name)
|
||
|| modelValueContains(widget.value, model.match.name);
|
||
if (applied) {
|
||
confirmedModelSelections.set(
|
||
modelSelectionKey(node.id, widget.name),
|
||
normalizedModelValue(widget.value) || normalizedModelValue(model.match.name),
|
||
);
|
||
}
|
||
return applied;
|
||
} catch (error) {
|
||
widget.value = previous;
|
||
node.graph?.afterChange?.(node);
|
||
console.error("[ComfyUI_FindModels] Failed to load model", model, error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function applyMatchEverywhere(model) {
|
||
const references = model.referencing_nodes?.length
|
||
? model.referencing_nodes
|
||
: [{ node_id: model.node_id, widget: model.widget, node_type: model.node_type }];
|
||
let applied = 0;
|
||
for (const reference of references) {
|
||
if (await applyMatch({ ...model, ...reference })) applied += 1;
|
||
}
|
||
return applied === references.length;
|
||
}
|
||
|
||
function locateNode(nodeId) {
|
||
const numericId = Number(nodeId);
|
||
const node = app.graph?.getNodeById?.(Number.isNaN(numericId) ? nodeId : numericId)
|
||
|| app.graph?._nodes?.find((item) => String(item.id) === String(nodeId));
|
||
if (!node) return false;
|
||
app.canvas?.selectNode?.(node);
|
||
app.canvas?.centerOnNode?.(node);
|
||
node.setDirtyCanvas?.(true, true);
|
||
return true;
|
||
}
|
||
|
||
function sourceHtml(item, model) {
|
||
const quark = item.quark ? escapeHtml(JSON.stringify(item.quark)) : "";
|
||
const downloadButton = item.downloadable !== false
|
||
? `<button data-download="${escapeHtml(item.url || "")}" data-quark-download="${quark}"
|
||
data-filename="${escapeHtml(item.name)}"
|
||
data-size="${escapeHtml(item.size || "")}"
|
||
data-category="${escapeHtml(model.category)}" data-node-id="${escapeHtml(model.node_id)}"
|
||
data-widget="${escapeHtml(model.widget)}" data-original="${escapeHtml(model.name)}">下载到模型目录</button>`
|
||
: '<span class="fm-candidate-warning">相似候选,请打开核对</span>';
|
||
return `<div class="fm-source">
|
||
${item.url ? `<a href="${escapeHtml(item.url)}" target="_blank" rel="noopener noreferrer">` : "<span>"}
|
||
${escapeHtml(item.provider)} · ${escapeHtml(item.name)} · ${formatSize(item.size)} · ${Math.round(item.confidence * 100)}%
|
||
${item.url ? "</a>" : "</span>"}
|
||
${downloadButton}
|
||
<div class="fm-download-progress" aria-live="polite"></div>
|
||
</div>`;
|
||
}
|
||
|
||
function officialSourceHtml(model) {
|
||
if (!model.source_url) return "";
|
||
return sourceHtml({
|
||
provider: "工作流内嵌来源",
|
||
name: model.name,
|
||
url: model.source_url,
|
||
size: model.size,
|
||
confidence: 1,
|
||
}, model);
|
||
}
|
||
|
||
function externalCandidatesHtml(model) {
|
||
const candidates = model.external_candidates || [];
|
||
if (!candidates.length) return "";
|
||
return `<div class="fm-external-candidates">${candidates.map((item) => `
|
||
<div class="fm-source fm-external-source">
|
||
<span>外部模型库 · ${escapeHtml(item.path)} · ${formatSize(item.size)}</span>
|
||
${model.category === "unknown" ? '<span class="fm-target-warning">未能从节点注册信息或外部目录识别官方模型分类,请检查该节点插件的模型目录注册。</span>' : `<button class="fm-external-move" data-external-move="${escapeHtml(item.path)}"
|
||
data-name="${escapeHtml(model.name)}" data-category="${escapeHtml(model.category)}"
|
||
data-node-id="${escapeHtml(model.node_id)}" data-widget="${escapeHtml(model.widget)}">
|
||
剪切到模型目录
|
||
</button>`}
|
||
</div>`).join("")}</div>`;
|
||
}
|
||
|
||
function referenceText(model) {
|
||
const count = model.referencing_nodes?.length || (model.node_id ? 1 : 0);
|
||
return count ? `${count} 个引用节点` : "工作流模型元数据";
|
||
}
|
||
|
||
function modelCardHtml(model) {
|
||
try {
|
||
const confidence = Number(model.match?.confidence);
|
||
const exactLocalMatch = model.match?.reason === "exact_filename" && confidence >= 0.99;
|
||
return `
|
||
<article class="fm-item fm-model-card fm-${escapeHtml(model.status || "missing")}">
|
||
<div class="fm-item-title"><span class="fm-status-icon">${model.status === "missing" ? "!" : "↻"}</span><strong title="${escapeHtml(model.name)}">${escapeHtml(model.name)}</strong><button class="fm-copy-name" data-copy-model-name="${escapeHtml(model.name)}" title="复制模型名称">⧉</button></div>
|
||
<div class="fm-meta"><span class="fm-badge">${escapeHtml(model.category || "unknown")}</span><span>${escapeHtml(model.official_missing ? "工作流总览判定缺失" : statusText(model.status))}</span><span>${referenceText(model)}</span><span data-model-size>大小:${formatSize(model.size)}</span></div>
|
||
${exactLocalMatch ? `<div class="fm-match">本地模型候选:${escapeHtml(model.match.name)} (99%)</div>` : ""}
|
||
${externalCandidatesHtml(model)}
|
||
<div class="fm-sources">${officialSourceHtml(model)}</div>
|
||
<div class="fm-item-actions fm-model-actions">
|
||
${model.node_id ? `<button data-locate-node="${escapeHtml(model.node_id)}">定位引用节点</button>` : ""}
|
||
${exactLocalMatch && model.match?.auto_apply ? `<button class="fm-local-load" data-apply="${escapeHtml(model.node_id)}:${escapeHtml(model.widget)}">加载本地模型</button>` : ""}
|
||
<button data-source="${escapeHtml(model.name)}">查找下载来源</button>
|
||
</div>
|
||
</article>`;
|
||
} catch (error) {
|
||
console.error("[ComfyUI_FindModels] Failed to render missing model card", model, error);
|
||
return `
|
||
<article class="fm-item fm-model-card fm-missing">
|
||
<div class="fm-item-title"><span class="fm-status-icon">!</span><strong>${escapeHtml(model?.name || "未知缺失模型")}</strong></div>
|
||
<div class="fm-meta"><span class="fm-badge">${escapeHtml(model?.category || "unknown")}</span><span>模型详情渲染异常,但模型仍然缺失</span></div>
|
||
</article>`;
|
||
}
|
||
}
|
||
|
||
function nodeCandidatesHtml(nodeType, candidates, githubSearchUrl = "", packageId = "") {
|
||
const links = candidates.map((item) => `
|
||
<div class="fm-source">
|
||
<a href="${escapeHtml(item.repo_url)}" target="_blank" rel="noopener noreferrer">
|
||
${escapeHtml(item.title)} · ${escapeHtml(item.author)} · ${Math.round(item.confidence * 100)}%
|
||
</a>
|
||
<button data-node-install="${escapeHtml(item.id)}" data-node-type="${escapeHtml(nodeType)}"
|
||
data-package-id="${escapeHtml(packageId)}">安装或更新插件</button>
|
||
</div>`).join("");
|
||
const searchTerm = packageId || nodeType;
|
||
const searchUrl = githubSearchUrl || `https://github.com/search?q=${encodeURIComponent(`${searchTerm} ComfyUI`)}&type=repositories`;
|
||
return `${links || "<span>官方映射暂未找到精确插件,可使用工作流提供的 GitHub 仓库或自定义链接。</span>"}
|
||
<div class="fm-node-actions">
|
||
<button data-node-refresh="${escapeHtml(nodeType)}" data-package-id="${escapeHtml(packageId)}">重新查找插件</button>
|
||
<a href="${escapeHtml(searchUrl)}" target="_blank" rel="noopener noreferrer">GitHub 搜索</a>
|
||
</div>`;
|
||
}
|
||
|
||
function setActiveTab(panel, tab) {
|
||
activeTab = tab;
|
||
panel.querySelectorAll("[data-tab]").forEach((button) =>
|
||
button.classList.toggle("active", button.dataset.tab === tab)
|
||
);
|
||
panel.querySelectorAll("[data-tab-panel]").forEach((content) =>
|
||
content.classList.toggle("active", content.dataset.tabPanel === tab)
|
||
);
|
||
panel.scrollTop = 0;
|
||
}
|
||
|
||
function installDependenciesEnabled(panel) {
|
||
return panel.querySelector("[data-install-dependencies]")?.checked !== false;
|
||
}
|
||
|
||
async function waitForPropertiesPanel() {
|
||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||
const properties = document.querySelector("[data-testid='properties-panel']");
|
||
if (properties) return properties;
|
||
await new Promise((resolve) => window.setTimeout(resolve, 25));
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function openDockedPanel(panel) {
|
||
if (nativeDockState && panel.classList.contains("native-docked")) return true;
|
||
let properties = document.querySelector("[data-testid='properties-panel']");
|
||
const wasOpen = Boolean(properties);
|
||
let openedByUs = false;
|
||
if (!properties) {
|
||
const toggle = findPropertyPanelButton();
|
||
if (toggle) {
|
||
toggle.click();
|
||
openedByUs = true;
|
||
properties = await waitForPropertiesPanel();
|
||
}
|
||
}
|
||
if (!properties?.parentElement) {
|
||
panel.classList.remove("native-docked");
|
||
panel.classList.add("open");
|
||
return false;
|
||
}
|
||
const host = properties.parentElement;
|
||
nativeDockState = {
|
||
properties,
|
||
host,
|
||
openedByUs,
|
||
wasOpen,
|
||
previousDisplay: properties.style.display,
|
||
};
|
||
properties.style.display = "none";
|
||
properties.setAttribute("aria-hidden", "true");
|
||
host.appendChild(panel);
|
||
panel.classList.add("native-docked", "open");
|
||
return true;
|
||
}
|
||
|
||
function closeDockedPanel(panel) {
|
||
panel.classList.remove("open", "native-docked");
|
||
document.body.appendChild(panel);
|
||
const state = nativeDockState;
|
||
nativeDockState = null;
|
||
if (!state) return;
|
||
state.properties.style.display = state.previousDisplay;
|
||
state.properties.removeAttribute("aria-hidden");
|
||
if (state.openedByUs && !state.wasOpen) {
|
||
window.requestAnimationFrame(() => findPropertyPanelButton()?.click());
|
||
}
|
||
}
|
||
|
||
function enablePanelResize(panel) {
|
||
const handle = panel.querySelector(".fm-resize-handle");
|
||
if (!handle || handle.dataset.ready) return;
|
||
handle.dataset.ready = "true";
|
||
handle.addEventListener("pointerdown", (event) => {
|
||
event.preventDefault();
|
||
handle.setPointerCapture(event.pointerId);
|
||
const startX = event.clientX;
|
||
const startWidth = panel.getBoundingClientRect().width;
|
||
const move = (current) => {
|
||
if (panel.classList.contains("native-docked")) return;
|
||
const width = Math.max(420, Math.min(window.innerWidth - 24, startWidth + startX - current.clientX));
|
||
panel.style.width = `${width}px`;
|
||
localStorage.setItem("findmodels.panelWidth", String(width));
|
||
};
|
||
const up = () => {
|
||
handle.removeEventListener("pointermove", move);
|
||
handle.removeEventListener("pointerup", up);
|
||
};
|
||
handle.addEventListener("pointermove", move);
|
||
handle.addEventListener("pointerup", up);
|
||
});
|
||
}
|
||
|
||
function ensurePanel() {
|
||
let panel = document.getElementById("find-models-panel");
|
||
if (panel) return panel;
|
||
panel = document.createElement("section");
|
||
panel.id = "find-models-panel";
|
||
panel.innerHTML = `
|
||
<div class="fm-header">
|
||
<div class="fm-resize-handle" title="拖动调整宽度"></div>
|
||
<div class="fm-header-copy"><strong>查找缺失模型和节点</strong><span>当前工作流缺失依赖管理</span></div>
|
||
<div class="fm-window-actions"><button data-action="scan" title="扫描当前工作流">↻</button><button data-action="close" title="关闭">×</button></div>
|
||
</div>
|
||
<div class="fm-scan-state"><span class="fm-scan-dot"></span><span data-scan-status>等待扫描当前工作流</span><button data-action="scan">重新扫描</button></div>
|
||
<nav class="fm-tabs">
|
||
<button data-tab="models"><span class="fm-tab-icon">缺</span><strong class="fm-tab-label">缺失模型</strong><b data-tab-count="models">0</b></button>
|
||
<button data-tab="nodes"><span class="fm-tab-icon">节</span><strong class="fm-tab-label">缺失节点</strong><b data-tab-count="nodes">0</b></button>
|
||
<button data-tab="downloads"><span class="fm-tab-icon">↓</span><strong class="fm-tab-label">下载任务</strong><b data-tab-count="downloads">0</b></button>
|
||
<button data-tab="settings"><span class="fm-tab-icon">设</span><strong class="fm-tab-label">设置</strong></button>
|
||
</nav>
|
||
<div class="fm-summary" aria-live="polite">尚未扫描</div>
|
||
<div class="fm-tab-panel" data-tab-panel="models">
|
||
<div class="fm-section-toolbar"><div><strong>缺失模型 <em data-section-count="models">0</em></strong><span>仅显示尚未加载的模型,解决后立即移出</span></div><div><button data-action="scan">↻ 扫描当前工作流</button><button class="fm-primary" data-action="adapt">⇧ 一键加载模型</button></div></div>
|
||
<div class="fm-model-list"></div>
|
||
</div>
|
||
<div class="fm-tab-panel" data-tab-panel="nodes">
|
||
<div class="fm-section-toolbar"><div><strong>缺失节点 <em data-section-count="nodes">0</em></strong><span>仅显示当前工作流尚未安装的节点包</span></div><div><button data-action="scan">↻ 扫描当前工作流</button><button class="fm-primary" data-action="install-missing-nodes">↓ 安装缺失节点</button></div></div>
|
||
<div class="fm-node-install-options">
|
||
<label><input data-install-dependencies type="checkbox" checked> 自动安装插件依赖 requirements.txt</label>
|
||
<div><input data-custom-github type="url" placeholder="输入自定义 GitHub 插件仓库链接"><button data-action="install-custom-github">链接安装插件</button></div>
|
||
</div>
|
||
<div class="fm-node-list"></div>
|
||
</div>
|
||
<div class="fm-tab-panel" data-tab-panel="downloads">
|
||
<div class="fm-section-toolbar"><div><strong>下载任务 <em data-section-count="downloads">0</em></strong><span>模型下载与节点安装任务统一显示</span></div><div><button data-action="refresh-downloads">↻ 刷新任务</button></div></div>
|
||
<div class="fm-downloads"></div>
|
||
</div>
|
||
<div class="fm-tab-panel" data-tab-panel="settings">
|
||
<div class="fm-section-toolbar"><div><strong>设置</strong><span>配置外部模型库与夸克网盘下载能力</span></div></div>
|
||
<div class="fm-settings-card"><strong>外部模型库</strong><span>指定本机外部模型目录,只精确匹配当前工作流缺失的模型文件名。</span>
|
||
<div class="fm-external-config">
|
||
<input data-external-folder type="text" placeholder="请选择外部模型库文件夹" readonly>
|
||
<button data-action="select-external-folder">选择文件夹</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
document.body.appendChild(panel);
|
||
const savedWidth = Number(localStorage.getItem("findmodels.panelWidth"));
|
||
if (Number.isFinite(savedWidth) && savedWidth >= 420) panel.style.width = `${savedWidth}px`;
|
||
enablePanelResize(panel);
|
||
panel.querySelector('[data-tab-panel="settings"]')?.insertAdjacentHTML("beforeend", `
|
||
<div class="fm-settings-card"><strong>夸克模型库</strong><span>“查找下载来源”会分页递归到分享目录最深层,并只下载文件名完全一致的模型。公开分享的大文件可能要求有效登录 Cookie。</span>
|
||
<div class="fm-quark-libraries" data-quark-libraries><span>正在读取分享库状态…</span></div>
|
||
<button data-action="check-quark-libraries">检测分享链接</button>
|
||
<div class="fm-quark-auth-config">
|
||
<input data-quark-cookie type="password" placeholder="可选:夸克登录 Cookie(仅保存在本机)">
|
||
<button data-action="save-quark-cookie">保存</button>
|
||
<button data-action="clear-quark-cookie">清除</button>
|
||
</div>
|
||
<span data-quark-auth-status>正在检查夸克登录态...</span>
|
||
</div>`);
|
||
const renderQuarkLibraries = (result) => {
|
||
const libraries = panel.querySelector("[data-quark-libraries]");
|
||
if (libraries) libraries.innerHTML = (result.libraries || []).map((library) => `
|
||
<div class="fm-quark-library">
|
||
<div><strong>${escapeHtml(library.name)}</strong><a href="${escapeHtml(library.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(library.url)}</a></div>
|
||
<span class="${library.reachable === false ? "failed" : "ready"}">${library.reachable === false ? "连接失败" : library.reachable === true ? "连接正常" : "已配置"}</span>
|
||
</div>`).join("");
|
||
};
|
||
get("/findmodels/quark-auth").then((result) => {
|
||
renderQuarkLibraries(result);
|
||
const status = panel.querySelector("[data-quark-auth-status]");
|
||
if (status) status.textContent = result.configured ? "已保存夸克登录态,可重试直链下载。" : "未保存夸克登录态,公开分享大文件可能被夸克限制。";
|
||
}).catch(() => {});
|
||
panel.querySelectorAll("[data-tab]").forEach((button) => {
|
||
button.onclick = () => setActiveTab(panel, button.dataset.tab);
|
||
});
|
||
setActiveTab(panel, activeTab);
|
||
panel.querySelector('[data-action="close"]').onclick = () => {
|
||
closeDockedPanel(panel);
|
||
};
|
||
panel.querySelectorAll('[data-action="scan"]').forEach((button) => {
|
||
button.onclick = () => scheduleScan(0, true);
|
||
});
|
||
panel.querySelector('[data-action="refresh-downloads"]').onclick = () => refreshDownloadJobs();
|
||
panel.querySelector('[data-action="install-missing-nodes"]').onclick = async () => {
|
||
const button = panel.querySelector('[data-action="install-missing-nodes"]');
|
||
button.disabled = true;
|
||
let installed = 0;
|
||
const failures = [];
|
||
const packages = lastResult?.missing_node_packages || [];
|
||
for (const nodePackage of packages) {
|
||
const nodeType = nodePackage.node_types?.[0] || "";
|
||
const packageId = nodePackage.known ? nodePackage.id : "";
|
||
const activityId = packageId || nodeType;
|
||
nodeActivities.set(activityId, { title: nodePackage.title, status: "downloading", progress: 15, message: "正在查找可信插件来源…" });
|
||
await refreshDownloadJobs();
|
||
button.textContent = `正在查找 ${nodePackage.title}…`;
|
||
try {
|
||
const found = await post("/findnodes/candidates", { node_type: nodeType, package_id: packageId });
|
||
const candidate = (found.candidates || []).find((item) => [
|
||
"workflow_package_id",
|
||
"workflow_package_github",
|
||
"official_node_mapping",
|
||
"comfy_manager_node_mapping",
|
||
"comfy_manager_github",
|
||
].includes(item.reason));
|
||
if (!candidate) throw new Error("没有找到由工作流 aux_id 或 ComfyUI-Manager 官方映射确认的 GitHub 仓库");
|
||
button.textContent = `正在安装 ${nodePackage.title}…`;
|
||
nodeActivities.set(activityId, { title: nodePackage.title, status: "downloading", progress: 55, message: "正在检查 requirements.txt、依赖冲突并安装…" });
|
||
await refreshDownloadJobs();
|
||
await post("/findnodes/install", {
|
||
node_type: nodeType,
|
||
package_id: packageId,
|
||
plugin_id: candidate.id,
|
||
install_dependencies: installDependenciesEnabled(panel),
|
||
});
|
||
resolvedNodePackages.add(activityId);
|
||
nodeActivities.set(activityId, { title: nodePackage.title, status: "completed", progress: 100, message: "安装完成,重启 ComfyUI 后生效" });
|
||
installed += 1;
|
||
} catch (error) {
|
||
nodeActivities.set(activityId, { title: nodePackage.title, status: "failed", progress: 100, message: `安装失败:${error.message}` });
|
||
failures.push(`${nodePackage.title}: ${error.message}`);
|
||
}
|
||
await refreshDownloadJobs();
|
||
}
|
||
button.disabled = false;
|
||
button.textContent = "安装缺失节点";
|
||
panel.querySelector(".fm-summary").textContent =
|
||
`已安装或更新 ${installed} 个精确匹配插件。${failures.length ? `失败 ${failures.length} 个:${failures.join(";")}` : "请重启 ComfyUI。"}`
|
||
if (installed) {
|
||
render(lastResult);
|
||
scheduleScan(0, true);
|
||
}
|
||
};
|
||
panel.querySelector('[data-action="select-external-folder"]').onclick = async () => {
|
||
const button = panel.querySelector('[data-action="select-external-folder"]');
|
||
const input = panel.querySelector("[data-external-folder]");
|
||
button.disabled = true;
|
||
button.textContent = "等待选择…";
|
||
try {
|
||
const result = await post("/findmodels/external-folder/select", {});
|
||
if (!result.cancelled) {
|
||
input.value = result.path;
|
||
scheduleScan(0, true);
|
||
scheduleEnrichedScan();
|
||
}
|
||
} catch (error) {
|
||
panel.querySelector(".fm-summary").textContent = `选择外部模型库失败:${error.message}`;
|
||
} finally {
|
||
button.disabled = false;
|
||
button.textContent = "选择文件夹";
|
||
}
|
||
};
|
||
panel.querySelector('[data-action="install-custom-github"]').onclick = async () => {
|
||
const button = panel.querySelector('[data-action="install-custom-github"]');
|
||
const input = panel.querySelector("[data-custom-github]");
|
||
const nodePackage = lastResult?.missing_node_packages?.[0];
|
||
const nodeType = nodePackage?.node_types?.[0] || "CustomNode";
|
||
if (!input.value.trim()) {
|
||
panel.querySelector(".fm-summary").textContent = "请输入完整的 GitHub 插件仓库链接。";
|
||
return;
|
||
}
|
||
button.disabled = true;
|
||
button.textContent = "正在安装…";
|
||
const activityId = input.value.trim();
|
||
nodeActivities.set(activityId, { title: activityId, status: "downloading", progress: 45, message: "正在克隆仓库并检查 requirements.txt…" });
|
||
await refreshDownloadJobs();
|
||
try {
|
||
const result = await post("/findnodes/install", {
|
||
node_type: nodeType,
|
||
package_id: nodePackage?.known ? nodePackage.id : "custom-github",
|
||
repo_url: input.value.trim(),
|
||
install_dependencies: installDependenciesEnabled(panel),
|
||
});
|
||
input.value = "";
|
||
nodeActivities.set(activityId, { title: result.title, status: "completed", progress: 100, message: "安装完成,重启 ComfyUI 后生效" });
|
||
panel.querySelector(".fm-summary").textContent = `${result.title} 已安装,请重启 ComfyUI。`;
|
||
await refreshDownloadJobs();
|
||
} catch (error) {
|
||
nodeActivities.set(activityId, { title: activityId, status: "failed", progress: 100, message: `安装失败:${error.message}` });
|
||
panel.querySelector(".fm-summary").textContent = `GitHub 插件安装失败:${error.message}`;
|
||
} finally {
|
||
button.disabled = false;
|
||
button.textContent = "链接安装插件";
|
||
}
|
||
};
|
||
panel.querySelector('[data-action="check-quark-libraries"]').onclick = async (event) => {
|
||
const button = event.currentTarget;
|
||
button.disabled = true;
|
||
button.textContent = "正在检测…";
|
||
try {
|
||
const result = await post("/findmodels/quark-libraries/check", {});
|
||
renderQuarkLibraries(result);
|
||
panel.querySelector("[data-quark-auth-status]").textContent = result.configured
|
||
? "分享链接可用性已刷新;已配置登录 Cookie。"
|
||
: "分享链接可用性已刷新;大文件直链仍可能要求登录 Cookie。";
|
||
} catch (error) {
|
||
panel.querySelector("[data-quark-auth-status]").textContent = `检测夸克分享链接失败:${error.message}`;
|
||
} finally {
|
||
button.disabled = false;
|
||
button.textContent = "检测分享链接";
|
||
}
|
||
};
|
||
panel.querySelector('[data-action="save-quark-cookie"]').onclick = async () => {
|
||
const input = panel.querySelector("[data-quark-cookie]");
|
||
const status = panel.querySelector("[data-quark-auth-status]");
|
||
try {
|
||
const result = await post("/findmodels/quark-auth", { cookie: input.value });
|
||
input.value = "";
|
||
status.textContent = result.configured ? "已保存夸克登录态,可重试直链下载。" : "Cookie 为空,未保存登录态。";
|
||
} catch (error) {
|
||
status.textContent = `保存夸克登录态失败:${error.message}`;
|
||
}
|
||
};
|
||
panel.querySelector('[data-action="clear-quark-cookie"]').onclick = async () => {
|
||
const input = panel.querySelector("[data-quark-cookie]");
|
||
const status = panel.querySelector("[data-quark-auth-status]");
|
||
try {
|
||
await post("/findmodels/quark-auth", { cookie: "" });
|
||
input.value = "";
|
||
status.textContent = "已清除夸克登录态。";
|
||
} catch (error) {
|
||
status.textContent = `清除夸克登录态失败:${error.message}`;
|
||
}
|
||
};
|
||
panel.querySelector('[data-action="adapt"]').onclick = async () => {
|
||
const button = panel.querySelector('[data-action="adapt"]');
|
||
button.disabled = true;
|
||
let count = 0;
|
||
let failed = 0;
|
||
for (const model of lastResult?.models || []) {
|
||
if (!model.match?.auto_apply) continue;
|
||
if (await applyMatchEverywhere(model)) count += 1;
|
||
else failed += 1;
|
||
}
|
||
panel.querySelector(".fm-summary").textContent =
|
||
`已加载 ${count} 个模型${failed ? `,${failed} 个加载失败` : ""}。请检查节点后保存工作流。`;
|
||
button.disabled = false;
|
||
if (count) {
|
||
for (const model of [...(lastResult?.models || [])]) {
|
||
if (model.match?.auto_apply) removeResolvedModel(model.name);
|
||
}
|
||
scheduleScan(0, true);
|
||
}
|
||
};
|
||
panel.addEventListener("click", async (event) => {
|
||
const copyButton = event.target.closest("[data-copy-model-name]");
|
||
if (copyButton) {
|
||
try {
|
||
await copyText(copyButton.dataset.copyModelName);
|
||
const original = copyButton.textContent;
|
||
copyButton.textContent = "已复制";
|
||
window.setTimeout(() => { copyButton.textContent = original; }, 1200);
|
||
} catch (error) {
|
||
panel.querySelector(".fm-summary").textContent = `复制模型名称失败:${error.message}`;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const locateButton = event.target.closest("[data-locate-node]");
|
||
if (locateButton) {
|
||
if (!locateNode(locateButton.dataset.locateNode)) {
|
||
panel.querySelector(".fm-summary").textContent = "无法定位节点,节点可能已被删除。";
|
||
}
|
||
return;
|
||
}
|
||
|
||
const controlButton = event.target.closest("[data-download-control]");
|
||
if (controlButton) {
|
||
controlButton.disabled = true;
|
||
try {
|
||
await post(`/findmodels/download/${controlButton.dataset.downloadControl}`, {
|
||
job_id: controlButton.dataset.jobId,
|
||
});
|
||
await refreshDownloadJobs();
|
||
} catch (error) {
|
||
panel.querySelector(".fm-summary").textContent = `下载任务操作失败:${error.message}`;
|
||
controlButton.disabled = false;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const nodeRefreshButton = event.target.closest("[data-node-refresh]");
|
||
if (nodeRefreshButton) {
|
||
nodeRefreshButton.disabled = true;
|
||
nodeRefreshButton.textContent = "正在查找…";
|
||
const target = nodeRefreshButton.closest(".fm-item").querySelector(".fm-node-candidates");
|
||
try {
|
||
const data = await post("/findnodes/candidates", {
|
||
node_type: nodeRefreshButton.dataset.nodeRefresh,
|
||
package_id: nodeRefreshButton.dataset.packageId,
|
||
});
|
||
target.innerHTML = nodeCandidatesHtml(
|
||
data.node_type,
|
||
data.candidates || [],
|
||
data.github_search_url,
|
||
data.package_id,
|
||
);
|
||
} catch (error) {
|
||
nodeRefreshButton.disabled = false;
|
||
nodeRefreshButton.textContent = "重新查找插件";
|
||
panel.querySelector(".fm-summary").textContent = `查找缺失节点插件失败:${error.message}`;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const moveButton = event.target.closest("[data-external-move]");
|
||
if (moveButton) {
|
||
moveButton.disabled = true;
|
||
moveButton.textContent = "正在剪切…";
|
||
try {
|
||
const moved = await post("/findmodels/external-move", {
|
||
source: moveButton.dataset.externalMove,
|
||
name: moveButton.dataset.name,
|
||
category: moveButton.dataset.category,
|
||
});
|
||
const model = (lastResult?.models || []).find((item) => item.name === moveButton.dataset.name);
|
||
const references = model?.referencing_nodes?.length
|
||
? model.referencing_nodes
|
||
: [{ node_id: moveButton.dataset.nodeId, widget: moveButton.dataset.widget }];
|
||
for (const reference of references) {
|
||
await applyMatch({
|
||
...reference,
|
||
name: moveButton.dataset.name,
|
||
match: { name: moved.relative_name },
|
||
});
|
||
}
|
||
removeResolvedModel(moveButton.dataset.name);
|
||
panel.querySelector(".fm-summary").textContent =
|
||
`已剪切 ${moved.relative_name} 到 ${moved.category} 模型目录。`;
|
||
scheduleScan(0, true);
|
||
scheduleEnrichedScan();
|
||
} catch (error) {
|
||
moveButton.disabled = false;
|
||
moveButton.textContent = "剪切失败,重试";
|
||
panel.querySelector(".fm-summary").textContent = `剪切模型失败:${error.message}`;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const installButton = event.target.closest("[data-node-install]");
|
||
if (installButton) {
|
||
const activityId = installButton.dataset.packageId || installButton.dataset.nodeType;
|
||
installButton.disabled = true;
|
||
installButton.textContent = "正在检查依赖并安装…";
|
||
nodeActivities.set(activityId, { title: activityId, status: "downloading", progress: 50, message: "正在检查 requirements.txt、依赖冲突并安装…" });
|
||
await refreshDownloadJobs();
|
||
try {
|
||
const result = await post("/findnodes/install", {
|
||
node_type: installButton.dataset.nodeType,
|
||
package_id: installButton.dataset.packageId,
|
||
plugin_id: installButton.dataset.nodeInstall,
|
||
install_dependencies: installDependenciesEnabled(panel),
|
||
});
|
||
resolvedNodePackages.add(activityId);
|
||
nodeActivities.set(activityId, {
|
||
title: result.title,
|
||
status: "completed",
|
||
progress: 100,
|
||
message: "安装完成,重启 ComfyUI 后生效",
|
||
});
|
||
installButton.textContent = result.action === "updated" ? "更新完成,需重启" : "安装完成,需重启";
|
||
panel.querySelector(".fm-summary").textContent =
|
||
`${result.title} 已${result.action === "updated" ? "更新" : "安装"};`
|
||
+ `${result.new_conflicts?.length ? `发现 ${result.new_conflicts.length} 个新增依赖冲突,请查看终端。` : "未发现新增依赖冲突。"}`
|
||
+ "请重启 ComfyUI。";
|
||
await refreshDownloadJobs();
|
||
render(lastResult);
|
||
scheduleScan(0, true);
|
||
} catch (error) {
|
||
nodeActivities.set(activityId, { title: activityId, status: "failed", progress: 100, message: `安装失败:${error.message}` });
|
||
await refreshDownloadJobs();
|
||
installButton.disabled = false;
|
||
installButton.textContent = "安装失败,重试";
|
||
panel.querySelector(".fm-summary").textContent = `插件安装已停止:${error.message}`;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const sourceButton = event.target.closest("[data-source]");
|
||
if (sourceButton) {
|
||
sourceButton.disabled = true;
|
||
sourceButton.textContent = "正在获取下载项…";
|
||
const model = lastResult?.models?.find((item) => item.name === sourceButton.dataset.source);
|
||
const target = sourceButton.closest(".fm-item").querySelector(".fm-sources");
|
||
try {
|
||
const data = await post("/findmodels/sources", {
|
||
name: sourceButton.dataset.source,
|
||
category: model.category,
|
||
});
|
||
if (data.size) {
|
||
model.size = data.size;
|
||
const sizeLabel = sourceButton.closest(".fm-item").querySelector("[data-model-size]");
|
||
if (sizeLabel) sizeLabel.textContent = `大小:${formatSize(data.size)}`;
|
||
}
|
||
const links = data.candidates.map((item) => sourceHtml(item, model)).join("");
|
||
const searches = data.search_urls
|
||
? `<div class="fm-node-actions"><a href="${escapeHtml(data.search_urls.huggingface)}" target="_blank" rel="noopener noreferrer">Hugging Face 搜索</a><a href="${escapeHtml(data.search_urls.civitai)}" target="_blank" rel="noopener noreferrer">Civitai 搜索</a></div>`
|
||
: "";
|
||
target.innerHTML = links
|
||
? `${data.exact_count ? "" : '<span class="fm-candidate-warning">未找到完全同名文件,以下为高置信相似候选,需人工核对。</span>'}${links}${searches}`
|
||
: `<span>未找到完全同名或高置信候选。</span>${searches}`;
|
||
} catch (error) {
|
||
target.textContent = `获取下载项失败:${error.message}`;
|
||
} finally {
|
||
sourceButton.disabled = false;
|
||
sourceButton.textContent = "查找下载来源";
|
||
}
|
||
return;
|
||
}
|
||
|
||
const downloadButton = event.target.closest("[data-download]");
|
||
if (!downloadButton) return;
|
||
downloadButton.disabled = true;
|
||
downloadButton.textContent = "正在下载…";
|
||
try {
|
||
const job = await post("/findmodels/download/start", {
|
||
url: downloadButton.dataset.download,
|
||
quark: downloadButton.dataset.quarkDownload ? JSON.parse(downloadButton.dataset.quarkDownload) : null,
|
||
filename: downloadButton.dataset.filename,
|
||
size: downloadButton.dataset.size || null,
|
||
category: downloadButton.dataset.category,
|
||
node_id: downloadButton.dataset.nodeId,
|
||
widget: downloadButton.dataset.widget,
|
||
original: downloadButton.dataset.original,
|
||
});
|
||
downloadStatuses.set(job.id, job.status);
|
||
downloadButton.textContent = "后台下载中";
|
||
await refreshDownloadJobs();
|
||
} catch (error) {
|
||
downloadButton.disabled = false;
|
||
downloadButton.textContent = "下载失败,重试";
|
||
panel.querySelector(".fm-summary").textContent = `下载失败:${error.message}`;
|
||
}
|
||
});
|
||
return panel;
|
||
}
|
||
|
||
function render(result) {
|
||
const panel = ensurePanel();
|
||
result.models = result.models || [];
|
||
result.summary = { ...result.summary, unresolved: result.models.length };
|
||
lastResult = result;
|
||
const summary = result.summary;
|
||
const missingNodePackages = (result.missing_node_packages || []).filter((nodePackage) => {
|
||
const nodeType = nodePackage.node_types?.[0] || "";
|
||
return !resolvedNodePackages.has(nodePackage.id) && !resolvedNodePackages.has(nodeType);
|
||
});
|
||
const missingNodeCandidates = result.missing_node_candidates || {};
|
||
updateToolbarButton(summary.unresolved + missingNodePackages.length);
|
||
panel.querySelector('[data-tab-count="models"]').textContent = summary.unresolved;
|
||
panel.querySelector('[data-tab-count="nodes"]').textContent = missingNodePackages.length;
|
||
panel.querySelector('[data-section-count="models"]').textContent = summary.unresolved;
|
||
panel.querySelector('[data-section-count="nodes"]').textContent = missingNodePackages.length;
|
||
const scanStatus = panel.querySelector("[data-scan-status]");
|
||
if (scanStatus) {
|
||
scanStatus.textContent = result.quick
|
||
? "已快速识别,正在补充外部模型库候选…"
|
||
: "当前工作流扫描完成";
|
||
}
|
||
const externalInput = panel.querySelector("[data-external-folder]");
|
||
if (externalInput && document.activeElement !== externalInput) {
|
||
externalInput.value = result.external_folder || "";
|
||
}
|
||
panel.querySelector(".fm-summary").innerHTML =
|
||
`<span class="fm-summary-ok">✓</span><span>当前页面仅显示尚未解决的依赖项</span>`;
|
||
const nodeHtml = missingNodePackages.map((nodePackage) => {
|
||
const nodeType = nodePackage.node_types?.[0] || "";
|
||
const nodeIds = nodePackage.node_ids || [];
|
||
return `
|
||
<article class="fm-item fm-missing">
|
||
<div class="fm-item-title"><span class="fm-status-icon">!</span><strong>${escapeHtml(nodePackage.title)}</strong><span class="fm-badge">${nodePackage.known ? "缺失节点包" : "未知包"}</span></div>
|
||
<div class="fm-meta"><span>${nodePackage.count} 个节点</span>${nodePackage.version ? `<span>${escapeHtml(nodePackage.version)}</span>` : ""}</div>
|
||
<div class="fm-node-types">${(nodePackage.node_types || []).map((item) => `<span>${escapeHtml(item)}</span>`).join("")}</div>
|
||
${nodeIds[0] ? `<div class="fm-item-actions"><button data-locate-node="${escapeHtml(nodeIds[0])}">定位节点</button></div>` : ""}
|
||
<div class="fm-node-candidates">${nodeCandidatesHtml(nodeType, missingNodeCandidates[nodeType] || [], "", nodePackage.known ? nodePackage.id : "")}</div>
|
||
</article>`;
|
||
}).join("");
|
||
const modelsHtml = (result.models || []).map(modelCardHtml).join("");
|
||
panel.querySelector(".fm-model-list").innerHTML = modelsHtml
|
||
? `<div class="fm-table-head fm-model-head"><span>文件名</span><span>类型 / 引用 / 本地候选</span><span>操作</span></div>${modelsHtml}<div class="fm-table-foot">共 ${result.models.length} 项</div>`
|
||
: '<div class="fm-empty"><strong>未发现缺失模型</strong><span>当前工作流模型已就绪</span></div>';
|
||
panel.querySelector(".fm-node-list").innerHTML = nodeHtml
|
||
? `<div class="fm-table-head fm-node-head"><span>包名(节点集)</span><span>引用 / 来源</span><span>操作</span></div>${nodeHtml}<div class="fm-table-foot">共 ${missingNodePackages.length} 项</div>`
|
||
: '<div class="fm-empty"><strong>未发现缺失节点</strong><span>当前工作流节点均已注册</span></div>';
|
||
panel.querySelectorAll("[data-apply]").forEach((button) => {
|
||
button.onclick = async () => {
|
||
const model = result.models.find((item) => `${item.node_id}:${item.widget}` === button.dataset.apply);
|
||
if (model && await applyMatchEverywhere(model)) {
|
||
removeResolvedModel(model.name);
|
||
scheduleScan(0, true);
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
async function scan(quiet = false, { quick = false } = {}) {
|
||
const panel = ensurePanel();
|
||
const snapshot = workflowSnapshot();
|
||
const signature = workflowSignature(snapshot);
|
||
const requestId = ++scanRequestId;
|
||
updateToolbarButton(null, "扫描中");
|
||
const scanStatus = panel.querySelector("[data-scan-status]");
|
||
if (scanStatus) scanStatus.textContent = quick ? "正在快速识别当前工作流…" : "正在补充外部模型库候选…";
|
||
if (!quiet && !lastResult) panel.querySelector(".fm-summary").textContent = "正在扫描…";
|
||
try {
|
||
const result = await post("/findmodels/scan", { ...snapshot, quick });
|
||
if (requestId !== scanRequestId || signature !== workflowSignature(workflowSnapshot())) return;
|
||
render(result);
|
||
if (quick) scheduleEnrichedScan();
|
||
} catch (error) {
|
||
if (requestId !== scanRequestId) return;
|
||
updateToolbarButton(null, "扫描失败");
|
||
if (scanStatus) scanStatus.textContent = `扫描失败:${error.message}`;
|
||
}
|
||
}
|
||
|
||
function updateToolbarButton(missing = null, state = null) {
|
||
const button = document.getElementById("find-models-launcher");
|
||
if (!button) return;
|
||
button.textContent = "查找模型";
|
||
button.title = state ? `查找模型:${state}` : `查找模型:未找到 ${missing ?? 0}`;
|
||
button.dataset.missing = String(missing ?? 0);
|
||
button.classList.toggle("has-missing", Number(missing) > 0);
|
||
}
|
||
|
||
function findRunButton() {
|
||
const selectors = [
|
||
"[data-testid='queue-button']",
|
||
"[data-testid='run-button']",
|
||
"button[aria-label*='Queue']",
|
||
"button[aria-label*='Run']",
|
||
"button[title*='Queue']",
|
||
"button[title*='Run']",
|
||
];
|
||
return selectors.map((selector) => document.querySelector(selector)).find(Boolean);
|
||
}
|
||
|
||
function findPropertyPanelButton() {
|
||
const selectors = [
|
||
"button[aria-label='开关属性面板']",
|
||
"button[title='开关属性面板']",
|
||
"button[aria-label*='属性面板']",
|
||
"button[title*='属性面板']",
|
||
"button[aria-label*='Toggle property panel']",
|
||
"button[title*='Toggle property panel']",
|
||
];
|
||
const direct = selectors.map((selector) => document.querySelector(selector)).find(Boolean);
|
||
if (direct) return direct;
|
||
return [...document.querySelectorAll("button")].find((button) =>
|
||
["开关属性面板", "Toggle property panel"].includes(button.textContent?.trim()),
|
||
);
|
||
}
|
||
|
||
function findActiveTasksButton() {
|
||
return [...document.querySelectorAll("button")].find((button) =>
|
||
/(?:\d+\s*个活动任务|\d+\s*active tasks?)/i.test(button.textContent?.trim() || ""),
|
||
);
|
||
}
|
||
|
||
function findTopToolbar() {
|
||
const propertyButton = findPropertyPanelButton();
|
||
if (propertyButton?.parentElement) return propertyButton.parentElement;
|
||
const activeTasksButton = findActiveTasksButton();
|
||
if (activeTasksButton?.parentElement) return activeTasksButton.parentElement;
|
||
const runButton = findRunButton();
|
||
if (runButton?.parentElement) return runButton.parentElement;
|
||
return document.querySelector(
|
||
"[data-testid='topbar'], header, .comfyui-body-top, .comfy-menu, #comfy-menu",
|
||
);
|
||
}
|
||
|
||
function mountToolbarButton() {
|
||
let existing = document.getElementById("find-models-launcher");
|
||
const propertyButton = findPropertyPanelButton();
|
||
const activeTasksButton = findActiveTasksButton();
|
||
const runButton = findRunButton();
|
||
const toolbar = findTopToolbar();
|
||
|
||
if (!existing) {
|
||
existing = document.createElement("button");
|
||
existing.id = "find-models-launcher";
|
||
existing.type = "button";
|
||
existing.textContent = "查找模型";
|
||
existing.title = "扫描当前工作流中的缺失模型";
|
||
document.body.appendChild(existing);
|
||
}
|
||
existing.onclick = async () => {
|
||
const panel = ensurePanel();
|
||
if (panel.classList.contains("open")) {
|
||
scan(true, { quick: true });
|
||
return;
|
||
}
|
||
await openDockedPanel(panel);
|
||
scan(true, { quick: true });
|
||
};
|
||
|
||
if (toolbar) {
|
||
existing.classList.remove("toolbar-fallback");
|
||
if (existing.parentElement !== toolbar) {
|
||
if (propertyButton) propertyButton.insertAdjacentElement("beforebegin", existing);
|
||
else if (activeTasksButton) activeTasksButton.insertAdjacentElement("afterend", existing);
|
||
else if (runButton) runButton.insertAdjacentElement("afterend", existing);
|
||
else toolbar.appendChild(existing);
|
||
} else if (propertyButton && existing.nextElementSibling !== propertyButton) {
|
||
propertyButton.insertAdjacentElement("beforebegin", existing);
|
||
} else if (!propertyButton && activeTasksButton && activeTasksButton.nextElementSibling !== existing) {
|
||
activeTasksButton.insertAdjacentElement("afterend", existing);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
existing.classList.add("toolbar-fallback");
|
||
if (existing.parentElement !== document.body) document.body.appendChild(existing);
|
||
return false;
|
||
}
|
||
|
||
function watchTopToolbar() {
|
||
mountToolbarButton();
|
||
toolbarObserver?.disconnect();
|
||
toolbarObserver = new MutationObserver(() => mountToolbarButton());
|
||
toolbarObserver.observe(document.body, { childList: true, subtree: true });
|
||
}
|
||
|
||
app.registerExtension({
|
||
name: EXTENSION,
|
||
setup() {
|
||
const style = document.createElement("link");
|
||
style.rel = "stylesheet";
|
||
style.href = new URL("./find_models.css", import.meta.url).href;
|
||
document.head.appendChild(style);
|
||
ensurePanel();
|
||
ensurePanel().classList.remove("open");
|
||
watchTopToolbar();
|
||
startDownloadMonitor();
|
||
startWorkflowMonitor();
|
||
scheduleScan(0, true);
|
||
},
|
||
async afterConfigureGraph() {
|
||
scanRequestId += 1;
|
||
resolvedNodePackages.clear();
|
||
scheduleScan(0, true);
|
||
},
|
||
});
|