mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 05:32:12 -03:00
- Add standalone mode detection via LORA_MANAGER_STANDALONE environment variable - Improve error handling for settings file loading with specific JSON decode errors - Add startup messages system to communicate configuration warnings and errors to users - Include settings file path and startup messages in settings API response - Automatically save settings when bootstrapping from defaults due to missing/invalid settings file - Add configuration warnings collection for environment variables and other settings issues The changes improve robustness of settings management and provide better user feedback when configuration issues occur.
This commit is contained in:
@@ -238,7 +238,16 @@ class SettingsHandler:
|
||||
value = self._settings.get(key)
|
||||
if value is not None:
|
||||
response_data[key] = value
|
||||
return web.json_response({"success": True, "settings": response_data})
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
if settings_file:
|
||||
response_data["settings_file"] = settings_file
|
||||
messages_getter = getattr(self._settings, "get_startup_messages", None)
|
||||
messages = list(messages_getter()) if callable(messages_getter) else []
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"settings": response_data,
|
||||
"messages": messages,
|
||||
})
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Error getting settings: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
@@ -53,6 +53,10 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
class SettingsManager:
|
||||
def __init__(self):
|
||||
self.settings_file = ensure_settings_file(logger)
|
||||
self._standalone_mode = self._detect_standalone_mode()
|
||||
self._startup_messages: List[Dict[str, Any]] = []
|
||||
self._needs_initial_save = False
|
||||
self._bootstrap_reason: Optional[str] = None
|
||||
self.settings = self._load_settings()
|
||||
self._migrate_setting_keys()
|
||||
self._ensure_default_settings()
|
||||
@@ -60,6 +64,16 @@ class SettingsManager:
|
||||
self._migrate_download_path_template()
|
||||
self._auto_set_default_roots()
|
||||
self._check_environment_variables()
|
||||
self._collect_configuration_warnings()
|
||||
|
||||
if self._needs_initial_save:
|
||||
self._save_settings()
|
||||
self._needs_initial_save = False
|
||||
|
||||
def _detect_standalone_mode(self) -> bool:
|
||||
"""Return ``True`` when running in standalone mode."""
|
||||
|
||||
return os.environ.get("LORA_MANAGER_STANDALONE") == "1"
|
||||
|
||||
def _load_settings(self) -> Dict[str, Any]:
|
||||
"""Load settings from file"""
|
||||
@@ -67,8 +81,39 @@ class SettingsManager:
|
||||
try:
|
||||
with open(self.settings_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading settings: {e}")
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.error("Failed to parse settings.json: %s", exc)
|
||||
self._add_startup_message(
|
||||
code="settings-json-invalid",
|
||||
title="Settings file could not be parsed",
|
||||
message=(
|
||||
"LoRA Manager could not parse settings.json. Default settings "
|
||||
"will be used for this session."
|
||||
),
|
||||
severity="error",
|
||||
actions=self._default_settings_actions(),
|
||||
details=str(exc),
|
||||
dismissible=False,
|
||||
)
|
||||
self._needs_initial_save = True
|
||||
self._bootstrap_reason = "invalid"
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.error("Unexpected error loading settings: %s", exc)
|
||||
self._add_startup_message(
|
||||
code="settings-json-unreadable",
|
||||
title="Settings file could not be read",
|
||||
message="LoRA Manager could not read settings.json. Default settings will be used for this session.",
|
||||
severity="error",
|
||||
actions=self._default_settings_actions(),
|
||||
details=str(exc),
|
||||
dismissible=False,
|
||||
)
|
||||
self._needs_initial_save = True
|
||||
self._bootstrap_reason = "unreadable"
|
||||
|
||||
if not os.path.exists(self.settings_file):
|
||||
self._needs_initial_save = True
|
||||
self._bootstrap_reason = "missing"
|
||||
return self._get_default_settings()
|
||||
|
||||
def _ensure_default_settings(self) -> None:
|
||||
@@ -393,6 +438,86 @@ class SettingsManager:
|
||||
self.settings['civitai_api_key'] = env_api_key
|
||||
self._save_settings()
|
||||
|
||||
def _default_settings_actions(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"action": "open-settings-location",
|
||||
"label": "Open settings folder",
|
||||
"type": "primary",
|
||||
"icon": "fas fa-folder-open",
|
||||
}
|
||||
]
|
||||
|
||||
def _add_startup_message(
|
||||
self,
|
||||
*,
|
||||
code: str,
|
||||
title: str,
|
||||
message: str,
|
||||
severity: str = "info",
|
||||
actions: Optional[List[Dict[str, Any]]] = None,
|
||||
details: Optional[str] = None,
|
||||
dismissible: bool = False,
|
||||
) -> None:
|
||||
if any(existing.get("code") == code for existing in self._startup_messages):
|
||||
return
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"code": code,
|
||||
"title": title,
|
||||
"message": message,
|
||||
"severity": severity.lower(),
|
||||
"dismissible": bool(dismissible),
|
||||
}
|
||||
|
||||
if actions:
|
||||
payload["actions"] = [dict(action) for action in actions]
|
||||
if details:
|
||||
payload["details"] = details
|
||||
payload["settings_file"] = self.settings_file
|
||||
|
||||
self._startup_messages.append(payload)
|
||||
|
||||
def _collect_configuration_warnings(self) -> None:
|
||||
if not self._standalone_mode:
|
||||
return
|
||||
|
||||
folder_paths = self.settings.get('folder_paths', {}) or {}
|
||||
monitored_keys = ('loras', 'checkpoints', 'embeddings')
|
||||
|
||||
has_valid_paths = False
|
||||
for key in monitored_keys:
|
||||
raw_paths = folder_paths.get(key) or []
|
||||
if isinstance(raw_paths, str):
|
||||
raw_paths = [raw_paths]
|
||||
try:
|
||||
iterator = list(raw_paths)
|
||||
except TypeError:
|
||||
continue
|
||||
if any(isinstance(path, str) and path and os.path.exists(path) for path in iterator):
|
||||
has_valid_paths = True
|
||||
break
|
||||
|
||||
if not has_valid_paths:
|
||||
if self._bootstrap_reason == "missing":
|
||||
message = (
|
||||
"LoRA Manager created a default settings.json because no configuration was found. "
|
||||
"Edit settings.json to add your model directories so library scanning can run."
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
"LoRA Manager could not locate any configured model directories. "
|
||||
"Edit settings.json to add your model folders so library scanning can run."
|
||||
)
|
||||
self._add_startup_message(
|
||||
code="missing-model-paths",
|
||||
title="Model folders need setup",
|
||||
message=message,
|
||||
severity="warning",
|
||||
actions=self._default_settings_actions(),
|
||||
dismissible=False,
|
||||
)
|
||||
|
||||
def refresh_environment_variables(self) -> None:
|
||||
"""Refresh settings from environment variables"""
|
||||
self._check_environment_variables()
|
||||
@@ -427,6 +552,9 @@ class SettingsManager:
|
||||
self._save_settings()
|
||||
return normalized.copy()
|
||||
|
||||
def get_startup_messages(self) -> List[Dict[str, Any]]:
|
||||
return [message.copy() for message in self._startup_messages]
|
||||
|
||||
def get_priority_tag_entries(self, model_type: str) -> List[PriorityTagEntry]:
|
||||
config = self.get_priority_tag_config()
|
||||
raw_config = config.get(model_type, "")
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
import sys
|
||||
import json
|
||||
from py.middleware.cache_middleware import cache_control
|
||||
from py.utils.settings_paths import ensure_settings_file, get_settings_dir
|
||||
from py.utils.settings_paths import ensure_settings_file
|
||||
|
||||
# Set environment variable to indicate standalone mode
|
||||
os.environ["LORA_MANAGER_STANDALONE"] = "1"
|
||||
@@ -228,54 +228,43 @@ class StandaloneServer:
|
||||
from py.lora_manager import LoraManager
|
||||
|
||||
def validate_settings():
|
||||
"""Validate that settings.json exists and has required configuration"""
|
||||
settings_path = ensure_settings_file(logger)
|
||||
if not os.path.exists(settings_path):
|
||||
logger.error("=" * 80)
|
||||
logger.error("CONFIGURATION ERROR: settings.json file not found!")
|
||||
logger.error("")
|
||||
logger.error("Expected location: %s", settings_path)
|
||||
logger.error("")
|
||||
logger.error("To run in standalone mode, you need to create a settings.json file.")
|
||||
logger.error("Please follow these steps:")
|
||||
logger.error("")
|
||||
logger.error("1. Copy the provided settings.json.example file to create a new file")
|
||||
logger.error(" named settings.json inside the LoRA Manager settings folder:")
|
||||
logger.error(" %s", get_settings_dir())
|
||||
logger.error("")
|
||||
logger.error("2. Edit settings.json to include your correct model folder paths")
|
||||
logger.error(" and CivitAI API key")
|
||||
logger.error("=" * 80)
|
||||
return False
|
||||
|
||||
# Check if settings.json has valid folder paths
|
||||
"""Initialize settings and log any startup warnings."""
|
||||
try:
|
||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||
settings = json.load(f)
|
||||
|
||||
folder_paths = settings.get('folder_paths', {})
|
||||
has_valid_paths = False
|
||||
|
||||
for path_type in ['loras', 'checkpoints', 'embeddings']:
|
||||
paths = folder_paths.get(path_type, [])
|
||||
if paths and any(os.path.exists(p) for p in paths):
|
||||
has_valid_paths = True
|
||||
break
|
||||
|
||||
if not has_valid_paths:
|
||||
logger.warning("=" * 80)
|
||||
logger.warning("CONFIGURATION WARNING: No valid model folder paths found!")
|
||||
logger.warning("")
|
||||
logger.warning("Your settings.json exists but doesn't contain valid folder paths.")
|
||||
logger.warning("Please check and update the folder_paths section in settings.json")
|
||||
logger.warning("to include existing directories for your models.")
|
||||
logger.warning("=" * 80)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading settings.json: {e}")
|
||||
from py.services.settings_manager import get_settings_manager
|
||||
|
||||
manager = get_settings_manager()
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Failed to initialise settings manager: %s", exc, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
messages = manager.get_startup_messages()
|
||||
if messages:
|
||||
logger.warning("=" * 80)
|
||||
logger.warning("Standalone mode is using fallback configuration values.")
|
||||
for message in messages:
|
||||
severity = (message.get("severity") or "info").lower()
|
||||
title = message.get("title")
|
||||
body = message.get("message") or ""
|
||||
details = message.get("details")
|
||||
location = message.get("settings_file") or manager.settings_file
|
||||
|
||||
text = f"{title}: {body}" if title else body
|
||||
log_method = logger.info
|
||||
if severity == "error":
|
||||
log_method = logger.error
|
||||
elif severity == "warning":
|
||||
log_method = logger.warning
|
||||
|
||||
log_method(text)
|
||||
if details:
|
||||
log_method("Details: %s", details)
|
||||
if location:
|
||||
log_method("Settings file: %s", location)
|
||||
|
||||
logger.warning("=" * 80)
|
||||
else:
|
||||
logger.info("Loaded settings from %s", manager.settings_file)
|
||||
|
||||
return True
|
||||
|
||||
class StandaloneLoraManager(LoraManager):
|
||||
|
||||
@@ -7,6 +7,7 @@ import { translate } from '../utils/i18nHelpers.js';
|
||||
import { i18n } from '../i18n/index.js';
|
||||
import { configureModelCardVideo } from '../components/shared/ModelCard.js';
|
||||
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
|
||||
import { bannerService } from './BannerService.js';
|
||||
|
||||
export class SettingsManager {
|
||||
constructor() {
|
||||
@@ -15,6 +16,8 @@ export class SettingsManager {
|
||||
this.initializationPromise = null;
|
||||
this.availableLibraries = {};
|
||||
this.activeLibrary = '';
|
||||
this.settingsFilePath = null;
|
||||
this.registeredStartupBannerIds = new Set();
|
||||
|
||||
// Add initialization to sync with modal state
|
||||
this.currentPage = document.body.dataset.page || 'loras';
|
||||
@@ -52,14 +55,18 @@ export class SettingsManager {
|
||||
const data = await response.json();
|
||||
if (data.success && data.settings) {
|
||||
state.global.settings = this.mergeSettingsWithDefaults(data.settings);
|
||||
this.settingsFilePath = data.settings.settings_file || this.settingsFilePath;
|
||||
this.registerStartupMessages(data.messages);
|
||||
console.log('Settings synced from backend');
|
||||
} else {
|
||||
console.error('Failed to sync settings from backend:', data.error);
|
||||
state.global.settings = this.mergeSettingsWithDefaults();
|
||||
this.registerStartupMessages(data?.messages);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to sync settings from backend:', error);
|
||||
state.global.settings = this.mergeSettingsWithDefaults();
|
||||
this.registerStartupMessages();
|
||||
}
|
||||
|
||||
await this.applyLanguageSetting();
|
||||
@@ -128,6 +135,90 @@ export class SettingsManager {
|
||||
return merged;
|
||||
}
|
||||
|
||||
registerStartupMessages(messages = []) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const severityPriority = {
|
||||
error: 90,
|
||||
warning: 60,
|
||||
info: 30,
|
||||
};
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.settingsFilePath && typeof message.settings_file === 'string') {
|
||||
this.settingsFilePath = message.settings_file;
|
||||
}
|
||||
|
||||
const bannerId = `startup-${message.code || index}`;
|
||||
if (this.registeredStartupBannerIds.has(bannerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const severity = (message.severity || 'info').toLowerCase();
|
||||
const bannerTitle = message.title || 'Configuration notice';
|
||||
const bannerContent = message.message || message.content || '';
|
||||
const priority = typeof message.priority === 'number'
|
||||
? message.priority
|
||||
: severityPriority[severity] || severityPriority.info;
|
||||
const dismissible = message.dismissible !== false;
|
||||
|
||||
const normalizedActions = Array.isArray(message.actions)
|
||||
? message.actions.map(action => ({
|
||||
text: action.label || action.text || 'Review settings',
|
||||
icon: action.icon || 'fas fa-cog',
|
||||
action: action.action,
|
||||
type: action.type || 'primary',
|
||||
url: action.url,
|
||||
}))
|
||||
: [];
|
||||
|
||||
bannerService.registerBanner(bannerId, {
|
||||
id: bannerId,
|
||||
title: bannerTitle,
|
||||
content: bannerContent,
|
||||
actions: normalizedActions,
|
||||
dismissible,
|
||||
priority,
|
||||
onRegister: (bannerElement) => {
|
||||
normalizedActions.forEach(action => {
|
||||
if (!action.action) {
|
||||
return;
|
||||
}
|
||||
|
||||
const button = bannerElement.querySelector(`.banner-action[data-action="${action.action}"]`);
|
||||
if (button) {
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
this.handleStartupBannerAction(action.action);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
this.registeredStartupBannerIds.add(bannerId);
|
||||
});
|
||||
}
|
||||
|
||||
handleStartupBannerAction(action) {
|
||||
switch (action) {
|
||||
case 'open-settings-modal':
|
||||
modalManager.showModal('settingsModal');
|
||||
break;
|
||||
case 'open-settings-location':
|
||||
this.openSettingsFileLocation();
|
||||
break;
|
||||
default:
|
||||
console.warn('Unhandled startup banner action:', action);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to determine if a setting should be saved to backend
|
||||
isBackendSetting(settingKey) {
|
||||
return this.backendSettingKeys.has(settingKey);
|
||||
@@ -199,6 +290,9 @@ export class SettingsManager {
|
||||
|
||||
const openSettingsLocationButton = document.querySelector('.settings-open-location-trigger');
|
||||
if (openSettingsLocationButton) {
|
||||
if (openSettingsLocationButton.dataset.settingsPath) {
|
||||
this.settingsFilePath = openSettingsLocationButton.dataset.settingsPath;
|
||||
}
|
||||
openSettingsLocationButton.addEventListener('click', () => {
|
||||
const filePath = openSettingsLocationButton.dataset.settingsPath;
|
||||
this.openSettingsFileLocation(filePath);
|
||||
@@ -235,7 +329,9 @@ export class SettingsManager {
|
||||
}
|
||||
|
||||
async openSettingsFileLocation(filePath) {
|
||||
if (!filePath) {
|
||||
const targetPath = filePath || this.settingsFilePath || document.querySelector('.settings-open-location-trigger')?.dataset.settingsPath;
|
||||
|
||||
if (!targetPath) {
|
||||
showToast('settings.openSettingsFileLocation.failed', {}, 'error');
|
||||
return;
|
||||
}
|
||||
@@ -246,13 +342,15 @@ export class SettingsManager {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ file_path: filePath }),
|
||||
body: JSON.stringify({ file_path: targetPath }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
this.settingsFilePath = targetPath;
|
||||
|
||||
showToast('settings.openSettingsFileLocation.success', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to open settings file location:', error);
|
||||
|
||||
@@ -108,9 +108,10 @@ def test_validate_settings_warns_for_missing_model_paths(caplog, standalone_modu
|
||||
}
|
||||
)
|
||||
|
||||
assert standalone_module.validate_settings() is False
|
||||
assert standalone_module.validate_settings() is True
|
||||
warning_lines = [record.message for record in caplog.records if record.levelname == "WARNING"]
|
||||
assert any("CONFIGURATION WARNING" in line for line in warning_lines)
|
||||
assert any("Standalone mode is using fallback" in line for line in warning_lines)
|
||||
assert any("Model folders need setup" in line for line in warning_lines)
|
||||
|
||||
|
||||
def test_standalone_lora_manager_registers_routes(monkeypatch, tmp_path, standalone_module):
|
||||
|
||||
93
tests/test_standalone_settings.py
Normal file
93
tests/test_standalone_settings.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from py.services.settings_manager import get_settings_manager, reset_settings_manager
|
||||
from py.utils import settings_paths
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_settings(tmp_path, monkeypatch):
|
||||
"""Reset the settings manager and redirect config to a temp directory."""
|
||||
def fake_user_config_dir(*args, **kwargs):
|
||||
return str(tmp_path / "config")
|
||||
|
||||
monkeypatch.setattr(settings_paths, "user_config_dir", fake_user_config_dir)
|
||||
monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1")
|
||||
reset_settings_manager()
|
||||
yield
|
||||
reset_settings_manager()
|
||||
|
||||
|
||||
def read_settings_file(path: Path) -> dict:
|
||||
with path.open('r', encoding='utf-8') as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def test_missing_settings_creates_defaults_and_emits_warnings(tmp_path):
|
||||
manager = get_settings_manager()
|
||||
settings_path = Path(manager.settings_file)
|
||||
|
||||
assert settings_path.exists()
|
||||
assert read_settings_file(settings_path)
|
||||
|
||||
messages = manager.get_startup_messages()
|
||||
codes = {message["code"] for message in messages}
|
||||
assert codes == {"missing-model-paths"}
|
||||
|
||||
warning = messages[0]
|
||||
assert "default settings.json" in warning["message"].lower()
|
||||
assert warning["dismissible"] is False
|
||||
|
||||
actions = warning.get("actions") or []
|
||||
assert actions == [
|
||||
{
|
||||
"action": "open-settings-location",
|
||||
"label": "Open settings folder",
|
||||
"type": "primary",
|
||||
"icon": "fas fa-folder-open",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_invalid_settings_recovers_with_defaults(tmp_path):
|
||||
config_dir = Path(settings_paths.user_config_dir())
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
settings_path = config_dir / "settings.json"
|
||||
settings_path.write_text("{ invalid json", encoding='utf-8')
|
||||
|
||||
manager = get_settings_manager()
|
||||
|
||||
assert settings_path.exists()
|
||||
data = read_settings_file(settings_path)
|
||||
assert isinstance(data, dict)
|
||||
|
||||
codes = {message["code"] for message in manager.get_startup_messages()}
|
||||
assert "settings-json-invalid" in codes
|
||||
|
||||
|
||||
def test_missing_settings_skips_warning_when_embedded(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LORA_MANAGER_STANDALONE", "0")
|
||||
reset_settings_manager()
|
||||
|
||||
manager = get_settings_manager()
|
||||
|
||||
assert manager.get_startup_messages() == []
|
||||
|
||||
|
||||
def test_validate_settings_logs_warnings(tmp_path, monkeypatch, caplog):
|
||||
monkeypatch.setattr(settings_paths, "user_config_dir", lambda *args, **kwargs: str(tmp_path / "config"))
|
||||
|
||||
reset_settings_manager()
|
||||
import standalone
|
||||
importlib.reload(standalone)
|
||||
|
||||
reset_settings_manager()
|
||||
|
||||
with caplog.at_level("INFO", logger="lora-manager-standalone"):
|
||||
assert standalone.validate_settings() is True
|
||||
|
||||
messages = [record.message for record in caplog.records]
|
||||
assert any("Standalone mode is using fallback configuration values." in message for message in messages)
|
||||
Reference in New Issue
Block a user