- Replaced all remaining 'Civicomfy' references with 'HuggingFace' - Updated JavaScript extension name and button text - Fixed all UI component references - Updated console logs and error messages - Changed extension registration name - Updated CSS and image paths This should fix the missing button issue in ComfyUI
466 lines
22 KiB
JavaScript
466 lines
22 KiB
JavaScript
import { Feedback } from "./feedback.js";
|
|
import { setupEventListeners } from "./handlers/eventListeners.js";
|
|
import { handleDownloadSubmit, fetchAndDisplayDownloadPreview, debounceFetchDownloadPreview } from "./handlers/downloadHandler.js";
|
|
import { handleSearchSubmit } from "./handlers/searchHandler.js";
|
|
import {
|
|
handleSettingsSave,
|
|
loadAndApplySettings,
|
|
loadSettingsFromCookie,
|
|
saveSettingsToCookie,
|
|
applySettings,
|
|
getDefaultSettings,
|
|
loadGlobalRootSetting,
|
|
handleSetGlobalRoot,
|
|
handleClearGlobalRoot,
|
|
} from "./handlers/settingsHandler.js";
|
|
import { startStatusUpdates, stopStatusUpdates, updateStatus, handleCancelDownload, handleRetryDownload, handleOpenPath, handleClearHistory } from "./handlers/statusHandler.js";
|
|
import { renderSearchResults } from "./searchRenderer.js";
|
|
import { renderDownloadList } from "./statusRenderer.js";
|
|
import { renderDownloadPreview } from "./previewRenderer.js";
|
|
import { modalTemplate } from "./templates.js";
|
|
import { HuggingFaceDownloaderAPI } from "../api/huggingface.js";
|
|
|
|
export class HuggingFaceDownloaderUI {
|
|
constructor() {
|
|
this.modal = null;
|
|
this.tabs = {};
|
|
this.tabContents = {};
|
|
this.activeTab = 'download';
|
|
this.modelTypes = {};
|
|
this.statusInterval = null;
|
|
this.statusData = { queue: [], active: [], history: [] };
|
|
this.baseModels = [];
|
|
this.searchPagination = { currentPage: 1, totalPages: 1, limit: 20 };
|
|
this.settings = this.getDefaultSettings();
|
|
this.toastTimeout = null;
|
|
this.modelPreviewDebounceTimeout = null;
|
|
|
|
this.updateStatus();
|
|
this.buildModalHTML();
|
|
this.cacheDOMElements();
|
|
this.setupEventListeners();
|
|
this.feedback = new Feedback(this.modal.querySelector('#huggingface-toast'));
|
|
// Ensure icon stylesheet is loaded so buttons render icons immediately
|
|
this.ensureFontAwesome();
|
|
}
|
|
|
|
// --- Core UI Methods ---
|
|
buildModalHTML() {
|
|
this.modal = document.createElement('div');
|
|
this.modal.className = 'huggingface-downloader-modal';
|
|
this.modal.id = 'huggingface-downloader-modal';
|
|
this.modal.innerHTML = modalTemplate(this.settings);
|
|
}
|
|
|
|
cacheDOMElements() {
|
|
this.closeButton = this.modal.querySelector('#huggingface-close-modal');
|
|
this.tabContainer = this.modal.querySelector('.huggingface-downloader-tabs');
|
|
|
|
// Download Tab
|
|
this.downloadForm = this.modal.querySelector('#huggingface-download-form');
|
|
this.downloadPreviewArea = this.modal.querySelector('#huggingface-download-preview-area');
|
|
this.modelUrlInput = this.modal.querySelector('#huggingface-model-url');
|
|
this.modelVersionIdInput = this.modal.querySelector('#huggingface-model-version-id');
|
|
this.downloadModelTypeSelect = this.modal.querySelector('#huggingface-model-type');
|
|
this.createModelTypeButton = this.modal.querySelector('#huggingface-create-model-type');
|
|
this.customFilenameInput = this.modal.querySelector('#huggingface-custom-filename');
|
|
this.subdirSelect = this.modal.querySelector('#huggingface-subdir-select');
|
|
this.createSubdirButton = this.modal.querySelector('#huggingface-create-subdir');
|
|
this.saveBasePathHint = this.modal.querySelector('#huggingface-save-base-path');
|
|
this.downloadConnectionsInput = this.modal.querySelector('#huggingface-connections');
|
|
this.forceRedownloadCheckbox = this.modal.querySelector('#huggingface-force-redownload');
|
|
this.downloadSubmitButton = this.modal.querySelector('#huggingface-download-submit');
|
|
|
|
// Search Tab
|
|
this.searchForm = this.modal.querySelector('#huggingface-search-form');
|
|
this.searchQueryInput = this.modal.querySelector('#huggingface-search-query');
|
|
this.searchTypeSelect = this.modal.querySelector('#huggingface-search-type');
|
|
this.searchBaseModelSelect = this.modal.querySelector('#huggingface-search-base-model');
|
|
this.searchSortSelect = this.modal.querySelector('#huggingface-search-sort');
|
|
this.searchPeriodSelect = this.modal.querySelector('#huggingface-search-period');
|
|
this.searchSubmitButton = this.modal.querySelector('#huggingface-search-submit');
|
|
this.searchResultsContainer = this.modal.querySelector('#huggingface-search-results');
|
|
this.searchPaginationContainer = this.modal.querySelector('#huggingface-search-pagination');
|
|
|
|
// Status Tab
|
|
this.statusContent = this.modal.querySelector('#huggingface-status-content');
|
|
this.activeListContainer = this.modal.querySelector('#huggingface-active-list');
|
|
this.queuedListContainer = this.modal.querySelector('#huggingface-queued-list');
|
|
this.historyListContainer = this.modal.querySelector('#huggingface-history-list');
|
|
this.statusIndicator = this.modal.querySelector('#huggingface-status-indicator');
|
|
this.activeCountSpan = this.modal.querySelector('#huggingface-active-count');
|
|
this.clearHistoryButton = this.modal.querySelector('#huggingface-clear-history-button');
|
|
this.confirmClearModal = this.modal.querySelector('#huggingface-confirm-clear-modal');
|
|
this.confirmClearYesButton = this.modal.querySelector('#huggingface-confirm-clear-yes');
|
|
this.confirmClearNoButton = this.modal.querySelector('#huggingface-confirm-clear-no');
|
|
|
|
// Settings Tab
|
|
this.settingsForm = this.modal.querySelector('#huggingface-settings-form');
|
|
this.settingsApiKeyInput = this.modal.querySelector('#huggingface-settings-api-key');
|
|
this.settingsGlobalRootInput = this.modal.querySelector('#huggingface-settings-global-root');
|
|
this.settingsSetGlobalRootButton = this.modal.querySelector('#huggingface-settings-set-global-root');
|
|
this.settingsClearGlobalRootButton = this.modal.querySelector('#huggingface-settings-clear-global-root');
|
|
this.settingsConnectionsInput = this.modal.querySelector('#huggingface-settings-connections');
|
|
this.settingsDefaultTypeSelect = this.modal.querySelector('#huggingface-settings-default-type');
|
|
this.settingsAutoOpenCheckbox = this.modal.querySelector('#huggingface-settings-auto-open-status');
|
|
this.settingsHideMatureCheckbox = this.modal.querySelector('#huggingface-settings-hide-mature');
|
|
this.settingsNsfwThresholdInput = this.modal.querySelector('#huggingface-settings-nsfw-threshold');
|
|
this.settingsSaveButton = this.modal.querySelector('#huggingface-settings-save');
|
|
|
|
// Toast Notification
|
|
this.toastElement = this.modal.querySelector('#huggingface-toast');
|
|
|
|
// Collect tabs and contents
|
|
this.tabs = {};
|
|
this.modal.querySelectorAll('.huggingface-downloader-tab').forEach(tab => {
|
|
this.tabs[tab.dataset.tab] = tab;
|
|
});
|
|
this.tabContents = {};
|
|
this.modal.querySelectorAll('.huggingface-downloader-tab-content').forEach(content => {
|
|
const tabName = content.id.replace('huggingface-tab-', '');
|
|
if (tabName) this.tabContents[tabName] = content;
|
|
});
|
|
}
|
|
|
|
async initializeUI() {
|
|
console.info("[HuggingFace] Initializing UI components...");
|
|
await this.populateModelTypes();
|
|
await this.populateBaseModels();
|
|
this.loadAndApplySettings();
|
|
await this.loadGlobalRootSetting();
|
|
if (this.downloadModelTypeSelect) {
|
|
await this.loadAndPopulateSubdirs(this.downloadModelTypeSelect.value);
|
|
}
|
|
}
|
|
|
|
async populateModelTypes() {
|
|
console.log("[HuggingFace] Populating model types...");
|
|
try {
|
|
const types = await HuggingFaceDownloaderAPI.getModelTypes();
|
|
if (!types || typeof types !== 'object' || Object.keys(types).length === 0) {
|
|
throw new Error("Received invalid model types data format.");
|
|
}
|
|
this.modelTypes = types;
|
|
const sortedTypes = Object.entries(this.modelTypes).sort((a, b) => a[1].localeCompare(b[1]));
|
|
|
|
this.downloadModelTypeSelect.innerHTML = '';
|
|
this.searchTypeSelect.innerHTML = '<option value="any">Any Type</option>';
|
|
this.settingsDefaultTypeSelect.innerHTML = '';
|
|
|
|
sortedTypes.forEach(([key, displayName]) => {
|
|
const option = document.createElement('option');
|
|
option.value = key;
|
|
option.textContent = displayName;
|
|
this.downloadModelTypeSelect.appendChild(option.cloneNode(true));
|
|
this.settingsDefaultTypeSelect.appendChild(option.cloneNode(true));
|
|
this.searchTypeSelect.appendChild(option.cloneNode(true));
|
|
});
|
|
// After types are populated, load subdirs for the current selection
|
|
await this.loadAndPopulateSubdirs(this.downloadModelTypeSelect.value);
|
|
} catch (error) {
|
|
console.error("[HuggingFace] Failed to get or populate model types:", error);
|
|
this.showToast('Failed to load model types', 'error');
|
|
this.downloadModelTypeSelect.innerHTML = '<option value="checkpoints">Checkpoints (Default)</option>';
|
|
this.modelTypes = { "checkpoints": "Checkpoints (Default)" };
|
|
}
|
|
}
|
|
|
|
async loadAndPopulateSubdirs(modelType) {
|
|
try {
|
|
const res = await HuggingFaceDownloaderAPI.getModelDirs(modelType);
|
|
const select = this.subdirSelect;
|
|
if (!select) return;
|
|
const current = select.value;
|
|
select.innerHTML = '';
|
|
const optRoot = document.createElement('option');
|
|
optRoot.value = '';
|
|
optRoot.textContent = '(root)';
|
|
select.appendChild(optRoot);
|
|
if (res && Array.isArray(res.subdirs)) {
|
|
// res.subdirs contains '' for root; skip empty since we added (root)
|
|
res.subdirs.filter(p => p && typeof p === 'string').forEach(rel => {
|
|
const opt = document.createElement('option');
|
|
opt.value = rel;
|
|
opt.textContent = rel;
|
|
select.appendChild(opt);
|
|
});
|
|
}
|
|
// Restore selection if still present
|
|
if (Array.from(select.options).some(o => o.value === current)) {
|
|
select.value = current;
|
|
}
|
|
if (this.saveBasePathHint) {
|
|
const basePath = (res && typeof res.base_dir === 'string') ? res.base_dir : '';
|
|
this.saveBasePathHint.textContent = basePath ? `Base path: ${basePath}` : '';
|
|
}
|
|
} catch (e) {
|
|
console.error('[HuggingFace] Failed to load subdirectories:', e);
|
|
if (this.subdirSelect) {
|
|
this.subdirSelect.innerHTML = '<option value="">(root)</option>';
|
|
}
|
|
if (this.saveBasePathHint) {
|
|
this.saveBasePathHint.textContent = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
// (loadAndPopulateRoots removed; dynamic types already reflect models/ subfolders)
|
|
|
|
async populateBaseModels() {
|
|
console.log("[HuggingFace] Populating base models...");
|
|
try {
|
|
const result = await HuggingFaceDownloaderAPI.getBaseModels();
|
|
if (!result || !Array.isArray(result.base_models)) {
|
|
throw new Error("Invalid base models data format received.");
|
|
}
|
|
this.baseModels = result.base_models.sort();
|
|
const existingOptions = Array.from(this.searchBaseModelSelect.options);
|
|
existingOptions.slice(1).forEach(opt => opt.remove());
|
|
this.baseModels.forEach(baseModelName => {
|
|
const option = document.createElement('option');
|
|
option.value = baseModelName;
|
|
option.textContent = baseModelName;
|
|
this.searchBaseModelSelect.appendChild(option);
|
|
});
|
|
} catch (error) {
|
|
console.error("[HuggingFace] Failed to get or populate base models:", error);
|
|
this.showToast('Failed to load base models list', 'error');
|
|
}
|
|
}
|
|
|
|
switchTab(tabId) {
|
|
if (this.activeTab === tabId || !this.tabs[tabId] || !this.tabContents[tabId]) return;
|
|
|
|
this.tabs[this.activeTab]?.classList.remove('active');
|
|
this.tabContents[this.activeTab]?.classList.remove('active');
|
|
|
|
this.tabs[tabId].classList.add('active');
|
|
this.tabContents[tabId].classList.add('active');
|
|
this.tabContents[tabId].scrollTop = 0;
|
|
this.activeTab = tabId;
|
|
|
|
if (tabId === 'status') this.updateStatus();
|
|
else if (tabId === 'settings') this.applySettings();
|
|
else if(tabId === 'download') {
|
|
this.downloadConnectionsInput.value = this.settings.numConnections;
|
|
if (Object.keys(this.modelTypes).length > 0) {
|
|
this.downloadModelTypeSelect.value = this.settings.defaultModelType;
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Modal Control ---
|
|
openModal() {
|
|
this.modal?.classList.add('open');
|
|
document.body.style.setProperty('overflow', 'hidden', 'important');
|
|
this.startStatusUpdates();
|
|
if (this.activeTab === 'status') this.updateStatus();
|
|
if (!this.settings.apiKey) this.switchTab('settings');
|
|
}
|
|
|
|
closeModal() {
|
|
this.modal?.classList.remove('open');
|
|
document.body.style.removeProperty('overflow');
|
|
this.stopStatusUpdates();
|
|
}
|
|
|
|
// --- Utility Methods ---
|
|
formatBytes(bytes, decimals = 2) {
|
|
if (bytes === null || bytes === undefined || isNaN(bytes)) return 'N/A';
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const dm = decimals < 0 ? 0 : decimals;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
|
}
|
|
|
|
formatSpeed(bytesPerSecond) {
|
|
if (!isFinite(bytesPerSecond) || bytesPerSecond <= 0) return '';
|
|
return this.formatBytes(bytesPerSecond) + '/s';
|
|
}
|
|
|
|
formatDuration(isoStart, isoEnd) {
|
|
try {
|
|
const diffSeconds = Math.round((new Date(isoEnd) - new Date(isoStart)) / 1000);
|
|
if (isNaN(diffSeconds) || diffSeconds < 0) return 'N/A';
|
|
if (diffSeconds < 60) return `${diffSeconds}s`;
|
|
const diffMinutes = Math.floor(diffSeconds / 60);
|
|
const remainingSeconds = diffSeconds % 60;
|
|
return `${diffMinutes}m ${remainingSeconds}s`;
|
|
} catch (e) {
|
|
return 'N/A';
|
|
}
|
|
}
|
|
|
|
showToast(message, type = 'info', duration = 3000) {
|
|
this.feedback?.show(message, type, duration);
|
|
}
|
|
|
|
ensureFontAwesome() {
|
|
this.feedback?.ensureFontAwesome();
|
|
}
|
|
|
|
// --- Rendering (delegated to external renderers) ---
|
|
renderDownloadList = (items, container, emptyMessage) => renderDownloadList(this, items, container, emptyMessage);
|
|
renderSearchResults = (items) => renderSearchResults(this, items);
|
|
renderDownloadPreview = (data) => renderDownloadPreview(this, data);
|
|
|
|
// --- Auto-select model type based on HuggingFace model type ---
|
|
inferFolderFromHuggingFaceType(huggingfaceType) {
|
|
if (!huggingfaceType || typeof huggingfaceType !== 'string') return null;
|
|
const t = huggingfaceType.trim().toLowerCase();
|
|
const keys = Object.keys(this.modelTypes || {});
|
|
if (keys.length === 0) return null;
|
|
|
|
const exists = (k) => keys.includes(k);
|
|
const findBy = (pred) => keys.find(pred);
|
|
|
|
// Direct matches first
|
|
if (exists(t)) return t;
|
|
if (exists(`${t}s`)) return `${t}s`;
|
|
|
|
// Common mappings from HuggingFace types to ComfyUI folders
|
|
const candidates = [];
|
|
const addIfExists = (k) => { if (exists(k)) candidates.push(k); };
|
|
|
|
switch (t) {
|
|
case 'checkpoint':
|
|
addIfExists('checkpoints');
|
|
addIfExists('models');
|
|
break;
|
|
case 'lora': case 'locon': case 'lycoris':
|
|
addIfExists('loras');
|
|
break;
|
|
case 'vae':
|
|
addIfExists('vae');
|
|
break;
|
|
case 'textualinversion': case 'embedding': case 'embeddings':
|
|
addIfExists('embeddings');
|
|
break;
|
|
case 'hypernetwork':
|
|
addIfExists('hypernetworks');
|
|
break;
|
|
case 'controlnet':
|
|
addIfExists('controlnet');
|
|
break;
|
|
case 'unet': case 'unet2':
|
|
addIfExists('unet');
|
|
break;
|
|
case 'diffusers': case 'diffusionmodels': case 'diffusion_models': case 'diffusion':
|
|
addIfExists('diffusers');
|
|
addIfExists('diffusion_models');
|
|
break;
|
|
case 'upscaler': case 'upscalers':
|
|
addIfExists('upscale_models');
|
|
addIfExists('upscalers');
|
|
break;
|
|
case 'motionmodule':
|
|
addIfExists('motion_models');
|
|
break;
|
|
case 'poses':
|
|
addIfExists('poses');
|
|
break;
|
|
case 'wildcards':
|
|
addIfExists('wildcards');
|
|
break;
|
|
case 'onnx':
|
|
addIfExists('onnx');
|
|
break;
|
|
}
|
|
if (candidates.length > 0) return candidates[0];
|
|
|
|
// Relaxed match: name contains type
|
|
const contains = findBy(k => k.toLowerCase().includes(t));
|
|
if (contains) return contains;
|
|
|
|
return null;
|
|
}
|
|
|
|
async autoSelectModelTypeFromHuggingFace(huggingfaceType) {
|
|
try {
|
|
const folder = this.inferFolderFromHuggingFaceType(huggingfaceType);
|
|
if (!folder) return;
|
|
if (this.downloadModelTypeSelect && this.downloadModelTypeSelect.value !== folder) {
|
|
this.downloadModelTypeSelect.value = folder;
|
|
await this.loadAndPopulateSubdirs(folder);
|
|
// Reset subdir to root after auto-switch
|
|
if (this.subdirSelect) this.subdirSelect.value = '';
|
|
}
|
|
} catch (e) {
|
|
console.warn('[HuggingFace] Auto-select model type failed:', e);
|
|
}
|
|
}
|
|
|
|
renderSearchPagination(metadata) {
|
|
if (!this.searchPaginationContainer) return;
|
|
if (!metadata || metadata.totalPages <= 1) {
|
|
this.searchPaginationContainer.innerHTML = '';
|
|
this.searchPagination = { ...this.searchPagination, ...metadata };
|
|
return;
|
|
}
|
|
|
|
this.searchPagination = { ...this.searchPagination, ...metadata };
|
|
const { currentPage, totalPages, totalItems } = this.searchPagination;
|
|
|
|
const createButton = (text, page, isDisabled = false, isCurrent = false) => {
|
|
const button = document.createElement('button');
|
|
button.className = `huggingface-button small huggingface-page-button ${isCurrent ? 'primary active' : ''}`;
|
|
button.dataset.page = page;
|
|
button.disabled = isDisabled;
|
|
button.innerHTML = text;
|
|
button.type = 'button';
|
|
return button;
|
|
};
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
fragment.appendChild(createButton('« Prev', currentPage - 1, currentPage === 1));
|
|
|
|
let startPage = Math.max(1, currentPage - 2);
|
|
let endPage = Math.min(totalPages, currentPage + 2);
|
|
|
|
if (startPage > 1) fragment.appendChild(createButton('1', 1));
|
|
if (startPage > 2) fragment.appendChild(document.createElement('span')).textContent = '...';
|
|
|
|
for (let i = startPage; i <= endPage; i++) {
|
|
fragment.appendChild(createButton(i, i, false, i === currentPage));
|
|
}
|
|
|
|
if (endPage < totalPages - 1) fragment.appendChild(document.createElement('span')).textContent = '...';
|
|
if (endPage < totalPages) fragment.appendChild(createButton(totalPages, totalPages));
|
|
|
|
fragment.appendChild(createButton('Next »', currentPage + 1, currentPage === totalPages));
|
|
|
|
const info = document.createElement('div');
|
|
info.className = 'huggingface-pagination-info';
|
|
info.textContent = `Page ${currentPage} of ${totalPages} (${totalItems.toLocaleString()} models)`;
|
|
fragment.appendChild(info);
|
|
|
|
this.searchPaginationContainer.innerHTML = '';
|
|
this.searchPaginationContainer.appendChild(fragment);
|
|
}
|
|
|
|
// --- Event Handlers and State Management (delegated to handlers) ---
|
|
setupEventListeners = () => setupEventListeners(this);
|
|
getDefaultSettings = () => getDefaultSettings();
|
|
loadAndApplySettings = () => loadAndApplySettings(this);
|
|
loadSettingsFromCookie = () => loadSettingsFromCookie(this);
|
|
saveSettingsToCookie = () => saveSettingsToCookie(this);
|
|
applySettings = () => applySettings(this);
|
|
handleSettingsSave = () => handleSettingsSave(this);
|
|
loadGlobalRootSetting = () => loadGlobalRootSetting(this);
|
|
handleSetGlobalRoot = () => handleSetGlobalRoot(this);
|
|
handleClearGlobalRoot = () => handleClearGlobalRoot(this);
|
|
handleDownloadSubmit = () => handleDownloadSubmit(this);
|
|
handleSearchSubmit = () => handleSearchSubmit(this);
|
|
fetchAndDisplayDownloadPreview = () => fetchAndDisplayDownloadPreview(this);
|
|
debounceFetchDownloadPreview = (delay) => debounceFetchDownloadPreview(this, delay);
|
|
startStatusUpdates = () => startStatusUpdates(this);
|
|
stopStatusUpdates = () => stopStatusUpdates(this);
|
|
updateStatus = () => updateStatus(this);
|
|
handleCancelDownload = (downloadId) => handleCancelDownload(this, downloadId);
|
|
handleRetryDownload = (downloadId, button) => handleRetryDownload(this, downloadId, button);
|
|
handleOpenPath = (downloadId, button) => handleOpenPath(this, downloadId, button);
|
|
handleClearHistory = () => handleClearHistory(this);
|
|
}
|