feat(settings): editable model library paths for standalone mode

Standalone users previously had to hand-edit settings.json to configure
primary folder_paths. Add a standalone-only Model Paths section to the
settings modal:

- Backend exposes standalone_mode, folder_paths (with template placeholder
  values filtered out) and a data-driven folder_path_schema derived from
  OTHER_MODEL_FOLDER_SUBTYPES via GET /api/lm/settings
- The new section renders multi-path editors per model type from the
  schema, with inline enable_other_models / sub-type controls so other
  model types are configured without leaving the tab
- Persistent restart-required cues after a save: nav dot, inline notice
  and a global banner (unique id per change so dismissals don't mute
  future reminders)
- The missing-model-paths startup banner and the Other Models no-paths
  empty state now deep-link into the new section instead of pointing at
  settings.json
This commit is contained in:
Will Miao
2026-09-18 19:36:19 +08:00
parent d4b82d98b2
commit 5adfa3be36
27 changed files with 1566 additions and 66 deletions
@@ -0,0 +1,418 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: {
closeModal: vi.fn(),
showModal: vi.fn(),
},
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
}));
vi.mock('../../../static/js/state/index.js', () => {
return {
state: {
global: {
settings: {},
},
},
createDefaultSettings: () => ({
language: 'en',
standalone_mode: false,
folder_paths: {},
folder_path_schema: [],
}),
};
});
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
resetAndReload: vi.fn(),
}));
vi.mock('../../../static/js/utils/constants.js', () => ({
DOWNLOAD_PATH_TEMPLATES: {},
DEFAULT_PATH_TEMPLATES: {},
MAPPABLE_BASE_MODELS: [],
PATH_TEMPLATE_PLACEHOLDERS: {},
DEFAULT_PRIORITY_TAG_CONFIG: {},
getMappableBaseModelsDynamic: () => [],
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: (_key, _params, fallback) => fallback ?? '',
}));
vi.mock('../../../static/js/i18n/index.js', () => ({
i18n: {
getCurrentLocale: () => 'en',
setLanguage: vi.fn().mockResolvedValue(),
},
}));
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
configureModelCardVideo: vi.fn(),
}));
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
import { bannerService } from '../../../static/js/managers/BannerService.js';
import { state } from '../../../static/js/state/index.js';
const CORE_SCHEMA = [
{ key: 'loras', category: 'core', sub_type: null },
{ key: 'checkpoints', category: 'core', sub_type: null },
{ key: 'unet', category: 'core', sub_type: null },
{ key: 'embeddings', category: 'core', sub_type: null },
];
const OTHER_SCHEMA = [
{ key: 'vae', category: 'other', sub_type: 'vae' },
{ key: 'controlnet', category: 'other', sub_type: 'controlnet' },
];
const createManager = () => {
const initSettingsSpy = vi
.spyOn(SettingsManager.prototype, 'initializeSettings')
.mockResolvedValue();
const initializeSpy = vi
.spyOn(SettingsManager.prototype, 'initialize')
.mockImplementation(() => {});
const manager = new SettingsManager();
initSettingsSpy.mockRestore();
initializeSpy.mockRestore();
return manager;
};
const buildModalDom = () => {
document.body.innerHTML = `
<nav class="settings-nav">
<ul class="settings-nav-list">
<li class="settings-nav-group">
<button type="button" class="settings-nav-item active" data-section="general">General</button>
</li>
</ul>
</nav>
<div class="settings-form">
<div class="settings-section active" id="section-general" data-section="general"></div>
</div>
`;
};
const setStandaloneSettings = (overrides = {}) => {
state.global.settings = {
standalone_mode: true,
folder_paths: {},
folder_path_schema: [...CORE_SCHEMA, ...OTHER_SCHEMA],
enable_other_models: false,
enabled_other_sub_types: [],
...overrides,
};
};
beforeEach(() => {
document.body.innerHTML = '';
vi.clearAllMocks();
bannerService.banners.clear();
});
afterEach(() => {
delete global.fetch;
});
describe('SettingsManager Model Paths section', () => {
it('does not create the section in plugin mode', () => {
buildModalDom();
state.global.settings = { standalone_mode: false };
const manager = createManager();
manager.setupModelPathsSection();
expect(document.querySelector('.settings-nav-item[data-section="modelPaths"]')).toBeNull();
expect(document.getElementById('section-modelPaths')).toBeNull();
});
it('creates nav item and section in standalone mode', () => {
buildModalDom();
setStandaloneSettings();
const manager = createManager();
manager.setupModelPathsSection();
expect(document.querySelector('.settings-nav-item[data-section="modelPaths"]')).not.toBeNull();
expect(document.getElementById('section-modelPaths')).not.toBeNull();
});
it('switches sections when the nav item is clicked', () => {
buildModalDom();
setStandaloneSettings();
const manager = createManager();
manager.setupModelPathsSection();
document.querySelector('.settings-nav-item[data-section="modelPaths"]').click();
expect(document.getElementById('section-modelPaths').classList.contains('active')).toBe(true);
expect(document.getElementById('section-general').classList.contains('active')).toBe(false);
});
it('static nav clicks clear the Model Paths active state (regression)', () => {
buildModalDom();
setStandaloneSettings();
const manager = createManager();
manager.setupModelPathsSection();
// Static nav items were bound before the Model Paths button existed;
// their handler must still clear its active state.
manager.initializeNavigation();
const modelPathsNav = document.querySelector('.settings-nav-item[data-section="modelPaths"]');
modelPathsNav.click();
expect(modelPathsNav.classList.contains('active')).toBe(true);
document.querySelector('.settings-nav-item[data-section="general"]').click();
expect(modelPathsNav.classList.contains('active')).toBe(false);
expect(document.getElementById('section-modelPaths').classList.contains('active')).toBe(false);
expect(document.getElementById('section-general').classList.contains('active')).toBe(true);
});
it('renders core editors and only enabled other-model editors', () => {
buildModalDom();
setStandaloneSettings({
enable_other_models: true,
enabled_other_sub_types: ['vae'],
folder_paths: { loras: ['/models/loras'] },
});
const manager = createManager();
manager.setupModelPathsSection();
manager.loadModelPaths();
// Core editors always rendered
CORE_SCHEMA.forEach(({ key }) => {
expect(document.getElementById(`modelFolderPaths-${key}`)).not.toBeNull();
});
// Only the enabled other-model sub-type is rendered
expect(document.getElementById('modelFolderPaths-vae')).not.toBeNull();
expect(document.getElementById('modelFolderPaths-controlnet')).toBeNull();
expect(document.getElementById('modelPathsOtherEmpty').style.display).toBe('none');
// Existing values populate rows
const loraInput = document.querySelector('#modelFolderPaths-loras .extra-folder-path-input');
expect(loraInput.value).toBe('/models/loras');
});
it('shows the empty hint when no other-model types are enabled', () => {
buildModalDom();
setStandaloneSettings();
const manager = createManager();
manager.setupModelPathsSection();
manager.loadModelPaths();
expect(document.getElementById('modelPathsOtherEmpty').style.display).toBe('block');
expect(document.getElementById('modelFolderPaths-vae')).toBeNull();
});
it('renders inline enable controls synced with current settings', () => {
buildModalDom();
setStandaloneSettings({
enable_other_models: true,
enabled_other_sub_types: ['vae'],
});
const manager = createManager();
manager.setupModelPathsSection();
manager.loadModelPaths();
const master = document.getElementById('modelPathsEnableOtherModels');
expect(master).not.toBeNull();
expect(master.checked).toBe(true);
const vaeBox = document.querySelector('[data-model-paths-subtype="vae"]');
const controlnetBox = document.querySelector('[data-model-paths-subtype="controlnet"]');
expect(vaeBox.checked).toBe(true);
expect(vaeBox.disabled).toBe(false);
expect(controlnetBox.checked).toBe(false);
});
it('inline master toggle saves the setting and re-renders editors', async () => {
buildModalDom();
setStandaloneSettings({
enable_other_models: true,
enabled_other_sub_types: ['vae'],
});
const manager = createManager();
manager.saveSetting = vi.fn().mockImplementation(async (key, value) => {
state.global.settings[key] = value;
});
manager.loadOtherRoots = vi.fn().mockResolvedValue();
manager.setupModelPathsSection();
manager.loadModelPaths();
expect(document.getElementById('modelFolderPaths-vae')).not.toBeNull();
const master = document.getElementById('modelPathsEnableOtherModels');
master.checked = false;
await manager.handleModelPathsEnableOtherModels();
expect(manager.saveSetting).toHaveBeenCalledWith('enable_other_models', false);
expect(state.global.settings.enable_other_models).toBe(false);
// Editors removed in place, empty hint back
expect(document.getElementById('modelFolderPaths-vae')).toBeNull();
expect(document.getElementById('modelPathsOtherEmpty').style.display).toBe('block');
});
it('inline sub-type checkboxes save the allow-list and re-render editors', async () => {
buildModalDom();
setStandaloneSettings({
enable_other_models: true,
enabled_other_sub_types: ['vae'],
});
const manager = createManager();
manager.saveSetting = vi.fn().mockImplementation(async (key, value) => {
state.global.settings[key] = value;
});
manager.loadOtherRoots = vi.fn().mockResolvedValue();
manager.setupModelPathsSection();
manager.loadModelPaths();
document.querySelector('[data-model-paths-subtype="controlnet"]').checked = true;
await manager.handleModelPathsSubTypeToggles();
expect(manager.saveSetting).toHaveBeenCalledWith('enabled_other_sub_types', ['vae', 'controlnet']);
expect(document.getElementById('modelFolderPaths-controlnet')).not.toBeNull();
});
it('saves collected folder paths via saveSetting', async () => {
buildModalDom();
setStandaloneSettings();
const manager = createManager();
manager.saveSetting = vi.fn().mockResolvedValue();
manager.setupModelPathsSection();
manager.loadModelPaths();
// No rows exist until the user clicks Add
expect(document.querySelector('#modelFolderPaths-loras .extra-folder-path-input')).toBeNull();
manager.addModelFolderPathRow('loras');
document.querySelector('#modelFolderPaths-loras .extra-folder-path-input').value = '/data/loras';
await manager.updateModelFolderPaths('loras');
expect(manager.saveSetting).toHaveBeenCalledWith('folder_paths', {
loras: ['/data/loras'],
checkpoints: [],
unet: [],
embeddings: [],
});
expect(state.global.settings.folder_paths.loras).toEqual(['/data/loras']);
});
it('blocks saving when checkpoints and unet share a path', async () => {
buildModalDom();
setStandaloneSettings();
const manager = createManager();
manager.saveSetting = vi.fn().mockResolvedValue();
manager.setupModelPathsSection();
manager.loadModelPaths();
manager.addModelFolderPathRow('checkpoints');
manager.addModelFolderPathRow('unet');
document.querySelector('#modelFolderPaths-checkpoints .extra-folder-path-input').value = '/same/dir';
document.querySelector('#modelFolderPaths-unet .extra-folder-path-input').value = '/same/dir';
await manager.updateModelFolderPaths('checkpoints');
expect(manager.saveSetting).not.toHaveBeenCalled();
const ckptInput = document.querySelector('#modelFolderPaths-checkpoints .extra-folder-path-input');
expect(ckptInput.classList.contains('has-error')).toBe(true);
});
it('appends a trailing empty row after filling one, but not after a removal', async () => {
buildModalDom();
setStandaloneSettings({ folder_paths: { loras: ['/data/a'] } });
const manager = createManager();
manager.saveSetting = vi.fn().mockResolvedValue();
manager.setupModelPathsSection();
manager.loadModelPaths();
// Fill the trailing empty row -> save appends a fresh empty row
manager.addModelFolderPathRow('loras');
const rows = () => document.querySelectorAll('#modelFolderPaths-loras .extra-folder-path-row');
expect(rows()).toHaveLength(2);
rows()[1].querySelector('.extra-folder-path-input').value = '/data/b';
await manager.updateModelFolderPaths('loras');
expect(rows()).toHaveLength(3);
// Removing a row never resurrects an empty row
const removeBtn = rows()[0].querySelector('.remove-path-btn');
manager.removeModelFolderPathRow(removeBtn, 'loras');
await vi.waitFor(() => {
expect(manager.saveSetting).toHaveBeenCalledTimes(2);
});
// Two rows left: the saved '/data/b' plus the pre-existing trailing
// empty row — removal must not append yet another empty row.
expect(rows()).toHaveLength(2);
const emptyRows = Array.from(rows()).filter(
(row) => row.querySelector('.extra-folder-path-input').value === '',
);
expect(emptyRows).toHaveLength(1);
});
it('restores previous state when saving fails', async () => {
buildModalDom();
setStandaloneSettings({ folder_paths: { loras: ['/original'] } });
const manager = createManager();
manager.saveSetting = vi.fn().mockRejectedValue(new Error('nope'));
manager.setupModelPathsSection();
manager.loadModelPaths();
const input = document.querySelector('#modelFolderPaths-loras .extra-folder-path-input');
input.value = '/changed';
await manager.updateModelFolderPaths('loras');
expect(state.global.settings.folder_paths).toEqual({ loras: ['/original'] });
// Rows reloaded from restored state
const reloaded = document.querySelector('#modelFolderPaths-loras .extra-folder-path-input');
expect(reloaded.value).toBe('/original');
});
it('marks pending-restart cues after a successful save', async () => {
buildModalDom();
setStandaloneSettings();
const manager = createManager();
manager.saveSetting = vi.fn().mockResolvedValue();
manager.setupModelPathsSection();
manager.loadModelPaths();
const navItem = document.querySelector('.settings-nav-item[data-section="modelPaths"]');
expect(navItem.classList.contains('has-pending-restart')).toBe(false);
manager.addModelFolderPathRow('loras');
document.querySelector('#modelFolderPaths-loras .extra-folder-path-input').value = '/data/loras';
await manager.updateModelFolderPaths('loras');
expect(navItem.classList.contains('has-pending-restart')).toBe(true);
expect(document.getElementById('modelPathsRestartNotice').classList.contains('visible')).toBe(true);
// A unique-per-change banner id: dismissing it once must not mute
// future reminders (dismissed ids persist across restarts).
const restartBanners = Array.from(bannerService.banners.keys())
.filter((id) => id.startsWith('model-paths-restart-'));
expect(restartBanners).toHaveLength(1);
});
});
@@ -25,6 +25,7 @@ describe('Other Models disabled page', () => {
document.body.innerHTML = [
'<button id="enableOtherModelsBtn"></button>',
'<button id="openOtherModelsSettingsBtn"></button>',
'<button id="openModelPathsSettingsBtn"></button>',
'<button id="openSettingsFolderBtn"></button>',
].join('');
@@ -65,6 +66,27 @@ describe('Other Models disabled page', () => {
expect(showModal).toHaveBeenCalledWith('settingsModal');
});
it('opens the Model Paths settings from the standalone no-folders state', async () => {
const showModal = vi.fn();
window.modalManager = { showModal };
const navItem = document.createElement('button');
navItem.className = 'settings-nav-item';
navItem.dataset.section = 'modelPaths';
const navClick = vi.fn();
navItem.addEventListener('click', navClick);
document.body.appendChild(navItem);
document.getElementById('openModelPathsSettingsBtn').dispatchEvent(
new MouseEvent('click', { bubbles: true }),
);
expect(showModal).toHaveBeenCalledWith('settingsModal');
await new Promise((resolve) => setTimeout(resolve, 150));
expect(navClick).toHaveBeenCalledTimes(1);
});
it('reveals the settings.json location from the standalone no-folders state', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
@@ -30,6 +30,7 @@
'language': 'en',
'llm_api_key_set': False,
'other_models_paths_available': False,
'standalone_mode': False,
'theme': 'dark',
}),
'success': True,
+131
View File
@@ -160,3 +160,134 @@ async def test_activate_library_unexpected_error_returns_500(monkeypatch):
assert response.status == 500
assert payload["success"] is False
assert payload["error"] == "bad things"
class DummySettingsForGet:
def __init__(self, values=None):
self._values = dict(values or {})
self.settings_file = "/tmp/settings.json"
self.set_calls = []
def keys(self):
return self._values.keys()
def get(self, key, default=None):
return self._values.get(key, default)
def set(self, key, value):
self.set_calls.append((key, value))
self._values[key] = value
def get_startup_messages(self):
return []
def make_get_handler(values=None) -> SettingsHandler:
return SettingsHandler(
settings_service=DummySettingsForGet(values),
metadata_provider_updater=noop_async,
downloader_factory=dummy_downloader_factory,
)
@pytest.fixture
def patch_other_models_availability(monkeypatch):
monkeypatch.setattr(
config,
"get_other_models_availability",
lambda: {"available": False},
)
@pytest.mark.asyncio
async def test_get_settings_plugin_mode_hides_folder_paths(
monkeypatch, patch_other_models_availability
):
monkeypatch.delenv("LORA_MANAGER_STANDALONE", raising=False)
handler = make_get_handler(
{
"language": "en",
"folder_paths": {"loras": ["/models/loras"]},
}
)
response = await handler.get_settings(FakeRequest())
payload = json_payload(response)
assert response.status == 200
settings = payload["settings"]
assert settings["standalone_mode"] is False
assert "folder_paths" not in settings
assert "folder_path_schema" not in settings
@pytest.mark.asyncio
async def test_get_settings_standalone_exposes_folder_paths_and_schema(
monkeypatch, patch_other_models_availability
):
monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1")
folder_paths = {"loras": ["/models/loras"], "vae": ["/models/vae"]}
handler = make_get_handler({"language": "en", "folder_paths": folder_paths})
response = await handler.get_settings(FakeRequest())
payload = json_payload(response)
assert response.status == 200
settings = payload["settings"]
assert settings["standalone_mode"] is True
assert settings["folder_paths"] == folder_paths
schema = settings["folder_path_schema"]
core_keys = [entry["key"] for entry in schema if entry["category"] == "core"]
assert core_keys == ["loras", "checkpoints", "unet", "embeddings"]
other_entries = {entry["key"]: entry for entry in schema if entry["category"] == "other"}
assert other_entries["vae"]["sub_type"] == "vae"
assert other_entries["text_encoders"]["sub_type"] == "text_encoder"
@pytest.mark.asyncio
async def test_update_settings_passes_folder_paths_through(
monkeypatch, patch_other_models_availability
):
monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1")
handler = make_get_handler({"folder_paths": {}})
new_paths = {"loras": ["/models/loras"]}
response = await handler.update_settings(
FakeRequest(json_data={"folder_paths": new_paths})
)
payload = json_payload(response)
assert response.status == 200
assert payload["success"] is True
assert handler._settings.set_calls == [("folder_paths", new_paths)]
@pytest.mark.asyncio
async def test_get_settings_standalone_filters_template_placeholders(
monkeypatch, patch_other_models_availability
):
"""Fresh installs are seeded from settings.json.example; its placeholder
paths must not show up as real values in the Model Paths UI."""
monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1")
handler = make_get_handler(
{
"folder_paths": {
"loras": ["C:/path/to/your/loras_folder", "/real/loras"],
"vae": ["C:/path/to/another/vae_folder"],
}
}
)
handler._settings.get_template_folder_path_placeholders = lambda: {
"C:/path/to/your/loras_folder",
"C:/path/to/another/vae_folder",
}
response = await handler.get_settings(FakeRequest())
payload = json_payload(response)
assert response.status == 200
assert payload["settings"]["folder_paths"] == {
"loras": ["/real/loras"],
"vae": [],
}
+17 -1
View File
@@ -52,12 +52,18 @@ def test_missing_settings_creates_defaults_and_emits_warnings(tmp_path):
actions = warning.get("actions") or []
assert actions == [
{
"action": "open-model-paths-settings",
"label": "Configure model folders",
"type": "primary",
"icon": "fas fa-cog",
},
{
"action": "open-settings-location",
"label": "Open settings folder",
"type": "primary",
"icon": "fas fa-folder-open",
}
},
]
@@ -155,3 +161,13 @@ def test_apply_settings_dir_from_argv():
os.environ.pop("LORA_MANAGER_SETTINGS_DIR", None)
else:
os.environ["LORA_MANAGER_SETTINGS_DIR"] = previous
def test_template_folder_path_placeholders_are_exposed():
manager = get_settings_manager()
placeholders = manager.get_template_folder_path_placeholders()
assert "C:/path/to/your/loras_folder" in placeholders
assert "C:/path/to/another/embeddings_folder" in placeholders
assert len(placeholders) == 8
+33 -1
View File
@@ -42,4 +42,36 @@ class TestIsEmptyPlaceholderHash:
def test_rejects_non_strings(self):
assert not is_empty_placeholder_hash(None)
assert not is_empty_placeholder_hash(123)
assert not is_empty_placeholder_hash(123)
class TestFolderPathSchema:
def test_core_keys_first_in_canonical_order(self):
from py.utils.constants import CORE_FOLDER_PATH_KEYS, folder_path_schema
schema = folder_path_schema()
core = [entry for entry in schema if entry["category"] == "core"]
assert [entry["key"] for entry in core] == CORE_FOLDER_PATH_KEYS
assert all(entry["sub_type"] is None for entry in core)
assert schema[: len(core)] == core
def test_other_entries_derive_from_subtypes_table(self):
from py.utils.constants import OTHER_MODEL_FOLDER_SUBTYPES, folder_path_schema
schema = folder_path_schema()
other = {entry["key"]: entry for entry in schema if entry["category"] == "other"}
assert set(other) == set(OTHER_MODEL_FOLDER_SUBTYPES)
for folder_key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items():
assert other[folder_key]["sub_type"] == sub_type
def test_text_encoder_exposes_both_folder_keys(self):
from py.utils.constants import folder_path_schema
text_encoder_keys = [
entry["key"]
for entry in folder_path_schema()
if entry["sub_type"] == "text_encoder"
]
assert text_encoder_keys == ["text_encoders", "clip"]