import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { ComfyDialog, $el } from "../../scripts/ui.js";
import { ComfyButton } from "../../scripts/ui/components/button.js";
function clamp(x, min, max) {
return Math.min(Math.max(x, min), max);
}
/**
* @param {string} url
* @param {any} [options=undefined]
* @returns {Promise}
*/
function comfyRequest(url, options = undefined) {
return new Promise((resolve, reject) => {
api.fetchApi(url, options)
.then((response) => {
if (!response.ok) {
reject(new Error(`HTTP error ${response.status}: ${response.statusText}`));
} else {
response.json()
.then(resolve)
.catch(error => reject(new Error(`Failed to parse JSON: ${error.message}`)));
}
})
.catch(error => reject(new Error(`Request error: ${error.message}`)));
});
}
/**
* @param {(...args) => Promise ";
for (let i = 0; i < map.length; i++) {
const v = map[i];
const tag = v[0];
const count = v[1];
text += tag + " (" + count + ")";
if (i !== map.length - 1) {
text += ", ";
}
}
text += "
", "\n\n") .replaceAll("", "**").replaceAll("", "**") .replaceAll("
", "`").replaceAll("", "`")
.replaceAll("", "\n") .replaceAll("
", "\n\n---\n\n") .replaceAll("", "\n") .replaceAll("
", "\n") .replaceAll("
", "\n") .replaceAll("
", "\n") .replaceAll("
", "\n") .replaceAll("
", "\n") .replace(/href="(\S*)">/g, 'href=""> $1 ') .replace(/src="(\S*)">/g, 'src=""> $1
') // // .replace(/<[^>]+>/g, "") // quick hack .replaceAll("<", "<").replaceAll(">", ">") .replaceAll("<e;", "<=").replaceAll(">e;", ">=") .replaceAll("&", "&"); version["files"].forEach((file) => { infos.push({ "images": images, "fileName": file["name"], "modelType": type, "downloadUrl": file["downloadUrl"], "downloadFilePath": "", "description": description, "details": { "fileSizeKB": file["sizeKB"], "fileType": file["type"], "fp": file["fp"], "quant": file["size"], "fileFormat": file["format"], "sha256": file["hashes"]["SHA256"], }, }); }); }); return [name, infos]; } if (urlText.startsWith("https://huggingface.co")) { const hfInfo = await HuggingFace.getFilteredInfo(urlText); if (Object.keys(hfInfo).length === 0) { return ["", []]; } const files = hfInfo["modelFiles"]; if (files.length === 0) { return ["", []]; } const name = hfInfo["name"]; const baseDownloadUrl = hfInfo["baseDownloadUrl"]; const infos = hfInfo["modelFiles"].map((file) => { const indexSep = file.lastIndexOf("/"); const filename = file.substring(indexSep + 1); // TODO: get sha256 of each HuggingFace model file return { "images": hfInfo["images"], "fileName": filename, "modelType": "", "downloadUrl": baseDownloadUrl + "/" + file + "?download=true", "downloadFilePath": file.substring(0, indexSep + 1), "description": "", "details": { "fileSizeKB": undefined, // TODO: too hard? }, }; }); return [name, infos]; } if (urlText.endsWith(".json")) { const indexInfo = await (async() => { try { const response = await fetch(url); const data = await response.json(); return data; } catch { return []; } })(); const name = urlText.substring(math.max(urlText.lastIndexOf("/"), 0)); const infos = indexInfo.map((file) => { return { "images": [], "fileName": file["name"], "modelType": DownloadView.modelTypeToComfyUiDirectory(file["type"], "") ?? "", "downloadUrl": file["download"], "downloadFilePath": "", "description": file["description"], "details": {}, }; }); return [name, infos]; } return ["", []]; })(); } class DownloadView { /** @type {HTMLDivElement} */ element = null; elements = { /** @type {HTMLInputElement} */ url: null, /** @type {HTMLDivElement} */ infos: null, /** @type {HTMLInputElement} */ overwrite: null, /** @type {HTMLInputElement} */ downloadNotes: null, /** @type {HTMLButtonElement} */ searchButton: null, /** @type {HTMLButtonElement} */ clearSearchButton: null, }; /** @type {Object.
} */ #settings = null; /** @type {() => Promise } */ #updateModels = () => {}; /** * @param {ModelData} modelData * @param {Object. } settings * @param {() => Promise } updateModels */ constructor(modelData, settings, updateModels) { this.#updateModels = updateModels; const update = async() => { await this.#update(modelData, settings); }; const reset = () => { this.elements.infos.innerHTML = ""; this.elements.infos.appendChild( $el("h1", ["Input a URL to select a model to download."]) ); }; const searchButton = new ComfyButton({ icon: "magnify", tooltip: "Search url", classList: "comfyui-button icon-button", action: async(e) => { const [button, icon, span] = comfyButtonDisambiguate(e.target); button.disabled = true; if (this.elements.url.value === "") { reset(); } else { await update(); } button.disabled = false; }, }).element; settings["model-real-time-search"].addEventListener("change", () => { const hideSearchButton = settings["text-input-always-hide-search-button"].checked; searchButton.style.display = hideSearchButton ? "none" : ""; }); settings["text-input-always-hide-search-button"].addEventListener("change", () => { const hideSearchButton = settings["text-input-always-hide-search-button"].checked; searchButton.style.display = hideSearchButton ? "none" : ""; }); this.elements.searchButton = searchButton; const clearSearchButton = new ComfyButton({ icon: "close", tooltip: "Clear search", classList: "comfyui-button icon-button", action: async(e) => { e.stopPropagation(); this.elements.url.value = ""; reset(); }, }).element; settings["text-input-always-hide-clear-button"].addEventListener("change", () => { const hideClearButton = settings["text-input-always-hide-clear-button"].checked; clearSearchButton.style.display = hideClearButton ? "none" : ""; }); this.elements.clearSearchButton = clearSearchButton; $el("div.tab-header", { $: (el) => (this.element = el), }, [ $el("div.row.tab-header-flex-block", [ $el("input.search-text-area", { $: (el) => (this.elements.url = el), type: "text", name: "model download url", autocomplete: "off", placeholder: "Search URL", onkeydown: async (e) => { if (e.key === "Enter") { e.stopPropagation(); searchButton.click(); e.target.blur(); } }, }), clearSearchButton, searchButton, ]), $el("div.download-model-infos", { $: (el) => (this.elements.infos = el), }, [ $el("h1", ["Input a URL to select a model to download."]), ]), ]); } /** * Tries to return the related ComfyUI model directory if unambiguous. * * @param {string | undefined} modelType - Model type. * @param {string | undefined} [fileType] - File type. Relevant for "Diffusers". * * @returns {(string | null)} Logical base directory name for model type. May be null if the directory is ambiguous or not a model type. */ static modelTypeToComfyUiDirectory(modelType, fileType) { if (fileType !== undefined && fileType !== null) { const f = fileType.toLowerCase(); if (f == "diffusers") { return "diffusers"; } // TODO: is this correct? } if (modelType !== undefined && modelType !== null) { const m = modelType.toLowerCase(); // TODO: somehow allow for SERVER to set dir? // TODO: allow user to choose EXISTING folder override/null? (style_models, HuggingFace) (use an object/map instead so settings can be dynamically set) if (m == "aestheticGradient") { return null; } else if (m == "checkpoint" || m == "checkpoints") { return "checkpoints"; } //else if (m == "") { return "clip"; } //else if (m == "") { return "clip_vision"; } else if (m == "controlnet") { return "controlnet"; } //else if (m == "Controlnet") { return "style_models"; } // are these controlnets? (TI-Adapter) //else if (m == "") { return "gligen"; } else if (m == "hypernetwork" || m == "hypernetworks") { return "hypernetworks"; } else if (m == "lora" || m == "loras") { return "loras"; } else if (m == "locon") { return "loras"; } else if (m == "motionmodule") { return null; } else if (m == "other") { return null; } else if (m == "pose") { return null; } else if (m == "textualinversion" || m == "embedding" || m == "embeddings") { return "embeddings"; } //else if (m == "") { return "unet"; } else if (m == "upscaler" || m == "upscale_model" || m == "upscale_models") { return "upscale_models"; } else if (m == "vae") { return "vae"; } else if (m == "wildcard" || m == "wildcards") { return null; } else if (m == "workflow" || m == "workflows") { return null; } } return null; } /** * Returns empty string on failure * @param {float | undefined} fileSizeKB * @returns {string} */ static #fileSizeToFormattedString(fileSizeKB) { if (fileSizeKB === undefined) { return ""; } const sizes = ["KB", "MB", "GB", "TB", "PB"]; let fileSizeString = fileSizeKB.toString(); const index = fileSizeString.indexOf("."); const indexMove = index % 3 === 0 ? 3 : index % 3; const sizeIndex = Math.floor((index - indexMove) / 3); if (sizeIndex >= sizes.length || sizeIndex < 0) { fileSizeString = fileSizeString.substring(0, fileSizeString.indexOf(".") + 3); return `(${fileSizeString} ${sizes[0]})`; } const split = fileSizeString.split("."); fileSizeString = split[0].substring(0, indexMove) + "." + split[0].substring(indexMove) + split[1]; fileSizeString = fileSizeString.substring(0, fileSizeString.indexOf(".") + 3); return `(${fileSizeString} ${sizes[sizeIndex]})`; } /** * @param {Object} info * @param {ModelData} modelData * @param {int} id * @param {any} settings * @returns {HTMLDivElement} */ #modelInfoHtml(info, modelData, id, settings) { const downloadPreviewSelect = new ImageSelect( "model-download-info-preview-model" + "-" + id, info["images"], ); const comfyUIModelType = ( DownloadView.modelTypeToComfyUiDirectory(info["details"]["fileType"]) ?? DownloadView.modelTypeToComfyUiDirectory(info["modelType"]) ?? "" ); const searchSeparator = modelData.searchSeparator; const defaultBasePath = searchSeparator + (comfyUIModelType === "" ? "" : comfyUIModelType + searchSeparator + "0"); const el_saveDirectoryPath = $el("input.search-text-area", { type: "text", name: "save directory", autocomplete: "off", placeholder: defaultBasePath, value: defaultBasePath, }); const searchDropdown = new DirectoryDropdown( modelData, el_saveDirectoryPath, true, ); const default_name = (() => { const filename = info["fileName"]; // TODO: only remove valid model file extensions const i = filename.lastIndexOf("."); return i === - 1 ? filename : filename.substring(0, i); })(); const el_filename = $el("input.plain-text-area", { type: "text", name: "model save file name", autocomplete: "off", placeholder: default_name, value: default_name, onkeydown: (e) => { if (e.key === "Enter") { e.stopPropagation(); e.target.blur(); } }, }); const infoNotes = $el("textarea.comfy-multiline-input.model-info-notes", { name: "model info notes", value: info["description"]??"", rows: 6, disabled: true, style: { display: info["description"] === undefined || info["description"] === "" ? "none" : "" }, }); const filepath = info["downloadFilePath"]; const modelInfo = $el("details.download-details", [ $el("summary", [filepath + info["fileName"]]), $el("div", [ downloadPreviewSelect.elements.previews, $el("div.download-settings-wrapper", [ $el("div.download-settings", [ new ComfyButton({ icon: "arrow-collapse-down", tooltip: "Download model", content: "Download " + DownloadView.#fileSizeToFormattedString(info["details"]["fileSizeKB"]), classList: "comfyui-button download-button", action: async (e) => { const pathDirectory = el_saveDirectoryPath.value; const modelName = (() => { const filename = info["fileName"]; const name = el_filename.value; if (name === "") { return filename; } const ext = MODEL_EXTENSIONS.find((ext) => { return filename.endsWith(ext); }) ?? ""; return name + ext; })(); const formData = new FormData(); formData.append("download", info["downloadUrl"]); formData.append("path", pathDirectory); formData.append("name", modelName); formData.append("sha256", info["details"]["sha256"]); const image = await downloadPreviewSelect.getImage(); formData.append("image", image === PREVIEW_NONE_URI ? "" : image); formData.append("overwrite", this.elements.overwrite.checked); const [button, icon, span] = comfyButtonDisambiguate(e.target); button.disabled = true; const [success, resultText] = await comfyRequest( "/model-manager/model/download", { method: "POST", body: formData, } ).then((data) => { const success = data["success"]; const message = data["alert"]; if (message !== undefined) { window.alert(message); } return [success, success ? "✔" : "📥︎"]; }).catch((err) => { return [false, "📥︎"]; }); if (success) { const description = infoNotes.value; if (this.elements.downloadNotes.checked && description !== "") { const modelPath = pathDirectory + searchSeparator + modelName; const saved = await saveNotes(modelPath, description); if (!saved) { console.warn("Model description was not saved!"); } } this.#updateModels(); } comfyButtonAlert(e.target, success, "mdi-check-bold", "mdi-close-thick", success); button.disabled = success; }, }).element, $el("div.row.tab-header-flex-block.input-dropdown-container", [ // TODO: magic class el_saveDirectoryPath, searchDropdown.element, ]), $el("div.row.tab-header-flex-block", [ el_filename, ]), downloadPreviewSelect.elements.radioGroup, infoNotes, ]), ]), ]), ]); return modelInfo; } /** * @param {ModelData} modelData * @param {any} settings */ async #update(modelData, settings) { const [name, modelInfos] = await getModelInfos(this.elements.url.value); const modelInfosHtml = modelInfos.filter((modelInfo) => { const filename = modelInfo["fileName"]; return MODEL_EXTENSIONS.find((ext) => { return filename.endsWith(ext); }) ?? false; }).map((modelInfo, id) => { return this.#modelInfoHtml( modelInfo, modelData, id, settings, ); }); if (modelInfosHtml.length === 0) { modelInfosHtml.push($el("h1", ["No models found."])); } else { if (modelInfosHtml.length === 1) { modelInfosHtml[0].open = true; } const header = $el("div", [ $el("h1", [name]), $el("div.model-manager-settings", [ $checkbox({ $: (el) => { this.elements.overwrite = el; }, textContent: "Overwrite Existing Files.", checked: false, }), $checkbox({ $: (el) => { this.elements.downloadNotes = el; }, textContent: "Save Notes.", checked: false, }), ]) ]); modelInfosHtml.unshift(header); } const infosHtml = this.elements.infos; infosHtml.innerHTML = ""; infosHtml.append.apply(infosHtml, modelInfosHtml); const downloadNotes = this.elements.downloadNotes; if (downloadNotes !== undefined && downloadNotes !== null) { downloadNotes.addEventListener("change", (e) => { const modelInfoNotes = infosHtml.querySelectorAll(`textarea.model-info-notes`); const disabled = !e.currentTarget.checked; for (let i = 0; i < modelInfoNotes.length; i++) { modelInfoNotes[i].disabled = disabled; } }); downloadNotes.checked = settings["download-save-description-as-text-file"].checked; downloadNotes.dispatchEvent(new Event('change')); } const hideSearchButtons = settings["text-input-always-hide-search-button"].checked; this.elements.searchButton.style.display = hideSearchButtons ? "none" : ""; const hideClearSearchButtons = settings["text-input-always-hide-clear-button"].checked; this.elements.clearSearchButton.style.display = hideClearSearchButtons ? "none" : ""; } } class BrowseView { /** @type {HTMLDivElement} */ element = null; elements = { /** @type {HTMLDivElement} */ modelGrid: null, /** @type {HTMLSelectElement} */ modelTypeSelect: null, /** @type {HTMLSelectElement} */ modelSortSelect: null, /** @type {HTMLInputElement} */ modelContentFilter: null, /** @type {HTMLButtonElement} */ searchButton: null, /** @type {HTMLButtonElement} */ clearSearchButton: null, }; /** @type {Array} */ previousModelFilters = []; /** @type {Object.<{value: string}>} */ previousModelType = { value: null }; /** @type {DirectoryDropdown} */ directoryDropdown = null; /** @type {ModelData} */ #modelData = null; /** @type {@param {() => Promise }} */ #updateModels = null; /** */ #settingsElements = null; /** @type {() => void} */ updateModelGrid = () => {}; /** * @param {() => Promise } updateModels * @param {ModelData} modelData * @param {(searchPath: string) => Promise } showModelInfo * @param {() => void} updateModelGridCallback * @param {any} settingsElements */ constructor(updateModels, modelData, showModelInfo, updateModelGridCallback, settingsElements) { /** @type {HTMLDivElement} */ const modelGrid = $el("div.comfy-grid"); this.elements.modelGrid = modelGrid; this.#updateModels = updateModels; this.#modelData = modelData; this.#settingsElements = settingsElements; const searchInput = $el("input.search-text-area", { $: (el) => (this.elements.modelContentFilter = el), type: "text", name: "model search", autocomplete: "off", placeholder: "/Search", }); const updatePreviousModelFilter = () => { const modelType = this.elements.modelTypeSelect.value; const value = this.elements.modelContentFilter.value; this.previousModelFilters[modelType] = value; }; const updateModelGrid = () => { const sortValue = this.elements.modelSortSelect.value; const reverseSort = sortValue[0] === "-"; const sortBy = reverseSort ? sortValue.substring(1) : sortValue; ModelGrid.update( this.elements.modelGrid, this.#modelData, this.elements.modelTypeSelect, this.previousModelType, this.#settingsElements, sortBy, reverseSort, this.previousModelFilters, this.elements.modelContentFilter, showModelInfo, ); updateModelGridCallback(); const hideSearchButtons = ( this.#settingsElements["model-real-time-search"].checked | this.#settingsElements["text-input-always-hide-search-button"].checked ); this.elements.searchButton.style.display = hideSearchButtons ? "none" : ""; const hideClearSearchButtons = this.#settingsElements["text-input-always-hide-clear-button"].checked; this.elements.clearSearchButton.style.display = hideClearSearchButtons ? "none" : ""; } this.updateModelGrid = updateModelGrid; const searchDropdown = new DirectoryDropdown( modelData, searchInput, false, () => { return this.elements.modelTypeSelect.value; }, updatePreviousModelFilter, updateModelGrid, () => { return this.#settingsElements["model-real-time-search"].checked; }, ); this.directoryDropdown = searchDropdown; const searchButton = new ComfyButton({ icon: "magnify", tooltip: "Search models", classList: "comfyui-button icon-button", action: (e) => { e.stopPropagation(); const [button, icon, span] = comfyButtonDisambiguate(e.target); button.disabled = true; updateModelGrid(); button.disabled = false; }, }).element; settingsElements["model-real-time-search"].addEventListener("change", () => { const hideSearchButton = ( this.#settingsElements["text-input-always-hide-search-button"].checked || this.#settingsElements["model-real-time-search"].checked ); searchButton.style.display = hideSearchButton ? "none" : ""; }); settingsElements["text-input-always-hide-search-button"].addEventListener("change", () => { const hideSearchButton = ( this.#settingsElements["text-input-always-hide-search-button"].checked || this.#settingsElements["model-real-time-search"].checked ); searchButton.style.display = hideSearchButton ? "none" : ""; }); this.elements.searchButton = searchButton; const clearSearchButton = new ComfyButton({ icon: "close", tooltip: "Clear search", classList: "comfyui-button icon-button", action: (e) => { e.stopPropagation(); const [button, icon, span] = comfyButtonDisambiguate(e.target); button.disabled = true; this.elements.modelContentFilter.value = ""; updateModelGrid(); button.disabled = false; }, }).element; settingsElements["text-input-always-hide-clear-button"].addEventListener("change", () => { const hideClearSearchButton = this.#settingsElements["text-input-always-hide-clear-button"].checked; clearSearchButton.style.display = hideClearSearchButton ? "none" : ""; }); this.elements.clearSearchButton = clearSearchButton; this.element = $el("div", [ $el("div.row.tab-header", [ $el("div.row.tab-header-flex-block", [ new ComfyButton({ icon: "reload", tooltip: "Reload model grid", classList: "comfyui-button icon-button", action: async(e) => { const [button, icon, span] = comfyButtonDisambiguate(e.target); button.disabled = true; updateModels(); button.disabled = false; }, }).element, $el("select.model-select-dropdown", { $: (el) => (this.elements.modelTypeSelect = el), name: "model-type", onchange: (e) => { const select = e.target; select.disabled = true; updateModelGrid(); select.disabled = false; }, }), $el("select.model-select-dropdown", { $: (el) => (this.elements.modelSortSelect = el), name: "model select dropdown", onchange: (e) => { const select = e.target; select.disabled = true; updateModelGrid(); select.disabled = false; }, }, [ $el("option", { value: MODEL_SORT_DATE_CREATED }, ["Created (newest first)"]), $el("option", { value: "-" + MODEL_SORT_DATE_CREATED }, ["Created (oldest first)"]), $el("option", { value: MODEL_SORT_DATE_MODIFIED }, ["Modified (newest first)"]), $el("option", { value: "-" + MODEL_SORT_DATE_MODIFIED }, ["Modified (oldest first)"]), $el("option", { value: MODEL_SORT_DATE_NAME }, ["Name (A-Z)"]), $el("option", { value: "-" + MODEL_SORT_DATE_NAME }, ["Name (Z-A)"]), $el("option", { value: MODEL_SORT_SIZE_BYTES }, ["Size (largest first)"]), $el("option", { value: "-" + MODEL_SORT_SIZE_BYTES }, ["Size (smallest first)"]), ], ), ]), $el("div.row.tab-header-flex-block", [ $el("div.search-models.input-dropdown-container", [ // TODO: magic class searchInput, searchDropdown.element, ]), clearSearchButton, searchButton, ]), ]), modelGrid, ]); } } class SettingsView { /** @type {HTMLDivElement} */ element = null; elements = { /** @type {HTMLButtonElement} */ reloadButton: null, /** @type {HTMLButtonElement} */ saveButton: null, /** @type {HTMLDivElement} */ setPreviewButton: null, settings: { /** @type {HTMLTextAreaElement} */ "model-search-always-append": null, /** @type {HTMLInputElement} */ "model-default-browser-model-type": null, /** @type {HTMLInputElement} */ "model-real-time-search": null, /** @type {HTMLInputElement} */ "model-persistent-search": null, /** @type {HTMLInputElement} */ "model-preview-thumbnail-type": null, /** @type {HTMLInputElement} */ "model-preview-fallback-search-safetensors-thumbnail": null, /** @type {HTMLInputElement} */ "model-show-label-extensions": null, /** @type {HTMLInputElement} */ "model-show-add-button": null, /** @type {HTMLInputElement} */ "model-show-copy-button": null, /** @type {HTMLInputElement} */ "model-show-load-workflow-button": null, /** @type {HTMLInputElement} */ "model-show-open-model-url-button": null, /** @type {HTMLInputElement} */ "model-info-button-on-left": null, /** @type {HTMLInputElement} */ "model-add-embedding-extension": null, /** @type {HTMLInputElement} */ "model-add-drag-strict-on-field": null, /** @type {HTMLInputElement} */ "model-add-offset": null, /** @type {HTMLInputElement} */ "model-info-autosave-notes": null, /** @type {HTMLInputElement} */ "download-save-description-as-text-file": null, /** @type {HTMLInputElement} */ "sidebar-default-width": null, /** @type {HTMLInputElement} */ "sidebar-default-height": null, /** @type {HTMLInputElement} */ "sidebar-control-always-compact": null, /** @type {HTMLInputElement} */ "text-input-always-hide-search-button": null, /** @type {HTMLInputElement} */ "text-input-always-hide-clear-button": null, /** @type {HTMLInputElement} */ "tag-generator-sampler-method": null, /** @type {HTMLInputElement} */ "tag-generator-count": null, /** @type {HTMLInputElement} */ "tag-generator-threshold": null, }, }; /** @return {() => Promise } */ #updateModels = () => {}; /** * @param {Object} settingsData * @param {boolean} updateModels */ async #setSettings(settingsData, updateModels) { const settings = this.elements.settings; for (const [key, value] of Object.entries(settingsData)) { const setting = settings[key]; if (setting === undefined || setting === null) { continue; } const type = setting.type; switch (type) { case "checkbox": setting.checked = Boolean(value); break; case "range": setting.value = parseFloat(value); break; case "textarea": setting.value = value; break; case "number": setting.value = parseInt(value); break; case "select-one": setting.value = value; break; default: console.warn(`Unknown settings input type '${type}'!`); } } if (updateModels) { await this.#updateModels(); // Is this slow? } } /** * @param {boolean} updateModels * @returns {Promise } */ async reload(updateModels) { const data = await comfyRequest("/model-manager/settings/load"); const settingsData = data["settings"]; await this.#setSettings(settingsData, updateModels); comfyButtonAlert(this.elements.reloadButton, true); } /** @returns {Promise } */ async save() { let settingsData = {}; for (const [setting, el] of Object.entries(this.elements.settings)) { if (!el) { continue; } // hack const type = el.type; let value = null; switch (type) { case "checkbox": value = el.checked; break; case "range": value = el.value; break; case "textarea": value = el.value; break; case "number": value = el.value; break; case "select-one": value = el.value; break; default: console.warn("Unknown settings input type!"); } settingsData[setting] = value; } const data = await comfyRequest( "/model-manager/settings/save", { method: "POST", body: JSON.stringify({ "settings": settingsData }), } ).catch((err) => { return { "success": false }; }); const success = data["success"]; if (success) { const settingsData = data["settings"]; await this.#setSettings(settingsData, true); } comfyButtonAlert(this.elements.saveButton, success); } /** * @param {() => Promise } updateModels * @param {() => void} updateSidebarButtons */ constructor(updateModels, updateSidebarButtons) { this.#updateModels = updateModels; const settings = this.elements.settings; const sidebarControl = $checkbox({ $: (el) => (settings["sidebar-control-always-compact"] = el), textContent: "Sidebar controls always compact", }); sidebarControl.getElementsByTagName('input')[0].addEventListener("change", () => { updateSidebarButtons(); }); const reloadButton = new ComfyButton({ content: "Reload", tooltip: "Reload settings and model manager files", action: async(e) => { const [button, icon, span] = comfyButtonDisambiguate(e.target); button.disabled = true; await this.reload(true); button.disabled = false; }, }).element; this.elements.reloadButton = reloadButton; const saveButton = new ComfyButton({ content: "Save", tooltip: "Save settings and reload model manager", action: async(e) => { const [button, icon, span] = comfyButtonDisambiguate(e.target); button.disabled = true; await this.save(); button.disabled = false; }, }).element; this.elements.saveButton = saveButton; const correctPreviewsButton = new ComfyButton({ content: "Fix Extensions", tooltip: "Correct image file extensions in all model directories", action: async(e) => { const [button, icon, span] = comfyButtonDisambiguate(e.target); button.disabled = true; const data = await comfyRequest( "/model-manager/preview/correct-extensions") .catch((err) => { return { "success": false }; }); const success = data["success"]; if (success) { const detectPlural = data["detected"] === 1 ? "" : "s"; const correctPlural = data["corrected"] === 1 ? "" : "s"; const message = `Detected ${data["detected"]} extension${detectPlural}.\nCorrected ${data["corrected"]} extension${correctPlural}.`; window.alert(message); } comfyButtonAlert(e.target, success); if (data["corrected"] > 0) { await this.reload(true); } button.disabled = false; }, }).element; $el("div.model-manager-settings", { $: (el) => (this.element = el), }, [ $el("h1", ["Settings"]), $el("div", [ reloadButton, saveButton, ]), $el("a", { style: { color: "var(--fg-color)" }, href: "https://github.com/hayden-fr/ComfyUI-Model-Manager/issues/" }, [ "File bugs and issues here." ] ), $el("h2", ["Model Search"]), $el("div", [ $el("div.search-settings-text", [ $el("p", ["Always include in model search:"]), $el("textarea.comfy-multiline-input", { $: (el) => (settings["model-search-always-append"] = el), name: "always include in model search", placeholder: "example: /0/sd1.5/styles \"pastel style\" -3d", rows: "6", }), ]), ]), $select({ $: (el) => (settings["model-default-browser-model-type"] = el), textContent: "Default model search type (on start up)", options: ["checkpoints", "clip", "clip_vision", "controlnet", "diffusers", "embeddings", "gligen", "hypernetworks", "loras", "photomaker", "style_models", "unet", "vae", "vae_approx"], }), $checkbox({ $: (el) => (settings["model-real-time-search"] = el), textContent: "Real-time search", }), $checkbox({ $: (el) => (settings["model-persistent-search"] = el), textContent: "Persistent search text (across model types)", }), $el("h2", ["Model Search Thumbnails"]), $select({ $: (el) => (settings["model-preview-thumbnail-type"] = el), textContent: "Preview thumbnail type", options: ["AUTO", "JPEG"], // should use AUTO to avoid artifacts from changing between formats; use JPEG for backward compatibility }), $checkbox({ $: (el) => (settings["model-preview-fallback-search-safetensors-thumbnail"] = el), textContent: "Fallback to embedded safetensors image (slow)", }), $checkbox({ $: (el) => (settings["model-show-label-extensions"] = el), textContent: "Show file extension", }), $checkbox({ $: (el) => (settings["model-show-copy-button"] = el), textContent: "Show \"Copy\" button", }), $checkbox({ $: (el) => (settings["model-show-add-button"] = el), textContent: "Show \"Add\" button", }), $checkbox({ $: (el) => (settings["model-show-load-workflow-button"] = el), textContent: "Show \"Load Workflow\" button", }),$checkbox({ $: (el) => (settings["model-show-open-model-url-button"] = el), textContent: "Show \"Open Model Url\" button", }), $checkbox({ $: (el) => (settings["model-info-button-on-left"] = el), textContent: "\"Model Info\" button on left", }), $el("h2", ["Node Graph"]), $checkbox({ $: (el) => (settings["model-add-embedding-extension"] = el), textContent: "Add embedding with extension", }), $checkbox({ $: (el) => (settings["model-add-drag-strict-on-field"] = el), // true -> must drag on field; false -> can drag on node when unambiguous textContent: "Must always drag thumbnail onto node's input field", }), $el("label", [ "Add offset", // if a node already was added to the same spot, add the next one with an offset $el("input", { $: (el) => (settings["model-add-offset"] = el), type: "number", name: "model add offset", step: 5, }), ]), $el("h2", ["Model Info"]), $checkbox({ $: (el) => (settings["model-info-autosave-notes"] = el), // note history deleted on model info close textContent: "Autosave notes", }), $el("h2", ["Download"]), $checkbox({ $: (el) => (settings["download-save-description-as-text-file"] = el), textContent: "Save notes by default.", }), $el("h2", ["Window"]), sidebarControl, $el("label", [ "Sidebar width (on start up)", $el("input", { $: (el) => (settings["sidebar-default-width"] = el), type: "range", name: "default sidebar width", value: 0.5, min: 0.0, max: 1.0, step: 0.05, }), ]), $el("label", [ "Sidebar height (on start up)", $el("input", { $: (el) => (settings["sidebar-default-height"] = el), type: "range", name: "default sidebar height", value: 0.5, min: 0.0, max: 1.0, step: 0.05, }), ]), $checkbox({ $: (el) => (settings["text-input-always-hide-search-button"] = el), textContent: "Always hide \"Search\" buttons.", }), $checkbox({ $: (el) => (settings["text-input-always-hide-clear-button"] = el), textContent: "Always hide \"Clear Search\" buttons.", }), $el("h2", ["Model Preview Images"]), $el("div", [ correctPreviewsButton, ]), $el("h2", ["Random Tag Generator"]), $select({ $: (el) => (settings["tag-generator-sampler-method"] = el), textContent: "Default sampling method", options: ["Frequency", "Uniform"], }), $el("label", [ "Default count", $el("input", { $: (el) => (settings["tag-generator-count"] = el), type: "number", name: "tag generator count", step: 1, min: 1, }), ]), $el("label", [ "Default minimum threshold", $el("input", { $: (el) => (settings["tag-generator-threshold"] = el), type: "number", name: "tag generator threshold", step: 1, min: 1, }), ]), ]); } } /** * @param {String[]} labels * @param {[(event: Event) => Promise ]} callbacks * @returns {HTMLDivElement} */ function GenerateRadioButtonGroup(labels, callbacks = []) { const RADIO_BUTTON_GROUP_ACTIVE = "radio-button-group-active"; const radioButtonGroup = $el("div.radio-button-group", []); const buttons = []; for (let i = 0; i < labels.length; i++) { const text = labels[i]; const callback = callbacks[i] ?? (() => {}); buttons.push( $el("button.radio-button", { textContent: text, onclick: (event) => { const targetIsActive = event.target.classList.contains(RADIO_BUTTON_GROUP_ACTIVE); if (targetIsActive) { return; } const children = radioButtonGroup.children; for (let i = 0; i < children.length; i++) { children[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); } event.target.classList.add(RADIO_BUTTON_GROUP_ACTIVE); callback(event); }, }) ); } radioButtonGroup.append.apply(radioButtonGroup, buttons); buttons[0]?.classList.add(RADIO_BUTTON_GROUP_ACTIVE); return radioButtonGroup; } /** * @param {String[]} labels * @param {[(event: Event) => Promise ]} activationCallbacks * @param {(event: Event) => Promise } deactivationCallback * @returns {HTMLDivElement} */ function GenerateToggleRadioButtonGroup(labels, activationCallbacks = [], deactivationCallback = () => {}) { const RADIO_BUTTON_GROUP_ACTIVE = "radio-button-group-active"; const radioButtonGroup = $el("div.radio-button-group", []); const buttons = []; for (let i = 0; i < labels.length; i++) { const text = labels[i]; const activationCallback = activationCallbacks[i] ?? (() => {}); buttons.push( $el("button.radio-button", { textContent: text, onclick: (event) => { const targetIsActive = event.target.classList.contains(RADIO_BUTTON_GROUP_ACTIVE); const children = radioButtonGroup.children; for (let i = 0; i < children.length; i++) { children[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); } if (targetIsActive) { deactivationCallback(event); } else { event.target.classList.add(RADIO_BUTTON_GROUP_ACTIVE); activationCallback(event); } }, }) ); } radioButtonGroup.append.apply(radioButtonGroup, buttons); return radioButtonGroup; } /** * Coupled-state select and radio buttons (hidden first radio button) * @param {String[]} labels * @param {[(button: HTMLButtonElement) => Promise ]} activationCallbacks * @returns {[HTMLDivElement, HTMLSelectElement]} */ function GenerateSidebarToggleRadioAndSelect(labels, activationCallbacks = []) { const RADIO_BUTTON_GROUP_ACTIVE = "radio-button-group-active"; const radioButtonGroup = $el("div.radio-button-group", []); const buttons = []; const select = $el("select", { name: "sidebar-select", onchange: (event) => { const select = event.target; const children = select.children; let value = undefined; for (let i = 0; i < children.length; i++) { const child = children[i]; if (child.selected) { value = child.value; } } for (let i = 0; i < buttons.length; i++) { const button = buttons[i]; if (button.textContent === value) { for (let i = 0; i < buttons.length; i++) { buttons[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); } button.classList.add(RADIO_BUTTON_GROUP_ACTIVE); activationCallbacks[i](button); break; } } }, }, labels.map((option) => { return $el("option", { value: option, }, option); }) ); for (let i = 0; i < labels.length; i++) { const text = labels[i]; const activationCallback = activationCallbacks[i] ?? (() => {}); buttons.push( $el("button.radio-button", { textContent: text, onclick: (event) => { const button = event.target; let textContent = button.textContent; const targetIsActive = button.classList.contains(RADIO_BUTTON_GROUP_ACTIVE); if (button === buttons[0] && buttons[0].classList.contains(RADIO_BUTTON_GROUP_ACTIVE)) { // do not deactivate 0 return; } // update button const children = radioButtonGroup.children; for (let i = 0; i < children.length; i++) { children[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); } if (targetIsActive) { // return to 0 textContent = labels[0]; buttons[0].classList.add(RADIO_BUTTON_GROUP_ACTIVE); activationCallbacks[0](buttons[0]); } else { // move to >0 button.classList.add(RADIO_BUTTON_GROUP_ACTIVE); activationCallback(button); } // update selection for (let i = 0; i < select.children.length; i++) { const option = select.children[i]; option.selected = option.value === textContent; } }, }) ); } radioButtonGroup.append.apply(radioButtonGroup, buttons); buttons[0].click(); buttons[0].style.display = "none"; return [radioButtonGroup, select]; } class ModelManager extends ComfyDialog { /** @type {HTMLDivElement} */ element = null; /** @type {ModelData} */ #modelData = null; /** @type {ModelInfo} */ #modelInfo = null; /** @type {DownloadView} */ #downloadView = null; /** @type {BrowseView} */ #browseView = null; /** @type {SettingsView} */ #settingsView = null; /** @type {HTMLDivElement} */ #topbarRight = null; /** @type {HTMLDivElement} */ #tabManagerButtons = null; /** @type {HTMLDivElement} */ #tabManagerContents = null; /** @type {HTMLDivElement} */ #tabInfoButtons = null; /** @type {HTMLDivElement} */ #tabInfoContents = null; /** @type {HTMLButtonElement} */ #sidebarButtonGroup = null; /** @type {HTMLButtonElement} */ #sidebarSelect = null; /** @type {HTMLButtonElement} */ #closeModelInfoButton = null; /** @type {String} */ #dragSidebarState = ""; constructor() { super(); this.#modelData = new ModelData(); this.#settingsView = new SettingsView( this.#refreshModels, () => this.#updateSidebarButtons(), ); this.#modelInfo = new ModelInfo( this.#modelData, this.#refreshModels, this.#settingsView.elements.settings, ); this.#browseView = new BrowseView( this.#refreshModels, this.#modelData, this.#showModelInfo, this.#resetManagerContentsScroll, this.#settingsView.elements.settings, // TODO: decouple settingsData from elements? ); this.#downloadView = new DownloadView( this.#modelData, this.#settingsView.elements.settings, this.#refreshModels, ); const [tabManagerButtons, tabManagerContents] = GenerateTabGroup([ { name: "Download", icon: "arrow-collapse-down", tabContent: this.#downloadView.element }, { name: "Models", icon: "folder-search-outline", tabContent: this.#browseView.element }, { name: "Settings", icon: "cog-outline", tabContent: this.#settingsView.element }, ]); tabManagerButtons[0]?.click(); const tabInfoButtons = this.#modelInfo.elements.tabButtons; const tabInfoContents = this.#modelInfo.elements.tabContents; const [sidebarButtonGroup, sidebarSelect] = GenerateSidebarToggleRadioAndSelect( ["◼", "◨", "⬒", "⬓", "◧"], [ () => { const element = this.element; if (element) { // callback on initialization as default state element.dataset["sidebarState"] = "none"; } }, () => { this.element.dataset["sidebarState"] = "right"; }, () => { this.element.dataset["sidebarState"] = "top"; }, () => { this.element.dataset["sidebarState"] = "bottom"; }, () => { this.element.dataset["sidebarState"] = "left"; }, ], ); this.#sidebarButtonGroup = sidebarButtonGroup; this.#sidebarSelect = sidebarSelect; sidebarButtonGroup.classList.add("sidebar-buttons"); const sidebarButtonGroupChildren = sidebarButtonGroup.children; for (let i = 0; i < sidebarButtonGroupChildren.length; i++) { sidebarButtonGroupChildren[i].classList.add("icon-button"); } const closeModelInfoButton = new ComfyButton({ icon: "arrow-u-left-bottom", tooltip: "Return to model search", classList: "comfyui-button icon-button", action: async() => await this.#tryHideModelInfo(true), }).element; this.#closeModelInfoButton = closeModelInfoButton; closeModelInfoButton.style.display = "none"; const modelManager = $el( "div.comfy-modal.model-manager", { $: (el) => (this.element = el), parent: document.body, dataset: { "sidebarState": "none", "sidebarLeftWidthDecimal": "", "sidebarRightWidthDecimal": "", "sidebarTopHeightDecimal": "", "sidebarBottomHeightDecimal": "", }, }, [ $el("div.comfy-modal-content", [ // TODO: settings.top_bar_left_to_right or settings.top_bar_right_to_left $el("div.model-manager-panel", [ $el("div.model-manager-head", [ $el("div.topbar-right", { $: (el) => (this.#topbarRight = el), }, [ new ComfyButton({ icon: "window-close", tooltip: "Close model manager", classList: "comfyui-button icon-button", action: async() => { const saved = await this.#modelInfo.trySave(true); if (saved) { this.close(); } }, }).element, closeModelInfoButton, sidebarSelect, sidebarButtonGroup, ]), $el("div.topbar-left", [ $el("div", [ $el("div.model-tab-group.no-highlight", { $: (el) => (this.#tabManagerButtons = el), }, tabManagerButtons), $el("div.model-tab-group.no-highlight", { $: (el) => (this.#tabInfoButtons = el), style: { display: "none"}, }, tabInfoButtons), ]), ]), ]), $el("div.model-manager-body", [ $el("div.tab-contents", { $: (el) => (this.#tabManagerContents = el), }, tabManagerContents), $el("div.tab-contents", { $: (el) => (this.#tabInfoContents = el), style: { display: "none"}, }, tabInfoContents), ]), ]), ]), ] ); new ResizeObserver(GenerateDynamicTabTextCallback(modelManager, tabManagerButtons, 704)).observe(modelManager); new ResizeObserver(GenerateDynamicTabTextCallback(modelManager, tabInfoButtons, 704)).observe(modelManager); new ResizeObserver(() => this.#updateSidebarButtons()).observe(modelManager); window.addEventListener('resize', () => { const width = window.innerWidth; const height = window.innerHeight; const leftDecimal = modelManager.dataset["sidebarLeftWidthDecimal"]; const rightDecimal = modelManager.dataset["sidebarRightWidthDecimal"]; const topDecimal = modelManager.dataset["sidebarTopHeightDecimal"]; const bottomDecimal = modelManager.dataset["sidebarBottomHeightDecimal"]; // restore decimal after resize modelManager.style.setProperty("--model-manager-sidebar-width-left", (leftDecimal * width) + "px"); modelManager.style.setProperty("--model-manager-sidebar-width-right", (rightDecimal * width) + "px"); modelManager.style.setProperty("--model-manager-sidebar-height-top", + (topDecimal * height) + "px"); modelManager.style.setProperty("--model-manager-sidebar-height-bottom", (bottomDecimal * height) + "px"); }); const EDGE_DELTA = 8; const endDragSidebar = (e) => { this.#dragSidebarState = ""; modelManager.classList.remove("cursor-drag-left"); modelManager.classList.remove("cursor-drag-top"); modelManager.classList.remove("cursor-drag-right"); modelManager.classList.remove("cursor-drag-bottom"); // cache for window resize modelManager.dataset["sidebarLeftWidthDecimal"] = parseInt(modelManager.style.getPropertyValue("--model-manager-sidebar-width-left")) / window.innerWidth; modelManager.dataset["sidebarRightWidthDecimal"] = parseInt(modelManager.style.getPropertyValue("--model-manager-sidebar-width-right")) / window.innerWidth; modelManager.dataset["sidebarTopHeightDecimal"] = parseInt(modelManager.style.getPropertyValue("--model-manager-sidebar-height-top")) / window.innerHeight; modelManager.dataset["sidebarBottomHeightDecimal"] = parseInt(modelManager.style.getPropertyValue("--model-manager-sidebar-height-bottom")) / window.innerHeight; }; document.addEventListener("mouseup", (e) => endDragSidebar(e)); document.addEventListener("touchend", (e) => endDragSidebar(e)); const detectDragSidebar = (e, x, y) => { const left = modelManager.offsetLeft; const top = modelManager.offsetTop; const width = modelManager.offsetWidth; const height = modelManager.offsetHeight; const right = left + width; const bottom = top + height; if (!(x >= left && x <= right && y >= top && y <= bottom)) { // click was not in model manager return; } const isOnEdgeLeft = x - left <= EDGE_DELTA; const isOnEdgeRight = right - x <= EDGE_DELTA; const isOnEdgeTop = y - top <= EDGE_DELTA; const isOnEdgeBottom = bottom - y <= EDGE_DELTA; const sidebarState = this.element.dataset["sidebarState"]; if (sidebarState === "left" && isOnEdgeRight) { this.#dragSidebarState = sidebarState; } else if (sidebarState === "right" && isOnEdgeLeft) { this.#dragSidebarState = sidebarState; } else if (sidebarState === "top" && isOnEdgeBottom) { this.#dragSidebarState = sidebarState; } else if (sidebarState === "bottom" && isOnEdgeTop) { this.#dragSidebarState = sidebarState; } if (this.#dragSidebarState !== "") { e.preventDefault(); e.stopPropagation(); } }; modelManager.addEventListener("mousedown", (e) => detectDragSidebar(e, e.clientX, e.clientY)); modelManager.addEventListener("touchstart", (e) => detectDragSidebar(e, e.touches[0].clientX, e.touches[0].clientY)); const updateSidebarCursor = (e, x, y) => { if (this.#dragSidebarState !== "") { // do not update cursor style while dragging return; } const left = modelManager.offsetLeft; const top = modelManager.offsetTop; const width = modelManager.offsetWidth; const height = modelManager.offsetHeight; const right = left + width; const bottom = top + height; const isOnEdgeLeft = x - left <= EDGE_DELTA; const isOnEdgeRight = right - x <= EDGE_DELTA; const isOnEdgeTop = y - top <= EDGE_DELTA; const isOnEdgeBottom = bottom - y <= EDGE_DELTA; const updateClass = (add, className) => { if (add) { modelManager.classList.add(className); } else { modelManager.classList.remove(className); } }; const sidebarState = this.element.dataset["sidebarState"]; updateClass(sidebarState === "right" && isOnEdgeLeft, "cursor-drag-left"); updateClass(sidebarState === "bottom" && isOnEdgeTop, "cursor-drag-top"); updateClass(sidebarState === "left" && isOnEdgeRight, "cursor-drag-right"); updateClass(sidebarState === "top" && isOnEdgeBottom, "cursor-drag-bottom"); }; modelManager.addEventListener("mousemove", (e) => updateSidebarCursor(e, e.clientX, e.clientY)); modelManager.addEventListener("touchmove", (e) => updateSidebarCursor(e, e.touches[0].clientX, e.touches[0].clientY)); const updateDragSidebar = (e, x, y) => { const sidebarState = this.#dragSidebarState; if (sidebarState === "") { return; } e.preventDefault(); const width = window.innerWidth; const height = window.innerHeight; if (sidebarState === "left") { const pixels = clamp(x, 0, width).toString() + "px"; modelManager.style.setProperty("--model-manager-sidebar-width-left", pixels); } else if (sidebarState === "right") { const pixels = clamp(width - x, 0, width).toString() + "px"; modelManager.style.setProperty("--model-manager-sidebar-width-right", pixels); } else if (sidebarState === "top") { const pixels = clamp(y, 0, height).toString() + "px"; modelManager.style.setProperty("--model-manager-sidebar-height-top", pixels); } else if (sidebarState === "bottom") { const pixels = clamp(height - y, 0, height).toString() + "px"; modelManager.style.setProperty("--model-manager-sidebar-height-bottom", pixels); } }; document.addEventListener("mousemove", (e) => updateDragSidebar(e, e.clientX, e.clientY)); document.addEventListener("touchmove", (e) => updateDragSidebar(e, e.touches[0].clientX, e.touches[0].clientY)); this.#init(); } async #init() { await this.#settingsView.reload(false); await this.#refreshModels(); const settings = this.#settingsView.elements.settings; { // initialize buttons' visibility state const hideSearchButtons = settings["text-input-always-hide-search-button"].checked; const hideClearSearchButtons = settings["text-input-always-hide-clear-button"].checked; this.#downloadView.elements.searchButton.style.display = hideSearchButtons ? "none" : ""; this.#downloadView.elements.clearSearchButton.style.display = hideClearSearchButtons ? "none" : ""; } { // set initial sidebar widths & heights const width = window.innerWidth; const height = window.innerHeight; const xDecimal = settings["sidebar-default-width"].value; const yDecimal = settings["sidebar-default-height"].value; this.element.dataset["sidebarLeftWidthDecimal"] = xDecimal; this.element.dataset["sidebarRightWidthDecimal"] = xDecimal; this.element.dataset["sidebarTopHeightDecimal"] = yDecimal; this.element.dataset["sidebarBottomHeightDecimal"] = yDecimal; const x = Math.floor(width * xDecimal); const y = Math.floor(height * yDecimal); const leftPixels = x.toString() + "px"; this.element.style.setProperty("--model-manager-sidebar-width-left", leftPixels); const rightPixels = x.toString() + "px"; this.element.style.setProperty("--model-manager-sidebar-width-right", rightPixels); const topPixels = y.toString() + "px"; this.element.style.setProperty("--model-manager-sidebar-height-top", topPixels); const bottomPixels = y.toString() + "px"; this.element.style.setProperty("--model-manager-sidebar-height-bottom", bottomPixels); } } #resetManagerContentsScroll = () => { this.#tabManagerContents.scrollTop = 0; } #refreshModels = async() => { const modelData = this.#modelData; modelData.systemSeparator = await comfyRequest("/model-manager/system-separator"); const newModels = await comfyRequest("/model-manager/models/list"); Object.assign(modelData.models, newModels); // NOTE: do NOT create a new object const newModelDirectories = await comfyRequest("/model-manager/models/directory-list"); modelData.directories.data.splice(0, Infinity, ...newModelDirectories); // NOTE: do NOT create a new array this.#browseView.updateModelGrid(); await this.#tryHideModelInfo(false); document.getElementById("comfy-refresh-button")?.click(); } /** * @param {searchPath: string} * @return {Promise } */ #showModelInfo = async(searchPath) => { await this.#modelInfo.update( searchPath, this.#refreshModels, this.#modelData.searchSeparator, ).then(() => { this.#tabManagerButtons.style.display = "none"; this.#tabManagerContents.style.display = "none"; this.#closeModelInfoButton.style.display = ""; this.#tabInfoButtons.style.display = ""; this.#tabInfoContents.style.display = ""; this.#tabInfoButtons.children[0]?.click(); this.#modelInfo.show(); this.#tabInfoContents.scrollTop = 0; }); } /** * @param {boolean} promptSave * @returns {Promise } */ #tryHideModelInfo = async(promptSave) => { if (this.#tabInfoContents.style.display !== "none") { if (!await this.#modelInfo.tryHide(promptSave)) { return false; } this.#closeModelInfoButton.style.display = "none"; this.#tabInfoButtons.style.display = "none"; this.#tabInfoContents.style.display = "none"; this.#tabManagerButtons.style.display = ""; this.#tabManagerContents.style.display = ""; } return true; } #updateSidebarButtons = () => { const managerRect = this.element.getBoundingClientRect(); const isNarrow = managerRect.width < 768; // TODO: `minWidth` is a magic value const alwaysShowCompactSidebarControls = this.#settingsView.elements.settings["sidebar-control-always-compact"].checked; if (isNarrow || alwaysShowCompactSidebarControls) { this.#sidebarButtonGroup.style.display = "none"; this.#sidebarSelect.style.display = ""; } else { this.#sidebarButtonGroup.style.display = ""; this.#sidebarSelect.style.display = "none"; } } } /** @type {ModelManager | undefined} */ let instance; /** @type {ComfyDialog | undefined} */ let modelManagerDialog; /** * @returns {ModelManager} */ function getInstance() { if (!instance) { instance = new ModelManager(); modelManagerDialog = new ComfyDialog(); modelManagerDialog.element.classList.add("model-manager-dialog"); instance.element.appendChild(modelManagerDialog.element); } return instance; } const toggleModelManager = () => { const modelManager = getInstance(); const style = modelManager.element.style; if (style.display === "" || style.display === "none") { modelManager.show(); } else { modelManager.close(); } }; app.registerExtension({ name: "Comfy.ModelManager", init() { }, async setup() { $el("link", { parent: document.head, rel: "stylesheet", href: "./extensions/ComfyUI-Model-Manager/model-manager.css", }); app.ui?.menuContainer?.appendChild( $el("button", { id: "comfyui-model-manager-button", parent: document.querySelector(".comfy-menu"), textContent: "Models", onclick: () => toggleModelManager(), }) ); // [Beta] mobile menu app.menu?.settingsGroup?.append(new ComfyButton({ icon: "folder-search", tooltip: "Opens model manager", action: () => toggleModelManager(), content: "Model Manager", popup: getInstance(), })); }, });