diff --git a/ui/index.html b/ui/index.html index c83f08bc..4f62b5b4 100644 --- a/ui/index.html +++ b/ui/index.html @@ -332,8 +332,7 @@ -
- +
@@ -788,6 +787,7 @@ + diff --git a/ui/media/js/auto-save.js b/ui/media/js/auto-save.js index bcfd4bc6..eff23a99 100644 --- a/ui/media/js/auto-save.js +++ b/ui/media/js/auto-save.js @@ -57,6 +57,7 @@ const SETTINGS_IDS_LIST = [ "json_toggle", "extract_lora_from_prompt", "embedding-card-size-selector", + "lora_model", ] const IGNORE_BY_DEFAULT = ["prompt"] diff --git a/ui/media/js/main.js b/ui/media/js/main.js index 1546f54b..643637d4 100644 --- a/ui/media/js/main.js +++ b/ui/media/js/main.js @@ -121,6 +121,7 @@ let clipSkipField = document.querySelector("#clip_skip") let tilingField = document.querySelector("#tiling") let controlnetModelField = new ModelDropdown(document.querySelector("#controlnet_model"), "controlnet", "None", false) let vaeModelField = new ModelDropdown(document.querySelector("#vae_model"), "vae", "None") +let loraModelField = new MultiModelSelector(document.querySelector("#lora_model"), "lora", "LoRA", 0.5, 0.02) let hypernetworkModelField = new ModelDropdown(document.querySelector("#hypernetwork_model"), "hypernetwork", "None") let hypernetworkStrengthSlider = document.querySelector("#hypernetwork_strength_slider") let hypernetworkStrengthField = document.querySelector("#hypernetwork_strength") @@ -2928,76 +2929,6 @@ prettifyInputs(document) promptField.focus() promptField.selectionStart = promptField.value.length -// multi-models -let modelCount = 0 - -function addModelEntry(modelContainer, modelsList, modelType, defaultValue, strengthStep) { - let idx = modelCount++ - let nameId = modelType + "_model_" + idx - let strengthId = modelType + "_alpha_" + idx - - const modelElement = document.createElement("div") - modelElement.className = "model_entry" - modelElement.innerHTML = ` - - - ` - modelContainer.appendChild(modelElement) - - let modelName = new ModelDropdown(modelElement.querySelector(".model_name"), modelType, "None") - let modelStrength = modelElement.querySelector(".model_strength") - let entry = [modelName, modelStrength, modelElement] - - let removeBtn = document.createElement("button") - removeBtn.className = "remove_model_btn" - removeBtn.setAttribute("title", "Remove model") - removeBtn.innerHTML = '' - - if (modelsList.length === 0) { - removeBtn.classList.add("displayNone") - } - - removeBtn.addEventListener("click", function() { - let entryIdx = modelsList.indexOf(entry) - modelsList.splice(entryIdx, 1) - modelContainer.removeChild(modelElement) - }) - - modelElement.appendChild(removeBtn) - - modelsList.push(entry) - - return modelElement -} - -function createLoraEntry() { - let container = document.querySelector("#lora_model_container .model_entries") - return addModelEntry(container, loraModels, "lora", 0.5, 0.02) -} - -function createLoraEntries() { - let firstEntry = createLoraEntry() - - let addLoraBtn = document.querySelector("#lora_model_container .add_model_entry") - addLoraBtn.addEventListener("click", () => { - createLoraEntry() - }) -} -createLoraEntries() - -// chrome-like spinners only on hover -// function showSpinnerOnlyOnHover(e) { -// e.addEventListener("mouseenter", () => { -// e.setAttribute("type", "number") -// }) -// e.addEventListener("mouseleave", () => { -// e.removeAttribute("type") -// }) -// e.removeAttribute("type") -// } - -// document.querySelectorAll("input[type=number]").forEach(showSpinnerOnlyOnHover) - ////////////////////////////// Image Size Widget ////////////////////////////////////////// function roundToMultiple(number, n) { diff --git a/ui/media/js/multi-model-selector.js b/ui/media/js/multi-model-selector.js new file mode 100644 index 00000000..24d5e0f1 --- /dev/null +++ b/ui/media/js/multi-model-selector.js @@ -0,0 +1,218 @@ +/** + * A component consisting of multiple model dropdowns, along with a "weight" field per model. + * + * Behaves like a single input element, giving an object in response to the .value field. + * + * Inspired by the design of the ModelDropdown component (searchable-models.js). + */ + +class MultiModelSelector { + root + modelType + modelNameFriendly + defaultWeight + weightStep + + modelContainer + addNewButton + + counter = 0 + + /* MIMIC A REGULAR INPUT FIELD */ + get id() { + return this.root.id + } + get parentElement() { + return this.root.parentElement + } + get parentNode() { + return this.root.parentNode + } + get value() { + let modelNames = [] + let modelWeights = [] + + this.modelElements.forEach((e) => { + modelNames.push(e.name.value) + modelWeights.push(e.weight.value) + }) + + return { modelNames: modelNames, modelWeights: modelWeights } + } + set value(modelData) { + if (typeof modelData !== "object") { + throw new Error("Multi-model selector expects an object containing modelNames and modelWeights as keys!") + } + if (!("modelNames" in modelData) || !("modelWeights" in modelData)) { + throw new Error("modelNames or modelWeights not present in the data passed to the multi-model selector") + } + + let newModelNames = modelData["modelNames"] + let newModelWeights = modelData["modelWeights"] + if (newModelNames.length !== newModelWeights.length) { + throw new Error("Need to pass an equal number of modelNames and modelWeights!") + } + + // expand or shrink entries + let currElements = this.modelElements + if (currElements.length < newModelNames.length) { + for (let i = currElements.length; i < newModelNames.length; i++) { + this.addModelEntry() + } + } else { + for (let i = newModelNames.length; i < currElements.length; i++) { + this.removeModelEntry() + } + } + + // assign to the corresponding elements + currElements = this.modelElements + for (let i = 0; i < newModelNames.length; i++) { + let curr = currElements[i] + + // update weight first, name second. + // for some unholy reason this order matters for dispatch chains + // the root of all this unholiness is because searchable-models automatically dispatches an update event + // as soon as the value is updated via JS, which is against the DOM pattern of not dispatching an event automatically + // unless the caller explicitly dispatches the event. + curr.weight.value = newModelWeights[i] + curr.name.value = newModelNames[i] + } + } + get disabled() { + return false + } + set disabled(state) { + // do nothing + } + get modelElements() { + let entries = this.root.querySelectorAll(".model_entry") + entries = [...entries] + let elements = entries.map((e) => { + return { name: e.querySelector(".model_name").field, weight: e.querySelector(".model_weight") } + }) + return elements + } + addEventListener(type, listener, options) { + // do nothing + } + dispatchEvent(event) { + // do nothing + } + appendChild(option) { + // do nothing + } + + // remember 'this' - http://blog.niftysnippets.org/2008/04/you-must-remember-this.html + bind(f, obj) { + return function() { + return f.apply(obj, arguments) + } + } + + constructor(root, modelType, modelNameFriendly = undefined, defaultWeight = 0.5, weightStep = 0.02) { + this.root = root + this.modelType = modelType + this.modelNameFriendly = modelNameFriendly || modelType + this.defaultWeight = defaultWeight + this.weightStep = weightStep + + let self = this + document.addEventListener("refreshModels", function() { + setTimeout(self.bind(self.populateModels, self), 1) + }) + + this.createStructure() + this.populateModels() + } + + createStructure() { + this.modelContainer = document.createElement("div") + this.modelContainer.className = "model_entries" + this.root.appendChild(this.modelContainer) + + this.addNewButton = document.createElement("button") + this.addNewButton.className = "add_model_entry" + this.addNewButton.innerHTML = ' add another ' + this.modelNameFriendly + this.addNewButton.addEventListener("click", this.bind(this.addModelEntry, this)) + this.root.appendChild(this.addNewButton) + } + + populateModels() { + if (this.root.dataset.path === "") { + if (this.length === 0) { + this.addModelEntry() // create a single blank entry + } + } else { + this.value = JSON.parse(this.root.dataset.path) + } + } + + addModelEntry() { + let idx = this.counter++ + let currLength = this.length + + const modelElement = document.createElement("div") + modelElement.className = "model_entry" + modelElement.innerHTML = ` + + + ` + this.modelContainer.appendChild(modelElement) + + let modelNameEl = modelElement.querySelector(".model_name") + modelNameEl.field = new ModelDropdown(modelNameEl, this.modelType, "None") + let modelWeightEl = modelElement.querySelector(".model_weight") + + let self = this + + function makeUpdateEvent(type) { + return function(e) { + e.stopPropagation() + + let modelData = self.value + self.root.dataset.path = JSON.stringify(modelData) + + self.root.dispatchEvent(new Event(type)) + } + } + + modelNameEl.addEventListener("change", makeUpdateEvent("change")) + modelNameEl.addEventListener("input", makeUpdateEvent("input")) + modelWeightEl.addEventListener("change", makeUpdateEvent("change")) + modelWeightEl.addEventListener("input", makeUpdateEvent("input")) + + let removeBtn = document.createElement("button") + removeBtn.className = "remove_model_btn" + removeBtn.setAttribute("title", "Remove model") + removeBtn.innerHTML = '' + + if (currLength === 0) { + removeBtn.classList.add("displayNone") + } + + removeBtn.addEventListener( + "click", + this.bind(function(e) { + this.modelContainer.removeChild(modelElement) + + makeUpdateEvent("change")(e) + }, this) + ) + + modelElement.appendChild(removeBtn) + } + + removeModelEntry() { + if (this.length === 0) { + return + } + + let lastEntry = this.modelContainer.lastElementChild + lastEntry.remove() + } + + get length() { + return this.modelContainer.childElementCount + } +} diff --git a/ui/media/js/searchable-models.js b/ui/media/js/searchable-models.js index 299c60dc..a4e3f284 100644 --- a/ui/media/js/searchable-models.js +++ b/ui/media/js/searchable-models.js @@ -118,13 +118,16 @@ class ModelDropdown { ) } - saveCurrentSelection(elem, value, path) { + saveCurrentSelection(elem, value, path, dispatchEvent = true) { this.currentSelection.elem = elem this.currentSelection.value = value this.currentSelection.path = path this.modelFilter.dataset.path = path this.modelFilter.value = value - this.modelFilter.dispatchEvent(new Event("change")) + + if (dispatchEvent) { + this.modelFilter.dispatchEvent(new Event("change")) + } } processClick(e) { @@ -348,13 +351,13 @@ class ModelDropdown { } } - selectEntry(path) { + selectEntry(path, dispatchEvent = true) { if (path !== undefined) { const entries = this.modelElements for (const elem of entries) { if (elem.dataset.path == path) { - this.saveCurrentSelection(elem, elem.innerText, elem.dataset.path) + this.saveCurrentSelection(elem, elem.innerText, elem.dataset.path, dispatchEvent) this.highlightedModelEntry = elem elem.scrollIntoView({ block: "nearest" }) break @@ -529,7 +532,7 @@ class ModelDropdown { rootModelList.style.minWidth = modelFilterStyle.width }) - this.selectEntry(this.activeModel) + this.selectEntry(this.activeModel, false) } /**