Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cfbb5ac0e | ||
|
|
4472357537 | ||
|
|
aabf3f99b3 | ||
|
|
6bd6b19c1d | ||
|
|
411219df7d | ||
|
|
cc29349aee | ||
|
|
f639e3c795 | ||
|
|
5251eeaa93 | ||
|
|
3bfc6c28af | ||
|
|
c91eff16ae | ||
|
|
2d638a3451 | ||
|
|
280b6ed7c0 | ||
|
|
7de73ae09c | ||
|
|
0fdea64c79 | ||
|
|
2b9327e6ca | ||
|
|
c33b4e0333 | ||
|
|
6dcaed7764 | ||
|
|
ab4e0d38e1 | ||
|
|
581d2c14fc | ||
|
|
811f1bc352 | ||
|
|
5342b7ec92 | ||
|
|
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 |
86
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
Normal file
86
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: 'Something is not behaving as expected.'
|
||||||
|
title: '[Bug]: '
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Before submitting a **Bug Report**, please ensure the following:
|
||||||
|
|
||||||
|
- **1:** You are running the latest version of ComfyUI-Model-Manager.
|
||||||
|
- **2:** You have looked at the existing bug reports and made sure this isn't already reported.
|
||||||
|
- **3:** You confirmed that the bug is not caused by other custom nodes.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Environment
|
||||||
|
description: 'Describe as detailed as possible what your current usage environment is. local? cloud? etc...'
|
||||||
|
value: |
|
||||||
|
[Operating System]:
|
||||||
|
[Python Version]:
|
||||||
|
[ComfyUI Version]:
|
||||||
|
[ComfyUI Frontend Version]:
|
||||||
|
[ComfyUI-Model-Manager Version]:
|
||||||
|
[Browser Version]:
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
description: 'What you expected to happen.'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Actual Behavior
|
||||||
|
description: 'What actually happened. Please include a screenshot / video clip of the issue if possible.'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
description: "Describe how to reproduce the issue. Please be sure to attach a workflow JSON or PNG, ideally one that doesn't require custom nodes to test. If the bug open happens when certain custom nodes are used, most likely that custom node is what has the bug rather than ComfyUI, in which case it should be reported to the node's author."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Debug Logs
|
||||||
|
description: 'Please copy the output from your terminal logs here.'
|
||||||
|
render: powershell
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Browser Logs
|
||||||
|
description: 'Please copy the output from your browser logs here. You can access this by pressing F12 to toggle the developer tools, then navigating to the Console tab.'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Setting JSON
|
||||||
|
description: 'Please upload the setting file here. The setting file is located at `user/default/comfy.settings.json`'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: browsers
|
||||||
|
attributes:
|
||||||
|
label: What browsers do you use to access the UI ?
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Mozilla Firefox
|
||||||
|
- Google Chrome
|
||||||
|
- Brave
|
||||||
|
- Apple Safari
|
||||||
|
- Microsoft Edge
|
||||||
|
- Android
|
||||||
|
- iOS
|
||||||
|
- Other
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Other
|
||||||
|
description: 'Any other additional information you think might be helpful.'
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
39
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest an idea for this project
|
||||||
|
title: '[Feature Request]: '
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Is there an existing issue for this?
|
||||||
|
description: Please search to see if an issue already exists for the feature you want, and that it's not implemented in a recent build/commit.
|
||||||
|
options:
|
||||||
|
- label: I have searched the existing issues and checked the recent builds/commits
|
||||||
|
required: true
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
*Please fill this form with as much information as possible, provide screenshots and/or illustrations of the feature if possible*
|
||||||
|
- type: textarea
|
||||||
|
id: feature
|
||||||
|
attributes:
|
||||||
|
label: What would your feature do ?
|
||||||
|
description: Tell us about your feature in a very clear and simple way, and what problem it would solve
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: workflow
|
||||||
|
attributes:
|
||||||
|
label: Proposed workflow
|
||||||
|
description: Please provide us with step by step information on how you'd like the feature to be accessed and used
|
||||||
|
value: |
|
||||||
|
1. Go to ....
|
||||||
|
2. Press ....
|
||||||
|
3. ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: misc
|
||||||
|
attributes:
|
||||||
|
label: Additional information
|
||||||
|
description: Add any other context or screenshots about the feature request here.
|
||||||
3
.github/workflows/publish.yml
vendored
3
.github/workflows/publish.yml
vendored
@@ -11,6 +11,7 @@ jobs:
|
|||||||
publish-node:
|
publish-node:
|
||||||
name: Release and Publish Custom Node to registry
|
name: Release and Publish Custom Node to registry
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.repository_owner == 'hayden-fr' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -60,7 +61,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 assets/ 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
|
||||||
|
|||||||
@@ -65,4 +65,3 @@ There are three installation methods, choose one
|
|||||||
<img src="demo/scan-model-info.png" alt="Model Manager Demo Screenshot" style="max-width: 100%; max-height: 300px"/>
|
<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.
|
- Scan models and try to download information & preview.
|
||||||
- Support migration from `cdb-boop/ComfyUI-Model-Manager/main`
|
|
||||||
|
|||||||
280
__init__.py
280
__init__.py
@@ -1,10 +1,18 @@
|
|||||||
import os
|
|
||||||
import folder_paths
|
import folder_paths
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: This is an experiment
|
||||||
|
# Add .gguf extension to supported_pt_extensions
|
||||||
|
folder_paths.supported_pt_extensions.add(".gguf")
|
||||||
|
|
||||||
|
|
||||||
|
import os
|
||||||
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__))
|
extension_uri = utils.normalize_path(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
# Install requirements
|
||||||
requirements_path = utils.join_path(extension_uri, "requirements.txt")
|
requirements_path = utils.join_path(extension_uri, "requirements.txt")
|
||||||
|
|
||||||
with open(requirements_path, "r", encoding="utf-8") as f:
|
with open(requirements_path, "r", encoding="utf-8") as f:
|
||||||
@@ -24,275 +32,21 @@ if len(uninstalled_package) > 0:
|
|||||||
# Init config settings
|
# Init config settings
|
||||||
config.extension_uri = extension_uri
|
config.extension_uri = extension_uri
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
from aiohttp import web
|
# Add api routes
|
||||||
from .py import services
|
from .py import manager
|
||||||
|
from .py import download
|
||||||
|
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}"
|
|
||||||
utils.print_error(error_msg)
|
|
||||||
return web.json_response({"success": False, "error": error_msg})
|
|
||||||
|
|
||||||
|
|
||||||
@routes.put("/model-manager/download/{task_id}")
|
|
||||||
async def resume_download_task(request):
|
|
||||||
"""
|
|
||||||
Toggle download task status.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
task_id = request.match_info.get("task_id", None)
|
|
||||||
if task_id is None:
|
|
||||||
raise web.HTTPBadRequest(reason="Invalid task id")
|
|
||||||
json_data = await request.json()
|
|
||||||
status = json_data.get("status", None)
|
|
||||||
if status == "pause":
|
|
||||||
await services.pause_model_download_task(task_id)
|
|
||||||
elif status == "resume":
|
|
||||||
await services.resume_model_download_task(task_id, request)
|
|
||||||
else:
|
|
||||||
raise web.HTTPBadRequest(reason="Invalid status")
|
|
||||||
|
|
||||||
return web.json_response({"success": True})
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Resume download task failed: {str(e)}"
|
|
||||||
utils.print_error(error_msg)
|
|
||||||
return web.json_response({"success": False, "error": error_msg})
|
|
||||||
|
|
||||||
|
|
||||||
@routes.delete("/model-manager/download/{task_id}")
|
|
||||||
async def delete_model_download_task(request):
|
|
||||||
"""
|
|
||||||
Delete download task.
|
|
||||||
"""
|
|
||||||
task_id = request.match_info.get("task_id", None)
|
|
||||||
try:
|
|
||||||
await services.delete_model_download_task(task_id)
|
|
||||||
return web.json_response({"success": True})
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Delete download task failed: {str(e)}"
|
|
||||||
utils.print_error(error_msg)
|
|
||||||
return web.json_response({"success": False, "error": error_msg})
|
|
||||||
|
|
||||||
|
|
||||||
@routes.get("/model-manager/base-folders")
|
|
||||||
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.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.json()
|
|
||||||
try:
|
|
||||||
task_id = await services.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})
|
|
||||||
|
|
||||||
|
|
||||||
@routes.get("/model-manager/models")
|
|
||||||
async def read_models(request):
|
|
||||||
"""
|
|
||||||
Scan all models and read their information.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = services.scan_models(request)
|
|
||||||
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/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)}"
|
|
||||||
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)
|
|
||||||
index = int(request.match_info.get("index", None))
|
|
||||||
filename = request.match_info.get("filename", None)
|
|
||||||
|
|
||||||
model_data: dict = await request.json()
|
|
||||||
|
|
||||||
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, 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)
|
|
||||||
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)}"
|
|
||||||
utils.print_error(error_msg)
|
|
||||||
return web.json_response({"success": False, "error": error_msg})
|
|
||||||
|
|
||||||
|
|
||||||
@routes.get("/model-manager/model-info")
|
|
||||||
async def fetch_model_info(request):
|
|
||||||
"""
|
|
||||||
Fetch model information from network with model page.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
model_page = request.query.get("model-page", None)
|
|
||||||
result = services.fetch_model_info(model_page)
|
|
||||||
return web.json_response({"success": True, "data": result})
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Fetch model info failed: {str(e)}"
|
|
||||||
utils.print_error(error_msg)
|
|
||||||
return web.json_response({"success": False, "error": error_msg})
|
|
||||||
|
|
||||||
|
|
||||||
@routes.post("/model-manager/model-info/scan")
|
|
||||||
async def download_model_info(request):
|
|
||||||
"""
|
|
||||||
Create a task to download model information.
|
|
||||||
"""
|
|
||||||
post = await utils.get_request_body(request)
|
|
||||||
try:
|
|
||||||
scan_mode = post.get("scanMode", "diff")
|
|
||||||
await services.download_model_info(scan_mode, 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)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@routes.post("/model-manager/migrate")
|
|
||||||
async def migrate_legacy_information(request):
|
|
||||||
"""
|
|
||||||
Migrate legacy information.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await services.migrate_legacy_information(request)
|
|
||||||
return web.json_response({"success": True})
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Migrate model info failed: {str(e)}"
|
|
||||||
utils.print_error(error_msg)
|
|
||||||
return web.json_response({"success": False, "error": error_msg})
|
|
||||||
|
|
||||||
|
|
||||||
WEB_DIRECTORY = "web"
|
WEB_DIRECTORY = "web"
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
"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",
|
||||||
@@ -34,13 +33,14 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
"vue": "^3.4.31",
|
"vue": "^3.5.6",
|
||||||
"vue-i18n": "^9.13.1",
|
"vue-i18n": "^9.14.0",
|
||||||
"yaml": "^2.6.0"
|
"yaml": "^2.6.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|||||||
77
pnpm-lock.yaml
generated
77
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
|
||||||
@@ -27,18 +30,15 @@ importers:
|
|||||||
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))
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.4.31
|
specifier: ^3.5.6
|
||||||
version: 3.5.6(typescript@5.6.2)
|
version: 3.5.6(typescript@5.6.2)
|
||||||
vue-i18n:
|
vue-i18n:
|
||||||
specifier: ^9.13.1
|
specifier: ^9.14.0
|
||||||
version: 9.14.0(vue@3.5.6(typescript@5.6.2))
|
version: 9.14.0(vue@3.5.6(typescript@5.6.2))
|
||||||
yaml:
|
yaml:
|
||||||
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
|
||||||
@@ -470,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==}
|
||||||
|
|
||||||
@@ -493,6 +488,9 @@ packages:
|
|||||||
'@types/node@22.5.5':
|
'@types/node@22.5.5':
|
||||||
resolution: {integrity: sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==}
|
resolution: {integrity: sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==}
|
||||||
|
|
||||||
|
'@types/web-bluetooth@0.0.20':
|
||||||
|
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.13.0':
|
'@typescript-eslint/eslint-plugin@8.13.0':
|
||||||
resolution: {integrity: sha512-nQtBLiZYMUPkclSeC3id+x4uVd1SGtHuElTxL++SfP47jR0zfkZBJHc+gL4qPsgTuypz0k8Y2GheaDYn6Gy3rg==}
|
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}
|
||||||
@@ -609,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:
|
||||||
@@ -698,8 +705,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001662:
|
caniuse-lite@1.0.30001712:
|
||||||
resolution: {integrity: sha512-sgMUVwLmGseH8ZIrm1d51UbrhqMCH3jvS7gF/M6byuHOnKyLOBL7W8yz5V02OHwgLGA36o/AFhWzzh4uc5aqTA==}
|
resolution: {integrity: sha512-MBqPpGYYdQ7/hfKiet9SCI+nmN5/hp4ZzveOJubl5DTAMa5oggjAuoi0Z4onBpKPFI2ePGnQuQIzF3VxDjDJig==}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
@@ -1676,6 +1683,17 @@ packages:
|
|||||||
vscode-uri@3.0.8:
|
vscode-uri@3.0.8:
|
||||||
resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==}
|
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}
|
||||||
@@ -2000,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': {}
|
||||||
@@ -2021,6 +2035,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.19.8
|
undici-types: 6.19.8
|
||||||
|
|
||||||
|
'@types/web-bluetooth@0.0.20': {}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.13.0(@typescript-eslint/parser@8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)':
|
'@typescript-eslint/eslint-plugin@8.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.12.1
|
'@eslint-community/regexpp': 4.12.1
|
||||||
@@ -2193,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
|
||||||
@@ -2236,7 +2271,7 @@ snapshots:
|
|||||||
autoprefixer@10.4.20(postcss@8.4.47):
|
autoprefixer@10.4.20(postcss@8.4.47):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.23.3
|
browserslist: 4.23.3
|
||||||
caniuse-lite: 1.0.30001662
|
caniuse-lite: 1.0.30001712
|
||||||
fraction.js: 4.3.7
|
fraction.js: 4.3.7
|
||||||
normalize-range: 0.1.2
|
normalize-range: 0.1.2
|
||||||
picocolors: 1.1.0
|
picocolors: 1.1.0
|
||||||
@@ -2264,7 +2299,7 @@ snapshots:
|
|||||||
|
|
||||||
browserslist@4.23.3:
|
browserslist@4.23.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
caniuse-lite: 1.0.30001662
|
caniuse-lite: 1.0.30001712
|
||||||
electron-to-chromium: 1.5.25
|
electron-to-chromium: 1.5.25
|
||||||
node-releases: 2.0.18
|
node-releases: 2.0.18
|
||||||
update-browserslist-db: 1.1.0(browserslist@4.23.3)
|
update-browserslist-db: 1.1.0(browserslist@4.23.3)
|
||||||
@@ -2273,7 +2308,7 @@ snapshots:
|
|||||||
|
|
||||||
camelcase-css@2.0.1: {}
|
camelcase-css@2.0.1: {}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001662: {}
|
caniuse-lite@1.0.30001712: {}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3192,6 +3227,10 @@ snapshots:
|
|||||||
|
|
||||||
vscode-uri@3.0.8: {}
|
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
|
||||||
|
|||||||
617
py/download.py
617
py/download.py
@@ -2,9 +2,16 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
import folder_paths
|
import folder_paths
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -61,7 +68,7 @@ class TaskContent:
|
|||||||
description: str
|
description: str
|
||||||
downloadPlatform: str
|
downloadPlatform: str
|
||||||
downloadUrl: str
|
downloadUrl: str
|
||||||
sizeBytes: int
|
sizeBytes: float
|
||||||
hashes: Optional[dict[str, str]] = None
|
hashes: Optional[dict[str, str]] = None
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
@@ -71,7 +78,7 @@ class TaskContent:
|
|||||||
self.description = kwargs.get("description", None)
|
self.description = kwargs.get("description", None)
|
||||||
self.downloadPlatform = kwargs.get("downloadPlatform", None)
|
self.downloadPlatform = kwargs.get("downloadPlatform", None)
|
||||||
self.downloadUrl = kwargs.get("downloadUrl", None)
|
self.downloadUrl = kwargs.get("downloadUrl", None)
|
||||||
self.sizeBytes = int(kwargs.get("sizeBytes", 0))
|
self.sizeBytes = float(kwargs.get("sizeBytes", 0))
|
||||||
self.hashes = kwargs.get("hashes", None)
|
self.hashes = kwargs.get("hashes", None)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
@@ -87,185 +94,272 @@ class TaskContent:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
download_model_task_status: dict[str, TaskStatus] = {}
|
class ModelDownload:
|
||||||
download_thread_pool = thread.DownloadThreadPool()
|
def add_routes(self, routes):
|
||||||
|
|
||||||
|
@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 set_task_content(task_id: str, task_content: Union[TaskContent, dict]):
|
@routes.put("/model-manager/download/{task_id}")
|
||||||
download_path = utils.get_download_path()
|
async def resume_download_task(request):
|
||||||
task_file_path = utils.join_path(download_path, f"{task_id}.task")
|
"""
|
||||||
utils.save_dict_pickle_file(task_file_path, task_content)
|
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 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_content(task_id: str):
|
@routes.delete("/model-manager/download/{task_id}")
|
||||||
download_path = utils.get_download_path()
|
async def delete_model_download_task(request):
|
||||||
task_file = utils.join_path(download_path, f"{task_id}.task")
|
"""
|
||||||
if not os.path.isfile(task_file):
|
Delete download task.
|
||||||
raise RuntimeError(f"Task {task_id} not found")
|
"""
|
||||||
task_content = utils.load_dict_pickle_file(task_file)
|
task_id = request.match_info.get("task_id", None)
|
||||||
return TaskContent(**task_content)
|
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})
|
||||||
|
|
||||||
|
@routes.post("/model-manager/model")
|
||||||
|
async def create_model(request):
|
||||||
|
"""
|
||||||
|
Create a new model.
|
||||||
|
|
||||||
def get_task_status(task_id: str):
|
request body: x-www-form-urlencoded
|
||||||
task_status = download_model_task_status.get(task_id, None)
|
- 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})
|
||||||
|
|
||||||
if task_status is None:
|
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,
|
|
||||||
|
def get_task_status(self, task_id: str):
|
||||||
|
task_status = self.download_model_task_status.get(task_id, None)
|
||||||
|
|
||||||
|
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
|
||||||
|
task_status = TaskStatus(
|
||||||
|
taskId=task_id,
|
||||||
|
type=task_content.type,
|
||||||
|
fullname=task_content.fullname,
|
||||||
|
preview=utils.get_model_preview_name(download_file),
|
||||||
|
platform=task_content.downloadPlatform,
|
||||||
|
downloadedSize=download_size,
|
||||||
|
totalSize=task_content.sizeBytes,
|
||||||
|
progress=download_size / total_size * 100 if total_size > 0 else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
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())
|
||||||
|
|
||||||
download_model_task_status[task_id] = task_status
|
return task_list
|
||||||
|
|
||||||
return task_status
|
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}")
|
||||||
|
|
||||||
def delete_task_status(task_id: str):
|
download_path = utils.get_download_path()
|
||||||
download_model_task_status.pop(task_id, None)
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
async def scan_model_download_task_list():
|
try:
|
||||||
"""
|
preview_file = task_data.pop("previewFile", None)
|
||||||
Scan the download directory and send the task list to the client.
|
utils.save_model_preview_image(task_path, preview_file, download_platform)
|
||||||
"""
|
self.set_task_content(task_id, task_data)
|
||||||
download_dir = utils.get_download_path()
|
task_status = TaskStatus(
|
||||||
task_files = utils.search_files(download_dir)
|
taskId=task_id,
|
||||||
task_files = folder_paths.filter_files_extensions(task_files, [".task"])
|
type=model_type,
|
||||||
task_files = sorted(
|
fullname=fullname,
|
||||||
task_files,
|
preview=utils.get_model_preview_name(task_path),
|
||||||
key=lambda x: os.stat(utils.join_path(download_dir, x)).st_ctime,
|
platform=download_platform,
|
||||||
reverse=True,
|
totalSize=float(task_data.get("sizeBytes", 0)),
|
||||||
)
|
)
|
||||||
task_list: list[dict] = []
|
self.download_model_task_status[task_id] = task_status
|
||||||
for task_file in task_files:
|
await utils.send_json("create_download_task", task_status.to_dict())
|
||||||
task_id = task_file.replace(".task", "")
|
except Exception as e:
|
||||||
task_status = get_task_status(task_id)
|
await self.delete_model_download_task(task_id)
|
||||||
task_list.append(task_status.to_dict())
|
raise RuntimeError(str(e)) from e
|
||||||
|
|
||||||
return task_list
|
await self.download_model(task_id, request)
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
async def pause_model_download_task(self, task_id: str):
|
||||||
async def create_model_download_task(task_data: dict, request):
|
task_status = self.get_task_status(task_id=task_id)
|
||||||
"""
|
|
||||||
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")
|
|
||||||
|
|
||||||
try:
|
|
||||||
preview_url = task_data.pop("preview", None)
|
|
||||||
utils.save_model_preview_image(task_path, preview_url)
|
|
||||||
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=task_data.get("downloadPlatform", None),
|
|
||||||
totalSize=float(task_data.get("sizeBytes", 0)),
|
|
||||||
)
|
|
||||||
download_model_task_status[task_id] = task_status
|
|
||||||
await utils.send_json("create_download_task", task_status.to_dict())
|
|
||||||
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"
|
task_status.status = "pause"
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
download_dir = utils.get_download_path()
|
async def delete_model_download_task(self, task_id: str):
|
||||||
task_file_list = os.listdir(download_dir)
|
task_status = self.get_task_status(task_id)
|
||||||
for task_file in task_file_list:
|
is_running = task_status.status == "doing"
|
||||||
task_file_target = os.path.splitext(task_file)[0]
|
task_status.status = "waiting"
|
||||||
if task_file_target == task_id:
|
await utils.send_json("delete_download_task", 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)
|
# 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))
|
||||||
|
|
||||||
async def download_model(task_id: str, request):
|
await utils.send_json("delete_download_task", task_id)
|
||||||
async def download_task(task_id: str):
|
|
||||||
async def report_progress(task_status: TaskStatus):
|
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())
|
await utils.send_json("update_download_task", task_status.to_dict())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# When starting a task from the queue, the task may not exist
|
|
||||||
task_status = get_task_status(task_id)
|
|
||||||
except:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Update task status
|
# Set download request headers
|
||||||
task_status.status = "doing"
|
headers = {"User-Agent": config.user_agent}
|
||||||
await utils.send_json("update_download_task", task_status.to_dict())
|
|
||||||
|
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:
|
try:
|
||||||
|
status = self.download_thread_pool.submit(download_task, task_id)
|
||||||
# Set download request headers
|
if status == "Waiting":
|
||||||
headers = {"User-Agent": config.user_agent}
|
task_status = self.get_task_status(task_id)
|
||||||
|
task_status.status = "waiting"
|
||||||
download_platform = task_status.platform
|
await utils.send_json("update_download_task", task_status.to_dict())
|
||||||
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,
|
|
||||||
)
|
|
||||||
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)
|
||||||
@@ -273,137 +367,122 @@ async def download_model(task_id: str, request):
|
|||||||
task_status.error = None
|
task_status.error = None
|
||||||
utils.print_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.to_dict())
|
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.to_dict())
|
|
||||||
task_status.error = None
|
|
||||||
utils.print_error(str(e))
|
|
||||||
|
|
||||||
|
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.to_dict())
|
# 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":
|
# Fixed issue #169. Some model information from Civitai, providing the wrong file size
|
||||||
break
|
response_total_size = float(response.headers.get("content-length", 0))
|
||||||
|
if total_size == 0 or total_size != response_total_size:
|
||||||
|
total_size = response_total_size
|
||||||
|
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.to_dict())
|
else:
|
||||||
|
task_status.status = "pause"
|
||||||
|
await utils.send_json("update_download_task", task_status.to_dict())
|
||||||
|
|||||||
577
py/information.py
Normal file
577
py/information.py
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
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
|
||||||
|
from . import thread
|
||||||
|
|
||||||
|
|
||||||
|
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.get("/model-manager/model-info/scan")
|
||||||
|
async def get_model_info_download_task(request):
|
||||||
|
"""
|
||||||
|
Get model information download task list.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = self.get_scan_model_info_task_list()
|
||||||
|
if result is not None:
|
||||||
|
await self.download_model_info(request)
|
||||||
|
return web.json_response({"success": True, "data": result})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Get model info download task list 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 create_model_info_download_task(request):
|
||||||
|
"""
|
||||||
|
Create a task to download model information.
|
||||||
|
|
||||||
|
- scanMode: The alternatives are diff and full.
|
||||||
|
- mode: The alternatives are diff and full.
|
||||||
|
- path: Scanning root path.
|
||||||
|
"""
|
||||||
|
post = await utils.get_request_body(request)
|
||||||
|
try:
|
||||||
|
# TODO scanMode is deprecated, use mode instead.
|
||||||
|
scan_mode = post.get("scanMode", "diff")
|
||||||
|
scan_mode = post.get("mode", scan_mode)
|
||||||
|
scan_path = post.get("path", None)
|
||||||
|
result = await self.create_scan_model_info_task(scan_mode, scan_path, request)
|
||||||
|
return web.json_response({"success": True, "data": result})
|
||||||
|
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
|
||||||
|
|
||||||
|
def get_scan_information_task_filepath(self):
|
||||||
|
download_dir = utils.get_download_path()
|
||||||
|
return utils.join_path(download_dir, "scan_information.task")
|
||||||
|
|
||||||
|
def get_scan_model_info_task_list(self):
|
||||||
|
scan_info_task_file = self.get_scan_information_task_filepath()
|
||||||
|
if os.path.isfile(scan_info_task_file):
|
||||||
|
return utils.load_dict_pickle_file(scan_info_task_file)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def create_scan_model_info_task(self, scan_mode: str, scan_path: str | None, request):
|
||||||
|
scan_info_task_file = self.get_scan_information_task_filepath()
|
||||||
|
scan_info_task_content = {"mode": scan_mode}
|
||||||
|
scan_models: dict[str, bool] = {}
|
||||||
|
|
||||||
|
scan_paths: list[str] = []
|
||||||
|
if scan_path is None:
|
||||||
|
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):
|
||||||
|
scan_paths.append(base_path)
|
||||||
|
else:
|
||||||
|
scan_paths = [scan_path]
|
||||||
|
|
||||||
|
for base_path in scan_paths:
|
||||||
|
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)
|
||||||
|
abs_model_path = utils.join_path(base_path, fullname)
|
||||||
|
utils.print_debug(f"Found model: {abs_model_path}")
|
||||||
|
scan_models[abs_model_path] = False
|
||||||
|
|
||||||
|
scan_info_task_content["models"] = scan_models
|
||||||
|
utils.save_dict_pickle_file(scan_info_task_file, scan_info_task_content)
|
||||||
|
await self.download_model_info(request)
|
||||||
|
return scan_info_task_content
|
||||||
|
|
||||||
|
download_thread_pool = thread.DownloadThreadPool()
|
||||||
|
|
||||||
|
async def download_model_info(self, request):
|
||||||
|
async def download_information_task(task_id: str):
|
||||||
|
scan_info_task_file = self.get_scan_information_task_filepath()
|
||||||
|
scan_info_task_content = utils.load_dict_pickle_file(scan_info_task_file)
|
||||||
|
scan_mode = scan_info_task_content.get("mode", "diff")
|
||||||
|
scan_models: dict[str, bool] = scan_info_task_content.get("models", {})
|
||||||
|
for key, value in scan_models.items():
|
||||||
|
if value is True:
|
||||||
|
continue
|
||||||
|
|
||||||
|
abs_model_path = key
|
||||||
|
base_path = os.path.dirname(abs_model_path)
|
||||||
|
|
||||||
|
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" or not has_preview or not has_description:
|
||||||
|
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)
|
||||||
|
|
||||||
|
scan_models[abs_model_path] = True
|
||||||
|
scan_info_task_content["models"] = scan_models
|
||||||
|
utils.save_dict_pickle_file(scan_info_task_file, scan_info_task_content)
|
||||||
|
utils.print_debug(f"Send update scan information task to frontend.")
|
||||||
|
await utils.send_json("update_scan_information_task", scan_info_task_content)
|
||||||
|
except Exception as e:
|
||||||
|
utils.print_error(f"Failed to download model info for {abs_model_path}: {e}")
|
||||||
|
|
||||||
|
os.remove(scan_info_task_file)
|
||||||
|
utils.print_info("Completed scan model information.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
task_id = uuid.uuid4().hex
|
||||||
|
self.download_thread_pool.submit(download_information_task, task_id)
|
||||||
|
except Exception as e:
|
||||||
|
utils.print_debug(str(e))
|
||||||
|
|
||||||
|
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))
|
||||||
317
py/searcher.py
317
py/searcher.py
@@ -1,317 +0,0 @@
|
|||||||
import os
|
|
||||||
import re
|
|
||||||
import yaml
|
|
||||||
import requests
|
|
||||||
import markdownify
|
|
||||||
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from urllib.parse import urlparse, parse_qs
|
|
||||||
|
|
||||||
from . import utils
|
|
||||||
|
|
||||||
|
|
||||||
class ModelSearcher(ABC):
|
|
||||||
"""
|
|
||||||
Abstract class for model searcher.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def search_by_url(self, url: str) -> list[dict]:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def search_by_hash(self, hash: str) -> dict:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class UnknownWebsiteSearcher(ModelSearcher):
|
|
||||||
def search_by_url(self, url: str):
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Unknown Website, please input a URL from huggingface.co or civitai.com."
|
|
||||||
)
|
|
||||||
|
|
||||||
def search_by_hash(self, hash: str):
|
|
||||||
raise RuntimeError(f"Unknown Website, unable to search with hash value.")
|
|
||||||
|
|
||||||
|
|
||||||
class CivitaiModelSearcher(ModelSearcher):
|
|
||||||
def search_by_url(self, url: str):
|
|
||||||
parsed_url = urlparse(url)
|
|
||||||
|
|
||||||
pathname = parsed_url.path
|
|
||||||
match = re.match(r"^/models/(\d*)", pathname)
|
|
||||||
model_id = match.group(1) if match else None
|
|
||||||
|
|
||||||
query_params = parse_qs(parsed_url.query)
|
|
||||||
version_id = query_params.get("modelVersionId", [None])[0]
|
|
||||||
|
|
||||||
if not model_id:
|
|
||||||
return []
|
|
||||||
|
|
||||||
response = requests.get(f"https://civitai.com/api/v1/models/{model_id}")
|
|
||||||
response.raise_for_status()
|
|
||||||
res_data: dict = response.json()
|
|
||||||
|
|
||||||
model_versions: list[dict] = res_data["modelVersions"]
|
|
||||||
if version_id:
|
|
||||||
model_versions = utils.filter_with(model_versions, {"id": int(version_id)})
|
|
||||||
|
|
||||||
models: list[dict] = []
|
|
||||||
|
|
||||||
for version in model_versions:
|
|
||||||
model_files: list[dict] = version.get("files", [])
|
|
||||||
model_files = utils.filter_with(model_files, {"type": "Model"})
|
|
||||||
|
|
||||||
shortname = version.get("name", None) if len(model_files) > 0 else None
|
|
||||||
|
|
||||||
for file in model_files:
|
|
||||||
fullname = file.get("name", None)
|
|
||||||
extension = os.path.splitext(fullname)[1]
|
|
||||||
basename = os.path.splitext(fullname)[0]
|
|
||||||
|
|
||||||
metadata_info = {
|
|
||||||
"website": "Civitai",
|
|
||||||
"modelPage": f"https://civitai.com/models/{model_id}?modelVersionId={version.get('id')}",
|
|
||||||
"author": res_data.get("creator", {}).get("username", None),
|
|
||||||
"baseModel": version.get("baseModel"),
|
|
||||||
"hashes": file.get("hashes"),
|
|
||||||
"metadata": file.get("metadata"),
|
|
||||||
"preview": [i["url"] for i in version["images"]],
|
|
||||||
}
|
|
||||||
|
|
||||||
description_parts: list[str] = []
|
|
||||||
description_parts.append("---")
|
|
||||||
description_parts.append(yaml.dump(metadata_info).strip())
|
|
||||||
description_parts.append("---")
|
|
||||||
description_parts.append("")
|
|
||||||
description_parts.append(f"# Trigger Words")
|
|
||||||
description_parts.append("")
|
|
||||||
description_parts.append(
|
|
||||||
", ".join(version.get("trainedWords", ["No trigger words"]))
|
|
||||||
)
|
|
||||||
description_parts.append("")
|
|
||||||
description_parts.append(f"# About this version")
|
|
||||||
description_parts.append("")
|
|
||||||
description_parts.append(
|
|
||||||
markdownify.markdownify(
|
|
||||||
version.get(
|
|
||||||
"description", "<p>No description about this version</p>"
|
|
||||||
)
|
|
||||||
).strip()
|
|
||||||
)
|
|
||||||
description_parts.append("")
|
|
||||||
description_parts.append(f"# {res_data.get('name')}")
|
|
||||||
description_parts.append("")
|
|
||||||
description_parts.append(
|
|
||||||
markdownify.markdownify(
|
|
||||||
res_data.get(
|
|
||||||
"description", "<p>No description about this model</p>"
|
|
||||||
)
|
|
||||||
).strip()
|
|
||||||
)
|
|
||||||
description_parts.append("")
|
|
||||||
|
|
||||||
model = {
|
|
||||||
"id": file.get("id"),
|
|
||||||
"shortname": shortname or basename,
|
|
||||||
"fullname": fullname,
|
|
||||||
"basename": basename,
|
|
||||||
"extension": extension,
|
|
||||||
"preview": metadata_info.get("preview"),
|
|
||||||
"sizeBytes": file.get("sizeKB", 0) * 1024,
|
|
||||||
"type": self._resolve_model_type(res_data.get("type", "unknown")),
|
|
||||||
"pathIndex": 0,
|
|
||||||
"description": "\n".join(description_parts),
|
|
||||||
"metadata": file.get("metadata"),
|
|
||||||
"downloadPlatform": "civitai",
|
|
||||||
"downloadUrl": file.get("downloadUrl"),
|
|
||||||
"hashes": file.get("hashes"),
|
|
||||||
}
|
|
||||||
models.append(model)
|
|
||||||
|
|
||||||
return models
|
|
||||||
|
|
||||||
def search_by_hash(self, hash: str):
|
|
||||||
if not hash:
|
|
||||||
raise RuntimeError(f"Hash value is empty.")
|
|
||||||
|
|
||||||
response = requests.get(
|
|
||||||
f"https://civitai.com/api/v1/model-versions/by-hash/{hash}"
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
version: dict = response.json()
|
|
||||||
|
|
||||||
model_id = version.get("modelId")
|
|
||||||
version_id = version.get("id")
|
|
||||||
|
|
||||||
model_page = (
|
|
||||||
f"https://civitai.com/models/{model_id}?modelVersionId={version_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
models = self.search_by_url(model_page)
|
|
||||||
|
|
||||||
for model in models:
|
|
||||||
sha256 = model.get("hashes", {}).get("SHA256")
|
|
||||||
if sha256 == hash:
|
|
||||||
return model
|
|
||||||
|
|
||||||
return models[0]
|
|
||||||
|
|
||||||
def _resolve_model_type(self, model_type: str):
|
|
||||||
map_legacy = {
|
|
||||||
"TextualInversion": "embeddings",
|
|
||||||
"LoCon": "loras",
|
|
||||||
"DoRA": "loras",
|
|
||||||
"Controlnet": "controlnet",
|
|
||||||
"Upscaler": "upscale_models",
|
|
||||||
"VAE": "vae",
|
|
||||||
"unknown": "unknown",
|
|
||||||
}
|
|
||||||
return map_legacy.get(model_type, f"{model_type.lower()}s")
|
|
||||||
|
|
||||||
|
|
||||||
class HuggingfaceModelSearcher(ModelSearcher):
|
|
||||||
def search_by_url(self, url: str):
|
|
||||||
parsed_url = urlparse(url)
|
|
||||||
|
|
||||||
pathname = parsed_url.path
|
|
||||||
|
|
||||||
space, name, *rest_paths = pathname.strip("/").split("/")
|
|
||||||
|
|
||||||
model_id = f"{space}/{name}"
|
|
||||||
rest_pathname = "/".join(rest_paths)
|
|
||||||
|
|
||||||
response = requests.get(f"https://huggingface.co/api/models/{model_id}")
|
|
||||||
response.raise_for_status()
|
|
||||||
res_data: dict = response.json()
|
|
||||||
|
|
||||||
sibling_files: list[str] = [
|
|
||||||
x.get("rfilename") for x in res_data.get("siblings", [])
|
|
||||||
]
|
|
||||||
|
|
||||||
model_files = utils.filter_with(
|
|
||||||
utils.filter_with(sibling_files, self._match_model_files()),
|
|
||||||
self._match_tree_files(rest_pathname),
|
|
||||||
)
|
|
||||||
|
|
||||||
image_files = utils.filter_with(
|
|
||||||
utils.filter_with(sibling_files, self._match_image_files()),
|
|
||||||
self._match_tree_files(rest_pathname),
|
|
||||||
)
|
|
||||||
image_files = [
|
|
||||||
f"https://huggingface.co/{model_id}/resolve/main/{filename}"
|
|
||||||
for filename in image_files
|
|
||||||
]
|
|
||||||
|
|
||||||
models: list[dict] = []
|
|
||||||
|
|
||||||
for filename in model_files:
|
|
||||||
fullname = os.path.basename(filename)
|
|
||||||
extension = os.path.splitext(fullname)[1]
|
|
||||||
basename = os.path.splitext(fullname)[0]
|
|
||||||
|
|
||||||
description_parts: list[str] = []
|
|
||||||
|
|
||||||
metadata_info = {
|
|
||||||
"website": "HuggingFace",
|
|
||||||
"modelPage": f"https://huggingface.co/{model_id}",
|
|
||||||
"author": res_data.get("author", None),
|
|
||||||
"preview": image_files,
|
|
||||||
}
|
|
||||||
|
|
||||||
description_parts: list[str] = []
|
|
||||||
description_parts.append("---")
|
|
||||||
description_parts.append(yaml.dump(metadata_info).strip())
|
|
||||||
description_parts.append("---")
|
|
||||||
description_parts.append("")
|
|
||||||
description_parts.append(f"# Trigger Words")
|
|
||||||
description_parts.append("")
|
|
||||||
description_parts.append("No trigger words")
|
|
||||||
description_parts.append("")
|
|
||||||
description_parts.append(f"# About this version")
|
|
||||||
description_parts.append("")
|
|
||||||
description_parts.append("No description about this version")
|
|
||||||
description_parts.append("")
|
|
||||||
description_parts.append(f"# {res_data.get('name')}")
|
|
||||||
description_parts.append("")
|
|
||||||
description_parts.append("No description about this model")
|
|
||||||
description_parts.append("")
|
|
||||||
|
|
||||||
model = {
|
|
||||||
"id": filename,
|
|
||||||
"shortname": filename,
|
|
||||||
"fullname": fullname,
|
|
||||||
"basename": basename,
|
|
||||||
"extension": extension,
|
|
||||||
"preview": image_files,
|
|
||||||
"sizeBytes": 0,
|
|
||||||
"type": "unknown",
|
|
||||||
"pathIndex": 0,
|
|
||||||
"description": "\n".join(description_parts),
|
|
||||||
"metadata": {},
|
|
||||||
"downloadPlatform": "",
|
|
||||||
"downloadUrl": f"https://huggingface.co/{model_id}/resolve/main/{filename}?download=true",
|
|
||||||
}
|
|
||||||
models.append(model)
|
|
||||||
|
|
||||||
return models
|
|
||||||
|
|
||||||
def search_by_hash(self, hash: str):
|
|
||||||
raise RuntimeError("Hash search is not supported by Huggingface.")
|
|
||||||
|
|
||||||
def _match_model_files(self):
|
|
||||||
extension = [
|
|
||||||
".bin",
|
|
||||||
".ckpt",
|
|
||||||
".gguf",
|
|
||||||
".onnx",
|
|
||||||
".pt",
|
|
||||||
".pth",
|
|
||||||
".safetensors",
|
|
||||||
]
|
|
||||||
|
|
||||||
def _filter_model_files(file: str):
|
|
||||||
return any(file.endswith(ext) for ext in extension)
|
|
||||||
|
|
||||||
return _filter_model_files
|
|
||||||
|
|
||||||
def _match_image_files(self):
|
|
||||||
extension = [
|
|
||||||
".png",
|
|
||||||
".webp",
|
|
||||||
".jpeg",
|
|
||||||
".jpg",
|
|
||||||
".jfif",
|
|
||||||
".gif",
|
|
||||||
".apng",
|
|
||||||
]
|
|
||||||
|
|
||||||
def _filter_image_files(file: str):
|
|
||||||
return any(file.endswith(ext) for ext in extension)
|
|
||||||
|
|
||||||
return _filter_image_files
|
|
||||||
|
|
||||||
def _match_tree_files(self, pathname: str):
|
|
||||||
target, *paths = pathname.split("/")
|
|
||||||
|
|
||||||
def _filter_tree_files(file: str):
|
|
||||||
if not target:
|
|
||||||
return True
|
|
||||||
if target != "tree" and target != "blob":
|
|
||||||
return True
|
|
||||||
|
|
||||||
prefix_path = "/".join(paths)
|
|
||||||
return file.startswith(prefix_path)
|
|
||||||
|
|
||||||
return _filter_tree_files
|
|
||||||
|
|
||||||
|
|
||||||
def get_model_searcher_by_url(url: str) -> ModelSearcher:
|
|
||||||
parsed_url = urlparse(url)
|
|
||||||
host_name = parsed_url.hostname
|
|
||||||
if host_name == "civitai.com":
|
|
||||||
return CivitaiModelSearcher()
|
|
||||||
elif host_name == "huggingface.co":
|
|
||||||
return HuggingfaceModelSearcher()
|
|
||||||
return UnknownWebsiteSearcher()
|
|
||||||
275
py/services.py
275
py/services.py
@@ -1,275 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
import folder_paths
|
|
||||||
|
|
||||||
from . import config
|
|
||||||
from . import utils
|
|
||||||
from . import download
|
|
||||||
from . import searcher
|
|
||||||
|
|
||||||
|
|
||||||
def scan_models(request):
|
|
||||||
result = []
|
|
||||||
model_base_paths = utils.resolve_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, 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]
|
|
||||||
extension = os.path.splitext(fullname)[1]
|
|
||||||
|
|
||||||
abs_path = utils.join_path(base_path, fullname)
|
|
||||||
file_stats = os.stat(abs_path)
|
|
||||||
|
|
||||||
# Resolve preview
|
|
||||||
image_name = utils.get_model_preview_name(abs_path)
|
|
||||||
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, model_data: dict):
|
|
||||||
|
|
||||||
if "previewFile" in model_data:
|
|
||||||
previewFile = model_data["previewFile"]
|
|
||||||
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(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(task_data, request):
|
|
||||||
return await download.create_model_download_task(task_data, 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)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_model_info(model_page: str):
|
|
||||||
if not model_page:
|
|
||||||
return []
|
|
||||||
|
|
||||||
model_searcher = searcher.get_model_searcher_by_url(model_page)
|
|
||||||
result = model_searcher.search_by_url(model_page)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def download_model_info(scan_mode: str, 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, extensions = 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 = searcher.CivitaiModelSearcher().search_by_hash(hash_value)
|
|
||||||
|
|
||||||
preview_url_list = model_info.get("preview", [])
|
|
||||||
preview_image_url = preview_url_list[0] if preview_url_list else None
|
|
||||||
if preview_image_url:
|
|
||||||
utils.print_debug(f"Save preview image to {abs_image_path}")
|
|
||||||
utils.save_model_preview_image(abs_model_path, preview_image_url)
|
|
||||||
|
|
||||||
description = model_info.get("description", None)
|
|
||||||
if description:
|
|
||||||
utils.save_model_description(abs_model_path, description)
|
|
||||||
except Exception as e:
|
|
||||||
utils.print_error(f"Failed to download model info for {abs_model_path}: {e}")
|
|
||||||
|
|
||||||
utils.print_debug("Completed scan model information.")
|
|
||||||
|
|
||||||
|
|
||||||
async def migrate_legacy_information(request):
|
|
||||||
import json
|
|
||||||
import yaml
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
utils.print_info(f"Migrating legacy information...")
|
|
||||||
|
|
||||||
model_base_paths = utils.resolve_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, request)
|
|
||||||
|
|
||||||
models = folder_paths.filter_files_extensions(files, folder_paths.supported_pt_extensions)
|
|
||||||
|
|
||||||
for fullname in models:
|
|
||||||
fullname = utils.normalize_path(fullname)
|
|
||||||
|
|
||||||
abs_model_path = utils.join_path(base_path, fullname)
|
|
||||||
|
|
||||||
base_file_name = os.path.splitext(abs_model_path)[0]
|
|
||||||
|
|
||||||
utils.print_debug(f"Try to migrate legacy info for {abs_model_path}")
|
|
||||||
|
|
||||||
preview_path = utils.join_path(
|
|
||||||
os.path.dirname(abs_model_path),
|
|
||||||
utils.get_model_preview_name(abs_model_path),
|
|
||||||
)
|
|
||||||
new_preview_path = f"{base_file_name}.webp"
|
|
||||||
|
|
||||||
if os.path.isfile(preview_path) and preview_path != new_preview_path:
|
|
||||||
utils.print_info(f"Migrate preview image from {fullname}")
|
|
||||||
with Image.open(preview_path) as image:
|
|
||||||
image.save(new_preview_path, format="WEBP")
|
|
||||||
|
|
||||||
description_path = f"{base_file_name}.md"
|
|
||||||
|
|
||||||
metadata_info = {
|
|
||||||
"website": "Civitai",
|
|
||||||
}
|
|
||||||
|
|
||||||
url_info_path = f"{base_file_name}.url"
|
|
||||||
if os.path.isfile(url_info_path):
|
|
||||||
with open(url_info_path, "r", encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
if line.startswith("URL="):
|
|
||||||
model_page_url = line[len("URL=") :].strip()
|
|
||||||
metadata_info.update({"modelPage": model_page_url})
|
|
||||||
|
|
||||||
json_info_path = f"{base_file_name}.json"
|
|
||||||
if os.path.isfile(json_info_path):
|
|
||||||
with open(json_info_path, "r", encoding="utf-8") as f:
|
|
||||||
version = json.load(f)
|
|
||||||
metadata_info.update(
|
|
||||||
{
|
|
||||||
"baseModel": version.get("baseModel"),
|
|
||||||
"preview": [i["url"] for i in version["images"]],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
description_parts: list[str] = [
|
|
||||||
"---",
|
|
||||||
yaml.dump(metadata_info).strip(),
|
|
||||||
"---",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
|
|
||||||
text_info_path = f"{base_file_name}.txt"
|
|
||||||
if os.path.isfile(text_info_path):
|
|
||||||
with open(text_info_path, "r", encoding="utf-8") as f:
|
|
||||||
description_parts.append(f.read())
|
|
||||||
|
|
||||||
description_path = f"{base_file_name}.md"
|
|
||||||
|
|
||||||
if os.path.isfile(text_info_path):
|
|
||||||
utils.print_info(f"Migrate description from {fullname}")
|
|
||||||
with open(description_path, "w", encoding="utf-8", newline="") as f:
|
|
||||||
f.write("\n".join(description_parts))
|
|
||||||
|
|
||||||
utils.print_debug("Completed migrate model information.")
|
|
||||||
104
py/utils.py
104
py/utils.py
@@ -7,12 +7,14 @@ import logging
|
|||||||
import requests
|
import requests
|
||||||
import traceback
|
import traceback
|
||||||
import configparser
|
import configparser
|
||||||
|
import functools
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
import comfy.utils
|
import comfy.utils
|
||||||
import folder_paths
|
import folder_paths
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
from . import config
|
from . import config
|
||||||
|
|
||||||
|
|
||||||
@@ -20,6 +22,10 @@ def print_info(msg, *args, **kwargs):
|
|||||||
logging.info(f"[{config.extension_tag}] {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):
|
def print_error(msg, *args, **kwargs):
|
||||||
logging.error(f"[{config.extension_tag}] {msg}", *args, **kwargs)
|
logging.error(f"[{config.extension_tag}] {msg}", *args, **kwargs)
|
||||||
logging.debug(traceback.format_exc())
|
logging.debug(traceback.format_exc())
|
||||||
@@ -29,6 +35,18 @@ def print_debug(msg, *args, **kwargs):
|
|||||||
logging.debug(f"[{config.extension_tag}] {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 _matches(predicate: dict):
|
||||||
def _filter(obj: dict):
|
def _filter(obj: dict):
|
||||||
return all(obj.get(key, None) == value for key, value in predicate.items())
|
return all(obj.get(key, None) == value for key, value in predicate.items())
|
||||||
@@ -116,7 +134,11 @@ def download_web_distribution(version: str):
|
|||||||
print_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())
|
||||||
model_base_paths = {}
|
model_base_paths = {}
|
||||||
folder_black_list = ["configs", "custom_nodes"]
|
folder_black_list = ["configs", "custom_nodes"]
|
||||||
@@ -128,6 +150,20 @@ def resolve_model_base_paths():
|
|||||||
return model_base_paths
|
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.
|
||||||
@@ -245,23 +281,67 @@ def get_model_preview_name(model_path: str):
|
|||||||
return images[0] if len(images) > 0 else "no-preview.png"
|
return images[0] if len(images) > 0 else "no-preview.png"
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_all_videos(model_path: str):
|
||||||
|
base_dirname = os.path.dirname(model_path)
|
||||||
|
files = search_files(base_dirname)
|
||||||
|
files = folder_paths.filter_files_content_types(files, ["video"])
|
||||||
|
|
||||||
|
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
|
output: list[str] = []
|
||||||
|
for file in files:
|
||||||
|
file_basename = os.path.splitext(file)[0]
|
||||||
|
if file_basename == basename:
|
||||||
|
output.append(file)
|
||||||
|
if file_basename == f"{basename}.preview":
|
||||||
|
output.append(file)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
|
|
||||||
def save_model_preview_image(model_path: str, image_url: str):
|
def remove_model_preview_image(model_path: str):
|
||||||
try:
|
basename = os.path.splitext(model_path)[0]
|
||||||
image_response = requests.get(image_url)
|
preview_path = f"{basename}.webp"
|
||||||
image_response.raise_for_status()
|
if os.path.exists(preview_path):
|
||||||
|
os.remove(preview_path)
|
||||||
|
|
||||||
basename = os.path.splitext(model_path)[0]
|
|
||||||
preview_path = f"{basename}.webp"
|
def save_model_preview_image(model_path: str, image_file_or_url: Any, platform: Optional[str] = None):
|
||||||
image = Image.open(BytesIO(image_response.content))
|
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")
|
image.save(preview_path, "WEBP")
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print_error(f"Failed to download image: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def get_model_all_descriptions(model_path: str):
|
def get_model_all_descriptions(model_path: str):
|
||||||
base_dirname = os.path.dirname(model_path)
|
base_dirname = os.path.dirname(model_path)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[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.1.2"
|
version = "2.6.2"
|
||||||
license = "LICENSE"
|
license = { file = "LICENSE" }
|
||||||
dependencies = ["markdownify"]
|
dependencies = ["markdownify"]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
35
src/App.vue
35
src/App.vue
@@ -7,7 +7,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DialogDownload from 'components/DialogDownload.vue'
|
import DialogDownload from 'components/DialogDownload.vue'
|
||||||
|
import DialogExplorer from 'components/DialogExplorer.vue'
|
||||||
import DialogManager from 'components/DialogManager.vue'
|
import DialogManager from 'components/DialogManager.vue'
|
||||||
|
import DialogScanning from 'components/DialogScanning.vue'
|
||||||
import GlobalDialogStack from 'components/GlobalDialogStack.vue'
|
import GlobalDialogStack from 'components/GlobalDialogStack.vue'
|
||||||
import GlobalLoading from 'components/GlobalLoading.vue'
|
import GlobalLoading from 'components/GlobalLoading.vue'
|
||||||
import GlobalToast from 'components/GlobalToast.vue'
|
import GlobalToast from 'components/GlobalToast.vue'
|
||||||
@@ -15,16 +17,18 @@ import { useStoreProvider } from 'hooks/store'
|
|||||||
import { useToast } from 'hooks/toast'
|
import { useToast } from 'hooks/toast'
|
||||||
import GlobalConfirm from 'primevue/confirmdialog'
|
import GlobalConfirm from 'primevue/confirmdialog'
|
||||||
import { $el, app, ComfyButton } from 'scripts/comfyAPI'
|
import { $el, app, ComfyButton } from 'scripts/comfyAPI'
|
||||||
import { onMounted } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
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',
|
||||||
@@ -32,6 +36,19 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openModelScanning = () => {
|
||||||
|
dialog.open({
|
||||||
|
key: 'model-information-scanning',
|
||||||
|
title: t('batchScanModelInformation'),
|
||||||
|
content: DialogScanning,
|
||||||
|
modal: true,
|
||||||
|
defaultSize: {
|
||||||
|
width: 680,
|
||||||
|
height: 490,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const openDownloadDialog = () => {
|
const openDownloadDialog = () => {
|
||||||
dialog.open({
|
dialog.open({
|
||||||
key: 'model-manager-download-list',
|
key: 'model-manager-download-list',
|
||||||
@@ -48,14 +65,24 @@ 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: 'scanning',
|
||||||
|
icon: 'mdi mdi-folder-search-outline text-lg',
|
||||||
|
command: openModelScanning,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'refresh',
|
key: 'refresh',
|
||||||
icon: 'pi pi-refresh',
|
icon: 'pi pi-refresh',
|
||||||
|
|||||||
@@ -66,10 +66,12 @@ 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 Button from 'primevue/button'
|
||||||
import { VersionModel } from 'types/typings'
|
import { VersionModel, WithResolved } from 'types/typings'
|
||||||
|
import { previewUrlToFile } from 'utils/common'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const { isMobile } = useConfig()
|
const { isMobile } = useConfig()
|
||||||
@@ -87,15 +89,55 @@ const searchModelsByUrl = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createDownTask = async (data: VersionModel) => {
|
const createDownTask = async (data: WithResolved<VersionModel>) => {
|
||||||
loading.show()
|
loading.show()
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
for (const key in data) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
||||||
|
let value = data[key]
|
||||||
|
|
||||||
|
// set preview file
|
||||||
|
if (key === 'preview') {
|
||||||
|
if (value) {
|
||||||
|
const previewFile = await previewUrlToFile(value).catch(() => {
|
||||||
|
loading.hide()
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: 'Failed to download preview',
|
||||||
|
life: 5000,
|
||||||
|
})
|
||||||
|
throw new Error('Failed to download preview')
|
||||||
|
})
|
||||||
|
formData.append('previewFile', previewFile)
|
||||||
|
} else {
|
||||||
|
formData.append('previewFile', value)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
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: JSON.stringify(data),
|
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 { useContainerQueries } from 'hooks/container'
|
||||||
import { useDialog } from 'hooks/dialog'
|
import { useDialog } from 'hooks/dialog'
|
||||||
import { useDownload } from 'hooks/download'
|
import { useDownload } from 'hooks/download'
|
||||||
import Button from 'primevue/button'
|
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 type { 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,31 +120,59 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="manager-dialog">
|
<script setup lang="ts" name="manager-dialog">
|
||||||
|
import { useElementSize } from '@vueuse/core'
|
||||||
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 ResponseScroll from 'components/ResponseScroll.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 { configSetting, useConfig } from 'hooks/config'
|
||||||
import { useModels } from 'hooks/model'
|
import { useContainerQueries } from 'hooks/container'
|
||||||
import { defineResizeCallback } from 'hooks/resize'
|
import { useModelNodeAction, useModels } from 'hooks/model'
|
||||||
import { chunk } from 'lodash'
|
import { chunk } from 'lodash'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import { app } from 'scripts/comfyAPI'
|
||||||
import { Model } from 'types/typings'
|
import { Model } from 'types/typings'
|
||||||
import { genModelKey } from 'utils/model'
|
import { genModelKey } from 'utils/model'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
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,
|
||||||
@@ -124,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 filterList = pureModels.filter((model) => {
|
||||||
|
const showAllModel = currentType.value === allType
|
||||||
|
|
||||||
const matchType = showAllModel || model.type === currentType.value
|
const matchType = showAllModel || model.type === currentType.value
|
||||||
const matchName = model.fullname
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchContent.value?.toLowerCase() || '')
|
|
||||||
|
|
||||||
return matchType && matchName
|
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
|
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
|
||||||
@@ -174,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,10 +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 { useModelNodeAction, useModels } from 'hooks/model'
|
import { genModelUrl, useModelNodeAction, useModels } from 'hooks/model'
|
||||||
import { useRequest } from 'hooks/request'
|
import { useRequest } from 'hooks/request'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import { BaseModel, Model } from 'types/typings'
|
import { BaseModel, Model, WithResolved } from 'types/typings'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -59,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',
|
||||||
})
|
})
|
||||||
@@ -72,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
|
||||||
}
|
}
|
||||||
@@ -85,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>
|
||||||
|
|||||||
271
src/components/DialogScanning.vue
Normal file
271
src/components/DialogScanning.vue
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-full px-4">
|
||||||
|
<div v-show="batchScanningStep === 0" class="h-full">
|
||||||
|
<div class="flex h-full items-center px-8">
|
||||||
|
<div class="h-20 w-full opacity-60">
|
||||||
|
<ProgressBar mode="indeterminate" style="height: 6px"></ProgressBar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Stepper
|
||||||
|
v-show="batchScanningStep === 1"
|
||||||
|
v-model:value="stepValue"
|
||||||
|
class="flex h-full flex-col"
|
||||||
|
linear
|
||||||
|
>
|
||||||
|
<StepList>
|
||||||
|
<Step value="1">{{ $t('selectModelType') }}</Step>
|
||||||
|
<Step value="2">{{ $t('selectSubdirectory') }}</Step>
|
||||||
|
<Step value="3">{{ $t('scanModelInformation') }}</Step>
|
||||||
|
</StepList>
|
||||||
|
<StepPanels class="flex-1 overflow-hidden">
|
||||||
|
<StepPanel value="1" class="h-full">
|
||||||
|
<div class="flex h-full flex-col overflow-hidden">
|
||||||
|
<ResponseScroll>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<Button
|
||||||
|
v-for="item in typeOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
@click="item.command"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
</ResponseScroll>
|
||||||
|
</div>
|
||||||
|
</StepPanel>
|
||||||
|
<StepPanel value="2" class="h-full">
|
||||||
|
<div class="flex h-full flex-col overflow-hidden">
|
||||||
|
<ResponseScroll class="flex-1">
|
||||||
|
<Tree
|
||||||
|
class="h-full"
|
||||||
|
v-model:selection-keys="selectedKey"
|
||||||
|
:value="pathOptions"
|
||||||
|
selectionMode="single"
|
||||||
|
:pt:nodeLabel:class="'text-ellipsis overflow-hidden'"
|
||||||
|
></Tree>
|
||||||
|
</ResponseScroll>
|
||||||
|
|
||||||
|
<div class="flex justify-between pt-6">
|
||||||
|
<Button
|
||||||
|
:label="$t('back')"
|
||||||
|
severity="secondary"
|
||||||
|
icon="pi pi-arrow-left"
|
||||||
|
@click="handleBackTypeSelect"
|
||||||
|
></Button>
|
||||||
|
<Button
|
||||||
|
:label="$t('next')"
|
||||||
|
icon="pi pi-arrow-right"
|
||||||
|
icon-pos="right"
|
||||||
|
:disabled="!enabledScan"
|
||||||
|
@click="handleConfirmSubdir"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</StepPanel>
|
||||||
|
<StepPanel value="3" class="h-full">
|
||||||
|
<div class="overflow-hidden break-words py-8">
|
||||||
|
<div class="overflow-hidden px-8">
|
||||||
|
<div v-show="currentType === allType" class="text-center">
|
||||||
|
{{ $t('selectedAllPaths') }}
|
||||||
|
</div>
|
||||||
|
<div v-show="currentType !== allType" class="text-center">
|
||||||
|
<div class="pb-2">
|
||||||
|
{{ $t('selectedSpecialPath') }}
|
||||||
|
</div>
|
||||||
|
<div class="leading-5 opacity-60">
|
||||||
|
{{ selectedModelFolder }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-4">
|
||||||
|
<Button
|
||||||
|
v-for="item in scanActions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:icon="item.icon"
|
||||||
|
@click="item.command.call(item)"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
</StepPanel>
|
||||||
|
</StepPanels>
|
||||||
|
</Stepper>
|
||||||
|
|
||||||
|
<div v-show="batchScanningStep === 2" class="h-full">
|
||||||
|
<div class="flex h-full items-center px-8">
|
||||||
|
<div class="h-20 w-full">
|
||||||
|
<div v-show="scanProgress > -1">
|
||||||
|
<ProgressBar :value="scanProgress">
|
||||||
|
{{ scanCompleteCount }} / {{ scanTotalCount }}
|
||||||
|
</ProgressBar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="scanProgress === -1" class="text-center">
|
||||||
|
<Button
|
||||||
|
severity="secondary"
|
||||||
|
:label="$t('back')"
|
||||||
|
icon="pi pi-arrow-left"
|
||||||
|
@click="handleBackTypeSelect"
|
||||||
|
></Button>
|
||||||
|
<span class="pl-2">{{ $t('noModelsInCurrentPath') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ResponseScroll from 'components/ResponseScroll.vue'
|
||||||
|
import { configSetting } from 'hooks/config'
|
||||||
|
import { useModelFolder, useModels } from 'hooks/model'
|
||||||
|
import { request } from 'hooks/request'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import ProgressBar from 'primevue/progressbar'
|
||||||
|
import Step from 'primevue/step'
|
||||||
|
import StepList from 'primevue/steplist'
|
||||||
|
import StepPanel from 'primevue/steppanel'
|
||||||
|
import StepPanels from 'primevue/steppanels'
|
||||||
|
import Stepper from 'primevue/stepper'
|
||||||
|
import Tree from 'primevue/tree'
|
||||||
|
import { api, app } from 'scripts/comfyAPI'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const stepValue = ref('1')
|
||||||
|
|
||||||
|
const { folders } = useModels()
|
||||||
|
|
||||||
|
const allType = 'All'
|
||||||
|
const currentType = ref<string>()
|
||||||
|
const typeOptions = computed(() => {
|
||||||
|
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 {
|
||||||
|
label: type,
|
||||||
|
value: type,
|
||||||
|
command: () => {
|
||||||
|
currentType.value = type
|
||||||
|
stepValue.value = currentType.value === allType ? '3' : '2'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const { pathOptions } = useModelFolder({ type: currentType })
|
||||||
|
|
||||||
|
const selectedModelFolder = ref<string>()
|
||||||
|
const selectedKey = computed({
|
||||||
|
get: () => {
|
||||||
|
const key = selectedModelFolder.value
|
||||||
|
return key ? { [key]: true } : {}
|
||||||
|
},
|
||||||
|
set: (val) => {
|
||||||
|
const key = Object.keys(val)[0]
|
||||||
|
selectedModelFolder.value = key
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const enabledScan = computed(() => {
|
||||||
|
return currentType.value === allType || !!selectedModelFolder.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleBackTypeSelect = () => {
|
||||||
|
selectedModelFolder.value = undefined
|
||||||
|
currentType.value = undefined
|
||||||
|
stepValue.value = '1'
|
||||||
|
batchScanningStep.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmSubdir = () => {
|
||||||
|
stepValue.value = '3'
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchScanningStep = ref(0)
|
||||||
|
const scanModelsList = ref<Record<string, boolean>>({})
|
||||||
|
const scanTotalCount = computed(() => {
|
||||||
|
return Object.keys(scanModelsList.value).length
|
||||||
|
})
|
||||||
|
const scanCompleteCount = computed(() => {
|
||||||
|
return Object.keys(scanModelsList.value).filter(
|
||||||
|
(key) => scanModelsList.value[key],
|
||||||
|
).length
|
||||||
|
})
|
||||||
|
const scanProgress = computed(() => {
|
||||||
|
if (scanTotalCount.value === 0) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
const progress = scanCompleteCount.value / scanTotalCount.value
|
||||||
|
return Number(progress.toFixed(4)) * 100
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleScanModelInformation = async function () {
|
||||||
|
batchScanningStep.value = 0
|
||||||
|
const mode = this.value
|
||||||
|
const path = selectedModelFolder.value
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await request('/model-info/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ mode, path }),
|
||||||
|
})
|
||||||
|
scanModelsList.value = result?.models ?? {}
|
||||||
|
batchScanningStep.value = 2
|
||||||
|
} catch {
|
||||||
|
batchScanningStep.value = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scanActions = ref([
|
||||||
|
{
|
||||||
|
value: 'back',
|
||||||
|
label: t('back'),
|
||||||
|
icon: 'pi pi-arrow-left',
|
||||||
|
command: () => {
|
||||||
|
stepValue.value = currentType.value === allType ? '1' : '2'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'full',
|
||||||
|
label: t('scanFullInformation'),
|
||||||
|
command: handleScanModelInformation,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'diff',
|
||||||
|
label: t('scanMissInformation'),
|
||||||
|
command: handleScanModelInformation,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const refreshTaskContent = async () => {
|
||||||
|
const result = await request('/model-info/scan')
|
||||||
|
const listContent = result?.models ?? {}
|
||||||
|
scanModelsList.value = listContent
|
||||||
|
batchScanningStep.value = Object.keys(listContent).length ? 2 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refreshTaskContent()
|
||||||
|
|
||||||
|
api.addEventListener('update_scan_information_task', (event) => {
|
||||||
|
const content = event.detail
|
||||||
|
scanModelsList.value = content.models
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</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)"
|
||||||
>
|
>
|
||||||
@@ -36,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 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 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,13 +7,60 @@
|
|||||||
</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="text-base opacity-60">
|
<span class="text-base opacity-60">
|
||||||
@@ -37,7 +84,17 @@
|
|||||||
<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>
|
||||||
@@ -48,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) => {
|
||||||
@@ -72,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,104 +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 { useDialog } from 'hooks/dialog'
|
|
||||||
import { useModelNodeAction } from 'hooks/model'
|
import { useModelNodeAction } from 'hooks/model'
|
||||||
import Button from 'primevue/button'
|
import { BaseModel } from 'types/typings'
|
||||||
import { Model } from 'types/typings'
|
import { computed, ref } from 'vue'
|
||||||
import { genModelKey } from 'utils/model'
|
|
||||||
import { computed } 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"
|
||||||
@@ -43,6 +48,7 @@ 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 ModelPreview from 'components/ModelPreview.vue'
|
import ModelPreview from 'components/ModelPreview.vue'
|
||||||
|
import { useContainerQueries } from 'hooks/container'
|
||||||
import {
|
import {
|
||||||
useModelBaseInfoEditor,
|
useModelBaseInfoEditor,
|
||||||
useModelDescriptionEditor,
|
useModelDescriptionEditor,
|
||||||
@@ -56,8 +62,8 @@ import TabList from 'primevue/tablist'
|
|||||||
import TabPanel from 'primevue/tabpanel'
|
import TabPanel from 'primevue/tabpanel'
|
||||||
import TabPanels from 'primevue/tabpanels'
|
import TabPanels from 'primevue/tabpanels'
|
||||||
import Tabs from 'primevue/tabs'
|
import Tabs from 'primevue/tabs'
|
||||||
import { BaseModel } from 'types/typings'
|
import { BaseModel, WithResolved } from 'types/typings'
|
||||||
import { toRaw, watch } from 'vue'
|
import { ref, toRaw, watch } from 'vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
model: BaseModel
|
model: BaseModel
|
||||||
@@ -67,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: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -94,4 +100,7 @@ watch(
|
|||||||
handleReset()
|
handleReset()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const container = ref<HTMLElement | null>(null)
|
||||||
|
const { $xl } = useContainerQueries(container)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -92,6 +102,7 @@ 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 { useConfig } from 'hooks/config'
|
import { useConfig } from 'hooks/config'
|
||||||
|
import { useContainerQueries } from 'hooks/container'
|
||||||
import { useModelPreview } from 'hooks/model'
|
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'
|
||||||
@@ -101,6 +112,7 @@ 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', $style.dialog]"
|
: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 }"
|
||||||
@@ -91,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>(), {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<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"
|
||||||
@@ -47,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,316 +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 { defineResizeCallback } from 'hooks/resize'
|
import { useDraggable, useElementSize, useScroll } from '@vueuse/core'
|
||||||
import { clamp, throttle } from 'lodash'
|
import { clamp } from 'lodash'
|
||||||
import { nextTick, onUnmounted, ref, watch } from 'vue'
|
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 = defineResizeCallback((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,12 +34,7 @@
|
|||||||
</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'"
|
||||||
@@ -130,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">
|
||||||
@@ -150,12 +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 { SelectOptions } from 'types/typings'
|
import { SelectOptions } from 'types/typings'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
const current = defineModel()
|
const current = defineModel()
|
||||||
|
|
||||||
@@ -195,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'
|
||||||
@@ -236,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,17 +1,15 @@
|
|||||||
import { request, useRequest } from 'hooks/request'
|
import SettingCardSize from 'components/SettingCardSize.vue'
|
||||||
import { defineStore } from 'hooks/store'
|
import { defineStore } from 'hooks/store'
|
||||||
import { $el, app, ComfyDialog } from 'scripts/comfyAPI'
|
import { app } from 'scripts/comfyAPI'
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, readonly, ref, watch } from 'vue'
|
||||||
import { useToast } from './toast'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
export const useConfig = defineStore('config', (store) => {
|
export const useConfig = defineStore('config', (store) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -24,19 +22,62 @@ export const useConfig = defineStore('config', (store) => {
|
|||||||
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
useAddConfigSettings(store)
|
||||||
|
|
||||||
return config
|
return config
|
||||||
@@ -50,45 +91,18 @@ declare module 'hooks/store' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const configSetting = {
|
||||||
|
excludeScanTypes: 'ModelManager.Scan.excludeScanTypes',
|
||||||
|
}
|
||||||
|
|
||||||
function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
|
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,
|
||||||
@@ -96,153 +110,60 @@ function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
|
|||||||
|
|
||||||
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,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Migrate
|
const defaultCardSize = store.config.defaultCardSizeMap
|
||||||
app.ui?.settings.addSetting({
|
|
||||||
id: 'ModelManager.Migrate.Migrate',
|
|
||||||
name: 'Migrate information from cdb-boop/main',
|
|
||||||
defaultValue: '',
|
|
||||||
type: () => {
|
|
||||||
return $el('button.p-button.p-component.p-button-secondary', {
|
|
||||||
textContent: 'Migrate',
|
|
||||||
onclick: () => {
|
|
||||||
confirm({
|
|
||||||
message: [
|
|
||||||
'This operation will delete old files and override current files if it exists.',
|
|
||||||
// 'This may take a while and generate MANY server requests!',
|
|
||||||
'Continue?',
|
|
||||||
].join('\n'),
|
|
||||||
accept: () => {
|
|
||||||
store.loading.loading.value = true
|
|
||||||
request('/migrate', {
|
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.add({
|
|
||||||
severity: 'success',
|
|
||||||
summary: 'Complete migration',
|
|
||||||
life: 2000,
|
|
||||||
})
|
|
||||||
store.models.refresh()
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.add({
|
|
||||||
severity: 'error',
|
|
||||||
summary: 'Error',
|
|
||||||
detail: err.message ?? 'Failed to migrate information',
|
|
||||||
life: 15000,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
store.loading.loading.value = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Scan information
|
|
||||||
app.ui?.settings.addSetting({
|
app.ui?.settings.addSetting({
|
||||||
id: 'ModelManager.ScanFiles.Full',
|
id: 'ModelManager.UI.CardSize',
|
||||||
name: "Override all models' information and preview",
|
category: [t('modelManager'), t('setting.ui'), 'CardSize'],
|
||||||
defaultValue: '',
|
name: t('setting.cardSize'),
|
||||||
type: () => {
|
defaultValue: 'size.extraLarge',
|
||||||
return $el('button.p-button.p-component.p-button-secondary', {
|
type: 'hidden',
|
||||||
textContent: 'Full Scan',
|
onChange: (val) => {
|
||||||
onclick: () => {
|
store.config.cardSizeFlag.value = val
|
||||||
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({
|
app.ui?.settings.addSetting({
|
||||||
id: 'ModelManager.ScanFiles.Incremental',
|
id: 'ModelManager.UI.CardSizeMap',
|
||||||
name: 'Download missing information or preview',
|
category: [t('modelManager'), t('setting.ui'), 'CardSizeMap'],
|
||||||
defaultValue: '',
|
name: t('setting.cardSize'),
|
||||||
type: () => {
|
defaultValue: JSON.stringify(defaultCardSize),
|
||||||
return $el('button.p-button.p-component.p-button-secondary', {
|
type: 'hidden',
|
||||||
textContent: 'Diff Scan',
|
onChange(value) {
|
||||||
onclick: () => {
|
store.config.cardSizeMap.value = JSON.parse(value)
|
||||||
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: '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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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({
|
app.ui?.settings.addSetting({
|
||||||
id: 'ModelManager.Scan.IncludeHiddenFiles',
|
id: 'ModelManager.Scan.IncludeHiddenFiles',
|
||||||
name: 'Include hidden files(start with .)',
|
category: [t('modelManager'), t('setting.scan'), 'IncludeHiddenFiles'],
|
||||||
|
name: t('setting.includeHiddenFiles'),
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
type: 'boolean',
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ interface HeaderButton {
|
|||||||
command: () => void
|
command: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DialogItem {
|
export interface DialogItem {
|
||||||
key: string
|
key: string
|
||||||
title: string
|
title: string
|
||||||
content: Component
|
content: Component
|
||||||
@@ -22,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', () => {
|
||||||
@@ -49,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
|
||||||
@@ -58,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' {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ 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[]>([])
|
||||||
@@ -29,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({
|
||||||
@@ -59,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: () => {},
|
||||||
})
|
})
|
||||||
@@ -71,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', () => {
|
||||||
|
|||||||
171
src/hooks/explorer.ts
Normal file
171
src/hooks/explorer.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
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: '',
|
||||||
|
previewType: 'image',
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,19 +38,19 @@ declare module 'hooks/store' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useLoading = () => {
|
export const useLoading = () => {
|
||||||
const timer = ref<NodeJS.Timeout>()
|
const targetTimer = ref<Record<string, NodeJS.Timeout | undefined>>({})
|
||||||
|
|
||||||
const show = () => {
|
const show = (target: string = '_default') => {
|
||||||
timer.value = setTimeout(() => {
|
targetTimer.value[target] = setTimeout(() => {
|
||||||
timer.value = undefined
|
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,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 } from 'types/typings'
|
import { BaseModel, Model, SelectEvent, WithResolved } from 'types/typings'
|
||||||
import { bytesToSize, formatDate } 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 updateData = new Map()
|
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) {
|
||||||
updateData.set('previewFile', data.preview)
|
const preview = data.preview
|
||||||
|
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) {
|
||||||
updateData.set('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)
|
||||||
updateData.set('type', data.type)
|
updateData.set('type', data.type)
|
||||||
updateData.set('pathIndex', data.pathIndex.toString())
|
updateData.set('pathIndex', data.pathIndex.toString())
|
||||||
updateData.set('fullname', data.fullname)
|
updateData.set('fullname', genModelFullName(data as BaseModel))
|
||||||
|
needUpdate = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updateData.size === 0) {
|
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: JSON.stringify(Object.fromEntries(updateData.entries())),
|
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
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -260,15 +379,20 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
|
|||||||
{
|
{
|
||||||
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',
|
||||||
@@ -303,7 +427,9 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
|
|||||||
baseInfo,
|
baseInfo,
|
||||||
basename,
|
basename,
|
||||||
extension,
|
extension,
|
||||||
|
subFolder,
|
||||||
pathIndex,
|
pathIndex,
|
||||||
|
modelFolders,
|
||||||
}
|
}
|
||||||
|
|
||||||
provide(baseInfoKey, result)
|
provide(baseInfoKey, result)
|
||||||
@@ -315,6 +441,74 @@ export const useModelBaseInfo = () => {
|
|||||||
return inject(baseInfoKey)!
|
return inject(baseInfoKey)!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useModelFolder = (
|
||||||
|
option: {
|
||||||
|
type?: MaybeRefOrGetter<string | undefined>
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
|
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.
|
||||||
*
|
*
|
||||||
@@ -338,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)
|
||||||
|
|
||||||
@@ -362,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(() => {
|
||||||
@@ -379,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'
|
||||||
@@ -395,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
|
||||||
@@ -485,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]))
|
||||||
@@ -498,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',
|
||||||
@@ -551,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)
|
|
||||||
}
|
|
||||||
83
src/i18n.ts
83
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,57 @@ 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',
|
||||||
|
back: 'Back',
|
||||||
|
next: 'Next',
|
||||||
|
batchScanModelInformation: 'Batch scan model information',
|
||||||
|
modelInformationScanning: 'Scanning model information',
|
||||||
|
selectModelType: 'Select model type',
|
||||||
|
selectSubdirectory: 'Select subdirectory',
|
||||||
|
scanModelInformation: 'Scan model information',
|
||||||
|
selectedAllPaths: 'Selected all model paths',
|
||||||
|
selectedSpecialPath: 'Selected special path',
|
||||||
|
scanMissInformation: 'Download missing information',
|
||||||
|
scanFullInformation: 'Override full information',
|
||||||
|
noModelsInCurrentPath: 'There are no models available in the current path',
|
||||||
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 +100,67 @@ const messages = {
|
|||||||
none: '无',
|
none: '无',
|
||||||
uploadFile: '上传文件',
|
uploadFile: '上传文件',
|
||||||
tapToChange: '点击描述可更改内容',
|
tapToChange: '点击描述可更改内容',
|
||||||
|
name: '名称',
|
||||||
|
width: '宽度',
|
||||||
|
height: '高度',
|
||||||
|
reset: '重置',
|
||||||
|
back: '返回',
|
||||||
|
next: '下一步',
|
||||||
|
batchScanModelInformation: '批量扫描模型信息',
|
||||||
|
modelInformationScanning: '扫描模型信息',
|
||||||
|
selectModelType: '选择模型类型',
|
||||||
|
selectSubdirectory: '选择子目录',
|
||||||
|
scanModelInformation: '扫描模型信息',
|
||||||
|
selectedAllPaths: '已选所有模型路径',
|
||||||
|
selectedSpecialPath: '已选指定路径',
|
||||||
|
scanMissInformation: '下载缺失信息',
|
||||||
|
scanFullInformation: '覆盖所有信息',
|
||||||
|
noModelsInCurrentPath: '当前路径中没有可用的模型',
|
||||||
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: {
|
||||||
|
|||||||
@@ -4,7 +4,3 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comfy-modal {
|
|
||||||
z-index: 3000;
|
|
||||||
}
|
|
||||||
|
|||||||
4
src/types/global.d.ts
vendored
4
src/types/global.d.ts
vendored
@@ -155,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 {}
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/types/typings.d.ts
vendored
9
src/types/typings.d.ts
vendored
@@ -3,13 +3,15 @@ export type ContainerPosition = { left: number; top: number }
|
|||||||
|
|
||||||
export 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>
|
||||||
}
|
}
|
||||||
@@ -17,6 +19,7 @@ export interface BaseModel {
|
|||||||
export interface Model extends BaseModel {
|
export interface Model extends BaseModel {
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
|
children?: Model[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VersionModel extends BaseModel {
|
export interface VersionModel extends BaseModel {
|
||||||
@@ -26,6 +29,10 @@ export interface VersionModel extends BaseModel {
|
|||||||
hashes?: Record<string, string>
|
hashes?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WithResolved<T> = Omit<T, 'preview'> & {
|
||||||
|
preview: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
export type PassThrough<T = void> = T | object | undefined
|
export type PassThrough<T = void> = T | object | undefined
|
||||||
|
|
||||||
export interface SelectOptions {
|
export interface SelectOptions {
|
||||||
|
|||||||
@@ -26,3 +26,14 @@ export const bytesToSize = (
|
|||||||
export const formatDate = (date: number | string | Date) => {
|
export const formatDate = (date: number | string | Date) => {
|
||||||
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
|
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const previewUrlToFile = async (url: string) => {
|
||||||
|
return fetch(url)
|
||||||
|
.then((res) => res.blob())
|
||||||
|
.then((blob) => {
|
||||||
|
const type = blob.type
|
||||||
|
const extension = type.split('/')[1]
|
||||||
|
const file = new File([blob], `preview.${extension}`, { type })
|
||||||
|
return file
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default defineConfig({
|
|||||||
outDir: 'web',
|
outDir: 'web',
|
||||||
minify: 'esbuild',
|
minify: 'esbuild',
|
||||||
target: 'es2022',
|
target: 'es2022',
|
||||||
sourcemap: true,
|
sourcemap: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
// Disabling tree-shaking
|
// Disabling tree-shaking
|
||||||
// Prevent vite remove unused exports
|
// Prevent vite remove unused exports
|
||||||
|
|||||||
Reference in New Issue
Block a user