Compare commits

...
15 Commits
Author SHA1 Message Date
Hayden 8ea0ddbc54 Prepare release 2.8.5 2026-07-02 21:19:53 +08:00
Christopher Schmitt 4b405d77d2 Add path traversal check in get_full_path and refactor get_validated_path (#222) 2026-07-02 21:16:49 +08:00
Hayden 39e4581558 Prepare release 2.8.4
update repository references from 'hayden-fr' to 'hayden-cn'
2026-05-27 13:04:38 +08:00
hayden c55ee62ac9 fix: update repository references from 'hayden-fr' to 'hayden-cn' 2026-05-27 13:01:33 +08:00
Hayden 33c43085c4 [feat]dialog header action tooltip support (#219)
* fix: remove duplicate variables resulting from merging

* feat(dialog): add tooltip support for header buttons
2026-02-26 15:23:40 +08:00
AinaemaetandHayden dc1d12649a feat(ui): add hidden-files toggle and i18n updates (#214)
* feat(search): add multi-token regex and wildcard support

* feat(ui): add layout toggle button in Model Manager header

* feat(ui): add hidden-files toggle and i18n updates

- Added a new header button in the Model Manager to toggle display of
hidden files/folders (dot-prefixed). This updates the existing
`ModelManager.Scan.IncludeHiddenFiles` setting, persists it, and
refreshes the dialog so changes apply immediately.
- Button shows dynamic icon + tooltip:
  - 👁 "Show hidden files"
  - 👁‍🗨 "Hide hidden files"

- Updated i18n locale files:
  - en.json: added strings for both the layout toggle and hidden-files toggle
  - zh.json: added translations for the same strings

Note: normally these i18n keys would have been split between the layout
commit and this one, but they are grouped here for simplicity so all UI
strings for the new header buttons are introduced together.

---------

Co-authored-by: Hayden <48267247+hayden-fr@users.noreply.github.com>
2026-02-26 15:09:26 +08:00
moldwebs 0fdbc5d7be Direct file download (#215) 2026-02-26 14:37:51 +08:00
Ainaemaet 60837ae35f feat(ui): add layout toggle + hidden toggle, cleanup and formatting (#213) 2026-02-26 14:24:50 +08:00
Ainaemaet e88a77f224 feat(search): add multi-token regex and wildcard support (#211)
* feat(search): add multi-token regex and wildcard support

* feat(ui): add layout toggle button in Model Manager header
2025-09-24 15:18:25 +08:00
Hayden f3de2006ef Prepare release 2.8.3 2025-09-05 16:53:05 +08:00
Hayden 0295dd6288 fix: Validate existence of entry path after improvements previews (#205) 2025-09-05 16:51:54 +08:00
Koro 4f9a437725 Improvements in previews reading (#204)
* Improve model preview handling and optimize file processing

* increate the version
2025-09-05 16:45:51 +08:00
Hayden 815a483cf0 Prepare release 2.8.1 2025-09-03 15:14:07 +08:00
Hayden ae37765017 fix: Add error message tag (#203) 2025-09-03 15:13:08 +08:00
Hayden ebef300279 fix: Validate existence of entry path in model preview generation (#202) 2025-09-03 15:06:50 +08:00
18 changed files with 620 additions and 111 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
publish-node:
name: Release and Publish Custom Node to registry
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'hayden-fr' }}
if: ${{ github.repository_owner == 'hayden-cn' }}
steps:
- name: Check out code
uses: actions/checkout@v4
+2 -2
View File
@@ -8,8 +8,8 @@ Designed to support desktop, mobile and multi-screen devices.
There are three installation methods, choose one
1. Clone the repository: `git clone https://github.com/hayden-fr/ComfyUI-Model-Manager.git` to your ComfyUI `custom_nodes` folder
2. Download the [latest release](https://github.com/hayden-fr/ComfyUI-Model-Manager/releases/latest/download/dist.tar.gz) and extract it to your ComfyUI `custom_nodes` folder
1. Clone the repository: `git clone https://github.com/hayden-cn/ComfyUI-Model-Manager.git` to your ComfyUI `custom_nodes` folder
2. Download the [latest release](https://github.com/hayden-cn/ComfyUI-Model-Manager/releases/latest/download/dist.tar.gz) and extract it to your ComfyUI `custom_nodes` folder
3. Use comfy cli: `comfy node registry-install comfyui-model-manager`
## Features
+1
View File
@@ -35,6 +35,7 @@
"@primevue/themes": "^4.0.7",
"@vueuse/core": "^11.3.0",
"dayjs": "^1.11.13",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"markdown-it": "^14.1.0",
"markdown-it-metadata-block": "^1.0.6",
+3 -9
View File
@@ -17,6 +17,9 @@ importers:
dayjs:
specifier: ^1.11.13
version: 1.11.13
js-yaml:
specifier: ^4.1.0
version: 4.1.0
lodash:
specifier: ^4.17.21
version: 4.17.21
@@ -405,55 +408,46 @@ packages:
resolution: {integrity: sha512-9pxQJSPwFsVi0ttOmqLY4JJ9pg9t1gKhK0JDbV1yUEETSx55fdyCjt39eBQ54OQCzAF0nVGO6LfEH1KnCPvelA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.22.0':
resolution: {integrity: sha512-YJ5Ku5BmNJZb58A4qSEo3JlIG4d3G2lWyBi13ABlXzO41SsdnUKi3HQHe83VpwBVG4jHFTW65jOQb8qyoR+qzg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.22.0':
resolution: {integrity: sha512-U4G4u7f+QCqHlVg1Nlx+qapZy+QoG+NV6ux+upo/T7arNGwKvKP2kmGM4W5QTbdewWFgudQxi3kDNST9GT1/mg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.22.0':
resolution: {integrity: sha512-aQpNlKmx3amwkA3a5J6nlXSahE1ijl0L9KuIjVOUhfOh7uw2S4piR3mtpxpRtbnK809SBtyPsM9q15CPTsY7HQ==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-powerpc64le-gnu@4.22.0':
resolution: {integrity: sha512-9fx6Zj/7vve/Fp4iexUFRKb5+RjLCff6YTRQl4CoDhdMfDoobWmhAxQWV3NfShMzQk1Q/iCnageFyGfqnsmeqQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.22.0':
resolution: {integrity: sha512-VWQiCcN7zBgZYLjndIEh5tamtnKg5TGxyZPWcN9zBtXBwfcGSZ5cHSdQZfQH/GB4uRxk0D3VYbOEe/chJhPGLQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-s390x-gnu@4.22.0':
resolution: {integrity: sha512-EHmPnPWvyYqncObwqrosb/CpH3GOjE76vWVs0g4hWsDRUVhg61hBmlVg5TPXqF+g+PvIbqkC7i3h8wbn4Gp2Fg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.22.0':
resolution: {integrity: sha512-tsSWy3YQzmpjDKnQ1Vcpy3p9Z+kMFbSIesCdMNgLizDWFhrLZIoN21JSq01g+MZMDFF+Y1+4zxgrlqPjid5ohg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.22.0':
resolution: {integrity: sha512-anr1Y11uPOQrpuU8XOikY5lH4Qu94oS6j0xrulHk3NkLDq19MlX8Ng/pVipjxBJ9a2l3+F39REZYyWQFkZ4/fw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.22.0':
resolution: {integrity: sha512-7LB+Bh+Ut7cfmO0m244/asvtIGQr5pG5Rvjz/l1Rnz1kDzM02pSX9jPaS0p+90H5I1x4d1FkCew+B7MOnoatNw==}
+1 -1
View File
@@ -71,7 +71,7 @@ class CivitaiModelSearcher(ModelSearcher):
for version in model_versions:
version_files: list[dict] = version.get("files", [])
model_files = utils.filter_with(version_files, {"type": "Model"})
# issue: https://github.com/hayden-fr/ComfyUI-Model-Manager/issues/188
# issue: https://github.com/hayden-cn/ComfyUI-Model-Manager/issues/188
# Some Embeddings do not have Model file, but Negative
# Make sure there are at least downloadable files
model_files = version_files if len(model_files) == 0 else model_files
+32 -19
View File
@@ -131,12 +131,15 @@ class ModelManager:
basename = os.path.splitext(filename)[0] if is_file else filename
extension = os.path.splitext(filename)[1] if is_file else ""
if is_file and extension not in folder_paths.supported_pt_extensions:
return None
model_preview = None
if is_file:
preview_name = utils.get_model_preview_name(entry.path)
preview_ext = f".{preview_name.split('.')[-1]}"
model_preview = f"/model-manager/preview/{folder}/{path_index}/{relative_path.replace(extension, preview_ext)}"
preview_name = utils.get_model_preview_name(entry.path)
preview_ext = f".{preview_name.split('.')[-1]}"
model_preview = f"/model-manager/preview/{folder}/{path_index}/{relative_path.replace(extension, preview_ext)}"
if not os.path.exists(entry.path):
utils.print_error(f"{entry.path} is not file or directory.")
return None
stat = entry.stat()
return {
@@ -147,35 +150,45 @@ class ModelManager:
"extension": extension,
"pathIndex": path_index,
"sizeBytes": stat.st_size if is_file else 0,
"preview": model_preview if is_file else None,
"preview": model_preview,
"createdAt": round(stat.st_ctime_ns / 1000000),
"updatedAt": round(stat.st_mtime_ns / 1000000),
}
def get_all_files_entry(directory: str):
entries: list[os.DirEntry[str]] = []
if not os.path.exists(directory):
return []
with os.scandir(directory) as it:
for entry in it:
# Skip hidden files
if not include_hidden_files:
if entry.name.startswith("."):
continue
entries.append(entry)
if entry.is_dir():
if not include_hidden_files and entry.name.startswith("."):
continue
if entry.is_file():
extension = os.path.splitext(entry.name)[1]
if extension in folder_paths.supported_pt_extensions:
entries.append(entry)
else:
entries.append(entry)
entries.extend(get_all_files_entry(entry.path))
return entries
BATCH_SIZE = 200
MAX_WORKERS = min(4, os.cpu_count() or 1)
for path_index, base_path in enumerate(folders):
if not os.path.exists(base_path):
continue
file_entries = get_all_files_entry(base_path)
with ThreadPoolExecutor() as executor:
futures = {executor.submit(get_file_info, entry, base_path, path_index): entry for entry in file_entries}
for future in as_completed(futures):
file_info = future.result()
if file_info is None:
continue
result.append(file_info)
for i in range(0, len(file_entries), BATCH_SIZE):
batch = file_entries[i:i + BATCH_SIZE]
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = {executor.submit(get_file_info, entry, base_path, path_index): entry for entry in batch}
for future in as_completed(futures):
file_info = future.result()
if file_info is not None:
result.append(file_info)
return result
+58 -62
View File
@@ -21,6 +21,9 @@ from . import config
VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.m4v', '.ogv']
IMAGE_EXTENSIONS = ['.webp', '.png', '.jpg', '.jpeg', '.gif', '.bmp']
# Preview extensions in priority order (videos first, then images)
PREVIEW_EXTENSIONS = ['.webm', '.mp4', '.webp', '.png', '.jpg', '.jpeg', '.gif', '.bmp']
# Content type mappings
VIDEO_CONTENT_TYPE_MAP = {
'video/mp4': '.mp4',
@@ -43,7 +46,7 @@ def print_warning(msg, *args, **kwargs):
def print_error(msg, *args, **kwargs):
logging.error(f"[{config.extension_tag}] {msg}", *args, **kwargs)
logging.error(f"[{config.extension_tag}][ERROR] {msg}", *args, **kwargs)
logging.debug(traceback.format_exc())
@@ -123,7 +126,7 @@ def download_web_distribution(version: str):
try:
print_info(f"current version {version}, web version {web_version}")
print_info("Downloading web distribution...")
download_url = f"https://github.com/hayden-fr/ComfyUI-Model-Manager/releases/download/v{version}/dist.tar.gz"
download_url = f"https://github.com/hayden-cn/ComfyUI-Model-Manager/releases/download/v{version}/dist.tar.gz"
response = requests.get(download_url, stream=True)
response.raise_for_status()
@@ -189,6 +192,10 @@ def get_full_path(model_type: str, path_index: int, filename: str):
raise RuntimeError(f"PathIndex {path_index} is not in {model_type}")
base_path = folders[path_index]
full_path = join_path(base_path, filename)
real_base = os.path.realpath(base_path)
real_full = os.path.realpath(full_path)
if not (real_full == real_base or real_full.startswith(real_base + os.sep)):
raise RuntimeError(f"Path traversal detected: filename escapes model directory")
return full_path
@@ -196,11 +203,7 @@ def get_valid_full_path(model_type: str, path_index: int, filename: str):
"""
Like get_full_path but it will check whether the file is valid.
"""
folders = resolve_model_base_paths().get(model_type, [])
if not path_index < len(folders):
raise RuntimeError(f"PathIndex {path_index} is not in {model_type}")
base_path = folders[path_index]
full_path = join_path(base_path, filename)
full_path = get_full_path(model_type, path_index, filename)
if os.path.isfile(full_path):
return full_path
elif os.path.islink(full_path):
@@ -268,40 +271,52 @@ def get_model_metadata(filename: str):
return {}
def get_model_all_previews(model_path: str):
def _check_preview_variants(base_dirname: str, basename: str, extensions: list[str]) -> list[str]:
"""Check for preview files with given extensions and return found files"""
found = []
for ext in extensions:
# Direct match (basename.ext)
preview_file = f"{basename}{ext}"
if os.path.isfile(join_path(base_dirname, preview_file)):
found.append(preview_file)
# Preview variant (basename.preview.ext)
preview_file = f"{basename}.preview{ext}"
if os.path.isfile(join_path(base_dirname, preview_file)):
found.append(preview_file)
return found
def _get_preview_path(model_path: str, extension: str) -> str:
"""Generate preview file path with given extension"""
basename = os.path.splitext(model_path)[0]
return f"{basename}{extension}"
def get_model_all_previews(model_path: str) -> list[str]:
"""Get all preview files for a model"""
base_dirname = os.path.dirname(model_path)
files = search_files(base_dirname)
files = folder_paths.filter_files_content_types(files, ["video", "image"])
basename = os.path.splitext(os.path.basename(model_path))[0]
output: list[str] = []
for file in files:
file_basename = os.path.splitext(file)[0]
if file_basename == basename:
output.append(file)
if file_basename == f"{basename}.preview":
output.append(file)
return output
return _check_preview_variants(base_dirname, basename, PREVIEW_EXTENSIONS)
def get_model_preview_name(model_path: str):
"""
Get the preview file name for a model. Checks for images and videos in all supported formats.
Returns the first available preview file or 'no-preview.png' if none found.
"""
def get_model_preview_name(model_path: str) -> str:
"""Get the first available preview file or 'no-preview.png' if none found"""
base_dirname = os.path.dirname(model_path)
basename = os.path.splitext(os.path.basename(model_path))[0]
# Prefer previews with these extensions in this order
preview_extensions = ['.webm', '.mp4', '.webp']
for ext in preview_extensions:
for ext in PREVIEW_EXTENSIONS:
# Check direct match first
preview_name = f"{basename}{ext}"
if os.path.exists(join_path(base_dirname, preview_name)):
if os.path.isfile(join_path(base_dirname, preview_name)):
return preview_name
# Check preview variant
preview_name = f"{basename}.preview{ext}"
if os.path.isfile(join_path(base_dirname, preview_name)):
return preview_name
# Fallback to any available preview files
all_previews = get_model_all_previews(model_path)
return all_previews[0] if len(all_previews) > 0 else "no-preview.png"
return "no-preview.png"
from PIL import Image
@@ -309,34 +324,19 @@ from io import BytesIO
def remove_model_preview(model_path: str):
"""
Remove preview files for a model.
"""
basename = os.path.splitext(model_path)[0]
"""Remove all preview files for a model"""
base_dirname = os.path.dirname(model_path)
basename = os.path.splitext(os.path.basename(model_path))[0]
# Remove all preview files
for ext in VIDEO_EXTENSIONS + IMAGE_EXTENSIONS:
preview_path = f"{basename}{ext}"
previews = _check_preview_variants(base_dirname, basename, PREVIEW_EXTENSIONS)
for preview in previews:
preview_path = join_path(base_dirname, preview)
if os.path.exists(preview_path):
os.remove(preview_path)
# Also check for .preview variants
files = search_files(base_dirname)
model_name = os.path.splitext(os.path.basename(model_path))[0]
for file in files:
if file.startswith(f"{model_name}.preview"):
file_path = join_path(base_dirname, file)
if os.path.exists(file_path):
os.remove(file_path)
def save_model_preview(model_path: str, file_or_url: Any, platform: Optional[str] = None):
"""
Save a preview file for a model.
Images are converted to WebP, videos are saved in their original format.
"""
basename = os.path.splitext(model_path)[0]
"""Save a preview file for a model. Images -> WebP, videos -> original format"""
# Download file if it is a URL
if type(file_or_url) is str:
@@ -357,15 +357,13 @@ def save_model_preview(model_path: str, file_or_url: Any, platform: Optional[str
if content_type.startswith("video/"):
# Save video in original format
# Try to get extension from URL or content-type
ext = _get_video_extension_from_url(url) or _get_extension_from_content_type(content_type)
if not ext:
ext = '.mp4' # Default fallback
preview_path = f"{basename}{ext}"
ext = _get_video_extension_from_url(url) or _get_extension_from_content_type(content_type) or '.mp4'
preview_path = _get_preview_path(model_path, ext)
with open(preview_path, 'wb') as f:
f.write(content)
else:
# Default to image processing for unknown or image types
preview_path = f"{basename}.webp"
preview_path = _get_preview_path(model_path, ".webp")
image = Image.open(BytesIO(content))
image.save(preview_path, "WEBP")
@@ -384,17 +382,15 @@ def save_model_preview(model_path: str, file_or_url: Any, platform: Optional[str
if content_type.startswith("video/"):
# Save video in original format for now, consider transcoding to webm to follow the pattern for images converting to webp
ext = os.path.splitext(filename.lower())[1]
if not ext:
ext = '.mp4' # Default fallback
preview_path = f"{basename}{ext}"
ext = os.path.splitext(filename.lower())[1] or '.mp4'
preview_path = _get_preview_path(model_path, ext)
file_obj.file.seek(0)
content = file_obj.file.read()
with open(preview_path, 'wb') as f:
f.write(content)
elif content_type.startswith("image/"):
# Convert image to webp
preview_path = f"{basename}.webp"
preview_path = _get_preview_path(model_path, ".webp")
image = Image.open(file_obj.file)
image.save(preview_path, "WEBP")
else:
+2 -2
View File
@@ -1,12 +1,12 @@
[project]
name = "comfyui-model-manager"
description = "Manage models: browsing, download and delete."
version = "2.8.0"
version = "2.8.5"
license = { file = "LICENSE" }
dependencies = ["markdownify"]
[project.urls]
Repository = "https://github.com/hayden-fr/ComfyUI-Model-Manager"
Repository = "https://github.com/hayden-cn/ComfyUI-Model-Manager"
# Used by Comfy Registry https://comfyregistry.org
[tool.comfy]
+42
View File
@@ -80,9 +80,30 @@ onMounted(() => {
})
}
const toggleLayout = () => {
const newValue = !config.flat.value
config.flat.value = newValue
app.ui?.settings.setSettingValue('ModelManager.UI.Flat', newValue)
dialog.closeAll()
openManagerDialog()
}
const openManagerDialog = () => {
const { cardWidth, gutter, aspect, flat } = config
const layoutIcon = flat.value ? 'pi pi-th-large' : 'pi pi-folder-open'
const includeHidden =
app.ui?.settings.getSettingValue(
'ModelManager.Scan.IncludeHiddenFiles',
) ?? false
const hiddenIcon = includeHidden ? 'pi pi-eye' : 'pi pi-eye-slash'
const hiddenTooltip = includeHidden
? t('hideHiddenFiles')
: t('showHiddenFiles')
if (firstOpenManager.value) {
models.refresh(true)
firstOpenManager.value = false
@@ -99,6 +120,27 @@ onMounted(() => {
icon: 'mdi mdi-folder-search-outline text-lg',
command: openModelScanning,
},
{
key: 'toggle-layout',
icon: layoutIcon,
command: toggleLayout,
tooltip: flat.value ? t('switchToFolderView') : t('switchToFlatView'),
},
{
key: 'toggle-hidden',
icon: hiddenIcon,
command: async () => {
const newValue = !includeHidden
app.ui?.settings.setSettingValue(
'ModelManager.Scan.IncludeHiddenFiles',
newValue,
)
await refreshModelsAndConfig() // ensure updated model list
dialog.closeAll()
openManagerDialog()
},
tooltip: hiddenTooltip,
},
{
key: 'refresh',
icon: 'pi pi-refresh',
+168 -3
View File
@@ -14,6 +14,27 @@
</template>
</ResponseInput>
<!-- Direct file URL indicator with folder selection -->
<div v-if="isDirectFile && modelUrl" class="flex flex-col gap-2">
<div
class="flex items-center gap-2 rounded bg-green-50 p-2 text-sm text-green-600"
>
<i class="pi pi-check-circle"></i>
<span>Direct file download detected</span>
</div>
<!-- Model Type/Folder Selection for direct downloads -->
<div class="flex items-center gap-2">
<label class="text-sm font-medium">{{ $t('modelType') }}:</label>
<ResponseSelect
v-model="selectedModelType"
:items="modelTypeOptions"
:type="'drop'"
class="flex-1"
/>
</div>
</div>
<div v-show="data.length > 0">
<ResponseSelect
v-model="current"
@@ -79,8 +100,13 @@ import { request } from 'hooks/request'
import { useToast } from 'hooks/toast'
import Button from 'primevue/button'
import { VersionModel, WithResolved } from 'types/typings'
import { previewUrlToFile } from 'utils/common'
import { ref } from 'vue'
import {
getFilenameFromUrl,
getModelTypeFromFilename,
isDirectFileUrl,
previewUrlToFile,
} from 'utils/common'
import { computed, ref, watch } from 'vue'
const { isMobile } = useConfig()
const { toast } = useToast()
@@ -89,14 +115,153 @@ const dialog = useDialog()
const modelUrl = ref<string>()
// Model type selection for direct downloads
const selectedModelType = ref<string>('checkpoints')
const modelTypeOptions = computed(() => [
{
label: 'Checkpoints',
value: 'checkpoints',
command: () => {
selectedModelType.value = 'checkpoints'
},
},
{
label: 'LoRA',
value: 'loras',
command: () => {
selectedModelType.value = 'loras'
},
},
{
label: 'ControlNet',
value: 'controlnet',
command: () => {
selectedModelType.value = 'controlnet'
},
},
{
label: 'VAE',
value: 'vae',
command: () => {
selectedModelType.value = 'vae'
},
},
{
label: 'Embeddings',
value: 'embeddings',
command: () => {
selectedModelType.value = 'embeddings'
},
},
{
label: 'Upscale Models',
value: 'upscale_models',
command: () => {
selectedModelType.value = 'upscale_models'
},
},
{
label: 'Diffusers',
value: 'diffusers',
command: () => {
selectedModelType.value = 'diffusers'
},
},
{
label: 'CLIP',
value: 'clip',
command: () => {
selectedModelType.value = 'clip'
},
},
{
label: 'CLIP Vision',
value: 'clip_vision',
command: () => {
selectedModelType.value = 'clip_vision'
},
},
{
label: 'UNet/Diffusion Models',
value: 'diffusion_models',
command: () => {
selectedModelType.value = 'diffusion_models'
},
},
{
label: 'Style Models',
value: 'style_models',
command: () => {
selectedModelType.value = 'style_models'
},
},
{
label: 'Hypernetworks',
value: 'hypernetworks',
command: () => {
selectedModelType.value = 'hypernetworks'
},
},
{
label: 'GLIGEN',
value: 'gligen',
command: () => {
selectedModelType.value = 'gligen'
},
},
{
label: 'PhotoMaker',
value: 'photomaker',
command: () => {
selectedModelType.value = 'photomaker'
},
},
{
label: 'VAE Approx',
value: 'vae_approx',
command: () => {
selectedModelType.value = 'vae_approx'
},
},
{
label: 'Classifiers',
value: 'classifiers',
command: () => {
selectedModelType.value = 'classifiers'
},
},
])
const isDirectFile = computed(() =>
modelUrl.value ? isDirectFileUrl(modelUrl.value) : false,
)
const { current, currentModel, data, search } = useModelSearch()
const searchModelsByUrl = async () => {
if (modelUrl.value) {
await search(modelUrl.value)
const modelType = isDirectFile.value ? selectedModelType.value : undefined
await search(modelUrl.value, modelType)
}
}
// Watch for direct file URL changes and set intelligent default
watch(modelUrl, (newUrl) => {
if (newUrl && isDirectFileUrl(newUrl)) {
const filename = getFilenameFromUrl(newUrl)
const suggestedType = getModelTypeFromFilename(filename)
selectedModelType.value = suggestedType
}
})
// Watch for model type changes on direct files and refresh the model
watch(selectedModelType, async () => {
if (isDirectFile.value && modelUrl.value) {
await search(modelUrl.value, selectedModelType.value)
}
})
const createDownTask = async (data: WithResolved<VersionModel>) => {
loading.show()
+22 -5
View File
@@ -225,16 +225,32 @@ const list = computed(() => {
return !item.isFolder
})
function buildRegex(raw: string): RegExp {
try {
// Escape regex specials, then restore * wildcards as .*
const escaped = raw
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.replace(/\\\*/g, '.*')
return new RegExp(escaped, 'i') // case-insensitive
} catch {
return new RegExp(raw, 'i')
}
}
const filterList = pureModels.filter((model) => {
const showAllModel = currentType.value === allType
const matchType = showAllModel || model.type === currentType.value
const filter = searchContent.value?.toLowerCase() ?? ''
const matchSubFolder = model.subFolder.toLowerCase().includes(filter)
const matchName = model.basename.toLowerCase().includes(filter)
const rawFilter = searchContent.value ?? ''
const tokens = rawFilter.split(/\s+/).filter(Boolean)
const regexes = tokens.map(buildRegex)
return matchType && (matchSubFolder || matchName)
// Require every token to match either the folder or the name
const matchesAll = regexes.every(
(re) => re.test(model.subFolder) || re.test(model.basename),
)
return matchType && matchesAll
})
let sortStrategy: (a: Model, b: Model) => number = () => 0
@@ -262,6 +278,7 @@ const list = computed(() => {
})
})
const contentStyle = computed(() => ({
gridTemplateColumns: `repeat(auto-fit, ${cardSize.value.width}px)`,
gap: `${gutter}px`,
+1
View File
@@ -16,6 +16,7 @@
<Button
v-for="action in item.headerButtons"
:key="action.key"
v-tooltip.top="action.tooltip"
severity="secondary"
:text="true"
:rounded="true"
+1
View File
@@ -6,6 +6,7 @@ interface HeaderButton {
key: string
icon: string
command: () => void
tooltip?: string
}
export interface DialogItem {
+171 -3
View File
@@ -11,7 +11,12 @@ import {
VersionModel,
VersionModelFile,
} from 'types/typings'
import { bytesToSize } from 'utils/common'
import {
bytesToSize,
getFilenameFromUrl,
getModelTypeFromFilename,
isDirectFileUrl,
} from 'utils/common'
import { onBeforeMount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import yaml from 'yaml'
@@ -191,7 +196,13 @@ export const useModelSearch = () => {
const regexp = /---\n([\s\S]*?)\n---/
const yamlMetadataMatch = item.description.match(regexp)
const yamlMetadata = yaml.parse(yamlMetadataMatch?.[1] || '')
let yamlMetadata: any = {}
try {
yamlMetadata = yaml.parse(yamlMetadataMatch?.[1] || '') || {}
} catch (e) {
console.warn('Failed to parse YAML metadata:', e)
yamlMetadata = {}
}
yamlMetadata.hashes = file.hashes
yamlMetadata.metadata = file.metadata
const yamlContent = `---\n${yaml.stringify(yamlMetadata)}---`
@@ -219,12 +230,169 @@ export const useModelSearch = () => {
return fileSelectionItem
}
const handleSearchByUrl = async (url: string) => {
const createDirectFileModel = (
url: string,
modelType?: string,
): VersionModel => {
try {
const filename = getFilenameFromUrl(url)
const parts = filename.split('.')
const extension = `.${parts.pop()}`
const basename = parts.join('.') || 'model'
const detectedModelType = modelType || getModelTypeFromFilename(filename)
// Create a proper YAML metadata structure for direct files
const yamlMetadata = {
source: 'direct-link',
original_url: url,
filename: filename,
modelType: detectedModelType,
downloadPlatform: 'Direct Link',
}
const description = `---
${Object.entries(yamlMetadata)
.map(([key, value]) => `${key}: ${value}`)
.join('\n')}
---
# Direct File Download
This is a direct download link to a model file. The file size will be determined during download.
**Source:** ${url}
**Filename:** ${filename}
**Type:** ${detectedModelType}`
return {
id: `direct-${Date.now()}`,
basename,
extension,
sizeBytes: 0, // Will be determined during download
type: detectedModelType,
subFolder: '',
pathIndex: 0,
isFolder: false,
preview: '',
description,
metadata: {
source: 'direct-link',
original_url: url,
},
shortname: basename,
downloadPlatform: 'Direct Link',
downloadUrl: url,
hashes: {},
files: [
{
id: 1,
sizeKB: 0, // Unknown until download starts
name: filename,
type: 'Model',
metadata: {
source: 'direct-link',
original_url: url,
},
hashes: {},
downloadUrl: url,
},
],
}
} catch (error) {
console.error('Error creating direct file model:', error)
// Return a fallback model
const fallbackType = modelType || 'checkpoints'
return {
id: `direct-${Date.now()}`,
basename: 'model',
extension: '.bin',
sizeBytes: 0,
type: fallbackType,
subFolder: '',
pathIndex: 0,
isFolder: false,
preview: '',
description: `---
source: direct-link
original_url: ${url}
filename: model.bin
modelType: ${fallbackType}
downloadPlatform: Direct Link
---
# Direct File Download
This is a direct download link to a model file.`,
metadata: {
source: 'direct-link',
original_url: url,
},
shortname: 'model',
downloadPlatform: 'Direct Link',
downloadUrl: url,
hashes: {},
files: [
{
id: 1,
sizeKB: 0,
name: 'model.bin',
type: 'Model',
metadata: {
source: 'direct-link',
original_url: url,
},
hashes: {},
downloadUrl: url,
},
],
}
}
}
const handleSearchByUrl = async (url: string, modelType?: string) => {
if (!url) {
return Promise.resolve([])
}
loading.show()
// Check if this is a direct file URL
if (isDirectFileUrl(url)) {
try {
// Create a mock model for direct file download
const directModel = createDirectFileModel(url, modelType)
const resolvedItem = genFileSelectionItem(directModel)
data.value = [
{
label: directModel.shortname,
value: directModel.id,
item: resolvedItem,
command() {
current.value = directModel.id
},
},
]
current.value = data.value[0]?.value
currentModel.value = data.value[0]?.item
loading.hide()
return [directModel]
} catch (error) {
console.error('Error processing direct file URL:', error)
loading.hide()
toast.add({
severity: 'error',
summary: 'Error',
detail: `Failed to process direct file URL: ${error instanceof Error ? error.message : 'Unknown error'}`,
life: 5000,
})
return []
}
}
// Original logic for model page URLs
return request(`/model-info?model-page=${encodeURIComponent(url)}`, {})
.then((resData: VersionModel[]) => {
data.value = resData.map((item) => {
+7 -2
View File
@@ -9,7 +9,8 @@
"downloadTask": "Download Task",
"createDownloadTask": "Create Download Task",
"parseModelUrl": "Parse Model URL",
"pleaseInputModelUrl": "Input a URL from civitai.com or huggingface.co",
"pleaseInputModelUrl": "Input a URL from civitai.com, huggingface.co, or direct file link (.safetensors, .ckpt, etc.)",
"selectModelTypeForDirect": "Select the folder/type for this model",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
@@ -73,5 +74,9 @@
"ui": "UI",
"cardSize": "Card Size",
"useFlatUI": "Flat Layout"
}
},
"switchToFolderView": "Switch to Folder View",
"switchToFlatView": "Switch to Flat View",
"hideHiddenFiles": "Hide hidden files",
"showHiddenFiles": "Show hidden files"
}
+7 -2
View File
@@ -9,7 +9,8 @@
"downloadTask": "下载任务",
"createDownloadTask": "创建下载任务",
"parseModelUrl": "解析模型URL",
"pleaseInputModelUrl": "输入 civitai.com 或 huggingface.co 的 URL",
"pleaseInputModelUrl": "输入 civitai.com, huggingface.co 的 URL 或直接文件链接 (.safetensors, .ckpt 等)",
"selectModelTypeForDirect": "为此模型选择文件夹/类型",
"cancel": "取消",
"save": "保存",
"delete": "删除",
@@ -73,5 +74,9 @@
"ui": "外观",
"cardSize": "卡片尺寸",
"useFlatUI": "展平布局"
}
},
"switchToFolderView": "切换到文件夹视图",
"switchToFlatView": "切换到平铺视图",
"hideHiddenFiles": "隐藏隐藏文件",
"showHiddenFiles": "显示隐藏文件"
}
+100
View File
@@ -37,3 +37,103 @@ export const previewUrlToFile = async (url: string) => {
return file
})
}
// Model file extensions that are supported for direct download
export const MODEL_FILE_EXTENSIONS = [
'.safetensors',
'.ckpt',
'.pt',
'.pth',
'.bin',
'.onnx',
'.tflite',
'.pb',
'.h5',
'.pkl',
'.pickle',
'.json', // for configuration files
'.yaml',
'.yml',
]
/**
* Checks if a URL points directly to a downloadable model file
*/
export const isDirectFileUrl = (url: string): boolean => {
if (!url || typeof url !== 'string') {
return false
}
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname.toLowerCase()
// Check if the URL ends with a model file extension
return MODEL_FILE_EXTENSIONS.some((ext) => pathname.endsWith(ext))
} catch {
return false
}
}
/**
* Extracts filename from a URL
*/
export const getFilenameFromUrl = (url: string): string => {
if (!url || typeof url !== 'string') {
return 'model.bin'
}
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname
const filename = pathname.split('/').pop() || ''
// If no filename with extension found, generate one
if (!filename || !filename.includes('.')) {
const extension =
MODEL_FILE_EXTENSIONS.find((ext) =>
pathname.toLowerCase().endsWith(ext),
) || '.bin'
return `model${extension}`
}
return filename
} catch {
return 'model.bin'
}
}
/**
* Determines model type based on file extension (fallback only)
* Note: This is now primarily used as a fallback when no manual selection is made
*/
export const getModelTypeFromFilename = (filename: string): string => {
if (!filename || typeof filename !== 'string') {
return 'checkpoints'
}
const extension = filename.toLowerCase().split('.').pop()
switch (extension) {
case 'safetensors':
case 'ckpt':
case 'pt':
case 'pth':
return 'checkpoints' // Default for these extensions, but user can override
case 'bin':
return 'diffusers'
case 'onnx':
return 'onnx'
case 'tflite':
return 'tflite'
case 'pb':
return 'tensorflow'
case 'h5':
return 'keras'
case 'pkl':
case 'pickle':
return 'embeddings'
default:
return 'checkpoints'
}
}
File diff suppressed because one or more lines are too long