16 Commits

Author SHA1 Message Date
Hayden
bd874e5ff3 prepare release 2.1.0
- feature scan model info
2024-11-21 22:07:40 +08:00
Hayden
6a64f3050a chore: update installation explanation (#54) 2024-11-21 22:05:56 +08:00
Hayden
659637c6e0 Feature scan info (#53)
* pref: migrate fetch model info to end back

* fix(download): can't select model type

* feat: add scan model info

* feat: add trigger button in setting

* feat: add printing logs

* chore: add explanation of scan model info
2024-11-21 22:04:39 +08:00
hayden
6ae7e1835f pref: add debug printer 2024-11-15 21:52:45 +08:00
hayden
4038e240f0 pref: optimize styles
Reduce the possibility of style pollution.
2024-11-11 14:21:52 +08:00
hayden
254ad8c597 pref: optimize parameter transmission 2024-11-11 12:08:01 +08:00
hayden
dfae915b77 pref: optimize print logging 2024-11-11 11:51:22 +08:00
hayden
f57ffc9e7a chore: add check action 2024-11-11 11:40:11 +08:00
hayden
6904aca24c chore: format code 2024-11-11 11:39:32 +08:00
Hayden
e36af38375 prepare release 2.0.3 2024-11-11 11:13:24 +08:00
Hayden
d4922f59d3 Merge pull request #50 from hayden-fr/feature-optimize-ui
Feature optimize UI
2024-11-11 11:11:30 +08:00
Hayden
f2e17744ae Merge pull request #47 from hayden-fr/feature-multi-user
feat: adapt to multi user
2024-11-11 11:11:09 +08:00
hayden
3b25d3e347 pref: optimize the timing of scrollbar reset 2024-11-08 12:42:00 +08:00
hayden
3a0676b29f pref(download): keep model content status 2024-11-08 11:49:18 +08:00
hayden
a1e5761dbc feat: adapt to multi user 2024-11-08 11:13:01 +08:00
hayden
ae518b541a chore(download): add todo notes 2024-11-07 09:42:37 +08:00
54 changed files with 1725 additions and 1118 deletions

39
.github/workflows/eslint.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: ESLint
on:
push:
paths:
- '**/*.vue'
- '**/*.ts'
- '**/*.tsx'
- '**/*.js'
pull_request:
paths:
- '**/*.vue'
- '**/*.ts'
- '**/*.tsx'
- '**/*.js'
jobs:
eslint:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- 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: Install dependencies
run: pnpm install
- name: Lint code
run: pnpm run lint

40
.github/workflows/format.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Prettier Check
on:
push:
paths:
- '**/*.vue'
- '**/*.ts'
- '**/*.tsx'
- '**/*.js'
pull_request:
paths:
- '**/*.vue'
- '**/*.ts'
- '**/*.tsx'
- '**/*.js'
jobs:
prettier:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- 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: Install dependencies
run: pnpm install
- name: Run Prettier check
run: pnpm exec prettier --check './**/*.{js,ts,tsx,vue}'

29
.github/workflows/pylint.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Python Linting
on:
push:
paths:
- '**/*.py'
pull_request:
paths:
- '**/*.py'
jobs:
pylint:
name: Run Pylint
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.x
- name: Install Pylint
run: pip install pylint
- name: Run Pylint
run: pylint --rcfile=.pylintrc $(find . -type f -name "*.py")

3
.pylintrc Normal file
View File

@@ -0,0 +1,3 @@
[MESSAGES CONTROL]
disable=all
enable=eval-used

View File

@@ -23,7 +23,6 @@
"inputgroup", "inputgroup",
"inputgroupaddon", "inputgroupaddon",
"iconfield", "iconfield",
"inputicon",
"inputtext", "inputtext",
"overlaybadge", "overlaybadge",
"usetoast", "usetoast",
@@ -45,4 +44,4 @@
"strings": "on" "strings": "on"
}, },
"css.lint.unknownAtRules": "ignore" "css.lint.unknownAtRules": "ignore"
} }

View File

