feat: LyCORIS (also called "locon") model supported.

feat: Support model deletion function in "Extra Network" model viewer.
fix: "Download all files" now working as expect.
fix: Auto select newest version when load model info from url.
fix: Auto select default '/' folder when load model info from url.
feat: Press "`" key to toggle "Extra Network" model viewer under "img2img" and "txt2img" tab.
feat: Press "x" key to fast paste Civitai model url and load model info under "Civitai Helper" tab,then click "Download Model".
feat: Press "ctrl + x" keys trigger model generate.
refact: Move scripted css to style.css and other style adjusts.
pull/211/head
backflow 2023-07-08 03:32:49 +08:00
parent 920ca3267f
commit b85fe70488
11 changed files with 498 additions and 470 deletions

View File

@ -29,7 +29,7 @@ Civitai: [Civitai Url](https://civitai.com/models/16768/civitai-helper-sd-webui-
- 🖼️: Modified "replace preview" text into this icon - 🖼️: Modified "replace preview" text into this icon
- 🌐: Open this model's Civitai url in a new tab - 🌐: Open this model's Civitai url in a new tab
- 💡: Add this model's trigger words to prompt - 💡: Add this model's trigger words to prompt
- 🏷️: Use this model's preview image's prompt - 🪞: Use this model's preview image's prompt
* Above buttons support thumbnail mode of Extra Network * Above buttons support thumbnail mode of Extra Network
* Option to always show additional buttons, to work with touchscreen. * Option to always show additional buttons, to work with touchscreen.

View File

@ -1,68 +1,58 @@
"use strict"; "use strict";
function ch_convert_file_path_to_url(path) {
function ch_convert_file_path_to_url(path){
let prefix = "file="; let prefix = "file=";
let path_to_url = path.replaceAll('\\', '/'); let path_to_url = path.replaceAll('\\', '/');
return prefix+path_to_url; return prefix + path_to_url;
} }
function ch_img_node_str(path){ function ch_img_node_str(path) {
return `<img src='${ch_convert_file_path_to_url(path)}' style="width:24px"/>`; return `<img src='${ch_convert_file_path_to_url(path)}' style="width:24px"/>`;
} }
function ch_gradio_version() {
function ch_gradio_version(){
let foot = gradioApp().getElementById("footer"); let foot = gradioApp().getElementById("footer");
if (!foot){return null;} if (!foot) { return null; }
let versions = foot.querySelector(".versions"); let versions = foot.querySelector(".versions");
if (!versions){return null;} if (!versions) { return null; }
if (versions.innerHTML.indexOf("gradio: 3.16.2")>0) { if (versions.innerHTML.indexOf("gradio: 3.16.2") > 0) {
return "3.16.2"; return "3.16.2";
} else { } else {
return "3.23.0"; return "3.23.0";
} }
} }
// send msg to python side by filling a hidden text box // send msg to python side by filling a hidden text box
// then will click a button to trigger an action // then will click a button to trigger an action
// msg is an object, not a string, will be stringify in this function // msg is an object, not a string, will be stringify in this function
function send_ch_py_msg(msg){ function send_ch_py_msg(msg) {
console.log("run send_ch_py_msg") // console.log("run send_ch_py_msg")
let js_msg_txtbox = gradioApp().querySelector("#ch_js_msg_txtbox textarea"); let js_msg_txtbox = gradioApp().querySelector("#ch_js_msg_txtbox textarea");
if (js_msg_txtbox && msg) { if (js_msg_txtbox && msg) {
// fill to msg box // fill to msg box
js_msg_txtbox.value = JSON.stringify(msg); js_msg_txtbox.value = JSON.stringify(msg);
js_msg_txtbox.dispatchEvent(new Event("input")); js_msg_txtbox.dispatchEvent(new Event("input"));
} }
} }
// get msg from python side from a hidden textbox // get msg from python side from a hidden textbox
// normally this is an old msg, need to wait for a new msg // normally this is an old msg, need to wait for a new msg
function get_ch_py_msg(){ function get_ch_py_msg() {
console.log("run get_ch_py_msg") console.log("run get_ch_py_msg")
const py_msg_txtbox = gradioApp().querySelector("#ch_py_msg_txtbox textarea"); const py_msg_txtbox = gradioApp().querySelector("#ch_py_msg_txtbox textarea");
if (py_msg_txtbox && py_msg_txtbox.value) { if (py_msg_txtbox && py_msg_txtbox.value) {
console.log("find py_msg_txtbox"); console.log("find py_msg_txtbox, value:", py_msg_txtbox.value)
console.log("py_msg_txtbox value: ");
console.log(py_msg_txtbox.value)
return py_msg_txtbox.value return py_msg_txtbox.value
} else { } else {
return "" return ""
} }
} }
// get msg from python side from a hidden textbox // get msg from python side from a hidden textbox
// it will try once in every sencond, until it reach the max try times // it will try once in every sencond, until it reach the max try times
const get_new_ch_py_msg = (max_count=3) => new Promise((resolve, reject) => { const get_new_ch_py_msg = (max_count = 7) => new Promise((resolve, reject) => {
console.log("run get_new_ch_py_msg")
let count = 0; let count = 0;
let new_msg = ""; let new_msg = "";
let find_msg = false; let find_msg = false;
@ -71,13 +61,10 @@ const get_new_ch_py_msg = (max_count=3) => new Promise((resolve, reject) => {
count++; count++;
if (py_msg_txtbox && py_msg_txtbox.value) { if (py_msg_txtbox && py_msg_txtbox.value) {
console.log("find py_msg_txtbox"); console.log("find py_msg_txtbox, value: ", py_msg_txtbox.value)
console.log("py_msg_txtbox value: ");
console.log(py_msg_txtbox.value)
new_msg = py_msg_txtbox.value new_msg = py_msg_txtbox.value
if (new_msg != "") { if (new_msg != "") {
find_msg=true find_msg = true
} }
} }
@ -96,11 +83,9 @@ const get_new_ch_py_msg = (max_count=3) => new Promise((resolve, reject) => {
reject(''); reject('');
clearInterval(interval); clearInterval(interval);
} }
}, 400);
}, 1000);
}) })
function getActiveTabType() { function getActiveTabType() {
const currentTab = get_uiCurrentTabContent(); const currentTab = get_uiCurrentTabContent();
switch (currentTab.id) { switch (currentTab.id) {
@ -112,8 +97,6 @@ function getActiveTabType() {
return null; return null;
} }
function getActivePrompt() { function getActivePrompt() {
const currentTab = get_uiCurrentTabContent(); const currentTab = get_uiCurrentTabContent();
switch (currentTab.id) { switch (currentTab.id) {
@ -136,9 +119,8 @@ function getActiveNegativePrompt() {
return null; return null;
} }
// button's click function
//button's click function async function open_model_url(event, model_type, search_term) {
async function open_model_url(event, model_type, search_term){
console.log("start open_model_url"); console.log("start open_model_url");
//get hidden components of extension //get hidden components of extension
@ -147,7 +129,6 @@ async function open_model_url(event, model_type, search_term){
return return
} }
//msg to python side //msg to python side
let msg = { let msg = {
"action": "", "action": "",
@ -157,7 +138,6 @@ async function open_model_url(event, model_type, search_term){
"neg_prompt": "", "neg_prompt": "",
} }
msg["action"] = "open_url"; msg["action"] = "open_url";
msg["model_type"] = model_type; msg["model_type"] = model_type;
msg["search_term"] = search_term; msg["search_term"] = search_term;
@ -176,8 +156,7 @@ async function open_model_url(event, model_type, search_term){
//check response msg from python //check response msg from python
let new_py_msg = await get_new_ch_py_msg(); let new_py_msg = await get_new_ch_py_msg();
console.log("new_py_msg:"); // console.log("new_py_msg:", new_py_msg);
console.log(new_py_msg);
//check msg //check msg
if (new_py_msg) { if (new_py_msg) {
@ -187,19 +166,12 @@ async function open_model_url(event, model_type, search_term){
if (py_msg_json.content.url) { if (py_msg_json.content.url) {
window.open(py_msg_json.content.url, "_blank"); window.open(py_msg_json.content.url, "_blank");
} }
} }
} }
console.log("end open_model_url")
console.log("end open_model_url");
} }
function add_trigger_words(event, model_type, search_term){ function add_trigger_words(event, model_type, search_term) {
console.log("start add_trigger_words"); console.log("start add_trigger_words");
//get hidden components of extension //get hidden components of extension
@ -208,7 +180,6 @@ function add_trigger_words(event, model_type, search_term){
return return
} }
//msg to python side //msg to python side
let msg = { let msg = {
"action": "", "action": "",
@ -232,16 +203,13 @@ function add_trigger_words(event, model_type, search_term){
//click hidden button //click hidden button
js_add_trigger_words_btn.click(); js_add_trigger_words_btn.click();
console.log("end add_trigger_words"); console.log("end add_trigger_words");
event.stopPropagation() event.stopPropagation()
event.preventDefault() event.preventDefault()
} }
function use_preview_prompt(event, model_type, search_term){ function use_preview_prompt(event, model_type, search_term) {
console.log("start use_preview_prompt"); console.log("start use_preview_prompt");
//get hidden components of extension //get hidden components of extension
@ -281,13 +249,53 @@ function use_preview_prompt(event, model_type, search_term){
event.stopPropagation() event.stopPropagation()
event.preventDefault() event.preventDefault()
} }
async function delete_model(event, model_type, search_term) {
if (!confirm('Comfirm delete model: "' + search_term + '"?')) { return }
//get hidden components of extension
let js_delete_model_btn = gradioApp().getElementById("ch_js_delete_model_btn");
if (!js_delete_model_btn) {
return
}
//msg to python side
let msg = {
"action": "delete_model",
"model_type": model_type,
"search_term": search_term
}
// fill to msg box
send_ch_py_msg(msg)
//click hidden button
js_delete_model_btn.click();
// stop parent event
event.stopPropagation()
event.preventDefault()
//check response msg from python
let new_py_msg = await get_new_ch_py_msg();
console.log("new_py_msg:", new_py_msg);
//check msg
if (new_py_msg) {
let py_msg_json = JSON.parse(new_py_msg);
//check delete result
console.log(py_msg_json)
if (py_msg_json && py_msg_json.result) {
alert('Model delete successfully!!')
let card = event.target.closest('.card')
card.parentNode.removeChild(card)
}
}
}
// download model's new version into SD at python side // download model's new version into SD at python side
function ch_dl_model_new_version(event, model_path, version_id, download_url){ function ch_dl_model_new_version(event, model_path, version_id, download_url) {
console.log("start ch_dl_model_new_version"); console.log("start ch_dl_model_new_version");
// must confirm before downloading // must confirm before downloading
@ -325,70 +333,37 @@ function ch_dl_model_new_version(event, model_path, version_id, download_url){
event.stopPropagation() event.stopPropagation()
event.preventDefault() event.preventDefault()
} }
function createAdditionalButton(btnProps) {
let el = document.createElement("a");
Object.assign(el, btnProps);
el.setAttribute('onclick', btnProps.onclick)
el.className = 'civitai-helper-action'
el.href = "#";
return el
}
onUiLoaded(() => { function convert_to_py_model_type(js_model_type) {
//get model_type for python side
switch (js_model_type) {
case "textual_inversion": return "ti";
case "hypernetworks": return "hyper";
case "checkpoints": return "ckp";
case "lora": return "lora";
case "lycoris": return "lycoris";
}
}
//get gradio version // add just one model_type cards buttons
let gradio_ver = ch_gradio_version(); function update_card_for_one_tab(model_type, container) {
console.log("gradio_ver:" + gradio_ver);
// get all extra network tabs let ch_btn_txts = ['🌐', '💡', '🪞', '🗑️'];
let tab_prefix_list = ["txt2img", "img2img"];
let model_type_list = ["textual_inversion", "hypernetworks", "checkpoints", "lora"];
let cardid_suffix = "cards";
//get init py msg
// let init_py_msg_str = get_ch_py_msg();
// let extension_path = "";
// if (!init_py_msg_str) {
// console.log("Can not get init_py_msg");
// } else {
// init_py_msg = JSON.parse(init_py_msg_str);
// if (init_py_msg) {
// extension_path = init_py_msg.extension_path;
// console.log("get extension path: " + extension_path);
// }
// }
// //icon image node as string
// function icon(icon_name){
// let icon_path = extension_path+"/icon/"+icon_name;
// return ch_img_node_str(icon_path);
// }
// update extra network tab pages' cards
// * replace "replace preview" text button into an icon
// * add 3 button to each card:
// - open model url 🌐
// - add trigger words 💡
// - use preview image's prompt 🏷️
// notice: javascript can not get response from python side
// so, these buttons just sent request to python
// then, python side gonna open url and update prompt text box, without telling js side.
function update_card_for_civitai(){
//css
let btn_margin = "0px 5px";
let btn_fontSize = "200%";
let btn_thumb_fontSize = "100%";
let btn_thumb_display = "inline";
let btn_thumb_pos = "static";
let btn_thumb_backgroundImage = "none";
let btn_thumb_background = "rgba(0, 0, 0, 0.8)";
let ch_btn_txts = ['🌐', '💡', '🏷️'];
let replace_preview_text = getTranslation("replace preview"); let replace_preview_text = getTranslation("replace preview");
if (!replace_preview_text) { if (!replace_preview_text) {
replace_preview_text = "replace preview"; replace_preview_text = "replace preview";
} }
// get component // get component
let ch_always_display_ckb = gradioApp().querySelector("#ch_always_display_ckb input"); let ch_always_display_ckb = gradioApp().querySelector("#ch_always_display_ckb input");
let ch_show_btn_on_thumb_ckb = gradioApp().querySelector("#ch_show_btn_on_thumb_ckb input"); let ch_show_btn_on_thumb_ckb = gradioApp().querySelector("#ch_show_btn_on_thumb_ckb input");
@ -401,115 +376,39 @@ onUiLoaded(() => {
ch_show_btn_on_thumb = ch_show_btn_on_thumb_ckb.checked; ch_show_btn_on_thumb = ch_show_btn_on_thumb_ckb.checked;
} }
//change all "replace preview" into an icon //change all "replace preview" into an icon
let extra_network_id = ""; let extra_network_id = "";
let extra_network_node = null;
let metadata_button = null; let metadata_button = null;
let additional_node = null; let additional_node = null;
let replace_preview_btn = null; let replace_preview_btn = null;
let ul_node = null; let ul_node = null;
let search_term_node = null; let search_term_node = null;
let search_term = ""; let search_term = "";
let model_type = "";
let cards = null;
let need_to_add_buttons = false;
let is_thumb_mode = false; let is_thumb_mode = false;
//get current tab
let active_tab_type = getActiveTabType();
if (!active_tab_type){active_tab_type = "txt2img";}
for (const tab_prefix of tab_prefix_list) {
if (tab_prefix != active_tab_type) {continue;}
//find out current selected model type tab
let active_extra_tab_type = "";
let extra_tabs = gradioApp().getElementById(tab_prefix+"_extra_tabs");
if (!extra_tabs) {console.log("can not find extra_tabs: " + tab_prefix+"_extra_tabs");}
//get active extratab
const active_extra_tab = Array.from(get_uiCurrentTabContent().querySelectorAll('.extra-network-cards,.extra-network-thumbs'))
.find(el => el.closest('.tabitem').style.display === 'block')
?.id.match(/^(txt2img|img2img)_(.+)_cards$/)[2]
console.log("found active tab: " + active_extra_tab);
switch (active_extra_tab) {
case "textual_inversion":
active_extra_tab_type = "ti";
break;
case "hypernetworks":
active_extra_tab_type = "hyper";
break;
case "checkpoints":
active_extra_tab_type = "ckp";
break;
case "lora":
active_extra_tab_type = "lora";
break;
}
for (const js_model_type of model_type_list) {
//get model_type for python side
switch (js_model_type) {
case "textual_inversion":
model_type = "ti";
break;
case "hypernetworks":
model_type = "hyper";
break;
case "checkpoints":
model_type = "ckp";
break;
case "lora":
model_type = "lora";
break;
}
if (!model_type) {
console.log("can not get model_type from: " + js_model_type);
continue;
}
//only handle current sub-tab
if (model_type != active_extra_tab_type) {
continue;
}
console.log("handle active extra tab");
extra_network_id = tab_prefix+"_"+js_model_type+"_"+cardid_suffix;
// console.log("searching extra_network_node: " + extra_network_id);
extra_network_node = gradioApp().getElementById(extra_network_id);
// check if extr network is under thumbnail mode // check if extr network is under thumbnail mode
is_thumb_mode = false is_thumb_mode = false;
if (extra_network_node) { if (container.className == "extra-network-thumbs") {
if (extra_network_node.className == "extra-network-thumbs") {
console.log(extra_network_id + " is in thumbnail mode");
is_thumb_mode = true; is_thumb_mode = true;
// if (!ch_show_btn_on_thumb) {continue;} // if (!ch_show_btn_on_thumb) {continue;}
} }
} else {
console.log("can not find extra_network_node: " + extra_network_id);
continue;
}
// console.log("find extra_network_node: " + extra_network_id);
// get all card nodes model_type = convert_to_py_model_type(model_type);
cards = extra_network_node.querySelectorAll(".card");
for (let card of cards) { for (let card of container.children) {
//get ul node, which is the parent of all buttons
ul_node = card.querySelector(".actions .additional ul");
if (ul_node.childElementCount > 1) {
// buttons all ready added, just quit
console.log('buttons all ready added, just quit')
return
}
//metadata_buttoncard //metadata_buttoncard
metadata_button = card.querySelector(".metadata-button"); metadata_button = card.querySelector(".metadata-button");
//additional node //additional node
additional_node = card.querySelector(".actions .additional"); additional_node = card.querySelector(".actions .additional");
//get ul node, which is the parent of all buttons
ul_node = card.querySelector(".actions .additional ul");
// replace preview text button // replace preview text button
replace_preview_btn = card.querySelector(".actions .additional a"); replace_preview_btn = card.querySelector(".actions .additional a");
@ -518,7 +417,7 @@ onUiLoaded(() => {
additional_node.style.display = null; additional_node.style.display = null;
if (ch_show_btn_on_thumb) { if (ch_show_btn_on_thumb) {
ul_node.style.background = btn_thumb_background; ul_node.style.background = "rgba(0, 0, 0, .5)";
} else { } else {
//reset //reset
ul_node.style.background = null; ul_node.style.background = null;
@ -532,7 +431,7 @@ onUiLoaded(() => {
//reset display //reset display
atag.style.display = null; atag.style.display = null;
//remove extension's button //remove extension's button
if (ch_btn_txts.indexOf(atag.innerHTML)>=0) { if (ch_btn_txts.indexOf(atag.innerHTML) >= 0) {
//need to remove //need to remove
ul_node.removeChild(atag); ul_node.removeChild(atag);
} else { } else {
@ -550,119 +449,75 @@ onUiLoaded(() => {
if (brtag) { if (brtag) {
ul_node.removeChild(brtag); ul_node.removeChild(brtag);
} }
} }
//just reset and remove nodes, do nothing else //just reset and remove nodes, do nothing else
continue; continue;
} }
} else { } else {
// full preview mode // full preview mode
if (ch_always_display) { additional_node.style.display = ch_always_display ? "block" : null;
additional_node.style.display = "block";
} else {
additional_node.style.display = null;
}
// remove br tag // remove br tag
let brtag = ul_node.querySelector("br"); let brtag = ul_node.querySelector("br");
if (brtag) { if (brtag) {
ul_node.removeChild(brtag); ul_node.removeChild(brtag);
} }
} }
// change replace preview text button into icon // change replace preview text button into icon
if (replace_preview_btn) { if (replace_preview_btn) {
if (replace_preview_btn.innerHTML !== "🖼️") { replace_preview_btn.className = "civitai-helper-action";
need_to_add_buttons = true;
replace_preview_btn.innerHTML = "🖼️"; replace_preview_btn.innerHTML = "🖼️";
if (!is_thumb_mode) {
replace_preview_btn.style.fontSize = btn_fontSize;
replace_preview_btn.style.margin = btn_margin;
} else {
replace_preview_btn.style.display = btn_thumb_display;
replace_preview_btn.style.fontSize = btn_thumb_fontSize;
replace_preview_btn.style.position = btn_thumb_pos;
replace_preview_btn.style.backgroundImage = btn_thumb_backgroundImage;
} }
}
}
if (!need_to_add_buttons) {
continue;
}
// search_term node // search_term node
// search_term = subfolder path + model name + ext // search_term = subfolder path + model name + ext
search_term_node = card.querySelector(".actions .additional .search_term"); search_term_node = card.querySelector(".actions .additional .search_term");
if (!search_term_node){ if (!search_term_node) {
console.log("can not find search_term node for cards in " + extra_network_id); console.log("can not find search_term node for cards in " + extra_network_id);
continue; continue;
} }
// get search_term // get search_term
search_term = search_term_node.innerHTML; search_term = search_term_node.innerText;
if (!search_term) { if (!search_term) {
console.log("search_term is empty for cards in " + extra_network_id); console.log("search_term is empty for cards in " + extra_network_id);
continue; continue;
} }
// remove webui added extra checkpoint's sha256 value,
// in file: stable-diffusion-webui/modules/ui_extra_networks_checkpoints.py
// line: 24: "search_term": self.search_terms_from_path(checkpoint.filename) + " " + (checkpoint.sha256 or ""),
search_term = search_term.split(" ")[0];
// if (is_thumb_mode) { // if (is_thumb_mode) {
// ul_node.style.background = btn_thumb_background; // ul_node.style.background = btn_thumb_background;
// } // }
// then we need to add 3 buttons to each ul node: // then we need to add 4 buttons to each ul node:
let open_url_node = document.createElement("a"); let open_url_node = createAdditionalButton({
open_url_node.href = "#"; innerHTML: "🌐",
open_url_node.innerHTML = "🌐"; title: "Open this model's civitai url",
if (!is_thumb_mode) { onclick: "open_model_url(event, '" + model_type + "', '" + search_term + "')"
open_url_node.style.fontSize = btn_fontSize; })
open_url_node.style.margin = btn_margin;
} else {
open_url_node.style.display = btn_thumb_display;
open_url_node.style.fontSize = btn_thumb_fontSize;
open_url_node.style.position = btn_thumb_pos;
open_url_node.style.backgroundImage = btn_thumb_backgroundImage;
}
open_url_node.title = "Open this model's civitai url";
open_url_node.setAttribute("onclick","open_model_url(event, '"+model_type+"', '"+search_term+"')");
let add_trigger_words_node = document.createElement("a"); let add_trigger_words_node = createAdditionalButton({
add_trigger_words_node.href = "#"; innerHTML: "💡",
add_trigger_words_node.innerHTML = "💡"; title: "Add trigger words to prompt",
if (!is_thumb_mode) { onclick: "add_trigger_words(event, '" + model_type + "', '" + search_term + "')"
add_trigger_words_node.style.fontSize = btn_fontSize; })
add_trigger_words_node.style.margin = btn_margin;
} else {
add_trigger_words_node.style.display = btn_thumb_display;
add_trigger_words_node.style.fontSize = btn_thumb_fontSize;
add_trigger_words_node.style.position = btn_thumb_pos;
add_trigger_words_node.style.backgroundImage = btn_thumb_backgroundImage;
}
add_trigger_words_node.title = "Add trigger words to prompt"; let use_preview_prompt_node = createAdditionalButton({
add_trigger_words_node.setAttribute("onclick","add_trigger_words(event, '"+model_type+"', '"+search_term+"')"); innerHTML: "🪞",
title: "Use prompt from preview image",
onclick: "use_preview_prompt(event, '" + model_type + "', '" + search_term + "')"
})
let use_preview_prompt_node = document.createElement("a"); let delete_model_node = createAdditionalButton({
use_preview_prompt_node.href = "#"; innerHTML: "🗑️",
use_preview_prompt_node.innerHTML = "🏷️"; title: "Delete model",
if (!is_thumb_mode) { onclick: "delete_model(event, '" + model_type + "', '" + search_term + "')",
use_preview_prompt_node.style.fontSize = btn_fontSize; })
use_preview_prompt_node.style.margin = btn_margin;
} else {
use_preview_prompt_node.style.display = btn_thumb_display;
use_preview_prompt_node.style.fontSize = btn_thumb_fontSize;
use_preview_prompt_node.style.position = btn_thumb_pos;
use_preview_prompt_node.style.backgroundImage = btn_thumb_backgroundImage;
}
use_preview_prompt_node.title = "Use prompt from preview image";
use_preview_prompt_node.setAttribute("onclick","use_preview_prompt(event, '"+model_type+"', '"+search_term+"')");
//add to card //add to card
ul_node.appendChild(open_url_node); ul_node.appendChild(open_url_node);
@ -672,57 +527,149 @@ onUiLoaded(() => {
} }
ul_node.appendChild(add_trigger_words_node); ul_node.appendChild(add_trigger_words_node);
ul_node.appendChild(use_preview_prompt_node); ul_node.appendChild(use_preview_prompt_node);
ul_node.appendChild(delete_model_node);
} }
}
// fast pasete civitai model url and trigger model info loading
async function checkClipboard() {
let text = await navigator.clipboard.readText()
if (text.startsWith('https://civitai.com/models/')) {
let comp = document.querySelector('#model_download_url_txt')
let textarea = comp.querySelector('textarea')
textarea.value = text
textarea.dispatchEvent(new Event('input'))
comp.querySelector('button').click()
}
}
// shotcut key event listener
window.addEventListener('keydown', e => {
let el = e.target
switch (e.key) {
case '`':
if (el.isContentEditable || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') { e.preventDefault() }
let type = getActiveTabType()
if (type.endsWith('2img')) {
document.querySelector('#' + type + '_extra_networks')?.click()
}
break
case 'x':
let txt = document.querySelector('#quicksettings + .tabs button.selected').innerText
if (txt == 'Civitai Helper')
checkClipboard()
else if (txt.endsWith('2img') && e.altKey)
document.querySelector(`button#${txt}_generate`).click()
break
}
})
onUiLoaded(() => {
//get gradio version
let gradio_ver = ch_gradio_version();
console.log("gradio_ver:" + gradio_ver);
let tab_prefix_list = ["txt2img", "img2img"];
let model_type_list = ["textual_inversion", "hypernetworks", "checkpoints", "lora", "lycoris"];
// add all model_type cards buttons by tab_prefix
function update_card_form_all_tab(tab_prefix) {
for (let model_type of model_type_list) {
let container_id = [tab_prefix, model_type, 'cards'].join('_')
let container = document.getElementById(container_id)
update_card_for_one_tab(model_type, container);
} }
} }
// get init py msg
// let init_py_msg_str = get_ch_py_msg();
// let extension_path = "";
// if (!init_py_msg_str) {
// console.log("Can not get init_py_msg");
// } else {
// init_py_msg = JSON.parse(init_py_msg_str);
// if (init_py_msg) {
// extension_path = init_py_msg.extension_path;
// console.log("get extension path: " + extension_path);
// }
// }
// // icon image node as string
// function icon(icon_name){
// let icon_path = extension_path+"/icon/"+icon_name;
// return ch_img_node_str(icon_path);
// }
// check cards number change, and re-craete buttons
function checkPeriodically(tab_prefix, model_type) {
let container_id = '#' + [tab_prefix, model_type, 'cards_html'].join('_')
let container = document.querySelector(container_id + ' ' + container_id)
// record current cards size
let len = container.querySelectorAll('.card').length
// we only wait 5s, after that we assumed that DOM will never changed
let millis = 1000 * 5
// wait for server response and DOM updates, if cards size changed,
// then our buttons is losted, put them back again
let timer = setInterval(() => {
let new_len = container.querySelectorAll('.card').length
if (len != new_len) {
update_card_for_one_tab(model_type, container.lastElementChild)
clearInterval(timer)
}
millis -= 500
if (millis <= 0) {
clearInterval(timer)
}
}, 500)
} }
let tab_id = "" let tab_id = ""
let extra_tab = null; let extra_tab = null;
let extra_toolbar = null; let extra_toolbar = null;
let extra_network_refresh_btn = null; let extra_network_refresh_btn = null;
//add refresh button to extra network's toolbar // add refresh button to extra network's toolbar
for (let prefix of tab_prefix_list) { for (let tab_prefix of tab_prefix_list) {
tab_id = prefix + "_extra_tabs"; tab_id = tab_prefix + "_extra_tabs";
extra_tab = gradioApp().getElementById(tab_id); extra_tab = gradioApp().getElementById(tab_id);
//get toolbar //get toolbar
//get Refresh button //get Refresh button
extra_network_refresh_btn = gradioApp().getElementById(prefix+"_extra_refresh"); extra_network_refresh_btn = gradioApp().getElementById(tab_prefix + "_extra_refresh");
if (!extra_network_refresh_btn) {
if (!extra_network_refresh_btn){
console.log("can not get extra network refresh button for " + tab_id); console.log("can not get extra network refresh button for " + tab_id);
continue; continue;
} }
// add refresh button to toolbar // add refresh button to toolbar
let ch_refresh = document.createElement("button"); let ch_refresh = document.createElement("button");
ch_refresh.innerHTML = "🔁"; ch_refresh.innerHTML = "🔄️";
ch_refresh.title = "Refresh Civitai Helper's additional buttons"; ch_refresh.title = "Refresh Civitai Helper's additional buttons";
ch_refresh.className = "lg secondary gradio-button"; ch_refresh.className = "lg secondary gradio-button";
ch_refresh.style.fontSize = "200%"; ch_refresh.style.fontSize = "2em";
ch_refresh.onclick = update_card_for_civitai; ch_refresh.onclick = () => update_card_form_all_tab(tab_prefix)
extra_network_refresh_btn.parentNode.appendChild(ch_refresh); extra_network_refresh_btn.parentNode.appendChild(ch_refresh);
// listen to refresh buttons' click event
// check and re-add buttons back on
document.getElementById(tab_prefix + '_extra_refresh').addEventListener('click', e => {
let model_type = e.target.closest('.tab-nav').querySelector('button.selected').innerText.replaceAll(' ', '_').toLowerCase()
checkPeriodically(tab_prefix, model_type)
})
// listen to "Extra Networks" toggle button's click event,
// then initialiy add all buttons, only trigger once,
// after that all updates are trigger by refresh button click.
document.getElementById(tab_prefix + '_extra_networks').addEventListener('click', () => {
// wait UI updates
setTimeout(() => update_card_form_all_tab(tab_prefix), 1500);
}, { once: true })
} }
//run it once
update_card_for_civitai();
}); });

View File

@ -23,7 +23,8 @@ model_type_dict = {
"TextualInversion": "ti", "TextualInversion": "ti",
"Hypernetwork": "hyper", "Hypernetwork": "hyper",
"LORA": "lora", "LORA": "lora",
"LoCon": "lora", "LoCon": "lycoris",
"LyCORIS": "lycoris",
} }
@ -277,6 +278,7 @@ def get_model_names_by_type_and_filter(model_type:str, filter:dict) -> list:
return model_names return model_names
def get_model_names_by_input(model_type, empty_info_only): def get_model_names_by_input(model_type, empty_info_only):
return get_model_names_by_type_and_filter(model_type, {"empty_info_only":empty_info_only}) return get_model_names_by_type_and_filter(model_type, {"empty_info_only":empty_info_only})
@ -365,9 +367,7 @@ def get_preview_image_by_model_path(model_path:str, max_size_preview, skip_nsfw_
# search local model by version id in 1 folder, no subfolder # search local model by version id in 1 folder, no subfolder
# return - model_info # return - model_info
def search_local_model_info_by_version_id(folder:str, version_id:int) -> dict: def search_local_model_info_by_version_id(folder:str, version_id:int) -> dict:
util.printD("Searching local model by version id") util.printD("Searching local model by version id: " + str(version_id) + "in folder: " + folder)
util.printD("folder: " + folder)
util.printD("version_id: " + str(version_id))
if not folder: if not folder:
util.printD("folder is none") util.printD("folder is none")
@ -414,9 +414,6 @@ def search_local_model_info_by_version_id(folder:str, version_id:int) -> dict:
return return
# check new version for a model by model path # check new version for a model by model path
# return (model_path, model_id, model_name, new_verion_id, new_version_name, description, download_url, img_url) # return (model_path, model_id, model_name, new_verion_id, new_version_name, description, download_url, img_url)
def check_model_new_version_by_path(model_path:str, delay:float=1) -> tuple: def check_model_new_version_by_path(model_path:str, delay:float=1) -> tuple:
@ -603,10 +600,46 @@ def check_models_new_version_by_model_types(model_types:list, delay:float=1) ->
# add to list # add to list
new_versions.append(r) new_versions.append(r)
return new_versions return new_versions
# delete model file by model type and search_term
# parameter: model_type, search_term
# return: delete result
def delete_model_by_search_term(model_type:str, search_term:str):
if model_type not in model.folders.keys():
msg = f"unknow model type: {model_type}"
util.printD(msg)
return
model_type_key = {k for k in model_type_dict if model_type_dict[k] == model_type}
util.printD(f"Find {model_type_key} model file: '{search_term}'")
# search_term = subfolderpath + model name + ext. And it always start with a / even there is no sub folder
base, ext = os.path.splitext(search_term)
model_info_base = base
if base[:1] == "/":
model_info_base = base[1:]
# find 3 kinds of files: .model.info, .safetensor and .priview.jpeg
model_folder = model.folders[model_type]
model_info_filename = model_info_base + suffix + model.info_ext
model_info_filepath = os.path.join(model_folder, model_info_filename)
model_image_filepath = os.path.join(model_folder, model_info_base + ".preview.png")
model_filename = model_folder + search_term
if os.path.isfile(model_info_filepath):
os.remove(model_info_filepath)
if os.path.isfile(model_image_filepath):
os.remove(model_image_filepath)
result = False
if os.path.isfile(model_filename):
os.remove(model_filename)
result = True
else:
print("Error: Model file: %s not found" % model_filename)
return result

View File

@ -10,6 +10,13 @@ dl_ext = ".downloading"
# disable ssl warning info # disable ssl warning info
requests.packages.urllib3.disable_warnings() requests.packages.urllib3.disable_warnings()
def human_readable_size(size, decimal_places=2):
for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB']:
if size < 1024.0 or unit == 'PiB':
break
size /= 1024.0
return f"{size:.{decimal_places}f} {unit}"
# output is downloaded file path # output is downloaded file path
def dl(url, folder, filename, filepath): def dl(url, folder, filename, filepath):
util.printD("Start downloading from: " + url) util.printD("Start downloading from: " + url)
@ -35,7 +42,7 @@ def dl(url, folder, filename, filepath):
# get file size # get file size
total_size = 0 total_size = 0
total_size = int(rh.headers['Content-Length']) total_size = int(rh.headers['Content-Length'])
util.printD(f"File size: {total_size}") util.printD(f"File size: {human_readable_size(total_size)}")
# if file_path is empty, need to get file name from download url's header # if file_path is empty, need to get file name from download url's header
if not file_path: if not file_path:
@ -65,7 +72,7 @@ def dl(url, folder, filename, filepath):
count = 2 count = 2
new_base = base new_base = base
while os.path.isfile(file_path): while os.path.isfile(file_path):
util.printD("Target file already exist.") util.printD("Target file'" + file_path + "'already exist.")
# re-name # re-name
new_base = base + "_" + str(count) new_base = base + "_" + str(count)
file_path = new_base + ext file_path = new_base + ext
@ -81,7 +88,7 @@ def dl(url, folder, filename, filepath):
downloaded_size = 0 downloaded_size = 0
if os.path.exists(dl_file_path): if os.path.exists(dl_file_path):
downloaded_size = os.path.getsize(dl_file_path) downloaded_size = os.path.getsize(dl_file_path)
if downloaded_size > 0:
util.printD(f"Downloaded size: {downloaded_size}") util.printD(f"Downloaded size: {downloaded_size}")
# create header range # create header range
@ -101,15 +108,15 @@ def dl(url, folder, filename, filepath):
f.flush() f.flush()
# progress # progress
progress = int(50 * downloaded_size / total_size) progress = int(100 * downloaded_size / total_size)
sys.stdout.reconfigure(encoding='utf-8') sys.stdout.reconfigure(encoding='utf-8')
sys.stdout.write("\r[%s%s] %d%%" % ('-' * progress, ' ' * (50 - progress), 100 * downloaded_size / total_size)) sys.stdout.write("\r[%s%s] %d%%" % ('=' * progress, ' ' * (100 - progress), 100 * downloaded_size / total_size))
sys.stdout.flush() sys.stdout.flush()
print() print()
# rename file # rename file
os.rename(dl_file_path, file_path) os.rename(dl_file_path, file_path)
util.printD(f"File Downloaded to: {file_path}") util.printD(f"File save to: {file_path}")
return file_path return file_path

View File

@ -175,6 +175,18 @@ def use_preview_image_prompt(msg):
return [preview_prompt, preview_neg_prompt, preview_prompt, preview_neg_prompt] return [preview_prompt, preview_neg_prompt, preview_prompt, preview_neg_prompt]
def delete_model(msg):
util.printD("Start delete_model")
result = msg_handler.parse_js_msg(msg)
if not result:
util.printD("Parsing js ms failed")
return
model_type = result["model_type"]
search_term = result["search_term"]
result = civitai.delete_model_by_search_term(model_type, search_term)
return json.dumps({"result": result})
# download model's new verson by model path, version id and download url # download model's new verson by model path, version id and download url
# output is a md log # output is a md log
@ -251,6 +263,6 @@ def dl_model_new_version(msg, max_size_preview, skip_nsfw_preview):
# then, get preview image # then, get preview image
civitai.get_preview_image_by_model_path(new_model_path, max_size_preview, skip_nsfw_preview) civitai.get_preview_image_by_model_path(new_model_path, max_size_preview, skip_nsfw_preview)
output = "Done. Model downloaded to: " + new_model_path output = "Done, model save to: " + new_model_path
util.printD(output) util.printD(output)
return output return output

View File

@ -17,6 +17,7 @@ folders = {
"hyper": os.path.join(root_path, "models", "hypernetworks"), "hyper": os.path.join(root_path, "models", "hypernetworks"),
"ckp": os.path.join(root_path, "models", "Stable-diffusion"), "ckp": os.path.join(root_path, "models", "Stable-diffusion"),
"lora": os.path.join(root_path, "models", "Lora"), "lora": os.path.join(root_path, "models", "Lora"),
"lycoris": os.path.join(root_path, "models", "LyCORIS"),
} }
exts = (".bin", ".pt", ".safetensors", ".ckpt") exts = (".bin", ".pt", ".safetensors", ".ckpt")
@ -48,7 +49,7 @@ def get_custom_model_folder():
# write model info to file # write model info to file
def write_model_info(path, model_info): def write_model_info(path, model_info):
util.printD("Write model info to file: " + path) util.printD("Write model info to: " + path)
with open(os.path.realpath(path), 'w') as f: with open(os.path.realpath(path), 'w') as f:
f.write(json.dumps(model_info, indent=4)) f.write(json.dumps(model_info, indent=4))

View File

@ -264,7 +264,7 @@ def get_model_info_by_url(model_url_or_id:str) -> tuple:
subfolders = [] subfolders = []
# add default root folder # add default root folder
subfolders.append("/") subfolders.append(os.sep)
util.printD("Get following info for downloading:") util.printD("Get following info for downloading:")
util.printD(f"model_name:{model_name}") util.printD(f"model_name:{model_name}")
@ -436,41 +436,29 @@ def dl_model_by_input(model_info:dict, model_type:str, subfolder_str:str, versio
util.printD(output) util.printD(output)
return output return output
version_id = ver_info["id"] modelVersions = model_info['modelVersions']
if dl_all_bool: if dl_all_bool:
# get all download url from files info
# some model versions have multiple files
download_urls = []
if "files" in ver_info.keys():
for file_info in ver_info["files"]:
if "downloadUrl" in file_info.keys():
download_urls.append(file_info["downloadUrl"])
if not len(download_urls):
if "downloadUrl" in ver_info.keys():
download_urls.append(ver_info["downloadUrl"])
# check if this model is already existed
r = civitai.search_local_model_info_by_version_id(model_folder, version_id)
if r:
output = "This model version is already existed"
util.printD(output)
return output
# download
filepath = "" filepath = ""
for url in download_urls: idx = 0
model_filepath = downloader.dl(url, model_folder, None, None) for modelVer in modelVersions:
if not model_filepath: # check if this model is already existed
r = civitai.search_local_model_info_by_version_id(model_folder, modelVer["id"])
if r:
util.printD("Model file: '" + r["files"][0]["name"] + "' already existed, skiping it.")
continue
filepath = downloader.dl(modelVer["downloadUrl"], model_folder, None, None)
if not filepath:
output = "Downloading failed, check console log for detail" output = "Downloading failed, check console log for detail"
util.printD(output) util.printD(output)
return output continue
if url == ver_info["downloadUrl"]: idx = idx + 1
filepath = model_filepath save_info_and_preview_image(filepath, modelVer, max_size_preview, skip_nsfw_preview)
util.printD(str(idx) + " of " + str(len(modelVersions)) + " files downloaded, save to: " + filepath)
output = "Done, " + str(idx) + "/" + str(len(modelVersions)) + " files downloaded"
else: else:
# only download one file # only download one file
# get download url # get download url
@ -487,17 +475,18 @@ def dl_model_by_input(model_info:dict, model_type:str, subfolder_str:str, versio
util.printD(output) util.printD(output)
return output return output
if not filepath:
filepath = model_filepath
# get version info # get version info
version_info = civitai.get_version_info_by_version_id(version_id) version_info = [mv for mv in modelVersions if mv["id"] == ver_info["id"]][0]
if not version_info: if not version_info:
output = "Model downloaded, but failed to get version info, check console log for detail. Model saved to: " + filepath output = "Model downloaded, but failed to get version info, check console log for detail. Model saved to: " + filepath
util.printD(output) util.printD(output)
return output return output
output = save_info_and_preview_image(filepath, version_info, max_size_preview, skip_nsfw_preview)
return output
def save_info_and_preview_image(filepath:str, version_info:dict, max_size_preview:bool, skip_nsfw_preview:bool):
# write version info to file # write version info to file
base, ext = os.path.splitext(filepath) base, ext = os.path.splitext(filepath)
info_file = base + civitai.suffix + model.info_ext info_file = base + civitai.suffix + model.info_ext
@ -506,6 +495,7 @@ def dl_model_by_input(model_info:dict, model_type:str, subfolder_str:str, versio
# then, get preview image # then, get preview image
civitai.get_preview_image_by_model_path(filepath, max_size_preview, skip_nsfw_preview) civitai.get_preview_image_by_model_path(filepath, max_size_preview, skip_nsfw_preview)
output = "Done. Model downloaded to: " + filepath output = "Done, model save to: " + filepath
util.printD(output) util.printD(output)
return output return output

View File

@ -4,7 +4,7 @@ import json
from . import util from . import util
# action list # action list
js_actions = ("open_url", "add_trigger_words", "use_preview_prompt", "dl_model_new_version") js_actions = ("open_url", "add_trigger_words", "use_preview_prompt", "delete_model", "dl_model_new_version")
py_actions = ("open_url") py_actions = ("open_url")

View File

@ -60,7 +60,7 @@ def download_file(url, path):
r.raw.decode_content = True r.raw.decode_content = True
shutil.copyfileobj(r.raw, f) shutil.copyfileobj(r.raw, f)
printD("File downloaded to: " + path) printD("File save to: " + path)
# get subfolder list # get subfolder list
def get_subfolders(folder:str) -> list: def get_subfolders(folder:str) -> list:

View File

@ -81,7 +81,9 @@ def on_ui_tabs():
if r: if r:
model_info, model_name, model_type, subfolders, version_strs = r model_info, model_name, model_type, subfolders, version_strs = r
return [model_info, model_name, model_type, dl_subfolder_drop.update(choices=subfolders), dl_version_drop.update(choices=version_strs)] value = os.sep if len(subfolders) == 1 else ""
return [model_info, model_name, model_type, dl_subfolder_drop.update(choices=subfolders, value=value), dl_version_drop.update(choices=version_strs, value=version_strs[0])]
# ====UI==== # ====UI====
with gr.Blocks(analytics_enabled=False) as civitai_helper: with gr.Blocks(analytics_enabled=False) as civitai_helper:
@ -133,7 +135,7 @@ def on_ui_tabs():
with gr.Box(elem_classes="ch_box"): with gr.Box(elem_classes="ch_box"):
with gr.Column(): with gr.Column():
gr.Markdown("### Download Model") gr.Markdown("### Download Model")
with gr.Row(): with gr.Row(elem_id="model_download_url_txt"):
dl_model_url_or_id_txtbox = gr.Textbox(label="Civitai URL", lines=1, value="") dl_model_url_or_id_txtbox = gr.Textbox(label="Civitai URL", lines=1, value="")
dl_model_info_btn = gr.Button(value="1. Get Model Info by Civitai Url", variant="primary") dl_model_info_btn = gr.Button(value="1. Get Model Info by Civitai Url", variant="primary")
@ -181,6 +183,7 @@ def on_ui_tabs():
js_open_url_btn = gr.Button(value="Open Model Url", visible=False, elem_id="ch_js_open_url_btn") js_open_url_btn = gr.Button(value="Open Model Url", visible=False, elem_id="ch_js_open_url_btn")
js_add_trigger_words_btn = gr.Button(value="Add Trigger Words", visible=False, elem_id="ch_js_add_trigger_words_btn") js_add_trigger_words_btn = gr.Button(value="Add Trigger Words", visible=False, elem_id="ch_js_add_trigger_words_btn")
js_use_preview_prompt_btn = gr.Button(value="Use Prompt from Preview Image", visible=False, elem_id="ch_js_use_preview_prompt_btn") js_use_preview_prompt_btn = gr.Button(value="Use Prompt from Preview Image", visible=False, elem_id="ch_js_use_preview_prompt_btn")
js_use_delete_model_btn = gr.Button(value="Delete Model", visible=False, elem_id="ch_js_delete_model_btn")
js_dl_model_new_version_btn = gr.Button(value="Download Model's new version", visible=False, elem_id="ch_js_dl_model_new_version_btn") js_dl_model_new_version_btn = gr.Button(value="Download Model's new version", visible=False, elem_id="ch_js_dl_model_new_version_btn")
# ====events==== # ====events====
@ -207,6 +210,7 @@ def on_ui_tabs():
js_open_url_btn.click(js_action_civitai.open_model_url, inputs=[js_msg_txtbox, open_url_with_js_ckb], outputs=py_msg_txtbox) js_open_url_btn.click(js_action_civitai.open_model_url, inputs=[js_msg_txtbox, open_url_with_js_ckb], outputs=py_msg_txtbox)
js_add_trigger_words_btn.click(js_action_civitai.add_trigger_words, inputs=[js_msg_txtbox], outputs=[txt2img_prompt, img2img_prompt]) js_add_trigger_words_btn.click(js_action_civitai.add_trigger_words, inputs=[js_msg_txtbox], outputs=[txt2img_prompt, img2img_prompt])
js_use_preview_prompt_btn.click(js_action_civitai.use_preview_image_prompt, inputs=[js_msg_txtbox], outputs=[txt2img_prompt, txt2img_neg_prompt, img2img_prompt, img2img_neg_prompt]) js_use_preview_prompt_btn.click(js_action_civitai.use_preview_image_prompt, inputs=[js_msg_txtbox], outputs=[txt2img_prompt, txt2img_neg_prompt, img2img_prompt, img2img_neg_prompt])
js_use_delete_model_btn.click(js_action_civitai.delete_model, inputs=[js_msg_txtbox], outputs=[py_msg_txtbox])
js_dl_model_new_version_btn.click(js_action_civitai.dl_model_new_version, inputs=[js_msg_txtbox, max_size_preview_ckb, skip_nsfw_preview_ckb], outputs=dl_log_md) js_dl_model_new_version_btn.click(js_action_civitai.dl_model_new_version, inputs=[js_msg_txtbox, max_size_preview_ckb, skip_nsfw_preview_ckb], outputs=dl_log_md)
# the third parameter is the element id on html, with a "tab_" as prefix # the third parameter is the element id on html, with a "tab_" as prefix

View File

@ -2,17 +2,51 @@ blockquote ul {
list-style:disc; list-style:disc;
margin:4px 40px; margin:4px 40px;
} }
blockquote ol { blockquote ol {
list-style:decimal; list-style:decimal;
margin:4px 40px; margin:4px 40px;
} }
.block.padded.ch_box { .block.padded.ch_box {
padding: 10px !important; padding: 10px !important;
} }
.block.padded.ch_vpadding { .block.padded.ch_vpadding {
padding: 10px 0 !important; padding: 10px 0 !important;
} }
.civitai-helper-action {
font-size: 1.8em;
line-height: 1;
vertical-align: middle;
opacity: .7;
}
.civitai-helper-action:hover {
filter: drop-shadow(2px 4px 6px black);
opacity: 1;
}
.extra-network-cards .civitai-helper-action:nth-child(2) {
margin: 0 .1em 0 .15em;
font-size: 3em;
}
.extra-network-thumbs .card {
height: 8rem !important;
width: 8rem !important;
}
.extra-network-thumbs .civitai-helper-action {
background-image: none !important;
display: inline-block !important;
position: static !important;
font-size: 1.25em !important;
line-height: 1.25em;
}
.extra-network-thumbs .civitai-helper-action:nth-child(2) {
font-size: 2em !important;
height: 1.35em !important;
margin-left: 0.25em;
}
.extra-network-thumbs .card ul {
position: absolute;
width: 100%;
height: 2em;
display: flex;
justify-content: space-between;
align-items: center;
}