Add download integration with auto-search on startup

This commit is contained in:
unknown
2026-01-18 05:44:31 +01:00
parent c7cdf78e60
commit 2843373c58
14 changed files with 7885 additions and 15 deletions
+282 -4
View File
@@ -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
+19
View File
@@ -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'
]
+312
View File
@@ -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
+23
View File
@@ -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'
]
+278
View File
@@ -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
+240
View File
@@ -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
+208
View File
@@ -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()
+136
View File
@@ -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()
+83
View File
@@ -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
+151
View File
@@ -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
View File
@@ -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"
+2
View File
@@ -0,0 +1,2 @@
requests>=2.28.0
aiohttp>=3.8.0
+466 -9
View File
@@ -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