Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30e1714397 | ||
|
|
384a106917 | ||
|
|
7378a7deae | ||
|
|
1975e2056d | ||
|
|
8877c1599b | ||
|
|
965905305e | ||
|
|
312138f981 | ||
|
|
76df8cd3cb | ||
|
|
df17eae0a2 | ||
|
|
7df89c7265 | ||
|
|
450072e49d | ||
|
|
759865e8ea | ||
|
|
304978a7b8 | ||
|
|
704f35a1a8 | ||
|
|
ce42960d57 | ||
|
|
05fa31f2c5 | ||
|
|
ea26ec5098 | ||
|
|
3d01c2dfda | ||
|
|
59552841e7 | ||
|
|
ad6045f286 | ||
|
|
86c11e5343 | ||
|
|
37be9a0b0d | ||
|
|
fcea052dde | ||
|
|
9e95e7bd74 | ||
|
|
7e58d0a82d | ||
|
|
55a4eff01b | ||
|
|
45cf18299f | ||
|
|
c7898c47f1 | ||
|
|
17ab373b9c | ||
|
|
f6368fe20b | ||
|
|
92f2d5ab9e | ||
|
|
130c75f5bf | ||
|
|
921dabc057 | ||
|
|
ac21c8015d | ||
|
|
123b46fa88 | ||
|
|
6a77554932 | ||
|
|
faf4c15865 | ||
|
|
f079d8bde5 | ||
|
|
56a2deb4eb | ||
|
|
448ea4b1ba | ||
|
|
e5d9950429 | ||
|
|
e7e2f4ce78 | ||
|
|
0575124d35 | ||
|
|
4df226be82 | ||
|
|
1ba80fab2e | ||
|
|
b9e637049a | ||
|
|
bfccc6f04f | ||
|
|
89c249542a | ||
|
|
136bc0ecd5 | ||
|
|
8653af1f14 | ||
|
|
354b5c840a | ||
|
|
be383ac6e1 | ||
|
|
c2406a1fd1 | ||
|
|
4132b2d8c4 | ||
|
|
40a1a7f43a | ||
|
|
14bb6f194d | ||
|
|
97b26549ce | ||
|
|
e75275dfff | ||
|
|
c1e89eb177 | ||
|
|
bfedcb2a7d | ||
|
|
1d01ce009f | ||
|
|
5c017137b0 | ||
|
|
00d23ff74f | ||
|
|
dc46f498be | ||
|
|
6d67b00b17 | ||
|
|
cda24405b5 | ||
|
|
6fa90be8c4 | ||
|
|
5a28789af7 | ||
|
|
dada903b2b | ||
|
|
e8916307aa | ||
|
|
8b6c6ebdea | ||
|
|
1796b101c5 | ||
|
|
bd874e5ff3 | ||
|
|
6a64f3050a | ||
|
|
659637c6e0 | ||
|
|
6ae7e1835f | ||
|
|
4038e240f0 | ||
|
|
254ad8c597 | ||
|
|
dfae915b77 | ||
|
|
f57ffc9e7a | ||
|
|
6904aca24c |
39
.github/workflows/eslint.yml
vendored
Normal file
39
.github/workflows/eslint.yml
vendored
Normal 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
40
.github/workflows/format.yml
vendored
Normal 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}'
|
||||||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -60,7 +60,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run build
|
pnpm run build
|
||||||
tar -czf dist.tar.gz py/ web/ __init__.py LICENSE pyproject.toml
|
tar -czf dist.tar.gz py/ web/ __init__.py LICENSE pyproject.toml requirements.txt
|
||||||
|
|
||||||
- name: Create release draft
|
- name: Create release draft
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
|
|||||||
29
.github/workflows/pylint.yml
vendored
Normal file
29
.github/workflows/pylint.yml
vendored
Normal 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
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -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,9 @@ 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.
|
||||||
|
|||||||
257
__init__.py
257
__init__.py
@@ -1,247 +1,44 @@
|
|||||||
import os
|
import os
|
||||||
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__))
|
||||||
|
|
||||||
|
# Install requirements
|
||||||
|
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()
|
|
||||||
|
|
||||||
|
# Try to download web distribution
|
||||||
version = utils.get_current_version()
|
version = utils.get_current_version()
|
||||||
utils.download_web_distribution(version)
|
utils.download_web_distribution(version)
|
||||||
|
|
||||||
|
|
||||||
import logging
|
# Add api routes
|
||||||
from aiohttp import web
|
from .py import manager
|
||||||
import traceback
|
from .py import download
|
||||||
from .py import services
|
from .py import information
|
||||||
|
|
||||||
|
|
||||||
routes = config.routes
|
routes = config.routes
|
||||||
|
|
||||||
|
manager.ModelManager().add_routes(routes)
|
||||||
@routes.get("/model-manager/download/task")
|
download.ModelDownload().add_routes(routes)
|
||||||
async def scan_download_tasks(request):
|
information.Information().add_routes(routes)
|
||||||
"""
|
|
||||||
Read download task list.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
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}"
|
|
||||||
logging.error(error_msg)
|
|
||||||
logging.debug(traceback.format_exc())
|
|
||||||
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)}"
|
|
||||||
logging.error(error_msg)
|
|
||||||
logging.debug(traceback.format_exc())
|
|
||||||
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)}"
|
|
||||||
logging.error(error_msg)
|
|
||||||
logging.debug(traceback.format_exc())
|
|
||||||
return web.json_response({"success": False, "error": error_msg})
|
|
||||||
|
|
||||||
|
|
||||||
@routes.get("/model-manager/base-folders")
|
|
||||||
async def get_model_paths(request):
|
|
||||||
"""
|
|
||||||
Returns the base folders for models.
|
|
||||||
"""
|
|
||||||
model_base_paths = config.model_base_paths
|
|
||||||
return web.json_response({"success": True, "data": model_base_paths})
|
|
||||||
|
|
||||||
|
|
||||||
@routes.post("/model-manager/model")
|
|
||||||
async def create_model(request):
|
|
||||||
"""
|
|
||||||
Create a new model.
|
|
||||||
|
|
||||||
request body: x-www-form-urlencoded
|
|
||||||
- type: model type.
|
|
||||||
- pathIndex: index of the model folders.
|
|
||||||
- fullname: filename that relative to the model folder.
|
|
||||||
- previewFile: preview file.
|
|
||||||
- description: description.
|
|
||||||
- downloadPlatform: download platform.
|
|
||||||
- downloadUrl: download url.
|
|
||||||
- hash: a JSON string containing the hash value of the downloaded model.
|
|
||||||
"""
|
|
||||||
post = await request.post()
|
|
||||||
try:
|
|
||||||
task_id = await services.create_model_download_task(post, request)
|
|
||||||
return web.json_response({"success": True, "data": {"taskId": task_id}})
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Create model download task failed: {str(e)}"
|
|
||||||
logging.error(error_msg)
|
|
||||||
logging.debug(traceback.format_exc())
|
|
||||||
return web.json_response({"success": False, "error": error_msg})
|
|
||||||
|
|
||||||
|
|
||||||
@routes.get("/model-manager/models")
|
|
||||||
async def read_models(request):
|
|
||||||
"""
|
|
||||||
Scan all models and read their information.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = services.scan_models()
|
|
||||||
return web.json_response({"success": True, "data": result})
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Read models failed: {str(e)}"
|
|
||||||
logging.error(error_msg)
|
|
||||||
logging.debug(traceback.format_exc())
|
|
||||||
return web.json_response({"success": False, "error": error_msg})
|
|
||||||
|
|
||||||
|
|
||||||
@routes.get("/model-manager/model/{type}/{index}/{filename:.*}")
|
|
||||||
async def read_model_info(request):
|
|
||||||
"""
|
|
||||||
Get the information of the specified model.
|
|
||||||
"""
|
|
||||||
model_type = request.match_info.get("type", None)
|
|
||||||
index = int(request.match_info.get("index", None))
|
|
||||||
filename = request.match_info.get("filename", None)
|
|
||||||
|
|
||||||
try:
|
|
||||||
model_path = utils.get_valid_full_path(model_type, index, filename)
|
|
||||||
result = services.get_model_info(model_path)
|
|
||||||
return web.json_response({"success": True, "data": result})
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Read model info failed: {str(e)}"
|
|
||||||
logging.error(error_msg)
|
|
||||||
logging.debug(traceback.format_exc())
|
|
||||||
return web.json_response({"success": False, "error": error_msg})
|
|
||||||
|
|
||||||
|
|
||||||
@routes.put("/model-manager/model/{type}/{index}/{filename:.*}")
|
|
||||||
async def update_model(request):
|
|
||||||
"""
|
|
||||||
Update model information.
|
|
||||||
|
|
||||||
request body: x-www-form-urlencoded
|
|
||||||
- previewFile: preview file.
|
|
||||||
- description: description.
|
|
||||||
- type: model type.
|
|
||||||
- pathIndex: index of the model folders.
|
|
||||||
- fullname: filename that relative to the model folder.
|
|
||||||
All fields are optional, but type, pathIndex and fullname must appear together.
|
|
||||||
"""
|
|
||||||
model_type = request.match_info.get("type", None)
|
|
||||||
index = int(request.match_info.get("index", None))
|
|
||||||
filename = request.match_info.get("filename", None)
|
|
||||||
|
|
||||||
post: dict = await request.post()
|
|
||||||
|
|
||||||
try:
|
|
||||||
model_path = utils.get_valid_full_path(model_type, index, filename)
|
|
||||||
if model_path is None:
|
|
||||||
raise RuntimeError(f"File {filename} not found")
|
|
||||||
services.update_model(model_path, post)
|
|
||||||
return web.json_response({"success": True})
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Update model failed: {str(e)}"
|
|
||||||
logging.error(error_msg)
|
|
||||||
logging.debug(traceback.format_exc())
|
|
||||||
return web.json_response({"success": False, "error": error_msg})
|
|
||||||
|
|
||||||
|
|
||||||
@routes.delete("/model-manager/model/{type}/{index}/{filename:.*}")
|
|
||||||
async def delete_model(request):
|
|
||||||
"""
|
|
||||||
Delete model.
|
|
||||||
"""
|
|
||||||
model_type = request.match_info.get("type", None)
|
|
||||||
index = int(request.match_info.get("index", None))
|
|
||||||
filename = request.match_info.get("filename", None)
|
|
||||||
|
|
||||||
try:
|
|
||||||
model_path = utils.get_valid_full_path(model_type, index, filename)
|
|
||||||
if model_path is None:
|
|
||||||
raise RuntimeError(f"File {filename} not found")
|
|
||||||
services.remove_model(model_path)
|
|
||||||
return web.json_response({"success": True})
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Delete model failed: {str(e)}"
|
|
||||||
logging.error(error_msg)
|
|
||||||
logging.debug(traceback.format_exc())
|
|
||||||
return web.json_response({"success": False, "error": error_msg})
|
|
||||||
|
|
||||||
|
|
||||||
@routes.get("/model-manager/preview/{type}/{index}/{filename:.*}")
|
|
||||||
async def read_model_preview(request):
|
|
||||||
"""
|
|
||||||
Get the file stream of the specified image.
|
|
||||||
If the file does not exist, no-preview.png is returned.
|
|
||||||
|
|
||||||
:param type: The type of the model. eg.checkpoints, loras, vae, etc.
|
|
||||||
:param index: The index of the model folders.
|
|
||||||
:param filename: The filename of the image.
|
|
||||||
"""
|
|
||||||
model_type = request.match_info.get("type", None)
|
|
||||||
index = int(request.match_info.get("index", None))
|
|
||||||
filename = request.match_info.get("filename", None)
|
|
||||||
|
|
||||||
extension_uri = config.extension_uri
|
|
||||||
|
|
||||||
try:
|
|
||||||
folders = folder_paths.get_folder_paths(model_type)
|
|
||||||
base_path = folders[index]
|
|
||||||
abs_path = utils.join_path(base_path, filename)
|
|
||||||
except:
|
|
||||||
abs_path = extension_uri
|
|
||||||
|
|
||||||
if not os.path.isfile(abs_path):
|
|
||||||
abs_path = utils.join_path(extension_uri, "assets", "no-preview.png")
|
|
||||||
return web.FileResponse(abs_path)
|
|
||||||
|
|
||||||
|
|
||||||
@routes.get("/model-manager/preview/download/{filename}")
|
|
||||||
async def read_download_preview(request):
|
|
||||||
filename = request.match_info.get("filename", None)
|
|
||||||
extension_uri = config.extension_uri
|
|
||||||
|
|
||||||
download_path = utils.get_download_path()
|
|
||||||
preview_path = utils.join_path(download_path, filename)
|
|
||||||
|
|
||||||
if not os.path.isfile(preview_path):
|
|
||||||
preview_path = utils.join_path(extension_uri, "assets", "no-preview.png")
|
|
||||||
|
|
||||||
return web.FileResponse(preview_path)
|
|
||||||
|
|
||||||
|
|
||||||
WEB_DIRECTORY = "web"
|
WEB_DIRECTORY = "web"
|
||||||
|
|||||||
BIN
demo/scan-model-info.png
Executable file
BIN
demo/scan-model-info.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
@@ -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',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
16
package.json
16
package.json
@@ -6,18 +6,18 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"lint": "eslint src",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
|
||||||
"@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,25 +27,25 @@
|
|||||||
"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",
|
||||||
|
"@vueuse/core": "^11.3.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"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"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
335
pnpm-lock.yaml
generated
335
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@primevue/themes':
|
'@primevue/themes':
|
||||||
specifier: ^4.0.7
|
specifier: ^4.0.7
|
||||||
version: 4.0.7
|
version: 4.0.7
|
||||||
|
'@vueuse/core':
|
||||||
|
specifier: ^11.3.0
|
||||||
|
version: 11.3.0(vue@3.5.6(typescript@5.6.2))
|
||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.13
|
specifier: ^1.11.13
|
||||||
version: 1.11.13
|
version: 1.11.13
|
||||||
@@ -26,9 +29,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)
|
||||||
@@ -39,9 +39,6 @@ importers:
|
|||||||
specifier: ^2.6.0
|
specifier: ^2.6.0
|
||||||
version: 2.6.0
|
version: 2.6.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@tailwindcss/container-queries':
|
|
||||||
specifier: ^0.1.1
|
|
||||||
version: 0.1.1(tailwindcss@3.4.12)
|
|
||||||
'@types/lodash':
|
'@types/lodash':
|
||||||
specifier: ^4.17.9
|
specifier: ^4.17.9
|
||||||
version: 4.17.9
|
version: 4.17.9
|
||||||
@@ -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'}
|
||||||
@@ -463,11 +470,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@tailwindcss/container-queries@0.1.1':
|
|
||||||
resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==}
|
|
||||||
peerDependencies:
|
|
||||||
tailwindcss: '>=3.2.0'
|
|
||||||
|
|
||||||
'@types/estree@1.0.5':
|
'@types/estree@1.0.5':
|
||||||
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
|
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
|
||||||
|
|
||||||
@@ -486,11 +488,11 @@ 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':
|
'@types/web-bluetooth@0.0.20':
|
||||||
resolution: {integrity: sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==}
|
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.6.0':
|
'@typescript-eslint/eslint-plugin@8.13.0':
|
||||||
resolution: {integrity: sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==}
|
resolution: {integrity: sha512-nQtBLiZYMUPkclSeC3id+x4uVd1SGtHuElTxL++SfP47jR0zfkZBJHc+gL4qPsgTuypz0k8Y2GheaDYn6Gy3rg==}
|
||||||
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 +502,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 +512,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 +525,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 +538,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 +555,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 +576,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==}
|
||||||
|
|
||||||
@@ -585,6 +607,15 @@ packages:
|
|||||||
'@vue/shared@3.5.6':
|
'@vue/shared@3.5.6':
|
||||||
resolution: {integrity: sha512-eidH0HInnL39z6wAt6SFIwBrvGOpDWsDxlw3rCgo1B+CQ1781WzQUSU3YjxgdkcJo9Q8S6LmXTkvI+cLHGkQfA==}
|
resolution: {integrity: sha512-eidH0HInnL39z6wAt6SFIwBrvGOpDWsDxlw3rCgo1B+CQ1781WzQUSU3YjxgdkcJo9Q8S6LmXTkvI+cLHGkQfA==}
|
||||||
|
|
||||||
|
'@vueuse/core@11.3.0':
|
||||||
|
resolution: {integrity: sha512-7OC4Rl1f9G8IT6rUfi9JrKiXy4bfmHhZ5x2Ceojy0jnd3mHNEvV4JaRygH362ror6/NZ+Nl+n13LPzGiPN8cKA==}
|
||||||
|
|
||||||
|
'@vueuse/metadata@11.3.0':
|
||||||
|
resolution: {integrity: sha512-pwDnDspTqtTo2HwfLw4Rp6yywuuBdYnPYDq+mO38ZYKGebCUQC/nVj/PXSiK9HX5otxLz8Fn7ECPbjiRz2CC3g==}
|
||||||
|
|
||||||
|
'@vueuse/shared@11.3.0':
|
||||||
|
resolution: {integrity: sha512-P8gSSWQeucH5821ek2mn/ciCk+MS/zoRKqdQIM3bHq6p7GXDAJLmnRRKmF5F65sAVJIfzQlwR3aDzwCn10s8hA==}
|
||||||
|
|
||||||
acorn-jsx@5.3.2:
|
acorn-jsx@5.3.2:
|
||||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -598,6 +629,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 +767,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 +972,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 +990,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 +1197,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 +1273,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 +1597,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 +1609,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 +1617,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 +1680,20 @@ packages:
|
|||||||
terser:
|
terser:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vscode-uri@3.0.8:
|
||||||
|
resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==}
|
||||||
|
|
||||||
|
vue-demi@0.14.10:
|
||||||
|
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@vue/composition-api': ^1.0.0-rc.1
|
||||||
|
vue: ^3.0.0-0 || ^2.6.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@vue/composition-api':
|
||||||
|
optional: true
|
||||||
|
|
||||||
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 +1706,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 +1851,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 +1932,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
|
||||||
@@ -1945,10 +2018,6 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc@4.22.0':
|
'@rollup/rollup-win32-x64-msvc@4.22.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.12)':
|
|
||||||
dependencies:
|
|
||||||
tailwindcss: 3.4.12
|
|
||||||
|
|
||||||
'@types/estree@1.0.5': {}
|
'@types/estree@1.0.5': {}
|
||||||
|
|
||||||
'@types/linkify-it@5.0.0': {}
|
'@types/linkify-it@5.0.0': {}
|
||||||
@@ -1966,32 +2035,32 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.19.8
|
undici-types: 6.19.8
|
||||||
|
|
||||||
'@types/turndown@5.0.5': {}
|
'@types/web-bluetooth@0.0.20': {}
|
||||||
|
|
||||||
'@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)':
|
||||||
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 +2068,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 +2123,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 +2165,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
|
||||||
@@ -2110,6 +2209,25 @@ snapshots:
|
|||||||
|
|
||||||
'@vue/shared@3.5.6': {}
|
'@vue/shared@3.5.6': {}
|
||||||
|
|
||||||
|
'@vueuse/core@11.3.0(vue@3.5.6(typescript@5.6.2))':
|
||||||
|
dependencies:
|
||||||
|
'@types/web-bluetooth': 0.0.20
|
||||||
|
'@vueuse/metadata': 11.3.0
|
||||||
|
'@vueuse/shared': 11.3.0(vue@3.5.6(typescript@5.6.2))
|
||||||
|
vue-demi: 0.14.10(vue@3.5.6(typescript@5.6.2))
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
- vue
|
||||||
|
|
||||||
|
'@vueuse/metadata@11.3.0': {}
|
||||||
|
|
||||||
|
'@vueuse/shared@11.3.0(vue@3.5.6(typescript@5.6.2))':
|
||||||
|
dependencies:
|
||||||
|
vue-demi: 0.14.10(vue@3.5.6(typescript@5.6.2))
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
- vue
|
||||||
|
|
||||||
acorn-jsx@5.3.2(acorn@8.12.1):
|
acorn-jsx@5.3.2(acorn@8.12.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.12.1
|
acorn: 8.12.1
|
||||||
@@ -2123,6 +2241,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 +2368,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 +2615,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 +2628,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 +2827,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 +2896,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 +2963,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 +3172,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 +3180,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 +3225,12 @@ snapshots:
|
|||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
less: 4.2.0
|
less: 4.2.0
|
||||||
|
|
||||||
|
vscode-uri@3.0.8: {}
|
||||||
|
|
||||||
|
vue-demi@0.14.10(vue@3.5.6(typescript@5.6.2)):
|
||||||
|
dependencies:
|
||||||
|
vue: 3.5.6(typescript@5.6.2)
|
||||||
|
|
||||||
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 +3251,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
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
extension_tag = "ComfyUI Model Manager"
|
||||||
|
|
||||||
extension_uri: str = None
|
extension_uri: str = None
|
||||||
model_base_paths: dict[str, list[str]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
setting_key = {
|
setting_key = {
|
||||||
@@ -10,6 +11,9 @@ setting_key = {
|
|||||||
"download": {
|
"download": {
|
||||||
"max_task_count": "ModelManager.Download.MaxTaskCount",
|
"max_task_count": "ModelManager.Download.MaxTaskCount",
|
||||||
},
|
},
|
||||||
|
"scan": {
|
||||||
|
"include_hidden_files": "ModelManager.Scan.IncludeHiddenFiles"
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
user_agent = "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
|
user_agent = "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
|
||||||
|
|||||||
697
py/download.py
697
py/download.py
@@ -1,12 +1,15 @@
|
|||||||
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 aiohttp import web
|
||||||
|
|
||||||
|
|
||||||
from . import config
|
from . import config
|
||||||
from . import utils
|
from . import utils
|
||||||
from . import thread
|
from . import thread
|
||||||
@@ -26,6 +29,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:
|
||||||
@@ -38,326 +69,416 @@ class TaskContent:
|
|||||||
sizeBytes: float
|
sizeBytes: float
|
||||||
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 = float(kwargs.get("sizeBytes", 0))
|
||||||
|
self.hashes = kwargs.get("hashes", None)
|
||||||
|
|
||||||
download_model_task_status: dict[str, TaskStatus] = {}
|
def to_dict(self):
|
||||||
download_thread_pool = thread.DownloadThreadPool()
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def set_task_content(task_id: str, task_content: Union[TaskContent, dict]):
|
class ModelDownload:
|
||||||
download_path = utils.get_download_path()
|
def add_routes(self, routes):
|
||||||
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))
|
|
||||||
|
|
||||||
|
@routes.get("/model-manager/download/task")
|
||||||
|
async def scan_download_tasks(request):
|
||||||
|
"""
|
||||||
|
Read download task list.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await self.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})
|
||||||
|
|
||||||
def get_task_content(task_id: str):
|
@routes.put("/model-manager/download/{task_id}")
|
||||||
download_path = utils.get_download_path()
|
async def resume_download_task(request):
|
||||||
task_file = utils.join_path(download_path, f"{task_id}.task")
|
"""
|
||||||
if not os.path.isfile(task_file):
|
Toggle download task status.
|
||||||
raise RuntimeError(f"Task {task_id} not found")
|
"""
|
||||||
task_content = utils.load_dict_pickle_file(task_file)
|
try:
|
||||||
task_content["pathIndex"] = int(task_content.get("pathIndex", 0))
|
task_id = request.match_info.get("task_id", None)
|
||||||
task_content["sizeBytes"] = float(task_content.get("sizeBytes", 0))
|
if task_id is None:
|
||||||
return TaskContent(**task_content)
|
raise web.HTTPBadRequest(reason="Invalid task id")
|
||||||
|
json_data = await request.json()
|
||||||
|
status = json_data.get("status", None)
|
||||||
|
if status == "pause":
|
||||||
|
await self.pause_model_download_task(task_id)
|
||||||
|
elif status == "resume":
|
||||||
|
await self.download_model(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})
|
||||||
|
|
||||||
def get_task_status(task_id: str):
|
@routes.delete("/model-manager/download/{task_id}")
|
||||||
task_status = download_model_task_status.get(task_id, None)
|
async def delete_model_download_task(request):
|
||||||
|
"""
|
||||||
|
Delete download task.
|
||||||
|
"""
|
||||||
|
task_id = request.match_info.get("task_id", None)
|
||||||
|
try:
|
||||||
|
await self.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})
|
||||||
|
|
||||||
if task_status is None:
|
@routes.post("/model-manager/model")
|
||||||
|
async def create_model(request):
|
||||||
|
"""
|
||||||
|
Create a new model.
|
||||||
|
|
||||||
|
request body: x-www-form-urlencoded
|
||||||
|
- type: model type.
|
||||||
|
- pathIndex: index of the model folders.
|
||||||
|
- fullname: filename that relative to the model folder.
|
||||||
|
- previewFile: preview file.
|
||||||
|
- description: description.
|
||||||
|
- downloadPlatform: download platform.
|
||||||
|
- downloadUrl: download url.
|
||||||
|
- hash: a JSON string containing the hash value of the downloaded model.
|
||||||
|
"""
|
||||||
|
task_data = await request.post()
|
||||||
|
task_data = dict(task_data)
|
||||||
|
try:
|
||||||
|
task_id = await self.create_model_download_task(task_data, request)
|
||||||
|
return web.json_response({"success": True, "data": {"taskId": task_id}})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Create model download task failed: {str(e)}"
|
||||||
|
utils.print_error(error_msg)
|
||||||
|
return web.json_response({"success": False, "error": error_msg})
|
||||||
|
|
||||||
|
download_model_task_status: dict[str, TaskStatus] = {}
|
||||||
|
|
||||||
|
download_thread_pool = thread.DownloadThreadPool()
|
||||||
|
|
||||||
|
def set_task_content(self, task_id: str, task_content: Union[TaskContent, dict]):
|
||||||
download_path = utils.get_download_path()
|
download_path = utils.get_download_path()
|
||||||
task_content = get_task_content(task_id)
|
task_file_path = utils.join_path(download_path, f"{task_id}.task")
|
||||||
download_file = utils.join_path(download_path, f"{task_id}.download")
|
utils.save_dict_pickle_file(task_file_path, task_content)
|
||||||
download_size = 0
|
|
||||||
if os.path.exists(download_file):
|
|
||||||
download_size = os.path.getsize(download_file)
|
|
||||||
|
|
||||||
total_size = task_content.sizeBytes
|
def get_task_content(self, task_id: str):
|
||||||
task_status = TaskStatus(
|
download_path = utils.get_download_path()
|
||||||
taskId=task_id,
|
task_file = utils.join_path(download_path, f"{task_id}.task")
|
||||||
type=task_content.type,
|
if not os.path.isfile(task_file):
|
||||||
fullname=task_content.fullname,
|
raise RuntimeError(f"Task {task_id} not found")
|
||||||
preview=utils.get_model_preview_name(download_file),
|
task_content = utils.load_dict_pickle_file(task_file)
|
||||||
platform=task_content.downloadPlatform,
|
if isinstance(task_content, TaskContent):
|
||||||
downloadedSize=download_size,
|
return task_content
|
||||||
totalSize=task_content.sizeBytes,
|
return TaskContent(**task_content)
|
||||||
progress=download_size / total_size * 100 if total_size > 0 else 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
download_model_task_status[task_id] = task_status
|
def get_task_status(self, task_id: str):
|
||||||
|
task_status = self.download_model_task_status.get(task_id, None)
|
||||||
|
|
||||||
return task_status
|
if task_status is None:
|
||||||
|
download_path = utils.get_download_path()
|
||||||
|
task_content = self.get_task_content(task_id)
|
||||||
|
download_file = utils.join_path(download_path, f"{task_id}.download")
|
||||||
|
download_size = 0
|
||||||
|
if os.path.exists(download_file):
|
||||||
|
download_size = os.path.getsize(download_file)
|
||||||
|
|
||||||
|
total_size = task_content.sizeBytes
|
||||||
def delete_task_status(task_id: str):
|
task_status = TaskStatus(
|
||||||
download_model_task_status.pop(task_id, None)
|
taskId=task_id,
|
||||||
|
type=task_content.type,
|
||||||
|
fullname=task_content.fullname,
|
||||||
async def scan_model_download_task_list():
|
preview=utils.get_model_preview_name(download_file),
|
||||||
"""
|
platform=task_content.downloadPlatform,
|
||||||
Scan the download directory and send the task list to the client.
|
downloadedSize=download_size,
|
||||||
"""
|
totalSize=task_content.sizeBytes,
|
||||||
download_dir = utils.get_download_path()
|
progress=download_size / total_size * 100 if total_size > 0 else 0,
|
||||||
task_files = utils.search_files(download_dir)
|
|
||||||
task_files = folder_paths.filter_files_extensions(task_files, [".task"])
|
|
||||||
task_files = sorted(
|
|
||||||
task_files,
|
|
||||||
key=lambda x: os.stat(utils.join_path(download_dir, x)).st_ctime,
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
task_list: list[dict] = []
|
|
||||||
for task_file in task_files:
|
|
||||||
task_id = task_file.replace(".task", "")
|
|
||||||
task_status = get_task_status(task_id)
|
|
||||||
task_list.append(task_status)
|
|
||||||
|
|
||||||
return utils.unpack_dataclass(task_list)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_model_download_task(post: dict, request):
|
|
||||||
"""
|
|
||||||
Creates a download task for the given post.
|
|
||||||
"""
|
|
||||||
model_type = post.get("type", None)
|
|
||||||
path_index = int(post.get("pathIndex", None))
|
|
||||||
fullname = post.get("fullname", None)
|
|
||||||
|
|
||||||
model_path = utils.get_full_path(model_type, path_index, fullname)
|
|
||||||
# Check if the model path is valid
|
|
||||||
if os.path.exists(model_path):
|
|
||||||
raise RuntimeError(f"File already exists: {model_path}")
|
|
||||||
|
|
||||||
download_path = utils.get_download_path()
|
|
||||||
|
|
||||||
task_id = uuid.uuid4().hex
|
|
||||||
task_path = utils.join_path(download_path, f"{task_id}.task")
|
|
||||||
if os.path.exists(task_path):
|
|
||||||
raise RuntimeError(f"Task {task_id} already exists")
|
|
||||||
|
|
||||||
try:
|
|
||||||
previewFile = post.pop("previewFile", None)
|
|
||||||
utils.save_model_preview_image(task_path, previewFile)
|
|
||||||
set_task_content(task_id, post)
|
|
||||||
task_status = TaskStatus(
|
|
||||||
taskId=task_id,
|
|
||||||
type=model_type,
|
|
||||||
fullname=fullname,
|
|
||||||
preview=utils.get_model_preview_name(task_path),
|
|
||||||
platform=post.get("downloadPlatform", None),
|
|
||||||
totalSize=float(post.get("sizeBytes", 0)),
|
|
||||||
)
|
|
||||||
download_model_task_status[task_id] = task_status
|
|
||||||
await utils.send_json("create_download_task", task_status)
|
|
||||||
except Exception as e:
|
|
||||||
await delete_model_download_task(task_id)
|
|
||||||
raise RuntimeError(str(e)) from e
|
|
||||||
|
|
||||||
await download_model(task_id, request)
|
|
||||||
return task_id
|
|
||||||
|
|
||||||
|
|
||||||
async def pause_model_download_task(task_id: str):
|
|
||||||
task_status = get_task_status(task_id=task_id)
|
|
||||||
task_status.status = "pause"
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_model_download_task(task_id: str):
|
|
||||||
task_status = get_task_status(task_id)
|
|
||||||
is_running = task_status.status == "doing"
|
|
||||||
task_status.status = "waiting"
|
|
||||||
await utils.send_json("delete_download_task", task_id)
|
|
||||||
|
|
||||||
# Pause the task
|
|
||||||
if is_running:
|
|
||||||
task_status.status = "pause"
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
download_dir = utils.get_download_path()
|
|
||||||
task_file_list = os.listdir(download_dir)
|
|
||||||
for task_file in task_file_list:
|
|
||||||
task_file_target = os.path.splitext(task_file)[0]
|
|
||||||
if task_file_target == task_id:
|
|
||||||
delete_task_status(task_id)
|
|
||||||
os.remove(utils.join_path(download_dir, task_file))
|
|
||||||
|
|
||||||
await utils.send_json("delete_download_task", task_id)
|
|
||||||
|
|
||||||
|
|
||||||
async def download_model(task_id: str, request):
|
|
||||||
async def download_task(task_id: str):
|
|
||||||
async def report_progress(task_status: TaskStatus):
|
|
||||||
await utils.send_json("update_download_task", task_status)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# When starting a task from the queue, the task may not exist
|
|
||||||
task_status = get_task_status(task_id)
|
|
||||||
except:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Update task status
|
|
||||||
task_status.status = "doing"
|
|
||||||
await utils.send_json("update_download_task", task_status)
|
|
||||||
|
|
||||||
try:
|
|
||||||
|
|
||||||
# Set download request headers
|
|
||||||
headers = {"User-Agent": config.user_agent}
|
|
||||||
|
|
||||||
download_platform = task_status.platform
|
|
||||||
if download_platform == "civitai":
|
|
||||||
api_key = utils.get_setting_value(request, "api_key.civitai")
|
|
||||||
if api_key:
|
|
||||||
headers["Authorization"] = f"Bearer {api_key}"
|
|
||||||
|
|
||||||
elif download_platform == "huggingface":
|
|
||||||
api_key = utils.get_setting_value(request, "api_key.huggingface")
|
|
||||||
if api_key:
|
|
||||||
headers["Authorization"] = f"Bearer {api_key}"
|
|
||||||
|
|
||||||
progress_interval = 1.0
|
|
||||||
await download_model_file(
|
|
||||||
task_id=task_id,
|
|
||||||
headers=headers,
|
|
||||||
progress_callback=report_progress,
|
|
||||||
interval=progress_interval,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.download_model_task_status[task_id] = task_status
|
||||||
|
|
||||||
|
return task_status
|
||||||
|
|
||||||
|
def delete_task_status(self, task_id: str):
|
||||||
|
self.download_model_task_status.pop(task_id, None)
|
||||||
|
|
||||||
|
async def scan_model_download_task_list(self):
|
||||||
|
"""
|
||||||
|
Scan the download directory and send the task list to the client.
|
||||||
|
"""
|
||||||
|
download_dir = utils.get_download_path()
|
||||||
|
task_files = utils.search_files(download_dir)
|
||||||
|
task_files = folder_paths.filter_files_extensions(task_files, [".task"])
|
||||||
|
task_files = sorted(
|
||||||
|
task_files,
|
||||||
|
key=lambda x: os.stat(utils.join_path(download_dir, x)).st_ctime,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
task_list: list[dict] = []
|
||||||
|
for task_file in task_files:
|
||||||
|
task_id = task_file.replace(".task", "")
|
||||||
|
task_status = self.get_task_status(task_id)
|
||||||
|
task_list.append(task_status.to_dict())
|
||||||
|
|
||||||
|
return task_list
|
||||||
|
|
||||||
|
async def create_model_download_task(self, task_data: dict, request):
|
||||||
|
"""
|
||||||
|
Creates a download task for the given data.
|
||||||
|
"""
|
||||||
|
model_type = task_data.get("type", None)
|
||||||
|
path_index = int(task_data.get("pathIndex", None))
|
||||||
|
fullname = task_data.get("fullname", None)
|
||||||
|
|
||||||
|
model_path = utils.get_full_path(model_type, path_index, fullname)
|
||||||
|
# Check if the model path is valid
|
||||||
|
if os.path.exists(model_path):
|
||||||
|
raise RuntimeError(f"File already exists: {model_path}")
|
||||||
|
|
||||||
|
download_path = utils.get_download_path()
|
||||||
|
|
||||||
|
task_id = uuid.uuid4().hex
|
||||||
|
task_path = utils.join_path(download_path, f"{task_id}.task")
|
||||||
|
if os.path.exists(task_path):
|
||||||
|
raise RuntimeError(f"Task {task_id} already exists")
|
||||||
|
download_platform = task_data.get("downloadPlatform", None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
preview_file = task_data.pop("previewFile", None)
|
||||||
|
utils.save_model_preview_image(task_path, preview_file, download_platform)
|
||||||
|
self.set_task_content(task_id, task_data)
|
||||||
|
task_status = TaskStatus(
|
||||||
|
taskId=task_id,
|
||||||
|
type=model_type,
|
||||||
|
fullname=fullname,
|
||||||
|
preview=utils.get_model_preview_name(task_path),
|
||||||
|
platform=download_platform,
|
||||||
|
totalSize=float(task_data.get("sizeBytes", 0)),
|
||||||
|
)
|
||||||
|
self.download_model_task_status[task_id] = task_status
|
||||||
|
await utils.send_json("create_download_task", task_status.to_dict())
|
||||||
|
except Exception as e:
|
||||||
|
await self.delete_model_download_task(task_id)
|
||||||
|
raise RuntimeError(str(e)) from e
|
||||||
|
|
||||||
|
await self.download_model(task_id, request)
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
async def pause_model_download_task(self, task_id: str):
|
||||||
|
task_status = self.get_task_status(task_id=task_id)
|
||||||
|
task_status.status = "pause"
|
||||||
|
|
||||||
|
async def delete_model_download_task(self, task_id: str):
|
||||||
|
task_status = self.get_task_status(task_id)
|
||||||
|
is_running = task_status.status == "doing"
|
||||||
|
task_status.status = "waiting"
|
||||||
|
await utils.send_json("delete_download_task", task_id)
|
||||||
|
|
||||||
|
# Pause the task
|
||||||
|
if is_running:
|
||||||
|
task_status.status = "pause"
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
download_dir = utils.get_download_path()
|
||||||
|
task_file_list = os.listdir(download_dir)
|
||||||
|
for task_file in task_file_list:
|
||||||
|
task_file_target = os.path.splitext(task_file)[0]
|
||||||
|
if task_file_target == task_id:
|
||||||
|
self.delete_task_status(task_id)
|
||||||
|
os.remove(utils.join_path(download_dir, task_file))
|
||||||
|
|
||||||
|
await utils.send_json("delete_download_task", task_id)
|
||||||
|
|
||||||
|
async def download_model(self, task_id: str, request):
|
||||||
|
async def download_task(task_id: str):
|
||||||
|
async def report_progress(task_status: TaskStatus):
|
||||||
|
await utils.send_json("update_download_task", task_status.to_dict())
|
||||||
|
|
||||||
|
try:
|
||||||
|
# When starting a task from the queue, the task may not exist
|
||||||
|
task_status = self.get_task_status(task_id)
|
||||||
|
except:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update task status
|
||||||
|
task_status.status = "doing"
|
||||||
|
await utils.send_json("update_download_task", task_status.to_dict())
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
# Set download request headers
|
||||||
|
headers = {"User-Agent": config.user_agent}
|
||||||
|
|
||||||
|
download_platform = task_status.platform
|
||||||
|
if download_platform == "civitai":
|
||||||
|
api_key = utils.get_setting_value(request, "api_key.civitai")
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
|
elif download_platform == "huggingface":
|
||||||
|
api_key = utils.get_setting_value(request, "api_key.huggingface")
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
|
progress_interval = 1.0
|
||||||
|
await self.download_model_file(
|
||||||
|
task_id=task_id,
|
||||||
|
headers=headers,
|
||||||
|
progress_callback=report_progress,
|
||||||
|
interval=progress_interval,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
task_status.status = "pause"
|
||||||
|
task_status.error = str(e)
|
||||||
|
await utils.send_json("update_download_task", task_status.to_dict())
|
||||||
|
task_status.error = None
|
||||||
|
utils.print_error(str(e))
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = self.download_thread_pool.submit(download_task, task_id)
|
||||||
|
if status == "Waiting":
|
||||||
|
task_status = self.get_task_status(task_id)
|
||||||
|
task_status.status = "waiting"
|
||||||
|
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 utils.send_json("update_download_task", 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:
|
async def download_model_file(
|
||||||
status = download_thread_pool.submit(download_task, task_id)
|
self,
|
||||||
if status == "Waiting":
|
task_id: str,
|
||||||
task_status = get_task_status(task_id)
|
headers: dict,
|
||||||
task_status.status = "waiting"
|
progress_callback: Callable[[TaskStatus], Awaitable[Any]],
|
||||||
await utils.send_json("update_download_task", task_status)
|
interval: float = 1.0,
|
||||||
except Exception as e:
|
):
|
||||||
task_status.status = "pause"
|
|
||||||
task_status.error = str(e)
|
|
||||||
await utils.send_json("update_download_task", task_status)
|
|
||||||
task_status.error = None
|
|
||||||
logging.error(traceback.format_exc())
|
|
||||||
|
|
||||||
|
async def download_complete():
|
||||||
|
"""
|
||||||
|
Restore the model information from the task file
|
||||||
|
and move the model file to the target directory.
|
||||||
|
"""
|
||||||
|
model_type = task_content.type
|
||||||
|
path_index = task_content.pathIndex
|
||||||
|
fullname = task_content.fullname
|
||||||
|
# Write description file
|
||||||
|
description = task_content.description
|
||||||
|
description_file = utils.join_path(download_path, f"{task_id}.md")
|
||||||
|
with open(description_file, "w", encoding="utf-8", newline="") as f:
|
||||||
|
f.write(description)
|
||||||
|
|
||||||
async def download_model_file(
|
model_path = utils.get_full_path(model_type, path_index, fullname)
|
||||||
task_id: str,
|
|
||||||
headers: dict,
|
|
||||||
progress_callback: Callable[[TaskStatus], Awaitable[Any]],
|
|
||||||
interval: float = 1.0,
|
|
||||||
):
|
|
||||||
|
|
||||||
async def download_complete():
|
utils.rename_model(download_tmp_file, model_path)
|
||||||
"""
|
|
||||||
Restore the model information from the task file
|
|
||||||
and move the model file to the target directory.
|
|
||||||
"""
|
|
||||||
model_type = task_content.type
|
|
||||||
path_index = task_content.pathIndex
|
|
||||||
fullname = task_content.fullname
|
|
||||||
# Write description file
|
|
||||||
description = task_content.description
|
|
||||||
description_file = utils.join_path(download_path, f"{task_id}.md")
|
|
||||||
with open(description_file, "w", encoding="utf-8", newline="") as f:
|
|
||||||
f.write(description)
|
|
||||||
|
|
||||||
model_path = utils.get_full_path(model_type, path_index, fullname)
|
time.sleep(1)
|
||||||
|
task_file = utils.join_path(download_path, f"{task_id}.task")
|
||||||
|
os.remove(task_file)
|
||||||
|
await utils.send_json("complete_download_task", task_id)
|
||||||
|
|
||||||
utils.rename_model(download_tmp_file, model_path)
|
async def update_progress():
|
||||||
|
nonlocal last_update_time
|
||||||
|
nonlocal last_downloaded_size
|
||||||
|
progress = (downloaded_size / total_size) * 100 if total_size > 0 else 0
|
||||||
|
task_status.downloadedSize = downloaded_size
|
||||||
|
task_status.progress = progress
|
||||||
|
task_status.bps = downloaded_size - last_downloaded_size
|
||||||
|
await progress_callback(task_status)
|
||||||
|
last_update_time = time.time()
|
||||||
|
last_downloaded_size = downloaded_size
|
||||||
|
|
||||||
time.sleep(1)
|
task_status = self.get_task_status(task_id)
|
||||||
task_file = utils.join_path(download_path, f"{task_id}.task")
|
task_content = self.get_task_content(task_id)
|
||||||
os.remove(task_file)
|
|
||||||
await utils.send_json("complete_download_task", task_id)
|
# Check download uri
|
||||||
|
model_url = task_content.downloadUrl
|
||||||
|
if not model_url:
|
||||||
|
raise RuntimeError("No downloadUrl found")
|
||||||
|
|
||||||
|
download_path = utils.get_download_path()
|
||||||
|
download_tmp_file = utils.join_path(download_path, f"{task_id}.download")
|
||||||
|
|
||||||
|
downloaded_size = 0
|
||||||
|
if os.path.isfile(download_tmp_file):
|
||||||
|
downloaded_size = os.path.getsize(download_tmp_file)
|
||||||
|
headers["Range"] = f"bytes={downloaded_size}-"
|
||||||
|
|
||||||
|
total_size = task_content.sizeBytes
|
||||||
|
|
||||||
|
if total_size > 0 and downloaded_size == total_size:
|
||||||
|
await download_complete()
|
||||||
|
return
|
||||||
|
|
||||||
async def update_progress():
|
|
||||||
nonlocal last_update_time
|
|
||||||
nonlocal last_downloaded_size
|
|
||||||
progress = (downloaded_size / total_size) * 100 if total_size > 0 else 0
|
|
||||||
task_status.downloadedSize = downloaded_size
|
|
||||||
task_status.progress = progress
|
|
||||||
task_status.bps = downloaded_size - last_downloaded_size
|
|
||||||
await progress_callback(task_status)
|
|
||||||
last_update_time = time.time()
|
last_update_time = time.time()
|
||||||
last_downloaded_size = downloaded_size
|
last_downloaded_size = downloaded_size
|
||||||
|
|
||||||
task_status = get_task_status(task_id)
|
response = requests.get(
|
||||||
task_content = get_task_content(task_id)
|
url=model_url,
|
||||||
|
headers=headers,
|
||||||
# Check download uri
|
stream=True,
|
||||||
model_url = task_content.downloadUrl
|
allow_redirects=True,
|
||||||
if not model_url:
|
|
||||||
raise RuntimeError("No downloadUrl found")
|
|
||||||
|
|
||||||
download_path = utils.get_download_path()
|
|
||||||
download_tmp_file = utils.join_path(download_path, f"{task_id}.download")
|
|
||||||
|
|
||||||
downloaded_size = 0
|
|
||||||
if os.path.isfile(download_tmp_file):
|
|
||||||
downloaded_size = os.path.getsize(download_tmp_file)
|
|
||||||
headers["Range"] = f"bytes={downloaded_size}-"
|
|
||||||
|
|
||||||
total_size = task_content.sizeBytes
|
|
||||||
|
|
||||||
if total_size > 0 and downloaded_size == total_size:
|
|
||||||
await download_complete()
|
|
||||||
return
|
|
||||||
|
|
||||||
last_update_time = time.time()
|
|
||||||
last_downloaded_size = downloaded_size
|
|
||||||
|
|
||||||
response = requests.get(
|
|
||||||
url=model_url,
|
|
||||||
headers=headers,
|
|
||||||
stream=True,
|
|
||||||
allow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code not in (200, 206):
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Failed to download {task_content.fullname}, status code: {response.status_code}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Some models require logging in before they can be downloaded.
|
if response.status_code not in (200, 206):
|
||||||
# If no token is carried, it will be redirected to the login page.
|
raise RuntimeError(f"Failed to download {task_content.fullname}, status code: {response.status_code}")
|
||||||
content_type = response.headers.get("content-type")
|
|
||||||
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, let’s 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(
|
|
||||||
f"{task_content.fullname} needs to be logged in to download. Please set the API-Key first."
|
|
||||||
)
|
|
||||||
|
|
||||||
# When parsing model information from HuggingFace API,
|
# Some models require logging in before they can be downloaded.
|
||||||
# the file size was not found and needs to be obtained from the response header.
|
# If no token is carried, it will be redirected to the login page.
|
||||||
if total_size == 0:
|
content_type = response.headers.get("content-type")
|
||||||
total_size = int(response.headers.get("content-length", 0))
|
if content_type and content_type.startswith("text/html"):
|
||||||
task_content.sizeBytes = total_size
|
# TODO More checks
|
||||||
task_status.totalSize = total_size
|
# In addition to requiring login to download, there may be other restrictions.
|
||||||
set_task_content(task_id, task_content)
|
# The currently one situation is early access??? issues#43
|
||||||
await utils.send_json("update_download_task", task_content)
|
# Due to the lack of test data, let’s 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(f"{task_content.fullname} needs to be logged in to download. Please set the API-Key first.")
|
||||||
|
|
||||||
with open(download_tmp_file, "ab") as f:
|
# When parsing model information from HuggingFace API,
|
||||||
for chunk in response.iter_content(chunk_size=8192):
|
# the file size was not found and needs to be obtained from the response header.
|
||||||
if task_status.status == "pause":
|
if total_size == 0:
|
||||||
break
|
total_size = float(response.headers.get("content-length", 0))
|
||||||
|
task_content.sizeBytes = total_size
|
||||||
|
task_status.totalSize = total_size
|
||||||
|
self.set_task_content(task_id, task_content)
|
||||||
|
await utils.send_json("update_download_task", task_content.to_dict())
|
||||||
|
|
||||||
f.write(chunk)
|
with open(download_tmp_file, "ab") as f:
|
||||||
downloaded_size += len(chunk)
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
if task_status.status == "pause":
|
||||||
|
break
|
||||||
|
|
||||||
if time.time() - last_update_time >= interval:
|
f.write(chunk)
|
||||||
await update_progress()
|
downloaded_size += len(chunk)
|
||||||
|
|
||||||
await update_progress()
|
if time.time() - last_update_time >= interval:
|
||||||
|
await update_progress()
|
||||||
|
|
||||||
if total_size > 0 and downloaded_size == total_size:
|
await update_progress()
|
||||||
await download_complete()
|
|
||||||
else:
|
if total_size > 0 and downloaded_size == total_size:
|
||||||
task_status.status = "pause"
|
await download_complete()
|
||||||
await utils.send_json("update_download_task", task_status)
|
else:
|
||||||
|
task_status.status = "pause"
|
||||||
|
await utils.send_json("update_download_task", task_status.to_dict())
|
||||||
|
|||||||
505
py/information.py
Normal file
505
py/information.py
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import math
|
||||||
|
import yaml
|
||||||
|
import requests
|
||||||
|
import markdownify
|
||||||
|
|
||||||
|
import folder_paths
|
||||||
|
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
from PIL import Image
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
|
||||||
|
from . import utils
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
name = file.get("name", None)
|
||||||
|
extension = os.path.splitext(name)[1]
|
||||||
|
basename = os.path.splitext(name)[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,
|
||||||
|
"basename": basename,
|
||||||
|
"extension": extension,
|
||||||
|
"preview": metadata_info.get("preview"),
|
||||||
|
"sizeBytes": file.get("sizeKB", 0) * 1024,
|
||||||
|
"type": self._resolve_model_type(res_data.get("type", "")),
|
||||||
|
"pathIndex": 0,
|
||||||
|
"subFolder": "",
|
||||||
|
"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": "",
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
"basename": basename,
|
||||||
|
"extension": extension,
|
||||||
|
"preview": image_files,
|
||||||
|
"sizeBytes": 0,
|
||||||
|
"type": "",
|
||||||
|
"pathIndex": 0,
|
||||||
|
"subFolder": "",
|
||||||
|
"description": "\n".join(description_parts),
|
||||||
|
"metadata": {},
|
||||||
|
"downloadPlatform": "huggingface",
|
||||||
|
"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
|
||||||
|
|
||||||
|
|
||||||
|
class Information:
|
||||||
|
def add_routes(self, routes):
|
||||||
|
|
||||||
|
@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 = self.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 self.download_model_info(scan_mode, request)
|
||||||
|
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})
|
||||||
|
|
||||||
|
@routes.get("/model-manager/preview/{type}/{index}/{filename:.*}")
|
||||||
|
async def read_model_preview(request):
|
||||||
|
"""
|
||||||
|
Get the file stream of the specified image.
|
||||||
|
If the file does not exist, no-preview.png is returned.
|
||||||
|
|
||||||
|
:param type: The type of the model. eg.checkpoints, loras, vae, etc.
|
||||||
|
:param index: The index of the model folders.
|
||||||
|
:param filename: The filename of the image.
|
||||||
|
"""
|
||||||
|
model_type = request.match_info.get("type", None)
|
||||||
|
index = int(request.match_info.get("index", None))
|
||||||
|
filename = request.match_info.get("filename", None)
|
||||||
|
|
||||||
|
content_type = utils.resolve_file_content_type(filename)
|
||||||
|
|
||||||
|
if content_type == "video":
|
||||||
|
abs_path = utils.get_full_path(model_type, index, filename)
|
||||||
|
return web.FileResponse(abs_path)
|
||||||
|
|
||||||
|
extension_uri = config.extension_uri
|
||||||
|
|
||||||
|
try:
|
||||||
|
folders = folder_paths.get_folder_paths(model_type)
|
||||||
|
base_path = folders[index]
|
||||||
|
abs_path = utils.join_path(base_path, filename)
|
||||||
|
preview_name = utils.get_model_preview_name(abs_path)
|
||||||
|
if preview_name:
|
||||||
|
dir_name = os.path.dirname(abs_path)
|
||||||
|
abs_path = utils.join_path(dir_name, preview_name)
|
||||||
|
except:
|
||||||
|
abs_path = extension_uri
|
||||||
|
|
||||||
|
if not os.path.isfile(abs_path):
|
||||||
|
abs_path = utils.join_path(extension_uri, "assets", "no-preview.png")
|
||||||
|
|
||||||
|
image_data = self.get_image_preview_data(abs_path)
|
||||||
|
return web.Response(body=image_data.getvalue(), content_type="image/webp")
|
||||||
|
|
||||||
|
@routes.get("/model-manager/preview/download/{filename}")
|
||||||
|
async def read_download_preview(request):
|
||||||
|
filename = request.match_info.get("filename", None)
|
||||||
|
extension_uri = config.extension_uri
|
||||||
|
|
||||||
|
download_path = utils.get_download_path()
|
||||||
|
preview_path = utils.join_path(download_path, filename)
|
||||||
|
|
||||||
|
if not os.path.isfile(preview_path):
|
||||||
|
preview_path = utils.join_path(extension_uri, "assets", "no-preview.png")
|
||||||
|
|
||||||
|
return web.FileResponse(preview_path)
|
||||||
|
|
||||||
|
def get_image_preview_data(self, filename: str):
|
||||||
|
with Image.open(filename) as img:
|
||||||
|
max_size = 1024
|
||||||
|
original_format = img.format
|
||||||
|
|
||||||
|
exif_data = img.info.get("exif")
|
||||||
|
icc_profile = img.info.get("icc_profile")
|
||||||
|
|
||||||
|
if getattr(img, "is_animated", False) and img.n_frames > 1:
|
||||||
|
total_frames = img.n_frames
|
||||||
|
step = max(1, math.ceil(total_frames / 30))
|
||||||
|
|
||||||
|
frames, durations = [], []
|
||||||
|
|
||||||
|
for frame_idx in range(0, total_frames, step):
|
||||||
|
img.seek(frame_idx)
|
||||||
|
frame = img.copy()
|
||||||
|
frame.thumbnail((max_size, max_size), Image.Resampling.NEAREST)
|
||||||
|
|
||||||
|
frames.append(frame)
|
||||||
|
durations.append(img.info.get("duration", 100) * step)
|
||||||
|
|
||||||
|
save_args = {
|
||||||
|
"format": "WEBP",
|
||||||
|
"save_all": True,
|
||||||
|
"append_images": frames[1:],
|
||||||
|
"duration": durations,
|
||||||
|
"loop": 0,
|
||||||
|
"quality": 80,
|
||||||
|
"method": 0,
|
||||||
|
"allow_mixed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if exif_data:
|
||||||
|
save_args["exif"] = exif_data
|
||||||
|
|
||||||
|
if icc_profile:
|
||||||
|
save_args["icc_profile"] = icc_profile
|
||||||
|
|
||||||
|
img_byte_arr = BytesIO()
|
||||||
|
frames[0].save(img_byte_arr, **save_args)
|
||||||
|
img_byte_arr.seek(0)
|
||||||
|
return img_byte_arr
|
||||||
|
|
||||||
|
img.thumbnail((max_size, max_size), Image.Resampling.BICUBIC)
|
||||||
|
|
||||||
|
img_byte_arr = BytesIO()
|
||||||
|
save_args = {"format": "WEBP", "quality": 80}
|
||||||
|
|
||||||
|
if exif_data:
|
||||||
|
save_args["exif"] = exif_data
|
||||||
|
if icc_profile:
|
||||||
|
save_args["icc_profile"] = icc_profile
|
||||||
|
|
||||||
|
img.save(img_byte_arr, **save_args)
|
||||||
|
img_byte_arr.seek(0)
|
||||||
|
return img_byte_arr
|
||||||
|
|
||||||
|
def fetch_model_info(self, model_page: str):
|
||||||
|
if not model_page:
|
||||||
|
return []
|
||||||
|
|
||||||
|
model_searcher = self.get_model_searcher_by_url(model_page)
|
||||||
|
result = model_searcher.search_by_url(model_page)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def download_model_info(self, scan_mode: str, request):
|
||||||
|
utils.print_info(f"Download model info for {scan_mode}")
|
||||||
|
model_base_paths = utils.resolve_model_base_paths()
|
||||||
|
for model_type in model_base_paths:
|
||||||
|
|
||||||
|
folders, *others = folder_paths.folder_names_and_paths[model_type]
|
||||||
|
for path_index, base_path in enumerate(folders):
|
||||||
|
files = utils.recursive_search_files(base_path, request)
|
||||||
|
|
||||||
|
models = folder_paths.filter_files_extensions(files, folder_paths.supported_pt_extensions)
|
||||||
|
|
||||||
|
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 = utils.get_model_preview_name(abs_model_path)
|
||||||
|
abs_image_path = utils.join_path(base_path, image_name)
|
||||||
|
|
||||||
|
has_preview = os.path.isfile(abs_image_path)
|
||||||
|
|
||||||
|
description_name = utils.get_model_description_name(abs_model_path)
|
||||||
|
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 = 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.")
|
||||||
|
|
||||||
|
def get_model_searcher_by_url(self, 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()
|
||||||
245
py/manager.py
Normal file
245
py/manager.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import os
|
||||||
|
import folder_paths
|
||||||
|
from aiohttp import web
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
|
||||||
|
from . import utils
|
||||||
|
|
||||||
|
|
||||||
|
class ModelManager:
|
||||||
|
|
||||||
|
def add_routes(self, routes):
|
||||||
|
|
||||||
|
@routes.get("/model-manager/base-folders")
|
||||||
|
@utils.deprecated(reason="Use `/model-manager/models` instead.")
|
||||||
|
async def get_model_paths(request):
|
||||||
|
"""
|
||||||
|
Returns the base folders for models.
|
||||||
|
"""
|
||||||
|
model_base_paths = utils.resolve_model_base_paths()
|
||||||
|
return web.json_response({"success": True, "data": model_base_paths})
|
||||||
|
|
||||||
|
@routes.get("/model-manager/models")
|
||||||
|
async def get_folders(request):
|
||||||
|
"""
|
||||||
|
Returns the base folders for models.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = utils.resolve_model_base_paths()
|
||||||
|
return web.json_response({"success": True, "data": result})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Read models failed: {str(e)}"
|
||||||
|
utils.print_error(error_msg)
|
||||||
|
return web.json_response({"success": False, "error": error_msg})
|
||||||
|
|
||||||
|
@routes.get("/model-manager/models/{folder}")
|
||||||
|
async def get_folder_models(request):
|
||||||
|
try:
|
||||||
|
folder = request.match_info.get("folder", None)
|
||||||
|
results = self.scan_models(folder, request)
|
||||||
|
return web.json_response({"success": True, "data": results})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Read models failed: {str(e)}"
|
||||||
|
utils.print_error(error_msg)
|
||||||
|
return web.json_response({"success": False, "error": error_msg})
|
||||||
|
|
||||||
|
@routes.get("/model-manager/model/{type}/{index}/{filename:.*}")
|
||||||
|
async def get_model_info(request):
|
||||||
|
"""
|
||||||
|
Get the information of the specified model.
|
||||||
|
"""
|
||||||
|
model_type = request.match_info.get("type", None)
|
||||||
|
path_index = int(request.match_info.get("index", None))
|
||||||
|
filename = request.match_info.get("filename", None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
model_path = utils.get_valid_full_path(model_type, path_index, filename)
|
||||||
|
result = self.get_model_info(model_path)
|
||||||
|
return web.json_response({"success": True, "data": result})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Read model info failed: {str(e)}"
|
||||||
|
utils.print_error(error_msg)
|
||||||
|
return web.json_response({"success": False, "error": error_msg})
|
||||||
|
|
||||||
|
@routes.put("/model-manager/model/{type}/{index}/{filename:.*}")
|
||||||
|
async def update_model(request):
|
||||||
|
"""
|
||||||
|
Update model information.
|
||||||
|
|
||||||
|
request body: x-www-form-urlencoded
|
||||||
|
- previewFile: preview file.
|
||||||
|
- description: description.
|
||||||
|
- type: model type.
|
||||||
|
- pathIndex: index of the model folders.
|
||||||
|
- fullname: filename that relative to the model folder.
|
||||||
|
All fields are optional, but type, pathIndex and fullname must appear together.
|
||||||
|
"""
|
||||||
|
model_type = request.match_info.get("type", None)
|
||||||
|
path_index = int(request.match_info.get("index", None))
|
||||||
|
filename = request.match_info.get("filename", None)
|
||||||
|
|
||||||
|
model_data = await request.post()
|
||||||
|
model_data = dict(model_data)
|
||||||
|
|
||||||
|
try:
|
||||||
|
model_path = utils.get_valid_full_path(model_type, path_index, filename)
|
||||||
|
if model_path is None:
|
||||||
|
raise RuntimeError(f"File {filename} not found")
|
||||||
|
self.update_model(model_path, model_data)
|
||||||
|
return web.json_response({"success": True})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Update model failed: {str(e)}"
|
||||||
|
utils.print_error(error_msg)
|
||||||
|
return web.json_response({"success": False, "error": error_msg})
|
||||||
|
|
||||||
|
@routes.delete("/model-manager/model/{type}/{index}/{filename:.*}")
|
||||||
|
async def delete_model(request):
|
||||||
|
"""
|
||||||
|
Delete model.
|
||||||
|
"""
|
||||||
|
model_type = request.match_info.get("type", None)
|
||||||
|
path_index = int(request.match_info.get("index", None))
|
||||||
|
filename = request.match_info.get("filename", None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
model_path = utils.get_valid_full_path(model_type, path_index, filename)
|
||||||
|
if model_path is None:
|
||||||
|
raise RuntimeError(f"File {filename} not found")
|
||||||
|
self.remove_model(model_path)
|
||||||
|
return web.json_response({"success": True})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Delete model failed: {str(e)}"
|
||||||
|
utils.print_error(error_msg)
|
||||||
|
return web.json_response({"success": False, "error": error_msg})
|
||||||
|
|
||||||
|
def scan_models(self, folder: str, request):
|
||||||
|
result = []
|
||||||
|
|
||||||
|
include_hidden_files = utils.get_setting_value(request, "scan.include_hidden_files", False)
|
||||||
|
folders, *others = folder_paths.folder_names_and_paths[folder]
|
||||||
|
|
||||||
|
def get_file_info(entry: os.DirEntry[str], base_path: str, path_index: int):
|
||||||
|
prefix_path = utils.normalize_path(base_path)
|
||||||
|
if not prefix_path.endswith("/"):
|
||||||
|
prefix_path = f"{prefix_path}/"
|
||||||
|
|
||||||
|
is_file = entry.is_file()
|
||||||
|
relative_path = utils.normalize_path(entry.path).replace(prefix_path, "")
|
||||||
|
sub_folder = os.path.dirname(relative_path)
|
||||||
|
filename = os.path.basename(relative_path)
|
||||||
|
basename = os.path.splitext(filename)[0] if is_file else filename
|
||||||
|
extension = os.path.splitext(filename)[1] if is_file else ""
|
||||||
|
|
||||||
|
if is_file and extension not in folder_paths.supported_pt_extensions:
|
||||||
|
return None
|
||||||
|
|
||||||
|
preview_type = "image"
|
||||||
|
preview_ext = ".webp"
|
||||||
|
preview_images = utils.get_model_all_images(entry.path)
|
||||||
|
if len(preview_images) > 0:
|
||||||
|
preview_type = "image"
|
||||||
|
preview_ext = ".webp"
|
||||||
|
else:
|
||||||
|
preview_videos = utils.get_model_all_videos(entry.path)
|
||||||
|
if len(preview_videos) > 0:
|
||||||
|
preview_type = "video"
|
||||||
|
preview_ext = f".{preview_videos[0].split('.')[-1]}"
|
||||||
|
|
||||||
|
model_preview = f"/model-manager/preview/{folder}/{path_index}/{relative_path.replace(extension, preview_ext)}"
|
||||||
|
|
||||||
|
stat = entry.stat()
|
||||||
|
return {
|
||||||
|
"type": folder,
|
||||||
|
"subFolder": sub_folder,
|
||||||
|
"isFolder": not is_file,
|
||||||
|
"basename": basename,
|
||||||
|
"extension": extension,
|
||||||
|
"pathIndex": path_index,
|
||||||
|
"sizeBytes": stat.st_size if is_file else 0,
|
||||||
|
"preview": model_preview if is_file else None,
|
||||||
|
"previewType": preview_type,
|
||||||
|
"createdAt": round(stat.st_ctime_ns / 1000000),
|
||||||
|
"updatedAt": round(stat.st_mtime_ns / 1000000),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_all_files_entry(directory: str):
|
||||||
|
entries: list[os.DirEntry[str]] = []
|
||||||
|
with os.scandir(directory) as it:
|
||||||
|
for entry in it:
|
||||||
|
# Skip hidden files
|
||||||
|
if not include_hidden_files:
|
||||||
|
if entry.name.startswith("."):
|
||||||
|
continue
|
||||||
|
entries.append(entry)
|
||||||
|
if entry.is_dir():
|
||||||
|
entries.extend(get_all_files_entry(entry.path))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
for path_index, base_path in enumerate(folders):
|
||||||
|
if not os.path.exists(base_path):
|
||||||
|
continue
|
||||||
|
file_entries = get_all_files_entry(base_path)
|
||||||
|
with ThreadPoolExecutor() as executor:
|
||||||
|
futures = {executor.submit(get_file_info, entry, base_path, path_index): entry for entry in file_entries}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
file_info = future.result()
|
||||||
|
if file_info is None:
|
||||||
|
continue
|
||||||
|
result.append(file_info)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_model_info(self, model_path: str):
|
||||||
|
directory = os.path.dirname(model_path)
|
||||||
|
|
||||||
|
metadata = utils.get_model_metadata(model_path)
|
||||||
|
|
||||||
|
description_file = utils.get_model_description_name(model_path)
|
||||||
|
description_file = utils.join_path(directory, description_file)
|
||||||
|
description = None
|
||||||
|
if os.path.isfile(description_file):
|
||||||
|
with open(description_file, "r", encoding="utf-8", newline="") as f:
|
||||||
|
description = f.read()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"metadata": metadata,
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_model(self, model_path: str, model_data: dict):
|
||||||
|
|
||||||
|
if "previewFile" in model_data:
|
||||||
|
previewFile = model_data["previewFile"]
|
||||||
|
if type(previewFile) is str and previewFile == "undefined":
|
||||||
|
utils.remove_model_preview_image(model_path)
|
||||||
|
else:
|
||||||
|
utils.save_model_preview_image(model_path, previewFile)
|
||||||
|
|
||||||
|
if "description" in model_data:
|
||||||
|
description = model_data["description"]
|
||||||
|
utils.save_model_description(model_path, description)
|
||||||
|
|
||||||
|
if "type" in model_data and "pathIndex" in model_data and "fullname" in model_data:
|
||||||
|
model_type = model_data.get("type", None)
|
||||||
|
path_index = int(model_data.get("pathIndex", None))
|
||||||
|
fullname = model_data.get("fullname", None)
|
||||||
|
if model_type is None or path_index is None or fullname is None:
|
||||||
|
raise RuntimeError("Invalid type or pathIndex or fullname")
|
||||||
|
|
||||||
|
# get new path
|
||||||
|
new_model_path = utils.get_full_path(model_type, path_index, fullname)
|
||||||
|
|
||||||
|
utils.rename_model(model_path, new_model_path)
|
||||||
|
|
||||||
|
def remove_model(self, model_path: str):
|
||||||
|
model_dirname = os.path.dirname(model_path)
|
||||||
|
os.remove(model_path)
|
||||||
|
|
||||||
|
model_previews = utils.get_model_all_images(model_path)
|
||||||
|
for preview in model_previews:
|
||||||
|
os.remove(utils.join_path(model_dirname, preview))
|
||||||
|
|
||||||
|
model_descriptions = utils.get_model_all_descriptions(model_path)
|
||||||
|
for description in model_descriptions:
|
||||||
|
os.remove(utils.join_path(model_dirname, description))
|
||||||
132
py/services.py
132
py/services.py
@@ -1,132 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
import folder_paths
|
|
||||||
|
|
||||||
from multidict import MultiDictProxy
|
|
||||||
from . import config
|
|
||||||
from . import utils
|
|
||||||
from . import download
|
|
||||||
|
|
||||||
|
|
||||||
def scan_models():
|
|
||||||
result = []
|
|
||||||
model_base_paths = config.model_base_paths
|
|
||||||
for model_type in model_base_paths:
|
|
||||||
|
|
||||||
folders, extensions = folder_paths.folder_names_and_paths[model_type]
|
|
||||||
for path_index, base_path in enumerate(folders):
|
|
||||||
files = utils.recursive_search_files(base_path)
|
|
||||||
|
|
||||||
models = folder_paths.filter_files_extensions(files, extensions)
|
|
||||||
images = folder_paths.filter_files_content_types(files, ["image"])
|
|
||||||
image_dict = utils.file_list_to_name_dict(images)
|
|
||||||
|
|
||||||
for fullname in models:
|
|
||||||
fullname = utils.normalize_path(fullname)
|
|
||||||
basename = os.path.splitext(fullname)[0]
|
|
||||||
extension = os.path.splitext(fullname)[1]
|
|
||||||
|
|
||||||
abs_path = utils.join_path(base_path, fullname)
|
|
||||||
file_stats = os.stat(abs_path)
|
|
||||||
|
|
||||||
# Resolve preview
|
|
||||||
image_name = image_dict.get(basename, "no-preview.png")
|
|
||||||
abs_image_path = utils.join_path(base_path, image_name)
|
|
||||||
if os.path.isfile(abs_image_path):
|
|
||||||
image_state = os.stat(abs_image_path)
|
|
||||||
image_timestamp = round(image_state.st_mtime_ns / 1000000)
|
|
||||||
image_name = f"{image_name}?ts={image_timestamp}"
|
|
||||||
model_preview = (
|
|
||||||
f"/model-manager/preview/{model_type}/{path_index}/{image_name}"
|
|
||||||
)
|
|
||||||
|
|
||||||
model_info = {
|
|
||||||
"fullname": fullname,
|
|
||||||
"basename": basename,
|
|
||||||
"extension": extension,
|
|
||||||
"type": model_type,
|
|
||||||
"pathIndex": path_index,
|
|
||||||
"sizeBytes": file_stats.st_size,
|
|
||||||
"preview": model_preview,
|
|
||||||
"createdAt": round(file_stats.st_ctime_ns / 1000000),
|
|
||||||
"updatedAt": round(file_stats.st_mtime_ns / 1000000),
|
|
||||||
}
|
|
||||||
|
|
||||||
result.append(model_info)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def get_model_info(model_path: str):
|
|
||||||
directory = os.path.dirname(model_path)
|
|
||||||
|
|
||||||
metadata = utils.get_model_metadata(model_path)
|
|
||||||
|
|
||||||
description_file = utils.get_model_description_name(model_path)
|
|
||||||
description_file = utils.join_path(directory, description_file)
|
|
||||||
description = None
|
|
||||||
if os.path.isfile(description_file):
|
|
||||||
with open(description_file, "r", encoding="utf-8", newline="") as f:
|
|
||||||
description = f.read()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"metadata": metadata,
|
|
||||||
"description": description,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def update_model(model_path: str, post: MultiDictProxy):
|
|
||||||
|
|
||||||
if "previewFile" in post:
|
|
||||||
previewFile = post["previewFile"]
|
|
||||||
utils.save_model_preview_image(model_path, previewFile)
|
|
||||||
|
|
||||||
if "description" in post:
|
|
||||||
description = post["description"]
|
|
||||||
utils.save_model_description(model_path, description)
|
|
||||||
|
|
||||||
if "type" in post and "pathIndex" in post and "fullname" in post:
|
|
||||||
model_type = post.get("type", None)
|
|
||||||
path_index = int(post.get("pathIndex", None))
|
|
||||||
fullname = post.get("fullname", None)
|
|
||||||
if model_type is None or path_index is None or fullname is None:
|
|
||||||
raise RuntimeError("Invalid type or pathIndex or fullname")
|
|
||||||
|
|
||||||
# get new path
|
|
||||||
new_model_path = utils.get_full_path(model_type, path_index, fullname)
|
|
||||||
|
|
||||||
utils.rename_model(model_path, new_model_path)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_model(model_path: str):
|
|
||||||
model_dirname = os.path.dirname(model_path)
|
|
||||||
os.remove(model_path)
|
|
||||||
|
|
||||||
model_previews = utils.get_model_all_images(model_path)
|
|
||||||
for preview in model_previews:
|
|
||||||
os.remove(utils.join_path(model_dirname, preview))
|
|
||||||
|
|
||||||
model_descriptions = utils.get_model_all_descriptions(model_path)
|
|
||||||
for description in model_descriptions:
|
|
||||||
os.remove(utils.join_path(model_dirname, description))
|
|
||||||
|
|
||||||
|
|
||||||
async def create_model_download_task(post, request):
|
|
||||||
dict_post = dict(post)
|
|
||||||
return await download.create_model_download_task(dict_post, request)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import threading
|
import threading
|
||||||
import queue
|
import queue
|
||||||
import logging
|
|
||||||
|
from . import utils
|
||||||
|
|
||||||
|
|
||||||
class DownloadThreadPool:
|
class DownloadThreadPool:
|
||||||
@@ -50,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
|
||||||
|
|||||||
291
py/utils.py
291
py/utils.py
@@ -5,7 +5,10 @@ import shutil
|
|||||||
import tarfile
|
import tarfile
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
|
import traceback
|
||||||
import configparser
|
import configparser
|
||||||
|
import functools
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
import comfy.utils
|
import comfy.utils
|
||||||
import folder_paths
|
import folder_paths
|
||||||
@@ -15,6 +18,56 @@ 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_warning(msg, *args, **kwargs):
|
||||||
|
logging.warning(f"[{config.extension_tag}][WARNING] {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 deprecated(reason: str):
|
||||||
|
def decorator(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
print_warning(f"{func.__name__} is deprecated: {reason}")
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
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 +105,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,40 +119,56 @@ 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/")
|
|
||||||
]
|
|
||||||
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() -> dict[str, list[str]]:
|
||||||
|
"""
|
||||||
|
Resolve model base paths.
|
||||||
|
eg. { "checkpoints": ["path/to/checkpoints"] }
|
||||||
|
"""
|
||||||
folders = list(folder_paths.folder_names_and_paths.keys())
|
folders = list(folder_paths.folder_names_and_paths.keys())
|
||||||
config.model_base_paths = {}
|
model_base_paths = {}
|
||||||
|
folder_black_list = ["configs", "custom_nodes"]
|
||||||
for folder in folders:
|
for folder in folders:
|
||||||
if folder == "configs":
|
if folder in folder_black_list:
|
||||||
continue
|
|
||||||
if folder == "custom_nodes":
|
|
||||||
continue
|
continue
|
||||||
folders = folder_paths.get_folder_paths(folder)
|
folders = folder_paths.get_folder_paths(folder)
|
||||||
config.model_base_paths[folder] = [normalize_path(f) for f in folders]
|
model_base_paths[folder] = [normalize_path(f) for f in folders]
|
||||||
|
return model_base_paths
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_file_content_type(filename: str):
|
||||||
|
extension_mimetypes_cache = folder_paths.extension_mimetypes_cache
|
||||||
|
extension = filename.split(".")[-1]
|
||||||
|
if extension not in extension_mimetypes_cache:
|
||||||
|
mime_type, _ = mimetypes.guess_type(filename, strict=False)
|
||||||
|
if not mime_type:
|
||||||
|
return None
|
||||||
|
content_type = mime_type.split("/")[0]
|
||||||
|
extension_mimetypes_cache[extension] = content_type
|
||||||
|
else:
|
||||||
|
content_type = extension_mimetypes_cache[extension]
|
||||||
|
return content_type
|
||||||
|
|
||||||
|
|
||||||
def get_full_path(model_type: str, path_index: int, filename: str):
|
def get_full_path(model_type: str, path_index: int, filename: str):
|
||||||
"""
|
"""
|
||||||
Get the absolute path in the model type through string concatenation.
|
Get the absolute path in the model type through string concatenation.
|
||||||
"""
|
"""
|
||||||
folders = config.model_base_paths.get(model_type, [])
|
folders = resolve_model_base_paths().get(model_type, [])
|
||||||
if not path_index < len(folders):
|
if not path_index < len(folders):
|
||||||
raise RuntimeError(f"PathIndex {path_index} is not in {model_type}")
|
raise RuntimeError(f"PathIndex {path_index} is not in {model_type}")
|
||||||
base_path = folders[path_index]
|
base_path = folders[path_index]
|
||||||
@@ -111,7 +180,7 @@ def get_valid_full_path(model_type: str, path_index: int, filename: str):
|
|||||||
"""
|
"""
|
||||||
Like get_full_path but it will check whether the file is valid.
|
Like get_full_path but it will check whether the file is valid.
|
||||||
"""
|
"""
|
||||||
folders = config.model_base_paths.get(model_type, [])
|
folders = resolve_model_base_paths().get(model_type, [])
|
||||||
if not path_index < len(folders):
|
if not path_index < len(folders):
|
||||||
raise RuntimeError(f"PathIndex {path_index} is not in {model_type}")
|
raise RuntimeError(f"PathIndex {path_index} is not in {model_type}")
|
||||||
base_path = folders[path_index]
|
base_path = folders[path_index]
|
||||||
@@ -119,9 +188,7 @@ def get_valid_full_path(model_type: str, path_index: int, filename: str):
|
|||||||
if os.path.isfile(full_path):
|
if os.path.isfile(full_path):
|
||||||
return full_path
|
return full_path
|
||||||
elif os.path.islink(full_path):
|
elif os.path.islink(full_path):
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"WARNING path {full_path} exists but doesn't link anywhere, skipping.")
|
||||||
f"WARNING path {full_path} exists but doesn't link anywhere, skipping."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_download_path():
|
def get_download_path():
|
||||||
@@ -131,11 +198,29 @@ def get_download_path():
|
|||||||
return download_path
|
return download_path
|
||||||
|
|
||||||
|
|
||||||
def recursive_search_files(directory: str):
|
def recursive_search_files(directory: str, request):
|
||||||
files, folder_all = folder_paths.recursive_search(
|
if not os.path.isdir(directory):
|
||||||
directory, excluded_dir_names=[".git"]
|
return []
|
||||||
)
|
|
||||||
return [normalize_path(f) for f in files]
|
excluded_dir_names = [".git"]
|
||||||
|
result = []
|
||||||
|
include_hidden_files = get_setting_value(request, "scan.include_hidden_files", False)
|
||||||
|
|
||||||
|
for dirpath, subdirs, filenames in os.walk(directory, followlinks=True, topdown=True):
|
||||||
|
subdirs[:] = [d for d in subdirs if d not in excluded_dir_names]
|
||||||
|
if not include_hidden_files:
|
||||||
|
subdirs[:] = [d for d in subdirs if not d.startswith(".")]
|
||||||
|
filenames[:] = [f for f in filenames if not f.startswith(".")]
|
||||||
|
|
||||||
|
for file_name in filenames:
|
||||||
|
try:
|
||||||
|
relative_path = os.path.relpath(os.path.join(dirpath, file_name), directory)
|
||||||
|
result.append(relative_path)
|
||||||
|
except:
|
||||||
|
logging.warning(f"Warning: Unable to access {file_name}. Skipping this file.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return [normalize_path(f) for f in result]
|
||||||
|
|
||||||
|
|
||||||
def search_files(directory: str):
|
def search_files(directory: str):
|
||||||
@@ -185,44 +270,77 @@ def get_model_all_images(model_path: str):
|
|||||||
|
|
||||||
def get_model_preview_name(model_path: str):
|
def get_model_preview_name(model_path: str):
|
||||||
images = get_model_all_images(model_path)
|
images = get_model_all_images(model_path)
|
||||||
|
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
|
|
||||||
|
for image in images:
|
||||||
|
image_name = os.path.splitext(image)[0]
|
||||||
|
image_ext = os.path.splitext(image)[1]
|
||||||
|
if image_name == basename and image_ext.lower() == ".webp":
|
||||||
|
return image
|
||||||
|
|
||||||
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):
|
def get_model_all_videos(model_path: str):
|
||||||
if not isinstance(image_file, web.FileField):
|
|
||||||
raise RuntimeError("Invalid image file")
|
|
||||||
|
|
||||||
content_type: str = image_file.content_type
|
|
||||||
if not content_type.startswith("image/"):
|
|
||||||
raise RuntimeError(f"FileTypeError: expected image, got {content_type}")
|
|
||||||
|
|
||||||
base_dirname = os.path.dirname(model_path)
|
base_dirname = os.path.dirname(model_path)
|
||||||
|
files = search_files(base_dirname)
|
||||||
|
files = folder_paths.filter_files_content_types(files, ["video"])
|
||||||
|
|
||||||
# remove old preview images
|
|
||||||
old_preview_images = get_model_all_images(model_path)
|
|
||||||
a1111_civitai_helper_image = False
|
|
||||||
for image in old_preview_images:
|
|
||||||
if os.path.splitext(image)[1].endswith(".preview"):
|
|
||||||
a1111_civitai_helper_image = True
|
|
||||||
image_path = join_path(base_dirname, image)
|
|
||||||
os.remove(image_path)
|
|
||||||
|
|
||||||
# save new preview image
|
|
||||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
extension = f".{content_type.split('/')[1]}"
|
output: list[str] = []
|
||||||
new_preview_path = join_path(base_dirname, f"{basename}{extension}")
|
for file in files:
|
||||||
|
file_basename = os.path.splitext(file)[0]
|
||||||
|
if file_basename == basename:
|
||||||
|
output.append(file)
|
||||||
|
if file_basename == f"{basename}.preview":
|
||||||
|
output.append(file)
|
||||||
|
return output
|
||||||
|
|
||||||
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?
|
from PIL import Image
|
||||||
if a1111_civitai_helper_image:
|
from io import BytesIO
|
||||||
"""
|
|
||||||
Keep preview image of a1111_civitai_helper
|
|
||||||
"""
|
def remove_model_preview_image(model_path: str):
|
||||||
new_preview_path = join_path(base_dirname, f"{basename}.preview{extension}")
|
basename = os.path.splitext(model_path)[0]
|
||||||
with open(new_preview_path, "wb") as f:
|
preview_path = f"{basename}.webp"
|
||||||
f.write(image_file.file.read())
|
if os.path.exists(preview_path):
|
||||||
|
os.remove(preview_path)
|
||||||
|
|
||||||
|
|
||||||
|
def save_model_preview_image(model_path: str, image_file_or_url: Any, platform: str | None = None):
|
||||||
|
basename = os.path.splitext(model_path)[0]
|
||||||
|
preview_path = f"{basename}.webp"
|
||||||
|
# Download image file if it is url
|
||||||
|
if type(image_file_or_url) is str:
|
||||||
|
image_url = image_file_or_url
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_response = requests.get(image_url)
|
||||||
|
image_response.raise_for_status()
|
||||||
|
|
||||||
|
image = Image.open(BytesIO(image_response.content))
|
||||||
|
image.save(preview_path, "WEBP")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Failed to download image: {e}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Assert image as file
|
||||||
|
image_file = image_file_or_url
|
||||||
|
|
||||||
|
if not isinstance(image_file, web.FileField):
|
||||||
|
raise RuntimeError("Invalid image file")
|
||||||
|
|
||||||
|
content_type: str = image_file.content_type
|
||||||
|
if not content_type.startswith("image/"):
|
||||||
|
if platform == "huggingface":
|
||||||
|
# huggingface previewFile content_type='text/plain', not startswith("image/")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"FileTypeError: expected image, got {content_type}")
|
||||||
|
image = Image.open(image_file.file)
|
||||||
|
image.save(preview_path, "WEBP")
|
||||||
|
|
||||||
|
|
||||||
def get_model_all_descriptions(model_path: str):
|
def get_model_all_descriptions(model_path: str):
|
||||||
@@ -251,12 +369,6 @@ def save_model_description(model_path: str, content: Any):
|
|||||||
|
|
||||||
base_dirname = os.path.dirname(model_path)
|
base_dirname = os.path.dirname(model_path)
|
||||||
|
|
||||||
# remove old descriptions
|
|
||||||
old_descriptions = get_model_all_descriptions(model_path)
|
|
||||||
for desc in old_descriptions:
|
|
||||||
description_path = join_path(base_dirname, desc)
|
|
||||||
os.remove(description_path)
|
|
||||||
|
|
||||||
# save new description
|
# save new description
|
||||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
extension = ".md"
|
extension = ".md"
|
||||||
@@ -347,20 +459,43 @@ def get_setting_value(request: web.Request, key: str, default: Any = None) -> An
|
|||||||
return settings.get(setting_id, default)
|
return settings.get(setting_id, default)
|
||||||
|
|
||||||
|
|
||||||
from dataclasses import asdict, is_dataclass
|
|
||||||
|
|
||||||
|
|
||||||
def unpack_dataclass(data: Any):
|
|
||||||
if isinstance(data, dict):
|
|
||||||
return {key: unpack_dataclass(value) for key, value in data.items()}
|
|
||||||
elif isinstance(data, list):
|
|
||||||
return [unpack_dataclass(x) for x in data]
|
|
||||||
elif is_dataclass(data):
|
|
||||||
return asdict(data)
|
|
||||||
else:
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
async def send_json(event: str, data: Any, sid: str = None):
|
async def send_json(event: str, data: Any, sid: str = None):
|
||||||
detail = unpack_dataclass(data)
|
await config.serverInstance.send_json(event, data, sid)
|
||||||
await config.serverInstance.send_json(event, detail, sid)
|
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import importlib.util
|
||||||
|
import importlib.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def is_installed(package_name: str):
|
||||||
|
try:
|
||||||
|
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()
|
||||||
|
|||||||
@@ -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.3"
|
version = "2.5.3"
|
||||||
license = "LICENSE"
|
license = { file = "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"
|
||||||
@@ -12,3 +13,6 @@ Repository = "https://github.com/hayden-fr/ComfyUI-Model-Manager"
|
|||||||
PublisherId = "hayden"
|
PublisherId = "hayden"
|
||||||
DisplayName = "ComfyUI-Model-Manager"
|
DisplayName = "ComfyUI-Model-Manager"
|
||||||
Icon = ""
|
Icon = ""
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 160
|
||||||
|
|||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
markdownify
|
||||||
31
src/App.vue
31
src/App.vue
@@ -6,25 +6,28 @@
|
|||||||
</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 DialogExplorer from 'components/DialogExplorer.vue'
|
||||||
import GlobalLoading from 'components/GlobalLoading.vue'
|
import DialogManager from 'components/DialogManager.vue'
|
||||||
import GlobalDialogStack from 'components/GlobalDialogStack.vue'
|
import GlobalDialogStack from 'components/GlobalDialogStack.vue'
|
||||||
import GlobalConfirm from 'primevue/confirmdialog'
|
import GlobalLoading from 'components/GlobalLoading.vue'
|
||||||
import { $el, app, ComfyButton } from 'scripts/comfyAPI'
|
import GlobalToast from 'components/GlobalToast.vue'
|
||||||
import { onMounted } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useStoreProvider } from 'hooks/store'
|
import { useStoreProvider } from 'hooks/store'
|
||||||
import { useToast } from 'hooks/toast'
|
import { useToast } from 'hooks/toast'
|
||||||
|
import GlobalConfirm from 'primevue/confirmdialog'
|
||||||
|
import { $el, app, ComfyButton } from 'scripts/comfyAPI'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { dialog, models, config, download } = useStoreProvider()
|
const { dialog, models, config, download } = useStoreProvider()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const firstOpenManager = ref(true)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const refreshModelsAndConfig = async () => {
|
const refreshModelsAndConfig = async () => {
|
||||||
await Promise.all([models.refresh(), config.refresh()])
|
await Promise.all([models.refresh(true)])
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: 'Refreshed Models',
|
summary: 'Refreshed Models',
|
||||||
@@ -39,6 +42,7 @@ onMounted(() => {
|
|||||||
content: DialogDownload,
|
content: DialogDownload,
|
||||||
headerButtons: [
|
headerButtons: [
|
||||||
{
|
{
|
||||||
|
key: 'refresh',
|
||||||
icon: 'pi pi-refresh',
|
icon: 'pi pi-refresh',
|
||||||
command: () => download.refresh(),
|
command: () => download.refresh(),
|
||||||
},
|
},
|
||||||
@@ -47,19 +51,26 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openManagerDialog = () => {
|
const openManagerDialog = () => {
|
||||||
const { cardWidth, gutter, aspect } = config
|
const { cardWidth, gutter, aspect, flat } = config
|
||||||
|
|
||||||
|
if (firstOpenManager.value) {
|
||||||
|
models.refresh(true)
|
||||||
|
firstOpenManager.value = false
|
||||||
|
}
|
||||||
|
|
||||||
dialog.open({
|
dialog.open({
|
||||||
key: 'model-manager',
|
key: 'model-manager',
|
||||||
title: t('modelManager'),
|
title: t('modelManager'),
|
||||||
content: DialogManager,
|
content: flat.value ? DialogManager : DialogExplorer,
|
||||||
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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -60,15 +60,17 @@
|
|||||||
<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 { genModelFullName } from 'hooks/model'
|
||||||
import { request } from 'hooks/request'
|
import { request } from 'hooks/request'
|
||||||
import { useToast } from 'hooks/toast'
|
import { useToast } from 'hooks/toast'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import { VersionModel, WithResolved } from 'types/typings'
|
||||||
import { previewUrlToFile } from 'utils/common'
|
import { previewUrlToFile } from 'utils/common'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
@@ -87,42 +89,55 @@ const searchModelsByUrl = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createDownTask = async (data: VersionModel) => {
|
const createDownTask = async (data: WithResolved<VersionModel>) => {
|
||||||
const formData = new FormData()
|
|
||||||
|
|
||||||
loading.show()
|
loading.show()
|
||||||
// set base info
|
|
||||||
formData.append('type', data.type)
|
const formData = new FormData()
|
||||||
formData.append('pathIndex', data.pathIndex.toString())
|
for (const key in data) {
|
||||||
formData.append('fullname', data.fullname)
|
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
||||||
// set preview
|
let value = data[key]
|
||||||
const previewFile = await previewUrlToFile(data.preview as string).catch(
|
|
||||||
() => {
|
// set preview file
|
||||||
loading.hide()
|
if (key === 'preview') {
|
||||||
toast.add({
|
if (value) {
|
||||||
severity: 'error',
|
const previewFile = await previewUrlToFile(value).catch(() => {
|
||||||
summary: 'Error',
|
loading.hide()
|
||||||
detail: 'Failed to download preview',
|
toast.add({
|
||||||
life: 15000,
|
severity: 'error',
|
||||||
})
|
summary: 'Error',
|
||||||
throw new Error('Failed to download preview')
|
detail: 'Failed to download preview',
|
||||||
},
|
life: 5000,
|
||||||
)
|
})
|
||||||
formData.append('previewFile', previewFile)
|
throw new Error('Failed to download preview')
|
||||||
// set description
|
})
|
||||||
formData.append('description', data.description)
|
formData.append('previewFile', previewFile)
|
||||||
// set model download info
|
} else {
|
||||||
formData.append('downloadPlatform', data.downloadPlatform)
|
formData.append('previewFile', value)
|
||||||
formData.append('downloadUrl', data.downloadUrl)
|
}
|
||||||
formData.append('sizeBytes', data.sizeBytes.toString())
|
continue
|
||||||
formData.append('hashes', JSON.stringify(data.hashes))
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
value = JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
value = value.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
formData.append(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullname = genModelFullName(data as VersionModel)
|
||||||
|
formData.append('fullname', fullname)
|
||||||
|
|
||||||
await request('/model', {
|
await request('/model', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
dialog.close({ key: 'model-manager-create-task' })
|
dialog.close()
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast.add({
|
toast.add({
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col gap-4">
|
<div class="flex h-full flex-col gap-4">
|
||||||
<div class="whitespace-nowrap px-4 @container">
|
<div ref="container" class="whitespace-nowrap px-4">
|
||||||
<div class="flex gap-4 @sm:justify-end">
|
<div :class="['flex gap-4', $sm('justify-end')]">
|
||||||
<Button
|
<Button
|
||||||
class="w-full @sm:w-auto"
|
:class="[$sm('w-auto', 'w-full')]"
|
||||||
:label="$t('createDownloadTask')"
|
:label="$t('createDownloadTask')"
|
||||||
@click="openCreateTask"
|
@click="openCreateTask"
|
||||||
></Button>
|
></Button>
|
||||||
@@ -73,9 +73,11 @@
|
|||||||
<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 { useContainerQueries } from 'hooks/container'
|
||||||
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 { ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const { data } = useDownload()
|
const { data } = useDownload()
|
||||||
@@ -85,9 +87,12 @@ const dialog = useDialog()
|
|||||||
|
|
||||||
const openCreateTask = () => {
|
const openCreateTask = () => {
|
||||||
dialog.open({
|
dialog.open({
|
||||||
key: 'model-manager-create-task',
|
key: `model-manager-create-task-${Date.now()}`,
|
||||||
title: t('parseModelUrl'),
|
title: t('parseModelUrl'),
|
||||||
content: DialogCreateTask,
|
content: DialogCreateTask,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const container = ref<HTMLElement | null>(null)
|
||||||
|
const { $sm } = useContainerQueries(container)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
337
src/components/DialogExplorer.vue
Normal file
337
src/components/DialogExplorer.vue
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex h-full w-full select-none flex-col overflow-hidden"
|
||||||
|
@contextmenu.prevent="nonContextMenu"
|
||||||
|
>
|
||||||
|
<div class="flex w-full gap-4 overflow-hidden px-4 pb-4">
|
||||||
|
<div :class="['flex gap-4 overflow-hidden', showToolbar || 'flex-1']">
|
||||||
|
<div class="flex overflow-hidden">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-arrow-up"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
severity="secondary"
|
||||||
|
:disabled="folderPaths.length < 2"
|
||||||
|
@click="handleGoBackParentFolder"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponseBreadcrumb
|
||||||
|
v-show="!showToolbar"
|
||||||
|
class="h-10 flex-1"
|
||||||
|
:items="folderPaths"
|
||||||
|
></ResponseBreadcrumb>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="['flex gap-4', showToolbar && 'flex-1']">
|
||||||
|
<ResponseInput
|
||||||
|
v-model="searchContent"
|
||||||
|
:placeholder="$t('searchModels')"
|
||||||
|
></ResponseInput>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-show="showToolbar"
|
||||||
|
class="flex flex-1 items-center justify-end gap-2"
|
||||||
|
>
|
||||||
|
<ResponseSelect
|
||||||
|
v-model="sortOrder"
|
||||||
|
:items="sortOrderOptions"
|
||||||
|
></ResponseSelect>
|
||||||
|
<ResponseSelect
|
||||||
|
v-model="cardSizeFlag"
|
||||||
|
:items="cardSizeOptions"
|
||||||
|
></ResponseSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
:icon="`mdi mdi-menu-${showToolbar ? 'close' : 'open'}`"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
@click="toggleToolbar"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="contentContainer"
|
||||||
|
class="relative flex-1 overflow-hidden px-2"
|
||||||
|
@contextmenu.stop.prevent=""
|
||||||
|
>
|
||||||
|
<ResponseScroll :items="renderedList" :item-size="itemSize">
|
||||||
|
<template #item="{ item }">
|
||||||
|
<div
|
||||||
|
class="grid h-full justify-center"
|
||||||
|
:style="{
|
||||||
|
gridTemplateColumns: `repeat(auto-fit, ${cardSize.width}px)`,
|
||||||
|
columnGap: `${gutter.x}px`,
|
||||||
|
rowGap: `${gutter.y}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ModelCard
|
||||||
|
v-for="rowItem in item.row"
|
||||||
|
:model="rowItem"
|
||||||
|
:key="genModelKey(rowItem)"
|
||||||
|
:style="{
|
||||||
|
width: `${cardSize.width}px`,
|
||||||
|
height: `${cardSize.height}px`,
|
||||||
|
}"
|
||||||
|
v-tooltip.top="{
|
||||||
|
value: getFullPath(rowItem),
|
||||||
|
disabled: folderPaths.length < 2,
|
||||||
|
autoHide: false,
|
||||||
|
showDelay: 800,
|
||||||
|
hideDelay: 300,
|
||||||
|
pt: { root: { style: { zIndex: 2100, maxWidth: '32rem' } } },
|
||||||
|
}"
|
||||||
|
@dblclick="openItem(rowItem, $event)"
|
||||||
|
@contextmenu.stop.prevent="openItemContext(rowItem, $event)"
|
||||||
|
></ModelCard>
|
||||||
|
<div class="col-span-full"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ResponseScroll>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between px-4 py-2 text-sm">
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ContextMenu ref="menu" :model="contextItems"></ContextMenu>
|
||||||
|
|
||||||
|
<ConfirmDialog group="confirm-name">
|
||||||
|
<template #container="{ acceptCallback: accept, rejectCallback: reject }">
|
||||||
|
<div class="flex w-90 flex-col items-end rounded px-4 pb-4 pt-8">
|
||||||
|
<InputText
|
||||||
|
class="w-full"
|
||||||
|
type="text"
|
||||||
|
v-model="confirmName"
|
||||||
|
v-focus
|
||||||
|
@keyup.enter="accept"
|
||||||
|
></InputText>
|
||||||
|
<div class="mt-6 flex items-center gap-2">
|
||||||
|
<Button :label="$t('cancel')" @click="reject" outlined></Button>
|
||||||
|
<Button :label="$t('confirm')" @click="accept"></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ConfirmDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useElementSize } from '@vueuse/core'
|
||||||
|
import ModelCard from 'components/ModelCard.vue'
|
||||||
|
import ResponseBreadcrumb from 'components/ResponseBreadcrumb.vue'
|
||||||
|
import ResponseInput from 'components/ResponseInput.vue'
|
||||||
|
import ResponseScroll from 'components/ResponseScroll.vue'
|
||||||
|
import ResponseSelect from 'components/ResponseSelect.vue'
|
||||||
|
import { useConfig } from 'hooks/config'
|
||||||
|
import { type ModelTreeNode, useModelExplorer } from 'hooks/explorer'
|
||||||
|
import { chunk } from 'lodash'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import ConfirmDialog from 'primevue/confirmdialog'
|
||||||
|
import ContextMenu from 'primevue/contextmenu'
|
||||||
|
import InputText from 'primevue/inputtext'
|
||||||
|
import { MenuItem } from 'primevue/menuitem'
|
||||||
|
import { genModelKey } from 'utils/model'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const gutter = {
|
||||||
|
x: 4,
|
||||||
|
y: 32,
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
dataTreeList,
|
||||||
|
folderPaths,
|
||||||
|
findFolder,
|
||||||
|
openFolder,
|
||||||
|
openModelDetail,
|
||||||
|
getFullPath,
|
||||||
|
} = useModelExplorer()
|
||||||
|
const { cardSize, cardSizeMap, cardSizeFlag, dialog: settings } = useConfig()
|
||||||
|
|
||||||
|
const showToolbar = ref(false)
|
||||||
|
const toggleToolbar = () => {
|
||||||
|
showToolbar.value = !showToolbar.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentContainer = ref<HTMLElement | null>(null)
|
||||||
|
const contentSize = useElementSize(contentContainer)
|
||||||
|
|
||||||
|
const itemSize = computed(() => {
|
||||||
|
return cardSize.value.height + gutter.y
|
||||||
|
})
|
||||||
|
|
||||||
|
const cols = computed(() => {
|
||||||
|
const containerWidth = contentSize.width.value + gutter.x
|
||||||
|
const itemWidth = cardSize.value.width + gutter.x
|
||||||
|
|
||||||
|
return Math.floor(containerWidth / itemWidth)
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchContent = ref<string>()
|
||||||
|
|
||||||
|
const sortOrder = ref('name')
|
||||||
|
const sortOrderOptions = ref(
|
||||||
|
['name', 'size', 'created', 'modified'].map((key) => {
|
||||||
|
return {
|
||||||
|
label: t(`sort.${key}`),
|
||||||
|
value: key,
|
||||||
|
icon: key === 'name' ? 'pi pi-sort-alpha-down' : 'pi pi-sort-amount-down',
|
||||||
|
command: () => {
|
||||||
|
sortOrder.value = key
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentDataList = computed(() => {
|
||||||
|
let renderedList = dataTreeList.value
|
||||||
|
for (const folderItem of folderPaths.value) {
|
||||||
|
const found = findFolder(renderedList, {
|
||||||
|
basename: folderItem.name,
|
||||||
|
pathIndex: folderItem.pathIndex,
|
||||||
|
})
|
||||||
|
renderedList = found?.children || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = searchContent.value?.toLowerCase().trim() ?? ''
|
||||||
|
if (filter) {
|
||||||
|
const filterItems: ModelTreeNode[] = []
|
||||||
|
|
||||||
|
const searchList = [...renderedList]
|
||||||
|
|
||||||
|
while (searchList.length) {
|
||||||
|
const item = searchList.pop()!
|
||||||
|
const children = (item as any).children ?? []
|
||||||
|
searchList.push(...children)
|
||||||
|
|
||||||
|
const matchSubFolder = `${item.subFolder}/`.toLowerCase().includes(filter)
|
||||||
|
const matchName = item.basename.toLowerCase().includes(filter)
|
||||||
|
|
||||||
|
if (matchSubFolder || matchName) {
|
||||||
|
filterItems.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderedList = filterItems
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderPaths.value.length > 1) {
|
||||||
|
const folderItems: ModelTreeNode[] = []
|
||||||
|
const modelItems: ModelTreeNode[] = []
|
||||||
|
|
||||||
|
for (const item of renderedList) {
|
||||||
|
if (item.isFolder) {
|
||||||
|
folderItems.push(item)
|
||||||
|
} else {
|
||||||
|
modelItems.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
folderItems.sort((a, b) => {
|
||||||
|
return a.basename.localeCompare(b.basename)
|
||||||
|
})
|
||||||
|
modelItems.sort((a, b) => {
|
||||||
|
const sortFieldMap = {
|
||||||
|
name: 'basename',
|
||||||
|
size: 'sizeBytes',
|
||||||
|
created: 'createdAt',
|
||||||
|
modified: 'updatedAt',
|
||||||
|
}
|
||||||
|
const sortField = sortFieldMap[sortOrder.value]
|
||||||
|
|
||||||
|
const aValue = a[sortField]
|
||||||
|
const bValue = b[sortField]
|
||||||
|
|
||||||
|
const result =
|
||||||
|
typeof aValue === 'string'
|
||||||
|
? aValue.localeCompare(bValue)
|
||||||
|
: aValue - bValue
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
renderedList = [...folderItems, ...modelItems]
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderedList
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderedList = computed(() => {
|
||||||
|
return chunk(currentDataList.value, cols.value).map((row) => {
|
||||||
|
return { key: row.map((o) => o.basename).join('#'), row }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const cardSizeOptions = computed(() => {
|
||||||
|
const customSize = 'size.custom'
|
||||||
|
|
||||||
|
const customOptionMap = {
|
||||||
|
...cardSizeMap.value,
|
||||||
|
[customSize]: 'custom',
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(customOptionMap).map((key) => {
|
||||||
|
return {
|
||||||
|
label: t(key),
|
||||||
|
value: key,
|
||||||
|
command: () => {
|
||||||
|
if (key === customSize) {
|
||||||
|
settings.showCardSizeSetting()
|
||||||
|
} else {
|
||||||
|
cardSizeFlag.value = key
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const menu = ref()
|
||||||
|
const contextItems = ref<MenuItem[]>([])
|
||||||
|
const confirmName = ref('')
|
||||||
|
|
||||||
|
const openItem = (item: ModelTreeNode, e: Event) => {
|
||||||
|
menu.value.hide(e)
|
||||||
|
if (item.isFolder) {
|
||||||
|
searchContent.value = undefined
|
||||||
|
openFolder(item)
|
||||||
|
} else {
|
||||||
|
openModelDetail(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openItemContext = (item: ModelTreeNode, e: Event) => {
|
||||||
|
if (folderPaths.value.length < 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contextItems.value = [
|
||||||
|
{
|
||||||
|
label: t('open'),
|
||||||
|
icon: 'pi pi-folder-open',
|
||||||
|
command: () => {
|
||||||
|
openItem(item, e)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
menu.value?.show(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonContextMenu = (e: Event) => {
|
||||||
|
menu.value.hide(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const vFocus = {
|
||||||
|
mounted: (el: HTMLInputElement) => el.focus(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGoBackParentFolder = () => {
|
||||||
|
folderPaths.value.pop()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,65 +1,110 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex h-full flex-col gap-4 overflow-hidden @container/content"
|
ref="contentContainer"
|
||||||
:style="{
|
class="flex h-full flex-col gap-4 overflow-hidden"
|
||||||
['--card-width']: `${cardWidth}px`,
|
|
||||||
['--gutter']: `${gutter}px`,
|
|
||||||
}"
|
|
||||||
v-resize="onContainerResize"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
:class="[
|
class="grid grid-cols-1 justify-center gap-4 px-8"
|
||||||
'grid grid-cols-1 justify-center gap-4 px-8',
|
:style="$content_lg(contentStyle)"
|
||||||
'@lg/content:grid-cols-[repeat(auto-fit,var(--card-width))]',
|
|
||||||
'@lg/content:gap-[var(--gutter)]',
|
|
||||||
'@lg/content:px-4',
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<div class="col-span-full @container/toolbar">
|
<div ref="toolbarContainer" class="col-span-full">
|
||||||
<div :class="['flex flex-col gap-4', '@2xl/toolbar:flex-row']">
|
<div :class="['flex gap-4', $toolbar_2xl('flex-row', 'flex-col')]">
|
||||||
<ResponseInput
|
<div class="flex-1">
|
||||||
v-model="searchContent"
|
<ResponseInput
|
||||||
:placeholder="$t('searchModels')"
|
v-model="searchContent"
|
||||||
:allow-clear="true"
|
:placeholder="$t('searchModels')"
|
||||||
suffix-icon="pi pi-search"
|
:allow-clear="true"
|
||||||
></ResponseInput>
|
suffix-icon="pi pi-search"
|
||||||
|
></ResponseInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4 overflow-hidden">
|
<div class="flex items-center justify-between gap-4 overflow-hidden">
|
||||||
<ResponseSelect
|
<ResponseSelect
|
||||||
|
class="flex-1"
|
||||||
v-model="currentType"
|
v-model="currentType"
|
||||||
:items="typeOptions"
|
:items="typeOptions"
|
||||||
:type="isMobile ? 'drop' : 'button'"
|
|
||||||
></ResponseSelect>
|
></ResponseSelect>
|
||||||
<ResponseSelect
|
<ResponseSelect
|
||||||
|
class="flex-1"
|
||||||
v-model="sortOrder"
|
v-model="sortOrder"
|
||||||
:items="sortOrderOptions"
|
:items="sortOrderOptions"
|
||||||
></ResponseSelect>
|
></ResponseSelect>
|
||||||
|
<ResponseSelect
|
||||||
|
class="flex-1"
|
||||||
|
v-model="cardSizeFlag"
|
||||||
|
:items="cardSizeOptions"
|
||||||
|
></ResponseSelect>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResponseScroll
|
<ResponseScroll :items="list" :itemSize="itemSize" class="h-full flex-1">
|
||||||
ref="responseScroll"
|
|
||||||
:items="list"
|
|
||||||
:itemSize="itemSize"
|
|
||||||
:row-key="(item) => item.map(genModelKey).join(',')"
|
|
||||||
class="h-full flex-1"
|
|
||||||
>
|
|
||||||
<template #item="{ item }">
|
<template #item="{ item }">
|
||||||
<div
|
<div
|
||||||
:class="[
|
class="grid grid-cols-1 justify-center gap-8 px-8"
|
||||||
'grid grid-cols-1 justify-center gap-8 px-8',
|
:style="contentStyle"
|
||||||
'@lg/content:grid-cols-[repeat(auto-fit,var(--card-width))]',
|
|
||||||
'@lg/content:gap-[var(--gutter)]',
|
|
||||||
'@lg/content:px-4',
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<ModelCard
|
<ModelCard
|
||||||
v-for="model in item"
|
v-for="model in item.row"
|
||||||
:key="genModelKey(model)"
|
:key="genModelKey(model)"
|
||||||
:model="model"
|
:model="model"
|
||||||
></ModelCard>
|
:style="{
|
||||||
|
width: `${cardSize.width}px`,
|
||||||
|
height: `${cardSize.height}px`,
|
||||||
|
}"
|
||||||
|
class="group/card cursor-pointer !p-0"
|
||||||
|
@click="openModelDetail(model)"
|
||||||
|
v-tooltip.top="{
|
||||||
|
value: getFullPath(model),
|
||||||
|
autoHide: false,
|
||||||
|
showDelay: 800,
|
||||||
|
hideDelay: 300,
|
||||||
|
pt: { root: { style: { zIndex: 2100, maxWidth: '32rem' } } },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #name>
|
||||||
|
<div
|
||||||
|
v-show="showModelName"
|
||||||
|
class="absolute top-0 h-full w-full p-2"
|
||||||
|
>
|
||||||
|
<div class="flex h-full flex-col justify-end text-lg">
|
||||||
|
<div class="line-clamp-3 break-all font-bold text-shadow">
|
||||||
|
{{ model.basename }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #extra>
|
||||||
|
<div
|
||||||
|
v-show="showModeAction"
|
||||||
|
class="pointer-events-none absolute right-2 top-2 opacity-0 duration-300 group-hover/card:opacity-100"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-plus"
|
||||||
|
severity="secondary"
|
||||||
|
rounded
|
||||||
|
@click.stop="addModelNode(model)"
|
||||||
|
></Button>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-copy"
|
||||||
|
severity="secondary"
|
||||||
|
rounded
|
||||||
|
@click.stop="copyModelNode(model)"
|
||||||
|
></Button>
|
||||||
|
<Button
|
||||||
|
v-show="model.preview"
|
||||||
|
icon="pi pi-file-import"
|
||||||
|
severity="secondary"
|
||||||
|
rounded
|
||||||
|
@click.stop="loadPreviewWorkflow(model)"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ModelCard>
|
||||||
<div class="col-span-full"></div>
|
<div class="col-span-full"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -75,30 +120,59 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="manager-dialog">
|
<script setup lang="ts" name="manager-dialog">
|
||||||
import { useConfig } from 'hooks/config'
|
import { useElementSize } from '@vueuse/core'
|
||||||
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, watch } from 'vue'
|
import ResponseSelect from 'components/ResponseSelect.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { configSetting, useConfig } from 'hooks/config'
|
||||||
|
import { useContainerQueries } from 'hooks/container'
|
||||||
|
import { useModelNodeAction, useModels } from 'hooks/model'
|
||||||
import { chunk } from 'lodash'
|
import { chunk } from 'lodash'
|
||||||
import { defineResizeCallback } from 'hooks/resize'
|
import Button from 'primevue/button'
|
||||||
|
import { app } from 'scripts/comfyAPI'
|
||||||
|
import { Model } from 'types/typings'
|
||||||
import { genModelKey } from 'utils/model'
|
import { genModelKey } from 'utils/model'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const { isMobile, cardWidth, gutter, aspect, modelFolders } = useConfig()
|
const {
|
||||||
|
isMobile,
|
||||||
|
gutter,
|
||||||
|
cardSize,
|
||||||
|
cardSizeMap,
|
||||||
|
cardSizeFlag,
|
||||||
|
dialog: settings,
|
||||||
|
} = useConfig()
|
||||||
|
|
||||||
const { data } = useModels()
|
const { data, folders, openModelDetail, getFullPath } = useModels()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const responseScroll = ref()
|
const toolbarContainer = ref<HTMLElement | null>(null)
|
||||||
|
const { $2xl: $toolbar_2xl } = useContainerQueries(toolbarContainer)
|
||||||
|
|
||||||
|
const contentContainer = ref<HTMLElement | null>(null)
|
||||||
|
const { $lg: $content_lg } = useContainerQueries(contentContainer)
|
||||||
|
|
||||||
const searchContent = ref<string>()
|
const searchContent = ref<string>()
|
||||||
|
|
||||||
const currentType = ref('all')
|
const allType = 'All'
|
||||||
|
const currentType = ref(allType)
|
||||||
const typeOptions = computed(() => {
|
const typeOptions = computed(() => {
|
||||||
return ['all', ...Object.keys(modelFolders.value)].map((type) => {
|
const excludeScanTypes = app.ui?.settings.getSettingValue<string>(
|
||||||
|
configSetting.excludeScanTypes,
|
||||||
|
)
|
||||||
|
const customBlackList =
|
||||||
|
excludeScanTypes
|
||||||
|
?.split(',')
|
||||||
|
.map((type) => type.trim())
|
||||||
|
.filter(Boolean) ?? []
|
||||||
|
return [
|
||||||
|
allType,
|
||||||
|
...Object.keys(folders.value).filter(
|
||||||
|
(folder) => !customBlackList.includes(folder),
|
||||||
|
),
|
||||||
|
].map((type) => {
|
||||||
return {
|
return {
|
||||||
label: type,
|
label: type,
|
||||||
value: type,
|
value: type,
|
||||||
@@ -123,40 +197,50 @@ const sortOrderOptions = ref(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
watch([searchContent, currentType], () => {
|
|
||||||
responseScroll.value.init()
|
|
||||||
})
|
|
||||||
|
|
||||||
const itemSize = computed(() => {
|
const itemSize = computed(() => {
|
||||||
let itemWidth = cardWidth
|
let itemHeight = cardSize.value.height
|
||||||
let itemGutter = gutter
|
let itemGutter = gutter
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
const baseSize = 16
|
const baseSize = 16
|
||||||
itemWidth = window.innerWidth - baseSize * 2 * 2
|
itemHeight = window.innerWidth - baseSize * 2 * 2
|
||||||
itemGutter = baseSize * 2
|
itemGutter = baseSize * 2
|
||||||
}
|
}
|
||||||
return itemWidth / aspect + itemGutter
|
return itemHeight + itemGutter
|
||||||
})
|
})
|
||||||
|
|
||||||
const colSpan = ref(1)
|
const { width } = useElementSize(contentContainer)
|
||||||
const colSpanWidth = ref(cardWidth)
|
|
||||||
|
const cols = computed(() => {
|
||||||
|
if (isMobile.value) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
const containerWidth = width.value
|
||||||
|
const itemWidth = cardSize.value.width
|
||||||
|
return Math.floor((containerWidth - gutter) / (itemWidth + gutter))
|
||||||
|
})
|
||||||
|
|
||||||
const list = computed(() => {
|
const list = computed(() => {
|
||||||
const filterList = data.value.filter((model) => {
|
const mergedList = Object.values(data.value).flat()
|
||||||
const showAllModel = currentType.value === 'all'
|
const pureModels = mergedList.filter((item) => {
|
||||||
|
return !item.isFolder
|
||||||
const matchType = showAllModel || model.type === currentType.value
|
|
||||||
const matchName = model.fullname
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchContent.value?.toLowerCase() || '')
|
|
||||||
|
|
||||||
return matchType && matchName
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let sortStrategy = (a: Model, b: Model) => 0
|
const filterList = pureModels.filter((model) => {
|
||||||
|
const showAllModel = currentType.value === allType
|
||||||
|
|
||||||
|
const matchType = showAllModel || model.type === currentType.value
|
||||||
|
|
||||||
|
const filter = searchContent.value?.toLowerCase() ?? ''
|
||||||
|
const matchSubFolder = model.subFolder.toLowerCase().includes(filter)
|
||||||
|
const matchName = model.basename.toLowerCase().includes(filter)
|
||||||
|
|
||||||
|
return matchType && (matchSubFolder || matchName)
|
||||||
|
})
|
||||||
|
|
||||||
|
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.basename.localeCompare(b.basename)
|
||||||
break
|
break
|
||||||
case 'size':
|
case 'size':
|
||||||
sortStrategy = (a, b) => b.sizeBytes - a.sizeBytes
|
sortStrategy = (a, b) => b.sizeBytes - a.sizeBytes
|
||||||
@@ -173,17 +257,49 @@ const list = computed(() => {
|
|||||||
|
|
||||||
const sortedList = filterList.sort(sortStrategy)
|
const sortedList = filterList.sort(sortStrategy)
|
||||||
|
|
||||||
return chunk(sortedList, colSpan.value)
|
return chunk(sortedList, cols.value).map((row) => {
|
||||||
|
return { key: row.map(genModelKey).join(','), row }
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const onContainerResize = defineResizeCallback((entries) => {
|
const contentStyle = computed(() => ({
|
||||||
const entry = entries[0]
|
gridTemplateColumns: `repeat(auto-fit, ${cardSize.value.width}px)`,
|
||||||
if (isMobile.value) {
|
gap: `${gutter}px`,
|
||||||
colSpan.value = 1
|
paddingLeft: `1rem`,
|
||||||
} else {
|
paddingRight: `1rem`,
|
||||||
const containerWidth = entry.contentRect.width
|
}))
|
||||||
colSpan.value = Math.floor((containerWidth - gutter) / (cardWidth + gutter))
|
|
||||||
colSpanWidth.value = colSpan.value * (cardWidth + gutter) - gutter
|
const cardSizeOptions = computed(() => {
|
||||||
|
const customSize = 'size.custom'
|
||||||
|
|
||||||
|
const customOptionMap = {
|
||||||
|
...cardSizeMap.value,
|
||||||
|
[customSize]: 'custom',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Object.keys(customOptionMap).map((key) => {
|
||||||
|
return {
|
||||||
|
label: t(key),
|
||||||
|
value: key,
|
||||||
|
command: () => {
|
||||||
|
if (key === customSize) {
|
||||||
|
settings.showCardSizeSetting()
|
||||||
|
} else {
|
||||||
|
cardSizeFlag.value = key
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showModelName = computed(() => {
|
||||||
|
return cardSize.value.width > 120 && cardSize.value.height > 160
|
||||||
|
})
|
||||||
|
|
||||||
|
const showModeAction = computed(() => {
|
||||||
|
return cardSize.value.width > 120 && cardSize.value.height > 160
|
||||||
|
})
|
||||||
|
|
||||||
|
const { addModelNode, copyModelNode, loadPreviewWorkflow } =
|
||||||
|
useModelNodeAction()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -18,12 +18,18 @@
|
|||||||
icon="pi pi-eye"
|
icon="pi pi-eye"
|
||||||
@click="openModelPage(metadata.modelPage)"
|
@click="openModelPage(metadata.modelPage)"
|
||||||
></Button>
|
></Button>
|
||||||
<Button icon="pi pi-plus" @click.stop="addModelNode"></Button>
|
<Button
|
||||||
<Button icon="pi pi-copy" @click.stop="copyModelNode"></Button>
|
icon="pi pi-plus"
|
||||||
|
@click.stop="addModelNode(model)"
|
||||||
|
></Button>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-copy"
|
||||||
|
@click.stop="copyModelNode(model)"
|
||||||
|
></Button>
|
||||||
<Button
|
<Button
|
||||||
v-show="model.preview"
|
v-show="model.preview"
|
||||||
icon="pi pi-file-import"
|
icon="pi pi-file-import"
|
||||||
@click.stop="loadPreviewWorkflow"
|
@click.stop="loadPreviewWorkflow(model)"
|
||||||
></Button>
|
></Button>
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-pen-to-square"
|
icon="pi pi-pen-to-square"
|
||||||
@@ -44,9 +50,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 { genModelUrl, 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, WithResolved } from 'types/typings'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -58,7 +65,7 @@ const { remove, update } = useModels()
|
|||||||
|
|
||||||
const editable = ref(false)
|
const editable = ref(false)
|
||||||
|
|
||||||
const modelDetailUrl = `/model/${props.model.type}/${props.model.pathIndex}/${props.model.fullname}`
|
const modelDetailUrl = genModelUrl(props.model)
|
||||||
const { data: extraInfo } = useRequest(modelDetailUrl, {
|
const { data: extraInfo } = useRequest(modelDetailUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
})
|
})
|
||||||
@@ -71,7 +78,7 @@ const handleCancel = () => {
|
|||||||
editable.value = false
|
editable.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async (data: BaseModel) => {
|
const handleSave = async (data: WithResolved<BaseModel>) => {
|
||||||
await update(modelContent.value, data)
|
await update(modelContent.value, data)
|
||||||
editable.value = false
|
editable.value = false
|
||||||
}
|
}
|
||||||
@@ -84,7 +91,6 @@ const openModelPage = (url: string) => {
|
|||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
const { addModelNode, copyModelNode, loadPreviewWorkflow } = useModelNodeAction(
|
const { addModelNode, copyModelNode, loadPreviewWorkflow } =
|
||||||
props.model,
|
useModelNodeAction()
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<ResponseDialog
|
<ResponseDialog
|
||||||
v-for="(item, index) in stack"
|
v-for="(item, index) in stack"
|
||||||
v-model:visible="item.visible"
|
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
:keep-alive="item.keepAlive"
|
v-model:visible="item.visible"
|
||||||
:default-size="item.defaultSize"
|
v-bind="omitProps(item)"
|
||||||
:default-mobile-size="item.defaultMobileSize"
|
:auto-z-index="false"
|
||||||
:resize-allow="item.resizeAllow"
|
:pt:mask:style="{ zIndex: baseZIndex + index + 1 }"
|
||||||
:min-width="item.minWidth"
|
|
||||||
:max-width="item.maxWidth"
|
|
||||||
:min-height="item.minHeight"
|
|
||||||
:max-height="item.maxHeight"
|
|
||||||
:z-index="index"
|
|
||||||
:pt:root:onMousedown="() => rise(item)"
|
:pt:root:onMousedown="() => rise(item)"
|
||||||
@hide="() => close(item)"
|
@hide="() => close(item)"
|
||||||
>
|
>
|
||||||
@@ -21,6 +15,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"
|
||||||
@@ -35,12 +30,34 @@
|
|||||||
<component :is="item.content" v-bind="item.contentProps"></component>
|
<component :is="item.content" v-bind="item.contentProps"></component>
|
||||||
</template>
|
</template>
|
||||||
</ResponseDialog>
|
</ResponseDialog>
|
||||||
|
<Dialog :visible="true" :pt:mask:style="{ display: 'none' }"></Dialog>
|
||||||
</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 { type DialogItem, useDialog } from 'hooks/dialog'
|
||||||
|
import { omit } from 'lodash'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import { usePrimeVue } from 'primevue/config'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const { stack, rise, close } = useDialog()
|
const { stack, rise, close } = useDialog()
|
||||||
|
|
||||||
|
const { config } = usePrimeVue()
|
||||||
|
|
||||||
|
const baseZIndex = computed(() => {
|
||||||
|
return config.zIndex?.modal ?? 1100
|
||||||
|
})
|
||||||
|
|
||||||
|
const omitProps = (item: DialogItem) => {
|
||||||
|
return omit(item, [
|
||||||
|
'key',
|
||||||
|
'visible',
|
||||||
|
'title',
|
||||||
|
'headerButtons',
|
||||||
|
'content',
|
||||||
|
'contentProps',
|
||||||
|
])
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,16 +7,63 @@
|
|||||||
</template>
|
</template>
|
||||||
</ResponseSelect>
|
</ResponseSelect>
|
||||||
|
|
||||||
<ResponseSelect class="w-full" v-model="pathIndex" :items="pathOptions">
|
<div class="flex gap-2 overflow-hidden">
|
||||||
</ResponseSelect>
|
<div class="flex-1 overflow-hidden rounded bg-gray-500/30">
|
||||||
|
<div class="flex h-full items-center justify-end">
|
||||||
|
<span class="overflow-hidden text-ellipsis whitespace-nowrap px-2">
|
||||||
|
{{ renderedModelFolder }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-folder"
|
||||||
|
:disabled="!type"
|
||||||
|
@click="handleSelectFolder"
|
||||||
|
></Button>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="folderSelectVisible"
|
||||||
|
:header="$t('folder')"
|
||||||
|
:auto-z-index="false"
|
||||||
|
:pt:mask:style="{ zIndex }"
|
||||||
|
:pt:root:style="{ height: '50vh', maxWidth: '50vw' }"
|
||||||
|
pt:content:class="flex-1"
|
||||||
|
>
|
||||||
|
<div class="flex h-full flex-col overflow-hidden">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<ResponseScroll>
|
||||||
|
<Tree
|
||||||
|
class="h-full"
|
||||||
|
v-model:selection-keys="modelFolder"
|
||||||
|
:value="pathOptions"
|
||||||
|
selectionMode="single"
|
||||||
|
:pt:nodeLabel:class="'text-ellipsis overflow-hidden'"
|
||||||
|
></Tree>
|
||||||
|
</ResponseScroll>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
:label="$t('cancel')"
|
||||||
|
severity="secondary"
|
||||||
|
@click="handleCancelSelectFolder"
|
||||||
|
></Button>
|
||||||
|
<Button
|
||||||
|
:label="$t('select')"
|
||||||
|
@click="handleConfirmSelectFolder"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ResponseInput
|
<ResponseInput
|
||||||
v-model.trim="basename"
|
v-model.trim.valid="basename"
|
||||||
class="-mr-2 text-right"
|
class="-mr-2 text-right"
|
||||||
update-trigger="blur"
|
update-trigger="blur"
|
||||||
|
:validate="validateBasename"
|
||||||
>
|
>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<span class="pi-inputicon">
|
<span class="text-base opacity-60">
|
||||||
{{ extension }}
|
{{ extension }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -29,11 +76,25 @@
|
|||||||
<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>
|
||||||
<td class="overflow-hidden text-ellipsis break-all px-4">
|
<td
|
||||||
|
class="overflow-hidden text-ellipsis break-all px-4"
|
||||||
|
v-tooltip.top="{
|
||||||
|
value: item.display,
|
||||||
|
disabled: !['pathIndex', 'basename'].includes(item.key),
|
||||||
|
autoHide: false,
|
||||||
|
showDelay: 800,
|
||||||
|
hideDelay: 300,
|
||||||
|
pt: { root: { style: { zIndex: 2100, maxWidth: '32rem' } } },
|
||||||
|
}"
|
||||||
|
>
|
||||||
{{ item.display }}
|
{{ item.display }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -44,16 +105,34 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ResponseInput from 'components/ResponseInput.vue'
|
import ResponseInput from 'components/ResponseInput.vue'
|
||||||
|
import ResponseScroll from 'components/ResponseScroll.vue'
|
||||||
import ResponseSelect from 'components/ResponseSelect.vue'
|
import ResponseSelect from 'components/ResponseSelect.vue'
|
||||||
import { useConfig } from 'hooks/config'
|
import { useDialog } from 'hooks/dialog'
|
||||||
import { useModelBaseInfo } from 'hooks/model'
|
import { useModelBaseInfo, useModelFolder } from 'hooks/model'
|
||||||
import { computed } from 'vue'
|
import { useToast } from 'hooks/toast'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import { usePrimeVue } from 'primevue/config'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
import Tree from 'primevue/tree'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
const editable = defineModel<boolean>('editable')
|
const editable = defineModel<boolean>('editable')
|
||||||
|
|
||||||
const { modelFolders } = useConfig()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const { baseInfo, pathIndex, basename, extension, type } = useModelBaseInfo()
|
const {
|
||||||
|
baseInfo,
|
||||||
|
pathIndex,
|
||||||
|
subFolder,
|
||||||
|
basename,
|
||||||
|
extension,
|
||||||
|
type,
|
||||||
|
modelFolders,
|
||||||
|
} = useModelBaseInfo()
|
||||||
|
|
||||||
|
watch(type, () => {
|
||||||
|
subFolder.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
const typeOptions = computed(() => {
|
const typeOptions = computed(() => {
|
||||||
return Object.keys(modelFolders.value).map((curr) => {
|
return Object.keys(modelFolders.value).map((curr) => {
|
||||||
@@ -68,25 +147,104 @@ const typeOptions = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const pathOptions = computed(() => {
|
|
||||||
return (modelFolders.value[type.value] ?? []).map((folder, index) => {
|
|
||||||
return {
|
|
||||||
value: index,
|
|
||||||
label: folder,
|
|
||||||
command: () => {
|
|
||||||
pathIndex.value = index
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const information = computed(() => {
|
const information = computed(() => {
|
||||||
return Object.values(baseInfo.value).filter((row) => {
|
return Object.values(baseInfo.value).filter((row) => {
|
||||||
if (editable.value) {
|
if (editable.value) {
|
||||||
const hiddenKeys = ['fullname', 'pathIndex']
|
const hiddenKeys = ['basename', 'pathIndex']
|
||||||
return !hiddenKeys.includes(row.key)
|
return !hiddenKeys.includes(row.key)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const validateBasename = (val: string | undefined) => {
|
||||||
|
if (!val) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
detail: 'basename is required',
|
||||||
|
life: 3000,
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const invalidChart = /[\\/:*?"<>|]/
|
||||||
|
if (invalidChart.test(val)) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
detail: 'basename is invalid, \\/:*?"<>|',
|
||||||
|
life: 3000,
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderSelectVisible = ref(false)
|
||||||
|
|
||||||
|
const { stack } = useDialog()
|
||||||
|
const { config } = usePrimeVue()
|
||||||
|
const zIndex = computed(() => {
|
||||||
|
const baseZIndex = config.zIndex?.modal ?? 1100
|
||||||
|
return baseZIndex + stack.value.length + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSelectFolder = () => {
|
||||||
|
if (!type.value) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: 'Please select model type first',
|
||||||
|
life: 5000,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
folderSelectVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pathOptions } = useModelFolder({ type })
|
||||||
|
|
||||||
|
const selectedModelFolder = ref<string>()
|
||||||
|
|
||||||
|
const modelFolder = computed({
|
||||||
|
get: () => {
|
||||||
|
const folderPath = baseInfo.value.pathIndex.display
|
||||||
|
const selectedKey = selectedModelFolder.value ?? folderPath
|
||||||
|
return { [selectedKey]: true }
|
||||||
|
},
|
||||||
|
set: (val) => {
|
||||||
|
const folderPath = Object.keys(val)[0]
|
||||||
|
selectedModelFolder.value = folderPath
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderedModelFolder = computed(() => {
|
||||||
|
return baseInfo.value.pathIndex?.display
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCancelSelectFolder = () => {
|
||||||
|
selectedModelFolder.value = undefined
|
||||||
|
folderSelectVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmSelectFolder = () => {
|
||||||
|
const folderPath = Object.keys(modelFolder.value)[0]
|
||||||
|
|
||||||
|
const folders = modelFolders.value[type.value]
|
||||||
|
pathIndex.value = folders.findIndex((item) => folderPath.includes(item))
|
||||||
|
if (pathIndex.value < 0) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
detail: 'Folder not found',
|
||||||
|
life: 3000,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const prefixPath = folders[pathIndex.value]
|
||||||
|
subFolder.value = folderPath.replace(prefixPath, '')
|
||||||
|
if (subFolder.value.startsWith('/')) {
|
||||||
|
subFolder.value = subFolder.value.replace('/', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedModelFolder.value = undefined
|
||||||
|
folderSelectVisible.value = false
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,103 +1,109 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="group/card relative w-full cursor-pointer select-none preview-aspect"
|
ref="container"
|
||||||
@click.stop="openDetailDialog"
|
class="relative h-full select-none rounded-lg hover:bg-gray-500/40"
|
||||||
>
|
>
|
||||||
<div class="h-full overflow-hidden rounded-lg">
|
<div data-card-main class="flex h-full w-full flex-col">
|
||||||
<div class="h-full bg-gray-500 duration-300 group-hover/card:scale-110">
|
<div data-card-preview class="flex-1 overflow-hidden">
|
||||||
<img class="h-full w-full object-cover" :src="preview" />
|
<div v-if="model.isFolder" class="h-full w-full">
|
||||||
|
<svg
|
||||||
|
class="icon"
|
||||||
|
viewBox="0 0 1024 1024"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M853.333333 256H469.333333l-85.333333-85.333333H170.666667c-46.933333 0-85.333333 38.4-85.333334 85.333333v170.666667h853.333334v-85.333334c0-46.933333-38.4-85.333333-85.333334-85.333333z"
|
||||||
|
fill="#FFA000"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M853.333333 256H170.666667c-46.933333 0-85.333333 38.4-85.333334 85.333333v426.666667c0 46.933333 38.4 85.333333 85.333334 85.333333h682.666666c46.933333 0 85.333333-38.4 85.333334-85.333333V341.333333c0-46.933333-38.4-85.333333-85.333334-85.333333z"
|
||||||
|
fill="#FFCA28"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="model.previewType === 'video'"
|
||||||
|
class="h-full w-full p-1 hover:p-0"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
playsinline
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
disablepictureinpicture
|
||||||
|
preload="none"
|
||||||
|
>
|
||||||
|
<source :src="preview" />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
<div v-else class="h-full w-full p-1 hover:p-0">
|
||||||
|
<img class="h-full w-full rounded-lg object-cover" :src="preview" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<slot name="name">
|
||||||
|
<div class="flex justify-center overflow-hidden px-1">
|
||||||
|
<span class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
|
{{ model.basename }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
v-if="!model.isFolder"
|
||||||
data-draggable-overlay
|
data-draggable-overlay
|
||||||
class="absolute left-0 top-0 h-full w-full"
|
class="absolute left-0 top-0 h-full w-full"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@dragend.stop="dragToAddModelNode"
|
@dragend.stop="dragToAddModelNode(model, $event)"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="pointer-events-none absolute left-0 top-0 h-full w-full p-4">
|
<div
|
||||||
<div class="relative h-full w-full text-white">
|
v-if="!model.isFolder"
|
||||||
<div class="absolute bottom-0 left-0">
|
data-mode-type
|
||||||
<div class="drop-shadow-[0px_2px_2px_rgba(0,0,0,0.75)]">
|
class="pointer-events-none absolute left-2 top-2"
|
||||||
<div class="line-clamp-3 break-all text-2xl font-bold @lg:text-lg">
|
:style="{
|
||||||
{{ model.basename }}
|
transform: `scale(${typeLabelScale})`,
|
||||||
</div>
|
transformOrigin: 'left top',
|
||||||
</div>
|
}"
|
||||||
</div>
|
>
|
||||||
|
<div class="rounded-full bg-black/50 px-3 py-1">
|
||||||
<div class="absolute left-0 top-0 w-full">
|
<span>{{ model.type }}</span>
|
||||||
<div class="flex flex-row items-start justify-between">
|
|
||||||
<div class="flex items-center rounded-full bg-black/30 px-3 py-2">
|
|
||||||
<div class="font-bold @lg:text-xs">
|
|
||||||
{{ model.type }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="opacity-0 duration-300 group-hover/card:opacity-100">
|
|
||||||
<div class="flex flex-col gap-4 *:pointer-events-auto">
|
|
||||||
<Button
|
|
||||||
icon="pi pi-plus"
|
|
||||||
severity="secondary"
|
|
||||||
rounded
|
|
||||||
@click.stop="addModelNode"
|
|
||||||
></Button>
|
|
||||||
<Button
|
|
||||||
icon="pi pi-copy"
|
|
||||||
severity="secondary"
|
|
||||||
rounded
|
|
||||||
@click.stop="copyModelNode"
|
|
||||||
></Button>
|
|
||||||
<Button
|
|
||||||
v-show="model.preview"
|
|
||||||
icon="pi pi-file-import"
|
|
||||||
severity="secondary"
|
|
||||||
rounded
|
|
||||||
@click.stop="loadPreviewWorkflow"
|
|
||||||
></Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<slot name="extra"></slot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DialogModelDetail from 'components/DialogModelDetail.vue'
|
import { useElementSize } from '@vueuse/core'
|
||||||
import Button from 'primevue/button'
|
|
||||||
import { genModelKey } from 'utils/model'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useModelNodeAction } from 'hooks/model'
|
import { useModelNodeAction } from 'hooks/model'
|
||||||
import { useDialog } from 'hooks/dialog'
|
import { BaseModel } from 'types/typings'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
model: Model
|
model: BaseModel
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const dialog = useDialog()
|
|
||||||
|
|
||||||
const openDetailDialog = () => {
|
|
||||||
const basename = props.model.fullname.split('/').pop()!
|
|
||||||
const filename = basename.replace(props.model.extension, '')
|
|
||||||
|
|
||||||
dialog.open({
|
|
||||||
key: genModelKey(props.model),
|
|
||||||
title: filename,
|
|
||||||
content: DialogModelDetail,
|
|
||||||
contentProps: { model: props.model },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const preview = computed(() =>
|
const preview = computed(() =>
|
||||||
Array.isArray(props.model.preview)
|
Array.isArray(props.model.preview)
|
||||||
? props.model.preview[0]
|
? props.model.preview[0]
|
||||||
: props.model.preview,
|
: props.model.preview,
|
||||||
)
|
)
|
||||||
|
|
||||||
const { addModelNode, dragToAddModelNode, copyModelNode, loadPreviewWorkflow } =
|
const container = ref<HTMLElement | null>(null)
|
||||||
useModelNodeAction(props.model)
|
|
||||||
|
const { width } = useElementSize(container)
|
||||||
|
|
||||||
|
const typeLabelScale = computed(() => {
|
||||||
|
return width.value / 200
|
||||||
|
})
|
||||||
|
|
||||||
|
const { dragToAddModelNode } = useModelNodeAction()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<form
|
<form
|
||||||
class="@container"
|
ref="container"
|
||||||
@submit.prevent="handleSubmit"
|
@submit.prevent="handleSubmit"
|
||||||
@reset.prevent="handleReset"
|
@reset.prevent="handleReset"
|
||||||
>
|
>
|
||||||
<div class="mx-auto w-full max-w-[50rem]">
|
<div class="mx-auto w-full max-w-[50rem]">
|
||||||
<div class="relative flex flex-col gap-4 overflow-hidden @xl:flex-row">
|
<div
|
||||||
|
:class="[
|
||||||
|
'relative flex gap-4 overflow-hidden',
|
||||||
|
$xl('flex-row', 'flex-col'),
|
||||||
|
]"
|
||||||
|
>
|
||||||
<ModelPreview
|
<ModelPreview
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
v-model:editable="editable"
|
v-model:editable="editable"
|
||||||
@@ -39,15 +44,11 @@
|
|||||||
</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 { useContainerQueries } from 'hooks/container'
|
||||||
import TabList from 'primevue/tablist'
|
|
||||||
import TabPanel from 'primevue/tabpanel'
|
|
||||||
import TabPanels from 'primevue/tabpanels'
|
|
||||||
import {
|
import {
|
||||||
useModelBaseInfoEditor,
|
useModelBaseInfoEditor,
|
||||||
useModelDescriptionEditor,
|
useModelDescriptionEditor,
|
||||||
@@ -55,8 +56,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, WithResolved } from 'types/typings'
|
||||||
|
import { ref, toRaw, watch } from 'vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
model: BaseModel
|
model: BaseModel
|
||||||
@@ -66,7 +73,7 @@ const props = defineProps<Props>()
|
|||||||
const editable = defineModel<boolean>('editable')
|
const editable = defineModel<boolean>('editable')
|
||||||
|
|
||||||
const emits = defineEmits<{
|
const emits = defineEmits<{
|
||||||
submit: [formData: BaseModel]
|
submit: [formData: WithResolved<BaseModel>]
|
||||||
reset: []
|
reset: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -93,4 +100,7 @@ watch(
|
|||||||
handleReset()
|
handleReset()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const container = ref<HTMLElement | null>(null)
|
||||||
|
const { $xl } = useContainerQueries(container)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="flex flex-col gap-4">
|
||||||
class="flex flex-col gap-4"
|
|
||||||
:style="{ ['--preview-width']: `${cardWidth}px` }"
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
:class="[
|
class="relative mx-auto w-full overflow-hidden rounded-lg preview-aspect"
|
||||||
'relative mx-auto w-full',
|
:style="$sm({ width: `${cardWidth}px` })"
|
||||||
'@sm:w-[var(--preview-width)]',
|
|
||||||
'overflow-hidden rounded-lg preview-aspect',
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<ResponseImage :src="preview" :error="noPreviewContent"></ResponseImage>
|
<div v-if="previewType === 'video'" class="h-full w-full p-1 hover:p-0">
|
||||||
|
<video
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
playsinline
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
disablepictureinpicture
|
||||||
|
preload="none"
|
||||||
|
>
|
||||||
|
<source :src="preview" />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponseImage
|
||||||
|
v-else
|
||||||
|
:src="preview"
|
||||||
|
:error="noPreviewContent"
|
||||||
|
></ResponseImage>
|
||||||
|
|
||||||
<Carousel
|
<Carousel
|
||||||
v-if="defaultContent.length > 1"
|
v-if="defaultContent.length > 1"
|
||||||
@@ -50,9 +61,8 @@
|
|||||||
<div class="h-10"></div>
|
<div class="h-10"></div>
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
'flex h-10 items-center gap-4',
|
'absolute flex h-10 items-center gap-4',
|
||||||
'absolute left-1/2 -translate-x-1/2',
|
$xl('left-0 translate-x-0', 'left-1/2 -translate-x-1/2'),
|
||||||
'@xl:left-0 @xl:translate-x-0',
|
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -88,19 +98,21 @@
|
|||||||
</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 { useContainerQueries } from 'hooks/container'
|
||||||
|
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()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
preview,
|
preview,
|
||||||
|
previewType,
|
||||||
typeOptions,
|
typeOptions,
|
||||||
currentType,
|
currentType,
|
||||||
defaultContent,
|
defaultContent,
|
||||||
@@ -109,4 +121,6 @@ const {
|
|||||||
updateLocalContent,
|
updateLocalContent,
|
||||||
noPreviewContent,
|
noPreviewContent,
|
||||||
} = useModelPreview()
|
} = useModelPreview()
|
||||||
|
|
||||||
|
const { $sm, $xl } = useContainerQueries()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
163
src/components/ResponseBreadcrumb.vue
Normal file
163
src/components/ResponseBreadcrumb.vue
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="container" class="breadcrumb-container">
|
||||||
|
<div v-if="firstItem" class="breadcrumb-item">
|
||||||
|
<span class="breadcrumb-label" @click="firstItem.onClick">
|
||||||
|
<i v-if="firstItem.icon" :class="firstItem.icon"></i>
|
||||||
|
<i v-else class="breadcrumb-name">{{ firstItem.name }}</i>
|
||||||
|
</span>
|
||||||
|
<ResponseSelect
|
||||||
|
v-if="!!firstItem.children?.length"
|
||||||
|
:items="firstItem.children"
|
||||||
|
>
|
||||||
|
<template #target="{ toggle, overlayVisible }">
|
||||||
|
<span class="breadcrumb-split" @click="toggle">
|
||||||
|
<i
|
||||||
|
class="pi pi-angle-right transition-all"
|
||||||
|
:style="{ transform: overlayVisible ? 'rotate(90deg)' : '' }"
|
||||||
|
></i>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ResponseSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!!renderedItems.collapsed.length" class="breadcrumb-item">
|
||||||
|
<ResponseSelect :items="renderedItems.collapsed">
|
||||||
|
<template #target="{ toggle }">
|
||||||
|
<span class="breadcrumb-split" @click="toggle">
|
||||||
|
<i class="pi pi-ellipsis-h"></i>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ResponseSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in renderedItems.tail"
|
||||||
|
:key="`${index}-${item.name}`"
|
||||||
|
class="breadcrumb-item"
|
||||||
|
>
|
||||||
|
<span class="breadcrumb-label" @click="item.onClick">
|
||||||
|
<i v-if="item.icon" :class="item.icon"></i>
|
||||||
|
<i v-else class="breadcrumb-name">{{ item.name }}</i>
|
||||||
|
</span>
|
||||||
|
<ResponseSelect v-if="!!item.children?.length" :items="item.children">
|
||||||
|
<template #target="{ toggle, overlayVisible }">
|
||||||
|
<span class="breadcrumb-split" @click="toggle">
|
||||||
|
<i
|
||||||
|
class="pi pi-angle-right transition-all"
|
||||||
|
:style="{ transform: overlayVisible ? 'rotate(90deg)' : '' }"
|
||||||
|
></i>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ResponseSelect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useElementSize } from '@vueuse/core'
|
||||||
|
import ResponseSelect from 'components/ResponseSelect.vue'
|
||||||
|
import { SelectOptions } from 'types/typings'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
name: string
|
||||||
|
icon?: string
|
||||||
|
onClick?: () => void
|
||||||
|
children?: SelectOptions[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: BreadcrumbItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const container = ref<HTMLElement | null>(null)
|
||||||
|
const { width } = useElementSize(container)
|
||||||
|
|
||||||
|
const firstItem = computed<BreadcrumbItem | null>(() => {
|
||||||
|
return props.items[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderedItems = computed(() => {
|
||||||
|
const [, ...items] = props.items
|
||||||
|
|
||||||
|
const lastItem = items.pop()
|
||||||
|
items.reverse()
|
||||||
|
|
||||||
|
const separatorWidth = 32
|
||||||
|
const calculateItemWidth = (item: BreadcrumbItem | undefined) => {
|
||||||
|
if (!item) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const context = canvas.getContext('2d')!
|
||||||
|
context.font = '16px Arial'
|
||||||
|
|
||||||
|
const text = item.name
|
||||||
|
return context.measureText(text).width + 16 + separatorWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstItemEL = container.value?.querySelector('div')
|
||||||
|
const firstItemWidth = firstItemEL?.getBoundingClientRect().width ?? 0
|
||||||
|
|
||||||
|
const lastItemWidth = calculateItemWidth(lastItem)
|
||||||
|
|
||||||
|
const collapseWidth = separatorWidth
|
||||||
|
|
||||||
|
let totalWidth = firstItemWidth + collapseWidth + lastItemWidth
|
||||||
|
const containerWidth = width.value - 18
|
||||||
|
const collapsed: SelectOptions[] = []
|
||||||
|
const tail: BreadcrumbItem[] = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const itemWidth = calculateItemWidth(item)
|
||||||
|
totalWidth += itemWidth
|
||||||
|
|
||||||
|
if (totalWidth < containerWidth) {
|
||||||
|
tail.unshift(item)
|
||||||
|
} else {
|
||||||
|
collapsed.unshift({
|
||||||
|
value: item.name,
|
||||||
|
label: item.name,
|
||||||
|
command: () => {
|
||||||
|
item.onClick?.()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastItem) {
|
||||||
|
tail.push(lastItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { collapsed, tail }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.breadcrumb-container {
|
||||||
|
@apply flex overflow-hidden rounded-lg bg-gray-500/30 px-2 py-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item {
|
||||||
|
@apply flex h-full overflow-hidden rounded border border-transparent hover:border-gray-500/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item:nth-of-type(-n + 2) {
|
||||||
|
@apply flex-shrink-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-label {
|
||||||
|
@apply flex h-full min-w-8 items-center overflow-hidden px-2 hover:bg-gray-500/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-name {
|
||||||
|
@apply overflow-hidden text-ellipsis whitespace-nowrap not-italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-split {
|
||||||
|
@apply flex aspect-square h-full min-w-8 items-center justify-center hover:bg-gray-500/30;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,13 +3,14 @@
|
|||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
:visible="true"
|
:visible="true"
|
||||||
@update:visible="updateVisible"
|
@update:visible="updateVisible"
|
||||||
|
:modal="modal"
|
||||||
:close-on-escape="false"
|
:close-on-escape="false"
|
||||||
:maximizable="!isMobile"
|
:maximizable="!isMobile"
|
||||||
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="p-0 flex-1"
|
||||||
:base-z-index="1000"
|
:base-z-index="1000"
|
||||||
:auto-z-index="isNil(zIndex)"
|
:auto-z-index="isNil(zIndex)"
|
||||||
:pt:mask:style="isNil(zIndex) ? {} : { zIndex: 1000 + zIndex }"
|
:pt:mask:style="isNil(zIndex) ? {} : { zIndex: 1000 + zIndex }"
|
||||||
@@ -75,9 +76,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 {
|
||||||
@@ -90,6 +92,7 @@ interface Props {
|
|||||||
minHeight?: number
|
minHeight?: number
|
||||||
maxHeight?: number
|
maxHeight?: number
|
||||||
zIndex?: number
|
zIndex?: number
|
||||||
|
modal?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -332,3 +335,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>
|
||||||
|
|||||||
@@ -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]
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
<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
|
||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
v-model="innerValue"
|
v-model="inputValue"
|
||||||
class="flex-1 border-none bg-transparent text-base outline-none"
|
class="flex-1 border-none bg-transparent text-base outline-none"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
@@ -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
|
||||||
@@ -36,22 +47,40 @@ interface Props {
|
|||||||
placeholder?: string
|
placeholder?: string
|
||||||
allowClear?: boolean
|
allowClear?: boolean
|
||||||
updateTrigger?: string
|
updateTrigger?: string
|
||||||
|
validate?: (value: string | undefined) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const [content, modifiers] = defineModel<string, 'trim'>()
|
const [content, modifiers] = defineModel<string, 'trim' | 'valid'>()
|
||||||
|
|
||||||
const inputRef = ref()
|
const inputRef = ref()
|
||||||
|
|
||||||
const innerValue = ref(content)
|
const innerValue = ref<string>()
|
||||||
|
const inputValue = computed({
|
||||||
|
get: () => {
|
||||||
|
return innerValue.value ?? content.value
|
||||||
|
},
|
||||||
|
set: (val) => {
|
||||||
|
innerValue.value = val
|
||||||
|
},
|
||||||
|
})
|
||||||
const trigger = computed(() => props.updateTrigger ?? 'change')
|
const trigger = computed(() => props.updateTrigger ?? 'change')
|
||||||
const updateContent = () => {
|
const updateContent = () => {
|
||||||
let value = innerValue.value
|
let value = inputValue.value
|
||||||
|
|
||||||
if (modifiers.trim) {
|
if (modifiers.trim) {
|
||||||
value = innerValue.value?.trim()
|
value = value?.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (modifiers.valid) {
|
||||||
|
const isValid = props.validate?.(value) ?? true
|
||||||
|
if (!isValid) {
|
||||||
|
innerValue.value = content.value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
innerValue.value = undefined
|
||||||
content.value = value
|
content.value = value
|
||||||
inputRef.value.value = value
|
inputRef.value.value = value
|
||||||
}
|
}
|
||||||
@@ -65,18 +94,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>
|
|
||||||
|
|||||||
@@ -1,315 +1,140 @@
|
|||||||
<template>
|
<template>
|
||||||
<div data-scroll-area class="group/scroll relative overflow-hidden">
|
<div class="group/scroll relative h-full overflow-hidden">
|
||||||
<div
|
<div ref="viewport" class="h-full w-full overflow-auto scrollbar-none">
|
||||||
ref="viewport"
|
<div ref="content">
|
||||||
data-scroll-viewport
|
|
||||||
class="h-full w-full overflow-auto scrollbar-none"
|
|
||||||
:style="{ contain: items ? 'strict' : undefined }"
|
|
||||||
@scroll="onContentScroll"
|
|
||||||
v-resize="onContainerResize"
|
|
||||||
>
|
|
||||||
<div data-scroll-content class="relative min-w-full">
|
|
||||||
<slot name="default">
|
<slot name="default">
|
||||||
<div
|
<slot v-if="renderedItems.length === 0" name="empty">
|
||||||
v-for="(item, index) in loadedItems"
|
|
||||||
:key="genRowKey(item, index)"
|
|
||||||
:style="{ height: `${itemSize}px` }"
|
|
||||||
>
|
|
||||||
<slot name="item" :item="item"></slot>
|
|
||||||
</div>
|
|
||||||
<slot v-if="loadedItems.length === 0" name="empty">
|
|
||||||
<div class="absolute w-full py-20 text-center">No Data</div>
|
<div class="absolute w-full py-20 text-center">No Data</div>
|
||||||
</slot>
|
</slot>
|
||||||
|
|
||||||
|
<div :style="{ height: `${headHeight}px` }"></div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
v-for="item in renderedItems"
|
||||||
|
:key="item.key"
|
||||||
|
:style="{ height: `${itemSize}px` }"
|
||||||
|
data-virtual-item
|
||||||
|
>
|
||||||
|
<slot name="item" :item="item"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :style="{ height: `${tailHeight}px` }"></div>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
data-scroll-space
|
|
||||||
class="pointer-events-none absolute left-0 top-0 h-px w-px"
|
|
||||||
:style="spaceStyle"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div ref="scroll" class="absolute right-0 top-0 h-full w-2">
|
||||||
v-for="scroll in scrollbars"
|
|
||||||
:key="scroll.direction"
|
|
||||||
v-show="scroll.visible"
|
|
||||||
v-bind="{ [`data-scroll-bar-${scroll.direction}`]: '' }"
|
|
||||||
:class="[
|
|
||||||
'pointer-events-none absolute z-auto h-full w-full rounded-full',
|
|
||||||
'data-[scroll-bar-horizontal]:bottom-0 data-[scroll-bar-horizontal]:left-0 data-[scroll-bar-horizontal]:h-2',
|
|
||||||
'data-[scroll-bar-vertical]:right-0 data-[scroll-bar-vertical]:top-0 data-[scroll-bar-vertical]:w-2',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-bind="{ ['data-scroll-thumb']: scroll.direction }"
|
ref="thumb"
|
||||||
:class="[
|
:class="[
|
||||||
'pointer-events-auto absolute h-full w-full rounded-full',
|
'absolute w-full cursor-pointer rounded-full bg-gray-500',
|
||||||
'cursor-pointer bg-black dark:bg-white',
|
'opacity-0 transition-opacity duration-300 group-hover/scroll:opacity-30',
|
||||||
'opacity-0 transition-opacity duration-300 group-hover/scroll:opacity-10',
|
|
||||||
]"
|
]"
|
||||||
:style="{
|
:style="{
|
||||||
[scrollbarAttrs[scroll.direction].size]: `${scroll.size}px`,
|
height: `${thumbSize}px`,
|
||||||
[scrollbarAttrs[scroll.direction].offset]: `${scroll.offset}px`,
|
top: `${thumbOffset}px`,
|
||||||
opacity: isDragging ? 0.1 : '',
|
opacity: isDragging ? '0.3' : undefined,
|
||||||
}"
|
}"
|
||||||
@mousedown="startDragThumb"
|
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" generic="T">
|
<script setup lang="ts" generic="T extends { key: string }">
|
||||||
import { nextTick, onUnmounted, ref, watch } from 'vue'
|
import { useDraggable, useElementSize, useScroll } from '@vueuse/core'
|
||||||
import { clamp, throttle } from 'lodash'
|
import { clamp } from 'lodash'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
interface ScrollAreaProps {
|
interface ScrollAreaProps {
|
||||||
items?: T[][]
|
items?: T[]
|
||||||
itemSize?: number
|
itemSize?: number
|
||||||
scrollbar?: boolean
|
|
||||||
rowKey?: string | ((item: T[]) => string)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<ScrollAreaProps>(), {
|
const props = defineProps<ScrollAreaProps>()
|
||||||
scrollbar: true,
|
|
||||||
})
|
|
||||||
const emit = defineEmits(['scroll', 'resize'])
|
|
||||||
|
|
||||||
type ScrollbarDirection = 'horizontal' | 'vertical'
|
const viewport = ref<HTMLElement | null>(null)
|
||||||
|
const content = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
interface Scrollbar {
|
const { height: viewportHeight } = useElementSize(viewport)
|
||||||
direction: ScrollbarDirection
|
const { height: contentHeight } = useElementSize(content)
|
||||||
visible: boolean
|
const { y: scrollY } = useScroll(viewport)
|
||||||
size: number
|
|
||||||
offset: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScrollbarAttribute {
|
const itemSize = computed(() => props.itemSize || 0)
|
||||||
clientSize: string
|
|
||||||
scrollOffset: string
|
|
||||||
pagePosition: string
|
|
||||||
offset: string
|
|
||||||
size: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollbarAttrs: Record<ScrollbarDirection, ScrollbarAttribute> = {
|
const viewRows = computed(() =>
|
||||||
horizontal: {
|
Math.ceil(viewportHeight.value / itemSize.value),
|
||||||
clientSize: 'clientWidth',
|
|
||||||
scrollOffset: 'scrollLeft',
|
|
||||||
pagePosition: 'pageX',
|
|
||||||
offset: 'left',
|
|
||||||
size: 'width',
|
|
||||||
},
|
|
||||||
vertical: {
|
|
||||||
clientSize: 'clientHeight',
|
|
||||||
scrollOffset: 'scrollTop',
|
|
||||||
pagePosition: 'pageY',
|
|
||||||
offset: 'top',
|
|
||||||
size: 'height',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollbars = ref<Record<ScrollbarDirection, Scrollbar>>({
|
|
||||||
horizontal: {
|
|
||||||
direction: 'horizontal',
|
|
||||||
visible: props.scrollbar,
|
|
||||||
size: 0,
|
|
||||||
offset: 0,
|
|
||||||
},
|
|
||||||
vertical: {
|
|
||||||
direction: 'vertical',
|
|
||||||
visible: props.scrollbar,
|
|
||||||
size: 0,
|
|
||||||
offset: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const isDragging = ref(false)
|
|
||||||
|
|
||||||
const spaceStyle = ref({})
|
|
||||||
const loadedItems = ref<T[][]>([])
|
|
||||||
|
|
||||||
const genRowKey = (item: any | any[], index: number) => {
|
|
||||||
if (typeof props.rowKey === 'function') {
|
|
||||||
return props.rowKey(item)
|
|
||||||
}
|
|
||||||
return item[props.rowKey ?? 'key'] ?? index
|
|
||||||
}
|
|
||||||
|
|
||||||
const setSpacerSize = () => {
|
|
||||||
const items = props.items
|
|
||||||
if (items) {
|
|
||||||
const itemSize = props.itemSize ?? 0
|
|
||||||
spaceStyle.value = { height: `${itemSize * items.length}px` }
|
|
||||||
} else {
|
|
||||||
spaceStyle.value = {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getContainerContent = (raw?: boolean): HTMLElement => {
|
|
||||||
const container = viewport.value as HTMLElement
|
|
||||||
|
|
||||||
if (props.items && !raw) {
|
|
||||||
return container.querySelector('[data-scroll-space]')!
|
|
||||||
}
|
|
||||||
return container.querySelector('[data-scroll-content]')!
|
|
||||||
}
|
|
||||||
|
|
||||||
const init = () => {
|
|
||||||
const container = viewport.value as HTMLElement
|
|
||||||
container.scrollTop = 0
|
|
||||||
getContainerContent().style.transform = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const calculateLoadItems = () => {
|
|
||||||
let visibleItems: any[] = []
|
|
||||||
|
|
||||||
if (props.items) {
|
|
||||||
const container = viewport.value as HTMLElement
|
|
||||||
const content = getContainerContent(true)
|
|
||||||
|
|
||||||
const resolveVisibleItems = (items: any[], attr: ScrollbarAttribute) => {
|
|
||||||
const containerSize = container[attr.clientSize]
|
|
||||||
const itemSize = props.itemSize!
|
|
||||||
const viewCount = Math.ceil(containerSize / itemSize)
|
|
||||||
|
|
||||||
let start = Math.floor(container[attr.scrollOffset] / itemSize)
|
|
||||||
const offset = start * itemSize
|
|
||||||
|
|
||||||
let end = start + viewCount
|
|
||||||
end = Math.min(end + viewCount, items.length)
|
|
||||||
|
|
||||||
content.style.transform = `translateY(${offset}px)`
|
|
||||||
return items.slice(start, end)
|
|
||||||
}
|
|
||||||
|
|
||||||
visibleItems = resolveVisibleItems(props.items, scrollbarAttrs.vertical)
|
|
||||||
}
|
|
||||||
|
|
||||||
loadedItems.value = visibleItems
|
|
||||||
}
|
|
||||||
|
|
||||||
const calculateScrollThumbSize = () => {
|
|
||||||
const container = viewport.value as HTMLElement
|
|
||||||
const content = getContainerContent()
|
|
||||||
|
|
||||||
const resolveScrollbarSize = (item: Scrollbar, attr: ScrollbarAttribute) => {
|
|
||||||
const containerSize: number = container[attr.clientSize]
|
|
||||||
const contentSize: number = content[attr.clientSize]
|
|
||||||
item.visible = props.scrollbar && contentSize > containerSize
|
|
||||||
item.size = Math.max(Math.pow(containerSize, 2) / contentSize, 16)
|
|
||||||
}
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
resolveScrollbarSize(scrollbars.value.horizontal, scrollbarAttrs.horizontal)
|
|
||||||
resolveScrollbarSize(scrollbars.value.vertical, scrollbarAttrs.vertical)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const onContainerResize: ResizeObserverCallback = throttle((entries) => {
|
|
||||||
emit('resize', entries)
|
|
||||||
if (isDragging.value) return
|
|
||||||
|
|
||||||
calculateScrollThumbSize()
|
|
||||||
})
|
|
||||||
|
|
||||||
const onContentScroll = throttle((event: Event) => {
|
|
||||||
emit('scroll', event)
|
|
||||||
if (isDragging.value) return
|
|
||||||
|
|
||||||
const container = event.target as HTMLDivElement
|
|
||||||
const content = getContainerContent()
|
|
||||||
|
|
||||||
const resolveOffset = (item: Scrollbar, attr: ScrollbarAttribute) => {
|
|
||||||
const containerSize = container[attr.clientSize]
|
|
||||||
const contentSize = content[attr.clientSize]
|
|
||||||
const scrollOffset = container[attr.scrollOffset]
|
|
||||||
|
|
||||||
item.offset =
|
|
||||||
(scrollOffset / (contentSize - containerSize)) *
|
|
||||||
(containerSize - item.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveOffset(scrollbars.value.horizontal, scrollbarAttrs.horizontal)
|
|
||||||
resolveOffset(scrollbars.value.vertical, scrollbarAttrs.vertical)
|
|
||||||
|
|
||||||
calculateLoadItems()
|
|
||||||
})
|
|
||||||
|
|
||||||
const viewport = ref<HTMLElement>()
|
|
||||||
const draggingDirection = ref<ScrollbarDirection>()
|
|
||||||
const prevDraggingEvent = ref<MouseEvent>()
|
|
||||||
|
|
||||||
const moveThumb = throttle((event: MouseEvent) => {
|
|
||||||
if (isDragging.value) {
|
|
||||||
const container = viewport.value!
|
|
||||||
const content = getContainerContent()
|
|
||||||
|
|
||||||
const resolveOffset = (item: Scrollbar, attr: ScrollbarAttribute) => {
|
|
||||||
const containerSize = container[attr.clientSize]
|
|
||||||
const contentSize = content[attr.clientSize]
|
|
||||||
|
|
||||||
// Resolve thumb position
|
|
||||||
const prevPagePos = prevDraggingEvent.value![attr.pagePosition]
|
|
||||||
const currPagePos = event[attr.pagePosition]
|
|
||||||
const offset = currPagePos - prevPagePos
|
|
||||||
item.offset = clamp(item.offset + offset, 0, containerSize - item.size)
|
|
||||||
|
|
||||||
// Resolve scroll position
|
|
||||||
const scrollOffset = containerSize - item.size
|
|
||||||
const offsetSize = contentSize - containerSize
|
|
||||||
|
|
||||||
container[attr.scrollOffset] = (item.offset / scrollOffset) * offsetSize
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollDirection = draggingDirection.value!
|
|
||||||
|
|
||||||
resolveOffset(
|
|
||||||
scrollbars.value[scrollDirection],
|
|
||||||
scrollbarAttrs[scrollDirection],
|
|
||||||
)
|
|
||||||
prevDraggingEvent.value = event
|
|
||||||
|
|
||||||
calculateLoadItems()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const stopMoveThumb = () => {
|
|
||||||
isDragging.value = false
|
|
||||||
draggingDirection.value = undefined
|
|
||||||
prevDraggingEvent.value = undefined
|
|
||||||
document.removeEventListener('mousemove', moveThumb)
|
|
||||||
document.removeEventListener('mouseup', stopMoveThumb)
|
|
||||||
document.body.style.userSelect = ''
|
|
||||||
document.body.style.cursor = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const startDragThumb = (event: MouseEvent) => {
|
|
||||||
isDragging.value = true
|
|
||||||
const target = event.target as HTMLElement
|
|
||||||
draggingDirection.value = <any>target.getAttribute('data-scroll-thumb')
|
|
||||||
prevDraggingEvent.value = event
|
|
||||||
document.addEventListener('mousemove', moveThumb)
|
|
||||||
document.addEventListener('mouseup', stopMoveThumb)
|
|
||||||
document.body.style.userSelect = 'none'
|
|
||||||
document.body.style.cursor = 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.items,
|
|
||||||
() => {
|
|
||||||
setSpacerSize()
|
|
||||||
calculateScrollThumbSize()
|
|
||||||
calculateLoadItems()
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
const offsetRows = computed(() => Math.floor(scrollY.value / itemSize.value))
|
||||||
|
|
||||||
onUnmounted(() => {
|
const items = computed(() => {
|
||||||
stopMoveThumb()
|
return props.items ?? []
|
||||||
})
|
})
|
||||||
|
|
||||||
defineExpose({
|
const state = computed(() => {
|
||||||
viewport,
|
const bufferRows = viewRows.value
|
||||||
init,
|
|
||||||
|
const fromRow = offsetRows.value - bufferRows
|
||||||
|
const toRow = offsetRows.value + bufferRows + viewRows.value
|
||||||
|
|
||||||
|
const itemCount = items.value.length
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: clamp(fromRow, 0, itemCount),
|
||||||
|
end: clamp(toRow, fromRow, itemCount),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderedItems = computed(() => {
|
||||||
|
const { start, end } = state.value
|
||||||
|
|
||||||
|
return props.items?.slice(start, end) ?? []
|
||||||
|
})
|
||||||
|
|
||||||
|
const headHeight = computed(() => {
|
||||||
|
return state.value.start * itemSize.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const tailHeight = computed(() => {
|
||||||
|
return (items.value.length - state.value.end) * itemSize.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const thumbSize = computed(() => {
|
||||||
|
if (viewportHeight.value >= contentHeight.value) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbHeight = Math.pow(viewportHeight.value, 2) / contentHeight.value
|
||||||
|
return Math.max(thumbHeight, 16)
|
||||||
|
})
|
||||||
|
|
||||||
|
const thumbOffset = computed({
|
||||||
|
get: () => {
|
||||||
|
return (
|
||||||
|
(scrollY.value / (contentHeight.value - viewportHeight.value)) *
|
||||||
|
(viewportHeight.value - thumbSize.value)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
set: (offset) => {
|
||||||
|
scrollY.value =
|
||||||
|
(offset / (viewportHeight.value - thumbSize.value)) *
|
||||||
|
(contentHeight.value - viewportHeight.value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const scroll = ref<HTMLElement | null>(null)
|
||||||
|
const thumb = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const { isDragging } = useDraggable(thumb, {
|
||||||
|
axis: 'y',
|
||||||
|
containerElement: scroll,
|
||||||
|
onStart: () => {
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
},
|
||||||
|
onMove: (position) => {
|
||||||
|
thumbOffset.value = position.y
|
||||||
|
},
|
||||||
|
onEnd: () => {
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
},
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,7 +2,14 @@
|
|||||||
<slot
|
<slot
|
||||||
v-if="type === 'drop'"
|
v-if="type === 'drop'"
|
||||||
name="target"
|
name="target"
|
||||||
v-bind="{ toggle, prefixIcon, suffixIcon, currentLabel, current }"
|
v-bind="{
|
||||||
|
toggle,
|
||||||
|
prefixIcon,
|
||||||
|
suffixIcon,
|
||||||
|
currentLabel,
|
||||||
|
current,
|
||||||
|
overlayVisible,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<div :class="['-my-1 py-1', $attrs.class]" @click="toggle">
|
<div :class="['-my-1 py-1', $attrs.class]" @click="toggle">
|
||||||
<Button
|
<Button
|
||||||
@@ -27,18 +34,15 @@
|
|||||||
</slot>
|
</slot>
|
||||||
|
|
||||||
<div v-else class="relative flex-1 overflow-hidden">
|
<div v-else class="relative flex-1 overflow-hidden">
|
||||||
<div
|
<div ref="scrollArea" class="h-full w-full overflow-auto scrollbar-none">
|
||||||
ref="scrollArea"
|
|
||||||
class="h-full w-full overflow-auto scrollbar-none"
|
|
||||||
v-resize="checkScrollPosition"
|
|
||||||
@scroll="checkScrollPosition"
|
|
||||||
>
|
|
||||||
<div ref="contentArea" class="table max-w-full">
|
<div ref="contentArea" class="table max-w-full">
|
||||||
<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 +71,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)"
|
||||||
>
|
>
|
||||||
@@ -126,7 +132,13 @@
|
|||||||
<slot v-else name="desktop">
|
<slot v-else name="desktop">
|
||||||
<slot name="container">
|
<slot name="container">
|
||||||
<slot name="desktop:container">
|
<slot name="desktop:container">
|
||||||
<Menu ref="menu" :model="items" :popup="true" :base-z-index="1000">
|
<Menu
|
||||||
|
ref="menu"
|
||||||
|
:model="items"
|
||||||
|
:popup="true"
|
||||||
|
:base-z-index="1000"
|
||||||
|
:pt:root:style="{ maxHeight: '300px', overflowX: 'hidden' }"
|
||||||
|
>
|
||||||
<template #item="{ item }">
|
<template #item="{ item }">
|
||||||
<slot name="item" :item="item">
|
<slot name="item" :item="item">
|
||||||
<slot name="desktop:container:item" :item="item">
|
<slot name="desktop:container:item" :item="item">
|
||||||
@@ -146,11 +158,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useElementSize, useScroll } from '@vueuse/core'
|
||||||
import { useConfig } from 'hooks/config'
|
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 { computed, ref } from 'vue'
|
import { SelectOptions } from 'types/typings'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
const current = defineModel()
|
const current = defineModel()
|
||||||
|
|
||||||
@@ -190,8 +204,12 @@ const toggle = (event: MouseEvent) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const overlayVisible = computed(() => {
|
||||||
|
return isMobile.value ? visible.value : (menu.value?.overlayVisible ?? false)
|
||||||
|
})
|
||||||
|
|
||||||
// Select Button Type
|
// Select Button Type
|
||||||
const scrollArea = ref()
|
const scrollArea = ref<HTMLElement | null>(null)
|
||||||
const contentArea = ref()
|
const contentArea = ref()
|
||||||
|
|
||||||
type ScrollPosition = 'left' | 'right'
|
type ScrollPosition = 'left' | 'right'
|
||||||
@@ -231,4 +249,16 @@ const checkScrollPosition = () => {
|
|||||||
scrollPosition.value = position
|
scrollPosition.value = position
|
||||||
showControlButton.value = contentWidth > containerWidth
|
showControlButton.value = contentWidth > containerWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { width, height } = useElementSize(scrollArea)
|
||||||
|
|
||||||
|
watch([width, height], () => {
|
||||||
|
checkScrollPosition()
|
||||||
|
})
|
||||||
|
|
||||||
|
useScroll(scrollArea, {
|
||||||
|
onScroll: () => {
|
||||||
|
checkScrollPosition()
|
||||||
|
},
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
110
src/components/SettingCardSize.vue
Normal file
110
src/components/SettingCardSize.vue
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<div class="flex-1 px-4">
|
||||||
|
<DataTable :value="sizeList">
|
||||||
|
<Column field="name" :header="$t('name')">
|
||||||
|
<template #body="{ data, field }">
|
||||||
|
{{ $t(data[field]) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="width" :header="$t('width')" class="min-w-36">
|
||||||
|
<template #body="{ data, field }">
|
||||||
|
<span class="flex items-center gap-4">
|
||||||
|
<Slider
|
||||||
|
v-model="data[field]"
|
||||||
|
class="flex-1"
|
||||||
|
v-bind="sizeStint"
|
||||||
|
></Slider>
|
||||||
|
<span>{{ data[field] }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="height" :header="$t('height')" class="min-w-36">
|
||||||
|
<template #body="{ data, field }">
|
||||||
|
<span class="flex items-center gap-4">
|
||||||
|
<Slider
|
||||||
|
v-model="data[field]"
|
||||||
|
class="flex-1"
|
||||||
|
v-bind="sizeStint"
|
||||||
|
></Slider>
|
||||||
|
<span>{{ data[field] }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between px-4">
|
||||||
|
<div></div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
:label="$t('reset')"
|
||||||
|
@click="handleReset"
|
||||||
|
></Button>
|
||||||
|
<Button :label="$t('cancel')" @click="handleCancelEditor"></Button>
|
||||||
|
<Button :label="$t('save')" @click="handleSaveSizeMap"></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useConfig } from 'hooks/config'
|
||||||
|
import { useDialog } from 'hooks/dialog'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Column from 'primevue/column'
|
||||||
|
import DataTable from 'primevue/datatable'
|
||||||
|
import Slider from 'primevue/slider'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const { cardSizeMap, defaultCardSizeMap } = useConfig()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const sizeList = ref()
|
||||||
|
|
||||||
|
const sizeStint = {
|
||||||
|
step: 10,
|
||||||
|
min: 80,
|
||||||
|
max: 320,
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSizeMap = (sizeMap: Record<string, string>) => {
|
||||||
|
return Object.entries(sizeMap).map(([key, value]) => {
|
||||||
|
const [width, height] = value.split('x')
|
||||||
|
return {
|
||||||
|
id: key,
|
||||||
|
name: key,
|
||||||
|
width: parseInt(width),
|
||||||
|
height: parseInt(height),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSizeList = (
|
||||||
|
sizeList: { name: string; width: number; height: number }[],
|
||||||
|
) => {
|
||||||
|
return Object.fromEntries(
|
||||||
|
sizeList.map(({ name, width, height }) => {
|
||||||
|
return [name, [width, height].join('x')]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
sizeList.value = resolveSizeMap(cardSizeMap.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
sizeList.value = resolveSizeMap(defaultCardSizeMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelEditor = () => {
|
||||||
|
sizeList.value = resolveSizeMap(cardSizeMap.value)
|
||||||
|
dialog.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveSizeMap = () => {
|
||||||
|
cardSizeMap.value = resolveSizeList(sizeList.value)
|
||||||
|
dialog.close()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { useRequest } from 'hooks/request'
|
import SettingCardSize from 'components/SettingCardSize.vue'
|
||||||
|
import { request } 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 { computed, onMounted, onUnmounted, readonly, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useToast } from './toast'
|
||||||
|
|
||||||
|
export const useConfig = defineStore('config', (store) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
export const useConfig = defineStore('config', () => {
|
|
||||||
const mobileDeviceBreakPoint = 759
|
const mobileDeviceBreakPoint = 759
|
||||||
const isMobile = ref(window.innerWidth < mobileDeviceBreakPoint)
|
const isMobile = ref(window.innerWidth < mobileDeviceBreakPoint)
|
||||||
|
|
||||||
type ModelFolder = Record<string, string[]>
|
|
||||||
const { data: modelFolders, refresh: refreshModelFolders } =
|
|
||||||
useRequest<ModelFolder>('/base-folders')
|
|
||||||
|
|
||||||
const checkDeviceType = () => {
|
const checkDeviceType = () => {
|
||||||
isMobile.value = window.innerWidth < mobileDeviceBreakPoint
|
isMobile.value = window.innerWidth < mobileDeviceBreakPoint
|
||||||
}
|
}
|
||||||
@@ -23,20 +24,63 @@ export const useConfig = defineStore('config', () => {
|
|||||||
window.removeEventListener('resize', checkDeviceType)
|
window.removeEventListener('resize', checkDeviceType)
|
||||||
})
|
})
|
||||||
|
|
||||||
const refresh = async () => {
|
const flatLayout = ref(false)
|
||||||
return Promise.all([refreshModelFolders()])
|
|
||||||
}
|
const defaultCardSizeMap = readonly({
|
||||||
|
'size.extraLarge': '240x320',
|
||||||
|
'size.large': '180x240',
|
||||||
|
'size.medium': '120x160',
|
||||||
|
'size.small': '80x120',
|
||||||
|
})
|
||||||
|
|
||||||
|
const cardSizeMap = ref<Record<string, string>>({ ...defaultCardSizeMap })
|
||||||
|
const cardSizeFlag = ref('size.extraLarge')
|
||||||
|
const cardSize = computed(() => {
|
||||||
|
const size = cardSizeMap.value[cardSizeFlag.value]
|
||||||
|
const [width = '120', height = '240'] = size.split('x')
|
||||||
|
return {
|
||||||
|
width: parseInt(width),
|
||||||
|
height: parseInt(height),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
isMobile,
|
isMobile,
|
||||||
gutter: 16,
|
gutter: 16,
|
||||||
|
defaultCardSizeMap: defaultCardSizeMap,
|
||||||
|
cardSizeMap: cardSizeMap,
|
||||||
|
cardSizeFlag: cardSizeFlag,
|
||||||
|
cardSize: cardSize,
|
||||||
cardWidth: 240,
|
cardWidth: 240,
|
||||||
aspect: 7 / 9,
|
aspect: 7 / 9,
|
||||||
modelFolders,
|
dialog: {
|
||||||
refresh,
|
showCardSizeSetting: () => {
|
||||||
|
store.dialog.open({
|
||||||
|
key: 'setting.cardSize',
|
||||||
|
title: t('setting.cardSize'),
|
||||||
|
content: SettingCardSize,
|
||||||
|
defaultSize: {
|
||||||
|
width: 500,
|
||||||
|
height: 390,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
flat: flatLayout,
|
||||||
}
|
}
|
||||||
|
|
||||||
useAddConfigSettings(config)
|
watch(cardSizeFlag, (val) => {
|
||||||
|
app.ui?.settings.setSettingValue('ModelManager.UI.CardSize', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(cardSizeMap, (val) => {
|
||||||
|
app.ui?.settings.setSettingValue(
|
||||||
|
'ModelManager.UI.CardSizeMap',
|
||||||
|
JSON.stringify(val),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
useAddConfigSettings(store)
|
||||||
|
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
@@ -49,11 +93,51 @@ declare module 'hooks/store' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function useAddConfigSettings(config: Config) {
|
export const configSetting = {
|
||||||
|
excludeScanTypes: 'ModelManager.Scan.excludeScanTypes',
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
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({
|
||||||
id: 'ModelManager.APIKey.HuggingFace',
|
id: 'ModelManager.APIKey.HuggingFace',
|
||||||
|
category: [t('modelManager'), t('setting.apiKey'), 'HuggingFace'],
|
||||||
name: 'HuggingFace API Key',
|
name: 'HuggingFace API Key',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
defaultValue: undefined,
|
defaultValue: undefined,
|
||||||
@@ -61,9 +145,157 @@ function useAddConfigSettings(config: Config) {
|
|||||||
|
|
||||||
app.ui?.settings.addSetting({
|
app.ui?.settings.addSetting({
|
||||||
id: 'ModelManager.APIKey.Civitai',
|
id: 'ModelManager.APIKey.Civitai',
|
||||||
|
category: [t('modelManager'), t('setting.apiKey'), 'Civitai'],
|
||||||
name: 'Civitai API Key',
|
name: 'Civitai API Key',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
defaultValue: undefined,
|
defaultValue: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const defaultCardSize = store.config.defaultCardSizeMap
|
||||||
|
|
||||||
|
app.ui?.settings.addSetting({
|
||||||
|
id: 'ModelManager.UI.CardSize',
|
||||||
|
category: [t('modelManager'), t('setting.ui'), 'CardSize'],
|
||||||
|
name: t('setting.cardSize'),
|
||||||
|
defaultValue: 'size.extraLarge',
|
||||||
|
type: 'hidden',
|
||||||
|
onChange: (val) => {
|
||||||
|
store.config.cardSizeFlag.value = val
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
app.ui?.settings.addSetting({
|
||||||
|
id: 'ModelManager.UI.CardSizeMap',
|
||||||
|
category: [t('modelManager'), t('setting.ui'), 'CardSizeMap'],
|
||||||
|
name: t('setting.cardSize'),
|
||||||
|
defaultValue: JSON.stringify(defaultCardSize),
|
||||||
|
type: 'hidden',
|
||||||
|
onChange(value) {
|
||||||
|
store.config.cardSizeMap.value = JSON.parse(value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
app.ui?.settings.addSetting({
|
||||||
|
id: 'ModelManager.UI.Flat',
|
||||||
|
category: [t('modelManager'), t('setting.ui'), 'Flat'],
|
||||||
|
name: t('setting.useFlatUI'),
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
onChange(value) {
|
||||||
|
store.dialog.closeAll()
|
||||||
|
store.config.flat.value = value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Scan information
|
||||||
|
app.ui?.settings.addSetting({
|
||||||
|
id: 'ModelManager.ScanFiles.Full',
|
||||||
|
category: [t('modelManager'), t('setting.scan'), 'Full'],
|
||||||
|
name: t('setting.scanAll'),
|
||||||
|
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',
|
||||||
|
category: [t('modelManager'), t('setting.scan'), 'Incremental'],
|
||||||
|
name: t('setting.scanMissing'),
|
||||||
|
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
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
app.ui?.settings.addSetting({
|
||||||
|
id: configSetting.excludeScanTypes,
|
||||||
|
category: [t('modelManager'), t('setting.scan'), 'ExcludeScanTypes'],
|
||||||
|
name: t('setting.excludeScanTypes'),
|
||||||
|
defaultValue: undefined,
|
||||||
|
type: 'text',
|
||||||
|
})
|
||||||
|
|
||||||
|
app.ui?.settings.addSetting({
|
||||||
|
id: 'ModelManager.Scan.IncludeHiddenFiles',
|
||||||
|
category: [t('modelManager'), t('setting.scan'), 'IncludeHiddenFiles'],
|
||||||
|
name: t('setting.includeHiddenFiles'),
|
||||||
|
defaultValue: false,
|
||||||
|
type: 'boolean',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/hooks/container.ts
Normal file
41
src/hooks/container.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useElementSize } from '@vueuse/core'
|
||||||
|
import { type InjectionKey, type Ref, inject, provide, toRef } from 'vue'
|
||||||
|
|
||||||
|
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||||
|
|
||||||
|
const containerKey = Symbol('container') as InjectionKey<
|
||||||
|
Ref<HTMLElement | null>
|
||||||
|
>
|
||||||
|
|
||||||
|
export const useContainerQueries = (
|
||||||
|
el?: HTMLElement | null | Ref<HTMLElement | null>,
|
||||||
|
) => {
|
||||||
|
const container = inject(containerKey, el ? toRef(el) : toRef(document.body))
|
||||||
|
|
||||||
|
provide(containerKey, container)
|
||||||
|
|
||||||
|
const { width } = useElementSize(container)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param size unit rem
|
||||||
|
*/
|
||||||
|
const generator = (size: number) => {
|
||||||
|
return (content: any, defaultContent: any = undefined) => {
|
||||||
|
return width.value > size * rem ? content : defaultContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
$xs: generator(20),
|
||||||
|
$sm: generator(24),
|
||||||
|
$md: generator(28),
|
||||||
|
$lg: generator(32),
|
||||||
|
$xl: generator(36),
|
||||||
|
$2xl: generator(42),
|
||||||
|
$3xl: generator(48),
|
||||||
|
$4xl: generator(54),
|
||||||
|
$5xl: generator(60),
|
||||||
|
$6xl: generator(66),
|
||||||
|
$7xl: generator(72),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DialogItem {
|
export interface DialogItem {
|
||||||
key: string
|
key: string
|
||||||
title: string
|
title: string
|
||||||
content: Component
|
content: Component
|
||||||
@@ -20,6 +22,7 @@ interface DialogItem {
|
|||||||
maxWidth?: number
|
maxWidth?: number
|
||||||
minHeight?: number
|
minHeight?: number
|
||||||
maxHeight?: number
|
maxHeight?: number
|
||||||
|
modal?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDialog = defineStore('dialog', () => {
|
export const useDialog = defineStore('dialog', () => {
|
||||||
@@ -47,7 +50,12 @@ export const useDialog = defineStore('dialog', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const close = (dialog: { key: string }) => {
|
const close = (dialog?: { key: string }) => {
|
||||||
|
if (!dialog) {
|
||||||
|
stack.value.pop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const item = stack.value.find((item) => item.key === dialog.key)
|
const item = stack.value.find((item) => item.key === dialog.key)
|
||||||
if (item?.keepAlive) {
|
if (item?.keepAlive) {
|
||||||
item.visible = false
|
item.visible = false
|
||||||
@@ -56,7 +64,11 @@ export const useDialog = defineStore('dialog', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { stack, open, close, rise }
|
const closeAll = () => {
|
||||||
|
stack.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stack, open, close, closeAll, rise }
|
||||||
})
|
})
|
||||||
|
|
||||||
declare module 'hooks/store' {
|
declare module 'hooks/store' {
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
import { useLoading } from 'hooks/loading'
|
import { useLoading } from 'hooks/loading'
|
||||||
import { MarkdownTool, useMarkdown } from 'hooks/markdown'
|
|
||||||
import { request } from 'hooks/request'
|
import { request } from 'hooks/request'
|
||||||
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 { 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'
|
||||||
|
|
||||||
export const useDownload = defineStore('download', (store) => {
|
export const useDownload = defineStore('download', (store) => {
|
||||||
const { toast, confirm } = useToast()
|
const { toast, confirm, wrapperToastError } = useToast()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const taskList = ref<DownloadTask[]>([])
|
const taskList = ref<DownloadTask[]>([])
|
||||||
@@ -23,20 +29,24 @@ 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() {
|
||||||
request(`/download/${item.taskId}`, {
|
wrapperToastError(async () =>
|
||||||
method: 'PUT',
|
request(`/download/${item.taskId}`, {
|
||||||
body: JSON.stringify({
|
method: 'PUT',
|
||||||
status: 'pause',
|
body: JSON.stringify({
|
||||||
|
status: 'pause',
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
})
|
)()
|
||||||
},
|
},
|
||||||
resumeTask: () => {
|
resumeTask: () => {
|
||||||
request(`/download/${item.taskId}`, {
|
wrapperToastError(async () =>
|
||||||
method: 'PUT',
|
request(`/download/${item.taskId}`, {
|
||||||
body: JSON.stringify({
|
method: 'PUT',
|
||||||
status: 'resume',
|
body: JSON.stringify({
|
||||||
|
status: 'resume',
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
})
|
)()
|
||||||
},
|
},
|
||||||
deleteTask: () => {
|
deleteTask: () => {
|
||||||
confirm.require({
|
confirm.require({
|
||||||
@@ -53,9 +63,11 @@ export const useDownload = defineStore('download', (store) => {
|
|||||||
severity: 'danger',
|
severity: 'danger',
|
||||||
},
|
},
|
||||||
accept: () => {
|
accept: () => {
|
||||||
request(`/download/${item.taskId}`, {
|
wrapperToastError(async () =>
|
||||||
method: 'DELETE',
|
request(`/download/${item.taskId}`, {
|
||||||
})
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
)()
|
||||||
},
|
},
|
||||||
reject: () => {},
|
reject: () => {},
|
||||||
})
|
})
|
||||||
@@ -65,21 +77,12 @@ export const useDownload = defineStore('download', (store) => {
|
|||||||
return task
|
return task
|
||||||
}
|
}
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = wrapperToastError(async () => {
|
||||||
return request('/download/task')
|
return request('/download/task').then((resData: DownloadTaskOptions[]) => {
|
||||||
.then((resData: DownloadTaskOptions[]) => {
|
taskList.value = resData.map((item) => createTaskItem(item))
|
||||||
taskList.value = resData.map((item) => createTaskItem(item))
|
return taskList.value
|
||||||
return taskList.value
|
})
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
|
||||||
toast.add({
|
|
||||||
severity: 'error',
|
|
||||||
summary: 'Error',
|
|
||||||
detail: err.message ?? 'Failed to refresh download task list',
|
|
||||||
life: 15000,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeMount(() => {
|
onBeforeMount(() => {
|
||||||
api.addEventListener('reconnected', () => {
|
api.addEventListener('reconnected', () => {
|
||||||
@@ -150,253 +153,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>()
|
||||||
@@ -407,22 +165,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,
|
||||||
|
|||||||
170
src/hooks/explorer.ts
Normal file
170
src/hooks/explorer.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { genModelFullName, useModels } from 'hooks/model'
|
||||||
|
import { cloneDeep, filter, find } from 'lodash'
|
||||||
|
import { BaseModel, Model, SelectOptions } from 'types/typings'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
export interface FolderPathItem {
|
||||||
|
name: string
|
||||||
|
pathIndex: number
|
||||||
|
icon?: string
|
||||||
|
onClick: () => void
|
||||||
|
children: SelectOptions[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelFolder = BaseModel & {
|
||||||
|
children: ModelTreeNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelItem = Model
|
||||||
|
|
||||||
|
export type ModelTreeNode = BaseModel & {
|
||||||
|
children?: ModelTreeNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TreeItemNode = ModelTreeNode & {
|
||||||
|
onDbClick: () => void
|
||||||
|
onContextMenu: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useModelExplorer = () => {
|
||||||
|
const { data, folders, initialized, ...modelRest } = useModels()
|
||||||
|
|
||||||
|
const folderPaths = ref<FolderPathItem[]>([])
|
||||||
|
|
||||||
|
const genFolderItem = (
|
||||||
|
basename: string,
|
||||||
|
folder?: string,
|
||||||
|
subFolder?: string,
|
||||||
|
): ModelFolder => {
|
||||||
|
return {
|
||||||
|
id: basename,
|
||||||
|
basename: basename,
|
||||||
|
subFolder: subFolder ?? '',
|
||||||
|
pathIndex: 0,
|
||||||
|
sizeBytes: 0,
|
||||||
|
extension: '',
|
||||||
|
description: '',
|
||||||
|
metadata: {},
|
||||||
|
preview: '',
|
||||||
|
type: folder ?? '',
|
||||||
|
isFolder: true,
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataTreeList = computed<ModelTreeNode[]>(() => {
|
||||||
|
const rootChildren: ModelTreeNode[] = []
|
||||||
|
|
||||||
|
for (const folder in folders.value) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(folders.value, folder)) {
|
||||||
|
const folderItem = genFolderItem(folder)
|
||||||
|
|
||||||
|
const folderModels = cloneDeep(data.value[folder]) ?? []
|
||||||
|
|
||||||
|
const pathMap: Record<string, ModelTreeNode> = Object.fromEntries(
|
||||||
|
folderModels.map((item) => [
|
||||||
|
`${item.pathIndex}-${genModelFullName(item)}`,
|
||||||
|
item,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const item of folderModels) {
|
||||||
|
const key = genModelFullName(item)
|
||||||
|
const parentKey = key.split('/').slice(0, -1).join('/')
|
||||||
|
|
||||||
|
if (parentKey === '') {
|
||||||
|
folderItem.children.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentItem = pathMap[`${item.pathIndex}-${parentKey}`]
|
||||||
|
if (parentItem) {
|
||||||
|
parentItem.children ??= []
|
||||||
|
parentItem.children.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootChildren.push(folderItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const root: ModelTreeNode = genFolderItem('root')
|
||||||
|
root.children = rootChildren
|
||||||
|
return [root]
|
||||||
|
})
|
||||||
|
|
||||||
|
function findFolder(
|
||||||
|
list: ModelTreeNode[],
|
||||||
|
feature: { basename: string; pathIndex: number },
|
||||||
|
) {
|
||||||
|
return find(list, { ...feature, isFolder: true }) as ModelFolder | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFolders(list: ModelTreeNode[]) {
|
||||||
|
return filter(list, { isFolder: true }) as ModelFolder[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFolder(item: BaseModel) {
|
||||||
|
const folderItems: FolderPathItem[] = []
|
||||||
|
|
||||||
|
const folder = item.type
|
||||||
|
const subFolderParts = item.subFolder.split('/').filter(Boolean)
|
||||||
|
|
||||||
|
const pathParts: string[] = []
|
||||||
|
if (folder) {
|
||||||
|
pathParts.push(folder, ...subFolderParts)
|
||||||
|
}
|
||||||
|
pathParts.push(item.basename)
|
||||||
|
if (pathParts[0] !== 'root') {
|
||||||
|
pathParts.unshift('root')
|
||||||
|
}
|
||||||
|
|
||||||
|
let levelFolders = findFolders(dataTreeList.value)
|
||||||
|
for (const [index, part] of pathParts.entries()) {
|
||||||
|
const pathIndex = index < 2 ? 0 : item.pathIndex
|
||||||
|
|
||||||
|
const currentFolder = findFolder(levelFolders, {
|
||||||
|
basename: part,
|
||||||
|
pathIndex: pathIndex,
|
||||||
|
})
|
||||||
|
if (!currentFolder) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
levelFolders = findFolders(currentFolder.children ?? [])
|
||||||
|
folderItems.push({
|
||||||
|
name: currentFolder.basename,
|
||||||
|
pathIndex: pathIndex,
|
||||||
|
icon: index === 0 ? 'pi pi-desktop' : '',
|
||||||
|
onClick: () => {
|
||||||
|
openFolder(currentFolder)
|
||||||
|
},
|
||||||
|
children: levelFolders.map((child) => {
|
||||||
|
const name = child.basename
|
||||||
|
return {
|
||||||
|
value: name,
|
||||||
|
label: name,
|
||||||
|
command: () => openFolder(child),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
folderPaths.value = folderItems
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(initialized, (val) => {
|
||||||
|
if (val) {
|
||||||
|
openFolder(dataTreeList.value[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
folders,
|
||||||
|
folderPaths,
|
||||||
|
dataTreeList,
|
||||||
|
...modelRest,
|
||||||
|
findFolder: findFolder,
|
||||||
|
findFolders: findFolders,
|
||||||
|
openFolder: openFolder,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,20 +31,26 @@ export const useGlobalLoading = defineStore('loading', () => {
|
|||||||
return { loading }
|
return { loading }
|
||||||
})
|
})
|
||||||
|
|
||||||
export const useLoading = () => {
|
declare module 'hooks/store' {
|
||||||
const timer = ref<NodeJS.Timeout>()
|
interface StoreProvider {
|
||||||
|
loading: ReturnType<typeof useGlobalLoading>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const show = () => {
|
export const useLoading = () => {
|
||||||
timer.value = setTimeout(() => {
|
const targetTimer = ref<Record<string, NodeJS.Timeout | undefined>>({})
|
||||||
timer.value = undefined
|
|
||||||
|
const show = (target: string = '_default') => {
|
||||||
|
targetTimer.value[target] = setTimeout(() => {
|
||||||
|
targetTimer.value[target] = undefined
|
||||||
globalLoading.show()
|
globalLoading.show()
|
||||||
}, 200)
|
}, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hide = () => {
|
const hide = (target: string = '_default') => {
|
||||||
if (timer.value) {
|
if (targetTimer.value[target]) {
|
||||||
clearTimeout(timer.value)
|
clearTimeout(targetTimer.value[target])
|
||||||
timer.value = undefined
|
targetTimer.value[target] = undefined
|
||||||
} else {
|
} else {
|
||||||
globalLoading.hide()
|
globalLoading.hide()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,66 +1,147 @@
|
|||||||
import { useConfig } from 'hooks/config'
|
import DialogModelDetail from 'components/DialogModelDetail.vue'
|
||||||
import { useLoading } from 'hooks/loading'
|
import { useLoading } from 'hooks/loading'
|
||||||
import { useMarkdown } from 'hooks/markdown'
|
import { useMarkdown } from 'hooks/markdown'
|
||||||
import { request, useRequest } from 'hooks/request'
|
import { request } from 'hooks/request'
|
||||||
import { defineStore } from 'hooks/store'
|
import { defineStore } from 'hooks/store'
|
||||||
import { useToast } from 'hooks/toast'
|
import { useToast } from 'hooks/toast'
|
||||||
import { cloneDeep } from 'lodash'
|
import { castArray, cloneDeep } from 'lodash'
|
||||||
|
import { TreeNode } from 'primevue/treenode'
|
||||||
import { app } from 'scripts/comfyAPI'
|
import { app } from 'scripts/comfyAPI'
|
||||||
|
import { BaseModel, Model, SelectEvent, WithResolved } from 'types/typings'
|
||||||
import { bytesToSize, formatDate, previewUrlToFile } from 'utils/common'
|
import { bytesToSize, formatDate, previewUrlToFile } 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 {
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
InjectionKey,
|
type InjectionKey,
|
||||||
|
MaybeRefOrGetter,
|
||||||
onMounted,
|
onMounted,
|
||||||
provide,
|
provide,
|
||||||
|
type Ref,
|
||||||
ref,
|
ref,
|
||||||
toRaw,
|
toRaw,
|
||||||
|
toValue,
|
||||||
unref,
|
unref,
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { configSetting } from './config'
|
||||||
|
|
||||||
|
type ModelFolder = Record<string, string[]>
|
||||||
|
|
||||||
|
const modelFolderProvideKey = Symbol('modelFolder') as InjectionKey<
|
||||||
|
Ref<ModelFolder>
|
||||||
|
>
|
||||||
|
|
||||||
|
export const genModelFullName = (model: BaseModel) => {
|
||||||
|
return [model.subFolder, `${model.basename}${model.extension}`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const genModelUrl = (model: BaseModel) => {
|
||||||
|
const fullname = genModelFullName(model)
|
||||||
|
return `/model/${model.type}/${model.pathIndex}/${fullname}`
|
||||||
|
}
|
||||||
|
|
||||||
export const useModels = defineStore('models', (store) => {
|
export const useModels = defineStore('models', (store) => {
|
||||||
const { data, refresh } = useRequest<Model[]>('/models', { defaultValue: [] })
|
|
||||||
const { toast, confirm } = useToast()
|
const { toast, confirm } = useToast()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const loading = useLoading()
|
const loading = useLoading()
|
||||||
|
|
||||||
const updateModel = async (model: BaseModel, data: BaseModel) => {
|
const folders = ref<ModelFolder>({})
|
||||||
const formData = new FormData()
|
const initialized = ref(false)
|
||||||
|
|
||||||
|
const refreshFolders = async () => {
|
||||||
|
return request('/models').then((resData) => {
|
||||||
|
folders.value = resData
|
||||||
|
initialized.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
provide(modelFolderProvideKey, folders)
|
||||||
|
|
||||||
|
const models = ref<Record<string, Model[]>>({})
|
||||||
|
|
||||||
|
const refreshModels = async (folder: string) => {
|
||||||
|
loading.show(folder)
|
||||||
|
return request(`/models/${folder}`)
|
||||||
|
.then((resData) => {
|
||||||
|
models.value[folder] = resData
|
||||||
|
return resData
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loading.hide(folder)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshAllModels = async (force = false) => {
|
||||||
|
const forceRefresh = force ? refreshFolders() : Promise.resolve()
|
||||||
|
models.value = {}
|
||||||
|
const excludeScanTypes = app.ui?.settings.getSettingValue<string>(
|
||||||
|
configSetting.excludeScanTypes,
|
||||||
|
)
|
||||||
|
const customBlackList =
|
||||||
|
excludeScanTypes
|
||||||
|
?.split(',')
|
||||||
|
.map((type) => type.trim())
|
||||||
|
.filter(Boolean) ?? []
|
||||||
|
await forceRefresh.then(() =>
|
||||||
|
Promise.allSettled(
|
||||||
|
Object.keys(folders.value)
|
||||||
|
.filter((folder) => !customBlackList.includes(folder))
|
||||||
|
.map(refreshModels),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateModel = async (
|
||||||
|
model: BaseModel,
|
||||||
|
data: WithResolved<BaseModel>,
|
||||||
|
) => {
|
||||||
|
const updateData = new FormData()
|
||||||
let oldKey: string | null = null
|
let oldKey: string | null = null
|
||||||
|
let needUpdate = false
|
||||||
|
|
||||||
// Check current preview
|
// Check current preview
|
||||||
if (model.preview !== data.preview) {
|
if (model.preview !== data.preview) {
|
||||||
const previewFile = await previewUrlToFile(data.preview as string)
|
const preview = data.preview
|
||||||
formData.append('previewFile', previewFile)
|
if (preview) {
|
||||||
|
const previewFile = await previewUrlToFile(data.preview as string)
|
||||||
|
updateData.set('previewFile', previewFile)
|
||||||
|
} else {
|
||||||
|
updateData.set('previewFile', 'undefined')
|
||||||
|
}
|
||||||
|
needUpdate = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
|
needUpdate = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check current name and pathIndex
|
// Check current name and pathIndex
|
||||||
if (
|
if (
|
||||||
model.fullname !== data.fullname ||
|
model.subFolder !== data.subFolder ||
|
||||||
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', genModelFullName(data as BaseModel))
|
||||||
|
needUpdate = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.keys().next().done) {
|
if (!needUpdate) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.show()
|
loading.show()
|
||||||
await request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
|
|
||||||
|
await request(genModelUrl(model), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: formData,
|
body: updateData,
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
const error_message = err.message ?? err.error
|
const error_message = err.message ?? err.error
|
||||||
@@ -80,7 +161,7 @@ export const useModels = defineStore('models', (store) => {
|
|||||||
store.dialog.close({ key: oldKey })
|
store.dialog.close({ key: oldKey })
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh()
|
refreshModels(data.type)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteModel = async (model: BaseModel) => {
|
const deleteModel = async (model: BaseModel) => {
|
||||||
@@ -101,18 +182,18 @@ export const useModels = defineStore('models', (store) => {
|
|||||||
accept: () => {
|
accept: () => {
|
||||||
const dialogKey = genModelKey(model)
|
const dialogKey = genModelKey(model)
|
||||||
loading.show()
|
loading.show()
|
||||||
request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
|
request(genModelUrl(model), {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: 'Success',
|
summary: 'Success',
|
||||||
detail: `${model.fullname} Deleted`,
|
detail: `${model.basename} Deleted`,
|
||||||
life: 2000,
|
life: 2000,
|
||||||
})
|
})
|
||||||
store.dialog.close({ key: dialogKey })
|
store.dialog.close({ key: dialogKey })
|
||||||
return refresh()
|
return refreshModels(model.type)
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
resolve(void 0)
|
resolve(void 0)
|
||||||
@@ -136,7 +217,33 @@ export const useModels = defineStore('models', (store) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { data, refresh, remove: deleteModel, update: updateModel }
|
function openModelDetail(model: BaseModel) {
|
||||||
|
const filename = model.basename.replace(model.extension, '')
|
||||||
|
|
||||||
|
store.dialog.open({
|
||||||
|
key: genModelKey(model),
|
||||||
|
title: filename,
|
||||||
|
content: DialogModelDetail,
|
||||||
|
contentProps: { model: model },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFullPath(model: BaseModel) {
|
||||||
|
const fullname = genModelFullName(model)
|
||||||
|
const prefixPath = folders.value[model.type]?.[model.pathIndex]
|
||||||
|
return [prefixPath, fullname].filter(Boolean).join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialized: initialized,
|
||||||
|
folders: folders,
|
||||||
|
data: models,
|
||||||
|
refresh: refreshAllModels,
|
||||||
|
remove: deleteModel,
|
||||||
|
update: updateModel,
|
||||||
|
openModelDetail: openModelDetail,
|
||||||
|
getFullPath: getFullPath,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
declare module 'hooks/store' {
|
declare module 'hooks/store' {
|
||||||
@@ -164,15 +271,15 @@ export const useModelFormData = (getFormData: () => BaseModel) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubmitCallback = (data: BaseModel) => void
|
type SubmitCallback = (data: WithResolved<BaseModel>) => void
|
||||||
const submitCallback = ref<SubmitCallback[]>([])
|
const submitCallback = ref<SubmitCallback[]>([])
|
||||||
|
|
||||||
const registerSubmit = (callback: SubmitCallback) => {
|
const registerSubmit = (callback: SubmitCallback) => {
|
||||||
submitCallback.value.push(callback)
|
submitCallback.value.push(callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = () => {
|
const submit = (): WithResolved<BaseModel> => {
|
||||||
const data = cloneDeep(toRaw(unref(formData)))
|
const data: any = cloneDeep(toRaw(unref(formData)))
|
||||||
for (const callback of submitCallback.value) {
|
for (const callback of submitCallback.value) {
|
||||||
callback(data)
|
callback(data)
|
||||||
}
|
}
|
||||||
@@ -204,7 +311,10 @@ const baseInfoKey = Symbol('baseInfo') as InjectionKey<
|
|||||||
export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
|
export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
|
||||||
const { formData: model, modelData } = formInstance
|
const { formData: model, modelData } = formInstance
|
||||||
|
|
||||||
const { modelFolders } = useConfig()
|
const provideModelFolders = inject(modelFolderProvideKey)
|
||||||
|
const modelFolders = computed<ModelFolder>(() => {
|
||||||
|
return provideModelFolders?.value ?? {}
|
||||||
|
})
|
||||||
|
|
||||||
const type = computed({
|
const type = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
@@ -224,16 +334,25 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const subFolder = computed({
|
||||||
|
get: () => {
|
||||||
|
return model.value.subFolder
|
||||||
|
},
|
||||||
|
set: (val) => {
|
||||||
|
model.value.subFolder = val
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const extension = computed(() => {
|
const extension = computed(() => {
|
||||||
return model.value.extension
|
return model.value.extension
|
||||||
})
|
})
|
||||||
|
|
||||||
const basename = computed({
|
const basename = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
return model.value.fullname.replace(model.value.extension, '')
|
return model.value.basename
|
||||||
},
|
},
|
||||||
set: (val) => {
|
set: (val) => {
|
||||||
model.value.fullname = `${val ?? ''}${model.value.extension}`
|
model.value.basename = val
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -245,27 +364,35 @@ 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',
|
||||||
formatter: () => {
|
formatter: () => {
|
||||||
const modelType = modelData.value.type
|
const modelType = model.value.type
|
||||||
const pathIndex = modelData.value.pathIndex
|
const pathIndex = model.value.pathIndex
|
||||||
|
if (!modelType) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
const folders = modelFolders.value[modelType] ?? []
|
const folders = modelFolders.value[modelType] ?? []
|
||||||
return `${folders[pathIndex]}`
|
return [`${folders[pathIndex]}`, model.value.subFolder]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('/')
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'fullname',
|
key: 'basename',
|
||||||
formatter: (val) => val,
|
formatter: (val) => `${val}${model.value.extension}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'sizeBytes',
|
key: 'sizeBytes',
|
||||||
@@ -300,7 +427,9 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
|
|||||||
baseInfo,
|
baseInfo,
|
||||||
basename,
|
basename,
|
||||||
extension,
|
extension,
|
||||||
|
subFolder,
|
||||||
pathIndex,
|
pathIndex,
|
||||||
|
modelFolders,
|
||||||
}
|
}
|
||||||
|
|
||||||
provide(baseInfoKey, result)
|
provide(baseInfoKey, result)
|
||||||
@@ -312,6 +441,74 @@ export const useModelBaseInfo = () => {
|
|||||||
return inject(baseInfoKey)!
|
return inject(baseInfoKey)!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useModelFolder = (
|
||||||
|
option: {
|
||||||
|
type?: MaybeRefOrGetter<string>
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
|
const { data: models, folders: modelFolders } = useModels()
|
||||||
|
|
||||||
|
const pathOptions = computed(() => {
|
||||||
|
const type = toValue(option.type)
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderItems = cloneDeep(models.value[type]) ?? []
|
||||||
|
const pureFolders = folderItems.filter((item) => item.isFolder)
|
||||||
|
pureFolders.sort((a, b) => a.basename.localeCompare(b.basename))
|
||||||
|
|
||||||
|
const folders = modelFolders.value[type] ?? []
|
||||||
|
|
||||||
|
const root: TreeNode[] = []
|
||||||
|
|
||||||
|
for (const [index, folder] of folders.entries()) {
|
||||||
|
const pathIndexItem: TreeNode = {
|
||||||
|
key: folder,
|
||||||
|
label: folder,
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = pureFolders
|
||||||
|
.filter((item) => item.pathIndex === index)
|
||||||
|
.map((item) => {
|
||||||
|
const node: TreeNode = {
|
||||||
|
key: `${folder}/${genModelFullName(item)}`,
|
||||||
|
label: item.basename,
|
||||||
|
data: item,
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
})
|
||||||
|
const itemMap = Object.fromEntries(items.map((item) => [item.key, item]))
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const key = item.key
|
||||||
|
const parentKey = key.split('/').slice(0, -1).join('/')
|
||||||
|
|
||||||
|
if (parentKey === folder) {
|
||||||
|
pathIndexItem.children!.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentItem = itemMap[parentKey]
|
||||||
|
if (parentItem) {
|
||||||
|
parentItem.children ??= []
|
||||||
|
parentItem.children.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
root.push(pathIndexItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
return root
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
pathOptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editable preview image.
|
* Editable preview image.
|
||||||
*
|
*
|
||||||
@@ -335,9 +532,7 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
|
|||||||
* Default images
|
* Default images
|
||||||
*/
|
*/
|
||||||
const defaultContent = computed(() => {
|
const defaultContent = computed(() => {
|
||||||
return Array.isArray(model.value.preview)
|
return model.value.preview ? castArray(model.value.preview) : []
|
||||||
? model.value.preview
|
|
||||||
: [model.value.preview]
|
|
||||||
})
|
})
|
||||||
const defaultContentPage = ref(0)
|
const defaultContentPage = ref(0)
|
||||||
|
|
||||||
@@ -359,7 +554,8 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
|
|||||||
* No preview
|
* No preview
|
||||||
*/
|
*/
|
||||||
const noPreviewContent = computed(() => {
|
const noPreviewContent = computed(() => {
|
||||||
return `/model-manager/preview/${model.value.type}/0/no-preview.png`
|
const folder = model.value.type || 'unknown'
|
||||||
|
return `/model-manager/preview/${folder}/0/no-preview.png`
|
||||||
})
|
})
|
||||||
|
|
||||||
const preview = computed(() => {
|
const preview = computed(() => {
|
||||||
@@ -376,13 +572,17 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
|
|||||||
content = localContent.value
|
content = localContent.value
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
content = noPreviewContent.value
|
content = undefined
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
return content
|
return content
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const previewType = computed(() => {
|
||||||
|
return model.value.previewType
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
registerReset(() => {
|
registerReset(() => {
|
||||||
currentType.value = 'default'
|
currentType.value = 'default'
|
||||||
@@ -392,12 +592,13 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
registerSubmit((data) => {
|
registerSubmit((data) => {
|
||||||
data.preview = preview.value ?? noPreviewContent.value
|
data.preview = preview.value
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
preview,
|
preview,
|
||||||
|
previewType,
|
||||||
typeOptions,
|
typeOptions,
|
||||||
currentType,
|
currentType,
|
||||||
// default value
|
// default value
|
||||||
@@ -482,11 +683,11 @@ export const useModelMetadata = () => {
|
|||||||
return inject(metadataKey)!
|
return inject(metadataKey)!
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useModelNodeAction = (model: BaseModel) => {
|
export const useModelNodeAction = () => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { toast, wrapperToastError } = useToast()
|
const { toast, wrapperToastError } = useToast()
|
||||||
|
|
||||||
const createNode = (options: Record<string, any> = {}) => {
|
const createNode = (model: BaseModel, options: Record<string, any> = {}) => {
|
||||||
const nodeType = resolveModelTypeLoader(model.type)
|
const nodeType = resolveModelTypeLoader(model.type)
|
||||||
if (!nodeType) {
|
if (!nodeType) {
|
||||||
throw new Error(t('unSupportedModelType', [model.type]))
|
throw new Error(t('unSupportedModelType', [model.type]))
|
||||||
@@ -495,50 +696,52 @@ export const useModelNodeAction = (model: BaseModel) => {
|
|||||||
const node = window.LiteGraph.createNode(nodeType, null, options)
|
const node = window.LiteGraph.createNode(nodeType, null, options)
|
||||||
const widgetIndex = node.widgets.findIndex((w) => w.type === 'combo')
|
const widgetIndex = node.widgets.findIndex((w) => w.type === 'combo')
|
||||||
if (widgetIndex > -1) {
|
if (widgetIndex > -1) {
|
||||||
node.widgets[widgetIndex].value = model.fullname
|
node.widgets[widgetIndex].value = genModelFullName(model)
|
||||||
}
|
}
|
||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
const dragToAddModelNode = wrapperToastError((event: DragEvent) => {
|
const dragToAddModelNode = wrapperToastError(
|
||||||
// const target = document.elementFromPoint(event.clientX, event.clientY)
|
(model: BaseModel, event: DragEvent) => {
|
||||||
// if (
|
// const target = document.elementFromPoint(event.clientX, event.clientY)
|
||||||
// target?.tagName.toLocaleLowerCase() === 'canvas' &&
|
// if (
|
||||||
// target.id === 'graph-canvas'
|
// target?.tagName.toLocaleLowerCase() === 'canvas' &&
|
||||||
// ) {
|
// target.id === 'graph-canvas'
|
||||||
// const pos = app.clientPosToCanvasPos([event.clientX - 20, event.clientY])
|
// ) {
|
||||||
// const node = createNode({ pos })
|
// const pos = app.clientPosToCanvasPos([event.clientX - 20, event.clientY])
|
||||||
// app.graph.add(node)
|
// const node = createNode({ pos })
|
||||||
// app.canvas.selectNode(node)
|
// app.graph.add(node)
|
||||||
// }
|
// app.canvas.selectNode(node)
|
||||||
//
|
// }
|
||||||
// Use the legacy method instead
|
//
|
||||||
const removeEmbeddingExtension = true
|
// Use the legacy method instead
|
||||||
const strictDragToAdd = false
|
const removeEmbeddingExtension = true
|
||||||
|
const strictDragToAdd = false
|
||||||
|
|
||||||
ModelGrid.dragAddModel(
|
ModelGrid.dragAddModel(
|
||||||
event,
|
event,
|
||||||
model.type,
|
model.type,
|
||||||
model.fullname,
|
genModelFullName(model),
|
||||||
removeEmbeddingExtension,
|
removeEmbeddingExtension,
|
||||||
strictDragToAdd,
|
strictDragToAdd,
|
||||||
)
|
)
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const addModelNode = wrapperToastError(() => {
|
const addModelNode = wrapperToastError((model: BaseModel) => {
|
||||||
const selectedNodes = app.canvas.selected_nodes
|
const selectedNodes = app.canvas.selected_nodes
|
||||||
const firstSelectedNode = Object.values(selectedNodes)[0]
|
const firstSelectedNode = Object.values(selectedNodes)[0]
|
||||||
const offset = 25
|
const offset = 25
|
||||||
const pos = firstSelectedNode
|
const pos = firstSelectedNode
|
||||||
? [firstSelectedNode.pos[0] + offset, firstSelectedNode.pos[1] + offset]
|
? [firstSelectedNode.pos[0] + offset, firstSelectedNode.pos[1] + offset]
|
||||||
: app.canvas.canvas_mouse
|
: app.canvas.canvas_mouse
|
||||||
const node = createNode({ pos })
|
const node = createNode(model, { pos })
|
||||||
app.graph.add(node)
|
app.graph.add(node)
|
||||||
app.canvas.selectNode(node)
|
app.canvas.selectNode(node)
|
||||||
})
|
})
|
||||||
|
|
||||||
const copyModelNode = wrapperToastError(() => {
|
const copyModelNode = wrapperToastError((model: BaseModel) => {
|
||||||
const node = createNode()
|
const node = createNode(model)
|
||||||
app.canvas.copyToClipboard([node])
|
app.canvas.copyToClipboard([node])
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
@@ -548,13 +751,13 @@ export const useModelNodeAction = (model: BaseModel) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadPreviewWorkflow = wrapperToastError(async () => {
|
const loadPreviewWorkflow = wrapperToastError(async (model: BaseModel) => {
|
||||||
const previewUrl = model.preview as string
|
const previewUrl = model.preview as string
|
||||||
const response = await fetch(previewUrl)
|
const response = await fetch(previewUrl)
|
||||||
const data = await response.blob()
|
const data = await response.blob()
|
||||||
const type = data.type
|
const type = data.type
|
||||||
const extension = type.split('/').pop()
|
const extension = type.split('/').pop()
|
||||||
const file = new File([data], `${model.fullname}.${extension}`, { type })
|
const file = new File([data], `${model.basename}.${extension}`, { type })
|
||||||
app.handleFile(file)
|
app.handleFile(file)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { throttle } from 'lodash'
|
|
||||||
import { Directive } from 'vue'
|
|
||||||
|
|
||||||
export const resizeDirective: Directive<HTMLElement, ResizeObserverCallback> = {
|
|
||||||
mounted: (el, binding) => {
|
|
||||||
const callback = binding.value ?? (() => {})
|
|
||||||
const observer = new ResizeObserver(callback)
|
|
||||||
observer.observe(el)
|
|
||||||
el['observer'] = observer
|
|
||||||
},
|
|
||||||
unmounted: (el) => {
|
|
||||||
const observer = el['observer']
|
|
||||||
observer.disconnect()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defineResizeCallback = (
|
|
||||||
callback: ResizeObserverCallback,
|
|
||||||
wait?: number,
|
|
||||||
) => {
|
|
||||||
return throttle(callback, wait ?? 100)
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
59
src/i18n.ts
59
src/i18n.ts
@@ -1,3 +1,4 @@
|
|||||||
|
import { app } from 'scripts/comfyAPI'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const messages = {
|
const messages = {
|
||||||
@@ -24,20 +25,45 @@ const messages = {
|
|||||||
none: 'None',
|
none: 'None',
|
||||||
uploadFile: 'Upload File',
|
uploadFile: 'Upload File',
|
||||||
tapToChange: 'Tap description to change content',
|
tapToChange: 'Tap description to change content',
|
||||||
|
name: 'Name',
|
||||||
|
width: 'Width',
|
||||||
|
height: 'Height',
|
||||||
|
reset: 'Reset',
|
||||||
sort: {
|
sort: {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
size: 'Largest',
|
size: 'Largest',
|
||||||
created: 'Latest created',
|
created: 'Latest created',
|
||||||
modified: 'Latest modified',
|
modified: 'Latest modified',
|
||||||
},
|
},
|
||||||
|
size: {
|
||||||
|
extraLarge: 'Extra Large Icons',
|
||||||
|
large: 'Large Icons',
|
||||||
|
medium: 'Medium Icons',
|
||||||
|
small: 'Small Icons',
|
||||||
|
custom: 'Custom Size',
|
||||||
|
customTip: 'Set in `Settings > Model Manager > UI`',
|
||||||
|
},
|
||||||
info: {
|
info: {
|
||||||
type: 'Model Type',
|
type: 'Model Type',
|
||||||
pathIndex: 'Directory',
|
pathIndex: 'Directory',
|
||||||
fullname: 'File Name',
|
basename: 'File Name',
|
||||||
sizeBytes: 'File Size',
|
sizeBytes: 'File Size',
|
||||||
createdAt: 'Created At',
|
createdAt: 'Created At',
|
||||||
updatedAt: 'Updated At',
|
updatedAt: 'Updated At',
|
||||||
},
|
},
|
||||||
|
setting: {
|
||||||
|
apiKey: 'API Key',
|
||||||
|
cardHeight: 'Card Height',
|
||||||
|
cardWidth: 'Card Width',
|
||||||
|
scan: 'Scan',
|
||||||
|
scanMissing: 'Download missing information or preview',
|
||||||
|
scanAll: "Override all models' information and preview",
|
||||||
|
includeHiddenFiles: 'Include hidden files(start with .)',
|
||||||
|
excludeScanTypes: 'Exclude scan types (separate with commas)',
|
||||||
|
ui: 'UI',
|
||||||
|
cardSize: 'Card Size',
|
||||||
|
useFlatUI: 'Flat Layout',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
model: '模型',
|
model: '模型',
|
||||||
@@ -62,30 +88,55 @@ const messages = {
|
|||||||
none: '无',
|
none: '无',
|
||||||
uploadFile: '上传文件',
|
uploadFile: '上传文件',
|
||||||
tapToChange: '点击描述可更改内容',
|
tapToChange: '点击描述可更改内容',
|
||||||
|
name: '名称',
|
||||||
|
width: '宽度',
|
||||||
|
height: '高度',
|
||||||
|
reset: '重置',
|
||||||
sort: {
|
sort: {
|
||||||
name: '名称',
|
name: '名称',
|
||||||
size: '最大',
|
size: '最大',
|
||||||
created: '最新创建',
|
created: '最新创建',
|
||||||
modified: '最新修改',
|
modified: '最新修改',
|
||||||
},
|
},
|
||||||
|
size: {
|
||||||
|
extraLarge: '超大图标',
|
||||||
|
large: '大图标',
|
||||||
|
medium: '中等图标',
|
||||||
|
small: '小图标',
|
||||||
|
custom: '自定义尺寸',
|
||||||
|
customTip: '在 `设置 > 模型管理器 > 外观` 中设置',
|
||||||
|
},
|
||||||
info: {
|
info: {
|
||||||
type: '类型',
|
type: '类型',
|
||||||
pathIndex: '目录',
|
pathIndex: '目录',
|
||||||
fullname: '文件名',
|
basename: '文件名',
|
||||||
sizeBytes: '文件大小',
|
sizeBytes: '文件大小',
|
||||||
createdAt: '创建时间',
|
createdAt: '创建时间',
|
||||||
updatedAt: '更新时间',
|
updatedAt: '更新时间',
|
||||||
},
|
},
|
||||||
|
setting: {
|
||||||
|
apiKey: '密钥',
|
||||||
|
cardHeight: '卡片高度',
|
||||||
|
cardWidth: '卡片宽度',
|
||||||
|
scan: '扫描',
|
||||||
|
scanMissing: '下载缺失的信息或预览图片',
|
||||||
|
scanAll: '覆盖所有模型信息和预览图片',
|
||||||
|
includeHiddenFiles: '包含隐藏文件(以 . 开头的文件或文件夹)',
|
||||||
|
excludeScanTypes: '排除扫描类型(使用英文逗号隔开)',
|
||||||
|
ui: '外观',
|
||||||
|
cardSize: '卡片尺寸',
|
||||||
|
useFlatUI: '展平布局',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLocalLanguage = () => {
|
const getLocalLanguage = () => {
|
||||||
const local =
|
const local =
|
||||||
localStorage.getItem('Comfy.Settings.Comfy.Locale') ||
|
app.ui?.settings.getSettingValue<string>('Comfy.Locale') ||
|
||||||
navigator.language.split('-')[0] ||
|
navigator.language.split('-')[0] ||
|
||||||
'en'
|
'en'
|
||||||
|
|
||||||
return local.replace(/['"]/g, '')
|
return local
|
||||||
}
|
}
|
||||||
|
|
||||||
export const i18n = createI18n({
|
export const i18n = createI18n({
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { definePreset } from '@primevue/themes'
|
import { definePreset } from '@primevue/themes'
|
||||||
import Aura from '@primevue/themes/aura'
|
import Aura from '@primevue/themes/aura'
|
||||||
import { resizeDirective } from 'hooks/resize'
|
|
||||||
import PrimeVue from 'primevue/config'
|
import PrimeVue from 'primevue/config'
|
||||||
import ConfirmationService from 'primevue/confirmationservice'
|
import ConfirmationService from 'primevue/confirmationservice'
|
||||||
import ToastService from 'primevue/toastservice'
|
import ToastService from 'primevue/toastservice'
|
||||||
@@ -20,7 +19,6 @@ const ComfyUIPreset = definePreset(Aura, {
|
|||||||
function createVueApp(rootContainer: string | HTMLElement) {
|
function createVueApp(rootContainer: string | HTMLElement) {
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.directive('tooltip', Tooltip)
|
app.directive('tooltip', Tooltip)
|
||||||
app.directive('resize', resizeDirective)
|
|
||||||
app
|
app
|
||||||
.use(PrimeVue, {
|
.use(PrimeVue, {
|
||||||
theme: {
|
theme: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
212
src/style.css
212
src/style.css
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
14
src/types/global.d.ts
vendored
14
src/types/global.d.ts
vendored
@@ -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 =
|
||||||
@@ -154,8 +155,10 @@ declare namespace ComfyAPI {
|
|||||||
deprecated?: boolean
|
deprecated?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
class ComfySettingsDialog {
|
class ComfySettingsDialog extends dialog.ComfyDialog {
|
||||||
addSetting: (params: SettingParams) => { value: any }
|
addSetting: (params: SettingParams) => { value: any }
|
||||||
|
getSettingValue: <T>(id: string, defaultValue?: T) => T
|
||||||
|
setSettingValue: <T>(id: string, value: T) => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +200,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 {
|
||||||
|
|||||||
6
src/types/shims.d.ts
vendored
6
src/types/shims.d.ts
vendored
@@ -1,11 +1,5 @@
|
|||||||
export {}
|
export {}
|
||||||
|
|
||||||
declare module 'vue' {
|
|
||||||
interface ComponentCustomProperties {
|
|
||||||
vResize: (typeof import('hooks/resize'))['resizeDirective']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module 'hooks/store' {
|
declare module 'hooks/store' {
|
||||||
interface StoreProvider {}
|
interface StoreProvider {}
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/types/typings.d.ts
vendored
33
src/types/typings.d.ts
vendored
@@ -1,50 +1,57 @@
|
|||||||
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
|
|
||||||
basename: string
|
basename: string
|
||||||
extension: string
|
extension: string
|
||||||
sizeBytes: number
|
sizeBytes: number
|
||||||
type: string
|
type: string
|
||||||
|
subFolder: string
|
||||||
pathIndex: number
|
pathIndex: number
|
||||||
|
isFolder: boolean
|
||||||
preview: string | string[]
|
preview: string | string[]
|
||||||
|
previewType: string
|
||||||
description: string
|
description: string
|
||||||
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
|
||||||
|
children?: Model[]
|
||||||
}
|
}
|
||||||
|
|
||||||
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 WithResolved<T> = Omit<T, 'preview'> & {
|
||||||
|
preview: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
interface SelectOptions {
|
export type PassThrough<T = void> = T | object | undefined
|
||||||
|
|
||||||
|
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 +64,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 +76,4 @@ interface DownloadTask
|
|||||||
deleteTask: () => void
|
deleteTask: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomEventListener = (event: CustomEvent) => void
|
export type CustomEventListener = (event: CustomEvent) => void
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { app } from 'scripts/comfyAPI'
|
import { app } from 'scripts/comfyAPI'
|
||||||
|
|
||||||
const LiteGraph = window.LiteGraph
|
const LiteGraph = window.LiteGraph
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { BaseModel } from 'types/typings'
|
||||||
|
|
||||||
const loader = {
|
const loader = {
|
||||||
checkpoints: 'CheckpointLoaderSimple',
|
checkpoints: 'CheckpointLoaderSimple',
|
||||||
loras: 'LoraLoader',
|
loras: 'LoraLoader',
|
||||||
@@ -23,5 +25,5 @@ export const resolveModelTypeLoader = (type: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const genModelKey = (model: BaseModel) => {
|
export const genModelKey = (model: BaseModel) => {
|
||||||
return `${model.type}:${model.pathIndex}:${model.fullname}`
|
return `${model.type}:${model.pathIndex}:${model.subFolder}:${model.basename}${model.extension}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import container from '@tailwindcss/container-queries'
|
|
||||||
import plugin from 'tailwindcss/plugin'
|
import plugin from 'tailwindcss/plugin'
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
@@ -8,9 +7,11 @@ export default {
|
|||||||
darkMode: ['selector', '.dark-theme'],
|
darkMode: ['selector', '.dark-theme'],
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
container,
|
|
||||||
plugin(({ addUtilities }) => {
|
plugin(({ addUtilities }) => {
|
||||||
addUtilities({
|
addUtilities({
|
||||||
|
'.text-shadow': {
|
||||||
|
'text-shadow': '2px 2px 4px rgba(0, 0, 0, 0.5)',
|
||||||
|
},
|
||||||
'.scrollbar-none': {
|
'.scrollbar-none': {
|
||||||
'scrollbar-width': 'none',
|
'scrollbar-width': 'none',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user