diff --git a/locales/de.json b/locales/de.json index 913ed9a7..b7d82a2c 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1245,7 +1245,8 @@ "hintStandalone": "Nur die oben aufgeführten Ordnerschlüssel werden gescannt; nicht benötigte Schlüssel können weggelassen werden.", "descriptionComfyUI": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die entsprechenden Modellordner zu Ihren ComfyUI-Modellpfaden hinzu und laden Sie diese Seite neu.", "hintComfyUI": "Weitere Modelle werden aus den Ordnern vae, upscale_models, text_encoders, clip_vision und controlnet von ComfyUI gelesen.", - "openSettings": "Einstellungen öffnen" + "openSettings": "Einstellungen öffnen", + "openSettingsFolder": "[TODO: Translate] Open Settings Folder" } }, "sidebar": { diff --git a/locales/en.json b/locales/en.json index e20f943d..0bacf253 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1241,11 +1241,12 @@ }, "noPaths": { "title": "No other-model folders found", - "descriptionStandalone": "Other Models management is on, but none of the configured model folders exist on disk. Add the folder paths below to settings.json and restart LoRA Manager.", + "descriptionStandalone": "Other Models management is on, but no other-model folders were found. Add the folder keys you need to the folder_paths section of your settings.json, then restart LoRA Manager.", "hintStandalone": "Only the folder keys listed above are scanned; keys you do not need can be omitted.", "descriptionComfyUI": "Other Models management is on, but none of the configured model folders exist on disk. Add the matching model folders to your ComfyUI model paths, then reload this page.", "hintComfyUI": "Other models are read from ComfyUI's vae, upscale_models, text_encoders, clip_vision and controlnet folders.", - "openSettings": "Open Settings" + "openSettings": "Open Settings", + "openSettingsFolder": "Open Settings Folder" } }, "sidebar": { diff --git a/locales/es.json b/locales/es.json index 32f82cbb..bc1291d8 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1245,7 +1245,8 @@ "hintStandalone": "Solo se escanean las claves de carpeta listadas arriba; las claves que no necesites puedes omitirlas.", "descriptionComfyUI": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las carpetas de modelos correspondientes a tus rutas de modelos de ComfyUI y recarga esta página.", "hintComfyUI": "Los otros modelos se leen de las carpetas vae, upscale_models, text_encoders, clip_vision y controlnet de ComfyUI.", - "openSettings": "Abrir configuración" + "openSettings": "Abrir configuración", + "openSettingsFolder": "[TODO: Translate] Open Settings Folder" } }, "sidebar": { diff --git a/locales/fr.json b/locales/fr.json index 54df6875..8b7322ab 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1245,7 +1245,8 @@ "hintStandalone": "Seules les clés de dossiers listées ci-dessus sont analysées ; les clés inutiles peuvent être omises.", "descriptionComfyUI": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés n’existe sur le disque. Ajoutez les dossiers de modèles correspondants à vos chemins de modèles ComfyUI, puis rechargez cette page.", "hintComfyUI": "Les autres modèles sont lus depuis les dossiers vae, upscale_models, text_encoders, clip_vision et controlnet de ComfyUI.", - "openSettings": "Ouvrir les paramètres" + "openSettings": "Ouvrir les paramètres", + "openSettingsFolder": "[TODO: Translate] Open Settings Folder" } }, "sidebar": { diff --git a/locales/he.json b/locales/he.json index d176e6ae..f2c8fdc4 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1245,7 +1245,8 @@ "hintStandalone": "רק מפתחות התיקיות המפורטים למעלה נסרקים; ניתן להשמיט מפתחות שאינך צריך.", "descriptionComfyUI": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את תיקיות המודלים המתאימות לנתיבי המודלים של ComfyUI וטען מחדש עמוד זה.", "hintComfyUI": "מודלים אחרים נקראים מתיקיות vae, upscale_models, text_encoders, clip_vision ו-controlnet של ComfyUI.", - "openSettings": "פתח הגדרות" + "openSettings": "פתח הגדרות", + "openSettingsFolder": "[TODO: Translate] Open Settings Folder" } }, "sidebar": { diff --git a/locales/ja.json b/locales/ja.json index 79a7f703..5acdc53f 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1245,7 +1245,8 @@ "hintStandalone": "スキャンされるのは上記のフォルダーキーのみです。不要なキーは省略できます。", "descriptionComfyUI": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。該当するモデルフォルダーをComfyUIのモデルパスに追加し、このページを再読み込みしてください。", "hintComfyUI": "その他のモデルは、ComfyUIのvae、upscale_models、text_encoders、clip_vision、controlnetフォルダーから読み込まれます。", - "openSettings": "設定を開く" + "openSettings": "設定を開く", + "openSettingsFolder": "[TODO: Translate] Open Settings Folder" } }, "sidebar": { diff --git a/locales/ko.json b/locales/ko.json index cfc3ec9b..8243ce21 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1245,7 +1245,8 @@ "hintStandalone": "위에 나열된 폴더 키만 스캔됩니다. 필요 없는 키는 생략할 수 있습니다.", "descriptionComfyUI": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 해당 모델 폴더를 ComfyUI 모델 경로에 추가한 뒤 이 페이지를 새로 고침하세요.", "hintComfyUI": "기타 모델은 ComfyUI의 vae, upscale_models, text_encoders, clip_vision, controlnet 폴더에서 읽어옵니다.", - "openSettings": "설정 열기" + "openSettings": "설정 열기", + "openSettingsFolder": "[TODO: Translate] Open Settings Folder" } }, "sidebar": { diff --git a/locales/ru.json b/locales/ru.json index 25691fba..2a53fc7b 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1245,7 +1245,8 @@ "hintStandalone": "Сканируются только перечисленные выше ключи папок; ненужные ключи можно опустить.", "descriptionComfyUI": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте соответствующие папки моделей в пути к моделям ComfyUI и перезагрузите эту страницу.", "hintComfyUI": "Другие модели читаются из папок vae, upscale_models, text_encoders, clip_vision и controlnet в ComfyUI.", - "openSettings": "Открыть настройки" + "openSettings": "Открыть настройки", + "openSettingsFolder": "[TODO: Translate] Open Settings Folder" } }, "sidebar": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 6d59d766..06a24257 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1245,7 +1245,8 @@ "hintStandalone": "只会扫描上面列出的文件夹键;不需要的键可以省略。", "descriptionComfyUI": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将对应的模型文件夹添加到 ComfyUI 的模型路径,然后重新加载此页面。", "hintComfyUI": "其他模型从 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 文件夹中读取。", - "openSettings": "打开设置" + "openSettings": "打开设置", + "openSettingsFolder": "[TODO: Translate] Open Settings Folder" } }, "sidebar": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 39f97379..2b1832ed 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1245,7 +1245,8 @@ "hintStandalone": "只會掃描上方列出的資料夾鍵;不需要的鍵可以省略。", "descriptionComfyUI": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將對應的模型資料夾加入 ComfyUI 的模型路徑,然後重新載入此頁面。", "hintComfyUI": "其他模型會從 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 資料夾讀取。", - "openSettings": "開啟設定" + "openSettings": "開啟設定", + "openSettingsFolder": "[TODO: Translate] Open Settings Folder" } }, "sidebar": { diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index e2db3849..1541d5dd 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -421,6 +421,11 @@ def _wsl_to_windows_path(wsl_path: str) -> str | None: return None +def _has_gui_display() -> bool: + """Check whether a GUI session is reachable for xdg-open.""" + return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) + + class PromptServerProtocol(Protocol): """Subset of PromptServer used by the handlers.""" @@ -3393,6 +3398,18 @@ class FileSystemHandler: subprocess.Popen(["open", "-R", settings_file]) else: folder = os.path.dirname(settings_file) + if not _has_gui_display(): + # Headless/SSH session: xdg-open cannot open a file + # manager, so hand the path to the browser for copying + # instead of reporting a success that never happened. + return web.json_response( + { + "success": True, + "message": "Headless session: path available for copying", + "path": settings_file, + "mode": "clipboard", + } + ) subprocess.Popen(["xdg-open", folder]) return web.json_response( diff --git a/py/routes/other_routes.py b/py/routes/other_routes.py index eedf1063..d19b7ca7 100644 --- a/py/routes/other_routes.py +++ b/py/routes/other_routes.py @@ -83,11 +83,16 @@ class OtherRoutes(BaseModelRoutes): # resolved to no existing folder. Render an actionable empty state # instead of an apparently broken empty grid. standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" - return { + context = { "other_disabled": False, "other_no_paths": not bool(config.other_roots), "standalone_mode": standalone_mode, } + if standalone_mode: + # The settings UI cannot edit primary folder_paths, so the empty + # state must point at the actual file the user has to edit. + context["settings_file"] = getattr(self._settings, "settings_file", "") or "" + return context def _get_expected_model_types(self) -> str: """Get expected model types string for error messages""" diff --git a/static/js/other_disabled.js b/static/js/other_disabled.js index 950a4482..8c11f1de 100644 --- a/static/js/other_disabled.js +++ b/static/js/other_disabled.js @@ -7,8 +7,10 @@ import { enableOtherModels, openOtherModelsSettings } from './utils/otherModels. * empty state whose button turns the feature on; the backend then rebuilds the * other-model roots and starts scanning, so a reload lands on the real page. * - * The same module backs the "enabled but no folders found" state, where the - * only useful action is jumping to Settings instead of enabling anything. + * The same module backs the "enabled but no folders found" state: ComfyUI + * mode points to the Settings page's Library section, while standalone mode + * (where the settings UI cannot edit primary folder paths) reveals the + * settings.json file the user must edit instead. */ async function handleEnableClick() { const button = document.getElementById('enableOtherModelsBtn'); @@ -32,6 +34,41 @@ function handleOpenSettingsClick(event) { openOtherModelsSettings(); } +/** + * Open the settings.json location from the standalone no-folders state. + * The settings UI cannot edit primary folder_paths, so the only useful + * action is revealing the file itself (or copying its path in Docker). + */ +async function handleOpenSettingsFolderClick() { + const button = document.getElementById('openSettingsFolderBtn'); + if (!button || button.disabled) return; + + button.disabled = true; + try { + const response = await fetch('/api/lm/settings/open-location', { method: 'POST' }); + const data = await response.json().catch(() => ({})); + if (!response.ok || data.success === false) { + throw new Error(data.error || `HTTP ${response.status}`); + } + if (data.mode === 'clipboard' && data.path) { + try { + await navigator.clipboard.writeText(data.path); + showToast('settings.openSettingsFileLocation.copied', { path: data.path }, 'success'); + } catch (clipboardError) { + console.warn('Clipboard API not available:', clipboardError); + showToast('settings.openSettingsFileLocation.clipboardFallback', { path: data.path }, 'info'); + } + } else { + showToast('settings.openSettingsFileLocation.success', {}, 'success'); + } + } catch (error) { + console.error('Failed to open settings location:', error); + showToast('settings.openSettingsFileLocation.failed', {}, 'error'); + } finally { + button.disabled = false; + } +} + async function initializeOtherDisabledPage() { // appCore.initialize() wires the shared header (theme, settings modal, // language) so this page is not a dead end. @@ -46,8 +83,13 @@ async function initializeOtherDisabledPage() { if (settingsButton) { settingsButton.addEventListener('click', handleOpenSettingsClick); } + + const settingsFolderButton = document.getElementById('openSettingsFolderBtn'); + if (settingsFolderButton) { + settingsFolderButton.addEventListener('click', handleOpenSettingsFolderClick); + } } document.addEventListener('DOMContentLoaded', initializeOtherDisabledPage); -export { handleEnableClick as enableOtherModels, initializeOtherDisabledPage }; +export { handleEnableClick as enableOtherModels, handleOpenSettingsFolderClick, initializeOtherDisabledPage }; diff --git a/templates/other.html b/templates/other.html index f3d9f1db..a8df5938 100644 --- a/templates/other.html +++ b/templates/other.html @@ -63,6 +63,19 @@ background: rgba(127, 127, 127, 0.15); border: 1px solid rgba(127, 127, 127, 0.25); } + .other-settings-file { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + } + .other-settings-file code { + padding: 4px 8px; + border-radius: 4px; + background: rgba(127, 127, 127, 0.15); + border: 1px solid rgba(127, 127, 127, 0.25); + word-break: break-all; + } {% endblock %} @@ -127,6 +140,9 @@

