import { app } from "../../../../../scripts/app.js"; import { api } from "../../../../../scripts/api.js"; import { $el } from "../../../../../scripts/ui.js"; import { getSvgIcon } from "../../utils/icon_utils.js"; import { LOG_LEVEL as DEFAULT_FRONTEND_LOG_LEVEL } from "../../log_system/config.js"; import { logger as frontendLogger } from "../../log_system/logger.js"; export const optionsMethods = { 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 => ``) .join(''); const renderPathTemplateRows = () => pathTemplateCategories .map(category => { const template = tokens.download_path_templates?.[category.key] ?? this.getDefaultDownloadPathTemplates()[category.key] ?? ''; const normalizedTemplate = this.normalizeDownloadPathTemplate(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 `
`; }) .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', '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 = ` `; 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 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 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 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 hfIndexCountEl = this.contentElement.querySelector('#mr-options-hf-index-count'); 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 navButtons = Array.from(this.contentElement.querySelectorAll('.mr-options-nav-btn')); const optionSections = Array.from(this.contentElement.querySelectorAll('.mr-options-section')); const trackedInputs = [ civitaiInput, civitaiSessionInput, hfInput, braveInput, civitaiLimitInput, civitaiUseTrpcSearchInput, civitaiUseHtmlFallbackInput, hfUseApiSearchInput, hfUseComfyOrgFallbackInput, hfUseBraveFallbackInput, autoFillBaseModelInput, autoFillSubfolderInput, autoRefreshComfyModelsInput, downloadBackendInput, aria2cPathInput, aria2AutoStopInput, 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 api.fetchApi('/model_resolver/logs/backend/export'); if (!response.ok) { let errorMsg = `Server returned ${response.status}: ${response.statusText}`; try { const errData = await response.json(); if (errData?.error) errorMsg = errData.error; } catch (_) { } throw new Error(errorMsg); } 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) { console.error('Model Resolver: backend log export failed:', error); setExportLogsStatus(exportBackendLogsStatus, error.message || 'Backend log export failed.'); this.showNotification('Backend log export failed', 'error'); } 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 setModelListBusy = (busy) => { if (modelListCheckBtn) modelListCheckBtn.disabled = busy; if (modelListUpdateBtn) modelListUpdateBtn.disabled = busy; }; const setBaseModelsBusy = (busy) => { if (baseModelsCheckBtn) baseModelsCheckBtn.disabled = busy; if (baseModelsUpdateBtn) baseModelsUpdateBtn.disabled = busy; }; const setHfIndexBusy = (busy) => { if (hfIndexRefreshBtn) hfIndexRefreshBtn.disabled = busy; }; const setTokenCheckStatus = (statusEl, text, mode = '') => { if (!statusEl) return; statusEl.textContent = text; statusEl.classList.remove('is-valid', 'is-invalid', 'is-pending'); if (mode) statusEl.classList.add(mode); }; const setAria2Status = (text, mode = '') => { if (!aria2StatusEl) return; aria2StatusEl.hidden = !text; aria2StatusEl.textContent = text; aria2StatusEl.classList.remove('is-valid', 'is-invalid', 'is-pending'); if (mode) aria2StatusEl.classList.add(mode); }; const setAria2SummaryValue = (element, text, mode = '') => { if (!element) return; element.textContent = text; element.classList.remove('is-valid', 'is-invalid', 'is-pending'); if (mode) element.classList.add(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'; setAria2SummaryValue(aria2BackendStateEl, backendLabel); if (state === 'checking') { syncAria2DaemonButton({ available: false, running: false }); setAria2SummaryValue(aria2AvailabilityEl, 'Checking...', 'is-pending'); setAria2SummaryValue(aria2DaemonEl, 'Checking...', 'is-pending'); return; } if (state === 'installing') { syncAria2DaemonButton({ available: false, running: false }); setAria2SummaryValue(aria2AvailabilityEl, 'Installing...', 'is-pending'); setAria2SummaryValue(aria2DaemonEl, 'Not started'); return; } if (data && typeof data.available === 'boolean') { syncAria2DaemonButton(data); setAria2SummaryValue( aria2AvailabilityEl, data.available ? 'Available' : 'Not found', data.available ? 'is-valid' : 'is-invalid' ); setAria2SummaryValue( aria2DaemonEl, data.running ? 'Running' : 'Starts on first download', data.running ? 'is-valid' : '' ); return; } syncAria2DaemonButton({ available: false, running: false }); setAria2SummaryValue(aria2AvailabilityEl, 'Not checked'); setAria2SummaryValue(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 } = {}) => { if (aria2CheckBtn) aria2CheckBtn.disabled = 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. Install aria2 or configure the aria2c 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 { if (aria2CheckBtn) aria2CheckBtn.disabled = 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'; localStorage.setItem('ModelResolver.aria2cPath', data.aria2c_path); localStorage.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 = {} } = {}) => { const response = await api.fetchApi(endpoint, { method, headers: { 'Content-Type': 'application/json' }, ...(method === 'POST' ? { body: JSON.stringify(body) } : {}) }); if (!response.ok) { let errorMsg = `Server returned ${response.status}: ${response.statusText}`; try { const errData = await response.json(); if (errData?.error) errorMsg = errData.error; } catch (_) { } const error = new Error(errorMsg); error.status = response.status; throw error; } return response.json(); }; const startAria2 = async () => { if (aria2StopBtn) aria2StopBtn.disabled = true; setAria2Status(''); setAria2SummaryValue(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(''); setAria2SummaryValue(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) { setTokenCheckStatus(statusEl, missingText, 'is-invalid'); return; } try { if (button) button.disabled = true; setTokenCheckStatus(statusEl, 'Checking...', 'is-pending'); const data = await this.fetchJson(endpoint, { method: 'POST', body: JSON.stringify({ [payloadKey]: value }) }, 'Check credential'); setTokenCheckStatus( statusEl, data.message || (data.valid ? 'Valid' : 'Invalid'), data.valid ? 'is-valid' : 'is-invalid' ); } catch (error) { console.error('Model Resolver: Credential check error:', error); setTokenCheckStatus(statusEl, error.message || 'Check failed', 'is-invalid'); } finally { if (button) button.disabled = false; } }; const renderModelListStatus = (data = {}, checkedRemote = false) => { 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 loadModelListStatus = async (checkRemote = false) => { try { setModelListBusy(true); if (modelListStateEl) { modelListStateEl.textContent = checkRemote ? 'Checking GitHub...' : 'Loading...'; } const data = await this.fetchJson(`/model_resolver/model-list/status${checkRemote ? '?check_remote=1' : ''}`, {}, 'Load model list status'); renderModelListStatus(data, checkRemote); return data; } catch (error) { console.error('Model Resolver: model-list status error:', error); if (modelListStateEl) modelListStateEl.textContent = 'Error'; if (modelListMessageEl) modelListMessageEl.textContent = error.message || 'Failed to read Local Database status.'; return null; } finally { setModelListBusy(false); } }; const updateModelList = async () => { try { setModelListBusy(true); if (modelListStateEl) modelListStateEl.textContent = 'Updating...'; if (modelListMessageEl) modelListMessageEl.textContent = 'Downloading latest ComfyUI-Manager model-list.json...'; const data = await this.fetchJson('/model_resolver/model-list/update', { method: 'POST' }, 'Update model list'); await this.clearSearchCaches(); renderModelListStatus(data, false); if (modelListStateEl) modelListStateEl.textContent = 'Updated'; if (modelListMessageEl) modelListMessageEl.textContent = `Local Database updated. ${Number(data.local_count || 0).toLocaleString()} models loaded.`; this.showNotification('Local Database updated', 'success'); } catch (error) { console.error('Model Resolver: model-list update error:', error); if (modelListStateEl) modelListStateEl.textContent = 'Error'; if (modelListMessageEl) modelListMessageEl.textContent = error.message || 'Failed to update Local Database.'; this.showNotification('Local Database update failed', 'error'); } finally { setModelListBusy(false); } }; const renderBaseModelsStatus = (data = {}, checkedRemote = false) => { 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 = async (checkRemote = false) => { try { setBaseModelsBusy(true); if (baseModelsStateEl) { baseModelsStateEl.textContent = checkRemote ? 'Checking CivitAI...' : 'Loading...'; } const data = await this.fetchJson(`/model_resolver/base-models/status${checkRemote ? '?check_remote=1' : ''}`, {}, 'Load base models status'); renderBaseModelsStatus(data, checkRemote); return data; } catch (error) { console.error('Model Resolver: base-models status error:', error); if (baseModelsStateEl) baseModelsStateEl.textContent = 'Error'; if (baseModelsMessageEl) baseModelsMessageEl.textContent = error.message || 'Failed to read Base Models status.'; return null; } finally { setBaseModelsBusy(false); } }; 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 = async () => { try { setBaseModelsBusy(true); if (baseModelsStateEl) baseModelsStateEl.textContent = 'Updating...'; if (baseModelsMessageEl) baseModelsMessageEl.textContent = 'Downloading latest base models from CivitAI...'; const data = await this.fetchJson('/model_resolver/base-models/update', { method: 'POST' }, 'Update base models'); await this.clearSearchCaches(); // Invalidate the in-memory cache so the base model dropdown // reloads fresh data from the server next time it is opened. this.baseModels = null; await this.ensureBaseModelsLoaded(); renderBaseModelsStatus(data, false); if (baseModelsStateEl) baseModelsStateEl.textContent = 'Updated'; 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}`; this.showNotification( addedCount > 0 ? `Base Models updated${newModelsSummary ? `: ${newModelsSummary}` : ''}` : 'Base Models updated. No new models added.', 'success' ); } catch (error) { console.error('Model Resolver: base-models update error:', error); if (baseModelsStateEl) baseModelsStateEl.textContent = 'Error'; if (baseModelsMessageEl) baseModelsMessageEl.textContent = error.message || 'Failed to update Base Models.'; this.showNotification('Base Models update failed', 'error'); } finally { setBaseModelsBusy(false); } }; const renderHfIndexStatus = (data = {}) => { if (hfIndexCountEl) { hfIndexCountEl.textContent = Number.isFinite(Number(data.file_count)) ? Number(data.file_count).toLocaleString() : '-'; } if (hfIndexUpdatedEl) { hfIndexUpdatedEl.textContent = data.updated_at ? formatOptionDate(Number(data.updated_at) * 1000) : 'Never'; } if (hfIndexStateEl) { if (!data.exists) { hfIndexStateEl.textContent = 'Not cached'; } else if (data.stale) { hfIndexStateEl.textContent = 'Refresh due'; } else { hfIndexStateEl.textContent = 'Cached'; } } if (hfIndexMessageEl) { if (!data.exists) { hfIndexMessageEl.textContent = 'The first Comfy-Org fallback search will build the local HuggingFace file index.'; } else if (data.stale) { hfIndexMessageEl.textContent = `Index has ${Number(data.repo_count || 0).toLocaleString()} repos and should be refreshed.`; } else { hfIndexMessageEl.textContent = `Index has ${Number(data.repo_count || 0).toLocaleString()} repos and is used before per-repo fallback checks.`; } } }; const loadHfIndexStatus = async () => { try { setHfIndexBusy(true); if (hfIndexStateEl) hfIndexStateEl.textContent = 'Loading...'; const data = await this.fetchJson('/model_resolver/huggingface/author-index/status', {}, 'Load HuggingFace index status'); renderHfIndexStatus(data); return data; } catch (error) { console.error('Model Resolver: HuggingFace index status error:', error); if (hfIndexStateEl) hfIndexStateEl.textContent = 'Error'; if (hfIndexMessageEl) hfIndexMessageEl.textContent = error.message || 'Failed to read HuggingFace index status.'; return null; } finally { setHfIndexBusy(false); } }; const refreshHfIndex = async () => { try { setHfIndexBusy(true); if (hfIndexStateEl) hfIndexStateEl.textContent = 'Refreshing...'; if (hfIndexMessageEl) hfIndexMessageEl.textContent = 'Downloading Comfy-Org file index from HuggingFace...'; const data = await this.fetchJson('/model_resolver/huggingface/author-index/refresh', { method: 'POST', body: JSON.stringify({ hf_token: hfInput?.value || '' }) }, 'Refresh HuggingFace index'); if (data?.success === false) { throw new Error(data.error || 'Refresh failed'); } await this.clearSearchCaches(); renderHfIndexStatus(data); if (hfIndexStateEl) hfIndexStateEl.textContent = 'Refreshed'; this.showNotification('HuggingFace Comfy-Org index refreshed', 'success'); } catch (error) { console.error('Model Resolver: HuggingFace index refresh error:', error); if (hfIndexStateEl) hfIndexStateEl.textContent = 'Error'; if (hfIndexMessageEl) hfIndexMessageEl.textContent = error.message || 'Failed to refresh HuggingFace index.'; this.showNotification('HuggingFace index refresh failed', 'error'); } finally { setHfIndexBusy(false); } }; const clearAllCachesFromOptions = async () => { try { if (clearAllCacheBtn) clearAllCacheBtn.disabled = true; if (clearAllCacheStatus) clearAllCacheStatus.textContent = 'Clearing frontend and backend cache...'; await this.clearAllResolverCaches(); if (clearAllCacheStatus) clearAllCacheStatus.textContent = 'Frontend and backend cache cleared.'; this.showNotification('Resolver cache cleared', 'success'); if (this.activeTab === 'options') { this.displayOptions(); } } catch (error) { console.error('Model Resolver: clear all cache error:', error); if (clearAllCacheStatus) clearAllCacheStatus.textContent = error.message || 'Failed to clear cache.'; this.showNotification('Cache clear failed', 'error'); } finally { if (clearAllCacheBtn) clearAllCacheBtn.disabled = 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); }); 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 renderBaseModelMappingOptions = (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 options = [ `` ]; const choices = getBaseModelMappingChoices(); if (currentValue && !choices.includes(currentValue)) { options.push(``); } choices.forEach((name) => { if (usedValues.has(name) && name !== currentValue) return; options.push(``); }); return options.join(''); }; const refreshBaseModelMappingOptions = () => { getBaseModelMappingRows().forEach((row) => { const select = row.querySelector('.mr-options-mapping-base'); if (!select) return; const currentValue = select.value; select.innerHTML = renderBaseModelMappingOptions(currentValue, row); select.value = currentValue; }); }; const addBaseModelMappingRow = (baseModel = '', pathValue = '') => { 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 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(); }; baseSelect?.addEventListener('change', markChanged); pathInput?.addEventListener('input', markChanged); pathInput?.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); pathInput.blur(); } }); removeBtn?.addEventListener('click', () => { row.remove(); if (!getBaseModelMappingRows().length) { addBaseModelMappingRow('', ''); } markChanged(); }); baseModelMappingsContainer.appendChild(row); refreshBaseModelMappingOptions(); this.bindTooltips(row); }; const renderBaseModelMappingRows = (mappings = {}) => { if (!baseModelMappingsContainer) return; baseModelMappingsContainer.innerHTML = ''; const entries = Object.entries(mappings || {}); if (!entries.length) { addBaseModelMappingRow('', ''); return; } entries.forEach(([baseModel, pathValue]) => { addBaseModelMappingRow(baseModel, pathValue); }); }; 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, '') }; let formatted = this.normalizeDownloadPathTemplate(template); Object.entries(replacements).forEach(([token, value]) => { formatted = formatted.split(token).join(value); }); formatted = formatted.replace(/\{[^{}]+\}/g, ''); return this.normalizeTemplateSubfolder(formatted) || '(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.normalizeDownloadPathTemplate(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();
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 (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 });
}
if (civitaiSessionCheckBtn) {
civitaiSessionCheckBtn.addEventListener('click', () => {
checkCredential({
input: civitaiSessionInput,
button: civitaiSessionCheckBtn,
statusEl: civitaiSessionCheckStatus,
endpoint: '/model_resolver/civitai/session-token/check',
payloadKey: 'civitai_session_token',
missingText: 'Paste token first'
});
});
}
if (civitaiKeyCheckBtn) {
civitaiKeyCheckBtn.addEventListener('click', () => {
checkCredential({
input: civitaiInput,
button: civitaiKeyCheckBtn,
statusEl: civitaiKeyCheckStatus,
endpoint: '/model_resolver/civitai/api-key/check',
payloadKey: 'civitai_key',
missingText: 'Paste API key first'
});
});
}
if (hfTokenCheckBtn) {
hfTokenCheckBtn.addEventListener('click', () => {
checkCredential({
input: hfInput,
button: hfTokenCheckBtn,
statusEl: hfTokenCheckStatus,
endpoint: '/model_resolver/huggingface/token/check',
payloadKey: 'hf_token',
missingText: 'Paste token first'
});
});
}
if (braveKeyCheckBtn) {
braveKeyCheckBtn.addEventListener('click', () => {
checkCredential({
input: braveInput,
button: braveKeyCheckBtn,
statusEl: braveKeyCheckStatus,
endpoint: '/model_resolver/brave/api-key/check',
payloadKey: 'brave_search_api_key',
missingText: 'Paste API key first'
});
});
}
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.normalizeDownloadPathTemplate(
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_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),
download_backend: downloadBackend,
aria2c_path: aria2cPathInput?.value?.trim() || '',
aria2_auto_stop_daemon: Boolean(aria2AutoStopInput?.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 localStorage (fast local cache)
localStorage.setItem('ModelResolver.civitaiApiKey', newSettings.civitai_key);
localStorage.setItem('ModelResolver.civitaiSessionToken', newSettings.civitai_session_token);
localStorage.setItem('ModelResolver.civitaiUseTrpcSearch', newSettings.civitai_use_trpc_search ? 'true' : 'false');
localStorage.setItem('ModelResolver.civitaiUseHtmlFallback', newSettings.civitai_use_html_fallback ? 'true' : 'false');
localStorage.setItem('ModelResolver.huggingFaceToken', newSettings.hf_token);
localStorage.setItem('ModelResolver.braveSearchApiKey', newSettings.brave_search_api_key);
localStorage.setItem('ModelResolver.hfUseApiSearch', newSettings.hf_use_api_search ? 'true' : 'false');
localStorage.setItem('ModelResolver.hfUseComfyOrgFallback', newSettings.hf_use_comfy_org_fallback ? 'true' : 'false');
localStorage.setItem('ModelResolver.hfUseBraveFallback', newSettings.hf_use_brave_fallback ? 'true' : 'false');
localStorage.setItem('ModelResolver.autoFillBaseModel', newSettings.auto_fill_base_model ? 'true' : 'false');
localStorage.setItem('ModelResolver.autoFillSubfolder', newSettings.auto_fill_subfolder ? 'true' : 'false');
localStorage.setItem('ModelResolver.autoRefreshComfyModelsAfterApply', newSettings.auto_refresh_comfy_models_after_apply ? 'true' : 'false');
localStorage.setItem('ModelResolver.downloadBackend', newSettings.download_backend);
localStorage.setItem('ModelResolver.aria2cPath', newSettings.aria2c_path);
localStorage.setItem('ModelResolver.aria2AutoStopDaemon', newSettings.aria2_auto_stop_daemon ? 'true' : 'false');
localStorage.setItem('ModelResolver.downloadPathMode', newSettings.download_path_mode);
localStorage.setItem('ModelResolver.downloadPathTemplates', JSON.stringify(newSettings.download_path_templates));
localStorage.setItem('ModelResolver.baseModelPathMappings', JSON.stringify(newSettings.base_model_path_mappings));
defaultRootSelectInputs.forEach((select) => {
const storageKey = select.dataset.storageKey;
if (storageKey) {
localStorage.setItem(storageKey, select.value || '');
}
});
localStorage.setItem('ModelResolver.frontendLogsEnabled', newSettings.frontend_logs_enabled ? 'true' : 'false');
localStorage.setItem('ModelResolver.backendLogsEnabled', newSettings.backend_logs_enabled ? 'true' : 'false');
localStorage.setItem('ModelResolver.frontendLogLevel', newSettings.frontend_log_level);
localStorage.setItem('ModelResolver.backendLogLevel', newSettings.backend_log_level);
localStorage.setItem('ModelResolver.civitaiCandidateLimit', `${civitaiCandidateLimit}`);
Object.entries(sourceEnabled).forEach(([key, val]) => {
localStorage.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 civitaiCandidateLimitRaw = parseInt(localStorage.getItem('ModelResolver.civitaiCandidateLimit') || '5', 10);
const civitai_candidate_limit = Number.isFinite(civitaiCandidateLimitRaw)
? Math.min(20, Math.max(1, civitaiCandidateLimitRaw))
: 5;
const search_source_enabled = this.getSearchSourceEnabledMap();
const storedFrontendLogsEnabled = localStorage.getItem('ModelResolver.frontendLogsEnabled');
const storedBackendLogsEnabled = localStorage.getItem('ModelResolver.backendLogsEnabled');
const storedFrontendLogLevel = localStorage.getItem('ModelResolver.frontendLogLevel');
const storedBackendLogLevel = localStorage.getItem('ModelResolver.backendLogLevel');
return {
civitai_key: localStorage.getItem('ModelResolver.civitaiApiKey') || '',
civitai_session_token: localStorage.getItem('ModelResolver.civitaiSessionToken') || '',
hf_token: localStorage.getItem('ModelResolver.huggingFaceToken') || '',
brave_search_api_key: localStorage.getItem('ModelResolver.braveSearchApiKey') || '',
civitai_use_trpc_search: localStorage.getItem('ModelResolver.civitaiUseTrpcSearch') !== 'false',
civitai_use_html_fallback: localStorage.getItem('ModelResolver.civitaiUseHtmlFallback') !== 'false',
hf_use_api_search: localStorage.getItem('ModelResolver.hfUseApiSearch') !== 'false',
hf_use_comfy_org_fallback: localStorage.getItem('ModelResolver.hfUseComfyOrgFallback') !== 'false',
hf_use_brave_fallback: localStorage.getItem('ModelResolver.hfUseBraveFallback') !== 'false',
auto_fill_base_model: localStorage.getItem('ModelResolver.autoFillBaseModel') !== 'false',
auto_fill_subfolder: localStorage.getItem('ModelResolver.autoFillSubfolder') !== 'false',
auto_refresh_comfy_models_after_apply: localStorage.getItem('ModelResolver.autoRefreshComfyModelsAfterApply') !== 'false',
frontend_logs_enabled: storedFrontendLogsEnabled === null
? frontendLogger.enabled !== false
: storedFrontendLogsEnabled !== 'false',
backend_logs_enabled: storedBackendLogsEnabled === null
? true
: storedBackendLogsEnabled !== 'false',
frontend_log_level: storedFrontendLogLevel || DEFAULT_FRONTEND_LOG_LEVEL,
backend_log_level: storedBackendLogLevel || 'DEBUG',
civitai_candidate_limit,
search_source_enabled,
download_backend: this.normalizeDownloadBackend(localStorage.getItem('ModelResolver.downloadBackend') || 'python'),
aria2c_path: localStorage.getItem('ModelResolver.aria2cPath') || '',
aria2_auto_stop_daemon: localStorage.getItem('ModelResolver.aria2AutoStopDaemon') !== 'false',
download_path_mode: this.getDownloadPathMode(),
download_path_templates: this.getDownloadPathTemplates(),
base_model_path_mappings: this.getBaseModelPathMappings(),
...this.getDefaultRootSettings()
};
},
applyFrontendLoggingPreference(enabled = true, levelName = 'DEBUG') {
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;
}
// Helper: write to localStorage only when the value from server is
// non-empty, so we don't overwrite a key the user already has locally.
const sync = (localKey, serverValue) => {
if (serverValue !== undefined && serverValue !== null && serverValue !== '') {
localStorage.setItem(localKey, serverValue);
}
};
sync('ModelResolver.civitaiApiKey', data.civitai_key);
sync('ModelResolver.civitaiSessionToken', data.civitai_session_token);
sync('ModelResolver.huggingFaceToken', data.hf_token);
sync('ModelResolver.braveSearchApiKey', data.brave_search_api_key);
if (data.civitai_use_trpc_search !== undefined)
localStorage.setItem('ModelResolver.civitaiUseTrpcSearch', data.civitai_use_trpc_search ? 'true' : 'false');
if (data.civitai_use_html_fallback !== undefined)
localStorage.setItem('ModelResolver.civitaiUseHtmlFallback', data.civitai_use_html_fallback ? 'true' : 'false');
if (data.hf_use_api_search !== undefined)
localStorage.setItem('ModelResolver.hfUseApiSearch', data.hf_use_api_search ? 'true' : 'false');
if (data.hf_use_comfy_org_fallback !== undefined)
localStorage.setItem('ModelResolver.hfUseComfyOrgFallback', data.hf_use_comfy_org_fallback ? 'true' : 'false');
if (data.hf_use_brave_fallback !== undefined)
localStorage.setItem('ModelResolver.hfUseBraveFallback', data.hf_use_brave_fallback ? 'true' : 'false');
if (data.auto_fill_base_model !== undefined)
localStorage.setItem('ModelResolver.autoFillBaseModel', data.auto_fill_base_model ? 'true' : 'false');
if (data.auto_fill_subfolder !== undefined)
localStorage.setItem('ModelResolver.autoFillSubfolder', data.auto_fill_subfolder ? 'true' : 'false');
if (data.auto_refresh_comfy_models_after_apply !== undefined)
localStorage.setItem('ModelResolver.autoRefreshComfyModelsAfterApply', data.auto_refresh_comfy_models_after_apply ? 'true' : 'false');
if (data.download_backend !== undefined)
localStorage.setItem('ModelResolver.downloadBackend', this.normalizeDownloadBackend(data.download_backend));
if (data.aria2c_path !== undefined)
localStorage.setItem('ModelResolver.aria2cPath', String(data.aria2c_path || ''));
if (data.aria2_auto_stop_daemon !== undefined)
localStorage.setItem('ModelResolver.aria2AutoStopDaemon', data.aria2_auto_stop_daemon ? 'true' : 'false');
if (data.download_path_mode !== undefined)
localStorage.setItem('ModelResolver.downloadPathMode', this.normalizeDownloadPathMode(data.download_path_mode));
if (data.download_path_templates !== undefined)
localStorage.setItem('ModelResolver.downloadPathTemplates', JSON.stringify(data.download_path_templates || {}));
if (data.base_model_path_mappings !== undefined)
localStorage.setItem('ModelResolver.baseModelPathMappings', JSON.stringify(data.base_model_path_mappings || {}));
this.getDefaultRootCategoryDefinitions().forEach((item) => {
if (data[item.settingKey] !== undefined) {
localStorage.setItem(item.storageKey, String(data[item.settingKey] || ''));
}
});
if (data.civitai_candidate_limit !== undefined)
localStorage.setItem('ModelResolver.civitaiCandidateLimit', `${data.civitai_candidate_limit}`);
if (data.frontend_logs_enabled !== undefined)
localStorage.setItem('ModelResolver.frontendLogsEnabled', data.frontend_logs_enabled ? 'true' : 'false');
if (data.backend_logs_enabled !== undefined)
localStorage.setItem('ModelResolver.backendLogsEnabled', data.backend_logs_enabled ? 'true' : 'false');
if (data.frontend_log_level !== undefined)
localStorage.setItem('ModelResolver.frontendLogLevel', String(data.frontend_log_level || 'DEBUG').toUpperCase());
if (data.backend_log_level !== undefined)
localStorage.setItem('ModelResolver.backendLogLevel', String(data.backend_log_level || 'DEBUG').toUpperCase());
// Source-enabled flags stored as a nested object
if (data.search_source_enabled && typeof data.search_source_enabled === 'object') {
Object.entries(data.search_source_enabled).forEach(([key, val]) => {
if (key) localStorage.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);
}
}
};