Added "View Model Info" and "Delete Model" functionality.

NOTE: Some small (but important) changes under the hood that may have broken things. "Quality control" tests were done, but may be have missed things.

- View model info in Model Tab.
- Delete model in Model Info view. (Uses Co
- Reduced model path (search path) in models request and added system separator request.
- Reworked "system path" "search path", "system separator" and "search separator" (a bit messy, maybe needs another rework).
- Updated REST uri structure to be more consistent.
This commit is contained in:
Christian Bastian
2024-02-17 13:30:58 -05:00
parent 8fbbff2175
commit acc15298bc
3 changed files with 414 additions and 71 deletions

View File

@@ -125,15 +125,23 @@
background-color: rgba(0, 0, 0, 0);
}
.comfy-grid .model-preview-top-right {
.comfy-grid .model-preview-top-right,
.comfy-grid .model-preview-top-left {
position: absolute;
display: flex;
flex-direction: column;
gap: 8px;
top: 8px;
}
.comfy-grid .model-preview-top-right {
right: 8px;
}
.comfy-grid .model-preview-top-left {
left: 8px;
}
.comfy-grid .model-button {
opacity: 0.65;
}
@@ -461,3 +469,28 @@
.model-manager [data-name="Download"] .download-settings {
flex: 1;
}
.model-manager .model-info-view {
background-color: var(--comfy-menu-bg);
display: none;
height: 100%;
overflow-wrap: break-word;
width: 100%;
}
.model-manager .model-info-container {
background-color: var(--bg-color);
border-radius: 16px;
color: var(--fg-color);
margin-top: 8px;
max-height: 90%;
padding: 16px;
overflow: auto;
width: auto;
}
.model-manager .no-select {
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}

View File

@@ -55,7 +55,7 @@ const MODEL_SORT_DATE_CREATED = "dateCreated";
const MODEL_SORT_DATE_MODIFIED = "dateModified";
const MODEL_SORT_DATE_NAME = "name";
const MODEL_EXTENSIONS = [".ckpt", ".pt", ".bin", ".pth", ".safetensors"]; // TODO: ask server for?
const MODEL_EXTENSIONS = [".bin", ".ckpt", ".onnx", ".pt", ".pth", ".safetensors"]; // TODO: ask server for?
const IMAGE_EXTENSIONS = [".apng", ".gif", ".jpeg", ".jpg", ".png", ".webp"]; // TODO: ask server for?
/**
@@ -174,10 +174,10 @@ function civitai_getModelFilesInfo(modelVersionInfo, type = null, fp = null, siz
*/
async function civitai_getFilteredInfo(stringUrl) {
const url = new URL(stringUrl);
if (url.hostname != 'civitai.com') { return {}; }
if (url.pathname == '/') { return {} }
if (url.hostname != "civitai.com") { return {}; }
if (url.pathname == "/") { return {} }
const urlPath = url.pathname;
if (urlPath.startsWith('/api')) {
if (urlPath.startsWith("/api")) {
const idEnd = urlPath.length - (urlPath.at(-1) == "/" ? 1 : 0);
const idStart = urlPath.lastIndexOf("/", idEnd - 1) + 1;
const modelVersionId = urlPath.substring(idStart, idEnd);
@@ -260,8 +260,8 @@ async function huggingFace_requestInfo(id, apiPath = "models") {
*/
async function huggingFace_getFilteredInfo(stringUrl) {
const url = new URL(stringUrl);
if (url.hostname != 'huggingface.co') { return {}; }
if (url.pathname == '/') { return {} }
if (url.hostname != "huggingface.co") { return {}; }
if (url.pathname == "/") { return {} }
const urlPath = url.pathname;
const i0 = 1;
const i1 = urlPath.indexOf("/", i0);
@@ -376,10 +376,10 @@ class DirectoryDropdown {
* @param {Function} updateDropdown
* @param {Function} [updateCallback= () => {}]
* @param {Function} [submitCallback= () => {}]
* @param {String} [sep="/"]
* @param {String} [searchSeparator="/"]
* @param {Boolean} [showDirectoriesOnly=false]
*/
constructor(input, updateDropdown, updateCallback = () => {}, submitCallback = () => {}, sep = "/", showDirectoriesOnly = false) {
constructor(input, updateDropdown, updateCallback = () => {}, submitCallback = () => {}, searchSeparator = "/", showDirectoriesOnly = false) {
/** @type {HTMLDivElement} */
const dropdown = $el("div.search-dropdown", { // TODO: change to `search-directory-dropdown`
style: {
@@ -423,7 +423,7 @@ class DirectoryDropdown {
e.stopPropagation();
e.preventDefault(); // prevent cursor move
const input = e.target;
DirectoryDropdown.selectionToInput(input, selection, sep);
DirectoryDropdown.selectionToInput(input, selection, searchSeparator);
updateDropdown();
//updateCallback();
//submitCallback();
@@ -439,11 +439,11 @@ class DirectoryDropdown {
else if (e.key === "ArrowLeft" && dropdown.style.display !== "none") {
const input = e.target;
const oldFilterText = input.value;
const iSep = oldFilterText.lastIndexOf(sep, oldFilterText.length - 2);
const iSep = oldFilterText.lastIndexOf(searchSeparator, oldFilterText.length - 2);
const newFilterText = oldFilterText.substring(0, iSep + 1);
if (oldFilterText !== newFilterText) {
const delta = oldFilterText.substring(iSep + 1);
let isMatch = delta[delta.length-1] === sep;
let isMatch = delta[delta.length-1] === searchSeparator;
if (!isMatch) {
const options = dropdown.children;
for (let i = 0; i < options.length; i++) {
@@ -488,7 +488,7 @@ class DirectoryDropdown {
const input = e.target
const selection = options[iSelection];
if (selection !== undefined && selection !== null) {
DirectoryDropdown.selectionToInput(input, selection, sep);
DirectoryDropdown.selectionToInput(input, selection, searchSeparator);
updateDropdown();
updateCallback();
}
@@ -542,23 +542,23 @@ class DirectoryDropdown {
/**
* @param {HTMLInputElement} input
* @param {HTMLParagraphElement | undefined | null} selection
* @param {String} [sep="/"]
* @param {String} searchSeparator
*/
static selectionToInput(input, selection, sep) {
static selectionToInput(input, selection, searchSeparator) {
selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS);
const selectedText = selection.innerText;
const oldFilterText = input.value;
const iSep = oldFilterText.lastIndexOf(sep);
const iSep = oldFilterText.lastIndexOf(searchSeparator);
const previousPath = oldFilterText.substring(0, iSep + 1);
input.value = previousPath + selectedText;
}
/**
* @param {DirectoryItem[]} directories
* @param {string} sep
* @param {string} searchSeparator
* @param {string} [modelType = ""]
*/
update(directories, sep, modelType = "") {
update(directories, searchSeparator, modelType = "") {
const dropdown = this.element;
const input = this.#input;
const updateDropdown = this.#updateDropdown;
@@ -567,7 +567,7 @@ class DirectoryDropdown {
const showDirectoriesOnly = this.showDirectoriesOnly;
const filter = input.value;
if (filter[0] !== sep) {
if (filter[0] !== searchSeparator) {
dropdown.style.display = "none";
return;
}
@@ -590,7 +590,7 @@ class DirectoryDropdown {
// TODO: directories === undefined?
let indexLastWord = 1;
while (true) {
const indexNextWord = filter.indexOf(sep, indexLastWord);
const indexNextWord = filter.indexOf(searchSeparator, indexLastWord);
if (indexNextWord === -1) {
// end of filter
break;
@@ -643,7 +643,7 @@ class DirectoryDropdown {
const isDir = grandChildCount !== undefined && grandChildCount !== null && grandChildCount > 0;
const itemName = child["name"];
if (itemName.startsWith(lastWord) && (!showDirectoriesOnly || (showDirectoriesOnly && isDir))) {
options.push(itemName + (isDir ? "/" : ""));
options.push(itemName + (isDir ? searchSeparator : ""));
}
}
}
@@ -680,7 +680,7 @@ class DirectoryDropdown {
const selection_submit = (e) => {
e.stopPropagation();
const selection = e.target;
DirectoryDropdown.selectionToInput(input, selection, sep);
DirectoryDropdown.selectionToInput(input, selection, searchSeparator);
updateDropdown();
updateCallback();e.target
submitCallback();
@@ -729,6 +729,16 @@ function pathToFileString(path) {
return path.slice(i);
}
/**
* @param {string} path
* @returns {string}
*/
function searchPathToSystemPath(path, searchSeparator, systemSeparator) {
const i1 = path.indexOf(searchSeparator, 1);
const i2 = path.indexOf(searchSeparator, i1 + 1);
return path.slice(i2 + 1).replaceAll(searchSeparator, systemSeparator);
}
/**
* @param {string} file
* @returns {string | undefined}
@@ -764,6 +774,9 @@ function insertEmbeddingIntoText(text, file, removeExtension) {
* @param {string} [resetText=""]
*/
function buttonAlert(element, success, successText = "", failureText = "", resetText = "") {
if (element === undefined || element === null) {
return;
}
const name = success ? "button-success" : "button-failure";
element.classList.add(name);
if (successText != "" && failureText != "") {
@@ -775,7 +788,7 @@ function buttonAlert(element, success, successText = "", failureText = "", reset
if (innerHTML != "") {
element.innerHTML = innerHTML;
}
}, 500, element, name, resetText);
}, 1000, element, name, resetText);
}
class Tabs {
@@ -874,7 +887,7 @@ class ModelGrid {
.filter(Boolean);
const regexSHA256 = /^[a-f0-9]{64}$/gi;
const fields = ["name", "searchPath"]; // TODO: Remove "searchPath" hack.
const fields = ["name", "path"];
return list.filter((element) => {
const text = fields
.reduce((memo, field) => memo + " " + element[field], "")
@@ -1063,9 +1076,12 @@ class ModelGrid {
* @param {Array} models
* @param {string} modelType
* @param {Object.<HTMLInputElement>} settingsElements
* @param {String} searchSeparator
* @param {String} systemSeparator
* @param {Function} modelInfoCallback
* @returns {HTMLElement[]}
*/
static #generateInnerHtml(models, modelType, settingsElements) {
static #generateInnerHtml(models, modelType, settingsElements, searchSeparator, systemSeparator, modelInfoCallback) {
// TODO: seperate text and model logic; getting too messy
// TODO: fallback on button failure to copy text?
const canShowButtons = modelNodeType[modelType] !== undefined;
@@ -1078,14 +1094,21 @@ class ModelGrid {
if (models.length > 0) {
return models.map((item) => {
const uri = item.post ?? "no-post";
const imgUrl = `/model-manager/image-preview?uri=${uri}`;
const imgUrl = `/model-manager/image/preview?uri=${uri}`;
const searchPath = item.path;
const path = searchPathToSystemPath(searchPath, searchSeparator, systemSeparator);
let buttons = [];
if (showAddButton) {
buttons.push(
$el("button.icon-button.model-button", {
type: "button",
textContent: "⧉︎",
onclick: (e) => ModelGrid.#copyModelToClipboard(e, modelType, item.path, removeEmbeddingExtension),
onclick: (e) => ModelGrid.#copyModelToClipboard(
e,
modelType,
path,
removeEmbeddingExtension
),
draggable: false,
})
);
@@ -1095,12 +1118,24 @@ class ModelGrid {
$el("button.icon-button.model-button", {
type: "button",
textContent: "✚",
onclick: (e) => ModelGrid.#addModel(e, modelType, item.path, removeEmbeddingExtension, addOffset),
onclick: (e) => ModelGrid.#addModel(
e,
modelType,
path,
removeEmbeddingExtension,
addOffset
),
draggable: false,
})
);
}
const dragAdd = (e) => ModelGrid.#dragAddModel(e, modelType, item.path, removeEmbeddingExtension, strictDragToAdd);
const dragAdd = (e) => ModelGrid.#dragAddModel(
e,
modelType,
path,
removeEmbeddingExtension,
strictDragToAdd
);
return $el("div.item", {}, [
$el("img.model-preview", {
src: imgUrl,
@@ -1115,6 +1150,16 @@ class ModelGrid {
},
buttons
),
$el("div.model-preview-top-left", {
draggable: false,
}, [
$el("button.icon-button.model-button", {
type: "button",
textContent: "ⓘ",
onclick: async() => modelInfoCallback(modelType, searchPath),
draggable: false,
}),
]),
$el("div.model-label", {
ondragend: (e) => dragAdd(e),
draggable: true,
@@ -1138,8 +1183,11 @@ class ModelGrid {
* @param {boolean} reverseSort
* @param {Array} previousModelFilters
* @param {HTMLInputElement} modelFilter
* @param {String} searchSeparator
* @param {String} systemSeparator
* @param {Function} modelInfoCallback
*/
static update(modelGrid, models, modelSelect, previousModelType, settings, sortBy, reverseSort, previousModelFilters, modelFilter) {
static update(modelGrid, models, modelSelect, previousModelType, settings, sortBy, reverseSort, previousModelFilters, modelFilter, searchSeparator, systemSeparator, modelInfoCallback) {
let modelType = modelSelect.value;
if (models[modelType] === undefined) {
modelType = "checkpoints"; // TODO: magic value
@@ -1173,7 +1221,14 @@ class ModelGrid {
ModelGrid.#sort(modelList, sortBy, reverseSort);
modelGrid.innerHTML = "";
const modelGridModels = ModelGrid.#generateInnerHtml(modelList, modelType, settings);
const modelGridModels = ModelGrid.#generateInnerHtml(
modelList,
modelType,
settings,
searchSeparator,
systemSeparator,
modelInfoCallback,
);
modelGrid.append.apply(modelGrid, modelGridModels);
}
}
@@ -1221,6 +1276,8 @@ function $radioGroup(attr) {
class ModelManager extends ComfyDialog {
#el = {
/** @type {HTMLDivElement} */ modelInfoView: null,
/** @type {HTMLDivElement} */ modelInfoContainer: null,
/** @type {HTMLDivElement} */ modelInfoUrl: null,
/** @type {HTMLDivElement} */ modelInfos: null,
@@ -1257,7 +1314,10 @@ class ModelManager extends ComfyDialog {
};
/** @type {string} */
#sep = "/";
#searchSeparator = "/";
/** @type {string} */
#systemSeparator = null;
constructor() {
super();
@@ -1268,6 +1328,53 @@ class ModelManager extends ComfyDialog {
},
[
$el("div.comfy-modal-content", [ // TODO: settings.top_bar_left_to_right or settings.top_bar_right_to_left
$el("div.model-info-view", {
$: (el) => (this.#el.modelInfoView = el),
}, [
$el("div", {
style: {
display: "flex",
gap: "8px",
},
}, [
$el("button.icon-button", {
textContent: "🗑︎",
onclick: async(e) => {
const affirmation = "delete";
const confirmation = window.prompt("Type \"" + affirmation + "\" to delete the model PERMANENTLY?\n\nThis includes all image or text files.");
let deleted = false;
if (confirmation === affirmation) {
const container = this.#el.modelInfoContainer;
const path = encodeURIComponent(container.dataset.path);
const type = encodeURIComponent(this.#el.modelTypeSelect.value);
await request(
`/model-manager/model/delete?path=${path}&type=${type}`,
{
method: "POST",
}
)
.then((result) => {
if (result["success"])
{
container.innerHTML = "";
this.#el.modelInfoView.style.display = "none";
this.#modelTab_updateModels();
deleted = true;
}
})
.catch(err => {});
}
if (!deleted) {
buttonAlert(e.target, false);
}
},
}),
]),
$el("div.model-info-container", {
$: (el) => (this.#el.modelInfoContainer = el),
"data-path": "",
}),
]),
$el("div.topbar-buttons",
[
$el("div.sidebar-buttons",
@@ -1294,7 +1401,15 @@ class ModelManager extends ComfyDialog {
]),
$el("button.icon-button", {
textContent: "✖",
onclick: () => this.close(),
onclick: () => {
const infoView = this.#el.modelInfoView;
if (infoView.style.display === "none") {
this.close();
}
else {
infoView.style.display = "none";
}
},
}),
]
),
@@ -1336,7 +1451,7 @@ class ModelManager extends ComfyDialog {
this.#modelTab_updateDirectoryDropdown,
this.#modelTab_updatePreviousModelFilter,
this.#modelTab_updateModelGrid,
this.#sep,
this.#searchSeparator,
false,
);
this.#modelContentFilterDirectoryDropdown = searchDropdown;
@@ -1398,13 +1513,17 @@ class ModelManager extends ComfyDialog {
sortBy,
reverseSort,
this.#data.previousModelFilters,
this.#el.modelContentFilter
this.#el.modelContentFilter,
this.#searchSeparator,
this.#systemSeparator,
this.#modelTab_showModelInfo,
);
}
async #modelTab_updateModels() {
this.#data.models = await request("/model-manager/models");
const newModelDirectories = await request("/model-manager/model-directory-list");
this.#systemSeparator = await request("/model-manager/system-separator");
this.#data.models = await request("/model-manager/models/list");
const newModelDirectories = await request("/model-manager/models/directory-list");
this.#data.modelDirectories.splice(0, Infinity, ...newModelDirectories); // note: do NOT create a new array
this.#modelTab_updateModelGrid();
}
@@ -1418,12 +1537,69 @@ class ModelManager extends ComfyDialog {
#modelTab_updateDirectoryDropdown = () => {
this.#modelContentFilterDirectoryDropdown.update(
this.#data.modelDirectories,
this.#sep,
this.#searchSeparator,
this.#el.modelTypeSelect.value,
);
this.#modelTab_updatePreviousModelFilter();
}
/**
* @param {string} modelType
* @param {string} searchPath
*/
#modelTab_showModelInfo = async(modelType, searchPath) => {
const type = encodeURIComponent(modelType);
const path = encodeURIComponent(searchPath);
const info = await request(`/model-manager/model/info?path=${path}&type=${type}`)
.catch(err => {
console.log(err);
return null;
});
if (info === null) {
return;
}
const infoHtml = this.#el.modelInfoContainer;
infoHtml.innerHTML = "";
infoHtml.dataset.path = searchPath;
const innerHtml = [];
const filename = info["File Name"];
if (filename !== undefined && filename !== null && filename !== "") {
innerHtml.push($el("h1", [filename]));
}
for (const [key, value] of Object.entries(info)) {
if (value === undefined || value === null || value === "") {
continue;
}
if (Array.isArray(value)) {
if (value.length > 0) {
innerHtml.push($el("h2", [key + ":"]));
let text = "<p>";
for (let i = 0; i < value.length; i++) {
const v = value[i];
const tag = v[0];
const count = v[1];
text += tag + "<span class=\"no-select\"> (" + count + ")</span>";
if (i !== value.length - 1) {
text += ", ";
}
}
text += "</p>";
const div = $el("div");
div.innerHTML = text;
innerHtml.push(div);
}
}
else {
innerHtml.push($el("p", [key + ": " + value]));
}
}
infoHtml.append.apply(infoHtml, innerHtml);
this.#el.modelInfoView.style.display = "block";
}
/**
* @param {HTMLInputElement[]} settings
* @param {boolean} reloadData
@@ -1482,7 +1658,9 @@ class ModelManager extends ComfyDialog {
method: "POST",
body: JSON.stringify({ "settings": settings }),
}
);
).catch((err) => {
return { "success": false };
});
const success = data["success"];
if (success) {
const settings = data["settings"];
@@ -1636,7 +1814,7 @@ class ModelManager extends ComfyDialog {
modelManager.classList.add(newSidebarState);
}
}
/**
* @param {HTMLDivElement} previewImageContainer
* @param {Event} e
@@ -1661,16 +1839,16 @@ class ModelManager extends ComfyDialog {
else if (currentIndex < 0) { currentIndex = children.length - 1; }
children[currentIndex].style.display = "block";
}
/**
* @param {Object} info
* @param {String[]} modelTypes
* @param {DirectoryItem[]} modelDirectories
* @param {String} sep
* @param {String} searchSeparator
* @param {int} id
* @returns {HTMLDivElement}
*/
#downloadTab_modelInfo(info, modelTypes, modelDirectories, sep, id) {
#downloadTab_modelInfo(info, modelTypes, modelDirectories, searchSeparator, id) {
// TODO: use passed in info
const RADIO_MODEL_PREVIEW_NONE = "No Preview";
const RADIO_MODEL_PREVIEW_DEFAULT = "Default Preview";
@@ -1715,13 +1893,13 @@ class ModelManager extends ComfyDialog {
if (modelType === "") { return; }
searchDropdown.update(
modelDirectories,
sep,
searchSeparator,
modelType,
);
},
() => {},
() => {},
sep,
searchSeparator,
true,
);
@@ -1869,7 +2047,7 @@ class ModelManager extends ComfyDialog {
let success = true;
let resultText = "✔";
await request(
"/model-manager/download",
"/model-manager/model/download",
{
method: "POST",
body: JSON.stringify(record),
@@ -1951,7 +2129,7 @@ class ModelManager extends ComfyDialog {
return modelInfo;
}
async #downloadTab_search() {
const infosHtml = this.#el.modelInfos;
infosHtml.innerHTML = "";
@@ -2039,7 +2217,7 @@ class ModelManager extends ComfyDialog {
modelInfo,
modelTypes,
this.#data.modelDirectories,
this.#sep,
this.#searchSeparator,
id,
);
});