Files
Azornes-Comfyui-Model-Resolver/web/resolver/views/options_methods.js
T
Dariusz L 92be0d6213 Improve base model alias resolution and update model metadata
- Add version-trimmed alias generation to handle trailing version zeros (e.g. '4.0' -> '4')
- Track newly added base model names in 'new_models_added_list' field
- Include Ideogram 4.0, Boogu, and PolyGen in base-models registry
- Update web resolver to match aliases using both original and version-trimmed tokens
- Enhance base models update UI to show which new models were added
2026-06-21 02:29:42 +02:00

1685 lines
104 KiB
JavaScript

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 => `<option value="${option.value}" ${option.value === selected ? 'selected' : ''} title="${this.escapeHtml(option.tooltip)}">${this.escapeHtml(option.label)}</option>`)
.join('');
};
const renderTemplatePlaceholderBadges = () => ['{base_model}', '{author}', '{first_tag}', '{model_name}', '{version_name}']
.map(placeholder => `<span class="mr-options-placeholder-badge">${this.escapeHtml(placeholder)}</span>`)
.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 => (
`<option value="${this.escapeHtml(preset.value)}" ${preset.value === selectedValue ? 'selected' : ''}>${this.escapeHtml(preset.label)}</option>`
)),
`<option value="custom" ${selectedValue === 'custom' ? 'selected' : ''}>Custom template</option>`
].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 `
<div class="mr-options-template-row" data-template-category="${this.escapeHtml(category.key)}">
<div class="mr-options-template-main">
<label class="mr-options-label" for="mr-options-template-${this.escapeHtml(category.key)}">${this.escapeHtml(category.label)}</label>
<select id="mr-options-template-${this.escapeHtml(category.key)}" class="mr-options-input mr-options-template-preset" data-template-category="${this.escapeHtml(category.key)}">
${options}
</select>
</div>
<input class="mr-options-input mr-options-template-custom ${selectedValue === 'custom' ? '' : 'is-hidden'}" data-template-category="${this.escapeHtml(category.key)}" type="text" value="${this.escapeHtml(normalizedTemplate)}" placeholder="{base_model}/{author}/{first_tag}">
<div class="mr-options-template-preview" data-template-preview="${this.escapeHtml(category.key)}"><span>Preview:</span> <code>${this.escapeHtml(preview)}</code></div>
</div>
`;
})
.join('');
const renderDefaultRootRows = () => defaultRootCategories
.map(category => {
const value = tokens[category.settingKey] || '';
return `
<div class="mr-options-template-row" data-root-category="${this.escapeHtml(category.key)}">
<div class="mr-options-template-main">
<label class="mr-options-label" for="mr-options-root-${this.escapeHtml(category.key)}">${this.escapeHtml(category.label)}</label>
<select id="mr-options-root-${this.escapeHtml(category.key)}" class="mr-options-input mr-options-default-root" data-root-category="${this.escapeHtml(category.key)}" data-setting-key="${this.escapeHtml(category.settingKey)}" data-storage-key="${this.escapeHtml(category.storageKey)}" data-current-value="${this.escapeHtml(value)}">
<option value="">Auto</option>
${value ? `<option value="${this.escapeHtml(value)}" selected>${this.escapeHtml(value)}</option>` : ''}
</select>
</div>
</div>
`;
})
.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 `
<label class="mr-options-toggle-row">
<div class="mr-options-toggle-copy">
<span class="mr-options-toggle-title">
<span class="mr-options-source-icon" aria-hidden="true">${getSvgIcon(iconName)}</span>
<span>${this.escapeHtml(this.getSearchSourceLabel(def.source))}</span>
<span class="mr-tooltip-badge" data-tooltip="${this.escapeHtml(def.tooltip)}">?</span>
</span>
</div>
<span class="mr-options-toggle-control">
<input id="${inputId}" class="mr-options-switch-input mr-options-source-enabled" type="checkbox" data-source="${this.escapeHtml(def.source)}" data-storage-key="${this.escapeHtml(def.storageKey)}" ${checked ? 'checked' : ''}>
<span class="mr-options-switch"></span>
</span>
</label>
`;
})
.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 => `<option value="${level}" ${level === selected ? 'selected' : ''}>${level}</option>`)
.join('');
};
this.contentElement.innerHTML = `
<div class="mr-options-wrap">
<div class="mr-options-shell">
<aside class="mr-options-sidebar">
<div class="mr-options-sidebar-group">
<h3 class="mr-options-sidebar-title">Application Settings</h3>
</div>
<div class="mr-options-sidebar-group">
<div class="mr-options-sidebar-label">Download</div>
<div class="mr-options-nav">
<button type="button" class="mr-options-nav-btn is-active" data-target="mr-options-section-sources">
<span class="mr-options-nav-main">
<span class="mr-options-nav-icon" aria-hidden="true">${getSvgIcon('download')}</span>
<span>Sources</span>
</span>
<span class="mr-options-nav-meta">01</span>
</button>
<button type="button" class="mr-options-nav-btn" data-target="mr-options-section-paths">
<span class="mr-options-nav-main">
<span class="mr-options-nav-icon" aria-hidden="true">${getSvgIcon('folderOpen')}</span>
<span>Paths</span>
</span>
<span class="mr-options-nav-meta">02</span>
</button>
<button type="button" class="mr-options-nav-btn" data-target="mr-options-section-local-db">
<span class="mr-options-nav-main">
<span class="mr-options-nav-icon" aria-hidden="true">${getSvgIcon('database')}</span>
<span>Local Database</span>
</span>
<span class="mr-options-nav-meta">03</span>
</button>
</div>
</div>
<div class="mr-options-sidebar-group">
<div class="mr-options-sidebar-label">Search</div>
<div class="mr-options-nav">
<button type="button" class="mr-options-nav-btn" data-target="mr-options-section-search">
<span class="mr-options-nav-main">
<span class="mr-options-nav-icon" aria-hidden="true">${getSvgIcon('search')}</span>
<span>Defaults</span>
</span>
<span class="mr-options-nav-meta">04</span>
</button>
</div>
</div>
<div class="mr-options-sidebar-group">
<div class="mr-options-sidebar-label">Providers</div>
<div class="mr-options-nav">
<button type="button" class="mr-options-nav-btn" data-target="mr-options-section-civitai">
<span class="mr-options-nav-main">
<span class="mr-options-nav-icon mr-options-provider-icon mr-options-provider-icon-civitai" aria-hidden="true">${getSvgIcon('civitai')}</span>
<span>CivitAI</span>
</span>
<span class="mr-options-nav-meta">05</span>
</button>
<button type="button" class="mr-options-nav-btn" data-target="mr-options-section-hf">
<span class="mr-options-nav-main">
<span class="mr-options-nav-icon mr-options-provider-icon mr-options-provider-icon-huggingface" aria-hidden="true">${getSvgIcon('huggingface')}</span>
<span>HuggingFace</span>
</span>
<span class="mr-options-nav-meta">06</span>
</button>
</div>
</div>
<div class="mr-options-sidebar-group">
<div class="mr-options-sidebar-label">System</div>
<div class="mr-options-nav">
<button type="button" class="mr-options-nav-btn" data-target="mr-options-section-maintenance">
<span class="mr-options-nav-main">
<span class="mr-options-nav-icon" aria-hidden="true">${getSvgIcon('wrench')}</span>
<span>Maintenance</span>
</span>
<span class="mr-options-nav-meta">07</span>
</button>
</div>
</div>
<div class="mr-options-actions">
<div id="mr-options-status" class="mr-options-status">Saved only on this machine.</div>
<button id="mr-options-save" class="mr-btn mr-btn-primary mr-footer-btn">Save</button>
</div>
</aside>
<div class="mr-options-main">
<section id="mr-options-section-sources" class="mr-options-card mr-options-section">
<div class="mr-options-section-head">
<h4 class="mr-options-section-title">Download Sources</h4>
</div>
<div class="mr-options-grid">
<div class="mr-options-panel">
<div class="mr-options-toggle-list">
${sourceDefaultsRows}
</div>
</div>
</div>
</section>
<section id="mr-options-section-paths" class="mr-options-card mr-options-section is-hidden">
<div class="mr-options-section-head">
<h4 class="mr-options-section-title">Download Paths</h4>
</div>
<div class="mr-options-grid">
<div class="mr-options-panel">
<div class="mr-options-stack">
<div class="mr-options-number-row mr-options-path-mode-row">
<div class="mr-options-number-copy">
<span class="mr-options-label">Subfolder fill mode <span class="mr-tooltip-badge" data-tooltip="Controls how Model Resolver fills the Subfolder field before a download. Manual edits in the download panel still win.">?</span></span>
</div>
<select id="mr-options-download-path-mode" class="mr-options-input">
${renderPathModeOptions(tokens.download_path_mode)}
</select>
</div>
<div class="mr-options-subsection-head">
<h5 class="mr-options-subsection-title">Default Roots</h5>
<span class="mr-tooltip-badge" data-tooltip="Choose the root directory used by downloads for each model type. Auto keeps Model Resolver's existing folder choice.">?</span>
</div>
<div class="mr-options-template-list">
${renderDefaultRootRows()}
</div>
<div class="mr-options-subsection-head mr-options-subsection-head-actions">
<div class="mr-options-subsection-title-wrap">
<h5 class="mr-options-subsection-title">Download Path Templates</h5>
<span class="mr-tooltip-badge" data-tooltip="Templates create subfolders under the selected root. Empty template means a flat folder.">?</span>
</div>
<button id="mr-options-detect-templates" type="button" class="mr-btn mr-btn-secondary mr-btn-sm">Detect from existing folders</button>
</div>
<div id="mr-options-template-detect-status" class="mr-options-template-detect-status">Scan your existing model folders and apply the most likely template presets.</div>
<div class="mr-options-placeholder-row">
<span>Available placeholders:</span>
${renderTemplatePlaceholderBadges()}
</div>
<div class="mr-options-template-list">
${renderPathTemplateRows()}
</div>
</div>
</div>
<div class="mr-options-panel">
<div class="mr-options-stack">
<div class="mr-options-mapping-section">
<div class="mr-options-mapping-head">
<label class="mr-options-label">Base Model Path Mappings <span class="mr-tooltip-badge" data-tooltip="Optional. Map a detected base model to a folder path, for example: Pony -> SDXL/Pony. These are applied to {base_model} and may include subfolders.">?</span></label>
<button id="mr-options-add-base-model-mapping" type="button" class="mr-btn mr-btn-primary mr-btn-sm">+ Add Mapping</button>
</div>
<div id="mr-options-base-model-mappings" class="mr-options-mapping-list"></div>
</div>
<div class="mr-options-db-message">
These settings only affect new downloads. The per-download Folder and Subfolder controls remain available for one-off overrides.
</div>
</div>
</div>
</div>
</section>
<section id="mr-options-section-search" class="mr-options-card mr-options-section is-hidden">
<div class="mr-options-section-head">
<h4 class="mr-options-section-title">Search Defaults</h4>
</div>
<div class="mr-options-grid">
<div class="mr-options-panel">
<div class="mr-options-toggle-list">
<label class="mr-options-toggle-row">
<div class="mr-options-toggle-copy">
<span class="mr-options-toggle-title">Auto-fill model filter <span class="mr-tooltip-badge" data-tooltip="When enabled, new searches use Auto with the detected workflow base model. When disabled, new searches start with Any model, and Auto remains available in the model dropdown.">?</span></span>
</div>
<span class="mr-options-toggle-control">
<input id="mr-options-auto-fill-base-model" class="mr-options-switch-input" type="checkbox" ${tokens.auto_fill_base_model ? 'checked' : ''}>
<span class="mr-options-switch"></span>
</span>
</label>
<label class="mr-options-toggle-row">
<div class="mr-options-toggle-copy">
<span class="mr-options-toggle-title">Auto-fill subfolder <span class="mr-tooltip-badge" data-tooltip="When enabled, Model Resolver fills a suggested subfolder from model metadata, filename patterns, and your existing folders. When disabled, the field stays empty unless you click Suggest.">?</span></span>
</div>
<span class="mr-options-toggle-control">
<input id="mr-options-auto-fill-subfolder" class="mr-options-switch-input" type="checkbox" ${tokens.auto_fill_subfolder ? 'checked' : ''}>
<span class="mr-options-switch"></span>
</span>
</label>
</div>
</div>
</div>
</section>
<section id="mr-options-section-local-db" class="mr-options-card mr-options-section is-hidden">
<div class="mr-options-section-head">
<h4 class="mr-options-section-title">Local Database</h4>
</div>
<div class="mr-options-grid">
<div class="mr-options-panel">
<div class="mr-options-stack">
<div class="mr-options-db-summary">
<div class="mr-options-db-row">
<span>Models</span>
<strong id="mr-options-model-list-count">Loading...</strong>
</div>
<div class="mr-options-db-row">
<span>Last update</span>
<strong id="mr-options-model-list-updated">Loading...</strong>
</div>
<div class="mr-options-db-row">
<span>Status</span>
<strong id="mr-options-model-list-state">Checking local file...</strong>
</div>
</div>
<div id="mr-options-model-list-message" class="mr-options-db-message">Local Database uses ComfyUI-Manager model-list.json.</div>
<div class="mr-options-db-actions">
<button id="mr-options-model-list-check" type="button" class="mr-btn mr-btn-secondary">${getSvgIcon('refreshCw')} Check latest</button>
<button id="mr-options-model-list-update" type="button" class="mr-btn mr-btn-primary">${getSvgIcon('download')} Update Local Database</button>
<a class="mr-options-inline-link" href="https://github.com/Comfy-Org/ComfyUI-Manager/blob/main/model-list.json" target="_blank" rel="noopener noreferrer">Source</a>
</div>
</div>
</div>
</div>
<div class="mr-options-section-head" style="margin-top: 24px;">
<h4 class="mr-options-section-title">Base Models Support</h4>
</div>
<div class="mr-options-grid">
<div class="mr-options-panel">
<div class="mr-options-stack">
<div class="mr-options-db-summary">
<div class="mr-options-db-row">
<span>Base Models</span>
<strong id="mr-options-base-models-count">Loading...</strong>
</div>
<div class="mr-options-db-row">
<span>Last update</span>
<strong id="mr-options-base-models-updated">Loading...</strong>
</div>
<div class="mr-options-db-row">
<span>Status</span>
<strong id="mr-options-base-models-state">Checking local file...</strong>
</div>
</div>
<div id="mr-options-base-models-message" class="mr-options-db-message">Base Models list maps new CivitAI models to local categories.</div>
<div class="mr-options-db-actions">
<button id="mr-options-base-models-check" type="button" class="mr-btn mr-btn-secondary">${getSvgIcon('refreshCw')} Check latest</button>
<button id="mr-options-base-models-update" type="button" class="mr-btn mr-btn-primary">${getSvgIcon('download')} Update Base Models</button>
<a class="mr-options-inline-link" href="https://civitai.com/api/v1/enums" target="_blank" rel="noopener noreferrer">Source</a>
</div>
</div>
</div>
</div>
</section>
<section id="mr-options-section-civitai" class="mr-options-card mr-options-section">
<div class="mr-options-section-head">
<h4 class="mr-options-section-title">CivitAI</h4>
</div>
<div class="mr-options-grid">
<div class="mr-options-panel">
<div class="mr-options-toggle-list">
<label class="mr-options-toggle-row">
<div class="mr-options-toggle-copy">
<span class="mr-options-toggle-title">Use CivitAI tRPC search <span class="mr-tooltip-badge" data-tooltip="Main CivitAI search method. Keep this enabled unless CivitAI search stops working.">?</span></span>
</div>
<span class="mr-options-toggle-control">
<input id="mr-options-civitai-use-trpc-search" class="mr-options-switch-input" type="checkbox" ${tokens.civitai_use_trpc_search ? 'checked' : ''}>
<span class="mr-options-switch"></span>
</span>
</label>
<div class="mr-options-dependent-block">
<div class="mr-options-field">
<div class="mr-options-input-row">
<label for="mr-options-civitai" class="mr-options-label">CivitAI API Key <a href="https://civitai.com/user/account" target="_blank" rel="noopener noreferrer" class="mr-options-inline-link">Get key</a> <span class="mr-tooltip-badge" data-tooltip="Optional. Used when downloading from CivitAI requires your account. Add this if CivitAI downloads fail or need access to models available to your account.">?</span></label>
<input id="mr-options-civitai" class="mr-options-input" type="password" placeholder="Paste CivitAI API key" value="${tokens.civitai_key}">
<button id="mr-options-civitai-toggle" type="button" class="mr-options-visibility-btn" aria-label="Toggle visibility for saved CivitAI API key" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
<div class="mr-options-token-check">
<button id="mr-options-civitai-key-check" type="button" class="mr-btn mr-btn-secondary mr-btn-sm">${getSvgIcon('refreshCw')} Check API key</button>
<span id="mr-options-civitai-key-check-status" class="mr-options-token-check-status">Not checked</span>
</div>
</div>
<div class="mr-options-field">
<div class="mr-options-input-row">
<label for="mr-options-civitai-session" class="mr-options-label">CivitAI Session Token <span class="mr-tooltip-badge" data-tooltip="Optional. Makes CivitAI search use your logged-in session, so results can match what you see in the browser. Useful for NSFW or account-visible results.">?</span></label>
<input id="mr-options-civitai-session" class="mr-options-input" type="password" placeholder="Paste __Secure-civitai-token" value="${tokens.civitai_session_token}">
<button id="mr-options-civitai-session-toggle" type="button" class="mr-options-visibility-btn" aria-label="Toggle visibility for saved CivitAI session token" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
<details class="mr-options-help">
<summary>How to find the session token</summary>
<ol>
<li>Sign in on civitai.com in your browser.</li>
<li>Open DevTools, then Application or Storage.</li>
<li>Open Cookies for civitai.com and copy <code>__Secure-civitai-token</code>.</li>
</ol>
<p>Treat it like a password. It is only needed when session-aware CivitAI search is required.</p>
</details>
<div class="mr-options-token-check">
<button id="mr-options-civitai-session-check" type="button" class="mr-btn mr-btn-secondary mr-btn-sm">${getSvgIcon('refreshCw')} Check session</button>
<span id="mr-options-civitai-session-check-status" class="mr-options-token-check-status">Not checked</span>
</div>
</div>
</div>
<label class="mr-options-toggle-row">
<div class="mr-options-toggle-copy">
<span class="mr-options-toggle-title">Use CivitAI HTML fallback <span class="mr-tooltip-badge" data-tooltip="Backup CivitAI search. Leave this enabled to try the regular CivitAI page when the main search does not find enough results.">?</span></span>
</div>
<span class="mr-options-toggle-control">
<input id="mr-options-civitai-use-html-fallback" class="mr-options-switch-input" type="checkbox" ${tokens.civitai_use_html_fallback ? 'checked' : ''}>
<span class="mr-options-switch"></span>
</span>
</label>
<div class="mr-options-dependent-block">
<div class="mr-options-number-row">
<div class="mr-options-number-copy">
<span class="mr-options-label">CivitAI Models To Inspect <span class="mr-tooltip-badge" data-tooltip="How many CivitAI results to check for the exact file. Higher values may find more matches, but searches can take longer. Range: 1-20.">?</span></span>
</div>
<input id="mr-options-civitai-limit" class="mr-options-input" type="number" min="1" max="20" step="1" value="${tokens.civitai_candidate_limit}">
</div>
</div>
</div>
</div>
</div>
</section>
<section id="mr-options-section-hf" class="mr-options-card mr-options-section">
<div class="mr-options-section-head">
<h4 class="mr-options-section-title">HuggingFace</h4>
</div>
<div class="mr-options-grid">
<div class="mr-options-panel">
<div class="mr-options-toggle-list">
<label class="mr-options-toggle-row">
<div class="mr-options-toggle-copy">
<span class="mr-options-toggle-title">Use HuggingFace API repo search <span class="mr-tooltip-badge" data-tooltip="Main Hugging Face search method. It searches by filename, then checks matching repos for the actual file.">?</span></span>
</div>
<span class="mr-options-toggle-control">
<input id="mr-options-hf-use-api-search" class="mr-options-switch-input" type="checkbox" ${tokens.hf_use_api_search ? 'checked' : ''}>
<span class="mr-options-switch"></span>
</span>
</label>
<div class="mr-options-dependent-block">
<div class="mr-options-field">
<div class="mr-options-input-row">
<label for="mr-options-hf" class="mr-options-label">HuggingFace Token <a href="https://huggingface.co/settings/tokens" target="_blank" rel="noopener noreferrer" class="mr-options-inline-link">Get key</a> <span class="mr-tooltip-badge" data-tooltip="Optional. Used to search and download files from Hugging Face repos your account can access, including gated repos. A read-only token is enough.">?</span></label>
<input id="mr-options-hf" class="mr-options-input" type="password" placeholder="Paste HuggingFace token" value="${tokens.hf_token}">
<button id="mr-options-hf-toggle" type="button" class="mr-options-visibility-btn" aria-label="Toggle visibility for saved HuggingFace token" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
<div class="mr-options-token-check">
<button id="mr-options-hf-token-check" type="button" class="mr-btn mr-btn-secondary mr-btn-sm">${getSvgIcon('refreshCw')} Check token</button>
<span id="mr-options-hf-token-check-status" class="mr-options-token-check-status">Not checked</span>
</div>
</div>
</div>
<label class="mr-options-toggle-row">
<div class="mr-options-toggle-copy">
<span class="mr-options-toggle-title">Use Comfy-Org fallback <span class="mr-tooltip-badge" data-tooltip="Checks Comfy-Org repositories directly. Useful for ComfyUI model packs that normal Hugging Face search may miss.">?</span></span>
</div>
<span class="mr-options-toggle-control">
<input id="mr-options-hf-use-comfy-org-fallback" class="mr-options-switch-input" type="checkbox" ${tokens.hf_use_comfy_org_fallback ? 'checked' : ''}>
<span class="mr-options-switch"></span>
</span>
</label>
<div class="mr-options-dependent-block">
<div class="mr-options-db-summary">
<div class="mr-options-db-row">
<span>Comfy-Org files</span>
<strong id="mr-options-hf-index-count">Loading...</strong>
</div>
<div class="mr-options-db-row">
<span>Last refresh</span>
<strong id="mr-options-hf-index-updated">Loading...</strong>
</div>
<div class="mr-options-db-row">
<span>Status</span>
<strong id="mr-options-hf-index-state">Checking local index...</strong>
</div>
</div>
<div id="mr-options-hf-index-message" class="mr-options-db-message">Comfy-Org fallback uses a cached HuggingFace file index.</div>
<div class="mr-options-db-actions">
<button id="mr-options-hf-index-refresh" type="button" class="mr-btn mr-btn-secondary">${getSvgIcon('refreshCw')} Refresh Comfy-Org Index</button>
</div>
</div>
<label class="mr-options-toggle-row">
<div class="mr-options-toggle-copy">
<span class="mr-options-toggle-title">Use Brave fallback <span class="mr-tooltip-badge" data-tooltip="Last-resort web search for the exact filename on huggingface.co. Results are still checked before Model Resolver offers them.">?</span></span>
</div>
<span class="mr-options-toggle-control">
<input id="mr-options-hf-use-brave-fallback" class="mr-options-switch-input" type="checkbox" ${tokens.hf_use_brave_fallback ? 'checked' : ''}>
<span class="mr-options-switch"></span>
</span>
</label>
<div class="mr-options-dependent-block">
<div class="mr-options-field">
<div class="mr-options-input-row">
<label for="mr-options-brave" class="mr-options-label">Brave Search API Key <a href="https://api-dashboard.search.brave.com/app/keys" target="_blank" rel="noopener noreferrer" class="mr-options-inline-link">Get key</a> <span class="mr-tooltip-badge" data-tooltip="Optional. Used only by the Brave fallback. It helps find Hugging Face files when Hugging Face search does not show the right repo.">?</span></label>
<input id="mr-options-brave" class="mr-options-input" type="password" placeholder="Paste Brave Search API key" value="${tokens.brave_search_api_key}">
<button id="mr-options-brave-toggle" type="button" class="mr-options-visibility-btn" aria-label="Toggle visibility for saved Brave Search API key" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
<div class="mr-options-token-check">
<button id="mr-options-brave-key-check" type="button" class="mr-btn mr-btn-secondary mr-btn-sm">${getSvgIcon('refreshCw')} Check API key</button>
<span id="mr-options-brave-key-check-status" class="mr-options-token-check-status">Not checked</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="mr-options-section-maintenance" class="mr-options-card mr-options-section is-hidden">
<div class="mr-options-section-head">
<h4 class="mr-options-section-title">Maintenance</h4>
</div>
<div class="mr-options-grid">
<div class="mr-options-panel">
<div class="mr-options-toggle-list">
<label class="mr-options-toggle-row">
<div class="mr-options-toggle-copy">
<span class="mr-options-toggle-title">Frontend logs <span class="mr-tooltip-badge" data-tooltip="When enabled, Model Resolver writes frontend diagnostic logs to the browser console.">?</span></span>
</div>
<span class="mr-options-toggle-control">
<input id="mr-options-frontend-logs-enabled" class="mr-options-switch-input" type="checkbox" ${tokens.frontend_logs_enabled ? 'checked' : ''}>
<span class="mr-options-switch"></span>
</span>
</label>
<div class="mr-options-dependent-block">
<div class="mr-options-number-row">
<div class="mr-options-number-copy">
<span class="mr-options-label">Frontend log level <span class="mr-tooltip-badge" data-tooltip="Minimum frontend log level shown in the browser console. NONE hides all frontend logger output.">?</span></span>
</div>
<select id="mr-options-frontend-log-level" class="mr-options-input">
${renderLogLevelOptions(tokens.frontend_log_level)}
</select>
</div>
</div>
<label class="mr-options-toggle-row">
<div class="mr-options-toggle-copy">
<span class="mr-options-toggle-title">Backend logs <span class="mr-tooltip-badge" data-tooltip="When enabled, Model Resolver writes backend logs to the ComfyUI console and plugin log file.">?</span></span>
</div>
<span class="mr-options-toggle-control">
<input id="mr-options-backend-logs-enabled" class="mr-options-switch-input" type="checkbox" ${tokens.backend_logs_enabled ? 'checked' : ''}>
<span class="mr-options-switch"></span>
</span>
</label>
<div class="mr-options-dependent-block">
<div class="mr-options-number-row">
<div class="mr-options-number-copy">
<span class="mr-options-label">Backend log level <span class="mr-tooltip-badge" data-tooltip="Minimum backend log level written by Model Resolver. NONE hides all backend logger output.">?</span></span>
</div>
<select id="mr-options-backend-log-level" class="mr-options-input">
${renderLogLevelOptions(tokens.backend_log_level)}
</select>
</div>
</div>
</div>
</div>
<div class="mr-options-panel">
<div class="mr-options-stack">
<div class="mr-options-db-message">
Clears only Model Resolver's temporary memory: remembered workflow analysis, search results, provider lookups, source status, folder lists, and subfolder suggestions. It does not delete downloaded models, metadata files, saved options, or API keys.
</div>
<div class="mr-options-db-actions">
<button id="mr-options-clear-all-cache" type="button" class="mr-btn mr-btn-secondary">${getSvgIcon('refreshCw')} Clear Frontend and Backend Cache</button>
</div>
<div id="mr-options-clear-cache-status" class="mr-options-db-message">Use this when search results look stale or the resolver gets into an inconsistent state. Your model files stay untouched.</div>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
`;
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 = [
`<option value="">Select Base Model</option>`
];
const choices = getBaseModelMappingChoices();
if (currentValue && !choices.includes(currentValue)) {
options.push(`<option value="${this.escapeHtml(currentValue)}" selected>${this.escapeHtml(currentValue)}</option>`);
}
choices.forEach((name) => {
if (usedValues.has(name) && name !== currentValue) return;
options.push(`<option value="${this.escapeHtml(name)}" ${name === currentValue ? 'selected' : ''}>${this.escapeHtml(name)}</option>`);
});
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 = `
<select class="mr-options-input mr-options-mapping-base">
${renderBaseModelMappingOptions(baseModel, row)}
</select>
<input class="mr-options-input mr-options-mapping-path" type="text" placeholder="Custom path (e.g., SDXL/Pony)" value="${this.escapeHtml(pathValue)}">
<button type="button" class="mr-options-mapping-remove" aria-label="Remove mapping" data-tooltip="Remove mapping">&times;</button>
`;
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 = `<span>Preview:</span> <code>${this.escapeHtml(renderTemplatePreview(getTemplateInputValue(categoryKey)))}</code>`;
}
};
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 => `<option value="${this.escapeHtml(option.value)}" ${option.value === currentValue ? 'selected' : ''}>${this.escapeHtml(option.label)}</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();
});
}
}
};