diff --git a/locales/de.json b/locales/de.json index 402fceba..1b7386a0 100644 --- a/locales/de.json +++ b/locales/de.json @@ -241,6 +241,10 @@ } }, "folderSettings": { + "activeLibrary": "Active Library", + "activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.", + "loadingLibraries": "Loading libraries...", + "noLibraries": "No libraries configured", "defaultLoraRoot": "Standard-LoRA-Stammordner", "defaultLoraRootHelp": "Legen Sie den Standard-LoRA-Stammordner für Downloads, Importe und Verschiebungen fest", "defaultCheckpointRoot": "Standard-Checkpoint-Stammordner", @@ -1125,6 +1129,8 @@ "compactModeToggled": "Kompakt-Modus {state}", "settingSaveFailed": "Fehler beim Speichern der Einstellung: {message}", "displayDensitySet": "Anzeige-Dichte auf {density} gesetzt", + "libraryLoadFailed": "Failed to load libraries: {message}", + "libraryActivateFailed": "Failed to activate library: {message}", "languageChangeFailed": "Fehler beim Ändern der Sprache: {message}", "cacheCleared": "Cache-Dateien wurden erfolgreich gelöscht. Cache wird bei der nächsten Aktion neu aufgebaut.", "cacheClearFailed": "Fehler beim Löschen des Caches: {error}", diff --git a/locales/en.json b/locales/en.json index 278aab0d..828ac4f8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -241,6 +241,10 @@ } }, "folderSettings": { + "activeLibrary": "Active Library", + "activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.", + "loadingLibraries": "Loading libraries...", + "noLibraries": "No libraries configured", "defaultLoraRoot": "Default LoRA Root", "defaultLoraRootHelp": "Set the default LoRA root directory for downloads, imports and moves", "defaultCheckpointRoot": "Default Checkpoint Root", @@ -1125,6 +1129,8 @@ "compactModeToggled": "Compact Mode {state}", "settingSaveFailed": "Failed to save setting: {message}", "displayDensitySet": "Display Density set to {density}", + "libraryLoadFailed": "Failed to load libraries: {message}", + "libraryActivateFailed": "Failed to activate library: {message}", "languageChangeFailed": "Failed to change language: {message}", "cacheCleared": "Cache files have been cleared successfully. Cache will rebuild on next action.", "cacheClearFailed": "Failed to clear cache: {error}", diff --git a/locales/es.json b/locales/es.json index e4ab8994..80d1be69 100644 --- a/locales/es.json +++ b/locales/es.json @@ -241,6 +241,10 @@ } }, "folderSettings": { + "activeLibrary": "Active Library", + "activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.", + "loadingLibraries": "Loading libraries...", + "noLibraries": "No libraries configured", "defaultLoraRoot": "Raíz predeterminada de LoRA", "defaultLoraRootHelp": "Establecer el directorio raíz predeterminado de LoRA para descargas, importaciones y movimientos", "defaultCheckpointRoot": "Raíz predeterminada de checkpoint", @@ -1125,6 +1129,8 @@ "compactModeToggled": "Modo compacto {state}", "settingSaveFailed": "Error al guardar configuración: {message}", "displayDensitySet": "Densidad de visualización establecida a {density}", + "libraryLoadFailed": "Failed to load libraries: {message}", + "libraryActivateFailed": "Failed to activate library: {message}", "languageChangeFailed": "Error al cambiar idioma: {message}", "cacheCleared": "Archivos de caché limpiados exitosamente. La caché se reconstruirá en la próxima acción.", "cacheClearFailed": "Error al limpiar caché: {error}", diff --git a/locales/fr.json b/locales/fr.json index ec3f3a4e..4049b1fa 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -241,6 +241,10 @@ } }, "folderSettings": { + "activeLibrary": "Active Library", + "activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.", + "loadingLibraries": "Loading libraries...", + "noLibraries": "No libraries configured", "defaultLoraRoot": "Racine LoRA par défaut", "defaultLoraRootHelp": "Définir le répertoire racine LoRA par défaut pour les téléchargements, imports et déplacements", "defaultCheckpointRoot": "Racine Checkpoint par défaut", @@ -1125,6 +1129,8 @@ "compactModeToggled": "Mode compact {state}", "settingSaveFailed": "Échec de la sauvegarde du paramètre : {message}", "displayDensitySet": "Densité d'affichage définie sur {density}", + "libraryLoadFailed": "Failed to load libraries: {message}", + "libraryActivateFailed": "Failed to activate library: {message}", "languageChangeFailed": "Échec du changement de langue : {message}", "cacheCleared": "Les fichiers de cache ont été vidés avec succès. Le cache sera reconstruit à la prochaine action.", "cacheClearFailed": "Échec du vidage du cache : {error}", diff --git a/locales/he.json b/locales/he.json index 8bfb805f..814d6f10 100644 --- a/locales/he.json +++ b/locales/he.json @@ -241,6 +241,10 @@ } }, "folderSettings": { + "activeLibrary": "Active Library", + "activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.", + "loadingLibraries": "Loading libraries...", + "noLibraries": "No libraries configured", "defaultLoraRoot": "תיקיית שורש ברירת מחדל של LoRA", "defaultLoraRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של LoRA להורדות, ייבוא והעברות", "defaultCheckpointRoot": "תיקיית שורש ברירת מחדל של Checkpoint", @@ -1125,6 +1129,8 @@ "compactModeToggled": "מצב קומפקטי {state}", "settingSaveFailed": "שמירת ההגדרה נכשלה: {message}", "displayDensitySet": "צפיפות התצוגה הוגדרה ל-{density}", + "libraryLoadFailed": "Failed to load libraries: {message}", + "libraryActivateFailed": "Failed to activate library: {message}", "languageChangeFailed": "שינוי השפה נכשל: {message}", "cacheCleared": "קבצי המטמון נוקו בהצלחה. המטמון ייבנה מחדש בפעולה הבאה.", "cacheClearFailed": "ניקוי המטמון נכשל: {error}", diff --git a/locales/ja.json b/locales/ja.json index 4d0a3577..832664fb 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -241,6 +241,10 @@ } }, "folderSettings": { + "activeLibrary": "Active Library", + "activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.", + "loadingLibraries": "Loading libraries...", + "noLibraries": "No libraries configured", "defaultLoraRoot": "デフォルトLoRAルート", "defaultLoraRootHelp": "ダウンロード、インポート、移動用のデフォルトLoRAルートディレクトリを設定", "defaultCheckpointRoot": "デフォルトCheckpointルート", @@ -1125,6 +1129,8 @@ "compactModeToggled": "コンパクトモード {state}", "settingSaveFailed": "設定の保存に失敗しました:{message}", "displayDensitySet": "表示密度が {density} に設定されました", + "libraryLoadFailed": "Failed to load libraries: {message}", + "libraryActivateFailed": "Failed to activate library: {message}", "languageChangeFailed": "言語の変更に失敗しました:{message}", "cacheCleared": "キャッシュファイルが正常にクリアされました。次回のアクションでキャッシュが再構築されます。", "cacheClearFailed": "キャッシュのクリアに失敗しました:{error}", diff --git a/locales/ko.json b/locales/ko.json index 882d1d4c..da128cc2 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -241,6 +241,10 @@ } }, "folderSettings": { + "activeLibrary": "Active Library", + "activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.", + "loadingLibraries": "Loading libraries...", + "noLibraries": "No libraries configured", "defaultLoraRoot": "기본 LoRA 루트", "defaultLoraRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 LoRA 루트 디렉토리를 설정합니다", "defaultCheckpointRoot": "기본 Checkpoint 루트", @@ -1125,6 +1129,8 @@ "compactModeToggled": "컴팩트 모드 {state}", "settingSaveFailed": "설정 저장 실패: {message}", "displayDensitySet": "표시 밀도가 {density}로 설정되었습니다", + "libraryLoadFailed": "Failed to load libraries: {message}", + "libraryActivateFailed": "Failed to activate library: {message}", "languageChangeFailed": "언어 변경 실패: {message}", "cacheCleared": "캐시 파일이 성공적으로 지워졌습니다. 다음 작업 시 캐시가 재구축됩니다.", "cacheClearFailed": "캐시 지우기 실패: {error}", diff --git a/locales/ru.json b/locales/ru.json index c062220e..9f11fe94 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -241,6 +241,10 @@ } }, "folderSettings": { + "activeLibrary": "Active Library", + "activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.", + "loadingLibraries": "Loading libraries...", + "noLibraries": "No libraries configured", "defaultLoraRoot": "Корневая папка LoRA по умолчанию", "defaultLoraRootHelp": "Установить корневую папку LoRA по умолчанию для загрузок, импорта и перемещений", "defaultCheckpointRoot": "Корневая папка Checkpoint по умолчанию", @@ -1125,6 +1129,8 @@ "compactModeToggled": "Компактный режим {state}", "settingSaveFailed": "Не удалось сохранить настройку: {message}", "displayDensitySet": "Плотность отображения установлена на {density}", + "libraryLoadFailed": "Failed to load libraries: {message}", + "libraryActivateFailed": "Failed to activate library: {message}", "languageChangeFailed": "Не удалось изменить язык: {message}", "cacheCleared": "Файлы кэша успешно очищены. Кэш будет пересобран при следующем действии.", "cacheClearFailed": "Не удалось очистить кэш: {error}", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 9a653b54..aad48214 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -247,6 +247,10 @@ } }, "folderSettings": { + "activeLibrary": "Active Library", + "activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.", + "loadingLibraries": "Loading libraries...", + "noLibraries": "No libraries configured", "defaultLoraRoot": "默认 LoRA 根目录", "defaultLoraRootHelp": "设置下载、导入和移动时的默认 LoRA 根目录", "defaultCheckpointRoot": "默认 Checkpoint 根目录", @@ -1131,6 +1135,8 @@ "compactModeToggled": "紧凑模式 {state}", "settingSaveFailed": "保存设置失败:{message}", "displayDensitySet": "显示密度已设置为 {density}", + "libraryLoadFailed": "Failed to load libraries: {message}", + "libraryActivateFailed": "Failed to activate library: {message}", "languageChangeFailed": "切换语言失败:{message}", "cacheCleared": "缓存文件已成功清除。下次操作将重建缓存。", "cacheClearFailed": "清除缓存失败:{error}", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index e9692aa4..f2b8f468 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -241,6 +241,10 @@ } }, "folderSettings": { + "activeLibrary": "Active Library", + "activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.", + "loadingLibraries": "Loading libraries...", + "noLibraries": "No libraries configured", "defaultLoraRoot": "預設 LoRA 根目錄", "defaultLoraRootHelp": "設定下載、匯入和移動時的預設 LoRA 根目錄", "defaultCheckpointRoot": "預設 Checkpoint 根目錄", @@ -1125,6 +1129,8 @@ "compactModeToggled": "緊湊模式已{state}", "settingSaveFailed": "儲存設定失敗:{message}", "displayDensitySet": "顯示密度已設為 {density}", + "libraryLoadFailed": "Failed to load libraries: {message}", + "libraryActivateFailed": "Failed to activate library: {message}", "languageChangeFailed": "切換語言失敗:{message}", "cacheCleared": "快取檔案已成功清除。快取將於下次操作時重建。", "cacheClearFailed": "清除快取失敗:{error}", diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 99e64f0a..34bad67d 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -12,6 +12,8 @@ export class SettingsManager { this.initialized = false; this.isOpen = false; this.initializationPromise = null; + this.availableLibraries = {}; + this.activeLibrary = ''; // Add initialization to sync with modal state this.currentPage = document.body.dataset.page || 'loras'; @@ -301,6 +303,9 @@ export class SettingsManager { // Load base model path mappings this.loadBaseModelMappings(); + // Load library options + await this.loadLibraries(); + // Load default lora root await this.loadLoraRoots(); @@ -372,6 +377,147 @@ export class SettingsManager { } } + async loadLibraries() { + const librarySelect = document.getElementById('librarySelect'); + if (!librarySelect) { + return; + } + + const setPlaceholderOption = (textKey, fallback) => { + librarySelect.innerHTML = ''; + const option = document.createElement('option'); + option.value = ''; + option.textContent = translate(textKey, {}, fallback); + librarySelect.appendChild(option); + }; + + setPlaceholderOption('settings.folderSettings.loadingLibraries', 'Loading libraries...'); + librarySelect.disabled = true; + + try { + const response = await fetch('/api/lm/settings/libraries'); + if (!response.ok) { + throw new Error('Failed to fetch library registry'); + } + + const data = await response.json(); + if (data.success === false) { + throw new Error(data.error || 'Failed to fetch library registry'); + } + + const libraries = data.libraries && typeof data.libraries === 'object' + ? data.libraries + : {}; + + this.availableLibraries = libraries; + + const entries = Object.entries(libraries); + if (entries.length === 0) { + this.activeLibrary = ''; + setPlaceholderOption('settings.folderSettings.noLibraries', 'No libraries configured'); + return; + } + + const activeName = data.active_library && libraries[data.active_library] + ? data.active_library + : entries[0][0]; + + this.activeLibrary = activeName; + + librarySelect.innerHTML = ''; + const fragment = document.createDocumentFragment(); + entries + .sort((a, b) => { + const nameA = this.getLibraryDisplayName(a[0], a[1]).toLowerCase(); + const nameB = this.getLibraryDisplayName(b[0], b[1]).toLowerCase(); + return nameA.localeCompare(nameB); + }) + .forEach(([name, info]) => { + const option = document.createElement('option'); + option.value = name; + option.textContent = this.getLibraryDisplayName(name, info); + fragment.appendChild(option); + }); + + librarySelect.appendChild(fragment); + librarySelect.value = activeName; + librarySelect.disabled = entries.length <= 1; + } catch (error) { + console.error('Error loading libraries:', error); + setPlaceholderOption('settings.folderSettings.noLibraries', 'No libraries configured'); + this.availableLibraries = {}; + this.activeLibrary = ''; + librarySelect.disabled = true; + showToast('toast.settings.libraryLoadFailed', { message: error.message }, 'error'); + } + } + + getLibraryDisplayName(libraryName, libraryData = {}) { + if (libraryData && typeof libraryData === 'object') { + const metadata = libraryData.metadata; + if (metadata && typeof metadata === 'object' && metadata.display_name) { + return metadata.display_name; + } + + if (libraryData.display_name) { + return libraryData.display_name; + } + } + + return libraryName; + } + + async handleLibraryChange() { + const librarySelect = document.getElementById('librarySelect'); + if (!librarySelect) { + return; + } + + const selectedLibrary = librarySelect.value; + if (!selectedLibrary || selectedLibrary === this.activeLibrary) { + librarySelect.value = this.activeLibrary; + return; + } + + librarySelect.disabled = true; + + try { + await this.activateLibrary(selectedLibrary); + window.location.reload(); + } catch (error) { + console.error('Failed to activate library:', error); + showToast('toast.settings.libraryActivateFailed', { message: error.message }, 'error'); + await this.loadLibraries(); + } finally { + if (!document.hidden) { + librarySelect.disabled = librarySelect.options.length <= 1; + } + } + } + + async activateLibrary(libraryName) { + const response = await fetch('/api/lm/settings/libraries/activate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ library: libraryName }), + }); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + const data = await response.json(); + if (data.success === false) { + throw new Error(data.error || 'Failed to activate library'); + } + + const activeName = data.active_library || libraryName; + this.activeLibrary = activeName; + return data; + } + async loadLoraRoots() { try { const defaultLoraRootSelect = document.getElementById('defaultLoraRoot'); diff --git a/templates/components/modals/settings_modal.html b/templates/components/modals/settings_modal.html index e036dc56..9282fb57 100644 --- a/templates/components/modals/settings_modal.html +++ b/templates/components/modals/settings_modal.html @@ -181,7 +181,23 @@

{{ t('settings.sections.folderSettings') }}

- + +
+
+
+ +
+
+ +
+
+
+ {{ t('settings.folderSettings.activeLibraryHelp') }} +
+
+
diff --git a/tests/frontend/managers/settingsManager.library.test.js b/tests/frontend/managers/settingsManager.library.test.js new file mode 100644 index 00000000..a0f819ee --- /dev/null +++ b/tests/frontend/managers/settingsManager.library.test.js @@ -0,0 +1,198 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +vi.mock('../../../static/js/managers/ModalManager.js', () => ({ + modalManager: { + closeModal: vi.fn(), + }, +})); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: vi.fn(), +})); + +vi.mock('../../../static/js/state/index.js', () => { + const settings = {}; + return { + state: { + global: { + settings, + }, + }, + createDefaultSettings: () => ({ + language: 'en', + }), + }; +}); + +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: {}, +})); + +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 { showToast } from '../../../static/js/utils/uiHelpers.js'; +import { state } from '../../../static/js/state/index.js'; + +const originalLocation = window.location; + +const createManager = () => { + state.global.settings = {}; + 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 appendLibrarySelect = () => { + const select = document.createElement('select'); + select.id = 'librarySelect'; + document.body.appendChild(select); + return select; +}; + +beforeEach(() => { + document.body.innerHTML = ''; + vi.clearAllMocks(); +}); + +afterEach(() => { + delete global.fetch; + delete document.hidden; + Object.defineProperty(window, 'location', { + value: originalLocation, + configurable: true, + writable: true, + }); +}); + +describe('SettingsManager library controls', () => { + it('loads libraries and populates the select', async () => { + const manager = createManager(); + const select = appendLibrarySelect(); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + libraries: { + beta: { display_name: 'Beta' }, + alpha: { metadata: { display_name: 'Alpha' } }, + }, + active_library: 'beta', + }), + }); + + await manager.loadLibraries(); + + expect(manager.availableLibraries).toEqual({ + beta: { display_name: 'Beta' }, + alpha: { metadata: { display_name: 'Alpha' } }, + }); + expect(manager.activeLibrary).toBe('beta'); + expect(select.options).toHaveLength(2); + expect(Array.from(select.options).map(option => option.value)).toEqual([ + 'alpha', + 'beta', + ]); + expect(select.value).toBe('beta'); + expect(select.disabled).toBe(false); + }); + + it('handles load errors by disabling the select and showing a toast', async () => { + const manager = createManager(); + const select = appendLibrarySelect(); + + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }); + + await manager.loadLibraries(); + + expect(select.options).toHaveLength(1); + expect(select.options[0].value).toBe(''); + expect(select.disabled).toBe(true); + expect(manager.availableLibraries).toEqual({}); + expect(manager.activeLibrary).toBe(''); + expect(showToast).toHaveBeenCalledWith( + 'toast.settings.libraryLoadFailed', + expect.objectContaining({ message: 'Failed to fetch library registry' }), + 'error', + ); + }); + + it('activates a newly selected library and reloads the page', async () => { + const manager = createManager(); + const select = appendLibrarySelect(); + select.appendChild(new Option('Alpha', 'alpha')); + select.appendChild(new Option('Beta', 'beta')); + select.value = 'beta'; + manager.activeLibrary = 'alpha'; + + Object.defineProperty(document, 'hidden', { + value: false, + configurable: true, + }); + + const reloadMock = vi.fn(); + Object.defineProperty(window, 'location', { + value: { reload: reloadMock }, + configurable: true, + }); + + const activateSpy = vi + .spyOn(manager, 'activateLibrary') + .mockResolvedValue({ success: true, active_library: 'beta' }); + + await manager.handleLibraryChange(); + + expect(activateSpy).toHaveBeenCalledWith('beta'); + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(select.disabled).toBe(false); + }); + + it('ignores changes when selecting the active library', async () => { + const manager = createManager(); + const select = appendLibrarySelect(); + select.appendChild(new Option('Alpha', 'alpha')); + select.value = 'alpha'; + manager.activeLibrary = 'alpha'; + + const activateSpy = vi.spyOn(manager, 'activateLibrary'); + + await manager.handleLibraryChange(); + + expect(select.value).toBe('alpha'); + expect(activateSpy).not.toHaveBeenCalled(); + }); +});