Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -60,7 +60,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run build
|
pnpm run build
|
||||||
tar -czf dist.tar.gz py/ web/ __init__.py LICENSE pyproject.toml
|
tar -czf dist.tar.gz py/ web/ __init__.py LICENSE pyproject.toml requirements.txt
|
||||||
|
|
||||||
- name: Create release draft
|
- name: Create release draft
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
|
|||||||
@@ -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`
|
|
||||||
|
|||||||
272
__init__.py
272
__init__.py
@@ -1,10 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
import folder_paths
|
|
||||||
from .py import config
|
from .py import config
|
||||||
from .py import utils
|
from .py import utils
|
||||||
|
|
||||||
extension_uri = utils.normalize_path(os.path.dirname(__file__))
|
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 +24,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,6 +33,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
|
|||||||
63
pnpm-lock.yaml
generated
63
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
|
||||||
@@ -36,9 +39,6 @@ importers:
|
|||||||
specifier: ^2.6.0
|
specifier: ^2.6.0
|
||||||
version: 2.6.0
|
version: 2.6.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@tailwindcss/container-queries':
|
|
||||||
specifier: ^0.1.1
|
|
||||||
version: 0.1.1(tailwindcss@3.4.12)
|
|
||||||
'@types/lodash':
|
'@types/lodash':
|
||||||
specifier: ^4.17.9
|
specifier: ^4.17.9
|
||||||
version: 4.17.9
|
version: 4.17.9
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
106
py/download.py
106
py/download.py
@@ -61,7 +61,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 +71,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):
|
||||||
@@ -103,6 +103,8 @@ def get_task_content(task_id: str):
|
|||||||
if not os.path.isfile(task_file):
|
if not os.path.isfile(task_file):
|
||||||
raise RuntimeError(f"Task {task_id} not found")
|
raise RuntimeError(f"Task {task_id} not found")
|
||||||
task_content = utils.load_dict_pickle_file(task_file)
|
task_content = utils.load_dict_pickle_file(task_file)
|
||||||
|
if isinstance(task_content, TaskContent):
|
||||||
|
return task_content
|
||||||
return TaskContent(**task_content)
|
return TaskContent(**task_content)
|
||||||
|
|
||||||
|
|
||||||
@@ -178,17 +180,18 @@ async def create_model_download_task(task_data: dict, request):
|
|||||||
task_path = utils.join_path(download_path, f"{task_id}.task")
|
task_path = utils.join_path(download_path, f"{task_id}.task")
|
||||||
if os.path.exists(task_path):
|
if os.path.exists(task_path):
|
||||||
raise RuntimeError(f"Task {task_id} already exists")
|
raise RuntimeError(f"Task {task_id} already exists")
|
||||||
|
download_platform = task_data.get("downloadPlatform", None)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
preview_url = task_data.pop("preview", None)
|
preview_file = task_data.pop("previewFile", None)
|
||||||
utils.save_model_preview_image(task_path, preview_url)
|
utils.save_model_preview_image(task_path, preview_file, download_platform)
|
||||||
set_task_content(task_id, task_data)
|
set_task_content(task_id, task_data)
|
||||||
task_status = TaskStatus(
|
task_status = TaskStatus(
|
||||||
taskId=task_id,
|
taskId=task_id,
|
||||||
type=model_type,
|
type=model_type,
|
||||||
fullname=fullname,
|
fullname=fullname,
|
||||||
preview=utils.get_model_preview_name(task_path),
|
preview=utils.get_model_preview_name(task_path),
|
||||||
platform=task_data.get("downloadPlatform", None),
|
platform=download_platform,
|
||||||
totalSize=float(task_data.get("sizeBytes", 0)),
|
totalSize=float(task_data.get("sizeBytes", 0)),
|
||||||
)
|
)
|
||||||
download_model_task_status[task_id] = task_status
|
download_model_task_status[task_id] = task_status
|
||||||
@@ -361,9 +364,7 @@ async def download_model_file(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code not in (200, 206):
|
if response.status_code not in (200, 206):
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"Failed to download {task_content.fullname}, status code: {response.status_code}")
|
||||||
f"Failed to download {task_content.fullname}, status code: {response.status_code}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Some models require logging in before they can be downloaded.
|
# Some models require logging in before they can be downloaded.
|
||||||
# If no token is carried, it will be redirected to the login page.
|
# If no token is carried, it will be redirected to the login page.
|
||||||
@@ -376,14 +377,12 @@ async def download_model_file(
|
|||||||
# If it cannot be downloaded, a redirect will definitely occur.
|
# If it cannot be downloaded, a redirect will definitely occur.
|
||||||
# Maybe consider getting the redirect url from response.history to make a judgment.
|
# Maybe consider getting the redirect url from response.history to make a judgment.
|
||||||
# Here we also need to consider how different websites are processed.
|
# Here we also need to consider how different websites are processed.
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"{task_content.fullname} needs to be logged in to download. Please set the API-Key first.")
|
||||||
f"{task_content.fullname} needs to be logged in to download. Please set the API-Key first."
|
|
||||||
)
|
|
||||||
|
|
||||||
# When parsing model information from HuggingFace API,
|
# When parsing model information from HuggingFace API,
|
||||||
# the file size was not found and needs to be obtained from the response header.
|
# the file size was not found and needs to be obtained from the response header.
|
||||||
if total_size == 0:
|
if total_size == 0:
|
||||||
total_size = int(response.headers.get("content-length", 0))
|
total_size = float(response.headers.get("content-length", 0))
|
||||||
task_content.sizeBytes = total_size
|
task_content.sizeBytes = total_size
|
||||||
task_status.totalSize = total_size
|
task_status.totalSize = total_size
|
||||||
set_task_content(task_id, task_content)
|
set_task_content(task_id, task_content)
|
||||||
@@ -407,3 +406,86 @@ async def download_model_file(
|
|||||||
else:
|
else:
|
||||||
task_status.status = "pause"
|
task_status.status = "pause"
|
||||||
await utils.send_json("update_download_task", task_status.to_dict())
|
await utils.send_json("update_download_task", task_status.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
|
||||||
|
class ModelDownload:
|
||||||
|
def add_routes(self, routes):
|
||||||
|
|
||||||
|
@routes.get("/model-manager/download/task")
|
||||||
|
async def scan_download_tasks(request):
|
||||||
|
"""
|
||||||
|
Read download task list.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await 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 pause_model_download_task(task_id)
|
||||||
|
elif status == "resume":
|
||||||
|
await 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})
|
||||||
|
|
||||||
|
@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 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.
|
||||||
|
|
||||||
|
request body: x-www-form-urlencoded
|
||||||
|
- type: model type.
|
||||||
|
- pathIndex: index of the model folders.
|
||||||
|
- fullname: filename that relative to the model folder.
|
||||||
|
- previewFile: preview file.
|
||||||
|
- description: description.
|
||||||
|
- downloadPlatform: download platform.
|
||||||
|
- downloadUrl: download url.
|
||||||
|
- hash: a JSON string containing the hash value of the downloaded model.
|
||||||
|
"""
|
||||||
|
task_data = await request.post()
|
||||||
|
task_data = dict(task_data)
|
||||||
|
try:
|
||||||
|
task_id = await 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})
|
||||||
|
|||||||
@@ -27,9 +27,7 @@ class ModelSearcher(ABC):
|
|||||||
|
|
||||||
class UnknownWebsiteSearcher(ModelSearcher):
|
class UnknownWebsiteSearcher(ModelSearcher):
|
||||||
def search_by_url(self, url: str):
|
def search_by_url(self, url: str):
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"Unknown Website, please input a URL from huggingface.co or civitai.com.")
|
||||||
f"Unknown Website, please input a URL from huggingface.co or civitai.com."
|
|
||||||
)
|
|
||||||
|
|
||||||
def search_by_hash(self, hash: str):
|
def search_by_hash(self, hash: str):
|
||||||
raise RuntimeError(f"Unknown Website, unable to search with hash value.")
|
raise RuntimeError(f"Unknown Website, unable to search with hash value.")
|
||||||
@@ -87,29 +85,15 @@ class CivitaiModelSearcher(ModelSearcher):
|
|||||||
description_parts.append("")
|
description_parts.append("")
|
||||||
description_parts.append(f"# Trigger Words")
|
description_parts.append(f"# Trigger Words")
|
||||||
description_parts.append("")
|
description_parts.append("")
|
||||||
description_parts.append(
|
description_parts.append(", ".join(version.get("trainedWords", ["No trigger words"])))
|
||||||
", ".join(version.get("trainedWords", ["No trigger words"]))
|
|
||||||
)
|
|
||||||
description_parts.append("")
|
description_parts.append("")
|
||||||
description_parts.append(f"# About this version")
|
description_parts.append(f"# About this version")
|
||||||
description_parts.append("")
|
description_parts.append("")
|
||||||
description_parts.append(
|
description_parts.append(markdownify.markdownify(version.get("description", "<p>No description about this version</p>")).strip())
|
||||||
markdownify.markdownify(
|
|
||||||
version.get(
|
|
||||||
"description", "<p>No description about this version</p>"
|
|
||||||
)
|
|
||||||
).strip()
|
|
||||||
)
|
|
||||||
description_parts.append("")
|
description_parts.append("")
|
||||||
description_parts.append(f"# {res_data.get('name')}")
|
description_parts.append(f"# {res_data.get('name')}")
|
||||||
description_parts.append("")
|
description_parts.append("")
|
||||||
description_parts.append(
|
description_parts.append(markdownify.markdownify(res_data.get("description", "<p>No description about this model</p>")).strip())
|
||||||
markdownify.markdownify(
|
|
||||||
res_data.get(
|
|
||||||
"description", "<p>No description about this model</p>"
|
|
||||||
)
|
|
||||||
).strip()
|
|
||||||
)
|
|
||||||
description_parts.append("")
|
description_parts.append("")
|
||||||
|
|
||||||
model = {
|
model = {
|
||||||
@@ -136,18 +120,14 @@ class CivitaiModelSearcher(ModelSearcher):
|
|||||||
if not hash:
|
if not hash:
|
||||||
raise RuntimeError(f"Hash value is empty.")
|
raise RuntimeError(f"Hash value is empty.")
|
||||||
|
|
||||||
response = requests.get(
|
response = requests.get(f"https://civitai.com/api/v1/model-versions/by-hash/{hash}")
|
||||||
f"https://civitai.com/api/v1/model-versions/by-hash/{hash}"
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
version: dict = response.json()
|
version: dict = response.json()
|
||||||
|
|
||||||
model_id = version.get("modelId")
|
model_id = version.get("modelId")
|
||||||
version_id = version.get("id")
|
version_id = version.get("id")
|
||||||
|
|
||||||
model_page = (
|
model_page = f"https://civitai.com/models/{model_id}?modelVersionId={version_id}"
|
||||||
f"https://civitai.com/models/{model_id}?modelVersionId={version_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
models = self.search_by_url(model_page)
|
models = self.search_by_url(model_page)
|
||||||
|
|
||||||
@@ -186,9 +166,7 @@ class HuggingfaceModelSearcher(ModelSearcher):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
res_data: dict = response.json()
|
res_data: dict = response.json()
|
||||||
|
|
||||||
sibling_files: list[str] = [
|
sibling_files: list[str] = [x.get("rfilename") for x in res_data.get("siblings", [])]
|
||||||
x.get("rfilename") for x in res_data.get("siblings", [])
|
|
||||||
]
|
|
||||||
|
|
||||||
model_files = utils.filter_with(
|
model_files = utils.filter_with(
|
||||||
utils.filter_with(sibling_files, self._match_model_files()),
|
utils.filter_with(sibling_files, self._match_model_files()),
|
||||||
@@ -199,10 +177,7 @@ class HuggingfaceModelSearcher(ModelSearcher):
|
|||||||
utils.filter_with(sibling_files, self._match_image_files()),
|
utils.filter_with(sibling_files, self._match_image_files()),
|
||||||
self._match_tree_files(rest_pathname),
|
self._match_tree_files(rest_pathname),
|
||||||
)
|
)
|
||||||
image_files = [
|
image_files = [f"https://huggingface.co/{model_id}/resolve/main/{filename}" for filename in image_files]
|
||||||
f"https://huggingface.co/{model_id}/resolve/main/{filename}"
|
|
||||||
for filename in image_files
|
|
||||||
]
|
|
||||||
|
|
||||||
models: list[dict] = []
|
models: list[dict] = []
|
||||||
|
|
||||||
@@ -250,7 +225,7 @@ class HuggingfaceModelSearcher(ModelSearcher):
|
|||||||
"pathIndex": 0,
|
"pathIndex": 0,
|
||||||
"description": "\n".join(description_parts),
|
"description": "\n".join(description_parts),
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"downloadPlatform": "",
|
"downloadPlatform": "huggingface",
|
||||||
"downloadUrl": f"https://huggingface.co/{model_id}/resolve/main/{filename}?download=true",
|
"downloadUrl": f"https://huggingface.co/{model_id}/resolve/main/{filename}?download=true",
|
||||||
}
|
}
|
||||||
models.append(model)
|
models.append(model)
|
||||||
@@ -315,3 +290,148 @@ def get_model_searcher_by_url(url: str) -> ModelSearcher:
|
|||||||
elif host_name == "huggingface.co":
|
elif host_name == "huggingface.co":
|
||||||
return HuggingfaceModelSearcher()
|
return HuggingfaceModelSearcher()
|
||||||
return UnknownWebsiteSearcher()
|
return UnknownWebsiteSearcher()
|
||||||
|
|
||||||
|
|
||||||
|
import folder_paths
|
||||||
|
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
|
||||||
|
class Information:
|
||||||
|
def add_routes(self, routes):
|
||||||
|
|
||||||
|
@routes.get("/model-manager/model-info")
|
||||||
|
async def fetch_model_info(request):
|
||||||
|
"""
|
||||||
|
Fetch model information from network with model page.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
model_page = request.query.get("model-page", None)
|
||||||
|
result = self.fetch_model_info(model_page)
|
||||||
|
return web.json_response({"success": True, "data": result})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Fetch model info failed: {str(e)}"
|
||||||
|
utils.print_error(error_msg)
|
||||||
|
return web.json_response({"success": False, "error": error_msg})
|
||||||
|
|
||||||
|
@routes.post("/model-manager/model-info/scan")
|
||||||
|
async def download_model_info(request):
|
||||||
|
"""
|
||||||
|
Create a task to download model information.
|
||||||
|
"""
|
||||||
|
post = await utils.get_request_body(request)
|
||||||
|
try:
|
||||||
|
scan_mode = post.get("scanMode", "diff")
|
||||||
|
await self.download_model_info(scan_mode, request)
|
||||||
|
return web.json_response({"success": True})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Download model info failed: {str(e)}"
|
||||||
|
utils.print_error(error_msg)
|
||||||
|
return web.json_response({"success": False, "error": error_msg})
|
||||||
|
|
||||||
|
@routes.get("/model-manager/preview/{type}/{index}/{filename:.*}")
|
||||||
|
async def read_model_preview(request):
|
||||||
|
"""
|
||||||
|
Get the file stream of the specified image.
|
||||||
|
If the file does not exist, no-preview.png is returned.
|
||||||
|
|
||||||
|
:param type: The type of the model. eg.checkpoints, loras, vae, etc.
|
||||||
|
:param index: The index of the model folders.
|
||||||
|
:param filename: The filename of the image.
|
||||||
|
"""
|
||||||
|
model_type = request.match_info.get("type", None)
|
||||||
|
index = int(request.match_info.get("index", None))
|
||||||
|
filename = request.match_info.get("filename", None)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def fetch_model_info(self, model_page: str):
|
||||||
|
if not model_page:
|
||||||
|
return []
|
||||||
|
|
||||||
|
model_searcher = get_model_searcher_by_url(model_page)
|
||||||
|
result = model_searcher.search_by_url(model_page)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def download_model_info(self, scan_mode: str, request):
|
||||||
|
utils.print_info(f"Download model info for {scan_mode}")
|
||||||
|
model_base_paths = utils.resolve_model_base_paths()
|
||||||
|
for model_type in model_base_paths:
|
||||||
|
|
||||||
|
folders, *others = folder_paths.folder_names_and_paths[model_type]
|
||||||
|
for path_index, base_path in enumerate(folders):
|
||||||
|
files = utils.recursive_search_files(base_path, request)
|
||||||
|
|
||||||
|
models = folder_paths.filter_files_extensions(files, folder_paths.supported_pt_extensions)
|
||||||
|
|
||||||
|
for fullname in models:
|
||||||
|
fullname = utils.normalize_path(fullname)
|
||||||
|
basename = os.path.splitext(fullname)[0]
|
||||||
|
|
||||||
|
abs_model_path = utils.join_path(base_path, fullname)
|
||||||
|
|
||||||
|
image_name = utils.get_model_preview_name(abs_model_path)
|
||||||
|
abs_image_path = utils.join_path(base_path, image_name)
|
||||||
|
|
||||||
|
has_preview = os.path.isfile(abs_image_path)
|
||||||
|
|
||||||
|
description_name = utils.get_model_description_name(abs_model_path)
|
||||||
|
abs_description_path = utils.join_path(base_path, description_name) if description_name else None
|
||||||
|
has_description = os.path.isfile(abs_description_path) if abs_description_path else False
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
utils.print_info(f"Checking model {abs_model_path}")
|
||||||
|
utils.print_debug(f"Scan mode: {scan_mode}")
|
||||||
|
utils.print_debug(f"Has preview: {has_preview}")
|
||||||
|
utils.print_debug(f"Has description: {has_description}")
|
||||||
|
|
||||||
|
if scan_mode != "full" and (has_preview and has_description):
|
||||||
|
continue
|
||||||
|
|
||||||
|
utils.print_debug(f"Calculate sha256 for {abs_model_path}")
|
||||||
|
hash_value = utils.calculate_sha256(abs_model_path)
|
||||||
|
utils.print_info(f"Searching model info by hash {hash_value}")
|
||||||
|
model_info = CivitaiModelSearcher().search_by_hash(hash_value)
|
||||||
|
|
||||||
|
preview_url_list = model_info.get("preview", [])
|
||||||
|
preview_image_url = preview_url_list[0] if preview_url_list else None
|
||||||
|
if preview_image_url:
|
||||||
|
utils.print_debug(f"Save preview image to {abs_image_path}")
|
||||||
|
utils.save_model_preview_image(abs_model_path, preview_image_url)
|
||||||
|
|
||||||
|
description = model_info.get("description", None)
|
||||||
|
if description:
|
||||||
|
utils.save_model_description(abs_model_path, description)
|
||||||
|
except Exception as e:
|
||||||
|
utils.print_error(f"Failed to download model info for {abs_model_path}: {e}")
|
||||||
|
|
||||||
|
utils.print_debug("Completed scan model information.")
|
||||||
225
py/manager.py
Normal file
225
py/manager.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
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):
|
||||||
|
fullname = utils.normalize_path(entry.path).replace(f"{base_path}/", "")
|
||||||
|
basename = os.path.splitext(fullname)[0]
|
||||||
|
extension = os.path.splitext(fullname)[1]
|
||||||
|
|
||||||
|
if extension not in folder_paths.supported_pt_extensions:
|
||||||
|
return None
|
||||||
|
|
||||||
|
model_preview = f"/model-manager/preview/{folder}/{path_index}/{basename}.webp"
|
||||||
|
|
||||||
|
stat = entry.stat()
|
||||||
|
return {
|
||||||
|
"fullname": fullname,
|
||||||
|
"basename": basename,
|
||||||
|
"extension": extension,
|
||||||
|
"type": folder,
|
||||||
|
"pathIndex": path_index,
|
||||||
|
"sizeBytes": stat.st_size,
|
||||||
|
"preview": model_preview,
|
||||||
|
"createdAt": round(stat.st_ctime_ns / 1000000),
|
||||||
|
"updatedAt": round(stat.st_mtime_ns / 1000000),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_all_files_entry(directory: str):
|
||||||
|
files = []
|
||||||
|
with os.scandir(directory) as it:
|
||||||
|
for entry in it:
|
||||||
|
# Skip hidden files
|
||||||
|
if not include_hidden_files:
|
||||||
|
if entry.name.startswith("."):
|
||||||
|
continue
|
||||||
|
if entry.is_dir():
|
||||||
|
files.extend(get_all_files_entry(entry.path))
|
||||||
|
elif entry.is_file():
|
||||||
|
files.append(entry)
|
||||||
|
return files
|
||||||
|
|
||||||
|
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))
|
||||||
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.")
|
|
||||||
71
py/utils.py
71
py/utils.py
@@ -7,6 +7,7 @@ import logging
|
|||||||
import requests
|
import requests
|
||||||
import traceback
|
import traceback
|
||||||
import configparser
|
import configparser
|
||||||
|
import functools
|
||||||
|
|
||||||
import comfy.utils
|
import comfy.utils
|
||||||
import folder_paths
|
import folder_paths
|
||||||
@@ -20,6 +21,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 +34,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 +133,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"]
|
||||||
@@ -249,19 +270,47 @@ 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: str | None = 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.3.2"
|
||||||
license = "LICENSE"
|
license = { file = "LICENSE" }
|
||||||
dependencies = ["markdownify"]
|
dependencies = ["markdownify"]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
11
src/App.vue
11
src/App.vue
@@ -15,16 +15,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',
|
||||||
@@ -50,6 +52,11 @@ onMounted(() => {
|
|||||||
const openManagerDialog = () => {
|
const openManagerDialog = () => {
|
||||||
const { cardWidth, gutter, aspect } = config
|
const { cardWidth, gutter, aspect } = 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'),
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ import { useLoading } from 'hooks/loading'
|
|||||||
import { request } from 'hooks/request'
|
import { request } from 'hooks/request'
|
||||||
import { useToast } from 'hooks/toast'
|
import { useToast } from 'hooks/toast'
|
||||||
import 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 +88,52 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -1,62 +1,49 @@
|
|||||||
<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
|
||||||
v-model="currentType"
|
v-model="currentType"
|
||||||
:items="typeOptions"
|
:items="typeOptions"
|
||||||
:type="isMobile ? 'drop' : 'button'"
|
|
||||||
></ResponseSelect>
|
></ResponseSelect>
|
||||||
<ResponseSelect
|
<ResponseSelect
|
||||||
v-model="sortOrder"
|
v-model="sortOrder"
|
||||||
:items="sortOrderOptions"
|
:items="sortOrderOptions"
|
||||||
></ResponseSelect>
|
></ResponseSelect>
|
||||||
|
<ResponseSelect
|
||||||
|
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>
|
></ModelCard>
|
||||||
@@ -75,31 +62,57 @@
|
|||||||
</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 { useContainerQueries } from 'hooks/container'
|
||||||
import { useModels } from 'hooks/model'
|
import { useModels } from 'hooks/model'
|
||||||
import { defineResizeCallback } from 'hooks/resize'
|
|
||||||
import { chunk } from 'lodash'
|
import { chunk } from 'lodash'
|
||||||
|
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 } = 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 currentType = ref('all')
|
||||||
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 [
|
||||||
|
'all',
|
||||||
|
...Object.keys(folders.value).filter(
|
||||||
|
(folder) => !customBlackList.includes(folder),
|
||||||
|
),
|
||||||
|
].map((type) => {
|
||||||
return {
|
return {
|
||||||
label: type,
|
label: type,
|
||||||
value: type,
|
value: type,
|
||||||
@@ -124,26 +137,32 @@ 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 filterList = mergedList.filter((model) => {
|
||||||
const showAllModel = currentType.value === 'all'
|
const showAllModel = currentType.value === 'all'
|
||||||
|
|
||||||
const matchType = showAllModel || model.type === currentType.value
|
const matchType = showAllModel || model.type === currentType.value
|
||||||
@@ -174,17 +193,38 @@ 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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import ResponseScroll from 'components/ResponseScroll.vue'
|
|||||||
import { useModelNodeAction, useModels } from 'hooks/model'
|
import { useModelNodeAction, useModels } from 'hooks/model'
|
||||||
import { useRequest } from 'hooks/request'
|
import { useRequest } from 'hooks/request'
|
||||||
import Button from 'primevue/button'
|
import 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 {
|
||||||
@@ -72,7 +72,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
:max-width="item.maxWidth"
|
:max-width="item.maxWidth"
|
||||||
:min-height="item.minHeight"
|
:min-height="item.minHeight"
|
||||||
:max-height="item.maxHeight"
|
:max-height="item.maxHeight"
|
||||||
:z-index="index"
|
:auto-z-index="false"
|
||||||
|
:pt:mask:style="{ zIndex: baseZIndex + index + 1 }"
|
||||||
:pt:root:onMousedown="() => rise(item)"
|
:pt:root:onMousedown="() => rise(item)"
|
||||||
@hide="() => close(item)"
|
@hide="() => close(item)"
|
||||||
>
|
>
|
||||||
@@ -42,6 +43,20 @@
|
|||||||
import ResponseDialog from 'components/ResponseDialog.vue'
|
import ResponseDialog from 'components/ResponseDialog.vue'
|
||||||
import { useDialog } from 'hooks/dialog'
|
import { useDialog } from 'hooks/dialog'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
|
import { usePrimeVue } from 'primevue/config'
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
|
||||||
const { stack, rise, close } = useDialog()
|
const { stack, rise, close } = useDialog()
|
||||||
|
|
||||||
|
const { config } = usePrimeVue()
|
||||||
|
|
||||||
|
const baseZIndex = computed(() => {
|
||||||
|
return config.zIndex?.modal ?? 1100
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
for (const key in config.zIndex) {
|
||||||
|
config.zIndex[key] = baseZIndex.value
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -49,15 +49,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ResponseInput from 'components/ResponseInput.vue'
|
import ResponseInput from 'components/ResponseInput.vue'
|
||||||
import ResponseSelect from 'components/ResponseSelect.vue'
|
import ResponseSelect from 'components/ResponseSelect.vue'
|
||||||
import { useConfig } from 'hooks/config'
|
|
||||||
import { useModelBaseInfo } from 'hooks/model'
|
import { useModelBaseInfo } from 'hooks/model'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const editable = defineModel<boolean>('editable')
|
const editable = defineModel<boolean>('editable')
|
||||||
|
|
||||||
const { modelFolders } = useConfig()
|
const { baseInfo, pathIndex, basename, extension, type, modelFolders } =
|
||||||
|
useModelBaseInfo()
|
||||||
const { baseInfo, pathIndex, basename, extension, type } = useModelBaseInfo()
|
|
||||||
|
|
||||||
const typeOptions = computed(() => {
|
const typeOptions = computed(() => {
|
||||||
return Object.keys(modelFolders.value).map((curr) => {
|
return Object.keys(modelFolders.value).map((curr) => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="group/card relative w-full cursor-pointer select-none preview-aspect"
|
class="group/card relative cursor-pointer select-none"
|
||||||
|
:style="{ width: `${cardSize.width}px`, height: `${cardSize.height}px` }"
|
||||||
|
v-tooltip.top="{ value: model.basename, disabled: showModelName }"
|
||||||
@click.stop="openDetailDialog"
|
@click.stop="openDetailDialog"
|
||||||
>
|
>
|
||||||
<div class="h-full overflow-hidden rounded-lg">
|
<div class="h-full overflow-hidden rounded-lg">
|
||||||
@@ -18,9 +20,14 @@
|
|||||||
|
|
||||||
<div class="pointer-events-none absolute left-0 top-0 h-full w-full p-4">
|
<div class="pointer-events-none absolute left-0 top-0 h-full w-full p-4">
|
||||||
<div class="relative h-full w-full text-white">
|
<div class="relative h-full w-full text-white">
|
||||||
<div class="absolute bottom-0 left-0">
|
<div v-show="showModelName" class="absolute bottom-0 left-0">
|
||||||
<div class="drop-shadow-[0px_2px_2px_rgba(0,0,0,0.75)]">
|
<div class="drop-shadow-[0px_2px_2px_rgba(0,0,0,0.75)]">
|
||||||
<div class="line-clamp-3 break-all text-2xl font-bold @lg:text-lg">
|
<div
|
||||||
|
:class="[
|
||||||
|
'line-clamp-3 break-all font-bold',
|
||||||
|
$lg('text-lg', 'text-2xl'),
|
||||||
|
]"
|
||||||
|
>
|
||||||
{{ model.basename }}
|
{{ model.basename }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,13 +35,19 @@
|
|||||||
|
|
||||||
<div class="absolute left-0 top-0 w-full">
|
<div class="absolute left-0 top-0 w-full">
|
||||||
<div class="flex flex-row items-start justify-between">
|
<div class="flex flex-row items-start justify-between">
|
||||||
<div class="flex items-center rounded-full bg-black/30 px-3 py-2">
|
<div
|
||||||
<div class="font-bold @lg:text-xs">
|
v-show="showModelType"
|
||||||
|
class="flex items-center rounded-full bg-black/30 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div :class="['font-bold', $lg('text-xs')]">
|
||||||
{{ model.type }}
|
{{ model.type }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="opacity-0 duration-300 group-hover/card:opacity-100">
|
<div
|
||||||
|
v-show="showToolButton"
|
||||||
|
class="opacity-0 duration-300 group-hover/card:opacity-100"
|
||||||
|
>
|
||||||
<div class="flex flex-col gap-4 *:pointer-events-auto">
|
<div class="flex flex-col gap-4 *:pointer-events-auto">
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-plus"
|
icon="pi pi-plus"
|
||||||
@@ -66,6 +79,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DialogModelDetail from 'components/DialogModelDetail.vue'
|
import DialogModelDetail from 'components/DialogModelDetail.vue'
|
||||||
|
import { useConfig } from 'hooks/config'
|
||||||
|
import { useContainerQueries } from 'hooks/container'
|
||||||
import { useDialog } from 'hooks/dialog'
|
import { useDialog } from 'hooks/dialog'
|
||||||
import { useModelNodeAction } from 'hooks/model'
|
import { useModelNodeAction } from 'hooks/model'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
@@ -79,6 +94,8 @@ interface Props {
|
|||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const { cardSize } = useConfig()
|
||||||
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
const openDetailDialog = () => {
|
const openDetailDialog = () => {
|
||||||
@@ -99,6 +116,20 @@ const preview = computed(() =>
|
|||||||
: props.model.preview,
|
: props.model.preview,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const showToolButton = computed(() => {
|
||||||
|
return cardSize.value.width >= 180 && cardSize.value.height >= 240
|
||||||
|
})
|
||||||
|
|
||||||
|
const showModelName = computed(() => {
|
||||||
|
return cardSize.value.width >= 160 && cardSize.value.height >= 120
|
||||||
|
})
|
||||||
|
|
||||||
|
const showModelType = computed(() => {
|
||||||
|
return cardSize.value.width >= 120
|
||||||
|
})
|
||||||
|
|
||||||
const { addModelNode, dragToAddModelNode, copyModelNode, loadPreviewWorkflow } =
|
const { addModelNode, dragToAddModelNode, copyModelNode, loadPreviewWorkflow } =
|
||||||
useModelNodeAction(props.model)
|
useModelNodeAction(props.model)
|
||||||
|
|
||||||
|
const { $lg } = useContainerQueries()
|
||||||
</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,15 +1,9 @@
|
|||||||
<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>
|
<ResponseImage :src="preview" :error="noPreviewContent"></ResponseImage>
|
||||||
|
|
||||||
@@ -50,9 +44,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 +85,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'
|
||||||
@@ -109,4 +103,6 @@ const {
|
|||||||
updateLocalContent,
|
updateLocalContent,
|
||||||
noPreviewContent,
|
noPreviewContent,
|
||||||
} = useModelPreview()
|
} = useModelPreview()
|
||||||
|
|
||||||
|
const { $sm, $xl } = useContainerQueries()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,316 +1,140 @@
|
|||||||
<template>
|
<template>
|
||||||
<div data-scroll-area class="group/scroll relative overflow-hidden">
|
<div class="group/scroll relative 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">
|
||||||
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 & { key: string })[]
|
||||||
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>
|
||||||
|
|||||||
@@ -27,12 +27,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 +125,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 +151,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()
|
||||||
|
|
||||||
@@ -196,7 +198,7 @@ const toggle = (event: MouseEvent) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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 +238,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,17 @@
|
|||||||
import { request, useRequest } from 'hooks/request'
|
import SettingCardSize from 'components/SettingCardSize.vue'
|
||||||
|
import { request } from 'hooks/request'
|
||||||
import { defineStore } from 'hooks/store'
|
import { defineStore } from 'hooks/store'
|
||||||
import { $el, app, ComfyDialog } from 'scripts/comfyAPI'
|
import { $el, app, ComfyDialog } from 'scripts/comfyAPI'
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, readonly, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
|
|
||||||
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,17 +24,46 @@ export const useConfig = defineStore('config', (store) => {
|
|||||||
window.removeEventListener('resize', checkDeviceType)
|
window.removeEventListener('resize', checkDeviceType)
|
||||||
})
|
})
|
||||||
|
|
||||||
const refresh = async () => {
|
const defaultCardSizeMap = readonly({
|
||||||
return Promise.all([refreshModelFolders()])
|
'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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
useAddConfigSettings(store)
|
useAddConfigSettings(store)
|
||||||
@@ -50,8 +79,13 @@ 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 { toast } = useToast()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const confirm = (opts: {
|
const confirm = (opts: {
|
||||||
message?: string
|
message?: string
|
||||||
@@ -89,6 +123,7 @@ function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
|
|||||||
// 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,61 +131,41 @@ 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({
|
app.ui?.settings.addSetting({
|
||||||
id: 'ModelManager.Migrate.Migrate',
|
id: 'ModelManager.UI.CardSize',
|
||||||
name: 'Migrate information from cdb-boop/main',
|
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: 'Migrate',
|
onChange: (val) => {
|
||||||
onclick: () => {
|
store.config.cardSizeFlag.value = val
|
||||||
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!',
|
app.ui?.settings.addSetting({
|
||||||
'Continue?',
|
id: 'ModelManager.UI.CardSizeMap',
|
||||||
].join('\n'),
|
category: [t('modelManager'), t('setting.ui'), 'CardSizeMap'],
|
||||||
accept: () => {
|
name: t('setting.cardSize'),
|
||||||
store.loading.loading.value = true
|
defaultValue: JSON.stringify(defaultCardSize),
|
||||||
request('/migrate', {
|
type: 'hidden',
|
||||||
method: 'POST',
|
onChange(value) {
|
||||||
})
|
store.config.cardSizeMap.value = JSON.parse(value)
|
||||||
.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
|
// Scan information
|
||||||
app.ui?.settings.addSetting({
|
app.ui?.settings.addSetting({
|
||||||
id: 'ModelManager.ScanFiles.Full',
|
id: 'ModelManager.ScanFiles.Full',
|
||||||
name: "Override all models' information and preview",
|
category: [t('modelManager'), t('setting.scan'), 'Full'],
|
||||||
|
name: t('setting.scanAll'),
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
type: () => {
|
type: () => {
|
||||||
return $el('button.p-button.p-component.p-button-secondary', {
|
return $el('button.p-button.p-component.p-button-secondary', {
|
||||||
@@ -196,7 +211,8 @@ function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
|
|||||||
|
|
||||||
app.ui?.settings.addSetting({
|
app.ui?.settings.addSetting({
|
||||||
id: 'ModelManager.ScanFiles.Incremental',
|
id: 'ModelManager.ScanFiles.Incremental',
|
||||||
name: 'Download missing information or preview',
|
category: [t('modelManager'), t('setting.scan'), 'Incremental'],
|
||||||
|
name: t('setting.scanMissing'),
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
type: () => {
|
type: () => {
|
||||||
return $el('button.p-button.p-component.p-button-secondary', {
|
return $el('button.p-button.p-component.p-button-secondary', {
|
||||||
@@ -240,9 +256,18 @@ function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,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
|
||||||
|
|||||||
@@ -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,13 +1,12 @@
|
|||||||
import { useConfig } from 'hooks/config'
|
|
||||||
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 { 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 {
|
||||||
@@ -21,25 +20,84 @@ import {
|
|||||||
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')
|
||||||
|
|
||||||
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 refreshFolders = async () => {
|
||||||
|
return request('/models').then((resData) => {
|
||||||
|
folders.value = resData
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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) ?? []
|
||||||
|
return 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
|
||||||
@@ -51,16 +109,17 @@ export const useModels = defineStore('models', (store) => {
|
|||||||
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', data.fullname)
|
||||||
|
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(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
|
||||||
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 +139,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) => {
|
||||||
@@ -112,7 +171,7 @@ export const useModels = defineStore('models', (store) => {
|
|||||||
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 +195,13 @@ export const useModels = defineStore('models', (store) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { data, refresh, remove: deleteModel, update: updateModel }
|
return {
|
||||||
|
folders: folders,
|
||||||
|
data: models,
|
||||||
|
refresh: refreshAllModels,
|
||||||
|
remove: deleteModel,
|
||||||
|
update: updateModel,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
declare module 'hooks/store' {
|
declare module 'hooks/store' {
|
||||||
@@ -164,15 +229,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 +269,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<any>(modelFolderProvideKey)
|
||||||
|
const modelFolders = computed<ModelFolder>(() => {
|
||||||
|
return provideModelFolders?.value ?? {}
|
||||||
|
})
|
||||||
|
|
||||||
const type = computed({
|
const type = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
@@ -304,6 +372,7 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
|
|||||||
basename,
|
basename,
|
||||||
extension,
|
extension,
|
||||||
pathIndex,
|
pathIndex,
|
||||||
|
modelFolders,
|
||||||
}
|
}
|
||||||
|
|
||||||
provide(baseInfoKey, result)
|
provide(baseInfoKey, result)
|
||||||
@@ -338,9 +407,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)
|
||||||
|
|
||||||
@@ -379,7 +446,7 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
|
|||||||
content = localContent.value
|
content = localContent.value
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
content = noPreviewContent.value
|
content = undefined
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,7 +462,7 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
registerSubmit((data) => {
|
registerSubmit((data) => {
|
||||||
data.preview = preview.value ?? noPreviewContent.value
|
data.preview = preview.value
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
53
src/i18n.ts
53
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,12 +25,24 @@ const messages = {
|
|||||||
none: 'None',
|
none: 'None',
|
||||||
uploadFile: 'Upload File',
|
uploadFile: 'Upload File',
|
||||||
tapToChange: 'Tap description to change content',
|
tapToChange: 'Tap description to change content',
|
||||||
|
name: 'Name',
|
||||||
|
width: 'Width',
|
||||||
|
height: 'Height',
|
||||||
|
reset: 'Reset',
|
||||||
sort: {
|
sort: {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
size: 'Largest',
|
size: 'Largest',
|
||||||
created: 'Latest created',
|
created: 'Latest created',
|
||||||
modified: 'Latest modified',
|
modified: 'Latest modified',
|
||||||
},
|
},
|
||||||
|
size: {
|
||||||
|
extraLarge: 'Extra Large Icons',
|
||||||
|
large: 'Large Icons',
|
||||||
|
medium: 'Medium Icons',
|
||||||
|
small: 'Small Icons',
|
||||||
|
custom: 'Custom Size',
|
||||||
|
customTip: 'Set in `Settings > Model Manager > UI`',
|
||||||
|
},
|
||||||
info: {
|
info: {
|
||||||
type: 'Model Type',
|
type: 'Model Type',
|
||||||
pathIndex: 'Directory',
|
pathIndex: 'Directory',
|
||||||
@@ -38,6 +51,18 @@ const messages = {
|
|||||||
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',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
model: '模型',
|
model: '模型',
|
||||||
@@ -62,12 +87,24 @@ const messages = {
|
|||||||
none: '无',
|
none: '无',
|
||||||
uploadFile: '上传文件',
|
uploadFile: '上传文件',
|
||||||
tapToChange: '点击描述可更改内容',
|
tapToChange: '点击描述可更改内容',
|
||||||
|
name: '名称',
|
||||||
|
width: '宽度',
|
||||||
|
height: '高度',
|
||||||
|
reset: '重置',
|
||||||
sort: {
|
sort: {
|
||||||
name: '名称',
|
name: '名称',
|
||||||
size: '最大',
|
size: '最大',
|
||||||
created: '最新创建',
|
created: '最新创建',
|
||||||
modified: '最新修改',
|
modified: '最新修改',
|
||||||
},
|
},
|
||||||
|
size: {
|
||||||
|
extraLarge: '超大图标',
|
||||||
|
large: '大图标',
|
||||||
|
medium: '中等图标',
|
||||||
|
small: '小图标',
|
||||||
|
custom: '自定义尺寸',
|
||||||
|
customTip: '在 `设置 > 模型管理器 > 外观` 中设置',
|
||||||
|
},
|
||||||
info: {
|
info: {
|
||||||
type: '类型',
|
type: '类型',
|
||||||
pathIndex: '目录',
|
pathIndex: '目录',
|
||||||
@@ -76,16 +113,28 @@ const messages = {
|
|||||||
createdAt: '创建时间',
|
createdAt: '创建时间',
|
||||||
updatedAt: '更新时间',
|
updatedAt: '更新时间',
|
||||||
},
|
},
|
||||||
|
setting: {
|
||||||
|
apiKey: '密钥',
|
||||||
|
cardHeight: '卡片高度',
|
||||||
|
cardWidth: '卡片宽度',
|
||||||
|
scan: '扫描',
|
||||||
|
scanMissing: '下载缺失的信息或预览图片',
|
||||||
|
scanAll: '覆盖所有模型信息和预览图片',
|
||||||
|
includeHiddenFiles: '包含隐藏文件(以 . 开头的文件或文件夹)',
|
||||||
|
excludeScanTypes: '排除扫描类型(使用英文逗号隔开)',
|
||||||
|
ui: '外观',
|
||||||
|
cardSize: '卡片尺寸',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
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
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 {}
|
||||||
}
|
}
|
||||||
|
|||||||
4
src/types/typings.d.ts
vendored
4
src/types/typings.d.ts
vendored
@@ -26,6 +26,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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,7 +7,6 @@ export default {
|
|||||||
darkMode: ['selector', '.dark-theme'],
|
darkMode: ['selector', '.dark-theme'],
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
container,
|
|
||||||
plugin(({ addUtilities }) => {
|
plugin(({ addUtilities }) => {
|
||||||
addUtilities({
|
addUtilities({
|
||||||
'.scrollbar-none': {
|
'.scrollbar-none': {
|
||||||
|
|||||||
Reference in New Issue
Block a user