feat(settings): show default root cue in download root rules editor

Each rule group now shows which root is the type's default ('Default:
{root} — unmatched downloads go here'), and the default root's option in
the per-rule root dropdown carries a '(default)' suffix. Comparison is
deliberately loose (separators, trailing slashes, case) since this is
display-only. Changing a default root select in the Library section
re-renders the rules editor so the cue never goes stale.
This commit is contained in:
Will Miao
2026-10-09 22:08:13 +08:00
parent 5dc2109d7c
commit 198232f3a4
13 changed files with 184 additions and 2 deletions
+2
View File
@@ -499,6 +499,8 @@
"title": "[TODO: Translate] Download Root Rules", "title": "[TODO: Translate] Download Root Rules",
"help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.",
"description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "[TODO: Translate] Default: {root} — unmatched downloads go here",
"defaultSuffix": "[TODO: Translate] default",
"addRule": "[TODO: Translate] Add rule", "addRule": "[TODO: Translate] Add rule",
"removeRule": "[TODO: Translate] Remove rule", "removeRule": "[TODO: Translate] Remove rule",
"moveUp": "[TODO: Translate] Move up", "moveUp": "[TODO: Translate] Move up",
+2
View File
@@ -499,6 +499,8 @@
"title": "Download Root Rules", "title": "Download Root Rules",
"help": "Route downloads to a different root folder based on the model's base model.", "help": "Route downloads to a different root folder based on the model's base model.",
"description": "Rules are checked in order. First match wins; unmatched downloads use the default root.", "description": "Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "Default: {root} — unmatched downloads go here",
"defaultSuffix": "default",
"addRule": "Add rule", "addRule": "Add rule",
"removeRule": "Remove rule", "removeRule": "Remove rule",
"moveUp": "Move up", "moveUp": "Move up",
+2
View File
@@ -499,6 +499,8 @@
"title": "[TODO: Translate] Download Root Rules", "title": "[TODO: Translate] Download Root Rules",
"help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.",
"description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "[TODO: Translate] Default: {root} — unmatched downloads go here",
"defaultSuffix": "[TODO: Translate] default",
"addRule": "[TODO: Translate] Add rule", "addRule": "[TODO: Translate] Add rule",
"removeRule": "[TODO: Translate] Remove rule", "removeRule": "[TODO: Translate] Remove rule",
"moveUp": "[TODO: Translate] Move up", "moveUp": "[TODO: Translate] Move up",
+2
View File
@@ -499,6 +499,8 @@
"title": "[TODO: Translate] Download Root Rules", "title": "[TODO: Translate] Download Root Rules",
"help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.",
"description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "[TODO: Translate] Default: {root} — unmatched downloads go here",
"defaultSuffix": "[TODO: Translate] default",
"addRule": "[TODO: Translate] Add rule", "addRule": "[TODO: Translate] Add rule",
"removeRule": "[TODO: Translate] Remove rule", "removeRule": "[TODO: Translate] Remove rule",
"moveUp": "[TODO: Translate] Move up", "moveUp": "[TODO: Translate] Move up",
+2
View File
@@ -499,6 +499,8 @@
"title": "[TODO: Translate] Download Root Rules", "title": "[TODO: Translate] Download Root Rules",
"help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.",
"description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "[TODO: Translate] Default: {root} — unmatched downloads go here",
"defaultSuffix": "[TODO: Translate] default",
"addRule": "[TODO: Translate] Add rule", "addRule": "[TODO: Translate] Add rule",
"removeRule": "[TODO: Translate] Remove rule", "removeRule": "[TODO: Translate] Remove rule",
"moveUp": "[TODO: Translate] Move up", "moveUp": "[TODO: Translate] Move up",
+2
View File
@@ -499,6 +499,8 @@
"title": "[TODO: Translate] Download Root Rules", "title": "[TODO: Translate] Download Root Rules",
"help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.",
"description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "[TODO: Translate] Default: {root} — unmatched downloads go here",
"defaultSuffix": "[TODO: Translate] default",
"addRule": "[TODO: Translate] Add rule", "addRule": "[TODO: Translate] Add rule",
"removeRule": "[TODO: Translate] Remove rule", "removeRule": "[TODO: Translate] Remove rule",
"moveUp": "[TODO: Translate] Move up", "moveUp": "[TODO: Translate] Move up",
+2
View File
@@ -499,6 +499,8 @@
"title": "[TODO: Translate] Download Root Rules", "title": "[TODO: Translate] Download Root Rules",
"help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.",
"description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "[TODO: Translate] Default: {root} — unmatched downloads go here",
"defaultSuffix": "[TODO: Translate] default",
"addRule": "[TODO: Translate] Add rule", "addRule": "[TODO: Translate] Add rule",
"removeRule": "[TODO: Translate] Remove rule", "removeRule": "[TODO: Translate] Remove rule",
"moveUp": "[TODO: Translate] Move up", "moveUp": "[TODO: Translate] Move up",
+2
View File
@@ -499,6 +499,8 @@
"title": "[TODO: Translate] Download Root Rules", "title": "[TODO: Translate] Download Root Rules",
"help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.",
"description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "[TODO: Translate] Default: {root} — unmatched downloads go here",
"defaultSuffix": "[TODO: Translate] default",
"addRule": "[TODO: Translate] Add rule", "addRule": "[TODO: Translate] Add rule",
"removeRule": "[TODO: Translate] Remove rule", "removeRule": "[TODO: Translate] Remove rule",
"moveUp": "[TODO: Translate] Move up", "moveUp": "[TODO: Translate] Move up",
+2
View File
@@ -499,6 +499,8 @@
"title": "[TODO: Translate] Download Root Rules", "title": "[TODO: Translate] Download Root Rules",
"help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.",
"description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "[TODO: Translate] Default: {root} — unmatched downloads go here",
"defaultSuffix": "[TODO: Translate] default",
"addRule": "[TODO: Translate] Add rule", "addRule": "[TODO: Translate] Add rule",
"removeRule": "[TODO: Translate] Remove rule", "removeRule": "[TODO: Translate] Remove rule",
"moveUp": "[TODO: Translate] Move up", "moveUp": "[TODO: Translate] Move up",
+2
View File
@@ -499,6 +499,8 @@
"title": "[TODO: Translate] Download Root Rules", "title": "[TODO: Translate] Download Root Rules",
"help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.", "help": "[TODO: Translate] Route downloads to a different root folder based on the model's base model.",
"description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.", "description": "[TODO: Translate] Rules are checked in order. First match wins; unmatched downloads use the default root.",
"defaultHint": "[TODO: Translate] Default: {root} — unmatched downloads go here",
"defaultSuffix": "[TODO: Translate] default",
"addRule": "[TODO: Translate] Add rule", "addRule": "[TODO: Translate] Add rule",
"removeRule": "[TODO: Translate] Remove rule", "removeRule": "[TODO: Translate] Remove rule",
"moveUp": "[TODO: Translate] Move up", "moveUp": "[TODO: Translate] Move up",
@@ -686,6 +686,11 @@
padding: 4px 0; padding: 4px 0;
} }
.download-root-rule-default-hint {
padding-top: 0;
overflow-wrap: anywhere;
}
.download-root-rule-panel { .download-root-rule-panel {
margin-top: var(--space-1); margin-top: var(--space-1);
} }
+55 -2
View File
@@ -59,6 +59,20 @@ const DOWNLOAD_ROOT_RULE_ROOT_ENDPOINTS = {
embeddings: '/api/lm/embeddings/roots', embeddings: '/api/lm/embeddings/roots',
}; };
// Rule type -> the settings key holding that type's default download root.
const DOWNLOAD_ROOT_RULE_DEFAULT_ROOT_SETTINGS = {
loras: 'default_lora_root',
checkpoints: 'default_checkpoint_root',
unet: 'default_unet_root',
embeddings: 'default_embedding_root',
};
// Setting keys whose change must refresh the download root rules editor
// (it shows which root is the default for each type).
const DOWNLOAD_ROOT_RULE_DEFAULT_SETTING_KEYS = new Set(
Object.values(DOWNLOAD_ROOT_RULE_DEFAULT_ROOT_SETTINGS)
);
export class SettingsManager { export class SettingsManager {
constructor() { constructor() {
this.initialized = false; this.initialized = false;
@@ -3257,6 +3271,12 @@ export class SettingsManager {
// Apply frontend settings immediately // Apply frontend settings immediately
this.applyFrontendSettings(); this.applyFrontendSettings();
// The download root rules editor marks each type's default root;
// refresh it when a default root select changes.
if (DOWNLOAD_ROOT_RULE_DEFAULT_SETTING_KEYS.has(settingKey)) {
this.renderDownloadRootRules();
}
// Recalculate layout when display density changes // Recalculate layout when display density changes
if (settingKey === 'display_density' && state.virtualScroller) { if (settingKey === 'display_density' && state.virtualScroller) {
state.virtualScroller.calculateLayout(); state.virtualScroller.calculateLayout();
@@ -4469,7 +4489,7 @@ export class SettingsManager {
renderDownloadRootRules() { renderDownloadRootRules() {
const container = document.getElementById('downloadRootRulesContainer'); const container = document.getElementById('downloadRootRulesContainer');
if (!container) { if (!container || !this.downloadRootRuleRoots) {
return; return;
} }
container.innerHTML = ''; container.innerHTML = '';
@@ -4478,6 +4498,24 @@ export class SettingsManager {
}); });
} }
getDownloadRootRuleDefaultRoot(typeKey) {
const settingKey = DOWNLOAD_ROOT_RULE_DEFAULT_ROOT_SETTINGS[typeKey];
const value = settingKey ? state.global.settings[settingKey] : '';
return typeof value === 'string' ? value.trim() : '';
}
// Loose display-only comparison: separator, trailing slash and case
// differences must not hide the "(default)" cue.
isDownloadRootRuleDefaultRoot(root, defaultRoot) {
const normalize = (value) => String(value || '')
.replace(/\\/g, '/')
.replace(/\/+$/, '')
.toLowerCase();
const normalizedRoot = normalize(root);
const normalizedDefault = normalize(defaultRoot);
return !!normalizedRoot && !!normalizedDefault && normalizedRoot === normalizedDefault;
}
buildDownloadRootRuleGroup(typeKey) { buildDownloadRootRuleGroup(typeKey) {
const group = document.createElement('div'); const group = document.createElement('div');
group.className = 'download-root-rule-group'; group.className = 'download-root-rule-group';
@@ -4496,6 +4534,18 @@ export class SettingsManager {
return group; return group;
} }
const defaultRoot = this.getDownloadRootRuleDefaultRoot(typeKey);
if (defaultRoot) {
const defaultHint = document.createElement('div');
defaultHint.className = 'download-root-rules-hint download-root-rule-default-hint';
defaultHint.textContent = translate(
'settings.downloadRootRules.defaultHint',
{ root: defaultRoot },
`Default: ${defaultRoot} — unmatched downloads go here`
);
group.appendChild(defaultHint);
}
const rules = this.getDownloadRootRules()[typeKey]; const rules = this.getDownloadRootRules()[typeKey];
rules.forEach((rule, index) => { rules.forEach((rule, index) => {
group.appendChild(this.buildDownloadRootRuleRow(typeKey, rule, index, false, roots)); group.appendChild(this.buildDownloadRootRuleRow(typeKey, rule, index, false, roots));
@@ -4548,10 +4598,13 @@ export class SettingsManager {
const rootSelect = document.createElement('select'); const rootSelect = document.createElement('select');
rootSelect.className = 'base-model-select download-root-rule-root'; rootSelect.className = 'base-model-select download-root-rule-root';
const defaultRoot = this.getDownloadRootRuleDefaultRoot(typeKey);
roots.forEach((root) => { roots.forEach((root) => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = root; option.value = root;
option.textContent = root; option.textContent = this.isDownloadRootRuleDefaultRoot(root, defaultRoot)
? `${root} (${translate('settings.downloadRootRules.defaultSuffix', {}, 'default')})`
: root;
rootSelect.appendChild(option); rootSelect.appendChild(option);
}); });
if (roots.includes(rule.root)) { if (roots.includes(rule.root)) {
@@ -325,3 +325,107 @@ describe('SettingsManager download root rules', () => {
} }
}); });
}); });
describe('SettingsManager download root rules default-root cue', () => {
beforeEach(() => {
document.body.innerHTML = '';
vi.clearAllMocks();
state.global.settings = {
download_root_rules: { loras: [], checkpoints: [], unet: [], embeddings: [] },
};
});
const defaultHintFor = (typeIndex) =>
document.querySelectorAll('.download-root-rule-group')[typeIndex]
.querySelector('.download-root-rule-default-hint');
it('renders a default hint under the group header when a default root is set', () => {
appendDownloadRootRulesUi();
state.global.settings.default_lora_root = '/models/loras-main';
const manager = createManager();
manager.renderDownloadRootRules();
expect(defaultHintFor(0).textContent)
.toBe('Default: /models/loras-main — unmatched downloads go here');
});
it('omits the default hint when the type has no roots or no default root', () => {
appendDownloadRootRulesUi();
state.global.settings.default_lora_root = '';
state.global.settings.default_embedding_root = '/models/embeddings';
const manager = createManager();
manager.renderDownloadRootRules();
// loras: default root is empty -> no hint
expect(defaultHintFor(0)).toBeNull();
// embeddings: no roots configured -> noRoots hint only, no default hint
expect(defaultHintFor(3)).toBeNull();
});
it('marks the default root option with a (default) suffix but keeps the plain value', () => {
appendDownloadRootRulesUi();
state.global.settings.default_lora_root = '/models/lora-archive';
state.global.settings.download_root_rules = {
loras: [{ match_type: 'base_model', match: ['Flux.1 D'], root: '/models/loras-main' }],
checkpoints: [],
unet: [],
embeddings: [],
};
const manager = createManager();
manager.renderDownloadRootRules();
const options = groupRows('loras')[0].querySelectorAll('select.download-root-rule-root option');
expect([...options].map((o) => o.value)).toEqual(['/models/loras-main', '/models/lora-archive']);
expect([...options].map((o) => o.textContent)).toEqual([
'/models/loras-main',
'/models/lora-archive (default)',
]);
});
it('compares roots loosely: separators, trailing slashes and case are ignored', () => {
const manager = createManager();
expect(manager.isDownloadRootRuleDefaultRoot('C:\\Models\\Loras\\', 'c:/models/loras')).toBe(true);
expect(manager.isDownloadRootRuleDefaultRoot('/a/b/', '/a/b')).toBe(true);
expect(manager.isDownloadRootRuleDefaultRoot('/a/b', '/a/c')).toBe(false);
expect(manager.isDownloadRootRuleDefaultRoot('/a/b', '')).toBe(false);
});
it('does not render groups before the roots have been fetched', () => {
appendDownloadRootRulesUi();
const manager = createManager();
manager.downloadRootRuleRoots = null;
manager.renderDownloadRootRules();
expect(document.querySelectorAll('.download-root-rule-group')).toHaveLength(0);
});
it('changing a default root select re-renders the rules editor with the new default', async () => {
appendDownloadRootRulesUi();
document.body.insertAdjacentHTML(
'beforeend',
'<select id="defaultLoraRoot"><option value="/models/loras-main">a</option><option value="/models/lora-archive" selected>b</option></select>'
);
state.global.settings.default_lora_root = '/models/loras-main';
const manager = createManager();
manager.applyFrontendSettings = vi.fn();
// Mimic the real saveSetting, which writes the value into state.
manager.saveSetting = vi.fn().mockImplementation(async (key, value) => {
state.global.settings[key] = value;
});
manager.renderDownloadRootRules();
expect(defaultHintFor(0).textContent)
.toBe('Default: /models/loras-main — unmatched downloads go here');
await manager.saveSelectSetting('defaultLoraRoot', 'default_lora_root');
expect(manager.saveSetting).toHaveBeenCalledWith('default_lora_root', '/models/lora-archive');
expect(defaultHintFor(0).textContent)
.toBe('Default: /models/lora-archive — unmatched downloads go here');
});
});