63 Commits

Author SHA1 Message Date
Hayden
8877c1599b prepare release 2.5.1 2025-02-24 11:09:08 +08:00
Hayden
965905305e fix: find subfolder incorrect (#154) 2025-02-24 11:07:43 +08:00
Hayden
312138f981 fix: auto open root folder (#151) 2025-02-22 18:30:29 +08:00
Hayden
76df8cd3cb prepare release 2.5.0 2025-02-22 18:14:38 +08:00
Hayden
df17eae0a2 fix: dialog cover tooltip (#150) 2025-02-22 18:10:43 +08:00
Hayden
7df89c7265 feat: add tooltip for model card and folder path (#149) 2025-02-22 18:10:28 +08:00
Hayden
450072e49d refactor(explorer): optimize openFolder (#148) 2025-02-22 18:10:11 +08:00
Hayden
759865e8ea feat: support search sub folder (#147) 2025-02-22 18:09:59 +08:00
Hayden
304978a7b8 prepare release 2.4.0 2025-02-19 16:16:14 +08:00
Hayden
704f35a1a8 feat: add context menu (#143) 2025-02-19 16:15:19 +08:00
Hayden
ce42960d57 fix(download): miss parameter (#142) 2025-02-19 16:11:15 +08:00
Hayden
05fa31f2c5 fix: download module error (#141) 2025-02-19 14:37:27 +08:00
Hayden
ea26ec5098 fix: dialog cover confirm and toast (#140) 2025-02-19 14:12:53 +08:00
Hayden
3d01c2dfda fix: content error in create download (#139) 2025-02-19 13:47:44 +08:00
Hayden
59552841e7 fix: add error tip (#137) 2025-02-18 16:44:53 +08:00
Hayden
ad6045f286 fix(Input): valid none value (#136) 2025-02-18 16:21:38 +08:00
Hayden
86c11e5343 [New Feature] sub directories support (#135)
* feat: add close all dialog

* feat: add new ui toggle setting

* feat: add tree display ui

* feat: add search and sort

* feat: change model data structure

* pref: Optimize model data structure

* feat: set sub folder by choose
2025-02-18 16:03:07 +08:00
Hayden
37be9a0b0d prepare release 2.3.4 2025-02-18 16:01:49 +08:00
Hayden
fcea052dde fix: resolve path (#132) 2025-02-10 17:00:08 +08:00
Hayden
9e95e7bd74 style: optimize style (#131) 2025-02-10 16:42:53 +08:00
Hayden
7e58d0a82d fix(setting): no modified value saved (#130)
* fix: save setting value

* prepare release 2.3.3
2025-02-10 13:51:45 +08:00
Hayden
55a4eff01b prepare releas 2.3.2 2025-02-10 12:42:29 +08:00
Hayden
45cf18299f feat: optimize resize card size (#129) 2025-02-10 12:41:00 +08:00
Hayden
c7898c47f1 fix: unpack folder_names_and_paths error (#128) 2025-02-10 10:59:36 +08:00
Hayden
17ab373b9c fix: change model size type to float (#126) 2025-02-06 12:02:00 +08:00
boeto
f6368fe20b fix: model preview path (#120) 2025-02-04 20:27:00 +08:00
Hayden
92f2d5ab9e Fix unable to install (#119)
* fix: release without requirements.txt

* prepare release 2.3.1
2025-02-04 11:12:15 +08:00
boeto
130c75f5bf fix huggingface download with tokens (#116) 2025-02-03 20:30:07 +08:00
Hayden
921dabc057 pref: optimize scan cost (#117) 2025-02-03 20:19:02 +08:00
Hayden
ac21c8015d style: optimize toolbar layout (#115) 2025-02-03 16:52:37 +08:00
Hayden
123b46fa88 prepare release 2.3.0 2025-02-03 16:41:24 +08:00
Hayden
6a77554932 Feat resize model card (#104)
* feat: Use setting definition card size

* refactor: Optimize computed value of the list items

- Add useContainerResize hooks
- Remove v-resize
- Change cols to computed

* refactor(ModelCard): Optimize style

- Control the display of button or name in difference sizes
- Add name tooltip when hiding name

* feat: Add i18n

* pref: optimize style code structure

* feat: add quick resize card size

* feat: add custom size tooltip

* feat: optimize card tool button display judgment
2025-02-03 16:40:33 +08:00
Hayden
faf4c15865 Pref hooks (#113)
* pref: replace useContainerResize

* pref: replace useContainerScroll
2025-02-02 19:54:23 +08:00
Hayden
f079d8bde5 feat: add scroll thumb draggable (#112)
add dependencies @vueuse/core
2025-02-02 19:44:44 +08:00
Hayden
56a2deb4eb pref: optimize virtual scroll (#111) 2025-02-02 16:42:25 +08:00
Hayden
448ea4b1ba pref: use hooks instead of directive (#108)
- remove v-resize
- add useContainerResize
- remove v-container
- add useContainerQueries
- add useContainerScroll
2025-02-01 11:56:17 +08:00
Hayden
e5d9950429 fix: overlay zIndex (#109) 2025-02-01 11:55:58 +08:00
Hayden
e7e2f4ce78 fix: set base-z-index (#107) 2025-01-31 11:53:01 +08:00
Hayden
0575124d35 Refactor code structure (#106)
* refactor: rename searcher.py to information.py

* refactor: move the routes into each sub-modules

* refactor: move services's func into sub-modules
2025-01-30 21:06:24 +08:00
Hayden
4df226be82 feat: add deprecated decorator (#105) 2025-01-30 10:04:56 +08:00
Hayden
1ba80fab2e prepare release 2.2.3 2025-01-16 10:24:42 +08:00
Hayden
b9e637049a fix: Inability to Scroll model dir list (#101) 2025-01-16 10:23:51 +08:00
Hayden
bfccc6f04f fix: can't change or delete preview (#100) 2025-01-15 16:48:41 +08:00
Hayden
89c249542a fix: cant't close create task dialog (#98) 2025-01-15 16:11:21 +08:00
Hayden
136bc0ecd5 feat(dialog): Optimize dialog closing logic (#97)
- Add optional parameters to the close function to support parameterless calling
- When the dialog parameter is not provided, automatically close the dialog box on the top of the stack
2025-01-15 16:03:46 +08:00
Hayden
8653af1f14 fix: misplaced preview buttons (#96) 2025-01-14 16:05:11 +08:00
Hayden
354b5c840a feat: allow multi create task dialog (#95) 2025-01-14 11:27:05 +08:00
Hayden
be383ac6e1 fix: potential bug after adding excluded directories (#94)
* Revert "fix: missing parameter (#93)"

This reverts commit c2406a1fd1.

* Revert "feat: add exclude scan model types (#92)"

This reverts commit 40a1a7f43a.

* feat: add exclude scan model types

* fix: potential bug after adding excluded directories
2025-01-14 11:04:41 +08:00
Hayden
c2406a1fd1 fix: missing parameter (#93) 2025-01-13 15:58:11 +08:00
Hayden
4132b2d8c4 prepare release 2.2.0 2025-01-13 15:16:31 +08:00
Hayden
40a1a7f43a feat: add exclude scan model types (#92) 2025-01-13 15:15:32 +08:00
Hayden
14bb6f194d Fix: i18n settings (#91)
* fix(i18n): Getting language configuration exception

* feat(i18n): Change settings display
2025-01-13 11:58:17 +08:00
Hayden
97b26549ce feat: Remove migration functionality (#89) 2025-01-10 17:11:15 +08:00
Hayden
e75275dfff fix: Container queries occasionally fail (#88)
- Use js dynamic calculation instead of container query
- Remove @tailwindcss/container-queries
2025-01-10 16:04:49 +08:00
Robin Huang
c1e89eb177 chore(licence-update): Update PyProject Toml - License (#87)
Co-authored-by: snomiao <snomiao+comfy-pr@gmail.com>
2025-01-09 10:16:53 +08:00
Hayden
bfedcb2a7d prepare release 2.1.6 2024-12-08 15:33:09 +08:00
Hayden
1d01ce009f Fixed infinite Load (#79) 2024-12-08 15:31:50 +08:00
Hayden
5c017137b0 Fixed the loading could not be closed correctly (#77)
* Fix hide loading before show it

* Release hotfix
2024-12-06 22:23:11 +08:00
Hayden
00d23ff74f prepare release 2.1.4
Optimize models request API
2024-12-03 14:14:11 +08:00
Hayden
dc46f498be Split model get list (#74)
Get the model list separately by model type and defer the request.
2024-12-03 14:05:18 +08:00
Hayden
6d67b00b17 Fix publishing failed (#69) 2024-11-29 09:40:25 +08:00
Hayden
cda24405b5 prepare release 2.1.3 2024-11-28 13:03:06 +08:00
Hayden
6fa90be8c4 Fix preview path (#66) 2024-11-28 13:02:36 +08:00
44 changed files with 2884 additions and 1583 deletions

View File

@@ -60,7 +60,7 @@ jobs:
run: |
pnpm install
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
uses: softprops/action-gh-release@v2

View File

@@ -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"/>
- Scan models and try to download information & preview.
- Support migration from `cdb-boop/ComfyUI-Model-Manager/main`

View File

@@ -1,10 +1,10 @@
import os
import folder_paths
from .py import config
from .py import utils
extension_uri = utils.normalize_path(os.path.dirname(__file__))
# Install requirements
requirements_path = utils.join_path(extension_uri, "requirements.txt")
with open(requirements_path, "r", encoding="utf-8") as f:
@@ -24,275 +24,21 @@ if len(uninstalled_package) > 0:
# Init config settings
config.extension_uri = extension_uri
# Try to download web distribution
version = utils.get_current_version()
utils.download_web_distribution(version)
from aiohttp import web
from .py import services
# Add api routes
from .py import manager
from .py import download
from .py import information
routes = config.routes
@routes.get("/model-manager/download/task")
async def scan_download_tasks(request):
"""
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})
manager.ModelManager().add_routes(routes)
download.ModelDownload().add_routes(routes)
information.Information().add_routes(routes)
WEB_DIRECTORY = "web"

View File

@@ -10,7 +10,6 @@
"prepare": "husky"
},
"devDependencies": {
"@tailwindcss/container-queries": "^0.1.1",
"@types/lodash": "^4.17.9",
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.5.5",
@@ -34,6 +33,7 @@
},
"dependencies": {
"@primevue/themes": "^4.0.7",
"@vueuse/core": "^11.3.0",
"dayjs": "^1.11.13",
"lodash": "^4.17.21",
"markdown-it": "^14.1.0",

63
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@primevue/themes':
specifier: ^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:
specifier: ^1.11.13
version: 1.11.13
@@ -36,9 +39,6 @@ importers:
specifier: ^2.6.0
version: 2.6.0
devDependencies:
'@tailwindcss/container-queries':
specifier: ^0.1.1
version: 0.1.1(tailwindcss@3.4.12)
'@types/lodash':
specifier: ^4.17.9
version: 4.17.9
@@ -470,11 +470,6 @@ packages:
cpu: [x64]
os: [win32]
'@tailwindcss/container-queries@0.1.1':
resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==}
peerDependencies:
tailwindcss: '>=3.2.0'
'@types/estree@1.0.5':
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
@@ -493,6 +488,9 @@ packages:
'@types/node@22.5.5':
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':
resolution: {integrity: sha512-nQtBLiZYMUPkclSeC3id+x4uVd1SGtHuElTxL++SfP47jR0zfkZBJHc+gL4qPsgTuypz0k8Y2GheaDYn6Gy3rg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -609,6 +607,15 @@ packages:
'@vue/shared@3.5.6':
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:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -1676,6 +1683,17 @@ packages:
vscode-uri@3.0.8:
resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
engines: {node: ^14.17.0 || >=16.0.0}
@@ -2000,10 +2018,6 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.22.0':
optional: true
'@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.12)':
dependencies:
tailwindcss: 3.4.12
'@types/estree@1.0.5': {}
'@types/linkify-it@5.0.0': {}
@@ -2021,6 +2035,8 @@ snapshots:
dependencies:
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)':
dependencies:
'@eslint-community/regexpp': 4.12.1
@@ -2193,6 +2209,25 @@ snapshots:
'@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):
dependencies:
acorn: 8.12.1
@@ -3192,6 +3227,10 @@ snapshots:
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)):
dependencies:
debug: 4.3.7

View File

@@ -3,8 +3,13 @@ import uuid
import time
import requests
import folder_paths
from typing import Callable, Awaitable, Any, Literal, Union, Optional
from dataclasses import dataclass
from aiohttp import web
from . import config
from . import utils
from . import thread
@@ -61,7 +66,7 @@ class TaskContent:
description: str
downloadPlatform: str
downloadUrl: str
sizeBytes: int
sizeBytes: float
hashes: Optional[dict[str, str]] = None
def __init__(self, **kwargs):
@@ -71,7 +76,7 @@ class TaskContent:
self.description = kwargs.get("description", None)
self.downloadPlatform = kwargs.get("downloadPlatform", None)
self.downloadUrl = kwargs.get("downloadUrl", None)
self.sizeBytes = int(kwargs.get("sizeBytes", 0))
self.sizeBytes = float(kwargs.get("sizeBytes", 0))
self.hashes = kwargs.get("hashes", None)
def to_dict(self):
@@ -87,185 +92,272 @@ class TaskContent:
}
download_model_task_status: dict[str, TaskStatus] = {}
download_thread_pool = thread.DownloadThreadPool()
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 self.scan_model_download_task_list()
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Read download task list failed: {e}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
def set_task_content(task_id: str, task_content: Union[TaskContent, dict]):
download_path = utils.get_download_path()
task_file_path = utils.join_path(download_path, f"{task_id}.task")
utils.save_dict_pickle_file(task_file_path, task_content)
@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 self.pause_model_download_task(task_id)
elif status == "resume":
await self.download_model(task_id, request)
else:
raise web.HTTPBadRequest(reason="Invalid status")
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Resume download task failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
def get_task_content(task_id: str):
download_path = utils.get_download_path()
task_file = utils.join_path(download_path, f"{task_id}.task")
if not os.path.isfile(task_file):
raise RuntimeError(f"Task {task_id} not found")
task_content = utils.load_dict_pickle_file(task_file)
return TaskContent(**task_content)
@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 self.delete_model_download_task(task_id)
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Delete download task failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.post("/model-manager/model")
async def create_model(request):
"""
Create a new model.
def get_task_status(task_id: str):
task_status = download_model_task_status.get(task_id, None)
request body: x-www-form-urlencoded
- type: model type.
- pathIndex: index of the model folders.
- fullname: filename that relative to the model folder.
- previewFile: preview file.
- description: description.
- downloadPlatform: download platform.
- downloadUrl: download url.
- hash: a JSON string containing the hash value of the downloaded model.
"""
task_data = await request.post()
task_data = dict(task_data)
try:
task_id = await self.create_model_download_task(task_data, request)
return web.json_response({"success": True, "data": {"taskId": task_id}})
except Exception as e:
error_msg = f"Create model download task failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
if task_status is None:
download_model_task_status: dict[str, TaskStatus] = {}
download_thread_pool = thread.DownloadThreadPool()
def set_task_content(self, task_id: str, task_content: Union[TaskContent, dict]):
download_path = utils.get_download_path()
task_content = get_task_content(task_id)
download_file = utils.join_path(download_path, f"{task_id}.download")
download_size = 0
if os.path.exists(download_file):
download_size = os.path.getsize(download_file)
task_file_path = utils.join_path(download_path, f"{task_id}.task")
utils.save_dict_pickle_file(task_file_path, task_content)
total_size = task_content.sizeBytes
task_status = TaskStatus(
taskId=task_id,
type=task_content.type,
fullname=task_content.fullname,
preview=utils.get_model_preview_name(download_file),
platform=task_content.downloadPlatform,
downloadedSize=download_size,
totalSize=task_content.sizeBytes,
progress=download_size / total_size * 100 if total_size > 0 else 0,
def get_task_content(self, task_id: str):
download_path = utils.get_download_path()
task_file = utils.join_path(download_path, f"{task_id}.task")
if not os.path.isfile(task_file):
raise RuntimeError(f"Task {task_id} not found")
task_content = utils.load_dict_pickle_file(task_file)
if isinstance(task_content, TaskContent):
return task_content
return TaskContent(**task_content)
def get_task_status(self, task_id: str):
task_status = self.download_model_task_status.get(task_id, None)
if task_status is None:
download_path = utils.get_download_path()
task_content = self.get_task_content(task_id)
download_file = utils.join_path(download_path, f"{task_id}.download")
download_size = 0
if os.path.exists(download_file):
download_size = os.path.getsize(download_file)
total_size = task_content.sizeBytes
task_status = TaskStatus(
taskId=task_id,
type=task_content.type,
fullname=task_content.fullname,
preview=utils.get_model_preview_name(download_file),
platform=task_content.downloadPlatform,
downloadedSize=download_size,
totalSize=task_content.sizeBytes,
progress=download_size / total_size * 100 if total_size > 0 else 0,
)
self.download_model_task_status[task_id] = task_status
return task_status
def delete_task_status(self, task_id: str):
self.download_model_task_status.pop(task_id, None)
async def scan_model_download_task_list(self):
"""
Scan the download directory and send the task list to the client.
"""
download_dir = utils.get_download_path()
task_files = utils.search_files(download_dir)
task_files = folder_paths.filter_files_extensions(task_files, [".task"])
task_files = sorted(
task_files,
key=lambda x: os.stat(utils.join_path(download_dir, x)).st_ctime,
reverse=True,
)
task_list: list[dict] = []
for task_file in task_files:
task_id = task_file.replace(".task", "")
task_status = self.get_task_status(task_id)
task_list.append(task_status.to_dict())
download_model_task_status[task_id] = task_status
return task_list
return task_status
async def create_model_download_task(self, task_data: dict, request):
"""
Creates a download task for the given data.
"""
model_type = task_data.get("type", None)
path_index = int(task_data.get("pathIndex", None))
fullname = task_data.get("fullname", None)
model_path = utils.get_full_path(model_type, path_index, fullname)
# Check if the model path is valid
if os.path.exists(model_path):
raise RuntimeError(f"File already exists: {model_path}")
def delete_task_status(task_id: str):
download_model_task_status.pop(task_id, None)
download_path = utils.get_download_path()
task_id = uuid.uuid4().hex
task_path = utils.join_path(download_path, f"{task_id}.task")
if os.path.exists(task_path):
raise RuntimeError(f"Task {task_id} already exists")
download_platform = task_data.get("downloadPlatform", None)
async def scan_model_download_task_list():
"""
Scan the download directory and send the task list to the client.
"""
download_dir = utils.get_download_path()
task_files = utils.search_files(download_dir)
task_files = folder_paths.filter_files_extensions(task_files, [".task"])
task_files = sorted(
task_files,
key=lambda x: os.stat(utils.join_path(download_dir, x)).st_ctime,
reverse=True,
)
task_list: list[dict] = []
for task_file in task_files:
task_id = task_file.replace(".task", "")
task_status = get_task_status(task_id)
task_list.append(task_status.to_dict())
try:
preview_file = task_data.pop("previewFile", None)
utils.save_model_preview_image(task_path, preview_file, download_platform)
self.set_task_content(task_id, task_data)
task_status = TaskStatus(
taskId=task_id,
type=model_type,
fullname=fullname,
preview=utils.get_model_preview_name(task_path),
platform=download_platform,
totalSize=float(task_data.get("sizeBytes", 0)),
)
self.download_model_task_status[task_id] = task_status
await utils.send_json("create_download_task", task_status.to_dict())
except Exception as e:
await self.delete_model_download_task(task_id)
raise RuntimeError(str(e)) from e
return task_list
await self.download_model(task_id, request)
return task_id
async def create_model_download_task(task_data: dict, request):
"""
Creates a download task for the given data.
"""
model_type = task_data.get("type", None)
path_index = int(task_data.get("pathIndex", None))
fullname = task_data.get("fullname", None)
model_path = utils.get_full_path(model_type, path_index, fullname)
# Check if the model path is valid
if os.path.exists(model_path):
raise RuntimeError(f"File already exists: {model_path}")
download_path = utils.get_download_path()
task_id = uuid.uuid4().hex
task_path = utils.join_path(download_path, f"{task_id}.task")
if os.path.exists(task_path):
raise RuntimeError(f"Task {task_id} already exists")
try:
preview_url = task_data.pop("preview", None)
utils.save_model_preview_image(task_path, preview_url)
set_task_content(task_id, task_data)
task_status = TaskStatus(
taskId=task_id,
type=model_type,
fullname=fullname,
preview=utils.get_model_preview_name(task_path),
platform=task_data.get("downloadPlatform", None),
totalSize=float(task_data.get("sizeBytes", 0)),
)
download_model_task_status[task_id] = task_status
await utils.send_json("create_download_task", task_status.to_dict())
except Exception as e:
await delete_model_download_task(task_id)
raise RuntimeError(str(e)) from e
await download_model(task_id, request)
return task_id
async def pause_model_download_task(task_id: str):
task_status = get_task_status(task_id=task_id)
task_status.status = "pause"
async def delete_model_download_task(task_id: str):
task_status = get_task_status(task_id)
is_running = task_status.status == "doing"
task_status.status = "waiting"
await utils.send_json("delete_download_task", task_id)
# Pause the task
if is_running:
async def pause_model_download_task(self, task_id: str):
task_status = self.get_task_status(task_id=task_id)
task_status.status = "pause"
time.sleep(1)
download_dir = utils.get_download_path()
task_file_list = os.listdir(download_dir)
for task_file in task_file_list:
task_file_target = os.path.splitext(task_file)[0]
if task_file_target == task_id:
delete_task_status(task_id)
os.remove(utils.join_path(download_dir, task_file))
async def delete_model_download_task(self, task_id: str):
task_status = self.get_task_status(task_id)
is_running = task_status.status == "doing"
task_status.status = "waiting"
await utils.send_json("delete_download_task", task_id)
await utils.send_json("delete_download_task", task_id)
# Pause the task
if is_running:
task_status.status = "pause"
time.sleep(1)
download_dir = utils.get_download_path()
task_file_list = os.listdir(download_dir)
for task_file in task_file_list:
task_file_target = os.path.splitext(task_file)[0]
if task_file_target == task_id:
self.delete_task_status(task_id)
os.remove(utils.join_path(download_dir, task_file))
async def download_model(task_id: str, request):
async def download_task(task_id: str):
async def report_progress(task_status: TaskStatus):
await utils.send_json("delete_download_task", task_id)
async def download_model(self, task_id: str, request):
async def download_task(task_id: str):
async def report_progress(task_status: TaskStatus):
await utils.send_json("update_download_task", task_status.to_dict())
try:
# When starting a task from the queue, the task may not exist
task_status = self.get_task_status(task_id)
except:
return
# Update task status
task_status.status = "doing"
await utils.send_json("update_download_task", task_status.to_dict())
try:
# When starting a task from the queue, the task may not exist
task_status = get_task_status(task_id)
except:
return
try:
# Update task status
task_status.status = "doing"
await utils.send_json("update_download_task", task_status.to_dict())
# Set download request headers
headers = {"User-Agent": config.user_agent}
download_platform = task_status.platform
if download_platform == "civitai":
api_key = utils.get_setting_value(request, "api_key.civitai")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
elif download_platform == "huggingface":
api_key = utils.get_setting_value(request, "api_key.huggingface")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
progress_interval = 1.0
await self.download_model_file(
task_id=task_id,
headers=headers,
progress_callback=report_progress,
interval=progress_interval,
)
except Exception as e:
task_status.status = "pause"
task_status.error = str(e)
await utils.send_json("update_download_task", task_status.to_dict())
task_status.error = None
utils.print_error(str(e))
try:
# Set download request headers
headers = {"User-Agent": config.user_agent}
download_platform = task_status.platform
if download_platform == "civitai":
api_key = utils.get_setting_value(request, "api_key.civitai")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
elif download_platform == "huggingface":
api_key = utils.get_setting_value(request, "api_key.huggingface")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
progress_interval = 1.0
await download_model_file(
task_id=task_id,
headers=headers,
progress_callback=report_progress,
interval=progress_interval,
)
status = self.download_thread_pool.submit(download_task, task_id)
if status == "Waiting":
task_status = self.get_task_status(task_id)
task_status.status = "waiting"
await utils.send_json("update_download_task", task_status.to_dict())
except Exception as e:
task_status.status = "pause"
task_status.error = str(e)
@@ -273,137 +365,120 @@ async def download_model(task_id: str, request):
task_status.error = None
utils.print_error(str(e))
try:
status = download_thread_pool.submit(download_task, task_id)
if status == "Waiting":
task_status = get_task_status(task_id)
task_status.status = "waiting"
await utils.send_json("update_download_task", task_status.to_dict())
except Exception as e:
task_status.status = "pause"
task_status.error = str(e)
await utils.send_json("update_download_task", task_status.to_dict())
task_status.error = None
utils.print_error(str(e))
async def download_model_file(
self,
task_id: str,
headers: dict,
progress_callback: Callable[[TaskStatus], Awaitable[Any]],
interval: float = 1.0,
):
async def download_complete():
"""
Restore the model information from the task file
and move the model file to the target directory.
"""
model_type = task_content.type
path_index = task_content.pathIndex
fullname = task_content.fullname
# Write description file
description = task_content.description
description_file = utils.join_path(download_path, f"{task_id}.md")
with open(description_file, "w", encoding="utf-8", newline="") as f:
f.write(description)
async def download_model_file(
task_id: str,
headers: dict,
progress_callback: Callable[[TaskStatus], Awaitable[Any]],
interval: float = 1.0,
):
model_path = utils.get_full_path(model_type, path_index, fullname)
async def download_complete():
"""
Restore the model information from the task file
and move the model file to the target directory.
"""
model_type = task_content.type
path_index = task_content.pathIndex
fullname = task_content.fullname
# Write description file
description = task_content.description
description_file = utils.join_path(download_path, f"{task_id}.md")
with open(description_file, "w", encoding="utf-8", newline="") as f:
f.write(description)
utils.rename_model(download_tmp_file, model_path)
model_path = utils.get_full_path(model_type, path_index, fullname)
time.sleep(1)
task_file = utils.join_path(download_path, f"{task_id}.task")
os.remove(task_file)
await utils.send_json("complete_download_task", task_id)
utils.rename_model(download_tmp_file, model_path)
async def update_progress():
nonlocal last_update_time
nonlocal last_downloaded_size
progress = (downloaded_size / total_size) * 100 if total_size > 0 else 0
task_status.downloadedSize = downloaded_size
task_status.progress = progress
task_status.bps = downloaded_size - last_downloaded_size
await progress_callback(task_status)
last_update_time = time.time()
last_downloaded_size = downloaded_size
time.sleep(1)
task_file = utils.join_path(download_path, f"{task_id}.task")
os.remove(task_file)
await utils.send_json("complete_download_task", task_id)
task_status = self.get_task_status(task_id)
task_content = self.get_task_content(task_id)
# Check download uri
model_url = task_content.downloadUrl
if not model_url:
raise RuntimeError("No downloadUrl found")
download_path = utils.get_download_path()
download_tmp_file = utils.join_path(download_path, f"{task_id}.download")
downloaded_size = 0
if os.path.isfile(download_tmp_file):
downloaded_size = os.path.getsize(download_tmp_file)
headers["Range"] = f"bytes={downloaded_size}-"
total_size = task_content.sizeBytes
if total_size > 0 and downloaded_size == total_size:
await download_complete()
return
async def update_progress():
nonlocal last_update_time
nonlocal last_downloaded_size
progress = (downloaded_size / total_size) * 100 if total_size > 0 else 0
task_status.downloadedSize = downloaded_size
task_status.progress = progress
task_status.bps = downloaded_size - last_downloaded_size
await progress_callback(task_status)
last_update_time = time.time()
last_downloaded_size = downloaded_size
task_status = get_task_status(task_id)
task_content = get_task_content(task_id)
# Check download uri
model_url = task_content.downloadUrl
if not model_url:
raise RuntimeError("No downloadUrl found")
download_path = utils.get_download_path()
download_tmp_file = utils.join_path(download_path, f"{task_id}.download")
downloaded_size = 0
if os.path.isfile(download_tmp_file):
downloaded_size = os.path.getsize(download_tmp_file)
headers["Range"] = f"bytes={downloaded_size}-"
total_size = task_content.sizeBytes
if total_size > 0 and downloaded_size == total_size:
await download_complete()
return
last_update_time = time.time()
last_downloaded_size = downloaded_size
response = requests.get(
url=model_url,
headers=headers,
stream=True,
allow_redirects=True,
)
if response.status_code not in (200, 206):
raise RuntimeError(
f"Failed to download {task_content.fullname}, status code: {response.status_code}"
response = requests.get(
url=model_url,
headers=headers,
stream=True,
allow_redirects=True,
)
# Some models require logging in before they can be downloaded.
# If no token is carried, it will be redirected to the login page.
content_type = response.headers.get("content-type")
if content_type and content_type.startswith("text/html"):
# TODO More checks
# In addition to requiring login to download, there may be other restrictions.
# The currently one situation is early access??? issues#43
# Due to the lack of test data, lets put it aside for now.
# If it cannot be downloaded, a redirect will definitely occur.
# Maybe consider getting the redirect url from response.history to make a judgment.
# Here we also need to consider how different websites are processed.
raise RuntimeError(
f"{task_content.fullname} needs to be logged in to download. Please set the API-Key first."
)
if response.status_code not in (200, 206):
raise RuntimeError(f"Failed to download {task_content.fullname}, status code: {response.status_code}")
# When parsing model information from HuggingFace API,
# the file size was not found and needs to be obtained from the response header.
if total_size == 0:
total_size = int(response.headers.get("content-length", 0))
task_content.sizeBytes = total_size
task_status.totalSize = total_size
set_task_content(task_id, task_content)
await utils.send_json("update_download_task", task_content.to_dict())
# Some models require logging in before they can be downloaded.
# If no token is carried, it will be redirected to the login page.
content_type = response.headers.get("content-type")
if content_type and content_type.startswith("text/html"):
# TODO More checks
# In addition to requiring login to download, there may be other restrictions.
# The currently one situation is early access??? issues#43
# Due to the lack of test data, lets put it aside for now.
# If it cannot be downloaded, a redirect will definitely occur.
# Maybe consider getting the redirect url from response.history to make a judgment.
# Here we also need to consider how different websites are processed.
raise RuntimeError(f"{task_content.fullname} needs to be logged in to download. Please set the API-Key first.")
with open(download_tmp_file, "ab") as f:
for chunk in response.iter_content(chunk_size=8192):
if task_status.status == "pause":
break
# When parsing model information from HuggingFace API,
# the file size was not found and needs to be obtained from the response header.
if total_size == 0:
total_size = float(response.headers.get("content-length", 0))
task_content.sizeBytes = total_size
task_status.totalSize = total_size
self.set_task_content(task_id, task_content)
await utils.send_json("update_download_task", task_content.to_dict())
f.write(chunk)
downloaded_size += len(chunk)
with open(download_tmp_file, "ab") as f:
for chunk in response.iter_content(chunk_size=8192):
if task_status.status == "pause":
break
if time.time() - last_update_time >= interval:
await update_progress()
f.write(chunk)
downloaded_size += len(chunk)
await update_progress()
if time.time() - last_update_time >= interval:
await update_progress()
if total_size > 0 and downloaded_size == total_size:
await download_complete()
else:
task_status.status = "pause"
await utils.send_json("update_download_task", task_status.to_dict())
await update_progress()
if total_size > 0 and downloaded_size == total_size:
await download_complete()
else:
task_status.status = "pause"
await utils.send_json("update_download_task", task_status.to_dict())

View File

@@ -27,9 +27,7 @@ class ModelSearcher(ABC):
class UnknownWebsiteSearcher(ModelSearcher):
def search_by_url(self, url: str):
raise RuntimeError(
f"Unknown Website, please input a URL from huggingface.co or civitai.com."
)
raise RuntimeError(f"Unknown Website, please input a URL from huggingface.co or civitai.com.")
def search_by_hash(self, hash: str):
raise RuntimeError(f"Unknown Website, unable to search with hash value.")
@@ -66,9 +64,9 @@ class CivitaiModelSearcher(ModelSearcher):
shortname = version.get("name", None) if len(model_files) > 0 else None
for file in model_files:
fullname = file.get("name", None)
extension = os.path.splitext(fullname)[1]
basename = os.path.splitext(fullname)[0]
name = file.get("name", None)
extension = os.path.splitext(name)[1]
basename = os.path.splitext(name)[0]
metadata_info = {
"website": "Civitai",
@@ -87,41 +85,27 @@ class CivitaiModelSearcher(ModelSearcher):
description_parts.append("")
description_parts.append(f"# Trigger Words")
description_parts.append("")
description_parts.append(
", ".join(version.get("trainedWords", ["No trigger words"]))
)
description_parts.append(", ".join(version.get("trainedWords", ["No trigger words"])))
description_parts.append("")
description_parts.append(f"# About this version")
description_parts.append("")
description_parts.append(
markdownify.markdownify(
version.get(
"description", "<p>No description about this version</p>"
)
).strip()
)
description_parts.append(markdownify.markdownify(version.get("description", "<p>No description about this version</p>")).strip())
description_parts.append("")
description_parts.append(f"# {res_data.get('name')}")
description_parts.append("")
description_parts.append(
markdownify.markdownify(
res_data.get(
"description", "<p>No description about this model</p>"
)
).strip()
)
description_parts.append(markdownify.markdownify(res_data.get("description", "<p>No description about this model</p>")).strip())
description_parts.append("")
model = {
"id": file.get("id"),
"shortname": shortname or basename,
"fullname": fullname,
"basename": basename,
"extension": extension,
"preview": metadata_info.get("preview"),
"sizeBytes": file.get("sizeKB", 0) * 1024,
"type": self._resolve_model_type(res_data.get("type", "unknown")),
"type": self._resolve_model_type(res_data.get("type", "")),
"pathIndex": 0,
"subFolder": "",
"description": "\n".join(description_parts),
"metadata": file.get("metadata"),
"downloadPlatform": "civitai",
@@ -136,18 +120,14 @@ class CivitaiModelSearcher(ModelSearcher):
if not hash:
raise RuntimeError(f"Hash value is empty.")
response = requests.get(
f"https://civitai.com/api/v1/model-versions/by-hash/{hash}"
)
response = requests.get(f"https://civitai.com/api/v1/model-versions/by-hash/{hash}")
response.raise_for_status()
version: dict = response.json()
model_id = version.get("modelId")
version_id = version.get("id")
model_page = (
f"https://civitai.com/models/{model_id}?modelVersionId={version_id}"
)
model_page = f"https://civitai.com/models/{model_id}?modelVersionId={version_id}"
models = self.search_by_url(model_page)
@@ -166,7 +146,7 @@ class CivitaiModelSearcher(ModelSearcher):
"Controlnet": "controlnet",
"Upscaler": "upscale_models",
"VAE": "vae",
"unknown": "unknown",
"unknown": "",
}
return map_legacy.get(model_type, f"{model_type.lower()}s")
@@ -186,9 +166,7 @@ class HuggingfaceModelSearcher(ModelSearcher):
response.raise_for_status()
res_data: dict = response.json()
sibling_files: list[str] = [
x.get("rfilename") for x in res_data.get("siblings", [])
]
sibling_files: list[str] = [x.get("rfilename") for x in res_data.get("siblings", [])]
model_files = utils.filter_with(
utils.filter_with(sibling_files, self._match_model_files()),
@@ -199,10 +177,7 @@ class HuggingfaceModelSearcher(ModelSearcher):
utils.filter_with(sibling_files, self._match_image_files()),
self._match_tree_files(rest_pathname),
)
image_files = [
f"https://huggingface.co/{model_id}/resolve/main/{filename}"
for filename in image_files
]
image_files = [f"https://huggingface.co/{model_id}/resolve/main/{filename}" for filename in image_files]
models: list[dict] = []
@@ -241,16 +216,16 @@ class HuggingfaceModelSearcher(ModelSearcher):
model = {
"id": filename,
"shortname": filename,
"fullname": fullname,
"basename": basename,
"extension": extension,
"preview": image_files,
"sizeBytes": 0,
"type": "unknown",
"type": "",
"pathIndex": 0,
"subFolder": "",
"description": "\n".join(description_parts),
"metadata": {},
"downloadPlatform": "",
"downloadPlatform": "huggingface",
"downloadUrl": f"https://huggingface.co/{model_id}/resolve/main/{filename}?download=true",
}
models.append(model)
@@ -315,3 +290,148 @@ def get_model_searcher_by_url(url: str) -> ModelSearcher:
elif host_name == "huggingface.co":
return HuggingfaceModelSearcher()
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.")

232
py/manager.py Normal file
View File

@@ -0,0 +1,232 @@
import os
import folder_paths
from aiohttp import web
from concurrent.futures import ThreadPoolExecutor, as_completed
from . import utils
class ModelManager:
def add_routes(self, routes):
@routes.get("/model-manager/base-folders")
@utils.deprecated(reason="Use `/model-manager/models` instead.")
async def get_model_paths(request):
"""
Returns the base folders for models.
"""
model_base_paths = utils.resolve_model_base_paths()
return web.json_response({"success": True, "data": model_base_paths})
@routes.get("/model-manager/models")
async def get_folders(request):
"""
Returns the base folders for models.
"""
try:
result = utils.resolve_model_base_paths()
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Read models failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.get("/model-manager/models/{folder}")
async def get_folder_models(request):
try:
folder = request.match_info.get("folder", None)
results = self.scan_models(folder, request)
return web.json_response({"success": True, "data": results})
except Exception as e:
error_msg = f"Read models failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.get("/model-manager/model/{type}/{index}/{filename:.*}")
async def get_model_info(request):
"""
Get the information of the specified model.
"""
model_type = request.match_info.get("type", None)
path_index = int(request.match_info.get("index", None))
filename = request.match_info.get("filename", None)
try:
model_path = utils.get_valid_full_path(model_type, path_index, filename)
result = self.get_model_info(model_path)
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Read model info failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.put("/model-manager/model/{type}/{index}/{filename:.*}")
async def update_model(request):
"""
Update model information.
request body: x-www-form-urlencoded
- previewFile: preview file.
- description: description.
- type: model type.
- pathIndex: index of the model folders.
- fullname: filename that relative to the model folder.
All fields are optional, but type, pathIndex and fullname must appear together.
"""
model_type = request.match_info.get("type", None)
path_index = int(request.match_info.get("index", None))
filename = request.match_info.get("filename", None)
model_data = await request.post()
model_data = dict(model_data)
try:
model_path = utils.get_valid_full_path(model_type, path_index, filename)
if model_path is None:
raise RuntimeError(f"File {filename} not found")
self.update_model(model_path, model_data)
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Update model failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.delete("/model-manager/model/{type}/{index}/{filename:.*}")
async def delete_model(request):
"""
Delete model.
"""
model_type = request.match_info.get("type", None)
path_index = int(request.match_info.get("index", None))
filename = request.match_info.get("filename", None)
try:
model_path = utils.get_valid_full_path(model_type, path_index, filename)
if model_path is None:
raise RuntimeError(f"File {filename} not found")
self.remove_model(model_path)
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Delete model failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
def scan_models(self, folder: str, request):
result = []
include_hidden_files = utils.get_setting_value(request, "scan.include_hidden_files", False)
folders, *others = folder_paths.folder_names_and_paths[folder]
def get_file_info(entry: os.DirEntry[str], base_path: str, path_index: int):
prefix_path = utils.normalize_path(base_path)
if not prefix_path.endswith("/"):
prefix_path = f"{prefix_path}/"
relative_path = utils.normalize_path(entry.path).replace(prefix_path, "")
sub_folder = os.path.dirname(relative_path)
filename = os.path.basename(relative_path)
basename = os.path.splitext(filename)[0]
extension = os.path.splitext(filename)[1]
is_file = entry.is_file()
if is_file and extension not in folder_paths.supported_pt_extensions:
return None
model_preview = f"/model-manager/preview/{folder}/{path_index}/{relative_path.replace(extension, '.webp')}"
stat = entry.stat()
return {
"type": folder,
"subFolder": sub_folder,
"isFolder": not is_file,
"basename": basename,
"extension": extension,
"pathIndex": path_index,
"sizeBytes": stat.st_size if is_file else 0,
"preview": model_preview if is_file else None,
"createdAt": round(stat.st_ctime_ns / 1000000),
"updatedAt": round(stat.st_mtime_ns / 1000000),
}
def get_all_files_entry(directory: str):
entries: list[os.DirEntry[str]] = []
with os.scandir(directory) as it:
for entry in it:
# Skip hidden files
if not include_hidden_files:
if entry.name.startswith("."):
continue
entries.append(entry)
if entry.is_dir():
entries.extend(get_all_files_entry(entry.path))
return entries
for path_index, base_path in enumerate(folders):
if not os.path.exists(base_path):
continue
file_entries = get_all_files_entry(base_path)
with ThreadPoolExecutor() as executor:
futures = {executor.submit(get_file_info, entry, base_path, path_index): entry for entry in file_entries}
for future in as_completed(futures):
file_info = future.result()
if file_info is None:
continue
result.append(file_info)
return result
def get_model_info(self, model_path: str):
directory = os.path.dirname(model_path)
metadata = utils.get_model_metadata(model_path)
description_file = utils.get_model_description_name(model_path)
description_file = utils.join_path(directory, description_file)
description = None
if os.path.isfile(description_file):
with open(description_file, "r", encoding="utf-8", newline="") as f:
description = f.read()
return {
"metadata": metadata,
"description": description,
}
def update_model(self, model_path: str, model_data: dict):
if "previewFile" in model_data:
previewFile = model_data["previewFile"]
if type(previewFile) is str and previewFile == "undefined":
utils.remove_model_preview_image(model_path)
else:
utils.save_model_preview_image(model_path, previewFile)
if "description" in model_data:
description = model_data["description"]
utils.save_model_description(model_path, description)
if "type" in model_data and "pathIndex" in model_data and "fullname" in model_data:
model_type = model_data.get("type", None)
path_index = int(model_data.get("pathIndex", None))
fullname = model_data.get("fullname", None)
if model_type is None or path_index is None or fullname is None:
raise RuntimeError("Invalid type or pathIndex or fullname")
# get new path
new_model_path = utils.get_full_path(model_type, path_index, fullname)
utils.rename_model(model_path, new_model_path)
def remove_model(self, model_path: str):
model_dirname = os.path.dirname(model_path)
os.remove(model_path)
model_previews = utils.get_model_all_images(model_path)
for preview in model_previews:
os.remove(utils.join_path(model_dirname, preview))
model_descriptions = utils.get_model_all_descriptions(model_path)
for description in model_descriptions:
os.remove(utils.join_path(model_dirname, description))

View File

@@ -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.")

View File

@@ -7,6 +7,7 @@ import logging
import requests
import traceback
import configparser
import functools
import comfy.utils
import folder_paths
@@ -20,6 +21,10 @@ def print_info(msg, *args, **kwargs):
logging.info(f"[{config.extension_tag}] {msg}", *args, **kwargs)
def print_warning(msg, *args, **kwargs):
logging.warning(f"[{config.extension_tag}][WARNING] {msg}", *args, **kwargs)
def print_error(msg, *args, **kwargs):
logging.error(f"[{config.extension_tag}] {msg}", *args, **kwargs)
logging.debug(traceback.format_exc())
@@ -29,6 +34,18 @@ def print_debug(msg, *args, **kwargs):
logging.debug(f"[{config.extension_tag}] {msg}", *args, **kwargs)
def deprecated(reason: str):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print_warning(f"{func.__name__} is deprecated: {reason}")
return func(*args, **kwargs)
return wrapper
return decorator
def _matches(predicate: dict):
def _filter(obj: dict):
return all(obj.get(key, None) == value for key, value in predicate.items())
@@ -116,7 +133,11 @@ def download_web_distribution(version: str):
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())
model_base_paths = {}
folder_black_list = ["configs", "custom_nodes"]
@@ -249,19 +270,47 @@ from PIL import Image
from io import BytesIO
def save_model_preview_image(model_path: str, image_url: str):
try:
image_response = requests.get(image_url)
image_response.raise_for_status()
def remove_model_preview_image(model_path: str):
basename = os.path.splitext(model_path)[0]
preview_path = f"{basename}.webp"
if os.path.exists(preview_path):
os.remove(preview_path)
basename = os.path.splitext(model_path)[0]
preview_path = f"{basename}.webp"
image = Image.open(BytesIO(image_response.content))
def save_model_preview_image(model_path: str, image_file_or_url: Any, platform: str | None = None):
basename = os.path.splitext(model_path)[0]
preview_path = f"{basename}.webp"
# Download image file if it is url
if type(image_file_or_url) is str:
image_url = image_file_or_url
try:
image_response = requests.get(image_url)
image_response.raise_for_status()
image = Image.open(BytesIO(image_response.content))
image.save(preview_path, "WEBP")
except Exception as e:
print_error(f"Failed to download image: {e}")
else:
# Assert image as file
image_file = image_file_or_url
if not isinstance(image_file, web.FileField):
raise RuntimeError("Invalid image file")
content_type: str = image_file.content_type
if not content_type.startswith("image/"):
if platform == "huggingface":
# huggingface previewFile content_type='text/plain', not startswith("image/")
return
else:
raise RuntimeError(f"FileTypeError: expected image, got {content_type}")
image = Image.open(image_file.file)
image.save(preview_path, "WEBP")
except Exception as e:
print_error(f"Failed to download image: {e}")
def get_model_all_descriptions(model_path: str):
base_dirname = os.path.dirname(model_path)

View File

@@ -1,8 +1,8 @@
[project]
name = "comfyui-model-manager"
description = "Manage models: browsing, download and delete."
version = "2.1.2"
license = "LICENSE"
version = "2.5.1"
license = { file = "LICENSE" }
dependencies = ["markdownify"]
[project.urls]

View File

@@ -7,6 +7,7 @@
<script setup lang="ts">
import DialogDownload from 'components/DialogDownload.vue'
import DialogExplorer from 'components/DialogExplorer.vue'
import DialogManager from 'components/DialogManager.vue'
import GlobalDialogStack from 'components/GlobalDialogStack.vue'
import GlobalLoading from 'components/GlobalLoading.vue'
@@ -15,16 +16,18 @@ import { useStoreProvider } from 'hooks/store'
import { useToast } from 'hooks/toast'
import GlobalConfirm from 'primevue/confirmdialog'
import { $el, app, ComfyButton } from 'scripts/comfyAPI'
import { onMounted } from 'vue'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { dialog, models, config, download } = useStoreProvider()
const { toast } = useToast()
const firstOpenManager = ref(true)
onMounted(() => {
const refreshModelsAndConfig = async () => {
await Promise.all([models.refresh(), config.refresh()])
await Promise.all([models.refresh(true)])
toast.add({
severity: 'success',
summary: 'Refreshed Models',
@@ -48,12 +51,17 @@ onMounted(() => {
}
const openManagerDialog = () => {
const { cardWidth, gutter, aspect } = config
const { cardWidth, gutter, aspect, flat } = config
if (firstOpenManager.value) {
models.refresh(true)
firstOpenManager.value = false
}
dialog.open({
key: 'model-manager',
title: t('modelManager'),
content: DialogManager,
content: flat.value ? DialogManager : DialogExplorer,
keepAlive: true,
headerButtons: [
{

View File

@@ -66,10 +66,12 @@ import { useConfig } from 'hooks/config'
import { useDialog } from 'hooks/dialog'
import { useModelSearch } from 'hooks/download'
import { useLoading } from 'hooks/loading'
import { genModelFullName } from 'hooks/model'
import { request } from 'hooks/request'
import { useToast } from 'hooks/toast'
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'
const { isMobile } = useConfig()
@@ -87,15 +89,55 @@ const searchModelsByUrl = async () => {
}
}
const createDownTask = async (data: VersionModel) => {
const createDownTask = async (data: WithResolved<VersionModel>) => {
loading.show()
const formData = new FormData()
for (const key in data) {
if (Object.prototype.hasOwnProperty.call(data, key)) {
let value = data[key]
// set preview file
if (key === 'preview') {
if (value) {
const previewFile = await previewUrlToFile(value).catch(() => {
loading.hide()
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to download preview',
life: 5000,
})
throw new Error('Failed to download preview')
})
formData.append('previewFile', previewFile)
} else {
formData.append('previewFile', value)
}
continue
}
if (typeof value === 'object') {
value = JSON.stringify(value)
}
if (typeof value === 'number') {
value = value.toString()
}
formData.append(key, value)
}
}
const fullname = genModelFullName(data as VersionModel)
formData.append('fullname', fullname)
await request('/model', {
method: 'POST',
body: JSON.stringify(data),
body: formData,
})
.then(() => {
dialog.close({ key: 'model-manager-create-task' })
dialog.close()
})
.catch((e) => {
toast.add({

View File

@@ -1,9 +1,9 @@
<template>
<div class="flex h-full flex-col gap-4">
<div class="whitespace-nowrap px-4 @container">
<div class="flex gap-4 @sm:justify-end">
<div ref="container" class="whitespace-nowrap px-4">
<div :class="['flex gap-4', $sm('justify-end')]">
<Button
class="w-full @sm:w-auto"
:class="[$sm('w-auto', 'w-full')]"
:label="$t('createDownloadTask')"
@click="openCreateTask"
></Button>
@@ -73,9 +73,11 @@
<script setup lang="ts">
import DialogCreateTask from 'components/DialogCreateTask.vue'
import ResponseScroll from 'components/ResponseScroll.vue'
import { useContainerQueries } from 'hooks/container'
import { useDialog } from 'hooks/dialog'
import { useDownload } from 'hooks/download'
import Button from 'primevue/button'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { data } = useDownload()
@@ -85,9 +87,12 @@ const dialog = useDialog()
const openCreateTask = () => {
dialog.open({
key: 'model-manager-create-task',
key: `model-manager-create-task-${Date.now()}`,
title: t('parseModelUrl'),
content: DialogCreateTask,
})
}
const container = ref<HTMLElement | null>(null)
const { $sm } = useContainerQueries(container)
</script>

View File

@@ -0,0 +1,337 @@
<template>
<div
class="flex h-full w-full select-none flex-col overflow-hidden"
@contextmenu.prevent="nonContextMenu"
>
<div class="flex w-full gap-4 overflow-hidden px-4 pb-4">
<div :class="['flex gap-4 overflow-hidden', showToolbar || 'flex-1']">
<div class="flex overflow-hidden">
<Button
icon="pi pi-arrow-up"
text
rounded
severity="secondary"
:disabled="folderPaths.length < 2"
@click="handleGoBackParentFolder"
></Button>
</div>
<ResponseBreadcrumb
v-show="!showToolbar"
class="h-10 flex-1"
:items="folderPaths"
></ResponseBreadcrumb>
</div>
<div :class="['flex gap-4', showToolbar && 'flex-1']">
<ResponseInput
v-model="searchContent"
:placeholder="$t('searchModels')"
></ResponseInput>
<div
v-show="showToolbar"
class="flex flex-1 items-center justify-end gap-2"
>
<ResponseSelect
v-model="sortOrder"
:items="sortOrderOptions"
></ResponseSelect>
<ResponseSelect
v-model="cardSizeFlag"
:items="cardSizeOptions"
></ResponseSelect>
</div>
<Button
:icon="`mdi mdi-menu-${showToolbar ? 'close' : 'open'}`"
text
severity="secondary"
@click="toggleToolbar"
></Button>
</div>
</div>
<div
ref="contentContainer"
class="relative flex-1 overflow-hidden px-2"
@contextmenu.stop.prevent=""
>
<ResponseScroll :items="renderedList" :item-size="itemSize">
<template #item="{ item }">
<div
class="grid h-full justify-center"
:style="{
gridTemplateColumns: `repeat(auto-fit, ${cardSize.width}px)`,
columnGap: `${gutter.x}px`,
rowGap: `${gutter.y}px`,
}"
>
<ModelCard
v-for="rowItem in item.row"
:model="rowItem"
:key="genModelKey(rowItem)"
:style="{
width: `${cardSize.width}px`,
height: `${cardSize.height}px`,
}"
v-tooltip.top="{
value: getFullPath(rowItem),
disabled: folderPaths.length < 2,
autoHide: false,
showDelay: 800,
hideDelay: 300,
pt: { root: { style: { zIndex: 2100, maxWidth: '32rem' } } },
}"
@dblclick="openItem(rowItem, $event)"
@contextmenu.stop.prevent="openItemContext(rowItem, $event)"
></ModelCard>
<div class="col-span-full"></div>
</div>
</template>
</ResponseScroll>
</div>
<div class="flex justify-between px-4 py-2 text-sm">
<div></div>
<div></div>
</div>
<ContextMenu ref="menu" :model="contextItems"></ContextMenu>
<ConfirmDialog group="confirm-name">
<template #container="{ acceptCallback: accept, rejectCallback: reject }">
<div class="flex w-90 flex-col items-end rounded px-4 pb-4 pt-8">
<InputText
class="w-full"
type="text"
v-model="confirmName"
v-focus
@keyup.enter="accept"
></InputText>
<div class="mt-6 flex items-center gap-2">
<Button :label="$t('cancel')" @click="reject" outlined></Button>
<Button :label="$t('confirm')" @click="accept"></Button>
</div>
</div>
</template>
</ConfirmDialog>
</div>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import ModelCard from 'components/ModelCard.vue'
import ResponseBreadcrumb from 'components/ResponseBreadcrumb.vue'
import ResponseInput from 'components/ResponseInput.vue'
import ResponseScroll from 'components/ResponseScroll.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import { useConfig } from 'hooks/config'
import { type ModelTreeNode, useModelExplorer } from 'hooks/explorer'
import { chunk } from 'lodash'
import Button from 'primevue/button'
import ConfirmDialog from 'primevue/confirmdialog'
import ContextMenu from 'primevue/contextmenu'
import InputText from 'primevue/inputtext'
import { MenuItem } from 'primevue/menuitem'
import { genModelKey } from 'utils/model'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const gutter = {
x: 4,
y: 32,
}
const {
dataTreeList,
folderPaths,
findFolder,
openFolder,
openModelDetail,
getFullPath,
} = useModelExplorer()
const { cardSize, cardSizeMap, cardSizeFlag, dialog: settings } = useConfig()
const showToolbar = ref(false)
const toggleToolbar = () => {
showToolbar.value = !showToolbar.value
}
const contentContainer = ref<HTMLElement | null>(null)
const contentSize = useElementSize(contentContainer)
const itemSize = computed(() => {
return cardSize.value.height + gutter.y
})
const cols = computed(() => {
const containerWidth = contentSize.width.value + gutter.x
const itemWidth = cardSize.value.width + gutter.x
return Math.floor(containerWidth / itemWidth)
})
const searchContent = ref<string>()
const sortOrder = ref('name')
const sortOrderOptions = ref(
['name', 'size', 'created', 'modified'].map((key) => {
return {
label: t(`sort.${key}`),
value: key,
icon: key === 'name' ? 'pi pi-sort-alpha-down' : 'pi pi-sort-amount-down',
command: () => {
sortOrder.value = key
},
}
}),
)
const currentDataList = computed(() => {
let renderedList = dataTreeList.value
for (const folderItem of folderPaths.value) {
const found = findFolder(renderedList, {
basename: folderItem.name,
pathIndex: folderItem.pathIndex,
})
renderedList = found?.children || []
}
const filter = searchContent.value?.toLowerCase().trim() ?? ''
if (filter) {
const filterItems: ModelTreeNode[] = []
const searchList = [...renderedList]
while (searchList.length) {
const item = searchList.pop()!
const children = (item as any).children ?? []
searchList.push(...children)
const matchSubFolder = `${item.subFolder}/`.toLowerCase().includes(filter)
const matchName = item.basename.toLowerCase().includes(filter)
if (matchSubFolder || matchName) {
filterItems.push(item)
}
}
renderedList = filterItems
}
if (folderPaths.value.length > 1) {
const folderItems: ModelTreeNode[] = []
const modelItems: ModelTreeNode[] = []
for (const item of renderedList) {
if (item.isFolder) {
folderItems.push(item)
} else {
modelItems.push(item)
}
}
folderItems.sort((a, b) => {
return a.basename.localeCompare(b.basename)
})
modelItems.sort((a, b) => {
const sortFieldMap = {
name: 'basename',
size: 'sizeBytes',
created: 'createdAt',
modified: 'updatedAt',
}
const sortField = sortFieldMap[sortOrder.value]
const aValue = a[sortField]
const bValue = b[sortField]
const result =
typeof aValue === 'string'
? aValue.localeCompare(bValue)
: aValue - bValue
return result
})
renderedList = [...folderItems, ...modelItems]
}
return renderedList
})
const renderedList = computed(() => {
return chunk(currentDataList.value, cols.value).map((row) => {
return { key: row.map((o) => o.basename).join('#'), row }
})
})
const cardSizeOptions = computed(() => {
const customSize = 'size.custom'
const customOptionMap = {
...cardSizeMap.value,
[customSize]: 'custom',
}
return Object.keys(customOptionMap).map((key) => {
return {
label: t(key),
value: key,
command: () => {
if (key === customSize) {
settings.showCardSizeSetting()
} else {
cardSizeFlag.value = key
}
},
}
})
})
const menu = ref()
const contextItems = ref<MenuItem[]>([])
const confirmName = ref('')
const openItem = (item: ModelTreeNode, e: Event) => {
menu.value.hide(e)
if (item.isFolder) {
searchContent.value = undefined
openFolder(item)
} else {
openModelDetail(item)
}
}
const openItemContext = (item: ModelTreeNode, e: Event) => {
if (folderPaths.value.length < 2) {
return
}
contextItems.value = [
{
label: t('open'),
icon: 'pi pi-folder-open',
command: () => {
openItem(item, e)
},
},
]
menu.value?.show(e)
}
const nonContextMenu = (e: Event) => {
menu.value.hide(e)
}
const vFocus = {
mounted: (el: HTMLInputElement) => el.focus(),
}
const handleGoBackParentFolder = () => {
folderPaths.value.pop()
}
</script>

View File

@@ -1,65 +1,110 @@
<template>
<div
class="flex h-full flex-col gap-4 overflow-hidden @container/content"
:style="{
['--card-width']: `${cardWidth}px`,
['--gutter']: `${gutter}px`,
}"
v-resize="onContainerResize"
ref="contentContainer"
class="flex h-full flex-col gap-4 overflow-hidden"
>
<div
:class="[
'grid grid-cols-1 justify-center gap-4 px-8',
'@lg/content:grid-cols-[repeat(auto-fit,var(--card-width))]',
'@lg/content:gap-[var(--gutter)]',
'@lg/content:px-4',
]"
class="grid grid-cols-1 justify-center gap-4 px-8"
:style="$content_lg(contentStyle)"
>
<div class="col-span-full @container/toolbar">
<div :class="['flex flex-col gap-4', '@2xl/toolbar:flex-row']">
<ResponseInput
v-model="searchContent"
:placeholder="$t('searchModels')"
:allow-clear="true"
suffix-icon="pi pi-search"
></ResponseInput>
<div ref="toolbarContainer" class="col-span-full">
<div :class="['flex gap-4', $toolbar_2xl('flex-row', 'flex-col')]">
<div class="flex-1">
<ResponseInput
v-model="searchContent"
:placeholder="$t('searchModels')"
:allow-clear="true"
suffix-icon="pi pi-search"
></ResponseInput>
</div>
<div class="flex items-center justify-between gap-4 overflow-hidden">
<ResponseSelect
class="flex-1"
v-model="currentType"
:items="typeOptions"
:type="isMobile ? 'drop' : 'button'"
></ResponseSelect>
<ResponseSelect
class="flex-1"
v-model="sortOrder"
:items="sortOrderOptions"
></ResponseSelect>
<ResponseSelect
class="flex-1"
v-model="cardSizeFlag"
:items="cardSizeOptions"
></ResponseSelect>
</div>
</div>
</div>
</div>
<ResponseScroll
ref="responseScroll"
:items="list"
:itemSize="itemSize"
:row-key="(item) => item.map(genModelKey).join(',')"
class="h-full flex-1"
>
<ResponseScroll :items="list" :itemSize="itemSize" class="h-full flex-1">
<template #item="{ item }">
<div
:class="[
'grid grid-cols-1 justify-center gap-8 px-8',
'@lg/content:grid-cols-[repeat(auto-fit,var(--card-width))]',
'@lg/content:gap-[var(--gutter)]',
'@lg/content:px-4',
]"
class="grid grid-cols-1 justify-center gap-8 px-8"
:style="contentStyle"
>
<ModelCard
v-for="model in item"
v-for="model in item.row"
:key="genModelKey(model)"
:model="model"
></ModelCard>
:style="{
width: `${cardSize.width}px`,
height: `${cardSize.height}px`,
}"
class="group/card cursor-pointer !p-0"
@click="openModelDetail(model)"
v-tooltip.top="{
value: getFullPath(model),
autoHide: false,
showDelay: 800,
hideDelay: 300,
pt: { root: { style: { zIndex: 2100, maxWidth: '32rem' } } },
}"
>
<template #name>
<div
v-show="showModelName"
class="absolute top-0 h-full w-full p-2"
>
<div class="flex h-full flex-col justify-end text-lg">
<div class="line-clamp-3 break-all font-bold text-shadow">
{{ model.basename }}
</div>
</div>
</div>
</template>
<template #extra>
<div
v-show="showModeAction"
class="pointer-events-none absolute right-2 top-2 opacity-0 duration-300 group-hover/card:opacity-100"
>
<div class="flex flex-col gap-2">
<Button
icon="pi pi-plus"
severity="secondary"
rounded
@click.stop="addModelNode(model)"
></Button>
<Button
icon="pi pi-copy"
severity="secondary"
rounded
@click.stop="copyModelNode(model)"
></Button>
<Button
v-show="model.preview"
icon="pi pi-file-import"
severity="secondary"
rounded
@click.stop="loadPreviewWorkflow(model)"
></Button>
</div>
</div>
</template>
</ModelCard>
<div class="col-span-full"></div>
</div>
</template>
@@ -75,31 +120,59 @@
</template>
<script setup lang="ts" name="manager-dialog">
import { useElementSize } from '@vueuse/core'
import ModelCard from 'components/ModelCard.vue'
import ResponseInput from 'components/ResponseInput.vue'
import ResponseScroll from 'components/ResponseScroll.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import { useConfig } from 'hooks/config'
import { useModels } from 'hooks/model'
import { defineResizeCallback } from 'hooks/resize'
import { configSetting, useConfig } from 'hooks/config'
import { useContainerQueries } from 'hooks/container'
import { useModelNodeAction, useModels } from 'hooks/model'
import { chunk } from 'lodash'
import Button from 'primevue/button'
import { app } from 'scripts/comfyAPI'
import { Model } from 'types/typings'
import { genModelKey } from 'utils/model'
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { isMobile, cardWidth, gutter, aspect, modelFolders } = useConfig()
const {
isMobile,
gutter,
cardSize,
cardSizeMap,
cardSizeFlag,
dialog: settings,
} = useConfig()
const { data } = useModels()
const { data, folders, openModelDetail, getFullPath } = useModels()
const { t } = useI18n()
const 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 currentType = ref('all')
const allType = 'All'
const currentType = ref(allType)
const typeOptions = computed(() => {
return ['all', ...Object.keys(modelFolders.value)].map((type) => {
const excludeScanTypes = app.ui?.settings.getSettingValue<string>(
configSetting.excludeScanTypes,
)
const customBlackList =
excludeScanTypes
?.split(',')
.map((type) => type.trim())
.filter(Boolean) ?? []
return [
allType,
...Object.keys(folders.value).filter(
(folder) => !customBlackList.includes(folder),
),
].map((type) => {
return {
label: type,
value: type,
@@ -124,40 +197,50 @@ const sortOrderOptions = ref(
}),
)
watch([searchContent, currentType], () => {
responseScroll.value.init()
})
const itemSize = computed(() => {
let itemWidth = cardWidth
let itemHeight = cardSize.value.height
let itemGutter = gutter
if (isMobile.value) {
const baseSize = 16
itemWidth = window.innerWidth - baseSize * 2 * 2
itemHeight = window.innerWidth - baseSize * 2 * 2
itemGutter = baseSize * 2
}
return itemWidth / aspect + itemGutter
return itemHeight + itemGutter
})
const colSpan = ref(1)
const colSpanWidth = ref(cardWidth)
const { width } = useElementSize(contentContainer)
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 filterList = data.value.filter((model) => {
const showAllModel = currentType.value === 'all'
const mergedList = Object.values(data.value).flat()
const pureModels = mergedList.filter((item) => {
return !item.isFolder
})
const filterList = pureModels.filter((model) => {
const showAllModel = currentType.value === allType
const matchType = showAllModel || model.type === currentType.value
const matchName = model.fullname
.toLowerCase()
.includes(searchContent.value?.toLowerCase() || '')
return matchType && matchName
const filter = searchContent.value?.toLowerCase() ?? ''
const matchSubFolder = model.subFolder.toLowerCase().includes(filter)
const matchName = model.basename.toLowerCase().includes(filter)
return matchType && (matchSubFolder || matchName)
})
let sortStrategy: (a: Model, b: Model) => number = () => 0
switch (sortOrder.value) {
case 'name':
sortStrategy = (a, b) => a.fullname.localeCompare(b.fullname)
sortStrategy = (a, b) => a.basename.localeCompare(b.basename)
break
case 'size':
sortStrategy = (a, b) => b.sizeBytes - a.sizeBytes
@@ -174,17 +257,49 @@ const list = computed(() => {
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 entry = entries[0]
if (isMobile.value) {
colSpan.value = 1
} else {
const containerWidth = entry.contentRect.width
colSpan.value = Math.floor((containerWidth - gutter) / (cardWidth + gutter))
colSpanWidth.value = colSpan.value * (cardWidth + gutter) - gutter
const contentStyle = computed(() => ({
gridTemplateColumns: `repeat(auto-fit, ${cardSize.value.width}px)`,
gap: `${gutter}px`,
paddingLeft: `1rem`,
paddingRight: `1rem`,
}))
const cardSizeOptions = computed(() => {
const customSize = 'size.custom'
const customOptionMap = {
...cardSizeMap.value,
[customSize]: 'custom',
}
return Object.keys(customOptionMap).map((key) => {
return {
label: t(key),
value: key,
command: () => {
if (key === customSize) {
settings.showCardSizeSetting()
} else {
cardSizeFlag.value = key
}
},
}
})
})
const showModelName = computed(() => {
return cardSize.value.width > 120 && cardSize.value.height > 160
})
const showModeAction = computed(() => {
return cardSize.value.width > 120 && cardSize.value.height > 160
})
const { addModelNode, copyModelNode, loadPreviewWorkflow } =
useModelNodeAction()
</script>

View File

@@ -18,12 +18,18 @@
icon="pi pi-eye"
@click="openModelPage(metadata.modelPage)"
></Button>
<Button icon="pi pi-plus" @click.stop="addModelNode"></Button>
<Button icon="pi pi-copy" @click.stop="copyModelNode"></Button>
<Button
icon="pi pi-plus"
@click.stop="addModelNode(model)"
></Button>
<Button
icon="pi pi-copy"
@click.stop="copyModelNode(model)"
></Button>
<Button
v-show="model.preview"
icon="pi pi-file-import"
@click.stop="loadPreviewWorkflow"
@click.stop="loadPreviewWorkflow(model)"
></Button>
<Button
icon="pi pi-pen-to-square"
@@ -44,10 +50,10 @@
<script setup lang="ts">
import ModelContent from 'components/ModelContent.vue'
import ResponseScroll from 'components/ResponseScroll.vue'
import { useModelNodeAction, useModels } from 'hooks/model'
import { genModelUrl, useModelNodeAction, useModels } from 'hooks/model'
import { useRequest } from 'hooks/request'
import Button from 'primevue/button'
import { BaseModel, Model } from 'types/typings'
import { BaseModel, Model, WithResolved } from 'types/typings'
import { computed, ref } from 'vue'
interface Props {
@@ -59,7 +65,7 @@ const { remove, update } = useModels()
const editable = ref(false)
const modelDetailUrl = `/model/${props.model.type}/${props.model.pathIndex}/${props.model.fullname}`
const modelDetailUrl = genModelUrl(props.model)
const { data: extraInfo } = useRequest(modelDetailUrl, {
method: 'GET',
})
@@ -72,7 +78,7 @@ const handleCancel = () => {
editable.value = false
}
const handleSave = async (data: BaseModel) => {
const handleSave = async (data: WithResolved<BaseModel>) => {
await update(modelContent.value, data)
editable.value = false
}
@@ -85,7 +91,6 @@ const openModelPage = (url: string) => {
window.open(url, '_blank')
}
const { addModelNode, copyModelNode, loadPreviewWorkflow } = useModelNodeAction(
props.model,
)
const { addModelNode, copyModelNode, loadPreviewWorkflow } =
useModelNodeAction()
</script>

View File

@@ -11,7 +11,8 @@
:max-width="item.maxWidth"
:min-height="item.minHeight"
:max-height="item.maxHeight"
:z-index="index"
:auto-z-index="false"
:pt:mask:style="{ zIndex: baseZIndex + index + 1 }"
:pt:root:onMousedown="() => rise(item)"
@hide="() => close(item)"
>
@@ -36,12 +37,22 @@
<component :is="item.content" v-bind="item.contentProps"></component>
</template>
</ResponseDialog>
<Dialog :visible="true" :pt:mask:style="{ display: 'none' }"></Dialog>
</template>
<script setup lang="ts">
import ResponseDialog from 'components/ResponseDialog.vue'
import { useDialog } from 'hooks/dialog'
import Button from 'primevue/button'
import { usePrimeVue } from 'primevue/config'
import Dialog from 'primevue/dialog'
import { computed } from 'vue'
const { stack, rise, close } = useDialog()
const { config } = usePrimeVue()
const baseZIndex = computed(() => {
return config.zIndex?.modal ?? 1100
})
</script>

View File

@@ -7,13 +7,60 @@
</template>
</ResponseSelect>
<ResponseSelect class="w-full" v-model="pathIndex" :items="pathOptions">
</ResponseSelect>
<div class="flex gap-2 overflow-hidden">
<div class="flex-1 overflow-hidden rounded bg-gray-500/30">
<div class="flex h-full items-center justify-end">
<span class="overflow-hidden text-ellipsis whitespace-nowrap px-2">
{{ renderedModelFolder }}
</span>
</div>
</div>
<Button
icon="pi pi-folder"
:disabled="!type"
@click="handleSelectFolder"
></Button>
<Dialog
v-model:visible="folderSelectVisible"
:header="$t('folder')"
:auto-z-index="false"
:pt:mask:style="{ zIndex }"
:pt:root:style="{ height: '50vh', maxWidth: '50vw' }"
pt:content:class="flex-1"
>
<div class="flex h-full flex-col overflow-hidden">
<div class="flex-1 overflow-hidden">
<ResponseScroll>
<Tree
class="h-full"
v-model:selection-keys="modelFolder"
:value="pathOptions"
selectionMode="single"
:pt:nodeLabel:class="'text-ellipsis overflow-hidden'"
></Tree>
</ResponseScroll>
</div>
<div class="flex justify-end gap-2">
<Button
:label="$t('cancel')"
severity="secondary"
@click="handleCancelSelectFolder"
></Button>
<Button
:label="$t('select')"
@click="handleConfirmSelectFolder"
></Button>
</div>
</div>
</Dialog>
</div>
<ResponseInput
v-model.trim="basename"
v-model.trim.valid="basename"
class="-mr-2 text-right"
update-trigger="blur"
:validate="validateBasename"
>
<template #suffix>
<span class="text-base opacity-60">
@@ -37,7 +84,17 @@
<td class="border-r bg-gray-300 px-4 dark:bg-gray-800">
{{ $t(`info.${item.key}`) }}
</td>
<td class="overflow-hidden text-ellipsis break-all px-4">
<td
class="overflow-hidden text-ellipsis break-all px-4"
v-tooltip.top="{
value: item.display,
disabled: !['pathIndex', 'basename'].includes(item.key),
autoHide: false,
showDelay: 800,
hideDelay: 300,
pt: { root: { style: { zIndex: 2100, maxWidth: '32rem' } } },
}"
>
{{ item.display }}
</td>
</tr>
@@ -48,16 +105,34 @@
<script setup lang="ts">
import ResponseInput from 'components/ResponseInput.vue'
import ResponseScroll from 'components/ResponseScroll.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import { useConfig } from 'hooks/config'
import { useModelBaseInfo } from 'hooks/model'
import { computed } from 'vue'
import { useDialog } from 'hooks/dialog'
import { useModelBaseInfo, useModelFolder } from 'hooks/model'
import { useToast } from 'hooks/toast'
import Button from 'primevue/button'
import { usePrimeVue } from 'primevue/config'
import Dialog from 'primevue/dialog'
import Tree from 'primevue/tree'
import { computed, ref, watch } from 'vue'
const editable = defineModel<boolean>('editable')
const { modelFolders } = useConfig()
const { toast } = useToast()
const { baseInfo, pathIndex, basename, extension, type } = useModelBaseInfo()
const {
baseInfo,
pathIndex,
subFolder,
basename,
extension,
type,
modelFolders,
} = useModelBaseInfo()
watch(type, () => {
subFolder.value = ''
})
const typeOptions = computed(() => {
return Object.keys(modelFolders.value).map((curr) => {
@@ -72,25 +147,104 @@ const typeOptions = computed(() => {
})
})
const pathOptions = computed(() => {
return (modelFolders.value[type.value] ?? []).map((folder, index) => {
return {
value: index,
label: folder,
command: () => {
pathIndex.value = index
},
}
})
})
const information = computed(() => {
return Object.values(baseInfo.value).filter((row) => {
if (editable.value) {
const hiddenKeys = ['fullname', 'pathIndex']
const hiddenKeys = ['basename', 'pathIndex']
return !hiddenKeys.includes(row.key)
}
return true
})
})
const validateBasename = (val: string | undefined) => {
if (!val) {
toast.add({
severity: 'error',
detail: 'basename is required',
life: 3000,
})
return false
}
const invalidChart = /[\\/:*?"<>|]/
if (invalidChart.test(val)) {
toast.add({
severity: 'error',
detail: 'basename is invalid, \\/:*?"<>|',
life: 3000,
})
return false
}
return true
}
const folderSelectVisible = ref(false)
const { stack } = useDialog()
const { config } = usePrimeVue()
const zIndex = computed(() => {
const baseZIndex = config.zIndex?.modal ?? 1100
return baseZIndex + stack.value.length + 1
})
const handleSelectFolder = () => {
if (!type.value) {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Please select model type first',
life: 5000,
})
return
}
folderSelectVisible.value = true
}
const { pathOptions } = useModelFolder({ type })
const selectedModelFolder = ref<string>()
const modelFolder = computed({
get: () => {
const folderPath = baseInfo.value.pathIndex.display
const selectedKey = selectedModelFolder.value ?? folderPath
return { [selectedKey]: true }
},
set: (val) => {
const folderPath = Object.keys(val)[0]
selectedModelFolder.value = folderPath
},
})
const renderedModelFolder = computed(() => {
return baseInfo.value.pathIndex?.display
})
const handleCancelSelectFolder = () => {
selectedModelFolder.value = undefined
folderSelectVisible.value = false
}
const handleConfirmSelectFolder = () => {
const folderPath = Object.keys(modelFolder.value)[0]
const folders = modelFolders.value[type.value]
pathIndex.value = folders.findIndex((item) => folderPath.includes(item))
if (pathIndex.value < 0) {
toast.add({
severity: 'error',
detail: 'Folder not found',
life: 3000,
})
return
}
const prefixPath = folders[pathIndex.value]
subFolder.value = folderPath.replace(prefixPath, '')
if (subFolder.value.startsWith('/')) {
subFolder.value = subFolder.value.replace('/', '')
}
selectedModelFolder.value = undefined
folderSelectVisible.value = false
}
</script>

View File

@@ -1,104 +1,94 @@
<template>
<div
class="group/card relative w-full cursor-pointer select-none preview-aspect"
@click.stop="openDetailDialog"
ref="container"
class="relative h-full select-none rounded-lg hover:bg-gray-500/40"
>
<div class="h-full overflow-hidden rounded-lg">
<div class="h-full bg-gray-500 duration-300 group-hover/card:scale-110">
<img class="h-full w-full object-cover" :src="preview" />
<div data-card-main class="flex h-full w-full flex-col">
<div data-card-preview class="flex-1 overflow-hidden">
<div v-if="model.isFolder" class="h-full w-full">
<svg
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
>
<path
d="M853.333333 256H469.333333l-85.333333-85.333333H170.666667c-46.933333 0-85.333333 38.4-85.333334 85.333333v170.666667h853.333334v-85.333334c0-46.933333-38.4-85.333333-85.333334-85.333333z"
fill="#FFA000"
></path>
<path
d="M853.333333 256H170.666667c-46.933333 0-85.333333 38.4-85.333334 85.333333v426.666667c0 46.933333 38.4 85.333333 85.333334 85.333333h682.666666c46.933333 0 85.333333-38.4 85.333334-85.333333V341.333333c0-46.933333-38.4-85.333333-85.333334-85.333333z"
fill="#FFCA28"
></path>
</svg>
</div>
<div v-else class="h-full w-full p-1 hover:p-0">
<img class="h-full w-full rounded-lg object-cover" :src="preview" />
</div>
</div>
<slot name="name">
<div class="flex justify-center overflow-hidden px-1">
<span class="overflow-hidden text-ellipsis whitespace-nowrap">
{{ model.basename }}
</span>
</div>
</slot>
</div>
<div
v-if="!model.isFolder"
data-draggable-overlay
class="absolute left-0 top-0 h-full w-full"
draggable="true"
@dragend.stop="dragToAddModelNode"
@dragend.stop="dragToAddModelNode(model, $event)"
></div>
<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="absolute bottom-0 left-0">
<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">
{{ model.basename }}
</div>
</div>
</div>
<div class="absolute left-0 top-0 w-full">
<div class="flex flex-row items-start justify-between">
<div class="flex items-center rounded-full bg-black/30 px-3 py-2">
<div class="font-bold @lg:text-xs">
{{ model.type }}
</div>
</div>
<div class="opacity-0 duration-300 group-hover/card:opacity-100">
<div class="flex flex-col gap-4 *:pointer-events-auto">
<Button
icon="pi pi-plus"
severity="secondary"
rounded
@click.stop="addModelNode"
></Button>
<Button
icon="pi pi-copy"
severity="secondary"
rounded
@click.stop="copyModelNode"
></Button>
<Button
v-show="model.preview"
icon="pi pi-file-import"
severity="secondary"
rounded
@click.stop="loadPreviewWorkflow"
></Button>
</div>
</div>
</div>
</div>
<div
v-if="!model.isFolder"
data-mode-type
class="pointer-events-none absolute left-2 top-2"
:style="{
transform: `scale(${typeLabelScale})`,
transformOrigin: 'left top',
}"
>
<div class="rounded-full bg-black/50 px-3 py-1">
<span>{{ model.type }}</span>
</div>
</div>
<slot name="extra"></slot>
</div>
</template>
<script setup lang="ts">
import DialogModelDetail from 'components/DialogModelDetail.vue'
import { useDialog } from 'hooks/dialog'
import { useElementSize } from '@vueuse/core'
import { useModelNodeAction } from 'hooks/model'
import Button from 'primevue/button'
import { Model } from 'types/typings'
import { genModelKey } from 'utils/model'
import { computed } from 'vue'
import { BaseModel } from 'types/typings'
import { computed, ref } from 'vue'
interface Props {
model: Model
model: BaseModel
}
const props = defineProps<Props>()
const dialog = useDialog()
const openDetailDialog = () => {
const basename = props.model.fullname.split('/').pop()!
const filename = basename.replace(props.model.extension, '')
dialog.open({
key: genModelKey(props.model),
title: filename,
content: DialogModelDetail,
contentProps: { model: props.model },
})
}
const preview = computed(() =>
Array.isArray(props.model.preview)
? props.model.preview[0]
: props.model.preview,
)
const { addModelNode, dragToAddModelNode, copyModelNode, loadPreviewWorkflow } =
useModelNodeAction(props.model)
const container = ref<HTMLElement | null>(null)
const { width } = useElementSize(container)
const typeLabelScale = computed(() => {
return width.value / 200
})
const { dragToAddModelNode } = useModelNodeAction()
</script>

View File

@@ -1,11 +1,16 @@
<template>
<form
class="@container"
ref="container"
@submit.prevent="handleSubmit"
@reset.prevent="handleReset"
>
<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
class="shrink-0"
v-model:editable="editable"
@@ -43,6 +48,7 @@ import ModelBaseInfo from 'components/ModelBaseInfo.vue'
import ModelDescription from 'components/ModelDescription.vue'
import ModelMetadata from 'components/ModelMetadata.vue'
import ModelPreview from 'components/ModelPreview.vue'
import { useContainerQueries } from 'hooks/container'
import {
useModelBaseInfoEditor,
useModelDescriptionEditor,
@@ -56,8 +62,8 @@ import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { BaseModel } from 'types/typings'
import { toRaw, watch } from 'vue'
import { BaseModel, WithResolved } from 'types/typings'
import { ref, toRaw, watch } from 'vue'
interface Props {
model: BaseModel
@@ -67,7 +73,7 @@ const props = defineProps<Props>()
const editable = defineModel<boolean>('editable')
const emits = defineEmits<{
submit: [formData: BaseModel]
submit: [formData: WithResolved<BaseModel>]
reset: []
}>()
@@ -94,4 +100,7 @@ watch(
handleReset()
},
)
const container = ref<HTMLElement | null>(null)
const { $xl } = useContainerQueries(container)
</script>

View File

@@ -1,15 +1,9 @@
<template>
<div
class="flex flex-col gap-4"
:style="{ ['--preview-width']: `${cardWidth}px` }"
>
<div class="flex flex-col gap-4">
<div>
<div
:class="[
'relative mx-auto w-full',
'@sm:w-[var(--preview-width)]',
'overflow-hidden rounded-lg preview-aspect',
]"
class="relative mx-auto w-full overflow-hidden rounded-lg preview-aspect"
:style="$sm({ width: `${cardWidth}px` })"
>
<ResponseImage :src="preview" :error="noPreviewContent"></ResponseImage>
@@ -50,9 +44,8 @@
<div class="h-10"></div>
<div
:class="[
'flex h-10 items-center gap-4',
'absolute left-1/2 -translate-x-1/2',
'@xl:left-0 @xl:translate-x-0',
'absolute flex h-10 items-center gap-4',
$xl('left-0 translate-x-0', 'left-1/2 -translate-x-1/2'),
]"
>
<Button
@@ -92,6 +85,7 @@ import ResponseFileUpload from 'components/ResponseFileUpload.vue'
import ResponseImage from 'components/ResponseImage.vue'
import ResponseInput from 'components/ResponseInput.vue'
import { useConfig } from 'hooks/config'
import { useContainerQueries } from 'hooks/container'
import { useModelPreview } from 'hooks/model'
import Button from 'primevue/button'
import Carousel from 'primevue/carousel'
@@ -109,4 +103,6 @@ const {
updateLocalContent,
noPreviewContent,
} = useModelPreview()
const { $sm, $xl } = useContainerQueries()
</script>

View File

@@ -0,0 +1,163 @@
<template>
<div ref="container" class="breadcrumb-container">
<div v-if="firstItem" class="breadcrumb-item">
<span class="breadcrumb-label" @click="firstItem.onClick">
<i v-if="firstItem.icon" :class="firstItem.icon"></i>
<i v-else class="breadcrumb-name">{{ firstItem.name }}</i>
</span>
<ResponseSelect
v-if="!!firstItem.children?.length"
:items="firstItem.children"
>
<template #target="{ toggle, overlayVisible }">
<span class="breadcrumb-split" @click="toggle">
<i
class="pi pi-angle-right transition-all"
:style="{ transform: overlayVisible ? 'rotate(90deg)' : '' }"
></i>
</span>
</template>
</ResponseSelect>
</div>
<div v-if="!!renderedItems.collapsed.length" class="breadcrumb-item">
<ResponseSelect :items="renderedItems.collapsed">
<template #target="{ toggle }">
<span class="breadcrumb-split" @click="toggle">
<i class="pi pi-ellipsis-h"></i>
</span>
</template>
</ResponseSelect>
</div>
<div
v-for="(item, index) in renderedItems.tail"
:key="`${index}-${item.name}`"
class="breadcrumb-item"
>
<span class="breadcrumb-label" @click="item.onClick">
<i v-if="item.icon" :class="item.icon"></i>
<i v-else class="breadcrumb-name">{{ item.name }}</i>
</span>
<ResponseSelect v-if="!!item.children?.length" :items="item.children">
<template #target="{ toggle, overlayVisible }">
<span class="breadcrumb-split" @click="toggle">
<i
class="pi pi-angle-right transition-all"
:style="{ transform: overlayVisible ? 'rotate(90deg)' : '' }"
></i>
</span>
</template>
</ResponseSelect>
</div>
</div>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import ResponseSelect from 'components/ResponseSelect.vue'
import { SelectOptions } from 'types/typings'
import { computed, ref } from 'vue'
interface BreadcrumbItem {
name: string
icon?: string
onClick?: () => void
children?: SelectOptions[]
}
interface Props {
items: BreadcrumbItem[]
}
const props = defineProps<Props>()
const container = ref<HTMLElement | null>(null)
const { width } = useElementSize(container)
const firstItem = computed<BreadcrumbItem | null>(() => {
return props.items[0]
})
const renderedItems = computed(() => {
const [, ...items] = props.items
const lastItem = items.pop()
items.reverse()
const separatorWidth = 32
const calculateItemWidth = (item: BreadcrumbItem | undefined) => {
if (!item) {
return 0
}
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')!
context.font = '16px Arial'
const text = item.name
return context.measureText(text).width + 16 + separatorWidth
}
const firstItemEL = container.value?.querySelector('div')
const firstItemWidth = firstItemEL?.getBoundingClientRect().width ?? 0
const lastItemWidth = calculateItemWidth(lastItem)
const collapseWidth = separatorWidth
let totalWidth = firstItemWidth + collapseWidth + lastItemWidth
const containerWidth = width.value - 18
const collapsed: SelectOptions[] = []
const tail: BreadcrumbItem[] = []
for (const item of items) {
const itemWidth = calculateItemWidth(item)
totalWidth += itemWidth
if (totalWidth < containerWidth) {
tail.unshift(item)
} else {
collapsed.unshift({
value: item.name,
label: item.name,
command: () => {
item.onClick?.()
},
})
}
}
if (lastItem) {
tail.push(lastItem)
}
return { collapsed, tail }
})
</script>
<style scoped>
.breadcrumb-container {
@apply flex overflow-hidden rounded-lg bg-gray-500/30 px-2 py-1;
}
.breadcrumb-item {
@apply flex h-full overflow-hidden rounded border border-transparent hover:border-gray-500/30;
}
.breadcrumb-item:nth-of-type(-n + 2) {
@apply flex-shrink-0;
}
.breadcrumb-label {
@apply flex h-full min-w-8 items-center overflow-hidden px-2 hover:bg-gray-500/30;
}
.breadcrumb-name {
@apply overflow-hidden text-ellipsis whitespace-nowrap not-italic;
}
.breadcrumb-split {
@apply flex aspect-square h-full min-w-8 items-center justify-center hover:bg-gray-500/30;
}
</style>

View File

@@ -9,7 +9,7 @@
minimizeIcon="pi pi-arrow-down-left-and-arrow-up-right-to-center"
:pt:mask:class="['group', { open: visible }]"
:pt:root:class="['max-h-full group-[:not(.open)]:!hidden', $style.dialog]"
pt:content:class="px-0 flex-1"
pt:content:class="p-0 flex-1"
:base-z-index="1000"
:auto-z-index="isNil(zIndex)"
:pt:mask:style="isNil(zIndex) ? {} : { zIndex: 1000 + zIndex }"

View File

@@ -14,7 +14,7 @@
<input
ref="inputRef"
v-model="innerValue"
v-model="inputValue"
class="flex-1 border-none bg-transparent text-base outline-none"
type="text"
:placeholder="placeholder"
@@ -47,22 +47,40 @@ interface Props {
placeholder?: string
allowClear?: boolean
updateTrigger?: string
validate?: (value: string | undefined) => boolean
}
const props = defineProps<Props>()
const [content, modifiers] = defineModel<string, 'trim'>()
const [content, modifiers] = defineModel<string, 'trim' | 'valid'>()
const inputRef = ref()
const innerValue = ref(content)
const innerValue = ref<string>()
const inputValue = computed({
get: () => {
return innerValue.value ?? content.value
},
set: (val) => {
innerValue.value = val
},
})
const trigger = computed(() => props.updateTrigger ?? 'change')
const updateContent = () => {
let value = innerValue.value
let value = inputValue.value
if (modifiers.trim) {
value = innerValue.value?.trim()
value = value?.trim()
}
if (modifiers.valid) {
const isValid = props.validate?.(value) ?? true
if (!isValid) {
innerValue.value = content.value
return
}
}
innerValue.value = undefined
content.value = value
inputRef.value.value = value
}

View File

@@ -1,316 +1,140 @@
<template>
<div data-scroll-area class="group/scroll relative overflow-hidden">
<div
ref="viewport"
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">
<div class="group/scroll relative h-full overflow-hidden">
<div ref="viewport" class="h-full w-full overflow-auto scrollbar-none">
<div ref="content">
<slot name="default">
<div
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">
<slot v-if="renderedItems.length === 0" name="empty">
<div class="absolute w-full py-20 text-center">No Data</div>
</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>
</div>
<div
data-scroll-space
class="pointer-events-none absolute left-0 top-0 h-px w-px"
:style="spaceStyle"
></div>
</div>
<div
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 ref="scroll" class="absolute right-0 top-0 h-full w-2">
<div
v-bind="{ ['data-scroll-thumb']: scroll.direction }"
ref="thumb"
:class="[
'pointer-events-auto absolute h-full w-full rounded-full',
'cursor-pointer bg-black dark:bg-white',
'opacity-0 transition-opacity duration-300 group-hover/scroll:opacity-10',
'absolute w-full cursor-pointer rounded-full bg-gray-500',
'opacity-0 transition-opacity duration-300 group-hover/scroll:opacity-30',
]"
:style="{
[scrollbarAttrs[scroll.direction].size]: `${scroll.size}px`,
[scrollbarAttrs[scroll.direction].offset]: `${scroll.offset}px`,
opacity: isDragging ? 0.1 : '',
height: `${thumbSize}px`,
top: `${thumbOffset}px`,
opacity: isDragging ? '0.3' : undefined,
}"
@mousedown="startDragThumb"
></div>
</div>
</div>
</template>
<script setup lang="ts" generic="T">
import { defineResizeCallback } from 'hooks/resize'
import { clamp, throttle } from 'lodash'
import { nextTick, onUnmounted, ref, watch } from 'vue'
<script setup lang="ts" generic="T extends { key: string }">
import { useDraggable, useElementSize, useScroll } from '@vueuse/core'
import { clamp } from 'lodash'
import { computed, ref } from 'vue'
interface ScrollAreaProps {
items?: T[][]
items?: T[]
itemSize?: number
scrollbar?: boolean
rowKey?: string | ((item: T[]) => string)
}
const props = withDefaults(defineProps<ScrollAreaProps>(), {
scrollbar: true,
})
const emit = defineEmits(['scroll', 'resize'])
const props = defineProps<ScrollAreaProps>()
type ScrollbarDirection = 'horizontal' | 'vertical'
const viewport = ref<HTMLElement | null>(null)
const content = ref<HTMLElement | null>(null)
interface Scrollbar {
direction: ScrollbarDirection
visible: boolean
size: number
offset: number
}
const { height: viewportHeight } = useElementSize(viewport)
const { height: contentHeight } = useElementSize(content)
const { y: scrollY } = useScroll(viewport)
interface ScrollbarAttribute {
clientSize: string
scrollOffset: string
pagePosition: string
offset: string
size: string
}
const itemSize = computed(() => props.itemSize || 0)
const scrollbarAttrs: Record<ScrollbarDirection, ScrollbarAttribute> = {
horizontal: {
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 viewRows = computed(() =>
Math.ceil(viewportHeight.value / itemSize.value),
)
const offsetRows = computed(() => Math.floor(scrollY.value / itemSize.value))
onUnmounted(() => {
stopMoveThumb()
const items = computed(() => {
return props.items ?? []
})
defineExpose({
viewport,
init,
const state = computed(() => {
const bufferRows = viewRows.value
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>

View File

@@ -2,7 +2,14 @@
<slot
v-if="type === 'drop'"
name="target"
v-bind="{ toggle, prefixIcon, suffixIcon, currentLabel, current }"
v-bind="{
toggle,
prefixIcon,
suffixIcon,
currentLabel,
current,
overlayVisible,
}"
>
<div :class="['-my-1 py-1', $attrs.class]" @click="toggle">
<Button
@@ -27,12 +34,7 @@
</slot>
<div v-else class="relative flex-1 overflow-hidden">
<div
ref="scrollArea"
class="h-full w-full overflow-auto scrollbar-none"
v-resize="checkScrollPosition"
@scroll="checkScrollPosition"
>
<div ref="scrollArea" class="h-full w-full overflow-auto scrollbar-none">
<div ref="contentArea" class="table max-w-full">
<div
v-show="showControlButton && scrollPosition !== 'left'"
@@ -130,7 +132,13 @@
<slot v-else name="desktop">
<slot name="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 }">
<slot name="item" :item="item">
<slot name="desktop:container:item" :item="item">
@@ -150,12 +158,13 @@
</template>
<script setup lang="ts">
import { useElementSize, useScroll } from '@vueuse/core'
import { useConfig } from 'hooks/config'
import Button, { ButtonProps } from 'primevue/button'
import Drawer from 'primevue/drawer'
import Menu from 'primevue/menu'
import { SelectOptions } from 'types/typings'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
const current = defineModel()
@@ -195,8 +204,12 @@ const toggle = (event: MouseEvent) => {
}
}
const overlayVisible = computed(() => {
return isMobile.value ? visible.value : (menu.value?.overlayVisible ?? false)
})
// Select Button Type
const scrollArea = ref()
const scrollArea = ref<HTMLElement | null>(null)
const contentArea = ref()
type ScrollPosition = 'left' | 'right'
@@ -236,4 +249,16 @@ const checkScrollPosition = () => {
scrollPosition.value = position
showControlButton.value = contentWidth > containerWidth
}
const { width, height } = useElementSize(scrollArea)
watch([width, height], () => {
checkScrollPosition()
})
useScroll(scrollArea, {
onScroll: () => {
checkScrollPosition()
},
})
</script>

View 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>

View File

@@ -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 { $el, app, ComfyDialog } from 'scripts/comfyAPI'
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, readonly, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from './toast'
export const useConfig = defineStore('config', (store) => {
const { t } = useI18n()
const mobileDeviceBreakPoint = 759
const isMobile = ref(window.innerWidth < mobileDeviceBreakPoint)
type ModelFolder = Record<string, string[]>
const { data: modelFolders, refresh: refreshModelFolders } =
useRequest<ModelFolder>('/base-folders')
const checkDeviceType = () => {
isMobile.value = window.innerWidth < mobileDeviceBreakPoint
}
@@ -24,19 +24,62 @@ export const useConfig = defineStore('config', (store) => {
window.removeEventListener('resize', checkDeviceType)
})
const refresh = async () => {
return Promise.all([refreshModelFolders()])
}
const flatLayout = ref(false)
const defaultCardSizeMap = readonly({
'size.extraLarge': '240x320',
'size.large': '180x240',
'size.medium': '120x160',
'size.small': '80x120',
})
const cardSizeMap = ref<Record<string, string>>({ ...defaultCardSizeMap })
const cardSizeFlag = ref('size.extraLarge')
const cardSize = computed(() => {
const size = cardSizeMap.value[cardSizeFlag.value]
const [width = '120', height = '240'] = size.split('x')
return {
width: parseInt(width),
height: parseInt(height),
}
})
const config = {
isMobile,
gutter: 16,
defaultCardSizeMap: defaultCardSizeMap,
cardSizeMap: cardSizeMap,
cardSizeFlag: cardSizeFlag,
cardSize: cardSize,
cardWidth: 240,
aspect: 7 / 9,
modelFolders,
refresh,
dialog: {
showCardSizeSetting: () => {
store.dialog.open({
key: 'setting.cardSize',
title: t('setting.cardSize'),
content: SettingCardSize,
defaultSize: {
width: 500,
height: 390,
},
})
},
},
flat: flatLayout,
}
watch(cardSizeFlag, (val) => {
app.ui?.settings.setSettingValue('ModelManager.UI.CardSize', val)
})
watch(cardSizeMap, (val) => {
app.ui?.settings.setSettingValue(
'ModelManager.UI.CardSizeMap',
JSON.stringify(val),
)
})
useAddConfigSettings(store)
return config
@@ -50,8 +93,13 @@ declare module 'hooks/store' {
}
}
export const configSetting = {
excludeScanTypes: 'ModelManager.Scan.excludeScanTypes',
}
function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
const { toast } = useToast()
const { t } = useI18n()
const confirm = (opts: {
message?: string
@@ -89,6 +137,7 @@ function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
// API keys
app.ui?.settings.addSetting({
id: 'ModelManager.APIKey.HuggingFace',
category: [t('modelManager'), t('setting.apiKey'), 'HuggingFace'],
name: 'HuggingFace API Key',
type: 'text',
defaultValue: undefined,
@@ -96,61 +145,53 @@ function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
app.ui?.settings.addSetting({
id: 'ModelManager.APIKey.Civitai',
category: [t('modelManager'), t('setting.apiKey'), 'Civitai'],
name: 'Civitai API Key',
type: 'text',
defaultValue: undefined,
})
// Migrate
const defaultCardSize = store.config.defaultCardSizeMap
app.ui?.settings.addSetting({
id: 'ModelManager.Migrate.Migrate',
name: 'Migrate information from cdb-boop/main',
defaultValue: '',
type: () => {
return $el('button.p-button.p-component.p-button-secondary', {
textContent: 'Migrate',
onclick: () => {
confirm({
message: [
'This operation will delete old files and override current files if it exists.',
// 'This may take a while and generate MANY server requests!',
'Continue?',
].join('\n'),
accept: () => {
store.loading.loading.value = true
request('/migrate', {
method: 'POST',
})
.then(() => {
toast.add({
severity: 'success',
summary: 'Complete migration',
life: 2000,
})
store.models.refresh()
})
.catch((err) => {
toast.add({
severity: 'error',
summary: 'Error',
detail: err.message ?? 'Failed to migrate information',
life: 15000,
})
})
.finally(() => {
store.loading.loading.value = false
})
},
})
},
})
id: 'ModelManager.UI.CardSize',
category: [t('modelManager'), t('setting.ui'), 'CardSize'],
name: t('setting.cardSize'),
defaultValue: 'size.extraLarge',
type: 'hidden',
onChange: (val) => {
store.config.cardSizeFlag.value = val
},
})
app.ui?.settings.addSetting({
id: 'ModelManager.UI.CardSizeMap',
category: [t('modelManager'), t('setting.ui'), 'CardSizeMap'],
name: t('setting.cardSize'),
defaultValue: JSON.stringify(defaultCardSize),
type: 'hidden',
onChange(value) {
store.config.cardSizeMap.value = JSON.parse(value)
},
})
app.ui?.settings.addSetting({
id: 'ModelManager.UI.Flat',
category: [t('modelManager'), t('setting.ui'), 'Flat'],
name: t('setting.useFlatUI'),
type: 'boolean',
defaultValue: false,
onChange(value) {
store.dialog.closeAll()
store.config.flat.value = value
},
})
// Scan information
app.ui?.settings.addSetting({
id: 'ModelManager.ScanFiles.Full',
name: "Override all models' information and preview",
category: [t('modelManager'), t('setting.scan'), 'Full'],
name: t('setting.scanAll'),
defaultValue: '',
type: () => {
return $el('button.p-button.p-component.p-button-secondary', {
@@ -196,7 +237,8 @@ function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
app.ui?.settings.addSetting({
id: 'ModelManager.ScanFiles.Incremental',
name: 'Download missing information or preview',
category: [t('modelManager'), t('setting.scan'), 'Incremental'],
name: t('setting.scanMissing'),
defaultValue: '',
type: () => {
return $el('button.p-button.p-component.p-button-secondary', {
@@ -240,9 +282,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({
id: 'ModelManager.Scan.IncludeHiddenFiles',
name: 'Include hidden files(start with .)',
category: [t('modelManager'), t('setting.scan'), 'IncludeHiddenFiles'],
name: t('setting.includeHiddenFiles'),
defaultValue: false,
type: 'boolean',
})

41
src/hooks/container.ts Normal file
View 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),
}
}

View File

@@ -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)
if (item?.keepAlive) {
item.visible = false
@@ -58,7 +63,11 @@ export const useDialog = defineStore('dialog', () => {
}
}
return { stack, open, close, rise }
const closeAll = () => {
stack.value = []
}
return { stack, open, close, closeAll, rise }
})
declare module 'hooks/store' {

View File

@@ -15,7 +15,7 @@ import { onBeforeMount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
export const useDownload = defineStore('download', (store) => {
const { toast, confirm } = useToast()
const { toast, confirm, wrapperToastError } = useToast()
const { t } = useI18n()
const taskList = ref<DownloadTask[]>([])
@@ -29,20 +29,24 @@ export const useDownload = defineStore('download', (store) => {
downloadProgress: `${bytesToSize(downloadedSize)} / ${bytesToSize(totalSize)}`,
downloadSpeed: `${bytesToSize(bps)}/s`,
pauseTask() {
request(`/download/${item.taskId}`, {
method: 'PUT',
body: JSON.stringify({
status: 'pause',
wrapperToastError(async () =>
request(`/download/${item.taskId}`, {
method: 'PUT',
body: JSON.stringify({
status: 'pause',
}),
}),
})
)()
},
resumeTask: () => {
request(`/download/${item.taskId}`, {
method: 'PUT',
body: JSON.stringify({
status: 'resume',
wrapperToastError(async () =>
request(`/download/${item.taskId}`, {
method: 'PUT',
body: JSON.stringify({
status: 'resume',
}),
}),
})
)()
},
deleteTask: () => {
confirm.require({
@@ -59,9 +63,11 @@ export const useDownload = defineStore('download', (store) => {
severity: 'danger',
},
accept: () => {
request(`/download/${item.taskId}`, {
method: 'DELETE',
})
wrapperToastError(async () =>
request(`/download/${item.taskId}`, {
method: 'DELETE',
}),
)()
},
reject: () => {},
})
@@ -71,21 +77,12 @@ export const useDownload = defineStore('download', (store) => {
return task
}
const refresh = async () => {
return request('/download/task')
.then((resData: DownloadTaskOptions[]) => {
taskList.value = resData.map((item) => createTaskItem(item))
return taskList.value
})
.catch((err) => {
toast.add({
severity: 'error',
summary: 'Error',
detail: err.message ?? 'Failed to refresh download task list',
life: 15000,
})
})
}
const refresh = wrapperToastError(async () => {
return request('/download/task').then((resData: DownloadTaskOptions[]) => {
taskList.value = resData.map((item) => createTaskItem(item))
return taskList.value
})
})
onBeforeMount(() => {
api.addEventListener('reconnected', () => {

170
src/hooks/explorer.ts Normal file
View File

@@ -0,0 +1,170 @@
import { genModelFullName, useModels } from 'hooks/model'
import { cloneDeep, filter, find } from 'lodash'
import { BaseModel, Model, SelectOptions } from 'types/typings'
import { computed, ref, watch } from 'vue'
export interface FolderPathItem {
name: string
pathIndex: number
icon?: string
onClick: () => void
children: SelectOptions[]
}
export type ModelFolder = BaseModel & {
children: ModelTreeNode[]
}
export type ModelItem = Model
export type ModelTreeNode = BaseModel & {
children?: ModelTreeNode[]
}
export type TreeItemNode = ModelTreeNode & {
onDbClick: () => void
onContextMenu: () => void
}
export const useModelExplorer = () => {
const { data, folders, initialized, ...modelRest } = useModels()
const folderPaths = ref<FolderPathItem[]>([])
const genFolderItem = (
basename: string,
folder?: string,
subFolder?: string,
): ModelFolder => {
return {
id: basename,
basename: basename,
subFolder: subFolder ?? '',
pathIndex: 0,
sizeBytes: 0,
extension: '',
description: '',
metadata: {},
preview: '',
type: folder ?? '',
isFolder: true,
children: [],
}
}
const dataTreeList = computed<ModelTreeNode[]>(() => {
const rootChildren: ModelTreeNode[] = []
for (const folder in folders.value) {
if (Object.prototype.hasOwnProperty.call(folders.value, folder)) {
const folderItem = genFolderItem(folder)
const folderModels = cloneDeep(data.value[folder]) ?? []
const pathMap: Record<string, ModelTreeNode> = Object.fromEntries(
folderModels.map((item) => [
`${item.pathIndex}-${genModelFullName(item)}`,
item,
]),
)
for (const item of folderModels) {
const key = genModelFullName(item)
const parentKey = key.split('/').slice(0, -1).join('/')
if (parentKey === '') {
folderItem.children.push(item)
continue
}
const parentItem = pathMap[`${item.pathIndex}-${parentKey}`]
if (parentItem) {
parentItem.children ??= []
parentItem.children.push(item)
}
}
rootChildren.push(folderItem)
}
}
const root: ModelTreeNode = genFolderItem('root')
root.children = rootChildren
return [root]
})
function findFolder(
list: ModelTreeNode[],
feature: { basename: string; pathIndex: number },
) {
return find(list, { ...feature, isFolder: true }) as ModelFolder | undefined
}
function findFolders(list: ModelTreeNode[]) {
return filter(list, { isFolder: true }) as ModelFolder[]
}
async function openFolder(item: BaseModel) {
const folderItems: FolderPathItem[] = []
const folder = item.type
const subFolderParts = item.subFolder.split('/').filter(Boolean)
const pathParts: string[] = []
if (folder) {
pathParts.push(folder, ...subFolderParts)
}
pathParts.push(item.basename)
if (pathParts[0] !== 'root') {
pathParts.unshift('root')
}
let levelFolders = findFolders(dataTreeList.value)
for (const [index, part] of pathParts.entries()) {
const pathIndex = index < 2 ? 0 : item.pathIndex
const currentFolder = findFolder(levelFolders, {
basename: part,
pathIndex: pathIndex,
})
if (!currentFolder) {
break
}
levelFolders = findFolders(currentFolder.children ?? [])
folderItems.push({
name: currentFolder.basename,
pathIndex: pathIndex,
icon: index === 0 ? 'pi pi-desktop' : '',
onClick: () => {
openFolder(currentFolder)
},
children: levelFolders.map((child) => {
const name = child.basename
return {
value: name,
label: name,
command: () => openFolder(child),
}
}),
})
}
folderPaths.value = folderItems
}
watch(initialized, (val) => {
if (val) {
openFolder(dataTreeList.value[0])
}
})
return {
folders,
folderPaths,
dataTreeList,
...modelRest,
findFolder: findFolder,
findFolders: findFolders,
openFolder: openFolder,
}
}

View File

@@ -38,19 +38,19 @@ declare module 'hooks/store' {
}
export const useLoading = () => {
const timer = ref<NodeJS.Timeout>()
const targetTimer = ref<Record<string, NodeJS.Timeout | undefined>>({})
const show = () => {
timer.value = setTimeout(() => {
timer.value = undefined
const show = (target: string = '_default') => {
targetTimer.value[target] = setTimeout(() => {
targetTimer.value[target] = undefined
globalLoading.show()
}, 200)
}
const hide = () => {
if (timer.value) {
clearTimeout(timer.value)
timer.value = undefined
const hide = (target: string = '_default') => {
if (targetTimer.value[target]) {
clearTimeout(targetTimer.value[target])
targetTimer.value[target] = undefined
} else {
globalLoading.hide()
}

View File

@@ -1,66 +1,147 @@
import { useConfig } from 'hooks/config'
import DialogModelDetail from 'components/DialogModelDetail.vue'
import { useLoading } from 'hooks/loading'
import { useMarkdown } from 'hooks/markdown'
import { request, useRequest } from 'hooks/request'
import { request } from 'hooks/request'
import { defineStore } from 'hooks/store'
import { useToast } from 'hooks/toast'
import { cloneDeep } from 'lodash'
import { castArray, cloneDeep } from 'lodash'
import { TreeNode } from 'primevue/treenode'
import { app } from 'scripts/comfyAPI'
import { BaseModel, Model, SelectEvent } from 'types/typings'
import { bytesToSize, formatDate } from 'utils/common'
import { BaseModel, Model, SelectEvent, WithResolved } from 'types/typings'
import { bytesToSize, formatDate, previewUrlToFile } from 'utils/common'
import { ModelGrid } from 'utils/legacy'
import { genModelKey, resolveModelTypeLoader } from 'utils/model'
import {
computed,
inject,
InjectionKey,
type InjectionKey,
MaybeRefOrGetter,
onMounted,
provide,
type Ref,
ref,
toRaw,
toValue,
unref,
} from 'vue'
import { useI18n } from 'vue-i18n'
import { configSetting } from './config'
type ModelFolder = Record<string, string[]>
const modelFolderProvideKey = Symbol('modelFolder') as InjectionKey<
Ref<ModelFolder>
>
export const genModelFullName = (model: BaseModel) => {
return [model.subFolder, `${model.basename}${model.extension}`]
.filter(Boolean)
.join('/')
}
export const genModelUrl = (model: BaseModel) => {
const fullname = genModelFullName(model)
return `/model/${model.type}/${model.pathIndex}/${fullname}`
}
export const useModels = defineStore('models', (store) => {
const { data, refresh } = useRequest<Model[]>('/models', { defaultValue: [] })
const { toast, confirm } = useToast()
const { t } = useI18n()
const loading = useLoading()
const updateModel = async (model: BaseModel, data: BaseModel) => {
const updateData = new Map()
const folders = ref<ModelFolder>({})
const initialized = ref(false)
const refreshFolders = async () => {
return request('/models').then((resData) => {
folders.value = resData
initialized.value = true
})
}
provide(modelFolderProvideKey, folders)
const models = ref<Record<string, Model[]>>({})
const refreshModels = async (folder: string) => {
loading.show(folder)
return request(`/models/${folder}`)
.then((resData) => {
models.value[folder] = resData
return resData
})
.finally(() => {
loading.hide(folder)
})
}
const refreshAllModels = async (force = false) => {
const forceRefresh = force ? refreshFolders() : Promise.resolve()
models.value = {}
const excludeScanTypes = app.ui?.settings.getSettingValue<string>(
configSetting.excludeScanTypes,
)
const customBlackList =
excludeScanTypes
?.split(',')
.map((type) => type.trim())
.filter(Boolean) ?? []
await forceRefresh.then(() =>
Promise.allSettled(
Object.keys(folders.value)
.filter((folder) => !customBlackList.includes(folder))
.map(refreshModels),
),
)
}
const updateModel = async (
model: BaseModel,
data: WithResolved<BaseModel>,
) => {
const updateData = new FormData()
let oldKey: string | null = null
let needUpdate = false
// Check current 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
if (model.description !== data.description) {
updateData.set('description', data.description)
needUpdate = true
}
// Check current name and pathIndex
if (
model.fullname !== data.fullname ||
model.subFolder !== data.subFolder ||
model.pathIndex !== data.pathIndex
) {
oldKey = genModelKey(model)
updateData.set('type', data.type)
updateData.set('pathIndex', data.pathIndex.toString())
updateData.set('fullname', data.fullname)
updateData.set('fullname', genModelFullName(data as BaseModel))
needUpdate = true
}
if (updateData.size === 0) {
if (!needUpdate) {
return
}
loading.show()
await request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
await request(genModelUrl(model), {
method: 'PUT',
body: JSON.stringify(Object.fromEntries(updateData.entries())),
body: updateData,
})
.catch((err) => {
const error_message = err.message ?? err.error
@@ -80,7 +161,7 @@ export const useModels = defineStore('models', (store) => {
store.dialog.close({ key: oldKey })
}
refresh()
refreshModels(data.type)
}
const deleteModel = async (model: BaseModel) => {
@@ -101,18 +182,18 @@ export const useModels = defineStore('models', (store) => {
accept: () => {
const dialogKey = genModelKey(model)
loading.show()
request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
request(genModelUrl(model), {
method: 'DELETE',
})
.then(() => {
toast.add({
severity: 'success',
summary: 'Success',
detail: `${model.fullname} Deleted`,
detail: `${model.basename} Deleted`,
life: 2000,
})
store.dialog.close({ key: dialogKey })
return refresh()
return refreshModels(model.type)
})
.then(() => {
resolve(void 0)
@@ -136,7 +217,33 @@ export const useModels = defineStore('models', (store) => {
})
}
return { data, refresh, remove: deleteModel, update: updateModel }
function openModelDetail(model: BaseModel) {
const filename = model.basename.replace(model.extension, '')
store.dialog.open({
key: genModelKey(model),
title: filename,
content: DialogModelDetail,
contentProps: { model: model },
})
}
function getFullPath(model: BaseModel) {
const fullname = genModelFullName(model)
const prefixPath = folders.value[model.type]?.[model.pathIndex]
return [prefixPath, fullname].filter(Boolean).join('/')
}
return {
initialized: initialized,
folders: folders,
data: models,
refresh: refreshAllModels,
remove: deleteModel,
update: updateModel,
openModelDetail: openModelDetail,
getFullPath: getFullPath,
}
})
declare module 'hooks/store' {
@@ -164,15 +271,15 @@ export const useModelFormData = (getFormData: () => BaseModel) => {
}
}
type SubmitCallback = (data: BaseModel) => void
type SubmitCallback = (data: WithResolved<BaseModel>) => void
const submitCallback = ref<SubmitCallback[]>([])
const registerSubmit = (callback: SubmitCallback) => {
submitCallback.value.push(callback)
}
const submit = () => {
const data = cloneDeep(toRaw(unref(formData)))
const submit = (): WithResolved<BaseModel> => {
const data: any = cloneDeep(toRaw(unref(formData)))
for (const callback of submitCallback.value) {
callback(data)
}
@@ -204,7 +311,10 @@ const baseInfoKey = Symbol('baseInfo') as InjectionKey<
export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
const { formData: model, modelData } = formInstance
const { modelFolders } = useConfig()
const provideModelFolders = inject(modelFolderProvideKey)
const modelFolders = computed<ModelFolder>(() => {
return provideModelFolders?.value ?? {}
})
const type = computed({
get: () => {
@@ -224,16 +334,25 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
},
})
const subFolder = computed({
get: () => {
return model.value.subFolder
},
set: (val) => {
model.value.subFolder = val
},
})
const extension = computed(() => {
return model.value.extension
})
const basename = computed({
get: () => {
return model.value.fullname.replace(model.value.extension, '')
return model.value.basename
},
set: (val) => {
model.value.fullname = `${val ?? ''}${model.value.extension}`
model.value.basename = val
},
})
@@ -260,15 +379,20 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
{
key: 'pathIndex',
formatter: () => {
const modelType = modelData.value.type
const pathIndex = modelData.value.pathIndex
const modelType = model.value.type
const pathIndex = model.value.pathIndex
if (!modelType) {
return undefined
}
const folders = modelFolders.value[modelType] ?? []
return `${folders[pathIndex]}`
return [`${folders[pathIndex]}`, model.value.subFolder]
.filter(Boolean)
.join('/')
},
},
{
key: 'fullname',
formatter: (val) => val,
key: 'basename',
formatter: (val) => `${val}${model.value.extension}`,
},
{
key: 'sizeBytes',
@@ -303,7 +427,9 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
baseInfo,
basename,
extension,
subFolder,
pathIndex,
modelFolders,
}
provide(baseInfoKey, result)
@@ -315,6 +441,74 @@ export const useModelBaseInfo = () => {
return inject(baseInfoKey)!
}
export const useModelFolder = (
option: {
type?: MaybeRefOrGetter<string>
} = {},
) => {
const { data: models, folders: modelFolders } = useModels()
const pathOptions = computed(() => {
const type = toValue(option.type)
if (!type) {
return []
}
const folderItems = cloneDeep(models.value[type]) ?? []
const pureFolders = folderItems.filter((item) => item.isFolder)
pureFolders.sort((a, b) => a.basename.localeCompare(b.basename))
const folders = modelFolders.value[type] ?? []
const root: TreeNode[] = []
for (const [index, folder] of folders.entries()) {
const pathIndexItem: TreeNode = {
key: folder,
label: folder,
children: [],
}
const items = pureFolders
.filter((item) => item.pathIndex === index)
.map((item) => {
const node: TreeNode = {
key: `${folder}/${genModelFullName(item)}`,
label: item.basename,
data: item,
}
return node
})
const itemMap = Object.fromEntries(items.map((item) => [item.key, item]))
for (const item of items) {
const key = item.key
const parentKey = key.split('/').slice(0, -1).join('/')
if (parentKey === folder) {
pathIndexItem.children!.push(item)
continue
}
const parentItem = itemMap[parentKey]
if (parentItem) {
parentItem.children ??= []
parentItem.children.push(item)
}
}
root.push(pathIndexItem)
}
return root
})
return {
pathOptions,
}
}
/**
* Editable preview image.
*
@@ -338,9 +532,7 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
* Default images
*/
const defaultContent = computed(() => {
return Array.isArray(model.value.preview)
? model.value.preview
: [model.value.preview]
return model.value.preview ? castArray(model.value.preview) : []
})
const defaultContentPage = ref(0)
@@ -362,7 +554,8 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
* No preview
*/
const noPreviewContent = computed(() => {
return `/model-manager/preview/${model.value.type}/0/no-preview.png`
const folder = model.value.type || 'unknown'
return `/model-manager/preview/${folder}/0/no-preview.png`
})
const preview = computed(() => {
@@ -379,7 +572,7 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
content = localContent.value
break
default:
content = noPreviewContent.value
content = undefined
break
}
@@ -395,7 +588,7 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
})
registerSubmit((data) => {
data.preview = preview.value ?? noPreviewContent.value
data.preview = preview.value
})
})
@@ -485,11 +678,11 @@ export const useModelMetadata = () => {
return inject(metadataKey)!
}
export const useModelNodeAction = (model: BaseModel) => {
export const useModelNodeAction = () => {
const { t } = useI18n()
const { toast, wrapperToastError } = useToast()
const createNode = (options: Record<string, any> = {}) => {
const createNode = (model: BaseModel, options: Record<string, any> = {}) => {
const nodeType = resolveModelTypeLoader(model.type)
if (!nodeType) {
throw new Error(t('unSupportedModelType', [model.type]))
@@ -498,50 +691,52 @@ export const useModelNodeAction = (model: BaseModel) => {
const node = window.LiteGraph.createNode(nodeType, null, options)
const widgetIndex = node.widgets.findIndex((w) => w.type === 'combo')
if (widgetIndex > -1) {
node.widgets[widgetIndex].value = model.fullname
node.widgets[widgetIndex].value = genModelFullName(model)
}
return node
}
const dragToAddModelNode = wrapperToastError((event: DragEvent) => {
// const target = document.elementFromPoint(event.clientX, event.clientY)
// if (
// target?.tagName.toLocaleLowerCase() === 'canvas' &&
// target.id === 'graph-canvas'
// ) {
// const pos = app.clientPosToCanvasPos([event.clientX - 20, event.clientY])
// const node = createNode({ pos })
// app.graph.add(node)
// app.canvas.selectNode(node)
// }
//
// Use the legacy method instead
const removeEmbeddingExtension = true
const strictDragToAdd = false
const dragToAddModelNode = wrapperToastError(
(model: BaseModel, event: DragEvent) => {
// const target = document.elementFromPoint(event.clientX, event.clientY)
// if (
// target?.tagName.toLocaleLowerCase() === 'canvas' &&
// target.id === 'graph-canvas'
// ) {
// const pos = app.clientPosToCanvasPos([event.clientX - 20, event.clientY])
// const node = createNode({ pos })
// app.graph.add(node)
// app.canvas.selectNode(node)
// }
//
// Use the legacy method instead
const removeEmbeddingExtension = true
const strictDragToAdd = false
ModelGrid.dragAddModel(
event,
model.type,
model.fullname,
removeEmbeddingExtension,
strictDragToAdd,
)
})
ModelGrid.dragAddModel(
event,
model.type,
genModelFullName(model),
removeEmbeddingExtension,
strictDragToAdd,
)
},
)
const addModelNode = wrapperToastError(() => {
const addModelNode = wrapperToastError((model: BaseModel) => {
const selectedNodes = app.canvas.selected_nodes
const firstSelectedNode = Object.values(selectedNodes)[0]
const offset = 25
const pos = firstSelectedNode
? [firstSelectedNode.pos[0] + offset, firstSelectedNode.pos[1] + offset]
: app.canvas.canvas_mouse
const node = createNode({ pos })
const node = createNode(model, { pos })
app.graph.add(node)
app.canvas.selectNode(node)
})
const copyModelNode = wrapperToastError(() => {
const node = createNode()
const copyModelNode = wrapperToastError((model: BaseModel) => {
const node = createNode(model)
app.canvas.copyToClipboard([node])
toast.add({
severity: 'success',
@@ -551,13 +746,13 @@ export const useModelNodeAction = (model: BaseModel) => {
})
})
const loadPreviewWorkflow = wrapperToastError(async () => {
const loadPreviewWorkflow = wrapperToastError(async (model: BaseModel) => {
const previewUrl = model.preview as string
const response = await fetch(previewUrl)
const data = await response.blob()
const type = data.type
const extension = type.split('/').pop()
const file = new File([data], `${model.fullname}.${extension}`, { type })
const file = new File([data], `${model.basename}.${extension}`, { type })
app.handleFile(file)
})

View File

@@ -1,22 +0,0 @@
import { throttle } from 'lodash'
import { Directive } from 'vue'
export const resizeDirective: Directive<HTMLElement, ResizeObserverCallback> = {
mounted: (el, binding) => {
const callback = binding.value ?? (() => {})
const observer = new ResizeObserver(callback)
observer.observe(el)
el['observer'] = observer
},
unmounted: (el) => {
const observer = el['observer']
observer.disconnect()
},
}
export const defineResizeCallback = (
callback: ResizeObserverCallback,
wait?: number,
) => {
return throttle(callback, wait ?? 100)
}

View File

@@ -1,3 +1,4 @@
import { app } from 'scripts/comfyAPI'
import { createI18n } from 'vue-i18n'
const messages = {
@@ -24,20 +25,45 @@ const messages = {
none: 'None',
uploadFile: 'Upload File',
tapToChange: 'Tap description to change content',
name: 'Name',
width: 'Width',
height: 'Height',
reset: 'Reset',
sort: {
name: 'Name',
size: 'Largest',
created: 'Latest created',
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: {
type: 'Model Type',
pathIndex: 'Directory',
fullname: 'File Name',
basename: 'File Name',
sizeBytes: 'File Size',
createdAt: 'Created At',
updatedAt: 'Updated At',
},
setting: {
apiKey: 'API Key',
cardHeight: 'Card Height',
cardWidth: 'Card Width',
scan: 'Scan',
scanMissing: 'Download missing information or preview',
scanAll: "Override all models' information and preview",
includeHiddenFiles: 'Include hidden files(start with .)',
excludeScanTypes: 'Exclude scan types (separate with commas)',
ui: 'UI',
cardSize: 'Card Size',
useFlatUI: 'Flat Layout',
},
},
zh: {
model: '模型',
@@ -62,30 +88,55 @@ const messages = {
none: '无',
uploadFile: '上传文件',
tapToChange: '点击描述可更改内容',
name: '名称',
width: '宽度',
height: '高度',
reset: '重置',
sort: {
name: '名称',
size: '最大',
created: '最新创建',
modified: '最新修改',
},
size: {
extraLarge: '超大图标',
large: '大图标',
medium: '中等图标',
small: '小图标',
custom: '自定义尺寸',
customTip: '在 `设置 > 模型管理器 > 外观` 中设置',
},
info: {
type: '类型',
pathIndex: '目录',
fullname: '文件名',
basename: '文件名',
sizeBytes: '文件大小',
createdAt: '创建时间',
updatedAt: '更新时间',
},
setting: {
apiKey: '密钥',
cardHeight: '卡片高度',
cardWidth: '卡片宽度',
scan: '扫描',
scanMissing: '下载缺失的信息或预览图片',
scanAll: '覆盖所有模型信息和预览图片',
includeHiddenFiles: '包含隐藏文件(以 . 开头的文件或文件夹)',
excludeScanTypes: '排除扫描类型(使用英文逗号隔开)',
ui: '外观',
cardSize: '卡片尺寸',
useFlatUI: '展平布局',
},
},
}
const getLocalLanguage = () => {
const local =
localStorage.getItem('Comfy.Settings.Comfy.Locale') ||
app.ui?.settings.getSettingValue<string>('Comfy.Locale') ||
navigator.language.split('-')[0] ||
'en'
return local.replace(/['"]/g, '')
return local
}
export const i18n = createI18n({

View File

@@ -1,6 +1,5 @@
import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
import { resizeDirective } from 'hooks/resize'
import PrimeVue from 'primevue/config'
import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
@@ -20,7 +19,6 @@ const ComfyUIPreset = definePreset(Aura, {
function createVueApp(rootContainer: string | HTMLElement) {
const app = createApp(App)
app.directive('tooltip', Tooltip)
app.directive('resize', resizeDirective)
app
.use(PrimeVue, {
theme: {

View File

@@ -155,8 +155,10 @@ declare namespace ComfyAPI {
deprecated?: boolean
}
class ComfySettingsDialog {
class ComfySettingsDialog extends dialog.ComfyDialog {
addSetting: (params: SettingParams) => { value: any }
getSettingValue: <T>(id: string, defaultValue?: T) => T
setSettingValue: <T>(id: string, value: T) => void
}
}

View File

@@ -1,11 +1,5 @@
export {}
declare module 'vue' {
interface ComponentCustomProperties {
vResize: (typeof import('hooks/resize'))['resizeDirective']
}
}
declare module 'hooks/store' {
interface StoreProvider {}
}

View File

@@ -3,12 +3,13 @@ export type ContainerPosition = { left: number; top: number }
export interface BaseModel {
id: number | string
fullname: string
basename: string
extension: string
sizeBytes: number
type: string
subFolder: string
pathIndex: number
isFolder: boolean
preview: string | string[]
description: string
metadata: Record<string, string>
@@ -17,6 +18,7 @@ export interface BaseModel {
export interface Model extends BaseModel {
createdAt: number
updatedAt: number
children?: Model[]
}
export interface VersionModel extends BaseModel {
@@ -26,6 +28,10 @@ export interface VersionModel extends BaseModel {
hashes?: Record<string, string>
}
export type WithResolved<T> = Omit<T, 'preview'> & {
preview: string | undefined
}
export type PassThrough<T = void> = T | object | undefined
export interface SelectOptions {

View File

@@ -26,3 +26,14 @@ export const bytesToSize = (
export const formatDate = (date: number | string | Date) => {
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}
export const previewUrlToFile = async (url: string) => {
return fetch(url)
.then((res) => res.blob())
.then((blob) => {
const type = blob.type
const extension = type.split('/')[1]
const file = new File([blob], `preview.${extension}`, { type })
return file
})
}

View File

@@ -25,5 +25,5 @@ export const resolveModelTypeLoader = (type: string) => {
}
export const genModelKey = (model: BaseModel) => {
return `${model.type}:${model.pathIndex}:${model.fullname}`
return `${model.type}:${model.pathIndex}:${model.subFolder}:${model.basename}${model.extension}`
}

View File

@@ -1,4 +1,3 @@
import container from '@tailwindcss/container-queries'
import plugin from 'tailwindcss/plugin'
/** @type {import('tailwindcss').Config} */
@@ -8,9 +7,11 @@ export default {
darkMode: ['selector', '.dark-theme'],
plugins: [
container,
plugin(({ addUtilities }) => {
addUtilities({
'.text-shadow': {
'text-shadow': '2px 2px 4px rgba(0, 0, 0, 0.5)',
},
'.scrollbar-none': {
'scrollbar-width': 'none',
},