feat(downloads): allow manual checkpoint/diffusion root override in download modal

This commit is contained in:
Will Miao
2026-10-02 21:40:06 +08:00
parent 034660d8c4
commit a5bd3845da
14 changed files with 437 additions and 43 deletions
+4
View File
@@ -1563,6 +1563,10 @@
"selectRootDirectory": "Wählen Sie ein Stammverzeichnis", "selectRootDirectory": "Wählen Sie ein Stammverzeichnis",
"selectModelRoot": "Modell-Stammverzeichnis auswählen:", "selectModelRoot": "Modell-Stammverzeichnis auswählen:",
"selectTypeRoot": "{type}-Stammverzeichnis auswählen:", "selectTypeRoot": "{type}-Stammverzeichnis auswählen:",
"routingOverride": {
"label": "[TODO: Translate] Destination type:",
"tooltip": "[TODO: Translate] Auto-detected from the model metadata. Switch if it was misdetected; switching turns off \"Use Default Path\" for this download."
},
"targetFolderPath": "Zielordnerpfad:", "targetFolderPath": "Zielordnerpfad:",
"browseFolders": "Ordner durchsuchen:", "browseFolders": "Ordner durchsuchen:",
"createNewFolder": "Neuen Ordner erstellen", "createNewFolder": "Neuen Ordner erstellen",
+4
View File
@@ -1563,6 +1563,10 @@
"selectRootDirectory": "Select a root directory", "selectRootDirectory": "Select a root directory",
"selectModelRoot": "Select Model Root:", "selectModelRoot": "Select Model Root:",
"selectTypeRoot": "Select {type} Root:", "selectTypeRoot": "Select {type} Root:",
"routingOverride": {
"label": "Destination type:",
"tooltip": "Auto-detected from the model metadata. Switch if it was misdetected; switching turns off \"Use Default Path\" for this download."
},
"targetFolderPath": "Target Folder Path:", "targetFolderPath": "Target Folder Path:",
"browseFolders": "Browse Folders:", "browseFolders": "Browse Folders:",
"createNewFolder": "Create new folder", "createNewFolder": "Create new folder",
+4
View File
@@ -1563,6 +1563,10 @@
"selectRootDirectory": "Selecciona un directorio raíz", "selectRootDirectory": "Selecciona un directorio raíz",
"selectModelRoot": "Seleccionar raíz del modelo:", "selectModelRoot": "Seleccionar raíz del modelo:",
"selectTypeRoot": "Seleccionar raíz de {type}:", "selectTypeRoot": "Seleccionar raíz de {type}:",
"routingOverride": {
"label": "[TODO: Translate] Destination type:",
"tooltip": "[TODO: Translate] Auto-detected from the model metadata. Switch if it was misdetected; switching turns off \"Use Default Path\" for this download."
},
"targetFolderPath": "Ruta de carpeta de destino:", "targetFolderPath": "Ruta de carpeta de destino:",
"browseFolders": "Explorar carpetas:", "browseFolders": "Explorar carpetas:",
"createNewFolder": "Crear nueva carpeta", "createNewFolder": "Crear nueva carpeta",
+4
View File
@@ -1563,6 +1563,10 @@
"selectRootDirectory": "Sélectionner un répertoire racine", "selectRootDirectory": "Sélectionner un répertoire racine",
"selectModelRoot": "Sélectionner la racine du modèle :", "selectModelRoot": "Sélectionner la racine du modèle :",
"selectTypeRoot": "Sélectionner la racine {type} :", "selectTypeRoot": "Sélectionner la racine {type} :",
"routingOverride": {
"label": "[TODO: Translate] Destination type:",
"tooltip": "[TODO: Translate] Auto-detected from the model metadata. Switch if it was misdetected; switching turns off \"Use Default Path\" for this download."
},
"targetFolderPath": "Chemin du dossier cible :", "targetFolderPath": "Chemin du dossier cible :",
"browseFolders": "Parcourir les dossiers :", "browseFolders": "Parcourir les dossiers :",
"createNewFolder": "Créer un nouveau dossier", "createNewFolder": "Créer un nouveau dossier",
+4
View File
@@ -1563,6 +1563,10 @@
"selectRootDirectory": "בחר ספריית שורש", "selectRootDirectory": "בחר ספריית שורש",
"selectModelRoot": "בחר שורש מודל:", "selectModelRoot": "בחר שורש מודל:",
"selectTypeRoot": "בחר שורש {type}:", "selectTypeRoot": "בחר שורש {type}:",
"routingOverride": {
"label": "[TODO: Translate] Destination type:",
"tooltip": "[TODO: Translate] Auto-detected from the model metadata. Switch if it was misdetected; switching turns off \"Use Default Path\" for this download."
},
"targetFolderPath": "נתיב תיקיית יעד:", "targetFolderPath": "נתיב תיקיית יעד:",
"browseFolders": "דפדף בתיקיות:", "browseFolders": "דפדף בתיקיות:",
"createNewFolder": "צור תיקייה חדשה", "createNewFolder": "צור תיקייה חדשה",
+4
View File
@@ -1563,6 +1563,10 @@
"selectRootDirectory": "ルートディレクトリを選択", "selectRootDirectory": "ルートディレクトリを選択",
"selectModelRoot": "モデルルートを選択:", "selectModelRoot": "モデルルートを選択:",
"selectTypeRoot": "{type}ルートを選択:", "selectTypeRoot": "{type}ルートを選択:",
"routingOverride": {
"label": "[TODO: Translate] Destination type:",
"tooltip": "[TODO: Translate] Auto-detected from the model metadata. Switch if it was misdetected; switching turns off \"Use Default Path\" for this download."
},
"targetFolderPath": "ターゲットフォルダパス:", "targetFolderPath": "ターゲットフォルダパス:",
"browseFolders": "フォルダを参照:", "browseFolders": "フォルダを参照:",
"createNewFolder": "新しいフォルダを作成", "createNewFolder": "新しいフォルダを作成",
+4
View File
@@ -1563,6 +1563,10 @@
"selectRootDirectory": "루트 디렉토리를 선택하세요", "selectRootDirectory": "루트 디렉토리를 선택하세요",
"selectModelRoot": "모델 루트 선택:", "selectModelRoot": "모델 루트 선택:",
"selectTypeRoot": "{type} 루트 선택:", "selectTypeRoot": "{type} 루트 선택:",
"routingOverride": {
"label": "[TODO: Translate] Destination type:",
"tooltip": "[TODO: Translate] Auto-detected from the model metadata. Switch if it was misdetected; switching turns off \"Use Default Path\" for this download."
},
"targetFolderPath": "대상 폴더 경로:", "targetFolderPath": "대상 폴더 경로:",
"browseFolders": "폴더 탐색:", "browseFolders": "폴더 탐색:",
"createNewFolder": "새 폴더 만들기", "createNewFolder": "새 폴더 만들기",
+4
View File
@@ -1563,6 +1563,10 @@
"selectRootDirectory": "Выберите корневую папку", "selectRootDirectory": "Выберите корневую папку",
"selectModelRoot": "Выберите корень моделей:", "selectModelRoot": "Выберите корень моделей:",
"selectTypeRoot": "Выберите корень {type}:", "selectTypeRoot": "Выберите корень {type}:",
"routingOverride": {
"label": "[TODO: Translate] Destination type:",
"tooltip": "[TODO: Translate] Auto-detected from the model metadata. Switch if it was misdetected; switching turns off \"Use Default Path\" for this download."
},
"targetFolderPath": "Путь к целевой папке:", "targetFolderPath": "Путь к целевой папке:",
"browseFolders": "Обзор папок:", "browseFolders": "Обзор папок:",
"createNewFolder": "Создать новую папку", "createNewFolder": "Создать новую папку",
+4
View File
@@ -1563,6 +1563,10 @@
"selectRootDirectory": "选择根目录", "selectRootDirectory": "选择根目录",
"selectModelRoot": "选择模型根目录:", "selectModelRoot": "选择模型根目录:",
"selectTypeRoot": "选择 {type} 根目录:", "selectTypeRoot": "选择 {type} 根目录:",
"routingOverride": {
"label": "[TODO: Translate] Destination type:",
"tooltip": "[TODO: Translate] Auto-detected from the model metadata. Switch if it was misdetected; switching turns off \"Use Default Path\" for this download."
},
"targetFolderPath": "目标文件夹路径:", "targetFolderPath": "目标文件夹路径:",
"browseFolders": "浏览文件夹:", "browseFolders": "浏览文件夹:",
"createNewFolder": "新建文件夹", "createNewFolder": "新建文件夹",
+4
View File
@@ -1563,6 +1563,10 @@
"selectRootDirectory": "選擇根目錄", "selectRootDirectory": "選擇根目錄",
"selectModelRoot": "選擇模型根目錄:", "selectModelRoot": "選擇模型根目錄:",
"selectTypeRoot": "選擇 {type} 根目錄:", "selectTypeRoot": "選擇 {type} 根目錄:",
"routingOverride": {
"label": "[TODO: Translate] Destination type:",
"tooltip": "[TODO: Translate] Auto-detected from the model metadata. Switch if it was misdetected; switching turns off \"Use Default Path\" for this download."
},
"targetFolderPath": "目標資料夾路徑:", "targetFolderPath": "目標資料夾路徑:",
"browseFolders": "瀏覽資料夾:", "browseFolders": "瀏覽資料夾:",
"createNewFolder": "建立新資料夾", "createNewFolder": "建立新資料夾",
@@ -1088,3 +1088,56 @@
[data-theme="dark"] .batch-preview-group-body { [data-theme="dark"] .batch-preview-group-body {
background: var(--border-color); background: var(--border-color);
} }
/* Routing override segmented toggle (Checkpoint / Diffusion Model) shown on
the checkpoints page of the download modal when auto-routing can misdetect. */
.routing-override-group {
margin-bottom: var(--space-2);
}
.routing-override-group label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
}
.routing-override-toggle {
display: flex;
gap: 0;
}
.routing-override-option {
flex: 1;
padding: 6px 8px;
border: 1px solid var(--border-color);
background: var(--bg-color);
color: var(--text-color);
opacity: 0.75;
cursor: pointer;
transition: var(--transition-base);
font-size: 0.9em;
}
.routing-override-option:first-child {
border-radius: var(--border-radius-xs) 0 0 var(--border-radius-xs);
border-right: none;
}
.routing-override-option:last-child {
border-radius: 0 var(--border-radius-xs) var(--border-radius-xs) 0;
}
.routing-override-option:not(.active):hover,
.routing-override-option:not(.active):focus-visible {
border-color: var(--lora-accent);
opacity: 1;
}
.routing-override-option.active {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
opacity: 1;
cursor: default;
}
+111 -27
View File
@@ -34,6 +34,9 @@ export class DownloadManager {
this.selectedFolder = ''; this.selectedFolder = '';
this.apiClient = null; this.apiClient = null;
this.useDefaultPath = false; this.useDefaultPath = false;
// Manual checkpoint/diffusion root override on the location step:
// null follows the auto classification, true/false is user-picked.
this._routingOverride = null;
// Multi-file selection state: selectedFile stays the first selected // Multi-file selection state: selectedFile stays the first selected
// file for backward compatibility with single-file flows (#1058). // file for backward compatibility with single-file flows (#1058).
@@ -127,6 +130,11 @@ export class DownloadManager {
// Default path toggle handler // Default path toggle handler
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath); document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
// Checkpoint/diffusion routing override toggle (checkpoints page only)
document.querySelectorAll('#routingOverrideGroup .routing-override-option').forEach(btn => {
btn.addEventListener('click', () => this.handleRoutingOverrideChange(btn.dataset.routing === 'diffusion'));
});
// Auto-append newline after pasting a URL so users can paste multiple URLs in succession // Auto-append newline after pasting a URL so users can paste multiple URLs in succession
setupAutoNewlineOnPaste('modelUrl'); setupAutoNewlineOnPaste('modelUrl');
} }
@@ -180,6 +188,7 @@ export class DownloadManager {
this.selectedFiles = []; this.selectedFiles = [];
this._lastDownloadError = null; this._lastDownloadError = null;
this._isDiffusionModel = false; this._isDiffusionModel = false;
this._routingOverride = null;
this.selectedFolder = ''; this.selectedFolder = '';
this.batchModels = []; this.batchModels = [];
@@ -974,9 +983,64 @@ export class DownloadManager {
try { try {
this._isDiffusionModel = await this._resolveIsDiffusionModel(); this._isDiffusionModel = await this._resolveIsDiffusionModel();
this._otherSubType = await this._resolveOtherSubType(); this._otherSubType = await this._resolveOtherSubType();
// Each location-step entry starts from the auto classification;
// the user can override it via the checkpoint/diffusion toggle.
this._routingOverride = null;
this._syncRoutingOverrideToggle();
await this._populateModelRoots();
// Set autocomplete="off" on folderPath input
const folderPathInput = document.getElementById('folderPath');
if (folderPathInput) {
folderPathInput.setAttribute('autocomplete', 'off');
}
// Initialize folder tree
await this.initializeFolderTree();
// Setup folder tree manager
this.folderTreeManager.init({
onPathChange: (path) => {
this.selectedFolder = path;
this.updateTargetPath();
}
});
// Setup model root change handler
const modelRoot = document.getElementById('modelRoot');
modelRoot.addEventListener('change', async () => {
await this.initializeFolderTree();
this.updateTargetPath();
});
// Load default path setting for current model type
this.loadDefaultPathSetting();
this.updateTargetPath();
} catch (error) {
showToast('toast.downloads.loadError', { message: error.message }, 'error');
}
}
/**
* Effective checkpoint/diffusion routing for the location step: the
* user's manual override when set, otherwise the auto classification.
*/
_getEffectiveIsDiffusionModel() {
return this._routingOverride ?? this._isDiffusionModel;
}
/**
* Fetch the root list for the effective routing group and repopulate
* the root dropdown, preselecting the matching default root and
* updating the root label.
*/
async _populateModelRoots() {
const isDiffusion = this._getEffectiveIsDiffusionModel();
let rootsData; let rootsData;
if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') { if (isDiffusion && this.apiClient.modelType === 'checkpoints') {
rootsData = await this.apiClient.fetchModelRoots('diffusion_model'); rootsData = await this.apiClient.fetchModelRoots('diffusion_model');
} else if (this.apiClient.modelType === 'other' && this._otherSubType) { } else if (this.apiClient.modelType === 'other' && this._otherSubType) {
rootsData = await this.apiClient.fetchModelRoots(this._otherSubType); rootsData = await this.apiClient.fetchModelRoots(this._otherSubType);
@@ -1000,12 +1064,12 @@ export class DownloadManager {
? (MODEL_SUBTYPE_DISPLAY_NAMES[this._otherSubType] || this._otherSubType) ? (MODEL_SUBTYPE_DISPLAY_NAMES[this._otherSubType] || this._otherSubType)
: this.apiClient.apiConfig.config.displayName; : this.apiClient.apiConfig.config.displayName;
} else { } else {
const singularType = this._isDiffusionModel const singularType = isDiffusion
? 'unet' ? 'unet'
: this.apiClient.modelType.replace(/s$/, ''); : this.apiClient.modelType.replace(/s$/, '');
const defaultRootKey = `default_${singularType}_root`; const defaultRootKey = `default_${singularType}_root`;
defaultRoot = state.global.settings[defaultRootKey]; defaultRoot = state.global.settings[defaultRootKey];
subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName; subtypeDisplay = isDiffusion ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName;
} }
console.log('Default root:', defaultRoot); console.log('Default root:', defaultRoot);
console.log('Available roots:', rootsData.roots); console.log('Available roots:', rootsData.roots);
@@ -1016,34 +1080,54 @@ export class DownloadManager {
document.getElementById('modelRootLabel').textContent = document.getElementById('modelRootLabel').textContent =
translate('modals.download.selectTypeRoot', { type: subtypeDisplay }); translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
// Set autocomplete="off" on folderPath input
const folderPathInput = document.getElementById('folderPath');
if (folderPathInput) {
folderPathInput.setAttribute('autocomplete', 'off');
} }
// Initialize folder tree /**
await this.initializeFolderTree(); * Show the checkpoint/diffusion override toggle on the checkpoints page
* (hidden elsewhere) and reflect the effective routing on its options.
*/
_syncRoutingOverrideToggle() {
const group = document.getElementById('routingOverrideGroup');
if (!group) return;
const isCheckpoints = this.apiClient.modelType === 'checkpoints';
group.style.display = isCheckpoints ? '' : 'none';
if (!isCheckpoints) return;
// Setup folder tree manager const effective = this._getEffectiveIsDiffusionModel();
this.folderTreeManager.init({ group.querySelectorAll('.routing-override-option').forEach(btn => {
onPathChange: (path) => { const active = (btn.dataset.routing === 'diffusion') === effective;
this.selectedFolder = path; btn.classList.toggle('active', active);
this.updateTargetPath(); btn.setAttribute('aria-pressed', active ? 'true' : 'false');
});
} }
});
// Setup model root change handler /**
modelRoot.addEventListener('change', async () => { * Handle the user switching the checkpoint/diffusion routing toggle.
* The override must never be silently ignored: the backend re-derives
* the routing group only under "use default path", so overriding turns
* that off for this session (the localStorage preference is left
* untouched) to guarantee the picked root is the actual destination.
*/
async handleRoutingOverrideChange(isDiffusion) {
if (this.apiClient.modelType !== 'checkpoints') return;
const overridden = isDiffusion !== this._isDiffusionModel;
this._routingOverride = overridden ? isDiffusion : null;
if (overridden && this.useDefaultPath) {
this.useDefaultPath = false;
const toggleInput = document.getElementById('useDefaultPath');
if (toggleInput) {
toggleInput.checked = false;
}
}
this._syncRoutingOverrideToggle();
try {
await this._populateModelRoots();
await this.initializeFolderTree(); await this.initializeFolderTree();
this.updateTargetPath(); this.updatePathSelectionUI();
});
// Load default path setting for current model type
this.loadDefaultPathSetting();
this.updateTargetPath();
} catch (error) { } catch (error) {
showToast('toast.downloads.loadError', { message: error.message }, 'error'); showToast('toast.downloads.loadError', { message: error.message }, 'error');
} }
@@ -2554,13 +2638,13 @@ export class DownloadManager {
const modelRoot = document.getElementById('modelRoot').value; const modelRoot = document.getElementById('modelRoot').value;
const config = this.apiClient.apiConfig.config; const config = this.apiClient.apiConfig.config;
const subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : config.displayName; const subtypeDisplay = this._getEffectiveIsDiffusionModel() ? 'Diffusion Model' : config.displayName;
let fullPath = modelRoot || translate('modals.download.selectTypeRoot', { type: subtypeDisplay }); let fullPath = modelRoot || translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
if (modelRoot) { if (modelRoot) {
if (this.useDefaultPath) { if (this.useDefaultPath) {
try { try {
const singularType = this._isDiffusionModel const singularType = this._getEffectiveIsDiffusionModel()
? 'unet' ? 'unet'
: this.apiClient.modelType.replace(/s$/, ''); : this.apiClient.modelType.replace(/s$/, '');
const templates = state.global?.settings?.download_path_templates; const templates = state.global?.settings?.download_path_templates;
@@ -80,6 +80,14 @@
</div> </div>
<!-- Model Root Selection (always visible) --> <!-- Model Root Selection (always visible) -->
<div class="input-group routing-override-group" id="routingOverrideGroup" style="display: none;"
title="{{ t('modals.download.routingOverride.tooltip') }}">
<label id="routingOverrideLabel">{{ t('modals.download.routingOverride.label') }}</label>
<div class="routing-override-toggle" role="group" aria-label="{{ t('modals.download.routingOverride.label') }}">
<button type="button" class="routing-override-option" data-routing="checkpoint">{{ t('checkpoints.modelTypes.checkpoint') }}</button>
<button type="button" class="routing-override-option" data-routing="diffusion">{{ t('checkpoints.modelTypes.diffusion_model') }}</button>
</div>
</div>
<div class="input-group"> <div class="input-group">
<label for="modelRoot" id="modelRootLabel">{{ t('modals.download.selectModelRoot') }}</label> <label for="modelRoot" id="modelRootLabel">{{ t('modals.download.selectModelRoot') }}</label>
<select id="modelRoot"></select> <select id="modelRoot"></select>
@@ -0,0 +1,209 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
DOWNLOAD_MANAGER_MODULE,
MODAL_MANAGER_MODULE,
UI_HELPERS_MODULE,
STATE_MODULE,
LOADING_MANAGER_MODULE,
API_FACTORY_MODULE,
STORAGE_HELPERS_MODULE,
FOLDER_TREE_MANAGER_MODULE,
I18N_HELPERS_MODULE,
SUMMARY_MODULE,
OTHER_MODELS_MODULE,
} = vi.hoisted(() => ({
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
OTHER_MODELS_MODULE: new URL('../../../static/js/utils/otherModels.js', import.meta.url).pathname,
}));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
showActionToast: vi.fn(),
setupAutoNewlineOnPaste: vi.fn(),
}));
vi.mock(STATE_MODULE, () => ({
state: { global: { settings: {} }, loadingManager: {} },
}));
vi.mock(LOADING_MANAGER_MODULE, () => ({
LoadingManager: vi.fn(() => ({})),
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(),
resetAndReload: vi.fn(),
}));
vi.mock(STORAGE_HELPERS_MODULE, () => ({
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
setStorageItem: vi.fn(),
}));
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
FolderTreeManager: vi.fn(() => ({})),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_key, _vars, fallback) => fallback ?? ''),
}));
vi.mock(SUMMARY_MODULE, () => ({
showDownloadBatchSummary: vi.fn(),
}));
vi.mock(OTHER_MODELS_MODULE, () => ({
enableOtherModels: vi.fn(),
openOtherModelsSettings: vi.fn(),
}));
const { DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE);
const { state } = await import(STATE_MODULE);
const { setStorageItem } = await import(STORAGE_HELPERS_MODULE);
describe('DownloadManager checkpoint/diffusion routing override', () => {
let manager;
function setupDom() {
document.body.innerHTML = `
<div id="routingOverrideGroup" style="display:none">
<button class="routing-override-option" data-routing="checkpoint"></button>
<button class="routing-override-option" data-routing="diffusion"></button>
</div>
<select id="modelRoot"></select>
<label id="modelRootLabel"></label>
<input id="folderPath" />
<input type="checkbox" id="useDefaultPath" />
<div id="manualPathSelection"></div>
<div id="targetPathDisplay"></div>
`;
}
function createManager(modelType, displayName = 'Model') {
manager = new DownloadManager();
manager.apiClient = {
modelType,
apiConfig: { config: { displayName } },
fetchModelRoots: vi.fn(),
};
manager.selectedFile = null;
manager.selectedFiles = [];
manager.currentVersion = null;
manager.initializeFolderTree = vi.fn().mockResolvedValue();
manager.folderTreeManager = { init: vi.fn(), getSelectedPath: vi.fn(() => '') };
manager.loadDefaultPathSetting = vi.fn();
vi.spyOn(manager, '_resolveOtherSubType').mockResolvedValue(null);
}
beforeEach(() => {
setupDom();
state.global.settings = {};
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('shows the toggle for diffusion-routed checkpoints and marks diffusion active', async () => {
createManager('checkpoints', 'Checkpoint');
vi.spyOn(manager, '_resolveIsDiffusionModel').mockResolvedValue(true);
manager.apiClient.fetchModelRoots.mockResolvedValue({
success: true,
roots: ['/models/unet'],
});
state.global.settings.default_unet_root = '/models/unet';
await manager.proceedToLocationContent();
const group = document.getElementById('routingOverrideGroup');
expect(group.style.display).not.toBe('none');
const [checkpointBtn, diffusionBtn] = group.querySelectorAll('.routing-override-option');
expect(diffusionBtn.classList.contains('active')).toBe(true);
expect(diffusionBtn.getAttribute('aria-pressed')).toBe('true');
expect(checkpointBtn.classList.contains('active')).toBe(false);
expect(manager.apiClient.fetchModelRoots).toHaveBeenCalledWith('diffusion_model');
expect(document.getElementById('modelRoot').value).toBe('/models/unet');
});
it('keeps the toggle hidden on non-checkpoints pages', async () => {
createManager('loras', 'LoRA');
manager.apiClient.fetchModelRoots.mockResolvedValue({
success: true,
roots: ['/models/loras'],
});
await manager.proceedToLocationContent();
expect(document.getElementById('routingOverrideGroup').style.display).toBe('none');
expect(manager.apiClient.fetchModelRoots).toHaveBeenCalledWith();
});
it('re-fetches diffusion roots and preselects the unet default when overriding to diffusion', async () => {
createManager('checkpoints', 'Checkpoint');
vi.spyOn(manager, '_resolveIsDiffusionModel').mockResolvedValue(false);
manager.apiClient.fetchModelRoots
.mockResolvedValueOnce({ success: true, roots: ['/models/checkpoints'] })
.mockResolvedValueOnce({ success: true, roots: ['/models/unet'] });
state.global.settings.default_unet_root = '/models/unet';
await manager.proceedToLocationContent();
expect(document.getElementById('modelRoot').value).toBe('/models/checkpoints');
await manager.handleRoutingOverrideChange(true);
expect(manager.apiClient.fetchModelRoots).toHaveBeenLastCalledWith('diffusion_model');
const modelRoot = document.getElementById('modelRoot');
expect(Array.from(modelRoot.options).map(o => o.value)).toEqual(['/models/unet']);
expect(modelRoot.value).toBe('/models/unet');
const group = document.getElementById('routingOverrideGroup');
const [checkpointBtn, diffusionBtn] = group.querySelectorAll('.routing-override-option');
expect(checkpointBtn.classList.contains('active')).toBe(false);
expect(diffusionBtn.classList.contains('active')).toBe(true);
});
it('turns off Use Default Path for the session without persisting when overriding', async () => {
createManager('checkpoints', 'Checkpoint');
vi.spyOn(manager, '_resolveIsDiffusionModel').mockResolvedValue(false);
manager.apiClient.fetchModelRoots.mockResolvedValue({
success: true,
roots: ['/models/checkpoints'],
});
await manager.proceedToLocationContent();
manager.useDefaultPath = true;
document.getElementById('useDefaultPath').checked = true;
await manager.handleRoutingOverrideChange(true);
expect(manager.useDefaultPath).toBe(false);
expect(document.getElementById('useDefaultPath').checked).toBe(false);
expect(setStorageItem).not.toHaveBeenCalled();
});
it('clears the override when switching back to the auto-classified side', async () => {
createManager('checkpoints', 'Checkpoint');
vi.spyOn(manager, '_resolveIsDiffusionModel').mockResolvedValue(false);
manager.apiClient.fetchModelRoots.mockResolvedValue({
success: true,
roots: ['/models/checkpoints'],
});
await manager.proceedToLocationContent();
await manager.handleRoutingOverrideChange(true);
expect(manager._routingOverride).toBe(true);
manager.apiClient.fetchModelRoots.mockClear();
await manager.handleRoutingOverrideChange(false);
expect(manager._routingOverride).toBeNull();
// Back on the checkpoint group: no routing argument
expect(manager.apiClient.fetchModelRoots).toHaveBeenCalledWith();
});
});