13 Commits

Author SHA1 Message Date
Hayden
304978a7b8 prepare release 2.4.0 2025-02-19 16:16:14 +08:00
Hayden
704f35a1a8 feat: add context menu (#143) 2025-02-19 16:15:19 +08:00
Hayden
ce42960d57 fix(download): miss parameter (#142) 2025-02-19 16:11:15 +08:00
Hayden
05fa31f2c5 fix: download module error (#141) 2025-02-19 14:37:27 +08:00
Hayden
ea26ec5098 fix: dialog cover confirm and toast (#140) 2025-02-19 14:12:53 +08:00
Hayden
3d01c2dfda fix: content error in create download (#139) 2025-02-19 13:47:44 +08:00
Hayden
59552841e7 fix: add error tip (#137) 2025-02-18 16:44:53 +08:00
Hayden
ad6045f286 fix(Input): valid none value (#136) 2025-02-18 16:21:38 +08:00
Hayden
86c11e5343 [New Feature] sub directories support (#135)
* feat: add close all dialog

* feat: add new ui toggle setting

* feat: add tree display ui

* feat: add search and sort

* feat: change model data structure

* pref: Optimize model data structure

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

* prepare release 2.3.3
2025-02-10 13:51:45 +08:00
26 changed files with 1580 additions and 594 deletions

View File

@@ -3,8 +3,13 @@ import uuid
import time import time
import requests import requests
import folder_paths import folder_paths
from typing import Callable, Awaitable, Any, Literal, Union, Optional from typing import Callable, Awaitable, Any, Literal, Union, Optional
from dataclasses import dataclass from dataclasses import dataclass
from aiohttp import web
from . import config from . import config
from . import utils from . import utils
from . import thread from . import thread
@@ -87,330 +92,6 @@ class TaskContent:
} }
download_model_task_status: dict[str, TaskStatus] = {}
download_thread_pool = thread.DownloadThreadPool()
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 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_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
class ModelDownload: class ModelDownload:
def add_routes(self, routes): def add_routes(self, routes):
@@ -420,7 +101,7 @@ class ModelDownload:
Read download task list. Read download task list.
""" """
try: try:
result = await scan_model_download_task_list() result = await self.scan_model_download_task_list()
return web.json_response({"success": True, "data": result}) return web.json_response({"success": True, "data": result})
except Exception as e: except Exception as e:
error_msg = f"Read download task list failed: {e}" error_msg = f"Read download task list failed: {e}"
@@ -439,9 +120,9 @@ class ModelDownload:
json_data = await request.json() json_data = await request.json()
status = json_data.get("status", None) status = json_data.get("status", None)
if status == "pause": if status == "pause":
await pause_model_download_task(task_id) await self.pause_model_download_task(task_id)
elif status == "resume": elif status == "resume":
await download_model(task_id, request) await self.download_model(task_id, request)
else: else:
raise web.HTTPBadRequest(reason="Invalid status") raise web.HTTPBadRequest(reason="Invalid status")
@@ -458,7 +139,7 @@ class ModelDownload:
""" """
task_id = request.match_info.get("task_id", None) task_id = request.match_info.get("task_id", None)
try: try:
await delete_model_download_task(task_id) await self.delete_model_download_task(task_id)
return web.json_response({"success": True}) return web.json_response({"success": True})
except Exception as e: except Exception as e:
error_msg = f"Delete download task failed: {str(e)}" error_msg = f"Delete download task failed: {str(e)}"
@@ -483,9 +164,321 @@ class ModelDownload:
task_data = await request.post() task_data = await request.post()
task_data = dict(task_data) task_data = dict(task_data)
try: 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}}) return web.json_response({"success": True, "data": {"taskId": task_id}})
except Exception as e: except Exception as e:
error_msg = f"Create model download task failed: {str(e)}" error_msg = f"Create model download task failed: {str(e)}"
utils.print_error(error_msg) utils.print_error(error_msg)
return web.json_response({"success": False, "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 = utils.get_setting_value(request, "api_key.civitai")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
elif download_platform == "huggingface":
api_key = utils.get_setting_value(request, "api_key.huggingface")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
progress_interval = 1.0
await self.download_model_file(
task_id=task_id,
headers=headers,
progress_callback=report_progress,
interval=progress_interval,
)
except Exception as e:
task_status.status = "pause"
task_status.error = str(e)
await utils.send_json("update_download_task", task_status.to_dict())
task_status.error = None
utils.print_error(str(e))
try:
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.
if total_size == 0:
total_size = float(response.headers.get("content-length", 0))
task_content.sizeBytes = total_size
task_status.totalSize = total_size
self.set_task_content(task_id, task_content)
await utils.send_json("update_download_task", task_content.to_dict())
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

@@ -64,9 +64,9 @@ class CivitaiModelSearcher(ModelSearcher):
shortname = version.get("name", None) if len(model_files) > 0 else None shortname = version.get("name", None) if len(model_files) > 0 else None
for file in model_files: for file in model_files:
fullname = file.get("name", None) name = file.get("name", None)
extension = os.path.splitext(fullname)[1] extension = os.path.splitext(name)[1]
basename = os.path.splitext(fullname)[0] basename = os.path.splitext(name)[0]
metadata_info = { metadata_info = {
"website": "Civitai", "website": "Civitai",
@@ -99,13 +99,13 @@ class CivitaiModelSearcher(ModelSearcher):
model = { model = {
"id": file.get("id"), "id": file.get("id"),
"shortname": shortname or basename, "shortname": shortname or basename,
"fullname": fullname,
"basename": basename, "basename": basename,
"extension": extension, "extension": extension,
"preview": metadata_info.get("preview"), "preview": metadata_info.get("preview"),
"sizeBytes": file.get("sizeKB", 0) * 1024, "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, "pathIndex": 0,
"subFolder": "",
"description": "\n".join(description_parts), "description": "\n".join(description_parts),
"metadata": file.get("metadata"), "metadata": file.get("metadata"),
"downloadPlatform": "civitai", "downloadPlatform": "civitai",
@@ -146,7 +146,7 @@ class CivitaiModelSearcher(ModelSearcher):
"Controlnet": "controlnet", "Controlnet": "controlnet",
"Upscaler": "upscale_models", "Upscaler": "upscale_models",
"VAE": "vae", "VAE": "vae",
"unknown": "unknown", "unknown": "",
} }
return map_legacy.get(model_type, f"{model_type.lower()}s") return map_legacy.get(model_type, f"{model_type.lower()}s")
@@ -216,13 +216,13 @@ class HuggingfaceModelSearcher(ModelSearcher):
model = { model = {
"id": filename, "id": filename,
"shortname": filename, "shortname": filename,
"fullname": fullname,
"basename": basename, "basename": basename,
"extension": extension, "extension": extension,
"preview": image_files, "preview": image_files,
"sizeBytes": 0, "sizeBytes": 0,
"type": "unknown", "type": "",
"pathIndex": 0, "pathIndex": 0,
"subFolder": "",
"description": "\n".join(description_parts), "description": "\n".join(description_parts),
"metadata": {}, "metadata": {},
"downloadPlatform": "huggingface", "downloadPlatform": "huggingface",

View File

@@ -120,41 +120,47 @@ class ModelManager:
folders, *others = folder_paths.folder_names_and_paths[folder] folders, *others = folder_paths.folder_names_and_paths[folder]
def get_file_info(entry: os.DirEntry[str], base_path: str, path_index: int): def get_file_info(entry: os.DirEntry[str], base_path: str, path_index: int):
fullname = utils.normalize_path(entry.path).replace(f"{base_path}/", "") prefix_path = utils.normalize_path(base_path)
basename = os.path.splitext(fullname)[0] if not prefix_path.endswith("/"):
extension = os.path.splitext(fullname)[1] prefix_path = f"{prefix_path}/"
if extension not in folder_paths.supported_pt_extensions: relative_path = utils.normalize_path(entry.path).replace(prefix_path, "")
sub_folder = os.path.dirname(relative_path)
filename = os.path.basename(relative_path)
basename = os.path.splitext(filename)[0]
extension = os.path.splitext(filename)[1]
is_file = entry.is_file()
if is_file and extension not in folder_paths.supported_pt_extensions:
return None return None
model_preview = f"/model-manager/preview/{folder}/{path_index}/{basename}.webp" model_preview = f"/model-manager/preview/{folder}/{path_index}/{relative_path.replace(extension, '.webp')}"
stat = entry.stat() stat = entry.stat()
return { return {
"fullname": fullname, "type": folder if is_file else "folder",
"subFolder": sub_folder,
"basename": basename, "basename": basename,
"extension": extension, "extension": extension,
"type": folder,
"pathIndex": path_index, "pathIndex": path_index,
"sizeBytes": stat.st_size, "sizeBytes": stat.st_size if is_file else 0,
"preview": model_preview, "preview": model_preview if is_file else None,
"createdAt": round(stat.st_ctime_ns / 1000000), "createdAt": round(stat.st_ctime_ns / 1000000),
"updatedAt": round(stat.st_mtime_ns / 1000000), "updatedAt": round(stat.st_mtime_ns / 1000000),
} }
def get_all_files_entry(directory: str): def get_all_files_entry(directory: str):
files = [] entries: list[os.DirEntry[str]] = []
with os.scandir(directory) as it: with os.scandir(directory) as it:
for entry in it: for entry in it:
# Skip hidden files # Skip hidden files
if not include_hidden_files: if not include_hidden_files:
if entry.name.startswith("."): if entry.name.startswith("."):
continue continue
entries.append(entry)
if entry.is_dir(): if entry.is_dir():
files.extend(get_all_files_entry(entry.path)) entries.extend(get_all_files_entry(entry.path))
elif entry.is_file(): return entries
files.append(entry)
return files
for path_index, base_path in enumerate(folders): for path_index, base_path in enumerate(folders):
if not os.path.exists(base_path): if not os.path.exists(base_path):

View File

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

View File

@@ -7,6 +7,7 @@
<script setup lang="ts"> <script setup lang="ts">
import DialogDownload from 'components/DialogDownload.vue' import DialogDownload from 'components/DialogDownload.vue'
import DialogExplorer from 'components/DialogExplorer.vue'
import DialogManager from 'components/DialogManager.vue' import DialogManager from 'components/DialogManager.vue'
import GlobalDialogStack from 'components/GlobalDialogStack.vue' import GlobalDialogStack from 'components/GlobalDialogStack.vue'
import GlobalLoading from 'components/GlobalLoading.vue' import GlobalLoading from 'components/GlobalLoading.vue'
@@ -50,7 +51,7 @@ onMounted(() => {
} }
const openManagerDialog = () => { const openManagerDialog = () => {
const { cardWidth, gutter, aspect } = config const { cardWidth, gutter, aspect, flat } = config
if (firstOpenManager.value) { if (firstOpenManager.value) {
models.refresh(true) models.refresh(true)
@@ -60,7 +61,7 @@ onMounted(() => {
dialog.open({ dialog.open({
key: 'model-manager', key: 'model-manager',
title: t('modelManager'), title: t('modelManager'),
content: DialogManager, content: flat.value ? DialogManager : DialogExplorer,
keepAlive: true, keepAlive: true,
headerButtons: [ headerButtons: [
{ {

View File

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

View File

@@ -0,0 +1,320 @@
<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"
@item-click="(item, index) => openFolder(index, item.name, item.icon)"
></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
:model="rowItem"
v-for="rowItem in item.row"
:key="genModelKey(rowItem)"
:style="{
width: `${cardSize.width}px`,
height: `${cardSize.height}px`,
}"
@dblclick="openItem(rowItem, $event)"
@contextmenu.stop.prevent="openItemContext(rowItem, $event)"
></ModelCard>
<div class="col-span-full"></div>
</div>
</template>
</ResponseScroll>
</div>
<div class="flex justify-between px-4 py-2 text-sm">
<div></div>
<div></div>
</div>
<ContextMenu ref="menu" :model="contextItems"></ContextMenu>
<ConfirmDialog group="confirm-name">
<template #container="{ acceptCallback: accept, rejectCallback: reject }">
<div class="flex w-90 flex-col items-end rounded px-4 pb-4 pt-8">
<InputText
class="w-full"
type="text"
v-model="confirmName"
v-focus
@keyup.enter="accept"
></InputText>
<div class="mt-6 flex items-center gap-2">
<Button :label="$t('cancel')" @click="reject" outlined></Button>
<Button :label="$t('confirm')" @click="accept"></Button>
</div>
</div>
</template>
</ConfirmDialog>
</div>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import ModelCard from 'components/ModelCard.vue'
import ResponseBreadcrumb from 'components/ResponseBreadcrumb.vue'
import ResponseInput from 'components/ResponseInput.vue'
import ResponseScroll from 'components/ResponseScroll.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import { useConfig } from 'hooks/config'
import { type ModelTreeNode, useModelExplorer } from 'hooks/explorer'
import { chunk } from 'lodash'
import Button from 'primevue/button'
import ConfirmDialog from 'primevue/confirmdialog'
import ContextMenu from 'primevue/contextmenu'
import InputText from 'primevue/inputtext'
import { MenuItem } from 'primevue/menuitem'
import { genModelKey } from 'utils/model'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const gutter = {
x: 4,
y: 32,
}
const { dataTreeList, folderPaths, findFolder, openFolder, openModelDetail } =
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, folderItem.name)
renderedList = found?.children || []
}
if (searchContent.value) {
const filterItems: ModelTreeNode[] = []
const searchList = [...renderedList]
while (searchList.length) {
const item = searchList.pop()!
const children = (item as any).children ?? []
searchList.push(...children)
if (
item.basename
.toLocaleLowerCase()
.includes(searchContent.value.toLocaleLowerCase())
) {
filterItems.push(item)
}
}
renderedList = filterItems
}
if (folderPaths.value.length > 1) {
const folderItems: ModelTreeNode[] = []
const modelItems: ModelTreeNode[] = []
for (const item of renderedList) {
if (item.type === 'folder') {
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.type === 'folder') {
openFolder(folderPaths.value.length, item.basename)
} 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"> <div class="flex items-center justify-between gap-4 overflow-hidden">
<ResponseSelect <ResponseSelect
class="flex-1"
v-model="currentType" v-model="currentType"
:items="typeOptions" :items="typeOptions"
></ResponseSelect> ></ResponseSelect>
<ResponseSelect <ResponseSelect
class="flex-1"
v-model="sortOrder" v-model="sortOrder"
:items="sortOrderOptions" :items="sortOrderOptions"
></ResponseSelect> ></ResponseSelect>
<ResponseSelect <ResponseSelect
class="flex-1"
v-model="cardSizeFlag" v-model="cardSizeFlag"
:items="cardSizeOptions" :items="cardSizeOptions"
></ResponseSelect> ></ResponseSelect>
@@ -46,7 +49,56 @@
v-for="model in item.row" v-for="model in item.row"
:key="genModelKey(model)" :key="genModelKey(model)"
:model="model" :model="model"
></ModelCard> :style="{
width: `${cardSize.width}px`,
height: `${cardSize.height}px`,
}"
class="group/card cursor-pointer !p-0"
@click="openModelDetail(model)"
v-tooltip.top="{ value: model.basename, disabled: showModelName }"
>
<template #name>
<div
v-show="showModelName"
class="absolute top-0 h-full w-full p-2"
>
<div class="flex h-full flex-col justify-end text-lg">
<div class="line-clamp-3 break-all font-bold text-shadow">
{{ model.basename }}
</div>
</div>
</div>
</template>
<template #extra>
<div
v-show="showModeAction"
class="pointer-events-none absolute right-2 top-2 opacity-0 duration-300 group-hover/card:opacity-100"
>
<div class="flex flex-col gap-2">
<Button
icon="pi pi-plus"
severity="secondary"
rounded
@click.stop="addModelNode(model)"
></Button>
<Button
icon="pi pi-copy"
severity="secondary"
rounded
@click.stop="copyModelNode(model)"
></Button>
<Button
v-show="model.preview"
icon="pi pi-file-import"
severity="secondary"
rounded
@click.stop="loadPreviewWorkflow(model)"
></Button>
</div>
</div>
</template>
</ModelCard>
<div class="col-span-full"></div> <div class="col-span-full"></div>
</div> </div>
</template> </template>
@@ -69,8 +121,9 @@ import ResponseScroll from 'components/ResponseScroll.vue'
import ResponseSelect from 'components/ResponseSelect.vue' import ResponseSelect from 'components/ResponseSelect.vue'
import { configSetting, useConfig } from 'hooks/config' import { configSetting, useConfig } from 'hooks/config'
import { useContainerQueries } from 'hooks/container' import { useContainerQueries } from 'hooks/container'
import { useModels } from 'hooks/model' import { useModelNodeAction, useModels } from 'hooks/model'
import { chunk } from 'lodash' import { chunk } from 'lodash'
import Button from 'primevue/button'
import { app } from 'scripts/comfyAPI' import { app } from 'scripts/comfyAPI'
import { Model } from 'types/typings' import { Model } from 'types/typings'
import { genModelKey } from 'utils/model' import { genModelKey } from 'utils/model'
@@ -86,7 +139,7 @@ const {
dialog: settings, dialog: settings,
} = useConfig() } = useConfig()
const { data, folders } = useModels() const { data, folders, openModelDetail } = useModels()
const { t } = useI18n() const { t } = useI18n()
const toolbarContainer = ref<HTMLElement | null>(null) const toolbarContainer = ref<HTMLElement | null>(null)
@@ -97,7 +150,8 @@ const { $lg: $content_lg } = useContainerQueries(contentContainer)
const searchContent = ref<string>() const searchContent = ref<string>()
const currentType = ref('all') const allType = 'All'
const currentType = ref(allType)
const typeOptions = computed(() => { const typeOptions = computed(() => {
const excludeScanTypes = app.ui?.settings.getSettingValue<string>( const excludeScanTypes = app.ui?.settings.getSettingValue<string>(
configSetting.excludeScanTypes, configSetting.excludeScanTypes,
@@ -108,7 +162,7 @@ const typeOptions = computed(() => {
.map((type) => type.trim()) .map((type) => type.trim())
.filter(Boolean) ?? [] .filter(Boolean) ?? []
return [ return [
'all', allType,
...Object.keys(folders.value).filter( ...Object.keys(folders.value).filter(
(folder) => !customBlackList.includes(folder), (folder) => !customBlackList.includes(folder),
), ),
@@ -161,12 +215,15 @@ const cols = computed(() => {
const list = computed(() => { const list = computed(() => {
const mergedList = Object.values(data.value).flat() const mergedList = Object.values(data.value).flat()
const pureModels = mergedList.filter((item) => {
return item.type !== 'folder'
})
const filterList = mergedList.filter((model) => { const filterList = pureModels.filter((model) => {
const showAllModel = currentType.value === 'all' const showAllModel = currentType.value === allType
const matchType = showAllModel || model.type === currentType.value const matchType = showAllModel || model.type === currentType.value
const matchName = model.fullname const matchName = model.basename
.toLowerCase() .toLowerCase()
.includes(searchContent.value?.toLowerCase() || '') .includes(searchContent.value?.toLowerCase() || '')
@@ -176,7 +233,7 @@ const list = computed(() => {
let sortStrategy: (a: Model, b: Model) => number = () => 0 let sortStrategy: (a: Model, b: Model) => number = () => 0
switch (sortOrder.value) { switch (sortOrder.value) {
case 'name': case 'name':
sortStrategy = (a, b) => a.fullname.localeCompare(b.fullname) sortStrategy = (a, b) => a.basename.localeCompare(b.basename)
break break
case 'size': case 'size':
sortStrategy = (a, b) => b.sizeBytes - a.sizeBytes sortStrategy = (a, b) => b.sizeBytes - a.sizeBytes
@@ -227,4 +284,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> </script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import SettingCardSize from 'components/SettingCardSize.vue'
import { request } from 'hooks/request' import { request } from 'hooks/request'
import { defineStore } from 'hooks/store' import { defineStore } from 'hooks/store'
import { $el, app, ComfyDialog } from 'scripts/comfyAPI' import { $el, app, ComfyDialog } from 'scripts/comfyAPI'
import { computed, onMounted, onUnmounted, readonly, ref } from 'vue' import { computed, onMounted, onUnmounted, readonly, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useToast } from './toast' import { useToast } from './toast'
@@ -24,6 +24,8 @@ export const useConfig = defineStore('config', (store) => {
window.removeEventListener('resize', checkDeviceType) window.removeEventListener('resize', checkDeviceType)
}) })
const flatLayout = ref(false)
const defaultCardSizeMap = readonly({ const defaultCardSizeMap = readonly({
'size.extraLarge': '240x320', 'size.extraLarge': '240x320',
'size.large': '180x240', 'size.large': '180x240',
@@ -64,8 +66,20 @@ export const useConfig = defineStore('config', (store) => {
}) })
}, },
}, },
flat: flatLayout,
} }
watch(cardSizeFlag, (val) => {
app.ui?.settings.setSettingValue('ModelManager.UI.CardSize', val)
})
watch(cardSizeMap, (val) => {
app.ui?.settings.setSettingValue(
'ModelManager.UI.CardSizeMap',
JSON.stringify(val),
)
})
useAddConfigSettings(store) useAddConfigSettings(store)
return config return config
@@ -161,6 +175,18 @@ function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
}, },
}) })
app.ui?.settings.addSetting({
id: 'ModelManager.UI.Flat',
category: [t('modelManager'), t('setting.ui'), 'Flat'],
name: t('setting.useFlatUI'),
type: 'boolean',
defaultValue: false,
onChange(value) {
store.dialog.closeAll()
store.config.flat.value = value
},
})
// Scan information // Scan information
app.ui?.settings.addSetting({ app.ui?.settings.addSetting({
id: 'ModelManager.ScanFiles.Full', id: 'ModelManager.ScanFiles.Full',

View File

@@ -63,7 +63,11 @@ export const useDialog = defineStore('dialog', () => {
} }
} }
return { stack, open, close, rise } const closeAll = () => {
stack.value = []
}
return { stack, open, close, closeAll, rise }
}) })
declare module 'hooks/store' { declare module 'hooks/store' {

View File

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

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

@@ -0,0 +1,147 @@
import { genModelFullName, useModels } from 'hooks/model'
import { cloneDeep, filter, find } from 'lodash'
import { BaseModel, Model, SelectOptions } from 'types/typings'
import { computed, ref, watchEffect } from 'vue'
export interface FolderPathItem {
name: string
icon?: string
onClick: () => void
children: SelectOptions[]
}
export type ModelFolder = BaseModel & {
type: 'folder'
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, ...modelRest } = useModels()
const folderPaths = ref<FolderPathItem[]>([])
const genFolderItem = (basename: string, subFolder: string): ModelFolder => {
return {
id: basename,
basename: basename,
subFolder: subFolder,
pathIndex: 0,
sizeBytes: 0,
extension: '',
description: '',
metadata: {},
preview: '',
type: 'folder',
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[], name: string) {
return find(list, { type: 'folder', basename: name }) as
| ModelFolder
| undefined
}
function findFolders(list: ModelTreeNode[]) {
return filter(list, { type: 'folder' }) as ModelFolder[]
}
async function openFolder(level: number, name: string, icon?: string) {
if (folderPaths.value.length >= level) {
folderPaths.value.splice(level)
}
let currentLevel = dataTreeList.value
for (const folderItem of folderPaths.value) {
const found = findFolder(currentLevel, folderItem.name)
currentLevel = found?.children || []
}
const folderItem = findFolder(currentLevel, name)
const folderItemChildren = folderItem?.children ?? []
const subFolders = findFolders(folderItemChildren)
folderPaths.value.push({
name,
icon,
onClick: () => {
openFolder(level, name, icon)
},
children: subFolders.map((item) => {
const name = item.basename
return {
value: name,
label: name,
command: () => openFolder(level + 1, name),
}
}),
})
}
watchEffect(() => {
if (Object.keys(folders.value).length > 0 && folderPaths.value.length < 2) {
openFolder(0, 'root', 'pi pi-desktop')
}
}, {})
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 { useLoading } from 'hooks/loading'
import { useMarkdown } from 'hooks/markdown' import { useMarkdown } from 'hooks/markdown'
import { request } from 'hooks/request' import { request } from 'hooks/request'
import { defineStore } from 'hooks/store' import { defineStore } from 'hooks/store'
import { useToast } from 'hooks/toast' import { useToast } from 'hooks/toast'
import { castArray, cloneDeep } from 'lodash' import { castArray, cloneDeep } from 'lodash'
import { TreeNode } from 'primevue/treenode'
import { app } from 'scripts/comfyAPI' import { app } from 'scripts/comfyAPI'
import { BaseModel, Model, SelectEvent, WithResolved } from 'types/typings' import { BaseModel, Model, SelectEvent, WithResolved } from 'types/typings'
import { bytesToSize, formatDate, previewUrlToFile } from 'utils/common' import { bytesToSize, formatDate, previewUrlToFile } from 'utils/common'
@@ -12,11 +14,14 @@ import { genModelKey, resolveModelTypeLoader } from 'utils/model'
import { import {
computed, computed,
inject, inject,
InjectionKey, type InjectionKey,
MaybeRefOrGetter,
onMounted, onMounted,
provide, provide,
type Ref,
ref, ref,
toRaw, toRaw,
toValue,
unref, unref,
} from 'vue' } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -24,7 +29,20 @@ import { configSetting } from './config'
type ModelFolder = Record<string, string[]> 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) => { export const useModels = defineStore('models', (store) => {
const { toast, confirm } = useToast() const { toast, confirm } = useToast()
@@ -32,6 +50,7 @@ export const useModels = defineStore('models', (store) => {
const loading = useLoading() const loading = useLoading()
const folders = ref<ModelFolder>({}) const folders = ref<ModelFolder>({})
const refreshFolders = async () => { const refreshFolders = async () => {
return request('/models').then((resData) => { return request('/models').then((resData) => {
folders.value = resData folders.value = resData
@@ -65,7 +84,7 @@ export const useModels = defineStore('models', (store) => {
?.split(',') ?.split(',')
.map((type) => type.trim()) .map((type) => type.trim())
.filter(Boolean) ?? [] .filter(Boolean) ?? []
return forceRefresh.then(() => await forceRefresh.then(() =>
Promise.allSettled( Promise.allSettled(
Object.keys(folders.value) Object.keys(folders.value)
.filter((folder) => !customBlackList.includes(folder)) .filter((folder) => !customBlackList.includes(folder))
@@ -102,13 +121,13 @@ export const useModels = defineStore('models', (store) => {
// Check current name and pathIndex // Check current name and pathIndex
if ( if (
model.fullname !== data.fullname || model.subFolder !== data.subFolder ||
model.pathIndex !== data.pathIndex model.pathIndex !== data.pathIndex
) { ) {
oldKey = genModelKey(model) oldKey = genModelKey(model)
updateData.set('type', data.type) updateData.set('type', data.type)
updateData.set('pathIndex', data.pathIndex.toString()) updateData.set('pathIndex', data.pathIndex.toString())
updateData.set('fullname', data.fullname) updateData.set('fullname', genModelFullName(data as BaseModel))
needUpdate = true needUpdate = true
} }
@@ -117,7 +136,8 @@ export const useModels = defineStore('models', (store) => {
} }
loading.show() loading.show()
await request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
await request(genModelUrl(model), {
method: 'PUT', method: 'PUT',
body: updateData, body: updateData,
}) })
@@ -160,14 +180,14 @@ export const useModels = defineStore('models', (store) => {
accept: () => { accept: () => {
const dialogKey = genModelKey(model) const dialogKey = genModelKey(model)
loading.show() loading.show()
request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, { request(genModelUrl(model), {
method: 'DELETE', method: 'DELETE',
}) })
.then(() => { .then(() => {
toast.add({ toast.add({
severity: 'success', severity: 'success',
summary: 'Success', summary: 'Success',
detail: `${model.fullname} Deleted`, detail: `${model.basename} Deleted`,
life: 2000, life: 2000,
}) })
store.dialog.close({ key: dialogKey }) store.dialog.close({ key: dialogKey })
@@ -195,12 +215,24 @@ 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 },
})
}
return { return {
folders: folders, folders: folders,
data: models, data: models,
refresh: refreshAllModels, refresh: refreshAllModels,
remove: deleteModel, remove: deleteModel,
update: updateModel, update: updateModel,
openModelDetail: openModelDetail,
} }
}) })
@@ -269,7 +301,7 @@ const baseInfoKey = Symbol('baseInfo') as InjectionKey<
export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => { export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
const { formData: model, modelData } = formInstance const { formData: model, modelData } = formInstance
const provideModelFolders = inject<any>(modelFolderProvideKey) const provideModelFolders = inject(modelFolderProvideKey)
const modelFolders = computed<ModelFolder>(() => { const modelFolders = computed<ModelFolder>(() => {
return provideModelFolders?.value ?? {} return provideModelFolders?.value ?? {}
}) })
@@ -292,16 +324,25 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
}, },
}) })
const subFolder = computed({
get: () => {
return model.value.subFolder
},
set: (val) => {
model.value.subFolder = val
},
})
const extension = computed(() => { const extension = computed(() => {
return model.value.extension return model.value.extension
}) })
const basename = computed({ const basename = computed({
get: () => { get: () => {
return model.value.fullname.replace(model.value.extension, '') return model.value.basename
}, },
set: (val) => { set: (val) => {
model.value.fullname = `${val ?? ''}${model.value.extension}` model.value.basename = val
}, },
}) })
@@ -328,15 +369,20 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
{ {
key: 'pathIndex', key: 'pathIndex',
formatter: () => { formatter: () => {
const modelType = modelData.value.type const modelType = model.value.type
const pathIndex = modelData.value.pathIndex const pathIndex = model.value.pathIndex
if (!modelType) {
return undefined
}
const folders = modelFolders.value[modelType] ?? [] const folders = modelFolders.value[modelType] ?? []
return `${folders[pathIndex]}` return [`${folders[pathIndex]}`, model.value.subFolder]
.filter(Boolean)
.join('/')
}, },
}, },
{ {
key: 'fullname', key: 'basename',
formatter: (val) => val, formatter: (val) => `${val}${model.value.extension}`,
}, },
{ {
key: 'sizeBytes', key: 'sizeBytes',
@@ -371,6 +417,7 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
baseInfo, baseInfo,
basename, basename,
extension, extension,
subFolder,
pathIndex, pathIndex,
modelFolders, modelFolders,
} }
@@ -384,6 +431,74 @@ export const useModelBaseInfo = () => {
return inject(baseInfoKey)! return inject(baseInfoKey)!
} }
export const useModelFolder = (
option: {
type?: MaybeRefOrGetter<string>
} = {},
) => {
const { data: models, folders: modelFolders } = useModels()
const pathOptions = computed(() => {
const type = toValue(option.type)
if (!type) {
return []
}
const folderItems = cloneDeep(models.value[type]) ?? []
const pureFolders = folderItems.filter((item) => item.type === 'folder')
pureFolders.sort((a, b) => a.basename.localeCompare(b.basename))
const folders = modelFolders.value[type] ?? []
const root: TreeNode[] = []
for (const [index, folder] of folders.entries()) {
const pathIndexItem: TreeNode = {
key: folder,
label: folder,
children: [],
}
const items = pureFolders
.filter((item) => item.pathIndex === index)
.map((item) => {
const node: TreeNode = {
key: `${folder}/${genModelFullName(item)}`,
label: item.basename,
data: item,
}
return node
})
const itemMap = Object.fromEntries(items.map((item) => [item.key, item]))
for (const item of items) {
const key = item.key
const parentKey = key.split('/').slice(0, -1).join('/')
if (parentKey === folder) {
pathIndexItem.children!.push(item)
continue
}
const parentItem = itemMap[parentKey]
if (parentItem) {
parentItem.children ??= []
parentItem.children.push(item)
}
}
root.push(pathIndexItem)
}
return root
})
return {
pathOptions,
}
}
/** /**
* Editable preview image. * Editable preview image.
* *
@@ -429,7 +544,8 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
* No preview * No preview
*/ */
const noPreviewContent = computed(() => { const noPreviewContent = computed(() => {
return `/model-manager/preview/${model.value.type}/0/no-preview.png` const folder = model.value.type || 'unknown'
return `/model-manager/preview/${folder}/0/no-preview.png`
}) })
const preview = computed(() => { const preview = computed(() => {
@@ -552,11 +668,11 @@ export const useModelMetadata = () => {
return inject(metadataKey)! return inject(metadataKey)!
} }
export const useModelNodeAction = (model: BaseModel) => { export const useModelNodeAction = () => {
const { t } = useI18n() const { t } = useI18n()
const { toast, wrapperToastError } = useToast() const { toast, wrapperToastError } = useToast()
const createNode = (options: Record<string, any> = {}) => { const createNode = (model: BaseModel, options: Record<string, any> = {}) => {
const nodeType = resolveModelTypeLoader(model.type) const nodeType = resolveModelTypeLoader(model.type)
if (!nodeType) { if (!nodeType) {
throw new Error(t('unSupportedModelType', [model.type])) throw new Error(t('unSupportedModelType', [model.type]))
@@ -565,50 +681,52 @@ export const useModelNodeAction = (model: BaseModel) => {
const node = window.LiteGraph.createNode(nodeType, null, options) const node = window.LiteGraph.createNode(nodeType, null, options)
const widgetIndex = node.widgets.findIndex((w) => w.type === 'combo') const widgetIndex = node.widgets.findIndex((w) => w.type === 'combo')
if (widgetIndex > -1) { if (widgetIndex > -1) {
node.widgets[widgetIndex].value = model.fullname node.widgets[widgetIndex].value = genModelFullName(model)
} }
return node return node
} }
const dragToAddModelNode = wrapperToastError((event: DragEvent) => { const dragToAddModelNode = wrapperToastError(
// const target = document.elementFromPoint(event.clientX, event.clientY) (model: BaseModel, event: DragEvent) => {
// if ( // const target = document.elementFromPoint(event.clientX, event.clientY)
// target?.tagName.toLocaleLowerCase() === 'canvas' && // if (
// target.id === 'graph-canvas' // target?.tagName.toLocaleLowerCase() === 'canvas' &&
// ) { // target.id === 'graph-canvas'
// const pos = app.clientPosToCanvasPos([event.clientX - 20, event.clientY]) // ) {
// const node = createNode({ pos }) // const pos = app.clientPosToCanvasPos([event.clientX - 20, event.clientY])
// app.graph.add(node) // const node = createNode({ pos })
// app.canvas.selectNode(node) // app.graph.add(node)
// } // app.canvas.selectNode(node)
// // }
// Use the legacy method instead //
const removeEmbeddingExtension = true // Use the legacy method instead
const strictDragToAdd = false const removeEmbeddingExtension = true
const strictDragToAdd = false
ModelGrid.dragAddModel( ModelGrid.dragAddModel(
event, event,
model.type, model.type,
model.fullname, genModelFullName(model),
removeEmbeddingExtension, removeEmbeddingExtension,
strictDragToAdd, strictDragToAdd,
) )
}) },
)
const addModelNode = wrapperToastError(() => { const addModelNode = wrapperToastError((model: BaseModel) => {
const selectedNodes = app.canvas.selected_nodes const selectedNodes = app.canvas.selected_nodes
const firstSelectedNode = Object.values(selectedNodes)[0] const firstSelectedNode = Object.values(selectedNodes)[0]
const offset = 25 const offset = 25
const pos = firstSelectedNode const pos = firstSelectedNode
? [firstSelectedNode.pos[0] + offset, firstSelectedNode.pos[1] + offset] ? [firstSelectedNode.pos[0] + offset, firstSelectedNode.pos[1] + offset]
: app.canvas.canvas_mouse : app.canvas.canvas_mouse
const node = createNode({ pos }) const node = createNode(model, { pos })
app.graph.add(node) app.graph.add(node)
app.canvas.selectNode(node) app.canvas.selectNode(node)
}) })
const copyModelNode = wrapperToastError(() => { const copyModelNode = wrapperToastError((model: BaseModel) => {
const node = createNode() const node = createNode(model)
app.canvas.copyToClipboard([node]) app.canvas.copyToClipboard([node])
toast.add({ toast.add({
severity: 'success', severity: 'success',
@@ -618,13 +736,13 @@ export const useModelNodeAction = (model: BaseModel) => {
}) })
}) })
const loadPreviewWorkflow = wrapperToastError(async () => { const loadPreviewWorkflow = wrapperToastError(async (model: BaseModel) => {
const previewUrl = model.preview as string const previewUrl = model.preview as string
const response = await fetch(previewUrl) const response = await fetch(previewUrl)
const data = await response.blob() const data = await response.blob()
const type = data.type const type = data.type
const extension = type.split('/').pop() const extension = type.split('/').pop()
const file = new File([data], `${model.fullname}.${extension}`, { type }) const file = new File([data], `${model.basename}.${extension}`, { type })
app.handleFile(file) app.handleFile(file)
}) })

View File

@@ -46,7 +46,7 @@ const messages = {
info: { info: {
type: 'Model Type', type: 'Model Type',
pathIndex: 'Directory', pathIndex: 'Directory',
fullname: 'File Name', basename: 'File Name',
sizeBytes: 'File Size', sizeBytes: 'File Size',
createdAt: 'Created At', createdAt: 'Created At',
updatedAt: 'Updated At', updatedAt: 'Updated At',
@@ -62,6 +62,7 @@ const messages = {
excludeScanTypes: 'Exclude scan types (separate with commas)', excludeScanTypes: 'Exclude scan types (separate with commas)',
ui: 'UI', ui: 'UI',
cardSize: 'Card Size', cardSize: 'Card Size',
useFlatUI: 'Flat Layout',
}, },
}, },
zh: { zh: {
@@ -108,7 +109,7 @@ const messages = {
info: { info: {
type: '类型', type: '类型',
pathIndex: '目录', pathIndex: '目录',
fullname: '文件名', basename: '文件名',
sizeBytes: '文件大小', sizeBytes: '文件大小',
createdAt: '创建时间', createdAt: '创建时间',
updatedAt: '更新时间', updatedAt: '更新时间',
@@ -124,6 +125,7 @@ const messages = {
excludeScanTypes: '排除扫描类型(使用英文逗号隔开)', excludeScanTypes: '排除扫描类型(使用英文逗号隔开)',
ui: '外观', ui: '外观',
cardSize: '卡片尺寸', cardSize: '卡片尺寸',
useFlatUI: '展平布局',
}, },
}, },
} }

View File

@@ -3,11 +3,11 @@ export type ContainerPosition = { left: number; top: number }
export interface BaseModel { export interface BaseModel {
id: number | string id: number | string
fullname: string
basename: string basename: string
extension: string extension: string
sizeBytes: number sizeBytes: number
type: string type: string
subFolder: string
pathIndex: number pathIndex: number
preview: string | string[] preview: string | string[]
description: string description: string
@@ -17,6 +17,7 @@ export interface BaseModel {
export interface Model extends BaseModel { export interface Model extends BaseModel {
createdAt: number createdAt: number
updatedAt: number updatedAt: number
children?: Model[]
} }
export interface VersionModel extends BaseModel { export interface VersionModel extends BaseModel {

View File

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

View File

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