feat(frontend): enable downloads on Other page and default_other_roots settings UI

This commit is contained in:
Will Miao
2026-09-12 15:56:47 +08:00
parent f2a7297cb9
commit fc3b2d7c13
20 changed files with 682 additions and 9 deletions
+68 -7
View File
@@ -8,6 +8,7 @@ import { isModelWeightFile } from '../utils/modelFileTypes.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { translate } from '../utils/i18nHelpers.js';
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';
@@ -956,11 +957,17 @@ export class DownloadManager {
try {
this._isDiffusionModel = await this._resolveIsDiffusionModel();
this._otherSubType = await this._resolveOtherSubType();
let rootsData;
if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') {
rootsData = await this.apiClient.fetchModelRoots('diffusion_model');
} else if (this.apiClient.modelType === 'other' && this._otherSubType) {
rootsData = await this.apiClient.fetchModelRoots(this._otherSubType);
} else {
// An undecidable other sub_type (null) intentionally lands
// here: fetchModelRoots() lists all other roots so the user
// can pick manually.
rootsData = await this.apiClient.fetchModelRoots();
}
const modelRoot = document.getElementById('modelRoot');
@@ -968,19 +975,29 @@ export class DownloadManager {
`<option value="${root}">${root}</option>`
).join('');
const singularType = this._isDiffusionModel
? 'unet'
: this.apiClient.modelType.replace(/s$/, '');
const defaultRootKey = `default_${singularType}_root`;
const defaultRoot = state.global.settings[defaultRootKey];
console.log(`Default root for ${singularType}:`, defaultRoot);
let defaultRoot;
let subtypeDisplay;
if (this.apiClient.modelType === 'other') {
const otherDefaultRoots = state.global.settings.default_other_roots || {};
defaultRoot = this._otherSubType ? (otherDefaultRoots[this._otherSubType] || '') : '';
subtypeDisplay = this._otherSubType
? (MODEL_SUBTYPE_DISPLAY_NAMES[this._otherSubType] || this._otherSubType)
: this.apiClient.apiConfig.config.displayName;
} else {
const singularType = this._isDiffusionModel
? 'unet'
: this.apiClient.modelType.replace(/s$/, '');
const defaultRootKey = `default_${singularType}_root`;
defaultRoot = state.global.settings[defaultRootKey];
subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName;
}
console.log('Default root:', defaultRoot);
console.log('Available roots:', rootsData.roots);
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
console.log(`Setting default root: ${defaultRoot}`);
modelRoot.value = defaultRoot;
}
const subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName;
document.getElementById('modelRootLabel').textContent =
translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
@@ -1065,6 +1082,50 @@ export class DownloadManager {
return localFileTypeCheck;
}
/**
* Resolve which other-page sub_type (vae/upscaler/text_encoder/
* clip_vision/controlnet) this download routes to. The backend owns the
* routing rule (explicit file pick first, model.type next, file.type
* fallback), so the location step sends both the picked file's type
* (selected_file_type) and the version's full file-type list and lets
* the backend apply its priority chain. Returns null when the sub_type
* cannot be decided; the location step then lists all other roots for
* manual selection instead of guessing a folder.
*/
async _resolveOtherSubType() {
// Only other-page downloads route by sub_type; without version
// metadata (e.g. Hugging Face downloads) there is nothing to route on.
if (this.apiClient.modelType !== 'other'
|| (!this.selectedFile && !this.currentVersion)) {
return null;
}
try {
const fileTypes = (this.currentVersion?.files || []).map(f => f.type);
const response = await fetch(DOWNLOAD_ENDPOINTS.routing, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_type: 'other',
base_model: this.currentVersion?.baseModel || '',
file_types: fileTypes,
...(this.selectedFile
? { selected_file_type: this.selectedFile.type }
: {}),
}),
});
if (!response.ok) {
throw new Error(`routing endpoint returned ${response.status}`);
}
const data = await response.json();
return data.sub_type || null;
} catch (error) {
console.warn('[download] other routing endpoint unavailable, '
+ 'falling back to manual root selection:', error);
return null;
}
}
loadDefaultPathSetting() {
const modelType = this.apiClient.modelType;
const storageKey = `use_default_path_${modelType}`;
+69
View File
@@ -1153,6 +1153,9 @@ export class SettingsManager {
// Load default unet root
await this.loadUnetRoots();
// Load default other-model roots (per sub_type)
await this.loadOtherRoots();
// Load extra folder paths
this.loadExtraFolderPaths();
@@ -1658,6 +1661,51 @@ export class SettingsManager {
}
}
async loadOtherRoots() {
const selects = document.querySelectorAll('select[data-other-root-subtype]');
if (!selects.length) return;
try {
// Fetch other-model roots grouped by sub_type
const response = await fetch('/api/lm/other/roots_by_subtype');
if (!response.ok) {
throw new Error('Failed to fetch other model roots');
}
const data = await response.json();
const groupedRoots = data.roots_by_subtype || {};
const defaultRoots = state.global.settings.default_other_roots || {};
selects.forEach((select) => {
const subType = select.dataset.otherRootSubtype;
const roots = groupedRoots[subType] || [];
if (!roots.length) {
this.showNoRootsPlaceholder(select);
return;
}
select.innerHTML = '';
select.disabled = false;
// Add options for each root
roots.forEach(root => {
const option = document.createElement('option');
option.value = root;
option.textContent = root;
select.appendChild(option);
});
const defaultRoot = defaultRoots[subType] || '';
select.value = roots.includes(defaultRoot) ? defaultRoot : roots[0];
});
} catch (error) {
console.error('Error loading other model roots:', error);
selects.forEach((select) => this.showNoRootsPlaceholder(select));
showToast('toast.settings.otherRootsFailed', { message: error.message }, 'error');
}
}
async loadEmbeddingRoots() {
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
if (!defaultEmbeddingRootSelect) return;
@@ -2339,6 +2387,27 @@ export class SettingsManager {
}
}
/**
* Save one sub_type entry of the default_other_roots dict setting
* (read-modify-write: the backend stores the whole mapping).
*/
async saveOtherRootSetting(subType, value) {
try {
const defaultRoots = { ...(state.global.settings.default_other_roots || {}) };
if (value) {
defaultRoots[subType] = value;
} else {
delete defaultRoots[subType];
}
await this.saveSetting('default_other_roots', defaultRoots);
showToast('toast.settings.settingsUpdated', { setting: `default ${subType} root` }, 'success');
} catch (error) {
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
/**
* Save the recipes page layout (grid | masonry) and rebuild the scroller.
* Shared entry point for the settings modal segmented control and the