feat: base function

This commit is contained in:
hayden
2023-09-06 16:24:42 +08:00
parent bc0c5e08b6
commit 4777886be9
5 changed files with 1039 additions and 0 deletions

205
web/model-manager.css Normal file
View File

@@ -0,0 +1,205 @@
/* comfy table */
.comfy-table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
}
.comfy-table .table-head tr {
background-color: var(--tr-even-bg-color);
}
/* comfy tabs */
.comfy-tabs {
color: #fff;
}
.comfy-tabs-head {
display: flex;
gap: 8px;
flex-wrap: wrap;
border-bottom: 1px solid #6a6a6a;
}
.comfy-tabs-head .head-item {
padding: 8px 12px;
border: 1px solid #6a6a6a;
border-bottom: none;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
cursor: pointer;
margin-bottom: -1px;
}
.comfy-tabs-head .head-item.active {
background-color: #2e2e2e;
cursor: default;
position: relative;
z-index: 1;
}
.comfy-tabs-body {
background-color: #2e2e2e;
border: 1px solid #6a6a6a;
border-top: none;
padding: 16px 0px;
}
/* comfy grid */
.comfy-grid {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.comfy-grid .item {
position: relative;
width: 230px;
height: 345px;
text-align: center;
overflow: hidden;
}
.comfy-grid .item img {
width: 100%;
height: 100%;
object-fit: contain;
}
.comfy-grid .item p {
position: absolute;
bottom: 0px;
background-color: #000a;
width: 100%;
margin: 0;
padding: 9px 0px;
}
/* comfy radio group */
.comfy-radio-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.comfy-radio {
display: flex;
gap: 4px;
padding: 4px 8px;
color: var(--input-text);
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: var(--comfy-input-bg);
font-size: 18px;
}
/* model manager */
.model-manager {
box-sizing: border-box;
width: 100%;
height: 100%;
max-width: unset;
max-height: unset;
padding: 10px;
color: #fff;
}
.model-manager .comfy-modal-content {
width: 100%;
gap: 16px;
}
/* model manager common */
.model-manager button,
.model-manager select,
.model-manager input {
padding: 4px 8px;
margin: 0;
}
.model-manager button:disabled,
.model-manager select:disabled,
.model-manager input:disabled {
background-color: #6a6a6a;
filter: brightness(1.2);
cursor: not-allowed;
}
.model-manager button.block {
width: 100%;
}
.comfy-table a {
color: #007acc;
text-decoration: none;
}
.model-manager ::-webkit-scrollbar {
width: 6px;
}
.model-manager ::-webkit-scrollbar-track {
background-color: #353535;
border-right: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
}
.model-manager ::-webkit-scrollbar-thumb {
background-color: #a1a1a1;
border-radius: 3px;
}
/* model manager row */
.model-manager .row {
display: flex;
gap: 8px;
}
/* comfy tabs */
.model-manager .comfy-tabs {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.model-manager .comfy-tabs-body {
flex: 1;
overflow: hidden;
}
.model-manager .comfy-tabs-body > div {
position: relative;
max-height: 100%;
padding: 0 16px;
overflow-x: hidden;
}
/* model manager special */
.model-manager .close {
position: absolute;
padding: 1px 6px;
top: 10px;
right: 10px;
}
.model-manager .row {
position: sticky;
padding-top: 2px;
margin-top: -2px;
padding-bottom: 18px;
margin-bottom: -2px;
top: 0px;
background-color: #2e2e2e;
z-index: 1;
}
.model-manager .table-head {
position: sticky;
top: 52px;
z-index: 1;
}
.model-manager div[data-name="Model List"] .row {
align-items: flex-start;
}

536
web/model-manager.js Normal file
View File

@@ -0,0 +1,536 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { ComfyDialog, $el } from "../../scripts/ui.js";
function debounce(func, delay) {
let timer;
return function () {
clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, arguments);
}, delay);
};
}
class Tabs {
/** @type {Record<string, HTMLDivElement>} */
#head = {};
/** @type {Record<string, HTMLDivElement>} */
#body = {};
/**
* @param {Array<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;
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<string, any>} option
* @param {Array<HTMLDivElement>} tabs
*/
function $tabs(tabs) {
const instance = new Tabs(tabs);
return instance.element;
}
/**
* @param {string} name
* @param {Array<HTMLDivElement>} el
* @returns {HTMLDivElement}
*/
function $tab(name, el) {
return $el("div", { dataset: { name } }, el);
}
class List {
/**
* @typedef Column
* @prop {string} title
* @prop {string} dataIndex
* @prop {number} width
* @prop {string} align
* @prop {Function} render
*/
/** @type {Array<Column>} */
#columns = [];
/** @type {Array<Record<string, any>>} */
#dataSource = [];
/** @type {HTMLDivElement} */
#tbody = null;
/**
* @param {Array<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) }),
]);
}
setData(dataSource) {
this.#dataSource = dataSource;
this.#updateList();
}
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);
})
);
}
}
class Grid {
constructor() {
this.element = $el("div.comfy-grid");
}
#dataSource = [];
setData(dataSource) {
this.#dataSource = dataSource;
this.element.innerHTML = [];
this.#updateList();
}
#updateList() {
this.element.innerHTML = null;
if (this.#dataSource.length > 0) {
this.element.append.apply(
this.element,
this.#dataSource.map((item) => {
const uri = item.post ?? "no-post";
const imgUrl = `/model-manager/imgPreview?uri=${uri}`;
return $el("div.item", {}, [
$el("img", { src: imgUrl }),
$el("p", [item.name]),
]);
})
);
} else {
this.element.innerHTML = "<h2>No Models</h2>";
}
}
}
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 {
#request(url, options) {
return new Promise((resolve, reject) => {
api.fetchApi(url, options)
.then((response) => response.json())
.then(resolve)
.catch(reject);
});
}
#el = {
loadSourceBtn: null,
loadSourceFromSelect: null,
loadSourceFromInput: null,
sourceInstalledFilter: null,
sourceContentFilter: null,
sourceFilterBtn: null,
modelTypeSelect: null,
};
#data = {
sourceList: [],
models: {},
};
/** @type {List} */
#sourceList = null;
constructor() {
super();
this.element = $el(
"div.comfy-modal.model-manager",
{ parent: document.body },
[
$el("div.comfy-modal-content", [
$el("button.close", {
textContent: "X",
onclick: () => this.close(),
}),
$tabs([
$tab("Source Install", this.#createSourceInstall()),
$tab("Customer Install", []),
$tab("Model List", this.#createModelList()),
]),
]),
]
);
this.#init();
}
#init() {
this.#refreshSourceList();
this.#refreshModelList();
}
#createSourceInstall() {
this.#createSourceList();
return [
$el("div.row", [
$el("button", {
type: "button",
textContent: "Load From",
$: (el) => (this.#el.loadSourceBtn = el),
onclick: () => this.#refreshSourceList(),
}),
$el(
"select",
{
$: (el) => (this.#el.loadSourceFromSelect = el),
onchange: (e) => {
const val = e.target.val;
this.#el.loadSourceFromInput.disabled =
val === "Local Source";
},
},
[
$el("option", ["Local Source"]),
$el("option", ["Web Source"]),
]
),
$el("input", {
$: (el) => (this.#el.loadSourceFromInput = el),
value: "https://github.com/hayden-fr/ComfyUI-Model-Manager/blob/main/index.json",
style: { flex: 1 },
disabled: true,
}),
$el("div", { style: { width: "50px" } }),
$el(
"select",
{
$: (el) => (this.#el.sourceInstalledFilter = el),
onchange: () => this.#filterSourceList(),
},
[
$el("option", ["Filter: All"]),
$el("option", ["Installed"]),
$el("option", ["Non-Installed"]),
]
),
$el("input", {
$: (el) => (this.#el.sourceContentFilter = el),
placeholder: "Input search keyword",
onkeyup: (e) =>
e.code === "Enter" && this.#filterSourceList(),
}),
$el("button", {
type: "button",
textContent: "Search",
onclick: () => this.#filterSourceList(),
}),
]),
this.#sourceList.element,
];
}
#createSourceList() {
const sourceList = new List([
{
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 ? "Installed" : "Install",
onclick: async (e) => {
e.disabled = true;
const response = await this.#request(
"/model-manager/download",
{
method: "POST",
body: JSON.stringify(record),
}
);
console.log(response);
e.disabled = false;
},
});
},
},
]);
this.#sourceList = sourceList;
return sourceList.element;
}
async #refreshSourceList() {
this.#el.loadSourceBtn.disabled = true;
this.#el.loadSourceFromSelect.disabled = true;
const sourceType = this.#el.loadSourceFromSelect.value;
const webSource = this.#el.loadSourceFromInput.value;
const uri = sourceType === "Local Source" ? "local" : webSource;
const dataSource = await this.#request(
`/model-manager/source?uri=${uri}`
).catch(() => []);
this.#data.sourceList = dataSource;
this.#sourceList.setData(dataSource);
this.#el.sourceInstalledFilter.value = "Filter: All";
this.#el.sourceContentFilter.value = "";
this.#el.loadSourceBtn.disabled = false;
this.#el.loadSourceFromSelect.disabled = false;
}
#filterSourceList() {
const installedType = this.#el.sourceInstalledFilter.value;
/** @type {Array<string>} */
const content = this.#el.sourceContentFilter.value
.split(" ")
.map((item) => item.toLowerCase())
.filter(Boolean);
const newDataSource = this.#data.sourceList.filter((row) => {
const filterField = ["type", "name", "base", "description"];
const rowContent = filterField
.reduce((memo, field) => memo + " " + row[field], "")
.toLowerCase();
return content.reduce((memo, target) => {
return memo && rowContent.includes(target);
}, true);
});
this.#sourceList.setData(newDataSource);
}
/** @type {Grid} */
#modelList = null;
#createModelList() {
const gridInstance = new Grid();
this.#modelList = gridInstance;
return [
$el("div.row", [
$radioGroup({
$: (el) => (this.#el.modelTypeSelect = el),
name: "model-type",
onchange: () => this.#updateModelList(),
options: [
{ value: "checkpoints" },
{ value: "clip" },
{ value: "clip_vision" },
{ value: "controlnet" },
{ value: "diffusers" },
{ value: "embeddings" },
{ value: "gligen" },
{ value: "hypernetworks" },
{ value: "loras" },
{ value: "style_models" },
{ value: "unet" },
{ value: "upscale_models" },
{ value: "vae" },
{ value: "vae_approx" },
],
}),
$el("button", {
type: "button",
textContent: "Refresh",
style: { marginLeft: "auto" },
onclick: () => this.#refreshModelList(),
}),
]),
gridInstance.element,
];
}
async #refreshModelList() {
const dataSource = await this.#request("/model-manager/models");
this.#data.models = dataSource;
this.#updateModelList();
}
#updateModelList() {
const type = this.#el.modelTypeSelect.value;
const list = this.#data.models[type];
this.#modelList.setData(list);
}
}
let instance;
/**
* @returns {ModelManager}
*/
function getInstance() {
if (!instance) {
instance = new ModelManager();
}
return instance;
}
app.registerExtension({
name: "Comfy.ModelManager",
async setup() {
$el("link", {
parent: document.head,
rel: "stylesheet",
href: "./extensions/ComfyUI-Model-Manager/model-manager.css",
});
$el("button", {
parent: document.querySelector(".comfy-menu"),
textContent: "Models",
style: { order: 1 },
onclick: () => {
getInstance().show();
},
});
},
});