diff --git a/src/webui/interface.py b/src/webui/interface.py index 083649e69..451600860 100644 --- a/src/webui/interface.py +++ b/src/webui/interface.py @@ -42,15 +42,210 @@ def create_ui(theme_name="Ocean"): } """ - # dark mode in default + # dark mode in default + browser-side autosave backup for UI settings js_func = """ - function refresh() { - const url = new URL(window.location); - + function initializeWebUiClientState() { + const url = new URL(window.location.href); if (url.searchParams.get('__theme') !== 'dark') { url.searchParams.set('__theme', 'dark'); - window.location.href = url.href; + window.location.href = url.toString(); + return; + } + + if (window.__webuiBackupInitDone) { + return; + } + window.__webuiBackupInitDone = true; + + const DB_NAME = "webui-config-backup"; + const STORE_NAME = "settings"; + const RECORD_KEY = "ui-components-v1"; + const FALLBACK_STORAGE_KEY = "webui-config-backup-fallback"; + const SAVE_INTERVAL_MS = 60000; + + const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + function isVisible(element) { + return !!(element && (element.offsetParent !== null || element.getClientRects().length)); } + + function firstVisible(elements) { + return Array.from(elements).find((el) => isVisible(el) && !el.disabled && !el.readOnly); + } + + function getPrimaryControl(componentRoot) { + return ( + firstVisible(componentRoot.querySelectorAll("input[type='checkbox']")) || + firstVisible(componentRoot.querySelectorAll("textarea")) || + firstVisible(componentRoot.querySelectorAll("select")) || + firstVisible(componentRoot.querySelectorAll("input[type='range']")) || + firstVisible(componentRoot.querySelectorAll("input[type='number']")) || + firstVisible( + componentRoot.querySelectorAll( + "input:not([type='file']):not([type='button']):not([type='submit']):not([type='hidden'])" + ) + ) + ); + } + + function readControlValue(componentRoot) { + const control = getPrimaryControl(componentRoot); + if (!control) { + return undefined; + } + if (control.type === "checkbox") { + return !!control.checked; + } + return control.value; + } + + function isSensitiveControl(componentRoot, control) { + const id = (componentRoot.id || "").toLowerCase(); + return ( + control.type === "password" || + id.includes("api-key") || + id.includes("apikey") || + id.includes("token") || + id.includes("secret") || + id.includes("password") + ); + } + + function writeControlValue(componentRoot, value) { + const control = getPrimaryControl(componentRoot); + if (!control) { + return; + } + if (control.type === "checkbox") { + control.checked = !!value; + } else { + control.value = value == null ? "" : String(value); + } + ["input", "change", "blur"].forEach((eventName) => { + control.dispatchEvent(new Event(eventName, { bubbles: true })); + }); + } + + function collectUiState() { + const state = {}; + document.querySelectorAll("[id^='webui-']").forEach((componentRoot) => { + const control = getPrimaryControl(componentRoot); + if (!control || isSensitiveControl(componentRoot, control)) { + return; + } + const value = readControlValue(componentRoot); + if (value !== undefined) { + state[componentRoot.id] = value; + } + }); + return state; + } + + function openBackupDb() { + return new Promise((resolve, reject) => { + const request = window.indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: "id" }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + async function loadBackupState() { + try { + const db = await openBackupDb(); + return await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + const request = store.get(RECORD_KEY); + request.onsuccess = () => resolve(request.result ? request.result.payload : null); + request.onerror = () => reject(request.error); + }); + } catch (_) { + const raw = localStorage.getItem(FALLBACK_STORAGE_KEY); + if (!raw) { + return null; + } + try { + return JSON.parse(raw); + } catch (_) { + localStorage.removeItem(FALLBACK_STORAGE_KEY); + return null; + } + } + } + + async function persistBackupState(payload) { + try { + const db = await openBackupDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + store.put({ + id: RECORD_KEY, + updatedAt: Date.now(), + payload + }); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + } catch (_) { + localStorage.setItem(FALLBACK_STORAGE_KEY, JSON.stringify(payload)); + } + } + + async function restoreUiState() { + const savedState = await loadBackupState(); + if (!savedState || typeof savedState !== "object") { + return; + } + + const entries = Object.entries(savedState); + for (let pass = 0; pass < 3; pass++) { + entries.forEach(([id, value]) => { + const componentRoot = document.getElementById(id); + if (componentRoot) { + const control = getPrimaryControl(componentRoot); + if (control && isSensitiveControl(componentRoot, control)) { + return; + } + writeControlValue(componentRoot, value); + } + }); + await delay(350); + } + } + + async function saveUiState() { + const state = collectUiState(); + await persistBackupState(state); + } + + async function setupPersistence() { + await delay(1000); + await restoreUiState(); + + setInterval(() => { + void saveUiState(); + }, SAVE_INTERVAL_MS); + + window.addEventListener("beforeunload", () => { + void saveUiState(); + }); + + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + void saveUiState(); + } + }); + } + + void setupPersistence(); } """ diff --git a/src/webui/webui_manager.py b/src/webui/webui_manager.py index 0a9d5e163..a88c1d56b 100644 --- a/src/webui/webui_manager.py +++ b/src/webui/webui_manager.py @@ -1,6 +1,6 @@ import json from collections.abc import Generator -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import os import gradio as gr from datetime import datetime @@ -56,6 +56,8 @@ def add_components(self, tab_name: str, components_dict: dict[str, "Component"]) """ for comp_name, component in components_dict.items(): comp_id = f"{tab_name}.{comp_name}" + if hasattr(component, "elem_id") and not getattr(component, "elem_id", None): + component.elem_id = f"webui-{tab_name}-{comp_name}".replace(".", "-").replace("_", "-") self.id_to_component[comp_id] = component self.component_to_id[component] = comp_id @@ -77,7 +79,7 @@ def get_id_by_component(self, comp: "Component") -> str: """ return self.component_to_id[comp] - def save_config(self, components: Dict["Component", str]) -> None: + def save_config(self, components: Dict["Component", str]) -> str: """ Save config """ @@ -94,29 +96,26 @@ def save_config(self, components: Dict["Component", str]) -> None: return os.path.join(self.settings_save_dir, f"{config_name}.json") - def load_config(self, config_path: str): + def load_config(self, config_path: str) -> Generator[Dict[Component, Any], None, None]: """ Load config """ with open(config_path, "r") as fr: - ui_settings = json.load(fr) + ui_settings: Dict[str, Any] = json.load(fr) - update_components = {} + update_components: Dict[Component, Any] = {} for comp_id, comp_val in ui_settings.items(): if comp_id in self.id_to_component: comp = self.id_to_component[comp_id] - if comp.__class__.__name__ == "Chatbot": - update_components[comp] = comp.__class__(value=comp_val, type="messages") - else: - update_components[comp] = comp.__class__(value=comp_val) - if comp_id == "agent_settings.planner_llm_provider": - yield update_components # yield provider, let callback run - time.sleep(0.1) # wait for Gradio UI callback + update_components[comp] = gr.update(value=comp_val) + if comp_id == "agent_settings.planner_llm_provider": + yield update_components # yield provider, let callback run + time.sleep(0.1) # wait for Gradio UI callback config_status = self.id_to_component["load_save_config.config_status"] update_components.update( { - config_status: config_status.__class__(value=f"Successfully loaded config: {config_path}") + config_status: gr.update(value=f"Successfully loaded config: {config_path}") } ) yield update_components