import { getSvgIcon } from "../../utils/icon_utils.js"; import { logger as frontendLogger } from "../../log_system/logger.js"; import { escapeHtml, pollBackgroundTask, safeStorage } from "../utils/html_utils.js"; const SETTINGS_MAP = []; export const optionsMethods = { confirmDiscardOptionsChanges() { if (this.activeTab !== 'options') return true; const status = this.contentElement?.querySelector('#mr-options-status'); if (!status?.classList.contains('is-dirty')) return true; return window.confirm('You have unsaved options. Are you sure you want to leave without saving?'); }, requestClose(...args) { if (!this.confirmDiscardOptionsChanges()) return false; return this.close(...args); }, buildLocalModelContext(item = {}, contextSource = '', extraFields = {}) { const modelLabel = item.relative_path || item.filename || item.model_path || 'Model'; const modelPath = item.model_path || ''; if (!modelPath) return null; return { context_scope: 'local_model', open_folder_label: 'Show File in Folder', name: item.filename || modelLabel, filename: item.filename || modelLabel, relative_path: item.relative_path || modelLabel, path: modelPath, resolved_path: modelPath, open_path: modelPath, folder_path: item.base_directory || '', category: item.category || '', metadata_path: item.metadata_path || '', ...extraFields, context_source: contextSource }; }, displayOptions() { if (!this.contentElement) return; this.contentElement.style.overflowY = 'hidden'; const tokens = this.getStoredTokens(); const pathTemplateCategories = this.getDownloadPathTemplateCategoryDefinitions(); const defaultRootCategories = this.getDefaultRootCategoryDefinitions(); const pathTemplatePresets = this.getDownloadPathTemplatePresetDefinitions(); const pathTemplatePresetValues = new Set(pathTemplatePresets.map(preset => preset.value)); const renderPathModeOptions = (selectedValue) => { const selected = this.normalizeDownloadPathMode(selectedValue); const options = [ { value: 'suggested', label: 'Suggest existing folders', tooltip: 'Uses current Model Resolver behavior: it fills an existing matching folder when possible.' }, { value: 'template', label: 'Use path templates', tooltip: 'Creates subfolders from template placeholders such as base model, author, tags, and model name.' }, { value: 'manual', label: 'Manual only', tooltip: 'Leaves the subfolder field empty until you type or pick a folder.' } ]; return options .map(option => ``) .join(''); }; const renderDownloadBackendOptions = (selectedValue) => { const selected = this.normalizeDownloadBackend(selectedValue); const options = [ { value: 'python', label: 'Python (built-in)', tooltip: 'Uses Model Resolver built-in downloader. This remains the default and requires no external tools.' }, { value: 'aria2', label: 'aria2 (experimental)', tooltip: 'Uses an external aria2c process for large downloads, pause/resume, and segmented transfers.' } ]; return options .map(option => ``) .join(''); }; const renderTemplatePlaceholderBadges = () => ['{base_model}', '{author}', '{first_tag}', '{model_name}', '{version_name}'] .map(placeholder => `${this.escapeHtml(placeholder)}`) .join(''); const renderPathTemplateRows = () => pathTemplateCategories .map(category => { const template = tokens.download_path_templates?.[category.key] ?? this.getDefaultDownloadPathTemplates()[category.key] ?? ''; const normalizedTemplate = this.normalizeDownloadPathValue(template); const isPreset = pathTemplatePresetValues.has(normalizedTemplate); const selectedValue = isPreset ? normalizedTemplate : 'custom'; const options = [ ...pathTemplatePresets.map(preset => ( `` )), `` ].join(''); const preview = this.calculateDownloadPathTemplateSubfolder(category.key, { base_model: 'Flux.1 D', tags: ['style', 'character'], author: 'example_author', model_name: 'Example Model', version_name: 'v1' }) || '(flat folder)'; return `
Preview: ${this.escapeHtml(preview)}
`; }) .join(''); const renderDefaultRootRows = () => defaultRootCategories .map(category => { const value = tokens[category.settingKey] || ''; return `
`; }) .join(''); const sourceDefaultsRows = this.getSearchSourceDefinitions() .filter(def => this.isSourceAvailable(def.source)) .map(def => { const iconName = this.getSearchSourceIconName(def.source); const checked = tokens.search_source_enabled?.[def.source] !== false; const inputId = `mr-options-source-${def.source.replace(/_/g, '-')}`; return ` `; }) .join(''); const logLevelValues = ['DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL', 'NONE']; const normalizeLogLevel = (value) => { const normalized = String(value || '').trim().toUpperCase(); return logLevelValues.includes(normalized) ? normalized : 'DEBUG'; }; const renderLogLevelOptions = (selectedValue) => { const selected = normalizeLogLevel(selectedValue); return logLevelValues .map(level => ``) .join(''); }; this.contentElement.innerHTML = `

Download Sources

Download Engine
?
Download backend
aria2c path ?
Backend aria2
Status Not checked
Daemon Not checked
Setup guide
Search Sources
?
${sourceDefaultsRows}

CivitAI

Search Methods
CivitAI Models To Inspect ?
Account Access
Not checked
How to find the session token
  1. Sign in on civitai.com in your browser.
  2. Open DevTools, then Application or Storage.
  3. Open Cookies for civitai.com and copy __Secure-civitai-token.

Treat it like a password. It is only needed when session-aware CivitAI search is required.

Not checked

HuggingFace

Not checked
Comfy-Org files Loading...
Kijai files Loading...
Last refresh Loading...
Status Checking local index...
Known HuggingFace fallback uses cached file indexes.
Not checked
`; const civitaiInput = this.contentElement.querySelector('#mr-options-civitai'); const civitaiSessionInput = this.contentElement.querySelector('#mr-options-civitai-session'); const hfInput = this.contentElement.querySelector('#mr-options-hf'); const braveInput = this.contentElement.querySelector('#mr-options-brave'); const civitaiToggle = this.contentElement.querySelector('#mr-options-civitai-toggle'); const civitaiSessionToggle = this.contentElement.querySelector('#mr-options-civitai-session-toggle'); const hfToggle = this.contentElement.querySelector('#mr-options-hf-toggle'); const braveToggle = this.contentElement.querySelector('#mr-options-brave-toggle'); const civitaiLimitInput = this.contentElement.querySelector('#mr-options-civitai-limit'); const civitaiUseTrpcSearchInput = this.contentElement.querySelector('#mr-options-civitai-use-trpc-search'); const civitaiUseApiSearchInput = this.contentElement.querySelector('#mr-options-civitai-use-api-search'); const civitaiUseHtmlFallbackInput = this.contentElement.querySelector('#mr-options-civitai-use-html-fallback'); const civitaiKeyCheckBtn = this.contentElement.querySelector('#mr-options-civitai-key-check'); const civitaiKeyCheckStatus = this.contentElement.querySelector('#mr-options-civitai-key-check-status'); const civitaiSessionCheckBtn = this.contentElement.querySelector('#mr-options-civitai-session-check'); const civitaiSessionCheckStatus = this.contentElement.querySelector('#mr-options-civitai-session-check-status'); const hfUseApiSearchInput = this.contentElement.querySelector('#mr-options-hf-use-api-search'); const hfUseComfyOrgFallbackInput = this.contentElement.querySelector('#mr-options-hf-use-comfy-org-fallback'); const hfUseBraveFallbackInput = this.contentElement.querySelector('#mr-options-hf-use-brave-fallback'); const autoFillBaseModelInput = this.contentElement.querySelector('#mr-options-auto-fill-base-model'); const autoFillSubfolderInput = this.contentElement.querySelector('#mr-options-auto-fill-subfolder'); const autoRefreshComfyModelsInput = this.contentElement.querySelector('#mr-options-auto-refresh-comfy-models'); const autoOpenOnMissingInput = this.contentElement.querySelector('#mr-options-auto-open-on-missing'); const workflowHashMetadataInput = this.contentElement.querySelector('#mr-options-workflow-hash-metadata'); const workflowDependencyMarkerInput = this.contentElement.querySelector('#mr-options-workflow-dependency-marker'); const addDependencyNodeBtn = this.contentElement.querySelector('#mr-options-add-dependency-node'); const downloadBackendInput = this.contentElement.querySelector('#mr-options-download-backend'); const aria2cPathInput = this.contentElement.querySelector('#mr-options-aria2c-path'); const aria2InstallBtn = this.contentElement.querySelector('#mr-options-aria2-install'); const aria2CheckBtn = this.contentElement.querySelector('#mr-options-aria2-check'); const aria2StopBtn = this.contentElement.querySelector('#mr-options-aria2-stop'); const aria2StatusEl = this.contentElement.querySelector('#mr-options-aria2-status'); const aria2BackendStateEl = this.contentElement.querySelector('#mr-options-aria2-backend-state'); const aria2AvailabilityEl = this.contentElement.querySelector('#mr-options-aria2-availability'); const aria2DaemonEl = this.contentElement.querySelector('#mr-options-aria2-daemon'); const aria2AutoStopInput = this.contentElement.querySelector('#mr-options-aria2-auto-stop'); const downloadSoundInput = this.contentElement.querySelector('#mr-options-download-sound'); const aria2SettingEls = Array.from(this.contentElement.querySelectorAll('.mr-options-aria2-setting')); const downloadPathModeInput = this.contentElement.querySelector('#mr-options-download-path-mode'); const defaultRootSelectInputs = Array.from(this.contentElement.querySelectorAll('.mr-options-default-root')); const templatePresetInputs = Array.from(this.contentElement.querySelectorAll('.mr-options-template-preset')); const templateCustomInputs = Array.from(this.contentElement.querySelectorAll('.mr-options-template-custom')); const detectTemplatesBtn = this.contentElement.querySelector('#mr-options-detect-templates'); const templateDetectStatus = this.contentElement.querySelector('#mr-options-template-detect-status'); const baseModelMappingsContainer = this.contentElement.querySelector('#mr-options-base-model-mappings'); const addBaseModelMappingBtn = this.contentElement.querySelector('#mr-options-add-base-model-mapping'); const frontendLogsEnabledInput = this.contentElement.querySelector('#mr-options-frontend-logs-enabled'); const backendLogsEnabledInput = this.contentElement.querySelector('#mr-options-backend-logs-enabled'); const frontendLogLevelInput = this.contentElement.querySelector('#mr-options-frontend-log-level'); const backendLogLevelInput = this.contentElement.querySelector('#mr-options-backend-log-level'); const exportFrontendLogsBtn = this.contentElement.querySelector('#mr-options-export-frontend-logs'); const exportBackendLogsBtn = this.contentElement.querySelector('#mr-options-export-backend-logs'); const exportFrontendLogsStatus = this.contentElement.querySelector('#mr-options-export-frontend-logs-status'); const exportBackendLogsStatus = this.contentElement.querySelector('#mr-options-export-backend-logs-status'); const hfTokenCheckBtn = this.contentElement.querySelector('#mr-options-hf-token-check'); const hfTokenCheckStatus = this.contentElement.querySelector('#mr-options-hf-token-check-status'); const braveKeyCheckBtn = this.contentElement.querySelector('#mr-options-brave-key-check'); const braveKeyCheckStatus = this.contentElement.querySelector('#mr-options-brave-key-check-status'); const sourceEnabledInputs = Array.from(this.contentElement.querySelectorAll('.mr-options-source-enabled')); const status = this.contentElement.querySelector('#mr-options-status'); const saveBtn = this.contentElement.querySelector('#mr-options-save'); const modelListCountEl = this.contentElement.querySelector('#mr-options-model-list-count'); const modelListUpdatedEl = this.contentElement.querySelector('#mr-options-model-list-updated'); const modelListStateEl = this.contentElement.querySelector('#mr-options-model-list-state'); const modelListMessageEl = this.contentElement.querySelector('#mr-options-model-list-message'); const modelListCheckBtn = this.contentElement.querySelector('#mr-options-model-list-check'); const modelListUpdateBtn = this.contentElement.querySelector('#mr-options-model-list-update'); const baseModelsCountEl = this.contentElement.querySelector('#mr-options-base-models-count'); const baseModelsUpdatedEl = this.contentElement.querySelector('#mr-options-base-models-updated'); const baseModelsStateEl = this.contentElement.querySelector('#mr-options-base-models-state'); const baseModelsMessageEl = this.contentElement.querySelector('#mr-options-base-models-message'); const baseModelsCheckBtn = this.contentElement.querySelector('#mr-options-base-models-check'); const baseModelsUpdateBtn = this.contentElement.querySelector('#mr-options-base-models-update'); const hfIndexAuthorsEl = this.contentElement.querySelector('#mr-options-hf-index-authors'); const hfIndexUpdatedEl = this.contentElement.querySelector('#mr-options-hf-index-updated'); const hfIndexStateEl = this.contentElement.querySelector('#mr-options-hf-index-state'); const hfIndexMessageEl = this.contentElement.querySelector('#mr-options-hf-index-message'); const hfIndexRefreshBtn = this.contentElement.querySelector('#mr-options-hf-index-refresh'); const clearAllCacheBtn = this.contentElement.querySelector('#mr-options-clear-all-cache'); const clearAllCacheStatus = this.contentElement.querySelector('#mr-options-clear-cache-status'); const metadataSizeAuditBtn = this.contentElement.querySelector('#mr-options-metadata-size-scan'); const metadataSizeAuditStatus = this.contentElement.querySelector('#mr-options-metadata-size-status'); const metadataSizeAuditResults = this.contentElement.querySelector('#mr-options-metadata-size-results'); const metadataSizeScannedEl = this.contentElement.querySelector('#mr-options-metadata-size-scanned'); const metadataSizeCheckedEl = this.contentElement.querySelector('#mr-options-metadata-size-checked'); const metadataSizeMismatchesEl = this.contentElement.querySelector('#mr-options-metadata-size-mismatches'); const metadataSizeMissingEl = this.contentElement.querySelector('#mr-options-metadata-size-missing'); const metadataSizeErrorsEl = this.contentElement.querySelector('#mr-options-metadata-size-errors'); const metadataSizeCollapseToggle = this.contentElement.querySelector('#mr-options-metadata-size-collapse-toggle'); const metadataSizeControls = this.contentElement.querySelector('#mr-options-metadata-size-controls'); const metadataSizeStack = this.contentElement.querySelector('.mr-options-metadata-size-stack'); const metadataBuildStartBtn = this.contentElement.querySelector('#mr-options-metadata-build-start'); const metadataBuildCancelBtn = this.contentElement.querySelector('#mr-options-metadata-build-cancel'); const metadataBuildStatus = this.contentElement.querySelector('#mr-options-metadata-build-status'); const metadataBuildProgress = this.contentElement.querySelector('#mr-options-metadata-build-progress'); const metadataBuildStageEl = this.contentElement.querySelector('#mr-options-metadata-build-stage'); const metadataBuildPercentEl = this.contentElement.querySelector('#mr-options-metadata-build-percent'); const metadataBuildBarEl = this.contentElement.querySelector('#mr-options-metadata-build-bar'); const metadataBuildCurrentModelEl = this.contentElement.querySelector('#mr-options-metadata-build-current-model'); const metadataBuildCurrentPathEl = this.contentElement.querySelector('#mr-options-metadata-build-current-path'); const metadataBuildBytesEl = this.contentElement.querySelector('#mr-options-metadata-build-bytes'); const metadataBuildWorkerListEl = this.contentElement.querySelector('#mr-options-metadata-build-worker-list'); const metadataBuildResults = this.contentElement.querySelector('#mr-options-metadata-build-results'); const metadataBuildTabButtons = Array.from(this.contentElement.querySelectorAll('.mr-options-build-tab')); const metadataBuildProgressPanel = this.contentElement.querySelector('#mr-options-metadata-build-progress-panel'); const metadataBuildHistoryPanel = this.contentElement.querySelector('#mr-options-metadata-build-history-panel'); const metadataBuildHistoryCountEl = this.contentElement.querySelector('#mr-options-metadata-build-history-count'); const metadataBuildCpuEl = this.contentElement.querySelector('#mr-options-metadata-build-cpu'); const metadataBuildWorkersEl = this.contentElement.querySelector('#mr-options-metadata-build-workers'); const metadataBuildWorkerInput = this.contentElement.querySelector('#mr-options-metadata-build-worker-count'); const metadataBuildWorkerHint = this.contentElement.querySelector('#mr-options-metadata-build-worker-hint'); const metadataBuildModeSelect = this.contentElement.querySelector('#mr-options-metadata-build-mode'); const metadataBuildModeHint = this.contentElement.querySelector('#mr-options-metadata-build-mode-hint'); const metadataBuildScannedEl = this.contentElement.querySelector('#mr-options-metadata-build-scanned'); const metadataBuildCreatedEl = this.contentElement.querySelector('#mr-options-metadata-build-created'); const metadataBuildUpdatedEl = this.contentElement.querySelector('#mr-options-metadata-build-updated'); const metadataBuildSkippedEl = this.contentElement.querySelector('#mr-options-metadata-build-skipped'); const metadataBuildHashesEl = this.contentElement.querySelector('#mr-options-metadata-build-hashes'); const metadataBuildErrorsEl = this.contentElement.querySelector('#mr-options-metadata-build-errors'); const metadataBuildCollapseToggle = this.contentElement.querySelector('#mr-options-metadata-build-collapse-toggle'); const metadataBuildControls = this.contentElement.querySelector('#mr-options-metadata-build-controls'); const metadataBuildStack = this.contentElement.querySelector('.mr-options-build-stack'); const optionsMain = this.contentElement.querySelector('.mr-options-main'); const navButtons = Array.from(this.contentElement.querySelectorAll('.mr-options-nav-btn')); const optionSections = Array.from(this.contentElement.querySelectorAll('.mr-options-section')); const metadataBuildHistoryFilterValues = ['all', 'done', 'created', 'updated', 'skipped', 'checked', 'error', 'cancelled']; if (!metadataBuildHistoryFilterValues.includes(this.metadataBuildHistoryFilter)) { this.metadataBuildHistoryFilter = 'all'; } this.metadataBuildHistoryFilterMenuOpen = false; const metadataBuildHistoryPageSize = 250; if (!Number.isFinite(Number(this.metadataBuildHistoryVisibleLimit)) || Number(this.metadataBuildHistoryVisibleLimit) < metadataBuildHistoryPageSize) { this.metadataBuildHistoryVisibleLimit = metadataBuildHistoryPageSize; } if (this.metadataBuildPollTimer) { window.clearTimeout(this.metadataBuildPollTimer); this.metadataBuildPollTimer = null; } const trackedInputs = [ civitaiInput, civitaiSessionInput, hfInput, braveInput, civitaiLimitInput, civitaiUseTrpcSearchInput, civitaiUseApiSearchInput, civitaiUseHtmlFallbackInput, hfUseApiSearchInput, hfUseComfyOrgFallbackInput, hfUseBraveFallbackInput, autoFillBaseModelInput, autoFillSubfolderInput, autoRefreshComfyModelsInput, autoOpenOnMissingInput, workflowHashMetadataInput, workflowDependencyMarkerInput, downloadBackendInput, aria2cPathInput, aria2AutoStopInput, downloadSoundInput, downloadPathModeInput, ...defaultRootSelectInputs, ...templatePresetInputs, ...templateCustomInputs, frontendLogsEnabledInput, backendLogsEnabledInput, frontendLogLevelInput, backendLogLevelInput, ...sourceEnabledInputs, ].filter(Boolean); const setStatus = (text, mode = '') => { if (!status) return; status.textContent = text; status.classList.remove('is-dirty', 'is-saved'); if (mode) status.classList.add(mode); }; const setExportLogsStatus = (statusEl, text) => { if (statusEl) statusEl.textContent = text; }; const parseDownloadFilename = (header, fallback) => { const value = String(header || ''); const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i); if (utf8Match?.[1]) { try { return decodeURIComponent(utf8Match[1].trim().replace(/^"|"$/g, '')); } catch (_error) { return utf8Match[1].trim().replace(/^"|"$/g, '') || fallback; } } const match = value.match(/filename="?([^";]+)"?/i); return match?.[1]?.trim() || fallback; }; const downloadBlob = (blob, filename) => { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }; const exportFrontendLogs = () => { try { if (exportFrontendLogsBtn) exportFrontendLogsBtn.disabled = true; const exported = frontendLogger.exportLogs('txt'); if (!exported) { setExportLogsStatus(exportFrontendLogsStatus, 'No frontend logs captured yet.'); this.showNotification('No frontend logs to export', 'warning'); return; } setExportLogsStatus(exportFrontendLogsStatus, 'Frontend logs exported.'); this.showNotification('Frontend logs exported', 'success'); } catch (error) { console.error('Model Resolver: frontend log export failed:', error); setExportLogsStatus(exportFrontendLogsStatus, error.message || 'Frontend log export failed.'); this.showNotification('Frontend log export failed', 'error'); } finally { if (exportFrontendLogsBtn) exportFrontendLogsBtn.disabled = false; } }; const exportBackendLogs = async () => { try { if (exportBackendLogsBtn) exportBackendLogsBtn.disabled = true; setExportLogsStatus(exportBackendLogsStatus, 'Preparing backend logs...'); const response = await this.fetchJson( '/model_resolver/logs/backend/export', { raw: true }, 'Export backend logs' ); const blob = await response.blob(); const filename = parseDownloadFilename( response.headers.get('Content-Disposition'), `model_resolver_backend_logs_${new Date().toISOString().replace(/[:.]/g, '-')}.txt` ); downloadBlob(blob, filename); setExportLogsStatus(exportBackendLogsStatus, 'Backend logs exported.'); this.showNotification('Backend logs exported', 'success'); } catch (error) { setExportLogsStatus(exportBackendLogsStatus, error.message || 'Backend log export failed.'); } finally { if (exportBackendLogsBtn) exportBackendLogsBtn.disabled = false; } }; const formatOptionDate = (value) => { if (!value) return 'Bundled'; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString(); }; const shortSha = (value) => value ? String(value).slice(0, 8) : ''; const refreshButtons = new Set([ modelListCheckBtn, baseModelsCheckBtn, hfIndexRefreshBtn, metadataSizeAuditBtn, aria2CheckBtn, civitaiKeyCheckBtn, civitaiSessionCheckBtn, hfTokenCheckBtn, braveKeyCheckBtn, clearAllCacheBtn ]); const refreshAnimations = new Map(); const setControlsBusyState = (buttons, busy, cancelBtn = null) => { const btnList = Array.isArray(buttons) ? buttons : [buttons]; btnList.forEach(btn => { if (!btn) return; btn.disabled = busy; if (!refreshButtons.has(btn)) return; refreshAnimations.get(btn)?.cancel(); refreshAnimations.delete(btn); btn.classList.toggle('mr-is-refreshing', busy); if (busy) { refreshAnimations.set(btn, this.startRefreshButtonAnimation(btn)); } }); if (cancelBtn) { cancelBtn.hidden = !busy; cancelBtn.disabled = false; } }; const setModelListBusy = (busy) => setControlsBusyState([modelListCheckBtn, modelListUpdateBtn], busy); const setBaseModelsBusy = (busy) => setControlsBusyState([baseModelsCheckBtn, baseModelsUpdateBtn], busy); const setHfIndexBusy = (busy) => setControlsBusyState(hfIndexRefreshBtn, busy); const setMetadataSizeAuditBusy = (busy) => setControlsBusyState(metadataSizeAuditBtn, busy); const setStatusMode = (element, mode = '') => { if (!element) return; element.classList.remove('is-valid', 'is-invalid', 'is-pending'); if (mode) element.classList.add(mode); }; const formatAuditNumber = (value) => { const number = Number(value || 0); return Number.isFinite(number) ? number.toLocaleString() : '0'; }; const setElementStatusText = (element, text, mode = '') => { if (!element) return; element.textContent = text; setStatusMode(element, mode); }; const setMetadataSizeAuditStatus = (text, mode = '') => setElementStatusText(metadataSizeAuditStatus, text, mode); const setSummaryValue = (element, value, mode = '') => { if (!element) return; element.textContent = formatAuditNumber(value); setStatusMode(element, mode); }; const setMetadataSizeControlsCollapsed = (collapsed, persist = true) => { const isCollapsed = Boolean(collapsed); this.metadataSizeControlsCollapsed = isCollapsed; if (metadataSizeControls) { metadataSizeControls.hidden = isCollapsed; } if (metadataSizeStack) { metadataSizeStack.classList.toggle('is-controls-collapsed', isCollapsed); } if (metadataSizeCollapseToggle) { metadataSizeCollapseToggle.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true'); this.setTooltip?.( metadataSizeCollapseToggle, isCollapsed ? 'Show metadata size audit controls' : 'Hide metadata size audit controls' ); } if (persist) { try { safeStorage.setItem( this.metadataSizeControlsCollapsedStorageKey || 'model_resolver_metadata_size_controls_collapsed', isCollapsed ? '1' : '0' ); } catch (error) { console.warn('Model Resolver: could not save metadata size audit collapse state:', error); } } }; if (!this.metadataSizeControlsCollapsedStorageKey) { this.metadataSizeControlsCollapsedStorageKey = 'model_resolver_metadata_size_controls_collapsed'; } if (typeof this.metadataSizeControlsCollapsed !== 'boolean') { try { this.metadataSizeControlsCollapsed = safeStorage.getItem(this.metadataSizeControlsCollapsedStorageKey) === '1'; } catch (_error) { this.metadataSizeControlsCollapsed = false; } } setMetadataSizeControlsCollapsed(this.metadataSizeControlsCollapsed, false); const updateMetadataSizeAuditSummary = (data = {}) => { const mismatchCount = Number(data.mismatch_count || 0); const missingSizeCount = Number(data.missing_size || 0); const errorCount = Number(data.error_count || 0); setSummaryValue(metadataSizeScannedEl, data.scanned_models || 0); setSummaryValue(metadataSizeCheckedEl, data.metadata_files ?? data.checked_metadata ?? 0); setSummaryValue(metadataSizeMismatchesEl, mismatchCount, mismatchCount > 0 ? 'is-invalid' : ''); setSummaryValue(metadataSizeMissingEl, missingSizeCount, missingSizeCount > 0 ? 'is-pending' : ''); setSummaryValue(metadataSizeErrorsEl, errorCount, errorCount > 0 ? 'is-invalid' : ''); }; const renderMetadataSizeAuditResults = (data = null) => { if (!metadataSizeAuditResults) return; if (!data) { metadataSizeAuditResults.hidden = true; metadataSizeAuditResults.innerHTML = ''; return; } const mismatches = Array.isArray(data.mismatches) ? data.mismatches : []; const errors = Array.isArray(data.errors) ? data.errors : []; const visibleMismatches = mismatches.slice(0, 500); let html = ''; if (mismatches.length) { const rows = visibleMismatches.map((item) => { const modelLabel = item.relative_path || item.filename || item.model_path || 'Model'; const modelPath = item.model_path || ''; const metadataPath = item.metadata_path || ''; const metadataSize = item.metadata_size_label || this.formatBytes(item.metadata_size); const actualSize = item.actual_size_label || this.formatBytes(item.actual_size); const difference = Number(item.difference || 0); const differenceClass = difference > 0 ? 'is-positive' : (difference < 0 ? 'is-negative' : ''); const differenceLabel = item.difference_label || `${difference >= 0 ? '+' : '-'}${this.formatBytes(Math.abs(difference))}`; const sizeField = item.size_field || 'size'; const auditContext = this.buildLocalModelContext(item, 'metadata_size_audit', { size: item.actual_size || 0, file_size: item.actual_size || 0, metadata_size: item.metadata_size || 0 }); const contextAttrs = auditContext ? this.getContextMenuAttrs(auditContext, 'Right-click for model options') : ''; return `
${this.escapeHtml(modelLabel)}
${this.escapeHtml(modelPath)}
${this.escapeHtml(item.category || '')}
${this.escapeHtml(metadataSize)}
${this.escapeHtml(sizeField)}
${this.escapeHtml(actualSize)} ${this.escapeHtml(differenceLabel)}
${this.escapeHtml(metadataPath)}
`; }).join(''); html += `
${rows}
Model Folder Metadata Actual Delta Metadata file
`; if (mismatches.length > visibleMismatches.length) { html += `
Showing first ${visibleMismatches.length.toLocaleString()} of ${mismatches.length.toLocaleString()} mismatches.
`; } } else { html += '
No metadata size mismatches found.
'; } if (errors.length) { const errorRows = errors.slice(0, 10).map((item) => { const targetPath = item.metadata_path || item.model_path || ''; return `
  • ${this.escapeHtml(item.message || 'Error')}${this.escapeHtml(targetPath)}
  • `; }).join(''); html += `
    Read errors
    `; } metadataSizeAuditResults.innerHTML = html; metadataSizeAuditResults.hidden = false; }; const runMetadataSizeAudit = async () => { setMetadataSizeAuditBusy(true); updateMetadataSizeAuditSummary({}); renderMetadataSizeAuditResults(null); setMetadataSizeAuditStatus('Checking metadata sizes...', 'is-pending'); try { const data = await this.fetchJson('/model_resolver/metadata-size-audit', { method: 'POST', body: JSON.stringify({ force_rescan: true }) }, 'Check metadata sizes'); updateMetadataSizeAuditSummary(data); renderMetadataSizeAuditResults(data); const mismatchCount = Number(data?.mismatch_count || 0); const checkedCount = Number(data?.metadata_files ?? data?.checked_metadata ?? 0); const errorCount = Number(data?.error_count || 0); const workerCount = Number(data?.worker_count || 0); const batchCount = Number(data?.batch_count || 0); const workerText = workerCount > 1 ? ` using ${workerCount.toLocaleString()} workers across ${batchCount.toLocaleString()} batch${batchCount === 1 ? '' : 'es'}` : ''; if (mismatchCount > 0) { setMetadataSizeAuditStatus(`${mismatchCount.toLocaleString()} metadata size mismatch${mismatchCount === 1 ? '' : 'es'} found${workerText}.`, 'is-invalid'); this.showNotification('Metadata size mismatches found', 'warning'); } else if (errorCount > 0) { setMetadataSizeAuditStatus(`Checked ${checkedCount.toLocaleString()} metadata file${checkedCount === 1 ? '' : 's'}${workerText} with ${errorCount.toLocaleString()} read error${errorCount === 1 ? '' : 's'}.`, 'is-invalid'); this.showNotification('Metadata size audit finished with errors', 'warning'); } else { setMetadataSizeAuditStatus(`Checked ${checkedCount.toLocaleString()} metadata file${checkedCount === 1 ? '' : 's'}${workerText}. No mismatches found.`, 'is-valid'); this.showNotification('Metadata sizes checked', 'success'); } } catch (error) { setMetadataSizeAuditStatus(error.message || 'Metadata size audit failed.', 'is-invalid'); renderMetadataSizeAuditResults(null); } finally { setMetadataSizeAuditBusy(false); } }; const setMetadataBuildBusy = (busy) => { setControlsBusyState(metadataBuildStartBtn, busy, metadataBuildCancelBtn); if (metadataBuildWorkerInput) metadataBuildWorkerInput.disabled = busy; if (metadataBuildModeSelect) metadataBuildModeSelect.disabled = busy; }; const setMetadataBuildStatus = (text, mode = '') => setElementStatusText(metadataBuildStatus, text, mode); const setMetadataBuildControlsCollapsed = (collapsed, persist = true) => { const isCollapsed = Boolean(collapsed); this.metadataBuildControlsCollapsed = isCollapsed; if (metadataBuildControls) { metadataBuildControls.hidden = isCollapsed; } if (metadataBuildStack) { metadataBuildStack.classList.toggle('is-controls-collapsed', isCollapsed); } if (metadataBuildCollapseToggle) { metadataBuildCollapseToggle.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true'); metadataBuildCollapseToggle.classList.toggle('is-collapsed', isCollapsed); this.setTooltip?.( metadataBuildCollapseToggle, isCollapsed ? 'Show metadata builder controls' : 'Hide metadata builder controls' ); } if (persist) { try { safeStorage.setItem( this.metadataBuildControlsCollapsedStorageKey || 'model_resolver_metadata_build_controls_collapsed', isCollapsed ? '1' : '0' ); } catch (error) { console.warn('Model Resolver: could not save metadata builder collapse state:', error); } } }; if (!this.metadataBuildControlsCollapsedStorageKey) { this.metadataBuildControlsCollapsedStorageKey = 'model_resolver_metadata_build_controls_collapsed'; } if (typeof this.metadataBuildControlsCollapsed !== 'boolean') { try { this.metadataBuildControlsCollapsed = safeStorage.getItem(this.metadataBuildControlsCollapsedStorageKey) === '1'; } catch (_error) { this.metadataBuildControlsCollapsed = false; } } setMetadataBuildControlsCollapsed(this.metadataBuildControlsCollapsed, false); let scheduleMetadataBuildHistoryRender = () => {}; const setMetadataBuildPanelHidden = (panel, hidden) => { if (!panel) return; panel.classList.toggle('is-hidden', hidden); panel.setAttribute('aria-hidden', hidden ? 'true' : 'false'); try { panel.inert = hidden; } catch (_error) { panel.toggleAttribute('inert', hidden); } }; const setMetadataBuildTab = (tabName = 'progress') => { const activeTab = tabName === 'history' ? 'history' : 'progress'; metadataBuildTabButtons.forEach((button) => { const isActive = button.dataset.buildTab === activeTab; button.classList.toggle('is-active', isActive); button.classList.toggle('mr-tab-active', isActive); button.setAttribute('aria-selected', isActive ? 'true' : 'false'); }); setMetadataBuildPanelHidden(metadataBuildProgressPanel, activeTab !== 'progress'); setMetadataBuildPanelHidden(metadataBuildHistoryPanel, activeTab !== 'history'); this.metadataBuildActiveTab = activeTab; if (activeTab === 'history') { scheduleMetadataBuildHistoryRender(); } }; const getMetadataBuildHistoryKey = (item = {}) => { const path = item.model_path || item.path || item.metadata_path || item.filename || ''; const action = item.action || ''; const sha = item.sha256 || ''; return `${action}::${path}::${sha}`; }; const appendMetadataBuildHistory = (data = {}) => { if (!this.metadataBuildHistory) this.metadataBuildHistory = []; if (!this.metadataBuildHistoryKeys) this.metadataBuildHistoryKeys = new Set(); const source = data?.result && typeof data.result === 'object' ? data.result : data; const items = []; if (Array.isArray(data.history_items)) items.push(...data.history_items); if (Array.isArray(source.history)) items.push(...source.history); if (!items.length && Array.isArray(source.updated)) items.push(...source.updated); items.forEach((rawItem) => { if (!rawItem || typeof rawItem !== 'object') return; const item = { ...rawItem }; const key = getMetadataBuildHistoryKey(item); if (!key || this.metadataBuildHistoryKeys.has(key)) return; this.metadataBuildHistoryKeys.add(key); this.metadataBuildHistory.unshift(item); }); if (metadataBuildHistoryCountEl) { metadataBuildHistoryCountEl.textContent = this.metadataBuildHistory.length.toLocaleString(); } }; const getMetadataBuildWorkerBounds = () => { const capabilities = this.metadataBuildCapabilities || {}; const min = Math.max(1, Number(capabilities.min_worker_count || 1)); const max = Math.max(min, Number(capabilities.max_worker_count || 64)); return { min, max }; }; const normalizeMetadataBuildWorkerCount = () => { const { min, max } = getMetadataBuildWorkerBounds(); const fallback = Number(this.metadataBuildCapabilities?.default_worker_count || metadataBuildWorkerInput?.value || 1); const raw = Number(metadataBuildWorkerInput?.value || fallback || 1); const workerCount = Math.max(min, Math.min(max, Number.isFinite(raw) ? Math.round(raw) : fallback)); if (metadataBuildWorkerInput) { metadataBuildWorkerInput.min = String(min); metadataBuildWorkerInput.max = String(max); metadataBuildWorkerInput.value = String(workerCount); } return workerCount; }; const updateMetadataBuildModeHint = () => { if (!metadataBuildModeHint) return; const metadataMode = metadataBuildModeSelect?.value || 'calculate_fresh'; metadataBuildModeHint.textContent = metadataMode === 'import_existing' ? 'Read metadata from other plugins without modifying it. Reuse a completed SHA-256 when available; otherwise calculate it.' : 'Ignore metadata from other plugins and calculate a new SHA-256 directly from each model file.'; }; const renderMetadataBuildCapabilities = (data = {}) => { this.metadataBuildCapabilities = data && typeof data === 'object' ? data : {}; const cpuCount = Number(this.metadataBuildCapabilities.cpu_count || navigator.hardwareConcurrency || 1); const defaultWorkers = Number(this.metadataBuildCapabilities.default_worker_count || Math.min(cpuCount, 4) || 1); const maxWorkers = Number(this.metadataBuildCapabilities.max_worker_count || 64); if (metadataBuildCpuEl) metadataBuildCpuEl.textContent = cpuCount.toLocaleString(); if (metadataBuildWorkerInput && !metadataBuildWorkerInput.dataset.userEdited) { metadataBuildWorkerInput.value = String(defaultWorkers); } normalizeMetadataBuildWorkerCount(); if (metadataBuildWorkerHint) { metadataBuildWorkerHint.textContent = `CPU cores: ${cpuCount.toLocaleString()}. Max workers: ${maxWorkers.toLocaleString()}.`; } const supportedModes = Array.isArray(this.metadataBuildCapabilities.metadata_modes) ? this.metadataBuildCapabilities.metadata_modes.map((item) => item?.value).filter(Boolean) : []; const defaultMode = this.metadataBuildCapabilities.default_metadata_mode || 'calculate_fresh'; if ( metadataBuildModeSelect && ( !metadataBuildModeSelect.dataset.userEdited || (supportedModes.length > 0 && !supportedModes.includes(metadataBuildModeSelect.value)) ) ) { metadataBuildModeSelect.value = defaultMode; } updateMetadataBuildModeHint(); if (metadataBuildWorkersEl) metadataBuildWorkersEl.textContent = normalizeMetadataBuildWorkerCount().toLocaleString(); }; const loadMetadataBuildCapabilities = async () => { try { const data = await this.fetchJson( '/model_resolver/metadata-build/capabilities', { silent: true }, 'Load metadata build capabilities' ); renderMetadataBuildCapabilities(data); } catch (_error) { renderMetadataBuildCapabilities({ cpu_count: navigator.hardwareConcurrency || 1, default_worker_count: Math.min(navigator.hardwareConcurrency || 1, 4), min_worker_count: 1, max_worker_count: 64 }); } }; const updateMetadataBuildSummary = (data = {}) => { const source = data?.result && typeof data.result === 'object' ? data.result : data; const created = Number(source.created_metadata || 0); const updated = Number(source.updated_metadata || 0); const calculatedHashes = Number(source.calculated_hashes || 0); const errorCount = Number(source.error_count || 0); if (metadataBuildCpuEl && source.cpu_count) { metadataBuildCpuEl.textContent = Number(source.cpu_count || 0).toLocaleString(); } if (metadataBuildWorkersEl) { const activeWorkers = Number(source.active_worker_count ?? source.worker_count ?? normalizeMetadataBuildWorkerCount()); metadataBuildWorkersEl.textContent = Number.isFinite(activeWorkers) ? activeWorkers.toLocaleString() : '0'; } setSummaryValue(metadataBuildScannedEl, source.scanned_models || source.current || 0); setSummaryValue(metadataBuildCreatedEl, created, created > 0 ? 'is-valid' : ''); setSummaryValue(metadataBuildUpdatedEl, updated, updated > 0 ? 'is-valid' : ''); setSummaryValue(metadataBuildSkippedEl, source.skipped_complete || 0); setSummaryValue(metadataBuildHashesEl, calculatedHashes, calculatedHashes > 0 ? 'is-pending' : ''); setSummaryValue(metadataBuildErrorsEl, errorCount, errorCount > 0 ? 'is-invalid' : ''); }; const metadataBuildStageLabels = { queued: 'Queued', scanning: 'Scanning', header: 'Reading header', hashing: 'Calculating SHA256', writing: 'Writing metadata', model_done: 'Processed', done: 'Done', cancelled: 'Cancelled', error: 'Error' }; const getMetadataBuildWorkerPercent = (item = {}) => { const direct = Number(item.percent); if (Number.isFinite(direct)) { return Math.max(0, Math.min(100, direct)); } const bytesRead = Number(item.bytes_read || 0); const totalBytes = Number(item.total_bytes || 0); if (totalBytes > 0) { return Math.max(0, Math.min(100, (bytesRead / totalBytes) * 100)); } return 0; }; const renderMetadataBuildWorkers = (data = {}, activeModels = []) => { if (!metadataBuildWorkerListEl) return; const statusValue = String(data.status || data.stage || '').toLowerCase(); if (['done', 'cancelled', 'error'].includes(statusValue)) { metadataBuildWorkerListEl.innerHTML = ''; metadataBuildWorkerListEl.hidden = true; return; } const workerItems = activeModels.length ? activeModels : (data.current_model ? [{ filename: data.current_model, path: data.current_path || data.metadata_path || '', stage: data.stage || '', percent: data.percent || 0, bytes_read: data.bytes_read || 0, total_bytes: data.total_bytes || 0 }] : []); if (!workerItems.length) { metadataBuildWorkerListEl.innerHTML = ''; metadataBuildWorkerListEl.hidden = true; return; } metadataBuildWorkerListEl.hidden = false; metadataBuildWorkerListEl.innerHTML = workerItems.map((item, index) => { const workerPercent = getMetadataBuildWorkerPercent(item); const stageLabel = metadataBuildStageLabels[item.stage] || item.stage || 'Running'; const bytesRead = Number(item.bytes_read || 0); const totalBytes = Number(item.total_bytes || 0); const bytesLabel = totalBytes > 0 ? `${this.formatBytes(bytesRead)} / ${this.formatBytes(totalBytes)}` : ''; const filename = item.filename || 'Model'; const path = item.path || ''; return `
    Worker ${index + 1} ${Math.round(workerPercent)}%
    ${this.escapeHtml(filename)}
    ${this.escapeHtml(stageLabel)} ${bytesLabel ? `${this.escapeHtml(bytesLabel)}` : ''}
    ${path ? `${this.escapeHtml(path)}` : ''}
    `; }).join(''); }; const updateMetadataBuildProgressView = (data = {}) => { if (metadataBuildProgress) metadataBuildProgress.hidden = false; const stage = String(data.stage || data.status || '').trim(); const percent = Math.max(0, Math.min(100, Number(data.percent || 0))); if (metadataBuildStageEl) metadataBuildStageEl.textContent = metadataBuildStageLabels[stage] || stage || 'Running'; if (metadataBuildPercentEl) metadataBuildPercentEl.textContent = `${Math.round(percent)}%`; if (metadataBuildBarEl) metadataBuildBarEl.style.width = `${percent}%`; const current = Number(data.current || 0); const total = Number(data.total || data.total_models || 0); const terminalStatus = ['done', 'cancelled', 'error'].includes(String(data.status || stage || '').toLowerCase()); const activeModels = !terminalStatus && Array.isArray(data.active_models) ? data.active_models : []; const currentModel = data.current_model || data.filename || ''; const suffix = total > 0 ? ` (${current.toLocaleString()} / ${total.toLocaleString()})` : ''; if (metadataBuildCurrentModelEl) { if (activeModels.length) { metadataBuildCurrentModelEl.textContent = `${activeModels.length.toLocaleString()} active worker${activeModels.length === 1 ? '' : 's'}${suffix}`; } else { metadataBuildCurrentModelEl.textContent = currentModel ? `${currentModel}${suffix}` : (data.message || `Preparing${suffix}`); } } if (metadataBuildCurrentPathEl) { const pathText = activeModels.length ? '' : (data.current_path || data.metadata_path || ''); metadataBuildCurrentPathEl.textContent = pathText; metadataBuildCurrentPathEl.hidden = !pathText; } const bytesRead = Number(data.bytes_read || 0); const totalBytes = Number(data.total_bytes || 0); if (metadataBuildBytesEl) { metadataBuildBytesEl.textContent = activeModels.length ? '' : totalBytes > 0 ? `${this.formatBytes(bytesRead)} / ${this.formatBytes(totalBytes)}` : ''; } renderMetadataBuildWorkers(data, activeModels); }; const metadataBuildHistoryFilterLabels = { all: 'All', done: 'Done', created: 'Created', updated: 'Updated', skipped: 'Skipped', checked: 'Checked', error: 'Errors', cancelled: 'Cancelled' }; const metadataBuildDoneActions = new Set(['created', 'updated', 'skipped', 'checked']); const getMetadataBuildHistoryAction = (item = {}) => String(item.action || 'checked').toLowerCase(); const getMetadataBuildHistoryFilter = () => { const value = this.metadataBuildHistoryFilter || 'all'; return metadataBuildHistoryFilterValues.includes(value) ? value : 'all'; }; const metadataBuildHistoryMatchesFilter = (item, filter) => { const action = getMetadataBuildHistoryAction(item); return metadataBuildHistoryActionMatchesFilter(action, filter); }; const metadataBuildHistoryActionMatchesFilter = (action, filter) => { if (filter === 'all') return true; if (filter === 'done') return metadataBuildDoneActions.has(action); return action === filter; }; const getMetadataBuildHistoryFilterCounts = (items = []) => { const counts = Object.fromEntries(metadataBuildHistoryFilterValues.map(value => [value, 0])); items.forEach((item) => { const action = getMetadataBuildHistoryAction(item); counts.all += 1; if (metadataBuildDoneActions.has(action)) counts.done += 1; if (Object.prototype.hasOwnProperty.call(counts, action)) counts[action] += 1; }); return counts; }; const updateMetadataBuildHistoryFilterUi = (allItems = [], filteredItems = [], filter = 'all') => { const counts = getMetadataBuildHistoryFilterCounts(allItems); this.metadataBuildHistoryFilterCounts = counts; this.metadataBuildHistoryFilteredCount = filteredItems.length; this.metadataBuildHistoryTotalCount = allItems.length; this.metadataBuildHistoryFilter = filter; updateMetadataBuildActionFilterControl(filter); return counts; }; const renderMetadataBuildActionFilterHeader = (counts = {}, filter = 'all', _filteredCount = 0, _totalCount = 0) => { const activeLabel = metadataBuildHistoryFilterLabels[filter] || metadataBuildHistoryFilterLabels.all; const menuOpen = Boolean(this.metadataBuildHistoryFilterMenuOpen); const options = metadataBuildHistoryFilterValues.map((value) => { const label = metadataBuildHistoryFilterLabels[value] || value; const count = Number(counts[value] || 0); const isActive = value === filter; return ` `; }).join(''); return `
    `; }; const setMetadataBuildHistoryFilterMenuOpen = (open) => { this.metadataBuildHistoryFilterMenuOpen = Boolean(open); const menu = metadataBuildResults?.querySelector?.('.mr-options-history-filter-menu'); const toggle = metadataBuildResults?.querySelector?.('[data-metadata-history-filter-toggle]'); if (menu) menu.hidden = !this.metadataBuildHistoryFilterMenuOpen; if (toggle) toggle.setAttribute('aria-expanded', this.metadataBuildHistoryFilterMenuOpen ? 'true' : 'false'); }; const updateMetadataBuildActionFilterControl = (filter = 'all') => { if (!metadataBuildResults) return; const normalizedFilter = metadataBuildHistoryFilterValues.includes(filter) ? filter : 'all'; const counts = this.metadataBuildHistoryFilterCounts || {}; const toggle = metadataBuildResults.querySelector('[data-metadata-history-filter-toggle]'); const chip = metadataBuildResults.querySelector('[data-metadata-history-filter-chip]'); if (toggle) toggle.classList.toggle('is-filtered', normalizedFilter !== 'all'); if (chip) { chip.textContent = metadataBuildHistoryFilterLabels[normalizedFilter] || normalizedFilter; chip.hidden = normalizedFilter === 'all'; } metadataBuildResults.querySelectorAll('[data-metadata-history-filter-option]').forEach((option) => { const value = option.dataset.metadataHistoryFilterOption || 'all'; const isActive = value === normalizedFilter; option.classList.toggle('is-active', isActive); option.setAttribute('aria-checked', isActive ? 'true' : 'false'); const countEl = option.querySelector('strong'); if (countEl) countEl.textContent = Number(counts[value] || 0).toLocaleString(); }); }; const applyMetadataBuildHistoryFilter = (filter = 'all') => { if (!metadataBuildResults) return; const normalizedFilter = metadataBuildHistoryFilterValues.includes(filter) ? filter : 'all'; this.metadataBuildHistoryFilter = normalizedFilter; this.metadataBuildHistoryVisibleLimit = metadataBuildHistoryPageSize; renderMetadataBuildResults(undefined); }; const renderMetadataBuildResults = (data = undefined) => { if (!metadataBuildResults) return; if (data !== undefined) { this.metadataBuildLastResultsData = data; } const renderData = data === undefined ? this.metadataBuildLastResultsData : data; const source = renderData?.result && typeof renderData.result === 'object' ? renderData.result : renderData; const historyItems = Array.isArray(this.metadataBuildHistory) ? this.metadataBuildHistory : []; if (!source && !historyItems.length) { updateMetadataBuildHistoryFilterUi([], [], getMetadataBuildHistoryFilter()); metadataBuildResults.hidden = true; metadataBuildResults.innerHTML = ''; return; } if (this.metadataBuildActiveTab !== 'history') { this.metadataBuildHistoryRenderPending = true; return; } this.metadataBuildHistoryRenderPending = false; const fallbackItems = source && Array.isArray(source.history) ? source.history : (source && Array.isArray(source.updated) ? source.updated : []); const displayItems = historyItems.length ? historyItems : fallbackItems; const errors = Array.isArray(source?.errors) ? source.errors : []; const activeHistoryFilter = getMetadataBuildHistoryFilter(); this.metadataBuildHistoryFilter = activeHistoryFilter; const filteredItems = displayItems.filter(item => metadataBuildHistoryMatchesFilter(item, activeHistoryFilter)); const filterCounts = updateMetadataBuildHistoryFilterUi(displayItems, filteredItems, activeHistoryFilter); const visibleLimit = Math.max( metadataBuildHistoryPageSize, Number(this.metadataBuildHistoryVisibleLimit || metadataBuildHistoryPageSize) ); const visibleItems = filteredItems.slice(0, visibleLimit); const hiddenByLimit = Math.max(0, filteredItems.length - visibleItems.length); let html = ''; if (displayItems.length) { const rows = visibleItems.map((item) => { const modelLabel = item.relative_path || item.filename || item.model_path || 'Model'; const modelPath = item.model_path || ''; const metadataPath = item.metadata_path || ''; const shaSource = item.sha256_source || ''; const shaLabel = item.sha256 ? `${String(item.sha256).slice(0, 12)}...` : 'Pending'; const changedFieldList = Array.isArray(item.changed_fields) ? item.changed_fields.filter(Boolean) : []; const changedFields = changedFieldList.join(', '); const changedFieldsSummary = changedFieldList.length ? `${changedFieldList.length} field${changedFieldList.length === 1 ? '' : 's'} changed` : ''; const changedFieldsTitle = changedFields ? ` title="${this.escapeHtml(changedFields)}"` : ''; const action = item.action || 'checked'; const actionValue = String(action).toLowerCase(); const actionClass = actionValue.replace(/[^a-z0-9_-]+/g, '-'); const message = item.message || changedFieldsSummary; const rowMatchesFilter = metadataBuildHistoryActionMatchesFilter(actionValue, activeHistoryFilter); const buildContext = this.buildLocalModelContext(item, 'metadata_builder', { size: item.size || 0, file_size: item.size || 0, sha256: item.sha256 || '' }); const contextAttrs = buildContext ? this.getContextMenuAttrs(buildContext, 'Right-click for model options') : ''; return `
    ${this.escapeHtml(modelLabel)}
    ${this.escapeHtml(modelPath)}
    ${this.escapeHtml(action)} ${message ? `
    ${this.escapeHtml(message)}
    ` : ''} ${this.escapeHtml(item.category || '')}
    ${this.escapeHtml(shaLabel)}
    ${this.escapeHtml(shaSource)}
    ${this.escapeHtml(item.size_label || this.formatBytes(item.size || 0))}
    ${this.escapeHtml(metadataPath)}
    `; }).join(''); const moreRow = hiddenByLimit > 0 ? ` ` : ''; const emptyRow = `
    No history items match the ${this.escapeHtml(metadataBuildHistoryFilterLabels[activeHistoryFilter] || activeHistoryFilter)} filter.
    `; html += `
    ${renderMetadataBuildActionFilterHeader(filterCounts, activeHistoryFilter, filteredItems.length, displayItems.length)} ${rows}${moreRow}${emptyRow}
    ModelFolder SHA256 Size Metadata file
    `; } else if (source?.cancelled) { html += '
    Metadata build was cancelled before any history item was captured.
    '; } else { html += '
    No metadata history captured yet.
    '; } if (errors.length) { const errorRows = errors.slice(0, 10).map((item) => { const targetPath = item.metadata_path || item.model_path || ''; return `
  • ${this.escapeHtml(item.message || 'Error')}${this.escapeHtml(targetPath)}
  • `; }).join(''); html += `
    Write errors
    `; } metadataBuildResults.innerHTML = html; metadataBuildResults.hidden = false; }; const stopMetadataBuildHistoryRender = () => { if (this.metadataBuildHistoryRenderFrame) { window.cancelAnimationFrame(this.metadataBuildHistoryRenderFrame); this.metadataBuildHistoryRenderFrame = 0; } if (this.metadataBuildHistoryRenderTimer) { window.clearTimeout(this.metadataBuildHistoryRenderTimer); this.metadataBuildHistoryRenderTimer = 0; } }; scheduleMetadataBuildHistoryRender = () => { if (!metadataBuildResults || this.metadataBuildActiveTab !== 'history') return; if (!this.metadataBuildHistoryRenderPending && metadataBuildResults.innerHTML) return; stopMetadataBuildHistoryRender(); metadataBuildResults.hidden = false; if (!metadataBuildResults.innerHTML) { metadataBuildResults.innerHTML = '
    Preparing history...
    '; } this.metadataBuildHistoryRenderFrame = window.requestAnimationFrame(() => { this.metadataBuildHistoryRenderFrame = 0; this.metadataBuildHistoryRenderTimer = window.setTimeout(() => { this.metadataBuildHistoryRenderTimer = 0; renderMetadataBuildResults(undefined); }, 0); }); }; const stopMetadataBuildPolling = () => { if (this.metadataBuildPollTimer) { window.clearTimeout(this.metadataBuildPollTimer); this.metadataBuildPollTimer = null; } }; const isMetadataBuildTerminal = (data = {}) => { const statusValue = String(data.status || '').toLowerCase(); return ['done', 'error', 'cancelled'].includes(statusValue); }; const getMetadataBuildProgressKey = (data = {}) => ( String(data.progress_id || data?.result?.progress_id || this.metadataBuildProgressId || '').trim() ); const finishMetadataBuild = (data = {}) => { const progressKey = getMetadataBuildProgressKey(data); if (progressKey && this.metadataBuildFinishedProgressId === progressKey) { return; } if (progressKey) { this.metadataBuildFinishedProgressId = progressKey; } appendMetadataBuildHistory(data); const source = data?.result && typeof data.result === 'object' ? data.result : data; setMetadataBuildBusy(false); renderMetadataBuildResults(source); if (data.status === 'cancelled' || source.cancelled) { setMetadataBuildStatus(`Metadata build cancelled after ${Number(source.scanned_models || data.current || 0).toLocaleString()} model${Number(source.scanned_models || data.current || 0) === 1 ? '' : 's'}.`, 'is-pending'); this.showNotification('Metadata build cancelled', 'warning'); return; } if (data.status === 'error') { setMetadataBuildStatus(data.message || 'Metadata build failed.', 'is-invalid'); this.showNotification('Metadata build failed', 'error'); return; } const created = Number(source.created_metadata || 0); const updated = Number(source.updated_metadata || 0); const errorCount = Number(source.error_count || 0); const calculatedHashes = Number(source.calculated_hashes || 0); const headerHashes = Number(source.header_hashes || 0); const changed = created + updated; const hashText = ` ${calculatedHashes.toLocaleString()} full SHA256 hash${calculatedHashes === 1 ? '' : 'es'} calculated, ${headerHashes.toLocaleString()} read from headers.`; if (errorCount > 0) { setMetadataBuildStatus(`Metadata build finished with ${errorCount.toLocaleString()} error${errorCount === 1 ? '' : 's'}.${hashText}`, 'is-invalid'); this.showNotification('Metadata build finished with errors', 'warning'); } else if (changed > 0) { setMetadataBuildStatus(`Created ${created.toLocaleString()} and updated ${updated.toLocaleString()} metadata file${changed === 1 ? '' : 's'}.${hashText}`, 'is-valid'); this.showNotification('Local metadata updated', 'success'); } else { setMetadataBuildStatus(`Checked ${Number(source.scanned_models || 0).toLocaleString()} model${Number(source.scanned_models || 0) === 1 ? '' : 's'}. All existing metadata already had SHA256.`, 'is-valid'); this.showNotification('Local metadata already complete', 'success'); } }; const pollMetadataBuildProgress = async () => { const progressId = this.metadataBuildProgressId; if (!progressId) return; stopMetadataBuildPolling(); await pollBackgroundTask({ endpoint: `/model_resolver/metadata-build/progress/${encodeURIComponent(progressId)}`, tokenCheck: () => this.metadataBuildProgressId === progressId, onProgress: (data) => { appendMetadataBuildHistory(data); updateMetadataBuildSummary(data); updateMetadataBuildProgressView(data); renderMetadataBuildResults(data); }, isTerminal: (data) => isMetadataBuildTerminal(data), onTerminal: (data) => { finishMetadataBuild(data); if (this.metadataBuildProgressId === progressId) { this.metadataBuildProgressId = ''; } }, onError: (error) => { this.metadataBuildProgressId = ''; setMetadataBuildBusy(false); setMetadataBuildStatus(error.message || 'Metadata build progress failed.', 'is-invalid'); }, intervalMs: 700, fetchJson: this.fetchJson.bind(this) }); }; const runMetadataBuild = async () => { stopMetadataBuildPolling(); stopMetadataBuildHistoryRender(); this.metadataBuildProgressId = ''; this.metadataBuildFinishedProgressId = ''; this.metadataBuildHistory = []; this.metadataBuildHistoryKeys = new Set(); this.metadataBuildHistoryVisibleLimit = metadataBuildHistoryPageSize; if (metadataBuildHistoryCountEl) metadataBuildHistoryCountEl.textContent = '0'; setMetadataBuildBusy(true); updateMetadataBuildSummary({}); renderMetadataBuildResults(null); setMetadataBuildTab('progress'); updateMetadataBuildProgressView({ stage: 'queued', message: 'Preparing local metadata build...', percent: 0, current: 0, total: 0 }); setMetadataBuildStatus('Preparing local metadata build...', 'is-pending'); try { const workerCount = normalizeMetadataBuildWorkerCount(); const metadataMode = metadataBuildModeSelect?.value || 'calculate_fresh'; const modeLabel = metadataMode === 'import_existing' ? 'using existing plugin metadata' : 'calculating SHA-256 from scratch'; const start = await this.fetchJson('/model_resolver/metadata-build/start', { method: 'POST', body: JSON.stringify({ force_rescan: true, worker_count: workerCount, metadata_mode: metadataMode }) }, 'Start metadata build'); if (!start?.progress_id) { throw new Error('Metadata build did not return a progress id.'); } this.metadataBuildProgressId = start.progress_id; setMetadataBuildStatus(`Building local metadata, ${modeLabel}...`, 'is-pending'); pollMetadataBuildProgress(); } catch (error) { this.metadataBuildProgressId = ''; setMetadataBuildBusy(false); setMetadataBuildStatus(error.message || 'Metadata build failed to start.', 'is-invalid'); } }; const cancelMetadataBuild = async () => { const progressId = this.metadataBuildProgressId; if (!progressId) return; if (metadataBuildCancelBtn) metadataBuildCancelBtn.disabled = true; setMetadataBuildStatus('Stopping metadata build...', 'is-pending'); try { await this.fetchJson( `/model_resolver/metadata-build/cancel/${encodeURIComponent(progressId)}`, { method: 'POST', silent: true }, 'Cancel metadata build' ); stopMetadataBuildPolling(); pollMetadataBuildProgress(); } catch (error) { setMetadataBuildStatus(error.message || 'Could not cancel metadata build.', 'is-invalid'); if (metadataBuildCancelBtn) metadataBuildCancelBtn.disabled = false; } }; const setAria2Status = (text, mode = '') => { if (!aria2StatusEl) return; aria2StatusEl.hidden = !text; setElementStatusText(aria2StatusEl, text, mode); }; const syncAria2DaemonButton = (data = {}) => { if (!aria2StopBtn) return; const available = data.available === true; const running = Boolean(data.running); aria2StopBtn.hidden = !available; aria2StopBtn.dataset.action = running ? 'stop' : 'start'; aria2StopBtn.textContent = running ? 'Stop' : 'Start'; aria2StopBtn.disabled = running && data.can_stop === false; aria2StopBtn.title = aria2StopBtn.disabled ? 'Cannot stop while aria2 has active downloads' : (running ? 'Stop aria2 daemon started by Model Resolver' : 'Start aria2 daemon now'); }; const renderAria2Summary = (data = {}, state = 'idle') => { const backend = this.normalizeDownloadBackend(data.backend || downloadBackendInput?.value || tokens.download_backend); const version = String(data.version || '').trim(); const backendLabel = backend === 'aria2' ? `aria2${version ? ` ${version}` : ''}` : 'Python'; setElementStatusText(aria2BackendStateEl, backendLabel); if (state === 'checking') { syncAria2DaemonButton({ available: false, running: false }); setElementStatusText(aria2AvailabilityEl, 'Checking...', 'is-pending'); setElementStatusText(aria2DaemonEl, 'Checking...', 'is-pending'); return; } if (state === 'installing') { syncAria2DaemonButton({ available: false, running: false }); setElementStatusText(aria2AvailabilityEl, 'Installing...', 'is-pending'); setElementStatusText(aria2DaemonEl, 'Not started'); return; } if (data && typeof data.available === 'boolean') { syncAria2DaemonButton(data); setElementStatusText( aria2AvailabilityEl, data.available ? 'Available' : 'Not found', data.available ? 'is-valid' : 'is-invalid' ); setElementStatusText( aria2DaemonEl, data.running ? 'Running' : 'Starts on first download', data.running ? 'is-valid' : '' ); return; } syncAria2DaemonButton({ available: false, running: false }); setElementStatusText(aria2AvailabilityEl, 'Not checked'); setElementStatusText(aria2DaemonEl, 'Not checked'); }; const syncAria2OptionsVisibility = () => { const backend = this.normalizeDownloadBackend(downloadBackendInput?.value || tokens.download_backend); const showAria2 = backend === 'aria2'; aria2SettingEls.forEach((element) => { element.hidden = !showAria2; }); renderAria2Summary({ backend }); }; const checkAria2Status = async ({ useUnsavedPath = true } = {}) => { setControlsBusyState(aria2CheckBtn, true); setAria2Status(''); renderAria2Summary({ backend: 'aria2' }, 'checking'); try { const data = await this.fetchJson('/model_resolver/aria2/status', { method: 'POST', body: JSON.stringify({ aria2c_path: useUnsavedPath ? (aria2cPathInput?.value || '') : (tokens.aria2c_path || '') }), silent: true }, 'Check aria2'); renderAria2Summary({ ...data, backend: downloadBackendInput?.value || data.backend }); if (data?.available) { setAria2Status(''); } else { setAria2Status(data?.error || 'aria2c was not found. Use the built-in installer or add aria2c to PATH.', 'is-invalid'); } } catch (error) { setAria2Status(error.message || 'aria2 check failed.', 'is-invalid'); renderAria2Summary({ backend: 'aria2', configured_path: aria2cPathInput?.value || '', resolved_path: '', available: false, running: false }); } finally { setControlsBusyState(aria2CheckBtn, false); } }; const installAria2 = async () => { if (aria2InstallBtn) aria2InstallBtn.disabled = true; if (aria2CheckBtn) aria2CheckBtn.disabled = true; setAria2Status(''); renderAria2Summary({ backend: 'aria2' }, 'installing'); try { const data = await this.fetchJson('/model_resolver/aria2/install', { method: 'POST', body: JSON.stringify({}), silent: true }, 'Install aria2'); if (!data?.success || !data?.aria2c_path) { throw new Error(data?.error || 'aria2 install did not return an executable path.'); } if (aria2cPathInput) { aria2cPathInput.value = data.aria2c_path; aria2cPathInput.dispatchEvent(new Event('input', { bubbles: true })); } if (downloadBackendInput) { downloadBackendInput.value = 'aria2'; downloadBackendInput.dispatchEvent(new Event('change', { bubbles: true })); } tokens.aria2c_path = data.aria2c_path; tokens.download_backend = 'aria2'; safeStorage.setItem('ModelResolver.aria2cPath', data.aria2c_path); safeStorage.setItem('ModelResolver.downloadBackend', 'aria2'); setAria2Status(''); renderAria2Summary({ backend: 'aria2', configured_path: data.aria2c_path, resolved_path: data.aria2c_path, available: true, version: data.version, running: false }); await checkAria2Status({ useUnsavedPath: true }); } catch (error) { setAria2Status(error.message || 'aria2 install failed.', 'is-invalid'); renderAria2Summary({ backend: 'aria2', configured_path: aria2cPathInput?.value || '', resolved_path: '', available: false, running: false }); } finally { if (aria2InstallBtn) aria2InstallBtn.disabled = false; if (aria2CheckBtn) aria2CheckBtn.disabled = false; } }; const requestAria2Control = async (endpoint, { method = 'POST', body = {} } = {}) => { return this.fetchJson(endpoint, { method, ...(method === 'POST' ? { body: JSON.stringify(body) } : {}), silent: true }, 'Aria2 Control'); }; const startAria2 = async () => { if (aria2StopBtn) aria2StopBtn.disabled = true; setAria2Status(''); setElementStatusText(aria2DaemonEl, 'Starting...', 'is-pending'); let refreshed = false; try { const result = await requestAria2Control('/model_resolver/aria2/start', { body: { aria2c_path: aria2cPathInput?.value || '' } }); if (!result?.success) { throw new Error(result?.error || 'Could not start aria2 daemon.'); } await checkAria2Status({ useUnsavedPath: true }); refreshed = true; } catch (error) { const message = error.message || 'aria2 start failed.'; await checkAria2Status({ useUnsavedPath: true }); refreshed = true; setAria2Status(message, 'is-invalid'); } finally { if (!refreshed && aria2StopBtn) aria2StopBtn.disabled = false; } }; const stopAria2 = async () => { if (aria2StopBtn) aria2StopBtn.disabled = true; setAria2Status(''); setElementStatusText(aria2DaemonEl, 'Stopping...', 'is-pending'); let refreshed = false; try { let result; try { result = await requestAria2Control('/model_resolver/aria2/stop'); } catch (error) { if (error?.status !== 405) throw error; result = await requestAria2Control('/model_resolver/aria2/stop', { method: 'GET' }); } if (!result?.success) { throw new Error(result?.error || 'Could not stop aria2 daemon.'); } await checkAria2Status({ useUnsavedPath: true }); refreshed = true; } catch (error) { const message = error.message || 'aria2 stop failed.'; await checkAria2Status({ useUnsavedPath: true }); refreshed = true; setAria2Status(message, 'is-invalid'); } finally { if (!refreshed && aria2StopBtn) aria2StopBtn.disabled = false; } }; const checkCredential = async ({ input, button, statusEl, endpoint, payloadKey, missingText }) => { const value = (input?.value || '').trim(); if (!value) { setElementStatusText(statusEl, missingText, 'is-invalid'); return; } try { setControlsBusyState(button, true); setElementStatusText(statusEl, 'Checking...', 'is-pending'); const data = await this.fetchJson(endpoint, { method: 'POST', body: JSON.stringify({ [payloadKey]: value }) }, 'Check credential'); setElementStatusText( statusEl, data.message || (data.valid ? 'Valid' : 'Invalid'), data.valid ? 'is-valid' : 'is-invalid' ); } catch (error) { console.error('Model Resolver: Credential check error:', error); setElementStatusText(statusEl, error.message || 'Check failed', 'is-invalid'); } finally { setControlsBusyState(button, false); } }; const renderModelListStatus = (data = {}, checkedRemote = false) => { const statusMode = checkedRemote && data.can_compare && !data.update_available ? 'is-valid' : ''; setStatusMode(modelListStateEl, statusMode); setStatusMode(modelListMessageEl, statusMode); if (modelListCountEl) { modelListCountEl.textContent = Number.isFinite(Number(data.local_count)) ? Number(data.local_count).toLocaleString() : '-'; } if (modelListUpdatedEl) { modelListUpdatedEl.textContent = formatOptionDate(data.local_updated_at); } if (modelListStateEl) { if (checkedRemote && data.can_compare && data.update_available) { modelListStateEl.textContent = 'Update available'; } else if (checkedRemote && data.can_compare) { modelListStateEl.textContent = 'Up to date'; } else if (checkedRemote && !data.can_compare) { modelListStateEl.textContent = 'Remote checked'; } else { modelListStateEl.textContent = data.local_sha ? 'Tracked' : 'Bundled'; } } if (modelListMessageEl) { const localSha = shortSha(data.local_sha); const remoteSha = shortSha(data.remote_sha); if (checkedRemote && data.update_available) { modelListMessageEl.textContent = `A newer ComfyUI-Manager model-list.json is available (${localSha || 'local'} -> ${remoteSha}).`; } else if (checkedRemote && data.can_compare) { modelListMessageEl.textContent = `Local Database is current (${localSha}).`; } else if (checkedRemote) { modelListMessageEl.textContent = `Remote file was checked. Local SHA is not recorded yet; run update once to track future changes.`; } else { modelListMessageEl.textContent = `Local Database uses ComfyUI-Manager model-list.json.`; } } }; const executeOptionsTask = async ({ setBusy, stateEl, messageEl, loadingText, startingMessageText, endpoint, fetchOptions = {}, errorContext, successCallback, successAction, errorFallbackText, notificationErrorText }) => { try { setBusy(true); setElementStatusText(stateEl, loadingText, 'is-pending'); if (messageEl) setElementStatusText(messageEl, startingMessageText || '', 'is-pending'); const data = await this.fetchJson(endpoint, fetchOptions, errorContext); if (successAction) { await successAction(data); } successCallback(data); return data; } catch (error) { console.error(`Model Resolver: ${errorContext} error:`, error); setElementStatusText(stateEl, 'Error', 'is-invalid'); setElementStatusText(messageEl, error.message || errorFallbackText, 'is-invalid'); if (notificationErrorText) { this.showNotification(notificationErrorText, 'error'); } return null; } finally { setBusy(false); } }; const loadModelListStatus = (checkRemote = false) => executeOptionsTask({ setBusy: setModelListBusy, stateEl: modelListStateEl, loadingText: checkRemote ? 'Checking GitHub...' : 'Loading...', endpoint: `/model_resolver/model-list/status${checkRemote ? '?check_remote=1' : ''}`, errorContext: 'Model-list status', successCallback: (data) => renderModelListStatus(data, checkRemote), errorFallbackText: 'Failed to read Local Database status.' }); const updateModelList = () => executeOptionsTask({ setBusy: setModelListBusy, stateEl: modelListStateEl, messageEl: modelListMessageEl, loadingText: 'Updating...', startingMessageText: 'Downloading latest ComfyUI-Manager model-list.json...', endpoint: '/model_resolver/model-list/update', fetchOptions: { method: 'POST' }, errorContext: 'Update model list', successAction: async () => { await this.clearSearchCaches(); }, successCallback: (data) => { renderModelListStatus(data, false); setElementStatusText(modelListStateEl, 'Updated', 'is-valid'); if (modelListMessageEl) modelListMessageEl.textContent = `Local Database updated. ${Number(data.local_count || 0).toLocaleString()} models loaded.`; setStatusMode(modelListMessageEl, 'is-valid'); this.showNotification('Local Database updated', 'success'); }, errorFallbackText: 'Failed to update Local Database.', notificationErrorText: 'Local Database update failed' }); const renderBaseModelsStatus = (data = {}, checkedRemote = false) => { const statusMode = checkedRemote && !data.update_available ? 'is-valid' : ''; setStatusMode(baseModelsStateEl, statusMode); setStatusMode(baseModelsMessageEl, statusMode); if (baseModelsCountEl) { baseModelsCountEl.textContent = Number.isFinite(Number(data.local_count)) ? Number(data.local_count).toLocaleString() : '-'; } if (baseModelsUpdatedEl) { baseModelsUpdatedEl.textContent = formatOptionDate(data.local_updated_at); } if (baseModelsStateEl) { if (checkedRemote && data.update_available) { baseModelsStateEl.textContent = 'Update available'; } else if (checkedRemote) { baseModelsStateEl.textContent = 'Up to date'; } else { baseModelsStateEl.textContent = data.local_updated_at ? 'Tracked' : 'Bundled'; } } if (baseModelsMessageEl) { if (checkedRemote && data.update_available) { baseModelsMessageEl.textContent = `New base models are available from CivitAI.`; } else if (checkedRemote) { baseModelsMessageEl.textContent = `Base Models list is current.`; } else { baseModelsMessageEl.textContent = `Base Models list maps new CivitAI models to local categories.`; } } }; const loadBaseModelsStatus = (checkRemote = false) => executeOptionsTask({ setBusy: setBaseModelsBusy, stateEl: baseModelsStateEl, loadingText: checkRemote ? 'Checking CivitAI...' : 'Loading...', endpoint: `/model_resolver/base-models/status${checkRemote ? '?check_remote=1' : ''}`, errorContext: 'Load base models status', successCallback: (data) => renderBaseModelsStatus(data, checkRemote), errorFallbackText: 'Failed to read Base Models status.' }); const formatNewBaseModelsSummary = (models = [], maxVisible = 6) => { const names = Array.isArray(models) ? models.map(name => String(name || '').trim()).filter(Boolean) : []; if (!names.length) return ''; const visible = names.slice(0, maxVisible); const suffix = names.length > visible.length ? ` and ${names.length - visible.length} more` : ''; return `${visible.join(', ')}${suffix}`; }; const updateBaseModels = () => executeOptionsTask({ setBusy: setBaseModelsBusy, stateEl: baseModelsStateEl, messageEl: baseModelsMessageEl, loadingText: 'Updating...', startingMessageText: 'Downloading latest base models from CivitAI...', endpoint: '/model_resolver/base-models/update', fetchOptions: { method: 'POST' }, errorContext: 'Update base models', successAction: async () => { await this.clearSearchCaches(); this.baseModels = null; await this.ensureBaseModelsLoaded(); }, successCallback: (data) => { renderBaseModelsStatus(data, false); setElementStatusText(baseModelsStateEl, 'Updated', 'is-valid'); const newModelsSummary = formatNewBaseModelsSummary(data.new_models_added_list); const addedCount = Number(data.new_models_added || 0); const addedText = addedCount > 0 ? ` Added ${addedCount.toLocaleString()} new model${addedCount === 1 ? '' : 's'}${newModelsSummary ? `: ${newModelsSummary}` : ''}.` : ' No new base models were added.'; if (baseModelsMessageEl) baseModelsMessageEl.textContent = `Base Models updated. ${Number(data.local_count || 0).toLocaleString()} base models loaded.${addedText}`; setStatusMode(baseModelsMessageEl, 'is-valid'); this.showNotification( addedCount > 0 ? `Base Models updated${newModelsSummary ? `: ${newModelsSummary}` : ''}` : 'Base Models updated. No new models added.', 'success' ); }, errorFallbackText: 'Failed to update Base Models.', notificationErrorText: 'Base Models update failed' }); const renderHfIndexStatus = (data = {}) => { const authorStatuses = Array.isArray(data.authors) && data.authors.length ? data.authors : [data]; if (hfIndexAuthorsEl) { hfIndexAuthorsEl.innerHTML = authorStatuses.map((status) => { const author = escapeHtml(String(status.author || 'Unknown')); const fileCount = Number.isFinite(Number(status.file_count)) ? Number(status.file_count).toLocaleString() : '-'; return `
    ${author} files ${fileCount}
    `; }).join(''); } if (hfIndexUpdatedEl) { hfIndexUpdatedEl.textContent = data.updated_at ? formatOptionDate(Number(data.updated_at) * 1000) : 'Never'; } if (hfIndexStateEl) { if (!data.exists) { hfIndexStateEl.textContent = 'Not cached'; } else if (data.fully_cached === false) { hfIndexStateEl.textContent = 'Partially cached'; } else if (data.stale) { hfIndexStateEl.textContent = 'Refresh due'; } else { hfIndexStateEl.textContent = 'Cached'; } } if (hfIndexMessageEl) { if (!data.exists) { hfIndexMessageEl.textContent = 'The first known-source fallback search will build the local HuggingFace file indexes.'; } else if (data.fully_cached === false) { hfIndexMessageEl.textContent = `${Number(data.cached_author_count || 0).toLocaleString()} of ${Number(data.author_count || authorStatuses.length).toLocaleString()} known-source indexes are cached.`; } else if (data.stale) { hfIndexMessageEl.textContent = `Indexes have ${Number(data.repo_count || 0).toLocaleString()} repos and should be refreshed.`; } else { hfIndexMessageEl.textContent = `Indexes have ${Number(data.repo_count || 0).toLocaleString()} repos and are used before per-repo fallback checks.`; } } }; const loadHfIndexStatus = () => executeOptionsTask({ setBusy: setHfIndexBusy, stateEl: hfIndexStateEl, loadingText: 'Loading...', endpoint: '/model_resolver/huggingface/author-index/status', errorContext: 'Load HuggingFace index status', successCallback: renderHfIndexStatus, errorFallbackText: 'Failed to read HuggingFace index status.' }); const refreshHfIndex = () => executeOptionsTask({ setBusy: setHfIndexBusy, stateEl: hfIndexStateEl, messageEl: hfIndexMessageEl, loadingText: 'Refreshing...', startingMessageText: 'Downloading known-source file indexes from HuggingFace...', endpoint: '/model_resolver/huggingface/author-index/refresh', fetchOptions: { method: 'POST', body: JSON.stringify({ hf_token: hfInput?.value || '' }) }, errorContext: 'Refresh HuggingFace index', successAction: async (data) => { if (data?.success === false) { throw new Error(data.error || 'Refresh failed'); } await this.clearSearchCaches(); }, successCallback: (data) => { renderHfIndexStatus(data); setElementStatusText(hfIndexStateEl, 'Refreshed', 'is-valid'); setStatusMode(hfIndexMessageEl, 'is-valid'); this.showNotification('Known HuggingFace indexes refreshed', 'success'); }, errorFallbackText: 'Failed to refresh HuggingFace index.', notificationErrorText: 'HuggingFace index refresh failed' }); const clearAllCachesFromOptions = async () => { try { setControlsBusyState(clearAllCacheBtn, true); setElementStatusText(clearAllCacheStatus, 'Clearing frontend and backend cache...', 'is-pending'); await this.clearAllResolverCaches(); setElementStatusText(clearAllCacheStatus, 'Frontend and backend cache cleared.', 'is-valid'); this.showNotification('Resolver cache cleared', 'success'); if (this.activeTab === 'options') { this.displayOptions(); } } catch (error) { console.error('Model Resolver: clear all cache error:', error); setElementStatusText(clearAllCacheStatus, error.message || 'Failed to clear cache.', 'is-invalid'); this.showNotification('Cache clear failed', 'error'); } finally { setControlsBusyState(clearAllCacheBtn, false); } }; const setActiveNav = (targetId) => { navButtons.forEach((btn) => { btn.classList.toggle('is-active', btn.dataset.target === targetId); }); }; const setVisibleSection = (targetId) => { optionSections.forEach((section) => { section.classList.toggle('is-hidden', section.id !== targetId); }); optionsMain?.classList.toggle( 'mr-options-main-fill-section', targetId === 'mr-options-section-metadata-audit' || targetId === 'mr-options-section-metadata-build' ); setActiveNav(targetId); }; const getVisibilityIcon = (visible) => visible ? getSvgIcon('eye') : getSvgIcon('eyeOff'); const syncVisibilityToggle = (input, button) => { if (!input || !button) return; const visible = input.type === 'text'; button.innerHTML = getVisibilityIcon(visible); button.classList.toggle('mr-is-active-text', visible); button.classList.toggle('mr-is-muted', !visible); button.setAttribute('aria-pressed', visible ? 'true' : 'false'); this.setTooltip(button, visible ? 'Hide saved value' : 'Show saved value'); }; const bindVisibilityToggle = (input, button) => { if (!input || !button) return; syncVisibilityToggle(input, button); button.addEventListener('click', () => { input.type = input.type === 'password' ? 'text' : 'password'; syncVisibilityToggle(input, button); }); }; bindVisibilityToggle(civitaiInput, civitaiToggle); bindVisibilityToggle(civitaiSessionInput, civitaiSessionToggle); bindVisibilityToggle(hfInput, hfToggle); bindVisibilityToggle(braveInput, braveToggle); const pathPreviewMetadata = { base_model: 'Flux.1 D', tags: ['style', 'character'], author: 'example_author', model_name: 'Example Model', version_name: 'v1' }; const getTemplateInputValue = (categoryKey) => { const select = templatePresetInputs.find(input => input.dataset.templateCategory === categoryKey); const custom = templateCustomInputs.find(input => input.dataset.templateCategory === categoryKey); if (!select) return ''; return select.value === 'custom' ? (custom?.value || '') : select.value; }; const getBaseModelMappingChoices = () => { const names = []; const seen = new Set(); const addName = (value) => { const name = String(value || '').trim(); if (!name || seen.has(name)) return; seen.add(name); names.push(name); }; const baseModelsList = this.baseModels?.base_models; if (Array.isArray(baseModelsList)) { baseModelsList.forEach(model => addName(model?.name)); } Object.keys(tokens.base_model_path_mappings || {}).forEach(addName); return names.sort((a, b) => a.localeCompare(b)); }; const getBaseModelMappingRows = () => ( baseModelMappingsContainer ? Array.from(baseModelMappingsContainer.querySelectorAll('.mr-options-mapping-row')) : [] ); const collectBaseModelPathMappings = () => { if (!baseModelMappingsContainer) { return { ...(tokens.base_model_path_mappings || {}) }; } return getBaseModelMappingRows().reduce((acc, row) => { const baseModel = row.querySelector('.mr-options-mapping-base')?.value?.trim() || ''; const pathValue = row.querySelector('.mr-options-mapping-path')?.value?.trim() || ''; if (baseModel && pathValue) { acc[baseModel] = pathValue; } return acc; }, {}); }; const getAvailableBaseModelMappingChoices = (currentValue = '', currentRow = null) => { const usedValues = new Set( getBaseModelMappingRows() .filter(row => row !== currentRow) .map(row => row.querySelector('.mr-options-mapping-base')?.value?.trim() || '') .filter(Boolean) ); const choices = getBaseModelMappingChoices(); return choices.filter(name => !usedValues.has(name) || name === currentValue); }; const refreshBaseModelMappingOptions = () => { getBaseModelMappingRows().forEach((row) => { row.refreshMappingChoices?.(); }); }; const addBaseModelMappingRow = (baseModel = '', pathValue = '', options = {}) => { const shouldRefresh = options.refresh !== false; if (!baseModelMappingsContainer) return; const row = document.createElement('div'); row.className = 'mr-options-mapping-row'; row.innerHTML = `
    `; const baseSelect = row.querySelector('.mr-options-mapping-base'); const baseList = row.querySelector('.mr-download-target-list'); const pathInput = row.querySelector('.mr-options-mapping-path'); const removeBtn = row.querySelector('.mr-options-mapping-remove'); const markChanged = () => { setStatus('You have unsaved changes.', 'is-dirty'); refreshBaseModelMappingOptions(); syncAllTemplateControls(); }; let filterText = ''; const renderChoices = (filter = '', show = true) => { filterText = filter; const search = filter.trim().toLowerCase(); const choices = getAvailableBaseModelMappingChoices(baseSelect.value.trim(), row) .filter(name => name.toLowerCase().includes(search)); baseList.innerHTML = choices.map(name => ( `
    ${this.escapeHtml(name)}
    ` )).join(''); baseList.querySelectorAll('.mr-download-target-option').forEach(option => { option.addEventListener('mousedown', event => { event.preventDefault(); baseSelect.value = decodeURIComponent(option.dataset.value); this.hideDropdownList(baseList); markChanged(); }); }); if (show && choices.length) this.showDropdownList(baseList, baseSelect); else if (!choices.length) this.hideDropdownList(baseList); }; row.refreshMappingChoices = () => { if (baseList.style.display !== 'none') renderChoices(filterText, false); }; this.enableWheelScrollChaining(baseList); this.bindDropdownOutsideDismiss(baseList, [baseSelect], () => this.hideDropdownList(baseList)); baseSelect.addEventListener('focus', () => renderChoices()); baseSelect.addEventListener('click', () => renderChoices()); baseSelect.addEventListener('input', () => { markChanged(); renderChoices(baseSelect.value); }); baseSelect.addEventListener('keydown', event => { if (event.key === 'Escape') this.hideDropdownList(baseList); if (event.key === 'ArrowDown') { event.preventDefault(); renderChoices(baseSelect.value); } if (event.key === 'Enter') { event.preventDefault(); const option = baseList.querySelector('.mr-download-target-option'); if (option && baseList.style.display !== 'none') { baseSelect.value = decodeURIComponent(option.dataset.value); markChanged(); } this.hideDropdownList(baseList); } }); baseSelect?.addEventListener('change', markChanged); pathInput?.addEventListener('input', markChanged); pathInput?.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); pathInput.blur(); } }); removeBtn?.addEventListener('click', () => { baseList._mlTreePicker?.cleanup(); row.remove(); if (!getBaseModelMappingRows().length) { addBaseModelMappingRow('', ''); } markChanged(); }); baseModelMappingsContainer.appendChild(row); if (shouldRefresh) { refreshBaseModelMappingOptions(); } this.bindTooltips(row); }; const renderBaseModelMappingRows = (mappings = {}) => { if (!baseModelMappingsContainer) return; getBaseModelMappingRows().forEach(row => row.querySelector('.mr-download-target-list')?._mlTreePicker?.cleanup()); baseModelMappingsContainer.innerHTML = ''; const entries = Object.entries(mappings || {}); if (!entries.length) { addBaseModelMappingRow('', ''); return; } entries.forEach(([baseModel, pathValue]) => { addBaseModelMappingRow(baseModel, pathValue, { refresh: false }); }); refreshBaseModelMappingOptions(); }; const renderTemplatePreview = (template) => { const mappings = collectBaseModelPathMappings(); const mappedBase = this.resolveBaseModelPathMapping( pathPreviewMetadata.base_model, mappings ); const replacements = { '{base_model}': this.sanitizeDownloadPathValue(mappedBase, 'Unknown Base Model'), '{author}': this.sanitizeDownloadPathSegment(pathPreviewMetadata.author, 'Anonymous'), '{first_tag}': this.sanitizeDownloadPathSegment(this.getPriorityDownloadTag(pathPreviewMetadata.tags), 'no tags'), '{model_name}': this.sanitizeDownloadPathSegment(pathPreviewMetadata.model_name, 'Model'), '{version_name}': this.sanitizeDownloadPathSegment(pathPreviewMetadata.version_name, '') }; return this.formatDownloadPathTemplate(template, replacements) || '(flat folder)'; }; const syncTemplateControl = (categoryKey) => { const select = templatePresetInputs.find(input => input.dataset.templateCategory === categoryKey); const custom = templateCustomInputs.find(input => input.dataset.templateCategory === categoryKey); const preview = this.contentElement.querySelector(`[data-template-preview="${categoryKey}"]`); const useCustom = select?.value === 'custom'; if (custom) { custom.classList.toggle('is-hidden', !useCustom); } if (preview) { preview.innerHTML = `Preview: ${this.escapeHtml(renderTemplatePreview(getTemplateInputValue(categoryKey)))}`; } }; const syncAllTemplateControls = () => { pathTemplateCategories.forEach(category => syncTemplateControl(category.key)); }; const getDefaultRootContextPath = (select) => { if (!select) return ''; const category = this.normalizeDownloadCategory(select.dataset.rootCategory || ''); const selectedValue = String(select.value || '').trim(); if (selectedValue) return selectedValue; const directDownloadRoot = this.downloadDirectories?.[category] || ''; if (directDownloadRoot) return directDownloadRoot; const rootOptions = this.downloadRootDirectories?.[category]; return Array.isArray(rootOptions) && rootOptions.length ? rootOptions[0] : ''; }; const getDefaultRootContextModel = (select) => { const path = getDefaultRootContextPath(select); if (!path) return null; const category = this.normalizeDownloadCategory(select.dataset.rootCategory || ''); const label = select.closest('.mr-options-template-row')?.querySelector('.mr-options-label')?.textContent?.trim() || this.getCategoryDisplayName(category); return { context_scope: 'download_root', name: label, path, resolved_path: path, folder_path: path, download_directory: path, category, open_folder_label: 'Open Root Folder' }; }; const syncDefaultRootContextTarget = (select) => { if (!select) return; const contextModel = getDefaultRootContextModel(select); const tooltip = contextModel ? `Right-click to open ${contextModel.name}` : 'No root folder available'; this.setDownloadFolderContextTarget(select, contextModel, tooltip); this.setDownloadFolderContextTarget(select.closest('.mr-options-template-row'), contextModel, tooltip); }; const setTemplateControlValue = (categoryKey, template) => { const select = templatePresetInputs.find(input => input.dataset.templateCategory === categoryKey); const custom = templateCustomInputs.find(input => input.dataset.templateCategory === categoryKey); if (!select) return false; const normalizedTemplate = this.normalizeDownloadPathValue(template); select.value = pathTemplatePresetValues.has(normalizedTemplate) ? normalizedTemplate : 'custom'; if (custom) { custom.value = normalizedTemplate; } syncTemplateControl(categoryKey); return true; }; const setTemplateDetectStatus = (text, mode = '') => { if (!templateDetectStatus) return; templateDetectStatus.textContent = text; templateDetectStatus.classList.remove('is-valid', 'is-invalid', 'is-pending'); if (mode) templateDetectStatus.classList.add(mode); }; const applyDetectedPathTemplates = async () => { if (!detectTemplatesBtn) return; detectTemplatesBtn.disabled = true; setTemplateDetectStatus('Scanning existing model folders...', 'is-pending'); try { const data = await this.fetchJson('/model_resolver/path-template-suggestions?force=1', {}, 'Detect path templates'); const categories = data?.categories && typeof data.categories === 'object' ? data.categories : {}; let appliedCount = 0; let inspectedCount = 0; pathTemplateCategories.forEach((category) => { const suggestion = categories[category.key]; if (!suggestion) return; if (Number(suggestion.model_count || 0) > 0) { inspectedCount += 1; } if (suggestion.apply && setTemplateControlValue(category.key, suggestion.template || '')) { appliedCount += 1; } }); let mappingCount = 0; const detectedMappings = data?.base_model_path_mappings || {}; if ( baseModelMappingsContainer && detectedMappings && typeof detectedMappings === 'object' && !Array.isArray(detectedMappings) ) { const currentMappings = collectBaseModelPathMappings(); Object.entries(detectedMappings).forEach(([source, target]) => { const sourceKey = String(source || '').trim(); const targetValue = String(target || '').trim(); if (sourceKey && targetValue && !currentMappings[sourceKey]) { currentMappings[sourceKey] = targetValue; mappingCount += 1; } }); if (mappingCount > 0) { renderBaseModelMappingRows(currentMappings); } } if (appliedCount > 0 || mappingCount > 0) { syncAllTemplateControls(); setStatus('You have unsaved changes.', 'is-dirty'); const mappingText = mappingCount ? ` Added ${mappingCount} base-model mapping${mappingCount === 1 ? '' : 's'}.` : ''; setTemplateDetectStatus( appliedCount > 0 ? `Detected ${appliedCount} template preset${appliedCount === 1 ? '' : 's'} from ${inspectedCount} model type${inspectedCount === 1 ? '' : 's'}.${mappingText}` : `Detected ${mappingCount} base-model mapping${mappingCount === 1 ? '' : 's'} from existing folders.`, 'is-valid' ); this.showNotification('Path templates detected. Review and save options.', 'success'); } else { setTemplateDetectStatus( 'No confident folder pattern found. Existing template settings were not changed.', 'is-invalid' ); this.showNotification('No confident path template pattern found.', 'info'); } } catch (error) { console.error('Model Resolver: path template detection error:', error); setTemplateDetectStatus(error.message || 'Template detection failed.', 'is-invalid'); this.showNotification('Path template detection failed', 'error'); } finally { detectTemplatesBtn.disabled = false; } }; templatePresetInputs.forEach(input => { input.addEventListener('change', () => syncTemplateControl(input.dataset.templateCategory)); }); templateCustomInputs.forEach(input => { input.addEventListener('input', () => syncTemplateControl(input.dataset.templateCategory)); }); renderBaseModelMappingRows(tokens.base_model_path_mappings || {}); if (typeof this.ensureBaseModelsLoaded === 'function') { this.ensureBaseModelsLoaded() .then(() => { refreshBaseModelMappingOptions(); }) .catch((error) => { console.warn('Model Resolver: could not load base models for mapping options', error); }); } if (addBaseModelMappingBtn) { addBaseModelMappingBtn.addEventListener('click', () => { addBaseModelMappingRow('', ''); setStatus('You have unsaved changes.', 'is-dirty'); }); } syncAllTemplateControls(); const populateDefaultRootSelects = async () => { const roots = await this.ensureDownloadRootDirectoriesLoaded(); await this.ensureDownloadDirectoriesLoaded(); defaultRootSelectInputs.forEach((select) => { const category = this.normalizeDownloadCategory(select.dataset.rootCategory || ''); const currentValue = select.value || select.dataset.currentValue || ''; const categoryRoots = Array.isArray(roots?.[category]) ? roots[category] : []; const options = [ { value: '', label: 'Auto' }, ...categoryRoots.map(path => ({ value: path, label: path })) ]; if (currentValue && !options.some(option => option.value === currentValue)) { options.push({ value: currentValue, label: `${currentValue} (not currently detected)` }); } select.innerHTML = options .map(option => ``) .join(''); syncDefaultRootContextTarget(select); }); }; populateDefaultRootSelects(); loadMetadataBuildCapabilities(); setStatus('Saved only on this machine.'); setVisibleSection('mr-options-section-sources'); trackedInputs.forEach((input) => { const eventName = input.type === 'checkbox' || input.tagName === 'SELECT' ? 'change' : 'input'; input.addEventListener(eventName, () => { setStatus('You have unsaved changes.', 'is-dirty'); }); }); defaultRootSelectInputs.forEach((select) => { select.addEventListener('change', () => { syncDefaultRootContextTarget(select); }); }); this.bindTooltips(this.contentElement); navButtons.forEach((btn) => { btn.addEventListener('click', () => { const targetId = btn.dataset.target; if (!targetId) return; setVisibleSection(targetId); if (targetId === 'mr-options-section-local-db') { loadModelListStatus(false); loadBaseModelsStatus(false); } else if (targetId === 'mr-options-section-hf') { loadHfIndexStatus(); } }); }); if (modelListCheckBtn) { modelListCheckBtn.addEventListener('click', () => { loadModelListStatus(true); }); } if (modelListUpdateBtn) { modelListUpdateBtn.addEventListener('click', () => { updateModelList(); }); } if (baseModelsCheckBtn) { baseModelsCheckBtn.addEventListener('click', () => { loadBaseModelsStatus(true); }); } if (baseModelsUpdateBtn) { baseModelsUpdateBtn.addEventListener('click', () => { updateBaseModels(); }); } if (hfIndexRefreshBtn) { hfIndexRefreshBtn.addEventListener('click', () => { refreshHfIndex(); }); } if (exportFrontendLogsBtn) { exportFrontendLogsBtn.addEventListener('click', () => { exportFrontendLogs(); }); } if (exportBackendLogsBtn) { exportBackendLogsBtn.addEventListener('click', () => { exportBackendLogs(); }); } if (clearAllCacheBtn) { clearAllCacheBtn.addEventListener('click', () => { clearAllCachesFromOptions(); }); } if (addDependencyNodeBtn) { addDependencyNodeBtn.addEventListener('click', () => { if (typeof window.ModelResolver?.addWorkflowDependencyMarkerNode !== 'function') { this.showNotification('Could not add Model Resolver opener node.', 'error'); return; } window.ModelResolver.addWorkflowDependencyMarkerNode(); }); } if (metadataSizeAuditBtn) { metadataSizeAuditBtn.addEventListener('click', () => { runMetadataSizeAudit(); }); } if (metadataSizeCollapseToggle) { metadataSizeCollapseToggle.addEventListener('click', () => { setMetadataSizeControlsCollapsed(!this.metadataSizeControlsCollapsed); }); } if (metadataBuildCollapseToggle) { metadataBuildCollapseToggle.addEventListener('click', () => { setMetadataBuildControlsCollapsed(!this.metadataBuildControlsCollapsed); }); } if (metadataBuildStartBtn) { metadataBuildStartBtn.addEventListener('click', () => { runMetadataBuild(); }); } if (metadataBuildCancelBtn) { metadataBuildCancelBtn.addEventListener('click', () => { cancelMetadataBuild(); }); } metadataBuildTabButtons.forEach((button) => { button.addEventListener('click', () => { setMetadataBuildTab(button.dataset.buildTab || 'progress'); }); }); if (metadataBuildResults) { metadataBuildResults.addEventListener('click', (event) => { const filterToggle = event.target?.closest?.('[data-metadata-history-filter-toggle]'); if (filterToggle) { event.preventDefault(); event.stopPropagation(); setMetadataBuildHistoryFilterMenuOpen(!this.metadataBuildHistoryFilterMenuOpen); return; } const filterOption = event.target?.closest?.('[data-metadata-history-filter-option]'); if (filterOption) { event.preventDefault(); event.stopPropagation(); const nextFilter = filterOption.dataset.metadataHistoryFilterOption || 'all'; applyMetadataBuildHistoryFilter(nextFilter); setMetadataBuildHistoryFilterMenuOpen(false); return; } const showMoreButton = event.target?.closest?.('[data-metadata-history-show-more]'); if (showMoreButton) { event.preventDefault(); event.stopPropagation(); this.metadataBuildHistoryVisibleLimit = Math.max( metadataBuildHistoryPageSize, Number(this.metadataBuildHistoryVisibleLimit || metadataBuildHistoryPageSize) ) + metadataBuildHistoryPageSize; renderMetadataBuildResults(undefined); } }); } this.contentElement.addEventListener('click', (event) => { if (!this.metadataBuildHistoryFilterMenuOpen) return; if (event.target?.closest?.('.mr-options-history-action-filter-wrap')) return; setMetadataBuildHistoryFilterMenuOpen(false); }); if (metadataBuildResults) { metadataBuildResults.addEventListener('keydown', (event) => { if (event.key !== 'Escape' || !this.metadataBuildHistoryFilterMenuOpen) return; event.preventDefault(); setMetadataBuildHistoryFilterMenuOpen(false); }); } if (metadataBuildWorkerInput) { metadataBuildWorkerInput.addEventListener('change', () => { metadataBuildWorkerInput.dataset.userEdited = 'true'; const workerCount = normalizeMetadataBuildWorkerCount(); if (metadataBuildWorkersEl && !this.metadataBuildProgressId) { metadataBuildWorkersEl.textContent = workerCount.toLocaleString(); } }); } if (metadataBuildModeSelect) { metadataBuildModeSelect.addEventListener('change', () => { metadataBuildModeSelect.dataset.userEdited = 'true'; updateMetadataBuildModeHint(); }); } if (detectTemplatesBtn) { detectTemplatesBtn.addEventListener('click', () => { applyDetectedPathTemplates(); }); } if (aria2CheckBtn) { aria2CheckBtn.addEventListener('click', () => { checkAria2Status({ useUnsavedPath: true }); }); } if (aria2InstallBtn) { aria2InstallBtn.addEventListener('click', () => { installAria2(); }); } if (aria2StopBtn) { aria2StopBtn.addEventListener('click', () => { if (aria2StopBtn.dataset.action === 'start') { startAria2(); } else { stopAria2(); } }); } if (aria2cPathInput) { aria2cPathInput.addEventListener('input', () => { renderAria2Summary({ backend: downloadBackendInput?.value || tokens.download_backend, configured_path: aria2cPathInput.value }); setAria2Status(''); }); } if (downloadBackendInput) { downloadBackendInput.addEventListener('change', () => { const backend = this.normalizeDownloadBackend(downloadBackendInput.value); syncAria2OptionsVisibility(); if (backend === 'aria2') { checkAria2Status({ useUnsavedPath: true }); } }); } syncAria2OptionsVisibility(); if (this.normalizeDownloadBackend(tokens.download_backend) === 'aria2') { checkAria2Status({ useUnsavedPath: false }); } const credentialChecks = [ { input: civitaiSessionInput, button: civitaiSessionCheckBtn, statusEl: civitaiSessionCheckStatus, endpoint: '/model_resolver/civitai/session-token/check', payloadKey: 'civitai_session_token', missingText: 'Paste token first' }, { input: civitaiInput, button: civitaiKeyCheckBtn, statusEl: civitaiKeyCheckStatus, endpoint: '/model_resolver/civitai/api-key/check', payloadKey: 'civitai_key', missingText: 'Paste API key first' }, { input: hfInput, button: hfTokenCheckBtn, statusEl: hfTokenCheckStatus, endpoint: '/model_resolver/huggingface/token/check', payloadKey: 'hf_token', missingText: 'Paste token first' }, { input: braveInput, button: braveKeyCheckBtn, statusEl: braveKeyCheckStatus, endpoint: '/model_resolver/brave/api-key/check', payloadKey: 'brave_search_api_key', missingText: 'Paste API key first' } ]; credentialChecks.forEach((config) => { if (!config.button) return; config.button.addEventListener('click', () => { checkCredential(config); }); }); loadModelListStatus(false); loadBaseModelsStatus(false); loadHfIndexStatus(); if (saveBtn) { saveBtn.addEventListener('click', async () => { const civitaiCandidateLimitRaw = parseInt(civitaiLimitInput?.value || `${tokens.civitai_candidate_limit}`, 10); const civitaiCandidateLimit = Number.isFinite(civitaiCandidateLimitRaw) ? Math.min(20, Math.max(1, civitaiCandidateLimitRaw)) : 5; // Collect source-enabled flags const sourceEnabled = {}; if (sourceEnabledInputs.length && !sourceEnabledInputs.some(input => input.checked)) { const localInput = sourceEnabledInputs.find(input => input.dataset.source === 'local'); if (localInput) localInput.checked = true; } sourceEnabledInputs.forEach((input) => { const key = input.dataset.storageKey; if (key) { sourceEnabled[key] = input.checked; } }); const downloadPathTemplates = {}; pathTemplateCategories.forEach(category => { downloadPathTemplates[category.key] = this.normalizeDownloadPathValue( getTemplateInputValue(category.key) ); }); const baseModelPathMappings = collectBaseModelPathMappings(); const downloadBackend = this.normalizeDownloadBackend(downloadBackendInput?.value || tokens.download_backend); const downloadPathMode = this.normalizeDownloadPathMode(downloadPathModeInput?.value || tokens.download_path_mode); const defaultRootSettings = {}; defaultRootSelectInputs.forEach((select) => { const settingKey = select.dataset.settingKey; if (settingKey) { defaultRootSettings[settingKey] = select.value || ''; } }); const newSettings = { civitai_key: civitaiInput?.value || '', civitai_session_token: civitaiSessionInput?.value || '', civitai_use_trpc_search: Boolean(civitaiUseTrpcSearchInput?.checked), civitai_use_api_search: Boolean(civitaiUseApiSearchInput?.checked), civitai_use_html_fallback: Boolean(civitaiUseHtmlFallbackInput?.checked), civitai_candidate_limit: civitaiCandidateLimit, hf_token: hfInput?.value || '', hf_use_api_search: Boolean(hfUseApiSearchInput?.checked), hf_use_comfy_org_fallback: Boolean(hfUseComfyOrgFallbackInput?.checked), hf_use_brave_fallback: Boolean(hfUseBraveFallbackInput?.checked), auto_fill_base_model: Boolean(autoFillBaseModelInput?.checked), auto_fill_subfolder: Boolean(autoFillSubfolderInput?.checked), auto_refresh_comfy_models_after_apply: Boolean(autoRefreshComfyModelsInput?.checked), auto_open_on_missing: Boolean(autoOpenOnMissingInput?.checked), workflow_hash_metadata_enabled: Boolean(workflowHashMetadataInput?.checked), workflow_dependency_marker_enabled: Boolean(workflowDependencyMarkerInput?.checked), download_backend: downloadBackend, aria2c_path: aria2cPathInput?.value?.trim() || '', aria2_auto_stop_daemon: Boolean(aria2AutoStopInput?.checked), download_sound_enabled: Boolean(downloadSoundInput?.checked), download_path_mode: downloadPathMode, download_path_templates: downloadPathTemplates, base_model_path_mappings: baseModelPathMappings, ...defaultRootSettings, frontend_logs_enabled: Boolean(frontendLogsEnabledInput?.checked), backend_logs_enabled: Boolean(backendLogsEnabledInput?.checked), frontend_log_level: normalizeLogLevel(frontendLogLevelInput?.value || tokens.frontend_log_level), backend_log_level: normalizeLogLevel(backendLogLevelInput?.value || tokens.backend_log_level), brave_search_api_key: braveInput?.value || '', search_source_enabled: sourceEnabled, }; // 1. Write to safeStorage (fast local cache) for (const opt of SETTINGS_MAP) { const val = newSettings[opt.serverKey]; if (opt.type === 'boolean' || opt.type === 'frontendLogsEnabled' || opt.type === 'backendLogsEnabled') { safeStorage.setItem(opt.localKey, val ? 'true' : 'false'); } else if (opt.type === 'json') { safeStorage.setItem(opt.localKey, JSON.stringify(val)); } else { safeStorage.setItem(opt.localKey, String(val ?? '')); } } defaultRootSelectInputs.forEach((select) => { const storageKey = select.dataset.storageKey; if (storageKey) { safeStorage.setItem(storageKey, select.value || ''); } }); Object.entries(sourceEnabled).forEach(([key, val]) => { safeStorage.setItem(key, val ? 'true' : 'false'); }); if (civitaiLimitInput) civitaiLimitInput.value = `${civitaiCandidateLimit}`; this.applyFrontendLoggingPreference(newSettings.frontend_logs_enabled, newSettings.frontend_log_level); // 2. Persist to server (survives new browsers / profiles) try { setStatus('Saving…', ''); await this.fetchJson('/model_resolver/settings', { method: 'POST', body: JSON.stringify(newSettings) }, 'Save settings'); setStatus('Saved on this machine (all browsers).', 'is-saved'); this.showNotification('Options saved', 'success'); } catch (err) { console.warn('Model Resolver: could not save settings to server, kept locally only.', err); setStatus('Saved locally only (server write failed).', 'is-saved'); this.showNotification('Options saved locally only', 'warning'); } this.downloadRootDirectories = null; this.downloadSubfolders?.clear(); await this.clearSearchCaches(); }); } }, normalizeDownloadBackend(value = '') { const backend = String(value || '').trim().toLowerCase(); return backend === 'aria2' ? 'aria2' : 'python'; }, getStoredTokens() { const tokens = {}; for (const opt of SETTINGS_MAP) { const raw = safeStorage.getItem(opt.localKey); if (opt.type === 'boolean') { tokens[opt.serverKey] = raw === null ? opt.default : raw !== 'false'; } else if (opt.type === 'json') { try { tokens[opt.serverKey] = raw ? JSON.parse(raw) : opt.default; } catch (_e) { tokens[opt.serverKey] = opt.default; } } else if (opt.type === 'backend') { tokens[opt.serverKey] = this.normalizeDownloadBackend(raw || opt.default); } else if (opt.type === 'pathMode') { tokens[opt.serverKey] = this.normalizeDownloadPathMode(raw || opt.default); } else if (opt.type === 'frontendLogsEnabled') { tokens[opt.serverKey] = raw === null ? (frontendLogger.enabled !== false) : raw !== 'false'; } else if (opt.type === 'backendLogsEnabled') { tokens[opt.serverKey] = raw === null ? true : raw !== 'false'; } else if (opt.type === 'candidateLimit') { const limit = parseInt(raw || String(opt.default), 10); tokens[opt.serverKey] = Number.isFinite(limit) ? Math.min(20, Math.max(1, limit)) : opt.default; } else { tokens[opt.serverKey] = raw || opt.default; } } tokens.search_source_enabled = this.getSearchSourceEnabledMap(); return { ...tokens, ...this.getDefaultRootSettings() }; }, applyFrontendLoggingPreference(enabled = true, levelName = 'ERROR') { frontendLogger.setEnabled(Boolean(enabled)); frontendLogger.setGlobalAndModuleLevel(frontendLogger.normalizeLevel(levelName)); }, /** * Fetch settings saved on the server and sync them into localStorage. * Call this once when the dialog initialises so every browser gets the * same tokens without the user having to re-enter them. */ async loadSettingsFromServer() { try { const data = await this.fetchJson('/model_resolver/settings', { silent: true }, 'Load settings from server'); if (!data || typeof data !== 'object') { const tokens = this.getStoredTokens(); this.applyFrontendLoggingPreference(tokens.frontend_logs_enabled, tokens.frontend_log_level); return; } let settingsObj = data; if (data.schema && Array.isArray(data.schema)) { SETTINGS_MAP.length = 0; SETTINGS_MAP.push(...data.schema); settingsObj = data.settings || {}; } for (const opt of SETTINGS_MAP) { const serverValue = settingsObj[opt.serverKey]; if (serverValue === undefined) continue; if (opt.type === 'boolean' || opt.type === 'frontendLogsEnabled' || opt.type === 'backendLogsEnabled') { safeStorage.setItem(opt.localKey, serverValue ? 'true' : 'false'); } else if (opt.type === 'json') { safeStorage.setItem(opt.localKey, JSON.stringify(serverValue || {})); } else if (opt.type === 'backend') { safeStorage.setItem(opt.localKey, this.normalizeDownloadBackend(serverValue)); } else if (opt.type === 'pathMode') { safeStorage.setItem(opt.localKey, this.normalizeDownloadPathMode(serverValue)); } else { if (serverValue !== null && serverValue !== '') { safeStorage.setItem(opt.localKey, String(serverValue)); } } } this.getDefaultRootCategoryDefinitions().forEach((item) => { if (settingsObj[item.settingKey] !== undefined) { safeStorage.setItem(item.storageKey, String(settingsObj[item.settingKey] || '')); } }); // Source-enabled flags stored as a nested object if (settingsObj.search_source_enabled && typeof settingsObj.search_source_enabled === 'object') { Object.entries(settingsObj.search_source_enabled).forEach(([key, val]) => { if (key) safeStorage.setItem(key, val ? 'true' : 'false'); }); } const tokens = this.getStoredTokens(); this.applyFrontendLoggingPreference(tokens.frontend_logs_enabled, tokens.frontend_log_level); } catch (err) { console.warn('Model Resolver: could not load settings from server, using localStorage only.', err); const tokens = this.getStoredTokens(); this.applyFrontendLoggingPreference(tokens.frontend_logs_enabled, tokens.frontend_log_level); } } };