Files
ComfyUI-Model-Manager/web/model-manager.js
2024-02-07 17:00:07 -05:00

1550 lines
54 KiB
JavaScript

import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { ComfyDialog, $el } from "../../scripts/ui.js";
/**
* @param {Function} callback
* @param {number | undefined} delay
* @returns {Function}
*/
function debounce(callback, delay) {
let timeoutId = null;
return (...args) => {
window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
callback(...args);
}, delay);
};
}
/**
* @param {string} url
* @param {any} options
* @returns {Promise}
*/
function request(url, options) {
return new Promise((resolve, reject) => {
api.fetchApi(url, options)
.then((response) => response.json())
.then(resolve)
.catch(reject);
});
}
const modelNodeType = {
"checkpoints": "CheckpointLoaderSimple",
"clip": "CLIPLoader",
"clip_vision": "CLIPVisionLoader",
"controlnet": "ControlNetLoader",
"diffusers": "DiffusersLoader",
"embeddings": "Embedding",
"gligen": "GLIGENLoader",
"hypernetworks": "HypernetworkLoader",
"photomaker": "PhotoMakerLoader",
"loras": "LoraLoader",
"style_models": "StyleModelLoader",
"unet": "UNETLoader",
"upscale_models": "UpscaleModelLoader",
"vae": "VAELoader",
"vae_approx": undefined,
};
const DROPDOWN_DIRECTORY_SELECTION_CLASS = "search-dropdown-selected";
const MODEL_SORT_DATE_CREATED = "dateCreated";
const MODEL_SORT_DATE_MODIFIED = "dateModified";
const MODEL_SORT_DATE_NAME = "name";
/**
* @typedef {Object} DirectoryItem
* @param {string} name
* @param {number | undefined} childCount
* @param {number | undefined} childIndex
*/
class DirectoryDropdown {
/** @type {HTMLDivElement} */
element = undefined;
/** @type {HTMLInputElement} */
#input = undefined;
/** @type {Function} */
#submitSearch = null;
/**
* @param {HTMLInputElement} input
* @param {Function} updateDropdown
* @param {Function} submitSearch
*/
constructor(input, updateDropdown, submitSearch) {
/** @type {HTMLDivElement} */
const dropdown = $el("div.search-dropdown", { // TODO: change to `search-directory-dropdown`
style: { display: "none" },
});
this.element = dropdown;
this.#input = input;
this.#submitSearch = submitSearch;
input.addEventListener("input", () => updateDropdown());
input.addEventListener("focus", () => updateDropdown());
input.addEventListener("blur", () => { dropdown.style.display = "none"; });
input.addEventListener(
"keydown",
(e) => {
const options = dropdown.children;
let iSelection;
for (iSelection = 0; iSelection < options.length; iSelection++) {
const selection = options[iSelection];
if (selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_CLASS)) {
break;
}
}
if (e.key === "Escape") {
e.stopPropagation();
if (iSelection < options.length) {
const selection = options[iSelection];
selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS);
}
else {
e.target.blur();
}
}
else if (e.key === "Enter") {
e.stopPropagation();
submitSearch(e.target, options[iSelection]);
}
else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.stopPropagation();
let iNext = options.length;
if (iSelection < options.length) {
const selection = options[iSelection];
selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS);
const delta = e.key === "ArrowDown" ? 1 : -1;
iNext = iSelection + delta;
if (0 <= iNext && iNext < options.length) {
const selectionNext = options[iNext];
selectionNext.classList.add(DROPDOWN_DIRECTORY_SELECTION_CLASS);
}
}
else if (iSelection === options.length) {
iNext = e.key === "ArrowDown" ? 0 : options.length-1;
const selection = options[iNext]
selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_CLASS);
}
if (0 <= iNext && iNext < options.length) {
let dropdownTop = dropdown.scrollTop;
const dropdownHeight = dropdown.offsetHeight;
const selection = options[iNext];
const selectionHeight = selection.offsetHeight;
const selectionTop = selection.offsetTop;
dropdownTop = Math.max(dropdownTop, selectionTop - dropdownHeight + selectionHeight);
dropdownTop = Math.min(dropdownTop, selectionTop);
dropdown.scrollTop = dropdownTop;
}
else {
dropdown.scrollTop = 0;
const options = dropdown.children;
for (iSelection = 0; iSelection < options.length; iSelection++) {
const selection = options[iSelection];
if (selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_CLASS)) {
selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS);
}
}
}
}
},
);
}
/**
* @param {DirectoryItem[]} directories
* @param {string} sep
* @param {string} [modelType = ""]
*/
update(directories, sep, modelType = "") {
const dropdown = this.element;
const input = this.#input;
const submitSearch = this.#submitSearch;
const filter = input.value;
if (filter[0] !== sep) {
dropdown.style.display = "none";
return;
}
let cwd = 0;
if (modelType !== "") {
const root = directories[0];
const rootChildIndex = root["childIndex"];
const rootChildCount = root["childCount"];
cwd = null;
for (let i = rootChildIndex; i < rootChildIndex + rootChildCount; i++) {
const modelDir = directories[i];
if (modelDir["name"] === modelType) {
cwd = i;
break;
}
}
}
// TODO: directories === undefined?
let indexLastWord = 1;
while (true) {
const indexNextWord = filter.indexOf(sep, indexLastWord);
if (indexNextWord === -1) {
// end of filter
break;
}
const item = directories[cwd];
const childCount = item["childCount"];
if (childCount === undefined) {
// file
break;
}
if (childCount === 0) {
// directory is empty
break;
}
const childIndex = item["childIndex"];
const items = directories.slice(childIndex, childIndex + childCount);
const word = filter.substring(indexLastWord, indexNextWord);
cwd = null;
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
const itemName = items[itemIndex]["name"];
if (itemName === word) {
// directory exists
cwd = childIndex + itemIndex;
break;
}
}
if (cwd === null) {
// directory does not exist
break;
}
indexLastWord = indexNextWord + 1;
}
if (cwd === null) {
dropdown.style.display = "none";
return;
}
let options = [];
const lastWord = filter.substring(indexLastWord);
const item = directories[cwd];
if (item["childIndex"] !== undefined) {
const childIndex = item["childIndex"];
const childCount = item["childCount"];
const items = directories.slice(childIndex, childIndex + childCount);
for (let i = 0; i < items.length; i++) {
const itemName = items[i]["name"];
if (itemName.startsWith(lastWord)) {
options.push(itemName);
}
}
}
else {
const filename = item["name"];
if (filename.startsWith(lastWord)) {
options.push(filename);
}
}
if (options.length === 0) {
dropdown.style.display = "none";
return;
}
const selection_select = (e) => {
const selection = e.target;
if (e.movementX === 0 && e.movementY === 0) { return; }
if (!selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_CLASS)) {
// assumes only one will ever selected at a time
e.stopPropagation();
const children = dropdown.children;
let iChild;
for (iChild = 0; iChild < children.length; iChild++) {
const child = children[iChild];
child.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS);
}
selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_CLASS);
}
};
const selection_deselect = (e) => {
e.stopPropagation();
e.target.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS);
};
const selection_submit = (e) => {
e.stopPropagation();
submitSearch(input, e.target);
};
const innerHtml = options.map((text) => {
/** @type {HTMLParagraphElement} */
const p = $el(
"p",
{
onmouseenter: (e) => selection_select(e),
onmousemove: (e) => selection_select(e),
onmouseleave: (e) => selection_deselect(e),
onmousedown: (e) => selection_submit(e),
},
[
text
]
);
return p;
});
dropdown.innerHTML = "";
dropdown.append.apply(dropdown, innerHtml);
dropdown.style.display = "block";
}
/**
* @param {HTMLParagraphElement} selection
* @param {HTMLInputElement} input
* @param {string} sep
*/
static appendSelectionToInput(selection, input, sep) {
selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS);
const selectedText = selection.innerText;
const oldFilterText = input.value;
const iSep = oldFilterText.lastIndexOf(sep);
const previousPath = oldFilterText.substring(0, iSep + 1);
input.value = previousPath + selectedText;
}
}
/**
* @param {string} nodeType
* @returns {int}
*/
function modelWidgetIndex(nodeType) {
return 0;
}
/**
* @param {string} path
* @returns {string}
*/
function pathToFileString(path) {
const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1;
return path.slice(i);
}
/**
* @param {string} file
* @returns {string | undefined}
*/
function removeModelExtension(file) {
// This is a bit sloppy (can assume server sends without)
const i = file.lastIndexOf(".");
if (i != -1) {
return file.substring(0, i);
}
}
/**
* @param {string} text
* @param {string} file
* @param {boolean} removeExtension
* @returns {string}
*/
function insertEmbeddingIntoText(text, file, removeExtension) {
let name = file;
if (removeExtension) {
name = removeModelExtension(name)
}
const sep = text.length === 0 || text.slice(-1).match(/\s/) ? "" : " ";
return text + sep + "(embedding:" + name + ":1.0)";
}
/**
* @param {HTMLButtonElement} element
* @param {boolean} success
* @param {string} [successText=""]
* @param {string} [failureText=""]
* @param {string} [resetText=""]
*/
function buttonAlert(element, success, successText = "", failureText = "", resetText = "") {
const name = success ? "button-success" : "button-failure";
element.classList.add(name);
if (successText != "" && failureText != "") {
element.innerHTML = success ? successText : failureText;
}
// TODO: debounce would be nice to get working...
window.setTimeout((element, name, innerHTML) => {
element.classList.remove(name);
if (innerHTML != "") {
element.innerHTML = innerHTML;
}
}, 500, element, name, resetText);
}
class Tabs {
/** @type {Record<string, HTMLDivElement>} */
#head = {};
/** @type {Record<string, HTMLDivElement>} */
#body = {};
/**
* @param {HTMLDivElement[]} tabs
*/
constructor(tabs) {
const head = [];
const body = [];
tabs.forEach((el, index) => {
const name = el.getAttribute("data-name");
/** @type {HTMLDivElement} */
const tag = $el(
"div.head-item",
{ onclick: () => this.active(name) },
[name]
);
if (index === 0) {
this.#active = name;
}
this.#head[name] = tag;
head.push(tag);
this.#body[name] = el;
body.push(el);
});
this.element = $el("div.comfy-tabs", [
$el("div.comfy-tabs-head", head),
$el("div.comfy-tabs-body", body),
]);
this.active(this.#active);
}
#active = undefined;
/**
* @param {string} name
*/
active(name) {
this.#active = name;
Object.keys(this.#head).forEach((key) => {
if (name === key) {
this.#head[key].classList.add("active");
this.#body[key].style.display = "";
} else {
this.#head[key].classList.remove("active");
this.#body[key].style.display = "none";
}
});
}
}
/**
* @param {Record<HTMLDivElement, Any>} tabs
* @returns {HTMLDivElement[]}
*/
function $tabs(tabs) {
const instance = new Tabs(tabs);
return instance.element;
}
/**
* @param {string} name
* @param {HTMLDivElement[]} el
* @returns {HTMLDivElement}
*/
function $tab(name, el) {
return $el("div", { dataset: { name } }, el);
}
class SourceList {
/**
* @typedef Column
* @prop {string} title
* @prop {string} dataIndex
* @prop {number} width
* @prop {string} align
* @prop {Function} render
*/
/** @type {Column[]} */
#columns = [];
/** @type {Record<string, any>[]} */
#dataSource = [];
/** @type {HTMLDivElement} */
#tbody = null;
/**
* @param {Column[]} columns
*/
constructor(columns) {
this.#columns = columns;
const colgroup = $el(
"colgroup",
columns.map((item) => {
return $el("col", {
style: { width: `${item.width}px` },
});
})
);
const listTitle = $el(
"tr",
columns.map((item) => {
return $el("th", [item.title ?? ""]);
})
);
this.element = $el("table.comfy-table", [
colgroup.cloneNode(true),
$el("thead.table-head", [listTitle]),
$el("tbody.table-body", { $: (el) => (this.#tbody = el) }),
]);
}
/**
* @param {Array} dataSource
*/
setData(dataSource) {
this.#dataSource = dataSource;
this.#updateList();
}
/**
* @returns {Array}
*/
getData() {
return this.#dataSource;
}
#updateList() {
this.#tbody.innerHTML = null;
this.#tbody.append.apply(
this.#tbody,
this.#dataSource.map((row, index) => {
const cells = this.#columns.map((item) => {
const dataIndex = item.dataIndex;
const cellValue = row[dataIndex] ?? "";
const content = item.render
? item.render(cellValue, row, index)
: cellValue ?? "-";
const style = { textAlign: item.align };
return $el("td", { style }, [content]);
});
return $el("tr", cells);
})
);
}
/**
* @param {Array} list
* @param {string} searchString
* @param {string} installedType
*/
filterList(list, searchString, installedType) {
/** @type {string[]} */
const keywords = searchString
.replace("*", " ")
.split(/(-?".*?"|[^\s"]+)+/g)
.map((item) => item
.trim()
.replace(/(?:'|")+/g, "")
.toLowerCase())
.filter(Boolean);
// TODO: handle /directory keywords seperately/differently
let fields = ["type", "name", "base", "description"];
const regexSHA256 = /^[a-f0-9]{64}$/gi;
const newList = list.filter((element) => {
if (installedType !== "Filter: All") {
if ((installedType === "Downloaded" && !element["installed"]) ||
(installedType === "Not Downloaded" && element["installed"])) {
return false;
}
}
const text = fields
.reduce((memo, field) => memo + " " + element[field], "")
.toLowerCase();
return keywords.reduce((memo, target) => {
const excludeTarget = target[0] === "-";
if (excludeTarget && target.length === 1) { return memo; }
const filteredTarget = excludeTarget ? target.slice(1) : target;
if (element["SHA256"] !== undefined && regexSHA256.test(filteredTarget)) {
return memo && excludeTarget !== (filteredTarget === element["SHA256"]);
}
else {
return memo && excludeTarget !== text.includes(filteredTarget);
}
}, true);
});
this.setData(newList);
}
}
class ModelGrid {
/**
* @param {Array} list
* @param {string} searchString
* @returns {Array}
*/
static #filter(list, searchString) {
/** @type {string[]} */
const keywords = searchString
//.replace("*", " ") // TODO: this is wrong for wildcards
.split(/(-?".*?"|[^\s"]+)+/g)
.map((item) => item
.trim()
.replace(/(?:")+/g, "")
.toLowerCase())
.filter(Boolean);
const regexSHA256 = /^[a-f0-9]{64}$/gi;
const fields = ["name", "searchPath"]; // TODO: Remove "searchPath" hack.
return list.filter((element) => {
const text = fields
.reduce((memo, field) => memo + " " + element[field], "")
.toLowerCase();
return keywords.reduce((memo, target) => {
const excludeTarget = target[0] === "-";
if (excludeTarget && target.length === 1) { return memo; }
const filteredTarget = excludeTarget ? target.slice(1) : target;
if (element["SHA256"] !== undefined && regexSHA256.test(filteredTarget)) {
return memo && excludeTarget !== (filteredTarget === element["SHA256"]);
}
else {
return memo && excludeTarget !== text.includes(filteredTarget);
}
}, true);
});
}
/**
* In-place sort. Returns an arrat alias.
* @param {Array} list
* @param {string} sortBy
* @param {bool} [reverse=false]
* @returns {Array}
*/
static #sort(list, sortBy, reverse = false) {
let compareFn = undefined;
switch (sortBy) {
case MODEL_SORT_DATE_NAME:
compareFn = (a, b) => { return a[MODEL_SORT_DATE_NAME].localeCompare(b[MODEL_SORT_DATE_NAME]); };
break;
case MODEL_SORT_DATE_MODIFIED:
compareFn = (a, b) => { return b[MODEL_SORT_DATE_MODIFIED] - a[MODEL_SORT_DATE_MODIFIED]; };
break;
case MODEL_SORT_DATE_CREATED:
compareFn = (a, b) => { return b[MODEL_SORT_DATE_CREATED] - a[MODEL_SORT_DATE_CREATED]; };
break;
default:
console.warn("Invalid filter sort value: '" + sortBy + "'");
return list;
}
const sorted = list.sort(compareFn);
return reverse ? sorted.reverse() : sorted;
}
/**
* @param {Event} event
* @param {string} modelType
* @param {string} path
* @param {boolean} removeEmbeddingExtension
* @param {int} addOffset
*/
static #addModel(event, modelType, path, removeEmbeddingExtension, addOffset) {
let success = false;
if (modelType !== "embeddings") {
const nodeType = modelNodeType[modelType];
const widgetIndex = modelWidgetIndex(nodeType);
let node = LiteGraph.createNode(nodeType, null, []);
if (node) {
node.widgets[widgetIndex].value = path;
const selectedNodes = app.canvas.selected_nodes;
let isSelectedNode = false;
for (var i in selectedNodes) {
const selectedNode = selectedNodes[i];
node.pos[0] = selectedNode.pos[0] + addOffset;
node.pos[1] = selectedNode.pos[1] + addOffset;
isSelectedNode = true;
break;
}
if (!isSelectedNode) {
const graphMouse = app.canvas.graph_mouse;
node.pos[0] = graphMouse[0];
node.pos[1] = graphMouse[1];
}
app.graph.add(node, {doProcessChange: true});
app.canvas.selectNode(node);
success = true;
}
event.stopPropagation();
}
else if (modelType === "embeddings") {
const embeddingFile = pathToFileString(path);
const selectedNodes = app.canvas.selected_nodes;
for (var i in selectedNodes) {
const selectedNode = selectedNodes[i];
const nodeType = modelNodeType[modelType];
const widgetIndex = modelWidgetIndex(nodeType);
const target = selectedNode.widgets[widgetIndex].element;
if (target && target.type === "textarea") {
target.value = insertEmbeddingIntoText(target.value, embeddingFile, removeEmbeddingExtension);
success = true;
}
}
if (!success) {
console.warn("Try selecting a node before adding the embedding.");
}
event.stopPropagation();
}
buttonAlert(event.target, success, "✔", "✖", "✚");
}
/**
* @param {Event} event
* @param {string} modelType
* @param {string} path
* @param {boolean} removeEmbeddingExtension
* @param {boolean} strictDragToAdd
*/
static #dragAddModel(event, modelType, path, removeEmbeddingExtension, strictDragToAdd) {
const target = document.elementFromPoint(event.x, event.y);
if (modelType !== "embeddings" && target.id === "graph-canvas") {
const nodeType = modelNodeType[modelType];
const widgetIndex = modelWidgetIndex(nodeType);
const pos = app.canvas.convertEventToCanvasOffset(event);
const nodeAtPos = app.graph.getNodeOnPos(pos[0], pos[1], app.canvas.visible_nodes);
let draggedOnNode = nodeAtPos && nodeAtPos.type === nodeType;
if (strictDragToAdd) {
const draggedOnWidget = app.canvas.processNodeWidgets(nodeAtPos, pos, event) === nodeAtPos.widgets[widgetIndex];
draggedOnNode = draggedOnNode && draggedOnWidget;
}
if (draggedOnNode) {
let node = nodeAtPos;
node.widgets[widgetIndex].value = path;
app.canvas.selectNode(node);
}
else {
let node = LiteGraph.createNode(nodeType, null, []);
if (node) {
node.pos[0] = pos[0];
node.pos[1] = pos[1];
node.widgets[widgetIndex].value = path;
app.graph.add(node, {doProcessChange: true});
app.canvas.selectNode(node);
}
}
event.stopPropagation();
}
else if (modelType === "embeddings" && target.type === "textarea") {
const pos = app.canvas.convertEventToCanvasOffset(event);
const nodeAtPos = app.graph.getNodeOnPos(pos[0], pos[1], app.canvas.visible_nodes);
if (nodeAtPos) {
app.canvas.selectNode(nodeAtPos);
const embeddingFile = pathToFileString(path);
target.value = insertEmbeddingIntoText(target.value, embeddingFile, removeEmbeddingExtension);
event.stopPropagation();
}
}
}
/**
* @param {Event} event
* @param {string} modelType
* @param {string} path
* @param {boolean} removeEmbeddingExtension
*/
static #copyModelToClipboard(event, modelType, path, removeEmbeddingExtension) {
const nodeType = modelNodeType[modelType];
let success = false;
if (nodeType === "Embedding") {
if (navigator.clipboard){
const embeddingFile = pathToFileString(path);
const embeddingText = insertEmbeddingIntoText("", embeddingFile, removeEmbeddingExtension);
navigator.clipboard.writeText(embeddingText);
success = true;
}
else {
console.warn("Cannot copy the embedding to the system clipboard; Try dragging it instead.");
}
}
else if (nodeType) {
const node = LiteGraph.createNode(nodeType, null, []);
const widgetIndex = modelWidgetIndex(nodeType);
node.widgets[widgetIndex].value = path;
app.canvas.copyToClipboard([node]);
success = true;
}
else {
console.warn(`Unable to copy unknown model type '${modelType}.`);
}
buttonAlert(event.target, success, "✔", "✖", "⧉︎");
}
/**
* @param {Array} models
* @param {string} modelType
* @param {Object.<HTMLInputElement>} settingsElements
* @returns {HTMLElement[]}
*/
static #generateInnerHtml(models, modelType, settingsElements) {
// TODO: seperate text and model logic; getting too messy
// TODO: fallback on button failure to copy text?
const canShowButtons = modelNodeType[modelType] !== undefined;
const showAddButton = canShowButtons && settingsElements["model-show-add-button"].checked;
const showCopyButton = canShowButtons && settingsElements["model-show-copy-button"].checked;
const strictDragToAdd = settingsElements["model-add-drag-strict-on-field"].checked;
const addOffset = parseInt(settingsElements["model-add-offset"].value);
const showModelExtension = settingsElements["model-show-label-extensions"].checked;
const removeEmbeddingExtension = !settingsElements["model-add-embedding-extension"].checked;
if (models.length > 0) {
return models.map((item) => {
const uri = item.post ?? "no-post";
const imgUrl = `/model-manager/image-preview?uri=${uri}`;
let buttons = [];
if (showAddButton) {
buttons.push(
$el("button.icon-button.model-button", {
type: "button",
textContent: "⧉︎",
onclick: (e) => ModelGrid.#copyModelToClipboard(e, modelType, item.path, removeEmbeddingExtension),
draggable: false,
})
);
}
if (showCopyButton) {
buttons.push(
$el("button.icon-button.model-button", {
type: "button",
textContent: "✚",
onclick: (e) => ModelGrid.#addModel(e, modelType, item.path, removeEmbeddingExtension, addOffset),
draggable: false,
})
);
}
const dragAdd = (e) => ModelGrid.#dragAddModel(e, modelType, item.path, removeEmbeddingExtension, strictDragToAdd);
return $el("div.item", {}, [
$el("img.model-preview", {
src: imgUrl,
draggable: false,
}),
$el("div.model-preview-overlay", {
ondragend: (e) => dragAdd(e),
draggable: true,
}),
$el("div.model-preview-top-right", {
draggable: false,
},
buttons
),
$el("div.model-label", {
ondragend: (e) => dragAdd(e),
draggable: true,
}, [
$el("p", [showModelExtension ? item.name : removeModelExtension(item.name)])
]),
]);
});
} else {
return [$el("h2", ["No Models"])];
}
}
/**
* @param {HTMLDivElement} modelGrid
* @param {Object} models
* @param {HTMLSelectElement} modelSelect
* @param {Object.<{value: string}>} previousModelType
* @param {Object} settings
* @param {string} sortBy
* @param {boolean} reverseSort
* @param {Array} previousModelFilters
* @param {HTMLInputElement} modelFilter
*/
static update(modelGrid, models, modelSelect, previousModelType, settings, sortBy, reverseSort, previousModelFilters, modelFilter) {
let modelType = modelSelect.value;
if (models[modelType] === undefined) {
modelType = "checkpoints"; // TODO: magic value
}
if (modelType !== previousModelType.value) {
if (settings["model-persistent-search"].checked) {
previousModelFilters.splice(0, previousModelFilters.length); // TODO: make sure this actually worked!
}
else {
// cache previous filter text
previousModelFilters[previousModelType.value] = modelFilter.value;
// read cached filter text
modelFilter.value = previousModelFilters[modelType] ?? "";
}
previousModelType.value = modelType;
}
let modelTypeOptions = [];
for (const [key, value] of Object.entries(models)) {
const el = $el("option", [key]);
modelTypeOptions.push(el);
}
modelSelect.innerHTML = "";
modelTypeOptions.forEach(option => modelSelect.add(option));
modelSelect.value = modelType;
const searchAppend = settings["model-search-always-append"].value;
const searchText = modelFilter.value + " " + searchAppend;
const modelList = ModelGrid.#filter(models[modelType], searchText);
ModelGrid.#sort(modelList, sortBy, reverseSort);
modelGrid.innerHTML = "";
const modelGridModels = ModelGrid.#generateInnerHtml(modelList, modelType, settings);
modelGrid.append.apply(modelGrid, modelGridModels);
}
}
/**
* @param {Any} attr
* @returns {HTMLDivElement}
*/
function $radioGroup(attr) {
const { name = Date.now(), onchange, options = [], $ } = attr;
/** @type {HTMLDivElement[]} */
const radioGroup = options.map((item, index) => {
const inputRef = { value: null };
return $el(
"div.comfy-radio",
{ onclick: () => inputRef.value.click() },
[
$el("input.radio-input", {
type: "radio",
name: name,
value: item.value,
checked: index === 0,
$: (el) => (inputRef.value = el),
}),
$el("label", [item.label ?? item.value]),
]
);
});
const element = $el("input", { value: options[0]?.value });
$?.(element);
radioGroup.forEach((radio) => {
radio.addEventListener("change", (event) => {
const selectedValue = event.target.value;
element.value = selectedValue;
onchange?.(selectedValue);
});
});
return $el("div.comfy-radio-group", radioGroup);
}
class ModelManager extends ComfyDialog {
#el = {
/** @type {HTMLButtonElement} */ loadSourceBtn: null,
/** @type {HTMLInputElement} */ loadSourceFromInput: null,
/** @type {HTMLSelectElement} */ sourceInstalledFilter: null,
/** @type {HTMLInputElement} */ sourceContentFilter: null,
/** @type {HTMLDivElement} */ modelGrid: null,
/** @type {HTMLSelectElement} */ modelTypeSelect: null,
/** @type {HTMLSelectElement} */ modelSortSelect: null,
/** @type {HTMLDivElement} */ searchDirectoryDropdown: null,
/** @type {HTMLInputElement} */ modelContentFilter: null,
/** @type {HTMLDivElement} */ sidebarButtons: null,
/** @type {HTMLDivElement} */ settingsTab: null,
/** @type {HTMLButtonElement} */ settings_reloadBtn: null,
/** @type {HTMLButtonElement} */ settings_saveBtn: null,
settings: {
//"sidebar-default-height": null,
//"sidebar-default-width": null,
/** @type {HTMLTextAreaElement} */ "model-search-always-append": null,
/** @type {HTMLInputElement} */ "model-persistent-search": 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-add-embedding-extension": null,
/** @type {HTMLInputElement} */ "model-add-drag-strict-on-field": null,
/** @type {HTMLInputElement} */ "model-add-offset": null,
}
};
#data = {
/** @type {Array} */ sources: [],
/** @type {Object} */ models: {},
/** @type {DirectoryItem[]} */ modelDirectories: [],
/** @type {Array} */ previousModelFilters: [],
/** @type {Object.<{value: string}>} */ previousModelType: { value: undefined },
};
/** @type {string} */
#sep = "/";
/** @type {SourceList} */
#sourceList = null;
constructor() {
super();
this.element = $el(
"div.comfy-modal.model-manager",
{
parent: document.body,
},
[
$el("div.comfy-modal-content", [ // TODO: settings.top_bar_left_to_right or settings.top_bar_right_to_left
$el("div.topbar-buttons",
[
$el("div.sidebar-buttons",
{
$: (el) => (this.#el.sidebarButtons = el),
},
[
$el("button.icon-button", {
textContent: "◧",
onclick: (event) => this.#setSidebar(event),
}),
$el("button.icon-button", {
textContent: "⬒",
onclick: (event) => this.#setSidebar(event),
}),
$el("button.icon-button", {
textContent: "⬓",
onclick: (event) => this.#setSidebar(event),
}),
$el("button.icon-button", {
textContent: "◨",
onclick: (event) => this.#setSidebar(event),
}),
]),
$el("button.icon-button", {
textContent: "✖",
onclick: () => this.close(),
}),
]
),
$tabs([
$tab("Install", this.#createSourceInstall()),
$tab("Models", this.#modelTab_new()),
$tab("Settings", [this.#settingsTab_new()]),
]),
]),
]
);
this.#init();
}
#init() {
this.#settingsTab_reload(false);
this.#refreshSourceList();
this.#modelTab_updateModels();
}
/**
* @returns {HTMLDivElement[]}
*/
#createSourceInstall() {
this.#createSourceList();
return [
$el("div.row.tab-header", [
$el("div.row.tab-header-flex-block", [
$el("button.icon-button", {
type: "button",
textContent: "⟳",
$: (el) => (this.#el.loadSourceBtn = el),
onclick: () => this.#refreshSourceList(),
}),
$el("input.source-text-area", {
$: (el) => (this.#el.loadSourceFromInput = el),
placeholder: "https://ComfyUI-Model-Manager/index.json",
}),
]),
$el("div.row.tab-header-flex-block", [
$el("input.search-text-area", {
$: (el) => (this.#el.sourceContentFilter = el),
placeholder: "example: \"sd_xl\" -vae",
onkeydown: (e) => e.key === "Enter" && this.#filterSourceList(),
}),
$el("select",
{
$: (el) => (this.#el.sourceInstalledFilter = el),
style: { width: 0 },
onchange: () => this.#filterSourceList(),
},
[
$el("option", ["Filter: All"]),
$el("option", ["Downloaded"]),
$el("option", ["Not Downloaded"]),
]
),
$el("button.icon-button", {
type: "button",
textContent: "🔍︎",
onclick: () => this.#filterSourceList(),
}),
]),
]),
this.#sourceList.element,
];
}
/**
* @returns {HTMLElement}
*/
#createSourceList() {
const sourceList = new SourceList([
{
title: "Type",
dataIndex: "type",
width: "120",
align: "center",
},
{
title: "Base",
dataIndex: "base",
width: "120",
align: "center",
},
{
title: "Name",
dataIndex: "name",
width: "280",
render: (value, record) => {
const href = record.page;
return $el("a", { target: "_blank", href }, [value]);
},
},
{
title: "Description",
dataIndex: "description",
},
{
title: "Download",
width: "150",
render: (_, record) => {
const installed = record.installed;
return $el("button.block", {
type: "button",
disabled: installed,
textContent: installed ? "✓︎" : "📥︎",
onclick: async (e) => {
e.disabled = true;
const response = await request(
"/model-manager/download",
{
method: "POST",
body: JSON.stringify(record),
}
);
e.disabled = false;
},
});
},
},
]);
this.#sourceList = sourceList;
return sourceList.element;
}
async #refreshSourceList() {
this.#el.loadSourceBtn.disabled = true;
const source = this.#el.loadSourceFromInput.value;
const uri = (source === "https://ComfyUI-Model-Manager/index.json") || (source === "") ? "local" : source;
const dataSource = await request(
`/model-manager/source?uri=${uri}`
).catch(() => []);
this.#data.sources = dataSource;
this.#sourceList.setData(dataSource);
this.#el.sourceInstalledFilter.value = "Filter: All";
this.#el.sourceContentFilter.value = "";
this.#el.loadSourceBtn.disabled = false;
}
#filterSourceList() {
this.#sourceList.filterList(
this.#data.sources,
this.#el.sourceContentFilter.value,
this.#el.sourceInstalledFilter.value
);
}
/** @type {DirectoryDropdown} */
#modelContentFilterDirectoryDropdown = null;
/**
* @returns {HTMLElement[]}
*/
#modelTab_new() {
/** @type {HTMLDivElement} */
const modelGrid = $el("div.comfy-grid");
this.#el.modelGrid = modelGrid;
const searchInput = $el("input.search-text-area", {
$: (el) => (this.#el.modelContentFilter = el),
placeholder: "example: /0/1.5/styles/clothing -.pt",
});
const searchDropdown = new DirectoryDropdown(
searchInput,
this.#modelTab_updateDirectoryDropdown,
this.#modelTab_submitSearch
);
this.#modelContentFilterDirectoryDropdown = searchDropdown;
return [
$el("div.row.tab-header", [
$el("div.row.tab-header-flex-block", [
$el("button.icon-button", {
type: "button",
textContent: "⟳",
onclick: () => this.#modelTab_updateModels(),
}),
$el("select.model-select-dropdown", {
$: (el) => (this.#el.modelTypeSelect = el),
name: "model-type",
onchange: () => this.#modelTab_updateModelGrid(),
}),
$el("select.model-select-dropdown",
{
$: (el) => (this.#el.modelSortSelect = el),
onchange: () => this.#modelTab_updateModelGrid(),
},
[
$el("option", { value: MODEL_SORT_DATE_CREATED }, ["Date Created (newest to oldest)"]),
$el("option", { value: "-" + MODEL_SORT_DATE_CREATED }, ["Date Created (oldest to newest)"]),
$el("option", { value: MODEL_SORT_DATE_MODIFIED }, ["Date Modified (newest to oldest)"]),
$el("option", { value: "-" + MODEL_SORT_DATE_MODIFIED }, ["Date Modified (oldest to newest)"]),
$el("option", { value: MODEL_SORT_DATE_NAME }, ["Name (A-Z)"]),
$el("option", { value: "-" + MODEL_SORT_DATE_NAME }, ["Name (Z-A)"]),
],
),
]),
$el("div.row.tab-header-flex-block", [
$el("div.search-models", [
searchInput,
searchDropdown.element,
]),
$el("button.icon-button", {
type: "button",
textContent: "🔍︎",
onclick: () => this.#modelTab_updateModelGrid(),
}),
]),
]),
modelGrid,
];
}
#modelTab_updateModelGrid = () => {
const sortValue = this.#el.modelSortSelect.value;
const reverseSort = sortValue[0] === "-";
const sortBy = reverseSort ? sortValue.substring(1) : sortValue;
ModelGrid.update(
this.#el.modelGrid,
this.#data.models,
this.#el.modelTypeSelect,
this.#data.previousModelType,
this.#el.settings,
sortBy,
reverseSort,
this.#data.previousModelFilters,
this.#el.modelContentFilter
);
}
async #modelTab_updateModels() {
this.#data.models = await request("/model-manager/models");
this.#data.modelDirectories = await request("/model-manager/model-directory-list");
this.#modelTab_updateModelGrid();
}
#modelTab_updateDirectoryDropdown = () => {
const modelType = this.#el.modelTypeSelect.value;
this.#modelContentFilterDirectoryDropdown.update(
this.#data.modelDirectories,
this.#sep,
modelType,
);
const value = this.#el.modelContentFilter.value;
this.#data.previousModelFilters[modelType] = value;
}
/**
* @param {HTMLInputElement} input
* @param {HTMLParagraphElement | undefined | null} selection
*/
#modelTab_submitSearch = (input, selection) => {
if (selection !== undefined && selection !== null) {
DirectoryDropdown.appendSelectionToInput(selection, input, this.#sep);
this.#modelTab_updateDirectoryDropdown();
}
input.blur();
this.#modelTab_updateModelGrid();
}
/**
* @param {HTMLInputElement[]} settings
* @param {boolean} reloadData
*/
#setSettings(settings, reloadData) {
const el = this.#el.settings;
for (const [key, value] of Object.entries(settings)) {
const setting = el[key];
if (setting) {
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;
default: console.warn("Unknown settings input type!");
}
}
}
if (reloadData) {
// Is this slow?
this.#refreshSourceList();
this.#modelTab_updateModels();
}
}
/**
* @param {boolean} reloadData
*/
async #settingsTab_reload(reloadData) {
const data = await request("/model-manager/settings/load");
const settings = data["settings"];
this.#setSettings(settings, reloadData);
buttonAlert(this.#el.settings_reloadBtn, true);
}
async #settingsTab_save() {
let settings = {};
for (const [setting, el] of Object.entries(this.#el.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;
default: console.warn("Unknown settings input type!");
}
settings[setting] = value;
}
const data = await request(
"/model-manager/settings/save",
{
method: "POST",
body: JSON.stringify({ "settings": settings }),
}
);
const success = data["success"];
if (success) {
const settings = data["settings"];
this.#setSettings(settings, true);
}
buttonAlert(this.#el.settings_saveBtn, success);
}
/**
* @returns {HTMLElement}
*/
#settingsTab_new() {
const settingsTab = $el("div.model-manager-settings", [
$el("h1", ["Settings"]),
$el("div", [
$el("button", {
$: (el) => (this.#el.settings_reloadBtn = el),
type: "button",
textContent: "Reload", // ⟳
onclick: () => this.#settingsTab_reload(true),
}),
$el("button", {
$: (el) => (this.#el.settings_saveBtn = el),
type: "button",
textContent: "Save", // 💾︎
onclick: () => this.#settingsTab_save(),
}),
]),
/*
$el("h2", ["Window"]),
$el("div", [
$el("p", ["Default sidebar width"]),
$el("input", {
$: (el) => (this.#el.settings["sidebar-default-width"] = el),
type: "number",
value: 0.5,
min: 0.0,
max: 1.0,
step: 0.05,
}),
]),
$el("div", [
$el("p", ["Default sidebar height"]),
$el("input", {
$: (el) => (this.#el.settings["sidebar-default-height"] = el),
type: "number",
textContent: "Default sidebar height",
value: 0.5,
min: 0.0,
max: 1.0,
step: 0.05,
}),
]),
*/
$el("h2", ["Model Search"]),
$el("div", [
$el("div.search-settings-text", [
$el("p", ["Always include in model search:"]),
$el("textarea.comfy-multiline-input", {
$: (el) => (this.#el.settings["model-search-always-append"] = el),
placeholder: "example: -nsfw",
}),
]),
]),
$el("div", [
$el("input", {
$: (el) => (this.#el.settings["model-persistent-search"] = el),
type: "checkbox",
}),
$el("p", ["Persistent search text across model types"]),
]),
$el("div", [
$el("input", {
$: (el) => (this.#el.settings["model-show-label-extensions"] = el),
type: "checkbox",
}),
$el("p", ["Show model file extension in labels"]),
]),
$el("div", [
$el("input", {
$: (el) => (this.#el.settings["model-show-add-button"] = el),
type: "checkbox",
}),
$el("p", ["Show add button"]),
]),
$el("div", [
$el("input", {
$: (el) => (this.#el.settings["model-show-copy-button"] = el),
type: "checkbox",
}),
$el("p", ["Show copy button"]),
]),
$el("h2", ["Model Add"]),
$el("div", [
$el("input", {
$: (el) => (this.#el.settings["model-add-embedding-extension"] = el),
type: "checkbox",
}),
$el("p", ["Add extension to embedding"]),
]),
$el("div", [
$el("input", {
$: (el) => (this.#el.settings["model-add-drag-strict-on-field"] = el),
type: "checkbox",
}),
$el("p", ["Strict dragging model onto a node's model field to add"]),
]),
$el("div", [
$el("input", {
$: (el) => (this.#el.settings["model-add-offset"] = el),
type: "number",
step: 5,
}),
$el("p", ["Add model offset"]),
]),
]);
this.#el.settingsTab = settingsTab;
return settingsTab;
}
/**
* @param {Event} e
*/
#setSidebar(e) {
// TODO: settings["sidebar-default-width"]
// TODO: settings["sidebar-default-height"]
// TODO: draggable resize?
const button = e.target;
const modelManager = this.element;
const sidebarButtons = this.#el.sidebarButtons.children;
let buttonIndex;
for (buttonIndex = 0; buttonIndex < sidebarButtons.length; buttonIndex++) {
if (sidebarButtons[buttonIndex] === button) {
break;
}
}
const sidebarStates = ["sidebar-left", "sidebar-top", "sidebar-bottom", "sidebar-right"];
let stateIndex;
for (stateIndex = 0; stateIndex < sidebarStates.length; stateIndex++) {
const state = sidebarStates[stateIndex];
if (modelManager.classList.contains(state)) {
modelManager.classList.remove(state);
break;
}
}
if (stateIndex != buttonIndex) {
const newSidebarState = sidebarStates[buttonIndex];
modelManager.classList.add(newSidebarState);
}
}
}
let instance;
/**
* @returns {ModelManager}
*/
function getInstance() {
if (!instance) {
instance = new ModelManager();
}
return instance;
}
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: () => { getInstance().show(); },
})
);
},
});