Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 200 additions & 5 deletions src/webui/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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();
}
"""

Expand Down
25 changes: 12 additions & 13 deletions src/webui/webui_manager.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
"""
Expand All @@ -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