Add CivitAI hash lookup and context menu UI
Enhance model linking with CivitAI integration and UI improvements. - __init__.py: Expand model path resolution by building filename→path lookup (including no-ext keys) and using folder_paths to map common categories; add a /model_linker/civitai-search POST route that attempts metadata/hash-based lookup (or filename fallback) and returns CivitAI model/version info for the frontend. - core/sources/civitai.py: Add hashing and metadata handling utilities: _get_sha256_hash, metadata discovery/reading, helpers to extract images/trained-words, get_model_info_by_hash and get_model_info_for_file (which prefers local metadata and otherwise queries CivitAI by hash). Also tidy/normalize existing search/resolve functions and add caching for hash lookups. - web/linker.js: Add a right-click context menu for model chips with actions (Show Info, Open in CivitAI), implement dialog UI to display model info, and wire calls to the new civitai-search endpoint. Includes positioning/closing behavior and dialog population from returned data. These changes enable accurate model identification via local metadata or sha256 lookups on CivitAI and provide convenient frontend actions to view model details or open CivitAI pages.
This commit is contained in:
+173
@@ -342,6 +342,47 @@ class ModelLinkerExtension:
|
||||
# Get available models for existence checking
|
||||
available_models = get_model_files()
|
||||
available_paths = {m.get("path") for m in available_models}
|
||||
# Create lookup for full paths by filename (with and without extension)
|
||||
path_by_filename = {}
|
||||
for m in available_models:
|
||||
rel_path = m.get("relative_path", "")
|
||||
if rel_path:
|
||||
filename = rel_path.split("/")[-1].split("\\")[-1]
|
||||
path_by_filename[filename] = m.get("path")
|
||||
# Also add without extension for matching (simple approach)
|
||||
if "." in filename:
|
||||
filename_no_ext = filename.rsplit(".", 1)[0]
|
||||
if filename_no_ext not in path_by_filename:
|
||||
path_by_filename[filename_no_ext] = m.get("path")
|
||||
# Add the full relative path as key too
|
||||
path_by_filename[rel_path] = m.get("path")
|
||||
|
||||
# Also use folder_paths.get_full_path() to get paths
|
||||
import folder_paths
|
||||
|
||||
for cat in [
|
||||
"loras",
|
||||
"checkpoints",
|
||||
"vae",
|
||||
"controlnet",
|
||||
"upscale_models",
|
||||
]:
|
||||
try:
|
||||
filenames = folder_paths.get_filename_list(cat)
|
||||
for fn in filenames:
|
||||
full_path = folder_paths.get_full_path(cat, fn)
|
||||
if (
|
||||
full_path
|
||||
and full_path not in path_by_filename.values()
|
||||
):
|
||||
path_by_filename[fn] = full_path
|
||||
fn_no_ext = (
|
||||
fn.rsplit(".", 1)[0] if "." in fn else fn
|
||||
)
|
||||
if fn_no_ext not in path_by_filename:
|
||||
path_by_filename[fn_no_ext] = full_path
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Analyze workflow to get all model references
|
||||
all_model_refs = analyze_workflow_models(workflow_json)
|
||||
@@ -416,6 +457,10 @@ class ModelLinkerExtension:
|
||||
"is_lora_v2": ref.get("is_lora_v2", False),
|
||||
"active": ref.get("active"),
|
||||
"connected": ref.get("connected", True),
|
||||
"resolved_path": (
|
||||
path_by_filename.get(model_name)
|
||||
or path_by_filename.get(original_path)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -468,6 +513,134 @@ class ModelLinkerExtension:
|
||||
)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
# ==================== CIVITAI SEARCH ROUTE ====================
|
||||
|
||||
@routes.post("/model_linker/civitai-search")
|
||||
async def civitai_search(request):
|
||||
"""Search CivitAI for a model using file hash."""
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = data.get("filename", "")
|
||||
category = data.get("category", "")
|
||||
resolved_path = data.get("resolved_path", "")
|
||||
|
||||
if not filename:
|
||||
return web.json_response(
|
||||
{"error": "Filename is required"}, status=400
|
||||
)
|
||||
|
||||
# Clean filename for display
|
||||
import os as _os
|
||||
|
||||
clean_name = _os.path.splitext(filename)[0]
|
||||
|
||||
# Get the file path to hash
|
||||
file_path = resolved_path if resolved_path else None
|
||||
|
||||
if not file_path and category:
|
||||
# Try to find the file in the model directories using folder_paths
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
# Map category to folder_paths type
|
||||
category_map = {
|
||||
"loras": "loras",
|
||||
"checkpoints": "checkpoints",
|
||||
"vae": "vae",
|
||||
"controlnet": "controlnet",
|
||||
"upscale_models": "upscale_models",
|
||||
}
|
||||
folder_type = category_map.get(
|
||||
category.lower(), category.lower()
|
||||
)
|
||||
file_path = folder_paths.get_full_path(
|
||||
folder_type, filename
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If not found, try scanner
|
||||
if not file_path:
|
||||
try:
|
||||
from .core.scanner import get_model_files
|
||||
|
||||
available_models = get_model_files()
|
||||
for m in available_models:
|
||||
if (
|
||||
m.get("relative_path", "").endswith(filename)
|
||||
or m.get("filename", "") == filename
|
||||
):
|
||||
file_path = m.get("path")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Search CivitAI for the model using hash
|
||||
if download_available and file_path and _os.path.exists(file_path):
|
||||
try:
|
||||
from .core.sources.civitai import (
|
||||
get_model_info_for_file,
|
||||
)
|
||||
|
||||
result = get_model_info_for_file(file_path)
|
||||
if result and result.get("url"):
|
||||
return web.json_response(
|
||||
{
|
||||
"filename": filename,
|
||||
"url": result.get("url"),
|
||||
"version_url": result.get("version_url"),
|
||||
"model_id": result.get("model_id"),
|
||||
"model_name": result.get(
|
||||
"model_name", clean_name
|
||||
),
|
||||
"model_type": result.get("model_type", ""),
|
||||
"version_id": result.get("version_id"),
|
||||
"version_name": result.get("version_name", ""),
|
||||
"sha256": result.get("sha256"),
|
||||
"base_model": result.get("base_model"),
|
||||
"tags": result.get("tags", []),
|
||||
"trained_words": result.get(
|
||||
"trained_words", []
|
||||
),
|
||||
"images": result.get("images", []),
|
||||
"clip_skip": result.get("clip_skip"),
|
||||
"description": result.get("description", ""),
|
||||
"model_description": result.get(
|
||||
"model_description", ""
|
||||
),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"CivitAI search error: {e}")
|
||||
|
||||
# No result found - try fallback to filename search
|
||||
if download_available:
|
||||
try:
|
||||
from .core.sources.civitai import (
|
||||
search_civitai_for_file,
|
||||
)
|
||||
|
||||
result = search_civitai_for_file(filename)
|
||||
if result and result.get("url"):
|
||||
return web.json_response(
|
||||
{
|
||||
"url": result["url"],
|
||||
"model_name": result.get("name", clean_name),
|
||||
"version_id": result.get("version_id"),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"CivitAI fallback search error: {e}")
|
||||
|
||||
# No result found
|
||||
return web.json_response({"url": None})
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Model Linker civitai-search error: {e}", exc_info=True
|
||||
)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
# ==================== DOWNLOAD ROUTES ====================
|
||||
|
||||
if download_available:
|
||||
|
||||
+598
-173
@@ -6,7 +6,9 @@ Search and download models from CivitAI.
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
import hashlib
|
||||
import requests
|
||||
from typing import Dict, Any, Optional, List
|
||||
from urllib.parse import urlparse, parse_qs, quote
|
||||
@@ -18,6 +20,7 @@ CIVITAI_API_URL = "https://civitai.com/api/v1"
|
||||
# Cache for search results and URN resolutions
|
||||
_search_cache: Dict[str, Any] = {}
|
||||
_urn_cache: Dict[tuple[int, int], Dict[str, Any]] = {}
|
||||
_hash_cache: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
|
||||
def parse_civitai_url(url: str) -> Optional[Dict[str, Any]]:
|
||||
@@ -25,22 +28,22 @@ 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:
|
||||
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 "/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)
|
||||
return {"version_id": int(match.group(1))}
|
||||
|
||||
match = re.search(r"/models/(\d+)", parsed.path)
|
||||
if match:
|
||||
result = {'model_id': int(match.group(1))}
|
||||
result = {"model_id": int(match.group(1))}
|
||||
query = parse_qs(parsed.query)
|
||||
if 'modelVersionId' in query:
|
||||
result['version_id'] = int(query['modelVersionId'][0])
|
||||
if "modelVersionId" in query:
|
||||
result["version_id"] = int(query["modelVersionId"][0])
|
||||
return result
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -59,117 +62,122 @@ def clean_filename_for_search(filename: str) -> str:
|
||||
"""
|
||||
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)
|
||||
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)
|
||||
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,
|
||||
exact_only: bool = False
|
||||
filename: str, api_key: Optional[str] = None, exact_only: bool = False
|
||||
) -> 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
|
||||
exact_only: If True, only return exact filename matches (for downloads).
|
||||
If False, also try partial matching (for local file resolution).
|
||||
|
||||
|
||||
Returns:
|
||||
Dict with download info if found, None otherwise
|
||||
"""
|
||||
global _search_cache
|
||||
|
||||
|
||||
cache_key = f"civit_{filename}_exact{exact_only}"
|
||||
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}'
|
||||
|
||||
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', [])
|
||||
|
||||
items = data.get("items", [])
|
||||
|
||||
filename_base = os.path.splitext(filename.lower())[0]
|
||||
|
||||
|
||||
for item in items:
|
||||
model_id = item.get('id')
|
||||
model_name = item.get('name', '')
|
||||
model_type = item.get('type', '')
|
||||
|
||||
model_versions = item.get('modelVersions', [])
|
||||
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', [])
|
||||
|
||||
version_id = version.get("id")
|
||||
files = version.get("files", [])
|
||||
|
||||
for file_info in files:
|
||||
file_name = file_info.get('name', '')
|
||||
file_name = file_info.get("name", "")
|
||||
file_base = os.path.splitext(file_name.lower())[0]
|
||||
|
||||
|
||||
# Check for exact filename match (case-insensitive) - always try this
|
||||
if file_name.lower() == filename.lower():
|
||||
download_url = file_info.get('downloadUrl', '')
|
||||
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,
|
||||
'match_type': 'exact'
|
||||
"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,
|
||||
"match_type": "exact",
|
||||
}
|
||||
_search_cache[cache_key] = result
|
||||
logger.info(f"Found {filename} on CivitAI: {model_name}")
|
||||
return result
|
||||
|
||||
|
||||
# Check for partial match (filename_base in file_base or vice versa)
|
||||
# Skip partial matches if exact_only is True - prevents confusing
|
||||
# users with wrong model suggestions for downloads
|
||||
if not exact_only:
|
||||
if filename_base in file_base or file_base in filename_base:
|
||||
download_url = file_info.get('downloadUrl', '')
|
||||
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,
|
||||
'match_type': 'partial'
|
||||
"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,
|
||||
"match_type": "partial",
|
||||
}
|
||||
_search_cache[cache_key] = result
|
||||
logger.info(f"Found similar file for {filename} on CivitAI: {model_name}")
|
||||
logger.info(
|
||||
f"Found similar file for {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
|
||||
@@ -179,221 +187,638 @@ def search_civitai(
|
||||
query: str,
|
||||
model_type: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
api_key: Optional[str] = None
|
||||
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',
|
||||
"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'
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
params["types"] = civitai_type
|
||||
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers['Authorization'] = f'Bearer {api_key}'
|
||||
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
response = requests.get(
|
||||
f"{CIVITAI_API_URL}/models",
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=15
|
||||
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', [])
|
||||
|
||||
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', [])
|
||||
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':
|
||||
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),
|
||||
"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
|
||||
|
||||
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
|
||||
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}'
|
||||
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
response = requests.get(
|
||||
f"{CIVITAI_API_URL}/model-versions/by-hash/{hash_value}",
|
||||
headers=headers,
|
||||
timeout=15
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
model_id = data.get('modelId')
|
||||
version_id = data.get('id')
|
||||
files = data.get('files', [])
|
||||
|
||||
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
|
||||
"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
|
||||
|
||||
|
||||
def resolve_urn(
|
||||
model_id: int,
|
||||
version_id: int,
|
||||
api_key: Optional[str] = None
|
||||
model_id: int, version_id: int, api_key: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Resolve URN model_id/version_id to model info and expected filename.
|
||||
|
||||
|
||||
Args:
|
||||
model_id: CivitAI model ID
|
||||
version_id: CivitAI version ID
|
||||
version_id: CivitAI version ID
|
||||
api_key: Optional API key
|
||||
|
||||
|
||||
Returns:
|
||||
Dict with model name and primary filename, or None
|
||||
"""
|
||||
cache_key = (model_id, version_id)
|
||||
if cache_key in _urn_cache:
|
||||
return _urn_cache[cache_key]
|
||||
|
||||
|
||||
try:
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers['Authorization'] = f'Bearer {api_key}'
|
||||
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
url = f"{CIVITAI_API_URL}/models/{model_id}"
|
||||
params = {'modelVersionId': version_id}
|
||||
|
||||
params = {"modelVersionId": version_id}
|
||||
|
||||
response = requests.get(url, headers=headers, params=params, timeout=15)
|
||||
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"CivitAI URN resolve failed: {response.status_code}")
|
||||
_urn_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
|
||||
data = response.json()
|
||||
versions = data.get('modelVersions', [])
|
||||
|
||||
versions = data.get("modelVersions", [])
|
||||
|
||||
if not versions:
|
||||
logger.warning(f"No versions found for model {model_id}/version {version_id}")
|
||||
logger.warning(
|
||||
f"No versions found for model {model_id}/version {version_id}"
|
||||
)
|
||||
_urn_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
|
||||
# Get specific version
|
||||
target_version = None
|
||||
for v in versions:
|
||||
if v.get('id') == version_id:
|
||||
if v.get("id") == version_id:
|
||||
target_version = v
|
||||
break
|
||||
|
||||
|
||||
if not target_version:
|
||||
logger.warning(f"Version {version_id} not found in model {model_id}")
|
||||
_urn_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
files = target_version.get('files', [])
|
||||
|
||||
files = target_version.get("files", [])
|
||||
primary_file = None
|
||||
|
||||
|
||||
# Prefer primary file or type=='Model'
|
||||
for f in files:
|
||||
if f.get('primary') or f.get('type') == 'Model':
|
||||
if f.get("primary") or f.get("type") == "Model":
|
||||
primary_file = f
|
||||
break
|
||||
|
||||
|
||||
if not primary_file and files:
|
||||
primary_file = files[0] # Fallback to first
|
||||
|
||||
|
||||
if not primary_file:
|
||||
logger.warning(f"No files found for version {version_id}")
|
||||
_urn_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
|
||||
result = {
|
||||
'model_name': data.get('name', 'Unknown'),
|
||||
'version_name': target_version.get('name', 'Unknown'),
|
||||
'expected_filename': primary_file.get('name', 'Unknown'),
|
||||
'files': [{'name': f.get('name'), 'size': f.get('sizeKB', 0)*1024} for f in files]
|
||||
"model_name": data.get("name", "Unknown"),
|
||||
"version_name": target_version.get("name", "Unknown"),
|
||||
"expected_filename": primary_file.get("name", "Unknown"),
|
||||
"files": [
|
||||
{"name": f.get("name"), "size": f.get("sizeKB", 0) * 1024}
|
||||
for f in files
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
_urn_cache[cache_key] = result
|
||||
logger.info(f"Resolved URN model {model_id}@{version_id} → {result['expected_filename']}")
|
||||
logger.info(
|
||||
f"Resolved URN model {model_id}@{version_id} → {result['expected_filename']}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CivitAI URN resolve error for {model_id}@{version_id}: {e}")
|
||||
_urn_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
|
||||
def _get_sha256_hash(file_path: str) -> Optional[str]:
|
||||
"""
|
||||
Compute sha256 hash of a file by reading it in chunks.
|
||||
|
||||
Args:
|
||||
file_path: Full path to the file
|
||||
|
||||
Returns:
|
||||
SHA256 hash as hex string, or None if file doesn't exist
|
||||
"""
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return None
|
||||
|
||||
BUF_SIZE = 1024 * 128 # 128KB chunks
|
||||
sha256_hash = hashlib.sha256()
|
||||
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
for byte_block in iter(lambda: f.read(BUF_SIZE), b""):
|
||||
sha256_hash.update(byte_block)
|
||||
return sha256_hash.hexdigest()
|
||||
except Exception as e:
|
||||
logger.error(f"Error computing hash for {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_model_info_by_hash(
|
||||
file_hash: str, api_key: Optional[str] = None, use_cache: bool = True
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Look up a model on CivitAI using its sha256 hash.
|
||||
Uses the CivitAI API endpoint: /api/v1/model-versions/by-hash/{hash}
|
||||
|
||||
Args:
|
||||
file_hash: SHA256 hash of the model file
|
||||
api_key: Optional CivitAI API key
|
||||
use_cache: Whether to use cached results
|
||||
|
||||
Returns:
|
||||
Dict with model info from CivitAI, or None if not found
|
||||
"""
|
||||
global _hash_cache
|
||||
|
||||
if not file_hash:
|
||||
return None
|
||||
|
||||
cache_key = f"hash_{file_hash}"
|
||||
|
||||
if use_cache and cache_key in _hash_cache:
|
||||
return _hash_cache[cache_key]
|
||||
|
||||
api_url = f"{CIVITAI_API_URL}/model-versions/by-hash/{file_hash}"
|
||||
|
||||
try:
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
response = requests.get(api_url, headers=headers, timeout=15)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# Extract useful info from the response
|
||||
model_info = data.get("model", {})
|
||||
version_info = data
|
||||
|
||||
# Get model_id from top level or nested model object
|
||||
model_id = data.get("modelId") or model_info.get("id")
|
||||
|
||||
# Extract images with metadata
|
||||
images = _extract_model_images(version_info)
|
||||
|
||||
result = {
|
||||
"source": "civitai",
|
||||
"model_id": model_id,
|
||||
"model_name": model_info.get("name"),
|
||||
"model_type": model_info.get("type"),
|
||||
"version_id": version_info.get("id"),
|
||||
"version_name": version_info.get("name"),
|
||||
"sha256": file_hash,
|
||||
"url": f"https://civitai.com/models/{model_id}" if model_id else None,
|
||||
"version_url": f"https://civitai.com/models/{model_id}?modelVersionId={version_info.get('id')}"
|
||||
if model_id
|
||||
else None,
|
||||
"download_url": version_info.get("downloadUrl"),
|
||||
"base_model": version_info.get("baseModel"),
|
||||
"tags": model_info.get("tags", []),
|
||||
"trained_words": _extract_trained_words(version_info),
|
||||
"images": images,
|
||||
"clip_skip": version_info.get("clipSkip"),
|
||||
"description": version_info.get("description", ""),
|
||||
"model_description": model_info.get("description", ""),
|
||||
}
|
||||
|
||||
_hash_cache[cache_key] = result
|
||||
logger.info(f"Found model by hash {file_hash}: {result.get('model_name')}")
|
||||
return result
|
||||
|
||||
elif response.status_code == 404:
|
||||
logger.info(f"Model not found on CivitAI for hash {file_hash}")
|
||||
_hash_cache[cache_key] = None
|
||||
return None
|
||||
else:
|
||||
logger.warning(
|
||||
f"CivitAI hash lookup returned {response.status_code} for {file_hash}"
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error looking up model by hash {file_hash}: {e}")
|
||||
return None
|
||||
|
||||
cache_key = f"hash_{file_hash}"
|
||||
|
||||
if use_cache and cache_key in _hash_cache:
|
||||
return _hash_cache[cache_key]
|
||||
|
||||
api_url = f"{CIVITAI_API_URL}/model-versions/by-hash/{file_hash}"
|
||||
|
||||
try:
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
response = requests.get(api_url, headers=headers, timeout=15)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# Extract useful info from the response
|
||||
model_info = data.get("model", {})
|
||||
version_info = data
|
||||
|
||||
# Get model_id from top level or nested model object
|
||||
model_id = data.get("modelId") or model_info.get("id")
|
||||
|
||||
# Extract images with metadata
|
||||
images = _extract_model_images(version_info)
|
||||
|
||||
result = {
|
||||
"source": "civitai",
|
||||
"model_id": model_id,
|
||||
"model_name": model_info.get("name"),
|
||||
"model_type": model_info.get("type"),
|
||||
"version_id": version_info.get("id"),
|
||||
"version_name": version_info.get("name"),
|
||||
"sha256": file_hash,
|
||||
"url": f"https://civitai.com/models/{model_id}" if model_id else None,
|
||||
"version_url": f"https://civitai.com/models/{model_id}?modelVersionId={version_info.get('id')}"
|
||||
if model_id
|
||||
else None,
|
||||
"download_url": version_info.get("downloadUrl"),
|
||||
"base_model": version_info.get("baseModel"),
|
||||
"tags": model_info.get("tags", []),
|
||||
"trained_words": _extract_trained_words(version_info),
|
||||
"images": images,
|
||||
"clip_skip": version_info.get("clipSkip"),
|
||||
"description": version_info.get("description", ""),
|
||||
"model_description": model_info.get("description", ""),
|
||||
}
|
||||
|
||||
_hash_cache[cache_key] = result
|
||||
logger.info(f"Found model by hash {file_hash}: {result.get('model_name')}")
|
||||
return result
|
||||
|
||||
elif response.status_code == 404:
|
||||
logger.info(f"Model not found on CivitAI for hash {file_hash}")
|
||||
_hash_cache[cache_key] = None
|
||||
return None
|
||||
else:
|
||||
logger.warning(
|
||||
f"CivitAI hash lookup returned {response.status_code} for {file_hash}"
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error looking up model by hash {file_hash}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _extract_trained_words(version_info: Dict[str, Any]) -> List[str]:
|
||||
"""
|
||||
Extract trained words/phrases from model version info.
|
||||
"""
|
||||
trained_words = []
|
||||
|
||||
# Try to get from metadata
|
||||
metadata = version_info.get("trainedWords", [])
|
||||
if isinstance(metadata, list):
|
||||
trained_words.extend(metadata)
|
||||
elif isinstance(metadata, str) and metadata:
|
||||
trained_words.append(metadata)
|
||||
|
||||
# Also check metadata field
|
||||
model = version_info.get("model", {})
|
||||
if isinstance(model, dict):
|
||||
model_tags = model.get("tags", [])
|
||||
if isinstance(model_tags, list):
|
||||
for tag in model_tags:
|
||||
if tag not in trained_words:
|
||||
trained_words.append(tag)
|
||||
|
||||
return trained_words
|
||||
|
||||
|
||||
def _extract_model_images(version_info: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Extract images with metadata from model version info.
|
||||
Each image can have: url, civitaiUrl, seed, steps, cfg, sampler, model, positive, negative
|
||||
"""
|
||||
images = []
|
||||
|
||||
# Get images from modelVersions or direct images field
|
||||
images_data = version_info.get("images", [])
|
||||
if not images_data:
|
||||
# Check nested modelVersions
|
||||
model_versions = version_info.get("modelVersions", [])
|
||||
if model_versions and len(model_versions) > 0:
|
||||
images_data = model_versions[0].get("images", [])
|
||||
|
||||
for img in images_data:
|
||||
if not isinstance(img, dict):
|
||||
continue
|
||||
|
||||
# Get the image URL
|
||||
img_url = img.get("url", "")
|
||||
if not img_url:
|
||||
continue
|
||||
|
||||
# Get metadata from image info (may be nested in 'meta' object)
|
||||
meta = img.get("meta", {})
|
||||
|
||||
img_info = {
|
||||
"url": img_url,
|
||||
"civitaiUrl": img.get("civitaiUrl", ""),
|
||||
"seed": img.get("seed") or meta.get("seed"),
|
||||
"steps": img.get("steps") or meta.get("steps"),
|
||||
"cfg": img.get("cfg") or meta.get("cfg"),
|
||||
"sampler": img.get("sampler") or meta.get("sampler"),
|
||||
"model": img.get("model") or meta.get("model"),
|
||||
"positive": img.get("positive") or meta.get("prompt"),
|
||||
"negative": img.get("negative") or meta.get("negative_prompt"),
|
||||
}
|
||||
|
||||
# Only add if we have at least a URL
|
||||
if img_info["url"]:
|
||||
images.append(img_info)
|
||||
|
||||
return images
|
||||
|
||||
|
||||
def _get_metadata_file_path(model_path: str) -> str:
|
||||
"""
|
||||
Get the path to the metadata file for a model.
|
||||
For example, for 'model.safetensors', it returns 'model.metadata.json'
|
||||
Also checks for variations without extension or with different extensions.
|
||||
"""
|
||||
if not model_path:
|
||||
return ""
|
||||
|
||||
# Get directory and filename
|
||||
directory = os.path.dirname(model_path)
|
||||
filename = os.path.basename(model_path)
|
||||
|
||||
# Try different variations of the metadata file name
|
||||
base_name = filename.rsplit(".", 1)[0] if "." in filename else filename
|
||||
|
||||
possible_names = [
|
||||
base_name + ".metadata.json",
|
||||
filename + ".metadata.json",
|
||||
base_name + ".json",
|
||||
filename.replace("_", " ").split()[0] + ".metadata.json"
|
||||
if "_" in base_name
|
||||
else None,
|
||||
]
|
||||
|
||||
for name in possible_names:
|
||||
if name:
|
||||
path = os.path.join(directory, name)
|
||||
if os.path.exists(path):
|
||||
logger.info(f"Found metadata file: {path}")
|
||||
return path
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _read_model_metadata(metadata_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Read model metadata from a JSON file.
|
||||
Returns the metadata if found and valid, None otherwise.
|
||||
"""
|
||||
if not metadata_path or not os.path.exists(metadata_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Validate it's a valid metadata file with CivitAI info
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
# Check if it has the needed info
|
||||
if not data.get("sha256") and not data.get("civitai"):
|
||||
return None
|
||||
|
||||
logger.info(f"Successfully read metadata from: {metadata_path}")
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.debug(f"Error reading metadata file {metadata_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _metadata_to_model_info(metadata: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert metadata file format to model info format used by our extension.
|
||||
"""
|
||||
civitai_data = metadata.get("civitai") or {}
|
||||
|
||||
# Extract images with metadata
|
||||
images = []
|
||||
for img in civitai_data.get("images") or []:
|
||||
if isinstance(img, dict) and img.get("url"):
|
||||
img_meta = img.get("meta") or {}
|
||||
images.append(
|
||||
{
|
||||
"url": img.get("url", ""),
|
||||
"civitaiUrl": img.get("civitaiUrl", "")
|
||||
or f"https://civitai.com/images/{img.get('id')}",
|
||||
"seed": img_meta.get("seed"),
|
||||
"steps": img_meta.get("steps"),
|
||||
"cfg": img_meta.get("cfg"),
|
||||
"sampler": img_meta.get("sampler"),
|
||||
"positive": img_meta.get("prompt"),
|
||||
}
|
||||
)
|
||||
|
||||
# Get trained words from CivitAI data
|
||||
trained_words = civitai_data.get("trainedWords") or []
|
||||
if isinstance(trained_words, str):
|
||||
trained_words = [trained_words] if trained_words else []
|
||||
|
||||
# Get model info
|
||||
model_info = civitai_data.get("model") or {}
|
||||
version_info = civitai_data
|
||||
|
||||
# Build model_id from CivitAI data
|
||||
model_id = civitai_data.get("modelId") or civitai_data.get("id")
|
||||
|
||||
return {
|
||||
"source": "metadata",
|
||||
"model_id": model_id,
|
||||
"model_name": metadata.get("model_name") or metadata.get("file_name", ""),
|
||||
"model_type": model_info.get("type", "") or civitai_data.get("type", ""),
|
||||
"version_id": civitai_data.get("id"),
|
||||
"version_name": civitai_data.get("name", ""),
|
||||
"sha256": metadata.get("sha256", ""),
|
||||
"url": f"https://civitai.com/models/{civitai_data.get('modelId')}"
|
||||
if civitai_data.get("modelId")
|
||||
else None,
|
||||
"version_url": f"https://civitai.com/models/{civitai_data.get('modelId')}?modelVersionId={civitai_data.get('id')}"
|
||||
if model_id
|
||||
else None,
|
||||
"download_url": civitai_data.get("downloadUrl"),
|
||||
"base_model": (metadata.get("base_model") or civitai_data.get("baseModel", "")),
|
||||
"tags": metadata.get("tags") or [],
|
||||
"trained_words": trained_words,
|
||||
"images": images,
|
||||
"clip_skip": civitai_data.get("clipSkip"),
|
||||
"description": metadata.get("model_description", "")
|
||||
or civitai_data.get("description", ""),
|
||||
"model_description": metadata.get("model_description", ""),
|
||||
"from_metadata": True,
|
||||
}
|
||||
|
||||
|
||||
def get_model_info_for_file(
|
||||
file_path: str, api_key: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get model info from CivitAI by computing file hash and looking up by hash.
|
||||
First checks if there's a metadata file in the same folder.
|
||||
|
||||
Args:
|
||||
file_path: Full path to the model file
|
||||
api_key: Optional CivitAI API key
|
||||
|
||||
Returns:
|
||||
Dict with model info from CivitAI, or None if not found
|
||||
"""
|
||||
logger.info(f"get_model_info_for_file called with: {file_path}")
|
||||
|
||||
# First check for metadata file
|
||||
metadata_path = _get_metadata_file_path(file_path)
|
||||
logger.info(f"Looking for metadata file, checked: {metadata_path}")
|
||||
|
||||
if metadata_path:
|
||||
metadata = _read_model_metadata(metadata_path)
|
||||
if metadata:
|
||||
logger.info(f"Using metadata file for {file_path}")
|
||||
return _metadata_to_model_info(metadata)
|
||||
else:
|
||||
# Debug: list all files in the same directory
|
||||
directory = os.path.dirname(file_path)
|
||||
if directory and os.path.exists(directory):
|
||||
files = os.listdir(directory)
|
||||
metadata_files = [
|
||||
f for f in files if "metadata" in f.lower() or f.endswith(".json")
|
||||
]
|
||||
logger.info(f"Files in {directory}: {metadata_files}")
|
||||
|
||||
# If no metadata file, compute hash and look up on CivitAI
|
||||
file_hash = _get_sha256_hash(file_path)
|
||||
if not file_hash:
|
||||
return None
|
||||
|
||||
return get_model_info_by_hash(file_hash, api_key)
|
||||
|
||||
+821
-4
@@ -43,6 +43,35 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
}
|
||||
});
|
||||
|
||||
// Create context menu for model chips
|
||||
this.contextMenu = $el("div.ml-context-menu", {
|
||||
parent: document.body,
|
||||
style: {
|
||||
position: "fixed",
|
||||
display: "none",
|
||||
zIndex: "100001"
|
||||
}
|
||||
}, [
|
||||
$el("div.ml-context-menu-item", {
|
||||
onclick: () => this.handleContextMenuAction('showInfo'),
|
||||
style: { cursor: "pointer" }
|
||||
}, [
|
||||
$el("span.ml-context-menu-item-icon", { textContent: "ℹ" }),
|
||||
$el("span", { textContent: "Show Info" })
|
||||
]),
|
||||
$el("div.ml-context-menu-divider"),
|
||||
$el("div.ml-context-menu-item", {
|
||||
onclick: () => this.handleContextMenuAction('civitai'),
|
||||
style: { cursor: "pointer" }
|
||||
}, [
|
||||
$el("span.ml-context-menu-item-icon", { textContent: "🌐" }),
|
||||
$el("span", { textContent: "Open in CivitAI" })
|
||||
])
|
||||
]);
|
||||
|
||||
// Selected model for context menu
|
||||
this._contextMenuModel = null;
|
||||
|
||||
// Create dialog element using $el
|
||||
this.element = $el("div.comfy-modal", {
|
||||
id: "model-linker-modal",
|
||||
@@ -78,6 +107,466 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
|
||||
// Add click listener to backdrop
|
||||
this.backdrop.addEventListener('click', () => this.close());
|
||||
|
||||
// Add click listener to hide context menu when clicking outside
|
||||
this.boundHandleContextMenuClick = (e) => this.handleContextMenuOutsideClick(e);
|
||||
document.addEventListener('click', this.boundHandleContextMenuClick);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click outside context menu to hide it
|
||||
*/
|
||||
handleContextMenuOutsideClick(e) {
|
||||
if (!this.contextMenu) return;
|
||||
if (this.contextMenu.style.display === 'none') return;
|
||||
|
||||
// Check if click is outside the context menu
|
||||
if (!this.contextMenu.contains(e.target)) {
|
||||
this.hideContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show context menu at the specified position
|
||||
*/
|
||||
showContextMenu(x, y, model) {
|
||||
if (!this.contextMenu) return;
|
||||
|
||||
this._contextMenuModel = model;
|
||||
|
||||
// Position the menu
|
||||
this.contextMenu.style.left = `${x}px`;
|
||||
this.contextMenu.style.top = `${y}px`;
|
||||
this.contextMenu.style.display = 'block';
|
||||
|
||||
// Adjust position if menu would go off screen
|
||||
const rect = this.contextMenu.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth) {
|
||||
this.contextMenu.style.left = `${window.innerWidth - rect.width - 10}px`;
|
||||
}
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
this.contextMenu.style.top = `${window.innerHeight - rect.height - 10}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide context menu
|
||||
*/
|
||||
hideContextMenu() {
|
||||
if (!this.contextMenu) return;
|
||||
this.contextMenu.style.display = 'none';
|
||||
this._contextMenuModel = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle context menu item click
|
||||
*/
|
||||
handleContextMenuAction(action) {
|
||||
const model = this._contextMenuModel;
|
||||
this.hideContextMenu();
|
||||
|
||||
if (!model) return;
|
||||
|
||||
if (action === 'civitai') {
|
||||
this.openInCivitAI(model);
|
||||
} else if (action === 'showInfo') {
|
||||
this.showModelInfo(model);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open model in CivitAI
|
||||
*/
|
||||
async openInCivitAI(model) {
|
||||
if (!model) return;
|
||||
|
||||
const name = model.name || model.original_path?.split(/[\/\\]/).pop() || '';
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
// Search CivitAI for this model using hash (pass resolved_path for hash lookup)
|
||||
const response = await api.fetchApi('/model_linker/civitai-search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filename: name,
|
||||
category: model.category,
|
||||
resolved_path: model.resolved_path || ''
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.showNotification('Nie znaleziono modelu na CivitAI', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.url) {
|
||||
window.open(data.url, '_blank');
|
||||
} else {
|
||||
// Try direct search on CivitAI
|
||||
const searchName = name.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
|
||||
const searchUrl = `https://civitai.com/search?q=${encodeURIComponent(searchName)}`;
|
||||
window.open(searchUrl, '_blank');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Model Linker: Error searching CivitAI:', e);
|
||||
// Fall back to direct search
|
||||
const searchName = name.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
|
||||
const searchUrl = `https://civitai.com/search?q=${encodeURIComponent(searchName)}`;
|
||||
window.open(searchUrl, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show model info dialog (similar to rgthree's RgthreeLoraInfoDialog)
|
||||
*/
|
||||
async showModelInfo(model) {
|
||||
if (!model) return;
|
||||
|
||||
const name = model.name || model.original_path?.split(/[\/\\]/).pop() || '';
|
||||
if (!name) return;
|
||||
|
||||
// Create and show the info dialog
|
||||
this.showModelInfoDialog(name, model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the model info dialog
|
||||
*/
|
||||
showModelInfoDialog(loraName, modelData) {
|
||||
// Create info dialog element
|
||||
const dialog = this.createInfoDialog(loraName, modelData);
|
||||
|
||||
// Show the dialog
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
// Add close handlers
|
||||
const closeBtn = dialog.querySelector('.ml-info-dialog-close');
|
||||
const footerCloseBtn = dialog.querySelector('.ml-info-dialog-close-btn');
|
||||
const backdrop = dialog.querySelector('.ml-info-dialog-backdrop');
|
||||
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => this.closeInfoDialog(dialog));
|
||||
}
|
||||
if (footerCloseBtn) {
|
||||
footerCloseBtn.addEventListener('click', () => this.closeInfoDialog(dialog));
|
||||
}
|
||||
if (backdrop) {
|
||||
backdrop.addEventListener('click', (e) => {
|
||||
// Only close if clicking backdrop itself, not its children
|
||||
if (e.target === backdrop) {
|
||||
this.closeInfoDialog(dialog);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch CivitAI info
|
||||
this.fetchModelInfoForDialog(loraName, modelData, dialog);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the info dialog element
|
||||
*/
|
||||
createInfoDialog(loraName, modelData) {
|
||||
const loraDisplayName = loraName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'ml-info-dialog-backdrop';
|
||||
dialog.innerHTML = `
|
||||
<div class="ml-info-dialog">
|
||||
<div class="ml-info-dialog-header">
|
||||
<h3 class="ml-info-dialog-title">${loraDisplayName}</h3>
|
||||
<button class="ml-info-dialog-close">×</button>
|
||||
</div>
|
||||
<div class="ml-info-dialog-content">
|
||||
<div class="ml-info-dialog-loading">Loading...</div>
|
||||
<div class="ml-info-dialog-body" style="display: none;">
|
||||
<div class="ml-info-area">
|
||||
<span class="ml-info-tag ml-info-type"></span>
|
||||
<span class="ml-info-tag ml-info-basemodel"></span>
|
||||
</div>
|
||||
<table class="ml-info-table">
|
||||
<tbody>
|
||||
<tr class="ml-info-file-row">
|
||||
<td><span>File <span class="ml-info-help" title=""></span></span></td>
|
||||
<td><span class="ml-info-file"></span></td>
|
||||
</tr>
|
||||
<tr class="ml-info-hash-row">
|
||||
<td><span>Hash (sha256) <span class="ml-info-help" title=""></span></span></td>
|
||||
<td><span class="ml-info-hash"></span></td>
|
||||
</tr>
|
||||
<tr class="ml-info-civitai-row">
|
||||
<td><span>CivitAI <span class="ml-info-help" title=""></span></span></td>
|
||||
<td><span class="ml-info-civitai-link"></span></td>
|
||||
</tr>
|
||||
<tr class="ml-info-name-row">
|
||||
<td><span>Name <span class="ml-info-help" title="The name for display."></span></span></td>
|
||||
<td><span class="ml-info-name"></span></td>
|
||||
</tr>
|
||||
<tr class="ml-info-basemodel-row">
|
||||
<td><span>Base Model <span class="ml-info-help" title=""></span></span></td>
|
||||
<td><span class="ml-info-base-model"></span></td>
|
||||
</tr>
|
||||
<tr class="ml-info-trainedwords-row" style="display: none;">
|
||||
<td><span>Trained Words <span class="ml-info-help" title="Trigger words/phrases for this Lora."></span></span></td>
|
||||
<td><span class="ml-info-trained-words"></span></td>
|
||||
</tr>
|
||||
<tr class="ml-info-clipskip-row" style="display: none;">
|
||||
<td><span>Clip Skip <span class="ml-info-help" title="Recommended clip skip value."></span></span></td>
|
||||
<td><span class="ml-info-clip-skip"></span></td>
|
||||
</tr>
|
||||
<tr class="ml-info-description-row" style="display: none;">
|
||||
<td><span>Description <span class="ml-info-help" title="Model description from CivitAI."></span></span></td>
|
||||
<td><span class="ml-info-description"></span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="ml-info-images"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-info-dialog-footer">
|
||||
<button class="ml-btn ml-btn-secondary ml-info-dialog-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch model info and update the dialog
|
||||
*/
|
||||
async fetchModelInfoForDialog(loraName, modelData, dialog) {
|
||||
try {
|
||||
const response = await api.fetchApi('/model_linker/civitai-search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filename: loraName,
|
||||
category: modelData?.category || '',
|
||||
resolved_path: modelData?.resolved_path || ''
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.updateInfoDialogWithData(dialog, data);
|
||||
} else {
|
||||
this.updateInfoDialogError(dialog, 'Model not found on CivitAI');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Model Linker: Error fetching model info:', e);
|
||||
this.updateInfoDialogError(dialog, 'Error fetching info');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the info dialog with data
|
||||
*/
|
||||
updateInfoDialogWithData(dialog, data) {
|
||||
const loadingDiv = dialog.querySelector('.ml-info-dialog-loading');
|
||||
const bodyDiv = dialog.querySelector('.ml-info-dialog-body');
|
||||
|
||||
if (loadingDiv) loadingDiv.style.display = 'none';
|
||||
if (bodyDiv) bodyDiv.style.display = 'block';
|
||||
|
||||
if (!data) {
|
||||
this.updateInfoDialogError(dialog, 'No data received');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update title
|
||||
const titleEl = dialog.querySelector('.ml-info-dialog-title');
|
||||
if (titleEl) {
|
||||
const modelName = data.model_name || data.modelName || 'Unknown Model';
|
||||
const versionName = data.version_name || data.versionName || '';
|
||||
titleEl.textContent = versionName ? `${modelName} - ${versionName}` : modelName;
|
||||
}
|
||||
|
||||
// Update type tag
|
||||
const typeTag = dialog.querySelector('.ml-info-type');
|
||||
if (typeTag) {
|
||||
const modelType = data.model_type || data.modelType || '';
|
||||
typeTag.textContent = modelType.toUpperCase();
|
||||
typeTag.className = `ml-info-tag ml-info-type -type-${modelType.toLowerCase()}`;
|
||||
}
|
||||
|
||||
// Update base model tag
|
||||
const baseModelTag = dialog.querySelector('.ml-info-basemodel');
|
||||
if (baseModelTag) {
|
||||
const baseModel = data.base_model || data.baseModel || '';
|
||||
baseModelTag.textContent = baseModel || '';
|
||||
if (baseModel) {
|
||||
baseModelTag.style.display = '';
|
||||
baseModelTag.className = `ml-info-tag ml-info-basemodel -basemodel-${baseModel.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
} else {
|
||||
baseModelTag.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Update file
|
||||
const fileEl = dialog.querySelector('.ml-info-file');
|
||||
if (fileEl && data.filename) {
|
||||
fileEl.textContent = data.filename;
|
||||
}
|
||||
|
||||
// Update hash
|
||||
const hashEl = dialog.querySelector('.ml-info-hash');
|
||||
if (hashEl) {
|
||||
hashEl.textContent = data.sha256 || data.hash || '';
|
||||
}
|
||||
|
||||
// Update CivitAI link
|
||||
const civitaiLinkEl = dialog.querySelector('.ml-info-civitai-link');
|
||||
if (civitaiLinkEl) {
|
||||
if (data.url || data.version_url) {
|
||||
const url = data.version_url || data.url;
|
||||
civitaiLinkEl.innerHTML = `
|
||||
<a href="${url}" target="_blank" class="ml-info-link">
|
||||
<svg viewBox="0 0 178 178" class="ml-info-civitai-logo">
|
||||
<defs>
|
||||
<linearGradient id="bgblue" gradientUnits="userSpaceOnUse" x1="89.3" y1="-665.5" x2="89.3" y2="-841.1" gradientTransform="matrix(1 0 0 -1 0 -664)">
|
||||
<stop offset="0" style="stop-color:#1284F7"></stop>
|
||||
<stop offset="1" style="stop-color:#0A20C9"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="#000" d="M13.3,45.4v87.7l76,43.9l76-43.9V45.4l-76-43.9L13.3,45.4z"></path>
|
||||
<path style="fill:url(#bgblue);" d="M89.3,29.2l52,30v60l-52,30l-52-30v-60L89.3,29.2 M89.3,1.5l-76,43.9v87.8l76,43.9l76-43.9V45.4L89.3,1.5z"></path>
|
||||
<path fill="#FFF" d="M104.1,97.2l-14.9,8.5l-14.9-8.5v-17l14.9-8.5l14.9,8.5h18.2V69.7l-33-19l-33,19v38.1l33,19l33-19V97.2H104.1z"></path>
|
||||
</svg>
|
||||
View on Civitai
|
||||
</a>
|
||||
`;
|
||||
} else {
|
||||
const searchName = data.model_name || data.modelName || 'Unknown';
|
||||
civitaiLinkEl.innerHTML = `
|
||||
<span class="ml-info-not-found">Model not found</span>
|
||||
<a href="https://civitai.com/search?q=${encodeURIComponent(searchName)}" target="_blank" class="ml-info-link">
|
||||
🔍 Search on CivitAI
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update name
|
||||
const nameEl = dialog.querySelector('.ml-info-name');
|
||||
if (nameEl) {
|
||||
nameEl.textContent = data.model_name || data.modelName || '';
|
||||
}
|
||||
|
||||
// Update base model row
|
||||
const baseModelRowEl = dialog.querySelector('.ml-info-base-model');
|
||||
if (baseModelRowEl) {
|
||||
const baseModel = data.base_model || data.baseModel || '';
|
||||
baseModelRowEl.textContent = baseModel;
|
||||
const row = baseModelRowEl.closest('tr');
|
||||
if (row && baseModel) {
|
||||
row.style.display = '';
|
||||
} else if (row) {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Update trained words
|
||||
const trainedWordsEl = dialog.querySelector('.ml-info-trained-words');
|
||||
if (trainedWordsEl) {
|
||||
const words = data.trained_words || data.trainedWords || [];
|
||||
if (Array.isArray(words) && words.length > 0) {
|
||||
trainedWordsEl.textContent = words.join(', ');
|
||||
const row = trainedWordsEl.closest('tr');
|
||||
if (row) row.style.display = '';
|
||||
} else {
|
||||
const row = trainedWordsEl.closest('tr');
|
||||
if (row) row.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Update clip skip
|
||||
const clipSkipEl = dialog.querySelector('.ml-info-clip-skip');
|
||||
if (clipSkipEl) {
|
||||
const clipSkip = data.clip_skip || data.clipSkip;
|
||||
if (clipSkip && clipSkip !== 'None') {
|
||||
clipSkipEl.textContent = clipSkip;
|
||||
const row = clipSkipEl.closest('tr');
|
||||
if (row) row.style.display = '';
|
||||
} else {
|
||||
const row = clipSkipEl.closest('tr');
|
||||
if (row) row.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Update description
|
||||
const descEl = dialog.querySelector('.ml-info-description');
|
||||
if (descEl) {
|
||||
const desc = data.description || data.model_description || data.modelDescription || '';
|
||||
if (desc) {
|
||||
descEl.textContent = desc.substring(0, 200) + (desc.length > 200 ? '...' : '');
|
||||
const row = descEl.closest('tr');
|
||||
if (row) row.style.display = '';
|
||||
} else {
|
||||
const row = descEl.closest('tr');
|
||||
if (row) row.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Update images
|
||||
this.updateInfoDialogImages(dialog, data.images || data.modelImages || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update images in the info dialog
|
||||
*/
|
||||
updateInfoDialogImages(dialog, images) {
|
||||
const imagesContainer = dialog.querySelector('.ml-info-images');
|
||||
if (!imagesContainer || !images.length) return;
|
||||
|
||||
let imagesHtml = '<div class="ml-info-images-header">Example Images</div><div class="ml-info-images-grid">';
|
||||
|
||||
for (const img of images.slice(0, 6)) {
|
||||
if (!img.url) continue;
|
||||
|
||||
let caption = '';
|
||||
if (img.civitaiUrl) {
|
||||
caption += `<a href="${img.civitaiUrl}" target="_blank" class="ml-info-image-link">civitai</a>`;
|
||||
}
|
||||
if (img.seed) caption += `<span><label>seed</label> ${img.seed}</span>`;
|
||||
if (img.steps) caption += `<span><label>steps</label> ${img.steps}</span>`;
|
||||
if (img.cfg) caption += `<span><label>cfg</label> ${img.cfg}</span>`;
|
||||
if (img.sampler) caption += `<span><label>sampler</label> ${img.sampler}</span>`;
|
||||
if (img.positive) caption += `<span><label>positive</label> ${img.positive.substring(0, 100)}${img.positive.length > 100 ? '...' : ''}</span>`;
|
||||
|
||||
imagesHtml += `
|
||||
<div class="ml-info-image-item">
|
||||
<figure>
|
||||
<img src="${img.url}" alt="Example" loading="lazy" />
|
||||
<figcaption>${caption}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
imagesHtml += '</div>';
|
||||
imagesContainer.innerHTML = imagesHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the info dialog with error
|
||||
*/
|
||||
updateInfoDialogError(dialog, message) {
|
||||
const civitaiLink = dialog.querySelector('.ml-info-civitai-link');
|
||||
if (civitaiLink) {
|
||||
civitaiLink.innerHTML = `<span class="ml-info-error">${message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the info dialog
|
||||
*/
|
||||
closeInfoDialog(dialog) {
|
||||
if (dialog && dialog.parentNode) {
|
||||
dialog.parentNode.removeChild(dialog);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -529,6 +1018,309 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
.ml-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Context menu */
|
||||
.ml-context-menu {
|
||||
position: fixed;
|
||||
background: var(--comfy-menu-bg, #202020);
|
||||
border: 1px solid var(--border-color, #555555);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||||
z-index: 100001;
|
||||
min-width: 160px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ml-context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--input-text, #e0e0e0);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.ml-context-menu-item:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
.ml-context-menu-item-icon {
|
||||
font-size: 14px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.ml-context-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color, #555555);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Model Info Dialog (similar to rgthree's RgthreeLoraInfoDialog) */
|
||||
.ml-info-dialog-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 100002;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ml-info-dialog {
|
||||
background: var(--comfy-menu-bg, #202020);
|
||||
border: 2px solid var(--border-color, #555555);
|
||||
border-radius: 8px;
|
||||
width: 500px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
}
|
||||
.ml-info-dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color, #555555);
|
||||
background: rgba(0,0,0,0.2);
|
||||
}
|
||||
.ml-info-dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--input-text, #e0e0e0);
|
||||
}
|
||||
.ml-info-dialog-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
color: var(--input-text, #e0e0e0);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ml-info-dialog-close:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
.ml-info-dialog-content {
|
||||
padding: 16px 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.ml-info-dialog-loading {
|
||||
text-align: center;
|
||||
color: var(--ml-text-muted, #888);
|
||||
padding: 20px;
|
||||
}
|
||||
.ml-info-section {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color, #555555);
|
||||
}
|
||||
.ml-info-section-header {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ml-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ml-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.ml-info-label {
|
||||
color: var(--ml-text-muted, #888);
|
||||
font-size: 13px;
|
||||
}
|
||||
.ml-info-value {
|
||||
color: var(--input-text, #e0e0e0);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ml-info-value.ml-info-active {
|
||||
color: #4CAF50;
|
||||
}
|
||||
.ml-info-value.ml-info-inactive {
|
||||
color: #888;
|
||||
}
|
||||
.ml-info-section-civitai {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.ml-info-civitai-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.ml-info-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ml-info-link:hover {
|
||||
background: #1976D2;
|
||||
}
|
||||
.ml-info-loading-small {
|
||||
color: var(--ml-text-muted, #888);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
.ml-info-not-found {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Info Area (type/base model tags) */
|
||||
.ml-info-area {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ml-info-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--input-text, #e0e0e0);
|
||||
}
|
||||
.ml-info-tag.-type-lora { background: rgba(33,150,243,0.2); color: #64B5F6; }
|
||||
.ml-info-tag.-type-checkpoint { background: rgba(76,175,80,0.2); color: #81C784; }
|
||||
.ml-info-tag.-type-vae { background: rgba(156,39,176,0.2); color: #BA68C8; }
|
||||
.ml-info-tag.-basemodel-sd1 { background: rgba(244,67,54,0.2); color: #EF9A9A; }
|
||||
.ml-info-tag.-basemodel-sdxl { background: rgba(33,150,243,0.2); color: #64B5F6; }
|
||||
.ml-info-tag.-basemodel-sdxl-turbo { background: rgba(33,150,243,0.2); color: #64B5F6; }
|
||||
.ml-info-tag.-basemodel-flux.1 { background: rgba(255,152,0,0.2); color: #FFB74D; }
|
||||
.ml-info-tag.-basemodel-flux.1-d { background: rgba(255,152,0,0.2); color: #FFB74D; }
|
||||
|
||||
/* Info Table */
|
||||
.ml-info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ml-info-table tr {
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.ml-info-table tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.ml-info-table td {
|
||||
padding: 8px 4px;
|
||||
font-size: 13px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.ml-info-table td:first-child {
|
||||
color: var(--ml-text-muted, #888);
|
||||
white-space: nowrap;
|
||||
width: 140px;
|
||||
}
|
||||
.ml-info-table td:last-child {
|
||||
color: var(--input-text, #e0e0e0);
|
||||
word-break: break-word;
|
||||
}
|
||||
.ml-info-help {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
line-height: 14px;
|
||||
cursor: help;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Images Section */
|
||||
.ml-info-images {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color, #555555);
|
||||
}
|
||||
.ml-info-images-header {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ml-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ml-info-images-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.ml-info-image-item {
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: rgba(0,0,0,0.2);
|
||||
}
|
||||
.ml-info-image-item img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.ml-info-image-item figcaption {
|
||||
padding: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--ml-text-muted, #888);
|
||||
}
|
||||
.ml-info-image-item figcaption span {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.ml-info-image-item figcaption span label {
|
||||
font-weight: 600;
|
||||
}
|
||||
.ml-info-image-link {
|
||||
color: #2196F3;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* CivitAI Logo */
|
||||
.ml-info-civitai-logo {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.ml-info-dialog-footer {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border-color, #555555);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.ml-info-dialog-close-btn {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.ml-info-error {
|
||||
color: #f44336;
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
document.head.appendChild(styles);
|
||||
@@ -1139,7 +1931,8 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown';
|
||||
const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
|
||||
const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null;
|
||||
html += `<span class="ml-model-chip">${name}${strength !== null ? `<span class="ml-model-chip-strength">${strength}</span>` : ''}</span>`;
|
||||
const modelData = encodeURIComponent(JSON.stringify(model));
|
||||
html += `<span class="ml-model-chip" data-model="${modelData}" oncontextmenu="window.MLOpenContextMenu(event, this)">${name}${strength !== null ? `<span class="ml-model-chip-strength">${strength}</span>` : ''}</span>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
@@ -1163,7 +1956,8 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown';
|
||||
const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
|
||||
const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null;
|
||||
html += `<span class="ml-model-chip">${name}${strength !== null ? `<span class="ml-model-chip-strength">${strength}</span>` : ''}</span>`;
|
||||
const modelData = encodeURIComponent(JSON.stringify(model));
|
||||
html += `<span class="ml-model-chip" data-model="${modelData}" oncontextmenu="window.MLOpenContextMenu(event, this)">${name}${strength !== null ? `<span class="ml-model-chip-strength">${strength}</span>` : ''}</span>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
@@ -3529,6 +4323,7 @@ class ModelLinker {
|
||||
// Create dialog instance
|
||||
if (!this.dialog) {
|
||||
this.dialog = new LinkerManagerDialog();
|
||||
window.modelLinkerDialog = this.dialog;
|
||||
}
|
||||
|
||||
// Register keyboard shortcut (Ctrl+Shift+L)
|
||||
@@ -3727,11 +4522,12 @@ class ModelLinker {
|
||||
// Small delay to let popup close
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
|
||||
// Create dialog if needed
|
||||
// Create dialog if needed
|
||||
if (!this.dialog) {
|
||||
this.dialog = new LinkerManagerDialog();
|
||||
window.modelLinkerDialog = this.dialog;
|
||||
}
|
||||
|
||||
|
||||
// Run auto-resolve for 100% matches - returns the updated workflow
|
||||
const updatedWorkflow = await this.dialog.autoResolve100Percent();
|
||||
|
||||
@@ -4041,6 +4837,7 @@ class ModelLinker {
|
||||
try {
|
||||
if (!this.dialog) {
|
||||
this.dialog = new LinkerManagerDialog();
|
||||
window.modelLinkerDialog = this.dialog;
|
||||
}
|
||||
this.dialog.show();
|
||||
} catch (error) {
|
||||
@@ -4181,3 +4978,23 @@ window.MLCopyCode = function(sectionId, btn) {
|
||||
});
|
||||
};
|
||||
|
||||
window.MLOpenContextMenu = function(event, element) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
try {
|
||||
const modelData = element.getAttribute('data-model');
|
||||
if (!modelData) return;
|
||||
|
||||
const model = JSON.parse(decodeURIComponent(modelData));
|
||||
|
||||
// Get dialog instance
|
||||
const dialog = window.modelLinkerDialog;
|
||||
if (dialog && dialog.showContextMenu) {
|
||||
dialog.showContextMenu(event.clientX, event.clientY, model);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Model Linker: Error opening context menu:', e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user