{{ t('other.noPaths.title') }}

{% if standalone_mode %}

{{ t('other.noPaths.descriptionStandalone') }}

+ {% if settings_file %} +

{{ settings_file }}

+ {% endif %}
"folder_paths": {
   "vae": ["/path/to/vae"],
   "upscale_models": ["/path/to/upscale_models"],
@@ -135,13 +151,16 @@
   "controlnet": ["/path/to/controlnet"]
 }

{{ t('other.noPaths.hintStandalone') }}

+ {% else %}

{{ t('other.noPaths.descriptionComfyUI') }}

{{ t('other.noPaths.hintComfyUI') }}

- {% endif %} + {% endif %} {% else %}
diff --git a/tests/frontend/pages/otherDisabledPage.test.js b/tests/frontend/pages/otherDisabledPage.test.js index 4d240ffd..f8b90748 100644 --- a/tests/frontend/pages/otherDisabledPage.test.js +++ b/tests/frontend/pages/otherDisabledPage.test.js @@ -25,6 +25,7 @@ describe('Other Models disabled page', () => { document.body.innerHTML = [ '', '', + '', ].join(''); Object.defineProperty(window, 'location', { @@ -64,6 +65,52 @@ describe('Other Models disabled page', () => { expect(showModal).toHaveBeenCalledWith('settingsModal'); }); + it('reveals the settings.json location from the standalone no-folders state', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, message: 'Opened settings folder' }), + }); + + const button = document.getElementById('openSettingsFolderBtn'); + button.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await vi.waitFor(() => expect(showToastMock).toHaveBeenCalled()); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/lm/settings/open-location', + expect.objectContaining({ method: 'POST' }), + ); + expect(showToastMock).toHaveBeenCalledWith( + 'settings.openSettingsFileLocation.success', + {}, + 'success', + ); + expect(button.disabled).toBe(false); + }); + + it('copies the settings path to the clipboard in Docker mode', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, mode: 'clipboard', path: '/data/settings.json' }), + }); + + document.getElementById('openSettingsFolderBtn').dispatchEvent( + new MouseEvent('click', { bubbles: true }), + ); + await vi.waitFor(() => expect(showToastMock).toHaveBeenCalled()); + + expect(writeText).toHaveBeenCalledWith('/data/settings.json'); + expect(showToastMock).toHaveBeenCalledWith( + 'settings.openSettingsFileLocation.copied', + { path: '/data/settings.json' }, + 'success', + ); + }); + it('enables Other Models through the settings API and reloads', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/tests/routes/test_misc_routes.py b/tests/routes/test_misc_routes.py index 2c2b49f5..0d918446 100644 --- a/tests/routes/test_misc_routes.py +++ b/tests/routes/test_misc_routes.py @@ -532,6 +532,62 @@ async def test_open_backup_location_uses_settings_directory(tmp_path, monkeypatc assert calls == [["xdg-open", str(backup_dir)]] +@pytest.mark.asyncio +async def test_open_settings_location_headless_returns_clipboard_mode(tmp_path, monkeypatch): + """Without a GUI session xdg-open cannot work; the handler must hand the + path to the browser instead of reporting a success that never happened.""" + settings_file = tmp_path / "settings" / "settings.json" + settings_file.parent.mkdir(parents=True, exist_ok=True) + settings_file.write_text("{}", encoding="utf-8") + + handler = FileSystemHandler(settings_service=SimpleNamespace(settings_file=str(settings_file))) + + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False) + monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False) + + popen_calls = [] + monkeypatch.setattr(subprocess, "Popen", lambda *args, **kwargs: popen_calls.append(args)) + + response = await handler.open_settings_location(FakeRequest()) # pyright: ignore[reportArgumentType] + payload = _json_payload(response) + + assert response.status == 200 + assert payload["success"] is True + assert payload["mode"] == "clipboard" + assert payload["path"] == str(settings_file) + assert popen_calls == [] + + +@pytest.mark.asyncio +async def test_open_settings_location_with_display_opens_folder(tmp_path, monkeypatch): + settings_file = tmp_path / "settings" / "settings.json" + settings_file.parent.mkdir(parents=True, exist_ok=True) + settings_file.write_text("{}", encoding="utf-8") + + handler = FileSystemHandler(settings_service=SimpleNamespace(settings_file=str(settings_file))) + + monkeypatch.setenv("DISPLAY", ":0") + monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False) + monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False) + + calls = [] + + def fake_popen(args): + calls.append(args) + return MagicMock() + + monkeypatch.setattr(subprocess, "Popen", fake_popen) + + response = await handler.open_settings_location(FakeRequest()) # pyright: ignore[reportArgumentType] + payload = _json_payload(response) + + assert response.status == 200 + assert payload["success"] is True + assert calls == [["xdg-open", str(settings_file.parent)]] + + @pytest.mark.asyncio async def test_open_wildcards_location_creates_and_opens_directory(tmp_path, monkeypatch): wildcards_dir = tmp_path / "settings" / "wildcards" diff --git a/tests/routes/test_other_routes.py b/tests/routes/test_other_routes.py index 3defdc35..a7af158d 100644 --- a/tests/routes/test_other_routes.py +++ b/tests/routes/test_other_routes.py @@ -121,6 +121,25 @@ def test_page_context_reports_feature_state(monkeypatch): assert provider(None) == {"other_disabled": True, "other_no_paths": False} +def test_page_context_exposes_settings_file_in_standalone(monkeypatch): + """Standalone users must edit settings.json by hand; the empty state + needs the real file path to point them at.""" + from py.config import config + from py.services.settings_manager import get_settings_manager + + manager = get_settings_manager() + handler = OtherRoutes() + provider = handler._get_page_context_provider() + + monkeypatch.setattr(config, "other_roots", [], raising=False) + monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1") + + context = provider(None) + assert context["other_no_paths"] is True + assert context["standalone_mode"] is True + assert context["settings_file"] == manager.settings_file + + def test_get_expected_model_types_mentions_supported_types(): expected = OtherRoutes()._get_expected_model_types() for name in ("VAE", "Upscaler", "TextEncoder", "CLIPVision", "Controlnet"):