diff --git a/.gitignore b/.gitignore index 68bc17f..32b09a4 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,5 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +ui_settings.yaml +server_settings.yaml diff --git a/README.md b/README.md index 139f1ed..8bc8582 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,104 @@ # comfyui-model-manager -Manage models: browsing, donwload and delete. + +Download, browse and delete models in ComfyUI. + +
+
+0: # DEPTH-FIRST + dir_path, dir_index = dir_stack.pop() + + dir_items = os.listdir(dir_path) + dir_items = sorted(dir_items, key=str.casefold) + + dir_child_count = 0 + + # TODO: sort content of directory: alphabetically + # TODO: sort content of directory: files first + + subdirs = [] + for item_name in dir_items: # BREADTH-FIRST + item_path = os.path.join(dir_path, item_name) + if os.path.isdir(item_path): + # dir + subdir_index = len(dir_list) # this must be done BEFORE `dir_list.append` + subdirs.append((item_path, subdir_index)) + dir_list.append({ "name": item_name, "childIndex": None, "childCount": 0 }) + dir_child_count += 1 + else: + # file + _, file_extension = os.path.splitext(item_name) + if extension_whitelist is None or file_extension in extension_whitelist: + dir_list.append({ "name": item_name }) + dir_child_count += 1 + if dir_child_count > 0: + dir_list[dir_index]["childIndex"] = len(dir_list) - dir_child_count + dir_list[dir_index]["childCount"] = dir_child_count + subdirs.reverse() + for dir_path, subdir_index in subdirs: + dir_stack.append((dir_path, subdir_index)) + return dir_list -requests.packages.urllib3.disable_warnings() - -def_headers = { - "User-Agent": "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148" -} +@server.PromptServer.instance.routes.get("/model-manager/models/directory-list") +async def get_directory_list(request): + #body = await request.json() + dir_list = linear_directory_hierarchy(True) + #json.dump(dir_list, sys.stdout, indent=4) + return web.json_response(dir_list) -def download_model_file(url, filename): - dl_filename = filename + ".download" +def download_file(url, filename, overwrite): + if not overwrite and os.path.isfile(filename): + raise ValueError("File already exists!") - rh = requests.get( - url=url, stream=True, verify=False, headers=def_headers, proxies=None - ) - print("temp file is " + dl_filename) - total_size = int(rh.headers["Content-Length"]) + filename_temp = filename + ".download" - basename, ext = os.path.splitext(filename) - print("Start download {}, file size: {}".format(basename, total_size)) + def_headers = { + "User-Agent": "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", + } + + if url.startswith("https://civitai.com/"): + api_key = server_settings["civitai_api_key"] + if (api_key != ""): + def_headers["Authorization"] = f"Bearer {api_key}" + url += "&" if "?" in url else "?" # not the most robust solution + url += f"token={api_key}" # TODO: Authorization didn't work in the header + elif url.startswith("https://huggingface.co/"): + api_key = server_settings["huggingface_api_key"] + if api_key != "": + def_headers["Authorization"] = f"Bearer {api_key}" + rh = requests.get(url=url, stream=True, verify=False, headers=def_headers, proxies=None, allow_redirects=False) + if not rh.ok: + raise ValueError( + "Unable to download! Request header status code: " + + str(rh.status_code) + ) downloaded_size = 0 - if os.path.exists(dl_filename): - downloaded_size = os.path.getsize(download_file) + if rh.status_code == 200 and os.path.exists(filename_temp): + downloaded_size = os.path.getsize(filename_temp) headers = {"Range": "bytes=%d-" % downloaded_size} headers["User-Agent"] = def_headers["User-Agent"] + + r = requests.get(url=url, stream=True, verify=False, headers=headers, proxies=None, allow_redirects=False) + if rh.status_code == 307 and r.status_code == 307: + # Civitai redirect + redirect_url = r.content.decode("utf-8") + if not redirect_url.startswith("http"): + # Civitai requires login (NSFW or user-required) + # TODO: inform user WHY download failed + raise ValueError("Unable to download from Civitai! Redirect url: " + str(redirect_url)) + download_file(redirect_url, filename, overwrite) + return + if rh.status_code == 302 and r.status_code == 302: + # HuggingFace redirect + redirect_url = r.content.decode("utf-8") + redirect_url_index = redirect_url.find("http") + if redirect_url_index == -1: + raise ValueError("Unable to download from HuggingFace! Redirect url: " + str(redirect_url)) + download_file(redirect_url[redirect_url_index:], filename, overwrite) + return + elif rh.status_code == 200 and r.status_code == 206: + # Civitai download link + pass - r = requests.get(url=url, stream=True, verify=False, headers=headers, proxies=None) + total_size = int(rh.headers.get("Content-Length", 0)) # TODO: pass in total size earlier - with open(dl_filename, "ab") as f: + print("Download file: " + filename) + if total_size != 0: + print("Download file size: " + str(total_size)) + + mode = "wb" if overwrite else "ab" + with open(filename_temp, mode) as f: for chunk in r.iter_content(chunk_size=1024): - if chunk: + if chunk is not None: downloaded_size += len(chunk) f.write(chunk) f.flush() - progress = int(50 * downloaded_size / total_size) - sys.stdout.reconfigure(encoding="utf-8") - sys.stdout.write( - "\r[%s%s] %d%%" - % ( - "-" * progress, - " " * (50 - progress), - 100 * downloaded_size / total_size, + if total_size != 0: + fraction = 1 if downloaded_size == total_size else downloaded_size / total_size + progress = int(50 * fraction) + sys.stdout.reconfigure(encoding="utf-8") + sys.stdout.write( + "\r[%s%s] %d%%" + % ( + "-" * progress, + " " * (50 - progress), + 100 * fraction, + ) ) - ) - sys.stdout.flush() - + sys.stdout.flush() print() - os.rename(dl_filename, filename) + + if overwrite and os.path.isfile(filename): + os.remove(filename) + os.rename(filename_temp, filename) -@server.PromptServer.instance.routes.post("/model-manager/download") -async def download_file(request): - body = await request.json() - model_type = body.get("type") - model_type_path = model_type_dir_dict.get(model_type) - if model_type_path is None: - return web.json_response({"success": False}) +def download_image(image_uri, model_path, overwrite): + _, extension = os.path.splitext(image_uri) # TODO: doesn't work for https://civitai.com/images/... + if not extension in image_extensions: + raise ValueError("Invalid image type!") + path_without_extension, _ = os.path.splitext(model_path) + file = path_without_extension + extension + download_file(image_uri, file, overwrite) + return file - download_uri = body.get("download") + +@server.PromptServer.instance.routes.get("/model-manager/model/info") +async def get_model_info(request): + model_path = request.query.get("path", None) + if model_path is None: + return web.json_response({ "success": False }) + model_path = urllib.parse.unquote(model_path) + + file, _ = search_path_to_system_path(model_path) + if file is None: + return web.json_response({}) + + info = {} + path, name = os.path.split(model_path) + info["File Name"] = name + info["File Directory"] = path + info["File Size"] = str(os.path.getsize(file)) + " bytes" + stats = pathlib.Path(file).stat() + date_format = "%Y-%m-%d %H:%M:%S" + date_modified = datetime.fromtimestamp(stats.st_mtime).strftime(date_format) + info["Date Modified"] = date_modified + info["Date Created"] = datetime.fromtimestamp(stats.st_ctime).strftime(date_format) + + file_name, _ = os.path.splitext(file) + + for extension in image_extensions: + maybe_image = file_name + extension + if os.path.isfile(maybe_image): + image_path, _ = os.path.splitext(model_path) + image_modified = pathlib.Path(maybe_image).stat().st_mtime_ns + info["Preview"] = { + "path": urllib.parse.quote_plus(image_path + extension), + "dateModified": urllib.parse.quote_plus(str(image_modified)), + } + break + + header = get_safetensor_header(file) + metadata = header.get("__metadata__", None) + #json.dump(metadata, sys.stdout, indent=4) + #print() + + if metadata is not None and info.get("Preview", None) is None: + thumbnail = metadata.get("modelspec.thumbnail") + if thumbnail is not None: + i0 = thumbnail.find("/") + 1 + i1 = thumbnail.find(";", i0) + thumbnail_extension = "." + thumbnail[i0:i1] + if thumbnail_extension in image_extensions: + info["Preview"] = { + "path": request.query["path"] + thumbnail_extension, + "dateModified": date_modified, + } + + if metadata is not None: + train_end = metadata.get("modelspec.date", "").replace("T", " ") + train_start = metadata.get("ss_training_started_at", "") + if train_start != "": + try: + train_start = float(train_start) + train_start = datetime.fromtimestamp(train_start).strftime(date_format) + except: + train_start = "" + info["Date Trained"] = ( + train_start + + (" ... " if train_start != "" and train_end != "" else "") + + train_end + ) + + info["Base Training Model"] = metadata.get("ss_sd_model_name", "") + info["Base Model"] = metadata.get("ss_base_model_version", "") + info["Architecture"] = metadata.get("modelspec.architecture", "") # "stable-diffusion-xl-v1-base" + + clip_skip = metadata.get("ss_clip_skip", "") + if clip_skip == "None": + clip_skip = "" + info["Clip Skip"] = clip_skip # default 1 (disable clip skip) + info["Model Sampling Type"] = metadata.get("modelspec.prediction_type", "") # "epsilon" + + # it is unclear what these are + #info["Hash SHA256"] = metadata.get("modelspec.hash_sha256", "") + #info["SSHS Model Hash"] = metadata.get("sshs_model_hash", "") + #info["SSHS Legacy Hash"] = metadata.get("sshs_legacy_hash", "") + #info["New SD Model Hash"] = metadata.get("ss_new_sd_model_hash", "") + + #info["Output Name"] = metadata.get("ss_output_name", "") + #info["Title"] = metadata.get("modelspec.title", "") + info["Author"] = metadata.get("modelspec.author", "") + info["License"] = metadata.get("modelspec.license", "") + + if metadata is not None: + training_comment = metadata.get("ss_training_comment", "") + info["Description"] = ( + metadata.get("modelspec.description", "") + + "\n\n" + + metadata.get("modelspec.usage_hint", "") + + "\n\n" + + training_comment if training_comment != "None" else "" + ).strip() + + txt_file = file_name + ".txt" + notes = "" + if os.path.isfile(txt_file): + with open(txt_file, 'r', encoding="utf-8") as f: + notes = f.read() + info["Notes"] = notes + + if metadata is not None: + img_buckets = metadata.get("ss_bucket_info", "{}") + if type(img_buckets) is str: + img_buckets = json.loads(img_buckets) + resolutions = {} + if img_buckets is not None: + buckets = img_buckets.get("buckets", {}) + for resolution in buckets.values(): + dim = resolution["resolution"] + x, y = dim[0], dim[1] + count = resolution["count"] + resolutions[str(x) + "x" + str(y)] = count + resolutions = list(resolutions.items()) + resolutions.sort(key=lambda x: x[1], reverse=True) + info["Bucket Resolutions"] = resolutions + + dir_tags = metadata.get("ss_tag_frequency", "{}") + if type(dir_tags) is str: + dir_tags = json.loads(dir_tags) + tags = {} + for train_tags in dir_tags.values(): + for tag, count in train_tags.items(): + tags[tag] = tags.get(tag, 0) + count + tags = list(tags.items()) + tags.sort(key=lambda x: x[1], reverse=True) + info["Tags"] = tags + + return web.json_response(info) + + +@server.PromptServer.instance.routes.get("/model-manager/system-separator") +async def get_system_separator(request): + return web.json_response(os.path.sep) + + +@server.PromptServer.instance.routes.post("/model-manager/model/download") +async def download_model(request): + formdata = await request.post() + result = { + "success": False, + "invalid": None, + } + + overwrite = formdata.get("overwrite", "false").lower() + overwrite = True if overwrite == "true" else False + + model_path = formdata.get("path", "/0") + directory, model_type = search_path_to_system_path(model_path) + if directory is None: + result["invalid"] = "path" + return web.json_response(result) + + download_uri = formdata.get("download") if download_uri is None: - return web.json_response({"success": False}) + result["invalid"] = "download" + return web.json_response(result) - model_name = body.get("name") - file_name = os.path.join(model_uri, model_type_path, model_name) - download_model_file(download_uri, file_name) - print("文件下载完成!") - return web.json_response({"success": True}) + name = formdata.get("name") + _, model_extension = os.path.splitext(name) + if not model_extension in folder_paths_get_supported_pt_extensions(model_type): + result["invalid"] = "name" + return web.json_response(result) + file_name = os.path.join(directory, name) + try: + download_file(download_uri, file_name, overwrite) + except Exception as e: + print(e, file=sys.stderr, flush=True) + result["invalid"] = "model" + return web.json_response(result) + + image = formdata.get("image") + if image is not None and image != "": + try: + download_model_preview({ + "path": model_path + os.sep + name, + "image": image, + "overwrite": formdata.get("overwrite"), + }) + except Exception as e: + print(e, file=sys.stderr, flush=True) + result["invalid"] = "preview" + + result["success"] = True + return web.json_response(result) + + +@server.PromptServer.instance.routes.post("/model-manager/model/move") +async def move_model(request): + body = await request.json() + + old_file = body.get("oldFile", None) + if old_file is None: + return web.json_response({ "success": False }) + old_file, old_model_type = search_path_to_system_path(old_file) + if not os.path.isfile(old_file): + return web.json_response({ "success": False }) + _, model_extension = os.path.splitext(old_file) + if not model_extension in folder_paths_get_supported_pt_extensions(old_model_type): + # cannot move arbitrary files + return web.json_response({ "success": False }) + + new_file = body.get("newFile", None) + if new_file is None or new_file == "": + # cannot have empty name + return web.json_response({ "success": False }) + new_file, new_model_type = search_path_to_system_path(new_file) + if not new_file.endswith(model_extension): + return web.json_response({ "success": False }) + if os.path.isfile(new_file): + # cannot overwrite existing file + return web.json_response({ "success": False }) + if not model_extension in folder_paths_get_supported_pt_extensions(new_model_type): + return web.json_response({ "success": False }) + new_file_dir, _ = os.path.split(new_file) + if not os.path.isdir(new_file_dir): + return web.json_response({ "success": False }) + + if old_file == new_file: + return web.json_response({ "success": False }) + try: + shutil.move(old_file, new_file) + except ValueError as e: + print(e, file=sys.stderr, flush=True) + return web.json_response({ "success": False }) + + old_file_without_extension, _ = os.path.splitext(old_file) + new_file_without_extension, _ = os.path.splitext(new_file) + + # TODO: this could overwrite existing files... + for extension in image_extensions + (".txt",): + old_file = old_file_without_extension + extension + if os.path.isfile(old_file): + try: + shutil.move(old_file, new_file_without_extension + extension) + except ValueError as e: + print(e, file=sys.stderr, flush=True) + + return web.json_response({ "success": True }) + + +def delete_same_name_files(path_without_extension, extensions, keep_extension=None): + for extension in extensions: + if extension == keep_extension: continue + image_file = path_without_extension + extension + if os.path.isfile(image_file): + os.remove(image_file) + + +@server.PromptServer.instance.routes.post("/model-manager/model/delete") +async def delete_model(request): + result = { "success": False } + + model_path = request.query.get("path", None) + if model_path is None: + return web.json_response(result) + model_path = urllib.parse.unquote(model_path) + + file, model_type = search_path_to_system_path(model_path) + if file is None: + return web.json_response(result) + + _, extension = os.path.splitext(file) + if not extension in folder_paths_get_supported_pt_extensions(model_type): + # cannot delete arbitrary files + return web.json_response(result) + + if os.path.isfile(file): + os.remove(file) + result["success"] = True + + path_and_name, _ = os.path.splitext(file) + + delete_same_name_files(path_and_name, image_extensions) + + txt_file = path_and_name + ".txt" + if os.path.isfile(txt_file): + os.remove(txt_file) + + return web.json_response(result) + + +@server.PromptServer.instance.routes.post("/model-manager/notes/save") +async def set_notes(request): + body = await request.json() + + text = body.get("notes", None) + if type(text) is not str: + return web.json_response({ "success": False }) + + model_path = body.get("path", None) + if type(model_path) is not str: + return web.json_response({ "success": False }) + model_path, _ = search_path_to_system_path(model_path) + file_path_without_extension, _ = os.path.splitext(model_path) + filename = os.path.normpath(file_path_without_extension + ".txt") + if text.isspace() or text == "": + if os.path.exists(filename): + os.remove(filename) + else: + try: + with open(filename, "w", encoding="utf-8") as f: + f.write(text) + except ValueError as e: + print(e, file=sys.stderr, flush=True) + web.json_response({ "success": False }) + + return web.json_response({ "success": True }) WEB_DIRECTORY = "web" diff --git a/config_loader.py b/config_loader.py new file mode 100644 index 0000000..1e10ff5 --- /dev/null +++ b/config_loader.py @@ -0,0 +1,65 @@ +import yaml +from dataclasses import dataclass + +@dataclass +class Rule: + key: any + value_default: any + value_type: type + value_min: int | float | None + value_max: int | float | None + + def __init__(self, key, value_default, value_type: type, value_min: int | float | None = None, value_max: int | float | None = None): + self.key = key + self.value_default = value_default + self.value_type = value_type + self.value_min = value_min + self.value_max = value_max + +def _get_valid_value(data: dict, r: Rule): + if r.value_type != type(r.value_default): + raise Exception(f"'value_type' does not match type of 'value_default'!") + value = data.get(r.key) + if value is None: + value = r.value_default + else: + try: + value = r.value_type(value) + except: + value = r.value_default + + value_is_numeric = r.value_type == int or r.value_type == float + if value_is_numeric and r.value_min: + if r.value_type != type(r.value_min): + raise Exception(f"Type of 'value_type' does not match the type of 'value_min'!") + value = max(r.value_min, value) + if value_is_numeric and r.value_max: + if r.value_type != type(r.value_max): + raise Exception(f"Type of 'value_type' does not match the type of 'value_max'!") + value = min(r.value_max, value) + + return value + +def validated(rules: list[Rule], data: dict = {}): + valid = {} + for r in rules: + valid[r.key] = _get_valid_value(data, r) + return valid + +def yaml_load(path, rules: list[Rule]): + data = {} + try: + with open(path, 'r') as file: + data = yaml.safe_load(file) + except: + pass + return validated(rules, data) + +def yaml_save(path, rules: list[Rule], data: dict) -> bool: + data = validated(rules, data) + try: + with open(path, 'w') as file: + yaml.dump(data, file) + return True + except: + return False diff --git a/demo-tab-download.png b/demo-tab-download.png new file mode 100644 index 0000000..03e2ca0 Binary files /dev/null and b/demo-tab-download.png differ diff --git a/demo-tab-models.png b/demo-tab-models.png new file mode 100644 index 0000000..d8431fe Binary files /dev/null and b/demo-tab-models.png differ diff --git a/index.json b/index.json deleted file mode 100644 index 59ebb56..0000000 --- a/index.json +++ /dev/null @@ -1,124 +0,0 @@ -[ - { - "type": "checkpoint", - "base": "sd-xl", - "name": "sd_xl_base_1.0.safetensors", - "page": "https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0", - "download": "https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors", - "description": "Stable Diffusion XL base model" - }, - { - "type": "checkpoint", - "base": "sd-xl", - "name": "sd_xl_refiner_1.0.safetensors", - "page": "https://huggingface.co/stabilityai/stable-diffusion-xl-refiner-1.0", - "download": "https://huggingface.co/stabilityai/stable-diffusion-xl-refiner-1.0/resolve/main/sd_xl_refiner_1.0.safetensors", - "description": "Stable Diffusion XL refiner model" - }, - { - "type": "vae", - "base": "sd-xl-vae", - "name": "sdxl_vae.safetensors", - "page": "https://huggingface.co/stabilityai/sdxl-vae", - "download": "https://huggingface.co/stabilityai/sdxl-vae/resolve/main/sdxl_vae.safetensors", - "description": "Stable Diffusion XL VAE" - }, - - { - "type": "checkpoint", - "base": "sd-1.5", - "name": "anything_v5.safetensors", - "page": "https://huggingface.co/stablediffusionapi/anything-v5", - "download": "https://huggingface.co/stablediffusionapi/anything-v5/resolve/main/unet/diffusion_pytorch_model.safetensors" - }, - { - "type": "vae", - "name": "anything_v5.vae.safetensors", - "download": "https://huggingface.co/stablediffusionapi/anything-v5/resolve/main/vae/diffusion_pytorch_model.safetensors" - }, - { - "type": "checkpoint", - "name": "Counterfeit-V3.0.safetensors", - "download": "https://huggingface.co/gsdf/Counterfeit-V3.0/resolve/main/Counterfeit-V3.0.safetensors" - }, - { - "type": "embeddings", - "name": "EasyNegative.safetensors", - "download": "https://huggingface.co/datasets/gsdf/EasyNegative/resolve/main/EasyNegative.safetensors" - }, - { - "type": "checkpoint", - "name": "CounterfeitXL_%CE%B2.safetensors", - "download": "https://huggingface.co/gsdf/CounterfeitXL/resolve/main/CounterfeitXL_%CE%B2.safetensors" - }, - { - "type": "checkpoint", - "name": "AOM3A1B_orangemixs.safetensors", - "download": "https://huggingface.co/WarriorMama777/OrangeMixs/resolve/main/Models/AbyssOrangeMix3/AOM3A1B_orangemixs.safetensors" - }, - { - "type": "vae", - "name": "orangemix.vae.pt", - "download": "https://huggingface.co/WarriorMama777/OrangeMixs/resolve/main/VAEs/orangemix.vae.pt" - }, - { - "type": "checkpoint", - "name": "Deliberate.safetensors", - "download": "https://huggingface.co/XpucT/Deliberate/resolve/main/Deliberate.safetensors" - }, - { - "type": "checkpoint", - "name": "Realistic_Vision_V5.1.safetensors", - "download": "https://huggingface.co/SG161222/Realistic_Vision_V5.1_noVAE/resolve/main/Realistic_Vision_V5.1.safetensors" - }, - { - "type": "vae", - "name": "sd_vae.safetensors", - "download": "https://huggingface.co/stabilityai/sd-vae-ft-mse-original/resolve/main/vae-ft-mse-840000-ema-pruned.safetensors" - }, - { - "type": "checkpoint", - "name": "LOFI_V3.safetensors", - "download": "https://huggingface.co/lenML/LOFI-v3/resolve/main/LOFI_V3.safetensors" - }, - { - "type": "checkpoint", - "name": "NeverendingDream_noVae.safetensors", - "download": "https://huggingface.co/Lykon/NeverEnding-Dream/resolve/main/NeverendingDream_noVae.safetensors" - }, - { - "type": "vae", - "name": "sd_vae.safetensors", - "download": "https://huggingface.co/stabilityai/sd-vae-ft-mse-original/resolve/main/vae-ft-mse-840000-ema-pruned.safetensors" - }, - { - "type": "checkpoint", - "name": "ProtoGen_X5.8.safetensors", - "download": "https://huggingface.co/darkstorm2150/Protogen_x5.8_Official_Release/resolve/main/ProtoGen_X5.8.safetensors" - }, - { - "type": "checkpoint", - "name": "GuoFeng3.4.safetensors", - "download": "https://huggingface.co/xiaolxl/GuoFeng3/resolve/main/GuoFeng3.4.safetensors" - }, - { - "type": "lora", - "name": "Xiaorenshu_v15.safetensors", - "download": "https://huggingface.co/datamonet/xiaorenshu/resolve/main/Xiaorenshu_v15.safetensors" - }, - { - "type": "lora", - "name": "Colorwater_v4.safetensors", - "download": "https://huggingface.co/niitokikei/Colorwater/resolve/main/Colorwater_v4.safetensors" - }, - { - "type": "lora", - "name": "huyefo-v1.0.safetensors", - "download": "https://civitai.com/api/download/models/104426" - }, - { - "type": "upscale_models", - "name": "RealESRGAN_x2plus.pth", - "download": "https://huggingface.co/Rainy-hh/Real-ESRGAN/resolve/main/RealESRGAN_x2plus.pth" - } -] diff --git a/web/model-manager.css b/web/model-manager.css index 984bdc8..df5109d 100644 --- a/web/model-manager.css +++ b/web/model-manager.css @@ -1,98 +1,3 @@ -/* 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; @@ -101,7 +6,7 @@ max-width: unset; max-height: unset; padding: 10px; - color: #fff; + color: var(--bg-color); z-index: 2000; } @@ -110,18 +15,55 @@ gap: 16px; } -/* model manager common */ +.model-manager.sidebar-left { + width: 50%; + left: 25%; +} + +.model-manager.sidebar-top { + height: 50%; + top: 25%; +} + +.model-manager.sidebar-bottom { + height: 50%; + top: 75%; +} + +.model-manager.sidebar-right { + width: 50%; + left: 75%; +} + +/* common */ +.model-manager h1 { + min-width: 0; +} + +.model-manager textarea { + width: 100%; + font-size: 1.2em; + border: solid 2px var(--border-color); + border-radius: 8px; + resize: vertical; +} + +.model-manager input[type="file"] { + width: 100%; +} + .model-manager button, .model-manager select, .model-manager input { padding: 4px 8px; margin: 0; + border: 2px solid var(--border-color); } .model-manager button:disabled, .model-manager select:disabled, .model-manager input:disabled { - background-color: #6a6a6a; + background-color: var(--comfy-menu-bg); filter: brightness(1.2); cursor: not-allowed; } @@ -136,27 +78,118 @@ } .model-manager ::-webkit-scrollbar { - width: 6px; + width: 16px; } .model-manager ::-webkit-scrollbar-track { - background-color: #353535; + background-color: var(--comfy-input-bg); border-right: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color); } .model-manager ::-webkit-scrollbar-thumb { - background-color: #a1a1a1; + background-color: var(--fg-color); border-radius: 3px; } -/* model manager row */ +.model-manager .search-text-area::-webkit-input-placeholder { + font-style: italic; +} +.model-manager .search-text-area:-moz-placeholder { + font-style: italic; +} +.model-manager .search-text-area::-moz-placeholder { + font-style: italic; +} +.model-manager .search-text-area:-ms-input-placeholder { + font-style: italic; +} + +.icon-button { + height: 40px; + width: 40px; + line-height: 1.15; +} + .model-manager .row { display: flex; + min-width: 0; gap: 8px; } -/* comfy tabs */ +.model-manager .tab-header { + display: flex; + padding: 8px 0; + flex-direction: column; + background-color: var(--bg-color); +} + +.model-manager .tab-header-flex-block { + width: 100%; + min-width: 0; +} + +.model-manager .button-success { + color: green; + border-color: green; +} + +.model-manager .button-failure { + color: darkred; + border-color: darkred; +} + +.model-manager .no-select { + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; +} + +/* sidebar buttons */ +.model-manager .sidebar-buttons { + overflow: hidden; + padding-right: 10px; + color: var(--input-text); +} + +/* tabs */ +.model-manager .comfy-tabs { + color: var(--fg-color); +} + +.model-manager .comfy-tabs-head { + display: flex; + gap: 8px; + flex-wrap: wrap; + border-bottom: 2px solid var(--border-color); +} + +.model-manager .comfy-tabs-head .head-item { + padding: 8px 12px; + border: 2px solid var(--border-color); + border-bottom: none; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + background-color: var(--comfy-menu-bg); + cursor: pointer; + margin-bottom: 0px; + z-index: 1; +} + +.model-manager .comfy-tabs-head .head-item.active { + background-color: var(--comfy-input-bg); + cursor: default; + position: relative; + z-index: 1; +} + +.model-manager .comfy-tabs-body { + background-color: var(--bg-color); + border: 2px solid var(--border-color); + border-top: none; + padding: 16px 0px; +} + .model-manager .comfy-tabs { flex: 1; display: flex; @@ -171,36 +204,302 @@ .model-manager .comfy-tabs-body > div { position: relative; - max-height: 100%; + height: 100%; + width: auto; padding: 0 16px; - overflow-x: hidden; + overflow-x: auto; } -/* model manager special */ -.model-manager .close { +/* model info view */ +.model-manager .model-info-view { + background-color: var(--bg-color); + border: 2px solid var(--border-color); + box-sizing: border-box; + display: flex; + flex-direction: column; + height: 100%; + margin-top: 40px; + overflow-wrap: break-word; + overflow-y: auto; + padding: 20px; +} + +.model-manager .model-info-container { + background-color: var(--bg-color); + border-radius: 16px; + color: var(--fg-color); + width: auto; +} + +/* download tab */ + +.model-manager [data-name="Download"] summary { + padding: 16px; + word-wrap: break-word; +} + +.model-manager [data-name="Download"] .download-settings { + flex: 1; +} + +.model-manager .download-model-infos { + padding: 16px 0; +} + +/* models tab */ +.model-manager [data-name="Models"] .row { + position: sticky; + z-index: 1; + top: 0; +} + +/* preview image */ +.model-manager .item { + position: relative; + width: 230px; + height: 345px; + text-align: center; + overflow: hidden; + border-radius: 8px; +} + +.model-manager .item img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.model-manager .model-preview-button-left, +.model-manager .model-preview-button-right { position: absolute; - padding: 1px 6px; + top: 0; + bottom: 0; + margin: auto; + border-radius: 20px; +} + +.model-manager .model-preview-button-right { + right: 4px; +} + +.model-manager .model-preview-button-left { + left: 4px; +} + +.model-manager .item .model-preview-overlay { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + background-color: rgba(0, 0, 0, 0); +} + +/* grid */ +.model-manager .comfy-grid { + display: flex; + flex-wrap: wrap; + gap: 16px; +} + +.model-manager .comfy-grid .model-label { + background-color: #000a; + width: 100%; + height: 2.2rem; + position: absolute; + bottom: 0; + text-align: center; + line-height: 2.2rem; +} + +.model-manager .comfy-grid .model-label > p { + width: calc(100% - 2rem); + overflow-x: scroll; + white-space: nowrap; + display: inline-block; + vertical-align: middle; + margin: 0; +} + +.model-manager .comfy-grid .model-label { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.model-manager .comfy-grid .model-label ::-webkit-scrollbar { + width: 0; + height: 0; +} + +.model-manager .comfy-grid .model-preview-top-right, +.model-manager .comfy-grid .model-preview-top-left { + position: absolute; + display: flex; + flex-direction: column; + gap: 8px; + top: 8px; +} + +.model-manager .comfy-grid .model-preview-top-right { + right: 8px; +} + +.model-manager .comfy-grid .model-preview-top-left { + left: 8px; +} + +.model-manager .comfy-grid .model-button { + opacity: 0.65; +} + +.model-manager .comfy-grid .model-button:hover { + opacity: 1; +} + +.model-manager .comfy-grid .model-label { + user-select: text; +} + +/* radio */ +.model-manager .comfy-radio-group { + display: flex; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +} + +.model-manager .comfy-radio { + display: flex; + gap: 4px; + padding: 4px 16px; + color: var(--input-text); + border: 2px solid var(--border-color); + border-radius: 16px; + background-color: var(--comfy-input-bg); + font-size: 18px; +} + +.model-manager .comfy-radio:has(> input[type="radio"]:checked) { + border-color: var(--border-color); + background-color: var(--comfy-menu-bg); +} + +.model-manager .comfy-radio input[type="radio"]:checked + label { + color: var(--fg-color); +} + +.model-manager .radio-input { + opacity: 0; + position: absolute; +} + +/* model preview select */ +.model-preview-select-radio-container { + min-width: 0; + flex: 1; +} + +.model-manager .model-preview-select-radio-container img { + position: relative; + width: 230px; + height: 345px; + text-align: center; + overflow: hidden; + border-radius: 8px; + object-fit: cover; +} + +/* topbar */ +.model-manager .topbar-buttons { + position: absolute; + display: flex; 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; +.model-manager .topbar-buttons button { + width: 33px; + height: 33px; + padding: 1px 6px; +} + +/* search dropdown */ +.model-manager .search-models { + display: flex; + flex-direction: row; + flex: 1; + min-width: 0; +} + +.model-manager .model-select-dropdown { + min-width: 0; + overflow: auto; +} + +.model-manager .search-text-area, +.model-manager .plain-text-area, +.model-manager .model-select-dropdown { + flex: 1; + min-height: 36px; + padding-block: 0; + min-width: 36px; +} + +.model-manager .model-select-dropdown { + min-height: 40px; +} + +.model-manager .search-dropdown { + position: absolute; + background-color: var(--bg-color); + border: 2px var(--border-color) solid; + color: var(--fg-color); + max-height: 30vh; + overflow: auto; + border-radius: 10px; z-index: 1; } -.model-manager .table-head { - position: sticky; - top: 52px; - z-index: 1; +.model-manager .search-dropdown:empty { + display: none; } -.model-manager div[data-name="Model List"] .row { - align-items: flex-start; +.model-manager .search-dropdown > p { + margin: 0; + padding: 0.85em 20px; + min-width: 0; +} +.model-manager .search-dropdown > p { + -ms-overflow-style: none; /* Internet Explorer 10+ */ + scrollbar-width: none; /* Firefox */ +} +.model-manager .search-dropdown > p::-webkit-scrollbar { + display: none; /* Safari and Chrome */ +} + +.model-manager .search-dropdown > p.search-dropdown-selected { + background-color: var(--border-color); +} + +/* model manager settings */ +.model-manager .model-manager-settings > div, +.model-manager .model-manager-settings > label { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + margin: 16px 0; +} + +.model-manager .model-manager-settings button { + height: 40px; + width: 120px; +} + +.model-manager .model-manager-settings input[type="number"] { + width: 50px; +} + +.search-settings-text { + width: 100%; } diff --git a/web/model-manager.js b/web/model-manager.js index 246d8a4..397a17d 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -2,59 +2,177 @@ 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); +/** + * @param {string} url + * @param {any} [options=undefined] + * @returns {Promise} + */ +function request(url, options = undefined) { + return new Promise((resolve, reject) => { + api.fetchApi(url, options) + .then((response) => response.json()) + .then(resolve) + .catch(reject); + }); +} + +const modelNodeType = { + "checkpoints": "CheckpointLoaderSimple", + "clip": "CLIPLoader", + "clip_vision": "CLIPVisionLoader", + "controlnet": "ControlNetLoader", + "diffusers": "DiffusersLoader", + "embeddings": "Embedding", + "gligen": "GLIGENLoader", + "hypernetworks": "HypernetworkLoader", + "photomaker": "PhotoMakerLoader", + "loras": "LoraLoader", + "style_models": "StyleModelLoader", + "unet": "UNETLoader", + "upscale_models": "UpscaleModelLoader", + "vae": "VAELoader", + "vae_approx": undefined, +}; + +const 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? + +class SearchPath { + /** + * @param {string} path + * @returns {[string, string]} + */ + static split(path) { + const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1; + return [path.slice(0, i), path.slice(i)]; + } + + /** + * @param {string} path + * @param {string[]} extensions + * @returns {[string, string]} + */ + static splitExtension(path) { + const i = path.lastIndexOf("."); + if (i === -1) { + return [path, ""]; + } + return [path.slice(0, i), path.slice(i)]; + } + + /** + * @param {string} path + * @returns {string} + */ + static systemPath(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 | undefined} [searchPath=undefined] + * @param {string | undefined} [dateImageModified=undefined] + * + * @returns {string} + */ +function imageUri(imageSearchPath = undefined, dateImageModified = undefined) { + const path = imageSearchPath ?? "no-preview"; + const date = dateImageModified; + let uri = `/model-manager/preview/get?uri=${path}`; + if (date !== undefined && date !== null) { + uri += `&v=${date}`; + } + return uri; +} + +/** + * @param {(...args) => void} callback + * @param {number | undefined} delay + * @returns {(...args) => void} + */ +function debounce(callback, delay) { + let timeoutId = null; + return (...args) => { + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + callback(...args); }, delay); }; } +/** + * @param {HTMLButtonElement} element + * @param {boolean} success + * @param {string} [successText=""] + * @param {string} [failureText=""] + * @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 != "") { + element.innerHTML = success ? successText : failureText; + } + // TODO: debounce would be nice to get working... + window.setTimeout((element, name, innerHTML) => { + element.classList.remove(name); + if (innerHTML != "") { + element.innerHTML = innerHTML; + } + }, 1000, element, name, resetText); +} + class Tabs { /** @type {Record} */ #head = {}; /** @type {Record } */ #body = {}; - + /** - * @param {Array } tabs + * @param {HTMLDivElement[]} tabs */ constructor(tabs) { const head = []; const body = []; - + tabs.forEach((el, index) => { const name = el.getAttribute("data-name"); - + /** @type {HTMLDivElement} */ const tag = $el( "div.head-item", { onclick: () => this.active(name) }, [name] ); - + if (index === 0) { this.#active = name; } - + this.#head[name] = tag; head.push(tag); this.#body[name] = el; body.push(el); }); - + this.element = $el("div.comfy-tabs", [ $el("div.comfy-tabs-head", head), $el("div.comfy-tabs-body", body), ]); - + this.active(this.#active); } - + #active = undefined; - + + /** + * @param {string} name + */ active(name) { this.#active = name; Object.keys(this.#head).forEach((key) => { @@ -70,8 +188,8 @@ class Tabs { } /** - * @param {Record } option - * @param {Array } tabs + * @param {Record } tabs + * @returns {HTMLDivElement[]} */ function $tabs(tabs) { const instance = new Tabs(tabs); @@ -80,131 +198,44 @@ function $tabs(tabs) { /** * @param {string} name - * @param {Array } el + * @param {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 } */ - #columns = []; - - /** @type {Array >} */ - #dataSource = []; - - /** @type {HTMLDivElement} */ - #tbody = null; - - /** - * @param {Array } 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 = " No Models
"; - } +/** + * @returns {HTMLLabelElement} + */ +function $checkbox(x = { $: (el) => {}, textContent: "", checked: false }) { + const text = x.textContent; + const input = $el("input", { + type: "checkbox", + name: text ?? "checkbox", + checked: x.checked ?? false, + }); + const label = $el("label", [ + input, + text === "" || text === undefined || text === null ? "" : " " + text, + ]); + if (x.$ !== undefined){ + x.$(input); } + return label; } +/** + * @param {Any} attr + * @returns {HTMLDivElement} + */ function $radioGroup(attr) { const { name = Date.now(), onchange, options = [], $ } = attr; - + /** @type {HTMLDivElement[]} */ const radioGroup = options.map((item, index) => { const inputRef = { value: null }; - + return $el( "div.comfy-radio", { onclick: () => inputRef.value.click() }, @@ -220,8 +251,11 @@ function $radioGroup(attr) { ] ); }); - - const element = $el("input", { value: options[0]?.value }); + + const element = $el("input", { + name: name + "-group", + value: options[0]?.value, + }); $?.(element); radioGroup.forEach((radio) => { @@ -231,274 +265,2660 @@ function $radioGroup(attr) { 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); +class ImageSelect { + /** @constant {string} */ #PREVIEW_DEFAULT = "Default"; + /** @constant {string} */ #PREVIEW_UPLOAD = "Upload"; + /** @constant {string} */ #PREVIEW_URL = "URL"; + /** @constant {string} */ #PREVIEW_NONE = "No Preview"; + + elements = { + /** @type {HTMLDivElement} */ radioGroup: null, + /** @type {HTMLDivElement} */ radioButtons: null, + /** @type {HTMLDivElement} */ previews: null, + + /** @type {HTMLImageElement} */ defaultPreviewNoImage: null, + /** @type {HTMLDivElement} */ defaultPreviews: null, + /** @type {HTMLDivElement} */ defaultUrl: null, + + /** @type {HTMLImageElement} */ customUrlPreview: null, + /** @type {HTMLInputElement} */ customUrl: null, + /** @type {HTMLDivElement} */ custom: null, + + /** @type {HTMLImageElement} */ uploadPreview: null, + /** @type {HTMLInputElement} */ uploadFile: null, + /** @type {HTMLDivElement} */ upload: null, + }; + + /** @type {string} */ + #name = null; + + /** @returns {string|File} */ + getImage() { + const name = this.#name; + const value = document.querySelector(`input[name="${name}"]:checked`).value; + const elements = this.elements; + switch (value) { + case this.#PREVIEW_DEFAULT: + const children = elements.defaultPreviews.children; + const noImage = imageUri(); + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.style.display !== "none" && + child.nodeName === "IMG" && + !child.src.endsWith(noImage) + ) { + return child.src; + } + } + return ""; + case this.#PREVIEW_URL: + return elements.customUrl.value; + case this.#PREVIEW_UPLOAD: + return elements.uploadFile.files[0] ?? ""; + case this.#PREVIEW_NONE: + return imageUri(); + } + return ""; + } + + /** @returns {void} */ + resetModelInfoPreview() { + let noimage = this.elements.defaultUrl.dataset.noimage; + [ + this.elements.defaultPreviewNoImage, + this.elements.defaultPreviews, + this.elements.customUrlPreview, + this.elements.uploadPreview, + ].forEach((el) => { + el.style.display = "none"; + if (this.elements.defaultPreviewNoImage !== el) { + if (el.nodeName === "IMG") { + el.src = noimage; + } + else { + el.children[0].src = noimage; + } + } + else { + el.src = imageUri(); + } }); + this.checkDefault(); + this.elements.uploadFile.value = ""; + this.elements.customUrl.value = ""; + this.elements.upload.style.display = "none"; + this.elements.custom.style.display = "none"; + } + + /** @returns {boolean} */ + defaultIsChecked() { + const children = this.elements.radioButtons.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const radioButton = child.children[0]; + if (radioButton.value === this.#PREVIEW_DEFAULT) { + return radioButton.checked; + } + }; + return false; + } + + /** @returns {void} */ + checkDefault() { + const children = this.elements.radioButtons.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const radioButton = child.children[0]; + if (radioButton.value === this.#PREVIEW_DEFAULT) { + this.elements.defaultPreviews.style.display = "block"; + radioButton.checked = true; + break; + } + }; + } + + /** + * @param {1 | -1} step + */ + stepDefaultPreviews(step) { + const children = this.elements.defaultPreviews.children; + if (children.length === 0) { + return; + } + let currentIndex = -step; + for (let i = 0; i < children.length; i++) { + const previewImage = children[i]; + const display = previewImage.style.display; + if (display !== "none") { + currentIndex = i; + } + previewImage.style.display = "none"; + } + currentIndex = currentIndex + step; + if (currentIndex >= children.length) { currentIndex = 0; } + else if (currentIndex < 0) { currentIndex = children.length - 1; } + children[currentIndex].style.display = "block"; + } + + /** + * @param {string} radioGroupName - Should be unique for every radio group. + * @param {string[]|undefined} defaultPreviews + */ + constructor(radioGroupName, defaultPreviews = []) { + if (defaultPreviews === undefined | defaultPreviews === null | defaultPreviews.length === 0) { + defaultPreviews = [imageUri()]; + } + this.#name = radioGroupName; + + const el_defaultUri = $el("div", { + $: (el) => (this.elements.defaultUrl = el), + style: { display: "none" }, + "data-noimage": imageUri(), + }); + + const el_defaultPreviewNoImage = $el("img", { + $: (el) => (this.elements.defaultPreviewNoImage = el), + src: imageUri(), + style: { display: "none" }, + loading: "lazy", + }); + + const el_defaultPreviews = $el("div", { + $: (el) => (this.elements.defaultPreviews = el), + style: { + width: "100%", + height: "100%", + }, + }, (() => { + const imgs = defaultPreviews.map((url) => { + return $el("img", { + src: url, + style: { display: "none" }, + loading: "lazy", + onerror: (e) => { + e.target.src = el_defaultUri.dataset.noimage ?? imageUri(); + }, + }); + }); + if (imgs.length > 0) { + imgs[0].style.display = "block"; + } + return imgs; + })()); + + const el_uploadPreview = $el("img", { + $: (el) => (this.elements.uploadPreview = el), + src: imageUri(), + style: { display : "none" }, + onerror: (e) => { + e.target.src = el_defaultUri.dataset.noimage ?? imageUri(); + }, + }); + const el_uploadFile = $el("input", { + $: (el) => (this.elements.uploadFile = el), + type: "file", + name: "upload preview image", + accept: IMAGE_EXTENSIONS.join(", "), + onchange: (e) => { + const file = e.target.files[0]; + if (file) { + el_uploadPreview.src = URL.createObjectURL(file); + } + else { + el_uploadPreview.src = el_defaultUri.dataset.noimage; + } + }, + }); + const el_upload = $el("div.row.tab-header-flex-block", { + $: (el) => (this.elements.upload = el), + style: { display: "none" }, + }, [ + el_uploadFile, + ]); + + const el_customUrlPreview = $el("img", { + $: (el) => (this.elements.customUrlPreview = el), + src: imageUri(), + style: { display: "none" }, + onerror: (e) => { + e.target.src = el_defaultUri.dataset.noimage ?? imageUri(); + }, + }); + const el_customUrl = $el("input.search-text-area", { + $: (el) => (this.elements.customUrl = el), + type: "text", + name: "custom preview image url", + autocomplete: "off", + placeholder: "https://custom-image-preview.png", + }); + const el_custom = $el("div.row.tab-header-flex-block", { + $: (el) => (this.elements.custom = el), + style: { display: "none" }, + }, [ + el_customUrl, + $el("button.icon-button", { + textContent: "🔍︎", + onclick: (e) => { + el_customUrlPreview.src = el_customUrl.value; + }, + }), + ]); + + const el_previewButtons = $el("div.model-preview-overlay", { + style: { + display: el_defaultPreviews.children.length > 1 ? "block" : "none", + }, + }, [ + $el("button.icon-button.model-preview-button-left", { + textContent: "←", + onclick: () => this.stepDefaultPreviews(-1), + }), + $el("button.icon-button.model-preview-button-right", { + textContent: "→", + onclick: () => this.stepDefaultPreviews(1), + }), + ]); + const el_previews = $el("div.item", { + $: (el) => (this.elements.previews = el), + }, [ + $el("div", { + style: { + "width": "100%", + "height": "100%", + }, + }, + [ + el_defaultPreviewNoImage, + el_defaultPreviews, + el_customUrlPreview, + el_uploadPreview, + ], + ), + el_previewButtons, + ]); + + const el_radioButtons = $radioGroup({ + name: radioGroupName, + onchange: (value) => { + el_custom.style.display = "none"; + el_upload.style.display = "none"; + + el_defaultPreviews.style.display = "none"; + el_previewButtons.style.display = "none"; + + el_defaultPreviewNoImage.style.display = "none"; + el_uploadPreview.style.display = "none"; + el_customUrlPreview.style.display = "none"; + + switch (value) { + case this.#PREVIEW_DEFAULT: + el_defaultPreviews.style.display = "block"; + el_previewButtons.style.display = el_defaultPreviews.children.length > 1 ? "block" : "none"; + break; + case this.#PREVIEW_UPLOAD: + el_upload.style.display = "flex"; + el_uploadPreview.style.display = "block"; + break; + case this.#PREVIEW_URL: + el_custom.style.display = "flex"; + el_customUrlPreview.style.display = "block"; + break; + case this.#PREVIEW_NONE: + default: + el_defaultPreviewNoImage.style.display = "block"; + break; + } + }, + options: [ + this.#PREVIEW_DEFAULT, + this.#PREVIEW_URL, + this.#PREVIEW_UPLOAD, + this.#PREVIEW_NONE, + ].map((value) => { + return { value: value, }; + }), + }); + this.elements.radioButtons = el_radioButtons; + + const children = el_radioButtons.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const radioButton = child.children[0]; + if (radioButton.value === this.#PREVIEW_DEFAULT) { + radioButton.checked = true; + break; + } + }; + + const el_radioGroup = $el("div.model-preview-select-radio-container", { + $: (el) => (this.elements.radioGroup = el), + }, [ + $el("div.row.tab-header-flex-block", [el_radioButtons]), + $el("div", [ + el_custom, + el_upload, + ]), + ]); + } +} + +/** + * @typedef {Object} DirectoryItem + * @param {string} name + * @param {number | undefined} childCount + * @param {number | undefined} childIndex + */ + +const DROPDOWN_DIRECTORY_SELECTION_CLASS = "search-dropdown-selected"; + +class DirectoryDropdown { + /** @type {HTMLDivElement} */ + element = null; + + /** @type {Boolean} */ + showDirectoriesOnly = false; + + /** @type {HTMLInputElement} */ + #input = null; + + // TODO: remove this + /** @type {() => void} */ + #updateDropdown = null; + + /** @type {() => void} */ + #updateCallback = null; + + /** @type {() => Promise} */ + #submitCallback = null; + + /** + * @param {HTMLInputElement} input + * @param {() => void} updateDropdown + * @param {() => void} [updateCallback= () => {}] + * @param {() => Promise } [submitCallback= () => {}] + * @param {String} [searchSeparator="/"] + * @param {Boolean} [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: { + display: "none", + }, + }); + this.element = dropdown; + this.#input = input; + this.#updateDropdown = updateDropdown; + this.#updateCallback = updateCallback; + this.#submitCallback = submitCallback; + this.showDirectoriesOnly = showDirectoriesOnly; + + input.addEventListener("input", () => updateDropdown()); + input.addEventListener("focus", () => updateDropdown()); + input.addEventListener("blur", () => { dropdown.style.display = "none"; }); + input.addEventListener( + "keydown", + (e) => { + const options = dropdown.children; + let iSelection; + for (iSelection = 0; iSelection < options.length; iSelection++) { + const selection = options[iSelection]; + if (selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_CLASS)) { + break; + } + } + if (e.key === "Escape") { + e.stopPropagation(); + if (iSelection < options.length) { + const selection = options[iSelection]; + selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS); + } + else { + e.target.blur(); + } + } + else if (e.key === "ArrowRight" && dropdown.style.display !== "none") { + const selection = options[iSelection]; + if (selection !== undefined && selection !== null) { + e.stopPropagation(); + e.preventDefault(); // prevent cursor move + const input = e.target; + DirectoryDropdown.selectionToInput(input, selection, searchSeparator); + updateDropdown(); + //updateCallback(); + //submitCallback(); + /* + const options = dropdown.children; + if (options.length > 0) { + // arrow key navigation + options[0].classList.add(DROPDOWN_DIRECTORY_SELECTION_CLASS); + } + */ + } + } + else if (e.key === "ArrowLeft" && dropdown.style.display !== "none") { + const input = e.target; + const oldFilterText = input.value; + 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] === searchSeparator; + if (!isMatch) { + const options = dropdown.children; + for (let i = 0; i < options.length; i++) { + const option = options[i]; + if (option.innerText.startsWith(delta)) { + isMatch = true; + break; + } + } + } + if (isMatch) { + e.stopPropagation(); + e.preventDefault(); // prevent cursor move + input.value = newFilterText; + updateDropdown(); + //updateCallback(); + //submitCallback(); + /* + const options = dropdown.children; + let isSelected = false; + for (let i = 0; i < options.length; i++) { + const option = options[i]; + if (option.innerText.startsWith(delta)) { + option.classList.add(DROPDOWN_DIRECTORY_SELECTION_CLASS); + isSelected = true; + break; + } + } + if (!isSelected) { + const options = dropdown.children; + if (options.length > 0) { + // arrow key navigation + options[0].classList.add(DROPDOWN_DIRECTORY_SELECTION_CLASS); + } + } + */ + } + } + } + else if (e.key === "Enter") { + e.stopPropagation(); + const input = e.target + const selection = options[iSelection]; + if (selection !== undefined && selection !== null) { + DirectoryDropdown.selectionToInput(input, selection, searchSeparator); + updateDropdown(); + updateCallback(); + } + submitCallback(); + input.blur(); + } + else if ((e.key === "ArrowDown" || e.key === "ArrowUp") && dropdown.style.display !== "none") { + e.stopPropagation(); + e.preventDefault(); // prevent cursor move + let iNext = options.length; + if (iSelection < options.length) { + const selection = options[iSelection]; + selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS); + const delta = e.key === "ArrowDown" ? 1 : -1; + iNext = iSelection + delta; + if (0 <= iNext && iNext < options.length) { + const selectionNext = options[iNext]; + selectionNext.classList.add(DROPDOWN_DIRECTORY_SELECTION_CLASS); + } + } + else if (iSelection === options.length) { + iNext = e.key === "ArrowDown" ? 0 : options.length-1; + const selection = options[iNext] + selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_CLASS); + } + if (0 <= iNext && iNext < options.length) { + let dropdownTop = dropdown.scrollTop; + const dropdownHeight = dropdown.offsetHeight; + const selection = options[iNext]; + const selectionHeight = selection.offsetHeight; + const selectionTop = selection.offsetTop; + dropdownTop = Math.max(dropdownTop, selectionTop - dropdownHeight + selectionHeight); + dropdownTop = Math.min(dropdownTop, selectionTop); + dropdown.scrollTop = dropdownTop; + } + else { + dropdown.scrollTop = 0; + const options = dropdown.children; + for (iSelection = 0; iSelection < options.length; iSelection++) { + const selection = options[iSelection]; + if (selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_CLASS)) { + selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS); + } + } + } + } + }, + ); } - #el = { - loadSourceBtn: null, - loadSourceFromSelect: null, - loadSourceFromInput: null, - sourceInstalledFilter: null, - sourceContentFilter: null, - sourceFilterBtn: null, - modelTypeSelect: null, - }; + /** + * @param {HTMLInputElement} input + * @param {HTMLParagraphElement | undefined | null} selection + * @param {String} searchSeparator + */ + static selectionToInput(input, selection, searchSeparator) { + selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS); + const selectedText = selection.innerText; + const oldFilterText = input.value; + const iSep = oldFilterText.lastIndexOf(searchSeparator); + const previousPath = oldFilterText.substring(0, iSep + 1); + input.value = previousPath + selectedText; + } + /** + * @param {DirectoryItem[]} directories + * @param {string} searchSeparator + * @param {string} [modelType = ""] + */ + update(directories, searchSeparator, modelType = "") { + const dropdown = this.element; + const input = this.#input; + const updateDropdown = this.#updateDropdown; + const updateCallback = this.#updateCallback; + const submitCallback = this.#submitCallback; + const showDirectoriesOnly = this.showDirectoriesOnly; + + const filter = input.value; + if (filter[0] !== searchSeparator) { + dropdown.style.display = "none"; + return; + } + + let cwd = 0; + if (modelType !== "") { + const root = directories[0]; + const rootChildIndex = root["childIndex"]; + const rootChildCount = root["childCount"]; + cwd = null; + for (let i = rootChildIndex; i < rootChildIndex + rootChildCount; i++) { + const modelDir = directories[i]; + if (modelDir["name"] === modelType) { + cwd = i; + break; + } + } + } + + // TODO: directories === undefined? + let indexLastWord = 1; + while (true) { + const indexNextWord = filter.indexOf(searchSeparator, indexLastWord); + if (indexNextWord === -1) { + // end of filter + break; + } + + const item = directories[cwd]; + const childCount = item["childCount"]; + if (childCount === undefined) { + // file + break; + } + if (childCount === 0) { + // directory is empty + break; + } + const childIndex = item["childIndex"]; + const items = directories.slice(childIndex, childIndex + childCount); + + const word = filter.substring(indexLastWord, indexNextWord); + cwd = null; + for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + const itemName = items[itemIndex]["name"]; + if (itemName === word) { + // directory exists + cwd = childIndex + itemIndex; + break; + } + } + if (cwd === null) { + // directory does not exist + break; + } + indexLastWord = indexNextWord + 1; + } + if (cwd === null) { + dropdown.style.display = "none"; + return; + } + + let options = []; + const lastWord = filter.substring(indexLastWord); + const item = directories[cwd]; + if (item["childIndex"] !== undefined) { + const childIndex = item["childIndex"]; + const childCount = item["childCount"]; + const items = directories.slice(childIndex, childIndex + childCount); + for (let i = 0; i < items.length; i++) { + const child = items[i]; + const grandChildCount = child["childCount"]; + const isDir = grandChildCount !== undefined && grandChildCount !== null; + const itemName = child["name"]; + if (itemName.startsWith(lastWord) && (!showDirectoriesOnly || (showDirectoriesOnly && isDir))) { + options.push(itemName + (isDir ? searchSeparator : "")); + } + } + } + else if (!showDirectoriesOnly) { + const filename = item["name"]; + if (filename.startsWith(lastWord)) { + options.push(filename); + } + } + if (options.length === 0) { + dropdown.style.display = "none"; + return; + } + + const selection_select = (e) => { + const selection = e.target; + if (e.movementX === 0 && e.movementY === 0) { return; } + if (!selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_CLASS)) { + // assumes only one will ever selected at a time + e.stopPropagation(); + const children = dropdown.children; + let iChild; + for (iChild = 0; iChild < children.length; iChild++) { + const child = children[iChild]; + child.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS); + } + selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_CLASS); + } + }; + const selection_deselect = (e) => { + e.stopPropagation(); + e.target.classList.remove(DROPDOWN_DIRECTORY_SELECTION_CLASS); + }; + const selection_submit = (e) => { + e.stopPropagation(); + const selection = e.target; + DirectoryDropdown.selectionToInput(input, selection, searchSeparator); + updateDropdown(); + updateCallback();e.target + submitCallback(); + }; + const innerHtml = options.map((text) => { + /** @type {HTMLParagraphElement} */ + const p = $el( + "p", + { + onmouseenter: (e) => selection_select(e), + onmousemove: (e) => selection_select(e), + onmouseleave: (e) => selection_deselect(e), + onmousedown: (e) => selection_submit(e), + }, + [ + text + ] + ); + return p; + }); + dropdown.innerHTML = ""; + dropdown.append.apply(dropdown, innerHtml); + // TODO: handle when dropdown is near the bottom of the window + const inputRect = input.getBoundingClientRect(); + dropdown.style.width = inputRect.width + "px"; + dropdown.style.top = (input.offsetTop + inputRect.height) + "px"; + dropdown.style.left = input.offsetLeft + "px"; + dropdown.style.display = "block"; + } +} + +const MODEL_SORT_DATE_CREATED = "dateCreated"; +const MODEL_SORT_DATE_MODIFIED = "dateModified"; +const MODEL_SORT_DATE_NAME = "name"; + +class ModelGrid { + /** + * @param {string} nodeType + * @returns {int} + */ + static modelWidgetIndex(nodeType) { + return 0; + } + + /** + * @param {string} text + * @param {string} file + * @param {boolean} removeExtension + * @returns {string} + */ + static insertEmbeddingIntoText(text, file, removeExtension) { + let name = file; + if (removeExtension) { + name = SearchPath.splitExtension(name)[0]; + } + const sep = text.length === 0 || text.slice(-1).match(/\s/) ? "" : " "; + return text + sep + "(embedding:" + name + ":1.0)"; + } + + /** + * @param {Array} list + * @param {string} searchString + * @returns {Array} + */ + static #filter(list, searchString) { + /** @type {string[]} */ + const keywords = searchString + //.replace("*", " ") // TODO: this is wrong for wildcards + .split(/(-?".*?"|[^\s"]+)+/g) + .map((item) => item + .trim() + .replace(/(?:")+/g, "") + .toLowerCase()) + .filter(Boolean); + + const regexSHA256 = /^[a-f0-9]{64}$/gi; + const fields = ["name", "path"]; + return list.filter((element) => { + const text = fields + .reduce((memo, field) => memo + " " + element[field], "") + .toLowerCase(); + return keywords.reduce((memo, target) => { + const excludeTarget = target[0] === "-"; + if (excludeTarget && target.length === 1) { return memo; } + const filteredTarget = excludeTarget ? target.slice(1) : target; + if (element["SHA256"] !== undefined && regexSHA256.test(filteredTarget)) { + return memo && excludeTarget !== (filteredTarget === element["SHA256"]); + } + else { + return memo && excludeTarget !== text.includes(filteredTarget); + } + }, true); + }); + } + + /** + * In-place sort. Returns an arrat alias. + * @param {Array} list + * @param {string} sortBy + * @param {bool} [reverse=false] + * @returns {Array} + */ + static #sort(list, sortBy, reverse = false) { + let compareFn = null; + switch (sortBy) { + case MODEL_SORT_DATE_NAME: + compareFn = (a, b) => { return a[MODEL_SORT_DATE_NAME].localeCompare(b[MODEL_SORT_DATE_NAME]); }; + break; + case MODEL_SORT_DATE_MODIFIED: + compareFn = (a, b) => { return b[MODEL_SORT_DATE_MODIFIED] - a[MODEL_SORT_DATE_MODIFIED]; }; + break; + case MODEL_SORT_DATE_CREATED: + compareFn = (a, b) => { return b[MODEL_SORT_DATE_CREATED] - a[MODEL_SORT_DATE_CREATED]; }; + break; + default: + console.warn("Invalid filter sort value: '" + sortBy + "'"); + return list; + } + const sorted = list.sort(compareFn); + return reverse ? sorted.reverse() : sorted; + } + + /** + * @param {Event} event + * @param {string} modelType + * @param {string} path + * @param {boolean} removeEmbeddingExtension + * @param {int} addOffset + */ + static #addModel(event, modelType, path, removeEmbeddingExtension, addOffset) { + let success = false; + if (modelType !== "embeddings") { + const nodeType = modelNodeType[modelType]; + const widgetIndex = ModelGrid.modelWidgetIndex(nodeType); + let node = LiteGraph.createNode(nodeType, null, []); + if (node) { + node.widgets[widgetIndex].value = path; + const selectedNodes = app.canvas.selected_nodes; + let isSelectedNode = false; + for (var i in selectedNodes) { + const selectedNode = selectedNodes[i]; + node.pos[0] = selectedNode.pos[0] + addOffset; + node.pos[1] = selectedNode.pos[1] + addOffset; + isSelectedNode = true; + break; + } + if (!isSelectedNode) { + const graphMouse = app.canvas.graph_mouse; + node.pos[0] = graphMouse[0]; + node.pos[1] = graphMouse[1]; + } + app.graph.add(node, {doProcessChange: true}); + app.canvas.selectNode(node); + success = true; + } + event.stopPropagation(); + } + else if (modelType === "embeddings") { + const [embeddingDirectory, embeddingFile] = SearchPath.split(path); + const selectedNodes = app.canvas.selected_nodes; + for (var i in selectedNodes) { + const selectedNode = selectedNodes[i]; + const nodeType = modelNodeType[modelType]; + const widgetIndex = ModelGrid.modelWidgetIndex(nodeType); + const target = selectedNode.widgets[widgetIndex].element; + if (target && target.type === "textarea") { + target.value = ModelGrid.insertEmbeddingIntoText(target.value, embeddingFile, removeEmbeddingExtension); + success = true; + } + } + if (!success) { + console.warn("Try selecting a node before adding the embedding."); + } + event.stopPropagation(); + } + buttonAlert(event.target, success, "✔", "✖", "✚"); + } + + /** + * @param {Event} event + * @param {string} modelType + * @param {string} path + * @param {boolean} removeEmbeddingExtension + * @param {boolean} strictDragToAdd + */ + static #dragAddModel(event, modelType, path, removeEmbeddingExtension, strictDragToAdd) { + const target = document.elementFromPoint(event.x, event.y); + if (modelType !== "embeddings" && target.id === "graph-canvas") { + const nodeType = modelNodeType[modelType]; + const widgetIndex = ModelGrid.modelWidgetIndex(nodeType); + const pos = app.canvas.convertEventToCanvasOffset(event); + const nodeAtPos = app.graph.getNodeOnPos(pos[0], pos[1], app.canvas.visible_nodes); + + let draggedOnNode = nodeAtPos && nodeAtPos.type === nodeType; + if (strictDragToAdd) { + const draggedOnWidget = app.canvas.processNodeWidgets(nodeAtPos, pos, event) === nodeAtPos.widgets[widgetIndex]; + draggedOnNode = draggedOnNode && draggedOnWidget; + } + + if (draggedOnNode) { + let node = nodeAtPos; + node.widgets[widgetIndex].value = path; + app.canvas.selectNode(node); + } + else { + let node = LiteGraph.createNode(nodeType, null, []); + if (node) { + node.pos[0] = pos[0]; + node.pos[1] = pos[1]; + node.widgets[widgetIndex].value = path; + app.graph.add(node, {doProcessChange: true}); + app.canvas.selectNode(node); + } + } + event.stopPropagation(); + } + else if (modelType === "embeddings" && target.type === "textarea") { + const pos = app.canvas.convertEventToCanvasOffset(event); + const nodeAtPos = app.graph.getNodeOnPos(pos[0], pos[1], app.canvas.visible_nodes); + if (nodeAtPos) { + app.canvas.selectNode(nodeAtPos); + const [embeddingDirectory, embeddingFile] = SearchPath.split(path); + target.value = ModelGrid.insertEmbeddingIntoText(target.value, embeddingFile, removeEmbeddingExtension); + event.stopPropagation(); + } + } + } + + /** + * @param {Event} event + * @param {string} modelType + * @param {string} path + * @param {boolean} removeEmbeddingExtension + */ + static #copyModelToClipboard(event, modelType, path, removeEmbeddingExtension) { + const nodeType = modelNodeType[modelType]; + let success = false; + if (nodeType === "Embedding") { + if (navigator.clipboard){ + const [embeddingDirectory, embeddingFile] = SearchPath.split(path); + const embeddingText = ModelGrid.insertEmbeddingIntoText("", embeddingFile, removeEmbeddingExtension); + navigator.clipboard.writeText(embeddingText); + success = true; + } + else { + console.warn("Cannot copy the embedding to the system clipboard; Try dragging it instead."); + } + } + else if (nodeType) { + const node = LiteGraph.createNode(nodeType, null, []); + const widgetIndex = ModelGrid.modelWidgetIndex(nodeType); + node.widgets[widgetIndex].value = path; + app.canvas.copyToClipboard([node]); + success = true; + } + else { + console.warn(`Unable to copy unknown model type '${modelType}.`); + } + buttonAlert(event.target, success, "✔", "✖", "⧉︎"); + } + + /** + * @param {Array} models + * @param {string} modelType + * @param {Object. } settingsElements + * @param {String} searchSeparator + * @param {String} systemSeparator + * @param {(searchPath: string) => Promise } modelInfoCallback + * @returns {HTMLElement[]} + */ + 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; + const showAddButton = canShowButtons && settingsElements["model-show-add-button"].checked; + const showCopyButton = canShowButtons && settingsElements["model-show-copy-button"].checked; + const strictDragToAdd = settingsElements["model-add-drag-strict-on-field"].checked; + const addOffset = parseInt(settingsElements["model-add-offset"].value); + const showModelExtension = settingsElements["model-show-label-extensions"].checked; + const removeEmbeddingExtension = !settingsElements["model-add-embedding-extension"].checked; + if (models.length > 0) { + return models.map((item) => { + const previewInfo = item.preview; + const searchPath = item.path; + const path = SearchPath.systemPath(searchPath, searchSeparator, systemSeparator); + let buttons = []; + if (showAddButton) { + buttons.push( + $el("button.icon-button.model-button", { + type: "button", + textContent: "⧉︎", + onclick: (e) => ModelGrid.#copyModelToClipboard( + e, + modelType, + path, + removeEmbeddingExtension + ), + draggable: false, + }) + ); + } + if (showCopyButton) { + buttons.push( + $el("button.icon-button.model-button", { + type: "button", + textContent: "✚", + onclick: (e) => ModelGrid.#addModel( + e, + modelType, + path, + removeEmbeddingExtension, + addOffset + ), + draggable: false, + }) + ); + } + const dragAdd = (e) => ModelGrid.#dragAddModel( + e, + modelType, + path, + removeEmbeddingExtension, + strictDragToAdd + ); + return $el("div.item", {}, [ + $el("img.model-preview", { + src: imageUri(previewInfo?.path, previewInfo?.dateModified), + draggable: false, + }), + $el("div.model-preview-overlay", { + ondragend: (e) => dragAdd(e), + draggable: true, + }), + $el("div.model-preview-top-right", { + draggable: false, + }, + buttons + ), + $el("div.model-preview-top-left", { + draggable: false, + }, [ + $el("button.icon-button.model-button", { + type: "button", + textContent: "ⓘ", + onclick: async() => modelInfoCallback(searchPath), + draggable: false, + }), + ]), + $el("div.model-label", { + ondragend: (e) => dragAdd(e), + draggable: true, + }, [ + $el("p", [showModelExtension ? item.name : SearchPath.splitExtension(item.name)[0]]) + ]), + ]); + }); + } else { + return [$el("h2", ["No Models"])]; + } + } + + /** + * @param {HTMLDivElement} modelGrid + * @param {Object} models + * @param {HTMLSelectElement} modelSelect + * @param {Object.<{value: string}>} previousModelType + * @param {Object} settings + * @param {string} sortBy + * @param {boolean} reverseSort + * @param {Array} previousModelFilters + * @param {HTMLInputElement} modelFilter + * @param {String} searchSeparator + * @param {String} systemSeparator + * @param {(searchPath: string) => Promise } modelInfoCallback + */ + 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 + } + + if (modelType !== previousModelType.value) { + if (settings["model-persistent-search"].checked) { + previousModelFilters.splice(0, previousModelFilters.length); // TODO: make sure this actually worked! + } + else { + // cache previous filter text + previousModelFilters[previousModelType.value] = modelFilter.value; + // read cached filter text + modelFilter.value = previousModelFilters[modelType] ?? ""; + } + previousModelType.value = modelType; + } + + let modelTypeOptions = []; + for (const [key, value] of Object.entries(models)) { + const el = $el("option", [key]); + modelTypeOptions.push(el); + } + modelSelect.innerHTML = ""; + modelTypeOptions.forEach(option => modelSelect.add(option)); + modelSelect.value = modelType; + + const searchAppend = settings["model-search-always-append"].value; + const searchText = modelFilter.value + " " + searchAppend; + const modelList = ModelGrid.#filter(models[modelType], searchText); + ModelGrid.#sort(modelList, sortBy, reverseSort); + + modelGrid.innerHTML = ""; + const modelGridModels = ModelGrid.#generateInnerHtml( + modelList, + modelType, + settings, + searchSeparator, + systemSeparator, + modelInfoCallback, + ); + modelGrid.append.apply(modelGrid, modelGridModels); + } +} + +class ModelInfoView { + /** @type {HTMLDivElement} */ + element = null; + + elements = { + /** @type {HTMLDivElement} */ info: null, + /** @type {HTMLButtonElement} */ setPreviewButton: null, + }; + + /** @type {ImageSelect} */ + previewSelect = null; + + /** + * @param {DirectoryItem[]} modelDirectories - Should be unique for every radio group. + * @param {() => Promise } updateModels + * @param {string} searchSeparator + */ + constructor(modelDirectories, updateModels, searchSeparator) { + const moveDestinationInput = $el("input.search-text-area", { + name: "move directory", + autocomplete: "off", + placeholder: searchSeparator, + }); + + let searchDropdown = null; + searchDropdown = new DirectoryDropdown( + moveDestinationInput, + () => { + searchDropdown.update( + modelDirectories, + searchSeparator, + ); + }, + () => {}, + () => {}, + searchSeparator, + true, + ); + + const previewSelect = new ImageSelect("model-info-preview-model-FYUIKMNVB"); + this.previewSelect = previewSelect; + previewSelect.elements.previews.style.display = "flex"; + + const setPreviewButton = $el("button", { + $: (el) => (this.elements.setPreviewButton = el), + textContent: "Set as Preview", + onclick: async(e) => { + const confirmation = window.confirm("Change preview image PERMANENTLY?"); + let updatedPreview = false; + if (confirmation) { + e.target.disabled = true; + const container = this.elements.info; + const path = container.dataset.path; + const imageUrl = previewSelect.getImage(); + if (imageUrl === imageUri()) { + const encodedPath = encodeURIComponent(path); + updatedPreview = await request( + `/model-manager/preview/delete?path=${encodedPath}`, + { + method: "POST", + body: JSON.stringify({}), + } + ) + .then((result) => { + return result["success"]; + }) + .catch((err) => { + return false; + }); + } + else { + const formData = new FormData(); + formData.append("path", path); + const image = imageUrl[0] == "/" ? "" : imageUrl; + formData.append("image", image); + updatedPreview = await request( + `/model-manager/preview/set`, + { + method: "POST", + body: formData, + } + ) + .then((result) => { + return result["success"]; + }) + .catch((err) => { + return false; + }); + } + if (updatedPreview) { + updateModels(); + const previewSelect = this.previewSelect; + previewSelect.elements.defaultUrl.dataset.noimage = imageUri(); + previewSelect.resetModelInfoPreview(); + this.element.style.display = "none"; + } + + e.target.disabled = false; + } + buttonAlert(e.target, updatedPreview); + }, + }); + previewSelect.elements.radioButtons.addEventListener("change", (e) => { + setPreviewButton.style.display = previewSelect.defaultIsChecked() ? "none" : "block"; + }); + + this.element = $el("div.model-info-view", { + style: { display: "none" }, + }, [ + $el("div.row.tab-header", { + display: "block", + }, [ + $el("div.row.tab-header-flex-block", [ + $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.elements.info; + const path = encodeURIComponent(container.dataset.path); + deleted = await request( + `/model-manager/model/delete?path=${path}`, + { + method: "POST", + } + ) + .then((result) => { + const deleted = result["success"]; + if (deleted) + { + container.innerHTML = ""; + this.element.style.display = "none"; + updateModels(); + } + return deleted; + }) + .catch((err) => { + return false; + }); + } + if (!deleted) { + buttonAlert(e.target, false); + } + }, + }), + $el("div.search-models", [ + moveDestinationInput, + searchDropdown.element, + ]), + $el("button", { + textContent: "Move", + onclick: async(e) => { + const confirmation = window.confirm("Move this file?"); + let moved = false; + if (confirmation) { + const container = this.elements.info; + const oldFile = container.dataset.path; + const [oldFilePath, oldFileName] = SearchPath.split(oldFile); + const newFile = ( + moveDestinationInput.value + + searchSeparator + + oldFileName + ); + moved = await request( + `/model-manager/model/move`, + { + method: "POST", + body: JSON.stringify({ + "oldFile": oldFile, + "newFile": newFile, + }), + } + ) + .then((result) => { + const moved = result["success"]; + if (moved) + { + moveDestinationInput.value = ""; + container.innerHTML = ""; + this.element.style.display = "none"; + updateModels(); + } + return moved; + }) + .catch(err => { + return false; + }); + } + buttonAlert(e.target, moved); + }, + }), + ]), + ]), + $el("div.model-info-container", { + $: (el) => (this.elements.info = el), + "data-path": "", + }), + ]); + } + + /** @returns {boolean} */ + isVisible() { + return this.element.style.display === "none"; + } + + /** @returns {void} */ + show() { + this.element.removeAttribute("style"); + } + + /** @returns {void} */ + hide() { + this.element.style.display = "none"; + } + + /** + * @param {string} searchPath + * @param {() => Promise } updateModels + * @param {string} searchSeparator + */ + async update(searchPath, updateModels, searchSeparator) { + const path = encodeURIComponent(searchPath); + const info = await request(`/model-manager/model/info?path=${path}`) + .catch((err) => { + console.log(err); + return null; + }); + if (info === null) { + return; + } + const infoHtml = this.elements.info; + infoHtml.innerHTML = ""; + infoHtml.dataset.path = searchPath; + const innerHtml = []; + const filename = info["File Name"]; + if (filename !== undefined && filename !== null && filename !== "") { + innerHtml.push( + $el("div.row", { + style: { margin: "8px 0 16px 0" }, + }, [ + $el("h1", { + style: { margin: "0" }, + }, [ + filename, + ]), + $el("div", [ + $el("button.icon-button", { + textContent: "✎", + onclick: async(e) => { + const container = this.elements.info; + const oldFile = container.dataset.path; + const [oldFilePath, oldFileName] = SearchPath.split(oldFile); + const oldName = SearchPath.splitExtension(oldFileName)[0]; + const newName = window.prompt("New model name:", oldName); + let renamed = false; + if (newName !== null && newName !== "" && newName != oldName) { + const newFile = ( + oldFilePath + + searchSeparator + + newName + + SearchPath.splitExtension(oldFile)[1] + ); + renamed = await request( + `/model-manager/model/move`, + { + method: "POST", + body: JSON.stringify({ + "oldFile": oldFile, + "newFile": newFile, + }), + } + ) + .then((result) => { + const renamed = result["success"]; + if (renamed) + { + container.innerHTML = ""; + this.element.style.display = "none"; + updateModels(); + } + return renamed; + }) + .catch(err => { + console.log(err); + return false; + }); + } + buttonAlert(e.target, renamed); + }, + }), + ]), + ]), + ); + } + + const previewSelect = this.previewSelect; + const defaultUrl = previewSelect.elements.defaultUrl; + if (info["Preview"]) { + const imagePath = info["Preview"]["path"]; + const imageDateModified = info["Preview"]["dateModified"]; + defaultUrl.dataset.noimage = imageUri(imagePath, imageDateModified); + } + else { + defaultUrl.dataset.noimage = imageUri(); + } + previewSelect.resetModelInfoPreview(); + const setPreviewButton = this.elements.setPreviewButton; + setPreviewButton.style.display = previewSelect.defaultIsChecked() ? "none" : "block"; + + innerHtml.push($el("div", [ + previewSelect.elements.previews, + $el("div.row.tab-header", [ + $el("div.row.tab-header-flex-block", [ + previewSelect.elements.radioGroup, + ]), + $el("div.row.tab-header-flex-block", [ + setPreviewButton, + ]), + ]), + $el("h2", ["Details:"]), + $el("div", + (() => { + const elements = []; + for (const [key, value] of Object.entries(info)) { + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + if (value.length > 0) { + elements.push($el("h2", [key + ":"])); + + let text = " "; + for (let i = 0; i < value.length; i++) { + const v = value[i]; + const tag = v[0]; + const count = v[1]; + text += tag + " (" + count + ")"; + if (i !== value.length - 1) { + text += ", "; + } + } + text += "
"; + const div = $el("div"); + div.innerHTML = text; + elements.push(div); + } + } + else { + if (key === "Notes") { + elements.push($el("h2", [key + ":"])); + const noteArea = $el("textarea.comfy-multiline-input", { + name: "model notes", + value: value, + rows: 10, + }); + elements.push(noteArea); + elements.push($el("button", { + textContent: "Save Notes", + onclick: (e) => { + const saved = request( + "/model-manager/notes/save", + { + method: "POST", + body: JSON.stringify({ + "path": this.elements.info.dataset.path, + "notes": noteArea.value, + }), + } + ).then((result) => { + return result["success"]; + }) + .catch((err) => { + return false; + }); + buttonAlert(e.target, saved); + }, + })); + } + else if (key === "Description") { + if (value !== "") { + elements.push($el("h2", [key + ":"])); + elements.push($el("p", [value])); + } + } + else if (key === "Preview") { + // + } + else { + if (value !== "") { + elements.push($el("p", [key + ": " + value])); + } + } + } + } + return elements; + })(), + ), + ])); + infoHtml.append.apply(infoHtml, innerHtml); + // TODO: set default value of dropdown and value to model type? + } +} + +class Civitai { + /** + * Get model info from Civitai. + * + * @param {string} id - Model ID. + * @param {string} apiPath - Civitai request subdirectory. "models" for 'model' urls. "model-version" for 'api' urls. + * + * @returns {Promise