47 Commits

Author SHA1 Message Date
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
Hayden
5a28789af7 prepare release 2.1.2 2024-11-28 12:07:45 +08:00
Hayden
dada903b2b Feature scan extra folders (#65)
* scan extra folders

Other extension may be add models folder in folder_paths

* Fix scanned non-model files

Model file suffix specified
2024-11-28 12:04:23 +08:00
Hayden
e8916307aa Skip hidden model files (#64) 2024-11-28 12:01:55 +08:00
Hayden
8b6c6ebdea Fix some minor bug (#62)
* Fix print info

* Delete empty line
2024-11-25 15:58:18 +08:00
Hayden
1796b101c5 Fix missing preview (#56)
* fix: can't scan .preview.ext file

* realse fix
2024-11-22 17:44:46 +08:00
38 changed files with 1423 additions and 1185 deletions

View File

@@ -60,7 +60,7 @@ jobs:
run: | run: |
pnpm install pnpm install
pnpm run build pnpm run build
tar -czf dist.tar.gz py/ web/ __init__.py LICENSE pyproject.toml tar -czf dist.tar.gz py/ web/ __init__.py LICENSE pyproject.toml requirements.txt
- name: Create release draft - name: Create release draft
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2

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"/> <img src="demo/scan-model-info.png" alt="Model Manager Demo Screenshot" style="max-width: 100%; max-height: 300px"/>
- Scan models and try to download information & preview. - Scan models and try to download information & preview.
- Support migration from `cdb-boop/ComfyUI-Model-Manager/main`

View File

@@ -1,10 +1,10 @@
import os import os
import folder_paths
from .py import config from .py import config
from .py import utils from .py import utils
extension_uri = utils.normalize_path(os.path.dirname(__file__)) extension_uri = utils.normalize_path(os.path.dirname(__file__))
# Install requirements
requirements_path = utils.join_path(extension_uri, "requirements.txt") requirements_path = utils.join_path(extension_uri, "requirements.txt")
with open(requirements_path, "r", encoding="utf-8") as f: with open(requirements_path, "r", encoding="utf-8") as f:
@@ -23,277 +23,22 @@ if len(uninstalled_package) > 0:
# Init config settings # Init config settings
config.extension_uri = extension_uri config.extension_uri = extension_uri
utils.resolve_model_base_paths()
# Try to download web distribution
version = utils.get_current_version() version = utils.get_current_version()
utils.download_web_distribution(version) utils.download_web_distribution(version)
from aiohttp import web # Add api routes
from .py import services from .py import manager
from .py import download
from .py import information
routes = config.routes routes = config.routes
manager.ModelManager().add_routes(routes)
@routes.get("/model-manager/download/task") download.ModelDownload().add_routes(routes)
async def scan_download_tasks(request): information.Information().add_routes(routes)
"""
Read download task list.
"""
try:
result = await services.scan_model_download_task_list()
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Read download task list failed: {e}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.put("/model-manager/download/{task_id}")
async def resume_download_task(request):
"""
Toggle download task status.
"""
try:
task_id = request.match_info.get("task_id", None)
if task_id is None:
raise web.HTTPBadRequest(reason="Invalid task id")
json_data = await request.json()
status = json_data.get("status", None)
if status == "pause":
await services.pause_model_download_task(task_id)
elif status == "resume":
await services.resume_model_download_task(task_id, request)
else:
raise web.HTTPBadRequest(reason="Invalid status")
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Resume download task failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.delete("/model-manager/download/{task_id}")
async def delete_model_download_task(request):
"""
Delete download task.
"""
task_id = request.match_info.get("task_id", None)
try:
await services.delete_model_download_task(task_id)
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Delete download task failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.get("/model-manager/base-folders")
async def get_model_paths(request):
"""
Returns the base folders for models.
"""
model_base_paths = config.model_base_paths
return web.json_response({"success": True, "data": model_base_paths})
@routes.post("/model-manager/model")
async def create_model(request):
"""
Create a new model.
request body: x-www-form-urlencoded
- type: model type.
- pathIndex: index of the model folders.
- fullname: filename that relative to the model folder.
- previewFile: preview file.
- description: description.
- downloadPlatform: download platform.
- downloadUrl: download url.
- hash: a JSON string containing the hash value of the downloaded model.
"""
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()
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)
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()
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Download model info failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
WEB_DIRECTORY = "web" WEB_DIRECTORY = "web"

View File

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

63
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@primevue/themes': '@primevue/themes':
specifier: ^4.0.7 specifier: ^4.0.7
version: 4.0.7 version: 4.0.7
'@vueuse/core':
specifier: ^11.3.0
version: 11.3.0(vue@3.5.6(typescript@5.6.2))
dayjs: dayjs:
specifier: ^1.11.13 specifier: ^1.11.13
version: 1.11.13 version: 1.11.13
@@ -36,9 +39,6 @@ importers:
specifier: ^2.6.0 specifier: ^2.6.0
version: 2.6.0 version: 2.6.0
devDependencies: devDependencies:
'@tailwindcss/container-queries':
specifier: ^0.1.1
version: 0.1.1(tailwindcss@3.4.12)
'@types/lodash': '@types/lodash':
specifier: ^4.17.9 specifier: ^4.17.9
version: 4.17.9 version: 4.17.9
@@ -470,11 +470,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@tailwindcss/container-queries@0.1.1':
resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==}
peerDependencies:
tailwindcss: '>=3.2.0'
'@types/estree@1.0.5': '@types/estree@1.0.5':
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
@@ -493,6 +488,9 @@ packages:
'@types/node@22.5.5': '@types/node@22.5.5':
resolution: {integrity: sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==} resolution: {integrity: sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==}
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@typescript-eslint/eslint-plugin@8.13.0': '@typescript-eslint/eslint-plugin@8.13.0':
resolution: {integrity: sha512-nQtBLiZYMUPkclSeC3id+x4uVd1SGtHuElTxL++SfP47jR0zfkZBJHc+gL4qPsgTuypz0k8Y2GheaDYn6Gy3rg==} resolution: {integrity: sha512-nQtBLiZYMUPkclSeC3id+x4uVd1SGtHuElTxL++SfP47jR0zfkZBJHc+gL4qPsgTuypz0k8Y2GheaDYn6Gy3rg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -609,6 +607,15 @@ packages:
'@vue/shared@3.5.6': '@vue/shared@3.5.6':
resolution: {integrity: sha512-eidH0HInnL39z6wAt6SFIwBrvGOpDWsDxlw3rCgo1B+CQ1781WzQUSU3YjxgdkcJo9Q8S6LmXTkvI+cLHGkQfA==} resolution: {integrity: sha512-eidH0HInnL39z6wAt6SFIwBrvGOpDWsDxlw3rCgo1B+CQ1781WzQUSU3YjxgdkcJo9Q8S6LmXTkvI+cLHGkQfA==}
'@vueuse/core@11.3.0':
resolution: {integrity: sha512-7OC4Rl1f9G8IT6rUfi9JrKiXy4bfmHhZ5x2Ceojy0jnd3mHNEvV4JaRygH362ror6/NZ+Nl+n13LPzGiPN8cKA==}
'@vueuse/metadata@11.3.0':
resolution: {integrity: sha512-pwDnDspTqtTo2HwfLw4Rp6yywuuBdYnPYDq+mO38ZYKGebCUQC/nVj/PXSiK9HX5otxLz8Fn7ECPbjiRz2CC3g==}
'@vueuse/shared@11.3.0':
resolution: {integrity: sha512-P8gSSWQeucH5821ek2mn/ciCk+MS/zoRKqdQIM3bHq6p7GXDAJLmnRRKmF5F65sAVJIfzQlwR3aDzwCn10s8hA==}
acorn-jsx@5.3.2: acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@@ -1676,6 +1683,17 @@ packages:
vscode-uri@3.0.8: vscode-uri@3.0.8:
resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-eslint-parser@9.4.3: vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==} resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
engines: {node: ^14.17.0 || >=16.0.0} engines: {node: ^14.17.0 || >=16.0.0}
@@ -2000,10 +2018,6 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.22.0': '@rollup/rollup-win32-x64-msvc@4.22.0':
optional: true optional: true
'@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.12)':
dependencies:
tailwindcss: 3.4.12
'@types/estree@1.0.5': {} '@types/estree@1.0.5': {}
'@types/linkify-it@5.0.0': {} '@types/linkify-it@5.0.0': {}
@@ -2021,6 +2035,8 @@ snapshots:
dependencies: dependencies:
undici-types: 6.19.8 undici-types: 6.19.8
'@types/web-bluetooth@0.0.20': {}
'@typescript-eslint/eslint-plugin@8.13.0(@typescript-eslint/parser@8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)': '@typescript-eslint/eslint-plugin@8.13.0(@typescript-eslint/parser@8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.1 '@eslint-community/regexpp': 4.12.1
@@ -2193,6 +2209,25 @@ snapshots:
'@vue/shared@3.5.6': {} '@vue/shared@3.5.6': {}
'@vueuse/core@11.3.0(vue@3.5.6(typescript@5.6.2))':
dependencies:
'@types/web-bluetooth': 0.0.20
'@vueuse/metadata': 11.3.0
'@vueuse/shared': 11.3.0(vue@3.5.6(typescript@5.6.2))
vue-demi: 0.14.10(vue@3.5.6(typescript@5.6.2))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/metadata@11.3.0': {}
'@vueuse/shared@11.3.0(vue@3.5.6(typescript@5.6.2))':
dependencies:
vue-demi: 0.14.10(vue@3.5.6(typescript@5.6.2))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
acorn-jsx@5.3.2(acorn@8.12.1): acorn-jsx@5.3.2(acorn@8.12.1):
dependencies: dependencies:
acorn: 8.12.1 acorn: 8.12.1
@@ -3192,6 +3227,10 @@ snapshots:
vscode-uri@3.0.8: {} vscode-uri@3.0.8: {}
vue-demi@0.14.10(vue@3.5.6(typescript@5.6.2)):
dependencies:
vue: 3.5.6(typescript@5.6.2)
vue-eslint-parser@9.4.3(eslint@9.10.0(jiti@1.21.6)): vue-eslint-parser@9.4.3(eslint@9.10.0(jiti@1.21.6)):
dependencies: dependencies:
debug: 4.3.7 debug: 4.3.7

View File

@@ -1,7 +1,6 @@
extension_tag = "ComfyUI Model Manager" extension_tag = "ComfyUI Model Manager"
extension_uri: str = None extension_uri: str = None
model_base_paths: dict[str, list[str]] = {}
setting_key = { setting_key = {
@@ -12,6 +11,9 @@ setting_key = {
"download": { "download": {
"max_task_count": "ModelManager.Download.MaxTaskCount", "max_task_count": "ModelManager.Download.MaxTaskCount",
}, },
"scan": {
"include_hidden_files": "ModelManager.Scan.IncludeHiddenFiles"
},
} }
user_agent = "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148" user_agent = "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"

View File

@@ -61,7 +61,7 @@ class TaskContent:
description: str description: str
downloadPlatform: str downloadPlatform: str
downloadUrl: str downloadUrl: str
sizeBytes: int sizeBytes: float
hashes: Optional[dict[str, str]] = None hashes: Optional[dict[str, str]] = None
def __init__(self, **kwargs): def __init__(self, **kwargs):
@@ -71,7 +71,7 @@ class TaskContent:
self.description = kwargs.get("description", None) self.description = kwargs.get("description", None)
self.downloadPlatform = kwargs.get("downloadPlatform", None) self.downloadPlatform = kwargs.get("downloadPlatform", None)
self.downloadUrl = kwargs.get("downloadUrl", None) self.downloadUrl = kwargs.get("downloadUrl", None)
self.sizeBytes = int(kwargs.get("sizeBytes", 0)) self.sizeBytes = float(kwargs.get("sizeBytes", 0))
self.hashes = kwargs.get("hashes", None) self.hashes = kwargs.get("hashes", None)
def to_dict(self): def to_dict(self):
@@ -103,6 +103,8 @@ def get_task_content(task_id: str):
if not os.path.isfile(task_file): if not os.path.isfile(task_file):
raise RuntimeError(f"Task {task_id} not found") raise RuntimeError(f"Task {task_id} not found")
task_content = utils.load_dict_pickle_file(task_file) task_content = utils.load_dict_pickle_file(task_file)
if isinstance(task_content, TaskContent):
return task_content
return TaskContent(**task_content) return TaskContent(**task_content)
@@ -178,17 +180,18 @@ async def create_model_download_task(task_data: dict, request):
task_path = utils.join_path(download_path, f"{task_id}.task") task_path = utils.join_path(download_path, f"{task_id}.task")
if os.path.exists(task_path): if os.path.exists(task_path):
raise RuntimeError(f"Task {task_id} already exists") raise RuntimeError(f"Task {task_id} already exists")
download_platform = task_data.get("downloadPlatform", None)
try: try:
preview_url = task_data.pop("preview", None) preview_file = task_data.pop("previewFile", None)
utils.save_model_preview_image(task_path, preview_url) utils.save_model_preview_image(task_path, preview_file, download_platform)
set_task_content(task_id, task_data) set_task_content(task_id, task_data)
task_status = TaskStatus( task_status = TaskStatus(
taskId=task_id, taskId=task_id,
type=model_type, type=model_type,
fullname=fullname, fullname=fullname,
preview=utils.get_model_preview_name(task_path), preview=utils.get_model_preview_name(task_path),
platform=task_data.get("downloadPlatform", None), platform=download_platform,
totalSize=float(task_data.get("sizeBytes", 0)), totalSize=float(task_data.get("sizeBytes", 0)),
) )
download_model_task_status[task_id] = task_status download_model_task_status[task_id] = task_status
@@ -361,9 +364,7 @@ async def download_model_file(
) )
if response.status_code not in (200, 206): if response.status_code not in (200, 206):
raise RuntimeError( raise RuntimeError(f"Failed to download {task_content.fullname}, status code: {response.status_code}")
f"Failed to download {task_content.fullname}, status code: {response.status_code}"
)
# Some models require logging in before they can be downloaded. # Some models require logging in before they can be downloaded.
# If no token is carried, it will be redirected to the login page. # If no token is carried, it will be redirected to the login page.
@@ -376,14 +377,12 @@ async def download_model_file(
# If it cannot be downloaded, a redirect will definitely occur. # If it cannot be downloaded, a redirect will definitely occur.
# Maybe consider getting the redirect url from response.history to make a judgment. # Maybe consider getting the redirect url from response.history to make a judgment.
# Here we also need to consider how different websites are processed. # Here we also need to consider how different websites are processed.
raise RuntimeError( raise RuntimeError(f"{task_content.fullname} needs to be logged in to download. Please set the API-Key first.")
f"{task_content.fullname} needs to be logged in to download. Please set the API-Key first."
)
# When parsing model information from HuggingFace API, # When parsing model information from HuggingFace API,
# the file size was not found and needs to be obtained from the response header. # the file size was not found and needs to be obtained from the response header.
if total_size == 0: if total_size == 0:
total_size = int(response.headers.get("content-length", 0)) total_size = float(response.headers.get("content-length", 0))
task_content.sizeBytes = total_size task_content.sizeBytes = total_size
task_status.totalSize = total_size task_status.totalSize = total_size
set_task_content(task_id, task_content) set_task_content(task_id, task_content)
@@ -407,3 +406,86 @@ async def download_model_file(
else: else:
task_status.status = "pause" task_status.status = "pause"
await utils.send_json("update_download_task", task_status.to_dict()) await utils.send_json("update_download_task", task_status.to_dict())
from aiohttp import web
class ModelDownload:
def add_routes(self, routes):
@routes.get("/model-manager/download/task")
async def scan_download_tasks(request):
"""
Read download task list.
"""
try:
result = await scan_model_download_task_list()
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Read download task list failed: {e}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.put("/model-manager/download/{task_id}")
async def resume_download_task(request):
"""
Toggle download task status.
"""
try:
task_id = request.match_info.get("task_id", None)
if task_id is None:
raise web.HTTPBadRequest(reason="Invalid task id")
json_data = await request.json()
status = json_data.get("status", None)
if status == "pause":
await pause_model_download_task(task_id)
elif status == "resume":
await download_model(task_id, request)
else:
raise web.HTTPBadRequest(reason="Invalid status")
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Resume download task failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.delete("/model-manager/download/{task_id}")
async def delete_model_download_task(request):
"""
Delete download task.
"""
task_id = request.match_info.get("task_id", None)
try:
await delete_model_download_task(task_id)
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Delete download task failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.post("/model-manager/model")
async def create_model(request):
"""
Create a new model.
request body: x-www-form-urlencoded
- type: model type.
- pathIndex: index of the model folders.
- fullname: filename that relative to the model folder.
- previewFile: preview file.
- description: description.
- downloadPlatform: download platform.
- downloadUrl: download url.
- hash: a JSON string containing the hash value of the downloaded model.
"""
task_data = await request.post()
task_data = dict(task_data)
try:
task_id = await create_model_download_task(task_data, request)
return web.json_response({"success": True, "data": {"taskId": task_id}})
except Exception as e:
error_msg = f"Create model download task failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})

View File

@@ -27,9 +27,7 @@ class ModelSearcher(ABC):
class UnknownWebsiteSearcher(ModelSearcher): class UnknownWebsiteSearcher(ModelSearcher):
def search_by_url(self, url: str): def search_by_url(self, url: str):
raise RuntimeError( raise RuntimeError(f"Unknown Website, please input a URL from huggingface.co or civitai.com.")
f"Unknown Website, please input a URL from huggingface.co or civitai.com."
)
def search_by_hash(self, hash: str): def search_by_hash(self, hash: str):
raise RuntimeError(f"Unknown Website, unable to search with hash value.") raise RuntimeError(f"Unknown Website, unable to search with hash value.")
@@ -87,29 +85,15 @@ class CivitaiModelSearcher(ModelSearcher):
description_parts.append("") description_parts.append("")
description_parts.append(f"# Trigger Words") description_parts.append(f"# Trigger Words")
description_parts.append("") description_parts.append("")
description_parts.append( description_parts.append(", ".join(version.get("trainedWords", ["No trigger words"])))
", ".join(version.get("trainedWords", ["No trigger words"]))
)
description_parts.append("") description_parts.append("")
description_parts.append(f"# About this version") description_parts.append(f"# About this version")
description_parts.append("") description_parts.append("")
description_parts.append( description_parts.append(markdownify.markdownify(version.get("description", "<p>No description about this version</p>")).strip())
markdownify.markdownify(
version.get(
"description", "<p>No description about this version</p>"
)
).strip()
)
description_parts.append("") description_parts.append("")
description_parts.append(f"# {res_data.get('name')}") description_parts.append(f"# {res_data.get('name')}")
description_parts.append("") description_parts.append("")
description_parts.append( description_parts.append(markdownify.markdownify(res_data.get("description", "<p>No description about this model</p>")).strip())
markdownify.markdownify(
res_data.get(
"description", "<p>No description about this model</p>"
)
).strip()
)
description_parts.append("") description_parts.append("")
model = { model = {
@@ -136,18 +120,14 @@ class CivitaiModelSearcher(ModelSearcher):
if not hash: if not hash:
raise RuntimeError(f"Hash value is empty.") raise RuntimeError(f"Hash value is empty.")
response = requests.get( response = requests.get(f"https://civitai.com/api/v1/model-versions/by-hash/{hash}")
f"https://civitai.com/api/v1/model-versions/by-hash/{hash}"
)
response.raise_for_status() response.raise_for_status()
version: dict = response.json() version: dict = response.json()
model_id = version.get("modelId") model_id = version.get("modelId")
version_id = version.get("id") version_id = version.get("id")
model_page = ( model_page = f"https://civitai.com/models/{model_id}?modelVersionId={version_id}"
f"https://civitai.com/models/{model_id}?modelVersionId={version_id}"
)
models = self.search_by_url(model_page) models = self.search_by_url(model_page)
@@ -186,9 +166,7 @@ class HuggingfaceModelSearcher(ModelSearcher):
response.raise_for_status() response.raise_for_status()
res_data: dict = response.json() res_data: dict = response.json()
sibling_files: list[str] = [ sibling_files: list[str] = [x.get("rfilename") for x in res_data.get("siblings", [])]
x.get("rfilename") for x in res_data.get("siblings", [])
]
model_files = utils.filter_with( model_files = utils.filter_with(
utils.filter_with(sibling_files, self._match_model_files()), utils.filter_with(sibling_files, self._match_model_files()),
@@ -199,10 +177,7 @@ class HuggingfaceModelSearcher(ModelSearcher):
utils.filter_with(sibling_files, self._match_image_files()), utils.filter_with(sibling_files, self._match_image_files()),
self._match_tree_files(rest_pathname), self._match_tree_files(rest_pathname),
) )
image_files = [ image_files = [f"https://huggingface.co/{model_id}/resolve/main/{filename}" for filename in image_files]
f"https://huggingface.co/{model_id}/resolve/main/{filename}"
for filename in image_files
]
models: list[dict] = [] models: list[dict] = []
@@ -250,7 +225,7 @@ class HuggingfaceModelSearcher(ModelSearcher):
"pathIndex": 0, "pathIndex": 0,
"description": "\n".join(description_parts), "description": "\n".join(description_parts),
"metadata": {}, "metadata": {},
"downloadPlatform": "", "downloadPlatform": "huggingface",
"downloadUrl": f"https://huggingface.co/{model_id}/resolve/main/{filename}?download=true", "downloadUrl": f"https://huggingface.co/{model_id}/resolve/main/{filename}?download=true",
} }
models.append(model) models.append(model)
@@ -315,3 +290,148 @@ def get_model_searcher_by_url(url: str) -> ModelSearcher:
elif host_name == "huggingface.co": elif host_name == "huggingface.co":
return HuggingfaceModelSearcher() return HuggingfaceModelSearcher()
return UnknownWebsiteSearcher() return UnknownWebsiteSearcher()
import folder_paths
from . import config
from aiohttp import web
class Information:
def add_routes(self, routes):
@routes.get("/model-manager/model-info")
async def fetch_model_info(request):
"""
Fetch model information from network with model page.
"""
try:
model_page = request.query.get("model-page", None)
result = self.fetch_model_info(model_page)
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Fetch model info failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.post("/model-manager/model-info/scan")
async def download_model_info(request):
"""
Create a task to download model information.
"""
post = await utils.get_request_body(request)
try:
scan_mode = post.get("scanMode", "diff")
await self.download_model_info(scan_mode, request)
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Download model info failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.get("/model-manager/preview/{type}/{index}/{filename:.*}")
async def read_model_preview(request):
"""
Get the file stream of the specified image.
If the file does not exist, no-preview.png is returned.
:param type: The type of the model. eg.checkpoints, loras, vae, etc.
:param index: The index of the model folders.
:param filename: The filename of the image.
"""
model_type = request.match_info.get("type", None)
index = int(request.match_info.get("index", None))
filename = request.match_info.get("filename", None)
extension_uri = config.extension_uri
try:
folders = folder_paths.get_folder_paths(model_type)
base_path = folders[index]
abs_path = utils.join_path(base_path, filename)
except:
abs_path = extension_uri
if not os.path.isfile(abs_path):
abs_path = utils.join_path(extension_uri, "assets", "no-preview.png")
return web.FileResponse(abs_path)
@routes.get("/model-manager/preview/download/{filename}")
async def read_download_preview(request):
filename = request.match_info.get("filename", None)
extension_uri = config.extension_uri
download_path = utils.get_download_path()
preview_path = utils.join_path(download_path, filename)
if not os.path.isfile(preview_path):
preview_path = utils.join_path(extension_uri, "assets", "no-preview.png")
return web.FileResponse(preview_path)
def fetch_model_info(self, model_page: str):
if not model_page:
return []
model_searcher = get_model_searcher_by_url(model_page)
result = model_searcher.search_by_url(model_page)
return result
async def download_model_info(self, scan_mode: str, request):
utils.print_info(f"Download model info for {scan_mode}")
model_base_paths = utils.resolve_model_base_paths()
for model_type in model_base_paths:
folders, *others = folder_paths.folder_names_and_paths[model_type]
for path_index, base_path in enumerate(folders):
files = utils.recursive_search_files(base_path, request)
models = folder_paths.filter_files_extensions(files, folder_paths.supported_pt_extensions)
for fullname in models:
fullname = utils.normalize_path(fullname)
basename = os.path.splitext(fullname)[0]
abs_model_path = utils.join_path(base_path, fullname)
image_name = utils.get_model_preview_name(abs_model_path)
abs_image_path = utils.join_path(base_path, image_name)
has_preview = os.path.isfile(abs_image_path)
description_name = utils.get_model_description_name(abs_model_path)
abs_description_path = utils.join_path(base_path, description_name) if description_name else None
has_description = os.path.isfile(abs_description_path) if abs_description_path else False
try:
utils.print_info(f"Checking model {abs_model_path}")
utils.print_debug(f"Scan mode: {scan_mode}")
utils.print_debug(f"Has preview: {has_preview}")
utils.print_debug(f"Has description: {has_description}")
if scan_mode != "full" and (has_preview and has_description):
continue
utils.print_debug(f"Calculate sha256 for {abs_model_path}")
hash_value = utils.calculate_sha256(abs_model_path)
utils.print_info(f"Searching model info by hash {hash_value}")
model_info = CivitaiModelSearcher().search_by_hash(hash_value)
preview_url_list = model_info.get("preview", [])
preview_image_url = preview_url_list[0] if preview_url_list else None
if preview_image_url:
utils.print_debug(f"Save preview image to {abs_image_path}")
utils.save_model_preview_image(abs_model_path, preview_image_url)
description = model_info.get("description", None)
if description:
utils.save_model_description(abs_model_path, description)
except Exception as e:
utils.print_error(f"Failed to download model info for {abs_model_path}: {e}")
utils.print_debug("Completed scan model information.")

225
py/manager.py Normal file
View File

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

View File

@@ -1,308 +0,0 @@
import os
import folder_paths
from . import config
from . import utils
from . import download
from . import searcher
def scan_models():
result = []
model_base_paths = config.model_base_paths
for model_type in model_base_paths:
folders, extensions = folder_paths.folder_names_and_paths[model_type]
for path_index, base_path in enumerate(folders):
files = utils.recursive_search_files(base_path)
models = folder_paths.filter_files_extensions(files, extensions)
images = folder_paths.filter_files_content_types(files, ["image"])
image_dict = utils.file_list_to_name_dict(images)
for fullname in models:
fullname = utils.normalize_path(fullname)
basename = os.path.splitext(fullname)[0]
extension = os.path.splitext(fullname)[1]
abs_path = utils.join_path(base_path, fullname)
file_stats = os.stat(abs_path)
# Resolve preview
image_name = image_dict.get(basename, "no-preview.png")
abs_image_path = utils.join_path(base_path, image_name)
if os.path.isfile(abs_image_path):
image_state = os.stat(abs_image_path)
image_timestamp = round(image_state.st_mtime_ns / 1000000)
image_name = f"{image_name}?ts={image_timestamp}"
model_preview = (
f"/model-manager/preview/{model_type}/{path_index}/{image_name}"
)
model_info = {
"fullname": fullname,
"basename": basename,
"extension": extension,
"type": model_type,
"pathIndex": path_index,
"sizeBytes": file_stats.st_size,
"preview": model_preview,
"createdAt": round(file_stats.st_ctime_ns / 1000000),
"updatedAt": round(file_stats.st_mtime_ns / 1000000),
}
result.append(model_info)
return result
def get_model_info(model_path: str):
directory = os.path.dirname(model_path)
metadata = utils.get_model_metadata(model_path)
description_file = utils.get_model_description_name(model_path)
description_file = utils.join_path(directory, description_file)
description = None
if os.path.isfile(description_file):
with open(description_file, "r", encoding="utf-8", newline="") as f:
description = f.read()
return {
"metadata": metadata,
"description": description,
}
def update_model(model_path: str, 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):
utils.print_info(f"Download model info for {scan_mode}")
model_base_paths = config.model_base_paths
for model_type in model_base_paths:
folders, extensions = folder_paths.folder_names_and_paths[model_type]
for path_index, base_path in enumerate(folders):
files = utils.recursive_search_files(base_path)
models = folder_paths.filter_files_extensions(files, extensions)
images = folder_paths.filter_files_content_types(files, ["image"])
image_dict = utils.file_list_to_name_dict(images)
descriptions = folder_paths.filter_files_extensions(files, [".md"])
description_dict = utils.file_list_to_name_dict(descriptions)
for fullname in models:
fullname = utils.normalize_path(fullname)
basename = os.path.splitext(fullname)[0]
abs_model_path = utils.join_path(base_path, fullname)
image_name = image_dict.get(basename, "no-preview.png")
abs_image_path = utils.join_path(base_path, image_name)
has_preview = os.path.isfile(abs_image_path)
description_name = description_dict.get(basename, None)
abs_description_path = (
utils.join_path(base_path, description_name)
if description_name
else None
)
has_description = (
os.path.isfile(abs_description_path)
if abs_description_path
else False
)
try:
utils.print_info(f"Checking model {abs_model_path}")
utils.print_debug(f"Scan mode: {scan_mode}")
utils.print_debug(f"Has preview: {has_preview}")
utils.print_debug(f"Has description: {has_description}")
if scan_mode != "full" and (has_preview and has_description):
continue
utils.print_debug(f"Calculate sha256 for {abs_model_path}")
hash_value = utils.calculate_sha256(abs_model_path)
utils.print_info(f"Searching model info by hash {hash_value}")
model_info = searcher.CivitaiModelSearcher().search_by_hash(
hash_value
)
preview_url_list = model_info.get("preview", [])
preview_image_url = (
preview_url_list[0] if preview_url_list else None
)
if preview_image_url:
utils.print_debug(f"Save preview image to {abs_image_path}")
utils.save_model_preview_image(
abs_model_path, preview_image_url
)
description = model_info.get("description", None)
if description:
utils.save_model_description(abs_model_path, description)
except Exception as e:
utils.print_error(
f"Failed to download model info for {abs_model_path}: {e}"
)
utils.print_debug("Completed scan model information.")
async def migrate_legacy_information():
import json
import yaml
from PIL import Image
utils.print_info(f"Migrating legacy information...")
model_base_paths = config.model_base_paths
for model_type in model_base_paths:
folders, extensions = folder_paths.folder_names_and_paths[model_type]
for path_index, base_path in enumerate(folders):
files = utils.recursive_search_files(base_path)
models = folder_paths.filter_files_extensions(files, extensions)
for fullname in models:
fullname = utils.normalize_path(fullname)
abs_model_path = utils.join_path(base_path, fullname)
base_file_name = os.path.splitext(abs_model_path)[0]
utils.print_debug(f"Try to migrate legacy info for {abs_model_path}")
preview_path = utils.join_path(
os.path.dirname(abs_model_path),
utils.get_model_preview_name(abs_model_path),
)
new_preview_path = f"{base_file_name}.webp"
if os.path.isfile(preview_path) and preview_path != new_preview_path:
utils.print_info(f"Migrate preview image from {fullname}")
with Image.open(preview_path) as image:
image.save(new_preview_path, format="WEBP")
os.remove(preview_path)
description_path = f"{base_file_name}.md"
metadata_info = {
"website": "Civitai",
}
url_info_path = f"{base_file_name}.url"
if os.path.isfile(url_info_path):
with open(url_info_path, "r", encoding="utf-8") as f:
for line in f:
if line.startswith("URL="):
model_page_url = line[len("URL=") :].strip()
metadata_info.update({"modelPage": model_page_url})
json_info_path = f"{base_file_name}.json"
if os.path.isfile(json_info_path):
with open(json_info_path, "r", encoding="utf-8") as f:
version = json.load(f)
metadata_info.update(
{
"baseModel": version.get("baseModel"),
"preview": [i["url"] for i in version["images"]],
}
)
description_parts: list[str] = [
"---",
yaml.dump(metadata_info).strip(),
"---",
"",
]
text_info_path = f"{base_file_name}.txt"
if os.path.isfile(text_info_path):
with open(text_info_path, "r", encoding="utf-8") as f:
description_parts.append(f.read())
description_path = f"{base_file_name}.md"
if os.path.isfile(text_info_path):
utils.print_info(f"Migrate description from {fullname}")
with open(description_path, "w", encoding="utf-8", newline="") as f:
f.write("\n".join(description_parts))
def try_to_remove_file(file_path):
if os.path.isfile(file_path):
os.remove(file_path)
try_to_remove_file(url_info_path)
try_to_remove_file(text_info_path)
try_to_remove_file(json_info_path)
utils.print_debug("Completed migrate model information.")

View File

@@ -7,6 +7,7 @@ import logging
import requests import requests
import traceback import traceback
import configparser import configparser
import functools
import comfy.utils import comfy.utils
import folder_paths import folder_paths
@@ -20,6 +21,10 @@ def print_info(msg, *args, **kwargs):
logging.info(f"[{config.extension_tag}] {msg}", *args, **kwargs) logging.info(f"[{config.extension_tag}] {msg}", *args, **kwargs)
def print_warning(msg, *args, **kwargs):
logging.warning(f"[{config.extension_tag}][WARNING] {msg}", *args, **kwargs)
def print_error(msg, *args, **kwargs): def print_error(msg, *args, **kwargs):
logging.error(f"[{config.extension_tag}] {msg}", *args, **kwargs) logging.error(f"[{config.extension_tag}] {msg}", *args, **kwargs)
logging.debug(traceback.format_exc()) logging.debug(traceback.format_exc())
@@ -29,6 +34,18 @@ def print_debug(msg, *args, **kwargs):
logging.debug(f"[{config.extension_tag}] {msg}", *args, **kwargs) logging.debug(f"[{config.extension_tag}] {msg}", *args, **kwargs)
def deprecated(reason: str):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print_warning(f"{func.__name__} is deprecated: {reason}")
return func(*args, **kwargs)
return wrapper
return decorator
def _matches(predicate: dict): def _matches(predicate: dict):
def _filter(obj: dict): def _filter(obj: dict):
return all(obj.get(key, None) == value for key, value in predicate.items()) return all(obj.get(key, None) == value for key, value in predicate.items())
@@ -103,9 +120,7 @@ def download_web_distribution(version: str):
print_info("Extracting web distribution...") print_info("Extracting web distribution...")
with tarfile.open(temp_file, "r:gz") as tar: with tarfile.open(temp_file, "r:gz") as tar:
members = [ members = [member for member in tar.getmembers() if member.name.startswith("web/")]
member for member in tar.getmembers() if member.name.startswith("web/")
]
tar.extractall(path=config.extension_uri, members=members) tar.extractall(path=config.extension_uri, members=members)
os.remove(temp_file) os.remove(temp_file)
@@ -118,23 +133,27 @@ def download_web_distribution(version: str):
print_error(f"An unexpected error occurred: {e}") print_error(f"An unexpected error occurred: {e}")
def resolve_model_base_paths(): def resolve_model_base_paths() -> dict[str, list[str]]:
"""
Resolve model base paths.
eg. { "checkpoints": ["path/to/checkpoints"] }
"""
folders = list(folder_paths.folder_names_and_paths.keys()) folders = list(folder_paths.folder_names_and_paths.keys())
config.model_base_paths = {} model_base_paths = {}
folder_black_list = ["configs", "custom_nodes"]
for folder in folders: for folder in folders:
if folder == "configs": if folder in folder_black_list:
continue
if folder == "custom_nodes":
continue continue
folders = folder_paths.get_folder_paths(folder) folders = folder_paths.get_folder_paths(folder)
config.model_base_paths[folder] = [normalize_path(f) for f in folders] model_base_paths[folder] = [normalize_path(f) for f in folders]
return model_base_paths
def get_full_path(model_type: str, path_index: int, filename: str): def get_full_path(model_type: str, path_index: int, filename: str):
""" """
Get the absolute path in the model type through string concatenation. Get the absolute path in the model type through string concatenation.
""" """
folders = config.model_base_paths.get(model_type, []) folders = resolve_model_base_paths().get(model_type, [])
if not path_index < len(folders): if not path_index < len(folders):
raise RuntimeError(f"PathIndex {path_index} is not in {model_type}") raise RuntimeError(f"PathIndex {path_index} is not in {model_type}")
base_path = folders[path_index] base_path = folders[path_index]
@@ -146,7 +165,7 @@ def get_valid_full_path(model_type: str, path_index: int, filename: str):
""" """
Like get_full_path but it will check whether the file is valid. Like get_full_path but it will check whether the file is valid.
""" """
folders = config.model_base_paths.get(model_type, []) folders = resolve_model_base_paths().get(model_type, [])
if not path_index < len(folders): if not path_index < len(folders):
raise RuntimeError(f"PathIndex {path_index} is not in {model_type}") raise RuntimeError(f"PathIndex {path_index} is not in {model_type}")
base_path = folders[path_index] base_path = folders[path_index]
@@ -154,9 +173,7 @@ def get_valid_full_path(model_type: str, path_index: int, filename: str):
if os.path.isfile(full_path): if os.path.isfile(full_path):
return full_path return full_path
elif os.path.islink(full_path): elif os.path.islink(full_path):
raise RuntimeError( raise RuntimeError(f"WARNING path {full_path} exists but doesn't link anywhere, skipping.")
f"WARNING path {full_path} exists but doesn't link anywhere, skipping."
)
def get_download_path(): def get_download_path():
@@ -166,11 +183,29 @@ def get_download_path():
return download_path return download_path
def recursive_search_files(directory: str): def recursive_search_files(directory: str, request):
files, folder_all = folder_paths.recursive_search( if not os.path.isdir(directory):
directory, excluded_dir_names=[".git"] return []
)
return [normalize_path(f) for f in files] excluded_dir_names = [".git"]
result = []
include_hidden_files = get_setting_value(request, "scan.include_hidden_files", False)
for dirpath, subdirs, filenames in os.walk(directory, followlinks=True, topdown=True):
subdirs[:] = [d for d in subdirs if d not in excluded_dir_names]
if not include_hidden_files:
subdirs[:] = [d for d in subdirs if not d.startswith(".")]
filenames[:] = [f for f in filenames if not f.startswith(".")]
for file_name in filenames:
try:
relative_path = os.path.relpath(os.path.join(dirpath, file_name), directory)
result.append(relative_path)
except:
logging.warning(f"Warning: Unable to access {file_name}. Skipping this file.")
continue
return [normalize_path(f) for f in result]
def search_files(directory: str): def search_files(directory: str):
@@ -220,6 +255,14 @@ def get_model_all_images(model_path: str):
def get_model_preview_name(model_path: str): def get_model_preview_name(model_path: str):
images = get_model_all_images(model_path) images = get_model_all_images(model_path)
basename = os.path.splitext(os.path.basename(model_path))[0]
for image in images:
image_name = os.path.splitext(image)[0]
image_ext = os.path.splitext(image)[1]
if image_name == basename and image_ext.lower() == ".webp":
return image
return images[0] if len(images) > 0 else "no-preview.png" return images[0] if len(images) > 0 else "no-preview.png"
@@ -227,19 +270,47 @@ from PIL import Image
from io import BytesIO from io import BytesIO
def save_model_preview_image(model_path: str, image_url: str): def remove_model_preview_image(model_path: str):
try: basename = os.path.splitext(model_path)[0]
image_response = requests.get(image_url) preview_path = f"{basename}.webp"
image_response.raise_for_status() if os.path.exists(preview_path):
os.remove(preview_path)
basename = os.path.splitext(model_path)[0]
preview_path = f"{basename}.webp" def save_model_preview_image(model_path: str, image_file_or_url: Any, platform: str | None = None):
image = Image.open(BytesIO(image_response.content)) basename = os.path.splitext(model_path)[0]
preview_path = f"{basename}.webp"
# Download image file if it is url
if type(image_file_or_url) is str:
image_url = image_file_or_url
try:
image_response = requests.get(image_url)
image_response.raise_for_status()
image = Image.open(BytesIO(image_response.content))
image.save(preview_path, "WEBP")
except Exception as e:
print_error(f"Failed to download image: {e}")
else:
# Assert image as file
image_file = image_file_or_url
if not isinstance(image_file, web.FileField):
raise RuntimeError("Invalid image file")
content_type: str = image_file.content_type
if not content_type.startswith("image/"):
if platform == "huggingface":
# huggingface previewFile content_type='text/plain', not startswith("image/")
return
else:
raise RuntimeError(f"FileTypeError: expected image, got {content_type}")
image = Image.open(image_file.file)
image.save(preview_path, "WEBP") image.save(preview_path, "WEBP")
except Exception as e:
print_error(f"Failed to download image: {e}")
def get_model_all_descriptions(model_path: str): def get_model_all_descriptions(model_path: str):
base_dirname = os.path.dirname(model_path) base_dirname = os.path.dirname(model_path)
@@ -267,12 +338,6 @@ def save_model_description(model_path: str, content: Any):
base_dirname = os.path.dirname(model_path) base_dirname = os.path.dirname(model_path)
# remove old descriptions
old_descriptions = get_model_all_descriptions(model_path)
for desc in old_descriptions:
description_path = join_path(base_dirname, desc)
os.remove(description_path)
# save new description # save new description
basename = os.path.splitext(os.path.basename(model_path))[0] basename = os.path.splitext(os.path.basename(model_path))[0]
extension = ".md" extension = ".md"

View File

@@ -1,8 +1,8 @@
[project] [project]
name = "comfyui-model-manager" name = "comfyui-model-manager"
description = "Manage models: browsing, download and delete." description = "Manage models: browsing, download and delete."
version = "2.1.0" version = "2.3.2"
license = "LICENSE" license = { file = "LICENSE" }
dependencies = ["markdownify"] dependencies = ["markdownify"]
[project.urls] [project.urls]
@@ -13,3 +13,6 @@ Repository = "https://github.com/hayden-fr/ComfyUI-Model-Manager"
PublisherId = "hayden" PublisherId = "hayden"
DisplayName = "ComfyUI-Model-Manager" DisplayName = "ComfyUI-Model-Manager"
Icon = "" Icon = ""
[tool.black]
line-length = 160

View File

@@ -15,16 +15,18 @@ import { useStoreProvider } from 'hooks/store'
import { useToast } from 'hooks/toast' import { useToast } from 'hooks/toast'
import GlobalConfirm from 'primevue/confirmdialog' import GlobalConfirm from 'primevue/confirmdialog'
import { $el, app, ComfyButton } from 'scripts/comfyAPI' import { $el, app, ComfyButton } from 'scripts/comfyAPI'
import { onMounted } from 'vue' import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const { t } = useI18n() const { t } = useI18n()
const { dialog, models, config, download } = useStoreProvider() const { dialog, models, config, download } = useStoreProvider()
const { toast } = useToast() const { toast } = useToast()
const firstOpenManager = ref(true)
onMounted(() => { onMounted(() => {
const refreshModelsAndConfig = async () => { const refreshModelsAndConfig = async () => {
await Promise.all([models.refresh(), config.refresh()]) await Promise.all([models.refresh(true)])
toast.add({ toast.add({
severity: 'success', severity: 'success',
summary: 'Refreshed Models', summary: 'Refreshed Models',
@@ -50,6 +52,11 @@ onMounted(() => {
const openManagerDialog = () => { const openManagerDialog = () => {
const { cardWidth, gutter, aspect } = config const { cardWidth, gutter, aspect } = config
if (firstOpenManager.value) {
models.refresh(true)
firstOpenManager.value = false
}
dialog.open({ dialog.open({
key: 'model-manager', key: 'model-manager',
title: t('modelManager'), title: t('modelManager'),

View File

@@ -69,7 +69,8 @@ import { useLoading } from 'hooks/loading'
import { request } from 'hooks/request' import { request } from 'hooks/request'
import { useToast } from 'hooks/toast' import { useToast } from 'hooks/toast'
import Button from 'primevue/button' import Button from 'primevue/button'
import { VersionModel } from 'types/typings' import { VersionModel, WithResolved } from 'types/typings'
import { previewUrlToFile } from 'utils/common'
import { ref } from 'vue' import { ref } from 'vue'
const { isMobile } = useConfig() const { isMobile } = useConfig()
@@ -87,15 +88,52 @@ const searchModelsByUrl = async () => {
} }
} }
const createDownTask = async (data: VersionModel) => { const createDownTask = async (data: WithResolved<VersionModel>) => {
loading.show() loading.show()
const formData = new FormData()
for (const key in data) {
if (Object.prototype.hasOwnProperty.call(data, key)) {
let value = data[key]
// set preview file
if (key === 'preview') {
if (value) {
const previewFile = await previewUrlToFile(value).catch(() => {
loading.hide()
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to download preview',
life: 5000,
})
throw new Error('Failed to download preview')
})
formData.append('previewFile', previewFile)
} else {
formData.append('previewFile', value)
}
continue
}
if (typeof value === 'object') {
value = JSON.stringify(value)
}
if (typeof value === 'number') {
value = value.toString()
}
formData.append(key, value)
}
}
await request('/model', { await request('/model', {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: formData,
}) })
.then(() => { .then(() => {
dialog.close({ key: 'model-manager-create-task' }) dialog.close()
}) })
.catch((e) => { .catch((e) => {
toast.add({ toast.add({

View File

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

View File

@@ -1,62 +1,49 @@
<template> <template>
<div <div
class="flex h-full flex-col gap-4 overflow-hidden @container/content" ref="contentContainer"
:style="{ class="flex h-full flex-col gap-4 overflow-hidden"
['--card-width']: `${cardWidth}px`,
['--gutter']: `${gutter}px`,
}"
v-resize="onContainerResize"
> >
<div <div
:class="[ class="grid grid-cols-1 justify-center gap-4 px-8"
'grid grid-cols-1 justify-center gap-4 px-8', :style="$content_lg(contentStyle)"
'@lg/content:grid-cols-[repeat(auto-fit,var(--card-width))]',
'@lg/content:gap-[var(--gutter)]',
'@lg/content:px-4',
]"
> >
<div class="col-span-full @container/toolbar"> <div ref="toolbarContainer" class="col-span-full">
<div :class="['flex flex-col gap-4', '@2xl/toolbar:flex-row']"> <div :class="['flex gap-4', $toolbar_2xl('flex-row', 'flex-col')]">
<ResponseInput <div class="flex-1">
v-model="searchContent" <ResponseInput
:placeholder="$t('searchModels')" v-model="searchContent"
:allow-clear="true" :placeholder="$t('searchModels')"
suffix-icon="pi pi-search" :allow-clear="true"
></ResponseInput> suffix-icon="pi pi-search"
></ResponseInput>
</div>
<div class="flex items-center justify-between gap-4 overflow-hidden"> <div class="flex items-center justify-between gap-4 overflow-hidden">
<ResponseSelect <ResponseSelect
v-model="currentType" v-model="currentType"
:items="typeOptions" :items="typeOptions"
:type="isMobile ? 'drop' : 'button'"
></ResponseSelect> ></ResponseSelect>
<ResponseSelect <ResponseSelect
v-model="sortOrder" v-model="sortOrder"
:items="sortOrderOptions" :items="sortOrderOptions"
></ResponseSelect> ></ResponseSelect>
<ResponseSelect
v-model="cardSizeFlag"
:items="cardSizeOptions"
></ResponseSelect>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<ResponseScroll <ResponseScroll :items="list" :itemSize="itemSize" class="h-full flex-1">
ref="responseScroll"
:items="list"
:itemSize="itemSize"
:row-key="(item) => item.map(genModelKey).join(',')"
class="h-full flex-1"
>
<template #item="{ item }"> <template #item="{ item }">
<div <div
:class="[ class="grid grid-cols-1 justify-center gap-8 px-8"
'grid grid-cols-1 justify-center gap-8 px-8', :style="contentStyle"
'@lg/content:grid-cols-[repeat(auto-fit,var(--card-width))]',
'@lg/content:gap-[var(--gutter)]',
'@lg/content:px-4',
]"
> >
<ModelCard <ModelCard
v-for="model in item" v-for="model in item.row"
:key="genModelKey(model)" :key="genModelKey(model)"
:model="model" :model="model"
></ModelCard> ></ModelCard>
@@ -75,31 +62,57 @@
</template> </template>
<script setup lang="ts" name="manager-dialog"> <script setup lang="ts" name="manager-dialog">
import { useElementSize } from '@vueuse/core'
import ModelCard from 'components/ModelCard.vue' import ModelCard from 'components/ModelCard.vue'
import ResponseInput from 'components/ResponseInput.vue' import ResponseInput from 'components/ResponseInput.vue'
import ResponseScroll from 'components/ResponseScroll.vue' import ResponseScroll from 'components/ResponseScroll.vue'
import ResponseSelect from 'components/ResponseSelect.vue' import ResponseSelect from 'components/ResponseSelect.vue'
import { useConfig } from 'hooks/config' import { configSetting, useConfig } from 'hooks/config'
import { useContainerQueries } from 'hooks/container'
import { useModels } from 'hooks/model' import { useModels } from 'hooks/model'
import { defineResizeCallback } from 'hooks/resize'
import { chunk } from 'lodash' import { chunk } from 'lodash'
import { app } from 'scripts/comfyAPI'
import { Model } from 'types/typings' import { Model } from 'types/typings'
import { genModelKey } from 'utils/model' import { genModelKey } from 'utils/model'
import { computed, ref, watch } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const { isMobile, cardWidth, gutter, aspect, modelFolders } = useConfig() const {
isMobile,
gutter,
cardSize,
cardSizeMap,
cardSizeFlag,
dialog: settings,
} = useConfig()
const { data } = useModels() const { data, folders } = useModels()
const { t } = useI18n() const { t } = useI18n()
const responseScroll = ref() const toolbarContainer = ref<HTMLElement | null>(null)
const { $2xl: $toolbar_2xl } = useContainerQueries(toolbarContainer)
const contentContainer = ref<HTMLElement | null>(null)
const { $lg: $content_lg } = useContainerQueries(contentContainer)
const searchContent = ref<string>() const searchContent = ref<string>()
const currentType = ref('all') const currentType = ref('all')
const typeOptions = computed(() => { const typeOptions = computed(() => {
return ['all', ...Object.keys(modelFolders.value)].map((type) => { const excludeScanTypes = app.ui?.settings.getSettingValue<string>(
configSetting.excludeScanTypes,
)
const customBlackList =
excludeScanTypes
?.split(',')
.map((type) => type.trim())
.filter(Boolean) ?? []
return [
'all',
...Object.keys(folders.value).filter(
(folder) => !customBlackList.includes(folder),
),
].map((type) => {
return { return {
label: type, label: type,
value: type, value: type,
@@ -124,26 +137,32 @@ const sortOrderOptions = ref(
}), }),
) )
watch([searchContent, currentType], () => {
responseScroll.value.init()
})
const itemSize = computed(() => { const itemSize = computed(() => {
let itemWidth = cardWidth let itemHeight = cardSize.value.height
let itemGutter = gutter let itemGutter = gutter
if (isMobile.value) { if (isMobile.value) {
const baseSize = 16 const baseSize = 16
itemWidth = window.innerWidth - baseSize * 2 * 2 itemHeight = window.innerWidth - baseSize * 2 * 2
itemGutter = baseSize * 2 itemGutter = baseSize * 2
} }
return itemWidth / aspect + itemGutter return itemHeight + itemGutter
}) })
const colSpan = ref(1) const { width } = useElementSize(contentContainer)
const colSpanWidth = ref(cardWidth)
const cols = computed(() => {
if (isMobile.value) {
return 1
}
const containerWidth = width.value
const itemWidth = cardSize.value.width
return Math.floor((containerWidth - gutter) / (itemWidth + gutter))
})
const list = computed(() => { const list = computed(() => {
const filterList = data.value.filter((model) => { const mergedList = Object.values(data.value).flat()
const filterList = mergedList.filter((model) => {
const showAllModel = currentType.value === 'all' const showAllModel = currentType.value === 'all'
const matchType = showAllModel || model.type === currentType.value const matchType = showAllModel || model.type === currentType.value
@@ -174,17 +193,38 @@ const list = computed(() => {
const sortedList = filterList.sort(sortStrategy) const sortedList = filterList.sort(sortStrategy)
return chunk(sortedList, colSpan.value) return chunk(sortedList, cols.value).map((row) => {
return { key: row.map(genModelKey).join(','), row }
})
}) })
const onContainerResize = defineResizeCallback((entries) => { const contentStyle = computed(() => ({
const entry = entries[0] gridTemplateColumns: `repeat(auto-fit, ${cardSize.value.width}px)`,
if (isMobile.value) { gap: `${gutter}px`,
colSpan.value = 1 paddingLeft: `1rem`,
} else { paddingRight: `1rem`,
const containerWidth = entry.contentRect.width }))
colSpan.value = Math.floor((containerWidth - gutter) / (cardWidth + gutter))
colSpanWidth.value = colSpan.value * (cardWidth + gutter) - gutter const cardSizeOptions = computed(() => {
const customSize = 'size.custom'
const customOptionMap = {
...cardSizeMap.value,
[customSize]: 'custom',
} }
return Object.keys(customOptionMap).map((key) => {
return {
label: t(key),
value: key,
command: () => {
if (key === customSize) {
settings.showCardSizeSetting()
} else {
cardSizeFlag.value = key
}
},
}
})
}) })
</script> </script>

View File

@@ -47,7 +47,7 @@ import ResponseScroll from 'components/ResponseScroll.vue'
import { useModelNodeAction, useModels } from 'hooks/model' import { useModelNodeAction, useModels } from 'hooks/model'
import { useRequest } from 'hooks/request' import { useRequest } from 'hooks/request'
import Button from 'primevue/button' import Button from 'primevue/button'
import { BaseModel, Model } from 'types/typings' import { BaseModel, Model, WithResolved } from 'types/typings'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
interface Props { interface Props {
@@ -72,7 +72,7 @@ const handleCancel = () => {
editable.value = false editable.value = false
} }
const handleSave = async (data: BaseModel) => { const handleSave = async (data: WithResolved<BaseModel>) => {
await update(modelContent.value, data) await update(modelContent.value, data)
editable.value = false editable.value = false
} }

View File

@@ -11,7 +11,8 @@
:max-width="item.maxWidth" :max-width="item.maxWidth"
:min-height="item.minHeight" :min-height="item.minHeight"
:max-height="item.maxHeight" :max-height="item.maxHeight"
:z-index="index" :auto-z-index="false"
:pt:mask:style="{ zIndex: baseZIndex + index + 1 }"
:pt:root:onMousedown="() => rise(item)" :pt:root:onMousedown="() => rise(item)"
@hide="() => close(item)" @hide="() => close(item)"
> >
@@ -42,6 +43,20 @@
import ResponseDialog from 'components/ResponseDialog.vue' import ResponseDialog from 'components/ResponseDialog.vue'
import { useDialog } from 'hooks/dialog' import { useDialog } from 'hooks/dialog'
import Button from 'primevue/button' import Button from 'primevue/button'
import { usePrimeVue } from 'primevue/config'
import { computed, onMounted } from 'vue'
const { stack, rise, close } = useDialog() const { stack, rise, close } = useDialog()
const { config } = usePrimeVue()
const baseZIndex = computed(() => {
return config.zIndex?.modal ?? 1100
})
onMounted(() => {
for (const key in config.zIndex) {
config.zIndex[key] = baseZIndex.value
}
})
</script> </script>

View File

@@ -49,15 +49,13 @@
<script setup lang="ts"> <script setup lang="ts">
import ResponseInput from 'components/ResponseInput.vue' import ResponseInput from 'components/ResponseInput.vue'
import ResponseSelect from 'components/ResponseSelect.vue' import ResponseSelect from 'components/ResponseSelect.vue'
import { useConfig } from 'hooks/config'
import { useModelBaseInfo } from 'hooks/model' import { useModelBaseInfo } from 'hooks/model'
import { computed } from 'vue' import { computed } from 'vue'
const editable = defineModel<boolean>('editable') const editable = defineModel<boolean>('editable')
const { modelFolders } = useConfig() const { baseInfo, pathIndex, basename, extension, type, modelFolders } =
useModelBaseInfo()
const { baseInfo, pathIndex, basename, extension, type } = useModelBaseInfo()
const typeOptions = computed(() => { const typeOptions = computed(() => {
return Object.keys(modelFolders.value).map((curr) => { return Object.keys(modelFolders.value).map((curr) => {

View File

@@ -1,6 +1,8 @@
<template> <template>
<div <div
class="group/card relative w-full cursor-pointer select-none preview-aspect" class="group/card relative cursor-pointer select-none"
:style="{ width: `${cardSize.width}px`, height: `${cardSize.height}px` }"
v-tooltip.top="{ value: model.basename, disabled: showModelName }"
@click.stop="openDetailDialog" @click.stop="openDetailDialog"
> >
<div class="h-full overflow-hidden rounded-lg"> <div class="h-full overflow-hidden rounded-lg">
@@ -18,9 +20,14 @@
<div class="pointer-events-none absolute left-0 top-0 h-full w-full p-4"> <div class="pointer-events-none absolute left-0 top-0 h-full w-full p-4">
<div class="relative h-full w-full text-white"> <div class="relative h-full w-full text-white">
<div class="absolute bottom-0 left-0"> <div v-show="showModelName" class="absolute bottom-0 left-0">
<div class="drop-shadow-[0px_2px_2px_rgba(0,0,0,0.75)]"> <div class="drop-shadow-[0px_2px_2px_rgba(0,0,0,0.75)]">
<div class="line-clamp-3 break-all text-2xl font-bold @lg:text-lg"> <div
:class="[
'line-clamp-3 break-all font-bold',
$lg('text-lg', 'text-2xl'),
]"
>
{{ model.basename }} {{ model.basename }}
</div> </div>
</div> </div>
@@ -28,13 +35,19 @@
<div class="absolute left-0 top-0 w-full"> <div class="absolute left-0 top-0 w-full">
<div class="flex flex-row items-start justify-between"> <div class="flex flex-row items-start justify-between">
<div class="flex items-center rounded-full bg-black/30 px-3 py-2"> <div
<div class="font-bold @lg:text-xs"> v-show="showModelType"
class="flex items-center rounded-full bg-black/30 px-3 py-2"
>
<div :class="['font-bold', $lg('text-xs')]">
{{ model.type }} {{ model.type }}
</div> </div>
</div> </div>
<div class="opacity-0 duration-300 group-hover/card:opacity-100"> <div
v-show="showToolButton"
class="opacity-0 duration-300 group-hover/card:opacity-100"
>
<div class="flex flex-col gap-4 *:pointer-events-auto"> <div class="flex flex-col gap-4 *:pointer-events-auto">
<Button <Button
icon="pi pi-plus" icon="pi pi-plus"
@@ -66,6 +79,8 @@
<script setup lang="ts"> <script setup lang="ts">
import DialogModelDetail from 'components/DialogModelDetail.vue' import DialogModelDetail from 'components/DialogModelDetail.vue'
import { useConfig } from 'hooks/config'
import { useContainerQueries } from 'hooks/container'
import { useDialog } from 'hooks/dialog' import { useDialog } from 'hooks/dialog'
import { useModelNodeAction } from 'hooks/model' import { useModelNodeAction } from 'hooks/model'
import Button from 'primevue/button' import Button from 'primevue/button'
@@ -79,6 +94,8 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
const { cardSize } = useConfig()
const dialog = useDialog() const dialog = useDialog()
const openDetailDialog = () => { const openDetailDialog = () => {
@@ -99,6 +116,20 @@ const preview = computed(() =>
: props.model.preview, : props.model.preview,
) )
const showToolButton = computed(() => {
return cardSize.value.width >= 180 && cardSize.value.height >= 240
})
const showModelName = computed(() => {
return cardSize.value.width >= 160 && cardSize.value.height >= 120
})
const showModelType = computed(() => {
return cardSize.value.width >= 120
})
const { addModelNode, dragToAddModelNode, copyModelNode, loadPreviewWorkflow } = const { addModelNode, dragToAddModelNode, copyModelNode, loadPreviewWorkflow } =
useModelNodeAction(props.model) useModelNodeAction(props.model)
const { $lg } = useContainerQueries()
</script> </script>

View File

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

View File

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

View File

@@ -1,316 +1,140 @@
<template> <template>
<div data-scroll-area class="group/scroll relative overflow-hidden"> <div class="group/scroll relative overflow-hidden">
<div <div ref="viewport" class="h-full w-full overflow-auto scrollbar-none">
ref="viewport" <div ref="content">
data-scroll-viewport
class="h-full w-full overflow-auto scrollbar-none"
:style="{ contain: items ? 'strict' : undefined }"
@scroll="onContentScroll"
v-resize="onContainerResize"
>
<div data-scroll-content class="relative min-w-full">
<slot name="default"> <slot name="default">
<div <slot v-if="renderedItems.length === 0" name="empty">
v-for="(item, index) in loadedItems"
:key="genRowKey(item, index)"
:style="{ height: `${itemSize}px` }"
>
<slot name="item" :item="item"></slot>
</div>
<slot v-if="loadedItems.length === 0" name="empty">
<div class="absolute w-full py-20 text-center">No Data</div> <div class="absolute w-full py-20 text-center">No Data</div>
</slot> </slot>
<div :style="{ height: `${headHeight}px` }"></div>
<div>
<div
v-for="item in renderedItems"
:key="item.key"
:style="{ height: `${itemSize}px` }"
data-virtual-item
>
<slot name="item" :item="item"></slot>
</div>
</div>
<div :style="{ height: `${tailHeight}px` }"></div>
</slot> </slot>
</div> </div>
<div
data-scroll-space
class="pointer-events-none absolute left-0 top-0 h-px w-px"
:style="spaceStyle"
></div>
</div> </div>
<div <div ref="scroll" class="absolute right-0 top-0 h-full w-2">
v-for="scroll in scrollbars"
:key="scroll.direction"
v-show="scroll.visible"
v-bind="{ [`data-scroll-bar-${scroll.direction}`]: '' }"
:class="[
'pointer-events-none absolute z-auto h-full w-full rounded-full',
'data-[scroll-bar-horizontal]:bottom-0 data-[scroll-bar-horizontal]:left-0 data-[scroll-bar-horizontal]:h-2',
'data-[scroll-bar-vertical]:right-0 data-[scroll-bar-vertical]:top-0 data-[scroll-bar-vertical]:w-2',
]"
>
<div <div
v-bind="{ ['data-scroll-thumb']: scroll.direction }" ref="thumb"
:class="[ :class="[
'pointer-events-auto absolute h-full w-full rounded-full', 'absolute w-full cursor-pointer rounded-full bg-gray-500',
'cursor-pointer bg-black dark:bg-white', 'opacity-0 transition-opacity duration-300 group-hover/scroll:opacity-30',
'opacity-0 transition-opacity duration-300 group-hover/scroll:opacity-10',
]" ]"
:style="{ :style="{
[scrollbarAttrs[scroll.direction].size]: `${scroll.size}px`, height: `${thumbSize}px`,
[scrollbarAttrs[scroll.direction].offset]: `${scroll.offset}px`, top: `${thumbOffset}px`,
opacity: isDragging ? 0.1 : '', opacity: isDragging ? '0.3' : undefined,
}" }"
@mousedown="startDragThumb"
></div> ></div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts" generic="T"> <script setup lang="ts" generic="T">
import { defineResizeCallback } from 'hooks/resize' import { useDraggable, useElementSize, useScroll } from '@vueuse/core'
import { clamp, throttle } from 'lodash' import { clamp } from 'lodash'
import { nextTick, onUnmounted, ref, watch } from 'vue' import { computed, ref } from 'vue'
interface ScrollAreaProps { interface ScrollAreaProps {
items?: T[][] items?: (T & { key: string })[]
itemSize?: number itemSize?: number
scrollbar?: boolean
rowKey?: string | ((item: T[]) => string)
} }
const props = withDefaults(defineProps<ScrollAreaProps>(), { const props = defineProps<ScrollAreaProps>()
scrollbar: true,
})
const emit = defineEmits(['scroll', 'resize'])
type ScrollbarDirection = 'horizontal' | 'vertical' const viewport = ref<HTMLElement | null>(null)
const content = ref<HTMLElement | null>(null)
interface Scrollbar { const { height: viewportHeight } = useElementSize(viewport)
direction: ScrollbarDirection const { height: contentHeight } = useElementSize(content)
visible: boolean const { y: scrollY } = useScroll(viewport)
size: number
offset: number
}
interface ScrollbarAttribute { const itemSize = computed(() => props.itemSize || 0)
clientSize: string
scrollOffset: string
pagePosition: string
offset: string
size: string
}
const scrollbarAttrs: Record<ScrollbarDirection, ScrollbarAttribute> = { const viewRows = computed(() =>
horizontal: { Math.ceil(viewportHeight.value / itemSize.value),
clientSize: 'clientWidth',
scrollOffset: 'scrollLeft',
pagePosition: 'pageX',
offset: 'left',
size: 'width',
},
vertical: {
clientSize: 'clientHeight',
scrollOffset: 'scrollTop',
pagePosition: 'pageY',
offset: 'top',
size: 'height',
},
}
const scrollbars = ref<Record<ScrollbarDirection, Scrollbar>>({
horizontal: {
direction: 'horizontal',
visible: props.scrollbar,
size: 0,
offset: 0,
},
vertical: {
direction: 'vertical',
visible: props.scrollbar,
size: 0,
offset: 0,
},
})
const isDragging = ref(false)
const spaceStyle = ref({})
const loadedItems = ref<T[][]>([])
const genRowKey = (item: any | any[], index: number) => {
if (typeof props.rowKey === 'function') {
return props.rowKey(item)
}
return item[props.rowKey ?? 'key'] ?? index
}
const setSpacerSize = () => {
const items = props.items
if (items) {
const itemSize = props.itemSize ?? 0
spaceStyle.value = { height: `${itemSize * items.length}px` }
} else {
spaceStyle.value = {}
}
}
const getContainerContent = (raw?: boolean): HTMLElement => {
const container = viewport.value as HTMLElement
if (props.items && !raw) {
return container.querySelector('[data-scroll-space]')!
}
return container.querySelector('[data-scroll-content]')!
}
const init = () => {
const container = viewport.value as HTMLElement
container.scrollTop = 0
getContainerContent().style.transform = ''
}
const calculateLoadItems = () => {
let visibleItems: any[] = []
if (props.items) {
const container = viewport.value as HTMLElement
const content = getContainerContent(true)
const resolveVisibleItems = (items: any[], attr: ScrollbarAttribute) => {
const containerSize = container[attr.clientSize]
const itemSize = props.itemSize!
const viewCount = Math.ceil(containerSize / itemSize)
let start = Math.floor(container[attr.scrollOffset] / itemSize)
const offset = start * itemSize
let end = start + viewCount
end = Math.min(end + viewCount, items.length)
content.style.transform = `translateY(${offset}px)`
return items.slice(start, end)
}
visibleItems = resolveVisibleItems(props.items, scrollbarAttrs.vertical)
}
loadedItems.value = visibleItems
}
const calculateScrollThumbSize = () => {
const container = viewport.value as HTMLElement
const content = getContainerContent()
const resolveScrollbarSize = (item: Scrollbar, attr: ScrollbarAttribute) => {
const containerSize: number = container[attr.clientSize]
const contentSize: number = content[attr.clientSize]
item.visible = props.scrollbar && contentSize > containerSize
item.size = Math.max(Math.pow(containerSize, 2) / contentSize, 16)
}
nextTick(() => {
resolveScrollbarSize(scrollbars.value.horizontal, scrollbarAttrs.horizontal)
resolveScrollbarSize(scrollbars.value.vertical, scrollbarAttrs.vertical)
})
}
const onContainerResize = defineResizeCallback((entries) => {
emit('resize', entries)
if (isDragging.value) return
calculateScrollThumbSize()
})
const onContentScroll = throttle((event: Event) => {
emit('scroll', event)
if (isDragging.value) return
const container = event.target as HTMLDivElement
const content = getContainerContent()
const resolveOffset = (item: Scrollbar, attr: ScrollbarAttribute) => {
const containerSize = container[attr.clientSize]
const contentSize = content[attr.clientSize]
const scrollOffset = container[attr.scrollOffset]
item.offset =
(scrollOffset / (contentSize - containerSize)) *
(containerSize - item.size)
}
resolveOffset(scrollbars.value.horizontal, scrollbarAttrs.horizontal)
resolveOffset(scrollbars.value.vertical, scrollbarAttrs.vertical)
calculateLoadItems()
})
const viewport = ref<HTMLElement>()
const draggingDirection = ref<ScrollbarDirection>()
const prevDraggingEvent = ref<MouseEvent>()
const moveThumb = throttle((event: MouseEvent) => {
if (isDragging.value) {
const container = viewport.value!
const content = getContainerContent()
const resolveOffset = (item: Scrollbar, attr: ScrollbarAttribute) => {
const containerSize = container[attr.clientSize]
const contentSize = content[attr.clientSize]
// Resolve thumb position
const prevPagePos = prevDraggingEvent.value![attr.pagePosition]
const currPagePos = event[attr.pagePosition]
const offset = currPagePos - prevPagePos
item.offset = clamp(item.offset + offset, 0, containerSize - item.size)
// Resolve scroll position
const scrollOffset = containerSize - item.size
const offsetSize = contentSize - containerSize
container[attr.scrollOffset] = (item.offset / scrollOffset) * offsetSize
}
const scrollDirection = draggingDirection.value!
resolveOffset(
scrollbars.value[scrollDirection],
scrollbarAttrs[scrollDirection],
)
prevDraggingEvent.value = event
calculateLoadItems()
}
})
const stopMoveThumb = () => {
isDragging.value = false
draggingDirection.value = undefined
prevDraggingEvent.value = undefined
document.removeEventListener('mousemove', moveThumb)
document.removeEventListener('mouseup', stopMoveThumb)
document.body.style.userSelect = ''
document.body.style.cursor = ''
}
const startDragThumb = (event: MouseEvent) => {
isDragging.value = true
const target = event.target as HTMLElement
draggingDirection.value = <any>target.getAttribute('data-scroll-thumb')
prevDraggingEvent.value = event
document.addEventListener('mousemove', moveThumb)
document.addEventListener('mouseup', stopMoveThumb)
document.body.style.userSelect = 'none'
document.body.style.cursor = 'default'
}
watch(
() => props.items,
() => {
setSpacerSize()
calculateScrollThumbSize()
calculateLoadItems()
},
) )
const offsetRows = computed(() => Math.floor(scrollY.value / itemSize.value))
onUnmounted(() => { const items = computed(() => {
stopMoveThumb() return props.items ?? []
}) })
defineExpose({ const state = computed(() => {
viewport, const bufferRows = viewRows.value
init,
const fromRow = offsetRows.value - bufferRows
const toRow = offsetRows.value + bufferRows + viewRows.value
const itemCount = items.value.length
return {
start: clamp(fromRow, 0, itemCount),
end: clamp(toRow, fromRow, itemCount),
}
})
const renderedItems = computed(() => {
const { start, end } = state.value
return props.items?.slice(start, end) ?? []
})
const headHeight = computed(() => {
return state.value.start * itemSize.value
})
const tailHeight = computed(() => {
return (items.value.length - state.value.end) * itemSize.value
})
const thumbSize = computed(() => {
if (viewportHeight.value >= contentHeight.value) {
return 0
}
const thumbHeight = Math.pow(viewportHeight.value, 2) / contentHeight.value
return Math.max(thumbHeight, 16)
})
const thumbOffset = computed({
get: () => {
return (
(scrollY.value / (contentHeight.value - viewportHeight.value)) *
(viewportHeight.value - thumbSize.value)
)
},
set: (offset) => {
scrollY.value =
(offset / (viewportHeight.value - thumbSize.value)) *
(contentHeight.value - viewportHeight.value)
},
})
const scroll = ref<HTMLElement | null>(null)
const thumb = ref<HTMLElement | null>(null)
const { isDragging } = useDraggable(thumb, {
axis: 'y',
containerElement: scroll,
onStart: () => {
document.body.style.userSelect = 'none'
},
onMove: (position) => {
thumbOffset.value = position.y
},
onEnd: () => {
document.body.style.userSelect = ''
},
}) })
</script> </script>

View File

@@ -27,12 +27,7 @@
</slot> </slot>
<div v-else class="relative flex-1 overflow-hidden"> <div v-else class="relative flex-1 overflow-hidden">
<div <div ref="scrollArea" class="h-full w-full overflow-auto scrollbar-none">
ref="scrollArea"
class="h-full w-full overflow-auto scrollbar-none"
v-resize="checkScrollPosition"
@scroll="checkScrollPosition"
>
<div ref="contentArea" class="table max-w-full"> <div ref="contentArea" class="table max-w-full">
<div <div
v-show="showControlButton && scrollPosition !== 'left'" v-show="showControlButton && scrollPosition !== 'left'"
@@ -130,7 +125,13 @@
<slot v-else name="desktop"> <slot v-else name="desktop">
<slot name="container"> <slot name="container">
<slot name="desktop:container"> <slot name="desktop:container">
<Menu ref="menu" :model="items" :popup="true" :base-z-index="1000"> <Menu
ref="menu"
:model="items"
:popup="true"
:base-z-index="1000"
:pt:root:style="{ maxHeight: '300px', overflowX: 'hidden' }"
>
<template #item="{ item }"> <template #item="{ item }">
<slot name="item" :item="item"> <slot name="item" :item="item">
<slot name="desktop:container:item" :item="item"> <slot name="desktop:container:item" :item="item">
@@ -150,12 +151,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useElementSize, useScroll } from '@vueuse/core'
import { useConfig } from 'hooks/config' import { useConfig } from 'hooks/config'
import Button, { ButtonProps } from 'primevue/button' import Button, { ButtonProps } from 'primevue/button'
import Drawer from 'primevue/drawer' import Drawer from 'primevue/drawer'
import Menu from 'primevue/menu' import Menu from 'primevue/menu'
import { SelectOptions } from 'types/typings' import { SelectOptions } from 'types/typings'
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
const current = defineModel() const current = defineModel()
@@ -196,7 +198,7 @@ const toggle = (event: MouseEvent) => {
} }
// Select Button Type // Select Button Type
const scrollArea = ref() const scrollArea = ref<HTMLElement | null>(null)
const contentArea = ref() const contentArea = ref()
type ScrollPosition = 'left' | 'right' type ScrollPosition = 'left' | 'right'
@@ -236,4 +238,16 @@ const checkScrollPosition = () => {
scrollPosition.value = position scrollPosition.value = position
showControlButton.value = contentWidth > containerWidth showControlButton.value = contentWidth > containerWidth
} }
const { width, height } = useElementSize(scrollArea)
watch([width, height], () => {
checkScrollPosition()
})
useScroll(scrollArea, {
onScroll: () => {
checkScrollPosition()
},
})
</script> </script>

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

View File

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

View File

@@ -1,13 +1,12 @@
import { useConfig } from 'hooks/config'
import { useLoading } from 'hooks/loading' import { useLoading } from 'hooks/loading'
import { useMarkdown } from 'hooks/markdown' import { useMarkdown } from 'hooks/markdown'
import { request, useRequest } from 'hooks/request' import { request } from 'hooks/request'
import { defineStore } from 'hooks/store' import { defineStore } from 'hooks/store'
import { useToast } from 'hooks/toast' import { useToast } from 'hooks/toast'
import { cloneDeep } from 'lodash' import { castArray, cloneDeep } from 'lodash'
import { app } from 'scripts/comfyAPI' import { app } from 'scripts/comfyAPI'
import { BaseModel, Model, SelectEvent } from 'types/typings' import { BaseModel, Model, SelectEvent, WithResolved } from 'types/typings'
import { bytesToSize, formatDate } from 'utils/common' import { bytesToSize, formatDate, previewUrlToFile } from 'utils/common'
import { ModelGrid } from 'utils/legacy' import { ModelGrid } from 'utils/legacy'
import { genModelKey, resolveModelTypeLoader } from 'utils/model' import { genModelKey, resolveModelTypeLoader } from 'utils/model'
import { import {
@@ -21,25 +20,84 @@ import {
unref, unref,
} from 'vue' } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { configSetting } from './config'
type ModelFolder = Record<string, string[]>
const modelFolderProvideKey = Symbol('modelFolder')
export const useModels = defineStore('models', (store) => { export const useModels = defineStore('models', (store) => {
const { data, refresh } = useRequest<Model[]>('/models', { defaultValue: [] })
const { toast, confirm } = useToast() const { toast, confirm } = useToast()
const { t } = useI18n() const { t } = useI18n()
const loading = useLoading() const loading = useLoading()
const updateModel = async (model: BaseModel, data: BaseModel) => { const folders = ref<ModelFolder>({})
const updateData = new Map() const refreshFolders = async () => {
return request('/models').then((resData) => {
folders.value = resData
})
}
provide(modelFolderProvideKey, folders)
const models = ref<Record<string, Model[]>>({})
const refreshModels = async (folder: string) => {
loading.show(folder)
return request(`/models/${folder}`)
.then((resData) => {
models.value[folder] = resData
return resData
})
.finally(() => {
loading.hide(folder)
})
}
const refreshAllModels = async (force = false) => {
const forceRefresh = force ? refreshFolders() : Promise.resolve()
models.value = {}
const excludeScanTypes = app.ui?.settings.getSettingValue<string>(
configSetting.excludeScanTypes,
)
const customBlackList =
excludeScanTypes
?.split(',')
.map((type) => type.trim())
.filter(Boolean) ?? []
return forceRefresh.then(() =>
Promise.allSettled(
Object.keys(folders.value)
.filter((folder) => !customBlackList.includes(folder))
.map(refreshModels),
),
)
}
const updateModel = async (
model: BaseModel,
data: WithResolved<BaseModel>,
) => {
const updateData = new FormData()
let oldKey: string | null = null let oldKey: string | null = null
let needUpdate = false
// Check current preview // Check current preview
if (model.preview !== data.preview) { if (model.preview !== data.preview) {
updateData.set('previewFile', data.preview) const preview = data.preview
if (preview) {
const previewFile = await previewUrlToFile(data.preview as string)
updateData.set('previewFile', previewFile)
} else {
updateData.set('previewFile', 'undefined')
}
needUpdate = true
} }
// Check current description // Check current description
if (model.description !== data.description) { if (model.description !== data.description) {
updateData.set('description', data.description) updateData.set('description', data.description)
needUpdate = true
} }
// Check current name and pathIndex // Check current name and pathIndex
@@ -51,16 +109,17 @@ export const useModels = defineStore('models', (store) => {
updateData.set('type', data.type) updateData.set('type', data.type)
updateData.set('pathIndex', data.pathIndex.toString()) updateData.set('pathIndex', data.pathIndex.toString())
updateData.set('fullname', data.fullname) updateData.set('fullname', data.fullname)
needUpdate = true
} }
if (updateData.size === 0) { if (!needUpdate) {
return return
} }
loading.show() loading.show()
await request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, { await request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(Object.fromEntries(updateData.entries())), body: updateData,
}) })
.catch((err) => { .catch((err) => {
const error_message = err.message ?? err.error const error_message = err.message ?? err.error
@@ -80,7 +139,7 @@ export const useModels = defineStore('models', (store) => {
store.dialog.close({ key: oldKey }) store.dialog.close({ key: oldKey })
} }
refresh() refreshModels(data.type)
} }
const deleteModel = async (model: BaseModel) => { const deleteModel = async (model: BaseModel) => {
@@ -112,7 +171,7 @@ export const useModels = defineStore('models', (store) => {
life: 2000, life: 2000,
}) })
store.dialog.close({ key: dialogKey }) store.dialog.close({ key: dialogKey })
return refresh() return refreshModels(model.type)
}) })
.then(() => { .then(() => {
resolve(void 0) resolve(void 0)
@@ -136,7 +195,13 @@ export const useModels = defineStore('models', (store) => {
}) })
} }
return { data, refresh, remove: deleteModel, update: updateModel } return {
folders: folders,
data: models,
refresh: refreshAllModels,
remove: deleteModel,
update: updateModel,
}
}) })
declare module 'hooks/store' { declare module 'hooks/store' {
@@ -164,15 +229,15 @@ export const useModelFormData = (getFormData: () => BaseModel) => {
} }
} }
type SubmitCallback = (data: BaseModel) => void type SubmitCallback = (data: WithResolved<BaseModel>) => void
const submitCallback = ref<SubmitCallback[]>([]) const submitCallback = ref<SubmitCallback[]>([])
const registerSubmit = (callback: SubmitCallback) => { const registerSubmit = (callback: SubmitCallback) => {
submitCallback.value.push(callback) submitCallback.value.push(callback)
} }
const submit = () => { const submit = (): WithResolved<BaseModel> => {
const data = cloneDeep(toRaw(unref(formData))) const data: any = cloneDeep(toRaw(unref(formData)))
for (const callback of submitCallback.value) { for (const callback of submitCallback.value) {
callback(data) callback(data)
} }
@@ -204,7 +269,10 @@ const baseInfoKey = Symbol('baseInfo') as InjectionKey<
export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => { export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
const { formData: model, modelData } = formInstance const { formData: model, modelData } = formInstance
const { modelFolders } = useConfig() const provideModelFolders = inject<any>(modelFolderProvideKey)
const modelFolders = computed<ModelFolder>(() => {
return provideModelFolders?.value ?? {}
})
const type = computed({ const type = computed({
get: () => { get: () => {
@@ -304,6 +372,7 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
basename, basename,
extension, extension,
pathIndex, pathIndex,
modelFolders,
} }
provide(baseInfoKey, result) provide(baseInfoKey, result)
@@ -338,9 +407,7 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
* Default images * Default images
*/ */
const defaultContent = computed(() => { const defaultContent = computed(() => {
return Array.isArray(model.value.preview) return model.value.preview ? castArray(model.value.preview) : []
? model.value.preview
: [model.value.preview]
}) })
const defaultContentPage = ref(0) const defaultContentPage = ref(0)
@@ -379,7 +446,7 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
content = localContent.value content = localContent.value
break break
default: default:
content = noPreviewContent.value content = undefined
break break
} }
@@ -395,7 +462,7 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
}) })
registerSubmit((data) => { registerSubmit((data) => {
data.preview = preview.value ?? noPreviewContent.value data.preview = preview.value
}) })
}) })

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

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,10 @@ export interface VersionModel extends BaseModel {
hashes?: Record<string, string> hashes?: Record<string, string>
} }
export type WithResolved<T> = Omit<T, 'preview'> & {
preview: string | undefined
}
export type PassThrough<T = void> = T | object | undefined export type PassThrough<T = void> = T | object | undefined
export interface SelectOptions { export interface SelectOptions {

View File

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

View File

@@ -1,4 +1,3 @@
import container from '@tailwindcss/container-queries'
import plugin from 'tailwindcss/plugin' import plugin from 'tailwindcss/plugin'
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
@@ -8,7 +7,6 @@ export default {
darkMode: ['selector', '.dark-theme'], darkMode: ['selector', '.dark-theme'],
plugins: [ plugins: [
container,
plugin(({ addUtilities }) => { plugin(({ addUtilities }) => {
addUtilities({ addUtilities({
'.scrollbar-none': { '.scrollbar-none': {