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"; 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 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.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 `
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', '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

${sourceDefaultsRows}

CivitAI

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
CivitAI Models To Inspect ?

HuggingFace

Not checked
Comfy-Org files Loading...
Last refresh Loading...
Status Checking local index...
Comfy-Org fallback uses a cached HuggingFace file index.
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 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 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 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, 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 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 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 response = await api.fetchApi(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [payloadKey]: value }) }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || `Check failed: ${response.status}`); } 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 response = await api.fetchApi(`/model_resolver/model-list/status${checkRemote ? '?check_remote=1' : ''}`); if (!response.ok) { throw new Error(`Status failed: ${response.status}`); } const data = await response.json(); 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 response = await api.fetchApi('/model_resolver/model-list/update', { method: 'POST' }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || `Update failed: ${response.status}`); } 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 response = await api.fetchApi(`/model_resolver/base-models/status${checkRemote ? '?check_remote=1' : ''}`); if (!response.ok) { throw new Error(`Status failed: ${response.status}`); } const data = await response.json(); 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 response = await api.fetchApi('/model_resolver/base-models/update', { method: 'POST' }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || `Update failed: ${response.status}`); } 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 response = await api.fetchApi('/model_resolver/huggingface/author-index/status'); if (!response.ok) { throw new Error(`Status failed: ${response.status}`); } const data = await response.json(); 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 response = await api.fetchApi('/model_resolver/huggingface/author-index/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hf_token: hfInput?.value || '' }) }); const data = await response.json(); if (!response.ok || data.success === false) { throw new Error(data.error || `Refresh failed: ${response.status}`); } 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 resp = await api.fetchApi('/model_resolver/path-template-suggestions?force=1'); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); 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) { if (appliedCount > 0 && downloadPathModeInput) { downloadPathModeInput.value = 'template'; } 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 (clearAllCacheBtn) { clearAllCacheBtn.addEventListener('click', () => { clearAllCachesFromOptions(); }); } if (detectTemplatesBtn) { detectTemplatesBtn.addEventListener('click', () => { applyDetectedPathTemplates(); }); } 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 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), 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.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…', ''); const resp = await api.fetchApi('/model_resolver/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newSettings), }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); 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(); }); } } };