feat(frontend): opt-in Other Models toggles, hidden nav and announcement

- The Other nav entry is hidden while the feature is off
  (nav-item--hidden, toggled client-side after enabling) and now uses the
  fa-shapes icon.
- Shared utils/otherModels.js helpers (enable through the settings API,
  open the settings Library section) are reused by the disabled page, the
  announcement banner and the download modal.
- BannerService registers a one-time dismissible "other-models-announcement"
  banner while the feature is off; SettingsManager drops the banner and
  updates the nav when the master switch flips.
- A disabled download routing answer now surfaces a showActionToast with an
  "Enable Other Models" action.
- Settings UI: master toggle + five sub_type checkboxes whose default-root
  selects are disabled when unchecked; i18n keys added to en.json and synced
  (other locales keep TODO placeholders).
This commit is contained in:
Will Miao
2026-09-13 07:59:28 +08:00
parent 28fbb86dce
commit 69a62d739c
25 changed files with 1006 additions and 19 deletions
+89
View File
@@ -6,9 +6,11 @@ import {
import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { enableOtherModels, openOtherModelsSettings } from '../utils/otherModels.js';
const COMMUNITY_SUPPORT_BANNER_ID = 'community-support';
const CACHE_HEALTH_BANNER_ID = 'cache-health-warning';
const OTHER_MODELS_BANNER_ID = 'other-models-announcement';
const COMMUNITY_SUPPORT_BANNER_DELAY_MS = 5 * 24 * 60 * 60 * 1000; // 5 days
const COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen_at';
const COMMUNITY_SUPPORT_VERSION_KEY = 'community_support_banner_state_version';
@@ -80,6 +82,7 @@ class BannerService {
});
this.prepareCommunitySupportBanner();
this.prepareOtherModelsBanner();
await this.showActiveBanners();
this.initialized = true;
@@ -541,6 +544,92 @@ class BannerService {
this.updateContainerVisibility();
}
/**
* Announce the opt-in Other Models management to users who have not turned
* it on yet. Dismissal is persisted through the shared dismissed_banners
* setting, so users who are not interested are not nagged again.
*/
prepareOtherModelsBanner() {
if (state.global.settings.enable_other_models) {
return;
}
if (this.isBannerDismissed(OTHER_MODELS_BANNER_ID)) {
return;
}
this.registerBanner(OTHER_MODELS_BANNER_ID, {
id: OTHER_MODELS_BANNER_ID,
title: translate(
'banners.otherModels.title',
{},
'Other Models Management is available'
),
content: translate(
'banners.otherModels.content',
{},
'Scan and manage VAE, upscaler, text encoder and CLIP vision files — and download them from CivitAI — from one dedicated page.'
),
actions: [
{
text: translate(
'banners.otherModels.enable',
{},
'Enable Other Models'
),
icon: 'fas fa-shapes',
type: 'primary',
action: 'enable-other-models'
},
{
text: translate(
'banners.otherModels.openSettings',
{},
'Open Settings'
),
icon: 'fas fa-cog',
type: 'secondary',
action: 'open-other-models-settings'
}
],
dismissible: true,
priority: 0,
onRegister: (bannerElement) => {
const enableButton = bannerElement.querySelector(
'.banner-action[data-action="enable-other-models"]'
);
if (enableButton) {
enableButton.addEventListener('click', (event) => {
event.preventDefault();
enableOtherModels().catch((error) => {
console.error('Failed to enable Other Models:', error);
});
});
}
const settingsButton = bannerElement.querySelector(
'.banner-action[data-action="open-other-models-settings"]'
);
if (settingsButton) {
settingsButton.addEventListener('click', (event) => {
event.preventDefault();
openOtherModelsSettings();
});
}
}
});
this.updateContainerVisibility();
}
/**
* Drop the Other Models announcement once the feature is enabled.
* Dismissal is deliberately NOT persisted, so the announcement can come
* back if the user switches the feature off again.
*/
removeOtherModelsAnnouncement() {
this.removeBannerElement(OTHER_MODELS_BANNER_ID);
}
initializeCommunitySupportState() {
const storedVersion = getStorageItem(COMMUNITY_SUPPORT_VERSION_KEY, null);
+12 -1
View File
@@ -1,5 +1,5 @@
import { modalManager } from './ModalManager.js';
import { showToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js';
import { showToast, showActionToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { LoadingManager } from './LoadingManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
@@ -12,6 +12,7 @@ import { MODEL_SUBTYPE_DISPLAY_NAMES } from '../utils/constants.js';
import { buildCivitaiUrl, extractCivitaiModelUrlParts, normalizeCivitaiPageHost } from '../utils/civitaiUtils.js';
import { formatFileSize } from '../utils/formatters.js';
import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
import { openOtherModelsSettings } from '../utils/otherModels.js';
export class DownloadManager {
constructor() {
@@ -1118,6 +1119,16 @@ export class DownloadManager {
throw new Error(`routing endpoint returned ${response.status}`);
}
const data = await response.json();
if (data.disabled) {
// The matching sub_type (or the whole Other Models feature) is
// switched off: auto-routing is refused, so offer the settings
// shortcut while the user's intent is clear.
showActionToast('other.disabled.downloadBlocked', {}, 'warning', {
actionText: translate('other.disabled.enableAction', {}, 'Enable Other Models'),
onAction: () => openOtherModelsSettings(),
});
return null;
}
return data.sub_type || null;
} catch (error) {
console.warn('[download] other routing endpoint unavailable, '
+87
View File
@@ -1155,6 +1155,7 @@ export class SettingsManager {
// Load default other-model roots (per sub_type)
await this.loadOtherRoots();
this.updateOtherModelsControls();
// Load extra folder paths
this.loadExtraFolderPaths();
@@ -2304,6 +2305,16 @@ export class SettingsManager {
await this.updateBackupStatus();
}
if (settingKey === 'enable_other_models') {
// Roots only exist while the feature is on, so re-fetch them
// after the backend rebuilt the other-model root set.
this.updateOtherModelsControls();
await this.loadOtherRoots();
this.updateOtherModelsControls();
this.updateOtherModelsNavVisibility(value);
this.removeOtherModelsAnnouncement(value);
}
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
// Apply frontend settings immediately
@@ -2408,6 +2419,82 @@ export class SettingsManager {
}
}
/**
* Reflect the opt-in Other Models state in the settings UI: the master
* toggle gates every sub_type checkbox, and a switched-off sub_type has
* its default-root select disabled. Never force-enables a select (the
* no-roots placeholder owns that state).
*/
updateOtherModelsControls() {
const enableOtherModels = !!state.global.settings.enable_other_models;
const enabledSubTypes = new Set(
state.global.settings.enabled_other_sub_types
|| ['vae', 'upscaler', 'text_encoder', 'clip_vision']
);
document.querySelectorAll('[data-other-subtype-toggle]').forEach((input) => {
input.checked = enabledSubTypes.has(input.value);
input.disabled = !enableOtherModels;
});
const container = document.getElementById('otherSubTypeToggles');
if (container) {
container.classList.toggle('is-disabled', !enableOtherModels);
}
document.querySelectorAll('select[data-other-root-subtype]').forEach((select) => {
const subType = select.dataset.otherRootSubtype;
if (!enableOtherModels || !enabledSubTypes.has(subType)) {
select.disabled = true;
}
});
}
/**
* Persist the whole enabled_other_sub_types list (the backend stores an
* allow-list) and refresh the per-sub_type default-root selects.
*/
async saveEnabledOtherSubTypes() {
const values = Array.from(
document.querySelectorAll('[data-other-subtype-toggle]')
)
.filter((input) => input.checked)
.map((input) => input.value);
try {
await this.saveSetting('enabled_other_sub_types', values);
this.updateOtherModelsControls();
await this.loadOtherRoots();
this.updateOtherModelsControls();
showToast('toast.settings.settingsUpdated', { setting: 'other model types' }, 'success');
} catch (error) {
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
/**
* Show or hide the Other Models nav entry. The nav is server-rendered, so
* toggling the class here keeps it in sync when the switch is flipped from
* the settings modal (no reload needed).
*/
updateOtherModelsNavVisibility(enabled) {
const navItem = document.getElementById('otherNavItem');
if (navItem) {
navItem.classList.toggle('nav-item--hidden', !enabled);
}
}
/**
* Drop the Other Models announcement banner once the feature is on.
*/
removeOtherModelsAnnouncement(enabled) {
if (!enabled) {
return;
}
bannerService.removeOtherModelsAnnouncement();
}
/**
* Save the recipes page layout (grid | masonry) and rebuild the scroller.
* Shared entry point for the settings modal segmented control and the
+36
View File
@@ -0,0 +1,36 @@
import { appCore } from './core.js';
import { showToast } from './utils/uiHelpers.js';
import { enableOtherModels } from './utils/otherModels.js';
/**
* Other Models is an opt-in feature. While it is disabled this page renders an
* empty state whose button turns the feature on; the backend then rebuilds the
* other-model roots and starts scanning, so a reload lands on the real page.
*/
async function handleEnableClick() {
const button = document.getElementById('enableOtherModelsBtn');
if (!button || button.disabled) return;
button.disabled = true;
try {
await enableOtherModels();
} catch (error) {
button.disabled = false;
showToast('other.disabled.enableFailed', { message: error.message }, 'error');
}
}
async function initializeOtherDisabledPage() {
// appCore.initialize() wires the shared header (theme, settings modal,
// language) so this page is not a dead end.
await appCore.initialize();
const button = document.getElementById('enableOtherModelsBtn');
if (button) {
button.addEventListener('click', handleEnableClick);
}
}
document.addEventListener('DOMContentLoaded', initializeOtherDisabledPage);
export { handleEnableClick as enableOtherModels, initializeOtherDisabledPage };
+3
View File
@@ -25,6 +25,8 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
default_checkpoint_root: '',
default_embedding_root: '',
default_other_roots: {},
enable_other_models: false,
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder', 'clip_vision'],
recipes_path: '',
base_model_path_mappings: {},
download_path_templates: {},
@@ -74,6 +76,7 @@ export function createDefaultSettings() {
download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
default_other_roots: {},
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder', 'clip_vision'],
};
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Shared helpers for the opt-in Other Models feature.
*
* Used by the disabled page, the announcement banner and the download modal so
* that enabling the feature always goes through the same settings API call and
* lands on the same settings section.
*/
/**
* Turn on Other Models management and reload so the server-rendered nav and
* the scanner state pick up the change.
*/
export async function enableOtherModels() {
const response = await fetch('/api/lm/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enable_other_models: true }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
throw new Error(data.error || `HTTP ${response.status}`);
}
window.location.reload();
}
/**
* Open the settings modal on the Library section and scroll the Other Models
* toggle into view. Mirrors DoctorManager's open-settings-syntax-format flow.
*/
export function openOtherModelsSettings() {
const modalManager = window.modalManager;
if (modalManager && typeof modalManager.showModal === 'function') {
modalManager.showModal('settingsModal');
}
window.setTimeout(() => {
document.querySelectorAll('.settings-section').forEach((section) => {
section.classList.remove('active');
});
document.getElementById('section-library')?.classList.add('active');
document.querySelectorAll('.settings-nav-item').forEach((item) => {
item.classList.remove('active');
});
document.querySelector('.settings-nav-item[data-section="library"]')?.classList.add('active');
document.getElementById('enableOtherModels')?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}, 100);
}