4242 lines
152 KiB
JavaScript
4242 lines
152 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
||
import { api } from "../../scripts/api.js";
|
||
|
||
function getLocalFlag(key, defaultValue = false) {
|
||
try {
|
||
const raw = window?.localStorage?.getItem(key);
|
||
if (raw == null) return defaultValue;
|
||
return raw === '1' || raw === 'true';
|
||
} catch (_e) {
|
||
return defaultValue;
|
||
}
|
||
}
|
||
|
||
function setLocalFlag(key, enabled) {
|
||
try {
|
||
if (enabled) {
|
||
window?.localStorage?.setItem(key, '1');
|
||
} else {
|
||
window?.localStorage?.removeItem(key);
|
||
}
|
||
} catch (_e) {
|
||
// ignored
|
||
}
|
||
}
|
||
|
||
function isVerboseLogsEnabled() {
|
||
return getLocalFlag('runpoddirect_debug', false);
|
||
}
|
||
|
||
function isAutoMissingCheckEnabled() {
|
||
return getLocalFlag('runpoddirect_auto_check', true);
|
||
}
|
||
|
||
function isPreQueueGuardEnabled() {
|
||
return getLocalFlag('runpoddirect_prequeue_guard', false);
|
||
}
|
||
|
||
function isStrictPreQueueHashCheckEnabled() {
|
||
return getLocalFlag('runpoddirect_prequeue_hash_check', false);
|
||
}
|
||
|
||
function debugLog(...args) {
|
||
if (!isVerboseLogsEnabled()) return;
|
||
console.log(...args);
|
||
}
|
||
|
||
// ComfyUI RunpodDirect Extension
|
||
// Version: 1.0.10
|
||
debugLog('[RunpodDirect] v1.0.10');
|
||
|
||
// Track download states
|
||
const downloadStates = new Map();
|
||
let downloadQueue = [];
|
||
let isDownloadingAll = false;
|
||
let completedDownloads = 0;
|
||
let totalDownloads = 0;
|
||
let downloadStartTimes = new Map();
|
||
|
||
// Session-only HF token (never persisted to disk)
|
||
let sessionHfToken = null;
|
||
let envHasHfToken = false;
|
||
let serverWsKeepaliveEnabled = true;
|
||
let serverCgroupRamPatchEnabled = true;
|
||
|
||
// 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 MODEL_FILE_EXTENSIONS = ['.safetensors', '.sft', '.ckpt', '.pth', '.pt'];
|
||
|
||
const DIRECTORY_ALIASES = {
|
||
// common aliases -> canonical Comfy folder keys
|
||
checkpoint: 'checkpoints',
|
||
checkpoints: 'checkpoints',
|
||
ckpt: 'checkpoints',
|
||
diffusion: 'diffusion_models',
|
||
diffusion_model: 'diffusion_models',
|
||
diffusion_models: 'diffusion_models',
|
||
unet: 'diffusion_models',
|
||
vae: 'vae',
|
||
vaes: 'vae',
|
||
lora: 'loras',
|
||
loras: 'loras',
|
||
text_encoder: 'text_encoders',
|
||
text_encoders: 'text_encoders',
|
||
textencoder: 'text_encoders',
|
||
clip: 'clip',
|
||
clip_vision: 'clip_vision',
|
||
controlnet: 'controlnet',
|
||
upscale: 'upscale_models',
|
||
upscale_model: 'upscale_models',
|
||
upscale_models: 'upscale_models',
|
||
latent_upscale: 'latent_upscale_models',
|
||
latent_upscale_model: 'latent_upscale_models',
|
||
latent_upscale_models: 'latent_upscale_models',
|
||
embeddings: 'embeddings',
|
||
hypernetwork: 'hypernetworks',
|
||
hypernetworks: 'hypernetworks',
|
||
style_model: 'style_models',
|
||
style_models: 'style_models',
|
||
gligen: 'gligen',
|
||
audio_encoder: 'audio_encoders',
|
||
audio_encoders: 'audio_encoders',
|
||
diffusers: 'diffusers',
|
||
model_patch: 'model_patches',
|
||
model_patches: 'model_patches',
|
||
photomaker: 'photomaker',
|
||
};
|
||
|
||
const URL_DIRECTORY_HINTS = {
|
||
checkpoints: 'checkpoints',
|
||
diffusion_models: 'diffusion_models',
|
||
diffusion: 'diffusion_models',
|
||
unet: 'diffusion_models',
|
||
vae: 'vae',
|
||
vaes: 'vae',
|
||
loras: 'loras',
|
||
lora: 'loras',
|
||
text_encoders: 'text_encoders',
|
||
text_encoder: 'text_encoders',
|
||
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',
|
||
audio_encoders: 'audio_encoders',
|
||
model_patches: 'model_patches',
|
||
diffusers: 'diffusers',
|
||
};
|
||
|
||
const MANAGER_TYPE_TO_DIRECTORY = {
|
||
checkpoints: 'checkpoints',
|
||
checkpoint: 'checkpoints',
|
||
unclip: 'checkpoints',
|
||
text_encoders: 'text_encoders',
|
||
text_encoder: 'text_encoders',
|
||
clip: 'text_encoders',
|
||
vae: 'vae',
|
||
vae_approx: 'vae_approx',
|
||
lora: 'loras',
|
||
loras: 'loras',
|
||
't2i-adapter': 'controlnet',
|
||
t2i_adapter: 'controlnet',
|
||
't2i-style': 'controlnet',
|
||
t2i_style: 'controlnet',
|
||
controlnet: 'controlnet',
|
||
clip_vision: 'clip_vision',
|
||
gligen: 'gligen',
|
||
upscale: 'upscale_models',
|
||
embedding: 'embeddings',
|
||
embeddings: 'embeddings',
|
||
unet: 'diffusion_models',
|
||
diffusion_model: 'diffusion_models',
|
||
diffusion_models: 'diffusion_models',
|
||
hypernetwork: 'hypernetworks',
|
||
hypernetworks: 'hypernetworks',
|
||
photomaker: 'photomaker',
|
||
classifiers: 'classifiers',
|
||
};
|
||
|
||
const DEFAULT_MODEL_DIRECTORIES = [
|
||
'checkpoints',
|
||
'diffusion_models',
|
||
'vae',
|
||
'text_encoders',
|
||
'loras',
|
||
'clip',
|
||
'clip_vision',
|
||
'controlnet',
|
||
'upscale_models',
|
||
'latent_upscale_models',
|
||
'embeddings',
|
||
'hypernetworks',
|
||
'style_models',
|
||
'gligen',
|
||
'audio_encoders',
|
||
'model_patches',
|
||
'diffusers',
|
||
'photomaker',
|
||
];
|
||
|
||
let folderPathsCache = null;
|
||
let folderPathsPromise = null;
|
||
const modelFolderCache = new Map();
|
||
const modelExistsAnywhereCache = new Map();
|
||
const modelDiskExistsCache = new Map();
|
||
const modelSizeCache = new Map();
|
||
const modelIntegrityCache = new Map();
|
||
const PREQUEUE_REPORT_TTL_MS = 10000;
|
||
let queueGuardInstalled = false;
|
||
let queueGuardBypassOnce = false;
|
||
let queueGuardOriginalPrompt = null;
|
||
let queueGuardWrappedPrompt = null;
|
||
let autoMissingModalTimer = null;
|
||
let autoMissingModalInFlight = false;
|
||
let autoMissingModalSignature = '';
|
||
let autoMissingSuppressedByNativeSignature = '';
|
||
let autoMissingListenersInstalled = false;
|
||
let runpodHubOverlayEl = null;
|
||
let runpodHubPanelEl = null;
|
||
let runpodHubQueueListEl = null;
|
||
let runpodHubStatsEl = null;
|
||
let runpodHubRefreshTimer = null;
|
||
let runpodHubListenersInstalled = false;
|
||
let runpodHubKeyHandler = null;
|
||
let runpodHubResizeHandler = null;
|
||
let runpodHubOutsidePointerHandler = null;
|
||
let runpodHubScrollHandler = null;
|
||
let runpodHubStylesInstalled = false;
|
||
let managerModelIndexCache = null;
|
||
let managerModelIndexPromise = null;
|
||
let workflowGraphSnapshot = null;
|
||
let preQueueReportCache = null;
|
||
|
||
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 installRunpodHubStyles() {
|
||
if (runpodHubStylesInstalled) return;
|
||
runpodHubStylesInstalled = true;
|
||
const style = document.createElement('style');
|
||
style.id = 'runpoddirect-hub-styles';
|
||
style.textContent = `
|
||
.runpoddirect-top-btn {
|
||
color: var(--base-foreground) !important;
|
||
background-color: var(--secondary-background) !important;
|
||
border: 1px solid var(--border-default) !important;
|
||
font-weight: 600 !important;
|
||
opacity: 1 !important;
|
||
display: inline-flex !important;
|
||
align-items: center !important;
|
||
justify-content: center !important;
|
||
gap: 6px !important;
|
||
height: 30px !important;
|
||
min-height: 30px !important;
|
||
padding: 0 10px !important;
|
||
line-height: 1 !important;
|
||
box-sizing: border-box !important;
|
||
}
|
||
.runpoddirect-top-btn::before {
|
||
content: "";
|
||
display: block;
|
||
width: 14px;
|
||
height: 14px;
|
||
flex: 0 0 14px;
|
||
align-self: center;
|
||
border-radius: 3px;
|
||
background-image: url("/extensions/ComfyUI-RunpodDirect/runpod-favicon.ico");
|
||
background-size: cover;
|
||
background-position: center;
|
||
background-repeat: no-repeat;
|
||
}
|
||
.runpoddirect-top-btn .p-button-icon,
|
||
.runpoddirect-top-btn [class*="icon-"] {
|
||
display: none !important;
|
||
width: 0 !important;
|
||
margin: 0 !important;
|
||
}
|
||
.runpoddirect-top-btn .p-button-label {
|
||
margin: 0 !important;
|
||
display: inline-flex !important;
|
||
align-items: center !important;
|
||
line-height: 1 !important;
|
||
}
|
||
.runpoddirect-top-btn:hover {
|
||
background-color: var(--secondary-background-hover) !important;
|
||
}
|
||
.runpoddirect-top-btn.runpoddirect-active {
|
||
background-color: var(--primary-background) !important;
|
||
border-color: var(--border-default) !important;
|
||
}
|
||
.runpoddirect-hub-panel {
|
||
backdrop-filter: blur(6px);
|
||
}
|
||
.runpoddirect-hub-row:hover {
|
||
background-color: var(--secondary-background-hover);
|
||
}
|
||
.runpoddirect-hub-action-btn:hover {
|
||
filter: brightness(1.06);
|
||
}
|
||
.runpoddirect-fallback-btn {
|
||
min-width: auto;
|
||
min-height: 30px;
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
function formatBytes(bytes) {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||
}
|
||
|
||
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';
|
||
}
|
||
|
||
function statusColor(status) {
|
||
if (status === 'downloading') return THEME.primary;
|
||
if (status === 'completed') return THEME.success;
|
||
if (status === 'error') return THEME.error;
|
||
if (status === 'cancelled') return THEME.muted;
|
||
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 });
|
||
window.dispatchEvent(new CustomEvent('serverDownloadUpdate', {
|
||
detail: { download_id, ...downloadStates.get(download_id) }
|
||
}));
|
||
});
|
||
|
||
api.addEventListener("server_download_complete", ({ detail }) => {
|
||
const { download_id, path, size } = detail;
|
||
invalidatePreQueueReportCache();
|
||
if (isDownloadingAll) {
|
||
completedDownloads++;
|
||
debugLog(`[RunpodDirect] Progress: ${completedDownloads}/${totalDownloads} completed`);
|
||
}
|
||
downloadStates.set(download_id, { status: 'completed', progress: 100, path, size });
|
||
window.dispatchEvent(new CustomEvent('serverDownloadUpdate', {
|
||
detail: { download_id, ...downloadStates.get(download_id) }
|
||
}));
|
||
debugLog(`Download completed: ${download_id} -> ${path}`);
|
||
if (isDownloadingAll && completedDownloads >= totalDownloads) {
|
||
debugLog('[RunpodDirect] All downloads completed!');
|
||
isDownloadingAll = false;
|
||
window.dispatchEvent(new CustomEvent('serverDownloadAllDone'));
|
||
}
|
||
});
|
||
|
||
api.addEventListener("server_download_error", ({ detail }) => {
|
||
const { download_id, error } = detail;
|
||
invalidatePreQueueReportCache();
|
||
if (isDownloadingAll) {
|
||
completedDownloads++;
|
||
debugLog(`[RunpodDirect] Progress: ${completedDownloads}/${totalDownloads} completed (1 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}`);
|
||
if (isDownloadingAll && completedDownloads >= totalDownloads) {
|
||
debugLog('[RunpodDirect] All downloads completed!');
|
||
isDownloadingAll = false;
|
||
window.dispatchEvent(new CustomEvent('serverDownloadAllDone'));
|
||
}
|
||
});
|
||
|
||
// --- API functions ---
|
||
|
||
async function startServerDownload(url, savePath, filename, markAsQueued = false, hash = null, hashType = null) {
|
||
try {
|
||
const download_id = `${savePath}/${filename}`;
|
||
if (markAsQueued) {
|
||
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 };
|
||
if (hash) body.hash = hash;
|
||
if (hashType) body.hash_type = hashType;
|
||
// 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(body)
|
||
});
|
||
const result = await response.json();
|
||
if (response.ok) {
|
||
if (!markAsQueued) {
|
||
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 };
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to start download:", error);
|
||
return { success: false, error: error.message };
|
||
}
|
||
}
|
||
|
||
function getDownloadStatus(downloadId) {
|
||
return downloadStates.get(downloadId) || null;
|
||
}
|
||
|
||
async function pauseDownload(downloadId) {
|
||
try {
|
||
const response = await api.fetchApi("/server_download/pause", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ download_id: downloadId })
|
||
});
|
||
const result = await response.json();
|
||
return { success: response.ok, ...result };
|
||
} catch (error) {
|
||
console.error("Failed to pause download:", error);
|
||
return { success: false, error: error.message };
|
||
}
|
||
}
|
||
|
||
async function resumeDownload(downloadId) {
|
||
try {
|
||
const response = await api.fetchApi("/server_download/resume", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ download_id: downloadId })
|
||
});
|
||
const result = await response.json();
|
||
return { success: response.ok, ...result };
|
||
} catch (error) {
|
||
console.error("Failed to resume download:", error);
|
||
return { success: false, error: error.message };
|
||
}
|
||
}
|
||
|
||
async function cancelDownload(downloadId) {
|
||
try {
|
||
const response = await api.fetchApi("/server_download/cancel", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ download_id: downloadId })
|
||
});
|
||
const result = await response.json();
|
||
return { success: response.ok, ...result };
|
||
} catch (error) {
|
||
console.error("Failed to cancel download:", error);
|
||
return { success: false, error: error.message };
|
||
}
|
||
}
|
||
|
||
async function processDownloadQueue() {
|
||
if (downloadQueue.length === 0) {
|
||
debugLog('[RunpodDirect] No downloads in queue');
|
||
return;
|
||
}
|
||
debugLog(`[RunpodDirect] Starting ${downloadQueue.length} downloads`);
|
||
const downloadsToStart = [...downloadQueue];
|
||
downloadQueue = [];
|
||
for (const download of downloadsToStart) {
|
||
debugLog(`[RunpodDirect] Queuing download ${download.filename}`);
|
||
await startServerDownload(
|
||
download.url,
|
||
download.directory,
|
||
download.filename,
|
||
true,
|
||
download.hash || null,
|
||
download.hash_type || null
|
||
);
|
||
}
|
||
debugLog(`[RunpodDirect] All ${downloadsToStart.length} downloads queued on backend`);
|
||
}
|
||
|
||
function getRunpodHubQueueEntries() {
|
||
const entries = Array.from(downloadStates.entries()).map(([download_id, state]) => ({
|
||
download_id,
|
||
...(state || {}),
|
||
}));
|
||
const priority = (status) => {
|
||
if (status === 'downloading') return 0;
|
||
if (status === 'queued') return 1;
|
||
if (status === 'paused') return 2;
|
||
if (status === 'error') return 3;
|
||
if (status === 'cancelled') return 4;
|
||
if (status === 'completed') return 5;
|
||
return 6;
|
||
};
|
||
entries.sort((a, b) => {
|
||
const p = priority(a.status) - priority(b.status);
|
||
if (p !== 0) return p;
|
||
return String(a.download_id || '').localeCompare(String(b.download_id || ''));
|
||
});
|
||
return entries;
|
||
}
|
||
|
||
function setRunpodHubButtonActive(active) {
|
||
const actionBtn = document.querySelector('.runpoddirect-top-btn');
|
||
if (!(actionBtn instanceof HTMLElement)) return;
|
||
actionBtn.classList.toggle('runpoddirect-active', !!active);
|
||
}
|
||
|
||
function ensureRunpodTopBarButton() {
|
||
if (document.querySelector('.runpoddirect-top-btn')) return true;
|
||
const group = document.querySelector('.comfyui-button-group');
|
||
if (!(group instanceof HTMLElement)) return false;
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'comfyui-button runpoddirect-top-btn runpoddirect-fallback-btn';
|
||
btn.textContent = 'RunpodDirect';
|
||
btn.title = 'RunpodDirect downloads and settings';
|
||
btn.onclick = () => toggleRunpodHubPanel();
|
||
group.appendChild(btn);
|
||
return true;
|
||
}
|
||
|
||
function getRunpodHubFilename(downloadId) {
|
||
const id = String(downloadId || '');
|
||
const parts = id.split('/');
|
||
return parts.length ? parts[parts.length - 1] : id;
|
||
}
|
||
|
||
function getRunpodHubProgressText(entry) {
|
||
const downloaded = Number(entry.downloaded || 0);
|
||
const total = Number(entry.total || 0);
|
||
if (downloaded > 0 && total > 0) {
|
||
return `${formatBytes(downloaded)} / ${formatBytes(total)}`;
|
||
}
|
||
if (total > 0) {
|
||
return `0 B / ${formatBytes(total)}`;
|
||
}
|
||
return '--';
|
||
}
|
||
|
||
async function refreshDownloadStatesFromBackend() {
|
||
try {
|
||
const response = await api.fetchApi('/server_download/status');
|
||
if (!response.ok) return;
|
||
const payload = await response.json();
|
||
if (!payload || typeof payload !== 'object') return;
|
||
for (const [download_id, state] of Object.entries(payload)) {
|
||
const prev = downloadStates.get(download_id) || {};
|
||
downloadStates.set(download_id, { ...prev, ...(state || {}) });
|
||
}
|
||
} catch (_e) {
|
||
// ignored
|
||
}
|
||
}
|
||
|
||
function positionRunpodHubPanel() {
|
||
if (!runpodHubPanelEl) return;
|
||
const width = runpodHubPanelEl.offsetWidth || 360;
|
||
const margin = 12;
|
||
const topDefault = 58;
|
||
let left = Math.max(margin, window.innerWidth - width - margin);
|
||
let top = topDefault;
|
||
|
||
const actionBtn = document.querySelector('.runpoddirect-top-btn');
|
||
if (actionBtn instanceof HTMLElement) {
|
||
const rect = actionBtn.getBoundingClientRect();
|
||
left = Math.min(
|
||
Math.max(margin, rect.right - width),
|
||
Math.max(margin, window.innerWidth - width - margin)
|
||
);
|
||
top = Math.max(topDefault, rect.bottom + 8);
|
||
}
|
||
|
||
runpodHubPanelEl.style.left = `${left}px`;
|
||
runpodHubPanelEl.style.top = `${top}px`;
|
||
const availableHeight = Math.max(260, window.innerHeight - top - margin);
|
||
runpodHubPanelEl.style.maxHeight = `${availableHeight}px`;
|
||
}
|
||
|
||
function renderRunpodHubQueue() {
|
||
if (!runpodHubQueueListEl || !runpodHubStatsEl) return;
|
||
runpodHubQueueListEl.textContent = '';
|
||
|
||
const entries = getRunpodHubQueueEntries();
|
||
const activeCount = entries.filter((e) => ['queued', 'downloading', 'paused'].includes(String(e.status))).length;
|
||
runpodHubStatsEl.textContent = `${activeCount} active / ${entries.length} tracked`;
|
||
|
||
if (!entries.length) {
|
||
runpodHubQueueListEl.appendChild(createEl('div', {
|
||
padding: '18px 12px',
|
||
fontSize: '0.75rem',
|
||
color: THEME.muted,
|
||
textAlign: 'center',
|
||
lineHeight: '1.35',
|
||
whiteSpace: 'pre-line',
|
||
}, 'No active downloads.\nStart from the missing-models dialog.'));
|
||
return;
|
||
}
|
||
|
||
const maxRows = 60;
|
||
for (const entry of entries.slice(0, maxRows)) {
|
||
const row = createEl('div', {
|
||
padding: '8px 10px',
|
||
borderBottom: `1px solid ${THEME.borderSubtle}`,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '6px',
|
||
});
|
||
row.className = 'runpoddirect-hub-row';
|
||
|
||
const top = createEl('div', {
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: '8px',
|
||
});
|
||
|
||
const name = createEl('span', {
|
||
minWidth: '0',
|
||
flex: '1',
|
||
fontSize: '0.8125rem',
|
||
color: THEME.foreground,
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}, getRunpodHubFilename(entry.download_id));
|
||
name.setAttribute('title', String(entry.download_id || ''));
|
||
|
||
const status = String(entry.status || 'unknown');
|
||
const badge = createEl('span', {
|
||
flexShrink: '0',
|
||
fontSize: '0.625rem',
|
||
fontWeight: '600',
|
||
textTransform: 'uppercase',
|
||
color: THEME.foreground,
|
||
backgroundColor: `${statusColor(status)}44`,
|
||
borderRadius: '9999px',
|
||
padding: '2px 7px',
|
||
}, status);
|
||
|
||
top.appendChild(name);
|
||
top.appendChild(badge);
|
||
row.appendChild(top);
|
||
|
||
const progress = Math.max(0, Math.min(100, Number(entry.progress || 0)));
|
||
const barOuter = createEl('div', {
|
||
width: '100%',
|
||
height: '4px',
|
||
borderRadius: '9999px',
|
||
backgroundColor: THEME.secondaryBgHover,
|
||
overflow: 'hidden',
|
||
});
|
||
const barInner = createEl('div', {
|
||
width: `${progress}%`,
|
||
height: '100%',
|
||
borderRadius: '9999px',
|
||
backgroundColor: statusColor(status),
|
||
});
|
||
barOuter.appendChild(barInner);
|
||
row.appendChild(barOuter);
|
||
|
||
const info = createEl('div', {
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: '8px',
|
||
fontSize: '0.6875rem',
|
||
color: THEME.muted,
|
||
});
|
||
info.appendChild(createEl('span', {}, `${progress.toFixed(1)}%`));
|
||
info.appendChild(createEl('span', {}, getRunpodHubProgressText(entry)));
|
||
row.appendChild(info);
|
||
|
||
if (entry.error) {
|
||
row.appendChild(createEl('div', {
|
||
fontSize: '0.6875rem',
|
||
color: THEME.error,
|
||
lineHeight: '1.3',
|
||
wordBreak: 'break-word',
|
||
}, String(entry.error)));
|
||
}
|
||
|
||
const actions = createEl('div', {
|
||
display: 'flex',
|
||
justifyContent: 'flex-end',
|
||
gap: '6px',
|
||
});
|
||
|
||
const makeActionBtn = (label, onclick, variant = 'secondary') => {
|
||
const btn = createEl('button', {
|
||
border: variant === 'secondary' ? `1px solid ${THEME.border}` : 'none',
|
||
backgroundColor: variant === 'secondary' ? THEME.secondaryBg : THEME.primary,
|
||
color: THEME.foreground,
|
||
borderRadius: '0.375rem',
|
||
height: '24px',
|
||
padding: '0 8px',
|
||
fontSize: '0.6875rem',
|
||
cursor: 'pointer',
|
||
}, label);
|
||
btn.className = 'runpoddirect-hub-action-btn';
|
||
btn.type = 'button';
|
||
btn.onmouseenter = () => {
|
||
btn.style.backgroundColor = variant === 'secondary' ? THEME.secondaryBgHover : THEME.primaryHover;
|
||
};
|
||
btn.onmouseleave = () => {
|
||
btn.style.backgroundColor = variant === 'secondary' ? THEME.secondaryBg : THEME.primary;
|
||
};
|
||
btn.onclick = onclick;
|
||
return btn;
|
||
};
|
||
|
||
if (status === 'downloading') {
|
||
actions.appendChild(makeActionBtn('Pause', async () => {
|
||
await pauseDownload(entry.download_id);
|
||
void refreshDownloadStatesFromBackend();
|
||
renderRunpodHubQueue();
|
||
}));
|
||
} else if (status === 'paused') {
|
||
actions.appendChild(makeActionBtn('Resume', async () => {
|
||
await resumeDownload(entry.download_id);
|
||
void refreshDownloadStatesFromBackend();
|
||
renderRunpodHubQueue();
|
||
}, 'primary'));
|
||
}
|
||
|
||
if (['queued', 'downloading', 'paused'].includes(status)) {
|
||
actions.appendChild(makeActionBtn('Cancel', async () => {
|
||
await cancelDownload(entry.download_id);
|
||
void refreshDownloadStatesFromBackend();
|
||
renderRunpodHubQueue();
|
||
}));
|
||
}
|
||
|
||
if (actions.childElementCount > 0) {
|
||
row.appendChild(actions);
|
||
}
|
||
|
||
runpodHubQueueListEl.appendChild(row);
|
||
}
|
||
|
||
if (entries.length > maxRows) {
|
||
runpodHubQueueListEl.appendChild(createEl('div', {
|
||
padding: '8px 10px',
|
||
fontSize: '0.6875rem',
|
||
color: THEME.muted,
|
||
}, `Showing ${maxRows} of ${entries.length} tracked downloads`));
|
||
}
|
||
}
|
||
|
||
function closeRunpodHubPanel() {
|
||
if (runpodHubRefreshTimer) {
|
||
clearInterval(runpodHubRefreshTimer);
|
||
runpodHubRefreshTimer = null;
|
||
}
|
||
if (runpodHubOverlayEl) {
|
||
runpodHubOverlayEl.remove();
|
||
}
|
||
runpodHubOverlayEl = null;
|
||
runpodHubPanelEl = null;
|
||
runpodHubQueueListEl = null;
|
||
runpodHubStatsEl = null;
|
||
if (runpodHubKeyHandler) {
|
||
document.removeEventListener('keydown', runpodHubKeyHandler);
|
||
runpodHubKeyHandler = null;
|
||
}
|
||
if (runpodHubResizeHandler) {
|
||
window.removeEventListener('resize', runpodHubResizeHandler);
|
||
runpodHubResizeHandler = null;
|
||
}
|
||
if (runpodHubOutsidePointerHandler) {
|
||
document.removeEventListener('pointerdown', runpodHubOutsidePointerHandler);
|
||
runpodHubOutsidePointerHandler = null;
|
||
}
|
||
if (runpodHubScrollHandler) {
|
||
window.removeEventListener('scroll', runpodHubScrollHandler, true);
|
||
runpodHubScrollHandler = null;
|
||
}
|
||
setRunpodHubButtonActive(false);
|
||
}
|
||
|
||
async function openRunpodHubPanel() {
|
||
if (runpodHubOverlayEl) {
|
||
renderRunpodHubQueue();
|
||
positionRunpodHubPanel();
|
||
return;
|
||
}
|
||
|
||
installRunpodHubStyles();
|
||
await Promise.all([
|
||
checkEnvHfToken(),
|
||
refreshServerWsKeepaliveSetting(),
|
||
refreshServerCgroupRamPatchSetting(),
|
||
]);
|
||
|
||
runpodHubPanelEl = createEl('div', {
|
||
position: 'fixed',
|
||
width: '360px',
|
||
maxWidth: 'calc(100vw - 24px)',
|
||
maxHeight: 'min(72vh, 640px)',
|
||
borderRadius: '0.7rem',
|
||
border: `1px solid ${THEME.border}`,
|
||
backgroundColor: THEME.baseBg,
|
||
color: THEME.foreground,
|
||
overflow: 'hidden',
|
||
boxShadow: '0 12px 30px rgba(0,0,0,0.38)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
});
|
||
runpodHubPanelEl.className = 'runpoddirect-hub-panel';
|
||
runpodHubPanelEl.setAttribute('role', 'dialog');
|
||
runpodHubPanelEl.onclick = (e) => e.stopPropagation();
|
||
|
||
const header = createEl('div', {
|
||
padding: '10px 12px',
|
||
borderBottom: `1px solid ${THEME.border}`,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: '8px',
|
||
});
|
||
header.appendChild(createEl('div', {
|
||
fontSize: '0.875rem',
|
||
fontWeight: '600',
|
||
}, 'RunpodDirect'));
|
||
const closeBtn = createEl('button', {
|
||
width: '24px',
|
||
height: '24px',
|
||
border: 'none',
|
||
borderRadius: '0.375rem',
|
||
backgroundColor: 'transparent',
|
||
color: THEME.muted,
|
||
cursor: 'pointer',
|
||
}, '×');
|
||
closeBtn.type = 'button';
|
||
closeBtn.setAttribute('aria-label', 'Close RunpodDirect panel');
|
||
closeBtn.onmouseenter = () => { closeBtn.style.backgroundColor = THEME.secondaryBgHover; };
|
||
closeBtn.onmouseleave = () => { closeBtn.style.backgroundColor = 'transparent'; };
|
||
closeBtn.onclick = () => closeRunpodHubPanel();
|
||
header.appendChild(closeBtn);
|
||
runpodHubPanelEl.appendChild(header);
|
||
|
||
const body = createEl('div', {
|
||
padding: '10px 12px',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '8px',
|
||
minHeight: '0',
|
||
});
|
||
|
||
const settings = createEl('div', {
|
||
border: `1px solid ${THEME.borderSubtle}`,
|
||
borderRadius: '0.5rem',
|
||
backgroundColor: THEME.secondaryBg,
|
||
padding: '8px',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '6px',
|
||
});
|
||
settings.appendChild(createEl('div', {
|
||
fontSize: '0.75rem',
|
||
fontWeight: '600',
|
||
color: THEME.foreground,
|
||
}, 'Extension Settings'));
|
||
settings.appendChild(createEl('div', {
|
||
fontSize: '0.6875rem',
|
||
color: THEME.muted,
|
||
lineHeight: '1.3',
|
||
}, envHasHfToken
|
||
? 'HF token source: server environment token detected'
|
||
: 'HF token source: no server environment token'));
|
||
|
||
const makeCheckboxRow = (label, description, isChecked, onToggle) => {
|
||
const row = createEl('label', {
|
||
display: 'flex',
|
||
alignItems: 'flex-start',
|
||
gap: '8px',
|
||
cursor: 'pointer',
|
||
});
|
||
const cb = document.createElement('input');
|
||
cb.type = 'checkbox';
|
||
cb.checked = isChecked();
|
||
cb.style.marginTop = '2px';
|
||
cb.onchange = async () => {
|
||
const next = !!cb.checked;
|
||
const prev = !next;
|
||
try {
|
||
const result = await onToggle(next, cb);
|
||
if (result === false) {
|
||
cb.checked = prev;
|
||
}
|
||
} catch (_e) {
|
||
cb.checked = prev;
|
||
}
|
||
};
|
||
const txt = createEl('div', {
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '2px',
|
||
});
|
||
txt.appendChild(createEl('span', {
|
||
fontSize: '0.75rem',
|
||
color: THEME.foreground,
|
||
}, label));
|
||
txt.appendChild(createEl('span', {
|
||
fontSize: '0.6875rem',
|
||
color: THEME.muted,
|
||
lineHeight: '1.3',
|
||
}, description));
|
||
row.appendChild(cb);
|
||
row.appendChild(txt);
|
||
return row;
|
||
};
|
||
|
||
settings.appendChild(makeCheckboxRow(
|
||
'Verbose debug logs',
|
||
'Show detailed RunpodDirect logs in browser console.',
|
||
() => isVerboseLogsEnabled(),
|
||
(enabled) => setLocalFlag('runpoddirect_debug', enabled),
|
||
));
|
||
|
||
settings.appendChild(makeCheckboxRow(
|
||
'Auto missing-model checks',
|
||
'Re-check workflow models on graph load and when tab regains focus.',
|
||
() => isAutoMissingCheckEnabled(),
|
||
(enabled) => setLocalFlag('runpoddirect_auto_check', enabled),
|
||
));
|
||
|
||
settings.appendChild(makeCheckboxRow(
|
||
'Pre-queue guard',
|
||
'Intercept Run and block queueing when required models appear to be missing.',
|
||
() => isPreQueueGuardEnabled(),
|
||
(enabled) => {
|
||
setLocalFlag('runpoddirect_prequeue_guard', enabled);
|
||
invalidatePreQueueReportCache();
|
||
syncPreQueueGuard();
|
||
return true;
|
||
},
|
||
));
|
||
|
||
settings.appendChild(makeCheckboxRow(
|
||
'Strict pre-queue hash checks',
|
||
'Re-hash matching files before queueing. Safer for corruption checks, slower on large models.',
|
||
() => isStrictPreQueueHashCheckEnabled(),
|
||
(enabled) => {
|
||
setLocalFlag('runpoddirect_prequeue_hash_check', enabled);
|
||
invalidatePreQueueReportCache();
|
||
return true;
|
||
},
|
||
));
|
||
|
||
settings.appendChild(makeCheckboxRow(
|
||
'Connection keepalive',
|
||
'Keep websocket alive via silent server pings to reduce proxy reconnects.',
|
||
() => isServerWsKeepaliveEnabled(),
|
||
async (enabled, checkbox) => {
|
||
if (checkbox) checkbox.disabled = true;
|
||
const ok = await setServerWsKeepaliveSetting(enabled);
|
||
if (checkbox) checkbox.disabled = false;
|
||
if (!ok) {
|
||
debugLog('[RunpodDirect] Failed to update keepalive setting');
|
||
return false;
|
||
}
|
||
return true;
|
||
},
|
||
));
|
||
|
||
settings.appendChild(makeCheckboxRow(
|
||
'Cgroup RAM-aware mode',
|
||
'Report pod memory limits instead of host RAM to reduce OOM risk on constrained pods.',
|
||
() => isServerCgroupRamPatchEnabled(),
|
||
async (enabled, checkbox) => {
|
||
if (checkbox) checkbox.disabled = true;
|
||
const ok = await setServerCgroupRamPatchSetting(enabled);
|
||
if (checkbox) checkbox.disabled = false;
|
||
if (!ok) {
|
||
debugLog('[RunpodDirect] Failed to update cgroup RAM-aware setting');
|
||
return false;
|
||
}
|
||
return true;
|
||
},
|
||
));
|
||
body.appendChild(settings);
|
||
|
||
const queueCard = createEl('div', {
|
||
border: `1px solid ${THEME.borderSubtle}`,
|
||
borderRadius: '0.5rem',
|
||
backgroundColor: THEME.secondaryBg,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
minHeight: '140px',
|
||
overflow: 'hidden',
|
||
});
|
||
const queueHeader = createEl('div', {
|
||
padding: '8px 10px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
borderBottom: `1px solid ${THEME.borderSubtle}`,
|
||
gap: '8px',
|
||
});
|
||
queueHeader.appendChild(createEl('span', {
|
||
fontSize: '0.75rem',
|
||
fontWeight: '600',
|
||
color: THEME.foreground,
|
||
}, 'Download Queue'));
|
||
runpodHubStatsEl = createEl('span', {
|
||
fontSize: '0.6875rem',
|
||
color: THEME.muted,
|
||
}, '--');
|
||
queueHeader.appendChild(runpodHubStatsEl);
|
||
queueCard.appendChild(queueHeader);
|
||
|
||
runpodHubQueueListEl = createEl('div', {
|
||
overflowY: 'auto',
|
||
minHeight: '0',
|
||
maxHeight: '260px',
|
||
});
|
||
queueCard.appendChild(runpodHubQueueListEl);
|
||
body.appendChild(queueCard);
|
||
|
||
const footer = createEl('div', {
|
||
display: 'flex',
|
||
justifyContent: 'flex-end',
|
||
alignItems: 'center',
|
||
gap: '8px',
|
||
});
|
||
const refreshBtn = createEl('button', {
|
||
border: `1px solid ${THEME.border}`,
|
||
backgroundColor: THEME.secondaryBg,
|
||
color: THEME.foreground,
|
||
borderRadius: '0.5rem',
|
||
height: '28px',
|
||
padding: '0 10px',
|
||
fontSize: '0.75rem',
|
||
cursor: 'pointer',
|
||
}, 'Refresh Status');
|
||
refreshBtn.type = 'button';
|
||
refreshBtn.onmouseenter = () => { refreshBtn.style.backgroundColor = THEME.secondaryBgHover; };
|
||
refreshBtn.onmouseleave = () => { refreshBtn.style.backgroundColor = THEME.secondaryBg; };
|
||
refreshBtn.onclick = async () => {
|
||
await refreshDownloadStatesFromBackend();
|
||
renderRunpodHubQueue();
|
||
};
|
||
const closeFooterBtn = createEl('button', {
|
||
border: 'none',
|
||
backgroundColor: THEME.primary,
|
||
color: THEME.foreground,
|
||
borderRadius: '0.5rem',
|
||
height: '28px',
|
||
padding: '0 10px',
|
||
fontSize: '0.75rem',
|
||
cursor: 'pointer',
|
||
}, 'Close');
|
||
closeFooterBtn.type = 'button';
|
||
closeFooterBtn.onmouseenter = () => { closeFooterBtn.style.backgroundColor = THEME.primaryHover; };
|
||
closeFooterBtn.onmouseleave = () => { closeFooterBtn.style.backgroundColor = THEME.primary; };
|
||
closeFooterBtn.onclick = () => closeRunpodHubPanel();
|
||
footer.appendChild(refreshBtn);
|
||
footer.appendChild(closeFooterBtn);
|
||
body.appendChild(footer);
|
||
|
||
runpodHubPanelEl.appendChild(body);
|
||
runpodHubOverlayEl = runpodHubPanelEl;
|
||
document.body.appendChild(runpodHubPanelEl);
|
||
setRunpodHubButtonActive(true);
|
||
|
||
positionRunpodHubPanel();
|
||
runpodHubKeyHandler = (event) => {
|
||
if (event.key === 'Escape') {
|
||
closeRunpodHubPanel();
|
||
}
|
||
};
|
||
document.addEventListener('keydown', runpodHubKeyHandler);
|
||
runpodHubResizeHandler = () => positionRunpodHubPanel();
|
||
window.addEventListener('resize', runpodHubResizeHandler);
|
||
runpodHubScrollHandler = () => positionRunpodHubPanel();
|
||
window.addEventListener('scroll', runpodHubScrollHandler, true);
|
||
runpodHubOutsidePointerHandler = (event) => {
|
||
if (!runpodHubPanelEl) return;
|
||
const target = event.target;
|
||
if (!(target instanceof Node)) return;
|
||
if (runpodHubPanelEl.contains(target)) return;
|
||
const actionBtn = document.querySelector('.runpoddirect-top-btn');
|
||
if (actionBtn instanceof HTMLElement && actionBtn.contains(target)) return;
|
||
closeRunpodHubPanel();
|
||
};
|
||
document.addEventListener('pointerdown', runpodHubOutsidePointerHandler);
|
||
|
||
await refreshDownloadStatesFromBackend();
|
||
renderRunpodHubQueue();
|
||
|
||
runpodHubRefreshTimer = setInterval(async () => {
|
||
await refreshDownloadStatesFromBackend();
|
||
renderRunpodHubQueue();
|
||
}, 3000);
|
||
}
|
||
|
||
function toggleRunpodHubPanel() {
|
||
if (runpodHubOverlayEl) {
|
||
closeRunpodHubPanel();
|
||
return;
|
||
}
|
||
void openRunpodHubPanel();
|
||
}
|
||
|
||
function installRunpodHubListeners() {
|
||
if (runpodHubListenersInstalled) return;
|
||
runpodHubListenersInstalled = true;
|
||
|
||
window.addEventListener('serverDownloadUpdate', () => {
|
||
if (!runpodHubOverlayEl) return;
|
||
renderRunpodHubQueue();
|
||
});
|
||
window.addEventListener('serverDownloadAllDone', () => {
|
||
if (!runpodHubOverlayEl) return;
|
||
renderRunpodHubQueue();
|
||
});
|
||
}
|
||
|
||
// --- DOM helpers (safe, no innerHTML) ---
|
||
|
||
function createEl(tag, styles, textContent) {
|
||
const el = document.createElement(tag);
|
||
if (styles) Object.assign(el.style, styles);
|
||
if (textContent) el.textContent = textContent;
|
||
return el;
|
||
}
|
||
|
||
function hasModelExtension(filename) {
|
||
if (!filename) return false;
|
||
const lower = filename.toLowerCase();
|
||
return MODEL_FILE_EXTENSIONS.some(ext => lower.endsWith(ext));
|
||
}
|
||
|
||
function sanitizeFilename(rawName) {
|
||
if (typeof rawName !== 'string') return '';
|
||
let value = rawName.trim();
|
||
if (!value) return '';
|
||
value = value.split('#')[0].split('?')[0];
|
||
value = value.replace(/\\/g, '/');
|
||
const parts = value.split('/').filter(Boolean);
|
||
const basename = parts.length ? parts[parts.length - 1] : value;
|
||
try {
|
||
return decodeURIComponent(basename).trim();
|
||
} catch (_e) {
|
||
return basename.trim();
|
||
}
|
||
}
|
||
|
||
function sanitizeUrl(rawUrl) {
|
||
if (typeof rawUrl !== 'string') return '';
|
||
return rawUrl.trim();
|
||
}
|
||
|
||
function normalizeDownloadUrl(rawUrl) {
|
||
const url = sanitizeUrl(rawUrl);
|
||
if (!url) return '';
|
||
try {
|
||
const parsed = new URL(url);
|
||
if (parsed.hostname.includes('huggingface.co')) {
|
||
parsed.pathname = parsed.pathname.replace('/blob/', '/resolve/');
|
||
}
|
||
return parsed.toString();
|
||
} catch (_e) {
|
||
return url;
|
||
}
|
||
}
|
||
|
||
function getFilenameFromUrl(rawUrl) {
|
||
const normalized = normalizeDownloadUrl(rawUrl);
|
||
if (!normalized) return null;
|
||
try {
|
||
const parsed = new URL(normalized);
|
||
const pathParts = parsed.pathname.split('/').filter(Boolean);
|
||
if (!pathParts.length) return null;
|
||
const candidate = sanitizeFilename(pathParts[pathParts.length - 1]);
|
||
return hasModelExtension(candidate) ? candidate : null;
|
||
} catch (_e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function getFallbackFolderOptions(validFolderKeys) {
|
||
const preferred = [
|
||
'checkpoints',
|
||
'diffusion_models',
|
||
'loras',
|
||
'vae',
|
||
'text_encoders',
|
||
'clip',
|
||
'clip_vision',
|
||
'controlnet',
|
||
'upscale_models',
|
||
'latent_upscale_models',
|
||
'embeddings',
|
||
];
|
||
const preferredExisting = preferred.filter(key => validFolderKeys.includes(key));
|
||
const rest = validFolderKeys.filter(key => !preferredExisting.includes(key)).sort();
|
||
return [...preferredExisting, ...rest];
|
||
}
|
||
|
||
function getKnownFolderKeys(folderPaths) {
|
||
const keys = Object.keys(folderPaths || {}).filter((k) => typeof k === 'string' && k.length > 0);
|
||
if (keys.length > 0) return Array.from(new Set(keys));
|
||
return [...DEFAULT_MODEL_DIRECTORIES];
|
||
}
|
||
|
||
function canonicalizeDirectoryKey(rawDirectory, validDirsSet) {
|
||
if (typeof rawDirectory !== 'string') return null;
|
||
const lowered = rawDirectory.trim().toLowerCase();
|
||
if (!lowered) return null;
|
||
const normalized = lowered.replace(/[\s-]+/g, '_');
|
||
if (validDirsSet.has(normalized)) return normalized;
|
||
const alias = DIRECTORY_ALIASES[normalized];
|
||
if (alias && validDirsSet.has(alias)) return alias;
|
||
const equivalents = {
|
||
diffusion_models: ['unet'],
|
||
unet: ['diffusion_models'],
|
||
clip: ['text_encoders'],
|
||
text_encoders: ['clip'],
|
||
};
|
||
const alternates = equivalents[normalized] || [];
|
||
for (const alt of alternates) {
|
||
if (validDirsSet.has(alt)) return alt;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function isMissingModelsDialogText(text) {
|
||
const lower = String(text || '').toLowerCase();
|
||
return lower.includes('missing models') || lower.includes('missing model');
|
||
}
|
||
|
||
function findVisibleMissingModelsDialog() {
|
||
const dialogs = document.querySelectorAll('[role="dialog"]');
|
||
for (const dialog of dialogs) {
|
||
if (!(dialog instanceof HTMLElement)) continue;
|
||
if (dialog.closest('.server-download-prequeue-overlay')) continue;
|
||
if (isMissingModelsDialogText(dialog.textContent || '')) {
|
||
return dialog;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function buildMissingReportSignature(report) {
|
||
const rows = [];
|
||
const all = [
|
||
...(Array.isArray(report?.missing) ? report.missing : []),
|
||
...(Array.isArray(report?.unresolved) ? report.unresolved : []),
|
||
];
|
||
for (const model of all) {
|
||
const filename = sanitizeFilename(model?.filename).toLowerCase();
|
||
const url = normalizeDownloadUrl(model?.url || '');
|
||
const directory = String(model?.directory || '').toLowerCase();
|
||
if (!filename) continue;
|
||
rows.push(`${filename}|${directory}|${url}`);
|
||
}
|
||
rows.sort();
|
||
return rows.join('||');
|
||
}
|
||
|
||
function buildPreQueueCandidateSignature(candidates, verifyHashes = false) {
|
||
const rows = [];
|
||
for (const model of Array.isArray(candidates) ? candidates : []) {
|
||
const filename = sanitizeFilename(model?.filename).toLowerCase();
|
||
const url = normalizeDownloadUrl(model?.url || '');
|
||
const directory = String(model?.directory || '').toLowerCase();
|
||
const hash = verifyHashes ? String(model?.hash || '').toLowerCase() : '';
|
||
if (!filename) continue;
|
||
rows.push(`${filename}|${directory}|${url}|${hash}`);
|
||
}
|
||
rows.sort();
|
||
return rows.join('||');
|
||
}
|
||
|
||
function clonePreQueueReport(report) {
|
||
return cloneWorkflowData(report);
|
||
}
|
||
|
||
function getCachedPreQueueReport(signature) {
|
||
if (!preQueueReportCache) return null;
|
||
if (preQueueReportCache.signature !== signature) return null;
|
||
if ((Date.now() - preQueueReportCache.createdAt) > PREQUEUE_REPORT_TTL_MS) return null;
|
||
return clonePreQueueReport(preQueueReportCache.report);
|
||
}
|
||
|
||
function setCachedPreQueueReport(signature, report) {
|
||
preQueueReportCache = {
|
||
signature,
|
||
createdAt: Date.now(),
|
||
report: clonePreQueueReport(report),
|
||
};
|
||
}
|
||
|
||
function invalidatePreQueueReportCache() {
|
||
preQueueReportCache = null;
|
||
}
|
||
|
||
function shouldWaitForNativeMissingDialog(reason = '') {
|
||
const lower = String(reason || '').toLowerCase();
|
||
return lower.includes('afterconfiguregraph')
|
||
|| lower.includes('setup-initial')
|
||
|| lower.includes('graph-load')
|
||
|| lower.includes('workflow-load')
|
||
|| lower.includes('template');
|
||
}
|
||
|
||
async function waitForNativeMissingDialog(timeoutMs = 4200, pollMs = 120) {
|
||
const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
|
||
while (Date.now() < deadline) {
|
||
const nativeOpen = !!getMissingModelsDialogState()
|
||
|| !!findVisibleMissingModelsDialog();
|
||
if (nativeOpen) return true;
|
||
await new Promise((resolve) => setTimeout(resolve, Math.max(50, Number(pollMs) || 120)));
|
||
}
|
||
return !!getMissingModelsDialogState() || !!findVisibleMissingModelsDialog();
|
||
}
|
||
|
||
async function maybeAutoShowMissingModelsModal(reason = 'auto') {
|
||
if (!isAutoMissingCheckEnabled()) return null;
|
||
debugLog(`[RunpodDirect] Auto-check (${reason}): skipped (auto fallback modal disabled)`);
|
||
return null;
|
||
}
|
||
|
||
function scheduleAutoMissingModelsCheck(reason = 'auto', delayMs = 260) {
|
||
if (!isAutoMissingCheckEnabled()) return;
|
||
if (autoMissingModalTimer) {
|
||
clearTimeout(autoMissingModalTimer);
|
||
autoMissingModalTimer = null;
|
||
}
|
||
autoMissingModalTimer = setTimeout(() => {
|
||
autoMissingModalTimer = null;
|
||
void maybeAutoShowMissingModelsModal(reason);
|
||
}, Math.max(0, Number(delayMs) || 0));
|
||
}
|
||
|
||
function installAutoMissingCheckListeners() {
|
||
if (autoMissingListenersInstalled) return;
|
||
autoMissingListenersInstalled = true;
|
||
|
||
window.addEventListener('focus', () => {
|
||
scheduleAutoMissingModelsCheck('window-focus', 180);
|
||
});
|
||
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'visible') {
|
||
scheduleAutoMissingModelsCheck('visibility-visible', 220);
|
||
}
|
||
});
|
||
}
|
||
|
||
function getVueRootElement() {
|
||
return document.getElementById('vue-app')
|
||
|| document.querySelector('[data-v-app]')
|
||
|| document.getElementById('app');
|
||
}
|
||
|
||
function getMissingModelsDialogState() {
|
||
const rootEl = getVueRootElement();
|
||
if (!rootEl) return null;
|
||
const vueApp = rootEl.__vue_app__;
|
||
if (!vueApp) return null;
|
||
const pinia = vueApp.config?.globalProperties?.$pinia;
|
||
if (!pinia || !pinia._s) return null;
|
||
const dialogStore = pinia._s.get('dialog');
|
||
if (!dialogStore) return null;
|
||
const stack = dialogStore.dialogStack;
|
||
if (!Array.isArray(stack)) return null;
|
||
const dialog = stack.find(d => d.key === 'global-missing-models-warning');
|
||
if (!dialog) return null;
|
||
const missingModels = Array.isArray(dialog.contentProps?.missingModels)
|
||
? dialog.contentProps.missingModels
|
||
: [];
|
||
const paths = dialog.contentProps?.paths && typeof dialog.contentProps.paths === 'object'
|
||
? dialog.contentProps.paths
|
||
: {};
|
||
return { dialog, missingModels, paths };
|
||
}
|
||
|
||
function extractModelsFromPinia() {
|
||
const dialogState = getMissingModelsDialogState();
|
||
if (!dialogState) return null;
|
||
const { missingModels, paths } = dialogState;
|
||
if (!missingModels.length) {
|
||
debugLog('[RunpodDirect] No missingModels in dialog contentProps');
|
||
return null;
|
||
}
|
||
debugLog(`[RunpodDirect] Found ${missingModels.length} models from Pinia dialog store`);
|
||
return {
|
||
models: missingModels.map((m) => ({
|
||
filename: sanitizeFilename(m.name),
|
||
url: normalizeDownloadUrl(m.url),
|
||
directory: m.directory ? String(m.directory) : null,
|
||
hash: typeof m.hash === 'string' ? m.hash : null,
|
||
hash_type: typeof m.hash_type === 'string' ? m.hash_type : null,
|
||
source: 'dialog',
|
||
nodeTypes: [],
|
||
})).filter(m => m.filename && m.url),
|
||
paths,
|
||
};
|
||
}
|
||
|
||
// 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 nameSpan = leftSide.querySelector('span[title]');
|
||
if (!nameSpan) return;
|
||
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();
|
||
}
|
||
|
||
// Try button title or anchor href for URL
|
||
const rightSide = getModelRowRightSide(row);
|
||
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');
|
||
}
|
||
}
|
||
|
||
if (filename && directory && url) {
|
||
models.push({
|
||
filename: sanitizeFilename(filename),
|
||
directory,
|
||
url: normalizeDownloadUrl(url),
|
||
source: 'dom',
|
||
nodeTypes: [],
|
||
});
|
||
}
|
||
});
|
||
return models.length > 0 ? models : null;
|
||
}
|
||
|
||
|
||
function extractModelsFromFooter() {
|
||
return null;
|
||
}
|
||
|
||
function cloneWorkflowData(data) {
|
||
if (!data || typeof data !== 'object') return data;
|
||
try {
|
||
if (typeof structuredClone === 'function') {
|
||
return structuredClone(data);
|
||
}
|
||
} catch (_e) {
|
||
// ignored
|
||
}
|
||
try {
|
||
return JSON.parse(JSON.stringify(data));
|
||
} catch (_e) {
|
||
return data;
|
||
}
|
||
}
|
||
|
||
function setWorkflowGraphSnapshot(graphData) {
|
||
if (!graphData || typeof graphData !== 'object') return;
|
||
workflowGraphSnapshot = cloneWorkflowData(graphData);
|
||
}
|
||
|
||
function getCurrentWorkflowData(options = {}) {
|
||
const preferSnapshot = options?.preferSnapshot !== false;
|
||
const allowSerialize = options?.allowSerialize !== false;
|
||
if (preferSnapshot && workflowGraphSnapshot && typeof workflowGraphSnapshot === 'object') {
|
||
return workflowGraphSnapshot;
|
||
}
|
||
if (!allowSerialize) return null;
|
||
try {
|
||
if (app?.rootGraph?.serialize) {
|
||
const data = app.rootGraph.serialize();
|
||
setWorkflowGraphSnapshot(data);
|
||
return data;
|
||
}
|
||
if (app?.graph?.serialize) {
|
||
const data = app.graph.serialize();
|
||
setWorkflowGraphSnapshot(data);
|
||
return data;
|
||
}
|
||
if (window?.app?.rootGraph?.serialize) {
|
||
const data = window.app.rootGraph.serialize();
|
||
setWorkflowGraphSnapshot(data);
|
||
return data;
|
||
}
|
||
if (window?.app?.graph?.serialize) {
|
||
const data = window.app.graph.serialize();
|
||
setWorkflowGraphSnapshot(data);
|
||
return data;
|
||
}
|
||
} catch (e) {
|
||
debugLog('[RunpodDirect] Failed to access current workflow data', e);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function walkObjectStrings(value, onString, seen = new Set()) {
|
||
if (value == null) return;
|
||
if (typeof value === 'string') {
|
||
onString(value);
|
||
return;
|
||
}
|
||
if (typeof value !== 'object') return;
|
||
if (seen.has(value)) return;
|
||
seen.add(value);
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) walkObjectStrings(item, onString, seen);
|
||
return;
|
||
}
|
||
for (const child of Object.values(value)) {
|
||
walkObjectStrings(child, onString, seen);
|
||
}
|
||
}
|
||
|
||
function walkWorkflowNodes(value, onNode, seen = new Set()) {
|
||
if (!value || typeof value !== 'object') return;
|
||
if (seen.has(value)) return;
|
||
seen.add(value);
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) walkWorkflowNodes(item, onNode, seen);
|
||
return;
|
||
}
|
||
const maybeNode = value;
|
||
const nodeType = typeof maybeNode.type === 'string'
|
||
? maybeNode.type
|
||
: (typeof maybeNode.class_type === 'string' ? maybeNode.class_type : '');
|
||
if (nodeType && (
|
||
maybeNode.widgets_values !== undefined ||
|
||
maybeNode.properties?.models !== undefined ||
|
||
(maybeNode.inputs && typeof maybeNode.inputs === 'object')
|
||
)) {
|
||
if (maybeNode.type === nodeType) {
|
||
onNode(maybeNode);
|
||
} else {
|
||
onNode({ ...maybeNode, type: nodeType });
|
||
}
|
||
}
|
||
for (const child of Object.values(value)) {
|
||
walkWorkflowNodes(child, onNode, seen);
|
||
}
|
||
}
|
||
|
||
function extractUrlsFromString(text) {
|
||
if (typeof text !== 'string' || !text.includes('http')) return [];
|
||
const matches = text.match(/https?:\/\/[^\s<>"'`]+/g);
|
||
if (!matches) return [];
|
||
return matches
|
||
.map((raw) => raw.replace(/[),.;]+$/g, ''))
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function looksLikeWidgetModelValue(value) {
|
||
if (typeof value !== 'string') return false;
|
||
const trimmed = value.trim();
|
||
if (!trimmed || trimmed.length > 260) return false;
|
||
if (trimmed.includes('\n') || trimmed.includes('\r')) return false;
|
||
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return false;
|
||
if (trimmed.includes('](') || trimmed.includes('```')) return false;
|
||
const filename = sanitizeFilename(trimmed);
|
||
if (!filename || !hasModelExtension(filename)) return false;
|
||
return true;
|
||
}
|
||
|
||
function collectNodeModelLikeValues(node) {
|
||
const values = [];
|
||
const widgets = node?.widgets_values;
|
||
if (Array.isArray(widgets)) {
|
||
values.push(...widgets);
|
||
} else if (widgets && typeof widgets === 'object') {
|
||
values.push(...Object.values(widgets));
|
||
}
|
||
|
||
const inputs = node?.inputs;
|
||
if (inputs && typeof inputs === 'object' && !Array.isArray(inputs)) {
|
||
for (const inputValue of Object.values(inputs)) {
|
||
if (typeof inputValue === 'string') {
|
||
values.push(inputValue);
|
||
continue;
|
||
}
|
||
if (Array.isArray(inputValue)) {
|
||
for (const item of inputValue) {
|
||
if (typeof item === 'string') values.push(item);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return values;
|
||
}
|
||
|
||
function inferDirectoryFromUrl(url, validDirsSet) {
|
||
if (!url) return null;
|
||
try {
|
||
const parsed = new URL(url);
|
||
const parts = parsed.pathname.toLowerCase().split('/').filter(Boolean);
|
||
// ignore the final segment (filename)
|
||
const dirParts = parts.slice(0, Math.max(parts.length - 1, 0)).reverse();
|
||
for (const segment of dirParts) {
|
||
const hint = URL_DIRECTORY_HINTS[segment] || segment;
|
||
const canonical = canonicalizeDirectoryKey(hint, validDirsSet);
|
||
if (canonical) return canonical;
|
||
}
|
||
} catch (_e) {
|
||
return null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function addDirectoryScore(scores, validDirsSet, rawDirectory, score) {
|
||
const canonical = canonicalizeDirectoryKey(rawDirectory, validDirsSet);
|
||
if (!canonical) return;
|
||
const current = scores.get(canonical) || 0;
|
||
if (score > current) scores.set(canonical, score);
|
||
}
|
||
|
||
function addNodeTypeDirectoryScores(scores, validDirsSet, nodeType) {
|
||
const type = String(nodeType || '').toLowerCase();
|
||
if (!type) return;
|
||
if (type.includes('latentupscalemodelloader')) addDirectoryScore(scores, validDirsSet, 'latent_upscale_models', 86);
|
||
if (type.includes('upscalemodelloader') || (type.includes('upscale') && !type.includes('latent'))) addDirectoryScore(scores, validDirsSet, 'upscale_models', 78);
|
||
if (type.includes('lora')) addDirectoryScore(scores, validDirsSet, 'loras', 84);
|
||
if (type.includes('vae')) addDirectoryScore(scores, validDirsSet, 'vae', 82);
|
||
if (type.includes('clipvision')) addDirectoryScore(scores, validDirsSet, 'clip_vision', 82);
|
||
if (type.includes('controlnet') || type.includes('t2iadapter')) addDirectoryScore(scores, validDirsSet, 'controlnet', 78);
|
||
if (type.includes('textencoder') || type.includes('text_encode') || type.includes('projection') || type.includes('t5') || type.includes('gemma')) {
|
||
addDirectoryScore(scores, validDirsSet, 'text_encoders', 76);
|
||
}
|
||
if (type.includes('clip') && !type.includes('clipvision')) {
|
||
addDirectoryScore(scores, validDirsSet, 'clip', 72);
|
||
addDirectoryScore(scores, validDirsSet, 'text_encoders', 68);
|
||
}
|
||
if (type.includes('checkpoint')) addDirectoryScore(scores, validDirsSet, 'checkpoints', 78);
|
||
if (type.includes('diffusion') || type.includes('unet') || type.includes('transformer')) {
|
||
addDirectoryScore(scores, validDirsSet, 'diffusion_models', 74);
|
||
}
|
||
if (type.includes('embedding') || type.includes('textualinversion')) addDirectoryScore(scores, validDirsSet, 'embeddings', 72);
|
||
}
|
||
|
||
function addFilenameDirectoryScores(scores, validDirsSet, filename) {
|
||
const lower = String(filename || '').toLowerCase();
|
||
if (!lower) return;
|
||
if (lower.includes('lora') || lower.includes('lycoris')) addDirectoryScore(scores, validDirsSet, 'loras', 64);
|
||
if (lower.includes('vae')) addDirectoryScore(scores, validDirsSet, 'vae', 62);
|
||
if (lower.includes('latent') && lower.includes('upscal')) addDirectoryScore(scores, validDirsSet, 'latent_upscale_models', 66);
|
||
if (lower.includes('upscal') && !lower.includes('latent')) addDirectoryScore(scores, validDirsSet, 'upscale_models', 60);
|
||
if (lower.includes('controlnet') || lower.includes('t2i')) addDirectoryScore(scores, validDirsSet, 'controlnet', 58);
|
||
if (lower.includes('clip_vision') || lower.includes('clipvision')) addDirectoryScore(scores, validDirsSet, 'clip_vision', 58);
|
||
if (lower.includes('text_encoder') || lower.includes('text-encoder') || lower.includes('gemma') || lower.includes('t5') || lower.includes('projection')) {
|
||
addDirectoryScore(scores, validDirsSet, 'text_encoders', 58);
|
||
}
|
||
if (lower.includes('embedding')) addDirectoryScore(scores, validDirsSet, 'embeddings', 56);
|
||
if (lower.includes('checkpoint')) addDirectoryScore(scores, validDirsSet, 'checkpoints', 54);
|
||
if (lower.includes('transformer') || lower.includes('diffusion') || lower.includes('flux') || lower.includes('hunyuan') || lower.includes('wan') || lower.includes('sd3') || lower.includes('ltx')) {
|
||
addDirectoryScore(scores, validDirsSet, 'diffusion_models', 52);
|
||
}
|
||
}
|
||
|
||
function resolveModelDirectory(model, validFolderKeys) {
|
||
const validDirsSet = new Set(validFolderKeys || []);
|
||
const scores = new Map();
|
||
if (model.directory) addDirectoryScore(scores, validDirsSet, model.directory, 100);
|
||
const urlDirectory = inferDirectoryFromUrl(model.url, validDirsSet);
|
||
if (urlDirectory) addDirectoryScore(scores, validDirsSet, urlDirectory, 80);
|
||
if (Array.isArray(model.nodeTypes)) {
|
||
for (const nodeType of model.nodeTypes) {
|
||
addNodeTypeDirectoryScores(scores, validDirsSet, nodeType);
|
||
}
|
||
}
|
||
addFilenameDirectoryScores(scores, validDirsSet, model.filename);
|
||
|
||
const ranked = Array.from(scores.entries())
|
||
.map(([directory, score]) => ({ directory, score }))
|
||
.sort((a, b) => b.score - a.score);
|
||
const best = ranked[0] || null;
|
||
const second = ranked[1] || null;
|
||
const options = ranked.map(item => item.directory);
|
||
const ambiguous = !!(best && second && (best.score - second.score) <= 12);
|
||
return {
|
||
directory: best ? best.directory : null,
|
||
ambiguous: ambiguous || !best,
|
||
options,
|
||
};
|
||
}
|
||
|
||
async function getAvailableFolderPaths(piniaPaths) {
|
||
const normalizeMap = (value) => {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||
const out = {};
|
||
for (const [rawKey, rawEntries] of Object.entries(value)) {
|
||
const key = typeof rawKey === 'string' ? rawKey.trim() : '';
|
||
if (!key) continue;
|
||
const entries = [];
|
||
if (Array.isArray(rawEntries)) {
|
||
for (const item of rawEntries) {
|
||
if (typeof item === 'string') {
|
||
entries.push(item);
|
||
} else if (Array.isArray(item) && typeof item[0] === 'string') {
|
||
entries.push(item[0]);
|
||
} else if (item && typeof item === 'object' && typeof item.path === 'string') {
|
||
entries.push(item.path);
|
||
}
|
||
}
|
||
}
|
||
out[key] = entries;
|
||
}
|
||
return out;
|
||
};
|
||
|
||
const mergeMaps = (...maps) => {
|
||
const merged = {};
|
||
for (const src of maps) {
|
||
const normalized = normalizeMap(src);
|
||
for (const [key, entries] of Object.entries(normalized)) {
|
||
if (!merged[key]) merged[key] = [];
|
||
for (const entry of entries) {
|
||
if (typeof entry !== 'string' || !entry) continue;
|
||
if (!merged[key].includes(entry)) merged[key].push(entry);
|
||
}
|
||
}
|
||
}
|
||
return merged;
|
||
};
|
||
|
||
const hasPinia = !!(piniaPaths && typeof piniaPaths === 'object' && Object.keys(piniaPaths).length);
|
||
if (folderPathsCache && !hasPinia) return folderPathsCache;
|
||
|
||
if (!folderPathsPromise || hasPinia) {
|
||
folderPathsPromise = (async () => {
|
||
try {
|
||
let merged = mergeMaps(folderPathsCache, piniaPaths);
|
||
|
||
if (typeof api.getFolderPaths === 'function') {
|
||
try {
|
||
const paths = await api.getFolderPaths();
|
||
merged = mergeMaps(merged, paths);
|
||
} catch (_e) {
|
||
// ignored
|
||
}
|
||
}
|
||
|
||
try {
|
||
const response = await api.fetchApi('/server_download/folder_paths');
|
||
if (response.ok) {
|
||
const paths = await response.json();
|
||
merged = mergeMaps(merged, paths);
|
||
}
|
||
} catch (_e) {
|
||
// ignored
|
||
}
|
||
|
||
if (Object.keys(merged).length > 0) return merged;
|
||
|
||
const folderNames = await fetchModelFolderNames();
|
||
const fallback = {};
|
||
for (const name of folderNames) fallback[name] = [];
|
||
if (Object.keys(fallback).length > 0) return fallback;
|
||
|
||
const defaults = {};
|
||
for (const name of DEFAULT_MODEL_DIRECTORIES) defaults[name] = [];
|
||
return defaults;
|
||
} catch (_e) {
|
||
return {};
|
||
}
|
||
})();
|
||
}
|
||
|
||
folderPathsCache = await folderPathsPromise;
|
||
folderPathsPromise = null;
|
||
return folderPathsCache || {};
|
||
}
|
||
|
||
function normalizeManagerSavePathToDirectory(savePath, typeName, validFolderKeys) {
|
||
const validDirsSet = new Set(validFolderKeys || []);
|
||
if (typeof savePath === 'string' && savePath && savePath !== 'default') {
|
||
const firstSegment = savePath.replace(/\\/g, '/').split('/').filter(Boolean)[0] || '';
|
||
const fromSavePath = canonicalizeDirectoryKey(firstSegment, validDirsSet);
|
||
if (fromSavePath) return fromSavePath;
|
||
}
|
||
const loweredType = String(typeName || '').trim().toLowerCase();
|
||
if (!loweredType) return null;
|
||
const mapped = MANAGER_TYPE_TO_DIRECTORY[loweredType] || loweredType;
|
||
return canonicalizeDirectoryKey(mapped, validDirsSet);
|
||
}
|
||
|
||
async function fetchManagerModelList() {
|
||
const modes = ['cache', 'local'];
|
||
for (const mode of modes) {
|
||
try {
|
||
const response = await api.fetchApi(`/externalmodel/getlist?mode=${encodeURIComponent(mode)}`);
|
||
if (!response.ok) continue;
|
||
const payload = await response.json();
|
||
if (Array.isArray(payload?.models)) {
|
||
return payload.models;
|
||
}
|
||
} catch (_e) {
|
||
// ignored
|
||
}
|
||
}
|
||
return [];
|
||
}
|
||
|
||
async function getManagerModelIndex() {
|
||
if (managerModelIndexCache) return managerModelIndexCache;
|
||
if (!managerModelIndexPromise) {
|
||
managerModelIndexPromise = (async () => {
|
||
const models = await fetchManagerModelList();
|
||
const byFilename = new Map();
|
||
for (const raw of models) {
|
||
if (!raw || typeof raw !== 'object') continue;
|
||
const filename = sanitizeFilename(raw.filename);
|
||
const url = normalizeDownloadUrl(raw.url || '');
|
||
if (!filename || !hasModelExtension(filename) || !url) continue;
|
||
const key = filename.toLowerCase();
|
||
const entry = {
|
||
filename,
|
||
url,
|
||
save_path: typeof raw.save_path === 'string' ? raw.save_path : '',
|
||
type: typeof raw.type === 'string' ? raw.type : '',
|
||
base: typeof raw.base === 'string' ? raw.base : '',
|
||
name: typeof raw.name === 'string' ? raw.name : '',
|
||
};
|
||
if (!byFilename.has(key)) byFilename.set(key, []);
|
||
byFilename.get(key).push(entry);
|
||
}
|
||
return byFilename;
|
||
})();
|
||
}
|
||
managerModelIndexCache = await managerModelIndexPromise;
|
||
managerModelIndexPromise = null;
|
||
return managerModelIndexCache || new Map();
|
||
}
|
||
|
||
function pickManagerFallbackEntry(entries, filename, nodeTypes, validFolderKeys) {
|
||
if (!Array.isArray(entries) || entries.length === 0) return null;
|
||
const preferredDirectory = resolveModelDirectory({
|
||
filename,
|
||
url: '',
|
||
directory: null,
|
||
nodeTypes: Array.isArray(nodeTypes) ? nodeTypes : [],
|
||
}, validFolderKeys).directory;
|
||
|
||
let best = null;
|
||
let bestScore = Number.NEGATIVE_INFINITY;
|
||
for (const entry of entries) {
|
||
const directory = normalizeManagerSavePathToDirectory(entry.save_path, entry.type, validFolderKeys);
|
||
let score = 0;
|
||
if (entry.url) score += 40;
|
||
if (directory) score += 20;
|
||
if (preferredDirectory && directory && preferredDirectory === directory) score += 30;
|
||
if (String(entry.base || '').toLowerCase().includes('comfy')) score += 2;
|
||
if (score > bestScore) {
|
||
bestScore = score;
|
||
best = { ...entry, directory };
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
async function resolveManagerModelFallback(filename, nodeTypes, validFolderKeys) {
|
||
const safeFilename = sanitizeFilename(filename);
|
||
if (!safeFilename || !hasModelExtension(safeFilename)) return null;
|
||
const index = await getManagerModelIndex();
|
||
if (!index || !(index instanceof Map)) return null;
|
||
const entries = index.get(safeFilename.toLowerCase());
|
||
if (!entries || entries.length === 0) return null;
|
||
const selected = pickManagerFallbackEntry(entries, safeFilename, nodeTypes, validFolderKeys);
|
||
if (!selected || !selected.url) return null;
|
||
return {
|
||
filename: safeFilename,
|
||
url: selected.url,
|
||
directory: selected.directory || null,
|
||
source: 'manager-db',
|
||
};
|
||
}
|
||
|
||
function normalizeModelListPayload(payload) {
|
||
if (!Array.isArray(payload)) return [];
|
||
return payload
|
||
.map((item) => {
|
||
if (typeof item === 'string') return item;
|
||
if (item && typeof item === 'object') {
|
||
return item.file_name || item.name || null;
|
||
}
|
||
return null;
|
||
})
|
||
.filter((v) => typeof v === 'string' && v.length > 0);
|
||
}
|
||
|
||
async function fetchModelFolderNames() {
|
||
try {
|
||
if (typeof api.getModelFolders === 'function') {
|
||
const response = await api.getModelFolders();
|
||
if (Array.isArray(response) && response.length > 0) {
|
||
const names = response
|
||
.map((entry) => (typeof entry === 'string' ? entry : entry?.name))
|
||
.filter((name) => typeof name === 'string' && name.length > 0);
|
||
if (names.length > 0) return names;
|
||
}
|
||
}
|
||
} catch (_e) {
|
||
// ignored
|
||
}
|
||
|
||
const urls = ['/experiment/models', '/models'];
|
||
for (const endpoint of urls) {
|
||
try {
|
||
const response = await api.fetchApi(endpoint);
|
||
if (!response.ok) continue;
|
||
const payload = await response.json();
|
||
if (!Array.isArray(payload)) continue;
|
||
const names = payload
|
||
.map((entry) => (typeof entry === 'string' ? entry : entry?.name))
|
||
.filter((name) => typeof name === 'string' && name.length > 0);
|
||
if (names.length > 0) return names;
|
||
} catch (_e) {
|
||
// ignored
|
||
}
|
||
}
|
||
|
||
return [...DEFAULT_MODEL_DIRECTORIES];
|
||
}
|
||
|
||
async function fetchModelsForDirectory(directory) {
|
||
let hadSuccessfulResponse = false;
|
||
|
||
try {
|
||
if (typeof api.getModels === 'function') {
|
||
const response = await api.getModels(directory);
|
||
hadSuccessfulResponse = true;
|
||
const normalized = normalizeModelListPayload(response);
|
||
return normalized;
|
||
}
|
||
} catch (_e) {
|
||
// ignored
|
||
}
|
||
|
||
const urls = [`/experiment/models/${encodeURIComponent(directory)}`, `/models/${encodeURIComponent(directory)}`];
|
||
for (const endpoint of urls) {
|
||
try {
|
||
const response = await api.fetchApi(endpoint);
|
||
if (!response.ok) continue;
|
||
hadSuccessfulResponse = true;
|
||
const payload = await response.json();
|
||
const normalized = normalizeModelListPayload(payload);
|
||
return normalized;
|
||
} catch (_e) {
|
||
// ignored
|
||
}
|
||
}
|
||
|
||
return hadSuccessfulResponse ? [] : null;
|
||
}
|
||
|
||
async function modelExistsInDirectory(directory, filename) {
|
||
if (!directory || !filename) return null;
|
||
const safeFilename = sanitizeFilename(filename);
|
||
if (!safeFilename) return null;
|
||
if (!modelFolderCache.has(directory)) {
|
||
modelFolderCache.set(directory, fetchModelsForDirectory(directory).catch(() => null));
|
||
}
|
||
const models = await modelFolderCache.get(directory);
|
||
const directDiskExists = async () => {
|
||
const key = `${String(directory).toLowerCase()}|${safeFilename.toLowerCase()}`;
|
||
if (modelDiskExistsCache.has(key)) {
|
||
return modelDiskExistsCache.get(key);
|
||
}
|
||
try {
|
||
const response = await api.fetchApi('/server_download/verify_model_integrity', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
directory,
|
||
filename: safeFilename,
|
||
}),
|
||
});
|
||
if (!response.ok) {
|
||
modelDiskExistsCache.set(key, null);
|
||
return null;
|
||
}
|
||
const data = await response.json();
|
||
const exists = typeof data?.exists === 'boolean' ? data.exists : null;
|
||
modelDiskExistsCache.set(key, exists);
|
||
return exists;
|
||
} catch (_e) {
|
||
modelDiskExistsCache.set(key, null);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
if (!Array.isArray(models)) {
|
||
return await directDiskExists();
|
||
}
|
||
|
||
const target = safeFilename.toLowerCase();
|
||
const listed = models.some((m) => {
|
||
if (typeof m === 'string') return sanitizeFilename(m).toLowerCase() === target;
|
||
const name = m?.file_name || m?.name;
|
||
return typeof name === 'string' && sanitizeFilename(name).toLowerCase() === target;
|
||
});
|
||
if (listed) return true;
|
||
|
||
// Model list can be stale right after refresh/startup; verify directly on disk.
|
||
const diskExists = await directDiskExists();
|
||
if (typeof diskExists === 'boolean') return diskExists;
|
||
|
||
return false;
|
||
}
|
||
|
||
async function modelExistsAnywhere(filename, folderPaths, preferredDirectories = []) {
|
||
const safeFilename = sanitizeFilename(filename);
|
||
if (!safeFilename) return null;
|
||
const key = safeFilename.toLowerCase();
|
||
if (modelExistsAnywhereCache.has(key)) {
|
||
return modelExistsAnywhereCache.get(key);
|
||
}
|
||
|
||
const knownDirs = getKnownFolderKeys(folderPaths);
|
||
const orderedDirs = Array.from(new Set([
|
||
...preferredDirectories.filter((v) => typeof v === 'string' && v.length > 0),
|
||
...knownDirs,
|
||
]));
|
||
|
||
let sawUnknown = false;
|
||
for (const directory of orderedDirs) {
|
||
const exists = await modelExistsInDirectory(directory, safeFilename);
|
||
if (exists === true) {
|
||
const result = { exists: true, directory };
|
||
modelExistsAnywhereCache.set(key, result);
|
||
return result;
|
||
}
|
||
if (exists === null) sawUnknown = true;
|
||
}
|
||
|
||
const result = sawUnknown ? { exists: null, directory: null } : { exists: false, directory: null };
|
||
modelExistsAnywhereCache.set(key, result);
|
||
return result;
|
||
}
|
||
|
||
async function verifyModelIntegrity(directory, filename, hash, hashType) {
|
||
if (!directory || !filename || !hash) return null;
|
||
const key = `${directory}|${sanitizeFilename(filename).toLowerCase()}|${String(hash).toLowerCase()}|${String(hashType || '').toLowerCase()}`;
|
||
if (modelIntegrityCache.has(key)) {
|
||
return modelIntegrityCache.get(key);
|
||
}
|
||
try {
|
||
const response = await api.fetchApi('/server_download/verify_model_integrity', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
directory,
|
||
filename: sanitizeFilename(filename),
|
||
hash,
|
||
hash_type: hashType || null,
|
||
}),
|
||
});
|
||
if (!response.ok) return null;
|
||
const data = await response.json();
|
||
modelIntegrityCache.set(key, data);
|
||
return data;
|
||
} catch (_e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function collectSmartWorkflowModels(baseModels, folderPaths) {
|
||
const workflow = getCurrentWorkflowData({ preferSnapshot: true, allowSerialize: false });
|
||
if (!workflow) return [];
|
||
|
||
const validFolderKeys = getKnownFolderKeys(folderPaths);
|
||
const selectedModelsByName = new Map();
|
||
const metadataByName = new Map();
|
||
const urlModelsByName = new Map();
|
||
const knownMissingNames = new Set((baseModels || []).map(m => sanitizeFilename(m.filename).toLowerCase()));
|
||
|
||
walkWorkflowNodes(workflow, (node) => {
|
||
const nodeType = typeof node.type === 'string'
|
||
? node.type
|
||
: (typeof node.class_type === 'string' ? node.class_type : '');
|
||
const values = collectNodeModelLikeValues(node);
|
||
for (const value of values) {
|
||
if (!looksLikeWidgetModelValue(value)) continue;
|
||
const filename = sanitizeFilename(value);
|
||
const key = filename.toLowerCase();
|
||
if (!selectedModelsByName.has(key)) {
|
||
selectedModelsByName.set(key, { filename, nodeTypes: new Set() });
|
||
}
|
||
if (nodeType) selectedModelsByName.get(key).nodeTypes.add(nodeType);
|
||
}
|
||
|
||
const models = node?.properties?.models;
|
||
if (Array.isArray(models)) {
|
||
for (const m of models) {
|
||
const filename = sanitizeFilename(m?.name);
|
||
const url = normalizeDownloadUrl(m?.url);
|
||
if (!filename || !url || !hasModelExtension(filename)) continue;
|
||
const key = filename.toLowerCase();
|
||
metadataByName.set(key, {
|
||
filename,
|
||
url,
|
||
directory: m?.directory ? String(m.directory) : null,
|
||
hash: typeof m?.hash === 'string' ? m.hash : null,
|
||
hash_type: typeof m?.hash_type === 'string' ? m.hash_type : null,
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
if (Array.isArray(workflow?.models)) {
|
||
for (const m of workflow.models) {
|
||
const filename = sanitizeFilename(m?.name);
|
||
const url = normalizeDownloadUrl(m?.url);
|
||
if (!filename || !url || !hasModelExtension(filename)) continue;
|
||
const key = filename.toLowerCase();
|
||
metadataByName.set(key, {
|
||
filename,
|
||
url,
|
||
directory: m?.directory ? String(m.directory) : null,
|
||
hash: typeof m?.hash === 'string' ? m.hash : null,
|
||
hash_type: typeof m?.hash_type === 'string' ? m.hash_type : null,
|
||
});
|
||
}
|
||
}
|
||
|
||
walkObjectStrings(workflow, (text) => {
|
||
const urls = extractUrlsFromString(text);
|
||
for (const rawUrl of urls) {
|
||
const normalizedUrl = normalizeDownloadUrl(rawUrl);
|
||
const filename = getFilenameFromUrl(normalizedUrl);
|
||
if (!filename) continue;
|
||
const key = filename.toLowerCase();
|
||
const inferredDirectory = inferDirectoryFromUrl(normalizedUrl, new Set(validFolderKeys));
|
||
if (!urlModelsByName.has(key)) {
|
||
urlModelsByName.set(key, {
|
||
filename,
|
||
url: normalizedUrl,
|
||
directory: inferredDirectory,
|
||
});
|
||
} else if (!urlModelsByName.get(key).directory && inferredDirectory) {
|
||
urlModelsByName.get(key).directory = inferredDirectory;
|
||
}
|
||
}
|
||
});
|
||
|
||
const extras = [];
|
||
for (const [key, selected] of selectedModelsByName.entries()) {
|
||
const metadata = metadataByName.get(key);
|
||
const urlRef = urlModelsByName.get(key);
|
||
const url = metadata?.url || urlRef?.url;
|
||
if (!url) continue;
|
||
const model = {
|
||
filename: selected.filename,
|
||
url,
|
||
directory: metadata?.directory || urlRef?.directory || null,
|
||
hash: metadata?.hash || null,
|
||
hash_type: metadata?.hash_type || null,
|
||
source: 'workflow',
|
||
nodeTypes: Array.from(selected.nodeTypes),
|
||
};
|
||
const resolved = resolveModelDirectory(model, validFolderKeys);
|
||
model.directory = resolved.directory;
|
||
model.needsFolderChoice = resolved.ambiguous || !resolved.directory;
|
||
model.folderOptions = resolved.options.length
|
||
? resolved.options
|
||
: getFallbackFolderOptions(validFolderKeys);
|
||
|
||
if (knownMissingNames.has(key)) {
|
||
extras.push(model);
|
||
continue;
|
||
}
|
||
|
||
if (!model.directory) {
|
||
extras.push(model);
|
||
continue;
|
||
}
|
||
|
||
const exists = await modelExistsInDirectory(model.directory, model.filename);
|
||
if (exists === false || exists === null) {
|
||
extras.push(model);
|
||
}
|
||
}
|
||
|
||
return extras;
|
||
}
|
||
|
||
function mergeAndNormalizeModels(baseModels, workflowModels, folderPaths) {
|
||
const merged = new Map();
|
||
const validFolderKeys = getKnownFolderKeys(folderPaths);
|
||
const fallbackOptions = getFallbackFolderOptions(validFolderKeys);
|
||
const allModels = [...(baseModels || []), ...(workflowModels || [])];
|
||
|
||
for (const rawModel of allModels) {
|
||
const filename = sanitizeFilename(rawModel.filename || rawModel.name);
|
||
const url = normalizeDownloadUrl(rawModel.url);
|
||
if (!filename || !url || !hasModelExtension(filename)) continue;
|
||
const key = filename.toLowerCase();
|
||
|
||
if (!merged.has(key)) {
|
||
merged.set(key, {
|
||
key: `${key}|${url}`,
|
||
filename,
|
||
url,
|
||
directory: rawModel.directory || null,
|
||
hash: typeof rawModel.hash === 'string' ? rawModel.hash : null,
|
||
hash_type: typeof rawModel.hash_type === 'string' ? rawModel.hash_type : null,
|
||
source: rawModel.source || 'unknown',
|
||
nodeTypes: Array.isArray(rawModel.nodeTypes) ? rawModel.nodeTypes : [],
|
||
needsFolderChoice: false,
|
||
folderOptions: [],
|
||
});
|
||
} else {
|
||
const existing = merged.get(key);
|
||
if (existing.source !== 'dialog' && rawModel.source === 'dialog') {
|
||
existing.source = 'dialog';
|
||
existing.url = url || existing.url;
|
||
}
|
||
if (!existing.directory && rawModel.directory) existing.directory = rawModel.directory;
|
||
if (!existing.hash && rawModel.hash) existing.hash = rawModel.hash;
|
||
if (!existing.hash_type && rawModel.hash_type) existing.hash_type = rawModel.hash_type;
|
||
if (Array.isArray(rawModel.nodeTypes)) {
|
||
existing.nodeTypes = Array.from(new Set([...(existing.nodeTypes || []), ...rawModel.nodeTypes]));
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const model of merged.values()) {
|
||
const resolved = resolveModelDirectory(model, validFolderKeys);
|
||
const currentDirectory = canonicalizeDirectoryKey(model.directory, new Set(validFolderKeys));
|
||
model.directory = currentDirectory || resolved.directory;
|
||
model.needsFolderChoice = !model.directory || resolved.ambiguous;
|
||
const candidateOptions = resolved.options.length ? resolved.options : [];
|
||
model.folderOptions = Array.from(new Set([
|
||
...candidateOptions,
|
||
...(model.directory ? [model.directory] : []),
|
||
...fallbackOptions,
|
||
]));
|
||
}
|
||
|
||
return Array.from(merged.values());
|
||
}
|
||
|
||
function augmentDialogMissingModels(allModels, folderPaths) {
|
||
const dialogState = getMissingModelsDialogState();
|
||
if (!dialogState || !Array.isArray(dialogState.missingModels)) return 0;
|
||
|
||
const missingModels = dialogState.missingModels;
|
||
const existingKeys = new Set(
|
||
missingModels.map((m) => `${sanitizeFilename(m?.name).toLowerCase()}|${normalizeDownloadUrl(m?.url)}`)
|
||
);
|
||
|
||
let added = 0;
|
||
for (const model of allModels || []) {
|
||
const filename = sanitizeFilename(model?.filename || model?.name);
|
||
const url = normalizeDownloadUrl(model?.url);
|
||
if (!filename || !url) continue;
|
||
const dedupeKey = `${filename.toLowerCase()}|${url}`;
|
||
if (existingKeys.has(dedupeKey)) continue;
|
||
|
||
const directory = model.directory
|
||
|| (Array.isArray(model.folderOptions) && model.folderOptions.length ? model.folderOptions[0] : null);
|
||
if (!directory) continue;
|
||
|
||
missingModels.push({
|
||
name: filename,
|
||
url,
|
||
directory,
|
||
...(model.hash ? { hash: model.hash } : {}),
|
||
...(model.hash_type ? { hash_type: model.hash_type } : {}),
|
||
});
|
||
existingKeys.add(dedupeKey);
|
||
added++;
|
||
}
|
||
|
||
if (dialogState.dialog?.contentProps?.paths && folderPaths && typeof folderPaths === 'object') {
|
||
const targetPaths = dialogState.dialog.contentProps.paths;
|
||
for (const [directory, values] of Object.entries(folderPaths)) {
|
||
if (!(directory in targetPaths)) {
|
||
targetPaths[directory] = values;
|
||
}
|
||
}
|
||
}
|
||
|
||
return added;
|
||
}
|
||
|
||
function parseDisplayedSizeToBytes(text) {
|
||
if (typeof text !== 'string') return null;
|
||
const match = text.trim().match(/^([\d.]+)\s*(B|KB|MB|GB|TB)$/i);
|
||
if (!match) return null;
|
||
const value = parseFloat(match[1]);
|
||
if (Number.isNaN(value)) return null;
|
||
const unit = match[2].toUpperCase();
|
||
const multipliers = {
|
||
B: 1,
|
||
KB: 1024,
|
||
MB: 1024 * 1024,
|
||
GB: 1024 * 1024 * 1024,
|
||
TB: 1024 * 1024 * 1024 * 1024,
|
||
};
|
||
const multiplier = multipliers[unit];
|
||
if (!multiplier) return null;
|
||
return Math.round(value * multiplier);
|
||
}
|
||
|
||
function getModelRowRightSide(row) {
|
||
if (!row) return null;
|
||
const direct = row.querySelector(':scope > div[class*="shrink-0"]');
|
||
if (direct) return direct;
|
||
const children = Array.from(row.children || []);
|
||
const byChildScan = children.find((child) =>
|
||
child.tagName === 'DIV' && String(child.className || '').includes('shrink-0')
|
||
);
|
||
if (byChildScan) return byChildScan;
|
||
return row.querySelector('div[class*="shrink-0"][class*="items-center"][class*="gap"]');
|
||
}
|
||
|
||
async function fetchModelSizeBytes(url) {
|
||
const normalized = normalizeDownloadUrl(url);
|
||
if (!normalized) return null;
|
||
if (modelSizeCache.has(normalized)) return modelSizeCache.get(normalized);
|
||
|
||
let size = null;
|
||
|
||
try {
|
||
const headResponse = await fetch(normalized, { method: 'HEAD' });
|
||
if (headResponse.ok) {
|
||
const contentLength = Number.parseInt(headResponse.headers.get('content-length') || '', 10);
|
||
if (Number.isFinite(contentLength) && contentLength > 0) {
|
||
size = contentLength;
|
||
}
|
||
}
|
||
} catch (_e) {
|
||
// ignored
|
||
}
|
||
|
||
if (!size) {
|
||
try {
|
||
const rangedResponse = await fetch(normalized, {
|
||
method: 'GET',
|
||
headers: { Range: 'bytes=0-0' },
|
||
});
|
||
const contentRange = rangedResponse.headers.get('content-range') || '';
|
||
const rangeMatch = contentRange.match(/\/(\d+)$/);
|
||
if (rangeMatch) {
|
||
const total = Number.parseInt(rangeMatch[1], 10);
|
||
if (Number.isFinite(total) && total > 0) size = total;
|
||
}
|
||
try {
|
||
await rangedResponse.body?.cancel?.();
|
||
} catch (_cancelErr) {
|
||
// ignored
|
||
}
|
||
} catch (_e) {
|
||
// ignored
|
||
}
|
||
}
|
||
|
||
modelSizeCache.set(normalized, size);
|
||
return size;
|
||
}
|
||
|
||
function findModelRowSizeElements(row) {
|
||
const rightSide = getModelRowRightSide(row);
|
||
if (!rightSide) return { rightSide: null, sizeEl: null };
|
||
let sizeEl = rightSide.querySelector('.server-download-size-label');
|
||
if (!sizeEl) {
|
||
// Native size label selector from MissingModelsContent.vue
|
||
sizeEl = rightSide.querySelector('span[class*="text-xs"][class*="text-muted-foreground"]');
|
||
}
|
||
return { rightSide, sizeEl };
|
||
}
|
||
|
||
function harmonizeRightSideLayout(rightSide, sizeEl) {
|
||
if (!rightSide) return;
|
||
rightSide.style.justifyContent = 'flex-end';
|
||
rightSide.style.minWidth = '132px';
|
||
if (!sizeEl) return;
|
||
sizeEl.style.minWidth = '76px';
|
||
sizeEl.style.textAlign = 'right';
|
||
sizeEl.style.fontVariantNumeric = 'tabular-nums';
|
||
}
|
||
|
||
function ensureSizeLabel(rightSide) {
|
||
let sizeEl = rightSide.querySelector('.server-download-size-label');
|
||
if (sizeEl) {
|
||
harmonizeRightSideLayout(rightSide, sizeEl);
|
||
return sizeEl;
|
||
}
|
||
sizeEl = createEl('span', {
|
||
paddingLeft: '2px',
|
||
fontSize: '0.75rem',
|
||
color: THEME.muted,
|
||
});
|
||
sizeEl.className = 'server-download-size-label';
|
||
const firstAction = rightSide.firstElementChild;
|
||
if (firstAction) {
|
||
rightSide.insertBefore(sizeEl, firstAction);
|
||
} else {
|
||
rightSide.appendChild(sizeEl);
|
||
}
|
||
harmonizeRightSideLayout(rightSide, sizeEl);
|
||
return sizeEl;
|
||
}
|
||
|
||
function getRowModelInfo(row, modelByFilename) {
|
||
const leftSide = row.querySelector('[class*="overflow-hidden"]');
|
||
const rightSide = getModelRowRightSide(row);
|
||
if (!leftSide || !rightSide) return null;
|
||
|
||
const nameSpan = leftSide.querySelector('span[title]');
|
||
const filename = sanitizeFilename(nameSpan?.getAttribute('title') || nameSpan?.textContent || '');
|
||
if (!filename) return null;
|
||
|
||
const urlFromButton = rightSide.querySelector('button[title]')?.getAttribute('title');
|
||
const urlFromAnchor = rightSide.querySelector('a[href]')?.getAttribute('href');
|
||
const inferredUrl = modelByFilename.get(filename.toLowerCase()) || null;
|
||
const url = normalizeDownloadUrl(urlFromButton || urlFromAnchor || inferredUrl || '');
|
||
if (!url) return null;
|
||
|
||
return { filename, url, rightSide };
|
||
}
|
||
|
||
function updateDialogTotalSize(container, sizeByUrl) {
|
||
const stickyRow = container.querySelector(':scope > div.sticky');
|
||
if (!stickyRow) return;
|
||
const spans = stickyRow.querySelectorAll('span');
|
||
if (!spans || spans.length < 2) return;
|
||
const totalEl = spans[1];
|
||
if (!totalEl) return;
|
||
let total = 0;
|
||
for (const value of sizeByUrl.values()) {
|
||
if (Number.isFinite(value) && value > 0) total += value;
|
||
}
|
||
if (total > 0) {
|
||
totalEl.textContent = formatBytes(total);
|
||
}
|
||
}
|
||
|
||
async function hydrateDialogModelSizes(container, allModels) {
|
||
if (!container) return;
|
||
if (container.dataset.serverDownloadSizing === 'busy') return;
|
||
container.dataset.serverDownloadSizing = 'busy';
|
||
|
||
try {
|
||
const modelByFilename = new Map(
|
||
(allModels || []).map((m) => [sanitizeFilename(m.filename).toLowerCase(), normalizeDownloadUrl(m.url)])
|
||
);
|
||
const rows = getModelRows(container);
|
||
const sizeByUrl = new Map();
|
||
|
||
for (const row of rows) {
|
||
const info = getRowModelInfo(row, modelByFilename);
|
||
if (!info) continue;
|
||
const { url, rightSide } = info;
|
||
const { sizeEl } = findModelRowSizeElements(row);
|
||
harmonizeRightSideLayout(rightSide, sizeEl);
|
||
const existingSizeText = sizeEl?.textContent?.trim();
|
||
const existingBytes = parseDisplayedSizeToBytes(existingSizeText || '');
|
||
if (existingBytes && existingBytes > 0) {
|
||
sizeByUrl.set(url, existingBytes);
|
||
continue;
|
||
}
|
||
|
||
const sizeBytes = await fetchModelSizeBytes(url);
|
||
if (sizeBytes && sizeBytes > 0) {
|
||
sizeByUrl.set(url, sizeBytes);
|
||
const label = ensureSizeLabel(rightSide);
|
||
label.textContent = formatBytes(sizeBytes);
|
||
}
|
||
}
|
||
|
||
updateDialogTotalSize(container, sizeByUrl);
|
||
} finally {
|
||
container.dataset.serverDownloadSizing = 'idle';
|
||
}
|
||
}
|
||
|
||
async function collectPreQueueModelCandidatesFromWorkflow(workflow, folderPaths) {
|
||
if (!workflow || typeof workflow !== 'object') return [];
|
||
const validFolderKeys = getKnownFolderKeys(folderPaths);
|
||
const validDirsSet = new Set(validFolderKeys);
|
||
const fallbackOptions = getFallbackFolderOptions(validFolderKeys);
|
||
|
||
const selectedByName = new Map();
|
||
const metadataByName = new Map();
|
||
const urlModelsByName = new Map();
|
||
|
||
walkWorkflowNodes(workflow, (node) => {
|
||
const nodeType = typeof node.type === 'string'
|
||
? node.type
|
||
: (typeof node.class_type === 'string' ? node.class_type : '');
|
||
const values = collectNodeModelLikeValues(node);
|
||
const selectedNamesInNode = new Set();
|
||
|
||
for (const value of values) {
|
||
if (!looksLikeWidgetModelValue(value)) continue;
|
||
const filename = sanitizeFilename(value);
|
||
const key = filename.toLowerCase();
|
||
selectedNamesInNode.add(key);
|
||
if (!selectedByName.has(key)) {
|
||
selectedByName.set(key, {
|
||
filename,
|
||
nodeTypes: new Set(),
|
||
});
|
||
}
|
||
if (nodeType) selectedByName.get(key).nodeTypes.add(nodeType);
|
||
}
|
||
|
||
const models = node?.properties?.models;
|
||
if (Array.isArray(models)) {
|
||
for (const m of models) {
|
||
const filename = sanitizeFilename(m?.name);
|
||
if (!filename || !hasModelExtension(filename)) continue;
|
||
const key = filename.toLowerCase();
|
||
if (selectedNamesInNode.size > 0 && !selectedNamesInNode.has(key)) continue;
|
||
metadataByName.set(key, {
|
||
filename,
|
||
url: normalizeDownloadUrl(m?.url || ''),
|
||
directory: m?.directory ? String(m.directory) : null,
|
||
hash: typeof m?.hash === 'string' ? m.hash : null,
|
||
hash_type: typeof m?.hash_type === 'string' ? m.hash_type : null,
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
if (Array.isArray(workflow?.models)) {
|
||
for (const m of workflow.models) {
|
||
const filename = sanitizeFilename(m?.name);
|
||
if (!filename || !hasModelExtension(filename)) continue;
|
||
const key = filename.toLowerCase();
|
||
if (selectedByName.size > 0 && !selectedByName.has(key)) continue;
|
||
metadataByName.set(key, {
|
||
filename,
|
||
url: normalizeDownloadUrl(m?.url || ''),
|
||
directory: m?.directory ? String(m.directory) : null,
|
||
hash: typeof m?.hash === 'string' ? m.hash : null,
|
||
hash_type: typeof m?.hash_type === 'string' ? m.hash_type : null,
|
||
});
|
||
}
|
||
}
|
||
|
||
walkObjectStrings(workflow, (text) => {
|
||
const urls = extractUrlsFromString(text);
|
||
for (const rawUrl of urls) {
|
||
const normalizedUrl = normalizeDownloadUrl(rawUrl);
|
||
const filename = getFilenameFromUrl(normalizedUrl);
|
||
if (!filename || !hasModelExtension(filename)) continue;
|
||
const key = filename.toLowerCase();
|
||
if (!selectedByName.has(key) && !metadataByName.has(key)) continue;
|
||
const inferredDirectory = inferDirectoryFromUrl(normalizedUrl, validDirsSet);
|
||
if (!urlModelsByName.has(key)) {
|
||
urlModelsByName.set(key, {
|
||
filename,
|
||
url: normalizedUrl,
|
||
directory: inferredDirectory,
|
||
});
|
||
} else {
|
||
const existing = urlModelsByName.get(key);
|
||
if (!existing.directory && inferredDirectory) {
|
||
existing.directory = inferredDirectory;
|
||
}
|
||
if (!existing.url && normalizedUrl) {
|
||
existing.url = normalizedUrl;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
const keys = new Set([...selectedByName.keys(), ...metadataByName.keys(), ...urlModelsByName.keys()]);
|
||
const candidates = [];
|
||
|
||
for (const key of keys) {
|
||
const selected = selectedByName.get(key);
|
||
const metadata = metadataByName.get(key);
|
||
const urlRef = urlModelsByName.get(key);
|
||
const filename = selected?.filename || metadata?.filename || urlRef?.filename;
|
||
if (!filename || !hasModelExtension(filename)) continue;
|
||
const candidateUrl = metadata?.url || urlRef?.url || '';
|
||
|
||
const candidate = {
|
||
key: `${key}|${candidateUrl}`,
|
||
filename,
|
||
url: candidateUrl,
|
||
directory: metadata?.directory || urlRef?.directory || null,
|
||
hash: metadata?.hash || null,
|
||
hash_type: metadata?.hash_type || null,
|
||
nodeTypes: Array.from(selected?.nodeTypes || []),
|
||
source: 'prequeue',
|
||
};
|
||
|
||
const resolved = resolveModelDirectory(candidate, validFolderKeys);
|
||
candidate.directory = canonicalizeDirectoryKey(candidate.directory, validDirsSet) || resolved.directory;
|
||
candidate.needsFolderChoice = !candidate.directory || resolved.ambiguous;
|
||
candidate.folderOptions = Array.from(new Set([
|
||
...(resolved.options || []),
|
||
...(candidate.directory ? [candidate.directory] : []),
|
||
...fallbackOptions,
|
||
]));
|
||
|
||
candidates.push(candidate);
|
||
}
|
||
|
||
return candidates;
|
||
}
|
||
|
||
async function collectPreQueueModelCandidates(folderPaths) {
|
||
const workflow = getCurrentWorkflowData();
|
||
if (!workflow) return [];
|
||
return await collectPreQueueModelCandidatesFromWorkflow(workflow, folderPaths);
|
||
}
|
||
|
||
async function checkMissingModelsViaBackend(candidates, folderPaths, options = {}) {
|
||
try {
|
||
const response = await api.fetchApi('/server_download/check_missing_models', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
models: candidates,
|
||
verify_hashes: !!options.verifyHashes,
|
||
}),
|
||
});
|
||
if (!response.ok) return null;
|
||
const data = await response.json();
|
||
return {
|
||
missing: Array.isArray(data?.missing) ? data.missing : [],
|
||
unresolved: Array.isArray(data?.unresolved) ? data.unresolved : [],
|
||
candidates,
|
||
folderPaths,
|
||
};
|
||
} catch (_e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function checkMissingModelsForCandidatesFallback(candidates, folderPaths, options = {}) {
|
||
const verifyHashes = !!options.verifyHashes;
|
||
// Legacy path for older backends: keep behavior compatible if the batch route is unavailable.
|
||
modelFolderCache.clear();
|
||
modelExistsAnywhereCache.clear();
|
||
modelDiskExistsCache.clear();
|
||
modelIntegrityCache.clear();
|
||
|
||
const missing = [];
|
||
const unresolved = [];
|
||
|
||
for (const model of candidates) {
|
||
if (!model.directory) {
|
||
const anywhere = await modelExistsAnywhere(model.filename, folderPaths);
|
||
if (anywhere?.exists === true) {
|
||
continue;
|
||
}
|
||
unresolved.push(model);
|
||
continue;
|
||
}
|
||
const exists = await modelExistsInDirectory(model.directory, model.filename);
|
||
if (exists === null) {
|
||
const anywhere = await modelExistsAnywhere(model.filename, folderPaths, [model.directory]);
|
||
if (anywhere?.exists === true) {
|
||
continue;
|
||
}
|
||
if (anywhere?.exists === null) {
|
||
unresolved.push({ ...model, reason: 'directory_listing_unavailable' });
|
||
continue;
|
||
}
|
||
}
|
||
if (exists === false) {
|
||
const anywhere = await modelExistsAnywhere(model.filename, folderPaths, [model.directory]);
|
||
if (anywhere?.exists === true) {
|
||
debugLog(`[RunpodDirect] Found ${model.filename} in ${anywhere.directory}, ignoring initial miss in ${model.directory}`);
|
||
continue;
|
||
}
|
||
if (anywhere?.exists === null) {
|
||
unresolved.push({ ...model, reason: 'directory_listing_unavailable' });
|
||
continue;
|
||
}
|
||
missing.push(model);
|
||
continue;
|
||
}
|
||
if (verifyHashes && exists === true && model.hash) {
|
||
const integrity = await verifyModelIntegrity(
|
||
model.directory,
|
||
model.filename,
|
||
model.hash,
|
||
model.hash_type || null
|
||
);
|
||
if (integrity?.exists === true && integrity?.valid === false) {
|
||
missing.push({ ...model, corrupted: true, reason: integrity.reason || 'hash_mismatch' });
|
||
}
|
||
}
|
||
}
|
||
|
||
return { missing, unresolved, candidates, folderPaths };
|
||
}
|
||
|
||
async function checkMissingModelsForWorkflow(workflow, piniaPaths = {}, options = {}) {
|
||
const verifyHashes = !!options.verifyHashes;
|
||
const folderPaths = await getAvailableFolderPaths(piniaPaths);
|
||
const candidates = await collectPreQueueModelCandidatesFromWorkflow(workflow, folderPaths);
|
||
if (!candidates.length) {
|
||
return { missing: [], unresolved: [], candidates: [], folderPaths };
|
||
}
|
||
|
||
const signature = buildPreQueueCandidateSignature(candidates, verifyHashes);
|
||
if (!options.forceRefresh) {
|
||
const cachedReport = getCachedPreQueueReport(signature);
|
||
if (cachedReport) {
|
||
return cachedReport;
|
||
}
|
||
}
|
||
|
||
let report = await checkMissingModelsViaBackend(candidates, folderPaths, { verifyHashes });
|
||
if (!report) {
|
||
report = await checkMissingModelsForCandidatesFallback(candidates, folderPaths, { verifyHashes });
|
||
}
|
||
|
||
setCachedPreQueueReport(signature, report);
|
||
return clonePreQueueReport(report);
|
||
}
|
||
|
||
async function checkMissingModelsBeforeQueue(options = {}) {
|
||
const dialogState = getMissingModelsDialogState();
|
||
const piniaPaths = dialogState?.paths || {};
|
||
const workflow = getCurrentWorkflowData({ preferSnapshot: false, allowSerialize: true });
|
||
const { missing, unresolved, candidates } = await checkMissingModelsForWorkflow(workflow, piniaPaths, options);
|
||
|
||
debugLog(
|
||
`[RunpodDirect] Pre-queue check: ${candidates.length} candidates, ${missing.length} missing, ${unresolved.length} unresolved`
|
||
);
|
||
|
||
return { missing, unresolved, candidates };
|
||
}
|
||
|
||
function seedWorkflowModelsForNativeMissingDialog(workflow, report) {
|
||
if (!workflow || typeof workflow !== 'object') return 0;
|
||
if (!Array.isArray(report?.missing) || report.missing.length === 0) return 0;
|
||
|
||
const rootModels = Array.isArray(workflow.models) ? workflow.models : [];
|
||
if (!Array.isArray(workflow.models)) {
|
||
workflow.models = rootModels;
|
||
}
|
||
|
||
const existingKeys = new Set();
|
||
const ingestModelKey = (entry) => {
|
||
const name = sanitizeFilename(entry?.name || entry?.filename || '');
|
||
const url = normalizeDownloadUrl(entry?.url || '');
|
||
if (!name || !url) return;
|
||
existingKeys.add(`${name.toLowerCase()}|${url}`);
|
||
};
|
||
|
||
for (const model of rootModels) ingestModelKey(model);
|
||
walkWorkflowNodes(workflow, (node) => {
|
||
const models = node?.properties?.models;
|
||
if (!Array.isArray(models)) return;
|
||
for (const model of models) ingestModelKey(model);
|
||
});
|
||
|
||
let added = 0;
|
||
for (const model of report.missing) {
|
||
const name = sanitizeFilename(model?.filename || model?.name || '');
|
||
const url = normalizeDownloadUrl(model?.url || '');
|
||
const directory = model?.directory || null;
|
||
if (!name || !url || !directory) continue;
|
||
const dedupeKey = `${name.toLowerCase()}|${url}`;
|
||
if (existingKeys.has(dedupeKey)) continue;
|
||
const entry = { name, url, directory };
|
||
if (typeof model?.hash === 'string' && model.hash) entry.hash = model.hash;
|
||
if (typeof model?.hash_type === 'string' && model.hash_type) entry.hash_type = model.hash_type;
|
||
rootModels.push(entry);
|
||
existingKeys.add(dedupeKey);
|
||
added++;
|
||
}
|
||
|
||
return added;
|
||
}
|
||
|
||
function closePreQueueMissingModal() {
|
||
const existing = document.querySelector('.server-download-prequeue-overlay');
|
||
if (existing) existing.remove();
|
||
}
|
||
|
||
function showPreQueueMissingModal(report, onQueueAnyway, options = {}) {
|
||
closePreQueueMissingModal();
|
||
const mode = options?.mode === 'auto' ? 'auto' : 'queue';
|
||
const isAutoMode = mode === 'auto';
|
||
|
||
const overlay = createEl('div', {
|
||
position: 'fixed',
|
||
inset: '0',
|
||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: '2000',
|
||
padding: '16px',
|
||
});
|
||
overlay.className = 'server-download-prequeue-overlay';
|
||
|
||
const dialog = createEl('div', {
|
||
width: '100%',
|
||
maxWidth: '560px',
|
||
borderRadius: '0.75rem',
|
||
border: `1px solid ${THEME.border}`,
|
||
backgroundColor: THEME.baseBg,
|
||
color: THEME.foreground,
|
||
overflow: 'hidden',
|
||
boxShadow: '0 20px 40px rgba(0,0,0,0.35)',
|
||
});
|
||
dialog.setAttribute('role', 'dialog');
|
||
dialog.onclick = (e) => e.stopPropagation();
|
||
|
||
const header = createEl('div', {
|
||
padding: '14px 16px',
|
||
borderBottom: `1px solid ${THEME.border}`,
|
||
fontSize: '1rem',
|
||
fontWeight: '600',
|
||
}, isAutoMode ? 'This Workflow Is Missing Models' : 'Missing Models Detected');
|
||
dialog.appendChild(header);
|
||
|
||
const body = createEl('div', {
|
||
padding: '14px 16px',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '10px',
|
||
});
|
||
|
||
const missingItems = Array.isArray(report?.missing) ? report.missing : [];
|
||
const unresolvedItems = Array.isArray(report?.unresolved) ? report.unresolved : [];
|
||
const hasHardMissing = missingItems.length > 0;
|
||
|
||
body.appendChild(createEl('div', {
|
||
fontSize: '0.8125rem',
|
||
color: THEME.muted,
|
||
lineHeight: '1.45',
|
||
}, isAutoMode
|
||
? (hasHardMissing
|
||
? 'Required model files are missing on disk. Some entries have no download URL in metadata, so they cannot be auto-downloaded.'
|
||
: 'Some model folders could not be resolved with confidence for this workflow.')
|
||
: (hasHardMissing
|
||
? 'Queue was blocked because required models are missing on disk. This prevents broken or partial generations.'
|
||
: 'Queue was blocked because some model folders could not be resolved with confidence.')));
|
||
|
||
const list = createEl('div', {
|
||
maxHeight: '260px',
|
||
overflowY: 'auto',
|
||
borderRadius: '0.5rem',
|
||
backgroundColor: THEME.secondaryBg,
|
||
border: `1px solid ${THEME.borderSubtle}`,
|
||
});
|
||
|
||
const items = hasHardMissing ? missingItems : unresolvedItems;
|
||
const maxRows = 18;
|
||
for (const model of items.slice(0, maxRows)) {
|
||
const row = createEl('div', {
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: '10px',
|
||
padding: '8px 10px',
|
||
borderBottom: `1px solid ${THEME.borderSubtle}`,
|
||
});
|
||
const name = createEl('span', {
|
||
minWidth: '0',
|
||
fontSize: '0.8125rem',
|
||
color: THEME.foreground,
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}, model.filename);
|
||
const badge = createEl('span', {
|
||
flexShrink: '0',
|
||
fontSize: '0.625rem',
|
||
fontWeight: '600',
|
||
textTransform: 'uppercase',
|
||
color: THEME.muted,
|
||
backgroundColor: `${THEME.muted}22`,
|
||
borderRadius: '9999px',
|
||
padding: '2px 6px',
|
||
letterSpacing: '0.02em',
|
||
}, String(model.directory || 'unknown'));
|
||
row.appendChild(name);
|
||
row.appendChild(badge);
|
||
list.appendChild(row);
|
||
}
|
||
if (items.length > maxRows) {
|
||
list.appendChild(createEl('div', {
|
||
padding: '8px 10px',
|
||
fontSize: '0.75rem',
|
||
color: THEME.muted,
|
||
}, `+ ${items.length - maxRows} more...`));
|
||
}
|
||
body.appendChild(list);
|
||
|
||
if (unresolvedItems.length > 0) {
|
||
body.appendChild(createEl('div', {
|
||
fontSize: '0.75rem',
|
||
color: THEME.warning,
|
||
lineHeight: '1.4',
|
||
}, `Note: ${unresolvedItems.length} model(s) have ambiguous folders and were not auto-validated.`));
|
||
}
|
||
|
||
dialog.appendChild(body);
|
||
|
||
const footer = createEl('div', {
|
||
padding: '12px 16px',
|
||
borderTop: `1px solid ${THEME.border}`,
|
||
display: 'flex',
|
||
justifyContent: 'flex-end',
|
||
gap: '8px',
|
||
});
|
||
|
||
const closeBtn = createEl('button', {
|
||
backgroundColor: THEME.secondaryBg,
|
||
color: THEME.foreground,
|
||
border: `1px solid ${THEME.border}`,
|
||
height: '32px',
|
||
padding: '0 10px',
|
||
fontSize: '0.75rem',
|
||
borderRadius: '0.5rem',
|
||
cursor: 'pointer',
|
||
}, isAutoMode ? 'Close' : 'Cancel');
|
||
closeBtn.type = 'button';
|
||
closeBtn.onclick = () => closePreQueueMissingModal();
|
||
footer.appendChild(closeBtn);
|
||
|
||
if (isAutoMode) {
|
||
const openHubBtn = createEl('button', {
|
||
backgroundColor: THEME.primary,
|
||
color: THEME.foreground,
|
||
border: 'none',
|
||
height: '32px',
|
||
padding: '0 10px',
|
||
fontSize: '0.75rem',
|
||
borderRadius: '0.5rem',
|
||
cursor: 'pointer',
|
||
fontWeight: '600',
|
||
}, 'Open RunpodDirect');
|
||
openHubBtn.type = 'button';
|
||
openHubBtn.onclick = () => {
|
||
closePreQueueMissingModal();
|
||
void openRunpodHubPanel();
|
||
};
|
||
footer.appendChild(openHubBtn);
|
||
} else {
|
||
const queueAnywayBtn = createEl('button', {
|
||
backgroundColor: THEME.warning,
|
||
color: THEME.foreground,
|
||
border: 'none',
|
||
height: '32px',
|
||
padding: '0 10px',
|
||
fontSize: '0.75rem',
|
||
borderRadius: '0.5rem',
|
||
cursor: 'pointer',
|
||
fontWeight: '600',
|
||
}, 'Queue Anyway');
|
||
queueAnywayBtn.type = 'button';
|
||
queueAnywayBtn.onclick = async () => {
|
||
closePreQueueMissingModal();
|
||
if (typeof onQueueAnyway === 'function') {
|
||
await onQueueAnyway();
|
||
}
|
||
};
|
||
footer.appendChild(queueAnywayBtn);
|
||
}
|
||
dialog.appendChild(footer);
|
||
|
||
overlay.onclick = () => closePreQueueMissingModal();
|
||
overlay.appendChild(dialog);
|
||
document.body.appendChild(overlay);
|
||
}
|
||
|
||
function syncPreQueueGuard(retryCount = 0) {
|
||
if (!isPreQueueGuardEnabled()) {
|
||
if (queueGuardInstalled && app?.queuePrompt === queueGuardWrappedPrompt && typeof queueGuardOriginalPrompt === 'function') {
|
||
app.queuePrompt = queueGuardOriginalPrompt;
|
||
debugLog('[RunpodDirect] Pre-queue model check guard removed');
|
||
}
|
||
queueGuardInstalled = false;
|
||
queueGuardWrappedPrompt = null;
|
||
queueGuardOriginalPrompt = null;
|
||
queueGuardBypassOnce = false;
|
||
closePreQueueMissingModal();
|
||
return;
|
||
}
|
||
|
||
if (queueGuardInstalled) return;
|
||
|
||
const currentQueuePrompt = app?.queuePrompt;
|
||
if (typeof currentQueuePrompt !== 'function') {
|
||
if (retryCount < 20) {
|
||
setTimeout(() => syncPreQueueGuard(retryCount + 1), 500);
|
||
} else {
|
||
debugLog('[RunpodDirect] queuePrompt hook not available, pre-queue guard not installed');
|
||
}
|
||
return;
|
||
}
|
||
|
||
const originalQueuePrompt = currentQueuePrompt;
|
||
queueGuardOriginalPrompt = originalQueuePrompt;
|
||
queueGuardWrappedPrompt = async function(number, batchCount = 1, queueNodeIds) {
|
||
if (!isPreQueueGuardEnabled()) {
|
||
return await originalQueuePrompt.call(this, number, batchCount, queueNodeIds);
|
||
}
|
||
|
||
if (queueGuardBypassOnce) {
|
||
queueGuardBypassOnce = false;
|
||
return await originalQueuePrompt.call(this, number, batchCount, queueNodeIds);
|
||
}
|
||
|
||
try {
|
||
const report = await checkMissingModelsBeforeQueue({
|
||
verifyHashes: isStrictPreQueueHashCheckEnabled(),
|
||
});
|
||
if (report.missing.length > 0 || report.unresolved.length > 0) {
|
||
debugLog(
|
||
`[RunpodDirect] Blocking queue: ${report.missing.length} missing, ${report.unresolved.length} unresolved model(s)`
|
||
);
|
||
showPreQueueMissingModal(report, async () => {
|
||
queueGuardBypassOnce = true;
|
||
await app.queuePrompt(number, batchCount, queueNodeIds);
|
||
});
|
||
return false;
|
||
}
|
||
} catch (error) {
|
||
console.error('[RunpodDirect] Pre-queue model check failed, allowing queue', error);
|
||
}
|
||
|
||
closePreQueueMissingModal();
|
||
autoMissingModalSignature = '';
|
||
autoMissingSuppressedByNativeSignature = '';
|
||
return await originalQueuePrompt.call(this, number, batchCount, queueNodeIds);
|
||
};
|
||
|
||
app.queuePrompt = queueGuardWrappedPrompt;
|
||
queueGuardInstalled = true;
|
||
debugLog('[RunpodDirect] Pre-queue model check guard installed');
|
||
}
|
||
|
||
// Check if HF_TOKEN env var is set on the backend
|
||
async function checkEnvHfToken() {
|
||
try {
|
||
const response = await api.fetchApi("/server_download/hf_token_status");
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
envHasHfToken = data.has_token;
|
||
}
|
||
} catch (e) {
|
||
// Ignore - endpoint may not exist on older backend
|
||
}
|
||
}
|
||
|
||
function isServerWsKeepaliveEnabled() {
|
||
return !!serverWsKeepaliveEnabled;
|
||
}
|
||
|
||
function isServerCgroupRamPatchEnabled() {
|
||
return !!serverCgroupRamPatchEnabled;
|
||
}
|
||
|
||
async function refreshServerWsKeepaliveSetting() {
|
||
try {
|
||
const response = await api.fetchApi('/server_download/keepalive_status');
|
||
if (!response.ok) return false;
|
||
const data = await response.json();
|
||
if (typeof data?.enabled === 'boolean') {
|
||
serverWsKeepaliveEnabled = data.enabled;
|
||
}
|
||
return true;
|
||
} catch (_e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function refreshServerCgroupRamPatchSetting() {
|
||
try {
|
||
const response = await api.fetchApi('/server_download/cgroup_ram_patch_status');
|
||
if (!response.ok) return false;
|
||
const data = await response.json();
|
||
if (typeof data?.enabled === 'boolean') {
|
||
serverCgroupRamPatchEnabled = data.enabled;
|
||
}
|
||
return true;
|
||
} catch (_e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function setServerCgroupRamPatchSetting(enabled) {
|
||
try {
|
||
const response = await api.fetchApi('/server_download/cgroup_ram_patch', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: !!enabled }),
|
||
});
|
||
if (!response.ok) return false;
|
||
const data = await response.json();
|
||
if (typeof data?.enabled === 'boolean') {
|
||
serverCgroupRamPatchEnabled = data.enabled;
|
||
} else {
|
||
serverCgroupRamPatchEnabled = !!enabled;
|
||
}
|
||
return true;
|
||
} catch (_e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function setServerWsKeepaliveSetting(enabled) {
|
||
try {
|
||
const response = await api.fetchApi('/server_download/keepalive', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: !!enabled }),
|
||
});
|
||
if (!response.ok) return false;
|
||
const data = await response.json();
|
||
if (typeof data?.enabled === 'boolean') {
|
||
serverWsKeepaliveEnabled = data.enabled;
|
||
} else {
|
||
serverWsKeepaliveEnabled = !!enabled;
|
||
}
|
||
return true;
|
||
} catch (_e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 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: sanitizeFilename(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 createFolderPickerSection(dialog, unresolvedModels, selectedFolders, onSelect) {
|
||
const existing = dialog.querySelector('.server-download-folder-picker');
|
||
if (existing) existing.remove();
|
||
if (!Array.isArray(unresolvedModels) || unresolvedModels.length === 0) return null;
|
||
|
||
const modelList = dialog.querySelector('[class*="scrollbar-custom"][class*="overflow-y-auto"][class*="rounded-lg"]');
|
||
if (!modelList) return null;
|
||
|
||
const section = createEl('div', {
|
||
borderRadius: '0.5rem',
|
||
backgroundColor: THEME.secondaryBg,
|
||
overflow: 'hidden',
|
||
});
|
||
section.className = 'server-download-folder-picker';
|
||
|
||
const header = createEl('div', {
|
||
padding: '8px 12px',
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
});
|
||
header.appendChild(createEl('span', {
|
||
fontSize: '0.75rem',
|
||
fontWeight: '600',
|
||
color: THEME.foreground,
|
||
}, 'Choose Model Folder'));
|
||
header.appendChild(createEl('span', {
|
||
fontSize: '0.6875rem',
|
||
color: THEME.warning,
|
||
}, `${unresolvedModels.length} unresolved`));
|
||
section.appendChild(header);
|
||
|
||
const body = createEl('div', {
|
||
padding: '0 12px 10px 12px',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '8px',
|
||
});
|
||
|
||
for (const model of unresolvedModels) {
|
||
const row = createEl('div', {
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '8px',
|
||
});
|
||
|
||
const name = createEl('span', {
|
||
flex: '1',
|
||
minWidth: '0',
|
||
fontSize: '0.75rem',
|
||
color: THEME.foreground,
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}, model.filename);
|
||
|
||
const select = document.createElement('select');
|
||
Object.assign(select.style, {
|
||
width: '200px',
|
||
height: '28px',
|
||
padding: '0 8px',
|
||
fontSize: '0.75rem',
|
||
borderRadius: '0.375rem',
|
||
border: `1px solid ${THEME.border}`,
|
||
backgroundColor: THEME.baseBg,
|
||
color: THEME.foreground,
|
||
outline: 'none',
|
||
});
|
||
select.onfocus = () => { select.style.borderColor = THEME.primary; };
|
||
select.onblur = () => { select.style.borderColor = THEME.border; };
|
||
|
||
const selected = selectedFolders.get(model.key);
|
||
const suggested = model.directory;
|
||
|
||
const placeholder = document.createElement('option');
|
||
placeholder.value = '';
|
||
placeholder.textContent = suggested
|
||
? `Select folder (suggested: ${suggested})`
|
||
: 'Select folder';
|
||
select.appendChild(placeholder);
|
||
|
||
const options = Array.isArray(model.folderOptions) ? model.folderOptions : [];
|
||
for (const option of options) {
|
||
const opt = document.createElement('option');
|
||
opt.value = option;
|
||
opt.textContent = option;
|
||
select.appendChild(opt);
|
||
}
|
||
|
||
if (selected) {
|
||
select.value = selected;
|
||
} else if (!model.needsFolderChoice && suggested) {
|
||
select.value = suggested;
|
||
} else {
|
||
select.value = '';
|
||
}
|
||
|
||
select.onchange = () => {
|
||
const value = select.value || null;
|
||
onSelect(model.key, value);
|
||
};
|
||
|
||
row.appendChild(name);
|
||
row.appendChild(select);
|
||
body.appendChild(row);
|
||
}
|
||
|
||
body.appendChild(createEl('div', {
|
||
fontSize: '0.6875rem',
|
||
color: THEME.muted,
|
||
lineHeight: '1.4',
|
||
}, 'Pick the destination folder for each model before downloading.'));
|
||
|
||
section.appendChild(body);
|
||
|
||
const afterEl = dialog.querySelector('.server-download-token-section') || modelList;
|
||
afterEl.parentElement.insertBefore(section, afterEl.nextSibling);
|
||
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
|
||
};
|
||
|
||
// --- Main injection logic ---
|
||
|
||
async function injectServerDownloadButtons() {
|
||
debugLog('[RunpodDirect] injectServerDownloadButtons called');
|
||
let dialogForLock = null;
|
||
try {
|
||
|
||
const container = findMissingModelsContainer();
|
||
if (!container) {
|
||
debugLog('[RunpodDirect] Missing models container not found');
|
||
return;
|
||
}
|
||
|
||
debugLog('[RunpodDirect] Found missing models container');
|
||
dialogForLock = container.closest('[role="dialog"]');
|
||
if (dialogForLock?.dataset?.serverDownloadInjecting === '1') {
|
||
debugLog('[RunpodDirect] Injection already in progress for this dialog');
|
||
return;
|
||
}
|
||
if (dialogForLock) {
|
||
dialogForLock.dataset.serverDownloadInjecting = '1';
|
||
}
|
||
if (dialogForLock?.querySelector('.server-download-all-btn') || document.querySelector('.server-download-all-btn')) {
|
||
debugLog('[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;
|
||
let piniaPaths = {};
|
||
|
||
// Strategy 1: Pinia dialog store (most reliable - works in production builds)
|
||
const piniaModels = extractModelsFromPinia();
|
||
if (piniaModels?.models?.length > 0) {
|
||
models = piniaModels.models;
|
||
piniaPaths = piniaModels.paths || {};
|
||
}
|
||
|
||
// 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;
|
||
debugLog(`[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;
|
||
debugLog(`[RunpodDirect] Got ${models.length} models from footer component`);
|
||
}
|
||
}
|
||
|
||
if (!models || models.length === 0) {
|
||
debugLog('[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 {
|
||
debugLog('[RunpodDirect] Max retries reached, giving up');
|
||
}
|
||
return;
|
||
}
|
||
|
||
const folderPaths = await getAvailableFolderPaths(piniaPaths);
|
||
|
||
// Additional model detection from workflow (widget selections + embedded URLs)
|
||
const workflowModels = await collectSmartWorkflowModels(models, folderPaths);
|
||
if (workflowModels.length > 0) {
|
||
debugLog(`[RunpodDirect] Found ${workflowModels.length} additional model(s) from workflow scan`);
|
||
}
|
||
|
||
models = mergeAndNormalizeModels(models, workflowModels, folderPaths);
|
||
|
||
if (!models.length) {
|
||
debugLog('[RunpodDirect] No valid downloadable models after normalization');
|
||
return;
|
||
}
|
||
|
||
const addedToDialog = augmentDialogMissingModels(models, folderPaths);
|
||
if (addedToDialog > 0) {
|
||
debugLog(`[RunpodDirect] Added ${addedToDialog} model(s) to visible missing-models list`);
|
||
// Let Vue flush the dialog list update before row-based DOM operations below.
|
||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||
}
|
||
void hydrateDialogModelSizes(container, models);
|
||
|
||
// Separate downloadable models from gated ones
|
||
const gatedModels = detectGatedModels(container);
|
||
const gatedFilenames = new Set(gatedModels.map(g => sanitizeFilename(g.filename).toLowerCase()));
|
||
|
||
// Models from Pinia include ALL models (including gated ones).
|
||
const downloadableModels = models.filter(m => !gatedFilenames.has(m.filename.toLowerCase()));
|
||
const gatedWithUrls = models.filter(m => gatedFilenames.has(m.filename.toLowerCase()));
|
||
|
||
downloadableModels.forEach((m, i) => {
|
||
debugLog(`[RunpodDirect] Model ${i + 1}: ${m.directory}/${m.filename} -> ${m.url}`);
|
||
});
|
||
if (gatedWithUrls.length > 0) {
|
||
debugLog(`[RunpodDirect] ${gatedWithUrls.length} gated model(s) detected`);
|
||
}
|
||
|
||
const selectedFolders = new Map();
|
||
// Mutable list — starts with only non-gated, grows when token is validated
|
||
let allModelsToDownload = [];
|
||
// 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"]');
|
||
|
||
function getResolvedDirectory(model) {
|
||
const selected = selectedFolders.get(model.key);
|
||
if (selected) return selected;
|
||
if (!model.needsFolderChoice) return model.directory;
|
||
return null;
|
||
}
|
||
|
||
function getUnresolvedModels() {
|
||
return models.filter((model) => model.needsFolderChoice && !selectedFolders.get(model.key));
|
||
}
|
||
|
||
function recomputeDownloadList() {
|
||
const activeModels = gatedVerified ? models : downloadableModels;
|
||
allModelsToDownload = activeModels
|
||
.map((model) => {
|
||
const directory = getResolvedDirectory(model);
|
||
const filename = sanitizeFilename(model.filename);
|
||
if (!directory || !filename) return null;
|
||
return {
|
||
...model,
|
||
directory,
|
||
filename,
|
||
};
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function refreshFolderPicker() {
|
||
if (!dialog) return;
|
||
const unresolved = getUnresolvedModels();
|
||
createFolderPickerSection(dialog, unresolved, selectedFolders, (modelKey, directory) => {
|
||
if (directory) {
|
||
selectedFolders.set(modelKey, directory);
|
||
} else {
|
||
selectedFolders.delete(modelKey);
|
||
}
|
||
updateBtnCount();
|
||
refreshFolderPicker();
|
||
});
|
||
}
|
||
|
||
// 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;
|
||
updateBtnCount();
|
||
debugLog(`[RunpodDirect] All gated models accessible, ${allModelsToDownload.length} total`);
|
||
},
|
||
onPartialAccess(_accessible, denied) {
|
||
// Some models need terms — keep button disabled
|
||
gatedVerified = false;
|
||
updateBtnCount();
|
||
debugLog(`[RunpodDirect] ${denied.length} model(s) need terms accepted`);
|
||
},
|
||
onFail() {
|
||
// Invalid token — keep button disabled
|
||
gatedVerified = false;
|
||
updateBtnCount();
|
||
debugLog('[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() {
|
||
recomputeDownloadList();
|
||
const count = allModelsToDownload.length;
|
||
const unresolvedCount = getUnresolvedModels().length;
|
||
downloadAllBtn.textContent = `Download to Pod (${count})`;
|
||
// Disable when: no models OR unresolved folders OR gated models exist but token not verified
|
||
const shouldDisable = count === 0 || unresolvedCount > 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';
|
||
}
|
||
if (unresolvedCount > 0) {
|
||
downloadAllBtn.title = `Choose folders for ${unresolvedCount} model(s) before downloading`;
|
||
} else if (hasGated && !gatedVerified) {
|
||
downloadAllBtn.title = 'Verify token for gated models first';
|
||
} else {
|
||
downloadAllBtn.title = 'Download all models directly to this RunPod instance (server-side)';
|
||
}
|
||
}
|
||
updateBtnCount();
|
||
refreshFolderPicker();
|
||
|
||
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: sanitizeFilename(m.filename),
|
||
hash: m.hash || null,
|
||
hash_type: m.hash_type || null,
|
||
}));
|
||
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 (dialog?.querySelector('.server-download-all-btn')) {
|
||
debugLog('[RunpodDirect] Button already present before insert, skipping duplicate');
|
||
return;
|
||
}
|
||
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);
|
||
}
|
||
|
||
debugLog('[RunpodDirect] Button injection complete');
|
||
setTimeout(() => { void hydrateDialogModelSizes(container, models); }, 350);
|
||
} catch (error) {
|
||
console.error('[RunpodDirect] Injection failed', error);
|
||
} finally {
|
||
if (dialogForLock?.dataset) {
|
||
delete dialogForLock.dataset.serverDownloadInjecting;
|
||
}
|
||
}
|
||
}
|
||
|
||
// MutationObserver for detecting the missing models dialog
|
||
function setupDialogObserver() {
|
||
debugLog('[RunpodDirect] Setting up dialog observer');
|
||
|
||
const observer = new MutationObserver((mutations) => {
|
||
for (const mutation of mutations) {
|
||
if (mutation.addedNodes.length) {
|
||
mutation.addedNodes.forEach(node => {
|
||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||
|
||
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')) {
|
||
debugLog('[RunpodDirect] Detected missing models dialog');
|
||
const fromCustomOverlay = !!dialog.closest('.server-download-prequeue-overlay');
|
||
if (!fromCustomOverlay && document.querySelector('.server-download-prequeue-overlay')) {
|
||
debugLog('[RunpodDirect] Closing custom missing-model dialog because native dialog is now visible');
|
||
closePreQueueMissingModal();
|
||
}
|
||
setTimeout(() => injectServerDownloadButtons(), 500);
|
||
}
|
||
}
|
||
}, 300);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
observer.observe(document.body, { childList: true, subtree: true });
|
||
debugLog('[RunpodDirect] Observer active');
|
||
}
|
||
|
||
// Register extension
|
||
app.registerExtension({
|
||
name: "ComfyUI.RunpodDirect",
|
||
actionBarButtons: [
|
||
{
|
||
label: 'RunpodDirect',
|
||
tooltip: 'RunpodDirect downloads and settings',
|
||
class: 'runpoddirect-top-btn',
|
||
onClick: () => {
|
||
toggleRunpodHubPanel();
|
||
},
|
||
},
|
||
],
|
||
|
||
async setup() {
|
||
debugLog("[RunpodDirect] Extension setup starting");
|
||
checkEnvHfToken();
|
||
syncPreQueueGuard();
|
||
setupDialogObserver();
|
||
installAutoMissingCheckListeners();
|
||
installRunpodHubListeners();
|
||
installRunpodHubStyles();
|
||
void refreshDownloadStatesFromBackend();
|
||
setTimeout(() => {
|
||
if (document.querySelector('.runpoddirect-top-btn')) return;
|
||
let attempts = 0;
|
||
const timer = setInterval(() => {
|
||
attempts += 1;
|
||
if (ensureRunpodTopBarButton() || attempts >= 20) {
|
||
clearInterval(timer);
|
||
}
|
||
}, 300);
|
||
}, 1200);
|
||
scheduleAutoMissingModelsCheck('setup-initial', 1200);
|
||
|
||
setTimeout(() => {
|
||
debugLog('[RunpodDirect] Checking for existing dialog...');
|
||
injectServerDownloadButtons();
|
||
}, 1000);
|
||
|
||
setTimeout(() => {
|
||
debugLog('[RunpodDirect] Second check for dialog...');
|
||
injectServerDownloadButtons();
|
||
}, 3000);
|
||
|
||
debugLog("[RunpodDirect] Extension setup complete");
|
||
},
|
||
|
||
async beforeConfigureGraph(graphData) {
|
||
try {
|
||
setWorkflowGraphSnapshot(graphData);
|
||
const folderPaths = await getAvailableFolderPaths({});
|
||
const candidates = await collectPreQueueModelCandidatesFromWorkflow(
|
||
graphData,
|
||
folderPaths
|
||
);
|
||
const seedable = candidates.filter((model) => {
|
||
const url = normalizeDownloadUrl(model?.url || '');
|
||
return !!(url && model?.directory);
|
||
});
|
||
const added = seedWorkflowModelsForNativeMissingDialog(graphData, { missing: seedable });
|
||
if (added > 0) {
|
||
debugLog(
|
||
`[RunpodDirect] beforeConfigureGraph: seeded ${added} model(s) into workflow.models ` +
|
||
`for native missing-models dialog (fast path, candidates=${candidates.length})`
|
||
);
|
||
} else {
|
||
debugLog(
|
||
`[RunpodDirect] beforeConfigureGraph: no additional native-model entries (fast path, candidates=${candidates.length})`
|
||
);
|
||
}
|
||
} catch (error) {
|
||
console.error('[RunpodDirect] beforeConfigureGraph native seeding failed', error);
|
||
}
|
||
},
|
||
|
||
async afterConfigureGraph(missingNodeTypes) {
|
||
const missingCount = Array.isArray(missingNodeTypes) ? missingNodeTypes.length : 0;
|
||
debugLog(`[RunpodDirect] afterConfigureGraph fired (missing node types: ${missingCount})`);
|
||
scheduleAutoMissingModelsCheck('afterConfigureGraph', 320);
|
||
setTimeout(() => { void maybeAutoShowMissingModelsModal('afterConfigureGraph-late'); }, 1400);
|
||
}
|
||
});
|
||
|
||
api.addEventListener("server_download_paused", ({ detail }) => {
|
||
const { download_id } = detail;
|
||
const prev = downloadStates.get(download_id) || {};
|
||
downloadStates.set(download_id, { ...prev, status: 'paused' });
|
||
window.dispatchEvent(new CustomEvent('serverDownloadUpdate', {
|
||
detail: { download_id, ...downloadStates.get(download_id) }
|
||
}));
|
||
});
|
||
|
||
api.addEventListener("server_download_resumed", ({ detail }) => {
|
||
const { download_id } = detail;
|
||
const prev = downloadStates.get(download_id) || {};
|
||
downloadStates.set(download_id, { ...prev, status: 'downloading' });
|
||
window.dispatchEvent(new CustomEvent('serverDownloadUpdate', {
|
||
detail: { download_id, ...downloadStates.get(download_id) }
|
||
}));
|
||
});
|
||
|
||
api.addEventListener("server_download_cancelled", ({ detail }) => {
|
||
const { download_id } = detail;
|
||
invalidatePreQueueReportCache();
|
||
const prev = downloadStates.get(download_id) || {};
|
||
downloadStates.set(download_id, { ...prev, status: 'cancelled' });
|
||
window.dispatchEvent(new CustomEvent('serverDownloadUpdate', {
|
||
detail: { download_id, ...downloadStates.get(download_id) }
|
||
}));
|
||
});
|