Add persistent HuggingFace author index cache and reorganize options UI

This commit is contained in:
Dariusz L
2026-06-05 03:36:58 +02:00
parent 4b57d07ffa
commit c3fad878af
6 changed files with 508 additions and 50 deletions
+26
View File
@@ -99,6 +99,8 @@ class ModelLinkerExtension:
)
from .core.sources.huggingface import (
search_huggingface_for_file,
get_author_fallback_index_status,
refresh_author_fallback_index,
clear_search_cache as clear_huggingface_search_cache,
)
from .core.sources.civitai import (
@@ -1268,6 +1270,30 @@ class ModelLinkerExtension:
log_exception(f"Clear search cache error: {e}")
return web.json_response({"error": str(e)}, status=500)
@routes.get("/model_linker/huggingface/author-index/status")
async def huggingface_author_index_status_route(request):
"""Return local HuggingFace author fallback index status."""
try:
return web.json_response(get_author_fallback_index_status())
except Exception as e:
log_exception(f"HuggingFace author index status error: {e}")
return web.json_response({"error": str(e)}, status=500)
@routes.post("/model_linker/huggingface/author-index/refresh")
async def huggingface_author_index_refresh_route(request):
"""Refresh HuggingFace author fallback index."""
try:
data = await request.json()
hf_token = data.get("hf_token", "")
result = await asyncio.to_thread(
refresh_author_fallback_index, hf_token or None
)
clear_huggingface_search_cache()
return web.json_response(result)
except Exception as e:
log_exception(f"HuggingFace author index refresh error: {e}")
return web.json_response({"error": str(e)}, status=500)
@routes.get("/model_linker/model-list/status")
async def model_list_status_route(request):
"""Return local model-list status and optionally compare with GitHub."""
+261 -1
View File
@@ -4,8 +4,11 @@ HuggingFace Source Module
Search and download models from HuggingFace Hub.
"""
import json
import os
import re
import threading
import time
import requests
from typing import Dict, Any, Optional, List
from urllib.parse import urlparse, quote
@@ -21,9 +24,19 @@ from ..log_system.log_funcs import (
HF_API_URL = "https://huggingface.co/api"
HF_AUTHOR_FALLBACKS = ["Comfy-Org"]
BRAVE_SEARCH_API_URL = "https://api.search.brave.com/res/v1/web/search"
HF_AUTHOR_INDEX_CACHE_TTL_SECONDS = 24 * 60 * 60
HF_AUTHOR_INDEX_CACHE_VERSION = 1
METADATA_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "metadata"
)
HF_AUTHOR_INDEX_CACHE_PATH = os.path.join(
METADATA_DIR, "huggingface-author-index.json"
)
# Cache for search results
_search_cache: Dict[str, Any] = {}
_author_index_cache: Dict[str, Dict[str, Any]] = {}
_author_index_lock = threading.RLock()
def clear_search_cache():
@@ -32,6 +45,186 @@ def clear_search_cache():
_search_cache.clear()
def _author_index_cache_key(author: str, headers: Dict[str, str]) -> str:
auth_key = "token" if headers.get("Authorization") else "public"
return f"{author}::{auth_key}"
def _is_author_index_fresh(index: Optional[Dict[str, Any]]) -> bool:
if not index:
return False
updated_at = index.get("updated_at")
if not isinstance(updated_at, (int, float)):
return False
return (time.time() - updated_at) < HF_AUTHOR_INDEX_CACHE_TTL_SECONDS
def _read_persistent_author_indexes() -> Dict[str, Any]:
if not os.path.exists(HF_AUTHOR_INDEX_CACHE_PATH):
return {"version": HF_AUTHOR_INDEX_CACHE_VERSION, "authors": {}}
try:
with open(HF_AUTHOR_INDEX_CACHE_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
if data.get("version") != HF_AUTHOR_INDEX_CACHE_VERSION:
return {"version": HF_AUTHOR_INDEX_CACHE_VERSION, "authors": {}}
if not isinstance(data.get("authors"), dict):
data["authors"] = {}
return data
except Exception as e:
log_debug(f"Error reading HuggingFace author index cache: {e}")
return {"version": HF_AUTHOR_INDEX_CACHE_VERSION, "authors": {}}
def _write_persistent_author_index(author: str, index: Dict[str, Any]) -> None:
try:
os.makedirs(METADATA_DIR, exist_ok=True)
data = _read_persistent_author_indexes()
data["authors"][author] = index
tmp_path = f"{HF_AUTHOR_INDEX_CACHE_PATH}.tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
os.replace(tmp_path, HF_AUTHOR_INDEX_CACHE_PATH)
except Exception as e:
log_debug(f"Error writing HuggingFace author index cache: {e}")
def _build_author_index_from_models(
author: str, repos: List[Dict[str, Any]]
) -> Dict[str, Any]:
files = []
repo_ids = []
for repo in repos:
repo_id = repo.get("id", "")
if not repo_id:
continue
repo_ids.append(repo_id)
for sibling in repo.get("siblings") or []:
file_path = sibling.get("rfilename") or sibling.get("path") or ""
if not file_path:
continue
files.append(
{
"repo_id": repo_id,
"path": file_path,
"filename": os.path.basename(file_path),
"size": sibling.get("size"),
}
)
return {
"author": author,
"updated_at": time.time(),
"repo_count": len(repo_ids),
"file_count": len(files),
"repos": repo_ids,
"files": files,
}
def _fetch_author_index(author: str, headers: Dict[str, str], limit: int = 200):
try:
response = requests.get(
f"{HF_API_URL}/models",
params={"author": author, "limit": limit, "full": "true"},
headers=headers,
timeout=15,
)
if response.status_code != 200:
log_debug(
f"HuggingFace author index returned {response.status_code} for author={author}"
)
return None
repos = response.json()
if not isinstance(repos, list):
return None
index = _build_author_index_from_models(author, repos)
log_info(
f"HuggingFace author index refreshed for author={author}: repos={index['repo_count']}, files={index['file_count']}"
)
return index
except Exception as e:
log_debug(f"Error refreshing HuggingFace author index for author={author}: {e}")
return None
def _get_author_index(
author: str, headers: Dict[str, str], force_refresh: bool = False
) -> Optional[Dict[str, Any]]:
cache_key = _author_index_cache_key(author, headers)
can_persist = not headers.get("Authorization")
with _author_index_lock:
memory_index = _author_index_cache.get(cache_key)
if not force_refresh and _is_author_index_fresh(memory_index):
return memory_index
persistent_index = None
if can_persist:
persistent_index = _read_persistent_author_indexes().get("authors", {}).get(
author
)
if not force_refresh and _is_author_index_fresh(persistent_index):
_author_index_cache[cache_key] = persistent_index
log_debug(f"HuggingFace author index cache hit for author={author}")
return persistent_index
fresh_index = _fetch_author_index(author, headers=headers)
if fresh_index:
_author_index_cache[cache_key] = fresh_index
if can_persist:
_write_persistent_author_index(author, fresh_index)
return fresh_index
if persistent_index:
log_warn(
f"Using stale HuggingFace author index for author={author}; refresh failed"
)
_author_index_cache[cache_key] = persistent_index
return persistent_index
return None
def get_author_fallback_index_status(author: str = "Comfy-Org") -> Dict[str, Any]:
"""Return persisted public author fallback index status for the options UI."""
with _author_index_lock:
index = _read_persistent_author_indexes().get("authors", {}).get(author)
updated_at = index.get("updated_at") if isinstance(index, dict) else None
age_seconds = (time.time() - updated_at) if isinstance(updated_at, (int, float)) else None
return {
"author": author,
"exists": bool(index),
"updated_at": updated_at,
"age_seconds": age_seconds,
"ttl_seconds": HF_AUTHOR_INDEX_CACHE_TTL_SECONDS,
"stale": bool(index) and not _is_author_index_fresh(index),
"repo_count": index.get("repo_count", 0) if isinstance(index, dict) else 0,
"file_count": index.get("file_count", 0) if isinstance(index, dict) else 0,
}
def refresh_author_fallback_index(
token: Optional[str] = None, author: str = "Comfy-Org"
) -> Dict[str, Any]:
"""Force refresh the public HuggingFace author fallback index."""
headers = {}
index = _get_author_index(author, headers=headers, force_refresh=True)
status = get_author_fallback_index_status(author)
status["success"] = bool(index)
status["persisted"] = bool(index)
return status
def parse_huggingface_url(url: str) -> Optional[Dict[str, str]]:
"""
Parse a HuggingFace URL to extract repo and filename.
@@ -168,6 +361,53 @@ def _find_matching_file_in_repo(
return partial_match
def _find_matching_file_in_author_index(
index: Dict[str, Any], filename: str, exact_only: bool = False
) -> Optional[Dict[str, Any]]:
files = index.get("files") or []
filename_lower = filename.lower()
filename_base = os.path.splitext(filename_lower)[0]
partial_match = None
for file_info in files:
repo_id = file_info.get("repo_id", "")
file_path = file_info.get("path", "")
if not repo_id or not file_path:
continue
file_name = os.path.basename(file_path)
file_name_lower = file_name.lower()
if file_name_lower == filename_lower or file_path.lower().endswith(
filename_lower
):
return _build_huggingface_result(repo_id, file_path, file_info, "exact")
if exact_only:
return None
for file_info in files:
repo_id = file_info.get("repo_id", "")
file_path = file_info.get("path", "")
if not repo_id or not file_path:
continue
file_name = os.path.basename(file_path)
file_name_lower = file_name.lower()
file_base = os.path.splitext(file_name_lower)[0]
file_path_lower = file_path.lower()
if filename_base in file_base or file_base in filename_base:
if file_path_lower.endswith(".safetensors") or file_path_lower.endswith(
".ckpt"
):
partial_match = _build_huggingface_result(
repo_id, file_path, file_info, "partial"
)
break
return partial_match
def _build_huggingface_search_queries(filename: str) -> List[str]:
filename_base = os.path.splitext(os.path.basename(filename))[0].lower()
cleaned_base = clean_filename_for_search(filename).lower()
@@ -371,8 +611,27 @@ def search_huggingface_for_file(
return result
author_fallback_repos = []
author_fallback_repo_count = 0
if use_comfy_org_fallback:
for author in HF_AUTHOR_FALLBACKS:
index = _get_author_index(author, headers={})
if index:
result = _find_matching_file_in_author_index(
index, filename, exact_only=exact_only
)
author_fallback_repo_count += int(index.get("repo_count") or 0)
if result:
_search_cache[cache_key] = result
log_info(
f"Found {result['match_type']} HuggingFace author index match for {filename}: {result['repo_id']}/{result['path']}"
)
return result
log_debug(
f"HuggingFace author index no match for author={author}, filename={filename}, repos={index.get('repo_count', 0)}, files={index.get('file_count', 0)}"
)
continue
repos = _get_repos_by_author(author, headers=headers)
log_info(
f"HuggingFace author fallback returned {len(repos)} repos for author={author}"
@@ -381,6 +640,7 @@ def search_huggingface_for_file(
if repo_id not in seen_repos:
seen_repos.add(repo_id)
author_fallback_repos.append(repo_id)
author_fallback_repo_count += 1
for repo_id in author_fallback_repos:
log_debug(
@@ -430,7 +690,7 @@ def search_huggingface_for_file(
# Not found
_search_cache[cache_key] = None
log_info(
f"HuggingFace search no result: filename={filename}, repos_checked={len(repos_to_check)}, author_fallback_repos={len(author_fallback_repos)}, brave_candidates={len(brave_candidates)}"
f"HuggingFace search no result: filename={filename}, repos_checked={len(repos_to_check)}, author_fallback_repos={author_fallback_repo_count}, brave_candidates={len(brave_candidates)}"
)
return None
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,5 +5,5 @@
"sha": "5fcb5f0637f9fbdedaf8d37da9a390f76ae0ded8",
"size": 268817,
"model_count": 527,
"updated_at": "2026-06-04T17:47:06+00:00"
"updated_at": "2026-06-05T01:04:57+00:00"
}
+44
View File
@@ -3543,6 +3543,42 @@
gap: 0;
align-items: stretch;
}
.ml-options-help,
#model-linker-modal .ml-options-help {
margin: 8px 0 0;
color: var(--ml-text-muted);
font-size: var(--ml-font-sm);
line-height: 1.45;
}
.ml-options-help summary,
#model-linker-modal .ml-options-help summary {
width: fit-content;
color: var(--ml-text-secondary);
cursor: pointer;
font-weight: 700;
}
.ml-options-help summary:hover,
#model-linker-modal .ml-options-help summary:hover {
color: var(--ml-accent);
}
.ml-options-help ol,
#model-linker-modal .ml-options-help ol {
margin: 8px 0 0 18px;
padding: 0;
}
.ml-options-help li,
#model-linker-modal .ml-options-help li {
margin: 4px 0;
}
.ml-options-help p,
#model-linker-modal .ml-options-help p {
margin: 8px 0 0;
}
.ml-options-help code,
#model-linker-modal .ml-options-help code {
color: var(--ml-text);
font-size: var(--ml-font-xs);
}
.ml-options-label {
display: flex;
align-items: center;
@@ -3690,6 +3726,14 @@
display: grid;
gap: 0;
}
.ml-options-dependent-block,
#model-linker-modal .ml-options-dependent-block {
display: grid;
gap: 10px;
margin: 0 0 8px 16px;
padding: 12px 0 4px 14px;
border-left: 1px solid rgba(255,255,255,0.12);
}
.ml-options-toggle-row {
display: flex;
align-items: center;
+175 -48
View File
@@ -4031,28 +4031,6 @@ class LinkerManagerDialog extends ComfyDialog {
<h4 class="ml-options-section-title">CivitAI</h4>
</div>
<div class="ml-options-grid">
<div class="ml-options-panel">
<div class="ml-options-stack">
<div class="ml-options-field">
<div class="ml-options-input-row">
<label for="ml-options-civitai" class="ml-options-label">CivitAI API Key <a href="https://civitai.com/user/account" target="_blank" rel="noopener noreferrer" class="ml-options-inline-link">Get key</a> <span class="ml-tooltip-badge" data-tooltip="Optional. Used when downloading from CivitAI requires your account. Add this if CivitAI downloads fail or need access to models available to your account.">?</span></label>
<input id="ml-options-civitai" class="ml-options-input" type="password" placeholder="Paste CivitAI API key" value="${tokens.civitai_key}">
<button id="ml-options-civitai-toggle" type="button" class="ml-options-visibility-btn" aria-label="Toggle visibility for saved CivitAI API key" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
</div>
<div class="ml-options-field">
<div class="ml-options-input-row">
<label for="ml-options-civitai-session" class="ml-options-label">CivitAI Session Token <span class="ml-tooltip-badge" data-tooltip="Optional. Makes CivitAI search use your logged-in session, so results can match what you see in the browser. Useful for NSFW or account-visible results.">?</span></label>
<input id="ml-options-civitai-session" class="ml-options-input" type="password" placeholder="Paste __Secure-civitai-token" value="${tokens.civitai_session_token}">
<button id="ml-options-civitai-session-toggle" type="button" class="ml-options-visibility-btn" aria-label="Toggle visibility for saved CivitAI session token" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
</div>
</div>
</div>
<div class="ml-options-panel">
<div class="ml-options-toggle-list">
<label class="ml-options-toggle-row">
@@ -4064,6 +4042,35 @@ class LinkerManagerDialog extends ComfyDialog {
<span class="ml-options-switch"></span>
</span>
</label>
<div class="ml-options-dependent-block">
<div class="ml-options-field">
<div class="ml-options-input-row">
<label for="ml-options-civitai" class="ml-options-label">CivitAI API Key <a href="https://civitai.com/user/account" target="_blank" rel="noopener noreferrer" class="ml-options-inline-link">Get key</a> <span class="ml-tooltip-badge" data-tooltip="Optional. Used when downloading from CivitAI requires your account. Add this if CivitAI downloads fail or need access to models available to your account.">?</span></label>
<input id="ml-options-civitai" class="ml-options-input" type="password" placeholder="Paste CivitAI API key" value="${tokens.civitai_key}">
<button id="ml-options-civitai-toggle" type="button" class="ml-options-visibility-btn" aria-label="Toggle visibility for saved CivitAI API key" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
</div>
<div class="ml-options-field">
<div class="ml-options-input-row">
<label for="ml-options-civitai-session" class="ml-options-label">CivitAI Session Token <span class="ml-tooltip-badge" data-tooltip="Optional. Makes CivitAI search use your logged-in session, so results can match what you see in the browser. Useful for NSFW or account-visible results.">?</span></label>
<input id="ml-options-civitai-session" class="ml-options-input" type="password" placeholder="Paste __Secure-civitai-token" value="${tokens.civitai_session_token}">
<button id="ml-options-civitai-session-toggle" type="button" class="ml-options-visibility-btn" aria-label="Toggle visibility for saved CivitAI session token" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
<details class="ml-options-help">
<summary>How to find the session token</summary>
<ol>
<li>Sign in on civitai.com in your browser.</li>
<li>Open DevTools, then Application or Storage.</li>
<li>Open Cookies for civitai.com and copy <code>__Secure-civitai-token</code>.</li>
</ol>
<p>Treat it like a password. It is only needed when session-aware CivitAI search is required.</p>
</details>
</div>
</div>
<label class="ml-options-toggle-row">
<div class="ml-options-toggle-copy">
<span class="ml-options-toggle-title">Use CivitAI HTML fallback <span class="ml-tooltip-badge" data-tooltip="Backup CivitAI search. Leave this enabled to try the regular CivitAI page when the main search does not find enough results.">?</span></span>
@@ -4073,11 +4080,13 @@ class LinkerManagerDialog extends ComfyDialog {
<span class="ml-options-switch"></span>
</span>
</label>
<div class="ml-options-number-row">
<div class="ml-options-number-copy">
<span class="ml-options-label">CivitAI Models To Inspect <span class="ml-tooltip-badge" data-tooltip="How many CivitAI results to check for the exact file. Higher values may find more matches, but searches can take longer. Range: 1-20.">?</span></span>
<div class="ml-options-dependent-block">
<div class="ml-options-number-row">
<div class="ml-options-number-copy">
<span class="ml-options-label">CivitAI Models To Inspect <span class="ml-tooltip-badge" data-tooltip="How many CivitAI results to check for the exact file. Higher values may find more matches, but searches can take longer. Range: 1-20.">?</span></span>
</div>
<input id="ml-options-civitai-limit" class="ml-options-input" type="number" min="1" max="20" step="1" value="${tokens.civitai_candidate_limit}">
</div>
<input id="ml-options-civitai-limit" class="ml-options-input" type="number" min="1" max="20" step="1" value="${tokens.civitai_candidate_limit}">
</div>
</div>
</div>
@@ -4088,28 +4097,6 @@ class LinkerManagerDialog extends ComfyDialog {
<h4 class="ml-options-section-title">HuggingFace</h4>
</div>
<div class="ml-options-grid">
<div class="ml-options-panel">
<div class="ml-options-stack">
<div class="ml-options-field">
<div class="ml-options-input-row">
<label for="ml-options-hf" class="ml-options-label">HuggingFace Token <a href="https://huggingface.co/settings/tokens" target="_blank" rel="noopener noreferrer" class="ml-options-inline-link">Get key</a> <span class="ml-tooltip-badge" data-tooltip="Optional. Used to search and download files from Hugging Face repos your account can access, including gated repos. A read-only token is enough.">?</span></label>
<input id="ml-options-hf" class="ml-options-input" type="password" placeholder="Paste HuggingFace token" value="${tokens.hf_token}">
<button id="ml-options-hf-toggle" type="button" class="ml-options-visibility-btn" aria-label="Toggle visibility for saved HuggingFace token" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
</div>
<div class="ml-options-field">
<div class="ml-options-input-row">
<label for="ml-options-brave" class="ml-options-label">Brave Search API Key <a href="https://api-dashboard.search.brave.com/app/keys" target="_blank" rel="noopener noreferrer" class="ml-options-inline-link">Get key</a> <span class="ml-tooltip-badge" data-tooltip="Optional. Used only by the Brave fallback. It helps find Hugging Face files when Hugging Face search does not show the right repo.">?</span></label>
<input id="ml-options-brave" class="ml-options-input" type="password" placeholder="Paste Brave Search API key" value="${tokens.brave_search_api_key}">
<button id="ml-options-brave-toggle" type="button" class="ml-options-visibility-btn" aria-label="Toggle visibility for saved Brave Search API key" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
</div>
</div>
</div>
<div class="ml-options-panel">
<div class="ml-options-toggle-list">
<label class="ml-options-toggle-row">
@@ -4121,6 +4108,17 @@ class LinkerManagerDialog extends ComfyDialog {
<span class="ml-options-switch"></span>
</span>
</label>
<div class="ml-options-dependent-block">
<div class="ml-options-field">
<div class="ml-options-input-row">
<label for="ml-options-hf" class="ml-options-label">HuggingFace Token <a href="https://huggingface.co/settings/tokens" target="_blank" rel="noopener noreferrer" class="ml-options-inline-link">Get key</a> <span class="ml-tooltip-badge" data-tooltip="Optional. Used to search and download files from Hugging Face repos your account can access, including gated repos. A read-only token is enough.">?</span></label>
<input id="ml-options-hf" class="ml-options-input" type="password" placeholder="Paste HuggingFace token" value="${tokens.hf_token}">
<button id="ml-options-hf-toggle" type="button" class="ml-options-visibility-btn" aria-label="Toggle visibility for saved HuggingFace token" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
</div>
</div>
<label class="ml-options-toggle-row">
<div class="ml-options-toggle-copy">
<span class="ml-options-toggle-title">Use Comfy-Org fallback <span class="ml-tooltip-badge" data-tooltip="Checks Comfy-Org repositories directly. Useful for ComfyUI model packs that normal Hugging Face search may miss.">?</span></span>
@@ -4130,6 +4128,26 @@ class LinkerManagerDialog extends ComfyDialog {
<span class="ml-options-switch"></span>
</span>
</label>
<div class="ml-options-dependent-block">
<div class="ml-options-db-summary">
<div class="ml-options-db-row">
<span>Comfy-Org files</span>
<strong id="ml-options-hf-index-count">Loading...</strong>
</div>
<div class="ml-options-db-row">
<span>Last refresh</span>
<strong id="ml-options-hf-index-updated">Loading...</strong>
</div>
<div class="ml-options-db-row">
<span>Status</span>
<strong id="ml-options-hf-index-state">Checking local index...</strong>
</div>
</div>
<div id="ml-options-hf-index-message" class="ml-options-db-message">Comfy-Org fallback uses a cached HuggingFace file index.</div>
<div class="ml-options-db-actions">
<button id="ml-options-hf-index-refresh" type="button" class="ml-btn ml-btn-secondary">${getSvgIcon('refreshCw')} Refresh Comfy-Org Index</button>
</div>
</div>
<label class="ml-options-toggle-row">
<div class="ml-options-toggle-copy">
<span class="ml-options-toggle-title">Use Brave fallback <span class="ml-tooltip-badge" data-tooltip="Last-resort web search for the exact filename on huggingface.co. Results are still checked before Model Linker offers them.">?</span></span>
@@ -4139,6 +4157,17 @@ class LinkerManagerDialog extends ComfyDialog {
<span class="ml-options-switch"></span>
</span>
</label>
<div class="ml-options-dependent-block">
<div class="ml-options-field">
<div class="ml-options-input-row">
<label for="ml-options-brave" class="ml-options-label">Brave Search API Key <a href="https://api-dashboard.search.brave.com/app/keys" target="_blank" rel="noopener noreferrer" class="ml-options-inline-link">Get key</a> <span class="ml-tooltip-badge" data-tooltip="Optional. Used only by the Brave fallback. It helps find Hugging Face files when Hugging Face search does not show the right repo.">?</span></label>
<input id="ml-options-brave" class="ml-options-input" type="password" placeholder="Paste Brave Search API key" value="${tokens.brave_search_api_key}">
<button id="ml-options-brave-toggle" type="button" class="ml-options-visibility-btn" aria-label="Toggle visibility for saved Brave Search API key" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -4171,6 +4200,11 @@ class LinkerManagerDialog extends ComfyDialog {
const modelListMessageEl = this.contentElement.querySelector('#ml-options-model-list-message');
const modelListCheckBtn = this.contentElement.querySelector('#ml-options-model-list-check');
const modelListUpdateBtn = this.contentElement.querySelector('#ml-options-model-list-update');
const hfIndexCountEl = this.contentElement.querySelector('#ml-options-hf-index-count');
const hfIndexUpdatedEl = this.contentElement.querySelector('#ml-options-hf-index-updated');
const hfIndexStateEl = this.contentElement.querySelector('#ml-options-hf-index-state');
const hfIndexMessageEl = this.contentElement.querySelector('#ml-options-hf-index-message');
const hfIndexRefreshBtn = this.contentElement.querySelector('#ml-options-hf-index-refresh');
const navButtons = Array.from(this.contentElement.querySelectorAll('.ml-options-nav-btn'));
const optionSections = Array.from(this.contentElement.querySelectorAll('.ml-options-section'));
const trackedInputs = [
@@ -4208,6 +4242,10 @@ class LinkerManagerDialog extends ComfyDialog {
if (modelListUpdateBtn) modelListUpdateBtn.disabled = busy;
};
const setHfIndexBusy = (busy) => {
if (hfIndexRefreshBtn) hfIndexRefreshBtn.disabled = busy;
};
const renderModelListStatus = (data = {}, checkedRemote = false) => {
if (modelListCountEl) {
modelListCountEl.textContent = Number.isFinite(Number(data.local_count))
@@ -4293,6 +4331,86 @@ class LinkerManagerDialog extends ComfyDialog {
}
};
const renderHfIndexStatus = (data = {}) => {
if (hfIndexCountEl) {
hfIndexCountEl.textContent = Number.isFinite(Number(data.file_count))
? Number(data.file_count).toLocaleString()
: '-';
}
if (hfIndexUpdatedEl) {
hfIndexUpdatedEl.textContent = data.updated_at
? formatOptionDate(Number(data.updated_at) * 1000)
: 'Never';
}
if (hfIndexStateEl) {
if (!data.exists) {
hfIndexStateEl.textContent = 'Not cached';
} else if (data.stale) {
hfIndexStateEl.textContent = 'Refresh due';
} else {
hfIndexStateEl.textContent = 'Cached';
}
}
if (hfIndexMessageEl) {
if (!data.exists) {
hfIndexMessageEl.textContent = 'The first Comfy-Org fallback search will build the local HuggingFace file index.';
} else if (data.stale) {
hfIndexMessageEl.textContent = `Index has ${Number(data.repo_count || 0).toLocaleString()} repos and should be refreshed.`;
} else {
hfIndexMessageEl.textContent = `Index has ${Number(data.repo_count || 0).toLocaleString()} repos and is used before per-repo fallback checks.`;
}
}
};
const loadHfIndexStatus = async () => {
try {
setHfIndexBusy(true);
if (hfIndexStateEl) hfIndexStateEl.textContent = 'Loading...';
const response = await api.fetchApi('/model_linker/huggingface/author-index/status');
if (!response.ok) {
throw new Error(`Status failed: ${response.status}`);
}
const data = await response.json();
renderHfIndexStatus(data);
return data;
} catch (error) {
console.error('Model Linker: HuggingFace index status error:', error);
if (hfIndexStateEl) hfIndexStateEl.textContent = 'Error';
if (hfIndexMessageEl) hfIndexMessageEl.textContent = error.message || 'Failed to read HuggingFace index status.';
return null;
} finally {
setHfIndexBusy(false);
}
};
const refreshHfIndex = async () => {
try {
setHfIndexBusy(true);
if (hfIndexStateEl) hfIndexStateEl.textContent = 'Refreshing...';
if (hfIndexMessageEl) hfIndexMessageEl.textContent = 'Downloading Comfy-Org file index from HuggingFace...';
const response = await api.fetchApi('/model_linker/huggingface/author-index/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hf_token: hfInput?.value || '' })
});
const data = await response.json();
if (!response.ok || data.success === false) {
throw new Error(data.error || `Refresh failed: ${response.status}`);
}
await this.clearSearchCaches();
renderHfIndexStatus(data);
if (hfIndexStateEl) hfIndexStateEl.textContent = 'Refreshed';
this.showNotification('HuggingFace Comfy-Org index refreshed', 'success');
} catch (error) {
console.error('Model Linker: HuggingFace index refresh error:', error);
if (hfIndexStateEl) hfIndexStateEl.textContent = 'Error';
if (hfIndexMessageEl) hfIndexMessageEl.textContent = error.message || 'Failed to refresh HuggingFace index.';
this.showNotification('HuggingFace index refresh failed', 'error');
} finally {
setHfIndexBusy(false);
}
};
const setActiveNav = (targetId) => {
navButtons.forEach((btn) => {
btn.classList.toggle('is-active', btn.dataset.target === targetId);
@@ -4352,6 +4470,8 @@ class LinkerManagerDialog extends ComfyDialog {
setVisibleSection(targetId);
if (targetId === 'ml-options-section-local-db') {
loadModelListStatus(false);
} else if (targetId === 'ml-options-section-hf') {
loadHfIndexStatus();
}
});
});
@@ -4368,7 +4488,14 @@ class LinkerManagerDialog extends ComfyDialog {
});
}
if (hfIndexRefreshBtn) {
hfIndexRefreshBtn.addEventListener('click', () => {
refreshHfIndex();
});
}
loadModelListStatus(false);
loadHfIndexStatus();
if (saveBtn) {
saveBtn.addEventListener('click', async () => {