From 08379648748c7bfdf722b4b09218813687166c03 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Sun, 19 Apr 2026 16:43:25 +0200 Subject: [PATCH] Add gguf support and nested widget handling Add support for the gguf model format across the codebase and improve handling of nested/dict-type widget model references. Changes include: - Add ".gguf" to model extension lists in core/linker.py, core/scanner.py, core/sources/huggingface.py, and core/workflow_analyzer.py so gguf files are detected and suggested. - Update HuggingFace search/repo listing to consider gguf files and improve some request/response handling and logging. - Improve scanner implementation: formatting/PEP8 cleanup, robust extension checks, normalized MODEL_EXTENSIONS set, safer path handling, and clearer logging. - Add NESTED_MODEL_KEYS and logic in workflow_analyzer to detect model filenames stored inside dict-type widget values (e.g. rgthree Power Lora Loader) and record a nested_key for resolution. - Propagate nested_key through apply_resolution mapping and update update_model_path to write resolved paths back into nested dict keys when present (instead of only replacing the top-level widget value). - Update web UI (web/linker.js) regexes to strip/display gguf extensions in model name formatting. These changes allow gguf models to be discovered, resolved and updated in workflows, and enable support for nodes that store model references inside nested dict fields. --- core/linker.py | 10 +- core/scanner.py | 100 +++++++++++------ core/sources/huggingface.py | 216 ++++++++++++++++++------------------ core/workflow_analyzer.py | 58 ++++++++++ core/workflow_updater.py | 15 ++- web/linker.js | 12 +- 6 files changed, 256 insertions(+), 155 deletions(-) diff --git a/core/linker.py b/core/linker.py index fbe1f45..0ebc9a3 100644 --- a/core/linker.py +++ b/core/linker.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) URL_PATTERN = re.compile(r'(https?://(?:huggingface\.co|civitai\.com)[^\s"\'<>\)\\]+)') # Model file extensions to look for -MODEL_EXTENSIONS = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".onnx") +MODEL_EXTENSIONS = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".onnx", ".gguf") def extract_workflow_urls(workflow_json: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: @@ -85,7 +85,7 @@ def extract_workflow_urls(workflow_json: Dict[str, Any]) -> Dict[str, Dict[str, # 3. Extract model filenames via regex model_pattern = re.compile( - r"([\w\-\.%]+\.(?:safetensors|ckpt|pt|pth|bin|onnx))", re.IGNORECASE + r"([\w\-\.%]+\.(?:safetensors|ckpt|pt|pth|bin|onnx|gguf))", re.IGNORECASE ) model_files_raw = model_pattern.findall(workflow_str) @@ -334,7 +334,8 @@ def apply_resolution( 'widget_index': widget index, 'resolved_path': absolute path to resolved model, 'category': model category (optional), - 'resolved_model': model dict from scanner (optional) + 'resolved_model': model dict from scanner (optional), + 'nested_key': nested key for dict-type widgets (optional) } Returns: @@ -359,6 +360,9 @@ def apply_resolution( "original_lora_name": resolution.get( "original_lora_name" ), # Original lora name for LoraManager replacement + "nested_key": resolution.get( + "nested_key" + ), # For dict-type widgets (e.g. Power Lora Loader) } # If resolved_model provided, extract path if needed diff --git a/core/scanner.py b/core/scanner.py index 4dd3ef3..1347e1c 100644 --- a/core/scanner.py +++ b/core/scanner.py @@ -17,41 +17,55 @@ except ImportError: # Model file extensions to look for # This matches folder_paths.supported_pt_extensions -MODEL_EXTENSIONS = {'.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft', '.onnx'} +MODEL_EXTENSIONS = { + ".ckpt", + ".pt", + ".pt2", + ".bin", + ".pth", + ".safetensors", + ".pkl", + ".sft", + ".onnx", + ".gguf", +} def get_model_directories() -> Dict[str, Tuple[List[str], set]]: """ Get all configured model directories from folder_paths. - + Returns: Dictionary mapping category name to a tuple. ComfyUI may provide either: - (paths, extensions), or - (paths, extensions, recursive_flag) """ global folder_paths - + if folder_paths is None: # Try to import again try: import folder_paths as fp + folder_paths = fp except ImportError: logging.error("Model Linker: folder_paths still not available") return {} - + return folder_paths.folder_names_and_paths.copy() -def scan_directory(directory: str, extensions: set, category: str) -> List[Dict[str, str]]: +def scan_directory( + directory: str, extensions: set, category: str +) -> List[Dict[str, str]]: """ Recursively scan a single directory for model files. - + Args: directory: Absolute path to directory to scan extensions: Set of file extensions to look for category: Model category name (e.g., 'checkpoints', 'loras') - + Returns: List of dictionaries with model information: { @@ -63,33 +77,37 @@ def scan_directory(directory: str, extensions: set, category: str) -> List[Dict[ } """ models = [] - + if not os.path.exists(directory) or not os.path.isdir(directory): logging.debug(f"Directory does not exist or is not accessible: {directory}") return models - + try: # Get absolute path and normalize base_directory = os.path.abspath(directory) - + # Walk through directory recursively for root, dirs, files in os.walk(base_directory, followlinks=True): # Skip hidden directories - dirs[:] = [d for d in dirs if not d.startswith('.')] - + dirs[:] = [d for d in dirs if not d.startswith(".")] + for filename in files: # Check if file has a model extension file_ext = os.path.splitext(filename)[1].lower() - + # For categories with empty extension set, accept all files # Otherwise, check if extension matches # Accept if: # - no explicit extensions configured, or # - matches configured extensions, or # - matches our known model extensions - if len(extensions or set()) == 0 or file_ext in extensions or file_ext in MODEL_EXTENSIONS: + if ( + len(extensions or set()) == 0 + or file_ext in extensions + or file_ext in MODEL_EXTENSIONS + ): full_path = os.path.join(root, filename) - + # Calculate relative path from base directory # IMPORTANT: Use OS-native path separators (backslashes on Windows) # This matches ComfyUI's recursive_search format for get_filename_list @@ -101,33 +119,35 @@ def scan_directory(directory: str, extensions: set, category: str) -> List[Dict[ except ValueError: # If paths are on different drives (Windows), use filename only relative_path = filename - - models.append({ - 'filename': filename, - 'path': full_path, - 'relative_path': relative_path, - 'category': category, - 'base_directory': base_directory - }) + + models.append( + { + "filename": filename, + "path": full_path, + "relative_path": relative_path, + "category": category, + "base_directory": base_directory, + } + ) except (OSError, PermissionError) as e: logging.warning(f"Error scanning directory {directory}: {e}") - + return models def scan_all_directories() -> List[Dict[str, str]]: """ Scan all configured model directories and return list of available models. - + Returns: List of dictionaries with model information (same format as scan_directory) """ all_models = [] directories = get_model_directories() - + for category, value in directories.items(): # Skip categories that aren't typically model directories - if category in ['custom_nodes', 'configs']: + if category in ["custom_nodes", "configs"]: continue # Unpack folder_paths value flexibly: (paths, extensions) or (paths, extensions, recursive) @@ -143,11 +163,13 @@ def scan_all_directories() -> List[Dict[str, str]]: paths = list(value) raw_exts = [] elif isinstance(value, dict): - paths = value.get('paths') or value.get('path') or [] - raw_exts = value.get('extensions') or [] + paths = value.get("paths") or value.get("path") or [] + raw_exts = value.get("extensions") or [] else: # Unknown format; skip category - logging.debug(f"Unexpected folder_paths format for category {category}: {type(value)}") + logging.debug( + f"Unexpected folder_paths format for category {category}: {type(value)}" + ) continue # Normalize extensions to a set[str] @@ -156,26 +178,32 @@ def scan_all_directories() -> List[Dict[str, str]]: elif raw_exts: extensions = {str(raw_exts).lower()} except Exception as e: - logging.warning(f"Error interpreting folder_paths entry for {category}: {e}") + logging.warning( + f"Error interpreting folder_paths entry for {category}: {e}" + ) continue for directory_path in paths: try: models = scan_directory(directory_path, extensions, category) all_models.extend(models) - logging.debug(f"Found {len(models)} models in {category}/{directory_path}") + logging.debug( + f"Found {len(models)} models in {category}/{directory_path}" + ) except Exception as e: - logging.warning(f"Error scanning {category} directory {directory_path}: {e}") - + logging.warning( + f"Error scanning {category} directory {directory_path}: {e}" + ) + return all_models def get_model_files() -> List[Dict[str, str]]: """ Get list of all available model files with metadata. - + This is the main entry point for getting model files. - + Returns: List of model dictionaries (same format as scan_directory) """ diff --git a/core/sources/huggingface.py b/core/sources/huggingface.py index fdcacf6..dbee763 100644 --- a/core/sources/huggingface.py +++ b/core/sources/huggingface.py @@ -22,36 +22,33 @@ _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 url.startswith("hf://"): + parts = url[5:].split("/", 2) if len(parts) >= 3: - return { - 'repo': f"{parts[0]}/{parts[1]}", - 'filename': parts[2] - } + return {"repo": f"{parts[0]}/{parts[1]}", "filename": parts[2]} return None - + parsed = urlparse(url) - if 'huggingface.co' not in parsed.netloc: + if "huggingface.co" not in parsed.netloc: return None - - match = re.match(r'^/([^/]+/[^/]+)/(resolve|blob)/([^/]+)/(.+)$', parsed.path) + + match = re.match(r"^/([^/]+/[^/]+)/(resolve|blob)/([^/]+)/(.+)$", parsed.path) if match: return { - 'repo': match.group(1), - 'branch': match.group(3), - 'filename': match.group(4) + "repo": match.group(1), + "branch": match.group(3), + "filename": match.group(4), } - + return None @@ -67,111 +64,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, + ) return base def search_huggingface_for_file( - filename: str, - token: Optional[str] = None, - exact_only: bool = False + filename: str, token: Optional[str] = None, exact_only: bool = False ) -> Optional[Dict[str, Any]]: """ Search HuggingFace for a specific model file. Returns the first repo that contains a matching file. - + Args: filename: Filename to search for token: Optional HF token exact_only: If True, only return exact filename matches (for downloads). If False, also try partial matching (for local file resolution). - + Returns: Dict with url, repo, filename if found, None otherwise """ global _search_cache - + cache_key = f"hf_{filename}_exact{exact_only}" if cache_key in _search_cache: return _search_cache[cache_key] - + try: # Use filename without extension for search filename_base = os.path.splitext(filename)[0].lower() - + headers = {} if token: - headers['Authorization'] = f'Bearer {token}' - + headers["Authorization"] = f"Bearer {token}" + # Search for repos containing this filename search_url = f"{HF_API_URL}/models?search={quote(filename_base)}&limit=10" - + response = requests.get(search_url, headers=headers, timeout=10) 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', '') + repo_id = repo.get("id", "") if not repo_id: continue - + # Check if this repo actually has a matching 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', '') - file_base = os.path.splitext(os.path.basename(file_path))[0].lower() - + file_path = file_info.get("path", "") + file_base = os.path.splitext(os.path.basename(file_path))[ + 0 + ].lower() + # Check for exact match first (always try this) if file_path.endswith(filename): result = { - 'source': 'huggingface', - 'repo_id': 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'), - 'match_type': 'exact' + "source": "huggingface", + "repo_id": 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"), + "match_type": "exact", } _search_cache[cache_key] = result logger.info(f"Found {filename} on HuggingFace: {repo_id}") 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: - if file_path.endswith('.safetensors') or file_path.endswith('.ckpt'): + if file_path.endswith( + ".safetensors" + ) or file_path.endswith(".ckpt"): result = { - 'source': 'huggingface', - 'repo_id': 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'), - 'match_type': 'partial' + "source": "huggingface", + "repo_id": 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"), + "match_type": "partial", } _search_cache[cache_key] = result - logger.info(f"Found similar file for {filename} on HuggingFace: {repo_id}/{file_path}") + logger.info( + f"Found similar file for {filename} on HuggingFace: {repo_id}/{file_path}" + ) 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 @@ -181,85 +189,79 @@ def search_huggingface( query: str, model_type: Optional[str] = None, limit: int = 10, - token: Optional[str] = None + 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' - } - + 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 + 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}" - }) - + 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]]: +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}' - + headers["Authorization"] = f"Bearer {token}" + response = requests.get( - f"{HF_API_URL}/models/{repo}/tree/main", - headers=headers, - timeout=15 + 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') - }) - + path = item.get("path", "") + if path.endswith( + (".safetensors", ".ckpt", ".pt", ".bin", ".pth", ".onnx", ".gguf") + ): + 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 diff --git a/core/workflow_analyzer.py b/core/workflow_analyzer.py index 5e17cd4..dbcf380 100644 --- a/core/workflow_analyzer.py +++ b/core/workflow_analyzer.py @@ -27,6 +27,7 @@ MODEL_EXTENSIONS = { ".pkl", ".sft", ".onnx", + ".gguf", } import re @@ -59,6 +60,7 @@ NODE_TYPE_TO_CATEGORY_HINTS = { "LoraLoaderModelOnly": "loras", "LoraLoaderV2": "loras", "Lora Loader (LoraManager)": "loras", # LoraManager custom node + "Power Lora Loader (rgthree)": "loras", # rgthree's Power Lora Loader "UNETLoader": "diffusion_models", "ControlNetLoader": "controlnet", "ControlNetLoaderAdvanced": "controlnet", @@ -66,6 +68,22 @@ NODE_TYPE_TO_CATEGORY_HINTS = { "UpscaleModelLoader": "upscale_models", "HypernetworkLoader": "hypernetworks", "EmbeddingLoader": "embeddings", + # LTX-Video nodes + "LTXVAudioVAELoader": "checkpoints", + "LowVRAMAudioVAELoader": "checkpoints", + "LTXVGemmaCLIPModelLoader": "text_encoders", +} + +# Keys within dict-type widget values that contain model file references. +# Some nodes (e.g. rgthree Power Lora Loader) store model info as objects like +# {"on": true, "lora": "name.safetensors", "strength": 1.0} inside widgets_values. +# Maps nested key name -> category hint. +NESTED_MODEL_KEYS = { + "lora": "loras", + "ckpt_name": "checkpoints", + "checkpoint": "checkpoints", + "vae_name": "vae", + "control_net_name": "controlnet", } @@ -277,6 +295,46 @@ def get_node_model_info(node: Dict[str, Any]) -> List[Dict[str, Any]]: # For each widget value, check if it looks like a model file or URN for idx, value in enumerate(widgets_values): if not is_model_filename(value): + # Check for dict-type widget values containing model references (e.g. Power Lora Loader) + # Some nodes store model info as objects like {"on": true, "lora": "name.safetensors", "strength": 1.0} + if isinstance(value, dict): + for nested_key, nested_category_hint in NESTED_MODEL_KEYS.items(): + nested_value = value.get(nested_key) + if ( + not nested_value + or not isinstance(nested_value, str) + or not is_model_filename(nested_value) + ): + continue + + value_str = nested_value.strip() + nested_categories = ( + [nested_category_hint] if nested_category_hint else None + ) + + resolved = try_resolve_model_path(value_str, nested_categories) + if resolved: + category, full_path = resolved + exists = os.path.exists(full_path) + else: + category = nested_category_hint or "unknown" + full_path = None + exists = False + + model_refs.append( + { + "node_id": node_id, + "node_type": node_type, + "widget_index": idx, + "original_path": value_str, + "category": category, + "full_path": full_path, + "exists": exists, + "is_urn": False, + "connected": is_active, + "nested_key": nested_key, # Track nested key for updates + } + ) continue value_str = str(value).strip() diff --git a/core/workflow_updater.py b/core/workflow_updater.py index 4b1e13f..d8c662a 100644 --- a/core/workflow_updater.py +++ b/core/workflow_updater.py @@ -312,9 +312,18 @@ def update_model_path( relative_path = resolved_path # Update the widget value - widgets_values[widget_index] = relative_path - - logging.debug(f"Updated node {node_id}, widget {widget_index} to: {relative_path}") + # Handle nested dict values (e.g. Power Lora Loader with {"on": true, "lora": "name.safetensors", "strength": 1.0}) + nested_key = mapping.get("nested_key") if mapping else None + if nested_key and isinstance(widgets_values[widget_index], dict): + widgets_values[widget_index][nested_key] = relative_path + logging.debug( + f"Updated node {node_id}, widget {widget_index}[{nested_key}] to: {relative_path}" + ) + else: + widgets_values[widget_index] = relative_path + logging.debug( + f"Updated node {node_id}, widget {widget_index} to: {relative_path}" + ) return True diff --git a/web/linker.js b/web/linker.js index 07d15ec..7c056f7 100644 --- a/web/linker.js +++ b/web/linker.js @@ -1069,8 +1069,8 @@ class LinkerManagerDialog extends ComfyDialog { const parts = models.map(model => { const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown'; let name = fullName; - if (fullName.match(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx)$/i)) { - name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx)$/i, ''); + if (fullName.match(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i)) { + 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) @@ -1123,7 +1123,7 @@ class LinkerManagerDialog extends ComfyDialog { if (hasActive) { const activeStr = modelsObj.active.map(m => { const fullName = m.name || m.original_path?.split(/[\/\\]/).pop() || 'Unknown'; - let name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx)$/i, ''); + let name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); const strength = m.strength !== null && m.strength !== undefined ? m.strength.toFixed(2) : '1.00'; return `<${displayName}:${name}:${strength}>`; }).join(' '); @@ -1137,7 +1137,7 @@ class LinkerManagerDialog extends ComfyDialog { for (const model of modelsObj.active) { const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown'; - const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx)$/i, ''); + 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 += `${name}${strength !== null ? `${strength}` : ''}`; } @@ -1147,7 +1147,7 @@ class LinkerManagerDialog extends ComfyDialog { if (hasInactive) { const inactiveStr = modelsObj.inactive.map(m => { const fullName = m.name || m.original_path?.split(/[\/\\]/).pop() || 'Unknown'; - let name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx)$/i, ''); + let name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); const strength = m.strength !== null && m.strength !== undefined ? m.strength.toFixed(2) : '1.00'; return `<${displayName}:${name}:${strength}>`; }).join(' '); @@ -1161,7 +1161,7 @@ class LinkerManagerDialog extends ComfyDialog { for (const model of modelsObj.inactive) { const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown'; - const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx)$/i, ''); + 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 += `${name}${strength !== null ? `${strength}` : ''}`; }