diff --git a/__init__.py b/__init__.py
index 484a287..7cb9a21 100644
--- a/__init__.py
+++ b/__init__.py
@@ -31,6 +31,7 @@ async def start_download(request):
url = json_data.get("url")
save_path = json_data.get("save_path") # e.g., "checkpoints"
filename = json_data.get("filename") # e.g., "model.safetensors"
+ token = json_data.get("token") # Optional HF token for gated models
if not url or not save_path or not filename:
return web.json_response(
@@ -103,11 +104,15 @@ async def start_download(request):
"priority": None
}
+ # Resolve token: prefer explicit token, fall back to HF_TOKEN env var
+ resolved_token = token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
+
# Add to queue
download_queue.append({
"download_id": download_id,
"url": url,
- "output_path": output_path
+ "output_path": output_path,
+ "token": resolved_token
})
# Process queue (will start download if slot available)
@@ -145,6 +150,7 @@ async def process_download_queue():
download_id = download_item["download_id"]
url = download_item["url"]
output_path = download_item["output_path"]
+ token = download_item.get("token")
# Set status to downloading
active_downloads[download_id]["status"] = "downloading"
@@ -162,7 +168,7 @@ async def process_download_queue():
})
# Start download task
- current_download_task = asyncio.create_task(download_file(url, output_path, download_id))
+ current_download_task = asyncio.create_task(download_file(url, output_path, download_id, token=token))
# Add completion callback to process next in queue
current_download_task.add_done_callback(lambda t: on_download_complete(download_id))
@@ -201,7 +207,7 @@ async def download_chunk(session, url, start, end, output_path, chunk_index, dow
return None
-async def download_file(url, output_path, download_id):
+async def download_file(url, output_path, download_id, token=None):
"""Download file with multi-connection support and progress tracking"""
import aiohttp
@@ -216,19 +222,44 @@ async def download_file(url, output_path, download_id):
"lock": asyncio.Lock() # Lock for thread-safe updates
}
+ # Build auth headers for gated HF models
+ auth_headers = {}
+ if token and 'huggingface.co' in url:
+ auth_headers['Authorization'] = f'Bearer {token}'
+ logging.info(f"[RunpodDirect] Using HF token for {download_id}")
+
timeout = aiohttp.ClientTimeout(total=None)
- async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with aiohttp.ClientSession(timeout=timeout, headers=auth_headers) as session:
# Get file size - try HEAD first, then fall back to GET with Range
total_size = 0
supports_range = False
+ # Helper to detect gated/auth errors and produce a useful message
+ def _check_gated_error(status_code, request_url):
+ if status_code in (401, 403, 451) and 'huggingface.co' in request_url:
+ # Convert download URL to repo URL for the user
+ # e.g. https://huggingface.co/org/repo/resolve/main/file.ext -> https://huggingface.co/org/repo
+ repo_url = request_url
+ resolve_idx = request_url.find('/resolve/')
+ if resolve_idx != -1:
+ repo_url = request_url[:resolve_idx]
+ if status_code == 401:
+ raise Exception(f"Authentication required. Provide a valid HF token. Repo: {repo_url}")
+ elif status_code == 403:
+ raise Exception(f"Access denied — you may need to accept the model's terms at {repo_url}")
+ elif status_code == 451:
+ raise Exception(f"Model is restricted. Accept the license agreement at {repo_url}")
+
try:
# Try HEAD request first
async with session.head(url, allow_redirects=True) as response:
+ _check_gated_error(response.status, url)
if response.status == 200:
total_size = int(response.headers.get('content-length', 0))
supports_range = response.headers.get('accept-ranges') == 'bytes'
except Exception as e:
+ if 'Accept the license' in str(e) or 'Access denied' in str(e) or 'Authentication required' in str(e):
+ raise
logging.warning(f"HEAD request failed for {download_id}: {e}")
# If HEAD didn't give us the size, try GET with Range header
@@ -237,6 +268,7 @@ async def download_file(url, output_path, download_id):
try:
headers = {'Range': 'bytes=0-0'}
async with session.get(url, headers=headers, allow_redirects=True) as response:
+ _check_gated_error(response.status, url)
if response.status in [200, 206]:
# Try to get size from Content-Range header first
content_range = response.headers.get('content-range', '')
@@ -251,6 +283,8 @@ async def download_file(url, output_path, download_id):
if total_size == 0:
total_size = int(response.headers.get('content-length', 0))
except Exception as e:
+ if 'Accept the license' in str(e) or 'Access denied' in str(e) or 'Authentication required' in str(e):
+ raise
logging.warning(f"GET with Range failed for {download_id}: {e}")
if total_size == 0:
@@ -340,6 +374,9 @@ async def download_chunk_with_progress(session, url, start, end, output_path, ch
try:
async with session.get(url, headers=headers) as response:
+ if response.status in (401, 403, 451) and 'huggingface.co' in url:
+ repo_url = url[:url.find('/resolve/')] if '/resolve/' in url else url
+ raise Exception(f"Access denied — accept the model's terms at {repo_url}")
if response.status not in [200, 206]:
raise Exception(f"HTTP {response.status} for chunk {chunk_index}")
@@ -391,6 +428,9 @@ async def download_single_connection(session, url, output_path, download_id, tot
downloaded_size = 0
async with session.get(url) as response:
+ if response.status in (401, 403, 451) and 'huggingface.co' in url:
+ repo_url = url[:url.find('/resolve/')] if '/resolve/' in url else url
+ raise Exception(f"Access denied — accept the model's terms at {repo_url}")
if response.status != 200:
raise Exception(f"HTTP {response.status}")
@@ -540,6 +580,77 @@ async def cancel_download(request):
return web.json_response({"error": str(e)}, status=500)
+@PromptServer.instance.routes.get("/server_download/hf_token_status")
+async def hf_token_status(request):
+ """Check if HF_TOKEN environment variable is set (without exposing the value)"""
+ has_token = bool(os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN"))
+ return web.json_response({"has_token": has_token})
+
+
+@PromptServer.instance.routes.post("/server_download/validate_hf_token")
+async def validate_hf_token(request):
+ """Validate a Hugging Face token and optionally check access to specific model URLs"""
+ import aiohttp
+
+ try:
+ json_data = await request.json()
+ token = json_data.get("token", "")
+ urls = json_data.get("urls", []) # Optional: list of model URLs to check access
+
+ # If token is the sentinel '__env__', use the environment variable
+ if token == "__env__":
+ token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or ""
+
+ if not token:
+ return web.json_response({"valid": False, "error": "No token provided"}, status=400)
+
+ # Validate token against HF API
+ timeout = aiohttp.ClientTimeout(total=10)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ # Check token validity via whoami endpoint
+ async with session.get(
+ "https://huggingface.co/api/whoami-v2",
+ headers={"Authorization": f"Bearer {token}"}
+ ) as response:
+ if response.status != 200:
+ return web.json_response({"valid": False, "error": "Invalid token"})
+ user_data = await response.json()
+ username = user_data.get("name", "unknown")
+
+ # If URLs provided, check access to each
+ url_access = {}
+ for url in urls[:10]: # Limit to 10 URLs
+ try:
+ async with session.head(
+ url,
+ headers={"Authorization": f"Bearer {token}"},
+ allow_redirects=True
+ ) as resp:
+ if resp.status == 200:
+ url_access[url] = {"accessible": True}
+ elif resp.status in (401, 403, 451):
+ repo_url = url[:url.find('/resolve/')] if '/resolve/' in url else url
+ url_access[url] = {
+ "accessible": False,
+ "reason": "terms_not_accepted",
+ "repo_url": repo_url
+ }
+ else:
+ url_access[url] = {"accessible": False, "reason": f"HTTP {resp.status}"}
+ except Exception:
+ url_access[url] = {"accessible": False, "reason": "request_failed"}
+
+ return web.json_response({
+ "valid": True,
+ "username": username,
+ "url_access": url_access
+ })
+
+ except Exception as e:
+ logging.error(f"Error validating HF token: {e}")
+ return web.json_response({"valid": False, "error": str(e)}, status=500)
+
+
@PromptServer.instance.routes.get("/extensions/ComfyUI-RunpodDirect/serverDownload.js")
async def serve_js_with_version(request):
"""Serve JS file with cache-busting headers"""
@@ -559,7 +670,7 @@ async def serve_js_with_version(request):
WEB_DIRECTORY = "./web"
# Version for cache busting - increment this when you update the JS
-__version__ = "1.0.6"
+__version__ = "1.0.7"
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
diff --git a/pyproject.toml b/pyproject.toml
index 76bef85..26ba56e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "comfyui-runpoddirect"
description = "Server-side model download extension for ComfyUI on Runpod - Download missing models directly to your pod with multi-connection downloads, queue management, and real-time progress tracking"
-version = "1.0.6"
+version = "1.0.7"
license = {file = "LICENSE"}
authors = [
{name = "Madiator2011"},
diff --git a/web/serverDownload.js b/web/serverDownload.js
index ba22573..d96981a 100644
--- a/web/serverDownload.js
+++ b/web/serverDownload.js
@@ -2,8 +2,8 @@ import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
// ComfyUI RunpodDirect Extension
-// Version: 1.0.6
-console.log('[RunpodDirect] v1.0.6');
+// Version: 1.0.7 - Updated for ComfyUI v0.17.0+ (frontend v1.42+)
+console.log('[RunpodDirect] v1.0.7');
// Track download states
const downloadStates = new Map();
@@ -13,7 +13,48 @@ let completedDownloads = 0;
let totalDownloads = 0;
let downloadStartTimes = new Map();
-// Format bytes to human readable
+// Session-only HF token (never persisted to disk)
+let sessionHfToken = null;
+let envHasHfToken = false;
+
+// Badge label -> folder_paths directory name mapping
+const BADGE_TO_DIRECTORY = {
+ 'VAE': 'vae',
+ 'DIFFUSION': 'diffusion_models',
+ 'TEXT ENCODER': 'text_encoders',
+ 'LORA': 'loras',
+ 'CHECKPOINT': 'checkpoints',
+ 'CLIP': 'clip',
+ 'CLIP_VISION': 'clip_vision',
+ 'CONTROLNET': 'controlnet',
+ 'UPSCALE_MODELS': 'upscale_models',
+ 'LATENT_UPSCALE_MODELS': 'latent_upscale_models',
+ 'EMBEDDINGS': 'embeddings',
+ 'HYPERNETWORKS': 'hypernetworks',
+ 'STYLE_MODELS': 'style_models',
+ 'GLIGEN': 'gligen',
+ 'UNET': 'unet',
+};
+
+const THEME = {
+ // Status colors
+ primary: 'var(--primary-background)',
+ primaryHover:'var(--primary-background-hover)',
+ success: 'var(--success-background)',
+ error: 'var(--destructive-background)',
+ warning: 'var(--warning-background)',
+ // Text
+ foreground: 'var(--base-foreground)',
+ muted: 'var(--muted-foreground)',
+ // Backgrounds
+ baseBg: 'var(--base-background)',
+ secondaryBg: 'var(--secondary-background)',
+ secondaryBgHover: 'var(--secondary-background-hover)',
+ // Borders
+ border: 'var(--border-default)',
+ borderSubtle:'var(--border-subtle)',
+};
+
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -22,37 +63,32 @@ function formatBytes(bytes) {
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
-// Calculate download speed
function calculateSpeed(downloadId, downloaded) {
const startTime = downloadStartTimes.get(downloadId);
if (!startTime) return '0 MB/s';
-
const elapsedSeconds = (Date.now() - startTime) / 1000;
if (elapsedSeconds < 1) return 'Calculating...';
-
const bytesPerSecond = downloaded / elapsedSeconds;
return formatBytes(bytesPerSecond) + '/s';
}
-// Listen for server download events
+function statusColor(status) {
+ if (status === 'downloading') return THEME.primary;
+ if (status === 'completed') return THEME.success;
+ if (status === 'error') return THEME.error;
+ if (status === 'paused' || status === 'queued') return THEME.warning;
+ return THEME.primary;
+}
+
+// --- WebSocket event listeners ---
+
api.addEventListener("server_download_progress", ({ detail }) => {
const { download_id, progress, downloaded, total } = detail;
-
if (!downloadStartTimes.has(download_id)) {
downloadStartTimes.set(download_id, Date.now());
}
-
const speed = calculateSpeed(download_id, downloaded);
-
- downloadStates.set(download_id, {
- status: 'downloading',
- progress,
- downloaded,
- total,
- speed
- });
-
- // Trigger update event for UI components
+ downloadStates.set(download_id, { status: 'downloading', progress, downloaded, total, speed });
window.dispatchEvent(new CustomEvent('serverDownloadUpdate', {
detail: { download_id, ...downloadStates.get(download_id) }
}));
@@ -60,106 +96,69 @@ api.addEventListener("server_download_progress", ({ detail }) => {
api.addEventListener("server_download_complete", ({ detail }) => {
const { download_id, path, size } = detail;
-
- // Increment counter BEFORE updating UI
if (isDownloadingAll) {
completedDownloads++;
console.log(`[RunpodDirect] Progress: ${completedDownloads}/${totalDownloads} completed`);
}
-
- downloadStates.set(download_id, {
- status: 'completed',
- progress: 100,
- path,
- size
- });
-
+ downloadStates.set(download_id, { status: 'completed', progress: 100, path, size });
window.dispatchEvent(new CustomEvent('serverDownloadUpdate', {
detail: { download_id, ...downloadStates.get(download_id) }
}));
-
console.log(`Download completed: ${download_id} -> ${path}`);
-
- // Check if all downloads are done
if (isDownloadingAll && completedDownloads >= totalDownloads) {
console.log('[RunpodDirect] All downloads completed!');
isDownloadingAll = false;
- showRefreshPrompt();
+ window.dispatchEvent(new CustomEvent('serverDownloadAllDone'));
}
});
api.addEventListener("server_download_error", ({ detail }) => {
const { download_id, error } = detail;
-
- // Increment counter BEFORE updating UI
if (isDownloadingAll) {
completedDownloads++;
console.log(`[RunpodDirect] Progress: ${completedDownloads}/${totalDownloads} completed (1 error)`);
}
-
- downloadStates.set(download_id, {
- status: 'error',
- error
- });
-
+ downloadStates.set(download_id, { status: 'error', error });
window.dispatchEvent(new CustomEvent('serverDownloadUpdate', {
detail: { download_id, ...downloadStates.get(download_id) }
}));
-
console.error(`Download error: ${download_id} - ${error}`);
-
- // Check if all downloads are done (including failed ones)
if (isDownloadingAll && completedDownloads >= totalDownloads) {
console.log('[RunpodDirect] All downloads completed!');
isDownloadingAll = false;
- showRefreshPrompt();
+ window.dispatchEvent(new CustomEvent('serverDownloadAllDone'));
}
});
-// Function to start a server download
+// --- API functions ---
+
async function startServerDownload(url, savePath, filename, markAsQueued = false) {
try {
const download_id = `${savePath}/${filename}`;
-
- // Mark as queued immediately if requested (for Download All)
if (markAsQueued) {
- downloadStates.set(download_id, {
- status: 'queued',
- progress: 0
- });
-
+ downloadStates.set(download_id, { status: 'queued', progress: 0 });
window.dispatchEvent(new CustomEvent('serverDownloadUpdate', {
detail: { download_id, ...downloadStates.get(download_id) }
}));
}
-
+ const body = { url, save_path: savePath, filename };
+ // Pass token for HF downloads (skip if using env var — backend reads it directly)
+ if (sessionHfToken && sessionHfToken !== '__env__' && url.includes('huggingface.co')) {
+ body.token = sessionHfToken;
+ }
const response = await api.fetchApi("/server_download/start", {
method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- url,
- save_path: savePath,
- filename
- })
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body)
});
-
const result = await response.json();
-
if (response.ok) {
- // If not already marked as queued, set as queued now
if (!markAsQueued) {
- downloadStates.set(download_id, {
- status: 'queued',
- progress: 0
- });
-
+ downloadStates.set(download_id, { status: 'queued', progress: 0 });
window.dispatchEvent(new CustomEvent('serverDownloadUpdate', {
detail: { download_id, ...downloadStates.get(download_id) }
}));
}
-
return { success: true, download_id };
} else {
return { success: false, error: result.error };
@@ -170,12 +169,10 @@ async function startServerDownload(url, savePath, filename, markAsQueued = false
}
}
-// Get download status
function getDownloadStatus(downloadId) {
return downloadStates.get(downloadId) || null;
}
-// Pause download
async function pauseDownload(downloadId) {
try {
const response = await api.fetchApi("/server_download/pause", {
@@ -183,7 +180,6 @@ async function pauseDownload(downloadId) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ download_id: downloadId })
});
-
const result = await response.json();
return { success: response.ok, ...result };
} catch (error) {
@@ -192,7 +188,6 @@ async function pauseDownload(downloadId) {
}
}
-// Resume download
async function resumeDownload(downloadId) {
try {
const response = await api.fetchApi("/server_download/resume", {
@@ -200,7 +195,6 @@ async function resumeDownload(downloadId) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ download_id: downloadId })
});
-
const result = await response.json();
return { success: response.ok, ...result };
} catch (error) {
@@ -209,7 +203,6 @@ async function resumeDownload(downloadId) {
}
}
-// Cancel download
async function cancelDownload(downloadId) {
try {
const response = await api.fetchApi("/server_download/cancel", {
@@ -217,7 +210,6 @@ async function cancelDownload(downloadId) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ download_id: downloadId })
});
-
const result = await response.json();
return { success: response.ok, ...result };
} catch (error) {
@@ -226,499 +218,892 @@ async function cancelDownload(downloadId) {
}
}
-// Process download queue - Sends all downloads to backend which handles queue management
async function processDownloadQueue() {
if (downloadQueue.length === 0) {
console.log('[RunpodDirect] No downloads in queue');
return;
}
-
- // Send all downloads to the backend (backend handles queue and priorities)
- console.log(`[RunpodDirect] Starting ${downloadQueue.length} downloads (backend will queue and prioritize)`);
-
+ console.log(`[RunpodDirect] Starting ${downloadQueue.length} downloads`);
const downloadsToStart = [...downloadQueue];
- downloadQueue = []; // Clear queue as we're sending all to backend
-
- // Start all downloads - backend will queue and manage priorities
- // Pass markAsQueued=true so buttons show "Queued" status immediately
+ downloadQueue = [];
for (const download of downloadsToStart) {
console.log(`[RunpodDirect] Queuing download ${download.filename}`);
await startServerDownload(download.url, download.directory, download.filename, true);
}
-
console.log(`[RunpodDirect] All ${downloadsToStart.length} downloads queued on backend`);
}
-// Show refresh prompt
-function showRefreshPrompt() {
- const dialog = document.querySelector('[role="dialog"]');
- if (!dialog) return;
+// --- DOM helpers (safe, no innerHTML) ---
- // Find the dialog content area
- const dialogContent = dialog.querySelector('.p-dialog-content');
- if (!dialogContent) return;
+function createEl(tag, styles, textContent) {
+ const el = document.createElement(tag);
+ if (styles) Object.assign(el.style, styles);
+ if (textContent) el.textContent = textContent;
+ return el;
+}
- // Check if prompt already exists
- if (document.querySelector('.server-download-refresh-prompt')) {
- console.log('[RunpodDirect] Refresh prompt already shown');
- return;
+function extractModelsFromPinia() {
+ // Find the Vue app root element (mounted on #vue-app)
+ const rootEl = document.getElementById('vue-app')
+ || document.querySelector('[data-v-app]')
+ || document.getElementById('app');
+ if (!rootEl) {
+ console.log('[RunpodDirect] No Vue root element found');
+ return null;
}
- // Create refresh prompt
- const refreshPrompt = document.createElement('div');
- refreshPrompt.className = 'server-download-refresh-prompt';
- refreshPrompt.style.cssText = `
- margin-top: 20px;
- padding: 16px;
- background: #4caf50;
- color: white;
- border-radius: 8px;
- text-align: center;
- font-weight: 500;
- `;
+ const vueApp = rootEl.__vue_app__;
+ if (!vueApp) {
+ console.log('[RunpodDirect] No __vue_app__ on root element');
+ return null;
+ }
- refreshPrompt.innerHTML = `
-
- ✅ All models downloaded successfully!
-
-
- `;
+ // Access Pinia through Vue app's global properties
+ const pinia = vueApp.config.globalProperties.$pinia;
+ if (!pinia || !pinia._s) {
+ console.log('[RunpodDirect] No Pinia store registry found');
+ return null;
+ }
- dialogContent.appendChild(refreshPrompt);
+ // Get the dialog store (registered as 'dialog')
+ const dialogStore = pinia._s.get('dialog');
+ if (!dialogStore) {
+ console.log('[RunpodDirect] Dialog store not found in Pinia');
+ return null;
+ }
+
+ // Find the missing models dialog in the stack
+ const stack = dialogStore.dialogStack;
+ if (!stack || !Array.isArray(stack)) {
+ console.log('[RunpodDirect] dialogStack not found or not an array');
+ return null;
+ }
+
+ const dialog = stack.find(d => d.key === 'global-missing-models-warning');
+ if (!dialog) {
+ console.log('[RunpodDirect] Missing models dialog not found in stack');
+ return null;
+ }
+
+ const missingModels = dialog.contentProps?.missingModels;
+ if (!missingModels || !Array.isArray(missingModels)) {
+ console.log('[RunpodDirect] No missingModels in dialog contentProps');
+ return null;
+ }
+
+ console.log(`[RunpodDirect] Found ${missingModels.length} models from Pinia dialog store`);
+ return missingModels.map(m => ({
+ filename: m.name,
+ url: m.url,
+ directory: m.directory,
+ }));
}
-// Create global progress area with individual progress bars for each download
-function createProgressArea(listbox) {
- // Remove existing progress area if any
- const existing = document.querySelector('.server-download-progress-area');
- if (existing) existing.remove();
+// Fallback: extract model info from DOM when Vue internals aren't accessible
+function extractModelsFromDOM(container) {
+ const rows = getModelRows(container);
+ const models = [];
+ rows.forEach((row) => {
+ const leftSide = row.querySelector('[class*="overflow-hidden"]');
+ if (!leftSide) return;
- const progressArea = document.createElement('div');
- progressArea.className = 'server-download-progress-area';
- progressArea.style.cssText = `
- margin-top: 20px;
- padding: 16px;
- background: var(--p-content-background, #1e1e1e);
- border: 1px solid var(--p-content-border-color, #333);
- border-radius: 8px;
- `;
+ const nameSpan = leftSide.querySelector('span[title]');
+ if (!nameSpan) return;
+ const filename = nameSpan.getAttribute('title') || nameSpan.textContent.trim();
- progressArea.innerHTML = `
-
-
- Download Progress
-
-
-
- Overall: 0/${totalDownloads} models completed
-
-
-
-
- `;
-
- listbox.parentElement.appendChild(progressArea);
-
- // Listen for updates
- const updateHandler = (event) => {
- const { download_id, status, progress, downloaded, total, speed } = event.detail;
-
- if (!isDownloadingAll) {
- return;
+ const badgeSpan = leftSide.querySelector('span[class*="rounded-full"]');
+ let directory = null;
+ if (badgeSpan) {
+ const badgeText = badgeSpan.textContent.trim().toUpperCase();
+ directory = BADGE_TO_DIRECTORY[badgeText] || badgeText.toLowerCase();
}
- // Update overall progress
- const overallProgress = document.getElementById('server-download-overall-progress');
- if (overallProgress) {
- overallProgress.textContent = `Overall: ${completedDownloads}/${totalDownloads} models completed`;
- }
-
- // Update or create individual progress item
- updateDownloadProgressItem(download_id, status, progress, downloaded, total, speed);
- };
-
- window.addEventListener('serverDownloadUpdate', updateHandler);
-}
-
-// Update or create a progress item for a specific download
-function updateDownloadProgressItem(download_id, status, progress, downloaded, total, speed) {
- // Declare variables at function scope so they're accessible across try blocks
- let item = null;
- let container = null;
- const itemId = `download-item-${download_id.replace(/\//g, '-')}`;
-
- try {
- container = document.getElementById('server-download-items-container');
- if (!container) return;
-
- item = document.getElementById(itemId);
-
- // Don't show queued items
- if (status === 'queued') {
- if (item) item.remove();
- return;
- }
-
- // Remove completed/error items after a delay
- if (status === 'completed' || status === 'error') {
- if (item && !item.dataset.removing) {
- item.dataset.removing = 'true';
- setTimeout(() => {
- try {
- if (item && item.parentNode) item.remove();
- } catch (e) {
- console.error('[RunpodDirect] Error removing progress item:', e);
- }
- }, 2000);
+ // Try button title or anchor href for URL
+ const rightSide = row.querySelector('[class*="shrink-0"]');
+ let url = null;
+ if (rightSide) {
+ const urlButton = rightSide.querySelector('button[title]');
+ if (urlButton) url = urlButton.getAttribute('title');
+ if (!url) {
+ const urlAnchor = rightSide.querySelector('a[href]');
+ if (urlAnchor) url = urlAnchor.getAttribute('href');
}
}
- // Create new item if it doesn't exist
- if (!item) {
- item = document.createElement('div');
- item.id = itemId;
- item.style.cssText = `
- padding: 12px;
- background: rgba(255, 255, 255, 0.05);
- border-radius: 6px;
- border: 1px solid rgba(255, 255, 255, 0.1);
- `;
- container.appendChild(item);
+ if (filename && directory && url) {
+ models.push({ filename, directory, url });
}
- } catch (e) {
- console.error('[RunpodDirect] Error in updateDownloadProgressItem:', e);
- return;
- }
+ });
+ return models.length > 0 ? models : null;
+}
+
+function extractModelsFromFooter() {
+ return null;
+}
+
+// Check if HF_TOKEN env var is set on the backend
+async function checkEnvHfToken() {
try {
- // Status icon and color (no priority badge needed - downloading one at a time)
- let statusIcon = '';
- let statusColor = '#2196F3';
- if (status === 'downloading') {
- statusIcon = '';
- statusColor = '#2196F3';
- } else if (status === 'completed') {
- statusIcon = '';
- statusColor = '#4CAF50';
- } else if (status === 'error') {
- statusIcon = '';
- statusColor = '#ef4444';
- } else if (status === 'paused') {
- statusIcon = '';
- statusColor = '#FF9800';
- }
-
- const progressPercent = progress || 0;
- const speedText = speed || '--';
- const sizeText = downloaded && total ? `${formatBytes(downloaded)} / ${formatBytes(total)}` : '--';
-
- if (item) {
- item.innerHTML = `
-
-
- ${statusIcon}${download_id}
-
-
- ${progressPercent.toFixed(1)}%
-
-
-
-
- Speed: ${speedText}
- ${sizeText}
-
- `;
+ const response = await api.fetchApi("/server_download/hf_token_status");
+ if (response.ok) {
+ const data = await response.json();
+ envHasHfToken = data.has_token;
}
} catch (e) {
- console.error('[RunpodDirect] Error updating progress item HTML:', e);
+ // Ignore - endpoint may not exist on older backend
}
}
-// Export functions for use in other modules
+// Detect gated models from the DOM (rows with "Accept terms" links)
+function detectGatedModels(container) {
+ const gated = [];
+ const rows = getModelRows(container);
+ for (const row of rows) {
+ const link = row.querySelector('a[target="_blank"]');
+ if (link && link.textContent.toLowerCase().includes('accept')) {
+ const leftSide = row.querySelector('[class*="overflow-hidden"]');
+ if (!leftSide) continue;
+ const nameSpan = leftSide.querySelector('span[title]');
+ if (!nameSpan) continue;
+ const filename = nameSpan.getAttribute('title') || nameSpan.textContent.trim();
+ const badgeSpan = leftSide.querySelector('span[class*="rounded-full"]');
+ let directory = null;
+ if (badgeSpan) {
+ const badgeText = badgeSpan.textContent.trim().toUpperCase();
+ directory = BADGE_TO_DIRECTORY[badgeText] || badgeText.toLowerCase();
+ }
+ gated.push({ filename, directory, repoUrl: link.href });
+ // Remove the native "Accept terms" link — our token section handles it
+ link.remove();
+ }
+ }
+ return gated;
+}
+
+// Validate HF token against backend and check access to specific URLs
+async function validateHfToken(token, urls) {
+ try {
+ const response = await api.fetchApi("/server_download/validate_hf_token", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token, urls })
+ });
+ if (response.ok) return await response.json();
+ return { valid: false, error: 'Validation request failed' };
+ } catch (e) {
+ return { valid: false, error: e.message };
+ }
+}
+
+// Token section for gated models.
+// Sits below the model list. Controls whether the download button gets enabled.
+//
+// States:
+// 1. Initial: show input + Verify btn. Download btn disabled.
+// 2. Token invalid: show error. Download btn disabled.
+// 3. Token valid, ALL gated accessible: green success. Download btn enabled for all.
+// 4. Token valid, SOME need terms: show which models need terms (with links). Download btn disabled.
+// User can click Accept terms → go to HF → come back → click Verify again.
+function createTokenSection(dialog, gatedModels, gatedWithUrls, callbacks) {
+ if (document.querySelector('.server-download-token-section')) return;
+
+ const modelList = dialog.querySelector('[class*="scrollbar-custom"][class*="overflow-y-auto"][class*="rounded-lg"]');
+ if (!modelList) return;
+
+ const section = createEl('div', {
+ borderRadius: '0.5rem',
+ backgroundColor: THEME.secondaryBg,
+ overflow: 'hidden',
+ });
+ section.className = 'server-download-token-section';
+
+ // Header
+ const header = createEl('div', {
+ padding: '8px 12px',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ });
+ const headerTitle = createEl('span', {
+ fontSize: '0.75rem',
+ fontWeight: '600',
+ color: THEME.foreground,
+ }, 'HF Token Required');
+ const headerStatus = createEl('span', {
+ fontSize: '0.6875rem',
+ color: THEME.muted,
+ }, `${gatedModels.length} gated model${gatedModels.length > 1 ? 's' : ''}`);
+ header.appendChild(headerTitle);
+ header.appendChild(headerStatus);
+ section.appendChild(header);
+
+ // Body area — holds input, status messages, terms list
+ const body = createEl('div', {
+ padding: '0 12px 10px 12px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '8px',
+ });
+ section.appendChild(body);
+
+ // Status/message area
+ const statusEl = createEl('div', {
+ fontSize: '0.6875rem',
+ color: THEME.muted,
+ lineHeight: '1.4',
+ });
+
+ // Terms list container (shown when some models need terms)
+ const termsListEl = createEl('div', {
+ display: 'none',
+ flexDirection: 'column',
+ gap: '4px',
+ });
+
+ function showTermsList(deniedModels) {
+ while (termsListEl.firstChild) termsListEl.removeChild(termsListEl.firstChild);
+ termsListEl.style.display = 'flex';
+ for (const m of deniedModels) {
+ const row = createEl('div', {
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: '4px 0',
+ });
+ const name = createEl('span', {
+ fontSize: '0.75rem',
+ color: THEME.foreground,
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ minWidth: '0',
+ }, m.filename);
+ const btn = createEl('button', {
+ backgroundColor: THEME.secondaryBgHover,
+ color: THEME.foreground,
+ border: 'none',
+ height: '22px',
+ padding: '0 8px',
+ fontSize: '0.6875rem',
+ fontWeight: '500',
+ borderRadius: '0.375rem',
+ cursor: 'pointer',
+ whiteSpace: 'nowrap',
+ flexShrink: '0',
+ marginLeft: '8px',
+ }, 'Accept terms');
+ btn.type = 'button';
+ btn.onclick = () => window.open(m.repoUrl, '_blank', 'noopener,noreferrer');
+ row.appendChild(name);
+ row.appendChild(btn);
+ termsListEl.appendChild(row);
+ }
+ }
+
+ function hideTermsList() {
+ termsListEl.style.display = 'none';
+ }
+
+ // Input row builder
+ function buildInputRow() {
+ const row = createEl('div', {
+ display: 'flex',
+ gap: '6px',
+ alignItems: 'center',
+ });
+
+ const input = document.createElement('input');
+ input.type = 'password';
+ input.placeholder = 'hf_...';
+ input.autocomplete = 'off';
+ input.spellcheck = false;
+ Object.assign(input.style, {
+ flex: '1',
+ height: '28px',
+ padding: '0 8px',
+ fontSize: '0.75rem',
+ borderRadius: '0.375rem',
+ border: `1px solid ${THEME.border}`,
+ backgroundColor: THEME.baseBg,
+ color: THEME.foreground,
+ outline: 'none',
+ fontFamily: 'monospace',
+ minWidth: '0',
+ });
+ input.onfocus = () => { input.style.borderColor = THEME.primary; };
+ input.onblur = () => { input.style.borderColor = THEME.border; };
+
+ const verifyBtn = createEl('button', {
+ backgroundColor: THEME.primary,
+ color: THEME.foreground,
+ border: 'none',
+ height: '28px',
+ padding: '0 10px',
+ fontSize: '0.75rem',
+ fontWeight: '500',
+ borderRadius: '0.375rem',
+ cursor: 'pointer',
+ whiteSpace: 'nowrap',
+ flexShrink: '0',
+ }, 'Verify');
+ verifyBtn.type = 'button';
+
+ verifyBtn.onclick = () => doVerify(input, verifyBtn);
+ input.onkeydown = (e) => { if (e.key === 'Enter') doVerify(input, verifyBtn); };
+
+ row.appendChild(input);
+ row.appendChild(verifyBtn);
+ return row;
+ }
+
+ async function doVerify(input, verifyBtn) {
+ const val = input.value.trim();
+ if (!val || !val.startsWith('hf_')) {
+ statusEl.textContent = 'Token must start with hf_';
+ statusEl.style.color = THEME.error;
+ return;
+ }
+
+ verifyBtn.disabled = true;
+ verifyBtn.textContent = 'Verifying...';
+ verifyBtn.style.opacity = '0.5';
+ statusEl.textContent = '';
+ hideTermsList();
+
+ const result = await validateHfToken(val, gatedWithUrls.map(m => m.url));
+
+ verifyBtn.disabled = false;
+ verifyBtn.textContent = 'Verify';
+ verifyBtn.style.opacity = '1';
+
+ if (!result.valid) {
+ statusEl.textContent = result.error || 'Invalid token';
+ statusEl.style.color = THEME.error;
+ callbacks.onFail();
+ return;
+ }
+
+ // Token valid — check per-model access
+ sessionHfToken = val;
+ const accessible = [];
+ const denied = [];
+ for (const m of gatedWithUrls) {
+ const access = result.url_access?.[m.url];
+ if (access?.accessible) {
+ accessible.push(m);
+ } else {
+ denied.push({ ...m, repoUrl: access?.repo_url || m.url });
+ }
+ }
+
+ if (denied.length === 0) {
+ // All good — green
+ headerStatus.textContent = 'all accessible';
+ headerStatus.style.color = THEME.success;
+ statusEl.textContent = `Verified as ${result.username}`;
+ statusEl.style.color = THEME.success;
+ input.disabled = true;
+ input.style.opacity = '0.5';
+ verifyBtn.textContent = 'OK';
+ verifyBtn.disabled = true;
+ verifyBtn.style.backgroundColor = THEME.success;
+ verifyBtn.style.opacity = '0.7';
+ callbacks.onAllAccessible(gatedWithUrls);
+ } else {
+ // Some need terms
+ statusEl.textContent = `Verified as ${result.username} — ${denied.length} model${denied.length > 1 ? 's' : ''} need terms accepted:`;
+ statusEl.style.color = THEME.warning;
+ showTermsList(denied);
+ // Keep input active so user can re-verify after accepting terms
+ callbacks.onPartialAccess(accessible, denied);
+ }
+ }
+
+ // Auto-validate env token
+ async function autoValidateEnv() {
+ statusEl.textContent = 'HF_TOKEN found in environment. Validating...';
+ statusEl.style.color = THEME.muted;
+ sessionHfToken = '__env__';
+
+ const result = await validateHfToken('__env__', gatedWithUrls.map(m => m.url));
+
+ if (!result.valid) {
+ statusEl.textContent = 'Environment HF_TOKEN is invalid.';
+ statusEl.style.color = THEME.error;
+ sessionHfToken = null;
+ // Show manual input as fallback
+ body.insertBefore(buildInputRow(), statusEl);
+ callbacks.onFail();
+ return;
+ }
+
+ const accessible = [];
+ const denied = [];
+ for (const m of gatedWithUrls) {
+ const access = result.url_access?.[m.url];
+ if (access?.accessible) {
+ accessible.push(m);
+ } else {
+ denied.push({ ...m, repoUrl: access?.repo_url || m.url });
+ }
+ }
+
+ if (denied.length === 0) {
+ headerStatus.textContent = 'all accessible';
+ headerStatus.style.color = THEME.success;
+ statusEl.textContent = `Verified as ${result.username}`;
+ statusEl.style.color = THEME.success;
+ callbacks.onAllAccessible(gatedWithUrls);
+ } else {
+ statusEl.textContent = `Verified as ${result.username} — ${denied.length} model${denied.length > 1 ? 's' : ''} need terms accepted:`;
+ statusEl.style.color = THEME.warning;
+ showTermsList(denied);
+ callbacks.onPartialAccess(accessible, denied);
+ }
+ }
+
+ // Build the body
+ if (envHasHfToken) {
+ body.appendChild(statusEl);
+ body.appendChild(termsListEl);
+ } else {
+ body.appendChild(buildInputRow());
+ body.appendChild(statusEl);
+ body.appendChild(termsListEl);
+ }
+
+ // Insert after model list
+ modelList.parentElement.insertBefore(section, modelList.nextSibling);
+
+ // Auto-validate if env token exists
+ if (envHasHfToken) {
+ autoValidateEnv();
+ }
+
+ return section;
+}
+
+function findMissingModelsContainer() {
+ const containers = document.querySelectorAll('[class*="scrollbar-custom"][class*="overflow-y-auto"][class*="rounded-lg"]');
+ for (const container of containers) {
+ const rows = container.querySelectorAll(':scope > div');
+ if (rows.length > 0) {
+ for (const row of rows) {
+ if (row.querySelector('span[class*="rounded-full"]')) {
+ return container;
+ }
+ }
+ }
+ }
+
+ const dialogs = document.querySelectorAll('[role="dialog"]');
+ for (const dialog of dialogs) {
+ const text = dialog.textContent || '';
+ if (text.includes('missing models') || text.includes('Missing models')) {
+ const scrollable = dialog.querySelector('[class*="overflow-y-auto"]');
+ if (scrollable) return scrollable;
+ }
+ }
+ return null;
+}
+
+function getModelRows(container) {
+ const allRows = container.querySelectorAll(':scope > div');
+ return Array.from(allRows).filter(row => !row.classList.contains('sticky'));
+}
+
+// --- UI injection (theme-aware, matches ComfyUI design system) ---
+
+function createProgressArea(container) {
+ const existing = document.querySelector('.server-download-progress-area');
+ if (existing) existing.remove();
+
+ // Match the model list container style: rounded-lg bg-secondary-background
+ const area = createEl('div', {
+ borderRadius: '0.5rem',
+ backgroundColor: THEME.secondaryBg,
+ overflow: 'hidden',
+ });
+ area.className = 'server-download-progress-area';
+
+ // Header row matching the sticky bottom row style from the model list
+ const header = createEl('div', {
+ padding: '8px 12px',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ borderBottom: `1px solid ${THEME.border}`,
+ });
+ const headerTitle = createEl('span', {
+ fontSize: '0.75rem',
+ fontWeight: '600',
+ color: THEME.foreground,
+ }, 'Download Progress');
+ const headerStatus = createEl('span', {
+ fontSize: '0.75rem',
+ color: THEME.muted,
+ }, `0/${totalDownloads} completed`);
+ headerStatus.id = 'server-download-overall-progress';
+ header.appendChild(headerTitle);
+ header.appendChild(headerStatus);
+ area.appendChild(header);
+
+ const itemsContainer = createEl('div', {
+ display: 'flex',
+ flexDirection: 'column',
+ });
+ itemsContainer.id = 'server-download-items-container';
+ area.appendChild(itemsContainer);
+
+ // Insert after the model list container (inside the same parent flex column)
+ container.parentElement.insertBefore(area, container.nextSibling);
+
+ window.addEventListener('serverDownloadUpdate', (event) => {
+ const { download_id, status, progress, downloaded, total, speed } = event.detail;
+ if (!isDownloadingAll) return;
+
+ const overallEl = document.getElementById('server-download-overall-progress');
+ if (overallEl) {
+ overallEl.textContent = `${completedDownloads}/${totalDownloads} completed`;
+ }
+ updateDownloadProgressItem(download_id, status, progress, downloaded, total, speed);
+ });
+}
+
+function updateDownloadProgressItem(download_id, status, progress, downloaded, total, speed) {
+ const itemId = `download-item-${download_id.replace(/\//g, '-')}`;
+ const container = document.getElementById('server-download-items-container');
+ if (!container) return;
+
+ let item = document.getElementById(itemId);
+
+ if (status === 'queued') {
+ if (item) item.remove();
+ return;
+ }
+
+ if ((status === 'completed' || status === 'error') && item && !item.dataset.removing) {
+ item.dataset.removing = 'true';
+ setTimeout(() => { try { if (item && item.parentNode) item.remove(); } catch (e) { /* */ } }, 2000);
+ }
+
+ if (!item) {
+ // Match model list row style: px-3 py-2 with no extra bg/border
+ item = createEl('div', { padding: '8px 12px' });
+ item.id = itemId;
+ container.appendChild(item);
+ }
+
+ const progressPercent = progress || 0;
+ const speedText = speed || '--';
+ const sizeText = downloaded && total ? `${formatBytes(downloaded)} / ${formatBytes(total)}` : '--';
+
+ while (item.firstChild) item.removeChild(item.firstChild);
+
+ // Row 1: filename + percentage (matches model row: name left, info right)
+ const nameRow = createEl('div', {
+ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px',
+ });
+ const filename = download_id.split('/').pop() || download_id;
+ const nameEl = createEl('span', {
+ fontSize: '0.875rem', color: THEME.foreground, fontWeight: '400',
+ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: '0',
+ }, filename);
+ const pctEl = createEl('span', {
+ fontSize: '0.75rem', color: THEME.muted, flexShrink: '0', paddingLeft: '8px',
+ }, progressPercent.toFixed(1) + '%');
+ nameRow.appendChild(nameEl);
+ nameRow.appendChild(pctEl);
+ item.appendChild(nameRow);
+
+ // Row 2: progress bar
+ const barOuter = createEl('div', {
+ width: '100%', height: '4px',
+ backgroundColor: THEME.secondaryBgHover,
+ borderRadius: '9999px', overflow: 'hidden', marginBottom: '4px',
+ });
+ const color = statusColor(status);
+ const barInner = createEl('div', {
+ height: '100%', backgroundColor: color,
+ borderRadius: '9999px',
+ width: progressPercent + '%', transition: 'width 0.3s',
+ });
+ barOuter.appendChild(barInner);
+ item.appendChild(barOuter);
+
+ // Row 3: speed + size
+ const infoRow = createEl('div', {
+ display: 'flex', justifyContent: 'space-between',
+ fontSize: '0.6875rem', color: THEME.muted,
+ });
+ infoRow.appendChild(createEl('span', {}, speedText));
+ infoRow.appendChild(createEl('span', {}, sizeText));
+ item.appendChild(infoRow);
+}
+
+// Export functions
window.serverDownload = {
start: startServerDownload,
getStatus: getDownloadStatus,
states: downloadStates
};
-// Helper to inject buttons using MutationObserver
+// --- Main injection logic ---
+
+function injectServerDownloadButtons() {
+ console.log('[RunpodDirect] injectServerDownloadButtons called');
+
+ const container = findMissingModelsContainer();
+ if (!container) {
+ console.log('[RunpodDirect] Missing models container not found');
+ return;
+ }
+
+ console.log('[RunpodDirect] Found missing models container');
+
+ if (document.querySelector('.server-download-all-btn')) {
+ console.log('[RunpodDirect] Buttons already injected');
+ return;
+ }
+
+ // Extract model data: try Pinia store first (most reliable - URLs aren't in DOM),
+ // then fall back to DOM parsing
+ let models = null;
+
+ // Strategy 1: Pinia dialog store (most reliable - works in production builds)
+ const piniaModels = extractModelsFromPinia();
+ if (piniaModels && piniaModels.length > 0) {
+ models = piniaModels;
+ }
+
+ // Strategy 2: DOM parsing (fallback - works when buttons with URLs are visible)
+ if (!models) {
+ const domModels = extractModelsFromDOM(container);
+ if (domModels && domModels.length > 0) {
+ models = domModels;
+ console.log(`[RunpodDirect] Got ${models.length} models from DOM parsing`);
+ }
+ }
+
+ // Strategy 3: Footer component fallback
+ if (!models) {
+ const footerData = extractModelsFromFooter();
+ if (footerData && footerData.models?.length > 0) {
+ models = footerData.models;
+ console.log(`[RunpodDirect] Got ${models.length} models from footer component`);
+ }
+ }
+
+ if (!models || models.length === 0) {
+ console.log('[RunpodDirect] No models could be extracted, will retry...');
+ // Retry after a short delay - Pinia store may not be populated yet
+ if (!container.dataset.retryCount || parseInt(container.dataset.retryCount) < 5) {
+ container.dataset.retryCount = (parseInt(container.dataset.retryCount || '0') + 1).toString();
+ setTimeout(() => injectServerDownloadButtons(), 500);
+ } else {
+ console.log('[RunpodDirect] Max retries reached, giving up');
+ }
+ return;
+ }
+
+ // Separate downloadable models from gated ones
+ const gatedModels = detectGatedModels(container);
+ const gatedFilenames = new Set(gatedModels.map(g => g.filename));
+
+ // Models from Pinia include ALL models (including gated ones).
+ const downloadableModels = models.filter(m => !gatedFilenames.has(m.filename));
+ const gatedWithUrls = models.filter(m => gatedFilenames.has(m.filename));
+
+ downloadableModels.forEach((m, i) => {
+ console.log(`[RunpodDirect] Model ${i + 1}: ${m.directory}/${m.filename} -> ${m.url}`);
+ });
+ if (gatedWithUrls.length > 0) {
+ console.log(`[RunpodDirect] ${gatedWithUrls.length} gated model(s) detected`);
+ }
+
+ // Mutable list — starts with only non-gated, grows when token is validated
+ let allModelsToDownload = [...downloadableModels];
+ // Track whether gated models block the download button
+ const hasGated = gatedModels.length > 0;
+ let gatedVerified = false;
+
+ const dialog = container.closest('[role="dialog"]');
+ const footerBtnRow = dialog?.querySelector('div[class*="justify-end"][class*="gap"]');
+
+ // Show gated models section if needed
+ if (hasGated && dialog) {
+ createTokenSection(dialog, gatedModels, gatedWithUrls, {
+ onAllAccessible(accessibleModels) {
+ // All gated models verified — enable download for everything
+ gatedVerified = true;
+ allModelsToDownload = [...downloadableModels, ...accessibleModels];
+ updateBtnCount();
+ console.log(`[RunpodDirect] All gated models accessible, ${allModelsToDownload.length} total`);
+ },
+ onPartialAccess(_accessible, denied) {
+ // Some models need terms — keep button disabled
+ gatedVerified = false;
+ allModelsToDownload = [...downloadableModels];
+ updateBtnCount();
+ console.log(`[RunpodDirect] ${denied.length} model(s) need terms accepted`);
+ },
+ onFail() {
+ // Invalid token — keep button disabled
+ gatedVerified = false;
+ allModelsToDownload = [...downloadableModels];
+ updateBtnCount();
+ console.log('[RunpodDirect] Token validation failed');
+ },
+ });
+ }
+
+ // Match ComfyUI's primary button: bg-primary-background text-base-foreground h-8 rounded-lg text-xs
+ const downloadAllBtn = createEl('button', {
+ backgroundColor: THEME.primary,
+ color: THEME.foreground,
+ border: 'none',
+ height: '32px',
+ padding: '0 8px',
+ fontSize: '0.75rem',
+ fontWeight: '500',
+ borderRadius: '0.5rem',
+ cursor: 'pointer',
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ whiteSpace: 'nowrap',
+ transition: 'background-color 0.15s',
+ });
+ downloadAllBtn.className = 'server-download-all-btn';
+ downloadAllBtn.type = 'button';
+ downloadAllBtn.title = 'Download all models directly to this RunPod instance (server-side)';
+
+ function updateBtnCount() {
+ const count = allModelsToDownload.length;
+ downloadAllBtn.textContent = `Download to Pod (${count})`;
+ // Disable when: no models OR gated models exist but token not verified
+ const shouldDisable = count === 0 || (hasGated && !gatedVerified);
+ if (shouldDisable) {
+ downloadAllBtn.disabled = true;
+ downloadAllBtn.style.opacity = '0.5';
+ downloadAllBtn.style.cursor = 'default';
+ } else {
+ downloadAllBtn.disabled = false;
+ downloadAllBtn.style.opacity = '1';
+ downloadAllBtn.style.cursor = 'pointer';
+ }
+ }
+ updateBtnCount();
+
+ downloadAllBtn.onmouseenter = () => { if (!downloadAllBtn.disabled) downloadAllBtn.style.backgroundColor = THEME.primaryHover; };
+ downloadAllBtn.onmouseleave = () => { if (!downloadAllBtn.disabled) downloadAllBtn.style.backgroundColor = THEME.primary; };
+
+ function setButtonRefresh() {
+ downloadAllBtn.disabled = false;
+ downloadAllBtn.style.opacity = '1';
+ downloadAllBtn.style.pointerEvents = 'auto';
+ downloadAllBtn.style.backgroundColor = THEME.success;
+ downloadAllBtn.style.color = THEME.foreground;
+ downloadAllBtn.textContent = 'Refresh Page';
+ downloadAllBtn.onmouseenter = null;
+ downloadAllBtn.onmouseleave = null;
+ downloadAllBtn.onclick = () => location.reload();
+ }
+
+ downloadAllBtn.onclick = async (e) => {
+ e.stopPropagation();
+ downloadAllBtn.disabled = true;
+ downloadAllBtn.style.opacity = '0.5';
+ downloadAllBtn.style.cursor = 'default';
+ downloadAllBtn.textContent = 'Downloading...';
+
+ downloadQueue = allModelsToDownload.map(m => ({ url: m.url, directory: m.directory, filename: m.filename }));
+ totalDownloads = allModelsToDownload.length;
+ completedDownloads = 0;
+ isDownloadingAll = true;
+
+ createProgressArea(container);
+
+ if (downloadQueue.length > 0) {
+ processDownloadQueue();
+ }
+ };
+
+ // Listen for all downloads completing to show refresh button and remove progress area
+ window.addEventListener('serverDownloadAllDone', () => {
+ setButtonRefresh();
+ const progressArea = document.querySelector('.server-download-progress-area');
+ if (progressArea) progressArea.remove();
+ });
+
+ if (footerBtnRow) {
+ // Insert before the native "Download all" button
+ footerBtnRow.insertBefore(downloadAllBtn, footerBtnRow.firstChild);
+ } else {
+ // Fallback: place above the model list
+ const fallbackContainer = createEl('div', {
+ padding: '0 16px 8px 16px',
+ display: 'flex',
+ justifyContent: 'center',
+ });
+ fallbackContainer.appendChild(downloadAllBtn);
+ container.parentElement.insertBefore(fallbackContainer, container);
+ }
+
+ console.log('[RunpodDirect] Button injection complete');
+}
+
+// MutationObserver for detecting the missing models dialog
function setupDialogObserver() {
console.log('[RunpodDirect] Setting up dialog observer');
- // Watch for the missing models dialog to appear
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.addedNodes.length) {
mutation.addedNodes.forEach(node => {
- if (node.nodeType === Node.ELEMENT_NODE) {
- // Check if this node or its descendants contain the missing models dialog
- const hasDialog = node.querySelector && (
- node.querySelector('.comfy-missing-models') ||
- node.classList?.contains('comfy-missing-models')
- );
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
- if (hasDialog) {
- console.log('[RunpodDirect] Detected missing models dialog, injecting buttons...');
- setTimeout(() => {
- injectServerDownloadButtons();
- }, 500);
- }
+ const isDialog = node.getAttribute?.('role') === 'dialog' ||
+ node.querySelector?.('[role="dialog"]');
+
+ if (isDialog) {
+ setTimeout(() => {
+ const dialog = node.getAttribute?.('role') === 'dialog'
+ ? node
+ : node.querySelector('[role="dialog"]');
+ if (dialog) {
+ const text = dialog.textContent || '';
+ if (text.includes('missing models') || text.includes('Missing models')) {
+ console.log('[RunpodDirect] Detected missing models dialog');
+ setTimeout(() => injectServerDownloadButtons(), 500);
+ }
+ }
+ }, 300);
}
});
}
}
});
- // Start observing the document body for dialog additions
- observer.observe(document.body, {
- childList: true,
- subtree: true
- });
-
+ observer.observe(document.body, { childList: true, subtree: true });
console.log('[RunpodDirect] Observer active');
}
-function injectServerDownloadButtons() {
- console.log('[RunpodDirect] injectServerDownloadButtons called');
-
- // Find the missing models listbox
- const listbox = document.querySelector('.comfy-missing-models');
- if (!listbox) {
- console.log('[RunpodDirect] Missing models listbox not found');
- return;
- }
-
- console.log('[RunpodDirect] Found .comfy-missing-models listbox');
-
- // Check if we already added our UI
- if (document.querySelector('.server-download-all-btn')) {
- console.log('[RunpodDirect] Buttons already injected');
- return;
- }
-
- const listItems = listbox.querySelectorAll('.p-listbox-option');
- console.log(`[RunpodDirect] Found ${listItems.length} list items`);
-
- if (listItems.length === 0) {
- console.log('[RunpodDirect] No list items found');
- return;
- }
-
- // Add "Download All" button before the listbox
- const downloadAllContainer = document.createElement('div');
- downloadAllContainer.style.cssText = 'margin-bottom: 16px; display: flex; justify-content: center;';
-
- const downloadAllBtn = document.createElement('button');
- downloadAllBtn.className = 'server-download-all-btn p-button p-component p-button-sm';
- downloadAllBtn.type = 'button';
- downloadAllBtn.style.cssText = 'background: #2196F3; color: white; border: none; padding: 10px 20px; font-weight: 600;';
-
- const downloadAllLabel = document.createElement('span');
- downloadAllLabel.className = 'p-button-label';
- downloadAllLabel.textContent = `Download All Models to Pod (${listItems.length})`;
- downloadAllBtn.appendChild(downloadAllLabel);
-
- downloadAllBtn.onclick = async (e) => {
- e.stopPropagation();
- downloadAllBtn.disabled = true;
- downloadAllLabel.textContent = 'Starting downloads...';
-
- // Collect all models
- downloadQueue = [];
- const models = [];
-
- listItems.forEach((item) => {
- const labelElement = item.querySelector('[title]');
- if (!labelElement) return;
-
- const label = labelElement.textContent.trim();
- const url = labelElement.getAttribute('title');
- const parts = label.split('/').map(p => p.trim());
- if (parts.length !== 2) return;
-
- const directory = parts[0];
- const filename = parts[1];
-
- models.push({ url, directory, filename });
- });
-
- downloadQueue = [...models];
- totalDownloads = models.length;
- completedDownloads = 0;
- isDownloadingAll = true;
-
- // Create progress area
- createProgressArea(listbox);
-
- // Start first download
- if (downloadQueue.length > 0) {
- processDownloadQueue();
- }
- };
-
- downloadAllContainer.appendChild(downloadAllBtn);
- listbox.parentElement.insertBefore(downloadAllContainer, listbox);
-
- listItems.forEach((item, index) => {
- console.log(`[RunpodDirect] Processing item ${index + 1}`);
-
- // Check if we already added the button
- if (item.querySelector('.server-download-btn')) {
- console.log(`[RunpodDirect] Item ${index + 1} already has server download button, skipping`);
- return;
- }
-
- // The HTML structure is:
- //
- // <- main container
- //
model info
- //
- //
- //
- //
-
- // Find the main flex container
- const mainContainer = item.querySelector('.flex.flex-row.items-center.gap-2');
- if (!mainContainer) {
- console.log('[RunpodDirect] Main flex container not found');
- return;
- }
-
- console.log('[RunpodDirect] Found main container');
-
- // We'll create a new div for our button (following the same pattern)
- const buttonWrapper = document.createElement('div');
-
- // Get model info from the item
- const labelElement = item.querySelector('[title]');
- if (!labelElement) {
- console.log('[RunpodDirect] No title element found');
- return;
- }
-
- const label = labelElement.textContent.trim();
- const url = labelElement.getAttribute('title');
- console.log(`[RunpodDirect] Model: ${label}, URL: ${url}`);
-
- // Parse "checkpoints / model.safetensors" format
- const parts = label.split('/').map(p => p.trim());
- if (parts.length !== 2) {
- console.log(`[RunpodDirect] Could not parse label format: ${label}`);
- return;
- }
-
- const directory = parts[0];
- const filename = parts[1];
- const download_id = `${directory}/${filename}`;
- console.log(`[RunpodDirect] Creating button for ${download_id}`);
-
- // Create server download button
- const serverDownloadBtn = document.createElement('button');
- serverDownloadBtn.className = 'server-download-btn p-button p-component p-button-outlined p-button-sm';
- serverDownloadBtn.type = 'button';
-
- // Create button content
- const btnLabel = document.createElement('span');
- btnLabel.className = 'p-button-label';
- btnLabel.textContent = 'Download to Pod';
- serverDownloadBtn.appendChild(btnLabel);
-
- // Status indicator (icon)
- const statusIcon = document.createElement('i');
- statusIcon.style.cssText = 'margin-left: 6px; font-size: 14px; display: none;';
- serverDownloadBtn.appendChild(statusIcon);
-
- // Button click handler
- serverDownloadBtn.onclick = async (e) => {
- e.stopPropagation();
- serverDownloadBtn.disabled = true;
- btnLabel.textContent = 'Starting...';
-
- const result = await startServerDownload(url, directory, filename);
-
- if (result.success) {
- btnLabel.textContent = 'Queued';
- statusIcon.className = 'pi pi-clock';
- statusIcon.style.display = 'inline';
- statusIcon.style.color = '#FF9800';
- } else {
- btnLabel.textContent = 'Error';
- statusIcon.className = 'pi pi-times-circle';
- statusIcon.style.display = 'inline';
- statusIcon.style.color = '#ef4444';
- console.error('Download start failed:', result.error);
- }
- };
-
- // Listen for download updates
- const updateHandler = (event) => {
- if (event.detail.download_id === download_id) {
- const { status, error } = event.detail;
-
- if (status === 'queued') {
- serverDownloadBtn.disabled = true;
- btnLabel.textContent = 'Queued';
- statusIcon.className = 'pi pi-clock';
- statusIcon.style.display = 'inline';
- statusIcon.style.color = '#FF9800';
- } else if (status === 'downloading') {
- serverDownloadBtn.disabled = true;
- btnLabel.textContent = 'Downloading';
- statusIcon.className = 'pi pi-spin pi-spinner';
- statusIcon.style.display = 'inline';
- statusIcon.style.color = '#2196F3';
- } else if (status === 'completed') {
- btnLabel.textContent = 'Completed';
- statusIcon.className = 'pi pi-check-circle';
- statusIcon.style.display = 'inline';
- statusIcon.style.color = '#4caf50';
- serverDownloadBtn.style.borderColor = '#4caf50';
- } else if (status === 'error') {
- btnLabel.textContent = 'Failed';
- statusIcon.className = 'pi pi-times-circle';
- statusIcon.style.display = 'inline';
- statusIcon.style.color = '#ef4444';
- serverDownloadBtn.title = error;
- }
- }
- };
-
- window.addEventListener('serverDownloadUpdate', updateHandler);
-
- // Add button to wrapper div
- buttonWrapper.appendChild(serverDownloadBtn);
-
- // Add wrapper to main container (alongside Download and Copy URL)
- mainContainer.appendChild(buttonWrapper);
- console.log(`[RunpodDirect] Button added to main container for ${download_id}`);
- });
-
- console.log('[RunpodDirect] Button injection complete');
-}
-
-// Register the extension
+// Register extension
app.registerExtension({
name: "ComfyUI.RunpodDirect",
async setup() {
console.log("[RunpodDirect] Extension setup starting");
-
- // Set up observer to watch for missing models dialog
+ checkEnvHfToken();
setupDialogObserver();
- // Also try to inject immediately if dialog already exists
setTimeout(() => {
console.log('[RunpodDirect] Checking for existing dialog...');
injectServerDownloadButtons();
}, 1000);
- // Try again after a longer delay
setTimeout(() => {
console.log('[RunpodDirect] Second check for dialog...');
injectServerDownloadButtons();