Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1975e2056d | ||
|
|
8877c1599b | ||
|
|
965905305e | ||
|
|
312138f981 | ||
|
|
76df8cd3cb | ||
|
|
df17eae0a2 | ||
|
|
7df89c7265 | ||
|
|
450072e49d | ||
|
|
759865e8ea | ||
|
|
304978a7b8 | ||
|
|
704f35a1a8 | ||
|
|
ce42960d57 | ||
|
|
05fa31f2c5 | ||
|
|
ea26ec5098 | ||
|
|
3d01c2dfda | ||
|
|
59552841e7 | ||
|
|
ad6045f286 | ||
|
|
86c11e5343 | ||
|
|
37be9a0b0d | ||
|
|
fcea052dde | ||
|
|
9e95e7bd74 | ||
|
|
7e58d0a82d |
651
py/download.py
651
py/download.py
@@ -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, let’s 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, let’s 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())
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -120,41 +120,48 @@ 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:
|
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
|
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,
|
||||||
|
"subFolder": sub_folder,
|
||||||
|
"isFolder": not is_file,
|
||||||
"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):
|
||||||
|
|||||||
@@ -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.5.2"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
dependencies = ["markdownify"]
|
dependencies = ["markdownify"]
|
||||||
|
|
||||||
|
|||||||
@@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
337
src/components/DialogExplorer.vue
Normal file
337
src/components/DialogExplorer.vue
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex h-full w-full select-none flex-col overflow-hidden"
|
||||||
|
@contextmenu.prevent="nonContextMenu"
|
||||||
|
>
|
||||||
|
<div class="flex w-full gap-4 overflow-hidden px-4 pb-4">
|
||||||
|
<div :class="['flex gap-4 overflow-hidden', showToolbar || 'flex-1']">
|
||||||
|
<div class="flex overflow-hidden">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-arrow-up"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
severity="secondary"
|
||||||
|
:disabled="folderPaths.length < 2"
|
||||||
|
@click="handleGoBackParentFolder"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponseBreadcrumb
|
||||||
|
v-show="!showToolbar"
|
||||||
|
class="h-10 flex-1"
|
||||||
|
:items="folderPaths"
|
||||||
|
></ResponseBreadcrumb>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="['flex gap-4', showToolbar && 'flex-1']">
|
||||||
|
<ResponseInput
|
||||||
|
v-model="searchContent"
|
||||||
|
:placeholder="$t('searchModels')"
|
||||||
|
></ResponseInput>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-show="showToolbar"
|
||||||
|
class="flex flex-1 items-center justify-end gap-2"
|
||||||
|
>
|
||||||
|
<ResponseSelect
|
||||||
|
v-model="sortOrder"
|
||||||
|
:items="sortOrderOptions"
|
||||||
|
></ResponseSelect>
|
||||||
|
<ResponseSelect
|
||||||
|
v-model="cardSizeFlag"
|
||||||
|
:items="cardSizeOptions"
|
||||||
|
></ResponseSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
:icon="`mdi mdi-menu-${showToolbar ? 'close' : 'open'}`"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
@click="toggleToolbar"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="contentContainer"
|
||||||
|
class="relative flex-1 overflow-hidden px-2"
|
||||||
|
@contextmenu.stop.prevent=""
|
||||||
|
>
|
||||||
|
<ResponseScroll :items="renderedList" :item-size="itemSize">
|
||||||
|
<template #item="{ item }">
|
||||||
|
<div
|
||||||
|
class="grid h-full justify-center"
|
||||||
|
:style="{
|
||||||
|
gridTemplateColumns: `repeat(auto-fit, ${cardSize.width}px)`,
|
||||||
|
columnGap: `${gutter.x}px`,
|
||||||
|
rowGap: `${gutter.y}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ModelCard
|
||||||
|
v-for="rowItem in item.row"
|
||||||
|
:model="rowItem"
|
||||||
|
:key="genModelKey(rowItem)"
|
||||||
|
:style="{
|
||||||
|
width: `${cardSize.width}px`,
|
||||||
|
height: `${cardSize.height}px`,
|
||||||
|
}"
|
||||||
|
v-tooltip.top="{
|
||||||
|
value: getFullPath(rowItem),
|
||||||
|
disabled: folderPaths.length < 2,
|
||||||
|
autoHide: false,
|
||||||
|
showDelay: 800,
|
||||||
|
hideDelay: 300,
|
||||||
|
pt: { root: { style: { zIndex: 2100, maxWidth: '32rem' } } },
|
||||||
|
}"
|
||||||
|
@dblclick="openItem(rowItem, $event)"
|
||||||
|
@contextmenu.stop.prevent="openItemContext(rowItem, $event)"
|
||||||
|
></ModelCard>
|
||||||
|
<div class="col-span-full"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ResponseScroll>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between px-4 py-2 text-sm">
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ContextMenu ref="menu" :model="contextItems"></ContextMenu>
|
||||||
|
|
||||||
|
<ConfirmDialog group="confirm-name">
|
||||||
|
<template #container="{ acceptCallback: accept, rejectCallback: reject }">
|
||||||
|
<div class="flex w-90 flex-col items-end rounded px-4 pb-4 pt-8">
|
||||||
|
<InputText
|
||||||
|
class="w-full"
|
||||||
|
type="text"
|
||||||
|
v-model="confirmName"
|
||||||
|
v-focus
|
||||||
|
@keyup.enter="accept"
|
||||||
|
></InputText>
|
||||||
|
<div class="mt-6 flex items-center gap-2">
|
||||||
|
<Button :label="$t('cancel')" @click="reject" outlined></Button>
|
||||||
|
<Button :label="$t('confirm')" @click="accept"></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ConfirmDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useElementSize } from '@vueuse/core'
|
||||||
|
import ModelCard from 'components/ModelCard.vue'
|
||||||
|
import ResponseBreadcrumb from 'components/ResponseBreadcrumb.vue'
|
||||||
|
import ResponseInput from 'components/ResponseInput.vue'
|
||||||
|
import ResponseScroll from 'components/ResponseScroll.vue'
|
||||||
|
import ResponseSelect from 'components/ResponseSelect.vue'
|
||||||
|
import { useConfig } from 'hooks/config'
|
||||||
|
import { type ModelTreeNode, useModelExplorer } from 'hooks/explorer'
|
||||||
|
import { chunk } from 'lodash'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import ConfirmDialog from 'primevue/confirmdialog'
|
||||||
|
import ContextMenu from 'primevue/contextmenu'
|
||||||
|
import InputText from 'primevue/inputtext'
|
||||||
|
import { MenuItem } from 'primevue/menuitem'
|
||||||
|
import { genModelKey } from 'utils/model'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const gutter = {
|
||||||
|
x: 4,
|
||||||
|
y: 32,
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
dataTreeList,
|
||||||
|
folderPaths,
|
||||||
|
findFolder,
|
||||||
|
openFolder,
|
||||||
|
openModelDetail,
|
||||||
|
getFullPath,
|
||||||
|
} = useModelExplorer()
|
||||||
|
const { cardSize, cardSizeMap, cardSizeFlag, dialog: settings } = useConfig()
|
||||||
|
|
||||||
|
const showToolbar = ref(false)
|
||||||
|
const toggleToolbar = () => {
|
||||||
|
showToolbar.value = !showToolbar.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentContainer = ref<HTMLElement | null>(null)
|
||||||
|
const contentSize = useElementSize(contentContainer)
|
||||||
|
|
||||||
|
const itemSize = computed(() => {
|
||||||
|
return cardSize.value.height + gutter.y
|
||||||
|
})
|
||||||
|
|
||||||
|
const cols = computed(() => {
|
||||||
|
const containerWidth = contentSize.width.value + gutter.x
|
||||||
|
const itemWidth = cardSize.value.width + gutter.x
|
||||||
|
|
||||||
|
return Math.floor(containerWidth / itemWidth)
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchContent = ref<string>()
|
||||||
|
|
||||||
|
const sortOrder = ref('name')
|
||||||
|
const sortOrderOptions = ref(
|
||||||
|
['name', 'size', 'created', 'modified'].map((key) => {
|
||||||
|
return {
|
||||||
|
label: t(`sort.${key}`),
|
||||||
|
value: key,
|
||||||
|
icon: key === 'name' ? 'pi pi-sort-alpha-down' : 'pi pi-sort-amount-down',
|
||||||
|
command: () => {
|
||||||
|
sortOrder.value = key
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentDataList = computed(() => {
|
||||||
|
let renderedList = dataTreeList.value
|
||||||
|
for (const folderItem of folderPaths.value) {
|
||||||
|
const found = findFolder(renderedList, {
|
||||||
|
basename: folderItem.name,
|
||||||
|
pathIndex: folderItem.pathIndex,
|
||||||
|
})
|
||||||
|
renderedList = found?.children || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = searchContent.value?.toLowerCase().trim() ?? ''
|
||||||
|
if (filter) {
|
||||||
|
const filterItems: ModelTreeNode[] = []
|
||||||
|
|
||||||
|
const searchList = [...renderedList]
|
||||||
|
|
||||||
|
while (searchList.length) {
|
||||||
|
const item = searchList.pop()!
|
||||||
|
const children = (item as any).children ?? []
|
||||||
|
searchList.push(...children)
|
||||||
|
|
||||||
|
const matchSubFolder = `${item.subFolder}/`.toLowerCase().includes(filter)
|
||||||
|
const matchName = item.basename.toLowerCase().includes(filter)
|
||||||
|
|
||||||
|
if (matchSubFolder || matchName) {
|
||||||
|
filterItems.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderedList = filterItems
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderPaths.value.length > 1) {
|
||||||
|
const folderItems: ModelTreeNode[] = []
|
||||||
|
const modelItems: ModelTreeNode[] = []
|
||||||
|
|
||||||
|
for (const item of renderedList) {
|
||||||
|
if (item.isFolder) {
|
||||||
|
folderItems.push(item)
|
||||||
|
} else {
|
||||||
|
modelItems.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
folderItems.sort((a, b) => {
|
||||||
|
return a.basename.localeCompare(b.basename)
|
||||||
|
})
|
||||||
|
modelItems.sort((a, b) => {
|
||||||
|
const sortFieldMap = {
|
||||||
|
name: 'basename',
|
||||||
|
size: 'sizeBytes',
|
||||||
|
created: 'createdAt',
|
||||||
|
modified: 'updatedAt',
|
||||||
|
}
|
||||||
|
const sortField = sortFieldMap[sortOrder.value]
|
||||||
|
|
||||||
|
const aValue = a[sortField]
|
||||||
|
const bValue = b[sortField]
|
||||||
|
|
||||||
|
const result =
|
||||||
|
typeof aValue === 'string'
|
||||||
|
? aValue.localeCompare(bValue)
|
||||||
|
: aValue - bValue
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
renderedList = [...folderItems, ...modelItems]
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderedList
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderedList = computed(() => {
|
||||||
|
return chunk(currentDataList.value, cols.value).map((row) => {
|
||||||
|
return { key: row.map((o) => o.basename).join('#'), row }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const cardSizeOptions = computed(() => {
|
||||||
|
const customSize = 'size.custom'
|
||||||
|
|
||||||
|
const customOptionMap = {
|
||||||
|
...cardSizeMap.value,
|
||||||
|
[customSize]: 'custom',
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(customOptionMap).map((key) => {
|
||||||
|
return {
|
||||||
|
label: t(key),
|
||||||
|
value: key,
|
||||||
|
command: () => {
|
||||||
|
if (key === customSize) {
|
||||||
|
settings.showCardSizeSetting()
|
||||||
|
} else {
|
||||||
|
cardSizeFlag.value = key
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const menu = ref()
|
||||||
|
const contextItems = ref<MenuItem[]>([])
|
||||||
|
const confirmName = ref('')
|
||||||
|
|
||||||
|
const openItem = (item: ModelTreeNode, e: Event) => {
|
||||||
|
menu.value.hide(e)
|
||||||
|
if (item.isFolder) {
|
||||||
|
searchContent.value = undefined
|
||||||
|
openFolder(item)
|
||||||
|
} else {
|
||||||
|
openModelDetail(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openItemContext = (item: ModelTreeNode, e: Event) => {
|
||||||
|
if (folderPaths.value.length < 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contextItems.value = [
|
||||||
|
{
|
||||||
|
label: t('open'),
|
||||||
|
icon: 'pi pi-folder-open',
|
||||||
|
command: () => {
|
||||||
|
openItem(item, e)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
menu.value?.show(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonContextMenu = (e: Event) => {
|
||||||
|
menu.value.hide(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const vFocus = {
|
||||||
|
mounted: (el: HTMLInputElement) => el.focus(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGoBackParentFolder = () => {
|
||||||
|
folderPaths.value.pop()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -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,62 @@
|
|||||||
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: 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 class="col-span-full"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -69,8 +127,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 +145,7 @@ const {
|
|||||||
dialog: settings,
|
dialog: settings,
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
|
|
||||||
const { data, folders } = useModels()
|
const { data, folders, openModelDetail, getFullPath } = useModels()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const toolbarContainer = ref<HTMLElement | null>(null)
|
const toolbarContainer = ref<HTMLElement | null>(null)
|
||||||
@@ -97,7 +156,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 +168,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,22 +221,26 @@ 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.isFolder
|
||||||
|
})
|
||||||
|
|
||||||
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
|
|
||||||
.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
|
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 +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>
|
</script>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
<component :is="item.content" v-bind="item.contentProps"></component>
|
<component :is="item.content" v-bind="item.contentProps"></component>
|
||||||
</template>
|
</template>
|
||||||
</ResponseDialog>
|
</ResponseDialog>
|
||||||
|
<Dialog :visible="true" :pt:mask:style="{ display: 'none' }"></Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -44,7 +45,8 @@ 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 Dialog from 'primevue/dialog'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const { stack, rise, close } = useDialog()
|
const { stack, rise, close } = useDialog()
|
||||||
|
|
||||||
@@ -53,10 +55,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>
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -37,7 +84,17 @@
|
|||||||
<td class="border-r bg-gray-300 px-4 dark:bg-gray-800">
|
<td class="border-r bg-gray-300 px-4 dark:bg-gray-800">
|
||||||
{{ $t(`info.${item.key}`) }}
|
{{ $t(`info.${item.key}`) }}
|
||||||
</td>
|
</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 }}
|
{{ item.display }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -48,14 +105,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 +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(() => {
|
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>
|
||||||
|
|||||||
@@ -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.isFolder" class="h-full w-full">
|
||||||
|
<svg
|
||||||
|
class="icon"
|
||||||
|
viewBox="0 0 1024 1024"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M853.333333 256H469.333333l-85.333333-85.333333H170.666667c-46.933333 0-85.333333 38.4-85.333334 85.333333v170.666667h853.333334v-85.333334c0-46.933333-38.4-85.333333-85.333334-85.333333z"
|
||||||
|
fill="#FFA000"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M853.333333 256H170.666667c-46.933333 0-85.333333 38.4-85.333334 85.333333v426.666667c0 46.933333 38.4 85.333333 85.333334 85.333333h682.666666c46.933333 0 85.333333-38.4 85.333334-85.333333V341.333333c0-46.933333-38.4-85.333333-85.333334-85.333333z"
|
||||||
|
fill="#FFCA28"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div v-else class="h-full w-full p-1 hover:p-0">
|
||||||
|
<img class="h-full w-full rounded-lg object-cover" :src="preview" />
|
||||||
|
</div>
|
||||||
</div>
|
</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.isFolder"
|
||||||
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.isFolder"
|
||||||
<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>
|
||||||
|
|||||||
163
src/components/ResponseBreadcrumb.vue
Normal file
163
src/components/ResponseBreadcrumb.vue
Normal 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>
|
||||||
@@ -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 }"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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' {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
170
src/hooks/explorer.ts
Normal file
170
src/hooks/explorer.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { genModelFullName, useModels } from 'hooks/model'
|
||||||
|
import { cloneDeep, filter, find } from 'lodash'
|
||||||
|
import { BaseModel, Model, SelectOptions } from 'types/typings'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
export interface FolderPathItem {
|
||||||
|
name: string
|
||||||
|
pathIndex: number
|
||||||
|
icon?: string
|
||||||
|
onClick: () => void
|
||||||
|
children: SelectOptions[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelFolder = BaseModel & {
|
||||||
|
children: ModelTreeNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelItem = Model
|
||||||
|
|
||||||
|
export type ModelTreeNode = BaseModel & {
|
||||||
|
children?: ModelTreeNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TreeItemNode = ModelTreeNode & {
|
||||||
|
onDbClick: () => void
|
||||||
|
onContextMenu: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useModelExplorer = () => {
|
||||||
|
const { data, folders, initialized, ...modelRest } = useModels()
|
||||||
|
|
||||||
|
const folderPaths = ref<FolderPathItem[]>([])
|
||||||
|
|
||||||
|
const genFolderItem = (
|
||||||
|
basename: string,
|
||||||
|
folder?: string,
|
||||||
|
subFolder?: string,
|
||||||
|
): ModelFolder => {
|
||||||
|
return {
|
||||||
|
id: basename,
|
||||||
|
basename: basename,
|
||||||
|
subFolder: subFolder ?? '',
|
||||||
|
pathIndex: 0,
|
||||||
|
sizeBytes: 0,
|
||||||
|
extension: '',
|
||||||
|
description: '',
|
||||||
|
metadata: {},
|
||||||
|
preview: '',
|
||||||
|
type: folder ?? '',
|
||||||
|
isFolder: true,
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataTreeList = computed<ModelTreeNode[]>(() => {
|
||||||
|
const rootChildren: ModelTreeNode[] = []
|
||||||
|
|
||||||
|
for (const folder in folders.value) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(folders.value, folder)) {
|
||||||
|
const folderItem = genFolderItem(folder)
|
||||||
|
|
||||||
|
const folderModels = cloneDeep(data.value[folder]) ?? []
|
||||||
|
|
||||||
|
const pathMap: Record<string, ModelTreeNode> = Object.fromEntries(
|
||||||
|
folderModels.map((item) => [
|
||||||
|
`${item.pathIndex}-${genModelFullName(item)}`,
|
||||||
|
item,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const item of folderModels) {
|
||||||
|
const key = genModelFullName(item)
|
||||||
|
const parentKey = key.split('/').slice(0, -1).join('/')
|
||||||
|
|
||||||
|
if (parentKey === '') {
|
||||||
|
folderItem.children.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentItem = pathMap[`${item.pathIndex}-${parentKey}`]
|
||||||
|
if (parentItem) {
|
||||||
|
parentItem.children ??= []
|
||||||
|
parentItem.children.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootChildren.push(folderItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const root: ModelTreeNode = genFolderItem('root')
|
||||||
|
root.children = rootChildren
|
||||||
|
return [root]
|
||||||
|
})
|
||||||
|
|
||||||
|
function findFolder(
|
||||||
|
list: ModelTreeNode[],
|
||||||
|
feature: { basename: string; pathIndex: number },
|
||||||
|
) {
|
||||||
|
return find(list, { ...feature, isFolder: true }) as ModelFolder | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFolders(list: ModelTreeNode[]) {
|
||||||
|
return filter(list, { isFolder: true }) as ModelFolder[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFolder(item: BaseModel) {
|
||||||
|
const folderItems: FolderPathItem[] = []
|
||||||
|
|
||||||
|
const folder = item.type
|
||||||
|
const subFolderParts = item.subFolder.split('/').filter(Boolean)
|
||||||
|
|
||||||
|
const pathParts: string[] = []
|
||||||
|
if (folder) {
|
||||||
|
pathParts.push(folder, ...subFolderParts)
|
||||||
|
}
|
||||||
|
pathParts.push(item.basename)
|
||||||
|
if (pathParts[0] !== 'root') {
|
||||||
|
pathParts.unshift('root')
|
||||||
|
}
|
||||||
|
|
||||||
|
let levelFolders = findFolders(dataTreeList.value)
|
||||||
|
for (const [index, part] of pathParts.entries()) {
|
||||||
|
const pathIndex = index < 2 ? 0 : item.pathIndex
|
||||||
|
|
||||||
|
const currentFolder = findFolder(levelFolders, {
|
||||||
|
basename: part,
|
||||||
|
pathIndex: pathIndex,
|
||||||
|
})
|
||||||
|
if (!currentFolder) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
levelFolders = findFolders(currentFolder.children ?? [])
|
||||||
|
folderItems.push({
|
||||||
|
name: currentFolder.basename,
|
||||||
|
pathIndex: pathIndex,
|
||||||
|
icon: index === 0 ? 'pi pi-desktop' : '',
|
||||||
|
onClick: () => {
|
||||||
|
openFolder(currentFolder)
|
||||||
|
},
|
||||||
|
children: levelFolders.map((child) => {
|
||||||
|
const name = child.basename
|
||||||
|
return {
|
||||||
|
value: name,
|
||||||
|
label: name,
|
||||||
|
command: () => openFolder(child),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
folderPaths.value = folderItems
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(initialized, (val) => {
|
||||||
|
if (val) {
|
||||||
|
openFolder(dataTreeList.value[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
folders,
|
||||||
|
folderPaths,
|
||||||
|
dataTreeList,
|
||||||
|
...modelRest,
|
||||||
|
findFolder: findFolder,
|
||||||
|
findFolders: findFolders,
|
||||||
|
openFolder: openFolder,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,9 +50,12 @@ export const useModels = defineStore('models', (store) => {
|
|||||||
const loading = useLoading()
|
const loading = useLoading()
|
||||||
|
|
||||||
const folders = ref<ModelFolder>({})
|
const folders = ref<ModelFolder>({})
|
||||||
|
const initialized = ref(false)
|
||||||
|
|
||||||
const refreshFolders = async () => {
|
const refreshFolders = async () => {
|
||||||
return request('/models').then((resData) => {
|
return request('/models').then((resData) => {
|
||||||
folders.value = resData
|
folders.value = resData
|
||||||
|
initialized.value = true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +86,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 +123,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 +138,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 +182,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 +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 {
|
return {
|
||||||
|
initialized: initialized,
|
||||||
folders: folders,
|
folders: folders,
|
||||||
data: models,
|
data: models,
|
||||||
refresh: refreshAllModels,
|
refresh: refreshAllModels,
|
||||||
remove: deleteModel,
|
remove: deleteModel,
|
||||||
update: updateModel,
|
update: updateModel,
|
||||||
|
openModelDetail: openModelDetail,
|
||||||
|
getFullPath: getFullPath,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -269,7 +311,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 +334,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 +379,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 +427,7 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
|
|||||||
baseInfo,
|
baseInfo,
|
||||||
basename,
|
basename,
|
||||||
extension,
|
extension,
|
||||||
|
subFolder,
|
||||||
pathIndex,
|
pathIndex,
|
||||||
modelFolders,
|
modelFolders,
|
||||||
}
|
}
|
||||||
@@ -384,6 +441,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.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.
|
* Editable preview image.
|
||||||
*
|
*
|
||||||
@@ -429,7 +554,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 +678,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 +691,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 +746,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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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: '展平布局',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
4
src/types/typings.d.ts
vendored
4
src/types/typings.d.ts
vendored
@@ -3,12 +3,13 @@ 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
|
||||||
|
isFolder: boolean
|
||||||
preview: string | string[]
|
preview: string | string[]
|
||||||
description: string
|
description: string
|
||||||
metadata: Record<string, string>
|
metadata: Record<string, string>
|
||||||
@@ -17,6 +18,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 {
|
||||||
|
|||||||
@@ -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}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user