diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 5d47c21..0000000 --- a/.editorconfig +++ /dev/null @@ -1,12 +0,0 @@ -# EditorConfig is awesome: https://EditorConfig.org - -# top-most EditorConfig file -root = true - -[*] -indent_style = space -indent_size = 2 -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 828f300..c63672b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,21 +1,106 @@ -name: Publish to Comfy registry +name: Release and Publish to Comfy registry on: workflow_dispatch: push: branches: - main paths: - - "pyproject.toml" + - 'pyproject.toml' jobs: publish-node: - name: Publish Custom Node to registry + name: Release and Publish Custom Node to registry runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v4 - - name: Publish Custom Node - uses: Comfy-Org/publish-node-action@main + + - name: Get current version + id: current_version + run: | + echo "version=$(cat pyproject.toml | grep 'version =' | cut -d'=' -f2 | xargs)" >> $GITHUB_OUTPUT + + - name: Check if tag exists + id: check-tag + uses: actions/github-script@v7 with: - ## Add your own personal access token to your Github Repository secrets and reference it here. - personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} \ No newline at end of file + script: | + const tag = `v${{ steps.current_version.outputs.version }}`; + try { + await github.rest.repos.getReleaseByTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag + }); + return true + } catch (error) { + console.error(error) + return false + } + + - name: Assert tag v${{ steps.current_version.outputs.version }} is not exist + run: | + if [ ${{ steps.check-tag.outputs.result }} == true ]; then + echo "Tag exists, skipping release" + exit 1 + fi + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: 'pnpm' + + - name: Build and Package + run: | + pnpm install + pnpm run build + tar -czf dist.tar.gz py/ web/ __init__.py LICENSE pyproject.toml + + - name: Create release draft + uses: softprops/action-gh-release@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + files: | + dist.tar.gz + name: ${{ steps.current_version.outputs.version }} + tag_name: v${{ steps.current_version.outputs.version }} + draft: true + make_latest: true + + - name: Prepare publish custom node to registry + run: | + find . -maxdepth 1 ! -name '.' ! -name 'dist.tar.gz' ! -name '.git' -exec rm -rf {} + + tar -xzf dist.tar.gz + rm -rf dist.tar.gz + + # - name: Publish Custom Node + # uses: Comfy-Org/publish-node-action@main + # with: + # ## Add your own personal access token to your Github Repository secrets and reference it here. + # personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} + # + # Publish Custom Node + # Copy from Comfy-Org/publish-node-action@main + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install comfy-cli + shell: bash + run: | + pip install comfy-cli + + - name: Publish Node + shell: bash + run: | + comfy --skip-prompt --no-enable-telemetry env + comfy node publish --token ${{ secrets.REGISTRY_ACCESS_TOKEN }} diff --git a/.gitignore b/.gitignore index 542369d..2585b6a 100644 --- a/.gitignore +++ b/.gitignore @@ -188,3 +188,12 @@ Icon Network Trash Folder Temporary Items .apdisk + +# dependencies +node_modules/ + +# dist +web/ + +# config +config/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..5ee7abd --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm exec lint-staged diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0113523 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,13 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "singleQuote": true, + "trailingComma": "all", + "endOfLine": "lf", + "semi": false, + "plugins": [ + "prettier-plugin-organize-imports", + "prettier-plugin-tailwindcss" + ] +} \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index a253ce4..0000000 --- a/.prettierrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "printWidth": 80, - "tabWidth": 2, - "useTabs": false, - "singleQuote": true, - "trailingComma": "all" -} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index f2c3f14..b1a7621 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,20 +1,48 @@ { - "cSpell.words": [ - "apng", - "Civitai", - "ckpt", - "comfyui", - "FYUIKMNVB", - "gguf", - "gligen", - "jfif", - "locon", - "loras", - "noimage", - "onnx", - "rfilename", - "unet", - "upscaler" - ], - "editor.defaultFormatter": "esbenp.prettier-vscode" + "cSpell.words": [ + "tailwindcss", + "vnode", + "unref", + "civitai", + "huggingface", + "comfyui", + "ckpt", + "gligen", + "loras", + "safetensors", + "unet", + "controlnet", + "hypernetwork", + "hypernetworks", + "photomaker", + "upscaler", + "comfyorg", + "fullname", + "primevue", + "maximizable", + "inputgroup", + "inputgroupaddon", + "iconfield", + "inputicon", + "inputtext", + "overlaybadge", + "usetoast", + "toastservice", + "useconfirm", + "confirmationservice", + "confirmdialog", + "popupmenu", + "inplace", + "contentcontainer", + "itemlist", + "virtualscroller" + ], + "editor.defaultFormatter": "esbenp.prettier-vscode", + "files.associations": { + "*.css": "tailwindcss" + }, + "editor.quickSuggestions": { + "strings": "on" + }, + "css.lint.unknownAtRules": "ignore" } \ No newline at end of file diff --git a/README.md b/README.md index 41626f7..ee81c9c 100644 --- a/README.md +++ b/README.md @@ -4,64 +4,60 @@ Download, browse and delete models in ComfyUI. Designed to support desktop, mobile and multi-screen devices. -Model Manager Demo Screenshot +# Usage -Model Manager Demo Screenshot +```bash +cd /path/to/ComfyUI/custom_nodes +git clone https://github.com/hayden-fr/ComfyUI-Model-Manager.git +cd /path/to/ComfyUI/custom_nodes/ComfyUI-Model-Manager +npm install +npm run build +``` ## Features -### Node Graph +## Freely adjust size and position -Model Manager Demo Screenshot + + +### Support Node Graph + + - Drag a model thumbnail onto the graph to add a new node. - Drag a model thumbnail onto an existing node to set the input field. - If there are multiple valid possible fields, then the drag must be exact. - Drag an embedding thumbnail onto a text area, or highlight any number of nodes, to append it onto the end of the text. - Drag the preview image in a model's info view onto the graph to load the embedded workflow (if it exists). - -Model Manager Demo Screenshot - - Press the "copy" button to copy a model to ComfyUI's clipboard or copy the embedding to the system clipboard. (Copying the embedding to the system clipboard requires a secure http connection.) - Press the "add" button to add the model to the ComfyUI graph or append the embedding to one or more selected nodes. - Press the "load workflow" button to try and load a workflow embedded in a model's preview image. ### Download Tab -Model Manager Demo Screenshot + - View multiple models associated with a url. - Select a save directory and input a filename. - Optionally set a model's preview image. -- Optionally edit and save descriptions as a .txt note. (Default behavior can be set in the settings tab.) -- Add Civitai and HuggingFace API tokens in `server_settings.yaml`. +- Optionally edit and save descriptions as a .md note. +- Add Civitai and HuggingFace API tokens in ComfyUI's settings. + + ### Models Tab -Model Manager Demo Screenshot +Model Manager Demo Screenshot - Search in real-time for models using the search bar. -- Use advance keyword search by typing `"multiple words in quotes"` or a minus sign before to `-exclude` a word or phrase. -- Add `/` at the start of a search to view a dropdown list of subdirectories (for example, `/0/1.5/styles/clothing`). - - Any directory paths in ComfyUI's `extra_model_paths.yaml` or directories added in `ComfyUI/models/` will automatically be detected. -- Sort models by "Date Created", "Date Modified", "Name" and "File Size". +- Sort models by "Name", "File Size", "Date Created" and "Date Modified". ### Model Info View -Model Manager Demo Screenshot +Model Manager Demo Screenshot - View file info and metadata. - Rename, move or **permanently** remove a model and all of it's related files. -- Read, edit and save notes. (Saved as a `.txt` file beside the model). - - `Ctrl+s` or `⌘+S` to save a note when the textarea is in focus. - - Autosave can be enabled in settings. (Note: Once the model info view is closed, the undo history is lost.) +- Read, edit and save notes. (Saved as a `.md` file beside the model). - Change or remove a model's preview image. - View training tags and use the random tag generator to generate prompt ideas. (Inspired by the one in A1111.) - -### Settings Tab - -Model Manager Demo Screenshot - -- Settings are saved to `ui_settings.yaml`. -- Most settings should update immediately, but a few may require a page reload to take effect. -- Press the "Fix Extensions" button to correct all image file extensions in the model directories. (Note: This may take a minute or so to complete.) diff --git a/__init__.py b/__init__.py index f63cc65..87cbb38 100644 --- a/__init__.py +++ b/__init__.py @@ -1,1232 +1,201 @@ import os -import io -import pathlib -import shutil -from datetime import datetime -import sys -import copy -import importlib -import re -import base64 - -from aiohttp import web -import server -import urllib.parse -import urllib.request -import struct -import json -import requests -requests.packages.urllib3.disable_warnings() - -import comfy.utils import folder_paths - -comfyui_model_uri = folder_paths.models_dir - -extension_uri = os.path.dirname(__file__) - -config_loader_path = os.path.join(extension_uri, 'config_loader.py') -config_loader_spec = importlib.util.spec_from_file_location('config_loader', config_loader_path) -config_loader = importlib.util.module_from_spec(config_loader_spec) -config_loader_spec.loader.exec_module(config_loader) - -no_preview_image = os.path.join(extension_uri, "no-preview.png") -ui_settings_uri = os.path.join(extension_uri, "ui_settings.yaml") -server_settings_uri = os.path.join(extension_uri, "server_settings.yaml") - -fallback_model_extensions = set([".bin", ".ckpt", ".gguf", ".onnx", ".pt", ".pth", ".safetensors"]) # TODO: magic values -jpeg_format_names = ["JPG", "JPEG", "JFIF"] -image_extensions = ( - ".png", # order matters - ".webp", - ".jpeg", - ".jpg", - ".jfif", - ".gif", - ".apng", -) -stable_diffusion_webui_civitai_helper_image_extensions = ( - ".preview.png", # order matters - ".preview.webp", - ".preview.jpeg", - ".preview.jpg", - ".preview.jfif", - ".preview.gif", - ".preview.apng", -) -preview_extensions = ( # TODO: JavaScript does not know about this (x2 states) - image_extensions + # order matters - stable_diffusion_webui_civitai_helper_image_extensions -) -model_info_extension = ".txt" -#video_extensions = (".avi", ".mp4", ".webm") # TODO: Requires ffmpeg or cv2. Cache preview frame? - -def split_valid_ext(s, *arg_exts): - sl = s.lower() - for exts in arg_exts: - for ext in exts: - if sl.endswith(ext.lower()): - return (s[:-len(ext)], ext) - return (s, "") - -_folder_names_and_paths = None # dict[str, tuple[list[str], list[str]]] -def folder_paths_folder_names_and_paths(refresh = False): - global _folder_names_and_paths - if refresh or _folder_names_and_paths is None: - _folder_names_and_paths = {} - for item_name in os.listdir(comfyui_model_uri): - item_path = os.path.join(comfyui_model_uri, item_name) - if not os.path.isdir(item_path): - continue - if item_name == "configs": - continue - if item_name in folder_paths.folder_names_and_paths: - dir_paths, extensions = copy.deepcopy(folder_paths.folder_names_and_paths[item_name]) - else: - dir_paths = [item_path] - extensions = copy.deepcopy(fallback_model_extensions) - _folder_names_and_paths[item_name] = (dir_paths, extensions) - return _folder_names_and_paths - -def folder_paths_get_folder_paths(folder_name, refresh = False): # API function crashes querying unknown model folder - paths = folder_paths_folder_names_and_paths(refresh) - if folder_name in paths: - return paths[folder_name][0] - - maybe_path = os.path.join(comfyui_model_uri, folder_name) - if os.path.exists(maybe_path): - return [maybe_path] - return [] - -def folder_paths_get_supported_pt_extensions(folder_name, refresh = False): # Missing API function - paths = folder_paths_folder_names_and_paths(refresh) - if folder_name in paths: - return paths[folder_name][1] - model_extensions = copy.deepcopy(fallback_model_extensions) - return model_extensions +from .py import config +from .py import utils -def search_path_to_system_path(model_path): - sep = os.path.sep - model_path = os.path.normpath(model_path.replace("/", sep)) - model_path = model_path.lstrip(sep) +# Init config settings +config.extension_uri = os.path.dirname(__file__) +utils.resolve_model_base_paths() - isep1 = model_path.find(sep, 0) - if isep1 == -1 or isep1 == len(model_path): - return (None, None) +version = utils.get_current_version() +utils.download_web_distribution(version) - isep2 = model_path.find(sep, isep1 + 1) - if isep2 == -1 or isep2 - isep1 == 1: - isep2 = len(model_path) - model_path_type = model_path[0:isep1] - paths = folder_paths_get_folder_paths(model_path_type) - if len(paths) == 0: - return (None, None) +import logging +from aiohttp import web +import traceback +from .py import services - model_path_index = model_path[isep1 + 1:isep2] + +routes = config.routes + + +@routes.get("/model-manager/ws") +async def socket_handler(request): + """ + Handle websocket connection. + """ + ws = await services.connect_websocket(request) + return ws + + +@routes.get("/model-manager/base-folders") +async def get_model_paths(request): + """ + Returns the base folders for models. + """ + model_base_paths = config.model_base_paths + return web.json_response({"success": True, "data": model_base_paths}) + + +@routes.post("/model-manager/model") +async def create_model(request): + """ + Create a new model. + + request body: x-www-form-urlencoded + - type: model type. + - pathIndex: index of the model folders. + - fullname: filename that relative to the model folder. + - previewFile: preview file. + - description: description. + - downloadPlatform: download platform. + - downloadUrl: download url. + - hash: a JSON string containing the hash value of the downloaded model. + """ + post = await request.post() try: - model_path_index = int(model_path_index) - except: - return (None, None) - if model_path_index < 0 or model_path_index >= len(paths): - return (None, None) - - system_path = os.path.normpath( - paths[model_path_index] + - sep + - model_path[isep2:] - ) - - return (system_path, model_path_type) - - -def get_safetensor_header(path): - try: - header_bytes = comfy.utils.safetensors_header(path) - header_json = json.loads(header_bytes) - return header_json if header_json is not None else {} - except: - return {} - - -def end_swap_and_pop(x, i): - x[i], x[-1] = x[-1], x[i] - return x.pop(-1) - - -def model_type_to_dir_name(model_type): - if model_type == "checkpoint": return "checkpoints" - #elif model_type == "clip": return "clip" - #elif model_type == "clip_vision": return "clip_vision" - #elif model_type == "controlnet": return "controlnet" - elif model_type == "diffuser": return "diffusers" - elif model_type == "embedding": return "embeddings" - #elif model_type== "gligen": return "gligen" - elif model_type == "hypernetwork": return "hypernetworks" - elif model_type == "lora": return "loras" - #elif model_type == "style_models": return "style_models" - #elif model_type == "unet": return "unet" - elif model_type == "upscale_model": return "upscale_models" - #elif model_type == "vae": return "vae" - #elif model_type == "vae_approx": return "vae_approx" - else: return model_type - - -def ui_rules(): - Rule = config_loader.Rule - return [ - Rule("model-search-always-append", "", str), - Rule("model-default-browser-model-type", "checkpoints", str), - Rule("model-real-time-search", True, bool), - Rule("model-persistent-search", True, bool), - - Rule("model-preview-thumbnail-type", "AUTO", str), - Rule("model-preview-fallback-search-safetensors-thumbnail", False, bool), - Rule("model-preview-thumbnail-width", 240, int, 150, 480), - Rule("model-preview-thumbnail-height", 360, int, 185, 480), - Rule("model-show-label-extensions", False, bool), - Rule("model-show-add-button", True, bool), - Rule("model-show-copy-button", True, bool), - Rule("model-show-load-workflow-button", True, bool), - Rule("model-info-button-on-left", False, bool), - Rule("model-buttons-only-on-hover", False, bool), - - Rule("model-add-embedding-extension", False, bool), - Rule("model-add-drag-strict-on-field", False, bool), - Rule("model-add-offset", 25, int), - - Rule("model-info-autosave-notes", False, bool), - - Rule("download-save-description-as-text-file", True, bool), - - Rule("sidebar-control-always-compact", False, bool), - Rule("sidebar-default-width", 0.5, float, 0.0, 1.0), - Rule("sidebar-default-height", 0.5, float, 0.0, 1.0), - Rule("sidebar-default-state", "None", str), - Rule("text-input-always-hide-search-button", False, bool), - Rule("text-input-always-hide-clear-button", False, bool), - - Rule("tag-generator-sampler-method", "Frequency", str), - Rule("tag-generator-count", 10, int), - Rule("tag-generator-threshold", 2, int), - ] - - -def server_rules(): - Rule = config_loader.Rule - return [ - #Rule("model_extension_download_whitelist", [".safetensors"], list), - Rule("civitai_api_key", "", str), - Rule("huggingface_api_key", "", str), - ] -server_settings = config_loader.yaml_load(server_settings_uri, server_rules()) -config_loader.yaml_save(server_settings_uri, server_rules(), server_settings) - - -def get_def_headers(url=""): - 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}" - - return def_headers - - -@server.PromptServer.instance.routes.get("/model-manager/timestamp") -async def get_timestamp(request): - return web.json_response({ "timestamp": datetime.now().timestamp() }) - - -@server.PromptServer.instance.routes.get("/model-manager/settings/load") -async def load_ui_settings(request): - rules = ui_rules() - settings = config_loader.yaml_load(ui_settings_uri, rules) - return web.json_response({ "settings": settings }) - - -@server.PromptServer.instance.routes.post("/model-manager/settings/save") -async def save_ui_settings(request): - body = await request.json() - settings = body.get("settings") - rules = ui_rules() - validated_settings = config_loader.validated(rules, settings) - success = config_loader.yaml_save(ui_settings_uri, rules, validated_settings) - print("Saved file: " + ui_settings_uri) - return web.json_response({ - "success": success, - "settings": validated_settings if success else "", - }) - - -from PIL import Image, TiffImagePlugin -from PIL.PngImagePlugin import PngInfo -def PIL_cast_serializable(v): - # source: https://github.com/python-pillow/Pillow/issues/6199#issuecomment-1214854558 - if isinstance(v, TiffImagePlugin.IFDRational): - return float(v) - elif isinstance(v, tuple): - return tuple(PIL_cast_serializable(t) for t in v) - elif isinstance(v, bytes): - return v.decode(errors="replace") - elif isinstance(v, dict): - for kk, vv in v.items(): - v[kk] = PIL_cast_serializable(vv) - return v - else: - return v - - -def get_safetensors_image_bytes(path): - if not os.path.isfile(path): - raise RuntimeError("Path was invalid!") - header = get_safetensor_header(path) - metadata = header.get("__metadata__", None) - if metadata is None: - return None - thumbnail = metadata.get("modelspec.thumbnail", None) - if thumbnail is None: - return None - image_data = thumbnail.split(',')[1] - return base64.b64decode(image_data) - - -def get_image_info(image): - metadata = None - if len(image.info) > 0: - metadata = PngInfo() - for (key, value) in image.info.items(): - value_str = str(PIL_cast_serializable(value)) # not sure if this is correct (sometimes includes exif) - metadata.add_text(key, value_str) - return metadata - - -def image_format_is_equal(f1, f2): - if not isinstance(f1, str) or not isinstance(f2, str): - return False - if f1[0] == ".": f1 = f1[1:] - if f2[0] == ".": f2 = f2[1:] - f1 = f1.upper() - f2 = f2.upper() - return f1 == f2 or (f1 in jpeg_format_names and f2 in jpeg_format_names) - - -def get_auto_thumbnail_format(original_format): - if original_format in ["JPEG", "WEBP", "JPG"]: # JFIF? - return original_format - return "JPEG" # default fallback - - -@server.PromptServer.instance.routes.get("/model-manager/preview/get") -async def get_model_preview(request): - uri = request.query.get("uri") - quality = 75 - response_image_format = request.query.get("image-format", None) - if isinstance(response_image_format, str): - response_image_format = response_image_format.upper() - - image_path = no_preview_image - file_name = os.path.split(no_preview_image)[1] - if uri != "no-preview": - sep = os.path.sep - uri = uri.replace("/" if sep == "\\" else "/", sep) - path, _ = search_path_to_system_path(uri) - head, extension = split_valid_ext(path, preview_extensions) - if os.path.exists(path): - image_path = path - file_name = os.path.split(head)[1] + extension - elif os.path.exists(head) and head.endswith(".safetensors"): - image_path = head - file_name = os.path.splitext(os.path.split(head)[1])[0] + extension - - w = request.query.get("width") - h = request.query.get("height") - try: - w = int(w) - if w < 1: - w = None - except: - w = None - try: - h = int(h) - if w < 1: - h = None - except: - h = None - - image_data = None - if w is None and h is None: # full size - if image_path.endswith(".safetensors"): - image_data = get_safetensors_image_bytes(image_path) - else: - with open(image_path, "rb") as image: - image_data = image.read() - fp = io.BytesIO(image_data) - with Image.open(fp) as image: - image_format = image.format - if response_image_format is None: - response_image_format = image_format - elif response_image_format == "AUTO": - response_image_format = get_auto_thumbnail_format(image_format) - - if not image_format_is_equal(response_image_format, image_format): - exif = image.getexif() - metadata = get_image_info(image) - if response_image_format in jpeg_format_names: - image = image.convert('RGB') - image_bytes = io.BytesIO() - image.save(image_bytes, format=response_image_format, exif=exif, pnginfo=metadata, quality=quality) - image_data = image_bytes.getvalue() - else: - if image_path.endswith(".safetensors"): - image_data = get_safetensors_image_bytes(image_path) - fp = io.BytesIO(image_data) - else: - fp = image_path - - with Image.open(fp) as image: - image_format = image.format - if response_image_format is None: - response_image_format = image_format - elif response_image_format == "AUTO": - response_image_format = get_auto_thumbnail_format(image_format) - - w0, h0 = image.size - if w is None: - w = (h * w0) // h0 - elif h is None: - h = (w * h0) // w0 - - exif = image.getexif() - metadata = get_image_info(image) - - ratio_original = w0 / h0 - ratio_thumbnail = w / h - if abs(ratio_original - ratio_thumbnail) < 0.01: - crop_box = (0, 0, w0, h0) - elif ratio_original > ratio_thumbnail: - crop_width_fp = h0 * w / h - x0 = int((w0 - crop_width_fp) / 2) - crop_box = (x0, 0, x0 + int(crop_width_fp), h0) - else: - crop_height_fp = w0 * h / w - y0 = int((h0 - crop_height_fp) / 2) - crop_box = (0, y0, w0, y0 + int(crop_height_fp)) - image = image.crop(crop_box) - - if w < w0 and h < h0: - resampling_method = Image.Resampling.BOX - else: - resampling_method = Image.Resampling.BICUBIC - image.thumbnail((w, h), resample=resampling_method) - - if not image_format_is_equal(image_format, response_image_format) and response_image_format in jpeg_format_names: - image = image.convert('RGB') - image_bytes = io.BytesIO() - image.save(image_bytes, format=response_image_format, exif=exif, pnginfo=metadata, quality=quality) - image_data = image_bytes.getvalue() - - response_file_name = os.path.splitext(file_name)[0] + '.' + response_image_format.lower() - return web.Response( - headers={ - "Content-Disposition": f"inline; filename={response_file_name}", - }, - body=image_data, - content_type="image/" + response_image_format.lower(), - ) - - -@server.PromptServer.instance.routes.get("/model-manager/image/extensions") -async def get_image_extensions(request): - return web.json_response(image_extensions) - - -def download_model_preview(formdata): - path = formdata.get("path", None) - if type(path) is not str: - raise ValueError("Invalid path!") - path, model_type = search_path_to_system_path(path) - model_type_extensions = folder_paths_get_supported_pt_extensions(model_type) - path_without_extension, _ = split_valid_ext(path, model_type_extensions) - - overwrite = formdata.get("overwrite", "true").lower() - overwrite = True if overwrite == "true" else False - - image = formdata.get("image", None) - if type(image) is str: - civitai_image_url = "https://civitai.com/images/" - if image.startswith(civitai_image_url): - image_id = re.search(r"^\d+", image[len(civitai_image_url):]).group(0) - image_id = str(int(image_id)) - image_info_url = f"https://civitai.com/api/v1/images?imageId={image_id}" - def_headers = get_def_headers(image_info_url) - response = requests.get( - url=image_info_url, - stream=False, - verify=False, - headers=def_headers, - proxies=None, - allow_redirects=False, - ) - if response.ok: - content_type = response.headers.get("Content-Type") - info = response.json() - items = info["items"] - if len(items) == 0: - raise RuntimeError("Civitai /api/v1/images returned 0 items!") - image = items[0]["url"] - else: - raise RuntimeError("Bad response from api/v1/images!") - _, image_extension = split_valid_ext(image, image_extensions) - if image_extension == "": - raise ValueError("Invalid image type!") - image_path = path_without_extension + image_extension - download_file(image, image_path, overwrite) - else: - content_type = image.content_type - if not content_type.startswith("image/"): - raise RuntimeError("Invalid content type!") - image_extension = "." + content_type[len("image/"):] - if image_extension not in image_extensions: - raise RuntimeError("Invalid extension!") - - image_path = path_without_extension + image_extension - if not overwrite and os.path.isfile(image_path): - raise RuntimeError("Image already exists!") - file: io.IOBase = image.file - image_data = file.read() - with open(image_path, "wb") as f: - f.write(image_data) - print("Saved file: " + image_path) - - if overwrite: - delete_same_name_files(path_without_extension, preview_extensions, image_extension) - - # detect (and try to fix) wrong file extension - image_format = None - with Image.open(image_path) as image: - image_format = image.format - image_dir_and_name, image_ext = os.path.splitext(image_path) - if not image_format_is_equal(image_format, image_ext): - corrected_image_path = image_dir_and_name + "." + image_format.lower() - if os.path.exists(corrected_image_path) and not overwrite: - print("WARNING: '" + image_path + "' has wrong extension!") - else: - os.rename(image_path, corrected_image_path) - print("Saved file: " + corrected_image_path) - image_path = corrected_image_path - return image_path # return in-case need corrected path - - -@server.PromptServer.instance.routes.post("/model-manager/preview/set") -async def set_model_preview(request): - formdata = await request.post() - try: - download_model_preview(formdata) - return web.json_response({ "success": True }) - except ValueError as e: - print(e, file=sys.stderr, flush=True) - return web.json_response({ - "success": False, - "alert": "Failed to set preview!\n\n" + str(e), - }) - - -@server.PromptServer.instance.routes.post("/model-manager/preview/delete") -async def delete_model_preview(request): - result = { "success": False } - - model_path = request.query.get("path", None) - if model_path is None: - result["alert"] = "Missing model path!" - return web.json_response(result) - model_path = urllib.parse.unquote(model_path) - - model_path, model_type = search_path_to_system_path(model_path) - model_extensions = folder_paths_get_supported_pt_extensions(model_type) - path_and_name, _ = split_valid_ext(model_path, model_extensions) - delete_same_name_files(path_and_name, preview_extensions) - - result["success"] = True - return web.json_response(result) - - -def correct_image_extensions(root_dir): - detected_image_count = 0 - corrected_image_count = 0 - for root, dirs, files in os.walk(root_dir): - for file_name in files: - file_path = root + os.path.sep + file_name - image_format = None - try: - with Image.open(file_path) as image: - image_format = image.format - except: - continue - image_path = file_path - image_dir_and_name, image_ext = os.path.splitext(image_path) - if not image_format_is_equal(image_format, image_ext): - detected_image_count += 1 - corrected_image_path = image_dir_and_name + "." + image_format.lower() - if os.path.exists(corrected_image_path): - print("WARNING: '" + image_path + "' has wrong extension!") - else: - try: - os.rename(image_path, corrected_image_path) - except: - print("WARNING: Unable to rename '" + image_path + "'!") - continue - ext0 = os.path.splitext(image_path)[1] - ext1 = os.path.splitext(corrected_image_path)[1] - print(f"({ext0} -> {ext1}): {corrected_image_path}") - corrected_image_count += 1 - return (detected_image_count, corrected_image_count) - - -@server.PromptServer.instance.routes.get("/model-manager/preview/correct-extensions") -async def correct_preview_extensions(request): - result = { "success": False } - - detected = 0 - corrected = 0 - - model_types = os.listdir(comfyui_model_uri) - model_types.remove("configs") - model_types.sort() - - for model_type in model_types: - for base_path_index, model_base_path in enumerate(folder_paths_get_folder_paths(model_type)): - if not os.path.exists(model_base_path): # TODO: Bug in main code? ("ComfyUI\output\checkpoints", "ComfyUI\output\clip", "ComfyUI\models\t2i_adapter", "ComfyUI\output\vae") - continue - d, c = correct_image_extensions(model_base_path) - detected += d - corrected += c - - result["success"] = True - result["detected"] = detected - result["corrected"] = corrected - return web.json_response(result) - - -@server.PromptServer.instance.routes.get("/model-manager/models/list") -async def get_model_list(request): - use_safetensor_thumbnail = ( - config_loader.yaml_load(ui_settings_uri, ui_rules()) - .get("model-preview-fallback-search-safetensors-thumbnail", False) - ) - - model_types = os.listdir(comfyui_model_uri) - model_types.remove("configs") - model_types.sort() - - models = {} - for model_type in model_types: - model_extensions = tuple(folder_paths_get_supported_pt_extensions(model_type)) - file_infos = [] - for base_path_index, model_base_path in enumerate(folder_paths_get_folder_paths(model_type)): - if not os.path.exists(model_base_path): # TODO: Bug in main code? ("ComfyUI\output\checkpoints", "ComfyUI\output\clip", "ComfyUI\models\t2i_adapter", "ComfyUI\output\vae") - continue - for cwd, subdirs, files in os.walk(model_base_path): - dir_models = [] - dir_images = [] - - for file in files: - if file.lower().endswith(model_extensions): - dir_models.append(file) - elif file.lower().endswith(preview_extensions): - dir_images.append(file) - - for model in dir_models: - model_name, model_ext = split_valid_ext(model, model_extensions) - image = None - image_modified = None - for ext in preview_extensions: # order matters - for iImage in range(len(dir_images)-1, -1, -1): - image_name = dir_images[iImage] - if not image_name.lower().endswith(ext.lower()): - continue - image_name = image_name[:-len(ext)] - if model_name == image_name: - image = end_swap_and_pop(dir_images, iImage) - img_abs_path = os.path.join(cwd, image) - image_modified = pathlib.Path(img_abs_path).stat().st_mtime_ns - break - if image is not None: - break - abs_path = os.path.join(cwd, model) - stats = pathlib.Path(abs_path).stat() - sizeBytes = stats.st_size - model_modified = stats.st_mtime_ns - model_created = stats.st_ctime_ns - if use_safetensor_thumbnail and image is None and model_ext == ".safetensors": - # try to fallback on safetensor embedded thumbnail - header = get_safetensor_header(abs_path) - metadata = header.get("__metadata__", None) - if metadata is not None: - thumbnail = metadata.get("modelspec.thumbnail", None) - if thumbnail is not None: - i0 = thumbnail.find("/") + 1 - i1 = thumbnail.find(";") - image_ext = "." + thumbnail[i0:i1] - if image_ext in image_extensions: - image = model + image_ext - image_modified = model_modified - rel_path = "" if cwd == model_base_path else os.path.relpath(cwd, model_base_path) - info = ( - model, - image, - base_path_index, - rel_path, - model_modified, - model_created, - image_modified, - sizeBytes, - ) - file_infos.append(info) - #file_infos.sort(key=lambda tup: tup[4], reverse=True) # TODO: remove sort; sorted on client - - model_items = [] - for model, image, base_path_index, rel_path, model_modified, model_created, image_modified, sizeBytes in file_infos: - item = { - "name": model, - "path": "/" + os.path.join(model_type, str(base_path_index), rel_path, model).replace(os.path.sep, "/"), # relative logical path - #"systemPath": os.path.join(rel_path, model), # relative system path (less information than "search path") - "dateModified": model_modified, - "dateCreated": model_created, - #"dateLastUsed": "", # TODO: track server-side, send increment client-side - #"countUsed": 0, # TODO: track server-side, send increment client-side - "sizeBytes": sizeBytes, - } - if image is not None: - raw_post = os.path.join(model_type, str(base_path_index), rel_path, image) - item["preview"] = { - "path": urllib.parse.quote_plus(raw_post), - "dateModified": urllib.parse.quote_plus(str(image_modified)), - } - model_items.append(item) - - models[model_type] = model_items - - return web.json_response(models) - - -def linear_directory_hierarchy(refresh = False): - model_paths = folder_paths_folder_names_and_paths(refresh) - dir_list = [] - dir_list.append({ "name": "", "childIndex": 1, "childCount": len(model_paths) }) - for model_dir_name, (model_dirs, _) in model_paths.items(): - dir_list.append({ "name": model_dir_name, "childIndex": None, "childCount": len(model_dirs) }) - for model_dir_index, (_, (model_dirs, extension_whitelist)) in enumerate(model_paths.items()): - model_dir_child_index = len(dir_list) - dir_list[model_dir_index + 1]["childIndex"] = model_dir_child_index - for dir_path_index, dir_path in enumerate(model_dirs): - dir_list.append({ "name": str(dir_path_index), "childIndex": None, "childCount": None }) - for dir_path_index, dir_path in enumerate(model_dirs): - if not os.path.exists(dir_path) or os.path.isfile(dir_path): - continue - - #dir_list.append({ "name": str(dir_path_index), "childIndex": None, "childCount": 0 }) - dir_stack = [(dir_path, model_dir_child_index + dir_path_index)] - while len(dir_stack) > 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 - if extension_whitelist is None or split_valid_ext(item_name, extension_whitelist)[1] != "": - 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 - - -@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_file(url, filename, overwrite): - if not overwrite and os.path.isfile(filename): - raise ValueError("File already exists!") - - filename_temp = filename + ".download" - - def_headers = get_def_headers(url) - 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 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"] - headers["Authorization"] = def_headers.get("Authorization", None) - - 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 - - total_size = int(rh.headers.get("Content-Length", 0)) # TODO: pass in total size earlier - - print("Downloading file: " + url) - 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 is not None: - downloaded_size += len(chunk) - f.write(chunk) - f.flush() - - 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() - print() - - if overwrite and os.path.isfile(filename): - os.remove(filename) - os.rename(filename_temp, filename) - print("Saved file: " + filename) - - -def bytes_to_size(total_bytes): - units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] - b = total_bytes - i = 0 - while True: - b = b >> 10 - if (b == 0): break - i = i + 1 - if i >= len(units) or i == 0: - return str(total_bytes) + " " + units[0] - return "{:.2f}".format(total_bytes / (1 << (i * 10))) + " " + units[i] - - -@server.PromptServer.instance.routes.get("/model-manager/model/info") -async def get_model_info(request): - result = { "success": False } - - model_path = request.query.get("path", None) - if model_path is None: - result["alert"] = "Missing model path!" - return web.json_response(result) - model_path = urllib.parse.unquote(model_path) - - abs_path, model_type = search_path_to_system_path(model_path) - if abs_path is None: - result["alert"] = "Invalid model path!" - return web.json_response(result) - - info = {} - comfyui_directory, name = os.path.split(model_path) - info["File Name"] = name - info["File Directory"] = comfyui_directory - info["File Size"] = bytes_to_size(os.path.getsize(abs_path)) - stats = pathlib.Path(abs_path).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) - - model_extensions = folder_paths_get_supported_pt_extensions(model_type) - abs_name , _ = split_valid_ext(abs_path, model_extensions) - - for extension in preview_extensions: - maybe_preview = abs_name + extension - if os.path.isfile(maybe_preview): - preview_path, _ = split_valid_ext(model_path, model_extensions) - preview_modified = pathlib.Path(maybe_preview).stat().st_mtime_ns - info["Preview"] = { - "path": urllib.parse.quote_plus(preview_path + extension), - "dateModified": urllib.parse.quote_plus(str(preview_modified)), - } - break - - header = get_safetensor_header(abs_path) - metadata = header.get("__metadata__", None) - - 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: - info["Base Training Model"] = metadata.get("ss_sd_model_name", "") - info["Base Model Version"] = metadata.get("ss_base_model_version", "") - info["Network Dimension"] = metadata.get("ss_network_dim", "") - info["Network Alpha"] = metadata.get("ss_network_alpha", "") - - 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() - - info_text_file = abs_name + model_info_extension - notes = "" - if os.path.isfile(info_text_file): - with open(info_text_file, 'r', encoding="utf-8") as f: - notes = f.read() - - if metadata is not None: - img_buckets = metadata.get("ss_bucket_info", None) - datasets = metadata.get("ss_datasets", None) - - if type(img_buckets) is str: - img_buckets = json.loads(img_buckets) - elif type(datasets) is str: - datasets = json.loads(datasets) - if isinstance(datasets, list): - datasets = datasets[0] - img_buckets = datasets.get("bucket_info", None) - 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 - - tags = None - if metadata is not None: - 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) - - result["success"] = True - result["info"] = info - if metadata is not None: - result["metadata"] = metadata - if tags is not None: - result["tags"] = tags - result["notes"] = notes - return web.json_response(result) - - -@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 } - - 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["alert"] = "Invalid save path!" - return web.json_response(result) - - download_uri = formdata.get("download") - if download_uri is None: - result["alert"] = "Invalid download url!" - return web.json_response(result) - - name = formdata.get("name") - model_extensions = folder_paths_get_supported_pt_extensions(model_type) - name_head, model_extension = split_valid_ext(name, model_extensions) - name_without_extension = os.path.split(name_head)[1] - if name_without_extension == "": - result["alert"] = "Cannot have empty model name!" - return web.json_response(result) - if model_extension == "": - result["alert"] = "Unrecognized model extension!" - return web.json_response(result) - file_name = os.path.join(directory, name) - try: - download_file(download_uri, file_name, overwrite) + task_id = await services.create_model_download_task(post) + return web.json_response({"success": True, "data": {"taskId": task_id}}) except Exception as e: - print(e, file=sys.stderr, flush=True) - result["alert"] = "Failed to download model!\n\n" + str(e) - 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["alert"] = "Failed to download preview!\n\n" + str(e) - - result["success"] = True - return web.json_response(result) + error_msg = f"Create model download task failed: {str(e)}" + logging.error(error_msg) + logging.debug(traceback.format_exc()) + return web.json_response({"success": False, "error": error_msg}) -@server.PromptServer.instance.routes.post("/model-manager/model/move") -async def move_model(request): - body = await request.json() - result = { "success": False } - - old_file = body.get("oldFile", None) - if old_file is None: - result["alert"] = "No model was given!" - return web.json_response(result) - old_file, old_model_type = search_path_to_system_path(old_file) - if not os.path.isfile(old_file): - result["alert"] = "Model does not exist!" - return web.json_response(result) - old_model_extensions = folder_paths_get_supported_pt_extensions(old_model_type) - old_file_without_extension, model_extension = split_valid_ext(old_file, old_model_extensions) - if model_extension == "": - result["alert"] = "Invalid model extension!" - return web.json_response(result) - - new_file = body.get("newFile", None) - if new_file is None or new_file == "": - result["alert"] = "New model name was invalid!" - return web.json_response(result) - new_file, new_model_type = search_path_to_system_path(new_file) - if not new_file.endswith(model_extension): - result["alert"] = "Cannot change model extension!" - return web.json_response(result) - if os.path.isfile(new_file): - result["alert"] = "Cannot overwrite existing model!" - return web.json_response(result) - new_model_extensions = folder_paths_get_supported_pt_extensions(new_model_type) - new_file_without_extension, new_model_extension = split_valid_ext(new_file, new_model_extensions) - if model_extension != new_model_extension: - result["alert"] = "Cannot change model extension!" - return web.json_response(result) - new_file_dir, new_file_name = os.path.split(new_file) - if not os.path.isdir(new_file_dir): - result["alert"] = "Destination directory does not exist!" - return web.json_response(result) - new_name_without_extension = os.path.splitext(new_file_name)[0] - if new_file_name == new_name_without_extension or new_name_without_extension == "": - result["alert"] = "New model name was empty!" - return web.json_response(result) - - if old_file == new_file: - # no-op - result["success"] = True - return web.json_response(result) +@routes.get("/model-manager/models") +async def read_models(request): + """ + Scan all models and read their information. + """ try: - shutil.move(old_file, new_file) - print("Moved file: " + new_file) - except ValueError as e: - print(e, file=sys.stderr, flush=True) - result["alert"] = "Failed to move model!\n\n" + str(e) - return web.json_response(result) - - # TODO: this could overwrite existing files in destination; do a check beforehand? - for extension in preview_extensions + (model_info_extension,): - old_file = old_file_without_extension + extension - if os.path.isfile(old_file): - new_file = new_file_without_extension + extension - try: - shutil.move(old_file, new_file) - print("Moved file: " + new_file) - except ValueError as e: - print(e, file=sys.stderr, flush=True) - msg = result.get("alert","") - if msg == "": - result["alert"] = "Failed to move model resource file!\n\n" + str(e) - else: - result["alert"] = msg + "\n" + str(e) - - result["success"] = True - return web.json_response(result) + result = services.scan_models() + return web.json_response({"success": True, "data": result}) + except Exception as e: + error_msg = f"Read models failed: {str(e)}" + logging.error(error_msg) + logging.debug(traceback.format_exc()) + return web.json_response({"success": False, "error": error_msg}) -def delete_same_name_files(path_without_extension, extensions, keep_extension=None): - for extension in extensions: - if extension == keep_extension: continue - file = path_without_extension + extension - if os.path.isfile(file): - os.remove(file) - print("Deleted file: " + file) +@routes.get("/model-manager/model/{type}/{index}/{filename:.*}") +async def read_model_info(request): + """ + Get the information of the specified model. + """ + model_type = request.match_info.get("type", None) + index = int(request.match_info.get("index", None)) + filename = request.match_info.get("filename", None) + + try: + model_path = utils.get_valid_full_path(model_type, index, filename) + result = services.get_model_info(model_path) + return web.json_response({"success": True, "data": result}) + except Exception as e: + error_msg = f"Read model info failed: {str(e)}" + logging.error(error_msg) + logging.debug(traceback.format_exc()) + return web.json_response({"success": False, "error": error_msg}) -@server.PromptServer.instance.routes.post("/model-manager/model/delete") +@routes.put("/model-manager/model/{type}/{index}/{filename:.*}") +async def update_model(request): + """ + Update model information. + + request body: x-www-form-urlencoded + - previewFile: preview file. + - description: description. + - type: model type. + - pathIndex: index of the model folders. + - fullname: filename that relative to the model folder. + All fields are optional, but type, pathIndex and fullname must appear together. + """ + model_type = request.match_info.get("type", None) + index = int(request.match_info.get("index", None)) + filename = request.match_info.get("filename", None) + + post: dict = await request.post() + + try: + model_path = utils.get_valid_full_path(model_type, index, filename) + if model_path is None: + raise RuntimeError(f"File {filename} not found") + services.update_model(model_path, post) + return web.json_response({"success": True}) + except Exception as e: + error_msg = f"Update model failed: {str(e)}" + logging.error(error_msg) + logging.debug(traceback.format_exc()) + return web.json_response({"success": False, "error": error_msg}) + + +@routes.delete("/model-manager/model/{type}/{index}/{filename:.*}") async def delete_model(request): - result = { "success": False } + """ + Delete model. + """ + model_type = request.match_info.get("type", None) + index = int(request.match_info.get("index", None)) + filename = request.match_info.get("filename", None) - model_path = request.query.get("path", None) - if model_path is None: - result["alert"] = "Missing model path!" - return web.json_response(result) - model_path = urllib.parse.unquote(model_path) - model_path, model_type = search_path_to_system_path(model_path) - if model_path is None: - result["alert"] = "Invalid model path!" - return web.json_response(result) - - model_extensions = folder_paths_get_supported_pt_extensions(model_type) - path_and_name, model_extension = split_valid_ext(model_path, model_extensions) - if model_extension == "": - result["alert"] = "Cannot delete file!" - return web.json_response(result) - - if os.path.isfile(model_path): - os.remove(model_path) - result["success"] = True - print("Deleted file: " + model_path) - - delete_same_name_files(path_and_name, preview_extensions) - delete_same_name_files(path_and_name, (model_info_extension,)) - - return web.json_response(result) + try: + model_path = utils.get_valid_full_path(model_type, index, filename) + if model_path is None: + raise RuntimeError(f"File {filename} not found") + services.remove_model(model_path) + return web.json_response({"success": True}) + except Exception as e: + error_msg = f"Delete model failed: {str(e)}" + logging.error(error_msg) + logging.debug(traceback.format_exc()) + return web.json_response({"success": False, "error": error_msg}) -@server.PromptServer.instance.routes.post("/model-manager/notes/save") -async def set_notes(request): - body = await request.json() - result = { "success": False } +@routes.get("/model-manager/preview/{type}/{index}/{filename:.*}") +async def read_model_preview(request): + """ + Get the file stream of the specified image. + If the file does not exist, no-preview.png is returned. - dt_epoch = body.get("timestamp", None) + :param type: The type of the model. eg.checkpoints, loras, vae, etc. + :param index: The index of the model folders. + :param filename: The filename of the image. + """ + model_type = request.match_info.get("type", None) + index = int(request.match_info.get("index", None)) + filename = request.match_info.get("filename", None) - text = body.get("notes", None) - if type(text) is not str: - result["alert"] = "Invalid note!" - return web.json_response(result) + extension_uri = config.extension_uri - model_path = body.get("path", None) - if type(model_path) is not str: - result["alert"] = "Missing model path!" - return web.json_response(result) - model_path, model_type = search_path_to_system_path(model_path) - model_extensions = folder_paths_get_supported_pt_extensions(model_type) - file_path_without_extension, _ = split_valid_ext(model_path, model_extensions) - filename = os.path.normpath(file_path_without_extension + model_info_extension) - - if dt_epoch is not None and os.path.exists(filename) and os.path.getmtime(filename) > dt_epoch: - # discard late save - result["success"] = True - return web.json_response(result) - - if text.isspace() or text == "": - if os.path.exists(filename): - os.remove(filename) - #print("Deleted file: " + filename) # autosave -> too verbose - else: - try: - with open(filename, "w", encoding="utf-8") as f: - f.write(text) - if dt_epoch is not None: - os.utime(filename, (dt_epoch, dt_epoch)) - #print("Saved file: " + filename) # autosave -> too verbose - except ValueError as e: - print(e, file=sys.stderr, flush=True) - result["alert"] = "Failed to save notes!\n\n" + str(e) - web.json_response(result) + try: + folders = folder_paths.get_folder_paths(model_type) + base_path = folders[index] + abs_path = os.path.join(base_path, filename) + except: + abs_path = extension_uri - result["success"] = True - return web.json_response(result) + if not os.path.isfile(abs_path): + abs_path = os.path.join(extension_uri, "assets", "no-preview.png") + return web.FileResponse(abs_path) + + +@routes.get("/model-manager/preview/download/{filename}") +async def read_download_preview(request): + filename = request.match_info.get("filename", None) + extension_uri = config.extension_uri + + download_path = utils.get_download_path() + preview_path = os.path.join(download_path, filename) + + if not os.path.isfile(preview_path): + preview_path = os.path.join(extension_uri, "assets", "no-preview.png") + + return web.FileResponse(preview_path) WEB_DIRECTORY = "web" NODE_CLASS_MAPPINGS = {} -__all__ = ["NODE_CLASS_MAPPINGS"] +__all__ = ["WEB_DIRECTORY", "NODE_CLASS_MAPPINGS"] diff --git a/no-preview.png b/assets/no-preview.png similarity index 100% rename from no-preview.png rename to assets/no-preview.png diff --git a/config_loader.py b/config_loader.py deleted file mode 100644 index 2f9e895..0000000 --- a/config_loader.py +++ /dev/null @@ -1,72 +0,0 @@ -import yaml -from dataclasses import dataclass - -@dataclass -class Rule: - key: any - value_default: any - value_type: type - value_min: any # int | float | None - value_max: any # int | float | None - - def __init__( - self, - key, - value_default, - value_type: type, - value_min: any = None, # int | float | None - value_max: any = None, # int | float | 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/beta-menu-model-manager-button-settings-group.png b/demo/beta-menu-model-manager-button-settings-group.png deleted file mode 100644 index e5f0d7e..0000000 Binary files a/demo/beta-menu-model-manager-button-settings-group.png and /dev/null differ diff --git a/demo/tab-download.png b/demo/tab-download.png index 4e5f7a7..50ffa5f 100644 Binary files a/demo/tab-download.png and b/demo/tab-download.png differ diff --git a/demo/tab-model-drag-add.gif b/demo/tab-model-drag-add.gif deleted file mode 100644 index 897b474..0000000 Binary files a/demo/tab-model-drag-add.gif and /dev/null differ diff --git a/demo/tab-model-info-overview.png b/demo/tab-model-info-overview.png old mode 100644 new mode 100755 index 0637e75..bef89c4 Binary files a/demo/tab-model-info-overview.png and b/demo/tab-model-info-overview.png differ diff --git a/demo/tab-model-node-graph.gif b/demo/tab-model-node-graph.gif new file mode 100755 index 0000000..638bcdc Binary files /dev/null and b/demo/tab-model-node-graph.gif differ diff --git a/demo/tab-model-preview-thumbnail-buttons-example.png b/demo/tab-model-preview-thumbnail-buttons-example.png deleted file mode 100644 index 8f96ba6..0000000 Binary files a/demo/tab-model-preview-thumbnail-buttons-example.png and /dev/null differ diff --git a/demo/tab-models-dropdown.png b/demo/tab-models-dropdown.png deleted file mode 100644 index e23f763..0000000 Binary files a/demo/tab-models-dropdown.png and /dev/null differ diff --git a/demo/tab-models.gif b/demo/tab-models.gif new file mode 100755 index 0000000..b3e28f0 Binary files /dev/null and b/demo/tab-models.gif differ diff --git a/demo/tab-models.png b/demo/tab-models.png old mode 100644 new mode 100755 index 672ecea..f2a8825 Binary files a/demo/tab-models.png and b/demo/tab-models.png differ diff --git a/demo/tab-settings.png b/demo/tab-settings.png old mode 100644 new mode 100755 index 13ee3ef..04f3b49 Binary files a/demo/tab-settings.png and b/demo/tab-settings.png differ diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..a9322a5 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,32 @@ +import globals from 'globals' +import pluginJs from '@eslint/js' +import tsEslint from 'typescript-eslint' +import pluginVue from 'eslint-plugin-vue' + +export default [ + { + files: ['src/**/*.{js,mjs,cjs,ts,vue}'], + }, + { + ignores: [ + 'src/scripts/*', + 'src/extensions/core/*', + 'src/types/vue-shim.d.ts', + ], + }, + { languageOptions: { globals: globals.browser } }, + pluginJs.configs.recommended, + ...tsEslint.configs.recommended, + ...pluginVue.configs['flat/essential'], + { + files: ['src/**/*.vue'], + languageOptions: { parserOptions: { parser: tsEslint.parser } }, + }, + { + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/prefer-as-const': 'off', + }, + }, +] diff --git a/index.html b/index.html new file mode 100644 index 0000000..5500a50 --- /dev/null +++ b/index.html @@ -0,0 +1,11 @@ + + + + + + ComfyUI-Model-Manager + + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..0c1f49e --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "comfyui-model-manager", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "prepare": "husky" + }, + "devDependencies": { + "@tailwindcss/container-queries": "^0.1.1", + "@types/lodash": "^4.17.9", + "@types/markdown-it": "^14.1.2", + "@types/node": "^22.5.5", + "@types/turndown": "^5.0.5", + "@vitejs/plugin-vue": "^5.1.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.10.0", + "eslint-plugin-vue": "^9.28.0", + "husky": "^9.1.6", + "less": "^4.2.0", + "lint-staged": "^15.2.10", + "postcss": "^8.4.47", + "prettier": "^3.3.3", + "prettier-plugin-organize-imports": "^4.1.0", + "prettier-plugin-tailwindcss": "^0.6.8", + "tailwindcss": "^3.4.12", + "typescript": "^5.6.2", + "typescript-eslint": "^8.6.0", + "vite": "^5.4.6" + }, + "dependencies": { + "@primevue/themes": "^4.0.7", + "dayjs": "^1.11.13", + "lodash": "^4.17.21", + "markdown-it": "^14.1.0", + "markdown-it-metadata-block": "^1.0.6", + "primevue": "^4.0.7", + "turndown": "^7.2.0", + "vue": "^3.4.31", + "vue-i18n": "^9.13.1", + "yaml": "^2.6.0" + }, + "lint-staged": { + "./**/*.{js,ts,tsx,vue}": [ + "prettier --write", + "git add" + ] + } +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..f155679 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3160 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@primevue/themes': + specifier: ^4.0.7 + version: 4.0.7 + dayjs: + specifier: ^1.11.13 + version: 1.11.13 + lodash: + specifier: ^4.17.21 + version: 4.17.21 + markdown-it: + specifier: ^14.1.0 + version: 14.1.0 + markdown-it-metadata-block: + specifier: ^1.0.6 + version: 1.0.6 + primevue: + specifier: ^4.0.7 + version: 4.0.7(vue@3.5.6(typescript@5.6.2)) + turndown: + specifier: ^7.2.0 + version: 7.2.0 + vue: + specifier: ^3.4.31 + version: 3.5.6(typescript@5.6.2) + vue-i18n: + specifier: ^9.13.1 + version: 9.14.0(vue@3.5.6(typescript@5.6.2)) + yaml: + specifier: ^2.6.0 + version: 2.6.0 + devDependencies: + '@tailwindcss/container-queries': + specifier: ^0.1.1 + version: 0.1.1(tailwindcss@3.4.12) + '@types/lodash': + specifier: ^4.17.9 + version: 4.17.9 + '@types/markdown-it': + specifier: ^14.1.2 + version: 14.1.2 + '@types/node': + specifier: ^22.5.5 + version: 22.5.5 + '@types/turndown': + specifier: ^5.0.5 + version: 5.0.5 + '@vitejs/plugin-vue': + specifier: ^5.1.4 + version: 5.1.4(vite@5.4.6(@types/node@22.5.5)(less@4.2.0))(vue@3.5.6(typescript@5.6.2)) + autoprefixer: + specifier: ^10.4.20 + version: 10.4.20(postcss@8.4.47) + eslint: + specifier: ^9.10.0 + version: 9.10.0(jiti@1.21.6) + eslint-plugin-vue: + specifier: ^9.28.0 + version: 9.28.0(eslint@9.10.0(jiti@1.21.6)) + husky: + specifier: ^9.1.6 + version: 9.1.6 + less: + specifier: ^4.2.0 + version: 4.2.0 + lint-staged: + specifier: ^15.2.10 + version: 15.2.10 + postcss: + specifier: ^8.4.47 + version: 8.4.47 + prettier: + specifier: ^3.3.3 + version: 3.3.3 + prettier-plugin-organize-imports: + specifier: ^4.1.0 + version: 4.1.0(prettier@3.3.3)(typescript@5.6.2) + prettier-plugin-tailwindcss: + specifier: ^0.6.8 + version: 0.6.8(prettier-plugin-organize-imports@4.1.0(prettier@3.3.3)(typescript@5.6.2))(prettier@3.3.3) + tailwindcss: + specifier: ^3.4.12 + version: 3.4.12 + typescript: + specifier: ^5.6.2 + version: 5.6.2 + typescript-eslint: + specifier: ^8.6.0 + version: 8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) + vite: + specifier: ^5.4.6 + version: 5.4.6(@types/node@22.5.5)(less@4.2.0) + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/helper-string-parser@7.24.8': + resolution: {integrity: sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.24.7': + resolution: {integrity: sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.25.6': + resolution: {integrity: sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.25.6': + resolution: {integrity: sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.4.0': + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.11.1': + resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.18.0': + resolution: {integrity: sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.1.0': + resolution: {integrity: sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.10.0': + resolution: {integrity: sha512-fuXtbiP5GWIn8Fz+LWoOMVf/Jxm+aajZYkhi6CuEm4SxymFM+eUWzbO9qXT+L0iCkL5+KGYMCSGxo686H19S1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.4': + resolution: {integrity: sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.1.0': + resolution: {integrity: sha512-autAXT203ixhqei9xt+qkYOvY8l6LAFIdT2UXc/RPNeUVfqRF1BV94GTJyVPFKT8nFM6MyVJhjLj9E8JWvf5zQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.3.0': + resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==} + engines: {node: '>=18.18'} + + '@intlify/core-base@9.14.0': + resolution: {integrity: sha512-zJn0imh9HIsZZUtt9v8T16PeVstPv6bP2YzlrYJwoF8F30gs4brZBwW2KK6EI5WYKFi3NeqX6+UU4gniz5TkGg==} + engines: {node: '>= 16'} + + '@intlify/message-compiler@9.14.0': + resolution: {integrity: sha512-sXNsoMI0YsipSXW8SR75drmVK56tnJHoYbPXUv2Cf9lz6FzvwsosFm6JtC1oQZI/kU+n7qx0qRrEWkeYFTgETA==} + engines: {node: '>= 16'} + + '@intlify/shared@9.14.0': + resolution: {integrity: sha512-r+N8KRQL7LgN1TMTs1A2svfuAU0J94Wu9wWdJVJqYsoMMLIeJxrPjazihfHpmJqfgZq0ah3Y9Q4pgWV2O90Fyg==} + engines: {node: '>= 16'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.5': + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@primeuix/styled@0.0.5': + resolution: {integrity: sha512-pVoGn/uPkVm/DyF3TR3EmH/pL/dP4nR42FcYbVduFq9VfO3KVeOEqvcCULHXos66RZO9MCbCFUoLy6ctf9GUGQ==} + engines: {node: '>=12.11.0'} + + '@primeuix/utils@0.0.5': + resolution: {integrity: sha512-ntUiUgtRtkF8KuaxHffzhYxQxoXk6LAPHm7CVlFjdqS8Rx8xRkLkZVyo84E+pO2hcNFkOGVP/GxHhQ2s94O8zA==} + engines: {node: '>=12.11.0'} + + '@primevue/core@4.0.7': + resolution: {integrity: sha512-SvWiNBEeR6hm4wjnze+rITUjHMFLwIzpRFlq+GqmJyZmjJy4h8UUksi0EoyqAWCAwKgmwlxY6XNqGJmMVyOguQ==} + engines: {node: '>=12.11.0'} + peerDependencies: + vue: ^3.0.0 + + '@primevue/icons@4.0.7': + resolution: {integrity: sha512-tj4dfRdV5iN6O0mbkpjhMsGlT3wZTqOPL779ndY5gKuCwN5zcFmKmABWVQmr/ClRivnMkw6Yr1x6gRTV/N0ydg==} + engines: {node: '>=12.11.0'} + + '@primevue/themes@4.0.7': + resolution: {integrity: sha512-ZbDUrpBmtuqdeegNwUaJTubaLDBBJWOc4Z6UoQM3DG2c7EAE19wQbuh+cG9zqA7sT/Xsp+ACC/Z9e4FnfqB55g==} + engines: {node: '>=12.11.0'} + + '@rollup/rollup-android-arm-eabi@4.22.0': + resolution: {integrity: sha512-/IZQvg6ZR0tAkEi4tdXOraQoWeJy9gbQ/cx4I7k9dJaCk9qrXEcdouxRVz5kZXt5C2bQ9pILoAA+KB4C/d3pfw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.22.0': + resolution: {integrity: sha512-ETHi4bxrYnvOtXeM7d4V4kZWixib2jddFacJjsOjwbgYSRsyXYtZHC4ht134OsslPIcnkqT+TKV4eU8rNBKyyQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.22.0': + resolution: {integrity: sha512-ZWgARzhSKE+gVUX7QWaECoRQsPwaD8ZR0Oxb3aUpzdErTvlEadfQpORPXkKSdKbFci9v8MJfkTtoEHnnW9Ulng==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.22.0': + resolution: {integrity: sha512-h0ZAtOfHyio8Az6cwIGS+nHUfRMWBDO5jXB8PQCARVF6Na/G6XS2SFxDl8Oem+S5ZsHQgtsI7RT4JQnI1qrlaw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-linux-arm-gnueabihf@4.22.0': + resolution: {integrity: sha512-9pxQJSPwFsVi0ttOmqLY4JJ9pg9t1gKhK0JDbV1yUEETSx55fdyCjt39eBQ54OQCzAF0nVGO6LfEH1KnCPvelA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.22.0': + resolution: {integrity: sha512-YJ5Ku5BmNJZb58A4qSEo3JlIG4d3G2lWyBi13ABlXzO41SsdnUKi3HQHe83VpwBVG4jHFTW65jOQb8qyoR+qzg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.22.0': + resolution: {integrity: sha512-U4G4u7f+QCqHlVg1Nlx+qapZy+QoG+NV6ux+upo/T7arNGwKvKP2kmGM4W5QTbdewWFgudQxi3kDNST9GT1/mg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.22.0': + resolution: {integrity: sha512-aQpNlKmx3amwkA3a5J6nlXSahE1ijl0L9KuIjVOUhfOh7uw2S4piR3mtpxpRtbnK809SBtyPsM9q15CPTsY7HQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-powerpc64le-gnu@4.22.0': + resolution: {integrity: sha512-9fx6Zj/7vve/Fp4iexUFRKb5+RjLCff6YTRQl4CoDhdMfDoobWmhAxQWV3NfShMzQk1Q/iCnageFyGfqnsmeqQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-gnu@4.22.0': + resolution: {integrity: sha512-VWQiCcN7zBgZYLjndIEh5tamtnKg5TGxyZPWcN9zBtXBwfcGSZ5cHSdQZfQH/GB4uRxk0D3VYbOEe/chJhPGLQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-s390x-gnu@4.22.0': + resolution: {integrity: sha512-EHmPnPWvyYqncObwqrosb/CpH3GOjE76vWVs0g4hWsDRUVhg61hBmlVg5TPXqF+g+PvIbqkC7i3h8wbn4Gp2Fg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.22.0': + resolution: {integrity: sha512-tsSWy3YQzmpjDKnQ1Vcpy3p9Z+kMFbSIesCdMNgLizDWFhrLZIoN21JSq01g+MZMDFF+Y1+4zxgrlqPjid5ohg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.22.0': + resolution: {integrity: sha512-anr1Y11uPOQrpuU8XOikY5lH4Qu94oS6j0xrulHk3NkLDq19MlX8Ng/pVipjxBJ9a2l3+F39REZYyWQFkZ4/fw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-win32-arm64-msvc@4.22.0': + resolution: {integrity: sha512-7LB+Bh+Ut7cfmO0m244/asvtIGQr5pG5Rvjz/l1Rnz1kDzM02pSX9jPaS0p+90H5I1x4d1FkCew+B7MOnoatNw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.22.0': + resolution: {integrity: sha512-+3qZ4rer7t/QsC5JwMpcvCVPRcJt1cJrYS/TMJZzXIJbxWFQEVhrIc26IhB+5Z9fT9umfVc+Es2mOZgl+7jdJQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.22.0': + resolution: {integrity: sha512-YdicNOSJONVx/vuPkgPTyRoAPx3GbknBZRCOUkK84FJ/YTfs/F0vl/YsMscrB6Y177d+yDRcj+JWMPMCgshwrA==} + cpu: [x64] + os: [win32] + + '@tailwindcss/container-queries@0.1.1': + resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==} + peerDependencies: + tailwindcss: '>=3.2.0' + + '@types/estree@1.0.5': + resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/lodash@4.17.9': + resolution: {integrity: sha512-w9iWudx1XWOHW5lQRS9iKpK/XuRhnN+0T7HvdCCd802FYkT1AMTnxndJHGrNJwRoRHkslGr4S29tjm1cT7x/7w==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/node@22.5.5': + resolution: {integrity: sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==} + + '@types/turndown@5.0.5': + resolution: {integrity: sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==} + + '@typescript-eslint/eslint-plugin@8.6.0': + resolution: {integrity: sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@8.6.0': + resolution: {integrity: sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@8.6.0': + resolution: {integrity: sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/type-utils@8.6.0': + resolution: {integrity: sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@8.6.0': + resolution: {integrity: sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.6.0': + resolution: {integrity: sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@8.6.0': + resolution: {integrity: sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + + '@typescript-eslint/visitor-keys@8.6.0': + resolution: {integrity: sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-vue@5.1.4': + resolution: {integrity: sha512-N2XSI2n3sQqp5w7Y/AN/L2XDjBIRGqXko+eDp42sydYSBeJuSm5a1sLf8zakmo8u7tA8NmBgoDLA1HeOESjp9A==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 + vue: ^3.2.25 + + '@vue/compiler-core@3.5.6': + resolution: {integrity: sha512-r+gNu6K4lrvaQLQGmf+1gc41p3FO2OUJyWmNqaIITaJU6YFiV5PtQSFZt8jfztYyARwqhoCayjprC7KMvT3nRA==} + + '@vue/compiler-dom@3.5.6': + resolution: {integrity: sha512-xRXqxDrIqK8v8sSScpistyYH0qYqxakpsIvqMD2e5sV/PXQ1mTwtXp4k42yHK06KXxKSmitop9e45Ui/3BrTEw==} + + '@vue/compiler-sfc@3.5.6': + resolution: {integrity: sha512-pjWJ8Kj9TDHlbF5LywjVso+BIxCY5wVOLhkEXRhuCHDxPFIeX1zaFefKs8RYoHvkSMqRWt93a0f2gNJVJixHwg==} + + '@vue/compiler-ssr@3.5.6': + resolution: {integrity: sha512-VpWbaZrEOCqnmqjE83xdwegtr5qO/2OPUC6veWgvNqTJ3bYysz6vY3VqMuOijubuUYPRpG3OOKIh9TD0Stxb9A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/reactivity@3.5.6': + resolution: {integrity: sha512-shZ+KtBoHna5GyUxWfoFVBCVd7k56m6lGhk5e+J9AKjheHF6yob5eukssHRI+rzvHBiU1sWs/1ZhNbLExc5oYQ==} + + '@vue/runtime-core@3.5.6': + resolution: {integrity: sha512-FpFULR6+c2lI+m1fIGONLDqPQO34jxV8g6A4wBOgne8eSRHP6PQL27+kWFIx5wNhhjkO7B4rgtsHAmWv7qKvbg==} + + '@vue/runtime-dom@3.5.6': + resolution: {integrity: sha512-SDPseWre45G38ENH2zXRAHL1dw/rr5qp91lS4lt/nHvMr0MhsbCbihGAWLXNB/6VfFOJe2O+RBRkXU+CJF7/sw==} + + '@vue/server-renderer@3.5.6': + resolution: {integrity: sha512-zivnxQnOnwEXVaT9CstJ64rZFXMS5ZkKxCjDQKiMSvUhXRzFLWZVbaBiNF4HGDqGNNsTgmjcCSmU6TB/0OOxLA==} + peerDependencies: + vue: 3.5.6 + + '@vue/shared@3.5.6': + resolution: {integrity: sha512-eidH0HInnL39z6wAt6SFIwBrvGOpDWsDxlw3rCgo1B+CQ1781WzQUSU3YjxgdkcJo9Q8S6LmXTkvI+cLHGkQfA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.12.1: + resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-escapes@7.0.0: + resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + autoprefixer@10.4.20: + resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.23.3: + resolution: {integrity: sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001662: + resolution: {integrity: sha512-sgMUVwLmGseH8ZIrm1d51UbrhqMCH3jvS7gF/M6byuHOnKyLOBL7W8yz5V02OHwgLGA36o/AFhWzzh4uc5aqTA==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.3.0: + resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-truncate@4.0.0: + resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} + engines: {node: '>=18'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + copy-anything@2.0.6: + resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==} + + cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + dayjs@1.11.13: + resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + electron-to-chromium@1.5.25: + resolution: {integrity: sha512-kMb204zvK3PsSlgvvwzI3wBIcAw15tRkYk+NQdsjdDtcQWTp2RABbMQ9rUBy8KNEOM+/E6ep+XC3AykiWZld4g==} + + emoji-regex@10.4.0: + resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + errno@0.1.8: + resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} + hasBin: true + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-vue@9.28.0: + resolution: {integrity: sha512-ShrihdjIhOTxs+MfWun6oJWuk+g/LAhN+CiuOl/jjkG3l0F2AuK5NMTaWqyvBgkFtpYmyks6P4603mLmhNJW8g==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-scope@8.0.2: + resolution: {integrity: sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.0.0: + resolution: {integrity: sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.10.0: + resolution: {integrity: sha512-Y4D0IgtBZfOcOUAIQTSXBKoNGfY0REGqHJG6+Q81vNippW5YlKjHFj4soMxamKK1NXHUWuBZTLdU3Km+L/pcHw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.1.0: + resolution: {integrity: sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.1: + resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + + foreground-child@3.3.0: + resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} + engines: {node: '>=14'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-east-asian-width@1.2.0: + resolution: {integrity: sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==} + engines: {node: '>=18'} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + husky@9.1.6: + resolution: {integrity: sha512-sqbjZKK7kf44hfdE94EoX8MZNk0n7HeW37O4YrVGCF4wzgQjp+akPAkfUK5LZ6KuR/6sqeAVuXHji+RzQgOn5A==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + image-size@0.5.5: + resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} + engines: {node: '>=0.10.0'} + hasBin: true + + import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.15.1: + resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@4.0.0: + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} + engines: {node: '>=12'} + + is-fullwidth-code-point@5.0.0: + resolution: {integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-what@3.14.1: + resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jiti@1.21.6: + resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + less@4.2.0: + resolution: {integrity: sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA==} + engines: {node: '>=6'} + hasBin: true + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + lilconfig@3.1.2: + resolution: {integrity: sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + lint-staged@15.2.10: + resolution: {integrity: sha512-5dY5t743e1byO19P9I4b3x8HJwalIznL5E1FWYnU6OWw33KxNBSLAc6Cy7F2PsFEO8FKnLwjwm5hx7aMF0jzZg==} + engines: {node: '>=18.12.0'} + hasBin: true + + listr2@8.2.4: + resolution: {integrity: sha512-opevsywziHd3zHCVQGAj8zu+Z3yHNkkoYhWIGnq54RrCVwLz0MozotJEDnKsIBLvkfLGN6BLOyAeRrYI0pKA4g==} + engines: {node: '>=18.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + magic-string@0.30.11: + resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} + + make-dir@2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} + + markdown-it-metadata-block@1.0.6: + resolution: {integrity: sha512-0nMBdV/CLy/bFfcw3wFdiZ6sgEv/yWAoNxgb3qY+5lLEP804r/JT9yLmLH3Z3YrqGDHb5xIi7gqhj7gwbPHycQ==} + + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + hasBin: true + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.7: + resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + needle@3.3.1: + resolution: {integrity: sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==} + engines: {node: '>= 4.4.x'} + hasBin: true + + node-releases@2.0.18: + resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.0: + resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-node-version@1.0.1: + resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} + engines: {node: '>= 0.10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + picocolors@1.1.0: + resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pidtree@0.6.0: + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} + engines: {node: '>=0.10'} + hasBin: true + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pirates@4.0.6: + resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} + engines: {node: '>= 6'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.0.1: + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@4.0.2: + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.47: + resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-organize-imports@4.1.0: + resolution: {integrity: sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==} + peerDependencies: + prettier: '>=2.0' + typescript: '>=2.9' + vue-tsc: ^2.1.0 + peerDependenciesMeta: + vue-tsc: + optional: true + + prettier-plugin-tailwindcss@0.6.8: + resolution: {integrity: sha512-dGu3kdm7SXPkiW4nzeWKCl3uoImdd5CTZEJGxyypEPL37Wj0HT2pLqjrvSei1nTeuQfO4PUfjeW5cTUNRLZ4sA==} + engines: {node: '>=14.21.3'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig-melody': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-import-sort: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-style-order: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig-melody': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-import-sort: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-style-order: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.3.3: + resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} + engines: {node: '>=14'} + hasBin: true + + primevue@4.0.7: + resolution: {integrity: sha512-88qazHqldkqsCxvhjnjO65XMBfJyHQoFW3BQvrJYO6RqPheHB4f7cY61eqtBpJAjnM5x+YKTZiWx/gBuUzqT7Q==} + engines: {node: '>=12.11.0'} + + prr@1.0.1: + resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rollup@4.22.0: + resolution: {integrity: sha512-W21MUIFPZ4+O2Je/EU+GP3iz7PH4pVPUXSbEZdatQnxo29+3rsUjgrJmzuAZU24z7yRAnFN6ukxeAhZh/c7hzg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slice-ansi@5.0.0: + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} + engines: {node: '>=12'} + + slice-ansi@7.1.0: + resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwindcss@3.4.12: + resolution: {integrity: sha512-Htf/gHj2+soPb9UayUNci/Ja3d8pTmu9ONTfh4QY8r3MATTZOzmv6UYWF7ZwikEIC8okpfqmGqrmDehua8mF8w==} + engines: {node: '>=14.0.0'} + hasBin: true + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + to-fast-properties@2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@1.3.0: + resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + + turndown@7.2.0: + resolution: {integrity: sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + typescript-eslint@8.6.0: + resolution: {integrity: sha512-eEhhlxCEpCd4helh3AO1hk0UP2MvbRi9CtIAJTVPQjuSXOOO2jsEacNi4UdcJzZJbeuVg1gMhtZ8UYb+NFYPrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + typescript@5.6.2: + resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + + update-browserslist-db@1.1.0: + resolution: {integrity: sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@5.4.6: + resolution: {integrity: sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vue-eslint-parser@9.4.3: + resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: '>=6.0.0' + + vue-i18n@9.14.0: + resolution: {integrity: sha512-LxmpRuCt2rI8gqU+kxeflRZMQn4D5+4M3oP3PWZdowW/ePJraHqhF7p4CuaME52mUxdw3Mmy2yAUKgfZYgCRjA==} + engines: {node: '>= 16'} + peerDependencies: + vue: ^3.0.0 + + vue@3.5.6: + resolution: {integrity: sha512-zv+20E2VIYbcJOzJPUWp03NOGFhMmpCKOfSxVTmCYyYFFko48H9tmuQFzYj7tu4qX1AeXlp9DmhIP89/sSxxhw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrap-ansi@9.0.0: + resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} + engines: {node: '>=18'} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + yaml@2.5.1: + resolution: {integrity: sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==} + engines: {node: '>= 14'} + hasBin: true + + yaml@2.6.0: + resolution: {integrity: sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==} + engines: {node: '>= 14'} + hasBin: true + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/helper-string-parser@7.24.8': {} + + '@babel/helper-validator-identifier@7.24.7': {} + + '@babel/parser@7.25.6': + dependencies: + '@babel/types': 7.25.6 + + '@babel/types@7.25.6': + dependencies: + '@babel/helper-string-parser': 7.24.8 + '@babel/helper-validator-identifier': 7.24.7 + to-fast-properties: 2.0.0 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@eslint-community/eslint-utils@4.4.0(eslint@9.10.0(jiti@1.21.6))': + dependencies: + eslint: 9.10.0(jiti@1.21.6) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.11.1': {} + + '@eslint/config-array@0.18.0': + dependencies: + '@eslint/object-schema': 2.1.4 + debug: 4.3.7 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/eslintrc@3.1.0': + dependencies: + ajv: 6.12.6 + debug: 4.3.7 + espree: 10.1.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.10.0': {} + + '@eslint/object-schema@2.1.4': {} + + '@eslint/plugin-kit@0.1.0': + dependencies: + levn: 0.4.1 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.3.0': {} + + '@intlify/core-base@9.14.0': + dependencies: + '@intlify/message-compiler': 9.14.0 + '@intlify/shared': 9.14.0 + + '@intlify/message-compiler@9.14.0': + dependencies: + '@intlify/shared': 9.14.0 + source-map-js: 1.2.1 + + '@intlify/shared@9.14.0': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.5': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@mixmark-io/domino@2.2.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@primeuix/styled@0.0.5': + dependencies: + '@primeuix/utils': 0.0.5 + + '@primeuix/utils@0.0.5': {} + + '@primevue/core@4.0.7(vue@3.5.6(typescript@5.6.2))': + dependencies: + '@primeuix/styled': 0.0.5 + '@primeuix/utils': 0.0.5 + vue: 3.5.6(typescript@5.6.2) + + '@primevue/icons@4.0.7(vue@3.5.6(typescript@5.6.2))': + dependencies: + '@primeuix/utils': 0.0.5 + '@primevue/core': 4.0.7(vue@3.5.6(typescript@5.6.2)) + transitivePeerDependencies: + - vue + + '@primevue/themes@4.0.7': + dependencies: + '@primeuix/styled': 0.0.5 + + '@rollup/rollup-android-arm-eabi@4.22.0': + optional: true + + '@rollup/rollup-android-arm64@4.22.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.22.0': + optional: true + + '@rollup/rollup-darwin-x64@4.22.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.22.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.22.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.22.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.22.0': + optional: true + + '@rollup/rollup-linux-powerpc64le-gnu@4.22.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.22.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.22.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.22.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.22.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.22.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.22.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.22.0': + optional: true + + '@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.12)': + dependencies: + tailwindcss: 3.4.12 + + '@types/estree@1.0.5': {} + + '@types/linkify-it@5.0.0': {} + + '@types/lodash@4.17.9': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@2.0.0': {} + + '@types/node@22.5.5': + dependencies: + undici-types: 6.19.8 + + '@types/turndown@5.0.5': {} + + '@typescript-eslint/eslint-plugin@8.6.0(@typescript-eslint/parser@8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)': + dependencies: + '@eslint-community/regexpp': 4.11.1 + '@typescript-eslint/parser': 8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) + '@typescript-eslint/scope-manager': 8.6.0 + '@typescript-eslint/type-utils': 8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) + '@typescript-eslint/utils': 8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) + '@typescript-eslint/visitor-keys': 8.6.0 + eslint: 9.10.0(jiti@1.21.6) + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.3.0(typescript@5.6.2) + optionalDependencies: + typescript: 5.6.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)': + dependencies: + '@typescript-eslint/scope-manager': 8.6.0 + '@typescript-eslint/types': 8.6.0 + '@typescript-eslint/typescript-estree': 8.6.0(typescript@5.6.2) + '@typescript-eslint/visitor-keys': 8.6.0 + debug: 4.3.7 + eslint: 9.10.0(jiti@1.21.6) + optionalDependencies: + typescript: 5.6.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.6.0': + dependencies: + '@typescript-eslint/types': 8.6.0 + '@typescript-eslint/visitor-keys': 8.6.0 + + '@typescript-eslint/type-utils@8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)': + dependencies: + '@typescript-eslint/typescript-estree': 8.6.0(typescript@5.6.2) + '@typescript-eslint/utils': 8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) + debug: 4.3.7 + ts-api-utils: 1.3.0(typescript@5.6.2) + optionalDependencies: + typescript: 5.6.2 + transitivePeerDependencies: + - eslint + - supports-color + + '@typescript-eslint/types@8.6.0': {} + + '@typescript-eslint/typescript-estree@8.6.0(typescript@5.6.2)': + dependencies: + '@typescript-eslint/types': 8.6.0 + '@typescript-eslint/visitor-keys': 8.6.0 + debug: 4.3.7 + fast-glob: 3.3.2 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.6.3 + ts-api-utils: 1.3.0(typescript@5.6.2) + optionalDependencies: + typescript: 5.6.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)': + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@9.10.0(jiti@1.21.6)) + '@typescript-eslint/scope-manager': 8.6.0 + '@typescript-eslint/types': 8.6.0 + '@typescript-eslint/typescript-estree': 8.6.0(typescript@5.6.2) + eslint: 9.10.0(jiti@1.21.6) + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@8.6.0': + dependencies: + '@typescript-eslint/types': 8.6.0 + eslint-visitor-keys: 3.4.3 + + '@vitejs/plugin-vue@5.1.4(vite@5.4.6(@types/node@22.5.5)(less@4.2.0))(vue@3.5.6(typescript@5.6.2))': + dependencies: + vite: 5.4.6(@types/node@22.5.5)(less@4.2.0) + vue: 3.5.6(typescript@5.6.2) + + '@vue/compiler-core@3.5.6': + dependencies: + '@babel/parser': 7.25.6 + '@vue/shared': 3.5.6 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.6': + dependencies: + '@vue/compiler-core': 3.5.6 + '@vue/shared': 3.5.6 + + '@vue/compiler-sfc@3.5.6': + dependencies: + '@babel/parser': 7.25.6 + '@vue/compiler-core': 3.5.6 + '@vue/compiler-dom': 3.5.6 + '@vue/compiler-ssr': 3.5.6 + '@vue/shared': 3.5.6 + estree-walker: 2.0.2 + magic-string: 0.30.11 + postcss: 8.4.47 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.6': + dependencies: + '@vue/compiler-dom': 3.5.6 + '@vue/shared': 3.5.6 + + '@vue/devtools-api@6.6.4': {} + + '@vue/reactivity@3.5.6': + dependencies: + '@vue/shared': 3.5.6 + + '@vue/runtime-core@3.5.6': + dependencies: + '@vue/reactivity': 3.5.6 + '@vue/shared': 3.5.6 + + '@vue/runtime-dom@3.5.6': + dependencies: + '@vue/reactivity': 3.5.6 + '@vue/runtime-core': 3.5.6 + '@vue/shared': 3.5.6 + csstype: 3.1.3 + + '@vue/server-renderer@3.5.6(vue@3.5.6(typescript@5.6.2))': + dependencies: + '@vue/compiler-ssr': 3.5.6 + '@vue/shared': 3.5.6 + vue: 3.5.6(typescript@5.6.2) + + '@vue/shared@3.5.6': {} + + acorn-jsx@5.3.2(acorn@8.12.1): + dependencies: + acorn: 8.12.1 + + acorn@8.12.1: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-escapes@7.0.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.1.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.1: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + autoprefixer@10.4.20(postcss@8.4.47): + dependencies: + browserslist: 4.23.3 + caniuse-lite: 1.0.30001662 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.0 + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + balanced-match@1.0.2: {} + + binary-extensions@2.3.0: {} + + boolbase@1.0.0: {} + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.23.3: + dependencies: + caniuse-lite: 1.0.30001662 + electron-to-chromium: 1.5.25 + node-releases: 2.0.18 + update-browserslist-db: 1.1.0(browserslist@4.23.3) + + callsites@3.1.0: {} + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001662: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.3.0: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-truncate@4.0.0: + dependencies: + slice-ansi: 5.0.0 + string-width: 7.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + commander@12.1.0: {} + + commander@4.1.1: {} + + concat-map@0.0.1: {} + + copy-anything@2.0.6: + dependencies: + is-what: 3.14.1 + + cross-spawn@7.0.3: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.1.3: {} + + dayjs@1.11.13: {} + + debug@4.3.7: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + eastasianwidth@0.2.0: {} + + electron-to-chromium@1.5.25: {} + + emoji-regex@10.4.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + entities@4.5.0: {} + + environment@1.1.0: {} + + errno@0.1.8: + dependencies: + prr: 1.0.1 + optional: true + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-vue@9.28.0(eslint@9.10.0(jiti@1.21.6)): + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@9.10.0(jiti@1.21.6)) + eslint: 9.10.0(jiti@1.21.6) + globals: 13.24.0 + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 6.1.2 + semver: 7.6.3 + vue-eslint-parser: 9.4.3(eslint@9.10.0(jiti@1.21.6)) + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - supports-color + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-scope@8.0.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.0.0: {} + + eslint@9.10.0(jiti@1.21.6): + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@9.10.0(jiti@1.21.6)) + '@eslint-community/regexpp': 4.11.1 + '@eslint/config-array': 0.18.0 + '@eslint/eslintrc': 3.1.0 + '@eslint/js': 9.10.0 + '@eslint/plugin-kit': 0.1.0 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.3.0 + '@nodelib/fs.walk': 1.2.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.7 + escape-string-regexp: 4.0.0 + eslint-scope: 8.0.2 + eslint-visitor-keys: 4.0.0 + espree: 10.1.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + optionalDependencies: + jiti: 1.21.6 + transitivePeerDependencies: + - supports-color + + espree@10.1.0: + dependencies: + acorn: 8.12.1 + acorn-jsx: 5.3.2(acorn@8.12.1) + eslint-visitor-keys: 4.0.0 + + espree@9.6.1: + dependencies: + acorn: 8.12.1 + acorn-jsx: 5.3.2(acorn@8.12.1) + eslint-visitor-keys: 3.4.3 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + esutils@2.0.3: {} + + eventemitter3@5.0.1: {} + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.3 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.2: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.17.1: + dependencies: + reusify: 1.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.1 + keyv: 4.5.4 + + flatted@3.3.1: {} + + foreground-child@3.3.0: + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + + fraction.js@4.3.7: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-east-asian-width@1.2.0: {} + + get-stream@8.0.1: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.4.5: + dependencies: + foreground-child: 3.3.0 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.0 + path-scurry: 1.11.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globals@14.0.0: {} + + graceful-fs@4.2.11: + optional: true + + graphemer@1.4.0: {} + + has-flag@4.0.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + human-signals@5.0.0: {} + + husky@9.1.6: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + optional: true + + ignore@5.3.2: {} + + image-size@0.5.5: + optional: true + + import-fresh@3.3.0: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.15.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@4.0.0: {} + + is-fullwidth-code-point@5.0.0: + dependencies: + get-east-asian-width: 1.2.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-stream@3.0.0: {} + + is-what@3.14.1: {} + + isexe@2.0.0: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@1.21.6: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + less@4.2.0: + dependencies: + copy-anything: 2.0.6 + parse-node-version: 1.0.1 + tslib: 2.7.0 + optionalDependencies: + errno: 0.1.8 + graceful-fs: 4.2.11 + image-size: 0.5.5 + make-dir: 2.1.0 + mime: 1.6.0 + needle: 3.3.1 + source-map: 0.6.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@2.1.0: {} + + lilconfig@3.1.2: {} + + lines-and-columns@1.2.4: {} + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + lint-staged@15.2.10: + dependencies: + chalk: 5.3.0 + commander: 12.1.0 + debug: 4.3.7 + execa: 8.0.1 + lilconfig: 3.1.2 + listr2: 8.2.4 + micromatch: 4.0.8 + pidtree: 0.6.0 + string-argv: 0.3.2 + yaml: 2.5.1 + transitivePeerDependencies: + - supports-color + + listr2@8.2.4: + dependencies: + cli-truncate: 4.0.0 + colorette: 2.0.20 + eventemitter3: 5.0.1 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lodash@4.17.21: {} + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.0.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.0 + strip-ansi: 7.1.0 + wrap-ansi: 9.0.0 + + lru-cache@10.4.3: {} + + magic-string@0.30.11: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + make-dir@2.1.0: + dependencies: + pify: 4.0.1 + semver: 5.7.2 + optional: true + + markdown-it-metadata-block@1.0.6: {} + + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + mdurl@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime@1.6.0: + optional: true + + mimic-fn@4.0.0: {} + + mimic-function@5.0.1: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + + minipass@7.1.2: {} + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.7: {} + + natural-compare@1.4.0: {} + + needle@3.3.1: + dependencies: + iconv-lite: 0.6.3 + sax: 1.4.1 + optional: true + + node-releases@2.0.18: {} + + normalize-path@3.0.0: {} + + normalize-range@0.1.2: {} + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-node-version@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + picocolors@1.1.0: {} + + picomatch@2.3.1: {} + + pidtree@0.6.0: {} + + pify@2.3.0: {} + + pify@4.0.1: + optional: true + + pirates@4.0.6: {} + + postcss-import@15.1.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.8 + + postcss-js@4.0.1(postcss@8.4.47): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.4.47 + + postcss-load-config@4.0.2(postcss@8.4.47): + dependencies: + lilconfig: 3.1.2 + yaml: 2.6.0 + optionalDependencies: + postcss: 8.4.47 + + postcss-nested@6.2.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.4.47: + dependencies: + nanoid: 3.3.7 + picocolors: 1.1.0 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-plugin-organize-imports@4.1.0(prettier@3.3.3)(typescript@5.6.2): + dependencies: + prettier: 3.3.3 + typescript: 5.6.2 + + prettier-plugin-tailwindcss@0.6.8(prettier-plugin-organize-imports@4.1.0(prettier@3.3.3)(typescript@5.6.2))(prettier@3.3.3): + dependencies: + prettier: 3.3.3 + optionalDependencies: + prettier-plugin-organize-imports: 4.1.0(prettier@3.3.3)(typescript@5.6.2) + + prettier@3.3.3: {} + + primevue@4.0.7(vue@3.5.6(typescript@5.6.2)): + dependencies: + '@primeuix/styled': 0.0.5 + '@primeuix/utils': 0.0.5 + '@primevue/core': 4.0.7(vue@3.5.6(typescript@5.6.2)) + '@primevue/icons': 4.0.7(vue@3.5.6(typescript@5.6.2)) + transitivePeerDependencies: + - vue + + prr@1.0.1: + optional: true + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + resolve-from@4.0.0: {} + + resolve@1.22.8: + dependencies: + is-core-module: 2.15.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + reusify@1.0.4: {} + + rfdc@1.4.1: {} + + rollup@4.22.0: + dependencies: + '@types/estree': 1.0.5 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.22.0 + '@rollup/rollup-android-arm64': 4.22.0 + '@rollup/rollup-darwin-arm64': 4.22.0 + '@rollup/rollup-darwin-x64': 4.22.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.22.0 + '@rollup/rollup-linux-arm-musleabihf': 4.22.0 + '@rollup/rollup-linux-arm64-gnu': 4.22.0 + '@rollup/rollup-linux-arm64-musl': 4.22.0 + '@rollup/rollup-linux-powerpc64le-gnu': 4.22.0 + '@rollup/rollup-linux-riscv64-gnu': 4.22.0 + '@rollup/rollup-linux-s390x-gnu': 4.22.0 + '@rollup/rollup-linux-x64-gnu': 4.22.0 + '@rollup/rollup-linux-x64-musl': 4.22.0 + '@rollup/rollup-win32-arm64-msvc': 4.22.0 + '@rollup/rollup-win32-ia32-msvc': 4.22.0 + '@rollup/rollup-win32-x64-msvc': 4.22.0 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safer-buffer@2.1.2: + optional: true + + sax@1.4.1: + optional: true + + semver@5.7.2: + optional: true + + semver@7.6.3: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + slice-ansi@5.0.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 4.0.0 + + slice-ansi@7.1.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 5.0.0 + + source-map-js@1.2.1: {} + + source-map@0.6.1: + optional: true + + string-argv@0.3.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.4.0 + get-east-asian-width: 1.2.0 + strip-ansi: 7.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + + strip-final-newline@3.0.0: {} + + strip-json-comments@3.1.1: {} + + sucrase@3.35.0: + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + commander: 4.1.1 + glob: 10.4.5 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.6 + ts-interface-checker: 0.1.13 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwindcss@3.4.12: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.2 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.6 + lilconfig: 2.1.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.0 + postcss: 8.4.47 + postcss-import: 15.1.0(postcss@8.4.47) + postcss-js: 4.0.1(postcss@8.4.47) + postcss-load-config: 4.0.2(postcss@8.4.47) + postcss-nested: 6.2.0(postcss@8.4.47) + postcss-selector-parser: 6.1.2 + resolve: 1.22.8 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + + text-table@0.2.0: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + to-fast-properties@2.0.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@1.3.0(typescript@5.6.2): + dependencies: + typescript: 5.6.2 + + ts-interface-checker@0.1.13: {} + + tslib@2.7.0: {} + + turndown@7.2.0: + dependencies: + '@mixmark-io/domino': 2.2.0 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + typescript-eslint@8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2): + dependencies: + '@typescript-eslint/eslint-plugin': 8.6.0(@typescript-eslint/parser@8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) + '@typescript-eslint/parser': 8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) + '@typescript-eslint/utils': 8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) + optionalDependencies: + typescript: 5.6.2 + transitivePeerDependencies: + - eslint + - supports-color + + typescript@5.6.2: {} + + uc.micro@2.1.0: {} + + undici-types@6.19.8: {} + + update-browserslist-db@1.1.0(browserslist@4.23.3): + dependencies: + browserslist: 4.23.3 + escalade: 3.2.0 + picocolors: 1.1.0 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vite@5.4.6(@types/node@22.5.5)(less@4.2.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.4.47 + rollup: 4.22.0 + optionalDependencies: + '@types/node': 22.5.5 + fsevents: 2.3.3 + less: 4.2.0 + + vue-eslint-parser@9.4.3(eslint@9.10.0(jiti@1.21.6)): + dependencies: + debug: 4.3.7 + eslint: 9.10.0(jiti@1.21.6) + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.6.0 + lodash: 4.17.21 + semver: 7.6.3 + transitivePeerDependencies: + - supports-color + + vue-i18n@9.14.0(vue@3.5.6(typescript@5.6.2)): + dependencies: + '@intlify/core-base': 9.14.0 + '@intlify/shared': 9.14.0 + '@vue/devtools-api': 6.6.4 + vue: 3.5.6(typescript@5.6.2) + + vue@3.5.6(typescript@5.6.2): + dependencies: + '@vue/compiler-dom': 3.5.6 + '@vue/compiler-sfc': 3.5.6 + '@vue/runtime-dom': 3.5.6 + '@vue/server-renderer': 3.5.6(vue@3.5.6(typescript@5.6.2)) + '@vue/shared': 3.5.6 + optionalDependencies: + typescript: 5.6.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrap-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 7.2.0 + strip-ansi: 7.1.0 + + xml-name-validator@4.0.0: {} + + yaml@2.5.1: {} + + yaml@2.6.0: {} + + yocto-queue@0.1.0: {} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/py/config.py b/py/config.py new file mode 100644 index 0000000..8efbdee --- /dev/null +++ b/py/config.py @@ -0,0 +1,33 @@ +extension_uri: str = None +model_base_paths: dict[str, list[str]] = {} + + +setting_key = { + "api_key": { + "civitai": "ModelManager.APIKey.Civitai", + "huggingface": "ModelManager.APIKey.HuggingFace", + }, + "download": { + "max_task_count": "ModelManager.Download.MaxTaskCount", + }, +} + +user_agent = "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148" + + +from server import PromptServer + +serverInstance = PromptServer.instance +routes = serverInstance.routes + + +class FakeRequest: + def __init__(self): + self.headers = {} + + +class CustomException(BaseException): + def __init__(self, type: str, message: str = None) -> None: + self.type = type + self.message = message + super().__init__(message) diff --git a/py/download.py b/py/download.py new file mode 100644 index 0000000..ca8f42d --- /dev/null +++ b/py/download.py @@ -0,0 +1,362 @@ +import os +import uuid +import time +import logging +import requests +import folder_paths +import traceback +from typing import Callable, Awaitable, Any, Literal, Union, Optional +from dataclasses import dataclass +from . import config +from . import utils +from . import socket +from . import thread + + +@dataclass +class TaskStatus: + taskId: str + type: str + fullname: str + preview: str + status: Literal["pause", "waiting", "doing"] = "pause" + platform: Union[str, None] = None + downloadedSize: float = 0 + totalSize: float = 0 + progress: float = 0 + bps: float = 0 + error: Optional[str] = None + + +@dataclass +class TaskContent: + type: str + pathIndex: int + fullname: str + description: str + downloadPlatform: str + downloadUrl: str + sizeBytes: float + hashes: Optional[dict[str, str]] = None + + +download_model_task_status: dict[str, TaskStatus] = {} +download_thread_pool = thread.DownloadThreadPool() + + +def set_task_content(task_id: str, task_content: Union[TaskContent, dict]): + download_path = utils.get_download_path() + task_file_path = os.path.join(download_path, f"{task_id}.task") + utils.save_dict_pickle_file(task_file_path, utils.unpack_dataclass(task_content)) + + +def get_task_content(task_id: str): + download_path = utils.get_download_path() + task_file = os.path.join(download_path, f"{task_id}.task") + if not os.path.isfile(task_file): + raise RuntimeError(f"Task {task_id} not found") + task_content = utils.load_dict_pickle_file(task_file) + task_content["pathIndex"] = int(task_content.get("pathIndex", 0)) + task_content["sizeBytes"] = float(task_content.get("sizeBytes", 0)) + return TaskContent(**task_content) + + +def get_task_status(task_id: str): + task_status = download_model_task_status.get(task_id, None) + + if task_status is None: + download_path = utils.get_download_path() + task_content = get_task_content(task_id) + download_file = os.path.join(download_path, f"{task_id}.download") + download_size = 0 + if os.path.exists(download_file): + download_size = os.path.getsize(download_file) + + total_size = task_content.sizeBytes + task_status = TaskStatus( + taskId=task_id, + type=task_content.type, + fullname=task_content.fullname, + preview=utils.get_model_preview_name(download_file), + platform=task_content.downloadPlatform, + downloadedSize=download_size, + totalSize=task_content.sizeBytes, + progress=download_size / total_size * 100 if total_size > 0 else 0, + ) + + download_model_task_status[task_id] = task_status + + return task_status + + +def delete_task_status(task_id: str): + download_model_task_status.pop(task_id, None) + + +async def scan_model_download_task_list(sid: str): + """ + Scan the download directory and send the task list to the client. + """ + try: + download_dir = utils.get_download_path() + task_files = utils.search_files(download_dir) + task_files = folder_paths.filter_files_extensions(task_files, [".task"]) + task_files = sorted( + task_files, + key=lambda x: os.stat(os.path.join(download_dir, x)).st_ctime, + reverse=True, + ) + task_list: list[dict] = [] + for task_file in task_files: + task_id = task_file.replace(".task", "") + task_status = get_task_status(task_id) + task_list.append(task_status) + + await socket.send_json("downloadTaskList", task_list, sid) + except Exception as e: + error_msg = f"Refresh task list failed: {e}" + await socket.send_json("error", error_msg, sid) + logging.error(error_msg) + + +async def create_model_download_task(post: dict): + """ + Creates a download task for the given post. + """ + model_type = post.get("type", None) + path_index = int(post.get("pathIndex", None)) + fullname = post.get("fullname", None) + + model_path = utils.get_full_path(model_type, path_index, fullname) + # Check if the model path is valid + if os.path.exists(model_path): + raise RuntimeError(f"File already exists: {model_path}") + + download_path = utils.get_download_path() + + task_id = uuid.uuid4().hex + task_path = os.path.join(download_path, f"{task_id}.task") + if os.path.exists(task_path): + raise RuntimeError(f"Task {task_id} already exists") + + try: + previewFile = post.pop("previewFile", None) + utils.save_model_preview_image(task_path, previewFile) + set_task_content(task_id, post) + task_status = TaskStatus( + taskId=task_id, + type=model_type, + fullname=fullname, + preview=utils.get_model_preview_name(task_path), + platform=post.get("downloadPlatform", None), + totalSize=float(post.get("sizeBytes", 0)), + ) + download_model_task_status[task_id] = task_status + await socket.send_json("createDownloadTask", task_status) + except Exception as e: + await delete_model_download_task(task_id) + raise RuntimeError(str(e)) from e + + await download_model(task_id) + return task_id + + +async def pause_model_download_task(task_id: str): + task_status = get_task_status(task_id=task_id) + task_status.status = "pause" + + +async def delete_model_download_task(task_id: str): + task_status = get_task_status(task_id) + is_running = task_status.status == "doing" + task_status.status = "waiting" + await socket.send_json("deleteDownloadTask", task_id) + + # Pause the task + if is_running: + task_status.status = "pause" + time.sleep(1) + + download_dir = utils.get_download_path() + task_file_list = os.listdir(download_dir) + for task_file in task_file_list: + task_file_target = os.path.splitext(task_file)[0] + if task_file_target == task_id: + delete_task_status(task_id) + os.remove(os.path.join(download_dir, task_file)) + + await socket.send_json("deleteDownloadTask", task_id) + + +async def download_model(task_id: str): + async def download_task(task_id: str): + async def report_progress(task_status: TaskStatus): + await socket.send_json("updateDownloadTask", task_status) + + try: + # When starting a task from the queue, the task may not exist + task_status = get_task_status(task_id) + except: + return + + # Update task status + task_status.status = "doing" + await socket.send_json("updateDownloadTask", task_status) + + try: + + # Set download request headers + headers = {"User-Agent": config.user_agent} + + download_platform = task_status.platform + if download_platform == "civitai": + api_key = utils.get_setting_value("api_key.civitai") + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + elif download_platform == "huggingface": + api_key = utils.get_setting_value("api_key.huggingface") + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + progress_interval = 1.0 + await download_model_file( + task_id=task_id, + headers=headers, + progress_callback=report_progress, + interval=progress_interval, + ) + except Exception as e: + task_status.status = "pause" + task_status.error = str(e) + await socket.send_json("updateDownloadTask", task_status) + task_status.error = None + logging.error(str(e)) + + try: + status = download_thread_pool.submit(download_task, task_id) + if status == "Waiting": + task_status = get_task_status(task_id) + task_status.status = "waiting" + await socket.send_json("updateDownloadTask", task_status) + except Exception as e: + task_status.status = "pause" + task_status.error = str(e) + await socket.send_json("updateDownloadTask", task_status) + task_status.error = None + logging.error(traceback.format_exc()) + + +async def download_model_file( + task_id: str, + headers: dict, + progress_callback: Callable[[TaskStatus], Awaitable[Any]], + interval: float = 1.0, +): + + async def download_complete(): + """ + Restore the model information from the task file + and move the model file to the target directory. + """ + model_type = task_content.type + path_index = task_content.pathIndex + fullname = task_content.fullname + # Write description file + description = task_content.description + description_file = os.path.join(download_path, f"{task_id}.md") + with open(description_file, "w") as f: + f.write(description) + + model_path = utils.get_full_path(model_type, path_index, fullname) + + utils.rename_model(download_tmp_file, model_path) + + time.sleep(1) + task_file = os.path.join(download_path, f"{task_id}.task") + os.remove(task_file) + await socket.send_json("completeDownloadTask", task_id) + + async def update_progress(): + nonlocal last_update_time + nonlocal last_downloaded_size + progress = (downloaded_size / total_size) * 100 if total_size > 0 else 0 + task_status.downloadedSize = downloaded_size + task_status.progress = progress + task_status.bps = downloaded_size - last_downloaded_size + await progress_callback(task_status) + last_update_time = time.time() + last_downloaded_size = downloaded_size + + task_status = get_task_status(task_id) + task_content = get_task_content(task_id) + + # Check download uri + model_url = task_content.downloadUrl + if not model_url: + raise RuntimeError("No downloadUrl found") + + download_path = utils.get_download_path() + download_tmp_file = os.path.join(download_path, f"{task_id}.download") + + downloaded_size = 0 + if os.path.isfile(download_tmp_file): + downloaded_size = os.path.getsize(download_tmp_file) + headers["Range"] = f"bytes={downloaded_size}-" + + total_size = task_content.sizeBytes + + if total_size > 0 and downloaded_size == total_size: + await download_complete() + return + + last_update_time = time.time() + last_downloaded_size = downloaded_size + + response = requests.get( + url=model_url, + headers=headers, + stream=True, + allow_redirects=True, + ) + + if response.status_code not in (200, 206): + raise RuntimeError( + f"Failed to download {task_content.fullname}, status code: {response.status_code}" + ) + + # Some models require logging in before they can be downloaded. + # If no token is carried, it will be redirected to the login page. + content_type = response.headers.get("content-type") + if content_type and content_type.startswith("text/html"): + raise RuntimeError( + f"{task_content.fullname} needs to be logged in to download. Please set the API-Key first." + ) + + # When parsing model information from HuggingFace API, + # the file size was not found and needs to be obtained from the response header. + if total_size == 0: + total_size = int(response.headers.get("content-length", 0)) + task_content.sizeBytes = total_size + task_status.totalSize = total_size + set_task_content(task_id, task_content) + await socket.send_json("updateDownloadTask", task_content) + + with open(download_tmp_file, "ab") as f: + for chunk in response.iter_content(chunk_size=8192): + if task_status.status == "pause": + break + + f.write(chunk) + downloaded_size += len(chunk) + + if time.time() - last_update_time >= interval: + await update_progress() + + await update_progress() + + if total_size > 0 and downloaded_size == total_size: + await download_complete() + else: + task_status.status = "pause" + await socket.send_json("updateDownloadTask", task_status) diff --git a/py/services.py b/py/services.py new file mode 100644 index 0000000..0edd8ca --- /dev/null +++ b/py/services.py @@ -0,0 +1,140 @@ +import os +import logging +import traceback +import folder_paths + +from typing import Any +from multidict import MultiDictProxy +from . import config +from . import utils +from . import socket +from . import download + + +async def connect_websocket(request): + async def message_handler(event_type: str, detail: Any, sid: str): + try: + if event_type == "downloadTaskList": + await download.scan_model_download_task_list(sid=sid) + + if event_type == "resumeDownloadTask": + await download.download_model(task_id=detail) + + if event_type == "pauseDownloadTask": + await download.pause_model_download_task(task_id=detail) + + if event_type == "deleteDownloadTask": + await download.delete_model_download_task(task_id=detail) + except Exception: + logging.error(traceback.format_exc()) + + ws = await socket.create_websocket_handler(request, handler=message_handler) + return ws + + +def scan_models(): + result = [] + model_base_paths = config.model_base_paths + for model_type in model_base_paths: + + folders, extensions = folder_paths.folder_names_and_paths[model_type] + for path_index, base_path in enumerate(folders): + files = utils.recursive_search_files(base_path) + + models = folder_paths.filter_files_extensions(files, extensions) + images = folder_paths.filter_files_content_types(files, ["image"]) + image_dict = utils.file_list_to_name_dict(images) + + for fullname in models: + fullname = fullname.replace(os.path.sep, "/") + basename = os.path.splitext(fullname)[0] + extension = os.path.splitext(fullname)[1] + + abs_path = os.path.join(base_path, fullname) + file_stats = os.stat(abs_path) + + # Resolve preview + image_name = image_dict.get(basename, "no-preview.png") + abs_image_path = os.path.join(base_path, image_name) + if os.path.isfile(abs_image_path): + image_state = os.stat(abs_image_path) + image_timestamp = round(image_state.st_mtime_ns / 1000000) + image_name = f"{image_name}?ts={image_timestamp}" + model_preview = ( + f"/model-manager/preview/{model_type}/{path_index}/{image_name}" + ) + + model_info = { + "fullname": fullname, + "basename": basename, + "extension": extension, + "type": model_type, + "pathIndex": path_index, + "sizeBytes": file_stats.st_size, + "preview": model_preview, + "createdAt": round(file_stats.st_ctime_ns / 1000000), + "updatedAt": round(file_stats.st_mtime_ns / 1000000), + } + + result.append(model_info) + + return result + + +def get_model_info(model_path: str): + directory = os.path.dirname(model_path) + + metadata = utils.get_model_metadata(model_path) + + description_file = utils.get_model_description_name(model_path) + description_file = os.path.join(directory, description_file) + description = None + if os.path.isfile(description_file): + with open(description_file, "r", encoding="utf-8") as f: + description = f.read() + + return { + "metadata": metadata, + "description": description, + } + + +def update_model(model_path: str, post: MultiDictProxy): + + if "previewFile" in post: + previewFile = post["previewFile"] + utils.save_model_preview_image(model_path, previewFile) + + if "description" in post: + description = post["description"] + utils.save_model_description(model_path, description) + + if "type" in post and "pathIndex" in post and "fullname" in post: + model_type = post.get("type", None) + path_index = int(post.get("pathIndex", None)) + fullname = post.get("fullname", None) + if model_type is None or path_index is None or fullname is None: + raise RuntimeError("Invalid type or pathIndex or fullname") + + # get new path + new_model_path = utils.get_full_path(model_type, path_index, fullname) + + utils.rename_model(model_path, new_model_path) + + +def remove_model(model_path: str): + model_dirname = os.path.dirname(model_path) + os.remove(model_path) + + model_previews = utils.get_model_all_images(model_path) + for preview in model_previews: + os.remove(os.path.join(model_dirname, preview)) + + model_descriptions = utils.get_model_all_descriptions(model_path) + for description in model_descriptions: + os.remove(os.path.join(model_dirname, description)) + + +async def create_model_download_task(post): + dict_post = dict(post) + return await download.create_model_download_task(dict_post) diff --git a/py/socket.py b/py/socket.py new file mode 100644 index 0000000..13a39f9 --- /dev/null +++ b/py/socket.py @@ -0,0 +1,63 @@ +import aiohttp +import logging +import uuid +import json +from aiohttp import web +from typing import Any, Callable, Awaitable +from . import utils + + +__sockets: dict[str, web.WebSocketResponse] = {} + + +async def create_websocket_handler( + request, handler: Callable[[str, Any, str], Awaitable[Any]] +): + ws = web.WebSocketResponse() + await ws.prepare(request) + sid = request.rel_url.query.get("clientId", "") + if sid: + # Reusing existing session, remove old + __sockets.pop(sid, None) + else: + sid = uuid.uuid4().hex + + __sockets[sid] = ws + + try: + async for msg in ws: + if msg.type == aiohttp.WSMsgType.ERROR: + logging.warning( + "ws connection closed with exception %s" % ws.exception() + ) + if msg.type == aiohttp.WSMsgType.TEXT: + data = json.loads(msg.data) + await handler(data.get("type"), data.get("detail"), sid) + finally: + __sockets.pop(sid, None) + return ws + + +async def send_json(event: str, data: Any, sid: str = None): + detail = utils.unpack_dataclass(data) + message = {"type": event, "data": detail} + + if sid is None: + socket_list = list(__sockets.values()) + for ws in socket_list: + await __send_socket_catch_exception(ws.send_json, message) + elif sid in __sockets: + await __send_socket_catch_exception(__sockets[sid].send_json, message) + + +async def __send_socket_catch_exception(function, message): + try: + await function(message) + except ( + aiohttp.ClientError, + aiohttp.ClientPayloadError, + ConnectionResetError, + BrokenPipeError, + ConnectionError, + ) as err: + logging.warning("send error: {}".format(err)) diff --git a/py/thread.py b/py/thread.py new file mode 100644 index 0000000..82689f7 --- /dev/null +++ b/py/thread.py @@ -0,0 +1,64 @@ +import asyncio +import threading +import queue +import logging +from . import utils + + +class DownloadThreadPool: + def __init__(self) -> None: + self.workers_count = 0 + self.task_queue = queue.Queue() + self.running_tasks = set() + self._lock = threading.Lock() + + default_max_workers = 5 + max_workers: int = utils.get_setting_value( + "download.max_task_count", default_max_workers + ) + + if max_workers <= 0: + max_workers = default_max_workers + utils.set_setting_value("download.max_task_count", max_workers) + + self.max_worker = max_workers + + def submit(self, task, task_id): + with self._lock: + if task_id in self.running_tasks: + return "Existing" + self.running_tasks.add(task_id) + self.task_queue.put((task, task_id)) + return self._adjust_worker_count() + + def _adjust_worker_count(self): + if self.workers_count < self.max_worker: + self._start_worker() + return "Running" + else: + return "Waiting" + + def _start_worker(self): + t = threading.Thread(target=self._worker, daemon=True) + t.start() + with self._lock: + self.workers_count += 1 + + def _worker(self): + loop = asyncio.new_event_loop() + + while True: + if self.task_queue.empty(): + break + + task, task_id = self.task_queue.get() + + try: + loop.run_until_complete(task(task_id)) + with self._lock: + self.running_tasks.remove(task_id) + except Exception as e: + logging.error(f"worker run error: {str(e)}") + + with self._lock: + self.workers_count -= 1 diff --git a/py/utils.py b/py/utils.py new file mode 100644 index 0000000..90c6515 --- /dev/null +++ b/py/utils.py @@ -0,0 +1,354 @@ +import os +import json +import yaml +import shutil +import tarfile +import logging +import requests +import configparser + +import comfy.utils +import folder_paths + +from aiohttp import web +from typing import Any +from . import config + + +def get_current_version(): + try: + pyproject_path = os.path.join(config.extension_uri, "pyproject.toml") + config_parser = configparser.ConfigParser() + config_parser.read(pyproject_path) + version = config_parser.get("project", "version") + return version.strip("'\"") + except: + return "0.0.0" + + +def download_web_distribution(version: str): + web_path = os.path.join(config.extension_uri, "web") + dev_web_file = os.path.join(web_path, "manager-dev.js") + if os.path.exists(dev_web_file): + return + + web_version = "0.0.0" + version_file = os.path.join(web_path, "version.yaml") + if os.path.exists(version_file): + with open(version_file, "r") as f: + version_content = yaml.safe_load(f) + web_version = version_content.get("version", web_version) + + if version == web_version: + return + + try: + logging.info(f"current version {version}, web version {web_version}") + logging.info("Downloading web distribution...") + download_url = f"https://github.com/hayden-fr/ComfyUI-Model-Manager/releases/download/v{version}/dist.tar.gz" + response = requests.get(download_url, stream=True) + response.raise_for_status() + + temp_file = os.path.join(config.extension_uri, "temp.tar.gz") + with open(temp_file, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + if os.path.exists(web_path): + shutil.rmtree(web_path) + + logging.info("Extracting web distribution...") + with tarfile.open(temp_file, "r:gz") as tar: + members = [ + member for member in tar.getmembers() if member.name.startswith("web/") + ] + tar.extractall(path=config.extension_uri, members=members) + + os.remove(temp_file) + logging.info("Web distribution downloaded successfully.") + except requests.exceptions.RequestException as e: + logging.error(f"Failed to download web distribution: {e}") + except tarfile.TarError as e: + logging.error(f"Failed to extract web distribution: {e}") + except Exception as e: + logging.error(f"An unexpected error occurred: {e}") + + +def resolve_model_base_paths(): + folders = list(folder_paths.folder_names_and_paths.keys()) + config.model_base_paths = {} + for folder in folders: + if folder == "configs": + continue + if folder == "custom_nodes": + continue + config.model_base_paths[folder] = folder_paths.get_folder_paths(folder) + + +def get_full_path(model_type: str, path_index: int, filename: str): + """ + Get the absolute path in the model type through string concatenation. + """ + folders = config.model_base_paths.get(model_type, []) + if not path_index < len(folders): + raise RuntimeError(f"PathIndex {path_index} is not in {model_type}") + base_path = folders[path_index] + return os.path.join(base_path, filename) + + +def get_valid_full_path(model_type: str, path_index: int, filename: str): + """ + Like get_full_path but it will check whether the file is valid. + """ + folders = config.model_base_paths.get(model_type, []) + if not path_index < len(folders): + raise RuntimeError(f"PathIndex {path_index} is not in {model_type}") + base_path = folders[path_index] + full_path = os.path.join(base_path, filename) + if os.path.isfile(full_path): + return full_path + elif os.path.islink(full_path): + raise RuntimeError( + f"WARNING path {full_path} exists but doesn't link anywhere, skipping." + ) + + +def get_download_path(): + download_path = os.path.join(config.extension_uri, "downloads") + if not os.path.exists(download_path): + os.makedirs(download_path) + return download_path + + +def recursive_search_files(directory: str): + files, folder_all = folder_paths.recursive_search( + directory, excluded_dir_names=[".git"] + ) + return files + + +def search_files(directory: str): + entries = os.listdir(directory) + files = [f for f in entries if os.path.isfile(os.path.join(directory, f))] + return files + + +def file_list_to_name_dict(files: list[str]): + file_dict: dict[str, str] = {} + for file in files: + filename = os.path.splitext(file)[0] + file_dict[filename] = file + return file_dict + + +def get_model_metadata(filename: str): + if not filename.endswith(".safetensors"): + return {} + try: + out = comfy.utils.safetensors_header(filename, max_size=1024 * 1024) + if out is None: + return {} + dt = json.loads(out) + if not "__metadata__" in dt: + return {} + return dt["__metadata__"] + except: + return {} + + +def get_model_all_images(model_path: str): + base_dirname = os.path.dirname(model_path) + files = search_files(base_dirname) + files = folder_paths.filter_files_content_types(files, ["image"]) + + basename = os.path.splitext(os.path.basename(model_path))[0] + output: list[str] = [] + for file in files: + file_basename = os.path.splitext(file)[0] + if file_basename == basename: + output.append(file) + if file_basename == f"{basename}.preview": + output.append(file) + return output + + +def get_model_preview_name(model_path: str): + images = get_model_all_images(model_path) + return images[0] if len(images) > 0 else "no-preview.png" + + +def save_model_preview_image(model_path: str, image_file: Any): + if not isinstance(image_file, web.FileField): + raise RuntimeError("Invalid image file") + + content_type: str = image_file.content_type + if not content_type.startswith("image/"): + raise RuntimeError(f"FileTypeError: expected image, got {content_type}") + + base_dirname = os.path.dirname(model_path) + + # remove old preview images + old_preview_images = get_model_all_images(model_path) + a1111_civitai_helper_image = False + for image in old_preview_images: + if os.path.splitext(image)[1].endswith(".preview"): + a1111_civitai_helper_image = True + image_path = os.path.join(base_dirname, image) + os.remove(image_path) + + # save new preview image + basename = os.path.splitext(os.path.basename(model_path))[0] + extension = f".{content_type.split('/')[1]}" + new_preview_path = os.path.join(base_dirname, f"{basename}{extension}") + + with open(new_preview_path, "wb") as f: + f.write(image_file.file.read()) + + # TODO Is it possible to abandon the current rules and adopt the rules of a1111 civitai_helper? + if a1111_civitai_helper_image: + """ + Keep preview image of a1111_civitai_helper + """ + new_preview_path = os.path.join(base_dirname, f"{basename}.preview{extension}") + with open(new_preview_path, "wb") as f: + f.write(image_file.file.read()) + + +def get_model_all_descriptions(model_path: str): + base_dirname = os.path.dirname(model_path) + files = search_files(base_dirname) + files = folder_paths.filter_files_extensions(files, [".txt", ".md"]) + + basename = os.path.splitext(os.path.basename(model_path))[0] + output: list[str] = [] + for file in files: + file_basename = os.path.splitext(file)[0] + if file_basename == basename: + output.append(file) + return output + + +def get_model_description_name(model_path: str): + descriptions = get_model_all_descriptions(model_path) + basename = os.path.splitext(os.path.basename(model_path))[0] + return descriptions[0] if len(descriptions) > 0 else f"{basename}.md" + + +def save_model_description(model_path: str, content: Any): + if not isinstance(content, str): + raise RuntimeError("Invalid description") + + base_dirname = os.path.dirname(model_path) + + # remove old descriptions + old_descriptions = get_model_all_descriptions(model_path) + for desc in old_descriptions: + description_path = os.path.join(base_dirname, desc) + os.remove(description_path) + + # save new description + basename = os.path.splitext(os.path.basename(model_path))[0] + extension = ".md" + new_desc_path = os.path.join(base_dirname, f"{basename}{extension}") + + with open(new_desc_path, "w", encoding="utf-8") as f: + f.write(content) + + +def rename_model(model_path: str, new_model_path: str): + if model_path == new_model_path: + return + + if os.path.exists(new_model_path): + raise RuntimeError(f"Model {new_model_path} already exists") + + model_name = os.path.splitext(os.path.basename(model_path))[0] + new_model_name = os.path.splitext(os.path.basename(new_model_path))[0] + + model_dirname = os.path.dirname(model_path) + new_model_dirname = os.path.dirname(new_model_path) + + if not os.path.exists(new_model_dirname): + os.makedirs(new_model_dirname) + + # move model + os.rename(model_path, new_model_path) + + # move preview + previews = get_model_all_images(model_path) + for preview in previews: + preview_path = os.path.join(model_dirname, preview) + preview_name = os.path.splitext(preview)[0] + preview_ext = os.path.splitext(preview)[1] + new_preview_path = ( + os.path.join(new_model_dirname, new_model_name + preview_ext) + if preview_name == model_name + else os.path.join( + new_model_dirname, new_model_name + ".preview" + preview_ext + ) + ) + os.rename(preview_path, new_preview_path) + + # move description + description = get_model_description_name(model_path) + description_path = os.path.join(model_dirname, description) + if os.path.isfile(description_path): + new_description_path = os.path.join(new_model_dirname, f"{new_model_name}.md") + os.rename(description_path, new_description_path) + + +import pickle + + +def save_dict_pickle_file(filename: str, data: dict): + with open(filename, "wb") as f: + pickle.dump(data, f) + + +def load_dict_pickle_file(filename: str) -> dict: + with open(filename, "rb") as f: + data = pickle.load(f) + return data + + +def resolve_setting_key(key: str) -> str: + key_paths = key.split(".") + setting_id = config.setting_key + try: + for key_path in key_paths: + setting_id = setting_id[key_path] + except: + pass + if not isinstance(setting_id, str): + raise RuntimeError(f"Invalid key: {key}") + + return setting_id + + +def set_setting_value(key: str, value: Any): + setting_id = resolve_setting_key(key) + fake_request = config.FakeRequest() + settings = config.serverInstance.user_manager.settings.get_settings(fake_request) + settings[setting_id] = value + config.serverInstance.user_manager.settings.save_settings(fake_request, settings) + + +def get_setting_value(key: str, default: Any = None) -> Any: + setting_id = resolve_setting_key(key) + fake_request = config.FakeRequest() + settings = config.serverInstance.user_manager.settings.get_settings(fake_request) + return settings.get(setting_id, default) + + +from dataclasses import asdict, is_dataclass + + +def unpack_dataclass(data: Any): + if isinstance(data, dict): + return {key: unpack_dataclass(value) for key, value in data.items()} + elif isinstance(data, list): + return [unpack_dataclass(x) for x in data] + elif is_dataclass(data): + return asdict(data) + else: + return data diff --git a/pyproject.toml b/pyproject.toml index fcb572f..d920d65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui-model-manager" description = "Manage models: browsing, download and delete." -version = "1.0.0" +version = "2.0.0" license = "LICENSE" [project.urls] diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..780f4f3 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,89 @@ + + + diff --git a/src/components/DialogCreateTask.vue b/src/components/DialogCreateTask.vue new file mode 100644 index 0000000..b56a2dd --- /dev/null +++ b/src/components/DialogCreateTask.vue @@ -0,0 +1,137 @@ + + + diff --git a/src/components/DialogDownload.vue b/src/components/DialogDownload.vue new file mode 100644 index 0000000..0d25328 --- /dev/null +++ b/src/components/DialogDownload.vue @@ -0,0 +1,93 @@ + + + diff --git a/src/components/DialogManager.vue b/src/components/DialogManager.vue new file mode 100644 index 0000000..721e0ec --- /dev/null +++ b/src/components/DialogManager.vue @@ -0,0 +1,182 @@ + + + diff --git a/src/components/DialogModelDetail.vue b/src/components/DialogModelDetail.vue new file mode 100644 index 0000000..ec0a047 --- /dev/null +++ b/src/components/DialogModelDetail.vue @@ -0,0 +1,90 @@ + + + diff --git a/src/components/GlobalDialogStack.vue b/src/components/GlobalDialogStack.vue new file mode 100644 index 0000000..74a6ac5 --- /dev/null +++ b/src/components/GlobalDialogStack.vue @@ -0,0 +1,46 @@ + + + diff --git a/src/components/GlobalLoading.vue b/src/components/GlobalLoading.vue new file mode 100644 index 0000000..f0be0e5 --- /dev/null +++ b/src/components/GlobalLoading.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/GlobalToast.vue b/src/components/GlobalToast.vue new file mode 100644 index 0000000..af090ee --- /dev/null +++ b/src/components/GlobalToast.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/ModelBaseInfo.vue b/src/components/ModelBaseInfo.vue new file mode 100644 index 0000000..e76096d --- /dev/null +++ b/src/components/ModelBaseInfo.vue @@ -0,0 +1,89 @@ + + + diff --git a/src/components/ModelCard.vue b/src/components/ModelCard.vue new file mode 100644 index 0000000..d8763c3 --- /dev/null +++ b/src/components/ModelCard.vue @@ -0,0 +1,103 @@ + + + diff --git a/src/components/ModelContent.vue b/src/components/ModelContent.vue new file mode 100644 index 0000000..e8c9128 --- /dev/null +++ b/src/components/ModelContent.vue @@ -0,0 +1,96 @@ + + + diff --git a/src/components/ModelDescription.vue b/src/components/ModelDescription.vue new file mode 100644 index 0000000..b42204a --- /dev/null +++ b/src/components/ModelDescription.vue @@ -0,0 +1,91 @@ + + + diff --git a/src/components/ModelMetadata.vue b/src/components/ModelMetadata.vue new file mode 100644 index 0000000..69f885d --- /dev/null +++ b/src/components/ModelMetadata.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/components/ModelPreview.vue b/src/components/ModelPreview.vue new file mode 100644 index 0000000..6b45e93 --- /dev/null +++ b/src/components/ModelPreview.vue @@ -0,0 +1,112 @@ + + + diff --git a/src/components/ResponseDialog.vue b/src/components/ResponseDialog.vue new file mode 100644 index 0000000..77086ea --- /dev/null +++ b/src/components/ResponseDialog.vue @@ -0,0 +1,334 @@ + + + diff --git a/src/components/ResponseFileUpload.vue b/src/components/ResponseFileUpload.vue new file mode 100644 index 0000000..24f2754 --- /dev/null +++ b/src/components/ResponseFileUpload.vue @@ -0,0 +1,56 @@ + + + diff --git a/src/components/ResponseImage.vue b/src/components/ResponseImage.vue new file mode 100644 index 0000000..8de839c --- /dev/null +++ b/src/components/ResponseImage.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/components/ResponseInput.vue b/src/components/ResponseInput.vue new file mode 100644 index 0000000..1083998 --- /dev/null +++ b/src/components/ResponseInput.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/src/components/ResponseScroll.vue b/src/components/ResponseScroll.vue new file mode 100644 index 0000000..0b60689 --- /dev/null +++ b/src/components/ResponseScroll.vue @@ -0,0 +1,315 @@ + + + diff --git a/src/components/ResponseSelect.vue b/src/components/ResponseSelect.vue new file mode 100644 index 0000000..a7ae032 --- /dev/null +++ b/src/components/ResponseSelect.vue @@ -0,0 +1,234 @@ + + + diff --git a/src/hooks/config.ts b/src/hooks/config.ts new file mode 100644 index 0000000..628be9a --- /dev/null +++ b/src/hooks/config.ts @@ -0,0 +1,69 @@ +import { useRequest } from 'hooks/request' +import { defineStore } from 'hooks/store' +import { app } from 'scripts/comfyAPI' +import { onMounted, onUnmounted, ref } from 'vue' + +export const useConfig = defineStore('config', () => { + const mobileDeviceBreakPoint = 759 + const isMobile = ref(window.innerWidth < mobileDeviceBreakPoint) + + type ModelFolder = Record + const { data: modelFolders, refresh: refreshModelFolders } = + useRequest('/base-folders') + + const checkDeviceType = () => { + isMobile.value = window.innerWidth < mobileDeviceBreakPoint + } + + onMounted(() => { + window.addEventListener('resize', checkDeviceType) + }) + + onUnmounted(() => { + window.removeEventListener('resize', checkDeviceType) + }) + + const refresh = async () => { + return Promise.all([refreshModelFolders()]) + } + + const config = { + isMobile, + gutter: 16, + cardWidth: 240, + aspect: 7 / 9, + modelFolders, + refresh, + } + + useAddConfigSettings(config) + + return config +}) + +type Config = ReturnType + +declare module 'hooks/store' { + interface StoreProvider { + config: Config + } +} + +function useAddConfigSettings(config: Config) { + onMounted(() => { + // API keys + app.ui?.settings.addSetting({ + id: 'ModelManager.APIKey.HuggingFace', + name: 'HuggingFace API Key', + type: 'text', + defaultValue: undefined, + }) + + app.ui?.settings.addSetting({ + id: 'ModelManager.APIKey.Civitai', + name: 'Civitai API Key', + type: 'text', + defaultValue: undefined, + }) + }) +} diff --git a/src/hooks/dialog.ts b/src/hooks/dialog.ts new file mode 100644 index 0000000..fd454cf --- /dev/null +++ b/src/hooks/dialog.ts @@ -0,0 +1,66 @@ +import { defineStore } from 'hooks/store' +import { Component, markRaw, ref } from 'vue' + +interface HeaderButton { + icon: string + command: () => void +} + +interface DialogItem { + key: string + title: string + content: Component + contentProps?: Record + keepAlive?: boolean + headerButtons?: HeaderButton[] + defaultSize?: Partial + defaultMobileSize?: Partial + resizeAllow?: { x?: boolean; y?: boolean } + minWidth?: number + maxWidth?: number + minHeight?: number + maxHeight?: number +} + +export const useDialog = defineStore('dialog', () => { + const stack = ref<(DialogItem & { visible?: boolean })[]>([]) + + const rise = (dialog: { key: string }) => { + const index = stack.value.findIndex((item) => item.key === dialog.key) + if (index !== -1) { + const item = stack.value.splice(index, 1) + stack.value.push(...item) + } + } + + const open = (dialog: DialogItem) => { + const item = stack.value.find((item) => item.key === dialog.key) + if (item) { + item.visible = true + rise(dialog) + } else { + stack.value.push({ + ...dialog, + content: markRaw(dialog.content), + visible: true, + }) + } + } + + const close = (dialog: { key: string }) => { + const item = stack.value.find((item) => item.key === dialog.key) + if (item?.keepAlive) { + item.visible = false + } else { + stack.value = stack.value.filter((item) => item.key !== dialog.key) + } + } + + return { stack, open, close, rise } +}) + +declare module 'hooks/store' { + interface StoreProvider { + dialog: ReturnType + } +} diff --git a/src/hooks/download.ts b/src/hooks/download.ts new file mode 100644 index 0000000..fb1f865 --- /dev/null +++ b/src/hooks/download.ts @@ -0,0 +1,442 @@ +import { useLoading } from 'hooks/loading' +import { MarkdownTool, useMarkdown } from 'hooks/markdown' +import { socket } from 'hooks/socket' +import { defineStore } from 'hooks/store' +import { useToast } from 'hooks/toast' +import { bytesToSize } from 'utils/common' +import { onBeforeMount, onMounted, ref, watch } from 'vue' +import { useI18n } from 'vue-i18n' + +export const useDownload = defineStore('download', (store) => { + const { toast, confirm } = useToast() + const { t } = useI18n() + + const taskList = ref([]) + + const refresh = () => { + socket.send('downloadTaskList', null) + } + + const createTaskItem = (item: DownloadTaskOptions) => { + const { downloadedSize, totalSize, bps, ...rest } = item + + const task: DownloadTask = { + ...rest, + preview: `/model-manager/preview/download/${item.preview}`, + downloadProgress: `${bytesToSize(downloadedSize)} / ${bytesToSize(totalSize)}`, + downloadSpeed: `${bytesToSize(bps)}/s`, + pauseTask() { + socket.send('pauseDownloadTask', item.taskId) + }, + resumeTask: () => { + socket.send('resumeDownloadTask', item.taskId) + }, + deleteTask: () => { + confirm.require({ + message: t('deleteAsk', [t('downloadTask').toLowerCase()]), + header: 'Danger', + icon: 'pi pi-info-circle', + rejectProps: { + label: t('cancel'), + severity: 'secondary', + outlined: true, + }, + acceptProps: { + label: t('delete'), + severity: 'danger', + }, + accept: () => { + socket.send('deleteDownloadTask', item.taskId) + }, + reject: () => {}, + }) + }, + } + + return task + } + + onBeforeMount(() => { + socket.addEventListener('reconnected', () => { + refresh() + }) + + socket.addEventListener('downloadTaskList', (event) => { + const data = event.detail as DownloadTaskOptions[] + + taskList.value = data.map((item) => { + return createTaskItem(item) + }) + }) + + socket.addEventListener('createDownloadTask', (event) => { + const item = event.detail as DownloadTaskOptions + taskList.value.unshift(createTaskItem(item)) + }) + + socket.addEventListener('updateDownloadTask', (event) => { + const item = event.detail as DownloadTaskOptions + + for (const task of taskList.value) { + if (task.taskId === item.taskId) { + if (item.error) { + toast.add({ + severity: 'error', + summary: 'Error', + detail: item.error, + life: 15000, + }) + item.error = undefined + } + Object.assign(task, createTaskItem(item)) + } + } + }) + + socket.addEventListener('deleteDownloadTask', (event) => { + const taskId = event.detail as string + taskList.value = taskList.value.filter((item) => item.taskId !== taskId) + }) + + socket.addEventListener('completeDownloadTask', (event) => { + const taskId = event.detail as string + const task = taskList.value.find((item) => item.taskId === taskId) + taskList.value = taskList.value.filter((item) => item.taskId !== taskId) + toast.add({ + severity: 'success', + summary: 'Success', + detail: `${task?.fullname} Download completed`, + life: 2000, + }) + store.models.refresh() + }) + }) + + onMounted(() => { + refresh() + }) + + return { data: taskList, refresh } +}) + +declare module 'hooks/store' { + interface StoreProvider { + download: ReturnType + } +} + +abstract class ModelSearch { + constructor(readonly md: MarkdownTool) {} + + abstract search(pathname: string): Promise +} + +class Civitai extends ModelSearch { + async search(searchUrl: string): Promise { + const { pathname, searchParams } = new URL(searchUrl) + + const [, modelId] = pathname.match(/^\/models\/(\d*)/) ?? [] + const versionId = searchParams.get('modelVersionId') + + if (!modelId) { + return Promise.resolve([]) + } + + return fetch(`https://civitai.com/api/v1/models/${modelId}`) + .then((response) => response.json()) + .then((resData) => { + const modelVersions: any[] = resData.modelVersions.filter( + (version: any) => { + if (versionId) { + return version.id == versionId + } + return true + }, + ) + + const models: VersionModel[] = [] + + for (const version of modelVersions) { + const modelFiles: any[] = version.files.filter( + (file: any) => file.type === 'Model', + ) + + const shortname = modelFiles.length > 0 ? version.name : undefined + + for (const file of modelFiles) { + const fullname = file.name + const extension = `.${fullname.split('.').pop()}` + const basename = fullname.replace(extension, '') + + models.push({ + id: file.id, + shortname: shortname ?? basename, + fullname: fullname, + basename: basename, + extension: extension, + preview: version.images.map((i: any) => i.url), + sizeBytes: file.sizeKB * 1024, + type: this.resolveType(resData.type), + pathIndex: 0, + description: [ + '---', + ...[ + `website: Civitai`, + `modelPage: https://civitai.com/models/${modelId}?modelVersionId=${version.id}`, + `author: ${resData.creator?.username}`, + version.baseModel && `baseModel: ${version.baseModel}`, + file.hashes && `hashes:`, + ...Object.entries(file.hashes ?? {}).map( + ([key, value]) => ` ${key}: ${value}`, + ), + file.metadata && `metadata:`, + ...Object.entries(file.metadata ?? {}).map( + ([key, value]) => ` ${key}: ${value}`, + ), + ].filter(Boolean), + '---', + '', + '# Trigger Words', + `\n${(version.trainedWords ?? ['No trigger words']).join(', ')}\n`, + '# About this version', + this.resolveDescription( + version.description, + '\nNo description about this version\n', + ), + `# ${resData.name}`, + this.resolveDescription( + resData.description, + 'No description about this model', + ), + ].join('\n'), + metadata: file.metadata, + downloadPlatform: 'civitai', + downloadUrl: file.downloadUrl, + hashes: file.hashes, + }) + } + } + + return models + }) + } + + private resolveType(type: string) { + const mapLegacy = { + TextualInversion: 'embeddings', + LoCon: 'loras', + DoRA: 'loras', + Controlnet: 'controlnet', + Upscaler: 'upscale_models', + VAE: 'vae', + } + return mapLegacy[type] ?? `${type.toLowerCase()}s` + } + + private resolveDescription(content: string, defaultContent: string) { + const mdContent = this.md.parse(content ?? '').trim() + return mdContent || defaultContent + } +} + +class Huggingface extends ModelSearch { + async search(searchUrl: string): Promise { + const { pathname } = new URL(searchUrl) + const [, space, name, ...restPaths] = pathname.split('/') + + if (!space || !name) { + return Promise.resolve([]) + } + + const modelId = `${space}/${name}` + const restPathname = restPaths.join('/') + + return fetch(`https://huggingface.co/api/models/${modelId}`) + .then((response) => response.json()) + .then((resData) => { + const siblingFiles: string[] = resData.siblings.map( + (item: any) => item.rfilename, + ) + + const modelFiles: string[] = this.filterTreeFiles( + this.filterModelFiles(siblingFiles), + restPathname, + ) + const images: string[] = this.filterTreeFiles( + this.filterImageFiles(siblingFiles), + restPathname, + ).map((filename) => { + return `https://huggingface.co/${modelId}/resolve/main/${filename}` + }) + + const models: VersionModel[] = [] + + for (const filename of modelFiles) { + const fullname = filename.split('/').pop()! + const extension = `.${fullname.split('.').pop()}` + const basename = fullname.replace(extension, '') + + models.push({ + id: filename, + shortname: filename, + fullname: fullname, + basename: basename, + extension: extension, + preview: images, + sizeBytes: 0, + type: 'unknown', + pathIndex: 0, + description: [ + '---', + ...[ + `website: HuggingFace`, + `modelPage: https://huggingface.co/${modelId}`, + `author: ${resData.author}`, + ].filter(Boolean), + '---', + '', + '# Trigger Words', + '\nNo trigger words\n', + '# About this version', + '\nNo description about this version\n', + `# ${resData.modelId}`, + '\nNo description about this model\n', + ].join('\n'), + metadata: {}, + downloadPlatform: 'huggingface', + downloadUrl: `https://huggingface.co/${modelId}/resolve/main/${filename}?download=true`, + }) + } + + return models + }) + } + + private filterTreeFiles(files: string[], pathname: string) { + const [target, , ...paths] = pathname.split('/') + + if (!target) return files + + if (target !== 'tree' && target !== 'blob') return files + + const pathPrefix = paths.join('/') + return files.filter((file) => { + return file.startsWith(pathPrefix) + }) + } + + private filterModelFiles(files: string[]) { + const extension = [ + '.bin', + '.ckpt', + '.gguf', + '.onnx', + '.pt', + '.pth', + '.safetensors', + ] + return files.filter((file) => { + const ext = file.split('.').pop() + return ext ? extension.includes(`.${ext}`) : false + }) + } + + private filterImageFiles(files: string[]) { + const extension = [ + '.png', + '.webp', + '.jpeg', + '.jpg', + '.jfif', + '.gif', + '.apng', + ] + + return files.filter((file) => { + const ext = file.split('.').pop() + return ext ? extension.includes(`.${ext}`) : false + }) + } +} + +class UnknownWebsite extends ModelSearch { + async search(searchUrl: string): Promise { + return Promise.reject( + new Error( + 'Unknown Website, please input a URL from huggingface.co or civitai.com.', + ), + ) + } +} + +export const useModelSearch = () => { + const loading = useLoading() + const md = useMarkdown() + const { toast } = useToast() + const data = ref<(SelectOptions & { item: VersionModel })[]>([]) + const current = ref() + const currentModel = ref() + + const handleSearchByUrl = async (url: string) => { + if (!url) { + return Promise.resolve([]) + } + + let instance: ModelSearch = new UnknownWebsite(md) + + const { hostname } = new URL(url ?? '') + + if (hostname === 'civitai.com') { + instance = new Civitai(md) + } + + if (hostname === 'huggingface.co') { + instance = new Huggingface(md) + } + + loading.show() + return instance + .search(url) + .then((resData) => { + data.value = resData.map((item) => ({ + label: item.shortname, + value: item.id, + item, + command() { + current.value = item.id + }, + })) + current.value = data.value[0]?.value + currentModel.value = data.value[0]?.item + + if (resData.length === 0) { + toast.add({ + severity: 'warn', + summary: 'No Model Found', + detail: `No model found for ${url}`, + life: 3000, + }) + } + + return resData + }) + .catch((err) => { + toast.add({ + severity: 'error', + summary: 'Error', + detail: err.message, + life: 15000, + }) + return [] + }) + .finally(() => loading.hide()) + } + + watch(current, () => { + currentModel.value = data.value.find( + (option) => option.value === current.value, + )?.item + }) + + return { data, current, currentModel, search: handleSearchByUrl } +} diff --git a/src/hooks/loading.ts b/src/hooks/loading.ts new file mode 100644 index 0000000..349b7d2 --- /dev/null +++ b/src/hooks/loading.ts @@ -0,0 +1,54 @@ +import { defineStore } from 'hooks/store' +import { Ref, ref } from 'vue' + +class GlobalLoading { + loading: Ref + + loadingStack = 0 + + bind(loading: Ref) { + this.loading = loading + } + + show() { + this.loadingStack++ + this.loading.value = true + } + + hide() { + this.loadingStack-- + if (this.loadingStack <= 0) this.loading.value = false + } +} + +export const globalLoading = new GlobalLoading() + +export const useGlobalLoading = defineStore('loading', () => { + const loading = ref(false) + + globalLoading.bind(loading) + + return { loading } +}) + +export const useLoading = () => { + const timer = ref() + + const show = () => { + timer.value = setTimeout(() => { + timer.value = undefined + globalLoading.show() + }, 200) + } + + const hide = () => { + if (timer.value) { + clearTimeout(timer.value) + timer.value = undefined + } else { + globalLoading.hide() + } + } + + return { show, hide } +} diff --git a/src/hooks/markdown.ts b/src/hooks/markdown.ts new file mode 100644 index 0000000..a358d1e --- /dev/null +++ b/src/hooks/markdown.ts @@ -0,0 +1,49 @@ +import MarkdownIt from 'markdown-it' +import metadata_block from 'markdown-it-metadata-block' +import TurndownService from 'turndown' +import yaml from 'yaml' + +interface MarkdownOptions { + metadata?: Record +} + +export const useMarkdown = (opts?: MarkdownOptions) => { + const md = new MarkdownIt({ + html: true, + linkify: true, + typographer: true, + }) + + md.use(metadata_block, { + parseMetadata: yaml.parse, + meta: opts?.metadata ?? {}, + }) + + md.renderer.rules.link_open = function (tokens, idx, options, env, self) { + const aIndex = tokens[idx].attrIndex('target') + + if (aIndex < 0) { + tokens[idx].attrPush(['target', '_blank']) + } else { + tokens[idx].attrs![aIndex][1] = '_blank' + } + + return self.renderToken(tokens, idx, options) + } + + const turndown = new TurndownService({ + headingStyle: 'atx', + bulletListMarker: '-', + }) + + turndown.addRule('paragraph', { + filter: 'p', + replacement: function (content) { + return `\n\n${content}` + }, + }) + + return { render: md.render.bind(md), parse: turndown.turndown.bind(turndown) } +} + +export type MarkdownTool = ReturnType diff --git a/src/hooks/model.ts b/src/hooks/model.ts new file mode 100644 index 0000000..0e2d1dc --- /dev/null +++ b/src/hooks/model.ts @@ -0,0 +1,543 @@ +import { useLoading } from 'hooks/loading' +import { useMarkdown } from 'hooks/markdown' +import { request, useRequest } from 'hooks/request' +import { defineStore } from 'hooks/store' +import { useToast } from 'hooks/toast' +import { cloneDeep } from 'lodash' +import { app } from 'scripts/comfyAPI' +import { bytesToSize, formatDate, previewUrlToFile } from 'utils/common' +import { ModelGrid } from 'utils/legacy' +import { resolveModelTypeLoader } from 'utils/model' +import { + computed, + inject, + InjectionKey, + onMounted, + provide, + ref, + toRaw, + unref, +} from 'vue' +import { useI18n } from 'vue-i18n' + +export const useModels = defineStore('models', () => { + const { data, refresh } = useRequest('/models', { defaultValue: [] }) + const { toast, confirm } = useToast() + const { t } = useI18n() + const loading = useLoading() + + const updateModel = async (model: BaseModel, data: BaseModel) => { + const formData = new FormData() + + // Check current preview + if (model.preview !== data.preview) { + const previewFile = await previewUrlToFile(data.preview as string) + formData.append('previewFile', previewFile) + } + + // Check current description + if (model.description !== data.description) { + formData.append('description', data.description) + } + + // Check current name and pathIndex + if ( + model.fullname !== data.fullname || + model.pathIndex !== data.pathIndex + ) { + formData.append('type', data.type) + formData.append('pathIndex', data.pathIndex.toString()) + formData.append('fullname', data.fullname) + } + + if (formData.keys().next().done) { + return + } + + loading.show() + await request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, { + method: 'PUT', + body: formData, + }) + .catch(() => { + toast.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to update model', + life: 15000, + }) + }) + .finally(() => { + loading.hide() + }) + + await refresh() + } + + const deleteModel = async (model: BaseModel) => { + return new Promise((resolve) => { + confirm.require({ + message: t('deleteAsk', [t('model').toLowerCase()]), + header: 'Danger', + icon: 'pi pi-info-circle', + rejectProps: { + label: t('cancel'), + severity: 'secondary', + outlined: true, + }, + acceptProps: { + label: t('delete'), + severity: 'danger', + }, + accept: () => { + loading.show() + request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, { + method: 'DELETE', + }) + .then(() => { + toast.add({ + severity: 'success', + summary: 'Success', + detail: `${model.fullname} Deleted`, + life: 2000, + }) + return refresh() + }) + .then(() => { + resolve(void 0) + }) + .catch((e) => { + toast.add({ + severity: 'error', + summary: 'Error', + detail: e.message ?? 'Failed to delete model', + life: 15000, + }) + }) + .finally(() => { + loading.hide() + }) + }, + reject: () => {}, + }) + }) + } + + return { data, refresh, remove: deleteModel, update: updateModel } +}) + +declare module 'hooks/store' { + interface StoreProvider { + models: ReturnType + } +} + +export const useModelFormData = (getFormData: () => BaseModel) => { + const formData = ref(getFormData()) + const modelData = ref(getFormData()) + + type ResetCallback = () => void + const resetCallback = ref([]) + + const registerReset = (callback: ResetCallback) => { + resetCallback.value.push(callback) + } + + const reset = () => { + formData.value = getFormData() + modelData.value = getFormData() + for (const callback of resetCallback.value) { + callback() + } + } + + type SubmitCallback = (data: BaseModel) => void + const submitCallback = ref([]) + + const registerSubmit = (callback: SubmitCallback) => { + submitCallback.value.push(callback) + } + + const submit = () => { + const data = cloneDeep(toRaw(unref(formData))) + for (const callback of submitCallback.value) { + callback(data) + } + return data + } + + const metadata = ref>({}) + + return { + formData, + modelData, + registerReset, + reset, + registerSubmit, + submit, + metadata, + } +} + +type ModelFormInstance = ReturnType + +/** + * Model base info + */ +const baseInfoKey = Symbol('baseInfo') as InjectionKey< + ReturnType +> + +export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => { + const { formData: model, modelData } = formInstance + + const type = computed({ + get: () => { + return model.value.type + }, + set: (val) => { + model.value.type = val + }, + }) + + const pathIndex = computed({ + get: () => { + return model.value.pathIndex + }, + set: (val) => { + model.value.pathIndex = val + }, + }) + + const extension = computed(() => { + return model.value.extension + }) + + const basename = computed({ + get: () => { + return model.value.fullname.replace(model.value.extension, '') + }, + set: (val) => { + model.value.fullname = `${val ?? ''}${model.value.extension}` + }, + }) + + interface BaseInfoItem { + key: string + display: string + value: any + } + + interface FieldsItem { + key: keyof Model + formatter: (val: any) => string + } + + const baseInfo = computed(() => { + const fields: FieldsItem[] = [ + { + key: 'type', + formatter: () => modelData.value.type, + }, + { + key: 'fullname', + formatter: (val) => val, + }, + { + key: 'sizeBytes', + formatter: (val) => (val == 0 ? 'Unknown' : bytesToSize(val)), + }, + { + key: 'createdAt', + formatter: (val) => val && formatDate(val), + }, + { + key: 'updatedAt', + formatter: (val) => val && formatDate(val), + }, + ] + + const information: Record = {} + for (const item of fields) { + const key = item.key + const value = model.value[key] + const display = item.formatter(value) + + if (display) { + information[key] = { key, value, display } + } + } + + return information + }) + + const result = { + type, + baseInfo, + basename, + extension, + pathIndex, + } + + provide(baseInfoKey, result) + + return result +} + +export const useModelBaseInfo = () => { + return inject(baseInfoKey)! +} + +/** + * Editable preview image. + * + * In edit mode, there are 4 methods for setting a preview picture: + * 1. default value, which is the default image of the model type + * 2. network picture + * 3. local file + * 4. no preview + */ +const previewKey = Symbol('preview') as InjectionKey< + ReturnType +> + +export const useModelPreviewEditor = (formInstance: ModelFormInstance) => { + const { formData: model, registerReset, registerSubmit } = formInstance + + const typeOptions = ref(['default', 'network', 'local', 'none']) + const currentType = ref('default') + + /** + * Default images + */ + const defaultContent = computed(() => { + return Array.isArray(model.value.preview) + ? model.value.preview + : [model.value.preview] + }) + const defaultContentPage = ref(0) + + /** + * Network picture url + */ + const networkContent = ref() + + /** + * Local file url + */ + const localContent = ref() + const updateLocalContent = async (event: SelectEvent) => { + const { files } = event + localContent.value = files[0].objectURL + } + + /** + * No preview + */ + const noPreviewContent = computed(() => { + return `/model-manager/preview/${model.value.type}/0/no-preview.png` + }) + + const preview = computed(() => { + let content: string | undefined + + switch (currentType.value) { + case 'default': + content = defaultContent.value[defaultContentPage.value] + break + case 'network': + content = networkContent.value + break + case 'local': + content = localContent.value + break + default: + content = noPreviewContent.value + break + } + + return content + }) + + onMounted(() => { + registerReset(() => { + currentType.value = 'default' + defaultContentPage.value = 0 + networkContent.value = undefined + localContent.value = undefined + }) + + registerSubmit((data) => { + data.preview = preview.value ?? noPreviewContent.value + }) + }) + + const result = { + preview, + typeOptions, + currentType, + // default value + defaultContent, + defaultContentPage, + // network picture + networkContent, + // local file + localContent, + updateLocalContent, + // no preview + noPreviewContent, + } + + provide(previewKey, result) + + return result +} + +export const useModelPreview = () => { + return inject(previewKey)! +} + +/** + * Model description + */ +const descriptionKey = Symbol('description') as InjectionKey< + ReturnType +> + +export const useModelDescriptionEditor = (formInstance: ModelFormInstance) => { + const { formData: model, metadata } = formInstance + + const md = useMarkdown({ metadata: metadata.value }) + + const description = computed({ + get: () => { + return model.value.description + }, + set: (val) => { + model.value.description = val + }, + }) + + const renderedDescription = computed(() => { + return description.value ? md.render(description.value) : undefined + }) + + const result = { renderedDescription, description } + + provide(descriptionKey, result) + + return result +} + +export const useModelDescription = () => { + return inject(descriptionKey)! +} + +/** + * Model metadata + */ +const metadataKey = Symbol('metadata') as InjectionKey< + ReturnType +> + +export const useModelMetadataEditor = (formInstance: ModelFormInstance) => { + const { formData: model } = formInstance + + const metadata = computed(() => { + return model.value.metadata + }) + + const result = { metadata } + + provide(metadataKey, result) + + return result +} + +export const useModelMetadata = () => { + return inject(metadataKey)! +} + +export const useModelNodeAction = (model: BaseModel) => { + const { t } = useI18n() + const { toast, wrapperToastError } = useToast() + + const createNode = (options: Record = {}) => { + const nodeType = resolveModelTypeLoader(model.type) + if (!nodeType) { + throw new Error(t('unSupportedModelType', [model.type])) + } + + const node = window.LiteGraph.createNode(nodeType, null, options) + const widgetIndex = node.widgets.findIndex((w) => w.type === 'combo') + if (widgetIndex > -1) { + node.widgets[widgetIndex].value = model.fullname + } + return node + } + + const dragToAddModelNode = wrapperToastError((event: DragEvent) => { + // const target = document.elementFromPoint(event.clientX, event.clientY) + // if ( + // target?.tagName.toLocaleLowerCase() === 'canvas' && + // target.id === 'graph-canvas' + // ) { + // const pos = app.clientPosToCanvasPos([event.clientX - 20, event.clientY]) + // const node = createNode({ pos }) + // app.graph.add(node) + // app.canvas.selectNode(node) + // } + // + // Use the legacy method instead + const removeEmbeddingExtension = true + const strictDragToAdd = false + + ModelGrid.dragAddModel( + event, + model.type, + model.fullname, + removeEmbeddingExtension, + strictDragToAdd, + ) + }) + + const addModelNode = wrapperToastError(() => { + const selectedNodes = app.canvas.selected_nodes + const firstSelectedNode = Object.values(selectedNodes)[0] + const offset = 25 + const pos = firstSelectedNode + ? [firstSelectedNode.pos[0] + offset, firstSelectedNode.pos[1] + offset] + : app.canvas.canvas_mouse + const node = createNode({ pos }) + app.graph.add(node) + app.canvas.selectNode(node) + }) + + const copyModelNode = wrapperToastError(() => { + const node = createNode() + app.canvas.copyToClipboard([node]) + toast.add({ + severity: 'success', + summary: 'Success', + detail: t('modelCopied'), + life: 2000, + }) + }) + + const loadPreviewWorkflow = wrapperToastError(async () => { + const previewUrl = model.preview as string + const response = await fetch(previewUrl) + const data = await response.blob() + const type = data.type + const extension = type.split('/').pop() + const file = new File([data], `${model.fullname}.${extension}`, { type }) + app.handleFile(file) + }) + + return { + addModelNode, + dragToAddModelNode, + copyModelNode, + loadPreviewWorkflow, + } +} diff --git a/src/hooks/request.ts b/src/hooks/request.ts new file mode 100644 index 0000000..840df82 --- /dev/null +++ b/src/hooks/request.ts @@ -0,0 +1,85 @@ +import { useLoading } from 'hooks/loading' +import { api } from 'scripts/comfyAPI' +import { onMounted, ref } from 'vue' + +export const request = async (url: string, options?: RequestInit) => { + return api + .fetchApi(`/model-manager${url}`, options) + .then((response) => response.json()) + .then((resData) => { + if (resData.success) { + return resData.data + } + throw new Error(resData.error) + }) +} + +export interface RequestOptions { + method?: RequestInit['method'] + headers?: RequestInit['headers'] + defaultParams?: Record + defaultValue?: any + postData?: (data: T) => T + manual?: boolean +} + +export const useRequest = ( + url: string, + options: RequestOptions = {}, +) => { + const loading = useLoading() + const postData = options.postData ?? ((data) => data) + + const data = ref(options.defaultValue) + const lastParams = ref() + + const fetch = async ( + params: Record = options.defaultParams ?? {}, + ) => { + loading.show() + + lastParams.value = params + + let requestUrl = url + const requestOptions: RequestInit = { + method: options.method, + headers: options.headers, + } + const requestParams = { ...params } + + const templatePattern = /\{(.*?)\}/g + const urlParamKeyMatches = requestUrl.matchAll(templatePattern) + for (const urlParamKey of urlParamKeyMatches) { + const [match, paramKey] = urlParamKey + if (paramKey in requestParams) { + const paramValue = requestParams[paramKey] + delete requestParams[paramKey] + requestUrl = requestUrl.replace(match, paramValue) + } + } + + if (!requestOptions.method) { + requestOptions.method = 'GET' + } + + if (requestOptions.method !== 'GET') { + requestOptions.body = JSON.stringify(requestParams) + } + + return request(requestUrl, requestOptions) + .then((resData) => (data.value = postData(resData))) + .finally(() => loading.hide()) + } + + onMounted(() => { + if (!options.manual) { + fetch() + } + }) + + const refresh = async () => { + return fetch(lastParams.value) + } + + return { data, refresh, fetch } +} diff --git a/src/hooks/resize.ts b/src/hooks/resize.ts new file mode 100644 index 0000000..5ca8f4b --- /dev/null +++ b/src/hooks/resize.ts @@ -0,0 +1,22 @@ +import { throttle } from 'lodash' +import { Directive } from 'vue' + +export const resizeDirective: Directive = { + mounted: (el, binding) => { + const callback = binding.value ?? (() => {}) + const observer = new ResizeObserver(callback) + observer.observe(el) + el['observer'] = observer + }, + unmounted: (el) => { + const observer = el['observer'] + observer.disconnect() + }, +} + +export const defineResizeCallback = ( + callback: ResizeObserverCallback, + wait?: number, +) => { + return throttle(callback, wait ?? 100) +} diff --git a/src/hooks/socket.ts b/src/hooks/socket.ts new file mode 100644 index 0000000..582a43a --- /dev/null +++ b/src/hooks/socket.ts @@ -0,0 +1,82 @@ +import { globalToast } from 'hooks/toast' +import { readonly } from 'vue' + +class WebSocketEvent extends EventTarget { + private socket: WebSocket | null + + constructor() { + super() + this.createSocket() + } + + private createSocket(isReconnect?: boolean) { + const api_host = location.host + const api_base = location.pathname.split('/').slice(0, -1).join('/') + + let opened = false + let existingSession = window.name + if (existingSession) { + existingSession = '?clientId=' + existingSession + } + + this.socket = readonly( + new WebSocket( + `ws${window.location.protocol === 'https:' ? 's' : ''}://${api_host}${api_base}/model-manager/ws${existingSession}`, + ), + ) + + this.socket.addEventListener('open', () => { + opened = true + if (isReconnect) { + this.dispatchEvent(new CustomEvent('reconnected')) + } + }) + + this.socket.addEventListener('error', () => { + if (this.socket) this.socket.close() + }) + + this.socket.addEventListener('close', (event) => { + setTimeout(() => { + this.socket = null + this.createSocket(true) + }, 300) + if (opened) { + this.dispatchEvent(new CustomEvent('status', { detail: null })) + this.dispatchEvent(new CustomEvent('reconnecting')) + } + }) + + this.socket.addEventListener('message', (event) => { + try { + const msg = JSON.parse(event.data) + if (msg.type === 'error') { + globalToast.value?.add({ + severity: 'error', + summary: 'Error', + detail: msg.data, + life: 15000, + }) + } else { + this.dispatchEvent(new CustomEvent(msg.type, { detail: msg.data })) + } + } catch (error) { + console.error(error) + } + }) + } + + addEventListener = ( + type: string, + callback: CustomEventListener | null, + options?: AddEventListenerOptions | boolean, + ) => { + super.addEventListener(type, callback, options) + } + + send(type: string, data: any) { + this.socket?.send(JSON.stringify({ type, detail: data })) + } +} + +export const socket = new WebSocketEvent() diff --git a/src/hooks/store.ts b/src/hooks/store.ts new file mode 100644 index 0000000..96431bd --- /dev/null +++ b/src/hooks/store.ts @@ -0,0 +1,51 @@ +import { inject, InjectionKey, provide } from 'vue' + +const providerHooks = new Map() +const storeEvent = {} as StoreProvider + +export const useStoreProvider = () => { + // const storeEvent = {} + + for (const [key, useHook] of providerHooks) { + storeEvent[key] = useHook() + } + + return storeEvent +} + +const storeKeys = new Map() + +const getStoreKey = (key: string) => { + let storeKey = storeKeys.get(key) + if (!storeKey) { + storeKey = Symbol(key) + storeKeys.set(key, storeKey) + } + return storeKey +} + +/** + * Using vue provide and inject to implement a simple store + */ +export const defineStore = ( + key: string, + useInitial: (event: StoreProvider) => T, +) => { + const storeKey = getStoreKey(key) as InjectionKey + + if (providerHooks.has(key) && !import.meta.hot) { + console.warn(`[defineStore] key: ${key} already exists.`) + } else { + providerHooks.set(key, () => { + const result = useInitial(storeEvent) + provide(storeKey, result ?? storeEvent[key]) + return result + }) + } + + const useStore = () => { + return inject(storeKey)! + } + + return useStore +} diff --git a/src/hooks/toast.ts b/src/hooks/toast.ts new file mode 100644 index 0000000..4e2f683 --- /dev/null +++ b/src/hooks/toast.ts @@ -0,0 +1,45 @@ +import { ToastServiceMethods } from 'primevue/toastservice' +import { useConfirm as usePrimeConfirm } from 'primevue/useconfirm' +import { useToast as usePrimeToast } from 'primevue/usetoast' + +export const globalToast = { value: null } as unknown as { + value: ToastServiceMethods +} + +export const useToast = () => { + const toast = usePrimeToast() + const confirm = usePrimeConfirm() + + globalToast.value = toast + + const wrapperToastError = (callback: T): T => { + const showToast = (error: Error) => { + toast.add({ + severity: 'error', + summary: 'Error', + detail: error.message, + life: 15000, + }) + } + + const isAsync = callback.constructor.name === 'AsyncFunction' + + let wrapperExec: any + + if (isAsync) { + wrapperExec = (...args: any[]) => callback(...args).catch(showToast) + } else { + wrapperExec = (...args: any[]) => { + try { + return callback(...args) + } catch (error) { + showToast(error) + } + } + } + + return wrapperExec + } + + return { toast, wrapperToastError, confirm } +} diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..39eb49b --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,94 @@ +import { createI18n } from 'vue-i18n' + +const messages = { + en: { + model: 'Model', + modelManager: 'Model Manager', + openModelManager: 'Open Model Manager', + searchModels: 'Search models', + modelCopied: 'Model Copied', + download: 'Download', + downloadList: 'Download List', + downloadTask: 'Download Task', + createDownloadTask: 'Create Download Task', + parseModelUrl: 'Parse Model URL', + pleaseInputModelUrl: 'Input a URL from civitai.com or huggingface.co', + cancel: 'Cancel', + save: 'Save', + delete: 'Delete', + deleteAsk: 'Confirm delete this {0}?', + modelType: 'Model Type', + default: 'Default', + network: 'Network', + local: 'Local', + none: 'None', + uploadFile: 'Upload File', + tapToChange: 'Tap description to change content', + sort: { + name: 'Name', + size: 'Largest', + created: 'Latest created', + modified: 'Latest modified', + }, + info: { + type: 'Model Type', + fullname: 'File Name', + sizeBytes: 'File Size', + createdAt: 'Created At', + updatedAt: 'Updated At', + }, + }, + zh: { + model: '模型', + modelManager: '模型管理器', + openModelManager: '打开模型管理器', + searchModels: '搜索模型', + modelCopied: '模型节点已拷贝', + download: '下载', + downloadList: '下载列表', + downloadTask: '下载任务', + createDownloadTask: '创建下载任务', + parseModelUrl: '解析模型URL', + pleaseInputModelUrl: '输入 civitai.com 或 huggingface.co 的 URL', + cancel: '取消', + save: '保存', + delete: '删除', + deleteAsk: '确定要删除此{0}?', + modelType: '模型类型', + default: '默认', + network: '网络', + local: '本地', + none: '无', + uploadFile: '上传文件', + tapToChange: '点击描述可更改内容', + sort: { + name: '名称', + size: '最大', + created: '最新创建', + modified: '最新修改', + }, + info: { + type: '类型', + fullname: '文件名', + sizeBytes: '文件大小', + createdAt: '创建时间', + updatedAt: '更新时间', + }, + }, +} + +const getLocalLanguage = () => { + const local = + localStorage.getItem('Comfy.Settings.Comfy.Locale') || + navigator.language.split('-')[0] || + 'en' + + return local.replace(/['"]/g, '') +} + +export const i18n = createI18n({ + legacy: false, + locale: getLocalLanguage(), + fallbackLocale: 'en', + messages, +}) diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..c2eb44a --- /dev/null +++ b/src/main.ts @@ -0,0 +1,55 @@ +import { definePreset } from '@primevue/themes' +import Aura from '@primevue/themes/aura' +import { resizeDirective } from 'hooks/resize' +import PrimeVue from 'primevue/config' +import ConfirmationService from 'primevue/confirmationservice' +import ToastService from 'primevue/toastservice' +import Tooltip from 'primevue/tooltip' +import { app } from 'scripts/comfyAPI' +import { createApp } from 'vue' +import App from './App.vue' +import { i18n } from './i18n' +import './style.css' + +const ComfyUIPreset = definePreset(Aura, { + semantic: { + primary: Aura['primitive'].blue, + }, +}) + +function createVueApp(rootContainer: string | HTMLElement) { + const app = createApp(App) + app.directive('tooltip', Tooltip) + app.directive('resize', resizeDirective) + app + .use(PrimeVue, { + theme: { + preset: ComfyUIPreset, + options: { + prefix: 'p', + cssLayer: { + name: 'primevue', + order: 'tailwind-base, primevue, tailwind-utilities', + }, + // This is a workaround for the issue with the dark mode selector + // https://github.com/primefaces/primevue/issues/5515 + darkModeSelector: '.dark-theme, :root:has(.dark-theme)', + }, + }, + }) + .use(ToastService) + .use(ConfirmationService) + .use(i18n) + .mount(rootContainer) +} + +app.registerExtension({ + name: 'Comfy.ModelManager', + setup() { + const container = document.createElement('div') + container.id = 'comfyui-model-manager' + document.body.appendChild(container) + + createVueApp(container) + }, +}) diff --git a/src/scripts/comfyAPI.ts b/src/scripts/comfyAPI.ts new file mode 100644 index 0000000..61394ff --- /dev/null +++ b/src/scripts/comfyAPI.ts @@ -0,0 +1,7 @@ +export const app = window.comfyAPI.app.app +export const api = window.comfyAPI.api.api + +export const $el = window.comfyAPI.ui.$el + +export const ComfyApp = window.comfyAPI.app.ComfyApp +export const ComfyButton = window.comfyAPI.button.ComfyButton diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..6d7905a --- /dev/null +++ b/src/style.css @@ -0,0 +1,157 @@ +@layer primevue, tailwind-utilities; + +@layer tailwind-utilities { + @tailwind components; + @tailwind utilities; + + :root { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; + } + + *.border, + *.border-x, + *.border-y, + *.border-l, + *.border-t, + *.border-r, + *.border-b { + border-style: solid; + } + + table, + th, + tr, + td { + border-width: 0px; + } +} + +.comfy-modal { + z-index: 3000; +} + +.markdown-it { + font-family: theme('fontFamily.sans'); + line-height: theme('lineHeight.relaxed'); + word-break: break-word; + margin: 0; + + h1 { + font-size: theme('fontSize.2xl'); + font-weight: theme('fontWeight.bold'); + border-bottom: 1px solid #ddd; + margin-top: theme('margin.4'); + margin-bottom: theme('margin.4'); + padding-bottom: theme('padding[2.5]'); + } + + h2 { + font-size: theme('fontSize.xl'); + font-weight: theme('fontWeight.bold'); + } + + h3 { + font-size: theme('fontSize.lg'); + } + + a { + color: #1e8bc3; + text-decoration: none; + word-break: break-all; + } + + a:hover { + text-decoration: underline; + } + + p { + margin: 1em 0; + } + + p img { + width: 100%; + height: 100%; + object-fit: cover; + } + + ul, + ol { + margin: 1em 0; + padding-left: 2em; + } + + li { + margin: 0.5em 0; + } + + blockquote { + border-left: 5px solid #ddd; + padding: 10px 20px; + margin: 1.5em 0; + background: #f9f9f9; + } + + code, + pre { + background: #f9f9f9; + padding: 3px 5px; + border: 1px solid #ddd; + border-radius: 3px; + font-family: 'Courier New', Courier, monospace; + } + + pre { + padding: 10px; + overflow-x: auto; + } +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts new file mode 100644 index 0000000..9a4e612 --- /dev/null +++ b/src/types/global.d.ts @@ -0,0 +1,272 @@ +declare namespace ComfyAPI { + namespace api { + class ComfyApi { + socket: WebSocket + fetchApi: (route: string, options?: RequestInit) => Promise + addEventListener: ( + type: string, + callback: (event: CustomEvent) => void, + options?: AddEventListenerOptions, + ) => void + } + + const api: ComfyApi + } + + namespace app { + interface ComfyExtension { + /** + * The name of the extension + */ + name: string + /** + * Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added + * @param app The ComfyUI app instance + */ + init?(app: ComfyApp): Promise | void + /** + * Allows any additional setup, called after the application is fully set up and running + * @param app The ComfyUI app instance + */ + setup?(app: ComfyApp): Promise | void + } + + interface BaseSidebarTabExtension { + id: string + title: string + icon?: string + iconBadge?: string | (() => string | null) + order?: number + tooltip?: string + } + + interface VueSidebarTabExtension extends BaseSidebarTabExtension { + type: 'vue' + component: import('vue').Component + } + + interface CustomSidebarTabExtension extends BaseSidebarTabExtension { + type: 'custom' + render: (container: HTMLElement) => void + destroy?: () => void + } + + type SidebarTabExtension = + | VueSidebarTabExtension + | CustomSidebarTabExtension + + interface ExtensionManager { + // Sidebar tabs + registerSidebarTab(tab: SidebarTabExtension): void + unregisterSidebarTab(id: string): void + getSidebarTabs(): SidebarTabExtension[] + + // Toast + toast: ToastManager + } + + class ComfyApp { + ui?: ui.ComfyUI + menu?: index.ComfyAppMenu + graph: lightGraph.LGraph + canvas: lightGraph.LGraphCanvas + extensionManager: ExtensionManager + registerExtension: (extension: ComfyExtension) => void + addNodeOnGraph: ( + nodeDef: lightGraph.ComfyNodeDef, + options?: Record, + ) => lightGraph.LGraphNode + getCanvasCenter: () => lightGraph.Vector2 + clientPosToCanvasPos: (pos: lightGraph.Vector2) => lightGraph.Vector2 + handleFile: (file: File) => void + } + + const app: ComfyApp + } + + namespace ui { + type Props = { + parent?: HTMLElement + $?: (el: HTMLElement) => void + dataset?: DOMStringMap + style?: Partial + for?: string + textContent?: string + [key: string]: any + } + + type Children = Element[] | Element | string | string[] + + type ElementType = K extends keyof HTMLElementTagNameMap + ? HTMLElementTagNameMap[K] + : HTMLElement + + const $el: ( + tag: TTag, + propsOrChildren?: Children | Props, + children?: Children, + ) => ElementType + + class ComfyUI { + app: app.ComfyApp + settings: ComfySettingsDialog + menuHamburger?: HTMLDivElement + menuContainer?: HTMLDivElement + } + + type SettingInputType = + | 'boolean' + | 'number' + | 'slider' + | 'combo' + | 'text' + | 'hidden' + + type SettingCustomRenderer = ( + name: string, + setter: (v: any) => void, + value: any, + attrs: any, + ) => HTMLElement + + interface SettingOption { + text: string + value?: string + } + + interface SettingParams { + id: string + name: string + type: SettingInputType | SettingCustomRenderer + defaultValue: any + onChange?: (newValue: any, oldValue?: any) => void + attrs?: any + tooltip?: string + options?: + | Array + | ((value: any) => SettingOption[]) + // By default category is id.split('.'). However, changing id to assign + // new category has poor backward compatibility. Use this field to overwrite + // default category from id. + // Note: Like id, category value need to be unique. + category?: string[] + experimental?: boolean + deprecated?: boolean + } + + class ComfySettingsDialog { + addSetting: (params: SettingParams) => { value: any } + } + } + + namespace index { + class ComfyAppMenu { + app: app.ComfyApp + logo: HTMLElement + actionsGroup: button.ComfyButtonGroup + settingsGroup: button.ComfyButtonGroup + viewGroup: button.ComfyButtonGroup + mobileMenuButton: ComfyButton + element: HTMLElement + } + } + + namespace button { + type ComfyButtonProps = { + icon?: string + overIcon?: string + iconSize?: number + content?: string | HTMLElement + tooltip?: string + enabled?: boolean + action?: (e: Event, btn: ComfyButton) => void + classList?: ClassList + visibilitySetting?: { id: keyof Settings; showValue: boolean } + app?: app.ComfyApp + } + + class ComfyButton { + constructor(props: ComfyButtonProps): ComfyButton + } + + class ComfyButtonGroup { + insert(button: ComfyButton, index: number): void + append(button: ComfyButton): void + remove(indexOrButton: ComfyButton | number): void + update(): void + constructor(...buttons: (HTMLElement | ComfyButton)[]): ComfyButtonGroup + } + } +} + +declare namespace lightGraph { + class LGraphNode implements ComfyNodeDef { + widgets: any[] + pos: Vector2 + } + + class LGraphGroup {} + + class LGraph { + /** + * Adds a new node instance to this graph + * @param node the instance of the node + */ + add(node: LGraphNode | LGraphGroup, skip_compute_order?: boolean): void + /** + * Returns the top-most node in this position of the canvas + * @param x the x coordinate in canvas space + * @param y the y coordinate in canvas space + * @param nodes_list a list with all the nodes to search from, by default is all the nodes in the graph + * @return the node at this position or null + */ + getNodeOnPos( + x: number, + y: number, + node_list?: LGraphNode[], + margin?: number, + ): T | null + } + + class LGraphCanvas { + selected_nodes: Record + canvas_mouse: Vector2 + selectNode: (node: LGraphNode) => void + copyToClipboard: (nodes: LGraphNode[]) => void + } + + const LiteGraph: { + createNode: ( + type: string, + title: string | null, + options: object, + ) => LGraphNode + } + + type ComfyNodeDef = { + input?: { + required?: Record + optional?: Record + hidden?: Record + } + output?: (string | any[])[] + output_is_list?: boolean[] + output_name?: string[] + output_tooltips?: string[] + name?: string + display_name?: string + description?: string + category?: string + output_node?: boolean + python_module?: string + deprecated?: boolean + experimental?: boolean + } + + type Vector2 = [number, number] +} + +interface Window { + comfyAPI: typeof ComfyAPI + LiteGraph: typeof lightGraph.LiteGraph +} diff --git a/src/types/shims.d.ts b/src/types/shims.d.ts new file mode 100644 index 0000000..38dbf75 --- /dev/null +++ b/src/types/shims.d.ts @@ -0,0 +1,11 @@ +export {} + +declare module 'vue' { + interface ComponentCustomProperties { + vResize: (typeof import('hooks/resize'))['resizeDirective'] + } +} + +declare module 'hooks/store' { + interface StoreProvider {} +} diff --git a/src/types/typings.d.ts b/src/types/typings.d.ts new file mode 100644 index 0000000..2683e49 --- /dev/null +++ b/src/types/typings.d.ts @@ -0,0 +1,72 @@ +type ContainerSize = { width: number; height: number } +type ContainerPosition = { left: number; top: number } + +interface BaseModel { + id: number | string + fullname: string + basename: string + extension: string + sizeBytes: number + type: string + pathIndex: number + preview: string | string[] + description: string + metadata: Record +} + +interface Model extends BaseModel { + createdAt: number + updatedAt: number +} + +interface VersionModel extends BaseModel { + shortname: string + downloadPlatform: string + downloadUrl: string + hashes?: Record +} + +type PassThrough = T | object | undefined + +interface SelectOptions { + label: string + value: any + icon?: string + command: () => void +} + +interface SelectFile extends File { + objectURL: string +} + +interface SelectEvent { + files: SelectFile[] + originalEvent: Event +} + +interface DownloadTaskOptions { + taskId: string + type: string + fullname: string + preview: string + status: 'pause' | 'waiting' | 'doing' + progress: number + downloadedSize: number + totalSize: number + bps: number + error?: string +} + +interface DownloadTask + extends Omit< + DownloadTaskOptions, + 'downloadedSize' | 'totalSize' | 'bps' | 'error' + > { + downloadProgress: string + downloadSpeed: string + pauseTask: () => void + resumeTask: () => void + deleteTask: () => void +} + +type CustomEventListener = (event: CustomEvent) => void diff --git a/src/utils/common.ts b/src/utils/common.ts new file mode 100644 index 0000000..5d6a259 --- /dev/null +++ b/src/utils/common.ts @@ -0,0 +1,39 @@ +import dayjs from 'dayjs' + +export const bytesToSize = ( + bytes: number | string | undefined | null, + decimals = 2, +) => { + if (typeof bytes === 'undefined' || bytes === null) { + bytes = 0 + } + if (typeof bytes === 'string') { + bytes = Number(bytes) + } + if (Number.isNaN(bytes)) { + return 'Unknown' + } + if (bytes === 0) { + return '0 Bytes' + } + const k = 1024 + const dm = decimals < 0 ? 0 : decimals + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] +} + +export const formatDate = (date: number | string | Date) => { + return dayjs(date).format('YYYY-MM-DD HH:mm:ss') +} + +export const previewUrlToFile = async (url: string) => { + return fetch(url) + .then((res) => res.blob()) + .then((blob) => { + const type = blob.type + const extension = type.split('/')[1] + const file = new File([blob], `preview.${extension}`, { type }) + return file + }) +} diff --git a/src/utils/legacy.ts b/src/utils/legacy.ts new file mode 100644 index 0000000..bc74917 --- /dev/null +++ b/src/utils/legacy.ts @@ -0,0 +1,620 @@ +// @ts-nocheck +import { app } from 'scripts/comfyAPI' + +const LiteGraph = window.LiteGraph + +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, +} + +export class ModelGrid { + /** + * @param {string} nodeType + * @returns {int} + */ + static modelWidgetIndex(nodeType) { + return nodeType === undefined ? -1 : 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 array 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 + case MODEL_SORT_SIZE_BYTES: + compareFn = (a, b) => { + return b[MODEL_SORT_SIZE_BYTES] - a[MODEL_SORT_SIZE_BYTES] + } + 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 (widgetIndex !== -1 && 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() + } + comfyButtonAlert(event.target, success, 'mdi-check-bold', 'mdi-close-thick') + } + + static #getWidgetComboIndices(node, value) { + const widgetIndices = [] + node?.widgets?.forEach((widget, index) => { + if (widget.type === 'combo' && widget.options.values?.includes(value)) { + widgetIndices.push(index) + } + }) + return widgetIndices + } + + /** + * @param {DragEvent} event + * @param {string} modelType + * @param {string} path + * @param {boolean} removeEmbeddingExtension + * @param {boolean} strictlyOnWidget + */ + static dragAddModel( + event, + modelType, + path, + removeEmbeddingExtension, + strictlyOnWidget, + ) { + const target = document.elementFromPoint(event.clientX, event.clientY) + if (modelType !== 'embeddings' && target.id === 'graph-canvas') { + const pos = app.canvas.convertEventToCanvasOffset(event) + + const node = app.graph.getNodeOnPos( + pos[0], + pos[1], + app.canvas.visible_nodes, + ) + + let widgetIndex = -1 + if (widgetIndex === -1) { + const widgetIndices = this.#getWidgetComboIndices(node, path) + if (widgetIndices.length === 0) { + widgetIndex = -1 + } else if (widgetIndices.length === 1) { + widgetIndex = widgetIndices[0] + if (strictlyOnWidget) { + const draggedWidget = app.canvas.processNodeWidgets( + node, + pos, + event, + ) + const widget = node.widgets[widgetIndex] + if (draggedWidget != widget) { + // != check NOT same object + widgetIndex = -1 + } + } + } else { + // ambiguous widget (strictlyOnWidget always true) + const draggedWidget = app.canvas.processNodeWidgets(node, pos, event) + widgetIndex = widgetIndices.findIndex((index) => { + return draggedWidget == node.widgets[index] // == check same object + }) + } + } + + if (widgetIndex !== -1) { + node.widgets[widgetIndex].value = path + app.canvas.selectNode(node) + } else { + const expectedNodeType = modelNodeType[modelType] + const newNode = LiteGraph.createNode(expectedNodeType, null, []) + let newWidgetIndex = ModelGrid.modelWidgetIndex(expectedNodeType) + if (newWidgetIndex === -1) { + newWidgetIndex = this.#getWidgetComboIndices(newNode, path)[0] ?? -1 + } + if ( + newNode !== undefined && + newNode !== null && + newWidgetIndex !== -1 + ) { + newNode.pos[0] = pos[0] + newNode.pos[1] = pos[1] + newNode.widgets[newWidgetIndex].value = path + app.graph.add(newNode, { doProcessChange: true }) + app.canvas.selectNode(newNode) + } + } + 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) + if (widgetIndex !== -1) { + node.widgets[widgetIndex].value = path + app.canvas.copyToClipboard([node]) + success = true + } + } else { + console.warn(`Unable to copy unknown model type '${modelType}.`) + } + comfyButtonAlert(event.target, success, 'mdi-check-bold', 'mdi-close-thick') + } + + /** + * @param {Array} models + * @param {string} modelType + * @param {Object.} settingsElements + * @param {String} searchSeparator + * @param {String} systemSeparator + * @param {(searchPath: string) => Promise} showModelInfo + * @returns {HTMLElement[]} + */ + static #generateInnerHtml( + models, + modelType, + settingsElements, + searchSeparator, + systemSeparator, + showModelInfo, + ) { + // TODO: separate 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 showLoadWorkflowButton = + canShowButtons && + settingsElements['model-show-load-workflow-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 modelInfoButtonOnLeft = + !settingsElements['model-info-button-on-left'].checked + const removeEmbeddingExtension = + !settingsElements['model-add-embedding-extension'].checked + const previewThumbnailFormat = + settingsElements['model-preview-thumbnail-type'].value + const previewThumbnailWidth = Math.round( + settingsElements['model-preview-thumbnail-width'].value / 0.75, + ) + const previewThumbnailHeight = Math.round( + settingsElements['model-preview-thumbnail-height'].value / 0.75, + ) + const buttonsOnlyOnHover = + settingsElements['model-buttons-only-on-hover'].checked + if (models.length > 0) { + const $overlay = IS_FIREFOX + ? (modelType, path, removeEmbeddingExtension, strictDragToAdd) => { + return $el('div.model-preview-overlay', { + ondragstart: (e) => { + const data = { + modelType: modelType, + path: path, + removeEmbeddingExtension: removeEmbeddingExtension, + strictDragToAdd: strictDragToAdd, + } + e.dataTransfer.setData('manager-model', JSON.stringify(data)) + e.dataTransfer.setData('text/plain', '') + }, + draggable: true, + }) + } + : (modelType, path, removeEmbeddingExtension, strictDragToAdd) => { + return $el('div.model-preview-overlay', { + ondragend: (e) => + ModelGrid.dragAddModel( + e, + modelType, + path, + removeEmbeddingExtension, + strictDragToAdd, + ), + draggable: true, + }) + } + const forHiddingButtonsClass = buttonsOnlyOnHover + ? 'model-buttons-hidden' + : 'model-buttons-visible' + + return models.map((item) => { + const previewInfo = item.preview + const previewThumbnail = $el('img.model-preview', { + loading: + 'lazy' /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */, + src: imageUri( + previewInfo?.path, + previewInfo?.dateModified, + previewThumbnailWidth, + previewThumbnailHeight, + previewThumbnailFormat, + ), + draggable: false, + }) + const searchPath = item.path + const path = SearchPath.systemPath( + searchPath, + searchSeparator, + systemSeparator, + ) + let actionButtons = [] + if (showCopyButton) { + actionButtons.push( + new ComfyButton({ + icon: 'content-copy', + tooltip: 'Copy model to clipboard', + classList: 'comfyui-button icon-button model-button', + action: (e) => + ModelGrid.#copyModelToClipboard( + e, + modelType, + path, + removeEmbeddingExtension, + ), + }).element, + ) + } + if ( + showAddButton && + !(modelType === 'embeddings' && !navigator.clipboard) + ) { + actionButtons.push( + new ComfyButton({ + icon: 'plus-box-outline', + tooltip: 'Add model to node grid', + classList: 'comfyui-button icon-button model-button', + action: (e) => + ModelGrid.#addModel( + e, + modelType, + path, + removeEmbeddingExtension, + addOffset, + ), + }).element, + ) + } + if (showLoadWorkflowButton) { + actionButtons.push( + new ComfyButton({ + icon: 'arrow-bottom-left-bold-box-outline', + tooltip: 'Load preview workflow', + classList: 'comfyui-button icon-button model-button', + action: async (e) => { + const urlString = previewThumbnail.src + const url = new URL(urlString) + const urlSearchParams = url.searchParams + const uri = urlSearchParams.get('uri') + const v = urlSearchParams.get('v') + const urlFull = + urlString.substring(0, urlString.indexOf('?')) + + '?uri=' + + uri + + '&v=' + + v + await loadWorkflow(urlFull) + }, + }).element, + ) + } + const infoButtons = [ + new ComfyButton({ + icon: 'information-outline', + tooltip: 'View model information', + classList: 'comfyui-button icon-button model-button', + action: async () => { + await showModelInfo(searchPath) + }, + }).element, + ] + return $el('div.item', {}, [ + previewThumbnail, + $overlay(modelType, path, removeEmbeddingExtension, strictDragToAdd), + $el( + 'div.model-preview-top-right.' + forHiddingButtonsClass, + { + draggable: false, + }, + modelInfoButtonOnLeft ? infoButtons : actionButtons, + ), + $el( + 'div.model-preview-top-left.' + forHiddingButtonsClass, + { + draggable: false, + }, + modelInfoButtonOnLeft ? actionButtons : infoButtons, + ), + $el( + 'div.model-label', + { + draggable: false, + }, + [ + $el('p', [ + showModelExtension + ? item.name + : SearchPath.splitExtension(item.name)[0], + ]), + ], + ), + ]) + }) + } else { + return [$el('h2', ['No Models'])] + } + } + + /** + * @param {HTMLDivElement} modelGrid + * @param {ModelData} modelData + * @param {HTMLSelectElement} modelSelect + * @param {Object.<{value: string}>} previousModelType + * @param {Object} settings + * @param {string} sortBy + * @param {boolean} reverseSort + * @param {Array} previousModelFilters + * @param {HTMLInputElement} modelFilter + * @param {(searchPath: string) => Promise} showModelInfo + */ + static update( + modelGrid, + modelData, + modelSelect, + previousModelType, + settings, + sortBy, + reverseSort, + previousModelFilters, + modelFilter, + showModelInfo, + ) { + const models = modelData.models + let modelType = modelSelect.value + if (models[modelType] === undefined) { + modelType = settings['model-default-browser-model-type'].value + } + if (models[modelType] === undefined) { + modelType = 'checkpoints' // panic fallback + } + + 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, + modelData.searchSeparator, + modelData.systemSeparator, + showModelInfo, + ) + modelGrid.append.apply(modelGrid, modelGridModels) + } +} diff --git a/src/utils/model.ts b/src/utils/model.ts new file mode 100644 index 0000000..b0db2dd --- /dev/null +++ b/src/utils/model.ts @@ -0,0 +1,27 @@ +const loader = { + checkpoints: 'CheckpointLoaderSimple', + loras: 'LoraLoader', + vae: 'VAELoader', + clip: 'CLIPLoader', + diffusion_models: 'UNETLoader', + unet: 'UNETLoader', + clip_vision: 'CLIPVisionLoader', + style_models: 'StyleModelLoader', + embeddings: undefined, + diffusers: 'DiffusersLoader', + vae_approx: undefined, + controlnet: 'ControlNetLoader', + gligen: 'GLIGENLoader', + upscale_models: 'UpscaleModelLoader', + hypernetworks: 'HypernetworkLoader', + photomaker: 'PhotoMakerLoader', + classifiers: undefined, +} + +export const resolveModelTypeLoader = (type: string) => { + return loader[type] +} + +export const genModelKey = (model: BaseModel) => { + return `${model.type}:${model.pathIndex}:${model.fullname}` +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..b562990 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,213 @@ +import container from '@tailwindcss/container-queries' +import plugin from 'tailwindcss/plugin' + +/** @type {import('tailwindcss').Config} */ +export default { + content: ['index.html', './src/**/*.vue'], + + darkMode: ['selector', '.dark-theme'], + + plugins: [ + container, + plugin(({ addUtilities }) => { + addUtilities({ + '.scrollbar-none': { + 'scrollbar-width': 'none', + }, + '.preview-aspect': { + 'aspect-ratio': '7/9', + img: { + width: '100%', + height: '100%', + objectFit: 'cover', + display: 'block', + }, + }, + }) + }), + ], + + corePlugins: { + preflight: false, // This disables Tailwind's base styles + }, + + theme: { + fontSize: { + xs: '0.75rem', + sm: '0.875rem', + base: '1rem', + lg: '1.125rem', + xl: '1.25rem', + '2xl': '1.5rem', + '3xl': '1.875rem', + '4xl': '2.25rem', + '5xl': '3rem', + '6xl': '4rem', + }, + + screens: { + sm: '640px', + md: '768px', + lg: '1024px', + xl: '1280px', + '2xl': '1536px', + '3xl': '1800px', + '4xl': '2500px', + '5xl': '3200px', + }, + + spacing: { + px: '1px', + 0: '0px', + 0.5: '0.125rem', + 1: '0.25rem', + 1.5: '0.375rem', + 2: '0.5rem', + 2.5: '0.625rem', + 3: '0.75rem', + 3.5: '0.875rem', + 4: '1rem', + 4.5: '1.125rem', + 5: '1.25rem', + 6: '1.5rem', + 7: '1.75rem', + 8: '2rem', + 9: '2.25rem', + 10: '2.5rem', + 11: '2.75rem', + 12: '3rem', + 14: '3.5rem', + 16: '4rem', + 18: '4.5rem', + 20: '5rem', + 24: '6rem', + 28: '7rem', + 32: '8rem', + 36: '9rem', + 40: '10rem', + 44: '11rem', + 48: '12rem', + 52: '13rem', + 56: '14rem', + 60: '15rem', + 64: '16rem', + 72: '18rem', + 80: '20rem', + 84: '22rem', + 90: '24rem', + 96: '26rem', + 100: '28rem', + 110: '32rem', + }, + + extend: { + gridTemplateColumns: { + dynamic: 'repeat(var(--tw-grid-cols-count), var(--tw-grid-cols-width))', + }, + + spacing: { + dynamic: 'var(--tw-spacing-size)', + }, + + colors: { + zinc: { + 50: '#fafafa', + 100: '#f4f4f5', + 200: '#e4e4e7', + 300: '#d4d4d8', + 400: '#a1a1aa', + 500: '#71717a', + 600: '#52525b', + 700: '#3f3f46', + 800: '#27272a', + 900: '#18181b', + 950: '#09090b', + }, + + gray: { + 50: '#f8fbfc', + 100: '#f3f6fa', + 200: '#edf2f7', + 300: '#e2e8f0', + 400: '#cbd5e0', + 500: '#a0aec0', + 600: '#718096', + 700: '#4a5568', + 800: '#2d3748', + 900: '#1a202c', + 950: '#0a1016', + }, + + teal: { + 50: '#f0fdfa', + 100: '#e0fcff', + 200: '#bef8fd', + 300: '#87eaf2', + 400: '#54d1db', + 500: '#38bec9', + 600: '#2cb1bc', + 700: '#14919b', + 800: '#0e7c86', + 900: '#005860', + 950: '#022c28', + }, + + blue: { + 50: '#eff6ff', + 100: '#ebf8ff', + 200: '#bee3f8', + 300: '#90cdf4', + 400: '#63b3ed', + 500: '#4299e1', + 600: '#3182ce', + 700: '#2b6cb0', + 800: '#2c5282', + 900: '#2a4365', + 950: '#172554', + }, + + green: { + 50: '#fcfff5', + 100: '#fafff3', + 200: '#eaf9c9', + 300: '#d1efa0', + 400: '#b2e16e', + 500: '#96ce4c', + 600: '#7bb53d', + 700: '#649934', + 800: '#507b2e', + 900: '#456829', + 950: '#355819', + }, + + fuchsia: { + 50: '#fdf4ff', + 100: '#fae8ff', + 200: '#f5d0fe', + 300: '#f0abfc', + 400: '#e879f9', + 500: '#d946ef', + 600: '#c026d3', + 700: '#a21caf', + 800: '#86198f', + 900: '#701a75', + 950: '#4a044e', + }, + + orange: { + 50: '#fff7ed', + 100: '#ffedd5', + 200: '#fedbb8', + 300: '#fbd38d', + 400: '#f6ad55', + 500: '#ed8936', + 600: '#dd6b20', + 700: '#c05621', + 800: '#9c4221', + 900: '#7b341e', + 950: '#431407', + }, + }, + }, + }, +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e2eb9cc --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "incremental": true, + "sourceMap": true, + "esModuleInterop": true, + "moduleResolution": "Node", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + + /* Linting */ + "strict": false, + "strictNullChecks": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "downlevelIteration": true, + + /* AllowJs during migration phase */ + "allowJs": true, + "baseUrl": ".", + "outDir": "./web", + "rootDir": "./", + "paths": { + "components/*": ["src/components/*"], + "hooks/*": ["src/hooks/*"], + "scripts/*": ["src/scripts/*"], + "types/*": ["src/types/*"], + "utils/*": ["src/utils/*"], + } + }, + "include": [ + "src/**/*", + "src/**/*.vue", + ] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..c912982 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,154 @@ +import vue from '@vitejs/plugin-vue' +import fs from 'node:fs' +import path from 'node:path' +import { defineConfig, Plugin } from 'vite' + +function css(): Plugin { + return { + name: 'vite-plugin-css-inject', + apply: 'build', + enforce: 'post', + generateBundle(_, bundle) { + const cssCode: string[] = [] + + for (const key in bundle) { + if (Object.prototype.hasOwnProperty.call(bundle, key)) { + const chunk = bundle[key] + if (chunk.type === 'asset' && chunk.fileName.endsWith('.css')) { + cssCode.push(chunk.source) + delete bundle[key] + } + } + } + + for (const key in bundle) { + if (Object.prototype.hasOwnProperty.call(bundle, key)) { + const chunk = bundle[key] + if (chunk.type === 'chunk' && /index-.*\.js$/.test(chunk.fileName)) { + const originalCode = chunk.code + chunk.code = '(function(){var s=document.createElement("style");' + chunk.code += 's.type="text/css",s.dataset.styleId="model-manager",' + chunk.code += 's.appendChild(document.createTextNode(' + chunk.code += JSON.stringify(cssCode.join('')) + chunk.code += ')),document.head.appendChild(s);})();' + chunk.code += originalCode + } + } + } + }, + } +} + +function output(): Plugin { + return { + name: 'vite-plugin-output-fix', + apply: 'build', + enforce: 'post', + generateBundle(_, bundle) { + for (const key in bundle) { + const chunk = bundle[key] + + if (chunk.type === 'asset') { + if (chunk.fileName === 'index.html') { + delete bundle[key] + } + } + + if (chunk.fileName.startsWith('assets/')) { + chunk.fileName = chunk.fileName.replace('assets/', '') + } + } + }, + } +} + +function dev(): Plugin { + return { + name: 'vite-plugin-dev-fix', + apply: 'serve', + enforce: 'post', + configureServer(server) { + server.httpServer?.on('listening', () => { + const rootDir = server.config.root + const outDir = server.config.build.outDir + + const outDirPath = path.join(rootDir, outDir) + if (fs.existsSync(outDirPath)) { + fs.rmSync(outDirPath, { recursive: true }) + } + fs.mkdirSync(outDirPath) + + const port = server.config.server.port + const content = `import "http://127.0.0.1:${port}/src/main.ts";` + fs.writeFileSync(path.join(outDirPath, 'manager-dev.js'), content) + }) + }, + } +} + +function createWebVersion(): Plugin { + return { + name: 'vite-plugin-web-version', + apply: 'build', + enforce: 'post', + writeBundle() { + const pyProjectContent = fs.readFileSync('pyproject.toml', 'utf8') + const [, version] = pyProjectContent.match(/version = "(.*)"/) ?? [] + + const metadata = [ + `version: ${version}`, + `build_time: ${new Date().toISOString()}`, + '', + ].join('\n') + + const metadataFilePath = path.join(__dirname, 'web', 'version.yaml') + fs.writeFileSync(metadataFilePath, metadata, 'utf-8') + }, + } +} + +export default defineConfig({ + plugins: [vue(), css(), output(), dev(), createWebVersion()], + + build: { + outDir: 'web', + minify: 'esbuild', + target: 'es2022', + sourcemap: true, + rollupOptions: { + // Disabling tree-shaking + // Prevent vite remove unused exports + treeshake: true, + output: { + manualChunks(id) { + if (id.includes('primevue')) { + return 'primevue' + } + }, + }, + }, + chunkSizeWarningLimit: 1024, + }, + + resolve: { + alias: { + src: resolvePath('src'), + components: resolvePath('src/components'), + hooks: resolvePath('src/hooks'), + scripts: resolvePath('src/scripts'), + types: resolvePath('src/types'), + utils: resolvePath('src/utils'), + }, + }, + + esbuild: { + minifyIdentifiers: false, + keepNames: true, + minifySyntax: true, + minifyWhitespace: true, + }, +}) + +function resolvePath(str: string) { + return path.resolve(__dirname, str) +} diff --git a/web/downshow.js b/web/downshow.js deleted file mode 100644 index 8c36e2d..0000000 --- a/web/downshow.js +++ /dev/null @@ -1,231 +0,0 @@ -/** - * downshow.js -- A javascript library to convert HTML to markdown. - * - * Copyright (c) 2013 Alex Cornejo. - * - * Original Markdown Copyright (c) 2004-2005 John Gruber - * - * - * Redistributable under a BSD-style open source license. - * - * downshow has no external dependencies. It has been tested in chrome and - * firefox, it probably works in internet explorer, but YMMV. - * - * Basic Usage: - * - * downshow(document.getElementById('#yourid').innerHTML); - * - * TODO: - * - Remove extra whitespace between words in headers and other places. - */ - -(function () { - var doc; - - // Use browser DOM with jsdom as a fallback (for node.js) - try { - doc = document; - } catch(e) { - var jsdom = require("jsdom").jsdom; - doc = jsdom(""); - } - - /** - * Returns every element in root in their bfs traversal order. - * - * In the process it transforms any nested lists to conform to the w3c - * standard, see: http://www.w3.org/wiki/HTML_lists#Nesting_lists - */ - function bfsOrder(root) { - var inqueue = [root], outqueue = []; - root._bfs_parent = null; - while (inqueue.length > 0) { - var elem = inqueue.shift(); - outqueue.push(elem); - var children = elem.childNodes; - var liParent = null; - for (var i=0 ; i 0) { - if (prefix && suffix) - node._bfs_text = prefix + content + suffix; - else - node._bfs_text = content; - } else - node._bfs_text = ''; - } - - /** - * Get a node's content. - */ - function getContent(node) { - var text = '', atom; - for (var i = 0; i 0) - setContent(node, '[' + text + '](' + href + (title ? ' "' + title + '"' : '') + ')'); - else - setContent(node, ''); - } else if (node.tagName === 'IMG') { - var src = node.getAttribute('src') ? nltrim(node.getAttribute('src')) : '', alt = node.alt ? nltrim(node.alt) : '', caption = node.title ? nltrim(node.title) : ''; - if (src.length > 0) - setContent(node, '![' + alt + '](' + src + (caption ? ' "' + caption + '"' : '') + ')'); - else - setContent(node, ''); - } else if (node.tagName === 'BLOCKQUOTE') { - var block_content = getContent(node); - if (block_content.length > 0) - setContent(node, prefixBlock('> ', block_content), '\n\n', '\n\n'); - else - setContent(node, ''); - } else if (node.tagName === 'CODE') { - if (node._bfs_parent.tagName === 'PRE' && node._bfs_parent._bfs_parent !== null) - setContent(node, prefixBlock(' ', getContent(node))); - else - setContent(node, nltrim(getContent(node)), '`', '`'); - } else if (node.tagName === 'LI') { - var list_content = getContent(node); - if (list_content.length > 0) - if (node._bfs_parent.tagName === 'OL') - setContent(node, trim(prefixBlock(' ', list_content, true)), '1. ', '\n\n'); - else - setContent(node, trim(prefixBlock(' ', list_content, true)), '- ', '\n\n'); - else - setContent(node, ''); - } else - setContent(node, getContent(node)); - } - - function downshow(html, options) { - var root = doc.createElement('pre'); - root.innerHTML = html; - var nodes = bfsOrder(root).reverse(), i; - - if (options && options.nodeParser) { - for (i = 0; i )+[^\n]*)\n+(\n(?:> )+)/g, "$1\n$2") - // remove empty blockquotes - .replace(/\n((?:> )+[ ]*\n)+/g, '\n\n') - // remove extra newlines - .replace(/\n[ \t]*(?:\n[ \t]*)+\n/g,'\n\n') - // remove trailing whitespace - .replace(/\s\s*$/, '') - // convert lists to inline when not using paragraphs - .replace(/^([ \t]*(?:\d+\.|\+|\-)[^\n]*)\n\n+(?=[ \t]*(?:\d+\.|\+|\-|\*)[^\n]*)/gm, "$1\n") - // remove starting newlines - .replace(/^\n\n*/, ''); - } - - // Export for use in server and client. - if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') - module.exports = downshow; - else if (typeof define === 'function' && define.amd) - define([], function () {return downshow;}); - else - window.downshow = downshow; - })(); \ No newline at end of file diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs deleted file mode 100644 index 2edbef8..0000000 --- a/web/eslint.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import globals from "globals"; -import pluginJs from "@eslint/js"; - - -export default [ - {languageOptions: { globals: globals.browser }}, - pluginJs.configs.recommended, -]; \ No newline at end of file diff --git a/web/marked.js b/web/marked.js deleted file mode 100644 index f5cea94..0000000 --- a/web/marked.js +++ /dev/null @@ -1,2498 +0,0 @@ -/** - * marked v14.1.0 - a markdown parser - * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed) - * https://github.com/markedjs/marked - */ - -/** - * DO NOT EDIT THIS FILE - * The code in this file is generated from files in ./src/ - */ - -/** - * Gets the original marked default options. - */ -function _getDefaults() { - return { - async: false, - breaks: false, - extensions: null, - gfm: true, - hooks: null, - pedantic: false, - renderer: null, - silent: false, - tokenizer: null, - walkTokens: null, - }; -} -let _defaults = _getDefaults(); -function changeDefaults(newDefaults) { - _defaults = newDefaults; -} - -/** - * Helpers - */ -const escapeTest = /[&<>"']/; -const escapeReplace = new RegExp(escapeTest.source, 'g'); -const escapeTestNoEncode = /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/; -const escapeReplaceNoEncode = new RegExp(escapeTestNoEncode.source, 'g'); -const escapeReplacements = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', -}; -const getEscapeReplacement = (ch) => escapeReplacements[ch]; -function escape$1(html, encode) { - if (encode) { - if (escapeTest.test(html)) { - return html.replace(escapeReplace, getEscapeReplacement); - } - } - else { - if (escapeTestNoEncode.test(html)) { - return html.replace(escapeReplaceNoEncode, getEscapeReplacement); - } - } - return html; -} -const caret = /(^|[^\[])\^/g; -function edit(regex, opt) { - let source = typeof regex === 'string' ? regex : regex.source; - opt = opt || ''; - const obj = { - replace: (name, val) => { - let valSource = typeof val === 'string' ? val : val.source; - valSource = valSource.replace(caret, '$1'); - source = source.replace(name, valSource); - return obj; - }, - getRegex: () => { - return new RegExp(source, opt); - }, - }; - return obj; -} -function cleanUrl(href) { - try { - href = encodeURI(href).replace(/%25/g, '%'); - } - catch { - return null; - } - return href; -} -const noopTest = { exec: () => null }; -function splitCells(tableRow, count) { - // ensure that every cell-delimiting pipe has a space - // before it to distinguish it from an escaped pipe - const row = tableRow.replace(/\|/g, (match, offset, str) => { - let escaped = false; - let curr = offset; - while (--curr >= 0 && str[curr] === '\\') - escaped = !escaped; - if (escaped) { - // odd number of slashes means | is escaped - // so we leave it alone - return '|'; - } - else { - // add space before unescaped | - return ' |'; - } - }), cells = row.split(/ \|/); - let i = 0; - // First/last cell in a row cannot be empty if it has no leading/trailing pipe - if (!cells[0].trim()) { - cells.shift(); - } - if (cells.length > 0 && !cells[cells.length - 1].trim()) { - cells.pop(); - } - if (count) { - if (cells.length > count) { - cells.splice(count); - } - else { - while (cells.length < count) - cells.push(''); - } - } - for (; i < cells.length; i++) { - // leading or trailing whitespace is ignored per the gfm spec - cells[i] = cells[i].trim().replace(/\\\|/g, '|'); - } - return cells; -} -/** - * Remove trailing 'c's. Equivalent to str.replace(/c*$/, ''). - * /c*$/ is vulnerable to REDOS. - * - * @param str - * @param c - * @param invert Remove suffix of non-c chars instead. Default falsey. - */ -function rtrim(str, c, invert) { - const l = str.length; - if (l === 0) { - return ''; - } - // Length of suffix matching the invert condition. - let suffLen = 0; - // Step left until we fail to match the invert condition. - while (suffLen < l) { - const currChar = str.charAt(l - suffLen - 1); - if (currChar === c && !invert) { - suffLen++; - } - else if (currChar !== c && invert) { - suffLen++; - } - else { - break; - } - } - return str.slice(0, l - suffLen); -} -function findClosingBracket(str, b) { - if (str.indexOf(b[1]) === -1) { - return -1; - } - let level = 0; - for (let i = 0; i < str.length; i++) { - if (str[i] === '\\') { - i++; - } - else if (str[i] === b[0]) { - level++; - } - else if (str[i] === b[1]) { - level--; - if (level < 0) { - return i; - } - } - } - return -1; -} - -function outputLink(cap, link, raw, lexer) { - const href = link.href; - const title = link.title ? escape$1(link.title) : null; - const text = cap[1].replace(/\\([\[\]])/g, '$1'); - if (cap[0].charAt(0) !== '!') { - lexer.state.inLink = true; - const token = { - type: 'link', - raw, - href, - title, - text, - tokens: lexer.inlineTokens(text), - }; - lexer.state.inLink = false; - return token; - } - return { - type: 'image', - raw, - href, - title, - text: escape$1(text), - }; -} -function indentCodeCompensation(raw, text) { - const matchIndentToCode = raw.match(/^(\s+)(?:```)/); - if (matchIndentToCode === null) { - return text; - } - const indentToCode = matchIndentToCode[1]; - return text - .split('\n') - .map(node => { - const matchIndentInNode = node.match(/^\s+/); - if (matchIndentInNode === null) { - return node; - } - const [indentInNode] = matchIndentInNode; - if (indentInNode.length >= indentToCode.length) { - return node.slice(indentToCode.length); - } - return node; - }) - .join('\n'); -} -/** - * Tokenizer - */ -class _Tokenizer { - options; - rules; // set by the lexer - lexer; // set by the lexer - constructor(options) { - this.options = options || _defaults; - } - space(src) { - const cap = this.rules.block.newline.exec(src); - if (cap && cap[0].length > 0) { - return { - type: 'space', - raw: cap[0], - }; - } - } - code(src) { - const cap = this.rules.block.code.exec(src); - if (cap) { - const text = cap[0].replace(/^ {1,4}/gm, ''); - return { - type: 'code', - raw: cap[0], - codeBlockStyle: 'indented', - text: !this.options.pedantic - ? rtrim(text, '\n') - : text, - }; - } - } - fences(src) { - const cap = this.rules.block.fences.exec(src); - if (cap) { - const raw = cap[0]; - const text = indentCodeCompensation(raw, cap[3] || ''); - return { - type: 'code', - raw, - lang: cap[2] ? cap[2].trim().replace(this.rules.inline.anyPunctuation, '$1') : cap[2], - text, - }; - } - } - heading(src) { - const cap = this.rules.block.heading.exec(src); - if (cap) { - let text = cap[2].trim(); - // remove trailing #s - if (/#$/.test(text)) { - const trimmed = rtrim(text, '#'); - if (this.options.pedantic) { - text = trimmed.trim(); - } - else if (!trimmed || / $/.test(trimmed)) { - // CommonMark requires space before trailing #s - text = trimmed.trim(); - } - } - return { - type: 'heading', - raw: cap[0], - depth: cap[1].length, - text, - tokens: this.lexer.inline(text), - }; - } - } - hr(src) { - const cap = this.rules.block.hr.exec(src); - if (cap) { - return { - type: 'hr', - raw: rtrim(cap[0], '\n'), - }; - } - } - blockquote(src) { - const cap = this.rules.block.blockquote.exec(src); - if (cap) { - let lines = rtrim(cap[0], '\n').split('\n'); - let raw = ''; - let text = ''; - const tokens = []; - while (lines.length > 0) { - let inBlockquote = false; - const currentLines = []; - let i; - for (i = 0; i < lines.length; i++) { - // get lines up to a continuation - if (/^ {0,3}>/.test(lines[i])) { - currentLines.push(lines[i]); - inBlockquote = true; - } - else if (!inBlockquote) { - currentLines.push(lines[i]); - } - else { - break; - } - } - lines = lines.slice(i); - const currentRaw = currentLines.join('\n'); - const currentText = currentRaw - // precede setext continuation with 4 spaces so it isn't a setext - .replace(/\n {0,3}((?:=+|-+) *)(?=\n|$)/g, '\n $1') - .replace(/^ {0,3}>[ \t]?/gm, ''); - raw = raw ? `${raw}\n${currentRaw}` : currentRaw; - text = text ? `${text}\n${currentText}` : currentText; - // parse blockquote lines as top level tokens - // merge paragraphs if this is a continuation - const top = this.lexer.state.top; - this.lexer.state.top = true; - this.lexer.blockTokens(currentText, tokens, true); - this.lexer.state.top = top; - // if there is no continuation then we are done - if (lines.length === 0) { - break; - } - const lastToken = tokens[tokens.length - 1]; - if (lastToken?.type === 'code') { - // blockquote continuation cannot be preceded by a code block - break; - } - else if (lastToken?.type === 'blockquote') { - // include continuation in nested blockquote - const oldToken = lastToken; - const newText = oldToken.raw + '\n' + lines.join('\n'); - const newToken = this.blockquote(newText); - tokens[tokens.length - 1] = newToken; - raw = raw.substring(0, raw.length - oldToken.raw.length) + newToken.raw; - text = text.substring(0, text.length - oldToken.text.length) + newToken.text; - break; - } - else if (lastToken?.type === 'list') { - // include continuation in nested list - const oldToken = lastToken; - const newText = oldToken.raw + '\n' + lines.join('\n'); - const newToken = this.list(newText); - tokens[tokens.length - 1] = newToken; - raw = raw.substring(0, raw.length - lastToken.raw.length) + newToken.raw; - text = text.substring(0, text.length - oldToken.raw.length) + newToken.raw; - lines = newText.substring(tokens[tokens.length - 1].raw.length).split('\n'); - continue; - } - } - return { - type: 'blockquote', - raw, - tokens, - text, - }; - } - } - list(src) { - let cap = this.rules.block.list.exec(src); - if (cap) { - let bull = cap[1].trim(); - const isordered = bull.length > 1; - const list = { - type: 'list', - raw: '', - ordered: isordered, - start: isordered ? +bull.slice(0, -1) : '', - loose: false, - items: [], - }; - bull = isordered ? `\\d{1,9}\\${bull.slice(-1)}` : `\\${bull}`; - if (this.options.pedantic) { - bull = isordered ? bull : '[*+-]'; - } - // Get next list item - const itemRegex = new RegExp(`^( {0,3}${bull})((?:[\t ][^\\n]*)?(?:\\n|$))`); - let endsWithBlankLine = false; - // Check if current bullet point can start a new List Item - while (src) { - let endEarly = false; - let raw = ''; - let itemContents = ''; - if (!(cap = itemRegex.exec(src))) { - break; - } - if (this.rules.block.hr.test(src)) { // End list if bullet was actually HR (possibly move into itemRegex?) - break; - } - raw = cap[0]; - src = src.substring(raw.length); - let line = cap[2].split('\n', 1)[0].replace(/^\t+/, (t) => ' '.repeat(3 * t.length)); - let nextLine = src.split('\n', 1)[0]; - let blankLine = !line.trim(); - let indent = 0; - if (this.options.pedantic) { - indent = 2; - itemContents = line.trimStart(); - } - else if (blankLine) { - indent = cap[1].length + 1; - } - else { - indent = cap[2].search(/[^ ]/); // Find first non-space char - indent = indent > 4 ? 1 : indent; // Treat indented code blocks (> 4 spaces) as having only 1 indent - itemContents = line.slice(indent); - indent += cap[1].length; - } - if (blankLine && /^ *$/.test(nextLine)) { // Items begin with at most one blank line - raw += nextLine + '\n'; - src = src.substring(nextLine.length + 1); - endEarly = true; - } - if (!endEarly) { - const nextBulletRegex = new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`); - const hrRegex = new RegExp(`^ {0,${Math.min(3, indent - 1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`); - const fencesBeginRegex = new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:\`\`\`|~~~)`); - const headingBeginRegex = new RegExp(`^ {0,${Math.min(3, indent - 1)}}#`); - // Check if following lines should be included in List Item - while (src) { - const rawLine = src.split('\n', 1)[0]; - nextLine = rawLine; - // Re-align to follow commonmark nesting rules - if (this.options.pedantic) { - nextLine = nextLine.replace(/^ {1,4}(?=( {4})*[^ ])/g, ' '); - } - // End list item if found code fences - if (fencesBeginRegex.test(nextLine)) { - break; - } - // End list item if found start of new heading - if (headingBeginRegex.test(nextLine)) { - break; - } - // End list item if found start of new bullet - if (nextBulletRegex.test(nextLine)) { - break; - } - // Horizontal rule found - if (hrRegex.test(src)) { - break; - } - if (nextLine.search(/[^ ]/) >= indent || !nextLine.trim()) { // Dedent if possible - itemContents += '\n' + nextLine.slice(indent); - } - else { - // not enough indentation - if (blankLine) { - break; - } - // paragraph continuation unless last line was a different block level element - if (line.search(/[^ ]/) >= 4) { // indented code block - break; - } - if (fencesBeginRegex.test(line)) { - break; - } - if (headingBeginRegex.test(line)) { - break; - } - if (hrRegex.test(line)) { - break; - } - itemContents += '\n' + nextLine; - } - if (!blankLine && !nextLine.trim()) { // Check if current line is blank - blankLine = true; - } - raw += rawLine + '\n'; - src = src.substring(rawLine.length + 1); - line = nextLine.slice(indent); - } - } - if (!list.loose) { - // If the previous item ended with a blank line, the list is loose - if (endsWithBlankLine) { - list.loose = true; - } - else if (/\n *\n *$/.test(raw)) { - endsWithBlankLine = true; - } - } - let istask = null; - let ischecked; - // Check for task list items - if (this.options.gfm) { - istask = /^\[[ xX]\] /.exec(itemContents); - if (istask) { - ischecked = istask[0] !== '[ ] '; - itemContents = itemContents.replace(/^\[[ xX]\] +/, ''); - } - } - list.items.push({ - type: 'list_item', - raw, - task: !!istask, - checked: ischecked, - loose: false, - text: itemContents, - tokens: [], - }); - list.raw += raw; - } - // Do not consume newlines at end of final item. Alternatively, make itemRegex *start* with any newlines to simplify/speed up endsWithBlankLine logic - list.items[list.items.length - 1].raw = list.items[list.items.length - 1].raw.trimEnd(); - list.items[list.items.length - 1].text = list.items[list.items.length - 1].text.trimEnd(); - list.raw = list.raw.trimEnd(); - // Item child tokens handled here at end because we needed to have the final item to trim it first - for (let i = 0; i < list.items.length; i++) { - this.lexer.state.top = false; - list.items[i].tokens = this.lexer.blockTokens(list.items[i].text, []); - if (!list.loose) { - // Check if list should be loose - const spacers = list.items[i].tokens.filter(t => t.type === 'space'); - const hasMultipleLineBreaks = spacers.length > 0 && spacers.some(t => /\n.*\n/.test(t.raw)); - list.loose = hasMultipleLineBreaks; - } - } - // Set all items to loose if list is loose - if (list.loose) { - for (let i = 0; i < list.items.length; i++) { - list.items[i].loose = true; - } - } - return list; - } - } - html(src) { - const cap = this.rules.block.html.exec(src); - if (cap) { - const token = { - type: 'html', - block: true, - raw: cap[0], - pre: cap[1] === 'pre' || cap[1] === 'script' || cap[1] === 'style', - text: cap[0], - }; - return token; - } - } - def(src) { - const cap = this.rules.block.def.exec(src); - if (cap) { - const tag = cap[1].toLowerCase().replace(/\s+/g, ' '); - const href = cap[2] ? cap[2].replace(/^<(.*)>$/, '$1').replace(this.rules.inline.anyPunctuation, '$1') : ''; - const title = cap[3] ? cap[3].substring(1, cap[3].length - 1).replace(this.rules.inline.anyPunctuation, '$1') : cap[3]; - return { - type: 'def', - tag, - raw: cap[0], - href, - title, - }; - } - } - table(src) { - const cap = this.rules.block.table.exec(src); - if (!cap) { - return; - } - if (!/[:|]/.test(cap[2])) { - // delimiter row must have a pipe (|) or colon (:) otherwise it is a setext heading - return; - } - const headers = splitCells(cap[1]); - const aligns = cap[2].replace(/^\||\| *$/g, '').split('|'); - const rows = cap[3] && cap[3].trim() ? cap[3].replace(/\n[ \t]*$/, '').split('\n') : []; - const item = { - type: 'table', - raw: cap[0], - header: [], - align: [], - rows: [], - }; - if (headers.length !== aligns.length) { - // header and align columns must be equal, rows can be different. - return; - } - for (const align of aligns) { - if (/^ *-+: *$/.test(align)) { - item.align.push('right'); - } - else if (/^ *:-+: *$/.test(align)) { - item.align.push('center'); - } - else if (/^ *:-+ *$/.test(align)) { - item.align.push('left'); - } - else { - item.align.push(null); - } - } - for (let i = 0; i < headers.length; i++) { - item.header.push({ - text: headers[i], - tokens: this.lexer.inline(headers[i]), - header: true, - align: item.align[i], - }); - } - for (const row of rows) { - item.rows.push(splitCells(row, item.header.length).map((cell, i) => { - return { - text: cell, - tokens: this.lexer.inline(cell), - header: false, - align: item.align[i], - }; - })); - } - return item; - } - lheading(src) { - const cap = this.rules.block.lheading.exec(src); - if (cap) { - return { - type: 'heading', - raw: cap[0], - depth: cap[2].charAt(0) === '=' ? 1 : 2, - text: cap[1], - tokens: this.lexer.inline(cap[1]), - }; - } - } - paragraph(src) { - const cap = this.rules.block.paragraph.exec(src); - if (cap) { - const text = cap[1].charAt(cap[1].length - 1) === '\n' - ? cap[1].slice(0, -1) - : cap[1]; - return { - type: 'paragraph', - raw: cap[0], - text, - tokens: this.lexer.inline(text), - }; - } - } - text(src) { - const cap = this.rules.block.text.exec(src); - if (cap) { - return { - type: 'text', - raw: cap[0], - text: cap[0], - tokens: this.lexer.inline(cap[0]), - }; - } - } - escape(src) { - const cap = this.rules.inline.escape.exec(src); - if (cap) { - return { - type: 'escape', - raw: cap[0], - text: escape$1(cap[1]), - }; - } - } - tag(src) { - const cap = this.rules.inline.tag.exec(src); - if (cap) { - if (!this.lexer.state.inLink && /^/i.test(cap[0])) { - this.lexer.state.inLink = false; - } - if (!this.lexer.state.inRawBlock && /^<(pre|code|kbd|script)(\s|>)/i.test(cap[0])) { - this.lexer.state.inRawBlock = true; - } - else if (this.lexer.state.inRawBlock && /^<\/(pre|code|kbd|script)(\s|>)/i.test(cap[0])) { - this.lexer.state.inRawBlock = false; - } - return { - type: 'html', - raw: cap[0], - inLink: this.lexer.state.inLink, - inRawBlock: this.lexer.state.inRawBlock, - block: false, - text: cap[0], - }; - } - } - link(src) { - const cap = this.rules.inline.link.exec(src); - if (cap) { - const trimmedUrl = cap[2].trim(); - if (!this.options.pedantic && /^$/.test(trimmedUrl))) { - return; - } - // ending angle bracket cannot be escaped - const rtrimSlash = rtrim(trimmedUrl.slice(0, -1), '\\'); - if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) { - return; - } - } - else { - // find closing parenthesis - const lastParenIndex = findClosingBracket(cap[2], '()'); - if (lastParenIndex > -1) { - const start = cap[0].indexOf('!') === 0 ? 5 : 4; - const linkLen = start + cap[1].length + lastParenIndex; - cap[2] = cap[2].substring(0, lastParenIndex); - cap[0] = cap[0].substring(0, linkLen).trim(); - cap[3] = ''; - } - } - let href = cap[2]; - let title = ''; - if (this.options.pedantic) { - // split pedantic href and title - const link = /^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(href); - if (link) { - href = link[1]; - title = link[3]; - } - } - else { - title = cap[3] ? cap[3].slice(1, -1) : ''; - } - href = href.trim(); - if (/^$/.test(trimmedUrl))) { - // pedantic allows starting angle bracket without ending angle bracket - href = href.slice(1); - } - else { - href = href.slice(1, -1); - } - } - return outputLink(cap, { - href: href ? href.replace(this.rules.inline.anyPunctuation, '$1') : href, - title: title ? title.replace(this.rules.inline.anyPunctuation, '$1') : title, - }, cap[0], this.lexer); - } - } - reflink(src, links) { - let cap; - if ((cap = this.rules.inline.reflink.exec(src)) - || (cap = this.rules.inline.nolink.exec(src))) { - const linkString = (cap[2] || cap[1]).replace(/\s+/g, ' '); - const link = links[linkString.toLowerCase()]; - if (!link) { - const text = cap[0].charAt(0); - return { - type: 'text', - raw: text, - text, - }; - } - return outputLink(cap, link, cap[0], this.lexer); - } - } - emStrong(src, maskedSrc, prevChar = '') { - let match = this.rules.inline.emStrongLDelim.exec(src); - if (!match) - return; - // _ can't be between two alphanumerics. \p{L}\p{N} includes non-english alphabet/numbers as well - if (match[3] && prevChar.match(/[\p{L}\p{N}]/u)) - return; - const nextChar = match[1] || match[2] || ''; - if (!nextChar || !prevChar || this.rules.inline.punctuation.exec(prevChar)) { - // unicode Regex counts emoji as 1 char; spread into array for proper count (used multiple times below) - const lLength = [...match[0]].length - 1; - let rDelim, rLength, delimTotal = lLength, midDelimTotal = 0; - const endReg = match[0][0] === '*' ? this.rules.inline.emStrongRDelimAst : this.rules.inline.emStrongRDelimUnd; - endReg.lastIndex = 0; - // Clip maskedSrc to same section of string as src (move to lexer?) - maskedSrc = maskedSrc.slice(-1 * src.length + lLength); - while ((match = endReg.exec(maskedSrc)) != null) { - rDelim = match[1] || match[2] || match[3] || match[4] || match[5] || match[6]; - if (!rDelim) - continue; // skip single * in __abc*abc__ - rLength = [...rDelim].length; - if (match[3] || match[4]) { // found another Left Delim - delimTotal += rLength; - continue; - } - else if (match[5] || match[6]) { // either Left or Right Delim - if (lLength % 3 && !((lLength + rLength) % 3)) { - midDelimTotal += rLength; - continue; // CommonMark Emphasis Rules 9-10 - } - } - delimTotal -= rLength; - if (delimTotal > 0) - continue; // Haven't found enough closing delimiters - // Remove extra characters. *a*** -> *a* - rLength = Math.min(rLength, rLength + delimTotal + midDelimTotal); - // char length can be >1 for unicode characters; - const lastCharLength = [...match[0]][0].length; - const raw = src.slice(0, lLength + match.index + lastCharLength + rLength); - // Create `em` if smallest delimiter has odd char count. *a*** - if (Math.min(lLength, rLength) % 2) { - const text = raw.slice(1, -1); - return { - type: 'em', - raw, - text, - tokens: this.lexer.inlineTokens(text), - }; - } - // Create 'strong' if smallest delimiter has even char count. **a*** - const text = raw.slice(2, -2); - return { - type: 'strong', - raw, - text, - tokens: this.lexer.inlineTokens(text), - }; - } - } - } - codespan(src) { - const cap = this.rules.inline.code.exec(src); - if (cap) { - let text = cap[2].replace(/\n/g, ' '); - const hasNonSpaceChars = /[^ ]/.test(text); - const hasSpaceCharsOnBothEnds = /^ /.test(text) && / $/.test(text); - if (hasNonSpaceChars && hasSpaceCharsOnBothEnds) { - text = text.substring(1, text.length - 1); - } - text = escape$1(text, true); - return { - type: 'codespan', - raw: cap[0], - text, - }; - } - } - br(src) { - const cap = this.rules.inline.br.exec(src); - if (cap) { - return { - type: 'br', - raw: cap[0], - }; - } - } - del(src) { - const cap = this.rules.inline.del.exec(src); - if (cap) { - return { - type: 'del', - raw: cap[0], - text: cap[2], - tokens: this.lexer.inlineTokens(cap[2]), - }; - } - } - autolink(src) { - const cap = this.rules.inline.autolink.exec(src); - if (cap) { - let text, href; - if (cap[2] === '@') { - text = escape$1(cap[1]); - href = 'mailto:' + text; - } - else { - text = escape$1(cap[1]); - href = text; - } - return { - type: 'link', - raw: cap[0], - text, - href, - tokens: [ - { - type: 'text', - raw: text, - text, - }, - ], - }; - } - } - url(src) { - let cap; - if (cap = this.rules.inline.url.exec(src)) { - let text, href; - if (cap[2] === '@') { - text = escape$1(cap[0]); - href = 'mailto:' + text; - } - else { - // do extended autolink path validation - let prevCapZero; - do { - prevCapZero = cap[0]; - cap[0] = this.rules.inline._backpedal.exec(cap[0])?.[0] ?? ''; - } while (prevCapZero !== cap[0]); - text = escape$1(cap[0]); - if (cap[1] === 'www.') { - href = 'http://' + cap[0]; - } - else { - href = cap[0]; - } - } - return { - type: 'link', - raw: cap[0], - text, - href, - tokens: [ - { - type: 'text', - raw: text, - text, - }, - ], - }; - } - } - inlineText(src) { - const cap = this.rules.inline.text.exec(src); - if (cap) { - let text; - if (this.lexer.state.inRawBlock) { - text = cap[0]; - } - else { - text = escape$1(cap[0]); - } - return { - type: 'text', - raw: cap[0], - text, - }; - } - } -} - -/** - * Block-Level Grammar - */ -const newline = /^(?: *(?:\n|$))+/; -const blockCode = /^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/; -const fences = /^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/; -const hr = /^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/; -const heading = /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/; -const bullet = /(?:[*+-]|\d{1,9}[.)])/; -const lheading = edit(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/) - .replace(/bull/g, bullet) // lists can interrupt - .replace(/blockCode/g, / {4}/) // indented code blocks can interrupt - .replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/) // fenced code blocks can interrupt - .replace(/blockquote/g, / {0,3}>/) // blockquote can interrupt - .replace(/heading/g, / {0,3}#{1,6}/) // ATX heading can interrupt - .replace(/html/g, / {0,3}<[^\n>]+>\n/) // block html can interrupt - .getRegex(); -const _paragraph = /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/; -const blockText = /^[^\n]+/; -const _blockLabel = /(?!\s*\])(?:\\.|[^\[\]\\])+/; -const def = edit(/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/) - .replace('label', _blockLabel) - .replace('title', /(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/) - .getRegex(); -const list = edit(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/) - .replace(/bull/g, bullet) - .getRegex(); -const _tag = 'address|article|aside|base|basefont|blockquote|body|caption' - + '|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption' - + '|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe' - + '|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option' - + '|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title' - + '|tr|track|ul'; -const _comment = /|$))/; -const html = edit('^ {0,3}(?:' // optional indentation - + '<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)' // (1) - + '|comment[^\\n]*(\\n+|$)' // (2) - + '|<\\?[\\s\\S]*?(?:\\?>\\n*|$)' // (3) - + '|\\n*|$)' // (4) - + '|\\n*|$)' // (5) - + '|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (6) - + '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (7) open tag - + '|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (7) closing tag - + ')', 'i') - .replace('comment', _comment) - .replace('tag', _tag) - .replace('attribute', / +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/) - .getRegex(); -const paragraph = edit(_paragraph) - .replace('hr', hr) - .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') - .replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs - .replace('|table', '') - .replace('blockquote', ' {0,3}>') - .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') - .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt - .replace('html', ')|<(?:script|pre|style|textarea|!--)') - .replace('tag', _tag) // pars can be interrupted by type (6) html blocks - .getRegex(); -const blockquote = edit(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/) - .replace('paragraph', paragraph) - .getRegex(); -/** - * Normal Block Grammar - */ -const blockNormal = { - blockquote, - code: blockCode, - def, - fences, - heading, - hr, - html, - lheading, - list, - newline, - paragraph, - table: noopTest, - text: blockText, -}; -/** - * GFM Block Grammar - */ -const gfmTable = edit('^ *([^\\n ].*)\\n' // Header - + ' {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)' // Align - + '(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)') // Cells - .replace('hr', hr) - .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') - .replace('blockquote', ' {0,3}>') - .replace('code', ' {4}[^\\n]') - .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') - .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt - .replace('html', ')|<(?:script|pre|style|textarea|!--)') - .replace('tag', _tag) // tables can be interrupted by type (6) html blocks - .getRegex(); -const blockGfm = { - ...blockNormal, - table: gfmTable, - paragraph: edit(_paragraph) - .replace('hr', hr) - .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') - .replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs - .replace('table', gfmTable) // interrupt paragraphs with table - .replace('blockquote', ' {0,3}>') - .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') - .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt - .replace('html', ')|<(?:script|pre|style|textarea|!--)') - .replace('tag', _tag) // pars can be interrupted by type (6) html blocks - .getRegex(), -}; -/** - * Pedantic grammar (original John Gruber's loose markdown specification) - */ -const blockPedantic = { - ...blockNormal, - html: edit('^ *(?:comment *(?:\\n|\\s*$)' - + '|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)' // closed tag - + '|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))') - .replace('comment', _comment) - .replace(/tag/g, '(?!(?:' - + 'a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub' - + '|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)' - + '\\b)\\w+(?!:|[^\\w\\s@]*@)\\b') - .getRegex(), - def: /^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/, - heading: /^(#{1,6})(.*)(?:\n+|$)/, - fences: noopTest, // fences not supported - lheading: /^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/, - paragraph: edit(_paragraph) - .replace('hr', hr) - .replace('heading', ' *#{1,6} *[^\n]') - .replace('lheading', lheading) - .replace('|table', '') - .replace('blockquote', ' {0,3}>') - .replace('|fences', '') - .replace('|list', '') - .replace('|html', '') - .replace('|tag', '') - .getRegex(), -}; -/** - * Inline-Level Grammar - */ -const escape = /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/; -const inlineCode = /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/; -const br = /^( {2,}|\\)\n(?!\s*$)/; -const inlineText = /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\ -const blockSkip = /\[[^[\]]*?\]\([^\(\)]*?\)|`[^`]*?`|<[^<>]*?>/g; -const emStrongLDelim = edit(/^(?:\*+(?:((?!\*)[punct])|[^\s*]))|^_+(?:((?!_)[punct])|([^\s_]))/, 'u') - .replace(/punct/g, _punctuation) - .getRegex(); -const emStrongRDelimAst = edit('^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)' // Skip orphan inside strong - + '|[^*]+(?=[^*])' // Consume to delim - + '|(?!\\*)[punct](\\*+)(?=[\\s]|$)' // (1) #*** can only be a Right Delimiter - + '|[^punct\\s](\\*+)(?!\\*)(?=[punct\\s]|$)' // (2) a***#, a*** can only be a Right Delimiter - + '|(?!\\*)[punct\\s](\\*+)(?=[^punct\\s])' // (3) #***a, ***a can only be Left Delimiter - + '|[\\s](\\*+)(?!\\*)(?=[punct])' // (4) ***# can only be Left Delimiter - + '|(?!\\*)[punct](\\*+)(?!\\*)(?=[punct])' // (5) #***# can be either Left or Right Delimiter - + '|[^punct\\s](\\*+)(?=[^punct\\s])', 'gu') // (6) a***a can be either Left or Right Delimiter - .replace(/punct/g, _punctuation) - .getRegex(); -// (6) Not allowed for _ -const emStrongRDelimUnd = edit('^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)' // Skip orphan inside strong - + '|[^_]+(?=[^_])' // Consume to delim - + '|(?!_)[punct](_+)(?=[\\s]|$)' // (1) #___ can only be a Right Delimiter - + '|[^punct\\s](_+)(?!_)(?=[punct\\s]|$)' // (2) a___#, a___ can only be a Right Delimiter - + '|(?!_)[punct\\s](_+)(?=[^punct\\s])' // (3) #___a, ___a can only be Left Delimiter - + '|[\\s](_+)(?!_)(?=[punct])' // (4) ___# can only be Left Delimiter - + '|(?!_)[punct](_+)(?!_)(?=[punct])', 'gu') // (5) #___# can be either Left or Right Delimiter - .replace(/punct/g, _punctuation) - .getRegex(); -const anyPunctuation = edit(/\\([punct])/, 'gu') - .replace(/punct/g, _punctuation) - .getRegex(); -const autolink = edit(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/) - .replace('scheme', /[a-zA-Z][a-zA-Z0-9+.-]{1,31}/) - .replace('email', /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/) - .getRegex(); -const _inlineComment = edit(_comment).replace('(?:-->|$)', '-->').getRegex(); -const tag = edit('^comment' - + '|^' // self-closing tag - + '|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>' // open tag - + '|^<\\?[\\s\\S]*?\\?>' // processing instruction, e.g. - + '|^' // declaration, e.g. - + '|^') // CDATA section - .replace('comment', _inlineComment) - .replace('attribute', /\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/) - .getRegex(); -const _inlineLabel = /(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/; -const link = edit(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/) - .replace('label', _inlineLabel) - .replace('href', /<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/) - .replace('title', /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/) - .getRegex(); -const reflink = edit(/^!?\[(label)\]\[(ref)\]/) - .replace('label', _inlineLabel) - .replace('ref', _blockLabel) - .getRegex(); -const nolink = edit(/^!?\[(ref)\](?:\[\])?/) - .replace('ref', _blockLabel) - .getRegex(); -const reflinkSearch = edit('reflink|nolink(?!\\()', 'g') - .replace('reflink', reflink) - .replace('nolink', nolink) - .getRegex(); -/** - * Normal Inline Grammar - */ -const inlineNormal = { - _backpedal: noopTest, // only used for GFM url - anyPunctuation, - autolink, - blockSkip, - br, - code: inlineCode, - del: noopTest, - emStrongLDelim, - emStrongRDelimAst, - emStrongRDelimUnd, - escape, - link, - nolink, - punctuation, - reflink, - reflinkSearch, - tag, - text: inlineText, - url: noopTest, -}; -/** - * Pedantic Inline Grammar - */ -const inlinePedantic = { - ...inlineNormal, - link: edit(/^!?\[(label)\]\((.*?)\)/) - .replace('label', _inlineLabel) - .getRegex(), - reflink: edit(/^!?\[(label)\]\s*\[([^\]]*)\]/) - .replace('label', _inlineLabel) - .getRegex(), -}; -/** - * GFM Inline Grammar - */ -const inlineGfm = { - ...inlineNormal, - escape: edit(escape).replace('])', '~|])').getRegex(), - url: edit(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/, 'i') - .replace('email', /[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/) - .getRegex(), - _backpedal: /(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/, - del: /^(~~?)(?=[^\s~])([\s\S]*?[^\s~])\1(?=[^~]|$)/, - text: /^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\ { - return leading + ' '.repeat(tabs.length); - }); - } - let token; - let lastToken; - let cutSrc; - while (src) { - if (this.options.extensions - && this.options.extensions.block - && this.options.extensions.block.some((extTokenizer) => { - if (token = extTokenizer.call({ lexer: this }, src, tokens)) { - src = src.substring(token.raw.length); - tokens.push(token); - return true; - } - return false; - })) { - continue; - } - // newline - if (token = this.tokenizer.space(src)) { - src = src.substring(token.raw.length); - if (token.raw.length === 1 && tokens.length > 0) { - // if there's a single \n as a spacer, it's terminating the last line, - // so move it there so that we don't get unnecessary paragraph tags - tokens[tokens.length - 1].raw += '\n'; - } - else { - tokens.push(token); - } - continue; - } - // code - if (token = this.tokenizer.code(src)) { - src = src.substring(token.raw.length); - lastToken = tokens[tokens.length - 1]; - // An indented code block cannot interrupt a paragraph. - if (lastToken && (lastToken.type === 'paragraph' || lastToken.type === 'text')) { - lastToken.raw += '\n' + token.raw; - lastToken.text += '\n' + token.text; - this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text; - } - else { - tokens.push(token); - } - continue; - } - // fences - if (token = this.tokenizer.fences(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // heading - if (token = this.tokenizer.heading(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // hr - if (token = this.tokenizer.hr(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // blockquote - if (token = this.tokenizer.blockquote(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // list - if (token = this.tokenizer.list(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // html - if (token = this.tokenizer.html(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // def - if (token = this.tokenizer.def(src)) { - src = src.substring(token.raw.length); - lastToken = tokens[tokens.length - 1]; - if (lastToken && (lastToken.type === 'paragraph' || lastToken.type === 'text')) { - lastToken.raw += '\n' + token.raw; - lastToken.text += '\n' + token.raw; - this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text; - } - else if (!this.tokens.links[token.tag]) { - this.tokens.links[token.tag] = { - href: token.href, - title: token.title, - }; - } - continue; - } - // table (gfm) - if (token = this.tokenizer.table(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // lheading - if (token = this.tokenizer.lheading(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // top-level paragraph - // prevent paragraph consuming extensions by clipping 'src' to extension start - cutSrc = src; - if (this.options.extensions && this.options.extensions.startBlock) { - let startIndex = Infinity; - const tempSrc = src.slice(1); - let tempStart; - this.options.extensions.startBlock.forEach((getStartIndex) => { - tempStart = getStartIndex.call({ lexer: this }, tempSrc); - if (typeof tempStart === 'number' && tempStart >= 0) { - startIndex = Math.min(startIndex, tempStart); - } - }); - if (startIndex < Infinity && startIndex >= 0) { - cutSrc = src.substring(0, startIndex + 1); - } - } - if (this.state.top && (token = this.tokenizer.paragraph(cutSrc))) { - lastToken = tokens[tokens.length - 1]; - if (lastParagraphClipped && lastToken?.type === 'paragraph') { - lastToken.raw += '\n' + token.raw; - lastToken.text += '\n' + token.text; - this.inlineQueue.pop(); - this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text; - } - else { - tokens.push(token); - } - lastParagraphClipped = (cutSrc.length !== src.length); - src = src.substring(token.raw.length); - continue; - } - // text - if (token = this.tokenizer.text(src)) { - src = src.substring(token.raw.length); - lastToken = tokens[tokens.length - 1]; - if (lastToken && lastToken.type === 'text') { - lastToken.raw += '\n' + token.raw; - lastToken.text += '\n' + token.text; - this.inlineQueue.pop(); - this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text; - } - else { - tokens.push(token); - } - continue; - } - if (src) { - const errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); - if (this.options.silent) { - console.error(errMsg); - break; - } - else { - throw new Error(errMsg); - } - } - } - this.state.top = true; - return tokens; - } - inline(src, tokens = []) { - this.inlineQueue.push({ src, tokens }); - return tokens; - } - /** - * Lexing/Compiling - */ - inlineTokens(src, tokens = []) { - let token, lastToken, cutSrc; - // String with links masked to avoid interference with em and strong - let maskedSrc = src; - let match; - let keepPrevChar, prevChar; - // Mask out reflinks - if (this.tokens.links) { - const links = Object.keys(this.tokens.links); - if (links.length > 0) { - while ((match = this.tokenizer.rules.inline.reflinkSearch.exec(maskedSrc)) != null) { - if (links.includes(match[0].slice(match[0].lastIndexOf('[') + 1, -1))) { - maskedSrc = maskedSrc.slice(0, match.index) + '[' + 'a'.repeat(match[0].length - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex); - } - } - } - } - // Mask out other blocks - while ((match = this.tokenizer.rules.inline.blockSkip.exec(maskedSrc)) != null) { - maskedSrc = maskedSrc.slice(0, match.index) + '[' + 'a'.repeat(match[0].length - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.blockSkip.lastIndex); - } - // Mask out escaped characters - while ((match = this.tokenizer.rules.inline.anyPunctuation.exec(maskedSrc)) != null) { - maskedSrc = maskedSrc.slice(0, match.index) + '++' + maskedSrc.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex); - } - while (src) { - if (!keepPrevChar) { - prevChar = ''; - } - keepPrevChar = false; - // extensions - if (this.options.extensions - && this.options.extensions.inline - && this.options.extensions.inline.some((extTokenizer) => { - if (token = extTokenizer.call({ lexer: this }, src, tokens)) { - src = src.substring(token.raw.length); - tokens.push(token); - return true; - } - return false; - })) { - continue; - } - // escape - if (token = this.tokenizer.escape(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // tag - if (token = this.tokenizer.tag(src)) { - src = src.substring(token.raw.length); - lastToken = tokens[tokens.length - 1]; - if (lastToken && token.type === 'text' && lastToken.type === 'text') { - lastToken.raw += token.raw; - lastToken.text += token.text; - } - else { - tokens.push(token); - } - continue; - } - // link - if (token = this.tokenizer.link(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // reflink, nolink - if (token = this.tokenizer.reflink(src, this.tokens.links)) { - src = src.substring(token.raw.length); - lastToken = tokens[tokens.length - 1]; - if (lastToken && token.type === 'text' && lastToken.type === 'text') { - lastToken.raw += token.raw; - lastToken.text += token.text; - } - else { - tokens.push(token); - } - continue; - } - // em & strong - if (token = this.tokenizer.emStrong(src, maskedSrc, prevChar)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // code - if (token = this.tokenizer.codespan(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // br - if (token = this.tokenizer.br(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // del (gfm) - if (token = this.tokenizer.del(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // autolink - if (token = this.tokenizer.autolink(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // url (gfm) - if (!this.state.inLink && (token = this.tokenizer.url(src))) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } - // text - // prevent inlineText consuming extensions by clipping 'src' to extension start - cutSrc = src; - if (this.options.extensions && this.options.extensions.startInline) { - let startIndex = Infinity; - const tempSrc = src.slice(1); - let tempStart; - this.options.extensions.startInline.forEach((getStartIndex) => { - tempStart = getStartIndex.call({ lexer: this }, tempSrc); - if (typeof tempStart === 'number' && tempStart >= 0) { - startIndex = Math.min(startIndex, tempStart); - } - }); - if (startIndex < Infinity && startIndex >= 0) { - cutSrc = src.substring(0, startIndex + 1); - } - } - if (token = this.tokenizer.inlineText(cutSrc)) { - src = src.substring(token.raw.length); - if (token.raw.slice(-1) !== '_') { // Track prevChar before string of ____ started - prevChar = token.raw.slice(-1); - } - keepPrevChar = true; - lastToken = tokens[tokens.length - 1]; - if (lastToken && lastToken.type === 'text') { - lastToken.raw += token.raw; - lastToken.text += token.text; - } - else { - tokens.push(token); - } - continue; - } - if (src) { - const errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); - if (this.options.silent) { - console.error(errMsg); - break; - } - else { - throw new Error(errMsg); - } - } - } - return tokens; - } -} - -/** - * Renderer - */ -class _Renderer { - options; - parser; // set by the parser - constructor(options) { - this.options = options || _defaults; - } - space(token) { - return ''; - } - code({ text, lang, escaped }) { - const langString = (lang || '').match(/^\S*/)?.[0]; - const code = text.replace(/\n$/, '') + '\n'; - if (!langString) { - return '
'
-                + (escaped ? code : escape$1(code, true))
-                + '
\n'; - } - return '
'
-            + (escaped ? code : escape$1(code, true))
-            + '
\n'; - } - blockquote({ tokens }) { - const body = this.parser.parse(tokens); - return `
\n${body}
\n`; - } - html({ text }) { - return text; - } - heading({ tokens, depth }) { - return `${this.parser.parseInline(tokens)}\n`; - } - hr(token) { - return '
\n'; - } - list(token) { - const ordered = token.ordered; - const start = token.start; - let body = ''; - for (let j = 0; j < token.items.length; j++) { - const item = token.items[j]; - body += this.listitem(item); - } - const type = ordered ? 'ol' : 'ul'; - const startAttr = (ordered && start !== 1) ? (' start="' + start + '"') : ''; - return '<' + type + startAttr + '>\n' + body + '\n'; - } - listitem(item) { - let itemBody = ''; - if (item.task) { - const checkbox = this.checkbox({ checked: !!item.checked }); - if (item.loose) { - if (item.tokens.length > 0 && item.tokens[0].type === 'paragraph') { - item.tokens[0].text = checkbox + ' ' + item.tokens[0].text; - if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === 'text') { - item.tokens[0].tokens[0].text = checkbox + ' ' + item.tokens[0].tokens[0].text; - } - } - else { - item.tokens.unshift({ - type: 'text', - raw: checkbox + ' ', - text: checkbox + ' ', - }); - } - } - else { - itemBody += checkbox + ' '; - } - } - itemBody += this.parser.parse(item.tokens, !!item.loose); - return `
  • ${itemBody}
  • \n`; - } - checkbox({ checked }) { - return ''; - } - paragraph({ tokens }) { - return `

    ${this.parser.parseInline(tokens)}

    \n`; - } - table(token) { - let header = ''; - // header - let cell = ''; - for (let j = 0; j < token.header.length; j++) { - cell += this.tablecell(token.header[j]); - } - header += this.tablerow({ text: cell }); - let body = ''; - for (let j = 0; j < token.rows.length; j++) { - const row = token.rows[j]; - cell = ''; - for (let k = 0; k < row.length; k++) { - cell += this.tablecell(row[k]); - } - body += this.tablerow({ text: cell }); - } - if (body) - body = `${body}`; - return '\n' - + '\n' - + header - + '\n' - + body - + '
    \n'; - } - tablerow({ text }) { - return `\n${text}\n`; - } - tablecell(token) { - const content = this.parser.parseInline(token.tokens); - const type = token.header ? 'th' : 'td'; - const tag = token.align - ? `<${type} align="${token.align}">` - : `<${type}>`; - return tag + content + `\n`; - } - /** - * span level renderer - */ - strong({ tokens }) { - return `${this.parser.parseInline(tokens)}`; - } - em({ tokens }) { - return `${this.parser.parseInline(tokens)}`; - } - codespan({ text }) { - return `${text}`; - } - br(token) { - return '
    '; - } - del({ tokens }) { - return `${this.parser.parseInline(tokens)}`; - } - link({ href, title, tokens }) { - const text = this.parser.parseInline(tokens); - const cleanHref = cleanUrl(href); - if (cleanHref === null) { - return text; - } - href = cleanHref; - let out = '
    '; - return out; - } - image({ href, title, text }) { - const cleanHref = cleanUrl(href); - if (cleanHref === null) { - return text; - } - href = cleanHref; - let out = `${text} { - const tokens = genericToken[childTokens].flat(Infinity); - values = values.concat(this.walkTokens(tokens, callback)); - }); - } - else if (genericToken.tokens) { - values = values.concat(this.walkTokens(genericToken.tokens, callback)); - } - } - } - } - return values; - } - use(...args) { - const extensions = this.defaults.extensions || { renderers: {}, childTokens: {} }; - args.forEach((pack) => { - // copy options to new object - const opts = { ...pack }; - // set async to true if it was set to true before - opts.async = this.defaults.async || opts.async || false; - // ==-- Parse "addon" extensions --== // - if (pack.extensions) { - pack.extensions.forEach((ext) => { - if (!ext.name) { - throw new Error('extension name required'); - } - if ('renderer' in ext) { // Renderer extensions - const prevRenderer = extensions.renderers[ext.name]; - if (prevRenderer) { - // Replace extension with func to run new extension but fall back if false - extensions.renderers[ext.name] = function (...args) { - let ret = ext.renderer.apply(this, args); - if (ret === false) { - ret = prevRenderer.apply(this, args); - } - return ret; - }; - } - else { - extensions.renderers[ext.name] = ext.renderer; - } - } - if ('tokenizer' in ext) { // Tokenizer Extensions - if (!ext.level || (ext.level !== 'block' && ext.level !== 'inline')) { - throw new Error("extension level must be 'block' or 'inline'"); - } - const extLevel = extensions[ext.level]; - if (extLevel) { - extLevel.unshift(ext.tokenizer); - } - else { - extensions[ext.level] = [ext.tokenizer]; - } - if (ext.start) { // Function to check for start of token - if (ext.level === 'block') { - if (extensions.startBlock) { - extensions.startBlock.push(ext.start); - } - else { - extensions.startBlock = [ext.start]; - } - } - else if (ext.level === 'inline') { - if (extensions.startInline) { - extensions.startInline.push(ext.start); - } - else { - extensions.startInline = [ext.start]; - } - } - } - } - if ('childTokens' in ext && ext.childTokens) { // Child tokens to be visited by walkTokens - extensions.childTokens[ext.name] = ext.childTokens; - } - }); - opts.extensions = extensions; - } - // ==-- Parse "overwrite" extensions --== // - if (pack.renderer) { - const renderer = this.defaults.renderer || new _Renderer(this.defaults); - for (const prop in pack.renderer) { - if (!(prop in renderer)) { - throw new Error(`renderer '${prop}' does not exist`); - } - if (['options', 'parser'].includes(prop)) { - // ignore options property - continue; - } - const rendererProp = prop; - const rendererFunc = pack.renderer[rendererProp]; - const prevRenderer = renderer[rendererProp]; - // Replace renderer with func to run extension, but fall back if false - renderer[rendererProp] = (...args) => { - let ret = rendererFunc.apply(renderer, args); - if (ret === false) { - ret = prevRenderer.apply(renderer, args); - } - return ret || ''; - }; - } - opts.renderer = renderer; - } - if (pack.tokenizer) { - const tokenizer = this.defaults.tokenizer || new _Tokenizer(this.defaults); - for (const prop in pack.tokenizer) { - if (!(prop in tokenizer)) { - throw new Error(`tokenizer '${prop}' does not exist`); - } - if (['options', 'rules', 'lexer'].includes(prop)) { - // ignore options, rules, and lexer properties - continue; - } - const tokenizerProp = prop; - const tokenizerFunc = pack.tokenizer[tokenizerProp]; - const prevTokenizer = tokenizer[tokenizerProp]; - // Replace tokenizer with func to run extension, but fall back if false - // @ts-expect-error cannot type tokenizer function dynamically - tokenizer[tokenizerProp] = (...args) => { - let ret = tokenizerFunc.apply(tokenizer, args); - if (ret === false) { - ret = prevTokenizer.apply(tokenizer, args); - } - return ret; - }; - } - opts.tokenizer = tokenizer; - } - // ==-- Parse Hooks extensions --== // - if (pack.hooks) { - const hooks = this.defaults.hooks || new _Hooks(); - for (const prop in pack.hooks) { - if (!(prop in hooks)) { - throw new Error(`hook '${prop}' does not exist`); - } - if (['options', 'block'].includes(prop)) { - // ignore options and block properties - continue; - } - const hooksProp = prop; - const hooksFunc = pack.hooks[hooksProp]; - const prevHook = hooks[hooksProp]; - if (_Hooks.passThroughHooks.has(prop)) { - // @ts-expect-error cannot type hook function dynamically - hooks[hooksProp] = (arg) => { - if (this.defaults.async) { - return Promise.resolve(hooksFunc.call(hooks, arg)).then(ret => { - return prevHook.call(hooks, ret); - }); - } - const ret = hooksFunc.call(hooks, arg); - return prevHook.call(hooks, ret); - }; - } - else { - // @ts-expect-error cannot type hook function dynamically - hooks[hooksProp] = (...args) => { - let ret = hooksFunc.apply(hooks, args); - if (ret === false) { - ret = prevHook.apply(hooks, args); - } - return ret; - }; - } - } - opts.hooks = hooks; - } - // ==-- Parse WalkTokens extensions --== // - if (pack.walkTokens) { - const walkTokens = this.defaults.walkTokens; - const packWalktokens = pack.walkTokens; - opts.walkTokens = function (token) { - let values = []; - values.push(packWalktokens.call(this, token)); - if (walkTokens) { - values = values.concat(walkTokens.call(this, token)); - } - return values; - }; - } - this.defaults = { ...this.defaults, ...opts }; - }); - return this; - } - setOptions(opt) { - this.defaults = { ...this.defaults, ...opt }; - return this; - } - lexer(src, options) { - return _Lexer.lex(src, options ?? this.defaults); - } - parser(tokens, options) { - return _Parser.parse(tokens, options ?? this.defaults); - } - parseMarkdown(blockType) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const parse = (src, options) => { - const origOpt = { ...options }; - const opt = { ...this.defaults, ...origOpt }; - const throwError = this.onError(!!opt.silent, !!opt.async); - // throw error if an extension set async to true but parse was called with async: false - if (this.defaults.async === true && origOpt.async === false) { - return throwError(new Error('marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise.')); - } - // throw error in case of non string input - if (typeof src === 'undefined' || src === null) { - return throwError(new Error('marked(): input parameter is undefined or null')); - } - if (typeof src !== 'string') { - return throwError(new Error('marked(): input parameter is of type ' - + Object.prototype.toString.call(src) + ', string expected')); - } - if (opt.hooks) { - opt.hooks.options = opt; - opt.hooks.block = blockType; - } - const lexer = opt.hooks ? opt.hooks.provideLexer() : (blockType ? _Lexer.lex : _Lexer.lexInline); - const parser = opt.hooks ? opt.hooks.provideParser() : (blockType ? _Parser.parse : _Parser.parseInline); - if (opt.async) { - return Promise.resolve(opt.hooks ? opt.hooks.preprocess(src) : src) - .then(src => lexer(src, opt)) - .then(tokens => opt.hooks ? opt.hooks.processAllTokens(tokens) : tokens) - .then(tokens => opt.walkTokens ? Promise.all(this.walkTokens(tokens, opt.walkTokens)).then(() => tokens) : tokens) - .then(tokens => parser(tokens, opt)) - .then(html => opt.hooks ? opt.hooks.postprocess(html) : html) - .catch(throwError); - } - try { - if (opt.hooks) { - src = opt.hooks.preprocess(src); - } - let tokens = lexer(src, opt); - if (opt.hooks) { - tokens = opt.hooks.processAllTokens(tokens); - } - if (opt.walkTokens) { - this.walkTokens(tokens, opt.walkTokens); - } - let html = parser(tokens, opt); - if (opt.hooks) { - html = opt.hooks.postprocess(html); - } - return html; - } - catch (e) { - return throwError(e); - } - }; - return parse; - } - onError(silent, async) { - return (e) => { - e.message += '\nPlease report this to https://github.com/markedjs/marked.'; - if (silent) { - const msg = '

    An error occurred:

    '
    -                    + escape$1(e.message + '', true)
    -                    + '
    '; - if (async) { - return Promise.resolve(msg); - } - return msg; - } - if (async) { - return Promise.reject(e); - } - throw e; - }; - } -} - -const markedInstance = new Marked(); -function marked(src, opt) { - return markedInstance.parse(src, opt); -} -/** - * Sets the default options. - * - * @param options Hash of options - */ -marked.options = - marked.setOptions = function (options) { - markedInstance.setOptions(options); - marked.defaults = markedInstance.defaults; - changeDefaults(marked.defaults); - return marked; - }; -/** - * Gets the original marked default options. - */ -marked.getDefaults = _getDefaults; -marked.defaults = _defaults; -/** - * Use Extension - */ -marked.use = function (...args) { - markedInstance.use(...args); - marked.defaults = markedInstance.defaults; - changeDefaults(marked.defaults); - return marked; -}; -/** - * Run callback for every token - */ -marked.walkTokens = function (tokens, callback) { - return markedInstance.walkTokens(tokens, callback); -}; -/** - * Compiles markdown to HTML without enclosing `p` tag. - * - * @param src String of markdown source to be compiled - * @param options Hash of options - * @return String of compiled HTML - */ -marked.parseInline = markedInstance.parseInline; -/** - * Expose - */ -marked.Parser = _Parser; -marked.parser = _Parser.parse; -marked.Renderer = _Renderer; -marked.TextRenderer = _TextRenderer; -marked.Lexer = _Lexer; -marked.lexer = _Lexer.lex; -marked.Tokenizer = _Tokenizer; -marked.Hooks = _Hooks; -marked.parse = marked; -const options = marked.options; -const setOptions = marked.setOptions; -const use = marked.use; -const walkTokens = marked.walkTokens; -const parseInline = marked.parseInline; -const parse = marked; -const parser = _Parser.parse; -const lexer = _Lexer.lex; - -export { _Hooks as Hooks, _Lexer as Lexer, Marked, _Parser as Parser, _Renderer as Renderer, _TextRenderer as TextRenderer, _Tokenizer as Tokenizer, _defaults as defaults, _getDefaults as getDefaults, lexer, marked, options, parse, parseInline, parser, setOptions, use, walkTokens }; -//# sourceMappingURL=marked.esm.js.map \ No newline at end of file diff --git a/web/model-manager.css b/web/model-manager.css deleted file mode 100644 index 394185e..0000000 --- a/web/model-manager.css +++ /dev/null @@ -1,717 +0,0 @@ -/* model manager */ -.model-manager { - background-color: var(--comfy-menu-bg); - box-sizing: border-box; - color: var(--bg-color); - font-family: monospace; - font-size: 15px; - height: 100%; - padding: 8px; - position: fixed; - overflow: hidden; - width: 100%; - z-index: 1100; - - /*override comfy-modal settings*/ - border-radius: 0; - box-shadow: none; - justify-content: unset; - max-height: 100vh; - max-width: 100vw; - transform: none; - /*disable double-tap zoom on model manager*/ - touch-action: manipulation; -} - -.model-manager .comfy-modal-content { - width: 100%; - gap: 16px; -} - -.model-manager .no-highlight { - user-select: none; - -moz-user-select: none; - -webkit-text-select: none; - -webkit-user-select: none; -} - -.model-manager label:has(> *){ - pointer-events: none; -} - -.model-manager label > * { - pointer-events: auto; -} - -/* sidebar */ - -.model-manager { - --model-manager-sidebar-width-left: 50vw; - --model-manager-sidebar-width-right: 50vw; - --model-manager-sidebar-height-top: 50vh; - --model-manager-sidebar-height-bottom: 50vh; - - --model-manager-thumbnail-width: 240px; - --model-manager-thumbnail-height: 360px; - - --model-manager-left: 0; - --model-manager-right: 0; - --model-manager-top: 0; - --model-manager-bottom: 0; - - left: var(--model-manager-left); - top: var(--model-manager-right); - right: var(--model-manager-top); - bottom: var(--model-manager-bottom); -} - -.model-manager.cursor-drag-left, -.model-manager.cursor-drag-right { - cursor: ew-resize; -} - -.model-manager.cursor-drag-top, -.model-manager.cursor-drag-bottom { - cursor: ns-resize; -} - -.model-manager.cursor-drag-top.cursor-drag-left, -.model-manager.cursor-drag-bottom.cursor-drag-right { - cursor: nwse-resize; -} - -.model-manager.cursor-drag-top.cursor-drag-right, -.model-manager.cursor-drag-bottom.cursor-drag-left { - cursor: nesw-resize; -} - -/* sidebar buttons */ -.model-manager .sidebar-buttons { - overflow: hidden; - color: var(--input-text); - display: flex; - gap: 2px; - flex-direction: row-reverse; - flex-wrap: wrap; -} - -.model-manager .sidebar-buttons .radio-button-group-active { - border-color: var(--fg-color); - color: var(--fg-color); - overflow: hidden; -} - -.model-manager[data-sidebar-state="left"] { - width: var(--model-manager-sidebar-width-left); - max-width: 95vw; - min-width: 22vw; - right: auto; - border-right: solid var(--border-color) 2px; -} - -.model-manager[data-sidebar-state="top"] { - height: var(--model-manager-sidebar-height-top); - max-height: 95vh; - min-height: 22vh; - bottom: auto; - border-bottom: solid var(--border-color) 2px; -} - -.model-manager[data-sidebar-state="bottom"] { - height: var(--model-manager-sidebar-height-bottom); - max-height: 95vh; - min-height: 22vh; - top: auto; - border-top: solid var(--border-color) 2px; -} - -.model-manager[data-sidebar-state="right"] { - width: var(--model-manager-sidebar-width-right); - max-width: 95vw; - min-width: 22vw; - left: auto; - border-left: solid var(--border-color) 2px; -} - -/* common */ -.model-manager h1 { - min-width: 0; - overflow-wrap: break-word; -} - -.model-manager textarea { - border: solid 2px var(--border-color); - border-radius: 8px; - font-size: 1.2em; - resize: vertical; - width: 100%; - height: 100%; -} - -.model-manager input[type="file"] { - width: 100%; -} - -.model-manager button, .model-manager .model-manager-head .topbar-right select { - margin: 0; - border: 2px solid var(--border-color); -} - -.model-manager button:not(.icon-button), -.model-manager select, -.model-manager input { - padding: 4px 8px; - margin: 0; -} - -.model-manager button:disabled, -.model-manager select:disabled, -.model-manager input:disabled { - background-color: var(--comfy-menu-bg); - filter: brightness(1.2); - cursor: not-allowed; -} - -.model-manager select:hover{ - filter: brightness(1.2); - cursor: pointer; -} - -.model-manager button.block { - width: 100%; -} - -.model-manager ::-webkit-scrollbar { - width: 16px; -} - -.model-manager ::-webkit-scrollbar-track { - 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: var(--fg-color); - border-radius: 3px; -} - -.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; -} - -.model-manager .icon-button { - height: 40px; - width: 40px; - line-height: 1.15; -} - -.model-manager .row { - display: flex; - min-width: 0; - gap: 8px; -} - -.model-manager .tab-header { - display: flex; - padding: 8px 0px; - flex-direction: column; - background-color: var(--bg-color); -} - -.model-manager .tab-header-flex-block { - width: 100%; - min-width: 0; -} - -.model-manager .comfy-button-success { - color: green; - border-color: green; -} - -.model-manager .comfy-button-failure { - color: darkred; - border-color: darkred; -} - -.model-manager .no-select { - -webkit-user-select: none; - -ms-user-select: none; - user-select: none; -} - -/* main content */ -.model-manager .model-manager-panel { - color: var(--fg-color); -} - -.model-manager .model-tab-group { - display: flex; - gap: 4px; - height: 44px; -} - -.model-manager .model-tab-group .tab-button { - background-color: var(--comfy-menu-bg); - border: 2px solid var(--border-color); - border-bottom: none; - border-radius: 8px 8px 0px 0px; - cursor: pointer; - padding: 8px 12px; - margin-bottom: 0px; - z-index: 1; -} - -.model-manager .model-tab-group .tab-button.active { - background-color: var(--bg-color); - margin-bottom: -2px; - cursor: default; - position: relative; - z-index: 1; - pointer-events: none; -} - -.model-manager .model-manager-body { - background-color: var(--bg-color); - border: 2px solid var(--border-color); -} - -.model-manager .model-manager-panel { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.model-manager .model-manager-body { - flex: 1; - overflow: hidden; - padding: 8px 0px 8px 16px; -} - -.model-manager .model-manager-body .tab-contents { - position: relative; - display: flex; - flex-direction: column; - height: 100%; - width: auto; - overflow-x: auto; - overflow-y: hidden; -} - -.model-manager .model-manager-body .tab-content { - display: flex; - flex-direction: column; - height: 100%; - overflow-y: auto; - padding-right: 16px; -} - -/* model info view */ -.model-manager .model-info-container { - background-color: var(--bg-color); - border-radius: 16px; - color: var(--fg-color); - width: auto; -} - -.model-manager .model-metadata { - table-layout: fixed; - text-align: left; - width: 100%; -} - -.model-manager .model-metadata-key { - overflow-wrap: break-word; - width: 20%; -} - -.model-manager .model-metadata-value { - overflow-wrap: anywhere; - width: 80%; -} - -.model-manager table { - border-collapse: collapse; -} - -.model-manager th { - border: 1px solid; - padding: 4px 8px; -} - -/* download tab */ - -.model-manager .download-model-infos { - display: flex; - flex-direction: column; - padding: 0; - row-gap: 10px; -} - -.model-manager .download-details summary { - background-color: var(--comfy-menu-bg); - border-radius: 16px; - padding: 16px; - word-wrap: break-word; -} - -.model-manager .download-details[open] summary { - background-color: var(--border-color); -} - -.model-manager .download-details > div { - column-gap: 8px; - display: flex; - flex-direction: row; - flex-wrap: wrap; - padding: 8px; - row-gap: 16px; -} - -.model-manager [data-name="Download"] .download-settings-wrapper { - flex: 1; -} - -.model-manager [data-name="Download"] .download-settings { - display: flex; - flex-direction: column; - row-gap: 16px; -} - -.model-manager .download-button { - max-width: fit-content; -} - -/* models tab */ -.model-manager [data-name="Models"] .row { - position: sticky; - z-index: 1; - top: 0; -} - -/* preview image */ -.model-manager .item { - position: relative; - width: var(--model-manager-thumbnail-width);; - height: var(--model-manager-thumbnail-height);; - text-align: center; - overflow: hidden; - border-radius: 8px; -} - -.model-manager .item img { - width: 100%; - height: 100%; - object-fit: cover; - border-radius: 8px; -} - -.model-manager .model-info-container .item { - width: auto; - height: auto; -} -.model-manager .model-info-container .item img { - height: auto; - width: auto; - max-width: 100%; - max-height: 50vh; -} - -.model-manager .model-preview-button-left, -.model-manager .model-preview-button-right { - position: absolute; - 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: rgb(from var(--content-hover-bg) r g b / 0.6); - 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; - 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 .item .model-buttons-hidden { - display: none; -} - -.model-manager .item:hover .model-buttons-hidden, -.model-manager .comfy-grid .model-buttons-visible { - display: flex; -} - -.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-manager .model-preview-select-radio-container { - min-width: 0; - flex: 1; -} - -.model-manager .model-preview-select-radio-inputs > div { - padding: 16px 0 8px 0; -} - -.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 { - display: flex; - float: right; -} - -.model-manager .topbar-buttons button { - height: 33px; - padding: 1px 6px; - width: 33px; -} - -.model-manager .model-manager-head .topbar-left { - display: flex; - float: left; -} - -.model-manager .model-manager-head .topbar-right { - column-gap: 4px; - display: flex; - flex-direction: row-reverse; - float: right; -} - -.model-manager .model-manager-head .topbar-right select { - position: relative; - top: 0; - bottom: 0; - font-size: 20px; - text-align-last: center; - -o-appearance: none; - -ms-appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; -} - -/* search dropdown */ -.model-manager .input-dropdown-container { - position: relative; -} - -.model-manager .search-models { - display: flex; - flex: 1; - flex-direction: row; - 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-directory-dropdown { - background-color: var(--bg-color); - border: 2px var(--border-color) solid; - border-radius: 10px; - color: var(--fg-color); - max-height: 40vh; - overflow: auto; - position: absolute; - z-index: 1; -} - -@media (pointer:none), (pointer:coarse) { - .model-manager .search-directory-dropdown { - max-height: 17.5vh; - } -} - -.model-manager .search-directory-dropdown:empty { - display: none; -} - -.model-manager .search-directory-dropdown > p { - margin: 0; - padding: 0.85em 20px; - min-width: 0; -} -.model-manager .search-directory-dropdown > p { - -ms-overflow-style: none; /* Internet Explorer 10+ */ - scrollbar-width: none; /* Firefox */ -} -.model-manager .search-directory-dropdown > p::-webkit-scrollbar { - display: none; /* Safari and Chrome */ -} - -.model-manager .search-directory-dropdown > p.search-directory-dropdown-key-selected, -.model-manager .search-directory-dropdown > p.search-directory-dropdown-mouse-selected { - background-color: var(--border-color); -} - -.model-manager .search-directory-dropdown > p.search-directory-dropdown-key-selected { - border-left: 1mm solid var(--input-text); -} - -/* model manager settings */ -.model-manager .model-manager-settings > div, -.model-manager .model-manager-settings > label, -.model-manager .tag-generator-settings > label, -.model-manager .tag-generator-settings > div { - display: flex; - flex-direction: row; - align-items: center; - gap: 8px; - margin: 16px 0; -} - -.model-manager .model-manager-settings button { - height: 40px; - min-width: 120px; - justify-content: center; -} - -.model-manager .model-manager-settings input[type="number"], -.model-manager .tag-generator-settings input[type="number"]{ - width: 60px; -} - -.model-manager .search-settings-text { - width: 100%; -} diff --git a/web/model-manager.js b/web/model-manager.js deleted file mode 100644 index 4a8413b..0000000 --- a/web/model-manager.js +++ /dev/null @@ -1,5642 +0,0 @@ -import { app } from '../../scripts/app.js'; -import { api } from '../../scripts/api.js'; -import { ComfyDialog, $el } from '../../scripts/ui.js'; -import { ComfyButton } from '../../scripts/ui/components/button.js'; -import { marked } from './marked.js'; -import('./downshow.js'); - -function clamp(x, min, max) { - return Math.min(Math.max(x, min), max); -} - -/** - * @param {string} url - * @param {any} [options=undefined] - * @returns {Promise} - */ -function comfyRequest(url, options = undefined) { - return new Promise((resolve, reject) => { - api - .fetchApi(url, options) - .then((response) => response.json()) - .then(resolve) - .catch(reject); - }); -} - -/** - * @param {(...args) => Promise} 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); - }; -} - -class KeyComboListener { - /** @type {string[]} */ - #keyCodes = []; - - /** @type {() => Promise} */ - action; - - /** @type {Element} */ - element; - - /** @type {string[]} */ - #combo = []; - - /** - * @param {string[]} keyCodes - * @param {() => Promise} action - * @param {Element} element - */ - constructor(keyCodes, action, element) { - this.#keyCodes = keyCodes; - this.action = action; - this.element = element; - - document.addEventListener('keydown', (e) => { - const code = e.code; - const keyCodes = this.#keyCodes; - const combo = this.#combo; - if (keyCodes.includes(code) && !combo.includes(code)) { - combo.push(code); - } - if (combo.length === 0 || keyCodes.length !== combo.length) { - return; - } - for (let i = 0; i < combo.length; i++) { - if (keyCodes[i] !== combo[i]) { - return; - } - } - if (document.activeElement !== this.element) { - return; - } - e.preventDefault(); - e.stopPropagation(); - this.action(); - this.#combo.length = 0; - }); - document.addEventListener('keyup', (e) => { - // Mac keyup doesn't fire when meta key is held: https://stackoverflow.com/a/73419500 - const code = e.code; - if (code === 'MetaLeft' || code === 'MetaRight') { - this.#combo.length = 0; - } else { - this.#combo = this.#combo.filter((x) => x !== code); - } - }); - } -} - -// This is used in Firefox to bypass the ‘dragend’ event because it returns incorrect ‘clientX’ and ‘clientY’ -const IS_FIREFOX = navigator.userAgent.indexOf('Firefox') > -1; - -/** - * @param {string} url - */ -async function loadWorkflow(url) { - const uri = new URL(url).searchParams.get('uri'); - const fileNameIndex = - Math.max(uri.lastIndexOf('/'), uri.lastIndexOf('\\')) + 1; - const fileName = uri.substring(fileNameIndex); - const response = await fetch(url); - const data = await response.blob(); - const file = new File([data], fileName, { type: data.type }); - app.handleFile(file); -} - -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', - 'gguf', - '.onnx', - '.pt', - '.pth', - '.safetensors', -]; // TODO: ask server for? -const IMAGE_EXTENSIONS = [ - '.png', - '.webp', - '.jpeg', - '.jpg', - '.jfif', - '.gif', - '.apng', - - '.preview.png', - '.preview.webp', - '.preview.jpeg', - '.preview.jpg', - '.preview.jfif', - '.preview.gif', - '.preview.apng', -]; // TODO: /model-manager/image/extensions - -/** - * @param {string} s - * @param {string} prefix - * @returns {string} - */ -function removePrefix(s, prefix) { - if (s.length >= prefix.length && s.startsWith(prefix)) { - return s.substring(prefix.length); - } - return s; -} - -/** - * @param {string} s - * @param {string} suffix - * @returns {string} - */ -function removeSuffix(s, suffix) { - if (s.length >= suffix.length && s.endsWith(suffix)) { - return s.substring(0, s.length - suffix.length); - } - return s; -} - -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] - * @param {string | undefined} [width=undefined] - * @param {string | undefined} [height=undefined] - * @param {string | undefined} [imageFormat=undefined] - * @returns {string} - */ -function imageUri( - imageSearchPath = undefined, - dateImageModified = undefined, - width = undefined, - height = undefined, - imageFormat = undefined, -) { - const path = imageSearchPath ?? 'no-preview'; - const date = dateImageModified; - let uri = `/model-manager/preview/get?uri=${path}`; - if (width !== undefined && width !== null) { - uri += `&width=${width}`; - } - if (height !== undefined && height !== null) { - uri += `&height=${height}`; - } - if (date !== undefined && date !== null) { - uri += `&v=${date}`; - } - if (imageFormat !== undefined && imageFormat !== null) { - uri += `&image-format=${imageFormat}`; - } - return uri; -} -const PREVIEW_NONE_URI = imageUri(); - -/** - * - * @param {HTMLButtonElement} element - * @returns {[HTMLButtonElement | undefined, HTMLElement | undefined, HTMLSpanElement | undefined]} [button, icon, span] - */ -function comfyButtonDisambiguate(element) { - // TODO: This likely can be removed by using a css rule that disables clicking on the inner elements of the button. - let button = undefined; - let icon = undefined; - let span = undefined; - const nodeName = element.nodeName.toLowerCase(); - if (nodeName === 'button') { - button = element; - icon = button.getElementsByTagName('i')[0]; - span = button.getElementsByTagName('span')[0]; - } else if (nodeName === 'i') { - icon = element; - button = element.parentElement; - span = button.getElementsByTagName('span')[0]; - } else if (nodeName === 'span') { - button = element.parentElement; - icon = button.getElementsByTagName('i')[0]; - span = element; - } - return [button, icon, span]; -} - -/** - * @param {HTMLButtonElement} element - * @param {boolean} success - * @param {string?} successClassName - * @param {string?} failureClassName - * @param {boolean?} [disableCallback=false] - */ -function comfyButtonAlert( - element, - success, - successClassName = undefined, - failureClassName = undefined, - disableCallback = false, -) { - if (element === undefined || element === null) { - return; - } - - const [button, icon, span] = comfyButtonDisambiguate(element); - if (button === undefined) { - console.warn('Unable to find button element!'); - console.warn(element); - return; - } - - // TODO: debounce would be nice, but needs some sort of "global" to avoid creating/destroying many objects - - const colorClassName = success - ? 'comfy-button-success' - : 'comfy-button-failure'; - - if (icon) { - const iconClassName = (success ? successClassName : failureClassName) ?? ''; - if (iconClassName !== '') { - icon.classList.add(iconClassName); - } - icon.classList.add(colorClassName); - if (!disableCallback) { - window.setTimeout( - (element, iconClassName, colorClassName) => { - if (iconClassName !== '') { - element.classList.remove(iconClassName); - } - element.classList.remove(colorClassName); - }, - 1000, - icon, - iconClassName, - colorClassName, - ); - } - } - - button.classList.add(colorClassName); - if (!disableCallback) { - window.setTimeout( - (element, colorClassName) => { - element.classList.remove(colorClassName); - }, - 1000, - button, - colorClassName, - ); - } -} - -/** - * - * @param {string} modelPath - * @param {string} newValue - * @returns {Promise} - */ -async function saveNotes(modelPath, newValue) { - const timestamp = await comfyRequest('/model-manager/timestamp').catch( - (err) => { - console.warn(err); - return false; - }, - ); - return await comfyRequest('/model-manager/notes/save', { - method: 'POST', - body: JSON.stringify({ - path: modelPath, - notes: newValue, - }), - timestamp: timestamp, - }) - .then((result) => { - const saved = result['success']; - const message = result['alert']; - if (message !== undefined) { - window.alert(message); - } - return saved; - }) - .catch((err) => { - console.warn(err); - return false; - }); -} - -/** - * @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; -} - -/** - * @returns {HTMLLabelElement} - */ -function $select(x = { $: (el) => {}, textContent: '', options: [''] }) { - const text = x.textContent; - const select = $el( - 'select', - { - name: text ?? 'select', - }, - x.options.map((option) => { - return $el( - 'option', - { - value: option, - }, - option, - ); - }), - ); - const label = $el('label', [ - text === '' || text === undefined || text === null ? '' : ' ' + text, - select, - ]); - if (x.$ !== undefined) { - x.$(select); - } - 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() }, [ - $el('input.radio-input', { - type: 'radio', - name: name, - value: item.value, - checked: index === 0, - $: (el) => (inputRef.value = el), - }), - $el('label.no-highlight', item.label ?? item.value), - ]); - }); - - const element = $el('input', { - name: name + '-group', - value: options[0]?.value, - }); - $?.(element); - - radioGroup.forEach((radio) => { - radio.addEventListener('change', (event) => { - const selectedValue = event.target.value; - element.value = selectedValue; - onchange?.(selectedValue); - }); - }); - - return $el('div.comfy-radio-group', radioGroup); -} - -/** - * @param {{name: string, icon: string, tabContent: HTMLDivElement}[]} tabData - * @returns {[HTMLDivElement[], HTMLDivElement[]]} - */ -function GenerateTabGroup(tabData) { - const ACTIVE_TAB_CLASS = 'active'; - - /** @type {HTMLDivElement[]} */ - const tabButtons = []; - - /** @type {HTMLDivElement[]} */ - const tabContents = []; - - tabData.forEach((data) => { - const name = data.name; - const icon = data.icon; - /** @type {HTMLDivElement} */ - const tab = new ComfyButton({ - icon: icon, - tooltip: 'Open ' + name.toLowerCase() + ' tab', - classList: 'comfyui-button tab-button', - content: name, - action: () => { - tabButtons.forEach((tabButton) => { - if (name === tabButton.getAttribute('data-name')) { - tabButton.classList.add(ACTIVE_TAB_CLASS); - } else { - tabButton.classList.remove(ACTIVE_TAB_CLASS); - } - }); - tabContents.forEach((tabContent) => { - if (name === tabContent.getAttribute('data-name')) { - tabContent.scrollTop = tabContent.dataset['scrollTop'] ?? 0; - tabContent.style.display = ''; - } else { - tabContent.dataset['scrollTop'] = tabContent.scrollTop; - tabContent.style.display = 'none'; - } - }); - }, - }).element; - tab.dataset.name = name; - const content = $el( - 'div.tab-content', - { - dataset: { - name: data.name, - }, - }, - [data.tabContent], - ); - tabButtons.push(tab); - tabContents.push(content); - }); - - return [tabButtons, tabContents]; -} - -/** - * @param {HTMLDivElement} element - * @param {Record[]} tabButtons - */ -function GenerateDynamicTabTextCallback(element, tabButtons, minWidth) { - return () => { - if (element.style.display === 'none') { - return; - } - const managerRect = element.getBoundingClientRect(); - const isIcon = managerRect.width < minWidth; // TODO: `minWidth` is a magic value - const iconDisplay = isIcon ? '' : 'none'; - const spanDisplay = isIcon ? 'none' : ''; - tabButtons.forEach((tabButton) => { - tabButton.getElementsByTagName('i')[0].style.display = iconDisplay; - tabButton.getElementsByTagName('span')[0].style.display = spanDisplay; - }); - }; -} - -/** - * @param {[String, int][]} map - * @returns {String} - */ -function TagCountMapToParagraph(map) { - let text = '

    '; - for (let i = 0; i < map.length; i++) { - const v = map[i]; - const tag = v[0]; - const count = v[1]; - text += tag + ' (' + count + ')'; - if (i !== map.length - 1) { - text += ', '; - } - } - text += '

    '; - return text; -} - -/** - * @param {String} p - * @returns {[String, int][]} - */ -function ParseTagParagraph(p) { - return p.split(',').map((x) => { - const text = x.endsWith(', ') ? x.substring(0, x.length - 2) : x; - const i = text.lastIndexOf('('); - const tag = text.substring(0, i).trim(); - const frequency = parseInt(text.substring(i + 1, text.length - 1)); - return [tag, frequency]; - }); -} - -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 {Promise | Promise} */ - async 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 = PREVIEW_NONE_URI; - let url = ''; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - if ( - child.style.display !== 'none' && - child.nodeName === 'IMG' && - !child.src.endsWith(noImage) - ) { - url = child.src; - } - } - if (url.startsWith(Civitai.imageUrlPrefix())) { - url = await Civitai.getFullSizeImageUrl(url).catch((err) => { - console.warn(err); - return url; - }); - } - return url; - } - case this.#PREVIEW_URL: { - const value = elements.customUrl.value; - if (value.startsWith(Civitai.imagePostUrlPrefix())) { - try { - const imageInfo = await Civitai.getImageInfo(value); - const items = imageInfo['items']; - if (items.length === 0) { - console.warn('Civitai /api/v1/images returned 0 items.'); - return value; - } - return items[0]['url']; - } catch (error) { - console.error('Failed to get image info from Civitai!', error); - return value; - } - } - return value; - } - case this.#PREVIEW_UPLOAD: - return elements.uploadFile.files[0] ?? ''; - case this.#PREVIEW_NONE: - return PREVIEW_NONE_URI; - } - 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 = PREVIEW_NONE_URI; - } - }); - 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 = [PREVIEW_NONE_URI]; - } - this.#name = radioGroupName; - - const el_defaultUri = $el('div', { - $: (el) => (this.elements.defaultUrl = el), - style: { display: 'none' }, - 'data-noimage': PREVIEW_NONE_URI, - }); - - const el_defaultPreviewNoImage = $el('img', { - $: (el) => (this.elements.defaultPreviewNoImage = el), - loading: - 'lazy' /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */, - src: PREVIEW_NONE_URI, - style: { display: 'none' }, - }); - - const el_defaultPreviews = $el( - 'div', - { - $: (el) => (this.elements.defaultPreviews = el), - style: { - width: '100%', - height: '100%', - }, - }, - (() => { - const imgs = defaultPreviews.map((url) => { - return $el('img', { - loading: - 'lazy' /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */, - src: url, - style: { display: 'none' }, - onerror: (e) => { - e.target.src = el_defaultUri.dataset.noimage ?? PREVIEW_NONE_URI; - }, - }); - }); - if (imgs.length > 0) { - imgs[0].style.display = 'block'; - } - return imgs; - })(), - ); - - const el_uploadPreview = $el('img', { - $: (el) => (this.elements.uploadPreview = el), - src: PREVIEW_NONE_URI, - style: { display: 'none' }, - onerror: (e) => { - e.target.src = el_defaultUri.dataset.noimage ?? PREVIEW_NONE_URI; - }, - }); - 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], - ); - - /** - * @param {string} url - * @returns {Promise} - */ - const getCustomPreviewUrl = async (url) => { - if (url.startsWith(Civitai.imagePostUrlPrefix())) { - return await Civitai.getImageInfo(url) - .then((imageInfo) => { - const items = imageInfo['items']; - if (items.length > 0) { - return items[0]['url']; - } else { - console.warn('Civitai /api/v1/images returned 0 items.'); - return url; - } - }) - .catch((error) => { - console.error('Failed to get image info from Civitai!', error); - return url; - }); - } else { - return url; - } - }; - - const el_customUrlPreview = $el('img', { - $: (el) => (this.elements.customUrlPreview = el), - src: PREVIEW_NONE_URI, - style: { display: 'none' }, - onerror: (e) => { - e.target.src = el_defaultUri.dataset.noimage ?? PREVIEW_NONE_URI; - }, - }); - 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', - onkeydown: async (e) => { - if (e.key === 'Enter') { - const value = e.target.value; - el_customUrlPreview.src = await getCustomPreviewUrl(value); - e.stopPropagation(); - e.target.blur(); - } - }, - }); - const el_custom = $el( - 'div.row.tab-header-flex-block', - { - $: (el) => (this.elements.custom = el), - style: { display: 'none' }, - }, - [ - el_customUrl, - new ComfyButton({ - icon: 'magnify', - tooltip: 'Search models', - classList: 'comfyui-button icon-button', - action: async (e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - const value = el_customUrl.value; - el_customUrlPreview.src = await getCustomPreviewUrl(value); - e.stopPropagation(); - el_customUrl.blur(); - button.disabled = false; - }, - }).element, - ], - ); - - const el_previewButtons = $el( - 'div.model-preview-overlay', - { - style: { - display: el_defaultPreviews.children.length > 1 ? 'block' : 'none', - }, - }, - [ - new ComfyButton({ - icon: 'arrow-left', - tooltip: 'Previous image', - classList: 'comfyui-button icon-button model-preview-button-left', - action: () => this.stepDefaultPreviews(-1), - }).element, - new ComfyButton({ - icon: 'arrow-right', - tooltip: 'Next image', - classList: 'comfyui-button icon-button model-preview-button-right', - action: () => this.stepDefaultPreviews(1), - }).element, - ], - ); - 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.model-preview-select-radio-inputs', [el_custom, el_upload]), - ], - ); - } -} - -/** - * @typedef {Object} DirectoryItem - * @property {String} name - * @property {number | undefined} childCount - * @property {number | undefined} childIndex - */ - -class ModelDirectories { - /** @type {DirectoryItem[]} */ - data = []; - - /** - * @returns {number} - */ - rootIndex() { - return 0; - } - - /** - * @param {any} index - * @returns {boolean} - */ - isValidIndex(index) { - return typeof index === 'number' && 0 <= index && index < this.data.length; - } - - /** - * @param {number} index - * @returns {DirectoryItem} - */ - getItem(index) { - if (!this.isValidIndex(index)) { - throw new Error(`Index '${index}' is not valid!`); - } - return this.data[index]; - } - - /** - * @param {DirectoryItem | number} item - * @returns {boolean} - */ - isDirectory(item) { - if (typeof item === 'number') { - item = this.getItem(item); - } - const childCount = item.childCount; - return childCount !== undefined && childCount != null; - } - - /** - * @param {DirectoryItem | number} item - * @returns {boolean} - */ - isEmpty(item) { - if (typeof item === 'number') { - item = this.getItem(item); - } - if (!this.isDirectory(item)) { - throw new Error('Item is not a directory!'); - } - return item.childCount === 0; - } - - /** - * Returns a slice of children from the directory list. - * @param {DirectoryItem | number} item - * @returns {DirectoryItem[]} - */ - getChildren(item) { - if (typeof item === 'number') { - item = this.getItem(item); - if (!this.isDirectory(item)) { - throw new Error('Item is not a directory!'); - } - } else if (!this.isDirectory(item)) { - throw new Error('Item is not a directory!'); - } - const count = item.childCount; - const index = item.childIndex; - return this.data.slice(index, index + count); - } - - /** - * Returns index of child in parent directory. Returns -1 if DNE. - * @param {DirectoryItem | number} parent - * @param {string} name - * @returns {number} - */ - findChildIndex(parent, name) { - const item = this.getItem(parent); - if (!this.isDirectory(item)) { - throw new Error('Item is not a directory!'); - } - const start = item.childIndex; - const children = this.getChildren(item); - const index = children.findIndex((item) => { - return item.name === name; - }); - if (index === -1) { - return -1; - } - return index + start; - } - - /** - * Returns a list of matching search results and valid path. - * @param {string} filter - * @param {string} searchSeparator - * @param {boolean} directoriesOnly - * @returns {[string[], string]} - */ - search(filter, searchSeparator, directoriesOnly) { - let cwd = this.rootIndex(); - let indexLastWord = 1; - while (true) { - const indexNextWord = filter.indexOf(searchSeparator, indexLastWord); - if (indexNextWord === -1) { - // end of filter - break; - } - - const item = this.getItem(cwd); - if (!this.isDirectory(item) || this.isEmpty(item)) { - break; - } - - const word = filter.substring(indexLastWord, indexNextWord); - cwd = this.findChildIndex(cwd, word); - if (!this.isValidIndex(cwd)) { - return [[], '']; - } - indexLastWord = indexNextWord + 1; - } - //const cwdPath = filter.substring(0, indexLastWord); - - const lastWord = filter.substring(indexLastWord); - const children = this.getChildren(cwd); - if (directoriesOnly) { - let indexPathEnd = indexLastWord; - const results = children - .filter((child) => { - return this.isDirectory(child) && child.name.startsWith(lastWord); - }) - .map((directory) => { - const children = this.getChildren(directory); - const hasChildren = children.some((item) => { - return this.isDirectory(item); - }); - const suffix = hasChildren ? searchSeparator : ''; - //const suffix = searchSeparator; - if (directory.name == lastWord) { - indexPathEnd += searchSeparator.length + directory.name.length + 1; - } - return directory.name + suffix; - }); - const path = filter.substring(0, indexPathEnd); - return [results, path]; - } else { - let indexPathEnd = indexLastWord; - const results = children - .filter((child) => { - return child.name.startsWith(lastWord); - }) - .map((item) => { - const isDir = this.isDirectory(item); - const isNonEmptyDirectory = isDir && item.childCount > 0; - const suffix = isNonEmptyDirectory ? searchSeparator : ''; - //const suffix = isDir ? searchSeparator : ""; - if (!isDir && item.name == lastWord) { - indexPathEnd += searchSeparator.length + item.name.length + 1; - } - return item.name + suffix; - }); - const path = filter.substring(0, indexPathEnd); - return [results, path]; - } - } -} - -const DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS = - 'search-directory-dropdown-key-selected'; -const DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS = - 'search-directory-dropdown-mouse-selected'; - -class ModelData { - /** @type {string} */ - searchSeparator = '/'; // TODO: other client or server code may be assuming this to always be "/" - - /** @type {string} */ - systemSeparator = null; - - /** @type {Object} */ - models = {}; - - /** @type {ModelDirectories} */ - directories = null; - - constructor() { - this.directories = new ModelDirectories(); - } -} - -class DirectoryDropdown { - /** @type {HTMLDivElement} */ - element = null; - - /** @type {Boolean} */ - showDirectoriesOnly = false; - - /** @type {HTMLInputElement} */ - #input = null; - - /** @type {() => string} */ - #getModelType = null; - - /** @type {ModelData} */ - #modelData = null; // READ ONLY - - /** @type {() => void} */ - #updateCallback = null; - - /** @type {() => Promise} */ - #submitCallback = null; - - /** @type {string} */ - #deepestPreviousPath = '/'; - - /** @type {Any} */ - #touchSelectionStart = null; - - /** @type {() => Boolean} */ - #isDynamicSearch = () => { - return false; - }; - - /** - * @param {ModelData} modelData - * @param {HTMLInputElement} input - * @param {Boolean} [showDirectoriesOnly=false] - * @param {() => string} [getModelType= () => { return ""; }] - * @param {() => void} [updateCallback= () => {}] - * @param {() => Promise} [submitCallback= () => {}] - * @param {() => Boolean} [isDynamicSearch= () => { return false; }] - */ - constructor( - modelData, - input, - showDirectoriesOnly = false, - getModelType = () => { - return ''; - }, - updateCallback = () => {}, - submitCallback = () => {}, - isDynamicSearch = () => { - return false; - }, - ) { - /** @type {HTMLDivElement} */ - const dropdown = $el('div.search-directory-dropdown', { - style: { - display: 'none', - }, - }); - this.element = dropdown; - this.#modelData = modelData; - this.#input = input; - this.#getModelType = getModelType; - this.#updateCallback = updateCallback; - this.#submitCallback = submitCallback; - this.showDirectoriesOnly = showDirectoriesOnly; - this.#isDynamicSearch = isDynamicSearch; - - input.addEventListener('input', async (e) => { - const path = this.#updateOptions(); - if (path !== undefined) { - this.#restoreSelectedOption(path); - this.#updateDeepestPath(path); - } - updateCallback(); - if (isDynamicSearch()) { - await submitCallback(); - } - }); - input.addEventListener('focus', () => { - const path = this.#updateOptions(); - if (path !== undefined) { - this.#deepestPreviousPath = path; - this.#restoreSelectedOption(path); - } - updateCallback(); - }); - input.addEventListener('blur', () => { - dropdown.style.display = 'none'; - }); - input.addEventListener('keydown', async (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_KEY_CLASS) - ) { - break; - } - } - if (e.key === 'Escape') { - e.stopPropagation(); - if (iSelection < options.length) { - const selection = options[iSelection]; - selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_KEY_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; - const searchSeparator = modelData.searchSeparator; - DirectoryDropdown.selectionToInput( - input, - selection, - searchSeparator, - DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS, - ); - const path = this.#updateOptions(); - if (path !== undefined) { - this.#restoreSelectedOption(path); - this.#updateDeepestPath(path); - } - updateCallback(); - if (isDynamicSearch()) { - await submitCallback(); - } - } - } else if (e.key === 'ArrowLeft' && dropdown.style.display !== 'none') { - const input = e.target; - const oldFilterText = input.value; - const searchSeparator = modelData.searchSeparator; - 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; - const path = this.#updateOptions(); - if (path !== undefined) { - this.#restoreSelectedOption(path); - this.#updateDeepestPath(path); - } - updateCallback(); - if (isDynamicSearch()) { - await submitCallback(); - } - } - } - } else if (e.key === 'Enter') { - e.stopPropagation(); - const input = e.target; - if (dropdown.style.display !== 'none') { - /* - // This is WAY too confusing. - const selection = options[iSelection]; - if (selection !== undefined && selection !== null) { - DirectoryDropdown.selectionToInput( - input, - selection, - modelData.searchSeparator, - DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS - ); - const path = this.#updateOptions(); - if (path !== undefined) { - this.#updateDeepestPath(path); - } - updateCallback(); - } - */ - } - await 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_KEY_CLASS); - const delta = e.key === 'ArrowDown' ? 1 : -1; - iNext = iSelection + delta; - if (iNext < 0) { - iNext = options.length - 1; - } else if (iNext >= options.length) { - iNext = 0; - } - const selectionNext = options[iNext]; - selectionNext.classList.add(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); - } else if (iSelection === options.length) { - // none - iNext = e.key === 'ArrowDown' ? 0 : options.length - 1; - const selection = options[iNext]; - selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); - } - if (0 <= iNext && iNext < options.length) { - DirectoryDropdown.#clampDropdownScrollTop(dropdown, options[iNext]); - } 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_KEY_CLASS, - ) - ) { - selection.classList.remove( - DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS, - ); - } - } - } - } - }); - } - - /** - * @param {HTMLInputElement} input - * @param {HTMLParagraphElement | undefined | null} selection - * @param {String} searchSeparator - * @param {String} className - * @returns {boolean} changed - */ - static selectionToInput(input, selection, searchSeparator, className) { - selection.classList.remove(className); - const selectedText = selection.innerText; - const oldFilterText = input.value; - const iSep = oldFilterText.lastIndexOf(searchSeparator); - const previousPath = oldFilterText.substring(0, iSep + 1); - const newFilterText = previousPath + selectedText; - input.value = newFilterText; - return newFilterText !== oldFilterText; - } - - /** - * @param {string} path - */ - #updateDeepestPath = (path) => { - const deepestPath = this.#deepestPreviousPath; - if (path.length > deepestPath.length || !deepestPath.startsWith(path)) { - this.#deepestPreviousPath = path; - } - }; - - /** - * @param {HTMLDivElement} dropdown - * @param {HTMLParagraphElement} selection - */ - static #clampDropdownScrollTop = (dropdown, selection) => { - let dropdownTop = dropdown.scrollTop; - const dropdownHeight = dropdown.offsetHeight; - const selectionHeight = selection.offsetHeight; - const selectionTop = selection.offsetTop; - dropdownTop = Math.max( - dropdownTop, - selectionTop - dropdownHeight + selectionHeight, - ); - dropdownTop = Math.min(dropdownTop, selectionTop); - dropdown.scrollTop = dropdownTop; - }; - - /** - * @param {string} path - */ - #restoreSelectedOption(path) { - const searchSeparator = this.#modelData.searchSeparator; - const deepest = this.#deepestPreviousPath; - if (deepest.length >= path.length && deepest.startsWith(path)) { - let name = deepest.substring(path.length); - name = removePrefix(name, searchSeparator); - const i1 = name.indexOf(searchSeparator); - if (i1 !== -1) { - name = name.substring(0, i1); - } - - const dropdown = this.element; - const options = dropdown.children; - let iSelection; - for (iSelection = 0; iSelection < options.length; iSelection++) { - const selection = options[iSelection]; - let text = removeSuffix(selection.innerText, searchSeparator); - if (text === name) { - selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); - dropdown.scrollTop = dropdown.scrollHeight; // snap to top - DirectoryDropdown.#clampDropdownScrollTop(dropdown, selection); - break; - } - } - if (iSelection === options.length) { - dropdown.scrollTop = 0; - } - } - } - - /** - * Returns path if update was successful. - * @returns {string | undefined} - */ - #updateOptions() { - const dropdown = this.element; - const input = this.#input; - - const searchSeparator = this.#modelData.searchSeparator; - const filter = input.value; - if (filter[0] !== searchSeparator) { - dropdown.style.display = 'none'; - return undefined; - } - - const modelType = this.#getModelType(); - const searchPrefix = modelType !== '' ? searchSeparator + modelType : ''; - const directories = this.#modelData.directories; - const [options, path] = directories.search( - searchPrefix + filter, - searchSeparator, - this.showDirectoriesOnly, - ); - if (options.length === 0) { - dropdown.style.display = 'none'; - return undefined; - } - - const mouse_selection_select = (e) => { - const selection = e.target; - if (e.movementX === 0 && e.movementY === 0) { - return; - } - if ( - !selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS) - ) { - // assumes only one will ever selected at a time - e.stopPropagation(); - const children = dropdown.children; - for (let iChild = 0; iChild < children.length; iChild++) { - const child = children[iChild]; - child.classList.remove(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS); - } - selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS); - } - }; - const mouse_selection_deselect = (e) => { - e.stopPropagation(); - e.target.classList.remove(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS); - }; - const selection_submit = async (e) => { - e.stopPropagation(); - e.preventDefault(); - const selection = e.target; - const changed = DirectoryDropdown.selectionToInput( - input, - selection, - searchSeparator, - DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS, - ); - if (!changed) { - dropdown.style.display = 'none'; - input.blur(); - } else { - const path = this.#updateOptions(); // TODO: is this needed? - if (path !== undefined) { - this.#updateDeepestPath(path); - } - } - this.#updateCallback(); - if (this.#isDynamicSearch()) { - await this.#submitCallback(); - } - }; - const touch_selection_select = async (e) => { - const [startX, startY] = this.#touchSelectionStart; - const [endX, endY] = [ - e.changedTouches[0].clientX, - e.changedTouches[0].clientY, - ]; - if (startX === endX && startY === endY) { - const touch = e.changedTouches[0]; - const box = dropdown.getBoundingClientRect(); - if ( - touch.clientX >= box.left && - touch.clientX <= box.right && - touch.clientY >= box.top && - touch.clientY <= box.bottom - ) { - selection_submit(e); - } - } - }; - const touch_start = (e) => { - this.#touchSelectionStart = [ - e.changedTouches[0].clientX, - e.changedTouches[0].clientY, - ]; - }; - dropdown.innerHTML = ''; - dropdown.append.apply( - dropdown, - options.map((text) => { - /** @type {HTMLParagraphElement} */ - const p = $el( - 'p', - { - onmouseenter: (e) => mouse_selection_select(e), - onmousemove: (e) => mouse_selection_select(e), - onmouseleave: (e) => mouse_selection_deselect(e), - onmousedown: (e) => selection_submit(e), - ontouchstart: (e) => touch_start(e), - ontouchmove: (e) => touch_move(e), - ontouchend: (e) => touch_selection_select(e), - }, - [text], - ); - return p; - }), - ); - // 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'; - - return path; - } -} - -const MODEL_SORT_DATE_CREATED = 'dateCreated'; -const MODEL_SORT_DATE_MODIFIED = 'dateModified'; -const MODEL_SORT_SIZE_BYTES = 'sizeBytes'; -const MODEL_SORT_DATE_NAME = 'name'; - -class ModelGrid { - /** - * @param {string} nodeType - * @returns {int} - */ - static modelWidgetIndex(nodeType) { - return nodeType === undefined ? -1 : 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 array 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; - case MODEL_SORT_SIZE_BYTES: - compareFn = (a, b) => { - return b[MODEL_SORT_SIZE_BYTES] - a[MODEL_SORT_SIZE_BYTES]; - }; - 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 (widgetIndex !== -1 && 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(); - } - comfyButtonAlert( - event.target, - success, - 'mdi-check-bold', - 'mdi-close-thick', - ); - } - - static #getWidgetComboIndices(node, value) { - const widgetIndices = []; - node?.widgets?.forEach((widget, index) => { - if (widget.type === 'combo' && widget.options.values?.includes(value)) { - widgetIndices.push(index); - } - }); - return widgetIndices; - } - - /** - * @param {DragEvent} event - * @param {string} modelType - * @param {string} path - * @param {boolean} removeEmbeddingExtension - * @param {boolean} strictlyOnWidget - */ - static dragAddModel( - event, - modelType, - path, - removeEmbeddingExtension, - strictlyOnWidget, - ) { - const target = document.elementFromPoint(event.clientX, event.clientY); - if (modelType !== 'embeddings' && target.id === 'graph-canvas') { - const pos = app.canvas.convertEventToCanvasOffset(event); - - const node = app.graph.getNodeOnPos( - pos[0], - pos[1], - app.canvas.visible_nodes, - ); - - let widgetIndex = -1; - if (widgetIndex === -1) { - const widgetIndices = this.#getWidgetComboIndices(node, path); - if (widgetIndices.length === 0) { - widgetIndex = -1; - } else if (widgetIndices.length === 1) { - widgetIndex = widgetIndices[0]; - if (strictlyOnWidget) { - const draggedWidget = app.canvas.processNodeWidgets( - node, - pos, - event, - ); - const widget = node.widgets[widgetIndex]; - if (draggedWidget != widget) { - // != check NOT same object - widgetIndex = -1; - } - } - } else { - // ambiguous widget (strictlyOnWidget always true) - const draggedWidget = app.canvas.processNodeWidgets(node, pos, event); - widgetIndex = widgetIndices.findIndex((index) => { - return draggedWidget == node.widgets[index]; // == check same object - }); - } - } - - if (widgetIndex !== -1) { - node.widgets[widgetIndex].value = path; - app.canvas.selectNode(node); - } else { - const expectedNodeType = modelNodeType[modelType]; - const newNode = LiteGraph.createNode(expectedNodeType, null, []); - let newWidgetIndex = ModelGrid.modelWidgetIndex(expectedNodeType); - if (newWidgetIndex === -1) { - newWidgetIndex = this.#getWidgetComboIndices(newNode, path)[0] ?? -1; - } - if ( - newNode !== undefined && - newNode !== null && - newWidgetIndex !== -1 - ) { - newNode.pos[0] = pos[0]; - newNode.pos[1] = pos[1]; - newNode.widgets[newWidgetIndex].value = path; - app.graph.add(newNode, { doProcessChange: true }); - app.canvas.selectNode(newNode); - } - } - 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); - if (widgetIndex !== -1) { - node.widgets[widgetIndex].value = path; - app.canvas.copyToClipboard([node]); - success = true; - } - } else { - console.warn(`Unable to copy unknown model type '${modelType}.`); - } - comfyButtonAlert( - event.target, - success, - 'mdi-check-bold', - 'mdi-close-thick', - ); - } - - /** - * @param {Array} models - * @param {string} modelType - * @param {Object.} settingsElements - * @param {String} searchSeparator - * @param {String} systemSeparator - * @param {(searchPath: string) => Promise} showModelInfo - * @returns {HTMLElement[]} - */ - static #generateInnerHtml( - models, - modelType, - settingsElements, - searchSeparator, - systemSeparator, - showModelInfo, - ) { - // TODO: separate 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 showLoadWorkflowButton = - canShowButtons && - settingsElements['model-show-load-workflow-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 modelInfoButtonOnLeft = - !settingsElements['model-info-button-on-left'].checked; - const removeEmbeddingExtension = - !settingsElements['model-add-embedding-extension'].checked; - const previewThumbnailFormat = - settingsElements['model-preview-thumbnail-type'].value; - const previewThumbnailWidth = - Math.round(settingsElements['model-preview-thumbnail-width'].value / 0.75); - const previewThumbnailHeight = - Math.round(settingsElements['model-preview-thumbnail-height'].value / 0.75); - const buttonsOnlyOnHover = - settingsElements['model-buttons-only-on-hover'].checked; - if (models.length > 0) { - - const $overlay = IS_FIREFOX - ? (( - modelType, - path, - removeEmbeddingExtension, - strictDragToAdd, - ) => { - return $el('div.model-preview-overlay', { - ondragstart: (e) =>{ - const data = { - modelType: modelType, - path: path, - removeEmbeddingExtension: removeEmbeddingExtension, - strictDragToAdd: strictDragToAdd, - }; - e.dataTransfer.setData('manager-model', JSON.stringify(data)); - e.dataTransfer.setData('text/plain', ''); - }, - draggable: true, - }); - }) - : (( - modelType, - path, - removeEmbeddingExtension, - strictDragToAdd, - ) => { - return $el('div.model-preview-overlay', { - ondragend: (e) => - ModelGrid.dragAddModel( - e, - modelType, - path, - removeEmbeddingExtension, - strictDragToAdd, - ), - draggable: true, - }); - }); - const forHiddingButtonsClass = buttonsOnlyOnHover - ? 'model-buttons-hidden' : 'model-buttons-visible'; - - return models.map((item) => { - const previewInfo = item.preview; - const previewThumbnail = $el('img.model-preview', { - loading: - 'lazy' /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */, - src: imageUri( - previewInfo?.path, - previewInfo?.dateModified, - previewThumbnailWidth, - previewThumbnailHeight, - previewThumbnailFormat, - ), - draggable: false, - }); - const searchPath = item.path; - const path = SearchPath.systemPath( - searchPath, - searchSeparator, - systemSeparator, - ); - let actionButtons = []; - if (showCopyButton) { - actionButtons.push( - new ComfyButton({ - icon: 'content-copy', - tooltip: 'Copy model to clipboard', - classList: 'comfyui-button icon-button model-button', - action: (e) => - ModelGrid.#copyModelToClipboard( - e, - modelType, - path, - removeEmbeddingExtension, - ), - }).element, - ); - } - if (showAddButton && !(modelType === 'embeddings' && !navigator.clipboard)) { - actionButtons.push( - new ComfyButton({ - icon: 'plus-box-outline', - tooltip: 'Add model to node grid', - classList: 'comfyui-button icon-button model-button', - action: (e) => - ModelGrid.#addModel( - e, - modelType, - path, - removeEmbeddingExtension, - addOffset, - ), - }).element, - ); - } - if (showLoadWorkflowButton) { - actionButtons.push( - new ComfyButton({ - icon: 'arrow-bottom-left-bold-box-outline', - tooltip: 'Load preview workflow', - classList: 'comfyui-button icon-button model-button', - action: async (e) => { - const urlString = previewThumbnail.src; - const url = new URL(urlString); - const urlSearchParams = url.searchParams; - const uri = urlSearchParams.get('uri'); - const v = urlSearchParams.get('v'); - const urlFull = - urlString.substring(0, urlString.indexOf('?')) + - '?uri=' + - uri + - '&v=' + - v; - await loadWorkflow(urlFull); - }, - }).element, - ); - } - const infoButtons = [ - new ComfyButton({ - icon: 'information-outline', - tooltip: 'View model information', - classList: 'comfyui-button icon-button model-button', - action: async () => { - await showModelInfo(searchPath); - }, - }).element, - ]; - return $el('div.item', {}, [ - previewThumbnail, - $overlay( - modelType, - path, - removeEmbeddingExtension, - strictDragToAdd, - ), - $el( - 'div.model-preview-top-right.' + forHiddingButtonsClass, - { - draggable: false, - }, - modelInfoButtonOnLeft ? infoButtons : actionButtons, - ), - $el( - 'div.model-preview-top-left.' + forHiddingButtonsClass, - { - draggable: false, - }, - modelInfoButtonOnLeft ? actionButtons : infoButtons, - ), - $el( - 'div.model-label', - { - draggable: false, - }, - [ - $el('p', [ - showModelExtension - ? item.name - : SearchPath.splitExtension(item.name)[0], - ]), - ], - ), - ]); - }); - } else { - return [$el('h2', ['No Models'])]; - } - } - - /** - * @param {HTMLDivElement} modelGrid - * @param {ModelData} modelData - * @param {HTMLSelectElement} modelSelect - * @param {Object.<{value: string}>} previousModelType - * @param {Object} settings - * @param {string} sortBy - * @param {boolean} reverseSort - * @param {Array} previousModelFilters - * @param {HTMLInputElement} modelFilter - * @param {(searchPath: string) => Promise} showModelInfo - */ - static update( - modelGrid, - modelData, - modelSelect, - previousModelType, - settings, - sortBy, - reverseSort, - previousModelFilters, - modelFilter, - showModelInfo, - ) { - const models = modelData.models; - let modelType = modelSelect.value; - if (models[modelType] === undefined) { - modelType = settings['model-default-browser-model-type'].value; - } - if (models[modelType] === undefined) { - modelType = 'checkpoints'; // panic fallback - } - - 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, - modelData.searchSeparator, - modelData.systemSeparator, - showModelInfo, - ); - modelGrid.append.apply(modelGrid, modelGridModels); - } -} - -class ModelInfo { - /** @type {HTMLDivElement} */ - element = null; - - elements = { - /** @type {HTMLDivElement[]} */ tabButtons: null, - /** @type {HTMLDivElement[]} */ tabContents: null, - /** @type {HTMLDivElement} */ info: null, - /** @type {HTMLTextAreaElement} */ notes: null, - /** @type {HTMLButtonElement} */ setPreviewButton: null, - /** @type {HTMLInputElement} */ moveDestinationInput: null, - }; - - /** @type {ImageSelect} */ - previewSelect = null; - - /** @type {string} */ - #savedNotesValue = null; - - /** @type {[HTMLElement][]} */ - #settingsElements = null; - - /** - * @param {ModelData} modelData - * @param {(withoutComfyRefresh?: boolean) => Promise} updateModels - * @param {any} settingsElements - */ - constructor(modelData, updateModels, settingsElements) { - this.#settingsElements = settingsElements; - const moveDestinationInput = $el('input.search-text-area', { - name: 'move directory', - autocomplete: 'off', - placeholder: modelData.searchSeparator, - value: modelData.searchSeparator, - }); - this.elements.moveDestinationInput = moveDestinationInput; - - const searchDropdown = new DirectoryDropdown( - modelData, - moveDestinationInput, - true, - ); - - const previewSelect = new ImageSelect('model-info-preview-model-FYUIKMNVB'); - this.previewSelect = previewSelect; - previewSelect.elements.previews.style.display = 'flex'; - - const setPreviewButton = new ComfyButton({ - tooltip: 'Overwrite current preview with selected image', - content: 'Set as Preview', - action: async (e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - const confirmation = window.confirm( - 'Change preview image(s) PERMANENTLY?', - ); - let updatedPreview = false; - if (confirmation) { - const container = this.elements.info; - const path = container.dataset.path; - const imageUrl = await previewSelect.getImage(); - if (imageUrl === PREVIEW_NONE_URI) { - const encodedPath = encodeURIComponent(path); - updatedPreview = await comfyRequest( - `/model-manager/preview/delete?path=${encodedPath}`, - { - method: 'POST', - body: JSON.stringify({}), - }, - ) - .then((result) => { - const message = result['alert']; - if (message !== undefined) { - window.alert(message); - } - 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 comfyRequest(`/model-manager/preview/set`, { - method: 'POST', - body: formData, - }) - .then((result) => { - const message = result['alert']; - if (message !== undefined) { - window.alert(message); - } - return result['success']; - }) - .catch((err) => { - return false; - }); - } - if (updatedPreview) { - updateModels(true); - const previewSelect = this.previewSelect; - previewSelect.elements.defaultUrl.dataset.noimage = - PREVIEW_NONE_URI; - previewSelect.resetModelInfoPreview(); - this.element.style.display = 'none'; - } - } - comfyButtonAlert(e.target, updatedPreview); - button.disabled = false; - }, - }).element; - this.elements.setPreviewButton = setPreviewButton; - previewSelect.elements.radioButtons.addEventListener('change', (e) => { - setPreviewButton.style.display = previewSelect.defaultIsChecked() - ? 'none' - : 'block'; - }); - - this.element = $el( - 'div', - { - style: { display: 'none' }, - }, - [ - $el( - 'div.row.tab-header', - { - display: 'block', - }, - [ - $el('div.row.tab-header-flex-block', [ - new ComfyButton({ - icon: 'trash-can-outline', - tooltip: 'Delete model FOREVER', - classList: 'comfyui-button icon-button', - action: async (e) => { - const [button, icon, span] = comfyButtonDisambiguate( - e.target, - ); - button.disabled = true; - 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 comfyRequest( - `/model-manager/model/delete?path=${path}`, - { - method: 'POST', - }, - ) - .then((result) => { - const deleted = result['success']; - const message = result['alert']; - if (message !== undefined) { - window.alert(message); - } - if (deleted) { - container.innerHTML = ''; - this.element.style.display = 'none'; - updateModels(); - } - return deleted; - }) - .catch((err) => { - return false; - }); - } - if (!deleted) { - comfyButtonAlert(e.target, false); - } - button.disabled = false; - }, - }).element, - $el('div.search-models.input-dropdown-container', [ - // TODO: magic class - moveDestinationInput, - searchDropdown.element, - ]), - new ComfyButton({ - icon: 'file-move-outline', - tooltip: 'Move file', - action: async (e) => { - const [button, icon, span] = comfyButtonDisambiguate( - e.target, - ); - button.disabled = true; - 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 + - modelData.searchSeparator + - oldFileName; - moved = await comfyRequest(`/model-manager/model/move`, { - method: 'POST', - body: JSON.stringify({ - oldFile: oldFile, - newFile: newFile, - }), - }) - .then((result) => { - const moved = result['success']; - const message = result['alert']; - if (message !== undefined) { - window.alert(message); - } - if (moved) { - moveDestinationInput.value = ''; - container.innerHTML = ''; - this.element.style.display = 'none'; - updateModels(); - } - return moved; - }) - .catch((err) => { - return false; - }); - } - comfyButtonAlert(e.target, moved); - button.disabled = false; - }, - }).element, - ]), - ], - ), - $el('div.model-info-container', { - $: (el) => (this.elements.info = el), - 'data-path': '', - }), - ], - ); - - [this.elements.tabButtons, this.elements.tabContents] = GenerateTabGroup([ - { - name: 'Overview', - icon: 'information-box-outline', - tabContent: this.element, - }, - { - name: 'Metadata', - icon: 'file-document-outline', - tabContent: $el('div', ['Metadata']), - }, - { - name: 'Tags', - icon: 'tag-outline', - tabContent: $el('div', ['Tags']), - }, - { - name: 'Notes', - icon: 'pencil-outline', - tabContent: $el('div', ['Notes']), - }, - ]); - } - - /** @returns {void} */ - show() { - this.element.style = ''; - this.element.scrollTop = 0; - } - - /** - * @param {boolean} promptUser - * @returns {Promise} - */ - async trySave(promptUser) { - if (this.element.style.display === 'none') { - return true; - } - - const noteValue = this.elements.notes.value; - const savedNotesValue = this.#savedNotesValue; - if (noteValue.trim() === savedNotesValue.trim()) { - return true; - } - const saveChanges = !promptUser || window.confirm('Save notes?'); - if (saveChanges) { - const path = this.elements.info.dataset.path; - const saved = await saveNotes(path, noteValue); - if (!saved) { - window.alert('Failed to save notes!'); - return false; - } - this.#savedNotesValue = noteValue; - this.elements.markdown.innerHTML = marked.parse(noteValue); - } else { - const discardChanges = window.confirm('Discard changes?'); - if (!discardChanges) { - return false; - } else { - this.elements.notes.value = savedNotesValue; - } - } - return true; - } - - /** - * @param {boolean?} promptSave - * @returns {Promise} - */ - async tryHide(promptSave = true) { - const notes = this.elements.notes; - if (promptSave && notes !== undefined && notes !== null) { - const saved = await this.trySave(promptSave); - if (!saved) { - return false; - } - this.#savedNotesValue = ''; - this.elements.notes.value = ''; - } - this.element.style.display = 'none'; - return true; - } - - /** - * @param {string} searchPath - * @param {(withoutComfyRefresh?: boolean) => Promise} updateModels - * @param {string} searchSeparator - */ - async update(searchPath, updateModels, searchSeparator) { - const path = encodeURIComponent(searchPath); - const [info, metadata, tags, noteText] = await comfyRequest( - `/model-manager/model/info?path=${path}`, - ) - .then((result) => { - const success = result['success']; - const message = result['alert']; - if (message !== undefined) { - window.alert(message); - } - if (!success) { - return undefined; - } - return [ - result['info'], - result['metadata'], - result['tags'], - result['notes'], - ]; - }) - .catch((err) => { - console.log(err); - return undefined; - }); - if (info === undefined || 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', [ - new ComfyButton({ - icon: 'pencil', - tooltip: 'Change file name', - classList: 'comfyui-button icon-button', - action: async (e) => { - const [button, icon, span] = comfyButtonDisambiguate( - e.target, - ); - button.disabled = true; - 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 comfyRequest(`/model-manager/model/move`, { - method: 'POST', - body: JSON.stringify({ - oldFile: oldFile, - newFile: newFile, - }), - }) - .then((result) => { - const renamed = result['success']; - const message = result['alert']; - if (message !== undefined) { - window.alert(message); - } - if (renamed) { - container.innerHTML = ''; - this.element.style.display = 'none'; - updateModels(); - } - return renamed; - }) - .catch((err) => { - console.log(err); - return false; - }); - } - comfyButtonAlert(e.target, renamed); - button.disabled = false; - }, - }).element, - ]), - ], - ), - ); - } - - const fileDirectory = info['File Directory']; - if ( - fileDirectory !== undefined && - fileDirectory !== null && - fileDirectory !== '' - ) { - this.elements.moveDestinationInput.placeholder = fileDirectory; - this.elements.moveDestinationInput.value = fileDirectory; // TODO: noise vs convenience - } else { - this.elements.moveDestinationInput.placeholder = searchSeparator; - this.elements.moveDestinationInput.value = searchSeparator; - } - - 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 = PREVIEW_NONE_URI; - } - 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', [ - new ComfyButton({ - content: 'Load Workflow', - tooltip: 'Attempt to load preview image workflow', - action: async () => { - const urlString = - previewSelect.elements.defaultPreviews.children[0].src; - await loadWorkflow(urlString); - }, - }).element, - ]), - $el('div.row.tab-header-flex-block', [ - previewSelect.elements.radioGroup, - ]), - $el('div.row.tab-header-flex-block', [setPreviewButton]), - ]), - $el('h2', ['File Info:']), - $el( - 'div', - (() => { - const elements = []; - for (const [key, value] of Object.entries(info)) { - if (value === undefined || value === null) { - continue; - } - - if (Array.isArray(value)) { - // currently only used for "Bucket Resolutions" - if (value.length > 0) { - elements.push($el('h2', [key + ':'])); - const text = TagCountMapToParagraph(value); - const div = $el('div'); - div.innerHTML = text; - elements.push(div); - } - } 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? - - /** @type {HTMLDivElement} */ - const metadataElement = this.elements.tabContents[1]; // TODO: remove magic value - const isMetadata = - typeof metadata === 'object' && - metadata !== null && - Object.keys(metadata).length > 0; - metadataElement.innerHTML = ''; - metadataElement.append.apply(metadataElement, [ - $el('h1', ['Metadata']), - $el( - 'div', - (() => { - const tableRows = []; - if (isMetadata) { - for (const [key, value] of Object.entries(metadata)) { - if (value === undefined || value === null) { - continue; - } - if (value !== '') { - tableRows.push( - $el('tr', [ - $el('th.model-metadata-key', [key]), - $el('th.model-metadata-value', [value]), - ]), - ); - } - } - } - return $el('table.model-metadata', tableRows); - })(), - ), - ]); - const metadataButton = this.elements.tabButtons[1]; // TODO: remove magic value - metadataButton.style.display = isMetadata ? '' : 'none'; - - /** @type {HTMLDivElement} */ - const tagsElement = this.elements.tabContents[2]; // TODO: remove magic value - const isTags = Array.isArray(tags) && tags.length > 0; - const tagsParagraph = $el( - 'div', - (() => { - const elements = []; - if (isTags) { - let text = TagCountMapToParagraph(tags); - const div = $el('div'); - div.innerHTML = text; - elements.push(div); - } - return elements; - })(), - ); - const tagGeneratorRandomizedOutput = $el('textarea.comfy-multiline-input', { - name: 'random tag generator output', - rows: 4, - }); - const TAG_GENERATOR_SAMPLER_NAME = 'model manager tag generator sampler'; - const tagGenerationCount = $el('input', { - type: 'number', - name: 'tag generator count', - step: 1, - min: 1, - value: this.#settingsElements['tag-generator-count'].value, - }); - const tagGenerationThreshold = $el('input', { - type: 'number', - name: 'tag generator threshold', - step: 1, - min: 1, - value: this.#settingsElements['tag-generator-threshold'].value, - }); - const selectedSamplerOption = - this.#settingsElements['tag-generator-sampler-method'].value; - const samplerOptions = ['Frequency', 'Uniform']; - const samplerRadioGroup = $radioGroup({ - name: TAG_GENERATOR_SAMPLER_NAME, - onchange: (value) => {}, - options: samplerOptions.map((option) => { - return { value: option }; - }), - }); - const samplerOptionInputs = samplerRadioGroup.getElementsByTagName('input'); - for (let i = 0; i < samplerOptionInputs.length; i++) { - const samplerOptionInput = samplerOptionInputs[i]; - if (samplerOptionInput.value === selectedSamplerOption) { - samplerOptionInput.click(); - break; - } - } - const tagGenerator = $el('div', [ - $el('h1', ['Tags']), - $el('h2', { style: { margin: '0px 0px 16px 0px' } }, [ - 'Random Tag Generator', - ]), - $el('div', [ - $el( - 'details.tag-generator-settings', - { - style: { margin: '10px 0', display: 'none' }, - open: false, - }, - [ - $el('summary', ['Settings']), - $el('div', ['Sampling Method', samplerRadioGroup]), - $el('label', ['Count', tagGenerationCount]), - $el('label', ['Threshold', tagGenerationThreshold]), - ], - ), - tagGeneratorRandomizedOutput, - new ComfyButton({ - content: 'Randomize', - tooltip: 'Randomly generate subset of tags', - action: () => { - const samplerName = document.querySelector( - `input[name="${TAG_GENERATOR_SAMPLER_NAME}"]:checked`, - ).value; - const sampler = - samplerName === 'Frequency' - ? ModelInfo.ProbabilisticTagSampling - : ModelInfo.UniformTagSampling; - const sampleCount = tagGenerationCount.value; - const frequencyThreshold = tagGenerationThreshold.value; - const tags = ParseTagParagraph(tagsParagraph.innerText); - const sampledTags = sampler(tags, sampleCount, frequencyThreshold); - tagGeneratorRandomizedOutput.value = sampledTags.join(', '); - }, - }).element, - ]), - ]); - tagsElement.innerHTML = ''; - tagsElement.append.apply(tagsElement, [ - tagGenerator, - $el('div', [ - $el( - 'h2', - { - style: { - margin: '24px 0px 8px 0px', - }, - }, - ['Tags'], - ), - tagsParagraph, - ]), - ]); - const tagButton = this.elements.tabButtons[2]; // TODO: remove magic value - tagButton.style.display = isTags ? '' : 'none'; - - const saveIcon = 'content-save'; - const savingIcon = 'cloud-upload-outline'; - - const saveNotesButton = new ComfyButton({ - icon: saveIcon, - tooltip: 'Save note', - classList: 'comfyui-button icon-button', - action: async (e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - const saved = await this.trySave(false); - comfyButtonAlert(e.target, saved); - button.disabled = false; - }, - }).element; - - const saveDebounce = debounce(async () => { - const saveIconClass = 'mdi-' + saveIcon; - const savingIconClass = 'mdi-' + savingIcon; - const iconElement = saveNotesButton.getElementsByTagName('i')[0]; - iconElement.classList.remove(saveIconClass); - iconElement.classList.add(savingIconClass); - const saved = await this.trySave(false); - iconElement.classList.remove(savingIconClass); - iconElement.classList.add(saveIconClass); - }, 1000); - - /** @type {HTMLDivElement} */ - const notesElement = this.elements.tabContents[3]; // TODO: remove magic value - notesElement.innerHTML = ''; - const markdown = $el('div', {}, ''); - markdown.innerHTML = marked.parse(noteText); - - notesElement.append.apply( - notesElement, - (() => { - const notes = $el('textarea.comfy-multiline-input', { - name: 'model notes', - value: noteText, - oninput: (e) => { - if (this.#settingsElements['model-info-autosave-notes'].checked) { - saveDebounce(); - } - }, - }); - - if (navigator.userAgent.includes('Mac')) { - new KeyComboListener(['MetaLeft', 'KeyS'], saveDebounce, notes); - new KeyComboListener(['MetaRight', 'KeyS'], saveDebounce, notes); - } else { - new KeyComboListener(['ControlLeft', 'KeyS'], saveDebounce, notes); - new KeyComboListener(['ControlRight', 'KeyS'], saveDebounce, notes); - } - - this.elements.notes = notes; - this.elements.markdown = markdown; - this.#savedNotesValue = noteText; - - const notes_editor = $el( - 'div', - { - style: { - display: noteText == '' ? 'flex' : 'none', - height: '100%', - 'min-height': '60px', - }, - }, - notes, - ); - const notes_viewer = $el( - 'div', - { - style: { - display: noteText == '' ? 'none' : 'flex', - height: '100%', - 'min-height': '60px', - overflow: 'scroll', - 'overflow-wrap': 'anywhere', - }, - }, - markdown, - ); - - const editNotesButton = new ComfyButton({ - icon: 'pencil', - tooltip: 'Change file name', - classList: 'comfyui-button icon-button', - action: async () => { - notes_editor.style.display = - notes_editor.style.display == 'flex' ? 'none' : 'flex'; - notes_viewer.style.display = - notes_viewer.style.display == 'none' ? 'flex' : 'none'; - }, - }).element; - - return [ - $el( - 'div.row', - { - style: { 'align-items': 'center' }, - }, - [$el('h1', ['Notes']), saveNotesButton, editNotesButton], - ), - notes_editor, - notes_viewer, - ]; - })(), - ); - } - - static UniformTagSampling( - tagsAndCounts, - sampleCount, - frequencyThreshold = 0, - ) { - const data = tagsAndCounts.filter((x) => x[1] >= frequencyThreshold); - let count = data.length; - const samples = []; - for (let i = 0; i < sampleCount; i++) { - if (count === 0) { - break; - } - const index = Math.floor(Math.random() * count); - const pair = data.splice(index, 1)[0]; - samples.push(pair); - count -= 1; - } - const sortedSamples = samples.sort((x1, x2) => { - return parseInt(x2[1]) - parseInt(x1[1]); - }); - return sortedSamples.map((x) => x[0]); - } - - static ProbabilisticTagSampling( - tagsAndCounts, - sampleCount, - frequencyThreshold = 0, - ) { - const data = tagsAndCounts.filter((x) => x[1] >= frequencyThreshold); - let tagFrequenciesSum = data.reduce( - (accumulator, x) => accumulator + x[1], - 0, - ); - let count = data.length; - const samples = []; - for (let i = 0; i < sampleCount; i++) { - if (count === 0) { - break; - } - const index = (() => { - let frequencyIndex = Math.floor(Math.random() * tagFrequenciesSum); - return data.findIndex((x) => { - const frequency = x[1]; - if (frequency > frequencyIndex) { - return true; - } - frequencyIndex = frequencyIndex - frequency; - return false; - }); - })(); - const pair = data.splice(index, 1)[0]; - samples.push(pair); - tagFrequenciesSum -= pair[1]; - count -= 1; - } - const sortedSamples = samples.sort((x1, x2) => { - return parseInt(x2[1]) - parseInt(x1[1]); - }); - return sortedSamples.map((x) => x[0]); - } -} - -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} Dictionary containing received model info. Returns an empty if fails. - */ - static async requestInfo(id, apiPath) { - const url = 'https://civitai.com/api/v1/' + apiPath + '/' + id; - try { - const response = await fetch(url); - const data = await response.json(); - return data; - } catch (error) { - console.error('Failed to get model info from Civitai!', error); - return {}; - } - } - - /** - * Extract file information from the given model version information. - * - * @param {Object} modelVersionInfo - Model version information. - * @param {(string|null)} [type=null] - Optional select by model type. - * @param {(string|null)} [fp=null] - Optional select by floating point quantization. - * @param {(string|null)} [size=null] - Optional select by sizing. - * @param {(string|null)} [format=null] - Optional select by file format. - * - * @returns {Object} - Extracted list of information on each file of the given model version. - */ - static getModelFilesInfo( - modelVersionInfo, - type = null, - fp = null, - size = null, - format = null, - ) { - const files = []; - const modelVersionFiles = modelVersionInfo['files']; - for (let i = 0; i < modelVersionFiles.length; i++) { - const modelVersionFile = modelVersionFiles[i]; - - const fileType = modelVersionFile['type']; - if (type instanceof String && type != fileType) { - continue; - } - - const fileMeta = modelVersionFile['metadata']; - - const fileFp = fileMeta['fp']; - if (fp instanceof String && fp != fileFp) { - continue; - } - - const fileSize = fileMeta['size']; - if (size instanceof String && size != fileSize) { - continue; - } - - const fileFormat = fileMeta['format']; - if (format instanceof String && format != fileFormat) { - continue; - } - - files.push({ - downloadUrl: modelVersionFile['downloadUrl'], - format: fileFormat, - fp: fileFp, - hashes: modelVersionFile['hashes'], - name: modelVersionFile['name'], - size: fileSize, - sizeKB: modelVersionFile['sizeKB'], - type: fileType, - }); - } - return { - files: files, - id: modelVersionInfo['id'], - images: modelVersionInfo['images'].map((image) => { - // TODO: do I need to double-check image matches resource? - return image['url']; - }), - name: modelVersionInfo['name'], - description: modelVersionInfo['description'], - tags: modelVersionInfo['trainedWords'], - }; - } - - /** - * @param {string} stringUrl - Model url. - * - * @returns {Promise} - Download information for the given url. - */ - static async getFilteredInfo(stringUrl) { - const url = new URL(stringUrl); - if (url.hostname != 'civitai.com') { - return {}; - } - if (url.pathname == '/') { - return {}; - } - const urlPath = url.pathname; - if (urlPath.startsWith('/api')) { - const idEnd = urlPath.length - (urlPath.at(-1) == '/' ? 1 : 0); - const idStart = urlPath.lastIndexOf('/', idEnd - 1) + 1; - const modelVersionId = urlPath.substring(idStart, idEnd); - if (parseInt(modelVersionId, 10) == NaN) { - return {}; - } - const modelVersionInfo = await Civitai.requestInfo( - modelVersionId, - 'model-versions', - ); - if (Object.keys(modelVersionInfo).length == 0) { - return {}; - } - const searchParams = url.searchParams; - const filesInfo = Civitai.getModelFilesInfo( - modelVersionInfo, - searchParams.get('type'), - searchParams.get('fp'), - searchParams.get('size'), - searchParams.get('format'), - ); - return { - name: modelVersionInfo['model']['name'], - type: modelVersionInfo['model']['type'], - description: modelVersionInfo['description'], - tags: modelVersionInfo['trainedWords'], - versions: [filesInfo], - }; - } else if (urlPath.startsWith('/models')) { - const idStart = urlPath.indexOf('models/') + 'models/'.length; - const idEnd = (() => { - const idEnd = urlPath.indexOf('/', idStart); - return idEnd === -1 ? urlPath.length : idEnd; - })(); - const modelId = urlPath.substring(idStart, idEnd); - if (parseInt(modelId, 10) == NaN) { - return {}; - } - const modelInfo = await Civitai.requestInfo(modelId, 'models'); - if (Object.keys(modelInfo).length == 0) { - return {}; - } - const modelVersionId = parseInt(url.searchParams.get('modelVersionId')); - const modelVersions = []; - const modelVersionInfos = modelInfo['modelVersions']; - for (let i = 0; i < modelVersionInfos.length; i++) { - const versionInfo = modelVersionInfos[i]; - if (!Number.isNaN(modelVersionId)) { - if (modelVersionId != versionInfo['id']) { - continue; - } - } - const filesInfo = Civitai.getModelFilesInfo(versionInfo); - modelVersions.push(filesInfo); - } - return { - name: modelInfo['name'], - type: modelInfo['type'], - description: modelInfo['description'], - versions: modelVersions, - }; - } else { - return {}; - } - } - - /** - * @returns {string} - */ - static imagePostUrlPrefix() { - return 'https://civitai.com/images/'; - } - - /** - * @returns {string} - */ - static imageUrlPrefix() { - return 'https://image.civitai.com/'; - } - - /** - * @param {string} stringUrl - https://civitai.com/images/{imageId}. - * - * @returns {Promise} - Image information. - */ - static async getImageInfo(stringUrl) { - const imagePostUrlPrefix = Civitai.imagePostUrlPrefix(); - if (!stringUrl.startsWith(imagePostUrlPrefix)) { - return {}; - } - const id = stringUrl.substring(imagePostUrlPrefix.length).match(/^\d+/)[0]; - const url = `https://civitai.com/api/v1/images?imageId=${id}`; - try { - const response = await fetch(url); - const data = await response.json(); - return data; - } catch (error) { - console.error('Failed to get image info from Civitai!', error); - return {}; - } - } - - /** - * @param {string} stringUrl - https://image.civitai.com/... - * - * @returns {Promise} - */ - static async getFullSizeImageUrl(stringUrl) { - const imageUrlPrefix = Civitai.imageUrlPrefix(); - if (!stringUrl.startsWith(imageUrlPrefix)) { - return ''; - } - const i0 = stringUrl.lastIndexOf('/'); - const i1 = stringUrl.lastIndexOf('.'); - if (i0 === -1 || i1 === -1) { - return ''; - } - const id = parseInt(stringUrl.substring(i0 + 1, i1)).toString(); - const url = `https://civitai.com/api/v1/images?imageId=${id}`; - try { - const response = await fetch(url); - const imageInfo = await response.json(); - const items = imageInfo['items']; - if (items.length === 0) { - console.warn('Civitai /api/v1/images returned 0 items.'); - return stringUrl; - } - return items[0]['url']; - } catch (error) { - console.error('Failed to get image info from Civitai!', error); - return stringUrl; - } - } -} - -class HuggingFace { - /** - * Get model info from Huggingface. - * - * @param {string} id - Model ID. - * @param {string} apiPath - API path. - * - * @returns {Promise} Dictionary containing received model info. Returns an empty if fails. - */ - static async requestInfo(id, apiPath = 'models') { - const url = 'https://huggingface.co/api/' + apiPath + '/' + id; - try { - const response = await fetch(url); - const data = await response.json(); - return data; - } catch (error) { - console.error('Failed to get model info from HuggingFace!', error); - return {}; - } - } - - /** - * - * - * @param {string} stringUrl - Model url. - * - * @returns {Promise} - */ - static async getFilteredInfo(stringUrl) { - const url = new URL(stringUrl); - if (url.hostname != 'huggingface.co') { - return {}; - } - if (url.pathname == '/') { - return {}; - } - const urlPath = url.pathname; - const i0 = 1; - const i1 = urlPath.indexOf('/', i0); - if (i1 == -1 || urlPath.length - 1 == i1) { - // user-name only - return {}; - } - let i2 = urlPath.indexOf('/', i1 + 1); - if (i2 == -1) { - // model id only - i2 = urlPath.length; - } - const modelId = urlPath.substring(i0, i2); - const urlPathEnd = urlPath.substring(i2); - - const isValidBranch = - urlPathEnd.startsWith('/resolve') || - urlPathEnd.startsWith('/blob') || - urlPathEnd.startsWith('/tree'); - - let branch = '/main'; - let filePath = ''; - if (isValidBranch) { - const i0 = branch.length; - const i1 = urlPathEnd.indexOf('/', i0 + 1); - if (i1 == -1) { - if (i0 != urlPathEnd.length) { - // ends with branch - branch = urlPathEnd.substring(i0); - } - } else { - branch = urlPathEnd.substring(i0, i1); - if (urlPathEnd.length - 1 > i1) { - filePath = urlPathEnd.substring(i1); - } - } - } - - const modelInfo = await HuggingFace.requestInfo(modelId); - //const modelInfo = await requestInfo(modelId + "/tree" + branch); // this only gives you the files at the given branch path... - // oid: SHA-1?, lfs.oid: SHA-256 - - const clippedFilePath = filePath.substring(filePath[0] === '/' ? 1 : 0); - const modelFiles = modelInfo['siblings'] - .filter((sib) => { - const filename = sib['rfilename']; - for (let i = 0; i < MODEL_EXTENSIONS.length; i++) { - if (filename.endsWith(MODEL_EXTENSIONS[i])) { - return filename.startsWith(clippedFilePath); - } - } - return false; - }) - .map((sib) => { - const filename = sib['rfilename']; - return filename; - }); - if (modelFiles.length === 0) { - return {}; - } - - const baseDownloadUrl = - url.origin + urlPath.substring(0, i2) + '/resolve' + branch; - - const images = modelInfo['siblings'] - .filter((sib) => { - const filename = sib['rfilename']; - for (let i = 0; i < IMAGE_EXTENSIONS.length; i++) { - if (filename.endsWith(IMAGE_EXTENSIONS[i])) { - return filename.startsWith(clippedFilePath); - } - } - return false; - }) - .map((sib) => { - return baseDownloadUrl + '/' + sib['rfilename']; - }); - - return { - baseDownloadUrl: baseDownloadUrl, - modelFiles: modelFiles, - images: images, - name: modelId, - }; - } -} - -/** - * @param {string} urlText - * @returns {Promise<[string, any[]]>} [name, modelInfos] - */ -async function getModelInfos(urlText) { - // TODO: class for proper return type - return await (async () => { - if (urlText.startsWith('https://civitai.com')) { - const civitaiInfo = await Civitai.getFilteredInfo(urlText); - if (Object.keys(civitaiInfo).length === 0) { - return ['', []]; - } - const name = civitaiInfo['name']; - const infos = []; - const type = civitaiInfo['type']; - - civitaiInfo['versions'].forEach((version) => { - const images = version['images']; - const tags = version['tags']?.map((tag) => - tag.trim().replace(/,$/, ''), - ); - const description = [ - tags !== undefined ? '# Trigger Words' : undefined, - tags?.join( - tags.some((tag) => { - return tag.includes(','); - }) - ? '\n' - : ', ', - ), - version['description'] !== undefined - ? '# About this version ' - : undefined, - version['description'], - civitaiInfo['description'] !== undefined ? '# ' + name : undefined, - civitaiInfo['description'], - ] - .filter((x) => x !== undefined) - .join('\n\n'); - version['files'].forEach((file) => { - infos.push({ - images: images, - fileName: file['name'], - modelType: type, - downloadUrl: file['downloadUrl'], - downloadFilePath: '', - description: downshow(description), - details: { - fileSizeKB: file['sizeKB'], - fileType: file['type'], - fp: file['fp'], - quant: file['size'], - fileFormat: file['format'], - }, - }); - }); - }); - return [name, infos]; - } - if (urlText.startsWith('https://huggingface.co')) { - const hfInfo = await HuggingFace.getFilteredInfo(urlText); - if (Object.keys(hfInfo).length === 0) { - return ['', []]; - } - const files = hfInfo['modelFiles']; - if (files.length === 0) { - return ['', []]; - } - const name = hfInfo['name']; - const baseDownloadUrl = hfInfo['baseDownloadUrl']; - const infos = hfInfo['modelFiles'].map((file) => { - const indexSep = file.lastIndexOf('/'); - const filename = file.substring(indexSep + 1); - return { - images: hfInfo['images'], - fileName: filename, - modelType: '', - downloadUrl: baseDownloadUrl + '/' + file + '?download=true', - downloadFilePath: file.substring(0, indexSep + 1), - description: '', - details: { - fileSizeKB: undefined, // TODO: too hard? - }, - }; - }); - return [name, infos]; - } - if (urlText.endsWith('.json')) { - const indexInfo = await (async () => { - try { - const response = await fetch(url); - const data = await response.json(); - return data; - } catch { - return []; - } - })(); - const name = urlText.substring(math.max(urlText.lastIndexOf('/'), 0)); - const infos = indexInfo.map((file) => { - return { - images: [], - fileName: file['name'], - modelType: - DownloadView.modelTypeToComfyUiDirectory(file['type'], '') ?? '', - downloadUrl: file['download'], - downloadFilePath: '', - description: file['description'], - details: {}, - }; - }); - return [name, infos]; - } - return ['', []]; - })(); -} - -class DownloadView { - /** @type {HTMLDivElement} */ - element = null; - - elements = { - /** @type {HTMLInputElement} */ url: null, - /** @type {HTMLDivElement} */ infos: null, - /** @type {HTMLInputElement} */ overwrite: null, - /** @type {HTMLInputElement} */ downloadNotes: null, - /** @type {HTMLButtonElement} */ searchButton: null, - /** @type {HTMLButtonElement} */ clearSearchButton: null, - }; - - /** @type {DOMParser} */ - #domParser = null; - - /** @type {Object.} */ - #settings = null; - - /** @type {(withoutComfyRefresh?: boolean) => Promise} */ - #updateModels = () => {}; - - /** - * @param {ModelData} modelData - * @param {Object.} settings - * @param {(withoutComfyRefresh?: boolean) => Promise} updateModels - */ - constructor(modelData, settings, updateModels) { - this.#domParser = new DOMParser(); - this.#updateModels = updateModels; - const update = async () => { - await this.#update(modelData, settings); - }; - const reset = () => { - this.elements.infos.innerHTML = ''; - this.elements.infos.appendChild( - $el('h1', ['Input a URL to select a model to download.']), - ); - }; - - const searchButton = new ComfyButton({ - icon: 'magnify', - tooltip: 'Search url', - classList: 'comfyui-button icon-button', - action: async (e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - if (this.elements.url.value === '') { - reset(); - } else { - await update(); - } - button.disabled = false; - }, - }).element; - settings['model-real-time-search'].addEventListener('change', () => { - const hideSearchButton = - settings['text-input-always-hide-search-button'].checked; - searchButton.style.display = hideSearchButton ? 'none' : ''; - }); - settings['text-input-always-hide-search-button'].addEventListener( - 'change', - () => { - const hideSearchButton = - settings['text-input-always-hide-search-button'].checked; - searchButton.style.display = hideSearchButton ? 'none' : ''; - }, - ); - this.elements.searchButton = searchButton; - - const clearSearchButton = new ComfyButton({ - icon: 'close', - tooltip: 'Clear search', - classList: 'comfyui-button icon-button', - action: async (e) => { - e.stopPropagation(); - this.elements.url.value = ''; - reset(); - }, - }).element; - settings['text-input-always-hide-clear-button'].addEventListener( - 'change', - () => { - const hideClearButton = - settings['text-input-always-hide-clear-button'].checked; - clearSearchButton.style.display = hideClearButton ? 'none' : ''; - }, - ); - this.elements.clearSearchButton = clearSearchButton; - - $el( - 'div.tab-header', - { - $: (el) => (this.element = el), - }, - [ - $el('div.row.tab-header-flex-block', [ - $el('input.search-text-area', { - $: (el) => (this.elements.url = el), - type: 'text', - name: 'model download url', - autocomplete: 'off', - placeholder: 'Search URL', - onkeydown: async (e) => { - if (e.key === 'Enter') { - e.stopPropagation(); - if (this.elements.url.value === '') { - reset(); - } else { - await update(); - } - e.target.blur(); - } - }, - }), - clearSearchButton, - searchButton, - ]), - $el( - 'div.download-model-infos', - { - $: (el) => (this.elements.infos = el), - }, - [$el('h1', ['Input a URL to select a model to download.'])], - ), - ], - ); - } - - /** - * Tries to return the related ComfyUI model directory if unambiguous. - * - * @param {string | undefined} modelType - Model type. - * @param {string | undefined} [fileType] - File type. Relevant for "Diffusers". - * - * @returns {(string | null)} Logical base directory name for model type. May be null if the directory is ambiguous or not a model type. - */ - static modelTypeToComfyUiDirectory(modelType, fileType) { - if (fileType !== undefined && fileType !== null) { - const f = fileType.toLowerCase(); - if (f == 'diffusers') { - return 'diffusers'; - } // TODO: is this correct? - } - - if (modelType !== undefined && modelType !== null) { - const m = modelType.toLowerCase(); - // TODO: somehow allow for SERVER to set dir? - // TODO: allow user to choose EXISTING folder override/null? (style_models, HuggingFace) (use an object/map instead so settings can be dynamically set) - if (m == 'aestheticGradient') { - return null; - } else if (m == 'checkpoint' || m == 'checkpoints') { - return 'checkpoints'; - } - //else if (m == "") { return "clip"; } - //else if (m == "") { return "clip_vision"; } - else if (m == 'controlnet') { - return 'controlnet'; - } - //else if (m == "Controlnet") { return "style_models"; } // are these controlnets? (TI-Adapter) - //else if (m == "") { return "gligen"; } - else if (m == 'hypernetwork' || m == 'hypernetworks') { - return 'hypernetworks'; - } else if (m == 'lora' || m == 'loras') { - return 'loras'; - } else if (m == 'locon') { - return 'loras'; - } else if (m == 'motionmodule') { - return null; - } else if (m == 'other') { - return null; - } else if (m == 'pose') { - return null; - } else if ( - m == 'textualinversion' || - m == 'embedding' || - m == 'embeddings' - ) { - return 'embeddings'; - } - //else if (m == "") { return "unet"; } - else if ( - m == 'upscaler' || - m == 'upscale_model' || - m == 'upscale_models' - ) { - return 'upscale_models'; - } else if (m == 'vae') { - return 'vae'; - } else if (m == 'wildcard' || m == 'wildcards') { - return null; - } else if (m == 'workflow' || m == 'workflows') { - return null; - } - } - return null; - } - - /** - * Returns empty string on failure - * @param {float | undefined} fileSizeKB - * @returns {string} - */ - static #fileSizeToFormattedString(fileSizeKB) { - if (fileSizeKB === undefined) { - return ''; - } - const sizes = ['KB', 'MB', 'GB', 'TB', 'PB']; - let fileSizeString = fileSizeKB.toString(); - const index = fileSizeString.indexOf('.'); - const indexMove = index % 3 === 0 ? 3 : index % 3; - const sizeIndex = Math.floor((index - indexMove) / 3); - if (sizeIndex >= sizes.length || sizeIndex < 0) { - fileSizeString = fileSizeString.substring( - 0, - fileSizeString.indexOf('.') + 3, - ); - return `(${fileSizeString} ${sizes[0]})`; - } - const split = fileSizeString.split('.'); - fileSizeString = - split[0].substring(0, indexMove) + - '.' + - split[0].substring(indexMove) + - split[1]; - fileSizeString = fileSizeString.substring( - 0, - fileSizeString.indexOf('.') + 3, - ); - return `(${fileSizeString} ${sizes[sizeIndex]})`; - } - - /** - * @param {Object} info - * @param {ModelData} modelData - * @param {int} id - * @param {any} settings - * @returns {HTMLDivElement} - */ - #modelInfoHtml(info, modelData, id, settings) { - const downloadPreviewSelect = new ImageSelect( - 'model-download-info-preview-model' + '-' + id, - info['images'], - ); - - const comfyUIModelType = - DownloadView.modelTypeToComfyUiDirectory(info['details']['fileType']) ?? - DownloadView.modelTypeToComfyUiDirectory(info['modelType']) ?? - ''; - const searchSeparator = modelData.searchSeparator; - const defaultBasePath = - searchSeparator + - (comfyUIModelType === '' ? '' : comfyUIModelType + searchSeparator + '0'); - - const el_saveDirectoryPath = $el('input.search-text-area', { - type: 'text', - name: 'save directory', - autocomplete: 'off', - placeholder: defaultBasePath, - value: defaultBasePath, - }); - const searchDropdown = new DirectoryDropdown( - modelData, - el_saveDirectoryPath, - true, - ); - - const default_name = (() => { - const filename = info['fileName']; - // TODO: only remove valid model file extensions - const i = filename.lastIndexOf('.'); - return i === -1 ? filename : filename.substring(0, i); - })(); - const el_filename = $el('input.plain-text-area', { - type: 'text', - name: 'model save file name', - autocomplete: 'off', - placeholder: default_name, - value: default_name, - onkeydown: (e) => { - if (e.key === 'Enter') { - e.stopPropagation(); - e.target.blur(); - } - }, - }); - - const infoNotes = $el('textarea.comfy-multiline-input.model-info-notes', { - name: 'model info notes', - value: info['description'] ?? '', - rows: 6, - disabled: true, - style: { - display: - info['description'] === undefined || info['description'] === '' - ? 'none' - : '', - }, - }); - - const filepath = info['downloadFilePath']; - const modelInfo = $el('details.download-details', [ - $el('summary', [filepath + info['fileName']]), - $el('div', [ - downloadPreviewSelect.elements.previews, - $el('div.download-settings-wrapper', [ - $el('div.download-settings', [ - new ComfyButton({ - icon: 'arrow-collapse-down', - tooltip: 'Download model', - content: - 'Download ' + - DownloadView.#fileSizeToFormattedString( - info['details']['fileSizeKB'], - ), - classList: 'comfyui-button download-button', - action: async (e) => { - const pathDirectory = el_saveDirectoryPath.value; - const modelName = (() => { - const filename = info['fileName']; - const name = el_filename.value; - if (name === '') { - return filename; - } - const ext = - MODEL_EXTENSIONS.find((ext) => { - return filename.endsWith(ext); - }) ?? ''; - return name + ext; - })(); - const formData = new FormData(); - formData.append('download', info['downloadUrl']); - formData.append('path', pathDirectory); - formData.append('name', modelName); - const image = await downloadPreviewSelect.getImage(); - formData.append( - 'image', - image === PREVIEW_NONE_URI ? '' : image, - ); - formData.append('overwrite', this.elements.overwrite.checked); - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - const [success, resultText] = await comfyRequest( - '/model-manager/model/download', - { - method: 'POST', - body: formData, - }, - ) - .then((data) => { - const success = data['success']; - const message = data['alert']; - if (message !== undefined) { - window.alert(message); - } - return [success, success ? '✔' : '📥︎']; - }) - .catch((err) => { - return [false, '📥︎']; - }); - if (success) { - const description = infoNotes.value; - if ( - this.elements.downloadNotes.checked && - description !== '' - ) { - const modelPath = - pathDirectory + searchSeparator + modelName; - const saved = await saveNotes(modelPath, description); - if (!saved) { - console.warn('Model description was not saved!'); - } - } - this.#updateModels(); - } - comfyButtonAlert( - e.target, - success, - 'mdi-check-bold', - 'mdi-close-thick', - success, - ); - button.disabled = success; - }, - }).element, - $el('div.row.tab-header-flex-block.input-dropdown-container', [ - // TODO: magic class - el_saveDirectoryPath, - searchDropdown.element, - ]), - $el('div.row.tab-header-flex-block', [el_filename]), - downloadPreviewSelect.elements.radioGroup, - infoNotes, - ]), - ]), - ]), - ]); - - return modelInfo; - } - - /** - * @param {ModelData} modelData - * @param {any} settings - */ - async #update(modelData, settings) { - const [name, modelInfos] = await getModelInfos(this.elements.url.value); - const modelInfosHtml = modelInfos - .filter((modelInfo) => { - const filename = modelInfo['fileName']; - return ( - MODEL_EXTENSIONS.find((ext) => { - return filename.endsWith(ext); - }) ?? false - ); - }) - .map((modelInfo, id) => { - return this.#modelInfoHtml(modelInfo, modelData, id, settings); - }); - if (modelInfosHtml.length === 0) { - modelInfosHtml.push($el('h1', ['No models found.'])); - } else { - if (modelInfosHtml.length === 1) { - modelInfosHtml[0].open = true; - } - - const header = $el('div', [ - $el('h1', [name]), - $el('div.model-manager-settings', [ - $checkbox({ - $: (el) => { - this.elements.overwrite = el; - }, - textContent: 'Overwrite Existing Files.', - checked: false, - }), - $checkbox({ - $: (el) => { - this.elements.downloadNotes = el; - }, - textContent: 'Save Notes.', - checked: false, - }), - ]), - ]); - modelInfosHtml.unshift(header); - } - - const infosHtml = this.elements.infos; - infosHtml.innerHTML = ''; - infosHtml.append.apply(infosHtml, modelInfosHtml); - - const downloadNotes = this.elements.downloadNotes; - if (downloadNotes !== undefined && downloadNotes !== null) { - downloadNotes.addEventListener('change', (e) => { - const modelInfoNotes = infosHtml.querySelectorAll( - `textarea.model-info-notes`, - ); - const disabled = !e.currentTarget.checked; - for (let i = 0; i < modelInfoNotes.length; i++) { - modelInfoNotes[i].disabled = disabled; - } - }); - downloadNotes.checked = - settings['download-save-description-as-text-file'].checked; - downloadNotes.dispatchEvent(new Event('change')); - } - - const hideSearchButtons = - settings['text-input-always-hide-search-button'].checked; - this.elements.searchButton.style.display = hideSearchButtons ? 'none' : ''; - - const hideClearSearchButtons = - settings['text-input-always-hide-clear-button'].checked; - this.elements.clearSearchButton.style.display = hideClearSearchButtons - ? 'none' - : ''; - } -} - -class BrowseView { - /** @type {HTMLDivElement} */ - element = null; - - elements = { - /** @type {HTMLDivElement} */ modelGrid: null, - /** @type {HTMLSelectElement} */ modelTypeSelect: null, - /** @type {HTMLSelectElement} */ modelSortSelect: null, - /** @type {HTMLInputElement} */ modelContentFilter: null, - /** @type {HTMLButtonElement} */ searchButton: null, - /** @type {HTMLButtonElement} */ clearSearchButton: null, - }; - - /** @type {Array} */ - previousModelFilters = []; - - /** @type {Object.<{value: string}>} */ - previousModelType = { value: null }; - - /** @type {DirectoryDropdown} */ - directoryDropdown = null; - - /** @type {ModelData} */ - #modelData = null; - - /** @type {(withoutComfyRefresh?: boolean) => Promise} */ - #updateModels = null; - - /** */ - #settingsElements = null; - - /** @type {() => void} */ - updateModelGrid = () => {}; - - /** - * @param {(withoutComfyRefresh?: boolean) => Promise} updateModels - * @param {ModelData} modelData - * @param {(searchPath: string) => Promise} showModelInfo - * @param {() => void} updateModelGridCallback - * @param {any} settingsElements - */ - constructor( - updateModels, - modelData, - showModelInfo, - updateModelGridCallback, - settingsElements, - ) { - /** @type {HTMLDivElement} */ - const modelGrid = $el('div.comfy-grid'); - this.elements.modelGrid = modelGrid; - - this.#updateModels = updateModels; - this.#modelData = modelData; - this.#settingsElements = settingsElements; - - const searchInput = $el('input.search-text-area', { - $: (el) => (this.elements.modelContentFilter = el), - type: 'text', - name: 'model search', - autocomplete: 'off', - placeholder: '/Search', - }); - - const updatePreviousModelFilter = () => { - const modelType = this.elements.modelTypeSelect.value; - const value = this.elements.modelContentFilter.value; - this.previousModelFilters[modelType] = value; - }; - - const updateModelGrid = () => { - const sortValue = this.elements.modelSortSelect.value; - const reverseSort = sortValue[0] === '-'; - const sortBy = reverseSort ? sortValue.substring(1) : sortValue; - ModelGrid.update( - this.elements.modelGrid, - this.#modelData, - this.elements.modelTypeSelect, - this.previousModelType, - this.#settingsElements, - sortBy, - reverseSort, - this.previousModelFilters, - this.elements.modelContentFilter, - showModelInfo, - ); - updateModelGridCallback(); - - const hideSearchButtons = - this.#settingsElements['model-real-time-search'].checked | - this.#settingsElements['text-input-always-hide-search-button'].checked; - this.elements.searchButton.style.display = hideSearchButtons - ? 'none' - : ''; - - const hideClearSearchButtons = - this.#settingsElements['text-input-always-hide-clear-button'].checked; - this.elements.clearSearchButton.style.display = hideClearSearchButtons - ? 'none' - : ''; - }; - this.updateModelGrid = updateModelGrid; - - const searchDropdown = new DirectoryDropdown( - modelData, - searchInput, - false, - () => { - return this.elements.modelTypeSelect.value; - }, - updatePreviousModelFilter, - updateModelGrid, - () => { - return this.#settingsElements['model-real-time-search'].checked; - }, - ); - this.directoryDropdown = searchDropdown; - - const searchButton = new ComfyButton({ - icon: 'magnify', - tooltip: 'Search models', - classList: 'comfyui-button icon-button', - action: (e) => { - e.stopPropagation(); - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - updateModelGrid(); - button.disabled = false; - }, - }).element; - settingsElements['model-real-time-search'].addEventListener( - 'change', - () => { - const hideSearchButton = - this.#settingsElements['text-input-always-hide-search-button'] - .checked || - this.#settingsElements['model-real-time-search'].checked; - searchButton.style.display = hideSearchButton ? 'none' : ''; - }, - ); - settingsElements['text-input-always-hide-search-button'].addEventListener( - 'change', - () => { - const hideSearchButton = - this.#settingsElements['text-input-always-hide-search-button'] - .checked || - this.#settingsElements['model-real-time-search'].checked; - searchButton.style.display = hideSearchButton ? 'none' : ''; - }, - ); - this.elements.searchButton = searchButton; - - const clearSearchButton = new ComfyButton({ - icon: 'close', - tooltip: 'Clear search', - classList: 'comfyui-button icon-button', - action: (e) => { - e.stopPropagation(); - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - this.elements.modelContentFilter.value = ''; - updateModelGrid(); - button.disabled = false; - }, - }).element; - settingsElements['text-input-always-hide-clear-button'].addEventListener( - 'change', - () => { - const hideClearSearchButton = - this.#settingsElements['text-input-always-hide-clear-button'].checked; - clearSearchButton.style.display = hideClearSearchButton ? 'none' : ''; - }, - ); - this.elements.clearSearchButton = clearSearchButton; - - this.element = $el('div', [ - $el('div.row.tab-header', [ - $el('div.row.tab-header-flex-block', [ - new ComfyButton({ - icon: 'reload', - tooltip: 'Reload model grid', - classList: 'comfyui-button icon-button', - action: async (e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - updateModels(); - button.disabled = false; - }, - }).element, - $el('select.model-select-dropdown', { - $: (el) => (this.elements.modelTypeSelect = el), - name: 'model-type', - onchange: (e) => { - const select = e.target; - select.disabled = true; - updateModelGrid(); - select.disabled = false; - }, - }), - $el( - 'select.model-select-dropdown', - { - $: (el) => (this.elements.modelSortSelect = el), - name: 'model select dropdown', - onchange: (e) => { - const select = e.target; - select.disabled = true; - updateModelGrid(); - select.disabled = false; - }, - }, - [ - $el('option', { value: MODEL_SORT_DATE_CREATED }, [ - 'Created (newest first)', - ]), - $el('option', { value: '-' + MODEL_SORT_DATE_CREATED }, [ - 'Created (oldest first)', - ]), - $el('option', { value: MODEL_SORT_DATE_MODIFIED }, [ - 'Modified (newest first)', - ]), - $el('option', { value: '-' + MODEL_SORT_DATE_MODIFIED }, [ - 'Modified (oldest first)', - ]), - $el('option', { value: MODEL_SORT_DATE_NAME }, ['Name (A-Z)']), - $el('option', { value: '-' + MODEL_SORT_DATE_NAME }, [ - 'Name (Z-A)', - ]), - $el('option', { value: MODEL_SORT_SIZE_BYTES }, [ - 'Size (largest first)', - ]), - $el('option', { value: '-' + MODEL_SORT_SIZE_BYTES }, [ - 'Size (smallest first)', - ]), - ], - ), - ]), - $el('div.row.tab-header-flex-block', [ - $el('div.search-models.input-dropdown-container', [ - // TODO: magic class - searchInput, - searchDropdown.element, - ]), - clearSearchButton, - searchButton, - ]), - ]), - modelGrid, - ]); - } -} - -class SettingsView { - /** @type {HTMLDivElement} */ - element = null; - - elements = { - /** @type {HTMLButtonElement} */ reloadButton: null, - /** @type {HTMLButtonElement} */ saveButton: null, - /** @type {HTMLDivElement} */ setPreviewButton: null, - settings: { - /** @type {HTMLTextAreaElement} */ 'model-search-always-append': null, - /** @type {HTMLInputElement} */ 'model-default-browser-model-type': null, - /** @type {HTMLInputElement} */ 'model-real-time-search': null, - /** @type {HTMLInputElement} */ 'model-persistent-search': null, - - /** @type {HTMLInputElement} */ 'model-preview-thumbnail-type': null, - /** @type {HTMLInputElement} */ 'model-preview-thumbnail-width': null, - /** @type {HTMLInputElement} */ 'model-preview-thumbnail-height': null, - /** @type {HTMLInputElement} */ 'model-preview-fallback-search-safetensors-thumbnail': - null, - /** @type {HTMLInputElement} */ 'model-show-label-extensions': null, - /** @type {HTMLInputElement} */ 'model-show-add-button': null, - /** @type {HTMLInputElement} */ 'model-show-copy-button': null, - /** @type {HTMLInputElement} */ 'model-show-load-workflow-button': null, - /** @type {HTMLInputElement} */ 'model-info-button-on-left': null, - /** @type {HTMLInputElement} */ 'model-buttons-only-on-hover': null, - - /** @type {HTMLInputElement} */ 'model-add-embedding-extension': null, - /** @type {HTMLInputElement} */ 'model-add-drag-strict-on-field': null, - /** @type {HTMLInputElement} */ 'model-add-offset': null, - - /** @type {HTMLInputElement} */ 'model-info-autosave-notes': null, - - /** @type {HTMLInputElement} */ 'download-save-description-as-text-file': - null, - - /** @type {HTMLInputElement} */ 'sidebar-default-width': null, - /** @type {HTMLInputElement} */ 'sidebar-default-height': null, - /** @type {HTMLInputElement} */ 'sidebar-control-always-compact': null, - /** @type {HTMLInputElement} */ 'text-input-always-hide-search-button': - null, - /** @type {HTMLInputElement} */ 'text-input-always-hide-clear-button': - null, - - /** @type {HTMLInputElement} */ 'tag-generator-sampler-method': null, - /** @type {HTMLInputElement} */ 'tag-generator-count': null, - /** @type {HTMLInputElement} */ 'tag-generator-threshold': null, - }, - }; - - /** @return {(withoutComfyRefresh?: boolean) => Promise} */ - #updateModels = () => {}; - - /** @return {() => void} */ - #updateSidebarSettings = () => {}; - - /** - * @param {Object} settingsData - * @param {boolean} withoutComfyRefresh - */ - async #setSettings(settingsData, withoutComfyRefresh) { - const settings = this.elements.settings; - for (const [key, value] of Object.entries(settingsData)) { - const setting = settings[key]; - if (setting === undefined || setting === null) { - continue; - } - const type = setting.type; - switch (type) { - case 'checkbox': - setting.checked = Boolean(value); - break; - case 'range': - setting.value = parseFloat(value); - break; - case 'textarea': - setting.value = value; - break; - case 'number': - setting.value = parseInt(value); - break; - case 'select-one': - setting.value = value; - break; - default: - console.warn(`Unknown settings input type '${type}'!`); - } - } - this.#updateSidebarSettings(settings); - await this.#updateModels(withoutComfyRefresh); - } - - /** - * @param {boolean} withoutComfyRefresh - * @returns {Promise} - */ - async reload(withoutComfyRefresh) { - const data = await comfyRequest('/model-manager/settings/load'); - const settingsData = data['settings']; - await this.#setSettings(settingsData, withoutComfyRefresh); - comfyButtonAlert(this.elements.reloadButton, true); - } - - /** @returns {Promise} */ - async save() { - let settingsData = {}; - for (const [setting, el] of Object.entries(this.elements.settings)) { - if (!el) { - continue; - } // hack - const type = el.type; - let value = null; - switch (type) { - case 'checkbox': - value = el.checked; - break; - case 'range': - value = el.value; - break; - case 'textarea': - value = el.value; - break; - case 'number': - value = el.value; - break; - case 'select-one': - value = el.value; - break; - default: - console.warn('Unknown settings input type!'); - } - settingsData[setting] = value; - } - - const data = await comfyRequest('/model-manager/settings/save', { - method: 'POST', - body: JSON.stringify({ settings: settingsData }), - }).catch((err) => { - return { success: false }; - }); - const success = data['success']; - if (success) { - const settingsData = data['settings']; - await this.#setSettings(settingsData, true); - } - comfyButtonAlert(this.elements.saveButton, success); - } - - /** - * @param {(withoutComfyRefresh?: boolean) => Promise} updateModels - * @param {() => void} updateSidebarButtons - * @param {(settings: Object) => void} updateSidebarSettings - */ - constructor(updateModels, updateSidebarButtons, updateSidebarSettings) { - this.#updateModels = updateModels; - this.#updateSidebarSettings = updateSidebarSettings; - const settings = this.elements.settings; - - const sidebarControl = $checkbox({ - $: (el) => (settings['sidebar-control-always-compact'] = el), - textContent: 'Sidebar controls always compact', - }); - sidebarControl - .getElementsByTagName('input')[0] - .addEventListener('change', () => { - updateSidebarButtons(); - }); - - const reloadButton = new ComfyButton({ - content: 'Reload', - tooltip: 'Reload settings and model manager files', - action: async (e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - await this.reload(true); - button.disabled = false; - }, - }).element; - this.elements.reloadButton = reloadButton; - - const saveButton = new ComfyButton({ - content: 'Save', - tooltip: 'Save settings and reload model manager', - action: async (e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - await this.save(); - button.disabled = false; - }, - }).element; - this.elements.saveButton = saveButton; - - const correctPreviewsButton = new ComfyButton({ - content: 'Fix Extensions', - tooltip: 'Correct image file extensions in all model directories', - action: async (e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - const data = await comfyRequest( - '/model-manager/preview/correct-extensions', - ).catch((err) => { - return { success: false }; - }); - const success = data['success']; - if (success) { - const detectPlural = data['detected'] === 1 ? '' : 's'; - const correctPlural = data['corrected'] === 1 ? '' : 's'; - const message = `Detected ${data['detected']} extension${detectPlural}.\nCorrected ${data['corrected']} extension${correctPlural}.`; - window.alert(message); - } - comfyButtonAlert(e.target, success); - if (data['corrected'] > 0) { - await this.reload(true); - } - button.disabled = false; - }, - }).element; - - $el( - 'div.model-manager-settings', - { - $: (el) => (this.element = el), - }, - [ - $el('h1', ['Settings']), - $el('div', [reloadButton, saveButton]), - $el( - 'a', - { - style: { color: 'var(--fg-color)' }, - href: 'https://github.com/hayden-fr/ComfyUI-Model-Manager/issues/', - target: '_blank', - }, - ['File bugs and issues here.'], - ), - $el('h2', ['Model Search']), - $el('div', [ - $el('div.search-settings-text', [ - $el('p', ['Always include in model search:']), - $el('textarea.comfy-multiline-input', { - $: (el) => (settings['model-search-always-append'] = el), - name: 'always include in model search', - placeholder: 'example: /0/sd1.5/styles "pastel style" -3d', - rows: '6', - }), - ]), - ]), - $select({ - $: (el) => (settings['model-default-browser-model-type'] = el), - textContent: 'Default model search type (on start up)', - options: [ - 'checkpoints', - 'clip', - 'clip_vision', - 'controlnet', - 'diffusers', - 'embeddings', - 'gligen', - 'hypernetworks', - 'loras', - 'photomaker', - 'style_models', - 'unet', - 'vae', - 'vae_approx', - ], - }), - $select({ - $: (el) => (settings['sidebar-default-state'] = el), - textContent: 'Default model manager position (on start up)', - options: ['Left', 'Right', 'Top', 'Bottom', 'None'], - }), - $checkbox({ - $: (el) => (settings['model-real-time-search'] = el), - textContent: 'Real-time search', - }), - $checkbox({ - $: (el) => (settings['model-persistent-search'] = el), - textContent: 'Persistent search text (across model types)', - }), - $el('h2', ['Model Search Thumbnails']), - $select({ - $: (el) => (settings['model-preview-thumbnail-type'] = el), - textContent: 'Preview thumbnail type', - options: ['AUTO', 'JPEG'], // should use AUTO to avoid artifacts from changing between formats; use JPEG for backward compatibility - }), - $el('label', [ - 'Preview thumbnail width', - $el('input', { - $: (el) => (settings['model-preview-thumbnail-width'] = el), - type: 'range', - name: 'default thumbnail width', - value: 240, - min: 150, - max: 480, - step: 5, - oninput: function(){ this.nextElementSibling.textContent = this.value + 'px'}, - }), - $el('span'), - ]), - $el('label', [ - 'Preview thumbnail height', - $el('input', { - $: (el) => (settings['model-preview-thumbnail-height'] = el), - type: 'range', - name: 'default thumbnail height', - value: 360, - min: 185, - max: 480, - step: 5, - oninput: function(){ this.nextElementSibling.textContent = this.value + 'px'}, - }), - $el('span'), - ]), - $checkbox({ - $: (el) => - (settings['model-preview-fallback-search-safetensors-thumbnail'] = - el), - textContent: 'Fallback to embedded safetensors image (slow)', - }), - $checkbox({ - $: (el) => (settings['model-show-label-extensions'] = el), - textContent: 'Show file extension', - }), - $checkbox({ - $: (el) => (settings['model-show-copy-button'] = el), - textContent: 'Show "Copy" button', - }), - $checkbox({ - $: (el) => (settings['model-show-add-button'] = el), - textContent: 'Show "Add" button', - }), - $checkbox({ - $: (el) => (settings['model-show-load-workflow-button'] = el), - textContent: 'Show "Load Workflow" button', - }), - $checkbox({ - $: (el) => (settings['model-info-button-on-left'] = el), - textContent: '"Model Info" button on left', - }), - $checkbox({ - $: (el) => (settings['model-buttons-only-on-hover'] = el), - textContent: 'Show buttons on hover only', - }), - $el('h2', ['Node Graph']), - $checkbox({ - $: (el) => (settings['model-add-embedding-extension'] = el), - textContent: 'Add embedding with extension', - }), - $checkbox({ - $: (el) => (settings['model-add-drag-strict-on-field'] = el), // true -> must drag on field; false -> can drag on node when unambiguous - textContent: "Must always drag thumbnail onto node's input field", - }), - $el('label', [ - 'Add offset', // if a node already was added to the same spot, add the next one with an offset - $el('input', { - $: (el) => (settings['model-add-offset'] = el), - type: 'number', - name: 'model add offset', - step: 5, - }), - ]), - $el('h2', ['Model Info']), - $checkbox({ - $: (el) => (settings['model-info-autosave-notes'] = el), // note history deleted on model info close - textContent: 'Autosave notes', - }), - $el('h2', ['Download']), - $checkbox({ - $: (el) => (settings['download-save-description-as-text-file'] = el), - textContent: 'Save notes by default.', - }), - $el('h2', ['Window']), - sidebarControl, - $el('label', [ - 'Sidebar width (on start up)', - $el('input', { - $: (el) => (settings['sidebar-default-width'] = el), - type: 'range', - name: 'default sidebar width', - value: 0.5, - min: 0.0, - max: 1.0, - step: 0.05, - }), - ]), - $el('label', [ - 'Sidebar height (on start up)', - $el('input', { - $: (el) => (settings['sidebar-default-height'] = el), - type: 'range', - name: 'default sidebar height', - value: 0.5, - min: 0.0, - max: 1.0, - step: 0.05, - }), - ]), - $checkbox({ - $: (el) => (settings['text-input-always-hide-search-button'] = el), - textContent: 'Always hide "Search" buttons.', - }), - $checkbox({ - $: (el) => (settings['text-input-always-hide-clear-button'] = el), - textContent: 'Always hide "Clear Search" buttons.', - }), - $el('h2', ['Model Preview Images']), - $el('div', [correctPreviewsButton]), - $el('h2', ['Random Tag Generator']), - $select({ - $: (el) => (settings['tag-generator-sampler-method'] = el), - textContent: 'Default sampling method', - options: ['Frequency', 'Uniform'], - }), - $el('label', [ - 'Default count', - $el('input', { - $: (el) => (settings['tag-generator-count'] = el), - type: 'number', - name: 'tag generator count', - step: 1, - min: 1, - }), - ]), - $el('label', [ - 'Default minimum threshold', - $el('input', { - $: (el) => (settings['tag-generator-threshold'] = el), - type: 'number', - name: 'tag generator threshold', - step: 1, - min: 1, - }), - ]), - ], - ); - } -} - -/** - * @param {String[]} labels - * @param {[(event: Event) => Promise]} callbacks - * @returns {HTMLDivElement} - */ -function GenerateRadioButtonGroup(labels, callbacks = []) { - const RADIO_BUTTON_GROUP_ACTIVE = 'radio-button-group-active'; - const radioButtonGroup = $el('div.radio-button-group', []); - const buttons = []; - for (let i = 0; i < labels.length; i++) { - const text = labels[i]; - const callback = callbacks[i] ?? (() => {}); - buttons.push( - $el('button.radio-button', { - textContent: text, - onclick: (event) => { - const targetIsActive = event.target.classList.contains( - RADIO_BUTTON_GROUP_ACTIVE, - ); - if (targetIsActive) { - return; - } - const children = radioButtonGroup.children; - for (let i = 0; i < children.length; i++) { - children[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); - } - event.target.classList.add(RADIO_BUTTON_GROUP_ACTIVE); - callback(event); - }, - }), - ); - } - radioButtonGroup.append.apply(radioButtonGroup, buttons); - buttons[0]?.classList.add(RADIO_BUTTON_GROUP_ACTIVE); - return radioButtonGroup; -} - -/** - * @param {String[]} labels - * @param {[(event: Event) => Promise]} activationCallbacks - * @param {(event: Event) => Promise} deactivationCallback - * @returns {HTMLDivElement} - */ -function GenerateToggleRadioButtonGroup( - labels, - activationCallbacks = [], - deactivationCallback = () => {}, -) { - const RADIO_BUTTON_GROUP_ACTIVE = 'radio-button-group-active'; - const radioButtonGroup = $el('div.radio-button-group', []); - const buttons = []; - for (let i = 0; i < labels.length; i++) { - const text = labels[i]; - const activationCallback = activationCallbacks[i] ?? (() => {}); - buttons.push( - $el('button.radio-button', { - textContent: text, - onclick: (event) => { - const targetIsActive = event.target.classList.contains( - RADIO_BUTTON_GROUP_ACTIVE, - ); - const children = radioButtonGroup.children; - for (let i = 0; i < children.length; i++) { - children[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); - } - if (targetIsActive) { - deactivationCallback(event); - } else { - event.target.classList.add(RADIO_BUTTON_GROUP_ACTIVE); - activationCallback(event); - } - }, - }), - ); - } - radioButtonGroup.append.apply(radioButtonGroup, buttons); - return radioButtonGroup; -} - -/** - * Coupled-state select and radio buttons (hidden first radio button) - * @param {String[]} labels - * @param {[(button: HTMLButtonElement) => Promise]} activationCallbacks - * @returns {[HTMLDivElement, HTMLSelectElement]} - */ -function GenerateSidebarToggleRadioAndSelect(labels, activationCallbacks = []) { - const RADIO_BUTTON_GROUP_ACTIVE = 'radio-button-group-active'; - const radioButtonGroup = $el('div.radio-button-group', []); - const buttons = []; - - const select = $el( - 'select', - { - name: 'sidebar-select', - classList: 'icon-button', - onchange: (event) => { - const select = event.target; - const children = select.children; - let value = undefined; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - if (child.selected) { - value = child.value; - } - } - for (let i = 0; i < buttons.length; i++) { - const button = buttons[i]; - if (button.textContent === value) { - for (let i = 0; i < buttons.length; i++) { - buttons[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); - } - button.classList.add(RADIO_BUTTON_GROUP_ACTIVE); - activationCallbacks[i](button); - break; - } - } - }, - }, - labels.map((option) => { - return $el( - 'option', - { - value: option, - }, - option, - ); - }), - ); - - for (let i = 0; i < labels.length; i++) { - const text = labels[i]; - const activationCallback = activationCallbacks[i] ?? (() => {}); - buttons.push( - $el('button.radio-button', { - textContent: text, - onclick: (event) => { - const button = event.target; - let textContent = button.textContent; - const targetIsActive = button.classList.contains( - RADIO_BUTTON_GROUP_ACTIVE, - ); - if ( - button === buttons[0] && - buttons[0].classList.contains(RADIO_BUTTON_GROUP_ACTIVE) - ) { - // do not deactivate 0 - return; - } - // update button - const children = radioButtonGroup.children; - for (let i = 0; i < children.length; i++) { - children[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); - } - if (targetIsActive) { - // return to 0 - textContent = labels[0]; - buttons[0].classList.add(RADIO_BUTTON_GROUP_ACTIVE); - activationCallbacks[0](buttons[0]); - } else { - // move to >0 - button.classList.add(RADIO_BUTTON_GROUP_ACTIVE); - activationCallback(button); - } - // update selection - for (let i = 0; i < select.children.length; i++) { - const option = select.children[i]; - option.selected = option.value === textContent; - } - }, - }), - ); - } - radioButtonGroup.append.apply(radioButtonGroup, buttons); - buttons[0].style.display = 'none'; - return [radioButtonGroup, select]; -} - -class ModelManager extends ComfyDialog { - /** @type {HTMLDivElement} */ - element = null; - - /** @type {ModelData} */ - #modelData = null; - - /** @type {ModelInfo} */ - #modelInfo = null; - - /** @type {DownloadView} */ - #downloadView = null; - - /** @type {BrowseView} */ - #browseView = null; - - /** @type {SettingsView} */ - #settingsView = null; - - /** @type {HTMLDivElement} */ - #topbarRight = null; - - /** @type {HTMLDivElement} */ - #tabManagerButtons = null; - - /** @type {HTMLDivElement} */ - #tabManagerContents = null; - - /** @type {HTMLDivElement} */ - #tabInfoButtons = null; - - /** @type {HTMLDivElement} */ - #tabInfoContents = null; - - /** @type {HTMLButtonElement} */ - #sidebarButtonGroup = null; - - /** @type {HTMLButtonElement} */ - #sidebarSelect = null; - - /** @type {HTMLButtonElement} */ - #closeModelInfoButton = null; - - /** @type {String} */ - #dragSidebarState = ''; - - constructor() { - super(); - - this.#modelData = new ModelData(); - - this.#settingsView = new SettingsView(this.#refreshModels, () => - this.#updateSidebarButtons(), - this.#updateSidebarSettings, - ); - - this.#modelInfo = new ModelInfo( - this.#modelData, - this.#refreshModels, - this.#settingsView.elements.settings, - ); - - this.#browseView = new BrowseView( - this.#refreshModels, - this.#modelData, - this.#showModelInfo, - this.#resetManagerContentsScroll, - this.#settingsView.elements.settings, // TODO: decouple settingsData from elements? - ); - - this.#downloadView = new DownloadView( - this.#modelData, - this.#settingsView.elements.settings, - this.#refreshModels, - ); - - const [tabManagerButtons, tabManagerContents] = GenerateTabGroup([ - { - name: 'Download', - icon: 'arrow-collapse-down', - tabContent: this.#downloadView.element, - }, - { - name: 'Models', - icon: 'folder-search-outline', - tabContent: this.#browseView.element, - }, - { - name: 'Settings', - icon: 'cog-outline', - tabContent: this.#settingsView.element, - }, - ]); - tabManagerButtons[0]?.click(); - - const tabInfoButtons = this.#modelInfo.elements.tabButtons; - const tabInfoContents = this.#modelInfo.elements.tabContents; - - const [sidebarButtonGroup, sidebarSelect] = - GenerateSidebarToggleRadioAndSelect( - ['◼', '◨', '⬒', '⬓', '◧'], - [ - () => { - this.element.dataset['sidebarState'] = 'none'; - }, - () => { - this.element.dataset['sidebarState'] = 'right'; - }, - () => { - this.element.dataset['sidebarState'] = 'top'; - }, - () => { - this.element.dataset['sidebarState'] = 'bottom'; - }, - () => { - this.element.dataset['sidebarState'] = 'left'; - }, - ], - ); - this.#sidebarButtonGroup = sidebarButtonGroup; - this.#sidebarSelect = sidebarSelect; - sidebarButtonGroup.classList.add('sidebar-buttons'); - const sidebarButtonGroupChildren = sidebarButtonGroup.children; - for (let i = 0; i < sidebarButtonGroupChildren.length; i++) { - sidebarButtonGroupChildren[i].classList.add('icon-button'); - } - - const closeModelInfoButton = new ComfyButton({ - icon: 'arrow-u-left-bottom', - tooltip: 'Return to model search', - classList: 'comfyui-button icon-button', - action: async () => await this.#tryHideModelInfo(true), - }).element; - this.#closeModelInfoButton = closeModelInfoButton; - closeModelInfoButton.style.display = 'none'; - - const modelManager = $el( - 'div.comfy-modal.model-manager', - { - $: (el) => (this.element = el), - parent: document.body, - dataset: { - sidebarState: 'none', - sidebarLeftWidthDecimal: '', - sidebarRightWidthDecimal: '', - sidebarTopHeightDecimal: '', - sidebarBottomHeightDecimal: '', - }, - }, - [ - $el('div.comfy-modal-content', [ - // TODO: settings.top_bar_left_to_right or settings.top_bar_right_to_left - $el('div.model-manager-panel', [ - $el('div.model-manager-head', [ - $el( - 'div.topbar-right', - { - $: (el) => (this.#topbarRight = el), - }, - [ - new ComfyButton({ - icon: 'window-close', - tooltip: 'Close model manager', - classList: 'comfyui-button icon-button', - action: async () => { - const saved = await this.#modelInfo.trySave(true); - if (saved) { - this.close(); - } - }, - }).element, - closeModelInfoButton, - sidebarSelect, - sidebarButtonGroup, - ], - ), - $el('div.topbar-left', [ - $el('div', [ - $el( - 'div.model-tab-group.no-highlight', - { - $: (el) => (this.#tabManagerButtons = el), - }, - tabManagerButtons, - ), - $el( - 'div.model-tab-group.no-highlight', - { - $: (el) => (this.#tabInfoButtons = el), - style: { display: 'none' }, - }, - tabInfoButtons, - ), - ]), - ]), - ]), - $el('div.model-manager-body', [ - $el( - 'div.tab-contents', - { - $: (el) => (this.#tabManagerContents = el), - }, - tabManagerContents, - ), - $el( - 'div.tab-contents', - { - $: (el) => (this.#tabInfoContents = el), - style: { display: 'none' }, - }, - tabInfoContents, - ), - ]), - ]), - ]), - ], - ); - - new ResizeObserver( - GenerateDynamicTabTextCallback(modelManager, tabManagerButtons, 704), - ).observe(modelManager); - new ResizeObserver( - GenerateDynamicTabTextCallback(modelManager, tabInfoButtons, 704), - ).observe(modelManager); - new ResizeObserver(() => this.#updateSidebarButtons()).observe( - modelManager, - ); - window.addEventListener('resize', () => { - const width = window.innerWidth; - const height = window.innerHeight; - - const leftDecimal = modelManager.dataset['sidebarLeftWidthDecimal']; - const rightDecimal = modelManager.dataset['sidebarRightWidthDecimal']; - const topDecimal = modelManager.dataset['sidebarTopHeightDecimal']; - const bottomDecimal = modelManager.dataset['sidebarBottomHeightDecimal']; - - // restore decimal after resize - modelManager.style.setProperty( - '--model-manager-sidebar-width-left', - leftDecimal * width + 'px', - ); - modelManager.style.setProperty( - '--model-manager-sidebar-width-right', - rightDecimal * width + 'px', - ); - modelManager.style.setProperty( - '--model-manager-sidebar-height-top', - +(topDecimal * height) + 'px', - ); - modelManager.style.setProperty( - '--model-manager-sidebar-height-bottom', - bottomDecimal * height + 'px', - ); - }); - - const EDGE_DELTA = 8; - - const endDragSidebar = (e) => { - this.#dragSidebarState = ''; - - modelManager.classList.remove('cursor-drag-left'); - modelManager.classList.remove('cursor-drag-top'); - modelManager.classList.remove('cursor-drag-right'); - modelManager.classList.remove('cursor-drag-bottom'); - - // cache for window resize - modelManager.dataset['sidebarLeftWidthDecimal'] = - parseInt( - modelManager.style.getPropertyValue( - '--model-manager-sidebar-width-left', - ), - ) / window.innerWidth; - modelManager.dataset['sidebarRightWidthDecimal'] = - parseInt( - modelManager.style.getPropertyValue( - '--model-manager-sidebar-width-right', - ), - ) / window.innerWidth; - modelManager.dataset['sidebarTopHeightDecimal'] = - parseInt( - modelManager.style.getPropertyValue( - '--model-manager-sidebar-height-top', - ), - ) / window.innerHeight; - modelManager.dataset['sidebarBottomHeightDecimal'] = - parseInt( - modelManager.style.getPropertyValue( - '--model-manager-sidebar-height-bottom', - ), - ) / window.innerHeight; - }; - document.addEventListener('mouseup', (e) => endDragSidebar(e)); - document.addEventListener('touchend', (e) => endDragSidebar(e)); - - const detectDragSidebar = (e, x, y) => { - const left = modelManager.offsetLeft; - const top = modelManager.offsetTop; - const width = modelManager.offsetWidth; - const height = modelManager.offsetHeight; - const right = left + width; - const bottom = top + height; - - if (!(x >= left && x <= right && y >= top && y <= bottom)) { - // click was not in model manager - return; - } - - const isOnEdgeLeft = x - left <= EDGE_DELTA; - const isOnEdgeRight = right - x <= EDGE_DELTA; - const isOnEdgeTop = y - top <= EDGE_DELTA; - const isOnEdgeBottom = bottom - y <= EDGE_DELTA; - - const sidebarState = this.element.dataset['sidebarState']; - if (sidebarState === 'left' && isOnEdgeRight) { - this.#dragSidebarState = sidebarState; - } else if (sidebarState === 'right' && isOnEdgeLeft) { - this.#dragSidebarState = sidebarState; - } else if (sidebarState === 'top' && isOnEdgeBottom) { - this.#dragSidebarState = sidebarState; - } else if (sidebarState === 'bottom' && isOnEdgeTop) { - this.#dragSidebarState = sidebarState; - } - - if (this.#dragSidebarState !== '') { - e.preventDefault(); - e.stopPropagation(); - } - }; - modelManager.addEventListener('mousedown', (e) => - detectDragSidebar(e, e.clientX, e.clientY), - ); - modelManager.addEventListener('touchstart', (e) => - detectDragSidebar(e, e.touches[0].clientX, e.touches[0].clientY), - ); - - const updateSidebarCursor = (e, x, y) => { - if (this.#dragSidebarState !== '') { - // do not update cursor style while dragging - return; - } - - const left = modelManager.offsetLeft; - const top = modelManager.offsetTop; - const width = modelManager.offsetWidth; - const height = modelManager.offsetHeight; - const right = left + width; - const bottom = top + height; - - const isOnEdgeLeft = x - left <= EDGE_DELTA; - const isOnEdgeRight = right - x <= EDGE_DELTA; - const isOnEdgeTop = y - top <= EDGE_DELTA; - const isOnEdgeBottom = bottom - y <= EDGE_DELTA; - - const updateClass = (add, className) => { - if (add) { - modelManager.classList.add(className); - } else { - modelManager.classList.remove(className); - } - }; - - const sidebarState = this.element.dataset['sidebarState']; - updateClass(sidebarState === 'right' && isOnEdgeLeft, 'cursor-drag-left'); - updateClass(sidebarState === 'bottom' && isOnEdgeTop, 'cursor-drag-top'); - updateClass( - sidebarState === 'left' && isOnEdgeRight, - 'cursor-drag-right', - ); - updateClass( - sidebarState === 'top' && isOnEdgeBottom, - 'cursor-drag-bottom', - ); - }; - modelManager.addEventListener('mousemove', (e) => - updateSidebarCursor(e, e.clientX, e.clientY), - ); - modelManager.addEventListener('touchmove', (e) => - updateSidebarCursor(e, e.touches[0].clientX, e.touches[0].clientY), - ); - - const updateDragSidebar = (e, x, y) => { - const sidebarState = this.#dragSidebarState; - if (sidebarState === '') { - return; - } - - e.preventDefault(); - - const width = window.innerWidth; - const height = window.innerHeight; - - if (sidebarState === 'left') { - const pixels = clamp(x, 0, width).toString() + 'px'; - modelManager.style.setProperty( - '--model-manager-sidebar-width-left', - pixels, - ); - } else if (sidebarState === 'right') { - const pixels = clamp(width - x, 0, width).toString() + 'px'; - modelManager.style.setProperty( - '--model-manager-sidebar-width-right', - pixels, - ); - } else if (sidebarState === 'top') { - const pixels = clamp(y, 0, height).toString() + 'px'; - modelManager.style.setProperty( - '--model-manager-sidebar-height-top', - pixels, - ); - } else if (sidebarState === 'bottom') { - const pixels = clamp(height - y, 0, height).toString() + 'px'; - modelManager.style.setProperty( - '--model-manager-sidebar-height-bottom', - pixels, - ); - } - }; - document.addEventListener('mousemove', (e) => - updateDragSidebar(e, e.clientX, e.clientY), - ); - document.addEventListener('touchmove', (e) => - updateDragSidebar(e, e.touches[0].clientX, e.touches[0].clientY), - ); - - if(IS_FIREFOX){ - app.canvasContainer.addEventListener('drop', (e) => { - if (e.dataTransfer.types.includes('manager-model')){ - const data = JSON.parse(e.dataTransfer.getData('manager-model')); - ModelGrid.dragAddModel( - e, - data.modelType, - data.path, - data.removeEmbeddingExtension, - data.strictDragToAdd, - ); - } - }); - } - this.#init(); - } - - async #init() { - await this.#settingsView.reload(true) - - const settings = this.#settingsView.elements.settings; - - { - // set initial sidebar state - const newSidebarState = settings['sidebar-default-state'].value; - let buttonNumb = 0; - if (newSidebarState === 'Right') { - buttonNumb = 1; - } else if (newSidebarState === 'Top') { - buttonNumb = 2; - } else if (newSidebarState === 'Bottom') { - buttonNumb = 3; - } else if (newSidebarState === 'Left') { - buttonNumb = 4; - } - this.#sidebarButtonGroup.children[buttonNumb].click(); - } - - { - // set initial sidebar widths & heights - const width = window.innerWidth; - const height = window.innerHeight; - - const xDecimal = settings['sidebar-default-width'].value; - const yDecimal = settings['sidebar-default-height'].value; - - this.element.dataset['sidebarLeftWidthDecimal'] = xDecimal; - this.element.dataset['sidebarRightWidthDecimal'] = xDecimal; - this.element.dataset['sidebarTopHeightDecimal'] = yDecimal; - this.element.dataset['sidebarBottomHeightDecimal'] = yDecimal; - - const x = Math.floor(width * xDecimal); - const y = Math.floor(height * yDecimal); - - const leftPixels = x.toString() + 'px'; - this.element.style.setProperty( - '--model-manager-sidebar-width-left', - leftPixels, - ); - - const rightPixels = x.toString() + 'px'; - this.element.style.setProperty( - '--model-manager-sidebar-width-right', - rightPixels, - ); - - const topPixels = y.toString() + 'px'; - this.element.style.setProperty( - '--model-manager-sidebar-height-top', - topPixels, - ); - - const bottomPixels = y.toString() + 'px'; - this.element.style.setProperty( - '--model-manager-sidebar-height-bottom', - bottomPixels, - ); - } - } - - #updateSidebarSettings = (settings) => { - { - // update buttons' visibility state - const hideSearchButtons = - settings['text-input-always-hide-search-button'].checked; - const hideClearSearchButtons = - settings['text-input-always-hide-clear-button'].checked; - this.#downloadView.elements.searchButton.style.display = hideSearchButtons - ? 'none' - : ''; - this.#downloadView.elements.clearSearchButton.style.display = - hideClearSearchButtons ? 'none' : ''; - } - - { - // update thumbnail widths & heights - const thumbnailWidthEl = settings['model-preview-thumbnail-width']; - const thumbnailHeightEl = settings['model-preview-thumbnail-height']; - - this.element.style.setProperty( - '--model-manager-thumbnail-width', - thumbnailWidthEl.value.toString() + 'px', - ); - thumbnailWidthEl.dispatchEvent(new Event('input')); - - this.element.style.setProperty( - '--model-manager-thumbnail-height', - thumbnailHeightEl.value.toString() + 'px', - ); - thumbnailHeightEl.dispatchEvent(new Event('input')); - } - } - - #resetManagerContentsScroll = () => { - this.#tabManagerContents.scrollTop = 0; - }; - - #refreshModels = async (withoutComfyRefresh = false) => { - const modelData = this.#modelData; - modelData.systemSeparator = await comfyRequest( - '/model-manager/system-separator', - ); - const newModels = await comfyRequest('/model-manager/models/list'); - Object.assign(modelData.models, newModels); // NOTE: do NOT create a new object - const newModelDirectories = await comfyRequest( - '/model-manager/models/directory-list', - ); - modelData.directories.data.splice(0, Infinity, ...newModelDirectories); // NOTE: do NOT create a new array - - this.#browseView.updateModelGrid(); - await this.#tryHideModelInfo(false); - if (!withoutComfyRefresh){ - document.getElementById('comfy-refresh-button')?.click(); - } - }; - - /** - * @param {searchPath: string} - * @return {Promise} - */ - #showModelInfo = async (searchPath) => { - await this.#modelInfo - .update(searchPath, this.#refreshModels, this.#modelData.searchSeparator) - .then(() => { - this.#tabManagerButtons.style.display = 'none'; - this.#tabManagerContents.style.display = 'none'; - - this.#closeModelInfoButton.style.display = ''; - this.#tabInfoButtons.style.display = ''; - this.#tabInfoContents.style.display = ''; - - this.#tabInfoButtons.children[0]?.click(); - this.#modelInfo.show(); - this.#tabInfoContents.scrollTop = 0; - }); - }; - - /** - * @param {boolean} promptSave - * @returns {Promise} - */ - #tryHideModelInfo = async (promptSave) => { - if (this.#tabInfoContents.style.display !== 'none') { - if (!(await this.#modelInfo.tryHide(promptSave))) { - return false; - } - - this.#closeModelInfoButton.style.display = 'none'; - this.#tabInfoButtons.style.display = 'none'; - this.#tabInfoContents.style.display = 'none'; - - this.#tabManagerButtons.style.display = ''; - this.#tabManagerContents.style.display = ''; - } - return true; - }; - - #updateSidebarButtons = () => { - const managerRect = this.element.getBoundingClientRect(); - const isNarrow = managerRect.width < 768; // TODO: `minWidth` is a magic value - const alwaysShowCompactSidebarControls = - this.#settingsView.elements.settings['sidebar-control-always-compact'] - .checked; - if (isNarrow || alwaysShowCompactSidebarControls) { - this.#sidebarButtonGroup.style.display = 'none'; - this.#sidebarSelect.style.display = ''; - } else { - this.#sidebarButtonGroup.style.display = ''; - this.#sidebarSelect.style.display = 'none'; - } - }; -} - -/** @type {ModelManager | undefined} */ -let instance; - -/** - * @returns {ModelManager} - */ -function getInstance() { - if (!instance) { - instance = new ModelManager(); - } - return instance; -} - -const toggleModelManager = () => { - const modelManager = getInstance(); - const style = modelManager.element.style; - if (style.display === '' || style.display === 'none') { - modelManager.show(); - } else { - modelManager.close(); - } -}; - -app.registerExtension({ - name: 'Comfy.ModelManager', - init() {}, - async setup() { - const cssFileUrl = new URL(import.meta.url).pathname.replace('.js', '.css'); - - $el('link', { - parent: document.head, - rel: 'stylesheet', - href: cssFileUrl, - }); - - app.ui?.menuContainer?.appendChild( - $el('button', { - id: 'comfyui-model-manager-button', - parent: document.querySelector('.comfy-menu'), - textContent: 'Models', - onclick: () => toggleModelManager(), - }), - ); - - // [Beta] mobile menu - app.menu?.settingsGroup?.append( - new ComfyButton({ - icon: 'folder-search', - tooltip: 'Opens model manager', - action: () => toggleModelManager(), - content: 'Model Manager', - popup: getInstance(), - }), - ); - }, -});