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.
-
+# Usage
-
+```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
-
+
+
+### 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).
-
-
-
- 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
-
+
- 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
-
+
- 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
-
+
- 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
-
-
-
-- 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 @@
+
+
+
+
+
+
+
+
+
+
+
+ version:
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+ -
+
+
+
![]()
+
+
+
+
+
+ {{ item.fullname }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ item.downloadProgress }}
+
+ {{ item.downloadSpeed }}
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+ close(item)"
+ >
+
+
+ {{ item.title }}
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ {{ $t('modelType') }}
+
+
+
+
+
+
+
+
+
+ {{ extension }}
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ {{ $t(`info.${item.key}`) }}
+ |
+ {{ item.display }} |
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+ {{ model.basename }}
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+ {{ $t('tapToChange') }}
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ |
+ {{ item.key }}
+ |
+ {{ item.value }} |
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ {{ $t('uploadFile') }}
+
+
+
+
+
+
+
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, ' + ')');
- 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]*?(?:\\1>[^\\n]*\\n+|$)' // (1)
- + '|comment[^\\n]*(\\n+|$)' // (2)
- + '|<\\?[\\s\\S]*?(?:\\?>\\n*|$)' // (3)
- + '|\\n*|$)' // (4)
- + '|\\n*|$)' // (5)
- + '|?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (6)
- + '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (7) open tag
- + '|(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\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', '?(?:tag)(?: +|\\n|/?>)|<(?: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', '?(?:tag)(?: +|\\n|/?>)|<(?: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', '?(?:tag)(?: +|\\n|/?>)|<(?: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]+?\\1> *(?:\\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: /^ *\[([^\]]+)\]: *([^\s>]+)>?(?: +(["(][^\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'
- + '|^[a-zA-Z][\\w:-]*\\s*>' // 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 + '' + type + '>\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 + `${type}>\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 = '' + text + '';
- return out;
- }
- image({ href, title, text }) {
- const cleanHref = cleanUrl(href);
- if (cleanHref === null) {
- return text;
- }
- href = cleanHref;
- let out = `
';
- return out;
- }
- text(token) {
- return 'tokens' in token && token.tokens ? this.parser.parseInline(token.tokens) : token.text;
- }
-}
-
-/**
- * TextRenderer
- * returns only the textual part of the token
- */
-class _TextRenderer {
- // no need for block level renderers
- strong({ text }) {
- return text;
- }
- em({ text }) {
- return text;
- }
- codespan({ text }) {
- return text;
- }
- del({ text }) {
- return text;
- }
- html({ text }) {
- return text;
- }
- text({ text }) {
- return text;
- }
- link({ text }) {
- return '' + text;
- }
- image({ text }) {
- return '' + text;
- }
- br() {
- return '';
- }
-}
-
-/**
- * Parsing & Compiling
- */
-class _Parser {
- options;
- renderer;
- textRenderer;
- constructor(options) {
- this.options = options || _defaults;
- this.options.renderer = this.options.renderer || new _Renderer();
- this.renderer = this.options.renderer;
- this.renderer.options = this.options;
- this.renderer.parser = this;
- this.textRenderer = new _TextRenderer();
- }
- /**
- * Static Parse Method
- */
- static parse(tokens, options) {
- const parser = new _Parser(options);
- return parser.parse(tokens);
- }
- /**
- * Static Parse Inline Method
- */
- static parseInline(tokens, options) {
- const parser = new _Parser(options);
- return parser.parseInline(tokens);
- }
- /**
- * Parse Loop
- */
- parse(tokens, top = true) {
- let out = '';
- for (let i = 0; i < tokens.length; i++) {
- const anyToken = tokens[i];
- // Run any renderer extensions
- if (this.options.extensions && this.options.extensions.renderers && this.options.extensions.renderers[anyToken.type]) {
- const genericToken = anyToken;
- const ret = this.options.extensions.renderers[genericToken.type].call({ parser: this }, genericToken);
- if (ret !== false || !['space', 'hr', 'heading', 'code', 'table', 'blockquote', 'list', 'html', 'paragraph', 'text'].includes(genericToken.type)) {
- out += ret || '';
- continue;
- }
- }
- const token = anyToken;
- switch (token.type) {
- case 'space': {
- out += this.renderer.space(token);
- continue;
- }
- case 'hr': {
- out += this.renderer.hr(token);
- continue;
- }
- case 'heading': {
- out += this.renderer.heading(token);
- continue;
- }
- case 'code': {
- out += this.renderer.code(token);
- continue;
- }
- case 'table': {
- out += this.renderer.table(token);
- continue;
- }
- case 'blockquote': {
- out += this.renderer.blockquote(token);
- continue;
- }
- case 'list': {
- out += this.renderer.list(token);
- continue;
- }
- case 'html': {
- out += this.renderer.html(token);
- continue;
- }
- case 'paragraph': {
- out += this.renderer.paragraph(token);
- continue;
- }
- case 'text': {
- let textToken = token;
- let body = this.renderer.text(textToken);
- while (i + 1 < tokens.length && tokens[i + 1].type === 'text') {
- textToken = tokens[++i];
- body += '\n' + this.renderer.text(textToken);
- }
- if (top) {
- out += this.renderer.paragraph({
- type: 'paragraph',
- raw: body,
- text: body,
- tokens: [{ type: 'text', raw: body, text: body }],
- });
- }
- else {
- out += body;
- }
- continue;
- }
- default: {
- const errMsg = 'Token with "' + token.type + '" type was not found.';
- if (this.options.silent) {
- console.error(errMsg);
- return '';
- }
- else {
- throw new Error(errMsg);
- }
- }
- }
- }
- return out;
- }
- /**
- * Parse Inline Tokens
- */
- parseInline(tokens, renderer) {
- renderer = renderer || this.renderer;
- let out = '';
- for (let i = 0; i < tokens.length; i++) {
- const anyToken = tokens[i];
- // Run any renderer extensions
- if (this.options.extensions && this.options.extensions.renderers && this.options.extensions.renderers[anyToken.type]) {
- const ret = this.options.extensions.renderers[anyToken.type].call({ parser: this }, anyToken);
- if (ret !== false || !['escape', 'html', 'link', 'image', 'strong', 'em', 'codespan', 'br', 'del', 'text'].includes(anyToken.type)) {
- out += ret || '';
- continue;
- }
- }
- const token = anyToken;
- switch (token.type) {
- case 'escape': {
- out += renderer.text(token);
- break;
- }
- case 'html': {
- out += renderer.html(token);
- break;
- }
- case 'link': {
- out += renderer.link(token);
- break;
- }
- case 'image': {
- out += renderer.image(token);
- break;
- }
- case 'strong': {
- out += renderer.strong(token);
- break;
- }
- case 'em': {
- out += renderer.em(token);
- break;
- }
- case 'codespan': {
- out += renderer.codespan(token);
- break;
- }
- case 'br': {
- out += renderer.br(token);
- break;
- }
- case 'del': {
- out += renderer.del(token);
- break;
- }
- case 'text': {
- out += renderer.text(token);
- break;
- }
- default: {
- const errMsg = 'Token with "' + token.type + '" type was not found.';
- if (this.options.silent) {
- console.error(errMsg);
- return '';
- }
- else {
- throw new Error(errMsg);
- }
- }
- }
- }
- return out;
- }
-}
-
-class _Hooks {
- options;
- block;
- constructor(options) {
- this.options = options || _defaults;
- }
- static passThroughHooks = new Set([
- 'preprocess',
- 'postprocess',
- 'processAllTokens',
- ]);
- /**
- * Process markdown before marked
- */
- preprocess(markdown) {
- return markdown;
- }
- /**
- * Process HTML after marked is finished
- */
- postprocess(html) {
- return html;
- }
- /**
- * Process all tokens before walk tokens
- */
- processAllTokens(tokens) {
- return tokens;
- }
- /**
- * Provide function to tokenize markdown
- */
- provideLexer() {
- return this.block ? _Lexer.lex : _Lexer.lexInline;
- }
- /**
- * Provide function to parse tokens
- */
- provideParser() {
- return this.block ? _Parser.parse : _Parser.parseInline;
- }
-}
-
-class Marked {
- defaults = _getDefaults();
- options = this.setOptions;
- parse = this.parseMarkdown(true);
- parseInline = this.parseMarkdown(false);
- Parser = _Parser;
- Renderer = _Renderer;
- TextRenderer = _TextRenderer;
- Lexer = _Lexer;
- Tokenizer = _Tokenizer;
- Hooks = _Hooks;
- constructor(...args) {
- this.use(...args);
- }
- /**
- * Run callback for every token
- */
- walkTokens(tokens, callback) {
- let values = [];
- for (const token of tokens) {
- values = values.concat(callback.call(this, token));
- switch (token.type) {
- case 'table': {
- const tableToken = token;
- for (const cell of tableToken.header) {
- values = values.concat(this.walkTokens(cell.tokens, callback));
- }
- for (const row of tableToken.rows) {
- for (const cell of row) {
- values = values.concat(this.walkTokens(cell.tokens, callback));
- }
- }
- break;
- }
- case 'list': {
- const listToken = token;
- values = values.concat(this.walkTokens(listToken.items, callback));
- break;
- }
- default: {
- const genericToken = token;
- if (this.defaults.extensions?.childTokens?.[genericToken.type]) {
- this.defaults.extensions.childTokens[genericToken.type].forEach((childTokens) => {
- 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