47 Commits

Author SHA1 Message Date
Hayden
8d7e32eaf6 Prepare release 2.6.3 2025-04-29 17:32:13 +08:00
Hayden
e964f26798 Secure storage API keys (#181)
* Migrate api key to private.key

* Optimize API Key setting
2025-04-29 17:30:36 +08:00
Hayden
3cfbb5ac0e 178 bug borderline race condition (#179)
* Add assets files

* Prepare release 2.6.2
2025-04-21 17:17:28 +08:00
Hayden
4472357537 176 bug the version selection panel didnt show up (#177)
* Remove custom z-index

* Prepare release 2.6.1
2025-04-21 10:54:00 +08:00
hayden
aabf3f99b3 Add browser version collection 2025-04-21 10:34:11 +08:00
hayden
6bd6b19c1d format code 2025-04-21 09:54:30 +08:00
Hayden
411219df7d Update publish.yml 2025-04-10 13:15:13 +08:00
Hayden
cc29349aee Prepare release 2.6.0 2025-04-10 13:08:46 +08:00
Hayden
f639e3c795 Support extension gguf (#175) 2025-04-10 13:07:54 +08:00
Hayden
5251eeaa93 Refactor scan infomation featurn (#174)
* feat: add scanning setting panel

* feat: implement the back-end interface

* feat: add i18n-zh

* chore: remove never used code
2025-04-10 13:07:33 +08:00
hayden
3bfc6c28af chore(deps): update vue 2025-04-08 10:32:57 +08:00
hayden
c91eff16ae Remove build sourcemap 2025-04-08 10:20:11 +08:00
hayden
2d638a3451 Add issue template 2025-04-08 10:16:53 +08:00
hayden
280b6ed7c0 Delete action permission restrictions 2025-04-08 10:15:35 +08:00
Hayden
7de73ae09c Cancel Release 2025-03-28 20:49:15 +08:00
Hayden
0fdea64c79 Update publish.yml 2025-03-28 20:47:24 +08:00
Hayden
2b9327e6ca Update publish.yml 2025-03-28 20:34:51 +08:00
Hayden
c33b4e0333 Update publish.yml 2025-03-28 20:25:02 +08:00
Hayden
6dcaed7764 Update publish.yml 2025-03-28 20:22:11 +08:00
Hayden
ab4e0d38e1 Error file size (#170)
* Information providing wrong file size

* prepare release 2.5.5
2025-03-28 17:23:41 +08:00
Robin Huang
581d2c14fc chore(publish): update GitHub Actions workflow for conditional execution and permissions (#168)
- Added permissions to allow writing to issues.
- Introduced conditional execution for the publish-node job based on repository owner.
- Updated checkout action to version 4.

Co-authored-by: snomiao <snomiao+comfy-pr@gmail.com>
2025-03-28 11:29:10 +08:00
Hayden
811f1bc352 Support optional in py3.9 (#165)
* fix: support optional in py3.9

* prepare release 2.5.4
2025-03-14 17:02:54 +08:00
Hayden
5342b7ec92 fix: miss property (#163) 2025-03-06 10:34:30 +08:00
Hayden
30e1714397 159 python version compatible (#160)
* fix: double quotes nest in f-strings

* prepare release 2.5.3
2025-03-04 15:17:20 +08:00
Hayden
384a106917 pref: optimize dialog property (#158) 2025-03-03 17:02:03 +08:00
Hayden
7378a7deae Feat optimize preview (#156)
* pref: change code structure

* feat(information): support gif preview

* feat(information): support video preview
2025-03-03 14:50:06 +08:00
Hayden
1975e2056d 152 cant click through some nested dirs in tree view (#157)
* fix: basename error

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

* feat: add new ui toggle setting

* feat: add tree display ui

* feat: add search and sort

* feat: change model data structure

* pref: Optimize model data structure

* feat: set sub folder by choose
2025-02-18 16:03:07 +08:00
Hayden
37be9a0b0d prepare release 2.3.4 2025-02-18 16:01:49 +08:00
Hayden
fcea052dde fix: resolve path (#132) 2025-02-10 17:00:08 +08:00
Hayden
9e95e7bd74 style: optimize style (#131) 2025-02-10 16:42:53 +08:00
39 changed files with 2611 additions and 791 deletions

86
.github/ISSUE_TEMPLATE/bug-report.yaml vendored Normal file
View File

@@ -0,0 +1,86 @@
name: Bug Report
description: 'Something is not behaving as expected.'
title: '[Bug]: '
body:
- type: markdown
attributes:
value: |
Before submitting a **Bug Report**, please ensure the following:
- **1:** You are running the latest version of ComfyUI-Model-Manager.
- **2:** You have looked at the existing bug reports and made sure this isn't already reported.
- **3:** You confirmed that the bug is not caused by other custom nodes.
- type: textarea
id: environment
attributes:
label: Environment
description: 'Describe as detailed as possible what your current usage environment is. local? cloud? etc...'
value: |
[Operating System]:
[Python Version]:
[ComfyUI Version]:
[ComfyUI Frontend Version]:
[ComfyUI-Model-Manager Version]:
[Browser Version]:
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: 'What you expected to happen.'
validations:
required: true
- type: textarea
attributes:
label: Actual Behavior
description: 'What actually happened. Please include a screenshot / video clip of the issue if possible.'
validations:
required: true
- type: textarea
attributes:
label: Steps to Reproduce
description: "Describe how to reproduce the issue. Please be sure to attach a workflow JSON or PNG, ideally one that doesn't require custom nodes to test. If the bug open happens when certain custom nodes are used, most likely that custom node is what has the bug rather than ComfyUI, in which case it should be reported to the node's author."
validations:
required: true
- type: textarea
attributes:
label: Debug Logs
description: 'Please copy the output from your terminal logs here.'
render: powershell
validations:
required: true
- type: textarea
attributes:
label: Browser Logs
description: 'Please copy the output from your browser logs here. You can access this by pressing F12 to toggle the developer tools, then navigating to the Console tab.'
validations:
required: true
- type: textarea
attributes:
label: Setting JSON
description: 'Please upload the setting file here. The setting file is located at `user/default/comfy.settings.json`'
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: What browsers do you use to access the UI ?
multiple: true
options:
- Mozilla Firefox
- Google Chrome
- Brave
- Apple Safari
- Microsoft Edge
- Android
- iOS
- Other
- type: textarea
attributes:
label: Other
description: 'Any other additional information you think might be helpful.'
validations:
required: false

View File

@@ -0,0 +1,39 @@
name: Feature Request
description: Suggest an idea for this project
title: '[Feature Request]: '
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the feature you want, and that it's not implemented in a recent build/commit.
options:
- label: I have searched the existing issues and checked the recent builds/commits
required: true
- type: markdown
attributes:
value: |
*Please fill this form with as much information as possible, provide screenshots and/or illustrations of the feature if possible*
- type: textarea
id: feature
attributes:
label: What would your feature do ?
description: Tell us about your feature in a very clear and simple way, and what problem it would solve
validations:
required: true
- type: textarea
id: workflow
attributes:
label: Proposed workflow
description: Please provide us with step by step information on how you'd like the feature to be accessed and used
value: |
1. Go to ....
2. Press ....
3. ...
validations:
required: true
- type: textarea
id: misc
attributes:
label: Additional information
description: Add any other context or screenshots about the feature request here.

View File

@@ -11,6 +11,7 @@ jobs:
publish-node:
name: Release and Publish Custom Node to registry
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'hayden-fr' }}
steps:
- name: Check out code
uses: actions/checkout@v4
@@ -60,7 +61,7 @@ jobs:
run: |
pnpm install
pnpm run build
tar -czf dist.tar.gz py/ web/ __init__.py LICENSE pyproject.toml requirements.txt
tar -czf dist.tar.gz assets/ py/ web/ __init__.py LICENSE pyproject.toml requirements.txt
- name: Create release draft
uses: softprops/action-gh-release@v2

3
.gitignore vendored
View File

@@ -197,3 +197,6 @@ web/
# config
config/
# private info
private.key

View File

@@ -1,3 +1,11 @@
import folder_paths
# NOTE: This is an experiment
# Add .gguf extension to supported_pt_extensions
folder_paths.supported_pt_extensions.add(".gguf")
import os
from .py import config
from .py import utils

View File

@@ -39,8 +39,8 @@
"markdown-it": "^14.1.0",
"markdown-it-metadata-block": "^1.0.6",
"primevue": "^4.0.7",
"vue": "^3.4.31",
"vue-i18n": "^9.13.1",
"vue": "^3.5.6",
"vue-i18n": "^9.14.0",
"yaml": "^2.6.0"
},
"lint-staged": {

14
pnpm-lock.yaml generated
View File

@@ -30,10 +30,10 @@ importers:
specifier: ^4.0.7
version: 4.0.7(vue@3.5.6(typescript@5.6.2))
vue:
specifier: ^3.4.31
specifier: ^3.5.6
version: 3.5.6(typescript@5.6.2)
vue-i18n:
specifier: ^9.13.1
specifier: ^9.14.0
version: 9.14.0(vue@3.5.6(typescript@5.6.2))
yaml:
specifier: ^2.6.0
@@ -705,8 +705,8 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
caniuse-lite@1.0.30001662:
resolution: {integrity: sha512-sgMUVwLmGseH8ZIrm1d51UbrhqMCH3jvS7gF/M6byuHOnKyLOBL7W8yz5V02OHwgLGA36o/AFhWzzh4uc5aqTA==}
caniuse-lite@1.0.30001712:
resolution: {integrity: sha512-MBqPpGYYdQ7/hfKiet9SCI+nmN5/hp4ZzveOJubl5DTAMa5oggjAuoi0Z4onBpKPFI2ePGnQuQIzF3VxDjDJig==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
@@ -2271,7 +2271,7 @@ snapshots:
autoprefixer@10.4.20(postcss@8.4.47):
dependencies:
browserslist: 4.23.3
caniuse-lite: 1.0.30001662
caniuse-lite: 1.0.30001712
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.1.0
@@ -2299,7 +2299,7 @@ snapshots:
browserslist@4.23.3:
dependencies:
caniuse-lite: 1.0.30001662
caniuse-lite: 1.0.30001712
electron-to-chromium: 1.5.25
node-releases: 2.0.18
update-browserslist-db: 1.1.0(browserslist@4.23.3)
@@ -2308,7 +2308,7 @@ snapshots:
camelcase-css@2.0.1: {}
caniuse-lite@1.0.30001662: {}
caniuse-lite@1.0.30001712: {}
chalk@4.1.2:
dependencies:

View File

@@ -2,9 +2,17 @@ import os
import uuid
import time
import requests
import base64
import folder_paths
from typing import Callable, Awaitable, Any, Literal, Union, Optional
from dataclasses import dataclass
from aiohttp import web
from . import config
from . import utils
from . import thread
@@ -87,332 +95,68 @@ class TaskContent:
}
download_model_task_status: dict[str, TaskStatus] = {}
download_thread_pool = thread.DownloadThreadPool()
class ApiKey:
__store: dict[str, str] = {}
def set_task_content(task_id: str, task_content: Union[TaskContent, dict]):
download_path = utils.get_download_path()
task_file_path = utils.join_path(download_path, f"{task_id}.task")
utils.save_dict_pickle_file(task_file_path, task_content)
def __init__(self):
self.__cache_file = os.path.join(config.extension_uri, "private.key")
def init(self, request):
# Try to migrate api key from user setting
if not os.path.exists(self.__cache_file):
self.__store = {
"civitai": utils.get_setting_value(request, "api_key.civitai"),
"huggingface": utils.get_setting_value(request, "api_key.huggingface"),
}
self.__update__()
# Remove api key from user setting
utils.set_setting_value(request, "api_key.civitai", None)
utils.set_setting_value(request, "api_key.huggingface", None)
self.__store = utils.load_dict_pickle_file(self.__cache_file)
# Desensitization returns
result: dict[str, str] = {}
for key in self.__store:
v = self.__store[key]
if v is not None:
result[key] = v[:4] + "****" + v[-4:]
return result
def get_task_content(task_id: str):
download_path = utils.get_download_path()
task_file = utils.join_path(download_path, f"{task_id}.task")
if not os.path.isfile(task_file):
raise RuntimeError(f"Task {task_id} not found")
task_content = utils.load_dict_pickle_file(task_file)
if isinstance(task_content, TaskContent):
return task_content
return TaskContent(**task_content)
def get_value(self, key: str):
return self.__store.get(key, None)
def set_value(self, key: str, value: str):
self.__store[key] = value
self.__update__()
def get_task_status(task_id: str):
task_status = download_model_task_status.get(task_id, None)
if task_status is None:
download_path = utils.get_download_path()
task_content = get_task_content(task_id)
download_file = utils.join_path(download_path, f"{task_id}.download")
download_size = 0
if os.path.exists(download_file):
download_size = os.path.getsize(download_file)
total_size = task_content.sizeBytes
task_status = TaskStatus(
taskId=task_id,
type=task_content.type,
fullname=task_content.fullname,
preview=utils.get_model_preview_name(download_file),
platform=task_content.downloadPlatform,
downloadedSize=download_size,
totalSize=task_content.sizeBytes,
progress=download_size / total_size * 100 if total_size > 0 else 0,
)
download_model_task_status[task_id] = task_status
return task_status
def delete_task_status(task_id: str):
download_model_task_status.pop(task_id, None)
async def scan_model_download_task_list():
"""
Scan the download directory and send the task list to the client.
"""
download_dir = utils.get_download_path()
task_files = utils.search_files(download_dir)
task_files = folder_paths.filter_files_extensions(task_files, [".task"])
task_files = sorted(
task_files,
key=lambda x: os.stat(utils.join_path(download_dir, x)).st_ctime,
reverse=True,
)
task_list: list[dict] = []
for task_file in task_files:
task_id = task_file.replace(".task", "")
task_status = get_task_status(task_id)
task_list.append(task_status.to_dict())
return task_list
async def create_model_download_task(task_data: dict, request):
"""
Creates a download task for the given data.
"""
model_type = task_data.get("type", None)
path_index = int(task_data.get("pathIndex", None))
fullname = task_data.get("fullname", None)
model_path = utils.get_full_path(model_type, path_index, fullname)
# Check if the model path is valid
if os.path.exists(model_path):
raise RuntimeError(f"File already exists: {model_path}")
download_path = utils.get_download_path()
task_id = uuid.uuid4().hex
task_path = utils.join_path(download_path, f"{task_id}.task")
if os.path.exists(task_path):
raise RuntimeError(f"Task {task_id} already exists")
download_platform = task_data.get("downloadPlatform", None)
try:
preview_file = task_data.pop("previewFile", None)
utils.save_model_preview_image(task_path, preview_file, download_platform)
set_task_content(task_id, task_data)
task_status = TaskStatus(
taskId=task_id,
type=model_type,
fullname=fullname,
preview=utils.get_model_preview_name(task_path),
platform=download_platform,
totalSize=float(task_data.get("sizeBytes", 0)),
)
download_model_task_status[task_id] = task_status
await utils.send_json("create_download_task", task_status.to_dict())
except Exception as e:
await delete_model_download_task(task_id)
raise RuntimeError(str(e)) from e
await download_model(task_id, request)
return task_id
async def pause_model_download_task(task_id: str):
task_status = get_task_status(task_id=task_id)
task_status.status = "pause"
async def delete_model_download_task(task_id: str):
task_status = get_task_status(task_id)
is_running = task_status.status == "doing"
task_status.status = "waiting"
await utils.send_json("delete_download_task", task_id)
# Pause the task
if is_running:
task_status.status = "pause"
time.sleep(1)
download_dir = utils.get_download_path()
task_file_list = os.listdir(download_dir)
for task_file in task_file_list:
task_file_target = os.path.splitext(task_file)[0]
if task_file_target == task_id:
delete_task_status(task_id)
os.remove(utils.join_path(download_dir, task_file))
await utils.send_json("delete_download_task", task_id)
async def download_model(task_id: str, request):
async def download_task(task_id: str):
async def report_progress(task_status: TaskStatus):
await utils.send_json("update_download_task", task_status.to_dict())
try:
# When starting a task from the queue, the task may not exist
task_status = get_task_status(task_id)
except:
return
# Update task status
task_status.status = "doing"
await utils.send_json("update_download_task", task_status.to_dict())
try:
# Set download request headers
headers = {"User-Agent": config.user_agent}
download_platform = task_status.platform
if download_platform == "civitai":
api_key = utils.get_setting_value(request, "api_key.civitai")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
elif download_platform == "huggingface":
api_key = utils.get_setting_value(request, "api_key.huggingface")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
progress_interval = 1.0
await download_model_file(
task_id=task_id,
headers=headers,
progress_callback=report_progress,
interval=progress_interval,
)
except Exception as e:
task_status.status = "pause"
task_status.error = str(e)
await utils.send_json("update_download_task", task_status.to_dict())
task_status.error = None
utils.print_error(str(e))
try:
status = download_thread_pool.submit(download_task, task_id)
if status == "Waiting":
task_status = get_task_status(task_id)
task_status.status = "waiting"
await utils.send_json("update_download_task", task_status.to_dict())
except Exception as e:
task_status.status = "pause"
task_status.error = str(e)
await utils.send_json("update_download_task", task_status.to_dict())
task_status.error = None
utils.print_error(str(e))
async def download_model_file(
task_id: str,
headers: dict,
progress_callback: Callable[[TaskStatus], Awaitable[Any]],
interval: float = 1.0,
):
async def download_complete():
"""
Restore the model information from the task file
and move the model file to the target directory.
"""
model_type = task_content.type
path_index = task_content.pathIndex
fullname = task_content.fullname
# Write description file
description = task_content.description
description_file = utils.join_path(download_path, f"{task_id}.md")
with open(description_file, "w", encoding="utf-8", newline="") as f:
f.write(description)
model_path = utils.get_full_path(model_type, path_index, fullname)
utils.rename_model(download_tmp_file, model_path)
time.sleep(1)
task_file = utils.join_path(download_path, f"{task_id}.task")
os.remove(task_file)
await utils.send_json("complete_download_task", task_id)
async def update_progress():
nonlocal last_update_time
nonlocal last_downloaded_size
progress = (downloaded_size / total_size) * 100 if total_size > 0 else 0
task_status.downloadedSize = downloaded_size
task_status.progress = progress
task_status.bps = downloaded_size - last_downloaded_size
await progress_callback(task_status)
last_update_time = time.time()
last_downloaded_size = downloaded_size
task_status = get_task_status(task_id)
task_content = get_task_content(task_id)
# Check download uri
model_url = task_content.downloadUrl
if not model_url:
raise RuntimeError("No downloadUrl found")
download_path = utils.get_download_path()
download_tmp_file = utils.join_path(download_path, f"{task_id}.download")
downloaded_size = 0
if os.path.isfile(download_tmp_file):
downloaded_size = os.path.getsize(download_tmp_file)
headers["Range"] = f"bytes={downloaded_size}-"
total_size = task_content.sizeBytes
if total_size > 0 and downloaded_size == total_size:
await download_complete()
return
last_update_time = time.time()
last_downloaded_size = downloaded_size
response = requests.get(
url=model_url,
headers=headers,
stream=True,
allow_redirects=True,
)
if response.status_code not in (200, 206):
raise RuntimeError(f"Failed to download {task_content.fullname}, status code: {response.status_code}")
# Some models require logging in before they can be downloaded.
# If no token is carried, it will be redirected to the login page.
content_type = response.headers.get("content-type")
if content_type and content_type.startswith("text/html"):
# TODO More checks
# In addition to requiring login to download, there may be other restrictions.
# The currently one situation is early access??? issues#43
# Due to the lack of test data, lets put it aside for now.
# If it cannot be downloaded, a redirect will definitely occur.
# Maybe consider getting the redirect url from response.history to make a judgment.
# Here we also need to consider how different websites are processed.
raise RuntimeError(f"{task_content.fullname} needs to be logged in to download. Please set the API-Key first.")
# When parsing model information from HuggingFace API,
# the file size was not found and needs to be obtained from the response header.
if total_size == 0:
total_size = float(response.headers.get("content-length", 0))
task_content.sizeBytes = total_size
task_status.totalSize = total_size
set_task_content(task_id, task_content)
await utils.send_json("update_download_task", task_content.to_dict())
with open(download_tmp_file, "ab") as f:
for chunk in response.iter_content(chunk_size=8192):
if task_status.status == "pause":
break
f.write(chunk)
downloaded_size += len(chunk)
if time.time() - last_update_time >= interval:
await update_progress()
await update_progress()
if total_size > 0 and downloaded_size == total_size:
await download_complete()
else:
task_status.status = "pause"
await utils.send_json("update_download_task", task_status.to_dict())
from aiohttp import web
def __update__(self):
utils.save_dict_pickle_file(self.__cache_file, self.__store)
class ModelDownload:
def __init__(self):
self.api_key = ApiKey()
def add_routes(self, routes):
@routes.post("/model-manager/download/init")
async def init_download(request):
"""
Init download setting.
"""
result = self.api_key.init(request)
return web.json_response({"success": True, "data": result})
@routes.post("/model-manager/download/setting")
async def set_download_setting(request):
"""
Set download setting.
"""
json_data = await request.json()
key = json_data.get("key", None)
value = json_data.get("value", None)
value = base64.b64decode(value).decode("utf-8") if value is not None else None
self.api_key.set_value(key, value)
return web.json_response({"success": True})
@routes.get("/model-manager/download/task")
async def scan_download_tasks(request):
@@ -420,7 +164,7 @@ class ModelDownload:
Read download task list.
"""
try:
result = await scan_model_download_task_list()
result = await self.scan_model_download_task_list()
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Read download task list failed: {e}"
@@ -439,9 +183,9 @@ class ModelDownload:
json_data = await request.json()
status = json_data.get("status", None)
if status == "pause":
await pause_model_download_task(task_id)
await self.pause_model_download_task(task_id)
elif status == "resume":
await download_model(task_id, request)
await self.download_model(task_id, request)
else:
raise web.HTTPBadRequest(reason="Invalid status")
@@ -458,7 +202,7 @@ class ModelDownload:
"""
task_id = request.match_info.get("task_id", None)
try:
await delete_model_download_task(task_id)
await self.delete_model_download_task(task_id)
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Delete download task failed: {str(e)}"
@@ -483,9 +227,323 @@ class ModelDownload:
task_data = await request.post()
task_data = dict(task_data)
try:
task_id = await create_model_download_task(task_data, request)
task_id = await self.create_model_download_task(task_data, request)
return web.json_response({"success": True, "data": {"taskId": task_id}})
except Exception as e:
error_msg = f"Create model download task failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
download_model_task_status: dict[str, TaskStatus] = {}
download_thread_pool = thread.DownloadThreadPool()
def set_task_content(self, task_id: str, task_content: Union[TaskContent, dict]):
download_path = utils.get_download_path()
task_file_path = utils.join_path(download_path, f"{task_id}.task")
utils.save_dict_pickle_file(task_file_path, task_content)
def get_task_content(self, task_id: str):
download_path = utils.get_download_path()
task_file = utils.join_path(download_path, f"{task_id}.task")
if not os.path.isfile(task_file):
raise RuntimeError(f"Task {task_id} not found")
task_content = utils.load_dict_pickle_file(task_file)
if isinstance(task_content, TaskContent):
return task_content
return TaskContent(**task_content)
def get_task_status(self, task_id: str):
task_status = self.download_model_task_status.get(task_id, None)
if task_status is None:
download_path = utils.get_download_path()
task_content = self.get_task_content(task_id)
download_file = utils.join_path(download_path, f"{task_id}.download")
download_size = 0
if os.path.exists(download_file):
download_size = os.path.getsize(download_file)
total_size = task_content.sizeBytes
task_status = TaskStatus(
taskId=task_id,
type=task_content.type,
fullname=task_content.fullname,
preview=utils.get_model_preview_name(download_file),
platform=task_content.downloadPlatform,
downloadedSize=download_size,
totalSize=task_content.sizeBytes,
progress=download_size / total_size * 100 if total_size > 0 else 0,
)
self.download_model_task_status[task_id] = task_status
return task_status
def delete_task_status(self, task_id: str):
self.download_model_task_status.pop(task_id, None)
async def scan_model_download_task_list(self):
"""
Scan the download directory and send the task list to the client.
"""
download_dir = utils.get_download_path()
task_files = utils.search_files(download_dir)
task_files = folder_paths.filter_files_extensions(task_files, [".task"])
task_files = sorted(
task_files,
key=lambda x: os.stat(utils.join_path(download_dir, x)).st_ctime,
reverse=True,
)
task_list: list[dict] = []
for task_file in task_files:
task_id = task_file.replace(".task", "")
task_status = self.get_task_status(task_id)
task_list.append(task_status.to_dict())
return task_list
async def create_model_download_task(self, task_data: dict, request):
"""
Creates a download task for the given data.
"""
model_type = task_data.get("type", None)
path_index = int(task_data.get("pathIndex", None))
fullname = task_data.get("fullname", None)
model_path = utils.get_full_path(model_type, path_index, fullname)
# Check if the model path is valid
if os.path.exists(model_path):
raise RuntimeError(f"File already exists: {model_path}")
download_path = utils.get_download_path()
task_id = uuid.uuid4().hex
task_path = utils.join_path(download_path, f"{task_id}.task")
if os.path.exists(task_path):
raise RuntimeError(f"Task {task_id} already exists")
download_platform = task_data.get("downloadPlatform", None)
try:
preview_file = task_data.pop("previewFile", None)
utils.save_model_preview_image(task_path, preview_file, download_platform)
self.set_task_content(task_id, task_data)
task_status = TaskStatus(
taskId=task_id,
type=model_type,
fullname=fullname,
preview=utils.get_model_preview_name(task_path),
platform=download_platform,
totalSize=float(task_data.get("sizeBytes", 0)),
)
self.download_model_task_status[task_id] = task_status
await utils.send_json("create_download_task", task_status.to_dict())
except Exception as e:
await self.delete_model_download_task(task_id)
raise RuntimeError(str(e)) from e
await self.download_model(task_id, request)
return task_id
async def pause_model_download_task(self, task_id: str):
task_status = self.get_task_status(task_id=task_id)
task_status.status = "pause"
async def delete_model_download_task(self, task_id: str):
task_status = self.get_task_status(task_id)
is_running = task_status.status == "doing"
task_status.status = "waiting"
await utils.send_json("delete_download_task", task_id)
# Pause the task
if is_running:
task_status.status = "pause"
time.sleep(1)
download_dir = utils.get_download_path()
task_file_list = os.listdir(download_dir)
for task_file in task_file_list:
task_file_target = os.path.splitext(task_file)[0]
if task_file_target == task_id:
self.delete_task_status(task_id)
os.remove(utils.join_path(download_dir, task_file))
await utils.send_json("delete_download_task", task_id)
async def download_model(self, task_id: str, request):
async def download_task(task_id: str):
async def report_progress(task_status: TaskStatus):
await utils.send_json("update_download_task", task_status.to_dict())
try:
# When starting a task from the queue, the task may not exist
task_status = self.get_task_status(task_id)
except:
return
# Update task status
task_status.status = "doing"
await utils.send_json("update_download_task", task_status.to_dict())
try:
# Set download request headers
headers = {"User-Agent": config.user_agent}
download_platform = task_status.platform
if download_platform == "civitai":
api_key = self.api_key.get_value("civitai")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
elif download_platform == "huggingface":
api_key = self.api_key.get_value("huggingface")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
progress_interval = 1.0
await self.download_model_file(
task_id=task_id,
headers=headers,
progress_callback=report_progress,
interval=progress_interval,
)
except Exception as e:
task_status.status = "pause"
task_status.error = str(e)
await utils.send_json("update_download_task", task_status.to_dict())
task_status.error = None
utils.print_error(str(e))
try:
status = self.download_thread_pool.submit(download_task, task_id)
if status == "Waiting":
task_status = self.get_task_status(task_id)
task_status.status = "waiting"
await utils.send_json("update_download_task", task_status.to_dict())
except Exception as e:
task_status.status = "pause"
task_status.error = str(e)
await utils.send_json("update_download_task", task_status.to_dict())
task_status.error = None
utils.print_error(str(e))
async def download_model_file(
self,
task_id: str,
headers: dict,
progress_callback: Callable[[TaskStatus], Awaitable[Any]],
interval: float = 1.0,
):
async def download_complete():
"""
Restore the model information from the task file
and move the model file to the target directory.
"""
model_type = task_content.type
path_index = task_content.pathIndex
fullname = task_content.fullname
# Write description file
description = task_content.description
description_file = utils.join_path(download_path, f"{task_id}.md")
with open(description_file, "w", encoding="utf-8", newline="") as f:
f.write(description)
model_path = utils.get_full_path(model_type, path_index, fullname)
utils.rename_model(download_tmp_file, model_path)
time.sleep(1)
task_file = utils.join_path(download_path, f"{task_id}.task")
os.remove(task_file)
await utils.send_json("complete_download_task", task_id)
async def update_progress():
nonlocal last_update_time
nonlocal last_downloaded_size
progress = (downloaded_size / total_size) * 100 if total_size > 0 else 0
task_status.downloadedSize = downloaded_size
task_status.progress = progress
task_status.bps = downloaded_size - last_downloaded_size
await progress_callback(task_status)
last_update_time = time.time()
last_downloaded_size = downloaded_size
task_status = self.get_task_status(task_id)
task_content = self.get_task_content(task_id)
# Check download uri
model_url = task_content.downloadUrl
if not model_url:
raise RuntimeError("No downloadUrl found")
download_path = utils.get_download_path()
download_tmp_file = utils.join_path(download_path, f"{task_id}.download")
downloaded_size = 0
if os.path.isfile(download_tmp_file):
downloaded_size = os.path.getsize(download_tmp_file)
headers["Range"] = f"bytes={downloaded_size}-"
total_size = task_content.sizeBytes
if total_size > 0 and downloaded_size == total_size:
await download_complete()
return
last_update_time = time.time()
last_downloaded_size = downloaded_size
response = requests.get(
url=model_url,
headers=headers,
stream=True,
allow_redirects=True,
)
if response.status_code not in (200, 206):
raise RuntimeError(f"Failed to download {task_content.fullname}, status code: {response.status_code}")
# Some models require logging in before they can be downloaded.
# If no token is carried, it will be redirected to the login page.
content_type = response.headers.get("content-type")
if content_type and content_type.startswith("text/html"):
# TODO More checks
# In addition to requiring login to download, there may be other restrictions.
# The currently one situation is early access??? issues#43
# Due to the lack of test data, lets put it aside for now.
# If it cannot be downloaded, a redirect will definitely occur.
# Maybe consider getting the redirect url from response.history to make a judgment.
# Here we also need to consider how different websites are processed.
raise RuntimeError(f"{task_content.fullname} needs to be logged in to download. Please set the API-Key first.")
# When parsing model information from HuggingFace API,
# the file size was not found and needs to be obtained from the response header.
# Fixed issue #169. Some model information from Civitai, providing the wrong file size
response_total_size = float(response.headers.get("content-length", 0))
if total_size == 0 or total_size != response_total_size:
total_size = response_total_size
task_content.sizeBytes = total_size
task_status.totalSize = total_size
self.set_task_content(task_id, task_content)
await utils.send_json("update_download_task", task_content.to_dict())
with open(download_tmp_file, "ab") as f:
for chunk in response.iter_content(chunk_size=8192):
if task_status.status == "pause":
break
f.write(chunk)
downloaded_size += len(chunk)
if time.time() - last_update_time >= interval:
await update_progress()
await update_progress()
if total_size > 0 and downloaded_size == total_size:
await download_complete()
else:
task_status.status = "pause"
await utils.send_json("update_download_task", task_status.to_dict())

View File

@@ -1,14 +1,25 @@
import os
import re
import uuid
import math
import yaml
import requests
import markdownify
import folder_paths
from aiohttp import web
from abc import ABC, abstractmethod
from urllib.parse import urlparse, parse_qs
from PIL import Image
from io import BytesIO
from . import utils
from . import config
from . import thread
class ModelSearcher(ABC):
@@ -64,9 +75,9 @@ class CivitaiModelSearcher(ModelSearcher):
shortname = version.get("name", None) if len(model_files) > 0 else None
for file in model_files:
fullname = file.get("name", None)
extension = os.path.splitext(fullname)[1]
basename = os.path.splitext(fullname)[0]
name = file.get("name", None)
extension = os.path.splitext(name)[1]
basename = os.path.splitext(name)[0]
metadata_info = {
"website": "Civitai",
@@ -99,13 +110,13 @@ class CivitaiModelSearcher(ModelSearcher):
model = {
"id": file.get("id"),
"shortname": shortname or basename,
"fullname": fullname,
"basename": basename,
"extension": extension,
"preview": metadata_info.get("preview"),
"sizeBytes": file.get("sizeKB", 0) * 1024,
"type": self._resolve_model_type(res_data.get("type", "unknown")),
"type": self._resolve_model_type(res_data.get("type", "")),
"pathIndex": 0,
"subFolder": "",
"description": "\n".join(description_parts),
"metadata": file.get("metadata"),
"downloadPlatform": "civitai",
@@ -146,7 +157,7 @@ class CivitaiModelSearcher(ModelSearcher):
"Controlnet": "controlnet",
"Upscaler": "upscale_models",
"VAE": "vae",
"unknown": "unknown",
"unknown": "",
}
return map_legacy.get(model_type, f"{model_type.lower()}s")
@@ -216,13 +227,13 @@ class HuggingfaceModelSearcher(ModelSearcher):
model = {
"id": filename,
"shortname": filename,
"fullname": fullname,
"basename": basename,
"extension": extension,
"preview": image_files,
"sizeBytes": 0,
"type": "unknown",
"type": "",
"pathIndex": 0,
"subFolder": "",
"description": "\n".join(description_parts),
"metadata": {},
"downloadPlatform": "huggingface",
@@ -282,25 +293,6 @@ class HuggingfaceModelSearcher(ModelSearcher):
return _filter_tree_files
def get_model_searcher_by_url(url: str) -> ModelSearcher:
parsed_url = urlparse(url)
host_name = parsed_url.hostname
if host_name == "civitai.com":
return CivitaiModelSearcher()
elif host_name == "huggingface.co":
return HuggingfaceModelSearcher()
return UnknownWebsiteSearcher()
import folder_paths
from . import config
from aiohttp import web
class Information:
def add_routes(self, routes):
@@ -318,16 +310,38 @@ class Information:
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.get("/model-manager/model-info/scan")
async def get_model_info_download_task(request):
"""
Get model information download task list.
"""
try:
result = self.get_scan_model_info_task_list()
if result is not None:
await self.download_model_info(request)
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Get model info download task list failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.post("/model-manager/model-info/scan")
async def download_model_info(request):
async def create_model_info_download_task(request):
"""
Create a task to download model information.
- scanMode: The alternatives are diff and full.
- mode: The alternatives are diff and full.
- path: Scanning root path.
"""
post = await utils.get_request_body(request)
try:
# TODO scanMode is deprecated, use mode instead.
scan_mode = post.get("scanMode", "diff")
await self.download_model_info(scan_mode, request)
return web.json_response({"success": True})
scan_mode = post.get("mode", scan_mode)
scan_path = post.get("path", None)
result = await self.create_scan_model_info_task(scan_mode, scan_path, request)
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Download model info failed: {str(e)}"
utils.print_error(error_msg)
@@ -347,18 +361,30 @@ class Information:
index = int(request.match_info.get("index", None))
filename = request.match_info.get("filename", None)
content_type = utils.resolve_file_content_type(filename)
if content_type == "video":
abs_path = utils.get_full_path(model_type, index, filename)
return web.FileResponse(abs_path)
extension_uri = config.extension_uri
try:
folders = folder_paths.get_folder_paths(model_type)
base_path = folders[index]
abs_path = utils.join_path(base_path, filename)
preview_name = utils.get_model_preview_name(abs_path)
if preview_name:
dir_name = os.path.dirname(abs_path)
abs_path = utils.join_path(dir_name, preview_name)
except:
abs_path = extension_uri
if not os.path.isfile(abs_path):
abs_path = utils.join_path(extension_uri, "assets", "no-preview.png")
return web.FileResponse(abs_path)
image_data = self.get_image_preview_data(abs_path)
return web.Response(body=image_data.getvalue(), content_type="image/webp")
@routes.get("/model-manager/preview/download/{filename}")
async def read_download_preview(request):
@@ -373,50 +399,142 @@ class Information:
return web.FileResponse(preview_path)
def get_image_preview_data(self, filename: str):
with Image.open(filename) as img:
max_size = 1024
original_format = img.format
exif_data = img.info.get("exif")
icc_profile = img.info.get("icc_profile")
if getattr(img, "is_animated", False) and img.n_frames > 1:
total_frames = img.n_frames
step = max(1, math.ceil(total_frames / 30))
frames, durations = [], []
for frame_idx in range(0, total_frames, step):
img.seek(frame_idx)
frame = img.copy()
frame.thumbnail((max_size, max_size), Image.Resampling.NEAREST)
frames.append(frame)
durations.append(img.info.get("duration", 100) * step)
save_args = {
"format": "WEBP",
"save_all": True,
"append_images": frames[1:],
"duration": durations,
"loop": 0,
"quality": 80,
"method": 0,
"allow_mixed": False,
}
if exif_data:
save_args["exif"] = exif_data
if icc_profile:
save_args["icc_profile"] = icc_profile
img_byte_arr = BytesIO()
frames[0].save(img_byte_arr, **save_args)
img_byte_arr.seek(0)
return img_byte_arr
img.thumbnail((max_size, max_size), Image.Resampling.BICUBIC)
img_byte_arr = BytesIO()
save_args = {"format": "WEBP", "quality": 80}
if exif_data:
save_args["exif"] = exif_data
if icc_profile:
save_args["icc_profile"] = icc_profile
img.save(img_byte_arr, **save_args)
img_byte_arr.seek(0)
return img_byte_arr
def fetch_model_info(self, model_page: str):
if not model_page:
return []
model_searcher = get_model_searcher_by_url(model_page)
model_searcher = self.get_model_searcher_by_url(model_page)
result = model_searcher.search_by_url(model_page)
return result
async def download_model_info(self, scan_mode: str, request):
utils.print_info(f"Download model info for {scan_mode}")
model_base_paths = utils.resolve_model_base_paths()
for model_type in model_base_paths:
def get_scan_information_task_filepath(self):
download_dir = utils.get_download_path()
return utils.join_path(download_dir, "scan_information.task")
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)
def get_scan_model_info_task_list(self):
scan_info_task_file = self.get_scan_information_task_filepath()
if os.path.isfile(scan_info_task_file):
return utils.load_dict_pickle_file(scan_info_task_file)
return None
models = folder_paths.filter_files_extensions(files, folder_paths.supported_pt_extensions)
async def create_scan_model_info_task(self, scan_mode: str, scan_path: str | None, request):
scan_info_task_file = self.get_scan_information_task_filepath()
scan_info_task_content = {"mode": scan_mode}
scan_models: dict[str, bool] = {}
for fullname in models:
fullname = utils.normalize_path(fullname)
basename = os.path.splitext(fullname)[0]
scan_paths: list[str] = []
if scan_path is None:
model_base_paths = utils.resolve_model_base_paths()
for model_type in model_base_paths:
folders, *others = folder_paths.folder_names_and_paths[model_type]
for path_index, base_path in enumerate(folders):
scan_paths.append(base_path)
else:
scan_paths = [scan_path]
abs_model_path = utils.join_path(base_path, fullname)
for base_path in scan_paths:
files = utils.recursive_search_files(base_path, request)
models = folder_paths.filter_files_extensions(files, folder_paths.supported_pt_extensions)
for fullname in models:
fullname = utils.normalize_path(fullname)
abs_model_path = utils.join_path(base_path, fullname)
utils.print_debug(f"Found model: {abs_model_path}")
scan_models[abs_model_path] = False
image_name = utils.get_model_preview_name(abs_model_path)
abs_image_path = utils.join_path(base_path, image_name)
scan_info_task_content["models"] = scan_models
utils.save_dict_pickle_file(scan_info_task_file, scan_info_task_content)
await self.download_model_info(request)
return scan_info_task_content
has_preview = os.path.isfile(abs_image_path)
download_thread_pool = thread.DownloadThreadPool()
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
async def download_model_info(self, request):
async def download_information_task(task_id: str):
scan_info_task_file = self.get_scan_information_task_filepath()
scan_info_task_content = utils.load_dict_pickle_file(scan_info_task_file)
scan_mode = scan_info_task_content.get("mode", "diff")
scan_models: dict[str, bool] = scan_info_task_content.get("models", {})
for key, value in scan_models.items():
if value is True:
continue
try:
abs_model_path = key
base_path = os.path.dirname(abs_model_path)
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}")
image_name = utils.get_model_preview_name(abs_model_path)
abs_image_path = utils.join_path(base_path, image_name)
if scan_mode != "full" and (has_preview and has_description):
continue
has_preview = os.path.isfile(abs_image_path)
description_name = utils.get_model_description_name(abs_model_path)
abs_description_path = utils.join_path(base_path, description_name) if description_name else None
has_description = os.path.isfile(abs_description_path) if abs_description_path else False
try:
utils.print_info(f"Checking model {abs_model_path}")
utils.print_debug(f"Scan mode: {scan_mode}")
utils.print_debug(f"Has preview: {has_preview}")
utils.print_debug(f"Has description: {has_description}")
if scan_mode == "full" or not has_preview or not has_description:
utils.print_debug(f"Calculate sha256 for {abs_model_path}")
hash_value = utils.calculate_sha256(abs_model_path)
utils.print_info(f"Searching model info by hash {hash_value}")
@@ -431,7 +549,29 @@ class Information:
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.")
scan_models[abs_model_path] = True
scan_info_task_content["models"] = scan_models
utils.save_dict_pickle_file(scan_info_task_file, scan_info_task_content)
utils.print_debug(f"Send update scan information task to frontend.")
await utils.send_json("update_scan_information_task", scan_info_task_content)
except Exception as e:
utils.print_error(f"Failed to download model info for {abs_model_path}: {e}")
os.remove(scan_info_task_file)
utils.print_info("Completed scan model information.")
try:
task_id = uuid.uuid4().hex
self.download_thread_pool.submit(download_information_task, task_id)
except Exception as e:
utils.print_debug(str(e))
def get_model_searcher_by_url(self, url: str) -> ModelSearcher:
parsed_url = urlparse(url)
host_name = parsed_url.hostname
if host_name == "civitai.com":
return CivitaiModelSearcher()
elif host_name == "huggingface.co":
return HuggingfaceModelSearcher()
return UnknownWebsiteSearcher()

View File

@@ -120,41 +120,61 @@ class ModelManager:
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]
prefix_path = utils.normalize_path(base_path)
if not prefix_path.endswith("/"):
prefix_path = f"{prefix_path}/"
if extension not in folder_paths.supported_pt_extensions:
is_file = entry.is_file()
relative_path = utils.normalize_path(entry.path).replace(prefix_path, "")
sub_folder = os.path.dirname(relative_path)
filename = os.path.basename(relative_path)
basename = os.path.splitext(filename)[0] if is_file else filename
extension = os.path.splitext(filename)[1] if is_file else ""
if is_file and extension not in folder_paths.supported_pt_extensions:
return None
model_preview = f"/model-manager/preview/{folder}/{path_index}/{basename}.webp"
preview_type = "image"
preview_ext = ".webp"
preview_images = utils.get_model_all_images(entry.path)
if len(preview_images) > 0:
preview_type = "image"
preview_ext = ".webp"
else:
preview_videos = utils.get_model_all_videos(entry.path)
if len(preview_videos) > 0:
preview_type = "video"
preview_ext = f".{preview_videos[0].split('.')[-1]}"
model_preview = f"/model-manager/preview/{folder}/{path_index}/{relative_path.replace(extension, preview_ext)}"
stat = entry.stat()
return {
"fullname": fullname,
"type": folder,
"subFolder": sub_folder,
"isFolder": not is_file,
"basename": basename,
"extension": extension,
"type": folder,
"pathIndex": path_index,
"sizeBytes": stat.st_size,
"preview": model_preview,
"sizeBytes": stat.st_size if is_file else 0,
"preview": model_preview if is_file else None,
"previewType": preview_type,
"createdAt": round(stat.st_ctime_ns / 1000000),
"updatedAt": round(stat.st_mtime_ns / 1000000),
}
def get_all_files_entry(directory: str):
files = []
entries: list[os.DirEntry[str]] = []
with os.scandir(directory) as it:
for entry in it:
# Skip hidden files
if not include_hidden_files:
if entry.name.startswith("."):
continue
entries.append(entry)
if entry.is_dir():
files.extend(get_all_files_entry(entry.path))
elif entry.is_file():
files.append(entry)
return files
entries.extend(get_all_files_entry(entry.path))
return entries
for path_index, base_path in enumerate(folders):
if not os.path.exists(base_path):

View File

@@ -8,12 +8,13 @@ import requests
import traceback
import configparser
import functools
import mimetypes
import comfy.utils
import folder_paths
from aiohttp import web
from typing import Any
from typing import Any, Optional
from . import config
@@ -149,6 +150,20 @@ def resolve_model_base_paths() -> dict[str, list[str]]:
return model_base_paths
def resolve_file_content_type(filename: str):
extension_mimetypes_cache = folder_paths.extension_mimetypes_cache
extension = filename.split(".")[-1]
if extension not in extension_mimetypes_cache:
mime_type, _ = mimetypes.guess_type(filename, strict=False)
if not mime_type:
return None
content_type = mime_type.split("/")[0]
extension_mimetypes_cache[extension] = content_type
else:
content_type = extension_mimetypes_cache[extension]
return content_type
def get_full_path(model_type: str, path_index: int, filename: str):
"""
Get the absolute path in the model type through string concatenation.
@@ -266,6 +281,22 @@ def get_model_preview_name(model_path: str):
return images[0] if len(images) > 0 else "no-preview.png"
def get_model_all_videos(model_path: str):
base_dirname = os.path.dirname(model_path)
files = search_files(base_dirname)
files = folder_paths.filter_files_content_types(files, ["video"])
basename = os.path.splitext(os.path.basename(model_path))[0]
output: list[str] = []
for file in files:
file_basename = os.path.splitext(file)[0]
if file_basename == basename:
output.append(file)
if file_basename == f"{basename}.preview":
output.append(file)
return output
from PIL import Image
from io import BytesIO
@@ -277,7 +308,7 @@ def remove_model_preview_image(model_path: str):
os.remove(preview_path)
def save_model_preview_image(model_path: str, image_file_or_url: Any, platform: str | None = None):
def save_model_preview_image(model_path: str, image_file_or_url: Any, platform: Optional[str] = None):
basename = os.path.splitext(model_path)[0]
preview_path = f"{basename}.webp"
# Download image file if it is url

View File

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

View File

@@ -7,7 +7,9 @@
<script setup lang="ts">
import DialogDownload from 'components/DialogDownload.vue'
import DialogExplorer from 'components/DialogExplorer.vue'
import DialogManager from 'components/DialogManager.vue'
import DialogScanning from 'components/DialogScanning.vue'
import GlobalDialogStack from 'components/GlobalDialogStack.vue'
import GlobalLoading from 'components/GlobalLoading.vue'
import GlobalToast from 'components/GlobalToast.vue'
@@ -34,6 +36,19 @@ onMounted(() => {
})
}
const openModelScanning = () => {
dialog.open({
key: 'model-information-scanning',
title: t('batchScanModelInformation'),
content: DialogScanning,
modal: true,
defaultSize: {
width: 680,
height: 490,
},
})
}
const openDownloadDialog = () => {
dialog.open({
key: 'model-manager-download-list',
@@ -50,7 +65,7 @@ onMounted(() => {
}
const openManagerDialog = () => {
const { cardWidth, gutter, aspect } = config
const { cardWidth, gutter, aspect, flat } = config
if (firstOpenManager.value) {
models.refresh(true)
@@ -60,9 +75,14 @@ onMounted(() => {
dialog.open({
key: 'model-manager',
title: t('modelManager'),
content: DialogManager,
content: flat.value ? DialogManager : DialogExplorer,
keepAlive: true,
headerButtons: [
{
key: 'scanning',
icon: 'mdi mdi-folder-search-outline text-lg',
command: openModelScanning,
},
{
key: 'refresh',
icon: 'pi pi-refresh',

View File

@@ -66,6 +66,7 @@ import { useConfig } from 'hooks/config'
import { useDialog } from 'hooks/dialog'
import { useModelSearch } from 'hooks/download'
import { useLoading } from 'hooks/loading'
import { genModelFullName } from 'hooks/model'
import { request } from 'hooks/request'
import { useToast } from 'hooks/toast'
import Button from 'primevue/button'
@@ -128,6 +129,9 @@ const createDownTask = async (data: WithResolved<VersionModel>) => {
}
}
const fullname = genModelFullName(data as VersionModel)
formData.append('fullname', fullname)
await request('/model', {
method: 'POST',
body: formData,

View File

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

View File

@@ -20,14 +20,17 @@
<div class="flex items-center justify-between gap-4 overflow-hidden">
<ResponseSelect
class="flex-1"
v-model="currentType"
:items="typeOptions"
></ResponseSelect>
<ResponseSelect
class="flex-1"
v-model="sortOrder"
:items="sortOrderOptions"
></ResponseSelect>
<ResponseSelect
class="flex-1"
v-model="cardSizeFlag"
:items="cardSizeOptions"
></ResponseSelect>
@@ -46,7 +49,62 @@
v-for="model in item.row"
:key="genModelKey(model)"
:model="model"
></ModelCard>
:style="{
width: `${cardSize.width}px`,
height: `${cardSize.height}px`,
}"
class="group/card cursor-pointer !p-0"
@click="openModelDetail(model)"
v-tooltip.top="{
value: getFullPath(model),
autoHide: false,
showDelay: 800,
hideDelay: 300,
pt: { root: { style: { zIndex: 2100, maxWidth: '32rem' } } },
}"
>
<template #name>
<div
v-show="showModelName"
class="absolute top-0 h-full w-full p-2"
>
<div class="flex h-full flex-col justify-end text-lg">
<div class="line-clamp-3 break-all font-bold text-shadow">
{{ model.basename }}
</div>
</div>
</div>
</template>
<template #extra>
<div
v-show="showModeAction"
class="pointer-events-none absolute right-2 top-2 opacity-0 duration-300 group-hover/card:opacity-100"
>
<div class="flex flex-col gap-2">
<Button
icon="pi pi-plus"
severity="secondary"
rounded
@click.stop="addModelNode(model)"
></Button>
<Button
icon="pi pi-copy"
severity="secondary"
rounded
@click.stop="copyModelNode(model)"
></Button>
<Button
v-show="model.preview"
icon="pi pi-file-import"
severity="secondary"
rounded
@click.stop="loadPreviewWorkflow(model)"
></Button>
</div>
</div>
</template>
</ModelCard>
<div class="col-span-full"></div>
</div>
</template>
@@ -69,8 +127,9 @@ import ResponseScroll from 'components/ResponseScroll.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import { configSetting, useConfig } from 'hooks/config'
import { useContainerQueries } from 'hooks/container'
import { useModels } from 'hooks/model'
import { useModelNodeAction, useModels } from 'hooks/model'
import { chunk } from 'lodash'
import Button from 'primevue/button'
import { app } from 'scripts/comfyAPI'
import { Model } from 'types/typings'
import { genModelKey } from 'utils/model'
@@ -86,7 +145,7 @@ const {
dialog: settings,
} = useConfig()
const { data, folders } = useModels()
const { data, folders, openModelDetail, getFullPath } = useModels()
const { t } = useI18n()
const toolbarContainer = ref<HTMLElement | null>(null)
@@ -97,7 +156,8 @@ const { $lg: $content_lg } = useContainerQueries(contentContainer)
const searchContent = ref<string>()
const currentType = ref('all')
const allType = 'All'
const currentType = ref(allType)
const typeOptions = computed(() => {
const excludeScanTypes = app.ui?.settings.getSettingValue<string>(
configSetting.excludeScanTypes,
@@ -108,7 +168,7 @@ const typeOptions = computed(() => {
.map((type) => type.trim())
.filter(Boolean) ?? []
return [
'all',
allType,
...Object.keys(folders.value).filter(
(folder) => !customBlackList.includes(folder),
),
@@ -161,22 +221,26 @@ const cols = computed(() => {
const list = computed(() => {
const mergedList = Object.values(data.value).flat()
const pureModels = mergedList.filter((item) => {
return !item.isFolder
})
const filterList = mergedList.filter((model) => {
const showAllModel = currentType.value === 'all'
const filterList = pureModels.filter((model) => {
const showAllModel = currentType.value === allType
const matchType = showAllModel || model.type === currentType.value
const matchName = model.fullname
.toLowerCase()
.includes(searchContent.value?.toLowerCase() || '')
return matchType && matchName
const filter = searchContent.value?.toLowerCase() ?? ''
const matchSubFolder = model.subFolder.toLowerCase().includes(filter)
const matchName = model.basename.toLowerCase().includes(filter)
return matchType && (matchSubFolder || matchName)
})
let sortStrategy: (a: Model, b: Model) => number = () => 0
switch (sortOrder.value) {
case 'name':
sortStrategy = (a, b) => a.fullname.localeCompare(b.fullname)
sortStrategy = (a, b) => a.basename.localeCompare(b.basename)
break
case 'size':
sortStrategy = (a, b) => b.sizeBytes - a.sizeBytes
@@ -227,4 +291,15 @@ const cardSizeOptions = computed(() => {
}
})
})
const showModelName = computed(() => {
return cardSize.value.width > 120 && cardSize.value.height > 160
})
const showModeAction = computed(() => {
return cardSize.value.width > 120 && cardSize.value.height > 160
})
const { addModelNode, copyModelNode, loadPreviewWorkflow } =
useModelNodeAction()
</script>

View File

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

View File

@@ -0,0 +1,271 @@
<template>
<div class="h-full px-4">
<div v-show="batchScanningStep === 0" class="h-full">
<div class="flex h-full items-center px-8">
<div class="h-20 w-full opacity-60">
<ProgressBar mode="indeterminate" style="height: 6px"></ProgressBar>
</div>
</div>
</div>
<Stepper
v-show="batchScanningStep === 1"
v-model:value="stepValue"
class="flex h-full flex-col"
linear
>
<StepList>
<Step value="1">{{ $t('selectModelType') }}</Step>
<Step value="2">{{ $t('selectSubdirectory') }}</Step>
<Step value="3">{{ $t('scanModelInformation') }}</Step>
</StepList>
<StepPanels class="flex-1 overflow-hidden">
<StepPanel value="1" class="h-full">
<div class="flex h-full flex-col overflow-hidden">
<ResponseScroll>
<div class="flex flex-wrap gap-4">
<Button
v-for="item in typeOptions"
:key="item.value"
:label="item.label"
@click="item.command"
></Button>
</div>
</ResponseScroll>
</div>
</StepPanel>
<StepPanel value="2" class="h-full">
<div class="flex h-full flex-col overflow-hidden">
<ResponseScroll class="flex-1">
<Tree
class="h-full"
v-model:selection-keys="selectedKey"
:value="pathOptions"
selectionMode="single"
:pt:nodeLabel:class="'text-ellipsis overflow-hidden'"
></Tree>
</ResponseScroll>
<div class="flex justify-between pt-6">
<Button
:label="$t('back')"
severity="secondary"
icon="pi pi-arrow-left"
@click="handleBackTypeSelect"
></Button>
<Button
:label="$t('next')"
icon="pi pi-arrow-right"
icon-pos="right"
:disabled="!enabledScan"
@click="handleConfirmSubdir"
></Button>
</div>
</div>
</StepPanel>
<StepPanel value="3" class="h-full">
<div class="overflow-hidden break-words py-8">
<div class="overflow-hidden px-8">
<div v-show="currentType === allType" class="text-center">
{{ $t('selectedAllPaths') }}
</div>
<div v-show="currentType !== allType" class="text-center">
<div class="pb-2">
{{ $t('selectedSpecialPath') }}
</div>
<div class="leading-5 opacity-60">
{{ selectedModelFolder }}
</div>
</div>
</div>
</div>
<div class="flex items-center justify-center gap-4">
<Button
v-for="item in scanActions"
:key="item.value"
:label="item.label"
:icon="item.icon"
@click="item.command.call(item)"
></Button>
</div>
</StepPanel>
</StepPanels>
</Stepper>
<div v-show="batchScanningStep === 2" class="h-full">
<div class="flex h-full items-center px-8">
<div class="h-20 w-full">
<div v-show="scanProgress > -1">
<ProgressBar :value="scanProgress">
{{ scanCompleteCount }} / {{ scanTotalCount }}
</ProgressBar>
</div>
<div v-show="scanProgress === -1" class="text-center">
<Button
severity="secondary"
:label="$t('back')"
icon="pi pi-arrow-left"
@click="handleBackTypeSelect"
></Button>
<span class="pl-2">{{ $t('noModelsInCurrentPath') }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ResponseScroll from 'components/ResponseScroll.vue'
import { configSetting } from 'hooks/config'
import { useModelFolder, useModels } from 'hooks/model'
import { request } from 'hooks/request'
import Button from 'primevue/button'
import ProgressBar from 'primevue/progressbar'
import Step from 'primevue/step'
import StepList from 'primevue/steplist'
import StepPanel from 'primevue/steppanel'
import StepPanels from 'primevue/steppanels'
import Stepper from 'primevue/stepper'
import Tree from 'primevue/tree'
import { api, app } from 'scripts/comfyAPI'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const stepValue = ref('1')
const { folders } = useModels()
const allType = 'All'
const currentType = ref<string>()
const typeOptions = computed(() => {
const excludeScanTypes = app.ui?.settings.getSettingValue<string>(
configSetting.excludeScanTypes,
)
const customBlackList =
excludeScanTypes
?.split(',')
.map((type) => type.trim())
.filter(Boolean) ?? []
return [
allType,
...Object.keys(folders.value).filter(
(folder) => !customBlackList.includes(folder),
),
].map((type) => {
return {
label: type,
value: type,
command: () => {
currentType.value = type
stepValue.value = currentType.value === allType ? '3' : '2'
},
}
})
})
const { pathOptions } = useModelFolder({ type: currentType })
const selectedModelFolder = ref<string>()
const selectedKey = computed({
get: () => {
const key = selectedModelFolder.value
return key ? { [key]: true } : {}
},
set: (val) => {
const key = Object.keys(val)[0]
selectedModelFolder.value = key
},
})
const enabledScan = computed(() => {
return currentType.value === allType || !!selectedModelFolder.value
})
const handleBackTypeSelect = () => {
selectedModelFolder.value = undefined
currentType.value = undefined
stepValue.value = '1'
batchScanningStep.value = 1
}
const handleConfirmSubdir = () => {
stepValue.value = '3'
}
const batchScanningStep = ref(0)
const scanModelsList = ref<Record<string, boolean>>({})
const scanTotalCount = computed(() => {
return Object.keys(scanModelsList.value).length
})
const scanCompleteCount = computed(() => {
return Object.keys(scanModelsList.value).filter(
(key) => scanModelsList.value[key],
).length
})
const scanProgress = computed(() => {
if (scanTotalCount.value === 0) {
return -1
}
const progress = scanCompleteCount.value / scanTotalCount.value
return Number(progress.toFixed(4)) * 100
})
const handleScanModelInformation = async function () {
batchScanningStep.value = 0
const mode = this.value
const path = selectedModelFolder.value
try {
const result = await request('/model-info/scan', {
method: 'POST',
body: JSON.stringify({ mode, path }),
})
scanModelsList.value = result?.models ?? {}
batchScanningStep.value = 2
} catch {
batchScanningStep.value = 1
}
}
const scanActions = ref([
{
value: 'back',
label: t('back'),
icon: 'pi pi-arrow-left',
command: () => {
stepValue.value = currentType.value === allType ? '1' : '2'
},
},
{
value: 'full',
label: t('scanFullInformation'),
command: handleScanModelInformation,
},
{
value: 'diff',
label: t('scanMissInformation'),
command: handleScanModelInformation,
},
])
const refreshTaskContent = async () => {
const result = await request('/model-info/scan')
const listContent = result?.models ?? {}
scanModelsList.value = listContent
batchScanningStep.value = Object.keys(listContent).length ? 2 : 1
}
onMounted(() => {
refreshTaskContent()
api.addEventListener('update_scan_information_task', (event) => {
const content = event.detail
scanModelsList.value = content.models
})
})
</script>

View File

@@ -1,16 +1,9 @@
<template>
<ResponseDialog
v-for="(item, index) in stack"
v-model:visible="item.visible"
:key="item.key"
:keep-alive="item.keepAlive"
:default-size="item.defaultSize"
:default-mobile-size="item.defaultMobileSize"
:resize-allow="item.resizeAllow"
:min-width="item.minWidth"
:max-width="item.maxWidth"
:min-height="item.minHeight"
:max-height="item.maxHeight"
v-model:visible="item.visible"
v-bind="omitProps(item)"
:auto-z-index="false"
:pt:mask:style="{ zIndex: baseZIndex + index + 1 }"
:pt:root:onMousedown="() => rise(item)"
@@ -37,14 +30,17 @@
<component :is="item.content" v-bind="item.contentProps"></component>
</template>
</ResponseDialog>
<Dialog :visible="true" :pt:mask:style="{ display: 'none' }"></Dialog>
</template>
<script setup lang="ts">
import ResponseDialog from 'components/ResponseDialog.vue'
import { useDialog } from 'hooks/dialog'
import { type DialogItem, useDialog } from 'hooks/dialog'
import { omit } from 'lodash'
import Button from 'primevue/button'
import { usePrimeVue } from 'primevue/config'
import { computed, onMounted } from 'vue'
import Dialog from 'primevue/dialog'
import { computed } from 'vue'
const { stack, rise, close } = useDialog()
@@ -54,9 +50,14 @@ const baseZIndex = computed(() => {
return config.zIndex?.modal ?? 1100
})
onMounted(() => {
for (const key in config.zIndex) {
config.zIndex[key] = baseZIndex.value
}
})
const omitProps = (item: DialogItem) => {
return omit(item, [
'key',
'visible',
'title',
'headerButtons',
'content',
'contentProps',
])
}
</script>

View File

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

View File

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

View File

@@ -5,7 +5,24 @@
class="relative mx-auto w-full overflow-hidden rounded-lg preview-aspect"
:style="$sm({ width: `${cardWidth}px` })"
>
<ResponseImage :src="preview" :error="noPreviewContent"></ResponseImage>
<div v-if="previewType === 'video'" class="h-full w-full p-1 hover:p-0">
<video
class="h-full w-full object-cover"
playsinline
autoplay
loop
disablepictureinpicture
preload="none"
>
<source :src="preview" />
</video>
</div>
<ResponseImage
v-else
:src="preview"
:error="noPreviewContent"
></ResponseImage>
<Carousel
v-if="defaultContent.length > 1"
@@ -95,6 +112,7 @@ const { cardWidth } = useConfig()
const {
preview,
previewType,
typeOptions,
currentType,
defaultContent,

View File

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

View File

@@ -3,13 +3,14 @@
ref="dialogRef"
:visible="true"
@update:visible="updateVisible"
:modal="modal"
:close-on-escape="false"
:maximizable="!isMobile"
maximizeIcon="pi pi-arrow-up-right-and-arrow-down-left-from-center"
minimizeIcon="pi pi-arrow-down-left-and-arrow-up-right-to-center"
:pt:mask:class="['group', { open: visible }]"
:pt:root:class="['max-h-full group-[:not(.open)]:!hidden', $style.dialog]"
pt:content:class="px-0 flex-1"
pt:content:class="p-0 flex-1"
:base-z-index="1000"
:auto-z-index="isNil(zIndex)"
:pt:mask:style="isNil(zIndex) ? {} : { zIndex: 1000 + zIndex }"
@@ -91,6 +92,7 @@ interface Props {
minHeight?: number
maxHeight?: number
zIndex?: number
modal?: boolean
}
const props = withDefaults(defineProps<Props>(), {

View File

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

View File

@@ -1,5 +1,5 @@
<template>
<div class="group/scroll relative overflow-hidden">
<div class="group/scroll relative h-full overflow-hidden">
<div ref="viewport" class="h-full w-full overflow-auto scrollbar-none">
<div ref="content">
<slot name="default">
@@ -40,13 +40,13 @@
</div>
</template>
<script setup lang="ts" generic="T">
<script setup lang="ts" generic="T extends { key: string }">
import { useDraggable, useElementSize, useScroll } from '@vueuse/core'
import { clamp } from 'lodash'
import { computed, ref } from 'vue'
interface ScrollAreaProps {
items?: (T & { key: string })[]
items?: T[]
itemSize?: number
}

View File

@@ -2,7 +2,14 @@
<slot
v-if="type === 'drop'"
name="target"
v-bind="{ toggle, prefixIcon, suffixIcon, currentLabel, current }"
v-bind="{
toggle,
prefixIcon,
suffixIcon,
currentLabel,
current,
overlayVisible,
}"
>
<div :class="['-my-1 py-1', $attrs.class]" @click="toggle">
<Button
@@ -197,6 +204,10 @@ const toggle = (event: MouseEvent) => {
}
}
const overlayVisible = computed(() => {
return isMobile.value ? visible.value : (menu.value?.overlayVisible ?? false)
})
// Select Button Type
const scrollArea = ref<HTMLElement | null>(null)
const contentArea = ref()

View File

@@ -0,0 +1,69 @@
<template>
<div class="p-4">
<InputText
class="w-full"
v-model="content"
placeholder="Set New API Key"
autocomplete="off"
></InputText>
<div class="mt-4 flex items-center justify-between">
<div>
<span v-show="showError" class="text-red-400">
API Key Not Allow Empty
</span>
</div>
<Button label="Save" autofocus @click="saveKeybinding"></Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useDialog } from 'hooks/dialog'
import { request } from 'hooks/request'
import { useToast } from 'hooks/toast'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import { ref, toValue } from 'vue'
interface Props {
keyField: string
setter: (val: string) => void
}
const props = defineProps<Props>()
const { close } = useDialog()
const { toast } = useToast()
const content = ref<string>()
const showError = ref<boolean>(false)
const saveKeybinding = async () => {
const value = toValue(content)
if (!value) {
showError.value = true
return
}
showError.value = false
const key = toValue(props.keyField)
try {
const encodeValue = value ? btoa(value) : null
await request('/download/setting', {
method: 'POST',
body: JSON.stringify({ key, value: encodeValue }),
})
const desString = value ? value.slice(0, 4) + '****' + value.slice(-4) : ''
props.setter(desString)
close()
} catch (error) {
toast.add({
severity: 'error',
summary: 'Error',
detail: error.message,
life: 3000,
})
}
}
</script>

View File

@@ -1,10 +1,11 @@
import SettingApiKey from 'components/SettingApiKey.vue'
import SettingCardSize from 'components/SettingCardSize.vue'
import { request } from 'hooks/request'
import { defineStore } from 'hooks/store'
import { $el, app, ComfyDialog } from 'scripts/comfyAPI'
import { useToast } from 'hooks/toast'
import { $el, app } from 'scripts/comfyAPI'
import { computed, onMounted, onUnmounted, readonly, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from './toast'
export const useConfig = defineStore('config', (store) => {
const { t } = useI18n()
@@ -24,6 +25,8 @@ export const useConfig = defineStore('config', (store) => {
window.removeEventListener('resize', checkDeviceType)
})
const flatLayout = ref(false)
const defaultCardSizeMap = readonly({
'size.extraLarge': '240x320',
'size.large': '180x240',
@@ -64,6 +67,8 @@ export const useConfig = defineStore('config', (store) => {
})
},
},
flat: flatLayout,
apiKeyInfo: ref<Record<string, string>>({}),
}
watch(cardSizeFlag, (val) => {
@@ -95,57 +100,102 @@ export const configSetting = {
}
function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
const { toast } = useToast()
const { t } = useI18n()
const { confirm } = useToast()
const confirm = (opts: {
message?: string
accept?: () => void
reject?: () => void
const iconButton = (opt: {
icon: string
onClick: () => void | Promise<void>
}) => {
const dialog = new ComfyDialog('div', [])
dialog.show(
$el('div', [
$el('p', { textContent: opts.message }),
$el('div.flex.gap-4', [
$el('button.flex-1', {
textContent: 'Cancel',
onclick: () => {
opts.reject?.()
dialog.close()
document.body.removeChild(dialog.element)
},
}),
$el('button.flex-1', {
textContent: 'Continue',
onclick: () => {
opts.accept?.()
dialog.close()
document.body.removeChild(dialog.element)
},
}),
]),
]),
return $el(
'span.h-4.cursor-pointer',
{ onclick: opt.onClick },
$el(`i.${opt.icon.replace(/\s/g, '.')}`),
)
}
const setApiKey = async (key: string, setter: (val: string) => void) => {
store.dialog.open({
key: `setting.api_key.${key}`,
title: t(`setting.api_key.${key}`),
content: SettingApiKey,
modal: true,
defaultSize: {
width: 500,
height: 200,
},
contentProps: {
keyField: key,
setter: setter,
},
})
}
const removeApiKey = async (key: string) => {
await new Promise((resolve, reject) => {
confirm.require({
message: t('deleteAsk'),
header: 'Danger',
icon: 'pi pi-info-circle',
accept: () => resolve(true),
reject: reject,
})
})
await request('/download/setting', {
method: 'POST',
body: JSON.stringify({ key, value: null }),
})
}
const renderApiKey = (key: string) => {
return () => {
const apiKey = store.config.apiKeyInfo.value[key] || 'None'
const apiKeyDisplayEl = $el('div.text-sm.text-gray-500.flex-1', {
textContent: apiKey,
})
const setter = (val: string) => {
store.config.apiKeyInfo.value[key] = val
apiKeyDisplayEl.textContent = val || 'None'
}
return $el('div.flex.gap-4', [
apiKeyDisplayEl,
iconButton({
icon: 'pi pi-pencil text-blue-400',
onClick: () => {
setApiKey(key, setter)
},
}),
iconButton({
icon: 'pi pi-trash text-red-400',
onClick: async () => {
const value = store.config.apiKeyInfo.value[key]
if (value) {
await removeApiKey(key)
setter('')
}
},
}),
])
}
}
onMounted(() => {
// API keys
app.ui?.settings.addSetting({
id: 'ModelManager.APIKey.HuggingFace',
category: [t('modelManager'), t('setting.apiKey'), 'HuggingFace'],
name: 'HuggingFace API Key',
type: 'text',
defaultValue: undefined,
type: renderApiKey('huggingface'),
})
app.ui?.settings.addSetting({
id: 'ModelManager.APIKey.Civitai',
category: [t('modelManager'), t('setting.apiKey'), 'Civitai'],
name: 'Civitai API Key',
type: 'text',
defaultValue: undefined,
type: renderApiKey('civitai'),
})
const defaultCardSize = store.config.defaultCardSizeMap
@@ -172,98 +222,15 @@ function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
},
})
// Scan information
app.ui?.settings.addSetting({
id: 'ModelManager.ScanFiles.Full',
category: [t('modelManager'), t('setting.scan'), 'Full'],
name: t('setting.scanAll'),
defaultValue: '',
type: () => {
return $el('button.p-button.p-component.p-button-secondary', {
textContent: 'Full Scan',
onclick: () => {
confirm({
message: [
'This operation will override current files.',
'This may take a while and generate MANY server requests!',
'USE AT YOUR OWN RISK! Continue?',
].join('\n'),
accept: () => {
store.loading.loading.value = true
request('/model-info/scan', {
method: 'POST',
body: JSON.stringify({ scanMode: 'full' }),
})
.then(() => {
toast.add({
severity: 'success',
summary: 'Complete download information',
life: 2000,
})
store.models.refresh()
})
.catch((err) => {
toast.add({
severity: 'error',
summary: 'Error',
detail: err.message ?? 'Failed to download information',
life: 15000,
})
})
.finally(() => {
store.loading.loading.value = false
})
},
})
},
})
},
})
app.ui?.settings.addSetting({
id: 'ModelManager.ScanFiles.Incremental',
category: [t('modelManager'), t('setting.scan'), 'Incremental'],
name: t('setting.scanMissing'),
defaultValue: '',
type: () => {
return $el('button.p-button.p-component.p-button-secondary', {
textContent: 'Diff Scan',
onclick: () => {
confirm({
message: [
'Download missing information or preview.',
'This may take a while and generate MANY server requests!',
'USE AT YOUR OWN RISK! Continue?',
].join('\n'),
accept: () => {
store.loading.loading.value = true
request('/model-info/scan', {
method: 'POST',
body: JSON.stringify({ scanMode: 'diff' }),
})
.then(() => {
toast.add({
severity: 'success',
summary: 'Complete download information',
life: 2000,
})
store.models.refresh()
})
.catch((err) => {
toast.add({
severity: 'error',
summary: 'Error',
detail: err.message ?? 'Failed to download information',
life: 15000,
})
})
.finally(() => {
store.loading.loading.value = false
})
},
})
},
})
id: 'ModelManager.UI.Flat',
category: [t('modelManager'), t('setting.ui'), 'Flat'],
name: t('setting.useFlatUI'),
type: 'boolean',
defaultValue: false,
onChange(value) {
store.dialog.closeAll()
store.config.flat.value = value
},
})

View File

@@ -8,7 +8,7 @@ interface HeaderButton {
command: () => void
}
interface DialogItem {
export interface DialogItem {
key: string
title: string
content: Component
@@ -22,6 +22,7 @@ interface DialogItem {
maxWidth?: number
minHeight?: number
maxHeight?: number
modal?: boolean
}
export const useDialog = defineStore('dialog', () => {
@@ -63,7 +64,11 @@ export const useDialog = defineStore('dialog', () => {
}
}
return { stack, open, close, rise }
const closeAll = () => {
stack.value = []
}
return { stack, open, close, closeAll, rise }
})
declare module 'hooks/store' {

View File

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

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

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

View File

@@ -1,9 +1,11 @@
import DialogModelDetail from 'components/DialogModelDetail.vue'
import { useLoading } from 'hooks/loading'
import { useMarkdown } from 'hooks/markdown'
import { request } from 'hooks/request'
import { defineStore } from 'hooks/store'
import { useToast } from 'hooks/toast'
import { castArray, cloneDeep } from 'lodash'
import { TreeNode } from 'primevue/treenode'
import { app } from 'scripts/comfyAPI'
import { BaseModel, Model, SelectEvent, WithResolved } from 'types/typings'
import { bytesToSize, formatDate, previewUrlToFile } from 'utils/common'
@@ -12,11 +14,14 @@ import { genModelKey, resolveModelTypeLoader } from 'utils/model'
import {
computed,
inject,
InjectionKey,
type InjectionKey,
MaybeRefOrGetter,
onMounted,
provide,
type Ref,
ref,
toRaw,
toValue,
unref,
} from 'vue'
import { useI18n } from 'vue-i18n'
@@ -24,7 +29,20 @@ import { configSetting } from './config'
type ModelFolder = Record<string, string[]>
const modelFolderProvideKey = Symbol('modelFolder')
const modelFolderProvideKey = Symbol('modelFolder') as InjectionKey<
Ref<ModelFolder>
>
export const genModelFullName = (model: BaseModel) => {
return [model.subFolder, `${model.basename}${model.extension}`]
.filter(Boolean)
.join('/')
}
export const genModelUrl = (model: BaseModel) => {
const fullname = genModelFullName(model)
return `/model/${model.type}/${model.pathIndex}/${fullname}`
}
export const useModels = defineStore('models', (store) => {
const { toast, confirm } = useToast()
@@ -32,9 +50,12 @@ export const useModels = defineStore('models', (store) => {
const loading = useLoading()
const folders = ref<ModelFolder>({})
const initialized = ref(false)
const refreshFolders = async () => {
return request('/models').then((resData) => {
folders.value = resData
initialized.value = true
})
}
@@ -65,7 +86,7 @@ export const useModels = defineStore('models', (store) => {
?.split(',')
.map((type) => type.trim())
.filter(Boolean) ?? []
return forceRefresh.then(() =>
await forceRefresh.then(() =>
Promise.allSettled(
Object.keys(folders.value)
.filter((folder) => !customBlackList.includes(folder))
@@ -102,13 +123,13 @@ export const useModels = defineStore('models', (store) => {
// Check current name and pathIndex
if (
model.fullname !== data.fullname ||
model.subFolder !== data.subFolder ||
model.pathIndex !== data.pathIndex
) {
oldKey = genModelKey(model)
updateData.set('type', data.type)
updateData.set('pathIndex', data.pathIndex.toString())
updateData.set('fullname', data.fullname)
updateData.set('fullname', genModelFullName(data as BaseModel))
needUpdate = true
}
@@ -117,7 +138,8 @@ export const useModels = defineStore('models', (store) => {
}
loading.show()
await request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
await request(genModelUrl(model), {
method: 'PUT',
body: updateData,
})
@@ -160,14 +182,14 @@ export const useModels = defineStore('models', (store) => {
accept: () => {
const dialogKey = genModelKey(model)
loading.show()
request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
request(genModelUrl(model), {
method: 'DELETE',
})
.then(() => {
toast.add({
severity: 'success',
summary: 'Success',
detail: `${model.fullname} Deleted`,
detail: `${model.basename} Deleted`,
life: 2000,
})
store.dialog.close({ key: dialogKey })
@@ -195,12 +217,32 @@ export const useModels = defineStore('models', (store) => {
})
}
function openModelDetail(model: BaseModel) {
const filename = model.basename.replace(model.extension, '')
store.dialog.open({
key: genModelKey(model),
title: filename,
content: DialogModelDetail,
contentProps: { model: model },
})
}
function getFullPath(model: BaseModel) {
const fullname = genModelFullName(model)
const prefixPath = folders.value[model.type]?.[model.pathIndex]
return [prefixPath, fullname].filter(Boolean).join('/')
}
return {
initialized: initialized,
folders: folders,
data: models,
refresh: refreshAllModels,
remove: deleteModel,
update: updateModel,
openModelDetail: openModelDetail,
getFullPath: getFullPath,
}
})
@@ -269,7 +311,7 @@ const baseInfoKey = Symbol('baseInfo') as InjectionKey<
export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
const { formData: model, modelData } = formInstance
const provideModelFolders = inject<any>(modelFolderProvideKey)
const provideModelFolders = inject(modelFolderProvideKey)
const modelFolders = computed<ModelFolder>(() => {
return provideModelFolders?.value ?? {}
})
@@ -292,16 +334,25 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
},
})
const subFolder = computed({
get: () => {
return model.value.subFolder
},
set: (val) => {
model.value.subFolder = val
},
})
const extension = computed(() => {
return model.value.extension
})
const basename = computed({
get: () => {
return model.value.fullname.replace(model.value.extension, '')
return model.value.basename
},
set: (val) => {
model.value.fullname = `${val ?? ''}${model.value.extension}`
model.value.basename = val
},
})
@@ -328,15 +379,20 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
{
key: 'pathIndex',
formatter: () => {
const modelType = modelData.value.type
const pathIndex = modelData.value.pathIndex
const modelType = model.value.type
const pathIndex = model.value.pathIndex
if (!modelType) {
return undefined
}
const folders = modelFolders.value[modelType] ?? []
return `${folders[pathIndex]}`
return [`${folders[pathIndex]}`, model.value.subFolder]
.filter(Boolean)
.join('/')
},
},
{
key: 'fullname',
formatter: (val) => val,
key: 'basename',
formatter: (val) => `${val}${model.value.extension}`,
},
{
key: 'sizeBytes',
@@ -371,6 +427,7 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
baseInfo,
basename,
extension,
subFolder,
pathIndex,
modelFolders,
}
@@ -384,6 +441,74 @@ export const useModelBaseInfo = () => {
return inject(baseInfoKey)!
}
export const useModelFolder = (
option: {
type?: MaybeRefOrGetter<string | undefined>
} = {},
) => {
const { data: models, folders: modelFolders } = useModels()
const pathOptions = computed(() => {
const type = toValue(option.type)
if (!type) {
return []
}
const folderItems = cloneDeep(models.value[type]) ?? []
const pureFolders = folderItems.filter((item) => item.isFolder)
pureFolders.sort((a, b) => a.basename.localeCompare(b.basename))
const folders = modelFolders.value[type] ?? []
const root: TreeNode[] = []
for (const [index, folder] of folders.entries()) {
const pathIndexItem: TreeNode = {
key: folder,
label: folder,
children: [],
}
const items = pureFolders
.filter((item) => item.pathIndex === index)
.map((item) => {
const node: TreeNode = {
key: `${folder}/${genModelFullName(item)}`,
label: item.basename,
data: item,
}
return node
})
const itemMap = Object.fromEntries(items.map((item) => [item.key, item]))
for (const item of items) {
const key = item.key
const parentKey = key.split('/').slice(0, -1).join('/')
if (parentKey === folder) {
pathIndexItem.children!.push(item)
continue
}
const parentItem = itemMap[parentKey]
if (parentItem) {
parentItem.children ??= []
parentItem.children.push(item)
}
}
root.push(pathIndexItem)
}
return root
})
return {
pathOptions,
}
}
/**
* Editable preview image.
*
@@ -429,7 +554,8 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
* No preview
*/
const noPreviewContent = computed(() => {
return `/model-manager/preview/${model.value.type}/0/no-preview.png`
const folder = model.value.type || 'unknown'
return `/model-manager/preview/${folder}/0/no-preview.png`
})
const preview = computed(() => {
@@ -453,6 +579,10 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
return content
})
const previewType = computed(() => {
return model.value.previewType
})
onMounted(() => {
registerReset(() => {
currentType.value = 'default'
@@ -468,6 +598,7 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
const result = {
preview,
previewType,
typeOptions,
currentType,
// default value
@@ -552,11 +683,11 @@ export const useModelMetadata = () => {
return inject(metadataKey)!
}
export const useModelNodeAction = (model: BaseModel) => {
export const useModelNodeAction = () => {
const { t } = useI18n()
const { toast, wrapperToastError } = useToast()
const createNode = (options: Record<string, any> = {}) => {
const createNode = (model: BaseModel, options: Record<string, any> = {}) => {
const nodeType = resolveModelTypeLoader(model.type)
if (!nodeType) {
throw new Error(t('unSupportedModelType', [model.type]))
@@ -565,50 +696,52 @@ export const useModelNodeAction = (model: BaseModel) => {
const node = window.LiteGraph.createNode(nodeType, null, options)
const widgetIndex = node.widgets.findIndex((w) => w.type === 'combo')
if (widgetIndex > -1) {
node.widgets[widgetIndex].value = model.fullname
node.widgets[widgetIndex].value = genModelFullName(model)
}
return node
}
const dragToAddModelNode = wrapperToastError((event: DragEvent) => {
// const target = document.elementFromPoint(event.clientX, event.clientY)
// if (
// target?.tagName.toLocaleLowerCase() === 'canvas' &&
// target.id === 'graph-canvas'
// ) {
// const pos = app.clientPosToCanvasPos([event.clientX - 20, event.clientY])
// const node = createNode({ pos })
// app.graph.add(node)
// app.canvas.selectNode(node)
// }
//
// Use the legacy method instead
const removeEmbeddingExtension = true
const strictDragToAdd = false
const dragToAddModelNode = wrapperToastError(
(model: BaseModel, event: DragEvent) => {
// const target = document.elementFromPoint(event.clientX, event.clientY)
// if (
// target?.tagName.toLocaleLowerCase() === 'canvas' &&
// target.id === 'graph-canvas'
// ) {
// const pos = app.clientPosToCanvasPos([event.clientX - 20, event.clientY])
// const node = createNode({ pos })
// app.graph.add(node)
// app.canvas.selectNode(node)
// }
//
// Use the legacy method instead
const removeEmbeddingExtension = true
const strictDragToAdd = false
ModelGrid.dragAddModel(
event,
model.type,
model.fullname,
removeEmbeddingExtension,
strictDragToAdd,
)
})
ModelGrid.dragAddModel(
event,
model.type,
genModelFullName(model),
removeEmbeddingExtension,
strictDragToAdd,
)
},
)
const addModelNode = wrapperToastError(() => {
const addModelNode = wrapperToastError((model: BaseModel) => {
const selectedNodes = app.canvas.selected_nodes
const firstSelectedNode = Object.values(selectedNodes)[0]
const offset = 25
const pos = firstSelectedNode
? [firstSelectedNode.pos[0] + offset, firstSelectedNode.pos[1] + offset]
: app.canvas.canvas_mouse
const node = createNode({ pos })
const node = createNode(model, { pos })
app.graph.add(node)
app.canvas.selectNode(node)
})
const copyModelNode = wrapperToastError(() => {
const node = createNode()
const copyModelNode = wrapperToastError((model: BaseModel) => {
const node = createNode(model)
app.canvas.copyToClipboard([node])
toast.add({
severity: 'success',
@@ -618,13 +751,13 @@ export const useModelNodeAction = (model: BaseModel) => {
})
})
const loadPreviewWorkflow = wrapperToastError(async () => {
const loadPreviewWorkflow = wrapperToastError(async (model: BaseModel) => {
const previewUrl = model.preview as string
const response = await fetch(previewUrl)
const data = await response.blob()
const type = data.type
const extension = type.split('/').pop()
const file = new File([data], `${model.fullname}.${extension}`, { type })
const file = new File([data], `${model.basename}.${extension}`, { type })
app.handleFile(file)
})

View File

@@ -29,6 +29,18 @@ const messages = {
width: 'Width',
height: 'Height',
reset: 'Reset',
back: 'Back',
next: 'Next',
batchScanModelInformation: 'Batch scan model information',
modelInformationScanning: 'Scanning model information',
selectModelType: 'Select model type',
selectSubdirectory: 'Select subdirectory',
scanModelInformation: 'Scan model information',
selectedAllPaths: 'Selected all model paths',
selectedSpecialPath: 'Selected special path',
scanMissInformation: 'Download missing information',
scanFullInformation: 'Override full information',
noModelsInCurrentPath: 'There are no models available in the current path',
sort: {
name: 'Name',
size: 'Largest',
@@ -46,7 +58,7 @@ const messages = {
info: {
type: 'Model Type',
pathIndex: 'Directory',
fullname: 'File Name',
basename: 'File Name',
sizeBytes: 'File Size',
createdAt: 'Created At',
updatedAt: 'Updated At',
@@ -62,6 +74,7 @@ const messages = {
excludeScanTypes: 'Exclude scan types (separate with commas)',
ui: 'UI',
cardSize: 'Card Size',
useFlatUI: 'Flat Layout',
},
},
zh: {
@@ -91,6 +104,18 @@ const messages = {
width: '宽度',
height: '高度',
reset: '重置',
back: '返回',
next: '下一步',
batchScanModelInformation: '批量扫描模型信息',
modelInformationScanning: '扫描模型信息',
selectModelType: '选择模型类型',
selectSubdirectory: '选择子目录',
scanModelInformation: '扫描模型信息',
selectedAllPaths: '已选所有模型路径',
selectedSpecialPath: '已选指定路径',
scanMissInformation: '下载缺失信息',
scanFullInformation: '覆盖所有信息',
noModelsInCurrentPath: '当前路径中没有可用的模型',
sort: {
name: '名称',
size: '最大',
@@ -108,7 +133,7 @@ const messages = {
info: {
type: '类型',
pathIndex: '目录',
fullname: '文件名',
basename: '文件名',
sizeBytes: '文件大小',
createdAt: '创建时间',
updatedAt: '更新时间',
@@ -124,6 +149,7 @@ const messages = {
excludeScanTypes: '排除扫描类型(使用英文逗号隔开)',
ui: '外观',
cardSize: '卡片尺寸',
useFlatUI: '展平布局',
},
},
}

View File

@@ -4,7 +4,3 @@
@tailwind components;
@tailwind utilities;
}
.comfy-modal {
z-index: 3000;
}

View File

@@ -3,13 +3,15 @@ export type ContainerPosition = { left: number; top: number }
export interface BaseModel {
id: number | string
fullname: string
basename: string
extension: string
sizeBytes: number
type: string
subFolder: string
pathIndex: number
isFolder: boolean
preview: string | string[]
previewType: string
description: string
metadata: Record<string, string>
}
@@ -17,6 +19,7 @@ export interface BaseModel {
export interface Model extends BaseModel {
createdAt: number
updatedAt: number
children?: Model[]
}
export interface VersionModel extends BaseModel {

View File

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

View File

@@ -9,6 +9,9 @@ export default {
plugins: [
plugin(({ addUtilities }) => {
addUtilities({
'.text-shadow': {
'text-shadow': '2px 2px 4px rgba(0, 0, 0, 0.5)',
},
'.scrollbar-none': {
'scrollbar-width': 'none',
},

View File

@@ -114,7 +114,7 @@ export default defineConfig({
outDir: 'web',
minify: 'esbuild',
target: 'es2022',
sourcemap: true,
sourcemap: false,
rollupOptions: {
// Disabling tree-shaking
// Prevent vite remove unused exports