feat: add server-side settings persistence for API keys and preferences

This commit is contained in:
Dariusz L
2026-06-05 06:00:06 +02:00
parent 5a8e68655f
commit 96d6456310
5 changed files with 175 additions and 22 deletions
+2
View File
@@ -29,3 +29,5 @@ Thumbs.db
*.bak
*.cache
# User settings – contains API keys and tokens, must NOT be committed
model_resolver_settings.json
+62 -1
View File
@@ -1,4 +1,4 @@
"""
"""
@author: Model Resolver Team
@title: ComfyUI Model Resolver
@nickname: Model Resolver
@@ -1603,6 +1603,67 @@ class ModelResolverExtension:
)
return web.json_response({"error": str(e)}, status=500)
# ==================== SETTINGS (server-side persistence) ====================
import os as _os
import json as _json
_SETTINGS_FILE = _os.path.join(
_os.path.dirname(_os.path.abspath(__file__)),
"model_resolver_settings.json",
)
# Keys that contain sensitive credentials – stored in the file but
# never logged.
_CREDENTIAL_KEYS = {
"civitai_key",
"civitai_session_token",
"hf_token",
"brave_search_api_key",
}
def _load_settings() -> dict:
try:
if _os.path.isfile(_SETTINGS_FILE):
with open(_SETTINGS_FILE, "r", encoding="utf-8") as f:
data = _json.load(f)
if isinstance(data, dict):
return data
except Exception as exc:
self.logger.warning(f"Model Resolver: could not read settings file: {exc}")
return {}
def _save_settings(payload: dict) -> None:
# Merge with existing settings so we never lose keys not sent
# by the current request.
current = _load_settings()
current.update({k: v for k, v in payload.items() if k})
with open(_SETTINGS_FILE, "w", encoding="utf-8") as f:
_json.dump(current, f, indent=2, ensure_ascii=False)
@routes.get("/model_resolver/settings")
async def get_settings_route(request):
"""Return persisted settings (API keys, preferences)."""
try:
data = await asyncio.to_thread(_load_settings)
return web.json_response(data)
except Exception as e:
self.logger.error(f"Model Resolver settings GET error: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500)
@routes.post("/model_resolver/settings")
async def save_settings_route(request):
"""Persist settings (API keys, preferences) to disk."""
try:
payload = await request.json()
if not isinstance(payload, dict):
return web.json_response({"error": "Expected JSON object"}, status=400)
await asyncio.to_thread(_save_settings, payload)
return web.json_response({"success": True})
except Exception as e:
self.logger.error(f"Model Resolver settings POST error: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500)
self.routes_setup = True
self.logger.info("Model Resolver: API routes registered successfully")
return True
+50 -1
View File
@@ -1,4 +1,4 @@
import { app } from "../../../../../scripts/app.js";
import { app } from "../../../../../scripts/app.js";
import { api } from "../../../../../scripts/api.js";
import { $el } from "../../../../../scripts/ui.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
@@ -484,6 +484,55 @@ export const downloadTargetMethods = {
};
},
/**
* Fetch settings saved on the server and sync them into localStorage.
* Call this once when the dialog initialises so every browser gets the
* same tokens without the user having to re-enter them.
*/
async loadSettingsFromServer() {
try {
const resp = await api.fetchApi('/model_resolver/settings');
if (!resp.ok) return;
const data = await resp.json();
if (!data || typeof data !== 'object') return;
// Helper: write to localStorage only when the value from server is
// non-empty, so we don't overwrite a key the user already has locally.
const sync = (localKey, serverValue) => {
if (serverValue !== undefined && serverValue !== null && serverValue !== '') {
localStorage.setItem(localKey, serverValue);
}
};
sync('ModelResolver.civitaiApiKey', data.civitai_key);
sync('ModelResolver.civitaiSessionToken', data.civitai_session_token);
sync('ModelResolver.huggingFaceToken', data.hf_token);
sync('ModelResolver.braveSearchApiKey', data.brave_search_api_key);
if (data.civitai_use_trpc_search !== undefined)
localStorage.setItem('ModelResolver.civitaiUseTrpcSearch', data.civitai_use_trpc_search ? 'true' : 'false');
if (data.civitai_use_html_fallback !== undefined)
localStorage.setItem('ModelResolver.civitaiUseHtmlFallback', data.civitai_use_html_fallback ? 'true' : 'false');
if (data.hf_use_api_search !== undefined)
localStorage.setItem('ModelResolver.hfUseApiSearch', data.hf_use_api_search ? 'true' : 'false');
if (data.hf_use_comfy_org_fallback !== undefined)
localStorage.setItem('ModelResolver.hfUseComfyOrgFallback', data.hf_use_comfy_org_fallback ? 'true' : 'false');
if (data.hf_use_brave_fallback !== undefined)
localStorage.setItem('ModelResolver.hfUseBraveFallback', data.hf_use_brave_fallback ? 'true' : 'false');
if (data.civitai_candidate_limit !== undefined)
localStorage.setItem('ModelResolver.civitaiCandidateLimit', `${data.civitai_candidate_limit}`);
// Source-enabled flags stored as a nested object
if (data.search_source_enabled && typeof data.search_source_enabled === 'object') {
Object.entries(data.search_source_enabled).forEach(([key, val]) => {
if (key) localStorage.setItem(key, val ? 'true' : 'false');
});
}
} catch (err) {
console.warn('Model Resolver: could not load settings from server, using localStorage only.', err);
}
},
async clearSearchCaches() {
for (const state of this.searchResultCache.values()) {
state.activeSearchRunId = null;
@@ -1,4 +1,4 @@
import { app } from "../../../../../scripts/app.js";
import { app } from "../../../../../scripts/app.js";
import { api } from "../../../../../scripts/api.js";
import { $el } from "../../../../../scripts/ui.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
@@ -23,6 +23,13 @@ export const lifecycleGraphMethods = {
// Update button state in case there are active downloads
this.updateDownloadAllButtonState();
// On first open: load settings from server so tokens are available on
// every browser without re-entering them.
if (!this._serverSettingsLoaded) {
this._serverSettingsLoaded = true;
await this.loadSettingsFromServer();
}
// Ensure all models are loaded for dropdown
await this.ensureCapabilitiesLoaded();
await this.ensureAllModelsLoaded();
+53 -19
View File
@@ -1,4 +1,4 @@
import { app } from "../../../../../scripts/app.js";
import { app } from "../../../../../scripts/app.js";
import { api } from "../../../../../scripts/api.js";
import { $el } from "../../../../../scripts/ui.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
@@ -717,34 +717,68 @@ export const optionsMethods = {
const civitaiCandidateLimit = Number.isFinite(civitaiCandidateLimitRaw)
? Math.min(20, Math.max(1, civitaiCandidateLimitRaw))
: 5;
localStorage.setItem('ModelResolver.civitaiApiKey', civitaiInput?.value || '');
localStorage.setItem('ModelResolver.civitaiSessionToken', civitaiSessionInput?.value || '');
localStorage.setItem('ModelResolver.civitaiUseTrpcSearch', civitaiUseTrpcSearchInput?.checked ? 'true' : 'false');
localStorage.setItem('ModelResolver.civitaiUseHtmlFallback', civitaiUseHtmlFallbackInput?.checked ? 'true' : 'false');
localStorage.setItem('ModelResolver.huggingFaceToken', hfInput?.value || '');
localStorage.setItem('ModelResolver.braveSearchApiKey', braveInput?.value || '');
localStorage.setItem('ModelResolver.hfUseApiSearch', hfUseApiSearchInput?.checked ? 'true' : 'false');
localStorage.setItem('ModelResolver.hfUseComfyOrgFallback', hfUseComfyOrgFallbackInput?.checked ? 'true' : 'false');
localStorage.setItem('ModelResolver.hfUseBraveFallback', hfUseBraveFallbackInput?.checked ? 'true' : 'false');
localStorage.setItem('ModelResolver.civitaiCandidateLimit', `${civitaiCandidateLimit}`);
// Collect source-enabled flags
const sourceEnabled = {};
if (sourceEnabledInputs.length && !sourceEnabledInputs.some(input => input.checked)) {
const localInput = sourceEnabledInputs.find(input => input.dataset.source === 'local');
if (localInput) {
localInput.checked = true;
}
if (localInput) localInput.checked = true;
}
sourceEnabledInputs.forEach((input) => {
const key = input.dataset.storageKey;
if (key) {
localStorage.setItem(key, input.checked ? 'true' : 'false');
sourceEnabled[key] = input.checked;
}
});
if (civitaiLimitInput) {
civitaiLimitInput.value = `${civitaiCandidateLimit}`;
const newSettings = {
civitai_key: civitaiInput?.value || '',
civitai_session_token: civitaiSessionInput?.value || '',
civitai_use_trpc_search: Boolean(civitaiUseTrpcSearchInput?.checked),
civitai_use_html_fallback: Boolean(civitaiUseHtmlFallbackInput?.checked),
civitai_candidate_limit: civitaiCandidateLimit,
hf_token: hfInput?.value || '',
hf_use_api_search: Boolean(hfUseApiSearchInput?.checked),
hf_use_comfy_org_fallback: Boolean(hfUseComfyOrgFallbackInput?.checked),
hf_use_brave_fallback: Boolean(hfUseBraveFallbackInput?.checked),
brave_search_api_key: braveInput?.value || '',
search_source_enabled: sourceEnabled,
};
// 1. Write to localStorage (fast local cache)
localStorage.setItem('ModelResolver.civitaiApiKey', newSettings.civitai_key);
localStorage.setItem('ModelResolver.civitaiSessionToken', newSettings.civitai_session_token);
localStorage.setItem('ModelResolver.civitaiUseTrpcSearch', newSettings.civitai_use_trpc_search ? 'true' : 'false');
localStorage.setItem('ModelResolver.civitaiUseHtmlFallback', newSettings.civitai_use_html_fallback ? 'true' : 'false');
localStorage.setItem('ModelResolver.huggingFaceToken', newSettings.hf_token);
localStorage.setItem('ModelResolver.braveSearchApiKey', newSettings.brave_search_api_key);
localStorage.setItem('ModelResolver.hfUseApiSearch', newSettings.hf_use_api_search ? 'true' : 'false');
localStorage.setItem('ModelResolver.hfUseComfyOrgFallback', newSettings.hf_use_comfy_org_fallback ? 'true' : 'false');
localStorage.setItem('ModelResolver.hfUseBraveFallback', newSettings.hf_use_brave_fallback ? 'true' : 'false');
localStorage.setItem('ModelResolver.civitaiCandidateLimit', `${civitaiCandidateLimit}`);
Object.entries(sourceEnabled).forEach(([key, val]) => {
localStorage.setItem(key, val ? 'true' : 'false');
});
if (civitaiLimitInput) civitaiLimitInput.value = `${civitaiCandidateLimit}`;
// 2. Persist to server (survives new browsers / profiles)
try {
setStatus('Saving…', '');
const resp = await api.fetchApi('/model_resolver/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSettings),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
setStatus('Saved on this machine (all browsers).', 'is-saved');
this.showNotification('Options saved', 'success');
} catch (err) {
console.warn('Model Resolver: could not save settings to server, kept locally only.', err);
setStatus('Saved locally only (server write failed).', 'is-saved');
this.showNotification('Options saved locally only', 'warning');
}
await this.clearSearchCaches();
setStatus('Options saved locally.', 'is-saved');
this.showNotification('Options saved and search cache cleared', 'success');
});
}