Add download integration with auto-search on startup
This commit is contained in:
+282
-4
@@ -2,8 +2,8 @@
|
||||
@author: Model Linker Team
|
||||
@title: ComfyUI Model Linker
|
||||
@nickname: Model Linker
|
||||
@version: 1.0.0
|
||||
@description: Extension for relinking missing models in ComfyUI workflows using fuzzy matching
|
||||
@version: 1.1.0
|
||||
@description: Extension for relinking missing models and downloading from HuggingFace/CivitAI
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -53,7 +53,7 @@ class ModelLinkerExtension:
|
||||
self.logger.debug(f"Model Linker: Could not access PromptServer: {e}")
|
||||
return False
|
||||
|
||||
# Import linker modules - use relative imports which should work for packages
|
||||
# Import linker modules
|
||||
try:
|
||||
from .core.linker import analyze_and_find_matches, apply_resolution
|
||||
from .core.scanner import get_model_files
|
||||
@@ -61,6 +61,23 @@ class ModelLinkerExtension:
|
||||
self.logger.error(f"Model Linker: Could not import core modules: {e}")
|
||||
return False
|
||||
|
||||
# Import download modules
|
||||
try:
|
||||
from .core.downloader import (
|
||||
start_background_download, get_progress, get_all_progress,
|
||||
cancel_download, get_download_directory
|
||||
)
|
||||
from .core.sources.popular import get_popular_model_url, search_popular_models
|
||||
from .core.sources.model_list import search_model_list, search_model_list_multiple
|
||||
from .core.sources.huggingface import search_huggingface_for_file
|
||||
from .core.sources.civitai import search_civitai_for_file
|
||||
download_available = True
|
||||
except ImportError as e:
|
||||
self.logger.warning(f"Model Linker: Download features not available: {e}")
|
||||
download_available = False
|
||||
|
||||
# ==================== ANALYZE ROUTES ====================
|
||||
|
||||
@routes.post("/model_linker/analyze")
|
||||
async def analyze_workflow(request):
|
||||
"""Analyze workflow and return missing models with matches."""
|
||||
@@ -77,6 +94,67 @@ class ModelLinkerExtension:
|
||||
# Analyze and find matches
|
||||
result = analyze_and_find_matches(workflow_json)
|
||||
|
||||
# If download available, auto-search for download sources when no 100% local match
|
||||
if download_available:
|
||||
for missing in result.get('missing_models', []):
|
||||
# Check if there's a 100% local match
|
||||
matches = missing.get('matches', [])
|
||||
has_perfect_match = any(m.get('confidence', 0) == 100 for m in matches)
|
||||
|
||||
if not has_perfect_match:
|
||||
filename = missing.get('original_path', '').split('/')[-1].split('\\')[-1]
|
||||
|
||||
# 1. Check popular models
|
||||
popular_info = get_popular_model_url(filename)
|
||||
if popular_info:
|
||||
missing['download_source'] = {
|
||||
'source': 'popular',
|
||||
'url': popular_info.get('url'),
|
||||
'filename': filename,
|
||||
'type': popular_info.get('type'),
|
||||
'directory': popular_info.get('directory')
|
||||
}
|
||||
continue
|
||||
|
||||
# 2. Check model list (ComfyUI Manager database)
|
||||
model_list_result = search_model_list(filename)
|
||||
if model_list_result:
|
||||
missing['download_source'] = {
|
||||
'source': 'model_list',
|
||||
'url': model_list_result.get('url'),
|
||||
'filename': model_list_result.get('filename'),
|
||||
'name': model_list_result.get('name'),
|
||||
'type': model_list_result.get('type'),
|
||||
'directory': model_list_result.get('directory'),
|
||||
'size': model_list_result.get('size'),
|
||||
'match_type': model_list_result.get('match_type'),
|
||||
'confidence': model_list_result.get('confidence')
|
||||
}
|
||||
continue
|
||||
|
||||
# 3. Search HuggingFace
|
||||
hf_result = search_huggingface_for_file(filename)
|
||||
if hf_result:
|
||||
missing['download_source'] = {
|
||||
'source': 'huggingface',
|
||||
'url': hf_result.get('url'),
|
||||
'filename': hf_result.get('filename'),
|
||||
'name': hf_result.get('repo_id', ''),
|
||||
'size': hf_result.get('size', '')
|
||||
}
|
||||
continue
|
||||
|
||||
# 4. Search CivitAI
|
||||
civitai_result = search_civitai_for_file(filename)
|
||||
if civitai_result:
|
||||
missing['download_source'] = {
|
||||
'source': 'civitai',
|
||||
'url': civitai_result.get('url'),
|
||||
'filename': civitai_result.get('filename'),
|
||||
'name': civitai_result.get('model_name', ''),
|
||||
'size': civitai_result.get('size', '')
|
||||
}
|
||||
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Model Linker analyze error: {e}", exc_info=True)
|
||||
@@ -121,7 +199,7 @@ class ModelLinkerExtension:
|
||||
|
||||
@routes.get("/model_linker/models")
|
||||
async def get_models(request):
|
||||
"""Get list of all available models (for debugging/UI display)."""
|
||||
"""Get list of all available models."""
|
||||
try:
|
||||
models = get_model_files()
|
||||
return web.json_response(models)
|
||||
@@ -132,6 +210,206 @@ class ModelLinkerExtension:
|
||||
status=500
|
||||
)
|
||||
|
||||
# ==================== DOWNLOAD ROUTES ====================
|
||||
|
||||
if download_available:
|
||||
|
||||
@routes.post("/model_linker/search")
|
||||
async def search_sources(request):
|
||||
"""Search for model download sources."""
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = data.get('filename', '')
|
||||
category = data.get('category', '')
|
||||
|
||||
if not filename:
|
||||
return web.json_response(
|
||||
{'error': 'Filename is required'},
|
||||
status=400
|
||||
)
|
||||
|
||||
results = {
|
||||
'popular': None,
|
||||
'model_list': None,
|
||||
'huggingface': None,
|
||||
'civitai': None,
|
||||
'found': False
|
||||
}
|
||||
|
||||
# 1. Check popular models first (curated database)
|
||||
popular_info = get_popular_model_url(filename)
|
||||
if popular_info:
|
||||
results['popular'] = {
|
||||
'source': 'popular',
|
||||
'filename': filename,
|
||||
**popular_info
|
||||
}
|
||||
results['found'] = True
|
||||
|
||||
# 2. Search model-list.json (ComfyUI Manager database with fuzzy matching)
|
||||
if not results['found']:
|
||||
model_list_result = search_model_list(filename)
|
||||
if model_list_result:
|
||||
results['model_list'] = model_list_result
|
||||
results['found'] = True
|
||||
|
||||
# 3. Search HuggingFace for exact file match
|
||||
if not results['found']:
|
||||
hf_result = search_huggingface_for_file(filename)
|
||||
if hf_result:
|
||||
results['huggingface'] = hf_result
|
||||
results['found'] = True
|
||||
|
||||
# 4. Search CivitAI for exact file match
|
||||
if not results['found']:
|
||||
civitai_result = search_civitai_for_file(filename)
|
||||
if civitai_result:
|
||||
results['civitai'] = civitai_result
|
||||
results['found'] = True
|
||||
|
||||
return web.json_response(results)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Model Linker search error: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
{'error': str(e)},
|
||||
status=500
|
||||
)
|
||||
|
||||
@routes.post("/model_linker/download")
|
||||
async def download_model(request):
|
||||
"""Start downloading a model."""
|
||||
try:
|
||||
data = await request.json()
|
||||
url = data.get('url', '')
|
||||
filename = data.get('filename', '')
|
||||
category = data.get('category', 'checkpoints')
|
||||
subfolder = data.get('subfolder', '')
|
||||
|
||||
if not url:
|
||||
return web.json_response(
|
||||
{'error': 'URL is required'},
|
||||
status=400
|
||||
)
|
||||
|
||||
if not filename:
|
||||
# Extract filename from URL
|
||||
from urllib.parse import urlparse, unquote
|
||||
parsed = urlparse(url)
|
||||
filename = unquote(parsed.path.split('/')[-1])
|
||||
|
||||
if not filename:
|
||||
return web.json_response(
|
||||
{'error': 'Could not determine filename'},
|
||||
status=400
|
||||
)
|
||||
|
||||
# Build headers if needed
|
||||
headers = {}
|
||||
if 'huggingface.co' in url:
|
||||
hf_token = data.get('hf_token', '')
|
||||
if hf_token:
|
||||
headers['Authorization'] = f'Bearer {hf_token}'
|
||||
elif 'civitai.com' in url:
|
||||
civitai_key = data.get('civitai_key', '')
|
||||
if civitai_key and 'token=' not in url:
|
||||
url += f"{'&' if '?' in url else '?'}token={civitai_key}"
|
||||
|
||||
# Start background download
|
||||
download_id = start_background_download(
|
||||
url=url,
|
||||
filename=filename,
|
||||
category=category,
|
||||
headers=headers if headers else None,
|
||||
subfolder=subfolder
|
||||
)
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'download_id': download_id,
|
||||
'filename': filename,
|
||||
'category': category
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Model Linker download error: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
{'error': str(e), 'success': False},
|
||||
status=500
|
||||
)
|
||||
|
||||
@routes.get("/model_linker/progress/{download_id}")
|
||||
async def get_download_progress(request):
|
||||
"""Get progress for a specific download."""
|
||||
try:
|
||||
download_id = request.match_info['download_id']
|
||||
progress = get_progress(download_id)
|
||||
|
||||
if progress:
|
||||
return web.json_response(progress)
|
||||
else:
|
||||
return web.json_response(
|
||||
{'error': 'Download not found'},
|
||||
status=404
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Model Linker progress error: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
{'error': str(e)},
|
||||
status=500
|
||||
)
|
||||
|
||||
@routes.get("/model_linker/progress")
|
||||
async def get_all_downloads_progress(request):
|
||||
"""Get progress for all downloads."""
|
||||
try:
|
||||
progress = get_all_progress()
|
||||
return web.json_response(progress)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Model Linker progress error: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
{'error': str(e)},
|
||||
status=500
|
||||
)
|
||||
|
||||
@routes.post("/model_linker/cancel/{download_id}")
|
||||
async def cancel_download_route(request):
|
||||
"""Cancel a download in progress."""
|
||||
try:
|
||||
download_id = request.match_info['download_id']
|
||||
cancel_download(download_id)
|
||||
return web.json_response({'success': True})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Model Linker cancel error: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
{'error': str(e), 'success': False},
|
||||
status=500
|
||||
)
|
||||
|
||||
@routes.get("/model_linker/directories")
|
||||
async def get_directories(request):
|
||||
"""Get available model directories."""
|
||||
try:
|
||||
categories = [
|
||||
'checkpoints', 'loras', 'vae', 'controlnet',
|
||||
'clip', 'clip_vision', 'embeddings', 'upscale_models',
|
||||
'diffusion_models', 'text_encoders', 'ipadapter', 'sams'
|
||||
]
|
||||
|
||||
directories = {}
|
||||
for cat in categories:
|
||||
path = get_download_directory(cat)
|
||||
if path:
|
||||
directories[cat] = path
|
||||
|
||||
return web.json_response(directories)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Model Linker directories error: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
{'error': str(e)},
|
||||
status=500
|
||||
)
|
||||
|
||||
self.routes_setup = True
|
||||
self.logger.info("Model Linker: API routes registered successfully")
|
||||
return True
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
"""
|
||||
Core modules for Model Linker extension.
|
||||
|
||||
Modules:
|
||||
- linker: Main API for analyzing and resolving missing models
|
||||
- scanner: Directory scanning for available models
|
||||
- matcher: Fuzzy matching for finding similar models
|
||||
- workflow_analyzer: Workflow JSON parsing
|
||||
- workflow_updater: Workflow modification
|
||||
- downloader: Model downloading with progress tracking
|
||||
- sources: Search integrations (HuggingFace, CivitAI, popular models)
|
||||
"""
|
||||
|
||||
from .linker import analyze_and_find_matches, apply_resolution
|
||||
from .scanner import get_model_files
|
||||
from .matcher import find_matches
|
||||
|
||||
__all__ = [
|
||||
'analyze_and_find_matches',
|
||||
'apply_resolution',
|
||||
'get_model_files',
|
||||
'find_matches'
|
||||
]
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
Model Downloader Module
|
||||
|
||||
Handles downloading models from various sources with progress tracking.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import requests
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
except ImportError:
|
||||
folder_paths = None
|
||||
|
||||
# Download state tracking
|
||||
download_progress: Dict[str, Dict[str, Any]] = {}
|
||||
download_lock = threading.Lock()
|
||||
cancelled_downloads: set = set()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_download_directory(category: str) -> Optional[str]:
|
||||
"""
|
||||
Get the appropriate download directory for a model category.
|
||||
|
||||
Args:
|
||||
category: Model category (e.g., 'checkpoints', 'loras', 'vae')
|
||||
|
||||
Returns:
|
||||
Absolute path to the download directory, or None if not found
|
||||
"""
|
||||
if folder_paths is None:
|
||||
return None
|
||||
|
||||
# Map common category names to folder_paths keys
|
||||
category_map = {
|
||||
'checkpoint': 'checkpoints',
|
||||
'checkpoints': 'checkpoints',
|
||||
'lora': 'loras',
|
||||
'loras': 'loras',
|
||||
'vae': 'vae',
|
||||
'controlnet': 'controlnet',
|
||||
'clip': 'clip',
|
||||
'clip_vision': 'clip_vision',
|
||||
'upscaler': 'upscale_models',
|
||||
'upscale_models': 'upscale_models',
|
||||
'embeddings': 'embeddings',
|
||||
'embedding': 'embeddings',
|
||||
'diffusion_models': 'diffusion_models',
|
||||
'unet': 'diffusion_models',
|
||||
'text_encoders': 'text_encoders',
|
||||
'text_encoder': 'text_encoders',
|
||||
'ipadapter': 'ipadapter',
|
||||
'ip-adapter': 'ipadapter',
|
||||
}
|
||||
|
||||
folder_key = category_map.get(category.lower(), category.lower())
|
||||
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(folder_key)
|
||||
if paths:
|
||||
# Return the first path (usually the main models directory)
|
||||
return paths[0]
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get folder path for {folder_key}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def generate_download_id() -> str:
|
||||
"""Generate a unique download ID."""
|
||||
import uuid
|
||||
return str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
def download_file(
|
||||
url: str,
|
||||
dest_path: str,
|
||||
download_id: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
chunk_size: int = 8192,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Download a file from URL with progress tracking.
|
||||
|
||||
Args:
|
||||
url: URL to download from
|
||||
dest_path: Destination file path
|
||||
download_id: Unique ID for tracking this download
|
||||
headers: Optional HTTP headers (for auth tokens)
|
||||
chunk_size: Download chunk size in bytes
|
||||
progress_callback: Optional callback(downloaded_bytes, total_bytes)
|
||||
|
||||
Returns:
|
||||
Result dictionary with status and info
|
||||
"""
|
||||
global download_progress, cancelled_downloads
|
||||
|
||||
result = {
|
||||
'success': False,
|
||||
'download_id': download_id,
|
||||
'path': dest_path,
|
||||
'error': None,
|
||||
'size': 0
|
||||
}
|
||||
|
||||
# Initialize progress tracking
|
||||
with download_lock:
|
||||
download_progress[download_id] = {
|
||||
'status': 'starting',
|
||||
'progress': 0,
|
||||
'total_size': 0,
|
||||
'downloaded': 0,
|
||||
'filename': os.path.basename(dest_path),
|
||||
'url': url,
|
||||
'error': None
|
||||
}
|
||||
|
||||
try:
|
||||
# Ensure destination directory exists
|
||||
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||||
|
||||
# Start download
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=headers,
|
||||
stream=True,
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Get total size
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
|
||||
with download_lock:
|
||||
download_progress[download_id]['total_size'] = total_size
|
||||
download_progress[download_id]['status'] = 'downloading'
|
||||
|
||||
downloaded = 0
|
||||
|
||||
# Download with progress
|
||||
with open(dest_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=chunk_size):
|
||||
# Check for cancellation
|
||||
if download_id in cancelled_downloads:
|
||||
with download_lock:
|
||||
download_progress[download_id]['status'] = 'cancelled'
|
||||
# Clean up partial file
|
||||
try:
|
||||
os.remove(dest_path)
|
||||
except:
|
||||
pass
|
||||
result['error'] = 'Download cancelled'
|
||||
return result
|
||||
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
# Update progress
|
||||
with download_lock:
|
||||
download_progress[download_id]['downloaded'] = downloaded
|
||||
if total_size > 0:
|
||||
download_progress[download_id]['progress'] = int((downloaded / total_size) * 100)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(downloaded, total_size)
|
||||
|
||||
# Success
|
||||
with download_lock:
|
||||
download_progress[download_id]['status'] = 'completed'
|
||||
download_progress[download_id]['progress'] = 100
|
||||
|
||||
result['success'] = True
|
||||
result['size'] = downloaded
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_msg = str(e)
|
||||
with download_lock:
|
||||
download_progress[download_id]['status'] = 'error'
|
||||
download_progress[download_id]['error'] = error_msg
|
||||
result['error'] = error_msg
|
||||
|
||||
# Clean up partial file
|
||||
try:
|
||||
if os.path.exists(dest_path):
|
||||
os.remove(dest_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
with download_lock:
|
||||
download_progress[download_id]['status'] = 'error'
|
||||
download_progress[download_id]['error'] = error_msg
|
||||
result['error'] = error_msg
|
||||
logger.error(f"Download error: {e}", exc_info=True)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def download_model(
|
||||
url: str,
|
||||
filename: str,
|
||||
category: str,
|
||||
download_id: Optional[str] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
subfolder: str = ""
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Download a model to the appropriate directory.
|
||||
|
||||
Args:
|
||||
url: URL to download from
|
||||
filename: Filename to save as
|
||||
category: Model category for directory selection
|
||||
download_id: Optional download ID (generated if not provided)
|
||||
headers: Optional HTTP headers
|
||||
subfolder: Optional subfolder within category directory
|
||||
|
||||
Returns:
|
||||
Result dictionary
|
||||
"""
|
||||
if download_id is None:
|
||||
download_id = generate_download_id()
|
||||
|
||||
# Get destination directory
|
||||
dest_dir = get_download_directory(category)
|
||||
if not dest_dir:
|
||||
return {
|
||||
'success': False,
|
||||
'download_id': download_id,
|
||||
'error': f'Could not find directory for category: {category}'
|
||||
}
|
||||
|
||||
# Add subfolder if specified
|
||||
if subfolder:
|
||||
dest_dir = os.path.join(dest_dir, subfolder)
|
||||
|
||||
dest_path = os.path.join(dest_dir, filename)
|
||||
|
||||
# Check if file already exists
|
||||
if os.path.exists(dest_path):
|
||||
return {
|
||||
'success': False,
|
||||
'download_id': download_id,
|
||||
'error': f'File already exists: {dest_path}',
|
||||
'path': dest_path
|
||||
}
|
||||
|
||||
return download_file(url, dest_path, download_id, headers)
|
||||
|
||||
|
||||
def get_progress(download_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get progress for a specific download."""
|
||||
with download_lock:
|
||||
return download_progress.get(download_id, {}).copy()
|
||||
|
||||
|
||||
def get_all_progress() -> Dict[str, Dict[str, Any]]:
|
||||
"""Get progress for all downloads."""
|
||||
with download_lock:
|
||||
return {k: v.copy() for k, v in download_progress.items()}
|
||||
|
||||
|
||||
def cancel_download(download_id: str) -> bool:
|
||||
"""Cancel a download in progress."""
|
||||
cancelled_downloads.add(download_id)
|
||||
return True
|
||||
|
||||
|
||||
def clear_completed_downloads():
|
||||
"""Clear completed/failed downloads from progress tracking."""
|
||||
with download_lock:
|
||||
to_remove = [
|
||||
did for did, info in download_progress.items()
|
||||
if info.get('status') in ('completed', 'error', 'cancelled')
|
||||
]
|
||||
for did in to_remove:
|
||||
del download_progress[did]
|
||||
cancelled_downloads.discard(did)
|
||||
|
||||
|
||||
def start_background_download(
|
||||
url: str,
|
||||
filename: str,
|
||||
category: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
subfolder: str = ""
|
||||
) -> str:
|
||||
"""
|
||||
Start a download in a background thread.
|
||||
|
||||
Returns:
|
||||
download_id for tracking progress
|
||||
"""
|
||||
download_id = generate_download_id()
|
||||
|
||||
def run_download():
|
||||
download_model(url, filename, category, download_id, headers, subfolder)
|
||||
|
||||
thread = threading.Thread(target=run_download, daemon=True)
|
||||
thread.start()
|
||||
|
||||
return download_id
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Model Sources Module
|
||||
|
||||
Provides search functionality for finding models from various sources.
|
||||
"""
|
||||
|
||||
from .popular import search_popular_models, get_popular_model_url
|
||||
from .model_list import search_model_list, search_model_list_multiple
|
||||
from .huggingface import search_huggingface, search_huggingface_for_file, get_huggingface_download_url
|
||||
from .civitai import search_civitai, search_civitai_for_file, get_civitai_download_url
|
||||
|
||||
__all__ = [
|
||||
'search_popular_models',
|
||||
'get_popular_model_url',
|
||||
'search_model_list',
|
||||
'search_model_list_multiple',
|
||||
'search_huggingface',
|
||||
'search_huggingface_for_file',
|
||||
'get_huggingface_download_url',
|
||||
'search_civitai',
|
||||
'search_civitai_for_file',
|
||||
'get_civitai_download_url'
|
||||
]
|
||||
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
CivitAI Source Module
|
||||
|
||||
Search and download models from CivitAI.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
import requests
|
||||
from typing import Dict, Any, Optional, List
|
||||
from urllib.parse import urlparse, parse_qs, quote
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CIVITAI_API_URL = "https://civitai.com/api/v1"
|
||||
|
||||
# Cache for search results
|
||||
_search_cache: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def parse_civitai_url(url: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Parse a CivitAI URL to extract model/version info.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if 'civitai.com' not in parsed.netloc:
|
||||
return None
|
||||
|
||||
if '/api/download/models/' in parsed.path:
|
||||
match = re.search(r'/api/download/models/(\d+)', parsed.path)
|
||||
if match:
|
||||
return {'version_id': int(match.group(1))}
|
||||
|
||||
match = re.search(r'/models/(\d+)', parsed.path)
|
||||
if match:
|
||||
result = {'model_id': int(match.group(1))}
|
||||
query = parse_qs(parsed.query)
|
||||
if 'modelVersionId' in query:
|
||||
result['version_id'] = int(query['modelVersionId'][0])
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_civitai_download_url(version_id: int, api_key: Optional[str] = None) -> str:
|
||||
"""Get download URL for a CivitAI model version."""
|
||||
url = f"https://civitai.com/api/download/models/{version_id}"
|
||||
if api_key:
|
||||
url += f"?token={api_key}"
|
||||
return url
|
||||
|
||||
|
||||
def clean_filename_for_search(filename: str) -> str:
|
||||
"""
|
||||
Clean up filename for better CivitAI search results.
|
||||
Remove common suffixes that might prevent matches.
|
||||
"""
|
||||
base = os.path.splitext(filename)[0]
|
||||
# Remove common precision/format suffixes
|
||||
base = re.sub(r'[-_]?(fp16|fp32|fp8|bf16|e4m3fn|scaled|pruned|emaonly|q4|q8).*$', '', base, flags=re.IGNORECASE)
|
||||
# Remove version numbers at end
|
||||
base = re.sub(r'[-_]?v?\d+(\.\d+)*$', '', base, flags=re.IGNORECASE)
|
||||
return base
|
||||
|
||||
|
||||
def search_civitai_for_file(
|
||||
filename: str,
|
||||
api_key: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Search CivitAI for a specific model file.
|
||||
Returns the first model that actually has this exact filename.
|
||||
|
||||
Args:
|
||||
filename: Exact filename to search for
|
||||
api_key: Optional API key
|
||||
|
||||
Returns:
|
||||
Dict with download info if found, None otherwise
|
||||
"""
|
||||
global _search_cache
|
||||
|
||||
cache_key = f"civit_{filename}"
|
||||
if cache_key in _search_cache:
|
||||
return _search_cache[cache_key]
|
||||
|
||||
try:
|
||||
# Clean filename for search
|
||||
search_term = clean_filename_for_search(filename)
|
||||
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers['Authorization'] = f'Bearer {api_key}'
|
||||
|
||||
search_url = f"{CIVITAI_API_URL}/models?query={quote(search_term)}&limit=10"
|
||||
|
||||
response = requests.get(search_url, headers=headers, timeout=15)
|
||||
if response.status_code != 200:
|
||||
logger.debug(f"CivitAI search returned {response.status_code}")
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
items = data.get('items', [])
|
||||
|
||||
for item in items:
|
||||
model_id = item.get('id')
|
||||
model_name = item.get('name', '')
|
||||
model_type = item.get('type', '')
|
||||
|
||||
model_versions = item.get('modelVersions', [])
|
||||
for version in model_versions:
|
||||
version_id = version.get('id')
|
||||
files = version.get('files', [])
|
||||
|
||||
for file_info in files:
|
||||
file_name = file_info.get('name', '')
|
||||
# Check for exact filename match (case-insensitive)
|
||||
if file_name.lower() == filename.lower():
|
||||
download_url = file_info.get('downloadUrl', '')
|
||||
if download_url:
|
||||
result = {
|
||||
'source': 'civitai',
|
||||
'model_id': model_id,
|
||||
'version_id': version_id,
|
||||
'name': model_name,
|
||||
'type': model_type,
|
||||
'filename': file_name,
|
||||
'url': f"https://civitai.com/models/{model_id}",
|
||||
'download_url': download_url,
|
||||
'size': file_info.get('sizeKB', 0) * 1024
|
||||
}
|
||||
_search_cache[cache_key] = result
|
||||
logger.info(f"Found {filename} on CivitAI: {model_name}")
|
||||
return result
|
||||
|
||||
# Not found
|
||||
_search_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CivitAI search error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def search_civitai(
|
||||
query: str,
|
||||
model_type: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
api_key: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search CivitAI for models (general search).
|
||||
Returns models that might be relevant.
|
||||
"""
|
||||
results = []
|
||||
|
||||
type_map = {
|
||||
'checkpoint': 'Checkpoint',
|
||||
'checkpoints': 'Checkpoint',
|
||||
'lora': 'LORA',
|
||||
'loras': 'LORA',
|
||||
'vae': 'VAE',
|
||||
'controlnet': 'Controlnet',
|
||||
'embedding': 'TextualInversion',
|
||||
'embeddings': 'TextualInversion',
|
||||
'upscaler': 'Upscaler',
|
||||
'upscale_models': 'Upscaler',
|
||||
}
|
||||
|
||||
try:
|
||||
params = {
|
||||
'query': query,
|
||||
'limit': limit,
|
||||
'nsfw': 'false'
|
||||
}
|
||||
|
||||
if model_type:
|
||||
civitai_type = type_map.get(model_type.lower())
|
||||
if civitai_type:
|
||||
params['types'] = civitai_type
|
||||
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers['Authorization'] = f'Bearer {api_key}'
|
||||
|
||||
response = requests.get(
|
||||
f"{CIVITAI_API_URL}/models",
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
for model in data.get('items', []):
|
||||
model_id = model.get('id')
|
||||
model_name = model.get('name', '')
|
||||
model_type = model.get('type', '')
|
||||
|
||||
versions = model.get('modelVersions', [])
|
||||
if versions:
|
||||
latest = versions[0]
|
||||
version_id = latest.get('id')
|
||||
|
||||
files = latest.get('files', [])
|
||||
primary_file = None
|
||||
for f in files:
|
||||
if f.get('primary', False) or f.get('type') == 'Model':
|
||||
primary_file = f
|
||||
break
|
||||
|
||||
if not primary_file and files:
|
||||
primary_file = files[0]
|
||||
|
||||
result = {
|
||||
'source': 'civitai',
|
||||
'model_id': model_id,
|
||||
'version_id': version_id,
|
||||
'name': model_name,
|
||||
'type': model_type,
|
||||
'url': f"https://civitai.com/models/{model_id}",
|
||||
'download_url': get_civitai_download_url(version_id, api_key),
|
||||
'downloads': model.get('stats', {}).get('downloadCount', 0),
|
||||
}
|
||||
|
||||
if primary_file:
|
||||
result['filename'] = primary_file.get('name', '')
|
||||
result['size'] = primary_file.get('sizeKB', 0) * 1024
|
||||
|
||||
results.append(result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CivitAI search error: {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def search_civitai_by_hash(
|
||||
hash_value: str,
|
||||
api_key: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Look up a model by file hash on CivitAI."""
|
||||
try:
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers['Authorization'] = f'Bearer {api_key}'
|
||||
|
||||
response = requests.get(
|
||||
f"{CIVITAI_API_URL}/model-versions/by-hash/{hash_value}",
|
||||
headers=headers,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
model_id = data.get('modelId')
|
||||
version_id = data.get('id')
|
||||
files = data.get('files', [])
|
||||
primary_file = files[0] if files else {}
|
||||
|
||||
return {
|
||||
'source': 'civitai',
|
||||
'model_id': model_id,
|
||||
'version_id': version_id,
|
||||
'name': data.get('model', {}).get('name', ''),
|
||||
'url': f"https://civitai.com/models/{model_id}",
|
||||
'download_url': get_civitai_download_url(version_id, api_key),
|
||||
'filename': primary_file.get('name', ''),
|
||||
'size': primary_file.get('sizeKB', 0) * 1024
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CivitAI hash lookup error: {e}")
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
HuggingFace Source Module
|
||||
|
||||
Search and download models from HuggingFace Hub.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
import requests
|
||||
from typing import Dict, Any, Optional, List
|
||||
from urllib.parse import urlparse, quote
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HF_API_URL = "https://huggingface.co/api"
|
||||
|
||||
# Cache for search results
|
||||
_search_cache: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def parse_huggingface_url(url: str) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Parse a HuggingFace URL to extract repo and filename.
|
||||
|
||||
Handles formats:
|
||||
- https://huggingface.co/user/repo/resolve/main/file.safetensors
|
||||
- https://huggingface.co/user/repo/blob/main/file.safetensors
|
||||
- hf://user/repo/file.safetensors
|
||||
|
||||
Returns:
|
||||
Dictionary with 'repo' and 'filename' keys, or None if not HF URL
|
||||
"""
|
||||
if url.startswith('hf://'):
|
||||
parts = url[5:].split('/', 2)
|
||||
if len(parts) >= 3:
|
||||
return {
|
||||
'repo': f"{parts[0]}/{parts[1]}",
|
||||
'filename': parts[2]
|
||||
}
|
||||
return None
|
||||
|
||||
parsed = urlparse(url)
|
||||
if 'huggingface.co' not in parsed.netloc:
|
||||
return None
|
||||
|
||||
match = re.match(r'^/([^/]+/[^/]+)/(resolve|blob)/([^/]+)/(.+)$', parsed.path)
|
||||
if match:
|
||||
return {
|
||||
'repo': match.group(1),
|
||||
'branch': match.group(3),
|
||||
'filename': match.group(4)
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_huggingface_download_url(repo: str, filename: str, branch: str = "main") -> str:
|
||||
"""Generate a direct download URL for a HuggingFace file."""
|
||||
return f"https://huggingface.co/{repo}/resolve/{branch}/{quote(filename)}"
|
||||
|
||||
|
||||
def clean_filename_for_search(filename: str) -> str:
|
||||
"""
|
||||
Clean up filename for better search results.
|
||||
Remove common suffixes that might prevent matches.
|
||||
"""
|
||||
base = os.path.splitext(filename)[0]
|
||||
# Remove common precision/format suffixes
|
||||
base = re.sub(r'[-_]?(fp16|fp32|fp8|bf16|e4m3fn|scaled|pruned|emaonly|q4|q8).*$', '', base, flags=re.IGNORECASE)
|
||||
return base
|
||||
|
||||
|
||||
def search_huggingface_for_file(
|
||||
filename: str,
|
||||
token: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Search HuggingFace for a specific model file.
|
||||
Returns the first repo that actually contains this exact file.
|
||||
|
||||
Args:
|
||||
filename: Exact filename to search for
|
||||
token: Optional HF token
|
||||
|
||||
Returns:
|
||||
Dict with url, repo, filename if found, None otherwise
|
||||
"""
|
||||
global _search_cache
|
||||
|
||||
cache_key = f"hf_{filename}"
|
||||
if cache_key in _search_cache:
|
||||
return _search_cache[cache_key]
|
||||
|
||||
try:
|
||||
# Clean filename for search
|
||||
search_term = clean_filename_for_search(filename)
|
||||
|
||||
headers = {}
|
||||
if token:
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
|
||||
# Search for repos containing this filename
|
||||
search_url = f"{HF_API_URL}/models?search={quote(search_term)}&limit=10"
|
||||
|
||||
response = requests.get(search_url, headers=headers, timeout=15)
|
||||
if response.status_code != 200:
|
||||
logger.debug(f"HuggingFace search returned {response.status_code}")
|
||||
return None
|
||||
|
||||
repos = response.json()
|
||||
|
||||
for repo in repos:
|
||||
repo_id = repo.get('id', '')
|
||||
if not repo_id:
|
||||
continue
|
||||
|
||||
# Check if this repo actually has the exact file
|
||||
files_url = f"{HF_API_URL}/models/{repo_id}/tree/main"
|
||||
|
||||
try:
|
||||
files_response = requests.get(files_url, headers=headers, timeout=10)
|
||||
if files_response.status_code == 200:
|
||||
files = files_response.json()
|
||||
|
||||
for file_info in files:
|
||||
file_path = file_info.get('path', '')
|
||||
# Check for exact filename match (case-insensitive)
|
||||
if file_path.lower().endswith(filename.lower()):
|
||||
result = {
|
||||
'source': 'huggingface',
|
||||
'repo': repo_id,
|
||||
'filename': os.path.basename(file_path),
|
||||
'path': file_path,
|
||||
'url': get_huggingface_download_url(repo_id, file_path),
|
||||
'size': file_info.get('size')
|
||||
}
|
||||
_search_cache[cache_key] = result
|
||||
logger.info(f"Found {filename} on HuggingFace: {repo_id}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error checking repo {repo_id}: {e}")
|
||||
continue
|
||||
|
||||
# Not found
|
||||
_search_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"HuggingFace search error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def search_huggingface(
|
||||
query: str,
|
||||
model_type: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
token: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search HuggingFace Hub for models (general search).
|
||||
Returns repos that might be relevant, not guaranteed to have exact file.
|
||||
"""
|
||||
results = []
|
||||
|
||||
try:
|
||||
headers = {}
|
||||
if token:
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
|
||||
params = {
|
||||
'search': query,
|
||||
'limit': limit,
|
||||
'full': 'true'
|
||||
}
|
||||
|
||||
response = requests.get(
|
||||
f"{HF_API_URL}/models",
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
models = response.json()
|
||||
|
||||
for model in models:
|
||||
repo_id = model.get('id', '')
|
||||
|
||||
results.append({
|
||||
'source': 'huggingface',
|
||||
'repo': repo_id,
|
||||
'name': model.get('modelId', repo_id),
|
||||
'downloads': model.get('downloads', 0),
|
||||
'likes': model.get('likes', 0),
|
||||
'url': f"https://huggingface.co/{repo_id}"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"HuggingFace search error: {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_repo_files(
|
||||
repo: str,
|
||||
token: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get list of model files in a HuggingFace repo."""
|
||||
files = []
|
||||
|
||||
try:
|
||||
headers = {}
|
||||
if token:
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
|
||||
response = requests.get(
|
||||
f"{HF_API_URL}/models/{repo}/tree/main",
|
||||
headers=headers,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
items = response.json()
|
||||
|
||||
for item in items:
|
||||
path = item.get('path', '')
|
||||
if path.endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.pth', '.onnx')):
|
||||
files.append({
|
||||
'filename': os.path.basename(path),
|
||||
'path': path,
|
||||
'url': get_huggingface_download_url(repo, path),
|
||||
'size': item.get('size')
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting repo files: {e}")
|
||||
|
||||
return files
|
||||
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Model List Database Module
|
||||
|
||||
Search the ComfyUI Manager model-list.json database with fuzzy matching.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Path to metadata directory
|
||||
METADATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'metadata')
|
||||
MODEL_LIST_FILE = os.path.join(METADATA_DIR, 'model-list.json')
|
||||
|
||||
# Cache for loaded data
|
||||
_model_list_cache: Optional[List[Dict]] = None
|
||||
|
||||
|
||||
def _load_model_list() -> List[Dict]:
|
||||
"""Load model list database."""
|
||||
global _model_list_cache
|
||||
|
||||
if _model_list_cache is not None:
|
||||
return _model_list_cache
|
||||
|
||||
try:
|
||||
if os.path.exists(MODEL_LIST_FILE):
|
||||
with open(MODEL_LIST_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
_model_list_cache = data.get('models', [])
|
||||
logger.info(f"Loaded {len(_model_list_cache)} models from model-list.json")
|
||||
return _model_list_cache
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading model list: {e}")
|
||||
|
||||
_model_list_cache = []
|
||||
return _model_list_cache
|
||||
|
||||
|
||||
def _normalize_filename(filename: str) -> str:
|
||||
"""Normalize filename for comparison."""
|
||||
# Remove extension
|
||||
base = os.path.splitext(filename)[0].lower()
|
||||
# Replace separators with spaces
|
||||
base = base.replace('-', ' ').replace('_', ' ').replace('.', ' ')
|
||||
return base
|
||||
|
||||
|
||||
def _similarity(a: str, b: str) -> float:
|
||||
"""Calculate similarity between two strings."""
|
||||
return SequenceMatcher(None, a, b).ratio()
|
||||
|
||||
|
||||
def search_model_list(filename: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Search model-list.json for a model by filename.
|
||||
Uses exact match first, then fuzzy matching.
|
||||
|
||||
Args:
|
||||
filename: Model filename to search for
|
||||
|
||||
Returns:
|
||||
Dict with url, filename, type, etc. if found, None otherwise
|
||||
"""
|
||||
models = _load_model_list()
|
||||
if not models:
|
||||
return None
|
||||
|
||||
filename_lower = filename.lower()
|
||||
filename_base = os.path.splitext(filename_lower)[0]
|
||||
filename_norm = _normalize_filename(filename)
|
||||
|
||||
# 1. Exact match first
|
||||
for model in models:
|
||||
model_filename = model.get('filename', '')
|
||||
if model_filename.lower() == filename_lower:
|
||||
url = model.get('url', '')
|
||||
if url:
|
||||
return {
|
||||
'source': 'model_list',
|
||||
'filename': model_filename,
|
||||
'url': url,
|
||||
'name': model.get('name', ''),
|
||||
'type': model.get('type', ''),
|
||||
'directory': model.get('save_path', 'checkpoints'),
|
||||
'size': model.get('size', ''),
|
||||
'match_type': 'exact'
|
||||
}
|
||||
|
||||
# 2. Fuzzy substring match - check if filename contains or is contained by model name
|
||||
best_match = None
|
||||
best_score = 0.0
|
||||
|
||||
for model in models:
|
||||
model_filename = model.get('filename', '')
|
||||
if not model_filename:
|
||||
continue
|
||||
|
||||
model_base = os.path.splitext(model_filename.lower())[0]
|
||||
model_norm = _normalize_filename(model_filename)
|
||||
|
||||
# Check substring matches (like WMD does)
|
||||
if filename_base in model_base or model_base in filename_base:
|
||||
url = model.get('url', '')
|
||||
if url:
|
||||
# Calculate similarity score
|
||||
score = _similarity(filename_norm, model_norm)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_match = {
|
||||
'source': 'model_list',
|
||||
'filename': model_filename,
|
||||
'url': url,
|
||||
'name': model.get('name', ''),
|
||||
'type': model.get('type', ''),
|
||||
'directory': model.get('save_path', 'checkpoints'),
|
||||
'size': model.get('size', ''),
|
||||
'match_type': 'fuzzy',
|
||||
'confidence': round(score * 100, 1)
|
||||
}
|
||||
|
||||
# Return best fuzzy match if score is good enough (>50%)
|
||||
if best_match and best_score > 0.5:
|
||||
return best_match
|
||||
|
||||
# 3. Try normalized similarity matching on all models
|
||||
for model in models:
|
||||
model_filename = model.get('filename', '')
|
||||
if not model_filename:
|
||||
continue
|
||||
|
||||
model_norm = _normalize_filename(model_filename)
|
||||
score = _similarity(filename_norm, model_norm)
|
||||
|
||||
if score > best_score and score > 0.6: # Require 60% similarity
|
||||
url = model.get('url', '')
|
||||
if url:
|
||||
best_score = score
|
||||
best_match = {
|
||||
'source': 'model_list',
|
||||
'filename': model_filename,
|
||||
'url': url,
|
||||
'name': model.get('name', ''),
|
||||
'type': model.get('type', ''),
|
||||
'directory': model.get('save_path', 'checkpoints'),
|
||||
'size': model.get('size', ''),
|
||||
'match_type': 'similar',
|
||||
'confidence': round(score * 100, 1)
|
||||
}
|
||||
|
||||
return best_match
|
||||
|
||||
|
||||
def search_model_list_multiple(filename: str, limit: int = 5) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search model-list.json and return multiple fuzzy matches.
|
||||
|
||||
Args:
|
||||
filename: Model filename to search for
|
||||
limit: Maximum results to return
|
||||
|
||||
Returns:
|
||||
List of matching models sorted by relevance
|
||||
"""
|
||||
models = _load_model_list()
|
||||
if not models:
|
||||
return []
|
||||
|
||||
filename_norm = _normalize_filename(filename)
|
||||
results = []
|
||||
|
||||
for model in models:
|
||||
model_filename = model.get('filename', '')
|
||||
if not model_filename:
|
||||
continue
|
||||
|
||||
model_norm = _normalize_filename(model_filename)
|
||||
score = _similarity(filename_norm, model_norm)
|
||||
|
||||
if score > 0.4: # Minimum 40% similarity
|
||||
url = model.get('url', '')
|
||||
if url:
|
||||
results.append({
|
||||
'source': 'model_list',
|
||||
'filename': model_filename,
|
||||
'url': url,
|
||||
'name': model.get('name', ''),
|
||||
'type': model.get('type', ''),
|
||||
'directory': model.get('save_path', 'checkpoints'),
|
||||
'size': model.get('size', ''),
|
||||
'confidence': round(score * 100, 1)
|
||||
})
|
||||
|
||||
# Sort by confidence descending
|
||||
results.sort(key=lambda x: x['confidence'], reverse=True)
|
||||
|
||||
return results[:limit]
|
||||
|
||||
|
||||
def reload_model_list():
|
||||
"""Force reload of model list."""
|
||||
global _model_list_cache
|
||||
_model_list_cache = None
|
||||
_load_model_list()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Popular Models Database
|
||||
|
||||
Curated list of common models with known download URLs.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Path to metadata directory
|
||||
METADATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'metadata')
|
||||
POPULAR_MODELS_FILE = os.path.join(METADATA_DIR, 'popular-models.json')
|
||||
MODEL_ALIASES_FILE = os.path.join(METADATA_DIR, 'model-aliases.json')
|
||||
|
||||
# Cache for loaded data
|
||||
_popular_models_cache: Optional[Dict] = None
|
||||
_model_aliases_cache: Optional[Dict] = None
|
||||
|
||||
|
||||
def _load_popular_models() -> Dict[str, Any]:
|
||||
"""Load popular models database."""
|
||||
global _popular_models_cache
|
||||
|
||||
if _popular_models_cache is not None:
|
||||
return _popular_models_cache
|
||||
|
||||
try:
|
||||
if os.path.exists(POPULAR_MODELS_FILE):
|
||||
with open(POPULAR_MODELS_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
_popular_models_cache = data.get('models', {})
|
||||
return _popular_models_cache
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading popular models: {e}")
|
||||
|
||||
_popular_models_cache = {}
|
||||
return _popular_models_cache
|
||||
|
||||
|
||||
def _load_model_aliases() -> Dict[str, List[str]]:
|
||||
"""Load model aliases database."""
|
||||
global _model_aliases_cache
|
||||
|
||||
if _model_aliases_cache is not None:
|
||||
return _model_aliases_cache
|
||||
|
||||
try:
|
||||
if os.path.exists(MODEL_ALIASES_FILE):
|
||||
with open(MODEL_ALIASES_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
_model_aliases_cache = data.get('aliases', {})
|
||||
return _model_aliases_cache
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading model aliases: {e}")
|
||||
|
||||
_model_aliases_cache = {}
|
||||
return _model_aliases_cache
|
||||
|
||||
|
||||
def get_popular_model_url(filename: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Look up a model filename in the popular models database.
|
||||
|
||||
Args:
|
||||
filename: Model filename to look up
|
||||
|
||||
Returns:
|
||||
Dictionary with url, type, directory if found, None otherwise
|
||||
"""
|
||||
models = _load_popular_models()
|
||||
|
||||
# Direct lookup
|
||||
if filename in models:
|
||||
return models[filename].copy()
|
||||
|
||||
# Try lowercase
|
||||
filename_lower = filename.lower()
|
||||
for name, info in models.items():
|
||||
if name.lower() == filename_lower:
|
||||
return info.copy()
|
||||
|
||||
# Try aliases
|
||||
aliases = _load_model_aliases()
|
||||
for canonical, alias_list in aliases.items():
|
||||
if filename in alias_list or filename_lower in [a.lower() for a in alias_list]:
|
||||
if canonical in models:
|
||||
result = models[canonical].copy()
|
||||
result['canonical_name'] = canonical
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def search_popular_models(query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search popular models database by filename pattern.
|
||||
|
||||
Args:
|
||||
query: Search query (partial filename)
|
||||
limit: Maximum results to return
|
||||
|
||||
Returns:
|
||||
List of matching models with url info
|
||||
"""
|
||||
models = _load_popular_models()
|
||||
query_lower = query.lower()
|
||||
|
||||
results = []
|
||||
for name, info in models.items():
|
||||
if query_lower in name.lower():
|
||||
result = info.copy()
|
||||
result['filename'] = name
|
||||
results.append(result)
|
||||
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_all_popular_models() -> Dict[str, Any]:
|
||||
"""Get all popular models."""
|
||||
return _load_popular_models().copy()
|
||||
|
||||
|
||||
def reload_databases():
|
||||
"""Force reload of all databases."""
|
||||
global _popular_models_cache, _model_aliases_cache
|
||||
_popular_models_cache = None
|
||||
_model_aliases_cache = None
|
||||
_load_popular_models()
|
||||
_load_model_aliases()
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"description": "Model filename aliases for fuzzy matching. Canonical names map to variant aliases.",
|
||||
"aliases": {
|
||||
"sd_xl_base_1.0.safetensors": [
|
||||
"sdxl_base.safetensors",
|
||||
"sdxl-base-1.0.safetensors",
|
||||
"sd-xl-base.safetensors",
|
||||
"sdxl_1.0_base.safetensors"
|
||||
],
|
||||
"sd_xl_refiner_1.0.safetensors": [
|
||||
"sdxl_refiner.safetensors",
|
||||
"sdxl-refiner-1.0.safetensors",
|
||||
"sd-xl-refiner.safetensors"
|
||||
],
|
||||
"flux1-dev.safetensors": [
|
||||
"flux-dev.safetensors",
|
||||
"FLUX.1-dev.safetensors",
|
||||
"flux1_dev.safetensors",
|
||||
"flux-1-dev.safetensors"
|
||||
],
|
||||
"flux1-schnell.safetensors": [
|
||||
"flux-schnell.safetensors",
|
||||
"FLUX.1-schnell.safetensors",
|
||||
"flux1_schnell.safetensors",
|
||||
"flux-1-schnell.safetensors"
|
||||
],
|
||||
"v1-5-pruned-emaonly.safetensors": [
|
||||
"sd15.safetensors",
|
||||
"sd-1-5.safetensors",
|
||||
"stable-diffusion-1-5.safetensors",
|
||||
"v1-5-pruned.safetensors",
|
||||
"sd_v1.5.safetensors"
|
||||
],
|
||||
"v2-1_768-ema-pruned.safetensors": [
|
||||
"sd21.safetensors",
|
||||
"sd-2-1.safetensors",
|
||||
"stable-diffusion-2-1.safetensors",
|
||||
"v2-1.safetensors"
|
||||
],
|
||||
"sd3_medium.safetensors": [
|
||||
"sd3-medium.safetensors",
|
||||
"sd3.safetensors",
|
||||
"stable-diffusion-3-medium.safetensors"
|
||||
],
|
||||
"RealESRGAN_x4plus.pth": [
|
||||
"realesrgan_x4plus.pth",
|
||||
"real-esrgan-x4.pth",
|
||||
"4x_realesrgan.pth"
|
||||
],
|
||||
"t5xxl_fp16.safetensors": [
|
||||
"t5-xxl-fp16.safetensors",
|
||||
"t5xxl.safetensors",
|
||||
"t5_xxl_fp16.safetensors"
|
||||
],
|
||||
"t5xxl_fp8_e4m3fn.safetensors": [
|
||||
"t5-xxl-fp8.safetensors",
|
||||
"t5xxl_fp8.safetensors",
|
||||
"t5_xxl_fp8.safetensors"
|
||||
],
|
||||
"clip_l.safetensors": [
|
||||
"clip-l.safetensors",
|
||||
"clip_large.safetensors",
|
||||
"openai-clip-l.safetensors"
|
||||
],
|
||||
"ae.safetensors": [
|
||||
"flux_ae.safetensors",
|
||||
"flux-ae.safetensors",
|
||||
"flux_vae.safetensors"
|
||||
]
|
||||
},
|
||||
"patterns": {
|
||||
"fp16_variants": {
|
||||
"description": "FP16/FP32 variants of the same model",
|
||||
"pattern": "(.+?)[-_]?(fp16|fp32|fp8)?\\.safetensors$",
|
||||
"note": "Models with fp16/fp32/fp8 suffix are often interchangeable"
|
||||
},
|
||||
"quantized_variants": {
|
||||
"description": "Quantized variants (Q4, Q8, GGUF)",
|
||||
"pattern": "(.+?)[-_]?(q4|q8|gguf)?\\.safetensors$"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"description": "Curated list of popular models with download URLs",
|
||||
"models": {
|
||||
"v1-5-pruned-emaonly.safetensors": {
|
||||
"url": "https://huggingface.co/runwayml/stable-diffusion-v1-5/resolve/main/v1-5-pruned-emaonly.safetensors",
|
||||
"type": "checkpoint",
|
||||
"directory": "checkpoints"
|
||||
},
|
||||
"v1-5-pruned.safetensors": {
|
||||
"url": "https://huggingface.co/runwayml/stable-diffusion-v1-5/resolve/main/v1-5-pruned.safetensors",
|
||||
"type": "checkpoint",
|
||||
"directory": "checkpoints"
|
||||
},
|
||||
"sd_xl_base_1.0.safetensors": {
|
||||
"url": "https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors",
|
||||
"type": "checkpoint",
|
||||
"directory": "checkpoints"
|
||||
},
|
||||
"sd_xl_refiner_1.0.safetensors": {
|
||||
"url": "https://huggingface.co/stabilityai/stable-diffusion-xl-refiner-1.0/resolve/main/sd_xl_refiner_1.0.safetensors",
|
||||
"type": "checkpoint",
|
||||
"directory": "checkpoints"
|
||||
},
|
||||
"sd3_medium_incl_clips_t5xxlfp8.safetensors": {
|
||||
"url": "https://huggingface.co/stabilityai/stable-diffusion-3-medium/resolve/main/sd3_medium_incl_clips_t5xxlfp8.safetensors",
|
||||
"type": "checkpoint",
|
||||
"directory": "checkpoints"
|
||||
},
|
||||
"sd3.5_large.safetensors": {
|
||||
"url": "https://huggingface.co/stabilityai/stable-diffusion-3.5-large/resolve/main/sd3.5_large.safetensors",
|
||||
"type": "checkpoint",
|
||||
"directory": "checkpoints"
|
||||
},
|
||||
"flux1-dev.safetensors": {
|
||||
"url": "https://huggingface.co/black-forest-labs/FLUX.1-dev/resolve/main/flux1-dev.safetensors",
|
||||
"type": "diffusion_model",
|
||||
"directory": "diffusion_models"
|
||||
},
|
||||
"flux1-schnell.safetensors": {
|
||||
"url": "https://huggingface.co/black-forest-labs/FLUX.1-schnell/resolve/main/flux1-schnell.safetensors",
|
||||
"type": "diffusion_model",
|
||||
"directory": "diffusion_models"
|
||||
},
|
||||
"ae.safetensors": {
|
||||
"url": "https://huggingface.co/black-forest-labs/FLUX.1-dev/resolve/main/ae.safetensors",
|
||||
"type": "vae",
|
||||
"directory": "vae"
|
||||
},
|
||||
"clip_l.safetensors": {
|
||||
"url": "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/clip_l.safetensors",
|
||||
"type": "clip",
|
||||
"directory": "clip"
|
||||
},
|
||||
"t5xxl_fp16.safetensors": {
|
||||
"url": "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/t5xxl_fp16.safetensors",
|
||||
"type": "text_encoder",
|
||||
"directory": "text_encoders"
|
||||
},
|
||||
"t5xxl_fp8_e4m3fn.safetensors": {
|
||||
"url": "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/t5xxl_fp8_e4m3fn.safetensors",
|
||||
"type": "text_encoder",
|
||||
"directory": "text_encoders"
|
||||
},
|
||||
"sdxl_vae.safetensors": {
|
||||
"url": "https://huggingface.co/stabilityai/sdxl-vae/resolve/main/sdxl_vae.safetensors",
|
||||
"type": "vae",
|
||||
"directory": "vae"
|
||||
},
|
||||
"vae-ft-mse-840000-ema-pruned.safetensors": {
|
||||
"url": "https://huggingface.co/stabilityai/sd-vae-ft-mse-original/resolve/main/vae-ft-mse-840000-ema-pruned.safetensors",
|
||||
"type": "vae",
|
||||
"directory": "vae"
|
||||
},
|
||||
"RealESRGAN_x4plus.pth": {
|
||||
"url": "https://huggingface.co/ai-forever/Real-ESRGAN/resolve/main/RealESRGAN_x4plus.pth",
|
||||
"type": "upscaler",
|
||||
"directory": "upscale_models"
|
||||
},
|
||||
"4x-UltraSharp.pth": {
|
||||
"url": "https://huggingface.co/Kim2091/4x-UltraSharp/resolve/main/4x-UltraSharp.pth",
|
||||
"type": "upscaler",
|
||||
"directory": "upscale_models"
|
||||
},
|
||||
"control_v11p_sd15_canny.pth": {
|
||||
"url": "https://huggingface.co/lllyasviel/ControlNet-v1-1/resolve/main/control_v11p_sd15_canny.pth",
|
||||
"type": "controlnet",
|
||||
"directory": "controlnet"
|
||||
},
|
||||
"control_v11p_sd15_openpose.pth": {
|
||||
"url": "https://huggingface.co/lllyasviel/ControlNet-v1-1/resolve/main/control_v11p_sd15_openpose.pth",
|
||||
"type": "controlnet",
|
||||
"directory": "controlnet"
|
||||
},
|
||||
"control_v11f1p_sd15_depth.pth": {
|
||||
"url": "https://huggingface.co/lllyasviel/ControlNet-v1-1/resolve/main/control_v11f1p_sd15_depth.pth",
|
||||
"type": "controlnet",
|
||||
"directory": "controlnet"
|
||||
},
|
||||
"control_v11p_sd15_lineart.pth": {
|
||||
"url": "https://huggingface.co/lllyasviel/ControlNet-v1-1/resolve/main/control_v11p_sd15_lineart.pth",
|
||||
"type": "controlnet",
|
||||
"directory": "controlnet"
|
||||
},
|
||||
"ip-adapter_sd15.safetensors": {
|
||||
"url": "https://huggingface.co/h94/IP-Adapter/resolve/main/models/ip-adapter_sd15.safetensors",
|
||||
"type": "ipadapter",
|
||||
"directory": "ipadapter"
|
||||
},
|
||||
"ip-adapter-plus_sd15.safetensors": {
|
||||
"url": "https://huggingface.co/h94/IP-Adapter/resolve/main/models/ip-adapter-plus_sd15.safetensors",
|
||||
"type": "ipadapter",
|
||||
"directory": "ipadapter"
|
||||
},
|
||||
"ip-adapter_sdxl.safetensors": {
|
||||
"url": "https://huggingface.co/h94/IP-Adapter/resolve/main/sdxl_models/ip-adapter_sdxl.safetensors",
|
||||
"type": "ipadapter",
|
||||
"directory": "ipadapter"
|
||||
},
|
||||
"ip-adapter-plus_sdxl_vit-h.safetensors": {
|
||||
"url": "https://huggingface.co/h94/IP-Adapter/resolve/main/sdxl_models/ip-adapter-plus_sdxl_vit-h.safetensors",
|
||||
"type": "ipadapter",
|
||||
"directory": "ipadapter"
|
||||
},
|
||||
"CLIP-ViT-H-14-laion2B-s32B-b79K.safetensors": {
|
||||
"url": "https://huggingface.co/h94/IP-Adapter/resolve/main/models/image_encoder/model.safetensors",
|
||||
"type": "clip_vision",
|
||||
"directory": "clip_vision"
|
||||
},
|
||||
"CLIP-ViT-bigG-14-laion2B-39B-b160k.safetensors": {
|
||||
"url": "https://huggingface.co/h94/IP-Adapter/resolve/main/sdxl_models/image_encoder/model.safetensors",
|
||||
"type": "clip_vision",
|
||||
"directory": "clip_vision"
|
||||
},
|
||||
"sam_vit_h_4b8939.pth": {
|
||||
"url": "https://huggingface.co/ybelkada/segment-anything/resolve/main/checkpoints/sam_vit_h_4b8939.pth",
|
||||
"type": "sams",
|
||||
"directory": "sams"
|
||||
},
|
||||
"sam_vit_b_01ec64.pth": {
|
||||
"url": "https://huggingface.co/ybelkada/segment-anything/resolve/main/checkpoints/sam_vit_b_01ec64.pth",
|
||||
"type": "sams",
|
||||
"directory": "sams"
|
||||
},
|
||||
"wd-v1-4-moat-tagger-v2.onnx": {
|
||||
"url": "https://huggingface.co/SmilingWolf/wd-v1-4-moat-tagger-v2/resolve/main/model.onnx",
|
||||
"type": "onnx",
|
||||
"directory": "onnx"
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -1,7 +1,19 @@
|
||||
[project]
|
||||
name = "comfyui-model-linker"
|
||||
version = "0.1.0"
|
||||
description = "A ComfyUI extension that helps users relink missing models in workflows"
|
||||
version = "1.1.0"
|
||||
description = "A ComfyUI extension that helps users relink missing models in workflows and download from HuggingFace/CivitAI"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">=3.8"
|
||||
dependencies = [
|
||||
"requests>=2.28.0",
|
||||
"aiohttp>=3.8.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/your-username/comfyui-model-linker"
|
||||
Documentation = "https://github.com/your-username/comfyui-model-linker#readme"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "model-linker"
|
||||
DisplayName = "Model Linker"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
requests>=2.28.0
|
||||
aiohttp>=3.8.0
|
||||
+466
-9
@@ -15,6 +15,7 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
super();
|
||||
this.currentWorkflow = null;
|
||||
this.missingModels = [];
|
||||
this.activeDownloads = {}; // Track active downloads
|
||||
|
||||
// Create dialog element using $el
|
||||
this.element = $el("div.comfy-modal", {
|
||||
@@ -268,6 +269,24 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Attach download button listener
|
||||
const downloadBtnId = `download-${missing.node_id}-${missing.widget_index}`;
|
||||
const downloadBtn = container.querySelector(`#${downloadBtnId}`);
|
||||
if (downloadBtn && missing.download_source) {
|
||||
downloadBtn.addEventListener('click', () => {
|
||||
this.downloadModel(missing);
|
||||
});
|
||||
}
|
||||
|
||||
// Attach search button listener
|
||||
const searchBtnId = `search-${missing.node_id}-${missing.widget_index}`;
|
||||
const searchBtn = container.querySelector(`#${searchBtnId}`);
|
||||
if (searchBtn) {
|
||||
searchBtn.addEventListener('click', () => {
|
||||
this.searchOnline(missing);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -280,6 +299,10 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
// Filter out matches below 70% confidence threshold
|
||||
const filteredMatches = allMatches.filter(m => m.confidence >= 70);
|
||||
const hasMatches = filteredMatches.length > 0;
|
||||
|
||||
// Calculate 100% matches upfront (needed for download section)
|
||||
const perfectMatches = filteredMatches.filter(m => m.confidence === 100);
|
||||
const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70);
|
||||
|
||||
let html = `<div style="border: 1px solid var(--border-color, #444); padding: 12px; border-radius: 4px;">`;
|
||||
|
||||
@@ -301,13 +324,6 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
html += `<div style="margin-bottom: 8px;"><strong>Category:</strong> ${missing.category || 'unknown'}</div>`;
|
||||
|
||||
if (hasMatches) {
|
||||
// Filter out matches below 70% confidence threshold
|
||||
const filteredMatches = allMatches.filter(m => m.confidence >= 70);
|
||||
|
||||
// Separate 100% matches from others (from filtered list)
|
||||
const perfectMatches = filteredMatches.filter(m => m.confidence === 100);
|
||||
const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70);
|
||||
|
||||
// If we have 100% matches, only show those. Otherwise, show other matches sorted by confidence
|
||||
const matchesToShow = perfectMatches.length > 0
|
||||
? perfectMatches
|
||||
@@ -347,9 +363,61 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
}
|
||||
} else if (allMatches.length > 0 && filteredMatches.length === 0) {
|
||||
// Had matches but all were below 70% threshold
|
||||
html += `<div style="color: orange; margin-top: 8px;">No matches found above 70% confidence threshold.</div>`;
|
||||
html += `<div style="color: orange; margin-top: 8px;">No local matches found above 70% confidence threshold.</div>`;
|
||||
} else {
|
||||
html += `<div style="color: orange; margin-top: 8px;">No matches found.</div>`;
|
||||
html += `<div style="color: orange; margin-top: 8px;">No local matches found.</div>`;
|
||||
}
|
||||
|
||||
// Show download option when no 100% local match exists
|
||||
const filename = missing.original_path?.split('/').pop()?.split('\\').pop() || '';
|
||||
const downloadSource = missing.download_source;
|
||||
|
||||
// Always show download/search when there's no perfect local match
|
||||
if (perfectMatches.length === 0) {
|
||||
html += `<div style="margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--border-color, #444);">`;
|
||||
|
||||
if (downloadSource && downloadSource.url) {
|
||||
// We have a known download URL - show Download button
|
||||
const isExact = downloadSource.match_type === 'exact' || downloadSource.source === 'popular' || downloadSource.source === 'huggingface' || downloadSource.source === 'civitai';
|
||||
const confidence = downloadSource.confidence ? ` (${downloadSource.confidence}% match)` : '';
|
||||
const sourceLabels = {
|
||||
'popular': 'Popular Models',
|
||||
'model_list': 'Model Database',
|
||||
'huggingface': 'HuggingFace',
|
||||
'civitai': 'CivitAI'
|
||||
};
|
||||
const sourceLabel = sourceLabels[downloadSource.source] || 'Online';
|
||||
const downloadFilename = downloadSource.filename || filename;
|
||||
const modelName = downloadSource.name ? ` (${downloadSource.name})` : '';
|
||||
const size = downloadSource.size ? ` [${downloadSource.size}]` : '';
|
||||
|
||||
html += `<div style="display: flex; align-items: flex-start; gap: 12px;">`;
|
||||
html += `<button id="download-${missing.node_id}-${missing.widget_index}"
|
||||
class="model-linker-download-btn"
|
||||
style="padding: 8px 16px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500;">
|
||||
Download
|
||||
</button>`;
|
||||
html += `<div style="flex: 1; font-size: 12px;">`;
|
||||
html += `<div style="color: ${isExact ? '#4CAF50' : '#9C27B0'};">`;
|
||||
html += isExact ? `✓ Found on ${sourceLabel}` : `~ Similar model found${confidence}`;
|
||||
html += `</div>`;
|
||||
html += `<div style="color: #888; margin-top: 2px;">`;
|
||||
html += `<code>${downloadFilename}</code>${modelName}${size}`;
|
||||
html += `</div>`;
|
||||
html += `</div>`;
|
||||
html += `</div>`;
|
||||
} else {
|
||||
// No known download - offer search
|
||||
html += `<button id="search-${missing.node_id}-${missing.widget_index}"
|
||||
class="model-linker-search-btn"
|
||||
style="padding: 6px 12px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer;">
|
||||
🔍 Search Online
|
||||
</button>`;
|
||||
html += `<div id="search-results-${missing.node_id}-${missing.widget_index}" style="margin-top: 8px; display: none;"></div>`;
|
||||
}
|
||||
|
||||
html += `<div id="download-progress-${missing.node_id}-${missing.widget_index}" style="margin-top: 8px; display: none;"></div>`;
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
@@ -621,6 +689,395 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a model from a known source
|
||||
*/
|
||||
async downloadModel(missing) {
|
||||
const source = missing.download_source;
|
||||
if (!source || !source.url) {
|
||||
this.showNotification('No download URL available', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use filename from download source if available (may be different from original)
|
||||
const originalFilename = missing.original_path?.split('/').pop()?.split('\\').pop() || 'model.safetensors';
|
||||
const filename = source.filename || originalFilename;
|
||||
const category = source.directory || missing.category || 'checkpoints';
|
||||
const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`;
|
||||
const progressDiv = this.contentElement?.querySelector(`#${progressId}`);
|
||||
const downloadBtn = this.contentElement?.querySelector(`#download-${missing.node_id}-${missing.widget_index}`);
|
||||
|
||||
try {
|
||||
// Disable button and show progress
|
||||
if (downloadBtn) {
|
||||
downloadBtn.disabled = true;
|
||||
downloadBtn.textContent = 'Starting...';
|
||||
}
|
||||
if (progressDiv) {
|
||||
progressDiv.style.display = 'block';
|
||||
progressDiv.innerHTML = '<span style="color: #2196F3;">Starting download...</span>';
|
||||
}
|
||||
|
||||
// Start download
|
||||
const response = await api.fetchApi('/model_linker/download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url: source.url,
|
||||
filename: filename,
|
||||
category: category
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Download failed');
|
||||
}
|
||||
|
||||
// Track download and poll for progress
|
||||
const downloadId = data.download_id;
|
||||
this.activeDownloads[downloadId] = { missing, progressDiv, downloadBtn };
|
||||
this.pollDownloadProgress(downloadId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Model Linker: Download error:', error);
|
||||
if (progressDiv) {
|
||||
progressDiv.innerHTML = `<span style="color: #f44336;">Error: ${error.message}</span>`;
|
||||
}
|
||||
if (downloadBtn) {
|
||||
downloadBtn.disabled = false;
|
||||
downloadBtn.textContent = 'Retry Download';
|
||||
}
|
||||
this.showNotification('Download failed: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll download progress
|
||||
*/
|
||||
async pollDownloadProgress(downloadId) {
|
||||
const info = this.activeDownloads[downloadId];
|
||||
if (!info) return;
|
||||
|
||||
try {
|
||||
const response = await api.fetchApi(`/model_linker/progress/${downloadId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get progress');
|
||||
}
|
||||
|
||||
const progress = await response.json();
|
||||
const { progressDiv, downloadBtn, missing } = info;
|
||||
|
||||
if (progress.status === 'downloading') {
|
||||
const percent = progress.progress || 0;
|
||||
const downloaded = this.formatBytes(progress.downloaded || 0);
|
||||
const total = this.formatBytes(progress.total_size || 0);
|
||||
|
||||
if (progressDiv) {
|
||||
progressDiv.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<div style="flex: 1; background: #333; border-radius: 4px; height: 8px; overflow: hidden;">
|
||||
<div style="width: ${percent}%; background: #4CAF50; height: 100%; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
<span style="font-size: 12px; color: #888;">${percent}% (${downloaded} / ${total})</span>
|
||||
<button class="cancel-download-btn" data-download-id="${downloadId}"
|
||||
style="padding: 2px 8px; background: #f44336; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 11px;">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
// Attach cancel handler
|
||||
const cancelBtn = progressDiv.querySelector('.cancel-download-btn');
|
||||
if (cancelBtn && !cancelBtn._hasListener) {
|
||||
cancelBtn._hasListener = true;
|
||||
cancelBtn.addEventListener('click', () => this.cancelDownload(downloadId));
|
||||
}
|
||||
}
|
||||
if (downloadBtn) {
|
||||
downloadBtn.textContent = `${percent}%`;
|
||||
}
|
||||
|
||||
// Continue polling
|
||||
setTimeout(() => this.pollDownloadProgress(downloadId), 1000);
|
||||
|
||||
} else if (progress.status === 'completed') {
|
||||
if (progressDiv) {
|
||||
progressDiv.innerHTML = '<span style="color: #4CAF50;">✓ Download complete! Refresh to see the model.</span>';
|
||||
}
|
||||
if (downloadBtn) {
|
||||
downloadBtn.textContent = '✓ Downloaded';
|
||||
downloadBtn.style.background = '#4CAF50';
|
||||
}
|
||||
delete this.activeDownloads[downloadId];
|
||||
this.showNotification(`Downloaded: ${progress.filename}`, 'success');
|
||||
|
||||
} else if (progress.status === 'error') {
|
||||
if (progressDiv) {
|
||||
progressDiv.innerHTML = `<span style="color: #f44336;">Error: ${progress.error || 'Unknown error'}</span>`;
|
||||
}
|
||||
if (downloadBtn) {
|
||||
downloadBtn.disabled = false;
|
||||
downloadBtn.textContent = 'Retry';
|
||||
}
|
||||
delete this.activeDownloads[downloadId];
|
||||
|
||||
} else if (progress.status === 'cancelled') {
|
||||
if (progressDiv) {
|
||||
progressDiv.innerHTML = '<span style="color: orange;">Download cancelled</span>';
|
||||
}
|
||||
if (downloadBtn) {
|
||||
downloadBtn.disabled = false;
|
||||
downloadBtn.textContent = 'Download';
|
||||
}
|
||||
delete this.activeDownloads[downloadId];
|
||||
|
||||
} else {
|
||||
// Still starting, keep polling
|
||||
setTimeout(() => this.pollDownloadProgress(downloadId), 500);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Model Linker: Progress poll error:', error);
|
||||
delete this.activeDownloads[downloadId];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an active download
|
||||
*/
|
||||
async cancelDownload(downloadId) {
|
||||
try {
|
||||
const response = await api.fetchApi(`/model_linker/cancel/${downloadId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to cancel download');
|
||||
}
|
||||
|
||||
const info = this.activeDownloads[downloadId];
|
||||
if (info?.progressDiv) {
|
||||
info.progressDiv.innerHTML = '<span style="color: orange;">Cancelling...</span>';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Model Linker: Cancel error:', error);
|
||||
this.showNotification('Failed to cancel download', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search online for a model
|
||||
*/
|
||||
async searchOnline(missing) {
|
||||
const filename = missing.original_path?.split('/').pop()?.split('\\').pop() || '';
|
||||
const category = missing.category || '';
|
||||
const resultsId = `search-results-${missing.node_id}-${missing.widget_index}`;
|
||||
const resultsDiv = this.contentElement?.querySelector(`#${resultsId}`);
|
||||
const searchBtn = this.contentElement?.querySelector(`#search-${missing.node_id}-${missing.widget_index}`);
|
||||
|
||||
try {
|
||||
if (searchBtn) {
|
||||
searchBtn.disabled = true;
|
||||
searchBtn.textContent = '🔍 Searching...';
|
||||
}
|
||||
if (resultsDiv) {
|
||||
resultsDiv.style.display = 'block';
|
||||
resultsDiv.innerHTML = '<span style="color: #2196F3;">Searching HuggingFace and CivitAI...</span>';
|
||||
}
|
||||
|
||||
const response = await api.fetchApi('/model_linker/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename, category })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Search failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.displaySearchResults(missing, data, resultsDiv);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Model Linker: Search error:', error);
|
||||
if (resultsDiv) {
|
||||
resultsDiv.innerHTML = `<span style="color: #f44336;">Search failed: ${error.message}</span>`;
|
||||
}
|
||||
} finally {
|
||||
if (searchBtn) {
|
||||
searchBtn.disabled = false;
|
||||
searchBtn.textContent = '🔍 Search Again';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display search results
|
||||
*/
|
||||
displaySearchResults(missing, data, container) {
|
||||
if (!container) return;
|
||||
|
||||
const popular = data.popular;
|
||||
const modelListResult = data.model_list; // ComfyUI Manager database result
|
||||
const hfResult = data.huggingface;
|
||||
const civitaiResult = data.civitai;
|
||||
const found = data.found || popular || modelListResult || hfResult || civitaiResult;
|
||||
|
||||
if (!found) {
|
||||
container.innerHTML = '<span style="color: orange;">No match found online for this model.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div style="margin-top: 8px;">';
|
||||
|
||||
// Popular models result (highest priority)
|
||||
if (popular) {
|
||||
html += `<div style="margin-bottom: 8px; padding: 8px; background: rgba(76, 175, 80, 0.1); border-radius: 4px;">`;
|
||||
html += `<strong style="color: #4CAF50;">✓ Found in Popular Models:</strong>`;
|
||||
html += `<div style="margin-top: 4px;">`;
|
||||
html += `<button class="search-download-btn" data-url="${popular.url}" data-filename="${popular.filename || missing.original_path?.split('/').pop()?.split('\\').pop()}" data-category="${popular.directory || missing.category}"
|
||||
style="padding: 4px 8px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">
|
||||
Download
|
||||
</button>`;
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
// Model list result (ComfyUI Manager database with fuzzy matching)
|
||||
if (modelListResult && modelListResult.url) {
|
||||
const confidence = modelListResult.confidence ? ` (${modelListResult.confidence}% match)` : '';
|
||||
const matchType = modelListResult.match_type === 'exact' ? '✓ Exact match' : '~ Similar model';
|
||||
const bgColor = modelListResult.match_type === 'exact' ? 'rgba(76, 175, 80, 0.1)' : 'rgba(156, 39, 176, 0.1)';
|
||||
const textColor = modelListResult.match_type === 'exact' ? '#4CAF50' : '#9C27B0';
|
||||
|
||||
html += `<div style="margin-bottom: 8px; padding: 8px; background: ${bgColor}; border-radius: 4px;">`;
|
||||
html += `<strong style="color: ${textColor};">${matchType} in Model Database${confidence}:</strong>`;
|
||||
html += `<div style="margin-top: 4px; font-size: 12px;">`;
|
||||
html += `<code>${modelListResult.filename}</code>`;
|
||||
if (modelListResult.name) {
|
||||
html += ` <span style="color: #888;">(${modelListResult.name})</span>`;
|
||||
}
|
||||
if (modelListResult.size) {
|
||||
html += ` <span style="color: #666; font-size: 11px;">[${modelListResult.size}]</span>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
html += `<div style="margin-top: 8px;">`;
|
||||
html += `<button class="search-download-btn" data-url="${modelListResult.url}" data-filename="${modelListResult.filename}" data-category="${modelListResult.directory || missing.category}"
|
||||
style="padding: 4px 8px; background: ${textColor}; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">
|
||||
Download
|
||||
</button>`;
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
// HuggingFace result
|
||||
if (hfResult && hfResult.url) {
|
||||
html += `<div style="margin-bottom: 8px; padding: 8px; background: rgba(33, 150, 243, 0.1); border-radius: 4px;">`;
|
||||
html += `<strong style="color: #2196F3;">✓ Found on HuggingFace:</strong>`;
|
||||
html += `<div style="margin-top: 4px; font-size: 12px;">`;
|
||||
html += `<code>${hfResult.filename}</code> `;
|
||||
html += `<span style="color: #888;">(${hfResult.repo})</span>`;
|
||||
html += `</div>`;
|
||||
html += `<div style="margin-top: 8px;">`;
|
||||
html += `<button class="search-download-btn" data-url="${hfResult.url}" data-filename="${hfResult.filename}" data-category="${missing.category}"
|
||||
style="padding: 4px 8px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">
|
||||
Download
|
||||
</button>`;
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
// CivitAI result
|
||||
if (civitaiResult && civitaiResult.download_url) {
|
||||
html += `<div style="margin-bottom: 8px; padding: 8px; background: rgba(255, 152, 0, 0.1); border-radius: 4px;">`;
|
||||
html += `<strong style="color: #FF9800;">✓ Found on CivitAI:</strong>`;
|
||||
html += `<div style="margin-top: 4px; font-size: 12px;">`;
|
||||
html += `<code>${civitaiResult.filename || civitaiResult.name}</code> `;
|
||||
html += `<span style="color: #888;">(${civitaiResult.type || civitaiResult.name})</span>`;
|
||||
html += `</div>`;
|
||||
html += `<div style="margin-top: 8px;">`;
|
||||
html += `<button class="search-download-btn" data-url="${civitaiResult.download_url}" data-filename="${civitaiResult.filename || civitaiResult.name + '.safetensors'}" data-category="${missing.category}"
|
||||
style="padding: 4px 8px; background: #FF9800; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">
|
||||
Download
|
||||
</button>`;
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
// Attach download listeners
|
||||
const downloadBtns = container.querySelectorAll('.search-download-btn');
|
||||
downloadBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const url = btn.dataset.url;
|
||||
const filename = btn.dataset.filename;
|
||||
const category = btn.dataset.category;
|
||||
this.downloadFromSearch(missing, url, filename, category, btn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download from search results
|
||||
*/
|
||||
async downloadFromSearch(missing, url, filename, category, btn) {
|
||||
const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`;
|
||||
const progressDiv = this.contentElement?.querySelector(`#${progressId}`);
|
||||
|
||||
try {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Starting...';
|
||||
|
||||
if (progressDiv) {
|
||||
progressDiv.style.display = 'block';
|
||||
progressDiv.innerHTML = '<span style="color: #2196F3;">Starting download...</span>';
|
||||
}
|
||||
|
||||
const response = await api.fetchApi('/model_linker/download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, filename, category })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Download failed');
|
||||
}
|
||||
|
||||
// Track and poll
|
||||
const downloadId = data.download_id;
|
||||
this.activeDownloads[downloadId] = { missing, progressDiv, downloadBtn: btn };
|
||||
this.pollDownloadProgress(downloadId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Model Linker: Download error:', error);
|
||||
if (progressDiv) {
|
||||
progressDiv.innerHTML = `<span style="color: #f44336;">Error: ${error.message}</span>`;
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Retry';
|
||||
this.showNotification('Download failed: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable string
|
||||
*/
|
||||
formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update workflow in ComfyUI's UI/memory
|
||||
* Updates the current workflow in place instead of creating a new tab
|
||||
|
||||
Reference in New Issue
Block a user