From 96d6456310b459fe9e531a91a0ec975af71c8e27 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Fri, 5 Jun 2026 06:00:06 +0200 Subject: [PATCH] feat: add server-side settings persistence for API keys and preferences --- .gitignore | 2 + __init__.py | 63 +++++++++++++++- .../search/download_target_methods.js | 51 ++++++++++++- web/resolver/shell/lifecycle_graph_methods.js | 9 ++- web/resolver/views/options_methods.js | 72 ++++++++++++++----- 5 files changed, 175 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index c75cae6..7b7a344 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ Thumbs.db *.bak *.cache +# User settings – contains API keys and tokens, must NOT be committed +model_resolver_settings.json diff --git a/__init__.py b/__init__.py index 35ee49b..d22c5f1 100644 --- a/__init__.py +++ b/__init__.py @@ -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 diff --git a/web/resolver/search/download_target_methods.js b/web/resolver/search/download_target_methods.js index 2ad6ed2..fee46ba 100644 --- a/web/resolver/search/download_target_methods.js +++ b/web/resolver/search/download_target_methods.js @@ -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; diff --git a/web/resolver/shell/lifecycle_graph_methods.js b/web/resolver/shell/lifecycle_graph_methods.js index a367689..5c43ad6 100644 --- a/web/resolver/shell/lifecycle_graph_methods.js +++ b/web/resolver/shell/lifecycle_graph_methods.js @@ -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(); diff --git a/web/resolver/views/options_methods.js b/web/resolver/views/options_methods.js index a4d0593..f6e488a 100644 --- a/web/resolver/views/options_methods.js +++ b/web/resolver/views/options_methods.js @@ -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'); }); }