@@ -4,15 +4,13 @@ Download, browse and delete models in ComfyUI.
Designed to support desktop, mobile and multi-screen devices. Designed to support desktop, mobile and multi-screen devices.
# Usage # Installation
```bash There are three installation methods, choose one
cd /path/to/ComfyUI/custom_nodes
git clone https://github.com/hayden-fr/ComfyUI-Model-Manager.git 1. Clone the repository: `git clone https://github.com/hayden-fr/ComfyUI-Model-Manager.git` to your ComfyUI `custom_nodes` folder
cd /path/to/ComfyUI/custom_nodes/ComfyUI-Model-Manager 2. Download the [latest release](https://github.com/hayden-fr/ComfyUI-Model-Manager/releases/latest/download/dist.tar.gz) and extract it to your ComfyUI `custom_nodes` folder
npm install 3. Use comfy cli: `comfy node registry-install comfyui-model-manager`
npm run build
```
## Features ## Features
@@ -61,3 +59,10 @@ npm run build
- Read, edit and save notes. (Saved as a `.md` file beside the model). - Read, edit and save notes. (Saved as a `.md` file beside the model).
- Change or remove a model's preview image. - 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.) - View training tags and use the random tag generator to generate prompt ideas. (Inspired by the one in A1111.)
### Scan Model Information
<img src="demo/scan-model-info.png" alt="Model Manager Demo Screenshot" style="max-width: 100%; max-height: 300px"/>
- Scan models and try to download information & preview.
- Support migration from `cdb-boop/ComfyUI-Model-Manager/main`

View File

@@ -3,31 +3,91 @@ import folder_paths
from .py import config from .py import config
from .py import utils from .py import utils
extension_uri = utils.normalize_path(os.path.dirname(__file__))
requirements_path = utils.join_path(extension_uri, "requirements.txt")
with open(requirements_path, "r", encoding="utf-8") as f:
requirements = f.readlines()
requirements = [x.strip() for x in requirements]
requirements = [x for x in requirements if not x.startswith("#")]
uninstalled_package = [p for p in requirements if not utils.is_installed(p)]
if len(uninstalled_package) > 0:
utils.print_info(f"Install dependencies...")
for p in uninstalled_package:
utils.pip_install(p)
# Init config settings # Init config settings
config.extension_uri = utils.normalize_path(os.path.dirname(__file__)) config.extension_uri = extension_uri
utils.resolve_model_base_paths() utils.resolve_model_base_paths()
version = utils.get_current_version() version = utils.get_current_version()
utils.download_web_distribution(version) utils.download_web_distribution(version)
import logging
from aiohttp import web from aiohttp import web
import traceback
from .py import services from .py import services
routes = config.routes routes = config.routes
@routes.get("/model-manager/ws") @routes.get("/model-manager/download/task")
async def socket_handler(request): async def scan_download_tasks(request):
""" """
Handle websocket connection. Read download task list.
""" """
ws = await services.connect_websocket(request) try:
return ws result = await services.scan_model_download_task_list()
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Read download task list failed: {e}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.put("/model-manager/download/{task_id}")
async def resume_download_task(request):
"""
Toggle download task status.
"""
try:
task_id = request.match_info.get("task_id", None)
if task_id is None:
raise web.HTTPBadRequest(reason="Invalid task id")
json_data = await request.json()
status = json_data.get("status", None)
if status == "pause":
await services.pause_model_download_task(task_id)
elif status == "resume":
await services.resume_model_download_task(task_id, request)
else:
raise web.HTTPBadRequest(reason="Invalid status")
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Resume download task failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.delete("/model-manager/download/{task_id}")
async def delete_model_download_task(request):
"""
Delete download task.
"""
task_id = request.match_info.get("task_id", None)
try:
await services.delete_model_download_task(task_id)
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Delete download task failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.get("/model-manager/base-folders") @routes.get("/model-manager/base-folders")
@@ -54,14 +114,13 @@ async def create_model(request):
- downloadUrl: download url. - downloadUrl: download url.
- hash: a JSON string containing the hash value of the downloaded model. - hash: a JSON string containing the hash value of the downloaded model.
""" """
post = await request.post() task_data = await request.json()
try: try:
task_id = await services.create_model_download_task(post) task_id = await services.create_model_download_task(task_data, request)
return web.json_response({"success": True, "data": {"taskId": task_id}}) return web.json_response({"success": True, "data": {"taskId": task_id}})
except Exception as e: except Exception as e:
error_msg = f"Create model download task failed: {str(e)}" error_msg = f"Create model download task failed: {str(e)}"
logging.error(error_msg) utils.print_error(error_msg)
logging.debug(traceback.format_exc())
return web.json_response({"success": False, "error": error_msg}) return web.json_response({"success": False, "error": error_msg})
@@ -75,8 +134,7 @@ async def read_models(request):
return web.json_response({"success": True, "data": result}) return web.json_response({"success": True, "data": result})
except Exception as e: except Exception as e:
error_msg = f"Read models failed: {str(e)}" error_msg = f"Read models failed: {str(e)}"
logging.error(error_msg) utils.print_error(error_msg)
logging.debug(traceback.format_exc())
return web.json_response({"success": False, "error": error_msg}) return web.json_response({"success": False, "error": error_msg})
@@ -95,8 +153,7 @@ async def read_model_info(request):
return web.json_response({"success": True, "data": result}) return web.json_response({"success": True, "data": result})
except Exception as e: except Exception as e:
error_msg = f"Read model info failed: {str(e)}" error_msg = f"Read model info failed: {str(e)}"
logging.error(error_msg) utils.print_error(error_msg)
logging.debug(traceback.format_exc())
return web.json_response({"success": False, "error": error_msg}) return web.json_response({"success": False, "error": error_msg})
@@ -117,18 +174,17 @@ async def update_model(request):
index = int(request.match_info.get("index", None)) index = int(request.match_info.get("index", None))
filename = request.match_info.get("filename", None) filename = request.match_info.get("filename", None)
post: dict = await request.post() model_data: dict = await request.json()
try: try:
model_path = utils.get_valid_full_path(model_type, index, filename) model_path = utils.get_valid_full_path(model_type, index, filename)
if model_path is None: if model_path is None:
raise RuntimeError(f"File {filename} not found") raise RuntimeError(f"File {filename} not found")
services.update_model(model_path, post) services.update_model(model_path, model_data)
return web.json_response({"success": True}) return web.json_response({"success": True})
except Exception as e: except Exception as e:
error_msg = f"Update model failed: {str(e)}" error_msg = f"Update model failed: {str(e)}"
logging.error(error_msg) utils.print_error(error_msg)
logging.debug(traceback.format_exc())
return web.json_response({"success": False, "error": error_msg}) return web.json_response({"success": False, "error": error_msg})
@@ -149,8 +205,38 @@ async def delete_model(request):
return web.json_response({"success": True}) return web.json_response({"success": True})
except Exception as e: except Exception as e:
error_msg = f"Delete model failed: {str(e)}" error_msg = f"Delete model failed: {str(e)}"
logging.error(error_msg) utils.print_error(error_msg)
logging.debug(traceback.format_exc()) return web.json_response({"success": False, "error": error_msg})
@routes.get("/model-manager/model-info")
async def fetch_model_info(request):
"""
Fetch model information from network with model page.
"""
try:
model_page = request.query.get("model-page", None)
result = services.fetch_model_info(model_page)
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Fetch model info failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.post("/model-manager/model-info/scan")
async def download_model_info(request):
"""
Create a task to download model information.
"""
post = await utils.get_request_body(request)
try:
scan_mode = post.get("scanMode", "diff")
await services.download_model_info(scan_mode)
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Download model info failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg}) return web.json_response({"success": False, "error": error_msg})
@@ -196,6 +282,20 @@ async def read_download_preview(request):
return web.FileResponse(preview_path) return web.FileResponse(preview_path)
@routes.post("/model-manager/migrate")
async def migrate_legacy_information(request):
"""
Migrate legacy information.
"""
try:
await services.migrate_legacy_information()
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Download model info failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
WEB_DIRECTORY = "web" WEB_DIRECTORY = "web"
NODE_CLASS_MAPPINGS = {} NODE_CLASS_MAPPINGS = {}
__all__ = ["WEB_DIRECTORY", "NODE_CLASS_MAPPINGS"] __all__ = ["WEB_DIRECTORY", "NODE_CLASS_MAPPINGS"]

BIN
demo/scan-model-info.png Executable file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -1,18 +1,15 @@
import globals from 'globals'
import pluginJs from '@eslint/js' import pluginJs from '@eslint/js'
import tsEslint from 'typescript-eslint'
import pluginVue from 'eslint-plugin-vue' import pluginVue from 'eslint-plugin-vue'
import globals from 'globals'
import tsEslint from 'typescript-eslint'
/** @type {import('eslint').Linter.Config[]} */
export default [ export default [
{ {
files: ['src/**/*.{js,mjs,cjs,ts,vue}'], files: ['src/**/*.{js,mjs,cjs,ts,vue}'],
}, },
{ {
ignores: [ ignores: ['src/scripts/*', 'src/types/shims.d.ts', 'src/utils/legacy.ts'],
'src/scripts/*',
'src/extensions/core/*',
'src/types/vue-shim.d.ts',
],
}, },
{ languageOptions: { globals: globals.browser } }, { languageOptions: { globals: globals.browser } },
pluginJs.configs.recommended, pluginJs.configs.recommended,
@@ -25,8 +22,6 @@ export default [
{ {
rules: { rules: {
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-as-const': 'off',
}, },
}, },
] ]

View File

@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint src",
"prepare": "husky" "prepare": "husky"
}, },
"devDependencies": { "devDependencies": {
@@ -13,11 +14,11 @@
"@types/lodash": "^4.17.9", "@types/lodash": "^4.17.9",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/node": "^22.5.5", "@types/node": "^22.5.5",
"@types/turndown": "^5.0.5",
"@vitejs/plugin-vue": "^5.1.4", "@vitejs/plugin-vue": "^5.1.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.10.0", "eslint": "^9.10.0",
"eslint-plugin-vue": "^9.28.0", "eslint-plugin-vue": "^9.28.0",
"globals": "^15.12.0",
"husky": "^9.1.6", "husky": "^9.1.6",
"less": "^4.2.0", "less": "^4.2.0",
"lint-staged": "^15.2.10", "lint-staged": "^15.2.10",
@@ -27,8 +28,9 @@
"prettier-plugin-tailwindcss": "^0.6.8", "prettier-plugin-tailwindcss": "^0.6.8",
"tailwindcss": "^3.4.12", "tailwindcss": "^3.4.12",
"typescript": "^5.6.2", "typescript": "^5.6.2",
"typescript-eslint": "^8.6.0", "typescript-eslint": "^8.13.0",
"vite": "^5.4.6" "vite": "^5.4.6",
"vue-tsc": "^2.1.10"
}, },
"dependencies": { "dependencies": {
"@primevue/themes": "^4.0.7", "@primevue/themes": "^4.0.7",
@@ -37,15 +39,13 @@
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"markdown-it-metadata-block": "^1.0.6", "markdown-it-metadata-block": "^1.0.6",
"primevue": "^4.0.7", "primevue": "^4.0.7",
"turndown": "^7.2.0",
"vue": "^3.4.31", "vue": "^3.4.31",
"vue-i18n": "^9.13.1", "vue-i18n": "^9.13.1",
"yaml": "^2.6.0" "yaml": "^2.6.0"
}, },
"lint-staged": { "lint-staged": {
"./**/*.{js,ts,tsx,vue}": [ "./**/*.{js,ts,tsx,vue}": [
"prettier --write", "prettier --write"
"git add"
] ]
} }
} }

276
pnpm-lock.yaml generated
View File

@@ -26,9 +26,6 @@ importers:
primevue: primevue:
specifier: ^4.0.7 specifier: ^4.0.7
version: 4.0.7(vue@3.5.6(typescript@5.6.2)) version: 4.0.7(vue@3.5.6(typescript@5.6.2))
turndown:
specifier: ^7.2.0
version: 7.2.0
vue: vue:
specifier: ^3.4.31 specifier: ^3.4.31
version: 3.5.6(typescript@5.6.2) version: 3.5.6(typescript@5.6.2)
@@ -51,9 +48,6 @@ importers:
'@types/node': '@types/node':
specifier: ^22.5.5 specifier: ^22.5.5
version: 22.5.5 version: 22.5.5
'@types/turndown':
specifier: ^5.0.5
version: 5.0.5
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^5.1.4 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)) 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))
@@ -66,6 +60,9 @@ importers:
eslint-plugin-vue: eslint-plugin-vue:
specifier: ^9.28.0 specifier: ^9.28.0
version: 9.28.0(eslint@9.10.0(jiti@1.21.6)) version: 9.28.0(eslint@9.10.0(jiti@1.21.6))
globals:
specifier: ^15.12.0
version: 15.12.0
husky: husky:
specifier: ^9.1.6 specifier: ^9.1.6
version: 9.1.6 version: 9.1.6
@@ -83,10 +80,10 @@ importers:
version: 3.3.3 version: 3.3.3
prettier-plugin-organize-imports: prettier-plugin-organize-imports:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0(prettier@3.3.3)(typescript@5.6.2) version: 4.1.0(prettier@3.3.3)(typescript@5.6.2)(vue-tsc@2.1.10(typescript@5.6.2))
prettier-plugin-tailwindcss: prettier-plugin-tailwindcss:
specifier: ^0.6.8 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) version: 0.6.8(prettier-plugin-organize-imports@4.1.0(prettier@3.3.3)(typescript@5.6.2)(vue-tsc@2.1.10(typescript@5.6.2)))(prettier@3.3.3)
tailwindcss: tailwindcss:
specifier: ^3.4.12 specifier: ^3.4.12
version: 3.4.12 version: 3.4.12
@@ -94,11 +91,14 @@ importers:
specifier: ^5.6.2 specifier: ^5.6.2
version: 5.6.2 version: 5.6.2
typescript-eslint: typescript-eslint:
specifier: ^8.6.0 specifier: ^8.13.0
version: 8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) version: 8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)
vite: vite:
specifier: ^5.4.6 specifier: ^5.4.6
version: 5.4.6(@types/node@22.5.5)(less@4.2.0) version: 5.4.6(@types/node@22.5.5)(less@4.2.0)
vue-tsc:
specifier: ^2.1.10
version: 2.1.10(typescript@5.6.2)
packages: packages:
@@ -267,10 +267,20 @@ packages:
peerDependencies: peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/eslint-utils@4.4.1':
resolution: {integrity: sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==}
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': '@eslint-community/regexpp@4.11.1':
resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint-community/regexpp@4.12.1':
resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint/config-array@0.18.0': '@eslint/config-array@0.18.0':
resolution: {integrity: sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==} resolution: {integrity: sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -333,9 +343,6 @@ packages:
'@jridgewell/trace-mapping@0.3.25': '@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} 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': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -486,11 +493,8 @@ packages:
'@types/node@22.5.5': '@types/node@22.5.5':
resolution: {integrity: sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==} resolution: {integrity: sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==}
'@types/turndown@5.0.5': '@typescript-eslint/eslint-plugin@8.13.0':
resolution: {integrity: sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==} resolution: {integrity: sha512-nQtBLiZYMUPkclSeC3id+x4uVd1SGtHuElTxL++SfP47jR0zfkZBJHc+gL4qPsgTuypz0k8Y2GheaDYn6Gy3rg==}
'@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} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
'@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0
@@ -500,8 +504,8 @@ packages:
typescript: typescript:
optional: true optional: true
'@typescript-eslint/parser@8.6.0': '@typescript-eslint/parser@8.13.0':
resolution: {integrity: sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==} resolution: {integrity: sha512-w0xp+xGg8u/nONcGw1UXAr6cjCPU1w0XVyBs6Zqaj5eLmxkKQAByTdV/uGgNN5tVvN/kKpoQlP2cL7R+ajZZIQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
@@ -510,12 +514,12 @@ packages:
typescript: typescript:
optional: true optional: true
'@typescript-eslint/scope-manager@8.6.0': '@typescript-eslint/scope-manager@8.13.0':
resolution: {integrity: sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==} resolution: {integrity: sha512-XsGWww0odcUT0gJoBZ1DeulY1+jkaHUciUq4jKNv4cpInbvvrtDoyBH9rE/n2V29wQJPk8iCH1wipra9BhmiMA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/type-utils@8.6.0': '@typescript-eslint/type-utils@8.13.0':
resolution: {integrity: sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==} resolution: {integrity: sha512-Rqnn6xXTR316fP4D2pohZenJnp+NwQ1mo7/JM+J1LWZENSLkJI8ID8QNtlvFeb0HnFSK94D6q0cnMX6SbE5/vA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '*' typescript: '*'
@@ -523,12 +527,12 @@ packages:
typescript: typescript:
optional: true optional: true
'@typescript-eslint/types@8.6.0': '@typescript-eslint/types@8.13.0':
resolution: {integrity: sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==} resolution: {integrity: sha512-4cyFErJetFLckcThRUFdReWJjVsPCqyBlJTi6IDEpc1GWCIIZRFxVppjWLIMcQhNGhdWJJRYFHpHoDWvMlDzng==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.6.0': '@typescript-eslint/typescript-estree@8.13.0':
resolution: {integrity: sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==} resolution: {integrity: sha512-v7SCIGmVsRK2Cy/LTLGN22uea6SaUIlpBcO/gnMGT/7zPtxp90bphcGf4fyrCQl3ZtiBKqVTG32hb668oIYy1g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '*' typescript: '*'
@@ -536,14 +540,14 @@ packages:
typescript: typescript:
optional: true optional: true
'@typescript-eslint/utils@8.6.0': '@typescript-eslint/utils@8.13.0':
resolution: {integrity: sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==} resolution: {integrity: sha512-A1EeYOND6Uv250nybnLZapeXpYMl8tkzYUxqmoKAWnI4sei3ihf2XdZVd+vVOmHGcp3t+P7yRrNsyyiXTvShFQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
'@typescript-eslint/visitor-keys@8.6.0': '@typescript-eslint/visitor-keys@8.13.0':
resolution: {integrity: sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==} resolution: {integrity: sha512-7N/+lztJqH4Mrf0lb10R/CbI1EaAMMGyF5y0oJvFoAhafwgiRA7TXyd8TFn8FC8k5y2dTsYogg238qavRGNnlw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitejs/plugin-vue@5.1.4': '@vitejs/plugin-vue@5.1.4':
@@ -553,6 +557,15 @@ packages:
vite: ^5.0.0 vite: ^5.0.0
vue: ^3.2.25 vue: ^3.2.25
'@volar/language-core@2.4.10':
resolution: {integrity: sha512-hG3Z13+nJmGaT+fnQzAkS0hjJRa2FCeqZt6Bd+oGNhUkQ+mTFsDETg5rqUTxyzIh5pSOGY7FHCWUS8G82AzLCA==}
'@volar/source-map@2.4.10':
resolution: {integrity: sha512-OCV+b5ihV0RF3A7vEvNyHPi4G4kFa6ukPmyVocmqm5QzOd8r5yAtiNvaPEjl8dNvgC/lj4JPryeeHLdXd62rWA==}
'@volar/typescript@2.4.10':
resolution: {integrity: sha512-F8ZtBMhSXyYKuBfGpYwqA5rsONnOwAVvjyE7KPYJ7wgZqo2roASqNWUnianOomJX5u1cxeRooHV59N0PhvEOgw==}
'@vue/compiler-core@3.5.6': '@vue/compiler-core@3.5.6':
resolution: {integrity: sha512-r+gNu6K4lrvaQLQGmf+1gc41p3FO2OUJyWmNqaIITaJU6YFiV5PtQSFZt8jfztYyARwqhoCayjprC7KMvT3nRA==} resolution: {integrity: sha512-r+gNu6K4lrvaQLQGmf+1gc41p3FO2OUJyWmNqaIITaJU6YFiV5PtQSFZt8jfztYyARwqhoCayjprC7KMvT3nRA==}
@@ -565,9 +578,20 @@ packages:
'@vue/compiler-ssr@3.5.6': '@vue/compiler-ssr@3.5.6':
resolution: {integrity: sha512-VpWbaZrEOCqnmqjE83xdwegtr5qO/2OPUC6veWgvNqTJ3bYysz6vY3VqMuOijubuUYPRpG3OOKIh9TD0Stxb9A==} resolution: {integrity: sha512-VpWbaZrEOCqnmqjE83xdwegtr5qO/2OPUC6veWgvNqTJ3bYysz6vY3VqMuOijubuUYPRpG3OOKIh9TD0Stxb9A==}
'@vue/compiler-vue2@2.7.16':
resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
'@vue/devtools-api@6.6.4': '@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/language-core@2.1.10':
resolution: {integrity: sha512-DAI289d0K3AB5TUG3xDp9OuQ71CnrujQwJrQnfuZDwo6eGNf0UoRlPuaVNO+Zrn65PC3j0oB2i7mNmVPggeGeQ==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
'@vue/reactivity@3.5.6': '@vue/reactivity@3.5.6':
resolution: {integrity: sha512-shZ+KtBoHna5GyUxWfoFVBCVd7k56m6lGhk5e+J9AKjheHF6yob5eukssHRI+rzvHBiU1sWs/1ZhNbLExc5oYQ==} resolution: {integrity: sha512-shZ+KtBoHna5GyUxWfoFVBCVd7k56m6lGhk5e+J9AKjheHF6yob5eukssHRI+rzvHBiU1sWs/1ZhNbLExc5oYQ==}
@@ -598,6 +622,9 @@ packages:
ajv@6.12.6: ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
alien-signals@0.2.1:
resolution: {integrity: sha512-FlEQrDJe9r2RI4cDlnK2zYqJezvx1uJaWEuwxsnlFqnPwvJbgitNBRumWrLDv8lA+7cCikpMxfJD2TTHiaTklA==}
ansi-escapes@7.0.0: ansi-escapes@7.0.0:
resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -733,6 +760,9 @@ packages:
dayjs@1.11.13: dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
de-indent@1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
debug@4.3.7: debug@4.3.7:
resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -935,6 +965,10 @@ packages:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
globals@15.12.0:
resolution: {integrity: sha512-1+gLErljJFhbOVyaetcwJiJ4+eLe45S2E7P5UiZ9xGfeq3ATQf5DOv9G7MH3gGbKQLkzmNh2DxfZwLdw+j6oTQ==}
engines: {node: '>=18'}
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -949,6 +983,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
human-signals@5.0.0: human-signals@5.0.0:
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
engines: {node: '>=16.17.0'} engines: {node: '>=16.17.0'}
@@ -1152,6 +1190,9 @@ packages:
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
mz@2.7.0: mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
@@ -1225,6 +1266,9 @@ packages:
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
path-exists@4.0.0: path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1546,8 +1590,8 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
ts-api-utils@1.3.0: ts-api-utils@1.4.0:
resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} resolution: {integrity: sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
peerDependencies: peerDependencies:
typescript: '>=4.2.0' typescript: '>=4.2.0'
@@ -1558,9 +1602,6 @@ packages:
tslib@2.7.0: tslib@2.7.0:
resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==}
turndown@7.2.0:
resolution: {integrity: sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==}
type-check@0.4.0: type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -1569,8 +1610,8 @@ packages:
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
typescript-eslint@8.6.0: typescript-eslint@8.13.0:
resolution: {integrity: sha512-eEhhlxCEpCd4helh3AO1hk0UP2MvbRi9CtIAJTVPQjuSXOOO2jsEacNi4UdcJzZJbeuVg1gMhtZ8UYb+NFYPrA==} resolution: {integrity: sha512-vIMpDRJrQd70au2G8w34mPps0ezFSPMEX4pXkTzUkrNbRX+36ais2ksGWN0esZL+ZMaFJEneOBHzCgSqle7DHw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '*' typescript: '*'
@@ -1632,6 +1673,9 @@ packages:
terser: terser:
optional: true optional: true
vscode-uri@3.0.8:
resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==}
vue-eslint-parser@9.4.3: vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==} resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
engines: {node: ^14.17.0 || >=16.0.0} engines: {node: ^14.17.0 || >=16.0.0}
@@ -1644,6 +1688,12 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.0.0 vue: ^3.0.0
vue-tsc@2.1.10:
resolution: {integrity: sha512-RBNSfaaRHcN5uqVqJSZh++Gy/YUzryuv9u1aFWhsammDJXNtUiJMNoJ747lZcQ68wUQFx6E73y4FY3D8E7FGMA==}
hasBin: true
peerDependencies:
typescript: '>=5.0.0'
vue@3.5.6: vue@3.5.6:
resolution: {integrity: sha512-zv+20E2VIYbcJOzJPUWp03NOGFhMmpCKOfSxVTmCYyYFFko48H9tmuQFzYj7tu4qX1AeXlp9DmhIP89/sSxxhw==} resolution: {integrity: sha512-zv+20E2VIYbcJOzJPUWp03NOGFhMmpCKOfSxVTmCYyYFFko48H9tmuQFzYj7tu4qX1AeXlp9DmhIP89/sSxxhw==}
peerDependencies: peerDependencies:
@@ -1783,8 +1833,15 @@ snapshots:
eslint: 9.10.0(jiti@1.21.6) eslint: 9.10.0(jiti@1.21.6)
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.4.1(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-community/regexpp@4.11.1': {}
'@eslint-community/regexpp@4.12.1': {}
'@eslint/config-array@0.18.0': '@eslint/config-array@0.18.0':
dependencies: dependencies:
'@eslint/object-schema': 2.1.4 '@eslint/object-schema': 2.1.4
@@ -1857,8 +1914,6 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0
'@mixmark-io/domino@2.2.0': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -1966,32 +2021,30 @@ snapshots:
dependencies: dependencies:
undici-types: 6.19.8 undici-types: 6.19.8
'@types/turndown@5.0.5': {} '@typescript-eslint/eslint-plugin@8.13.0(@typescript-eslint/parser@8.13.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/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: dependencies:
'@eslint-community/regexpp': 4.11.1 '@eslint-community/regexpp': 4.12.1
'@typescript-eslint/parser': 8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) '@typescript-eslint/parser': 8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)
'@typescript-eslint/scope-manager': 8.6.0 '@typescript-eslint/scope-manager': 8.13.0
'@typescript-eslint/type-utils': 8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) '@typescript-eslint/type-utils': 8.13.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/utils': 8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)
'@typescript-eslint/visitor-keys': 8.6.0 '@typescript-eslint/visitor-keys': 8.13.0
eslint: 9.10.0(jiti@1.21.6) eslint: 9.10.0(jiti@1.21.6)
graphemer: 1.4.0 graphemer: 1.4.0
ignore: 5.3.2 ignore: 5.3.2
natural-compare: 1.4.0 natural-compare: 1.4.0
ts-api-utils: 1.3.0(typescript@5.6.2) ts-api-utils: 1.4.0(typescript@5.6.2)
optionalDependencies: optionalDependencies:
typescript: 5.6.2 typescript: 5.6.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/parser@8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)': '@typescript-eslint/parser@8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.6.0 '@typescript-eslint/scope-manager': 8.13.0
'@typescript-eslint/types': 8.6.0 '@typescript-eslint/types': 8.13.0
'@typescript-eslint/typescript-estree': 8.6.0(typescript@5.6.2) '@typescript-eslint/typescript-estree': 8.13.0(typescript@5.6.2)
'@typescript-eslint/visitor-keys': 8.6.0 '@typescript-eslint/visitor-keys': 8.13.0
debug: 4.3.7 debug: 4.3.7
eslint: 9.10.0(jiti@1.21.6) eslint: 9.10.0(jiti@1.21.6)
optionalDependencies: optionalDependencies:
@@ -1999,54 +2052,54 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/scope-manager@8.6.0': '@typescript-eslint/scope-manager@8.13.0':
dependencies: dependencies:
'@typescript-eslint/types': 8.6.0 '@typescript-eslint/types': 8.13.0
'@typescript-eslint/visitor-keys': 8.6.0 '@typescript-eslint/visitor-keys': 8.13.0
'@typescript-eslint/type-utils@8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)': '@typescript-eslint/type-utils@8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)':
dependencies: dependencies:
'@typescript-eslint/typescript-estree': 8.6.0(typescript@5.6.2) '@typescript-eslint/typescript-estree': 8.13.0(typescript@5.6.2)
'@typescript-eslint/utils': 8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) '@typescript-eslint/utils': 8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)
debug: 4.3.7 debug: 4.3.7
ts-api-utils: 1.3.0(typescript@5.6.2) ts-api-utils: 1.4.0(typescript@5.6.2)
optionalDependencies: optionalDependencies:
typescript: 5.6.2 typescript: 5.6.2
transitivePeerDependencies: transitivePeerDependencies:
- eslint - eslint
- supports-color - supports-color
'@typescript-eslint/types@8.6.0': {} '@typescript-eslint/types@8.13.0': {}
'@typescript-eslint/typescript-estree@8.6.0(typescript@5.6.2)': '@typescript-eslint/typescript-estree@8.13.0(typescript@5.6.2)':
dependencies: dependencies:
'@typescript-eslint/types': 8.6.0 '@typescript-eslint/types': 8.13.0
'@typescript-eslint/visitor-keys': 8.6.0 '@typescript-eslint/visitor-keys': 8.13.0
debug: 4.3.7 debug: 4.3.7
fast-glob: 3.3.2 fast-glob: 3.3.2
is-glob: 4.0.3 is-glob: 4.0.3
minimatch: 9.0.5 minimatch: 9.0.5
semver: 7.6.3 semver: 7.6.3
ts-api-utils: 1.3.0(typescript@5.6.2) ts-api-utils: 1.4.0(typescript@5.6.2)
optionalDependencies: optionalDependencies:
typescript: 5.6.2 typescript: 5.6.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)': '@typescript-eslint/utils@8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.4.0(eslint@9.10.0(jiti@1.21.6)) '@eslint-community/eslint-utils': 4.4.1(eslint@9.10.0(jiti@1.21.6))
'@typescript-eslint/scope-manager': 8.6.0 '@typescript-eslint/scope-manager': 8.13.0
'@typescript-eslint/types': 8.6.0 '@typescript-eslint/types': 8.13.0
'@typescript-eslint/typescript-estree': 8.6.0(typescript@5.6.2) '@typescript-eslint/typescript-estree': 8.13.0(typescript@5.6.2)
eslint: 9.10.0(jiti@1.21.6) eslint: 9.10.0(jiti@1.21.6)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
- typescript - typescript
'@typescript-eslint/visitor-keys@8.6.0': '@typescript-eslint/visitor-keys@8.13.0':
dependencies: dependencies:
'@typescript-eslint/types': 8.6.0 '@typescript-eslint/types': 8.13.0
eslint-visitor-keys: 3.4.3 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))': '@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))':
@@ -2054,6 +2107,18 @@ snapshots:
vite: 5.4.6(@types/node@22.5.5)(less@4.2.0) vite: 5.4.6(@types/node@22.5.5)(less@4.2.0)
vue: 3.5.6(typescript@5.6.2) vue: 3.5.6(typescript@5.6.2)
'@volar/language-core@2.4.10':
dependencies:
'@volar/source-map': 2.4.10
'@volar/source-map@2.4.10': {}
'@volar/typescript@2.4.10':
dependencies:
'@volar/language-core': 2.4.10
path-browserify: 1.0.1
vscode-uri: 3.0.8
'@vue/compiler-core@3.5.6': '@vue/compiler-core@3.5.6':
dependencies: dependencies:
'@babel/parser': 7.25.6 '@babel/parser': 7.25.6
@@ -2084,8 +2149,26 @@ snapshots:
'@vue/compiler-dom': 3.5.6 '@vue/compiler-dom': 3.5.6
'@vue/shared': 3.5.6 '@vue/shared': 3.5.6
'@vue/compiler-vue2@2.7.16':
dependencies:
de-indent: 1.0.2
he: 1.2.0
'@vue/devtools-api@6.6.4': {} '@vue/devtools-api@6.6.4': {}
'@vue/language-core@2.1.10(typescript@5.6.2)':
dependencies:
'@volar/language-core': 2.4.10
'@vue/compiler-dom': 3.5.6
'@vue/compiler-vue2': 2.7.16
'@vue/shared': 3.5.6
alien-signals: 0.2.1
minimatch: 9.0.5
muggle-string: 0.4.1
path-browserify: 1.0.1
optionalDependencies:
typescript: 5.6.2
'@vue/reactivity@3.5.6': '@vue/reactivity@3.5.6':
dependencies: dependencies:
'@vue/shared': 3.5.6 '@vue/shared': 3.5.6
@@ -2123,6 +2206,8 @@ snapshots:
json-schema-traverse: 0.4.1 json-schema-traverse: 0.4.1
uri-js: 4.4.1 uri-js: 4.4.1
alien-signals@0.2.1: {}
ansi-escapes@7.0.0: ansi-escapes@7.0.0:
dependencies: dependencies:
environment: 1.1.0 environment: 1.1.0
@@ -2248,6 +2333,8 @@ snapshots:
dayjs@1.11.13: {} dayjs@1.11.13: {}
de-indent@1.0.2: {}
debug@4.3.7: debug@4.3.7:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@@ -2493,6 +2580,8 @@ snapshots:
globals@14.0.0: {} globals@14.0.0: {}
globals@15.12.0: {}
graceful-fs@4.2.11: graceful-fs@4.2.11:
optional: true optional: true
@@ -2504,6 +2593,8 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
he@1.2.0: {}
human-signals@5.0.0: {} human-signals@5.0.0: {}
husky@9.1.6: {} husky@9.1.6: {}
@@ -2701,6 +2792,8 @@ snapshots:
ms@2.1.3: {} ms@2.1.3: {}
muggle-string@0.4.1: {}
mz@2.7.0: mz@2.7.0:
dependencies: dependencies:
any-promise: 1.3.0 any-promise: 1.3.0
@@ -2768,6 +2861,8 @@ snapshots:
parse-node-version@1.0.1: {} parse-node-version@1.0.1: {}
path-browserify@1.0.1: {}
path-exists@4.0.0: {} path-exists@4.0.0: {}
path-key@3.1.1: {} path-key@3.1.1: {}
@@ -2833,16 +2928,18 @@ snapshots:
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier-plugin-organize-imports@4.1.0(prettier@3.3.3)(typescript@5.6.2): prettier-plugin-organize-imports@4.1.0(prettier@3.3.3)(typescript@5.6.2)(vue-tsc@2.1.10(typescript@5.6.2)):
dependencies: dependencies:
prettier: 3.3.3 prettier: 3.3.3
typescript: 5.6.2 typescript: 5.6.2
optionalDependencies:
vue-tsc: 2.1.10(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): prettier-plugin-tailwindcss@0.6.8(prettier-plugin-organize-imports@4.1.0(prettier@3.3.3)(typescript@5.6.2)(vue-tsc@2.1.10(typescript@5.6.2)))(prettier@3.3.3):
dependencies: dependencies:
prettier: 3.3.3 prettier: 3.3.3
optionalDependencies: optionalDependencies:
prettier-plugin-organize-imports: 4.1.0(prettier@3.3.3)(typescript@5.6.2) prettier-plugin-organize-imports: 4.1.0(prettier@3.3.3)(typescript@5.6.2)(vue-tsc@2.1.10(typescript@5.6.2))
prettier@3.3.3: {} prettier@3.3.3: {}
@@ -3040,7 +3137,7 @@ snapshots:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0
ts-api-utils@1.3.0(typescript@5.6.2): ts-api-utils@1.4.0(typescript@5.6.2):
dependencies: dependencies:
typescript: 5.6.2 typescript: 5.6.2
@@ -3048,21 +3145,17 @@ snapshots:
tslib@2.7.0: {} tslib@2.7.0: {}
turndown@7.2.0:
dependencies:
'@mixmark-io/domino': 2.2.0
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-fest@0.20.2: {} type-fest@0.20.2: {}
typescript-eslint@8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2): typescript-eslint@8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2):
dependencies: 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/eslint-plugin': 8.13.0(@typescript-eslint/parser@8.13.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/parser': 8.13.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/utils': 8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)
optionalDependencies: optionalDependencies:
typescript: 5.6.2 typescript: 5.6.2
transitivePeerDependencies: transitivePeerDependencies:
@@ -3097,6 +3190,8 @@ snapshots:
fsevents: 2.3.3 fsevents: 2.3.3
less: 4.2.0 less: 4.2.0
vscode-uri@3.0.8: {}
vue-eslint-parser@9.4.3(eslint@9.10.0(jiti@1.21.6)): vue-eslint-parser@9.4.3(eslint@9.10.0(jiti@1.21.6)):
dependencies: dependencies:
debug: 4.3.7 debug: 4.3.7
@@ -3117,6 +3212,13 @@ snapshots:
'@vue/devtools-api': 6.6.4 '@vue/devtools-api': 6.6.4
vue: 3.5.6(typescript@5.6.2) vue: 3.5.6(typescript@5.6.2)
vue-tsc@2.1.10(typescript@5.6.2):
dependencies:
'@volar/typescript': 2.4.10
'@vue/language-core': 2.1.10(typescript@5.6.2)
semver: 7.6.3
typescript: 5.6.2
vue@3.5.6(typescript@5.6.2): vue@3.5.6(typescript@5.6.2):
dependencies: dependencies:
'@vue/compiler-dom': 3.5.6 '@vue/compiler-dom': 3.5.6

View File

@@ -1,3 +1,5 @@
extension_tag = "ComfyUI Model Manager"
extension_uri: str = None extension_uri: str = None
model_base_paths: dict[str, list[str]] = {} model_base_paths: dict[str, list[str]] = {}
@@ -19,15 +21,3 @@ from server import PromptServer
serverInstance = PromptServer.instance serverInstance = PromptServer.instance
routes = serverInstance.routes 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)

View File

@@ -1,15 +1,12 @@
import os import os
import uuid import uuid
import time import time
import logging
import requests import requests
import folder_paths import folder_paths
import traceback
from typing import Callable, Awaitable, Any, Literal, Union, Optional from typing import Callable, Awaitable, Any, Literal, Union, Optional
from dataclasses import dataclass from dataclasses import dataclass
from . import config from . import config
from . import utils from . import utils
from . import socket
from . import thread from . import thread
@@ -27,6 +24,34 @@ class TaskStatus:
bps: float = 0 bps: float = 0
error: Optional[str] = None error: Optional[str] = None
def __init__(self, **kwargs):
self.taskId = kwargs.get("taskId", None)
self.type = kwargs.get("type", None)
self.fullname = kwargs.get("fullname", None)
self.preview = kwargs.get("preview", None)
self.status = kwargs.get("status", "pause")
self.platform = kwargs.get("platform", None)
self.downloadedSize = kwargs.get("downloadedSize", 0)
self.totalSize = kwargs.get("totalSize", 0)
self.progress = kwargs.get("progress", 0)
self.bps = kwargs.get("bps", 0)
self.error = kwargs.get("error", None)
def to_dict(self):
return {
"taskId": self.taskId,
"type": self.type,
"fullname": self.fullname,
"preview": self.preview,
"status": self.status,
"platform": self.platform,
"downloadedSize": self.downloadedSize,
"totalSize": self.totalSize,
"progress": self.progress,
"bps": self.bps,
"error": self.error,
}
@dataclass @dataclass
class TaskContent: class TaskContent:
@@ -36,9 +61,31 @@ class TaskContent:
description: str description: str
downloadPlatform: str downloadPlatform: str
downloadUrl: str downloadUrl: str
sizeBytes: float sizeBytes: int
hashes: Optional[dict[str, str]] = None hashes: Optional[dict[str, str]] = None
def __init__(self, **kwargs):
self.type = kwargs.get("type", None)
self.pathIndex = int(kwargs.get("pathIndex", 0))
self.fullname = kwargs.get("fullname", None)
self.description = kwargs.get("description", None)
self.downloadPlatform = kwargs.get("downloadPlatform", None)
self.downloadUrl = kwargs.get("downloadUrl", None)
self.sizeBytes = int(kwargs.get("sizeBytes", 0))
self.hashes = kwargs.get("hashes", None)
def to_dict(self):
return {
"type": self.type,
"pathIndex": self.pathIndex,
"fullname": self.fullname,
"description": self.description,
"downloadPlatform": self.downloadPlatform,
"downloadUrl": self.downloadUrl,
"sizeBytes": self.sizeBytes,
"hashes": self.hashes,
}
download_model_task_status: dict[str, TaskStatus] = {} download_model_task_status: dict[str, TaskStatus] = {}
download_thread_pool = thread.DownloadThreadPool() download_thread_pool = thread.DownloadThreadPool()
@@ -47,7 +94,7 @@ download_thread_pool = thread.DownloadThreadPool()
def set_task_content(task_id: str, task_content: Union[TaskContent, dict]): def set_task_content(task_id: str, task_content: Union[TaskContent, dict]):
download_path = utils.get_download_path() download_path = utils.get_download_path()
task_file_path = utils.join_path(download_path, f"{task_id}.task") task_file_path = utils.join_path(download_path, f"{task_id}.task")
utils.save_dict_pickle_file(task_file_path, utils.unpack_dataclass(task_content)) utils.save_dict_pickle_file(task_file_path, task_content)
def get_task_content(task_id: str): def get_task_content(task_id: str):
@@ -56,8 +103,6 @@ def get_task_content(task_id: str):
if not os.path.isfile(task_file): if not os.path.isfile(task_file):
raise RuntimeError(f"Task {task_id} not found") raise RuntimeError(f"Task {task_id} not found")
task_content = utils.load_dict_pickle_file(task_file) 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) return TaskContent(**task_content)
@@ -93,39 +138,34 @@ def delete_task_status(task_id: str):
download_model_task_status.pop(task_id, None) download_model_task_status.pop(task_id, None)
async def scan_model_download_task_list(sid: str): async def scan_model_download_task_list():
""" """
Scan the download directory and send the task list to the client. Scan the download directory and send the task list to the client.
""" """
try: download_dir = utils.get_download_path()
download_dir = utils.get_download_path() task_files = utils.search_files(download_dir)
task_files = utils.search_files(download_dir) task_files = folder_paths.filter_files_extensions(task_files, [".task"])
task_files = folder_paths.filter_files_extensions(task_files, [".task"]) task_files = sorted(
task_files = sorted( task_files,
task_files, key=lambda x: os.stat(utils.join_path(download_dir, x)).st_ctime,
key=lambda x: os.stat(utils.join_path(download_dir, x)).st_ctime, reverse=True,
reverse=True, )
) task_list: list[dict] = []
task_list: list[dict] = [] for task_file in task_files:
for task_file in task_files: task_id = task_file.replace(".task", "")
task_id = task_file.replace(".task", "") task_status = get_task_status(task_id)
task_status = get_task_status(task_id) task_list.append(task_status.to_dict())
task_list.append(task_status)
await socket.send_json("downloadTaskList", task_list, sid) return task_list
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): async def create_model_download_task(task_data: dict, request):
""" """
Creates a download task for the given post. Creates a download task for the given data.
""" """
model_type = post.get("type", None) model_type = task_data.get("type", None)
path_index = int(post.get("pathIndex", None)) path_index = int(task_data.get("pathIndex", None))
fullname = post.get("fullname", None) fullname = task_data.get("fullname", None)
model_path = utils.get_full_path(model_type, path_index, fullname) model_path = utils.get_full_path(model_type, path_index, fullname)
# Check if the model path is valid # Check if the model path is valid
@@ -140,24 +180,24 @@ async def create_model_download_task(post: dict):
raise RuntimeError(f"Task {task_id} already exists") raise RuntimeError(f"Task {task_id} already exists")
try: try:
previewFile = post.pop("previewFile", None) preview_url = task_data.pop("preview", None)
utils.save_model_preview_image(task_path, previewFile) utils.save_model_preview_image(task_path, preview_url)
set_task_content(task_id, post) set_task_content(task_id, task_data)
task_status = TaskStatus( task_status = TaskStatus(
taskId=task_id, taskId=task_id,
type=model_type, type=model_type,
fullname=fullname, fullname=fullname,
preview=utils.get_model_preview_name(task_path), preview=utils.get_model_preview_name(task_path),
platform=post.get("downloadPlatform", None), platform=task_data.get("downloadPlatform", None),
totalSize=float(post.get("sizeBytes", 0)), totalSize=float(task_data.get("sizeBytes", 0)),
) )
download_model_task_status[task_id] = task_status download_model_task_status[task_id] = task_status
await socket.send_json("createDownloadTask", task_status) await utils.send_json("create_download_task", task_status.to_dict())
except Exception as e: except Exception as e:
await delete_model_download_task(task_id) await delete_model_download_task(task_id)
raise RuntimeError(str(e)) from e raise RuntimeError(str(e)) from e
await download_model(task_id) await download_model(task_id, request)
return task_id return task_id
@@ -170,7 +210,7 @@ async def delete_model_download_task(task_id: str):
task_status = get_task_status(task_id) task_status = get_task_status(task_id)
is_running = task_status.status == "doing" is_running = task_status.status == "doing"
task_status.status = "waiting" task_status.status = "waiting"
await socket.send_json("deleteDownloadTask", task_id) await utils.send_json("delete_download_task", task_id)
# Pause the task # Pause the task
if is_running: if is_running:
@@ -185,13 +225,13 @@ async def delete_model_download_task(task_id: str):
delete_task_status(task_id) delete_task_status(task_id)
os.remove(utils.join_path(download_dir, task_file)) os.remove(utils.join_path(download_dir, task_file))
await socket.send_json("deleteDownloadTask", task_id) await utils.send_json("delete_download_task", task_id)
async def download_model(task_id: str): async def download_model(task_id: str, request):
async def download_task(task_id: str): async def download_task(task_id: str):
async def report_progress(task_status: TaskStatus): async def report_progress(task_status: TaskStatus):
await socket.send_json("updateDownloadTask", task_status) await utils.send_json("update_download_task", task_status.to_dict())
try: try:
# When starting a task from the queue, the task may not exist # When starting a task from the queue, the task may not exist
@@ -201,7 +241,7 @@ async def download_model(task_id: str):
# Update task status # Update task status
task_status.status = "doing" task_status.status = "doing"
await socket.send_json("updateDownloadTask", task_status) await utils.send_json("update_download_task", task_status.to_dict())
try: try:
@@ -210,12 +250,12 @@ async def download_model(task_id: str):
download_platform = task_status.platform download_platform = task_status.platform
if download_platform == "civitai": if download_platform == "civitai":
api_key = utils.get_setting_value("api_key.civitai") api_key = utils.get_setting_value(request, "api_key.civitai")
if api_key: if api_key:
headers["Authorization"] = f"Bearer {api_key}" headers["Authorization"] = f"Bearer {api_key}"
elif download_platform == "huggingface": elif download_platform == "huggingface":
api_key = utils.get_setting_value("api_key.huggingface") api_key = utils.get_setting_value(request, "api_key.huggingface")
if api_key: if api_key:
headers["Authorization"] = f"Bearer {api_key}" headers["Authorization"] = f"Bearer {api_key}"
@@ -229,22 +269,22 @@ async def download_model(task_id: str):
except Exception as e: except Exception as e:
task_status.status = "pause" task_status.status = "pause"
task_status.error = str(e) task_status.error = str(e)
await socket.send_json("updateDownloadTask", task_status) await utils.send_json("update_download_task", task_status.to_dict())
task_status.error = None task_status.error = None
logging.error(str(e)) utils.print_error(str(e))
try: try:
status = download_thread_pool.submit(download_task, task_id) status = download_thread_pool.submit(download_task, task_id)
if status == "Waiting": if status == "Waiting":
task_status = get_task_status(task_id) task_status = get_task_status(task_id)
task_status.status = "waiting" task_status.status = "waiting"
await socket.send_json("updateDownloadTask", task_status) await utils.send_json("update_download_task", task_status.to_dict())
except Exception as e: except Exception as e:
task_status.status = "pause" task_status.status = "pause"
task_status.error = str(e) task_status.error = str(e)
await socket.send_json("updateDownloadTask", task_status) await utils.send_json("update_download_task", task_status.to_dict())
task_status.error = None task_status.error = None
logging.error(traceback.format_exc()) utils.print_error(str(e))
async def download_model_file( async def download_model_file(
@@ -275,7 +315,7 @@ async def download_model_file(
time.sleep(1) time.sleep(1)
task_file = utils.join_path(download_path, f"{task_id}.task") task_file = utils.join_path(download_path, f"{task_id}.task")
os.remove(task_file) os.remove(task_file)
await socket.send_json("completeDownloadTask", task_id) await utils.send_json("complete_download_task", task_id)
async def update_progress(): async def update_progress():
nonlocal last_update_time nonlocal last_update_time
@@ -329,6 +369,13 @@ async def download_model_file(
# If no token is carried, it will be redirected to the login page. # If no token is carried, it will be redirected to the login page.
content_type = response.headers.get("content-type") content_type = response.headers.get("content-type")
if content_type and content_type.startswith("text/html"): if content_type and content_type.startswith("text/html"):
# TODO More checks
# In addition to requiring login to download, there may be other restrictions.
# The currently one situation is early access??? issues#43
# Due to the lack of test data, lets put it aside for now.
# If it cannot be downloaded, a redirect will definitely occur.
# Maybe consider getting the redirect url from response.history to make a judgment.
# Here we also need to consider how different websites are processed.
raise RuntimeError( raise RuntimeError(
f"{task_content.fullname} needs to be logged in to download. Please set the API-Key first." f"{task_content.fullname} needs to be logged in to download. Please set the API-Key first."
) )
@@ -340,7 +387,7 @@ async def download_model_file(
task_content.sizeBytes = total_size task_content.sizeBytes = total_size
task_status.totalSize = total_size task_status.totalSize = total_size
set_task_content(task_id, task_content) set_task_content(task_id, task_content)
await socket.send_json("updateDownloadTask", task_content) await utils.send_json("update_download_task", task_content.to_dict())
with open(download_tmp_file, "ab") as f: with open(download_tmp_file, "ab") as f:
for chunk in response.iter_content(chunk_size=8192): for chunk in response.iter_content(chunk_size=8192):
@@ -359,4 +406,4 @@ async def download_model_file(
await download_complete() await download_complete()
else: else:
task_status.status = "pause" task_status.status = "pause"
await socket.send_json("updateDownloadTask", task_status) await utils.send_json("update_download_task", task_status.to_dict())

317
py/searcher.py Normal file
View File

@@ -0,0 +1,317 @@
import os
import re
import yaml
import requests
import markdownify
from abc import ABC, abstractmethod
from urllib.parse import urlparse, parse_qs
from . import utils
class ModelSearcher(ABC):
"""
Abstract class for model searcher.
"""
@abstractmethod
def search_by_url(self, url: str) -> list[dict]:
pass
@abstractmethod
def search_by_hash(self, hash: str) -> dict:
pass
class UnknownWebsiteSearcher(ModelSearcher):
def search_by_url(self, url: str):
raise RuntimeError(
f"Unknown Website, please input a URL from huggingface.co or civitai.com."
)
def search_by_hash(self, hash: str):
raise RuntimeError(f"Unknown Website, unable to search with hash value.")
class CivitaiModelSearcher(ModelSearcher):
def search_by_url(self, url: str):
parsed_url = urlparse(url)
pathname = parsed_url.path
match = re.match(r"^/models/(\d*)", pathname)
model_id = match.group(1) if match else None
query_params = parse_qs(parsed_url.query)
version_id = query_params.get("modelVersionId", [None])[0]
if not model_id:
return []
response = requests.get(f"https://civitai.com/api/v1/models/{model_id}")
response.raise_for_status()
res_data: dict = response.json()
model_versions: list[dict] = res_data["modelVersions"]
if version_id:
model_versions = utils.filter_with(model_versions, {"id": int(version_id)})
models: list[dict] = []
for version in model_versions:
model_files: list[dict] = version.get("files", [])
model_files = utils.filter_with(model_files, {"type": "Model"})
shortname = version.get("name", None) if len(model_files) > 0 else None
for file in model_files:
fullname = file.get("name", None)
extension = os.path.splitext(fullname)[1]
basename = os.path.splitext(fullname)[0]
metadata_info = {
"website": "Civitai",
"modelPage": f"https://civitai.com/models/{model_id}?modelVersionId={version.get('id')}",
"author": res_data.get("creator", {}).get("username", None),
"baseModel": version.get("baseModel"),
"hashes": file.get("hashes"),
"metadata": file.get("metadata"),
"preview": [i["url"] for i in version["images"]],
}
description_parts: list[str] = []
description_parts.append("---")
description_parts.append(yaml.dump(metadata_info).strip())
description_parts.append("---")
description_parts.append("")
description_parts.append(f"# Trigger Words")
description_parts.append("")
description_parts.append(
", ".join(version.get("trainedWords", ["No trigger words"]))
)
description_parts.append("")
description_parts.append(f"# About this version")
description_parts.append("")
description_parts.append(
markdownify.markdownify(
version.get(
"description", "<p>No description about this version</p>"
)
).strip()
)
description_parts.append("")
description_parts.append(f"# {res_data.get('name')}")
description_parts.append("")
description_parts.append(
markdownify.markdownify(
res_data.get(
"description", "<p>No description about this model</p>"
)
).strip()
)
description_parts.append("")
model = {
"id": file.get("id"),
"shortname": shortname or basename,
"fullname": fullname,
"basename": basename,
"extension": extension,
"preview": metadata_info.get("preview"),
"sizeBytes": file.get("sizeKB", 0) * 1024,
"type": self._resolve_model_type(res_data.get("type", "unknown")),
"pathIndex": 0,
"description": "\n".join(description_parts),
"metadata": file.get("metadata"),
"downloadPlatform": "civitai",
"downloadUrl": file.get("downloadUrl"),
"hashes": file.get("hashes"),
}
models.append(model)
return models
def search_by_hash(self, hash: str):
if not hash:
raise RuntimeError(f"Hash value is empty.")
response = requests.get(
f"https://civitai.com/api/v1/model-versions/by-hash/{hash}"
)
response.raise_for_status()
version: dict = response.json()
model_id = version.get("modelId")
version_id = version.get("id")
model_page = (
f"https://civitai.com/models/{model_id}?modelVersionId={version_id}"
)
models = self.search_by_url(model_page)
for model in models:
sha256 = model.get("hashes", {}).get("SHA256")
if sha256 == hash:
return model
return models[0]
def _resolve_model_type(self, model_type: str):
map_legacy = {
"TextualInversion": "embeddings",
"LoCon": "loras",
"DoRA": "loras",
"Controlnet": "controlnet",
"Upscaler": "upscale_models",
"VAE": "vae",
"unknown": "unknown",
}
return map_legacy.get(model_type, f"{model_type.lower()}s")
class HuggingfaceModelSearcher(ModelSearcher):
def search_by_url(self, url: str):
parsed_url = urlparse(url)
pathname = parsed_url.path
space, name, *rest_paths = pathname.strip("/").split("/")
model_id = f"{space}/{name}"
rest_pathname = "/".join(rest_paths)
response = requests.get(f"https://huggingface.co/api/models/{model_id}")
response.raise_for_status()
res_data: dict = response.json()
sibling_files: list[str] = [
x.get("rfilename") for x in res_data.get("siblings", [])
]
model_files = utils.filter_with(
utils.filter_with(sibling_files, self._match_model_files()),
self._match_tree_files(rest_pathname),
)
image_files = utils.filter_with(
utils.filter_with(sibling_files, self._match_image_files()),
self._match_tree_files(rest_pathname),
)
image_files = [
f"https://huggingface.co/{model_id}/resolve/main/{filename}"
for filename in image_files
]
models: list[dict] = []
for filename in model_files:
fullname = os.path.basename(filename)
extension = os.path.splitext(fullname)[1]
basename = os.path.splitext(fullname)[0]
description_parts: list[str] = []
metadata_info = {
"website": "HuggingFace",
"modelPage": f"https://huggingface.co/{model_id}",
"author": res_data.get("author", None),
"preview": image_files,
}
description_parts: list[str] = []
description_parts.append("---")
description_parts.append(yaml.dump(metadata_info).strip())
description_parts.append("---")
description_parts.append("")
description_parts.append(f"# Trigger Words")
description_parts.append("")
description_parts.append("No trigger words")
description_parts.append("")
description_parts.append(f"# About this version")
description_parts.append("")
description_parts.append("No description about this version")
description_parts.append("")
description_parts.append(f"# {res_data.get('name')}")
description_parts.append("")
description_parts.append("No description about this model")
description_parts.append("")
model = {
"id": filename,
"shortname": filename,
"fullname": fullname,
"basename": basename,
"extension": extension,
"preview": image_files,
"sizeBytes": 0,
"type": "unknown",
"pathIndex": 0,
"description": "\n".join(description_parts),
"metadata": {},
"downloadPlatform": "",
"downloadUrl": f"https://huggingface.co/{model_id}/resolve/main/{filename}?download=true",
}
models.append(model)
return models
def search_by_hash(self, hash: str):
raise RuntimeError("Hash search is not supported by Huggingface.")
def _match_model_files(self):
extension = [
".bin",
".ckpt",
".gguf",
".onnx",
".pt",
".pth",
".safetensors",
]
def _filter_model_files(file: str):
return any(file.endswith(ext) for ext in extension)
return _filter_model_files
def _match_image_files(self):
extension = [
".png",
".webp",
".jpeg",
".jpg",
".jfif",
".gif",
".apng",
]
def _filter_image_files(file: str):
return any(file.endswith(ext) for ext in extension)
return _filter_image_files
def _match_tree_files(self, pathname: str):
target, *paths = pathname.split("/")
def _filter_tree_files(file: str):
if not target:
return True
if target != "tree" and target != "blob":
return True
prefix_path = "/".join(paths)
return file.startswith(prefix_path)
return _filter_tree_files
def get_model_searcher_by_url(url: str) -> ModelSearcher:
parsed_url = urlparse(url)
host_name = parsed_url.hostname
if host_name == "civitai.com":
return CivitaiModelSearcher()
elif host_name == "huggingface.co":
return HuggingfaceModelSearcher()
return UnknownWebsiteSearcher()

View File

@@ -1,35 +1,11 @@
import os import os
import logging
import traceback
import folder_paths import folder_paths
from typing import Any
from multidict import MultiDictProxy
from . import config from . import config
from . import utils from . import utils
from . import socket
from . import download from . import download
from . import searcher
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(): def scan_models():
@@ -99,20 +75,20 @@ def get_model_info(model_path: str):
} }
def update_model(model_path: str, post: MultiDictProxy): def update_model(model_path: str, model_data: dict):
if "previewFile" in post: if "previewFile" in model_data:
previewFile = post["previewFile"] previewFile = model_data["previewFile"]
utils.save_model_preview_image(model_path, previewFile) utils.save_model_preview_image(model_path, previewFile)
if "description" in post: if "description" in model_data:
description = post["description"] description = model_data["description"]
utils.save_model_description(model_path, description) utils.save_model_description(model_path, description)
if "type" in post and "pathIndex" in post and "fullname" in post: if "type" in model_data and "pathIndex" in model_data and "fullname" in model_data:
model_type = post.get("type", None) model_type = model_data.get("type", None)
path_index = int(post.get("pathIndex", None)) path_index = int(model_data.get("pathIndex", None))
fullname = post.get("fullname", None) fullname = model_data.get("fullname", None)
if model_type is None or path_index is None or fullname is None: if model_type is None or path_index is None or fullname is None:
raise RuntimeError("Invalid type or pathIndex or fullname") raise RuntimeError("Invalid type or pathIndex or fullname")
@@ -135,6 +111,198 @@ def remove_model(model_path: str):
os.remove(utils.join_path(model_dirname, description)) os.remove(utils.join_path(model_dirname, description))
async def create_model_download_task(post): async def create_model_download_task(task_data, request):
dict_post = dict(post) return await download.create_model_download_task(task_data, request)
return await download.create_model_download_task(dict_post)
async def scan_model_download_task_list():
return await download.scan_model_download_task_list()
async def pause_model_download_task(task_id):
return await download.pause_model_download_task(task_id)
async def resume_model_download_task(task_id, request):
return await download.download_model(task_id, request)
async def delete_model_download_task(task_id):
return await download.delete_model_download_task(task_id)
def fetch_model_info(model_page: str):
if not model_page:
return []
model_searcher = searcher.get_model_searcher_by_url(model_page)
result = model_searcher.search_by_url(model_page)
return result
async def download_model_info(scan_mode: str):
utils.print_info(f"Download model info for {scan_mode}")
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)
descriptions = folder_paths.filter_files_extensions(files, [".md"])
description_dict = utils.file_list_to_name_dict(descriptions)
for fullname in models:
fullname = utils.normalize_path(fullname)
basename = os.path.splitext(fullname)[0]
abs_model_path = utils.join_path(base_path, fullname)
image_name = image_dict.get(basename, "no-preview.png")
abs_image_path = utils.join_path(base_path, image_name)
has_preview = os.path.isfile(abs_image_path)
description_name = description_dict.get(basename, None)
abs_description_path = (
utils.join_path(base_path, description_name)
if description_name
else None
)
has_description = (
os.path.isfile(abs_description_path)
if abs_description_path
else False
)
try:
utils.print_info(f"Checking model {abs_model_path}")
utils.print_debug(f"Scan mode: {scan_mode}")
utils.print_debug(f"Has preview: {has_preview}")
utils.print_debug(f"Has description: {has_description}")
if scan_mode != "full" and (has_preview and has_description):
continue
utils.print_debug(f"Calculate sha256 for {abs_model_path}")
hash_value = utils.calculate_sha256(abs_model_path)
utils.print_info(f"Searching model info by hash {hash_value}")
model_info = searcher.CivitaiModelSearcher().search_by_hash(
hash_value
)
preview_url_list = model_info.get("preview", [])
preview_image_url = (
preview_url_list[0] if preview_url_list else None
)
if preview_image_url:
utils.print_debug(f"Save preview image to {abs_image_path}")
utils.save_model_preview_image(
abs_model_path, preview_image_url
)
description = model_info.get("description", None)
if description:
utils.save_model_description(abs_model_path, description)
except Exception as e:
utils.print_error(
f"Failed to download model info for {abs_model_path}: {e}"
)
utils.print_debug("Completed scan model information.")
async def migrate_legacy_information():
import json
import yaml
from PIL import Image
utils.print_info(f"Migrating legacy information...")
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)
for fullname in models:
fullname = utils.normalize_path(fullname)
abs_model_path = utils.join_path(base_path, fullname)
base_file_name = os.path.splitext(abs_model_path)[0]
utils.print_debug(f"Try to migrate legacy info for {abs_model_path}")
preview_path = utils.join_path(
os.path.dirname(abs_model_path),
utils.get_model_preview_name(abs_model_path),
)
new_preview_path = f"{base_file_name}.webp"
if os.path.isfile(preview_path) and preview_path != new_preview_path:
utils.print_info(f"Migrate preview image from {fullname}")
with Image.open(preview_path) as image:
image.save(new_preview_path, format="WEBP")
os.remove(preview_path)
description_path = f"{base_file_name}.md"
metadata_info = {
"website": "Civitai",
}
url_info_path = f"{base_file_name}.url"
if os.path.isfile(url_info_path):
with open(url_info_path, "r", encoding="utf-8") as f:
for line in f:
if line.startswith("URL="):
model_page_url = line[len("URL=") :].strip()
metadata_info.update({"modelPage": model_page_url})
json_info_path = f"{base_file_name}.json"
if os.path.isfile(json_info_path):
with open(json_info_path, "r", encoding="utf-8") as f:
version = json.load(f)
metadata_info.update(
{
"baseModel": version.get("baseModel"),
"preview": [i["url"] for i in version["images"]],
}
)
description_parts: list[str] = [
"---",
yaml.dump(metadata_info).strip(),
"---",
"",
]
text_info_path = f"{base_file_name}.txt"
if os.path.isfile(text_info_path):
with open(text_info_path, "r", encoding="utf-8") as f:
description_parts.append(f.read())
description_path = f"{base_file_name}.md"
if os.path.isfile(text_info_path):
utils.print_info(f"Migrate description from {fullname}")
with open(description_path, "w", encoding="utf-8", newline="") as f:
f.write("\n".join(description_parts))
def try_to_remove_file(file_path):
if os.path.isfile(file_path):
os.remove(file_path)
try_to_remove_file(url_info_path)
try_to_remove_file(text_info_path)
try_to_remove_file(json_info_path)
utils.print_debug("Completed migrate model information.")

View File

@@ -1,63 +0,0 @@
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))

View File

@@ -1,7 +1,7 @@
import asyncio import asyncio
import threading import threading
import queue import queue
import logging
from . import utils from . import utils
@@ -13,14 +13,7 @@ class DownloadThreadPool:
self._lock = threading.Lock() self._lock = threading.Lock()
default_max_workers = 5 default_max_workers = 5
max_workers: int = utils.get_setting_value( max_workers: int = default_max_workers
"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 self.max_worker = max_workers
def submit(self, task, task_id): def submit(self, task, task_id):
@@ -58,7 +51,7 @@ class DownloadThreadPool:
with self._lock: with self._lock:
self.running_tasks.remove(task_id) self.running_tasks.remove(task_id)
except Exception as e: except Exception as e:
logging.error(f"worker run error: {str(e)}") utils.print_error(f"worker run error: {str(e)}")
with self._lock: with self._lock:
self.workers_count -= 1 self.workers_count -= 1

View File

@@ -5,6 +5,7 @@ import shutil
import tarfile import tarfile
import logging import logging
import requests import requests
import traceback
import configparser import configparser
import comfy.utils import comfy.utils
@@ -15,6 +16,40 @@ from typing import Any
from . import config from . import config
def print_info(msg, *args, **kwargs):
logging.info(f"[{config.extension_tag}] {msg}", *args, **kwargs)
def print_error(msg, *args, **kwargs):
logging.error(f"[{config.extension_tag}] {msg}", *args, **kwargs)
logging.debug(traceback.format_exc())
def print_debug(msg, *args, **kwargs):
logging.debug(f"[{config.extension_tag}] {msg}", *args, **kwargs)
def _matches(predicate: dict):
def _filter(obj: dict):
return all(obj.get(key, None) == value for key, value in predicate.items())
return _filter
def filter_with(list: list, predicate):
if isinstance(predicate, dict):
predicate = _matches(predicate)
return [item for item in list if predicate(item)]
async def get_request_body(request) -> dict:
try:
return await request.json()
except:
return {}
def normalize_path(path: str): def normalize_path(path: str):
normpath = os.path.normpath(path) normpath = os.path.normpath(path)
return normpath.replace(os.path.sep, "/") return normpath.replace(os.path.sep, "/")
@@ -52,8 +87,8 @@ def download_web_distribution(version: str):
return return
try: try:
logging.info(f"current version {version}, web version {web_version}") print_info(f"current version {version}, web version {web_version}")
logging.info("Downloading web distribution...") print_info("Downloading web distribution...")
download_url = f"https://github.com/hayden-fr/ComfyUI-Model-Manager/releases/download/v{version}/dist.tar.gz" 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 = requests.get(download_url, stream=True)
response.raise_for_status() response.raise_for_status()
@@ -66,7 +101,7 @@ def download_web_distribution(version: str):
if os.path.exists(web_path): if os.path.exists(web_path):
shutil.rmtree(web_path) shutil.rmtree(web_path)
logging.info("Extracting web distribution...") print_info("Extracting web distribution...")
with tarfile.open(temp_file, "r:gz") as tar: with tarfile.open(temp_file, "r:gz") as tar:
members = [ members = [
member for member in tar.getmembers() if member.name.startswith("web/") member for member in tar.getmembers() if member.name.startswith("web/")
@@ -74,13 +109,13 @@ def download_web_distribution(version: str):
tar.extractall(path=config.extension_uri, members=members) tar.extractall(path=config.extension_uri, members=members)
os.remove(temp_file) os.remove(temp_file)
logging.info("Web distribution downloaded successfully.") print_info("Web distribution downloaded successfully.")
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logging.error(f"Failed to download web distribution: {e}") print_error(f"Failed to download web distribution: {e}")
except tarfile.TarError as e: except tarfile.TarError as e:
logging.error(f"Failed to extract web distribution: {e}") print_error(f"Failed to extract web distribution: {e}")
except Exception as e: except Exception as e:
logging.error(f"An unexpected error occurred: {e}") print_error(f"An unexpected error occurred: {e}")
def resolve_model_base_paths(): def resolve_model_base_paths():
@@ -188,41 +223,22 @@ def get_model_preview_name(model_path: str):
return images[0] if len(images) > 0 else "no-preview.png" return images[0] if len(images) > 0 else "no-preview.png"
def save_model_preview_image(model_path: str, image_file: Any): from PIL import Image
if not isinstance(image_file, web.FileField): from io import BytesIO
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) def save_model_preview_image(model_path: str, image_url: str):
try:
image_response = requests.get(image_url)
image_response.raise_for_status()
# remove old preview images basename = os.path.splitext(model_path)[0]
old_preview_images = get_model_all_images(model_path) preview_path = f"{basename}.webp"
a1111_civitai_helper_image = False image = Image.open(BytesIO(image_response.content))
for image in old_preview_images: image.save(preview_path, "WEBP")
if os.path.splitext(image)[1].endswith(".preview"):
a1111_civitai_helper_image = True
image_path = join_path(base_dirname, image)
os.remove(image_path)
# save new preview image except Exception as e:
basename = os.path.splitext(os.path.basename(model_path))[0] print_error(f"Failed to download image: {e}")
extension = f".{content_type.split('/')[1]}"
new_preview_path = join_path(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 = join_path(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): def get_model_all_descriptions(model_path: str):
@@ -334,30 +350,56 @@ def resolve_setting_key(key: str) -> str:
return setting_id return setting_id
def set_setting_value(key: str, value: Any): def set_setting_value(request: web.Request, key: str, value: Any):
setting_id = resolve_setting_key(key) setting_id = resolve_setting_key(key)
fake_request = config.FakeRequest() settings = config.serverInstance.user_manager.settings.get_settings(request)
settings = config.serverInstance.user_manager.settings.get_settings(fake_request)
settings[setting_id] = value settings[setting_id] = value
config.serverInstance.user_manager.settings.save_settings(fake_request, settings) config.serverInstance.user_manager.settings.save_settings(request, settings)
def get_setting_value(key: str, default: Any = None) -> Any: def get_setting_value(request: web.Request, key: str, default: Any = None) -> Any:
setting_id = resolve_setting_key(key) setting_id = resolve_setting_key(key)
fake_request = config.FakeRequest() settings = config.serverInstance.user_manager.settings.get_settings(request)
settings = config.serverInstance.user_manager.settings.get_settings(fake_request)
return settings.get(setting_id, default) return settings.get(setting_id, default)
from dataclasses import asdict, is_dataclass async def send_json(event: str, data: Any, sid: str = None):
await config.serverInstance.send_json(event, data, sid)
def unpack_dataclass(data: Any): import sys
if isinstance(data, dict): import subprocess
return {key: unpack_dataclass(value) for key, value in data.items()} import importlib.util
elif isinstance(data, list): import importlib.metadata
return [unpack_dataclass(x) for x in data]
elif is_dataclass(data):
return asdict(data) def is_installed(package_name: str):
else: try:
return data dist = importlib.metadata.distribution(package_name)
except importlib.metadata.PackageNotFoundError:
try:
spec = importlib.util.find_spec(package_name)
except ModuleNotFoundError:
return False
return spec is not None
return dist is not None
def pip_install(package_name: str):
subprocess.run([sys.executable, "-m", "pip", "install", package_name], check=True)
import hashlib
def calculate_sha256(path, buffer_size=1024 * 1024):
sha256 = hashlib.sha256()
with open(path, "rb") as f:
while True:
data = f.read(buffer_size)
if not data:
break
sha256.update(data)
return sha256.hexdigest()

View File

@@ -1,8 +1,9 @@
[project] [project]
name = "comfyui-model-manager" name = "comfyui-model-manager"
description = "Manage models: browsing, download and delete." description = "Manage models: browsing, download and delete."
version = "2.0.2" version = "2.1.0"
license = "LICENSE" license = "LICENSE"
dependencies = ["markdownify"]
[project.urls] [project.urls]
Repository = "https://github.com/hayden-fr/ComfyUI-Model-Manager" Repository = "https://github.com/hayden-fr/ComfyUI-Model-Manager"

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
markdownify

View File

@@ -6,17 +6,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import DialogManager from 'components/DialogManager.vue'
import DialogDownload from 'components/DialogDownload.vue' import DialogDownload from 'components/DialogDownload.vue'
import GlobalToast from 'components/GlobalToast.vue' import DialogManager from 'components/DialogManager.vue'
import GlobalLoading from 'components/GlobalLoading.vue'
import GlobalDialogStack from 'components/GlobalDialogStack.vue' import GlobalDialogStack from 'components/GlobalDialogStack.vue'
import GlobalLoading from 'components/GlobalLoading.vue'
import GlobalToast from 'components/GlobalToast.vue'
import { useStoreProvider } from 'hooks/store'
import { useToast } from 'hooks/toast'
import GlobalConfirm from 'primevue/confirmdialog' import GlobalConfirm from 'primevue/confirmdialog'
import { $el, app, ComfyButton } from 'scripts/comfyAPI' import { $el, app, ComfyButton } from 'scripts/comfyAPI'
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useStoreProvider } from 'hooks/store'
import { useToast } from 'hooks/toast'
const { t } = useI18n() const { t } = useI18n()
const { dialog, models, config, download } = useStoreProvider() const { dialog, models, config, download } = useStoreProvider()
@@ -39,6 +39,7 @@ onMounted(() => {
content: DialogDownload, content: DialogDownload,
headerButtons: [ headerButtons: [
{ {
key: 'refresh',
icon: 'pi pi-refresh', icon: 'pi pi-refresh',
command: () => download.refresh(), command: () => download.refresh(),
}, },
@@ -56,10 +57,12 @@ onMounted(() => {
keepAlive: true, keepAlive: true,
headerButtons: [ headerButtons: [
{ {
key: 'refresh',
icon: 'pi pi-refresh', icon: 'pi pi-refresh',
command: refreshModelsAndConfig, command: refreshModelsAndConfig,
}, },
{ {
key: 'download',
icon: 'pi pi-download', icon: 'pi pi-download',
command: openDownloadDialog, command: openDownloadDialog,
}, },

View File

@@ -8,7 +8,7 @@
> >
<template #suffix> <template #suffix>
<span <span
class="pi pi-search pi-inputicon" class="pi pi-search text-base opacity-60"
@click="searchModelsByUrl" @click="searchModelsByUrl"
></span> ></span>
</template> </template>
@@ -28,21 +28,23 @@
<ResponseScroll class="-mx-5 h-full"> <ResponseScroll class="-mx-5 h-full">
<div class="px-5"> <div class="px-5">
<ModelContent <KeepAlive>
v-if="currentModel" <ModelContent
:key="currentModel.id" v-if="currentModel"
:model="currentModel" :key="currentModel.id"
:editable="true" :model="currentModel"
@submit="createDownTask" :editable="true"
> @submit="createDownTask"
<template #action> >
<Button <template #action>
icon="pi pi-download" <Button
:label="$t('download')" icon="pi pi-download"
type="submit" :label="$t('download')"
></Button> type="submit"
</template> ></Button>
</ModelContent> </template>
</ModelContent>
</KeepAlive>
<div v-show="data.length === 0"> <div v-show="data.length === 0">
<div class="flex flex-col items-center gap-4 py-8"> <div class="flex flex-col items-center gap-4 py-8">
@@ -58,16 +60,16 @@
<script setup lang="ts"> <script setup lang="ts">
import ModelContent from 'components/ModelContent.vue' import ModelContent from 'components/ModelContent.vue'
import ResponseInput from 'components/ResponseInput.vue' import ResponseInput from 'components/ResponseInput.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import ResponseScroll from 'components/ResponseScroll.vue' import ResponseScroll from 'components/ResponseScroll.vue'
import Button from 'primevue/button' import ResponseSelect from 'components/ResponseSelect.vue'
import { useConfig } from 'hooks/config' import { useConfig } from 'hooks/config'
import { useDialog } from 'hooks/dialog' import { useDialog } from 'hooks/dialog'
import { useModelSearch } from 'hooks/download' import { useModelSearch } from 'hooks/download'
import { useLoading } from 'hooks/loading' import { useLoading } from 'hooks/loading'
import { request } from 'hooks/request' import { request } from 'hooks/request'
import { useToast } from 'hooks/toast' import { useToast } from 'hooks/toast'
import { previewUrlToFile } from 'utils/common' import Button from 'primevue/button'
import { VersionModel } from 'types/typings'
import { ref } from 'vue' import { ref } from 'vue'
const { isMobile } = useConfig() const { isMobile } = useConfig()
@@ -86,38 +88,11 @@ const searchModelsByUrl = async () => {
} }
const createDownTask = async (data: VersionModel) => { const createDownTask = async (data: VersionModel) => {
const formData = new FormData()
loading.show() loading.show()
// set base info
formData.append('type', data.type)
formData.append('pathIndex', data.pathIndex.toString())
formData.append('fullname', data.fullname)
// set preview
const previewFile = await previewUrlToFile(data.preview as string).catch(
() => {
loading.hide()
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to download preview',
life: 15000,
})
throw new Error('Failed to download preview')
},
)
formData.append('previewFile', previewFile)
// set description
formData.append('description', data.description)
// set model download info
formData.append('downloadPlatform', data.downloadPlatform)
formData.append('downloadUrl', data.downloadUrl)
formData.append('sizeBytes', data.sizeBytes.toString())
formData.append('hashes', JSON.stringify(data.hashes))
await request('/model', { await request('/model', {
method: 'POST', method: 'POST',
body: formData, body: JSON.stringify(data),
}) })
.then(() => { .then(() => {
dialog.close({ key: 'model-manager-create-task' }) dialog.close({ key: 'model-manager-create-task' })

View File

@@ -73,9 +73,9 @@
<script setup lang="ts"> <script setup lang="ts">
import DialogCreateTask from 'components/DialogCreateTask.vue' import DialogCreateTask from 'components/DialogCreateTask.vue'
import ResponseScroll from 'components/ResponseScroll.vue' import ResponseScroll from 'components/ResponseScroll.vue'
import Button from 'primevue/button'
import { useDownload } from 'hooks/download'
import { useDialog } from 'hooks/dialog' import { useDialog } from 'hooks/dialog'
import { useDownload } from 'hooks/download'
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const { data } = useDownload() const { data } = useDownload()

View File

@@ -40,6 +40,7 @@
</div> </div>
<ResponseScroll <ResponseScroll
ref="responseScroll"
:items="list" :items="list"
:itemSize="itemSize" :itemSize="itemSize"
:row-key="(item) => item.map(genModelKey).join(',')" :row-key="(item) => item.map(genModelKey).join(',')"
@@ -74,23 +75,26 @@
</template> </template>
<script setup lang="ts" name="manager-dialog"> <script setup lang="ts" name="manager-dialog">
import { useConfig } from 'hooks/config'
import { useModels } from 'hooks/model'
import ModelCard from 'components/ModelCard.vue' import ModelCard from 'components/ModelCard.vue'
import ResponseInput from 'components/ResponseInput.vue' import ResponseInput from 'components/ResponseInput.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import ResponseScroll from 'components/ResponseScroll.vue' import ResponseScroll from 'components/ResponseScroll.vue'
import { computed, ref } from 'vue' import ResponseSelect from 'components/ResponseSelect.vue'
import { useI18n } from 'vue-i18n' import { useConfig } from 'hooks/config'
import { chunk } from 'lodash' import { useModels } from 'hooks/model'
import { defineResizeCallback } from 'hooks/resize' import { defineResizeCallback } from 'hooks/resize'
import { chunk } from 'lodash'
import { Model } from 'types/typings'
import { genModelKey } from 'utils/model' import { genModelKey } from 'utils/model'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { isMobile, cardWidth, gutter, aspect, modelFolders } = useConfig() const { isMobile, cardWidth, gutter, aspect, modelFolders } = useConfig()
const { data } = useModels() const { data } = useModels()
const { t } = useI18n() const { t } = useI18n()
const responseScroll = ref()
const searchContent = ref<string>() const searchContent = ref<string>()
const currentType = ref('all') const currentType = ref('all')
@@ -120,6 +124,10 @@ const sortOrderOptions = ref(
}), }),
) )
watch([searchContent, currentType], () => {
responseScroll.value.init()
})
const itemSize = computed(() => { const itemSize = computed(() => {
let itemWidth = cardWidth let itemWidth = cardWidth
let itemGutter = gutter let itemGutter = gutter
@@ -146,7 +154,7 @@ const list = computed(() => {
return matchType && matchName return matchType && matchName
}) })
let sortStrategy = (a: Model, b: Model) => 0 let sortStrategy: (a: Model, b: Model) => number = () => 0
switch (sortOrder.value) { switch (sortOrder.value) {
case 'name': case 'name':
sortStrategy = (a, b) => a.fullname.localeCompare(b.fullname) sortStrategy = (a, b) => a.fullname.localeCompare(b.fullname)

View File

@@ -44,9 +44,10 @@
<script setup lang="ts"> <script setup lang="ts">
import ModelContent from 'components/ModelContent.vue' import ModelContent from 'components/ModelContent.vue'
import ResponseScroll from 'components/ResponseScroll.vue' import ResponseScroll from 'components/ResponseScroll.vue'
import Button from 'primevue/button'
import { useModelNodeAction, useModels } from 'hooks/model' import { useModelNodeAction, useModels } from 'hooks/model'
import { useRequest } from 'hooks/request' import { useRequest } from 'hooks/request'
import Button from 'primevue/button'
import { BaseModel, Model } from 'types/typings'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
interface Props { interface Props {

View File

@@ -21,6 +21,7 @@
<div class="p-dialog-header-actions"> <div class="p-dialog-header-actions">
<Button <Button
v-for="action in item.headerButtons" v-for="action in item.headerButtons"
:key="action.key"
severity="secondary" severity="secondary"
:text="true" :text="true"
:rounded="true" :rounded="true"
@@ -38,9 +39,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Button from 'primevue/button'
import ResponseDialog from 'components/ResponseDialog.vue' import ResponseDialog from 'components/ResponseDialog.vue'
import { useDialog } from 'hooks/dialog' import { useDialog } from 'hooks/dialog'
import Button from 'primevue/button'
const { stack, rise, close } = useDialog() const { stack, rise, close } = useDialog()
</script> </script>

View File

@@ -16,7 +16,7 @@
update-trigger="blur" update-trigger="blur"
> >
<template #suffix> <template #suffix>
<span class="pi-inputicon"> <span class="text-base opacity-60">
{{ extension }} {{ extension }}
</span> </span>
</template> </template>
@@ -29,7 +29,11 @@
<col /> <col />
</colgroup> </colgroup>
<tbody> <tbody>
<tr v-for="item in information" class="h-8 whitespace-nowrap border-b"> <tr
v-for="item in information"
:key="item.key"
class="h-8 whitespace-nowrap border-b"
>
<td class="border-r bg-gray-300 px-4 dark:bg-gray-800"> <td class="border-r bg-gray-300 px-4 dark:bg-gray-800">
{{ $t(`info.${item.key}`) }} {{ $t(`info.${item.key}`) }}
</td> </td>

View File

@@ -66,11 +66,12 @@
<script setup lang="ts"> <script setup lang="ts">
import DialogModelDetail from 'components/DialogModelDetail.vue' import DialogModelDetail from 'components/DialogModelDetail.vue'
import { useDialog } from 'hooks/dialog'
import { useModelNodeAction } from 'hooks/model'
import Button from 'primevue/button' import Button from 'primevue/button'
import { Model } from 'types/typings'
import { genModelKey } from 'utils/model' import { genModelKey } from 'utils/model'
import { computed } from 'vue' import { computed } from 'vue'
import { useModelNodeAction } from 'hooks/model'
import { useDialog } from 'hooks/dialog'
interface Props { interface Props {
model: Model model: Model

View File

@@ -39,15 +39,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ModelPreview from 'components/ModelPreview.vue'
import ModelBaseInfo from 'components/ModelBaseInfo.vue' import ModelBaseInfo from 'components/ModelBaseInfo.vue'
import ModelDescription from 'components/ModelDescription.vue' import ModelDescription from 'components/ModelDescription.vue'
import ModelMetadata from 'components/ModelMetadata.vue' import ModelMetadata from 'components/ModelMetadata.vue'
import Tab from 'primevue/tab' import ModelPreview from 'components/ModelPreview.vue'
import Tabs from 'primevue/tabs'
import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import { import {
useModelBaseInfoEditor, useModelBaseInfoEditor,
useModelDescriptionEditor, useModelDescriptionEditor,
@@ -55,8 +50,14 @@ import {
useModelMetadataEditor, useModelMetadataEditor,
useModelPreviewEditor, useModelPreviewEditor,
} from 'hooks/model' } from 'hooks/model'
import { toRaw, watch } from 'vue'
import { cloneDeep } from 'lodash' import { cloneDeep } from 'lodash'
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { BaseModel } from 'types/typings'
import { toRaw, watch } from 'vue'
interface Props { interface Props {
model: BaseModel model: BaseModel

View File

@@ -26,7 +26,7 @@
<div class="relative"> <div class="relative">
<div <div
v-if="renderedDescription" v-if="renderedDescription"
class="markdown-it" :class="$style['markdown-body']"
v-html="renderedDescription" v-html="renderedDescription"
></div> ></div>
<div v-else class="flex flex-col items-center gap-2 py-5"> <div v-else class="flex flex-col items-center gap-2 py-5">
@@ -89,3 +89,146 @@ const exitEditMode = () => {
active.value = false active.value = false
} }
</script> </script>
<style lang="less" module>
.markdown-body {
font-family: theme('fontFamily.sans');
font-size: theme('fontSize.base');
line-height: theme('lineHeight.relaxed');
word-break: break-word;
margin: 0;
&::before {
display: table;
content: '';
}
&::after {
display: table;
content: '';
clear: both;
}
> *:first-child {
margin-top: 0 !important;
}
> *:last-child {
margin-bottom: 0 !important;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 1.5em;
margin-bottom: 1em;
font-weight: 600;
line-height: 1.25;
}
h1 {
font-size: 2em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--p-surface-700);
}
h2 {
font-size: 1.5em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--p-surface-700);
}
h3 {
font-size: 1.25em;
}
h4 {
font-size: 1em;
}
h5 {
font-size: 0.875em;
}
h6 {
font-size: 0.85em;
color: var(--p-surface-500);
}
a {
color: #1e8bc3;
text-decoration: none;
word-break: break-all;
}
a:hover {
text-decoration: underline;
}
p,
blockquote,
ul,
ol,
dl,
table,
pre,
details {
margin-top: 0;
margin-bottom: 1em;
}
p img {
width: 100%;
height: 100%;
object-fit: cover;
}
ul,
ol {
padding-left: 2em;
}
li {
margin: 0.5em 0;
}
blockquote {
padding: 0px 1em;
border-left: 0.25em solid var(--p-surface-500);
color: var(--p-surface-500);
margin: 1em 0;
}
blockquote > *:first-child {
margin-top: 0;
}
blockquote > *:last-child {
margin-bottom: 0;
}
pre {
font-size: 85%;
border-radius: 6px;
padding: 8px 16px;
overflow-x: auto;
background: var(--p-dialog-background);
filter: invert(10%);
}
pre code,
pre tt {
display: inline;
padding: 0;
margin: 0;
overflow: visible;
line-height: inherit;
word-wrap: normal;
background-color: transparent;
border: 0;
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template> <template>
<table v-if="dataSource.length" class="w-full border-collapse border"> <table v-if="dataSource.length" class="w-full border-collapse border">
<tbody> <tbody>
<tr v-for="item in dataSource" class="h-8 border-b"> <tr v-for="item in dataSource" :key="item.key" class="h-8 border-b">
<td class="border-r bg-gray-300 px-4 dark:bg-gray-800"> <td class="border-r bg-gray-300 px-4 dark:bg-gray-800">
{{ item.key }} {{ item.key }}
</td> </td>

View File

@@ -88,13 +88,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ResponseFileUpload from 'components/ResponseFileUpload.vue'
import ResponseImage from 'components/ResponseImage.vue' import ResponseImage from 'components/ResponseImage.vue'
import ResponseInput from 'components/ResponseInput.vue' import ResponseInput from 'components/ResponseInput.vue'
import ResponseFileUpload from 'components/ResponseFileUpload.vue' import { useConfig } from 'hooks/config'
import { useModelPreview } from 'hooks/model'
import Button from 'primevue/button' import Button from 'primevue/button'
import Carousel from 'primevue/carousel' import Carousel from 'primevue/carousel'
import { useModelPreview } from 'hooks/model'
import { useConfig } from 'hooks/config'
const editable = defineModel<boolean>('editable') const editable = defineModel<boolean>('editable')
const { cardWidth } = useConfig() const { cardWidth } = useConfig()

View File

@@ -8,7 +8,7 @@
maximizeIcon="pi pi-arrow-up-right-and-arrow-down-left-from-center" maximizeIcon="pi pi-arrow-up-right-and-arrow-down-left-from-center"
minimizeIcon="pi pi-arrow-down-left-and-arrow-up-right-to-center" minimizeIcon="pi pi-arrow-down-left-and-arrow-up-right-to-center"
:pt:mask:class="['group', { open: visible }]" :pt:mask:class="['group', { open: visible }]"
pt:root:class="max-h-full group-[:not(.open)]:!hidden" :pt:root:class="['max-h-full group-[:not(.open)]:!hidden', $style.dialog]"
pt:content:class="px-0 flex-1" pt:content:class="px-0 flex-1"
:base-z-index="1000" :base-z-index="1000"
:auto-z-index="isNil(zIndex)" :auto-z-index="isNil(zIndex)"
@@ -75,9 +75,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Dialog from 'primevue/dialog'
import { clamp, isNil } from 'lodash'
import { useConfig } from 'hooks/config' import { useConfig } from 'hooks/config'
import { clamp, isNil } from 'lodash'
import Dialog from 'primevue/dialog'
import { ContainerPosition, ContainerSize } from 'types/typings'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
interface Props { interface Props {
@@ -332,3 +333,16 @@ defineExpose({
updateContainerPosition, updateContainerPosition,
}) })
</script> </script>
<style lang="css" module>
@layer tailwind-utilities {
:where(.dialog) {
*,
*::before,
*::after {
box-sizing: border-box;
border: 0 solid var(--p-surface-500);
}
}
}
</style>

View File

@@ -19,6 +19,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SelectEvent, SelectFile } from 'types/typings'
const emits = defineEmits<{ const emits = defineEmits<{
select: [event: SelectEvent] select: [event: SelectEvent]
}>() }>()

View File

@@ -1,7 +1,15 @@
<template> <template>
<div class="p-component p-inputtext flex items-center gap-2"> <div
:class="[
'p-component p-inputtext flex items-center gap-2 border',
'focus-within:border-[--p-inputtext-focus-border-color]',
]"
>
<slot name="prefix"> <slot name="prefix">
<span v-if="prefixIcon" :class="[prefixIcon, 'pi-inputicon']"></span> <span
v-if="prefixIcon"
:class="[prefixIcon, 'text-base opacity-60']"
></span>
</slot> </slot>
<input <input
@@ -18,17 +26,20 @@
<span <span
v-if="allowClear" v-if="allowClear"
v-show="content" v-show="content"
class="pi pi-times pi-inputicon" class="pi pi-times text-base opacity-60"
@click="clearContent" @click="clearContent"
></span> ></span>
<slot name="suffix"> <slot name="suffix">
<span v-if="suffixIcon" :class="[suffixIcon, 'pi-inputicon']"></span> <span
v-if="suffixIcon"
:class="[suffixIcon, 'text-base opacity-60']"
></span>
</slot> </slot>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref } from 'vue' import { computed, ref } from 'vue'
interface Props { interface Props {
prefixIcon?: string prefixIcon?: string
@@ -65,18 +76,3 @@ const clearContent = () => {
inputRef.value?.focus() inputRef.value?.focus()
} }
</script> </script>
<style>
.p-inputtext:focus-within {
border-color: var(--p-inputtext-focus-border-color);
box-shadow: var(--p-inputtext-focus-ring-shadow);
outline: var(--p-inputtext-focus-ring-width)
var(--p-inputtext-focus-ring-style) var(--p-inputtext-focus-ring-color);
outline-offset: var(--p-inputtext-focus-ring-offset);
}
.p-inputtext .pi-inputicon {
font-size: 1rem;
opacity: 0.6;
}
</style>

View File

@@ -60,8 +60,9 @@
</template> </template>
<script setup lang="ts" generic="T"> <script setup lang="ts" generic="T">
import { nextTick, onUnmounted, ref, watch } from 'vue' import { defineResizeCallback } from 'hooks/resize'
import { clamp, throttle } from 'lodash' import { clamp, throttle } from 'lodash'
import { nextTick, onUnmounted, ref, watch } from 'vue'
interface ScrollAreaProps { interface ScrollAreaProps {
items?: T[][] items?: T[][]
@@ -206,7 +207,7 @@ const calculateScrollThumbSize = () => {
}) })
} }
const onContainerResize: ResizeObserverCallback = throttle((entries) => { const onContainerResize = defineResizeCallback((entries) => {
emit('resize', entries) emit('resize', entries)
if (isDragging.value) return if (isDragging.value) return
@@ -298,7 +299,6 @@ const startDragThumb = (event: MouseEvent) => {
watch( watch(
() => props.items, () => props.items,
() => { () => {
init()
setSpacerSize() setSpacerSize()
calculateScrollThumbSize() calculateScrollThumbSize()
calculateLoadItems() calculateLoadItems()
@@ -311,5 +311,6 @@ onUnmounted(() => {
defineExpose({ defineExpose({
viewport, viewport,
init,
}) })
</script> </script>

View File

@@ -37,8 +37,10 @@
<div <div
v-show="showControlButton && scrollPosition !== 'left'" v-show="showControlButton && scrollPosition !== 'left'"
:class="[ :class="[
'pointer-events-none absolute left-0 top-1/2 z-10', 'pointer-events-none absolute z-10 flex h-full items-center',
'-translate-y-1/2 bg-gradient-to-r from-current to-transparent pr-16', 'top-1/2 [transform:translateY(-50%)]',
'left-0 pr-16',
'[background-image:linear-gradient(to_right,currentColor,transparent)]',
]" ]"
style="color: var(--p-dialog-background)" style="color: var(--p-dialog-background)"
> >
@@ -67,8 +69,10 @@
<div <div
v-show="showControlButton && scrollPosition !== 'right'" v-show="showControlButton && scrollPosition !== 'right'"
:class="[ :class="[
'pointer-events-none absolute right-0 top-1/2 z-10', 'pointer-events-none absolute z-10 flex h-full items-center',
'-translate-y-1/2 bg-gradient-to-l from-current to-transparent pl-16', 'top-1/2 [transform:translateY(-50%)]',
'right-0 pl-16',
'[background-image:linear-gradient(to_left,currentColor,transparent)]',
]" ]"
style="color: var(--p-dialog-background)" style="color: var(--p-dialog-background)"
> >
@@ -150,6 +154,7 @@ import { useConfig } from 'hooks/config'
import Button, { ButtonProps } from 'primevue/button' import Button, { ButtonProps } from 'primevue/button'
import Drawer from 'primevue/drawer' import Drawer from 'primevue/drawer'
import Menu from 'primevue/menu' import Menu from 'primevue/menu'
import { SelectOptions } from 'types/typings'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
const current = defineModel() const current = defineModel()

View File

@@ -1,9 +1,10 @@
import { useRequest } from 'hooks/request' import { request, useRequest } from 'hooks/request'
import { defineStore } from 'hooks/store' import { defineStore } from 'hooks/store'
import { app } from 'scripts/comfyAPI' import { $el, app, ComfyDialog } from 'scripts/comfyAPI'
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { useToast } from './toast'
export const useConfig = defineStore('config', () => { export const useConfig = defineStore('config', (store) => {
const mobileDeviceBreakPoint = 759 const mobileDeviceBreakPoint = 759
const isMobile = ref(window.innerWidth < mobileDeviceBreakPoint) const isMobile = ref(window.innerWidth < mobileDeviceBreakPoint)
@@ -36,7 +37,7 @@ export const useConfig = defineStore('config', () => {
refresh, refresh,
} }
useAddConfigSettings(config) useAddConfigSettings(store)
return config return config
}) })
@@ -49,7 +50,41 @@ declare module 'hooks/store' {
} }
} }
function useAddConfigSettings(config: Config) { function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
const { toast } = useToast()
const confirm = (opts: {
message?: string
accept?: () => void
reject?: () => void
}) => {
const dialog = new ComfyDialog('div', [])
dialog.show(
$el('div', [
$el('p', { textContent: opts.message }),
$el('div.flex.gap-4', [
$el('button.flex-1', {
textContent: 'Cancel',
onclick: () => {
opts.reject?.()
dialog.close()
document.body.removeChild(dialog.element)
},
}),
$el('button.flex-1', {
textContent: 'Continue',
onclick: () => {
opts.accept?.()
dialog.close()
document.body.removeChild(dialog.element)
},
}),
]),
]),
)
}
onMounted(() => { onMounted(() => {
// API keys // API keys
app.ui?.settings.addSetting({ app.ui?.settings.addSetting({
@@ -65,5 +100,144 @@ function useAddConfigSettings(config: Config) {
type: 'text', type: 'text',
defaultValue: undefined, defaultValue: undefined,
}) })
// Migrate
app.ui?.settings.addSetting({
id: 'ModelManager.Migrate.Migrate',
name: 'Migrate information from cdb-boop/main',
defaultValue: '',
type: () => {
return $el('button.p-button.p-component.p-button-secondary', {
textContent: 'Migrate',
onclick: () => {
confirm({
message: [
'This operation will delete old files and override current files if it exists.',
// 'This may take a while and generate MANY server requests!',
'Continue?',
].join('\n'),
accept: () => {
store.loading.loading.value = true
request('/migrate', {
method: 'POST',
})
.then(() => {
toast.add({
severity: 'success',
summary: 'Complete migration',
life: 2000,
})
store.models.refresh()
})
.catch((err) => {
toast.add({
severity: 'error',
summary: 'Error',
detail: err.message ?? 'Failed to migrate information',
life: 15000,
})
})
.finally(() => {
store.loading.loading.value = false
})
},
})
},
})
},
})
// Scan information
app.ui?.settings.addSetting({
id: 'ModelManager.ScanFiles.Full',
name: "Override all models' information and preview",
defaultValue: '',
type: () => {
return $el('button.p-button.p-component.p-button-secondary', {
textContent: 'Full Scan',
onclick: () => {
confirm({
message: [
'This operation will override current files.',
'This may take a while and generate MANY server requests!',
'USE AT YOUR OWN RISK! Continue?',
].join('\n'),
accept: () => {
store.loading.loading.value = true
request('/model-info/scan', {
method: 'POST',
body: JSON.stringify({ scanMode: 'full' }),
})
.then(() => {
toast.add({
severity: 'success',
summary: 'Complete download information',
life: 2000,
})
store.models.refresh()
})
.catch((err) => {
toast.add({
severity: 'error',
summary: 'Error',
detail: err.message ?? 'Failed to download information',
life: 15000,
})
})
.finally(() => {
store.loading.loading.value = false
})
},
})
},
})
},
})
app.ui?.settings.addSetting({
id: 'ModelManager.ScanFiles.Incremental',
name: 'Download missing information or preview',
defaultValue: '',
type: () => {
return $el('button.p-button.p-component.p-button-secondary', {
textContent: 'Diff Scan',
onclick: () => {
confirm({
message: [
'Download missing information or preview.',
'This may take a while and generate MANY server requests!',
'USE AT YOUR OWN RISK! Continue?',
].join('\n'),
accept: () => {
store.loading.loading.value = true
request('/model-info/scan', {
method: 'POST',
body: JSON.stringify({ scanMode: 'diff' }),
})
.then(() => {
toast.add({
severity: 'success',
summary: 'Complete download information',
life: 2000,
})
store.models.refresh()
})
.catch((err) => {
toast.add({
severity: 'error',
summary: 'Error',
detail: err.message ?? 'Failed to download information',
life: 15000,
})
})
.finally(() => {
store.loading.loading.value = false
})
},
})
},
})
},
})
}) })
} }

View File

@@ -1,7 +1,9 @@
import { defineStore } from 'hooks/store' import { defineStore } from 'hooks/store'
import { ContainerSize } from 'types/typings'
import { Component, markRaw, ref } from 'vue' import { Component, markRaw, ref } from 'vue'
interface HeaderButton { interface HeaderButton {
key: string
icon: string icon: string
command: () => void command: () => void
} }

View File

@@ -1,8 +1,15 @@
import { useLoading } from 'hooks/loading' import { useLoading } from 'hooks/loading'
import { MarkdownTool, useMarkdown } from 'hooks/markdown' import { request } from 'hooks/request'
import { socket } from 'hooks/socket'
import { defineStore } from 'hooks/store' import { defineStore } from 'hooks/store'
import { useToast } from 'hooks/toast' import { useToast } from 'hooks/toast'
import { api } from 'scripts/comfyAPI'
import {
BaseModel,
DownloadTask,
DownloadTaskOptions,
SelectOptions,
VersionModel,
} from 'types/typings'
import { bytesToSize } from 'utils/common' import { bytesToSize } from 'utils/common'
import { onBeforeMount, onMounted, ref, watch } from 'vue' import { onBeforeMount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -13,10 +20,6 @@ export const useDownload = defineStore('download', (store) => {
const taskList = ref<DownloadTask[]>([]) const taskList = ref<DownloadTask[]>([])
const refresh = () => {
socket.send('downloadTaskList', null)
}
const createTaskItem = (item: DownloadTaskOptions) => { const createTaskItem = (item: DownloadTaskOptions) => {
const { downloadedSize, totalSize, bps, ...rest } = item const { downloadedSize, totalSize, bps, ...rest } = item
@@ -26,10 +29,20 @@ export const useDownload = defineStore('download', (store) => {
downloadProgress: `${bytesToSize(downloadedSize)} / ${bytesToSize(totalSize)}`, downloadProgress: `${bytesToSize(downloadedSize)} / ${bytesToSize(totalSize)}`,
downloadSpeed: `${bytesToSize(bps)}/s`, downloadSpeed: `${bytesToSize(bps)}/s`,
pauseTask() { pauseTask() {
socket.send('pauseDownloadTask', item.taskId) request(`/download/${item.taskId}`, {
method: 'PUT',
body: JSON.stringify({
status: 'pause',
}),
})
}, },
resumeTask: () => { resumeTask: () => {
socket.send('resumeDownloadTask', item.taskId) request(`/download/${item.taskId}`, {
method: 'PUT',
body: JSON.stringify({
status: 'resume',
}),
})
}, },
deleteTask: () => { deleteTask: () => {
confirm.require({ confirm.require({
@@ -46,7 +59,9 @@ export const useDownload = defineStore('download', (store) => {
severity: 'danger', severity: 'danger',
}, },
accept: () => { accept: () => {
socket.send('deleteDownloadTask', item.taskId) request(`/download/${item.taskId}`, {
method: 'DELETE',
})
}, },
reject: () => {}, reject: () => {},
}) })
@@ -56,12 +71,28 @@ export const useDownload = defineStore('download', (store) => {
return task return task
} }
const refresh = async () => {
return request('/download/task')
.then((resData: DownloadTaskOptions[]) => {
taskList.value = resData.map((item) => createTaskItem(item))
return taskList.value
})
.catch((err) => {
toast.add({
severity: 'error',
summary: 'Error',
detail: err.message ?? 'Failed to refresh download task list',
life: 15000,
})
})
}
onBeforeMount(() => { onBeforeMount(() => {
socket.addEventListener('reconnected', () => { api.addEventListener('reconnected', () => {
refresh() refresh()
}) })
socket.addEventListener('downloadTaskList', (event) => { api.addEventListener('fetch_download_task_list', (event) => {
const data = event.detail as DownloadTaskOptions[] const data = event.detail as DownloadTaskOptions[]
taskList.value = data.map((item) => { taskList.value = data.map((item) => {
@@ -69,12 +100,12 @@ export const useDownload = defineStore('download', (store) => {
}) })
}) })
socket.addEventListener('createDownloadTask', (event) => { api.addEventListener('create_download_task', (event) => {
const item = event.detail as DownloadTaskOptions const item = event.detail as DownloadTaskOptions
taskList.value.unshift(createTaskItem(item)) taskList.value.unshift(createTaskItem(item))
}) })
socket.addEventListener('updateDownloadTask', (event) => { api.addEventListener('update_download_task', (event) => {
const item = event.detail as DownloadTaskOptions const item = event.detail as DownloadTaskOptions
for (const task of taskList.value) { for (const task of taskList.value) {
@@ -93,12 +124,12 @@ export const useDownload = defineStore('download', (store) => {
} }
}) })
socket.addEventListener('deleteDownloadTask', (event) => { api.addEventListener('delete_download_task', (event) => {
const taskId = event.detail as string const taskId = event.detail as string
taskList.value = taskList.value.filter((item) => item.taskId !== taskId) taskList.value = taskList.value.filter((item) => item.taskId !== taskId)
}) })
socket.addEventListener('completeDownloadTask', (event) => { api.addEventListener('complete_download_task', (event) => {
const taskId = event.detail as string const taskId = event.detail as string
const task = taskList.value.find((item) => item.taskId === taskId) const task = taskList.value.find((item) => item.taskId === taskId)
taskList.value = taskList.value.filter((item) => item.taskId !== taskId) taskList.value = taskList.value.filter((item) => item.taskId !== taskId)
@@ -125,253 +156,8 @@ declare module 'hooks/store' {
} }
} }
abstract class ModelSearch {
constructor(readonly md: MarkdownTool) {}
abstract search(pathname: string): Promise<VersionModel[]>
}
class Civitai extends ModelSearch {
async search(searchUrl: string): Promise<VersionModel[]> {
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<VersionModel[]> {
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<VersionModel[]> {
return Promise.reject(
new Error(
'Unknown Website, please input a URL from huggingface.co or civitai.com.',
),
)
}
}
export const useModelSearch = () => { export const useModelSearch = () => {
const loading = useLoading() const loading = useLoading()
const md = useMarkdown()
const { toast } = useToast() const { toast } = useToast()
const data = ref<(SelectOptions & { item: VersionModel })[]>([]) const data = ref<(SelectOptions & { item: VersionModel })[]>([])
const current = ref<string | number>() const current = ref<string | number>()
@@ -382,22 +168,9 @@ export const useModelSearch = () => {
return Promise.resolve([]) 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() loading.show()
return instance return request(`/model-info?model-page=${encodeURIComponent(url)}`, {})
.search(url) .then((resData: VersionModel[]) => {
.then((resData) => {
data.value = resData.map((item) => ({ data.value = resData.map((item) => ({
label: item.shortname, label: item.shortname,
value: item.id, value: item.id,

View File

@@ -31,6 +31,12 @@ export const useGlobalLoading = defineStore('loading', () => {
return { loading } return { loading }
}) })
declare module 'hooks/store' {
interface StoreProvider {
loading: ReturnType<typeof useGlobalLoading>
}
}
export const useLoading = () => { export const useLoading = () => {
const timer = ref<NodeJS.Timeout>() const timer = ref<NodeJS.Timeout>()

View File

@@ -1,6 +1,5 @@
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import metadata_block from 'markdown-it-metadata-block' import metadata_block from 'markdown-it-metadata-block'
import TurndownService from 'turndown'
import yaml from 'yaml' import yaml from 'yaml'
interface MarkdownOptions { interface MarkdownOptions {
@@ -31,19 +30,7 @@ export const useMarkdown = (opts?: MarkdownOptions) => {
return self.renderToken(tokens, idx, options) return self.renderToken(tokens, idx, options)
} }
const turndown = new TurndownService({ return { render: md.render.bind(md) }
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<typeof useMarkdown> export type MarkdownTool = ReturnType<typeof useMarkdown>

View File

@@ -6,7 +6,8 @@ import { defineStore } from 'hooks/store'
import { useToast } from 'hooks/toast' import { useToast } from 'hooks/toast'
import { cloneDeep } from 'lodash' import { cloneDeep } from 'lodash'
import { app } from 'scripts/comfyAPI' import { app } from 'scripts/comfyAPI'
import { bytesToSize, formatDate, previewUrlToFile } from 'utils/common' import { BaseModel, Model, SelectEvent } from 'types/typings'
import { bytesToSize, formatDate } from 'utils/common'
import { ModelGrid } from 'utils/legacy' import { ModelGrid } from 'utils/legacy'
import { genModelKey, resolveModelTypeLoader } from 'utils/model' import { genModelKey, resolveModelTypeLoader } from 'utils/model'
import { import {
@@ -28,18 +29,17 @@ export const useModels = defineStore('models', (store) => {
const loading = useLoading() const loading = useLoading()
const updateModel = async (model: BaseModel, data: BaseModel) => { const updateModel = async (model: BaseModel, data: BaseModel) => {
const formData = new FormData() const updateData = new Map()
let oldKey: string | null = null let oldKey: string | null = null
// Check current preview // Check current preview
if (model.preview !== data.preview) { if (model.preview !== data.preview) {
const previewFile = await previewUrlToFile(data.preview as string) updateData.set('previewFile', data.preview)
formData.append('previewFile', previewFile)
} }
// Check current description // Check current description
if (model.description !== data.description) { if (model.description !== data.description) {
formData.append('description', data.description) updateData.set('description', data.description)
} }
// Check current name and pathIndex // Check current name and pathIndex
@@ -48,19 +48,19 @@ export const useModels = defineStore('models', (store) => {
model.pathIndex !== data.pathIndex model.pathIndex !== data.pathIndex
) { ) {
oldKey = genModelKey(model) oldKey = genModelKey(model)
formData.append('type', data.type) updateData.set('type', data.type)
formData.append('pathIndex', data.pathIndex.toString()) updateData.set('pathIndex', data.pathIndex.toString())
formData.append('fullname', data.fullname) updateData.set('fullname', data.fullname)
} }
if (formData.keys().next().done) { if (updateData.size === 0) {
return return
} }
loading.show() loading.show()
await request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, { await request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
method: 'PUT', method: 'PUT',
body: formData, body: JSON.stringify(Object.fromEntries(updateData.entries())),
}) })
.catch((err) => { .catch((err) => {
const error_message = err.message ?? err.error const error_message = err.message ?? err.error
@@ -245,14 +245,17 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
interface FieldsItem { interface FieldsItem {
key: keyof Model key: keyof Model
formatter: (val: any) => string formatter: (val: any) => string | undefined | null
} }
const baseInfo = computed(() => { const baseInfo = computed(() => {
const fields: FieldsItem[] = [ const fields: FieldsItem[] = [
{ {
key: 'type', key: 'type',
formatter: () => modelData.value.type, formatter: () =>
modelData.value.type in modelFolders.value
? modelData.value.type
: undefined,
}, },
{ {
key: 'pathIndex', key: 'pathIndex',

View File

@@ -1,82 +0,0 @@
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()

View File

@@ -13,7 +13,7 @@ export const useStoreProvider = () => {
return storeEvent return storeEvent
} }
const storeKeys = new Map<string, Symbol>() const storeKeys = new Map<string, symbol>()
const getStoreKey = (key: string) => { const getStoreKey = (key: string) => {
let storeKey = storeKeys.get(key) let storeKey = storeKeys.get(key)

View File

@@ -12,7 +12,7 @@ export const useToast = () => {
globalToast.value = toast globalToast.value = toast
const wrapperToastError = <T extends Function>(callback: T): T => { const wrapperToastError = <T extends CallableFunction>(callback: T): T => {
const showToast = (error: Error) => { const showToast = (error: Error) => {
toast.add({ toast.add({
severity: 'error', severity: 'error',

View File

@@ -5,3 +5,4 @@ export const $el = window.comfyAPI.ui.$el
export const ComfyApp = window.comfyAPI.app.ComfyApp export const ComfyApp = window.comfyAPI.app.ComfyApp
export const ComfyButton = window.comfyAPI.button.ComfyButton export const ComfyButton = window.comfyAPI.button.ComfyButton
export const ComfyDialog = window.comfyAPI.dialog.ComfyDialog

View File

@@ -3,220 +3,8 @@
@layer tailwind-utilities { @layer tailwind-utilities {
@tailwind components; @tailwind components;
@tailwind utilities; @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 { .comfy-modal {
z-index: 3000; z-index: 3000;
} }
.markdown-it {
font-family: theme('fontFamily.sans');
font-size: theme('fontSize.base');
line-height: theme('lineHeight.relaxed');
word-break: break-word;
margin: 0;
&::before {
display: table;
content: '';
}
&::after {
display: table;
content: '';
clear: both;
}
> *:first-child {
margin-top: 0 !important;
}
> *:last-child {
margin-bottom: 0 !important;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 1.5em;
margin-bottom: 1em;
font-weight: 600;
line-height: 1.25;
}
h1 {
font-size: 2em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--p-surface-700);
}
h2 {
font-size: 1.5em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--p-surface-700);
}
h3 {
font-size: 1.25em;
}
h4 {
font-size: 1em;
}
h5 {
font-size: 0.875em;
}
h6 {
font-size: 0.85em;
color: var(--p-surface-500);
}
a {
color: #1e8bc3;
text-decoration: none;
word-break: break-all;
}
a:hover {
text-decoration: underline;
}
p,
blockquote,
ul,
ol,
dl,
table,
pre,
details {
margin-top: 0;
margin-bottom: 1em;
}
p img {
width: 100%;
height: 100%;
object-fit: cover;
}
ul,
ol {
padding-left: 2em;
}
li {
margin: 0.5em 0;
}
blockquote {
padding: 0px 1em;
border-left: 0.25em solid var(--p-surface-500);
color: var(--p-surface-500);
margin: 1em 0;
}
blockquote > *:first-child {
margin-top: 0;
}
blockquote > *:last-child {
margin-bottom: 0;
}
pre {
font-size: 85%;
border-radius: 6px;
padding: 8px 16px;
overflow-x: auto;
background: var(--p-dialog-background);
filter: invert(10%);
}
pre code,
pre tt {
display: inline;
padding: 0;
margin: 0;
overflow: visible;
line-height: inherit;
word-wrap: normal;
background-color: transparent;
border: 0;
}
}

10
src/types/global.d.ts vendored
View File

@@ -112,6 +112,7 @@ declare namespace ComfyAPI {
settings: ComfySettingsDialog settings: ComfySettingsDialog
menuHamburger?: HTMLDivElement menuHamburger?: HTMLDivElement
menuContainer?: HTMLDivElement menuContainer?: HTMLDivElement
dialog: dialog.ComfyDialog
} }
type SettingInputType = type SettingInputType =
@@ -197,6 +198,15 @@ declare namespace ComfyAPI {
constructor(...buttons: (HTMLElement | ComfyButton)[]): ComfyButtonGroup constructor(...buttons: (HTMLElement | ComfyButton)[]): ComfyButtonGroup
} }
} }
namespace dialog {
class ComfyDialog {
constructor(type = 'div', buttons: HTMLElement[] = null)
element: HTMLElement
close(): void
show(html: string | HTMLElement | HTMLElement[]): void
}
}
} }
declare namespace lightGraph { declare namespace lightGraph {

View File

@@ -1,7 +1,7 @@
type ContainerSize = { width: number; height: number } export type ContainerSize = { width: number; height: number }
type ContainerPosition = { left: number; top: number } export type ContainerPosition = { left: number; top: number }
interface BaseModel { export interface BaseModel {
id: number | string id: number | string
fullname: string fullname: string
basename: string basename: string
@@ -14,37 +14,37 @@ interface BaseModel {
metadata: Record<string, string> metadata: Record<string, string>
} }
interface Model extends BaseModel { export interface Model extends BaseModel {
createdAt: number createdAt: number
updatedAt: number updatedAt: number
} }
interface VersionModel extends BaseModel { export interface VersionModel extends BaseModel {
shortname: string shortname: string
downloadPlatform: string downloadPlatform: string
downloadUrl: string downloadUrl: string
hashes?: Record<string, string> hashes?: Record<string, string>
} }
type PassThrough<T = void> = T | object | undefined export type PassThrough<T = void> = T | object | undefined
interface SelectOptions { export interface SelectOptions {
label: string label: string
value: any value: any
icon?: string icon?: string
command: () => void command: () => void
} }
interface SelectFile extends File { export interface SelectFile extends File {
objectURL: string objectURL: string
} }
interface SelectEvent { export interface SelectEvent {
files: SelectFile[] files: SelectFile[]
originalEvent: Event originalEvent: Event
} }
interface DownloadTaskOptions { export interface DownloadTaskOptions {
taskId: string taskId: string
type: string type: string
fullname: string fullname: string
@@ -57,7 +57,7 @@ interface DownloadTaskOptions {
error?: string error?: string
} }
interface DownloadTask export interface DownloadTask
extends Omit< extends Omit<
DownloadTaskOptions, DownloadTaskOptions,
'downloadedSize' | 'totalSize' | 'bps' | 'error' 'downloadedSize' | 'totalSize' | 'bps' | 'error'
@@ -69,4 +69,4 @@ interface DownloadTask
deleteTask: () => void deleteTask: () => void
} }
type CustomEventListener = (event: CustomEvent) => void export type CustomEventListener = (event: CustomEvent) => void

View File

@@ -26,14 +26,3 @@ export const bytesToSize = (
export const formatDate = (date: number | string | Date) => { export const formatDate = (date: number | string | Date) => {
return dayjs(date).format('YYYY-MM-DD HH:mm:ss') 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
})
}

View File

@@ -1,4 +1,3 @@
// @ts-nocheck
import { app } from 'scripts/comfyAPI' import { app } from 'scripts/comfyAPI'
const LiteGraph = window.LiteGraph const LiteGraph = window.LiteGraph

View File

@@ -1,3 +1,5 @@
import { BaseModel } from 'types/typings'
const loader = { const loader = {
checkpoints: 'CheckpointLoaderSimple', checkpoints: 'CheckpointLoaderSimple',
loras: 'LoraLoader', loras: 'LoraLoader',

View File

@@ -20,21 +20,13 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"downlevelIteration": true, "downlevelIteration": true,
/* AllowJs during migration phase */
"allowJs": true,
"baseUrl": ".",
"outDir": "./web",
"rootDir": "./",
"paths": { "paths": {
"components/*": ["src/components/*"], "components/*": ["./src/components/*"],
"hooks/*": ["src/hooks/*"], "hooks/*": ["./src/hooks/*"],
"scripts/*": ["src/scripts/*"], "scripts/*": ["./src/scripts/*"],
"types/*": ["src/types/*"], "types/*": ["./src/types/*"],
"utils/*": ["src/utils/*"], "utils/*": ["./src/utils/*"]
} }
}, },
"include": [ "include": ["./src/**/*"]
"src/**/*", }
"src/**/*.vue",
]
}