diff --git a/scripts/verify_frontend_decomposition_contract.mjs b/scripts/verify_frontend_decomposition_contract.mjs new file mode 100644 index 0000000..6386345 --- /dev/null +++ b/scripts/verify_frontend_decomposition_contract.mjs @@ -0,0 +1,111 @@ +/** Verify the frozen R224 Settings/API frontend contract. */ + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const CONTRACT_PATH = path.join(ROOT, "web", "tests", "fixtures", "frontend_decomposition_contract_r224.json"); + +function canonicalJson(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function read(relativePath) { + return fs.readFileSync(path.join(ROOT, relativePath), "utf8"); +} + +function familySources(directory, prefix) { + return fs.readdirSync(path.join(ROOT, directory)) + .filter((name) => name === `${prefix}.js` || name.startsWith(`${prefix}_`)) + .sort() + .map((name) => read(path.join(directory, name))) + .join("\n"); +} + +function uniqueSorted(values) { + return [...new Set(values)].sort(); +} + +function matches(source, pattern, group = 1) { + return [...source.matchAll(pattern)].map((match) => match[group]); +} + +function methodSignatures(source) { + const result = {}; + const pattern = /^\s{4}(?:async\s+)?([A-Za-z_$][\w$]*)\(([^)]*)\)\s*\{/gm; + for (const match of source.matchAll(pattern)) { + result[match[1]] = match[2].replace(/\s+/g, " ").trim(); + } + return Object.fromEntries(Object.entries(result).sort(([a], [b]) => a.localeCompare(b))); +} + +function digest(relativePath) { + return crypto.createHash("sha256").update(fs.readFileSync(path.join(ROOT, relativePath))).digest("hex"); +} + +export function buildContract() { + const apiFacade = read("web/openclaw_api.js"); + const settingsFacade = read("web/tabs/settings_tab.js"); + const apiSources = familySources("web", "openclaw_api"); + const settingsSources = familySources("web/tabs", "settings_tab"); + return { + schema_version: 1, + api: { + exports: matches(apiFacade, /^export\s+(?:class|const)\s+([A-Za-z_$][\w$]*)/gm), + methods: methodSignatures(apiSources), + constructor_state: uniqueSorted(matches( + apiFacade, + /^\s{8}this\.([A-Za-z_$][\w$]*)\s*=/gm, + )), + path_suffixes: uniqueSorted(matches(apiSources, /this\._path\("([^"]+)"\)/g)), + compatibility_seams: [ + "fetch", + "_fetchWithCandidates", + "_capabilitiesCache", + "_capabilitiesCacheTs", + "streamSSEPost", + "subscribeEvents", + ], + }, + settings: { + exports: matches(settingsFacade, /^export\s+const\s+([A-Za-z_$][\w$]*)/gm), + identity: { + id: settingsFacade.match(/\bid:\s*"([^"]+)"/)?.[1] || "", + title: settingsFacade.match(/\btitle:\s*"([^"]+)"/)?.[1] || "", + icon: settingsFacade.match(/\bicon:\s*"([^"]+)"/)?.[1] || "", + }, + dom_ids: uniqueSorted(matches(settingsSources, /id="(openclaw-[^"]+)"/g)), + class_tokens: uniqueSorted(matches(settingsSources, /\b(openclaw-[a-z0-9-]+)\b/g)), + section_headings: uniqueSorted(matches( + settingsSources, + /create(?:Collapsible)?Section\("([^"]+)"/g, + )), + compatibility_seams: ["settingsTab", "settingsTab.render"], + }, + upstream_contract_digests: { + "tests/api_route_contract_r220.json": digest("tests/api_route_contract_r220.json"), + "tests/api_config_contract_r221.json": digest("tests/api_config_contract_r221.json"), + "tests/platform_adapter_contract_r223.json": digest("tests/platform_adapter_contract_r223.json"), + }, + }; +} + +export function verifyContract({ writeBaseline = false } = {}) { + const actual = buildContract(); + if (writeBaseline) { + fs.mkdirSync(path.dirname(CONTRACT_PATH), { recursive: true }); + fs.writeFileSync(CONTRACT_PATH, canonicalJson(actual), "utf8"); + return { ok: true, message: `FRONTEND-CONTRACT-WRITTEN:${CONTRACT_PATH}` }; + } + const expected = JSON.parse(fs.readFileSync(CONTRACT_PATH, "utf8")); + const ok = canonicalJson(actual) === canonicalJson(expected); + return { ok, message: ok ? "FRONTEND-CONTRACT-PASS" : "FRONTEND-CONTRACT-FAIL" }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const result = verifyContract({ writeBaseline: process.argv.includes("--write-baseline") }); + console.log(result.message); + process.exit(result.ok ? 0 : 1); +} diff --git a/web/openclaw_api.js b/web/openclaw_api.js index 623e832..09c2205 100644 --- a/web/openclaw_api.js +++ b/web/openclaw_api.js @@ -3,10 +3,14 @@ * Provides consistent fetch usage, timeout handling, and type-safe response shapes. */ import { OpenClawSession } from "./openclaw_session.js"; -import { fetchApi, apiURL, fileURL } from "./openclaw_comfy_api.js"; +import { fetchApi, fileURL } from "./openclaw_comfy_api.js"; import { API_PREFIXES, buildAdminTokenHeaders, getApiPathCandidates } from "./openclaw_compat.js"; import { isAbortError, linkAbortSignal, parseJsonSafe } from "./openclaw_utils.js"; -import { normalizeComfyOutputRef } from "./openclaw_asset_refs.js"; +import { configApiMethods } from "./openclaw_api_config.js"; +import { eventApiMethods } from "./openclaw_api_events.js"; +import { generationApiMethods } from "./openclaw_api_generation.js"; +import { modelApiMethods } from "./openclaw_api_models.js"; +import { resourceApiMethods } from "./openclaw_api_resources.js"; import { composeFetchWrappersOnce, withAbortPassthrough, @@ -187,203 +191,6 @@ export class OpenClawAPI { // --- Endpoints --- - async getHealth() { - return this.fetch(this._path("/health")); - } - - async getLogs(lines = 200) { - return this.fetch(`${this._path("/logs/tail")}?lines=${lines}`); - } - - async validateWebhook(payload) { - return this.fetch(this._path("/webhook"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - } - - async submitWebhook(payload) { - return this.fetch(this._path("/webhook/submit"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - } - - // R19: Capabilities - async getCapabilities() { - const now = Date.now(); - if (this._capabilitiesCache && (now - this._capabilitiesCacheTs) < 5000) { - return this._capabilitiesCache; - } - const res = await this.fetch(this._path("/capabilities")); - if (res?.ok) { - this._capabilitiesCache = res; - this._capabilitiesCacheTs = now; - } - return res; - } - - async supportsAssistStreaming() { - const caps = await this.getCapabilities(); - return !!caps?.ok && !!caps?.data?.features?.assist_streaming; - } - - // F17: ComfyUI History - async getHistory(promptId) { - // /history is a ComfyUI native endpoint. - // ComfyUI's shim handles it if we pass "/history/..."? - // Wait, ComfyUI endpoints are usually /history. - // fetchApi('/history/...') maps to /api/history/... - // ComfyUI backend registers /history? - // Checking ComfyUI source: yes, app.routes.get("/history"...) - // But usually under /api ? - // Actually ComfyUI 'fetchApi' prefixes with '/api'. - // Does 'history' live under '/api/history'? Yes. - const res = await this.fetch(`/history/${promptId}`); - if (!res.ok) return res; - - // ComfyUI returns: { "": { ...historyItem... } } - const data = res.data; - const historyItem = (data && typeof data === "object") ? data[promptId] : null; - return { ...res, data: historyItem }; - } - - async getPromptQueue() { - return this.fetch("/queue"); - } - - // R25: Trace timeline (optional) - async getTrace(promptId) { - return this.fetch(`${this._path("/trace")}/${encodeURIComponent(promptId)}`); - } - - // Helper: Build ComfyUI /view URL - buildViewUrl(filename, subfolder = "", type = "output") { - const params = new URLSearchParams({ filename, type }); - if (subfolder) params.set("subfolder", subfolder); - // apiURL returns the full path including standard base - return apiURL(`/view?${params.toString()}`); - } - - buildViewUrlForRef(imageRef) { - const normalized = normalizeComfyOutputRef(imageRef); - if (!normalized || !normalized.viewParams) { - return ""; - } - return apiURL(`/view?${new URLSearchParams(normalized.viewParams).toString()}`); - } - - // R21/F20: Get config - async getConfig() { - return this.fetch(this._path("/config")); - } - - // R21/S13/F20: Update config (requires admin token) - async putConfig(config, adminToken) { - return this.fetch(this._path("/config"), { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...this._adminTokenHeaders(adminToken), - }, - body: JSON.stringify(config), - }); - } - - // F20: Test LLM connection (uses effective config, no api_key in frontend) - async runLLMTest() { - return this.fetch(this._path("/llm/test"), { - method: "POST", - headers: { - "Content-Type": "application/json", - ...this._adminTokenHeaders(), - }, - body: JSON.stringify({}), // Empty body = use effective config - timeout: 30000, - }); - } - - // Backwards compatibility alias for settings_tab.js - async testLLM(adminToken, overrides = null) { - return this.fetch(this._path("/llm/test"), { - method: "POST", - headers: { - "Content-Type": "application/json", - ...this._adminTokenHeaders(adminToken), - }, - // IMPORTANT: Settings UI uses this to test the currently selected provider/model - // without requiring a config "Save" first. Backend accepts an empty body too. - body: JSON.stringify(overrides || {}), - timeout: 30000, - }); - } - - // F20+: Fetch remote model list (best-effort; admin boundary) - async getModelList(providerId, adminToken) { - const q = providerId ? `?provider=${encodeURIComponent(providerId)}` : ""; - return this.fetch(`${this._path("/llm/models")}${q}`, { - method: "GET", - headers: { - ...this._adminTokenHeaders(adminToken), - }, - timeout: 30000, - }); - } - - // --- S25: Secrets Management (Admin-gated) --- - - /** - * Get secrets status (NO VALUES). - * Admin boundary (token if configured; otherwise loopback-only). - */ - async getSecretsStatus(adminToken) { - return this.fetch(this._path("/secrets/status"), { - method: "GET", - headers: { - ...this._adminTokenHeaders(adminToken), - }, - }); - } - - /** - * Save API key to server store. - * Admin boundary (token if configured; otherwise loopback-only). - * - * @param {string} provider - Provider ID ("openai", "anthropic", "generic") - * @param {string} apiKey - API key value (NEVER logged) - * @param {string} adminToken - Admin token - */ - async saveSecret(provider, apiKey, adminToken) { - return this.fetch(this._path("/secrets"), { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...this._adminTokenHeaders(adminToken), - }, - body: JSON.stringify({ - provider: provider, - api_key: apiKey, - }), - }); - } - - /** - * Clear provider secret. - * Admin boundary (token if configured; otherwise loopback-only). - */ - async clearSecret(provider, adminToken) { - return this.fetch(this._path(`/secrets/${encodeURIComponent(provider)}`), { - method: "DELETE", - headers: { - ...this._adminTokenHeaders(adminToken), - }, - }); - } - - // --- Assist Endpoints (F8/F21) --- - _parseSSEChunk(rawChunk) { const lines = rawChunk.split(/\r?\n/); let event = "message"; @@ -535,420 +342,16 @@ export class OpenClawAPI { * @param {object} params - { profile, requirements, style_directives, seed } * @param {AbortSignal} signal - Optional AbortSignal for cancellation (R38-Lite) */ - async runPlanner(params, signal = null) { - return this.fetch(this._path("/assist/planner"), { - method: "POST", - headers: { - "Content-Type": "application/json", - ...this._adminTokenHeaders(), - }, - body: JSON.stringify(params), - timeout: 60000, // LLM calls may be slow - signal, // R38-Lite: Pass signal - }); - } - async listPlannerProfiles(signal = null) { - return this.fetch(this._path("/assist/planner/profiles"), { - headers: { - ...this._adminTokenHeaders(), - }, - signal, - }); - } - - async runPlannerStream(params, { signal = null, onEvent = null } = {}) { - return this.streamSSEPost(this._path("/assist/planner/stream"), params, { - signal, - timeout: 60000, - onEvent, - }); - } - - /** - * Run Prompt Refiner. - * @param {object} params - { image_b64, orig_positive, orig_negative, issue, params_json, goal } - * @param {AbortSignal} signal - Optional AbortSignal for cancellation (R38-Lite) - */ - async runRefiner(params, signal = null) { - return this.fetch(this._path("/assist/refiner"), { - method: "POST", - headers: { - "Content-Type": "application/json", - ...this._adminTokenHeaders(), - }, - body: JSON.stringify(params), - timeout: 60000, - signal, // R38-Lite: Pass signal - }); - } - - async runRefinerStream(params, { signal = null, onEvent = null } = {}) { - return this.streamSSEPost(this._path("/assist/refiner/stream"), params, { - signal, - timeout: 60000, - onEvent, - }); - } - - // --- F22: Presets --- - - async listPresets(category) { - const query = category ? `?category=${encodeURIComponent(category)}` : ""; - return this.fetch(`${this._path("/presets")}${query}`, { - headers: { - ...this._adminTokenHeaders(), - }, - }); - } - - async getPreset(id) { - return this.fetch(`${this._path("/presets")}/${encodeURIComponent(id)}`, { - headers: { - ...this._adminTokenHeaders(), - }, - }); - } - - async createPreset(data) { - return this.fetch(this._path("/presets"), { - method: "POST", - headers: { - "Content-Type": "application/json", - ...this._adminTokenHeaders(), - }, - body: JSON.stringify(data), - }); - } - - async updatePreset(id, data) { - return this.fetch(`${this._path("/presets")}/${encodeURIComponent(id)}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...this._adminTokenHeaders(), - }, - body: JSON.stringify(data), - }); - } - - async deletePreset(id) { - return this.fetch(`${this._path("/presets")}/${encodeURIComponent(id)}`, { - method: "DELETE", - headers: { - ...this._adminTokenHeaders(), - }, - }); - } - // --- S7: Approval Gates --- - - async getApprovals({ status, limit = 100, offset = 0 } = {}) { - const params = new URLSearchParams({ limit, offset }); - if (status) params.set("status", status); - - return this.fetch(`${this._path("/approvals")}?${params.toString()}`, { - headers: { - ...this._adminTokenHeaders(), - }, - }); - } - - async getApproval(id) { - return this.fetch(`${this._path("/approvals")}/${encodeURIComponent(id)}`, { - headers: { - ...this._adminTokenHeaders(), - }, - }); - } - - async approveRequest(id, { actor = "web_user", autoExecute = true } = {}) { - return this.fetch(`${this._path("/approvals")}/${encodeURIComponent(id)}/approve`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...this._adminTokenHeaders(), - }, - body: JSON.stringify({ actor, auto_execute: autoExecute }), - }); - } - - async rejectRequest(id, { actor = "web_user" } = {}) { - return this.fetch(`${this._path("/approvals")}/${encodeURIComponent(id)}/reject`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...this._adminTokenHeaders(), - }, - body: JSON.stringify({ actor }), - }); - } - - // --- S8/F11: Asset Packs --- - - async getPacks() { - return this.fetch(this._path("/packs"), { - headers: { - ...this._adminTokenHeaders(), - }, - }); - } - - async importPack(file, overwrite = false) { - const formData = new FormData(); - formData.append("file", file); - - const query = overwrite ? "?overwrite=true" : ""; - - return this.fetch(`${this._path("/packs/import")}${query}`, { - method: "POST", - headers: { - ...this._adminTokenHeaders(), - // Let browser set Content-Type for FormData - }, - body: formData, - }); - } - - async exportPack(name, version) { - // Return URL for download (or blob fetch if needed) - // Since it requires a token, we might need to fetch blob - // But for simplicity, we can use a token parameter if supported, or fetch blob and create object URL. - - // Fetch as blob - // R26: Use fetchApi to ensure base path - const primaryPath = `${this._path("/packs/export")}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`; - const legacyPath = getApiPathCandidates(primaryPath)[1]; - - const headers = this._adminTokenHeaders(); - - let res = await fetchApi(primaryPath, { headers }); - if (res.status === 404) res = await fetchApi(legacyPath, { headers }); - - if (res.status === 404) { - try { - res = await fetch(fileURL(primaryPath), { headers }); - } catch { } - } - if (res.status === 404) { - try { - res = await fetch(fileURL(legacyPath), { headers }); - } catch { } - } - - if (res.ok) { - const blob = await res.blob(); - return { ok: true, data: blob }; - } - - // If error, try to parse json error - let error = "Download failed"; - try { - const json = await res.json(); - error = json.error || error; - } catch (e) { } - - return { ok: false, error }; - } - - async deletePack(name, version) { - return this.fetch(`${this._path("/packs")}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, { - method: "DELETE", - headers: { - ...this._adminTokenHeaders(), - }, - }); - } - - // --- R42/F28: Preflight & Explorer --- - - async runPreflight(workflow) { - return this.fetch(this._path("/preflight"), { - method: "POST", - headers: { - "Content-Type": "application/json", - ...this._adminTokenHeaders(), - }, - body: JSON.stringify(workflow), - }); - } - - async getInventory() { - return this.fetch(this._path("/preflight/inventory"), { - method: "GET", - headers: { - ...this._adminTokenHeaders(), - }, - }); - } - - // --- R47: Checkpoints --- - - async listCheckpoints() { - return this.fetch(this._path("/checkpoints"), { - headers: { ...this._adminTokenHeaders() } - }); - } - - async createCheckpoint(name, workflow, description = "") { - return this.fetch(this._path("/checkpoints"), { - method: "POST", - headers: { - "Content-Type": "application/json", - ...this._adminTokenHeaders() - }, - body: JSON.stringify({ name, workflow, description }) - }); - } - - async getCheckpoint(id) { - return this.fetch(`${this._path("/checkpoints")}/${encodeURIComponent(id)}`, { - headers: { ...this._adminTokenHeaders() } - }); - } - - async deleteCheckpoint(id) { - return this.fetch(`${this._path("/checkpoints")}/${encodeURIComponent(id)}`, { - method: "DELETE", - headers: { ...this._adminTokenHeaders() } - }); - } - - // --- F54: Model Search / Download / Import --- - - async searchModels(params = {}) { - const qs = new URLSearchParams(); - if (params.q) qs.set("q", String(params.q)); - if (params.source) qs.set("source", String(params.source)); - if (params.model_type) qs.set("model_type", String(params.model_type)); - if (typeof params.installed === "boolean") qs.set("installed", params.installed ? "true" : "false"); - if (params.limit != null) qs.set("limit", String(params.limit)); - if (params.offset != null) qs.set("offset", String(params.offset)); - const suffix = qs.toString() ? `?${qs.toString()}` : ""; - return this.fetch(`${this._path("/models/search")}${suffix}`, { - headers: { ...this._adminTokenHeaders() } - }); - } - - async createModelDownloadTask(payload) { - return this.fetch(this._path("/models/downloads"), { - method: "POST", - headers: { - "Content-Type": "application/json", - ...this._adminTokenHeaders() - }, - body: JSON.stringify(payload || {}) - }); - } - - async listModelDownloadTasks(params = {}) { - const qs = new URLSearchParams(); - if (params.state) qs.set("state", String(params.state)); - if (params.limit != null) qs.set("limit", String(params.limit)); - if (params.offset != null) qs.set("offset", String(params.offset)); - if (params.since_seq != null) qs.set("since_seq", String(params.since_seq)); - const suffix = qs.toString() ? `?${qs.toString()}` : ""; - return this.fetch(`${this._path("/models/downloads")}${suffix}`, { - headers: { ...this._adminTokenHeaders() } - }); - } - - async getModelDownloadTask(taskId) { - return this.fetch(`${this._path("/models/downloads")}/${encodeURIComponent(taskId)}`, { - headers: { ...this._adminTokenHeaders() } - }); - } - - async cancelModelDownloadTask(taskId) { - return this.fetch(`${this._path("/models/downloads")}/${encodeURIComponent(taskId)}/cancel`, { - method: "POST", - headers: { ...this._adminTokenHeaders() } - }); - } - - async importDownloadedModel(payload) { - return this.fetch(this._path("/models/import"), { - method: "POST", - headers: { - "Content-Type": "application/json", - ...this._adminTokenHeaders() - }, - body: JSON.stringify(payload || {}) - }); - } - - async listModelInstallations(params = {}) { - const qs = new URLSearchParams(); - if (params.model_type) qs.set("model_type", String(params.model_type)); - if (params.limit != null) qs.set("limit", String(params.limit)); - if (params.offset != null) qs.set("offset", String(params.offset)); - const suffix = qs.toString() ? `?${qs.toString()}` : ""; - return this.fetch(`${this._path("/models/installations")}${suffix}`, { - headers: { ...this._adminTokenHeaders() } - }); - } - - async parsePngInfo(imageB64) { - return this.fetch(this._path("/pnginfo"), { - method: "POST", - headers: { - "Content-Type": "application/json", - ...this._adminTokenHeaders() - }, - body: JSON.stringify({ - image_b64: String(imageB64 || "") - }), - timeout: 30000, - }); - } - - // --- R71: Job Events --- - - /** - * Poll for recent events (fallback). - * @param {number} lastSeq - Sequence ID to start from - */ - async getEvents(lastSeq = 0) { - return this.fetch(`${this._path("/events")}?since=${lastSeq}`); - } - - /** - * Subscribe to SSE event stream. - * @param {function} onEvent - Callback for events (eventData) => void - * @param {function} onError - Callback for errors (error) => void - * @returns {EventSource} The event source instance (caller must .close() it) - */ - subscribeEvents(onEvent, onError) { - // Use apiURL from shim to get full path - const url = apiURL(this._path("/events/stream")); - const es = new EventSource(url); - - const handle = (e) => { - if (!e.data) return; - const parsed = parseJsonSafe(e.data); - if (!parsed.ok || !parsed.value || typeof parsed.value !== "object") { - console.warn("[OpenClaw] Failed to parse SSE event:", parsed.error); - return; - } - const data = parsed.value; - // Unified event type injection if missing - if (!data.event_type && e.type !== "message") { - data.event_type = e.type; - } - onEvent(data); - }; - - es.onmessage = handle; - es.addEventListener("queued", handle); - es.addEventListener("running", handle); - es.addEventListener("completed", handle); - es.addEventListener("failed", handle); - - es.onerror = (err) => { - if (onError) onError(err); - }; - - return es; - } } +Object.assign( + OpenClawAPI.prototype, + configApiMethods, + generationApiMethods, + resourceApiMethods, + modelApiMethods, + eventApiMethods, +); + export const openclawApi = new OpenClawAPI(); diff --git a/web/openclaw_api_config.js b/web/openclaw_api_config.js new file mode 100644 index 0000000..cafdefd --- /dev/null +++ b/web/openclaw_api_config.js @@ -0,0 +1,215 @@ +/** Config, health, history, trace, and secret route-family methods. */ +import { apiURL } from "./openclaw_comfy_api.js"; +import { normalizeComfyOutputRef } from "./openclaw_asset_refs.js"; + +export const configApiMethods = { + async getHealth() { + return this.fetch(this._path("/health")); + }, + + async getLogs(lines = 200) { + return this.fetch(`${this._path("/logs/tail")}?lines=${lines}`); + }, + + async validateWebhook(payload) { + return this.fetch(this._path("/webhook"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + }, + + async submitWebhook(payload) { + return this.fetch(this._path("/webhook/submit"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + }, + + // R19: Capabilities + + async getCapabilities() { + const now = Date.now(); + if (this._capabilitiesCache && (now - this._capabilitiesCacheTs) < 5000) { + return this._capabilitiesCache; + } + const res = await this.fetch(this._path("/capabilities")); + if (res?.ok) { + this._capabilitiesCache = res; + this._capabilitiesCacheTs = now; + } + return res; + }, + + async supportsAssistStreaming() { + const caps = await this.getCapabilities(); + return !!caps?.ok && !!caps?.data?.features?.assist_streaming; + }, + + // F17: ComfyUI History + + async getHistory(promptId) { + // /history is a ComfyUI native endpoint. + // ComfyUI's shim handles it if we pass "/history/..."? + // Wait, ComfyUI endpoints are usually /history. + // fetchApi('/history/...') maps to /api/history/... + // ComfyUI backend registers /history? + // Checking ComfyUI source: yes, app.routes.get("/history"...) + // But usually under /api ? + // Actually ComfyUI 'fetchApi' prefixes with '/api'. + // Does 'history' live under '/api/history'? Yes. + const res = await this.fetch(`/history/${promptId}`); + if (!res.ok) return res; + + // ComfyUI returns: { "": { ...historyItem... } } + const data = res.data; + const historyItem = (data && typeof data === "object") ? data[promptId] : null; + return { ...res, data: historyItem }; + }, + + async getPromptQueue() { + return this.fetch("/queue"); + }, + + // R25: Trace timeline (optional) + + async getTrace(promptId) { + return this.fetch(`${this._path("/trace")}/${encodeURIComponent(promptId)}`); + }, + + // Helper: Build ComfyUI /view URL + + buildViewUrl(filename, subfolder = "", type = "output") { + const params = new URLSearchParams({ filename, type }); + if (subfolder) params.set("subfolder", subfolder); + // apiURL returns the full path including standard base + return apiURL(`/view?${params.toString()}`); + }, + + buildViewUrlForRef(imageRef) { + const normalized = normalizeComfyOutputRef(imageRef); + if (!normalized || !normalized.viewParams) { + return ""; + } + return apiURL(`/view?${new URLSearchParams(normalized.viewParams).toString()}`); + }, + + // R21/F20: Get config + + async getConfig() { + return this.fetch(this._path("/config")); + }, + + // R21/S13/F20: Update config (requires admin token) + + async putConfig(config, adminToken) { + return this.fetch(this._path("/config"), { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...this._adminTokenHeaders(adminToken), + }, + body: JSON.stringify(config), + }); + }, + + // F20: Test LLM connection (uses effective config, no api_key in frontend) + + async runLLMTest() { + return this.fetch(this._path("/llm/test"), { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this._adminTokenHeaders(), + }, + body: JSON.stringify({}), // Empty body = use effective config + timeout: 30000, + }); + }, + + // Backwards compatibility alias for settings_tab.js + + async testLLM(adminToken, overrides = null) { + return this.fetch(this._path("/llm/test"), { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this._adminTokenHeaders(adminToken), + }, + // IMPORTANT: Settings UI uses this to test the currently selected provider/model + // without requiring a config "Save" first. Backend accepts an empty body too. + body: JSON.stringify(overrides || {}), + timeout: 30000, + }); + }, + + // F20+: Fetch remote model list (best-effort; admin boundary) + + async getModelList(providerId, adminToken) { + const q = providerId ? `?provider=${encodeURIComponent(providerId)}` : ""; + return this.fetch(`${this._path("/llm/models")}${q}`, { + method: "GET", + headers: { + ...this._adminTokenHeaders(adminToken), + }, + timeout: 30000, + }); + }, + + // --- S25: Secrets Management (Admin-gated) --- + + /** + * Get secrets status (NO VALUES). + * Admin boundary (token if configured; otherwise loopback-only). + */ + + async getSecretsStatus(adminToken) { + return this.fetch(this._path("/secrets/status"), { + method: "GET", + headers: { + ...this._adminTokenHeaders(adminToken), + }, + }); + }, + + /** + * Save API key to server store. + * Admin boundary (token if configured; otherwise loopback-only). + * + * @param {string} provider - Provider ID ("openai", "anthropic", "generic") + * @param {string} apiKey - API key value (NEVER logged) + * @param {string} adminToken - Admin token + */ + + async saveSecret(provider, apiKey, adminToken) { + return this.fetch(this._path("/secrets"), { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...this._adminTokenHeaders(adminToken), + }, + body: JSON.stringify({ + provider: provider, + api_key: apiKey, + }), + }); + }, + + /** + * Clear provider secret. + * Admin boundary (token if configured; otherwise loopback-only). + */ + + async clearSecret(provider, adminToken) { + return this.fetch(this._path(`/secrets/${encodeURIComponent(provider)}`), { + method: "DELETE", + headers: { + ...this._adminTokenHeaders(adminToken), + }, + }); + }, + + // --- Assist Endpoints (F8/F21) --- + +}; diff --git a/web/openclaw_api_events.js b/web/openclaw_api_events.js new file mode 100644 index 0000000..c36f386 --- /dev/null +++ b/web/openclaw_api_events.js @@ -0,0 +1,50 @@ +/** Job event polling and SSE subscription route-family methods. */ +import { apiURL } from "./openclaw_comfy_api.js"; +import { parseJsonSafe } from "./openclaw_utils.js"; + +export const eventApiMethods = { + async getEvents(lastSeq = 0) { + return this.fetch(`${this._path("/events")}?since=${lastSeq}`); + }, + + /** + * Subscribe to SSE event stream. + * @param {function} onEvent - Callback for events (eventData) => void + * @param {function} onError - Callback for errors (error) => void + * @returns {EventSource} The event source instance (caller must .close() it) + */ + + subscribeEvents(onEvent, onError) { + // Use apiURL from shim to get full path + const url = apiURL(this._path("/events/stream")); + const es = new EventSource(url); + + const handle = (e) => { + if (!e.data) return; + const parsed = parseJsonSafe(e.data); + if (!parsed.ok || !parsed.value || typeof parsed.value !== "object") { + console.warn("[OpenClaw] Failed to parse SSE event:", parsed.error); + return; + } + const data = parsed.value; + // Unified event type injection if missing + if (!data.event_type && e.type !== "message") { + data.event_type = e.type; + } + onEvent(data); + }; + + es.onmessage = handle; + es.addEventListener("queued", handle); + es.addEventListener("running", handle); + es.addEventListener("completed", handle); + es.addEventListener("failed", handle); + + es.onerror = (err) => { + if (onError) onError(err); + }; + + return es; + }, + +}; diff --git a/web/openclaw_api_generation.js b/web/openclaw_api_generation.js new file mode 100644 index 0000000..5f227c7 --- /dev/null +++ b/web/openclaw_api_generation.js @@ -0,0 +1,63 @@ +/** Planner and refiner route-family methods. */ + +export const generationApiMethods = { + async runPlanner(params, signal = null) { + return this.fetch(this._path("/assist/planner"), { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this._adminTokenHeaders(), + }, + body: JSON.stringify(params), + timeout: 60000, // LLM calls may be slow + signal, // R38-Lite: Pass signal + }); + }, + + async listPlannerProfiles(signal = null) { + return this.fetch(this._path("/assist/planner/profiles"), { + headers: { + ...this._adminTokenHeaders(), + }, + signal, + }); + }, + + async runPlannerStream(params, { signal = null, onEvent = null } = {}) { + return this.streamSSEPost(this._path("/assist/planner/stream"), params, { + signal, + timeout: 60000, + onEvent, + }); + }, + + /** + * Run Prompt Refiner. + * @param {object} params - { image_b64, orig_positive, orig_negative, issue, params_json, goal } + * @param {AbortSignal} signal - Optional AbortSignal for cancellation (R38-Lite) + */ + + async runRefiner(params, signal = null) { + return this.fetch(this._path("/assist/refiner"), { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this._adminTokenHeaders(), + }, + body: JSON.stringify(params), + timeout: 60000, + signal, // R38-Lite: Pass signal + }); + }, + + async runRefinerStream(params, { signal = null, onEvent = null } = {}) { + return this.streamSSEPost(this._path("/assist/refiner/stream"), params, { + signal, + timeout: 60000, + onEvent, + }); + }, + + // --- F22: Presets --- + +}; diff --git a/web/openclaw_api_models.js b/web/openclaw_api_models.js new file mode 100644 index 0000000..cc53902 --- /dev/null +++ b/web/openclaw_api_models.js @@ -0,0 +1,97 @@ +/** Model search, download, installation, and PNG-info route-family methods. */ + +export const modelApiMethods = { + async searchModels(params = {}) { + const qs = new URLSearchParams(); + if (params.q) qs.set("q", String(params.q)); + if (params.source) qs.set("source", String(params.source)); + if (params.model_type) qs.set("model_type", String(params.model_type)); + if (typeof params.installed === "boolean") qs.set("installed", params.installed ? "true" : "false"); + if (params.limit != null) qs.set("limit", String(params.limit)); + if (params.offset != null) qs.set("offset", String(params.offset)); + const suffix = qs.toString() ? `?${qs.toString()}` : ""; + return this.fetch(`${this._path("/models/search")}${suffix}`, { + headers: { ...this._adminTokenHeaders() } + }); + }, + + async createModelDownloadTask(payload) { + return this.fetch(this._path("/models/downloads"), { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this._adminTokenHeaders() + }, + body: JSON.stringify(payload || {}) + }); + }, + + async listModelDownloadTasks(params = {}) { + const qs = new URLSearchParams(); + if (params.state) qs.set("state", String(params.state)); + if (params.limit != null) qs.set("limit", String(params.limit)); + if (params.offset != null) qs.set("offset", String(params.offset)); + if (params.since_seq != null) qs.set("since_seq", String(params.since_seq)); + const suffix = qs.toString() ? `?${qs.toString()}` : ""; + return this.fetch(`${this._path("/models/downloads")}${suffix}`, { + headers: { ...this._adminTokenHeaders() } + }); + }, + + async getModelDownloadTask(taskId) { + return this.fetch(`${this._path("/models/downloads")}/${encodeURIComponent(taskId)}`, { + headers: { ...this._adminTokenHeaders() } + }); + }, + + async cancelModelDownloadTask(taskId) { + return this.fetch(`${this._path("/models/downloads")}/${encodeURIComponent(taskId)}/cancel`, { + method: "POST", + headers: { ...this._adminTokenHeaders() } + }); + }, + + async importDownloadedModel(payload) { + return this.fetch(this._path("/models/import"), { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this._adminTokenHeaders() + }, + body: JSON.stringify(payload || {}) + }); + }, + + async listModelInstallations(params = {}) { + const qs = new URLSearchParams(); + if (params.model_type) qs.set("model_type", String(params.model_type)); + if (params.limit != null) qs.set("limit", String(params.limit)); + if (params.offset != null) qs.set("offset", String(params.offset)); + const suffix = qs.toString() ? `?${qs.toString()}` : ""; + return this.fetch(`${this._path("/models/installations")}${suffix}`, { + headers: { ...this._adminTokenHeaders() } + }); + }, + + async parsePngInfo(imageB64) { + return this.fetch(this._path("/pnginfo"), { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this._adminTokenHeaders() + }, + body: JSON.stringify({ + image_b64: String(imageB64 || "") + }), + timeout: 30000, + }); + }, + + // --- R71: Job Events --- + + /** + * Poll for recent events (fallback). + * @param {number} lastSeq - Sequence ID to start from + */ + +}; diff --git a/web/openclaw_api_resources.js b/web/openclaw_api_resources.js new file mode 100644 index 0000000..562c2d9 --- /dev/null +++ b/web/openclaw_api_resources.js @@ -0,0 +1,228 @@ +/** Preset, approval, pack, preflight, and checkpoint route-family methods. */ +import { fetchApi, fileURL } from "./openclaw_comfy_api.js"; +import { getApiPathCandidates } from "./openclaw_compat.js"; + +export const resourceApiMethods = { + async listPresets(category) { + const query = category ? `?category=${encodeURIComponent(category)}` : ""; + return this.fetch(`${this._path("/presets")}${query}`, { + headers: { + ...this._adminTokenHeaders(), + }, + }); + }, + + async getPreset(id) { + return this.fetch(`${this._path("/presets")}/${encodeURIComponent(id)}`, { + headers: { + ...this._adminTokenHeaders(), + }, + }); + }, + + async createPreset(data) { + return this.fetch(this._path("/presets"), { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this._adminTokenHeaders(), + }, + body: JSON.stringify(data), + }); + }, + + async updatePreset(id, data) { + return this.fetch(`${this._path("/presets")}/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...this._adminTokenHeaders(), + }, + body: JSON.stringify(data), + }); + }, + + async deletePreset(id) { + return this.fetch(`${this._path("/presets")}/${encodeURIComponent(id)}`, { + method: "DELETE", + headers: { + ...this._adminTokenHeaders(), + }, + }); + }, + // --- S7: Approval Gates --- + + async getApprovals({ status, limit = 100, offset = 0 } = {}) { + const params = new URLSearchParams({ limit, offset }); + if (status) params.set("status", status); + + return this.fetch(`${this._path("/approvals")}?${params.toString()}`, { + headers: { + ...this._adminTokenHeaders(), + }, + }); + }, + + async getApproval(id) { + return this.fetch(`${this._path("/approvals")}/${encodeURIComponent(id)}`, { + headers: { + ...this._adminTokenHeaders(), + }, + }); + }, + + async approveRequest(id, { actor = "web_user", autoExecute = true } = {}) { + return this.fetch(`${this._path("/approvals")}/${encodeURIComponent(id)}/approve`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this._adminTokenHeaders(), + }, + body: JSON.stringify({ actor, auto_execute: autoExecute }), + }); + }, + + async rejectRequest(id, { actor = "web_user" } = {}) { + return this.fetch(`${this._path("/approvals")}/${encodeURIComponent(id)}/reject`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this._adminTokenHeaders(), + }, + body: JSON.stringify({ actor }), + }); + }, + + // --- S8/F11: Asset Packs --- + + async getPacks() { + return this.fetch(this._path("/packs"), { + headers: { + ...this._adminTokenHeaders(), + }, + }); + }, + + async importPack(file, overwrite = false) { + const formData = new FormData(); + formData.append("file", file); + + const query = overwrite ? "?overwrite=true" : ""; + + return this.fetch(`${this._path("/packs/import")}${query}`, { + method: "POST", + headers: { + ...this._adminTokenHeaders(), + // Let browser set Content-Type for FormData + }, + body: formData, + }); + }, + + async exportPack(name, version) { + // Return URL for download (or blob fetch if needed) + // Since it requires a token, we might need to fetch blob + // But for simplicity, we can use a token parameter if supported, or fetch blob and create object URL. + + // Fetch as blob + // R26: Use fetchApi to ensure base path + const primaryPath = `${this._path("/packs/export")}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`; + const legacyPath = getApiPathCandidates(primaryPath)[1]; + + const headers = this._adminTokenHeaders(); + + let res = await fetchApi(primaryPath, { headers }); + if (res.status === 404) res = await fetchApi(legacyPath, { headers }); + + if (res.status === 404) { + try { + res = await fetch(fileURL(primaryPath), { headers }); + } catch { } + } + if (res.status === 404) { + try { + res = await fetch(fileURL(legacyPath), { headers }); + } catch { } + } + + if (res.ok) { + const blob = await res.blob(); + return { ok: true, data: blob }; + } + + // If error, try to parse json error + let error = "Download failed"; + try { + const json = await res.json(); + error = json.error || error; + } catch (e) { } + + return { ok: false, error }; + }, + + async deletePack(name, version) { + return this.fetch(`${this._path("/packs")}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, { + method: "DELETE", + headers: { + ...this._adminTokenHeaders(), + }, + }); + }, + + // --- R42/F28: Preflight & Explorer --- + + async runPreflight(workflow) { + return this.fetch(this._path("/preflight"), { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this._adminTokenHeaders(), + }, + body: JSON.stringify(workflow), + }); + }, + + async getInventory() { + return this.fetch(this._path("/preflight/inventory"), { + method: "GET", + headers: { + ...this._adminTokenHeaders(), + }, + }); + }, + + // --- R47: Checkpoints --- + + async listCheckpoints() { + return this.fetch(this._path("/checkpoints"), { + headers: { ...this._adminTokenHeaders() } + }); + }, + + async createCheckpoint(name, workflow, description = "") { + return this.fetch(this._path("/checkpoints"), { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this._adminTokenHeaders() + }, + body: JSON.stringify({ name, workflow, description }) + }); + }, + + async getCheckpoint(id) { + return this.fetch(`${this._path("/checkpoints")}/${encodeURIComponent(id)}`, { + headers: { ...this._adminTokenHeaders() } + }); + }, + + async deleteCheckpoint(id) { + return this.fetch(`${this._path("/checkpoints")}/${encodeURIComponent(id)}`, { + method: "DELETE", + headers: { ...this._adminTokenHeaders() } + }); + }, + + // --- F54: Model Search / Download / Import --- + +}; diff --git a/web/openclaw_tabs.js b/web/openclaw_tabs.js index 7367126..c7787b6 100644 --- a/web/openclaw_tabs.js +++ b/web/openclaw_tabs.js @@ -17,6 +17,7 @@ import { * @property {string} title Visible tab label and error-boundary name. * @property {string=} icon Optional icon class for the tab button. * @property {(pane: HTMLElement) => (void|Promise)} render Render function for the tab pane. + * @property {(pane: HTMLElement) => boolean=} dispose Optional pending-render cleanup; true requests rerender. * @property {boolean=} loaded Internal lazy-render state. */ @@ -105,6 +106,21 @@ export class TabManager { } activateTab(id) { + const previousId = this.activeTabId; + if (previousId && previousId !== id) { + const previousTab = this.tabs.find(tab => tab.id === previousId); + const previousPane = + this.contentEl.querySelector(`#openclaw-tab-${previousId}`) || + this.contentEl.querySelector(`#moltbot-tab-${previousId}`); + if (previousTab?.dispose && previousPane) { + // IMPORTANT: invalidate pending async tab work before hiding its owner pane. + const requiresRerender = previousTab.dispose(previousPane) === true; + if (requiresRerender) { + previousTab.loaded = false; + previousPane.replaceChildren(); + } + } + } this.activeTabId = id; setMirroredStorageValue(localStorage, STORAGE_KEYS.local.activeTab, id); diff --git a/web/tabs/settings_tab.js b/web/tabs/settings_tab.js index 1ef6ba5..ada033c 100644 --- a/web/tabs/settings_tab.js +++ b/web/tabs/settings_tab.js @@ -1,18 +1,23 @@ /** * S26: Settings Tab (simplified, collapsible secrets) */ +import { getAdminErrorMessage } from "../admin_errors.js"; import { openclawApi } from "../openclaw_api.js"; import { OpenClawSession } from "../openclaw_session.js"; -import { getAdminErrorMessage } from "../admin_errors.js"; +import { beginSettingsRender, disposeSettingsRender, finishSettingsRender } from "./settings_tab_lifecycle.js"; +import { renderSettingsLlm } from "./settings_tab_llm.js"; +import { renderSettingsLogs } from "./settings_tab_logs.js"; +import { renderSettingsSecrets } from "./settings_tab_secrets.js"; +import { renderSettingsStatus } from "./settings_tab_status.js"; export const settingsTab = { id: "settings", title: "Settings", icon: "pi pi-cog", render: async (container) => { + const lifecycle = beginSettingsRender(container); // IMPORTANT (UI layout): `.openclaw-content` has `overflow: hidden`. - // This tab MUST render its own scroll container (`.openclaw-scroll-area`), - // otherwise lower sections (e.g. UI Key Store) will be clipped with no way to scroll. + // This tab MUST render its own scroll container or lower sections are clipped. container.innerHTML = `
@@ -22,966 +27,34 @@ export const settingsTab = { `; const scroll = container.querySelector("#openclaw-settings-scroll"); - // F39: Capability probe FIRST — gate optional features before any rendering. let capabilities = {}; try { const capRes = await openclawApi.getCapabilities(); - if (capRes.ok && capRes.data?.features) { - capabilities = capRes.data.features; - } + if (!lifecycle.isCurrent()) return; + if (capRes.ok && capRes.data?.features) capabilities = capRes.data.features; } catch { /* non-fatal */ } + if (!lifecycle.isCurrent()) return; const [healthRes, logRes, configRes] = await Promise.all([ openclawApi.getHealth(), openclawApi.getLogs(50), openclawApi.getConfig(), ]); + if (!lifecycle.isCurrent()) return; - // F39: Remove loading gate (readiness gating prevents first-render flicker) scroll.innerHTML = ""; - - // If everything is 404, backend routes not registered - - // -- UI Boot Diagnostics / Backend Warning -- - const all404 = [healthRes, logRes, configRes].every(r => r && r.ok === false && r.status === 404); - if (all404) { - const warn = createSection("Backend Not Loaded"); - const hint = document.createElement("div"); - hint.className = "openclaw-note openclaw-note moltbot-note"; - hint.style.borderLeft = "4px solid #ff4444"; - hint.innerHTML = ` - OpenClaw UI loaded, but the server endpoints returned HTTP 404. - This usually means ComfyUI did not load the Python part of this custom node pack. -

- Check ComfyUI startup logs for errors while importing ComfyUI-OpenClaw/Comfyui-OpenClaw, - then restart ComfyUI. -

- Expected endpoints: -
    -
  • /openclaw/health (legacy: /moltbot/health)
  • -
  • /openclaw/config
  • -
  • /openclaw/logs/tail
  • -
- `; - warn.appendChild(hint); - scroll.appendChild(warn); - } - - // F39: Show degraded-state banner when capabilities are missing or partial - if (!all404 && healthRes.ok && Object.keys(capabilities).length === 0) { - const degradedWarn = createSection("Limited Mode"); - const degradedHint = document.createElement("div"); - degradedHint.className = "openclaw-note openclaw-note moltbot-note"; - degradedHint.style.borderLeft = "4px solid #ffaa00"; - degradedHint.innerHTML = ` - ⚠ Capabilities endpoint unavailable. Some features may be hidden or behave differently. - This can happen if the backend pack version is older than the frontend UI. -
Consider updating ComfyUI-OpenClaw to the latest version. - `; - degradedWarn.appendChild(degradedHint); - scroll.appendChild(degradedWarn); - } - - // -- System Health & Diagnostics -- - const healthSec = createSection("System Health"); - - // F26: Diagnostics Block (Shim status + ComfyUI version) - const diagDetails = document.createElement("details"); - diagDetails.style.marginBottom = "10px"; - diagDetails.style.padding = "8px"; - diagDetails.style.background = "var(--comfy-input-bg)"; - diagDetails.style.borderRadius = "4px"; - diagDetails.style.fontSize = "12px"; - diagDetails.style.color = "var(--input-text)"; - - // Detect Shim - const hasShim = typeof window.comfyAPI?.fetchApi === "function" || typeof window.fetchApi === "function" || !!healthRes.ok; - // Note: fetchApi is imported in module scope, not global. If request worked, shim worked. - // Actually best check is if healthRes.ok or we can inspect 'openclawApi.prefix' implicitly. - - const packVer = (healthRes.ok && healthRes.data?.pack?.version) || "Unknown"; - const basePath = (healthRes.ok && healthRes.data?.pack?.base_path) || "/openclaw (inferred)"; - const comfyVersion = await detectComfyUiVersion(openclawApi); - - // Collapsed by default; auto-expand if errors - diagDetails.open = all404 || !hasShim; - - const summary = document.createElement("summary"); - summary.style.display = "flex"; - summary.style.justifyContent = "space-between"; - summary.style.alignItems = "center"; - summary.style.cursor = "pointer"; - summary.innerHTML = ` - UI Boot Status - ${all404 ? "⚠️ Backend 404" : "✓ Connected"} - `; - - const body = document.createElement("div"); - body.innerHTML = ` -
- ComfyUI: ${comfyVersion || "Unknown"} | Pack: ${packVer} | Prefix: ${basePath} -
-
- Shim: ${hasShim ? "✓ Detected" : "⚠️ Missing (shim broken)"} -
- `; - - diagDetails.appendChild(summary); - diagDetails.appendChild(body); - healthSec.appendChild(diagDetails); - - if (healthRes.ok) { - const { pack, config, uptime_sec } = healthRes.data; - addRow(healthSec, "Uptime", `${Math.floor(uptime_sec)}s`); - - const keyStatus = config.llm_key_configured - ? "Configured" - : (config.llm_key_required ? "Missing" : "Not Req"); - const keyClass = (config.llm_key_configured || !config.llm_key_required) ? "ok" : "error"; - addRow(healthSec, "API Key", keyStatus, keyClass); - } else { - if (!all404) { - addRow(healthSec, "Status", "Error", "error"); - const detail = [ - healthRes.status ? `HTTP ${healthRes.status}` : null, - healthRes.error || "request_failed", - ].filter(Boolean).join(" — "); - addRow(healthSec, "Detail", detail); - } - } - scroll.appendChild(healthSec); - - // -- LLM Settings Section -- - const llmSec = createSection("LLM Settings"); - if (configRes.ok) { - // R54: Null-safe destructuring with defaults - const data = configRes.data || {}; - const config = data.config || {}; - const sources = data.sources || {}; - const providers = data.providers || []; - // R53: Apply feedback (optional, for debug/toast later) - const applyInfo = data.apply || {}; - // R70: Settings schema (for frontend validation) - const schema = data.schema || {}; - - - // Provider dropdown - const providerRow = createFormRow("Provider", sources.provider === "env"); - const providerSelect = document.createElement("select"); - providerSelect.className = "openclaw-input openclaw-input moltbot-input"; - providerSelect.disabled = sources.provider === "env"; - providers.forEach(p => { - const opt = document.createElement("option"); - opt.value = p.id; - opt.textContent = p.label; - if (p.id === config.provider) opt.selected = true; - providerSelect.appendChild(opt); - }); - providerRow.appendChild(providerSelect); - llmSec.appendChild(providerRow); - - // R60: Reset model list when provider changes (avoids showing stale models from another provider). - const resetModelList = () => { - modelsLoaded = false; - lastLoadedModels = []; - modelSelect.innerHTML = ""; - modelDatalist.innerHTML = ""; - modelsStatus.textContent = ""; - modelsStatus.className = "openclaw-status openclaw-status moltbot-status"; - updateModelUiVisibility(); - }; - providerSelect.onchange = () => resetModelList(); - - // Model input - const modelRow = createFormRow("Model", sources.model === "env"); - const modelWrap = document.createElement("div"); - modelWrap.style.display = "flex"; - modelWrap.style.gap = "8px"; - modelWrap.style.alignItems = "center"; - - // Model selection UX: - // - Default: free-text input (works even if model listing isn't supported). - // - After "Load Models": show a real visible and use "Custom…" as a bridge. - const showSelect = modelsLoaded; - const showInput = !modelsLoaded || modelSelect.value === MODEL_CUSTOM; - - modelSelect.style.display = showSelect ? "" : "none"; - modelInput.style.display = showInput ? "" : "none"; - }; - - const populateModelSelect = (models) => { - modelSelect.innerHTML = ""; - - const customOpt = document.createElement("option"); - customOpt.value = MODEL_CUSTOM; - customOpt.textContent = "Custom…"; - modelSelect.appendChild(customOpt); - - models.slice(0, 5000).forEach((m) => { - const opt = document.createElement("option"); - opt.value = m; - opt.textContent = m; - modelSelect.appendChild(opt); - }); - - modelsLoaded = true; - const current = (modelInput.value || "").trim(); - if (current && models.includes(current)) { - modelSelect.value = current; - } else { - modelSelect.value = MODEL_CUSTOM; - } - updateModelUiVisibility(); - }; - - modelSelect.onchange = () => { - const v = modelSelect.value; - if (v === MODEL_CUSTOM) { - updateModelUiVisibility(); - modelInput.focus(); - return; - } - modelInput.value = v; - updateModelUiVisibility(); - }; - - const refreshModelsBtn = document.createElement("button"); - refreshModelsBtn.className = "openclaw-btn openclaw-btn moltbot-btn openclaw-btn-secondary openclaw-btn-secondary moltbot-btn-secondary"; - refreshModelsBtn.textContent = "Load Models"; - refreshModelsBtn.disabled = false; - refreshModelsBtn.title = "Fetch remote model list (admin boundary)."; - - const modelsStatus = document.createElement("div"); - modelsStatus.className = "openclaw-status openclaw-status moltbot-status"; - modelsStatus.style.minWidth = "120px"; - - let tokenInput; // Will be set below - - refreshModelsBtn.onclick = async () => { - const token = (tokenInput?.value || OpenClawSession.getAdminToken() || "").trim(); - refreshModelsBtn.disabled = true; - modelsStatus.textContent = "Loading..."; - modelsStatus.className = "openclaw-status openclaw-status moltbot-status"; - - const res = await openclawApi.getModelList(providerSelect.value, token); - if (res.ok) { - modelDatalist.innerHTML = ""; - const models = Array.isArray(res.data?.models) ? res.data.models : []; - lastLoadedModels = models; - models.slice(0, 5000).forEach(m => { - const opt = document.createElement("option"); - opt.value = m; - modelDatalist.appendChild(opt); - }); - populateModelSelect(models); - modelsStatus.textContent = `✓ ${models.length} models`; - modelsStatus.className = "openclaw-status openclaw-status moltbot-status ok"; - } else { - const detail = [ - res.status ? `HTTP ${res.status}` : null, - res.error || "Failed", - ].filter(Boolean).join(" — "); - modelsStatus.textContent = `✗ ${detail}`; - modelsStatus.className = "openclaw-status openclaw-status moltbot-status error"; - } - refreshModelsBtn.disabled = false; - }; - - modelWrap.appendChild(modelSelect); - modelWrap.appendChild(modelInput); - modelWrap.appendChild(refreshModelsBtn); - modelWrap.appendChild(modelsStatus); - - modelRow.appendChild(modelWrap); - modelRow.appendChild(modelDatalist); - llmSec.appendChild(modelRow); - - // Base URL input - const baseUrlRow = createFormRow("Base URL", sources.base_url === "env"); - const baseUrlInput = document.createElement("input"); - baseUrlInput.type = "text"; - baseUrlInput.className = "openclaw-input openclaw-input moltbot-input"; - baseUrlInput.value = config.base_url || ""; - baseUrlInput.placeholder = "Leave empty for provider default"; - baseUrlInput.disabled = sources.base_url === "env"; - baseUrlRow.appendChild(baseUrlInput); - llmSec.appendChild(baseUrlRow); - - // R60: Reset model list when base URL changes (cache key includes base_url). - baseUrlInput.onchange = () => resetModelList(); - - // Timeout - const timeoutRow = createFormRow("Timeout (sec)", sources.timeout_sec === "env"); - const timeoutInput = document.createElement("input"); - timeoutInput.type = "number"; - timeoutInput.className = "openclaw-input openclaw-input moltbot-input openclaw-input-sm openclaw-input-sm moltbot-input-sm"; - timeoutInput.value = config.timeout_sec || 120; - timeoutInput.min = 5; - timeoutInput.max = 300; - timeoutInput.disabled = sources.timeout_sec === "env"; - timeoutRow.appendChild(timeoutInput); - llmSec.appendChild(timeoutRow); - - // Max Retries - const retriesRow = createFormRow("Max Retries", sources.max_retries === "env"); - const retriesInput = document.createElement("input"); - retriesInput.type = "number"; - retriesInput.className = "openclaw-input openclaw-input moltbot-input openclaw-input-sm openclaw-input-sm moltbot-input-sm"; - retriesInput.value = config.max_retries || 3; - retriesInput.min = 0; - retriesInput.max = 10; - retriesInput.disabled = sources.max_retries === "env"; - retriesRow.appendChild(retriesInput); - llmSec.appendChild(retriesRow); - - // --- Admin Token Section --- - const tokenRow = createFormRow( - "Admin Token", - false, - createHelpButton( - "Admin Token", - ` -

The Admin Token authorizes write actions (save config, test LLM, store keys).

-
    -
  • If OPENCLAW_ADMIN_TOKEN (or legacy MOLTBOT_ADMIN_TOKEN) is set on the server, you must enter the same token here.
  • -
  • If no server token is configured, admin actions are allowed on localhost only (convenience mode).
  • -
  • Never expose ComfyUI/OpenClaw to the public internet without proper access controls.
  • -
-

PowerShell: $env:OPENCLAW_ADMIN_TOKEN="your-secret-token"

-

CMD: set OPENCLAW_ADMIN_TOKEN=your-secret-token

- ` - ) - ); - tokenInput = document.createElement("input"); - tokenInput.type = "password"; - tokenInput.className = "openclaw-input openclaw-input moltbot-input"; - tokenInput.placeholder = "Enter OPENCLAW_ADMIN_TOKEN if required (localhost-only if not configured)"; - tokenInput.value = ""; - tokenInput.autocomplete = "off"; - - const tokenClearBtn = document.createElement("button"); - tokenClearBtn.className = "openclaw-btn openclaw-btn moltbot-btn openclaw-btn-secondary openclaw-btn-secondary moltbot-btn-secondary"; - tokenClearBtn.textContent = "Clear"; - tokenClearBtn.style.marginLeft = "4px"; - tokenClearBtn.onclick = () => { - tokenInput.value = ""; - OpenClawSession.setAdminToken(""); - }; - - tokenRow.appendChild(tokenInput); - tokenRow.appendChild(tokenClearBtn); - llmSec.appendChild(tokenRow); - - // Status message area - const statusDiv = document.createElement("div"); - statusDiv.className = "openclaw-status openclaw-status moltbot-status"; - llmSec.appendChild(statusDiv); - - // Buttons row - const btnRow = document.createElement("div"); - btnRow.className = "openclaw-btn-row openclaw-btn-row moltbot-btn-row"; - - // Save button - const saveBtn = document.createElement("button"); - saveBtn.className = "openclaw-btn openclaw-btn moltbot-btn"; - saveBtn.textContent = "Save"; - saveBtn.onclick = async () => { - const token = (tokenInput.value || OpenClawSession.getAdminToken() || "").trim(); - if (token) OpenClawSession.setAdminToken(token); - - saveBtn.disabled = true; - statusDiv.textContent = "Saving..."; - statusDiv.className = "openclaw-status openclaw-status moltbot-status"; - - const updates = { - provider: providerSelect.value, - model: modelInput.value, - base_url: baseUrlInput.value, - timeout_sec: parseInt(timeoutInput.value) || 120, - max_retries: parseInt(retriesInput.value) || 3, - }; - - // R70: Client-side schema coercion (if schema available) - if (schema && Object.keys(schema).length > 0) { - for (const [k, v] of Object.entries(updates)) { - const def = schema[k]; - if (!def) continue; - if (def.type === "int" && typeof v !== "number") { - updates[k] = parseInt(v) || def.default; - } - } - } - - const res = await openclawApi.putConfig(updates, token); - // R53: Hot-Reload Feedback - if (res.ok) { - const apply = res.data?.apply || {}; - let msg = "✓ Saved!"; - - if (apply.restart_required?.length > 0) { - msg += " Restart required for: " + apply.restart_required.join(", "); - statusDiv.className = "openclaw-status openclaw-status moltbot-status warning"; // Yellow/Orange - } else if (apply.applied_now?.length > 0) { - msg += " Applied immediately (Hot Reload)."; - statusDiv.className = "openclaw-status openclaw-status moltbot-status ok"; - } else { - // No changes or unknown - statusDiv.className = "openclaw-status openclaw-status moltbot-status ok"; - } - statusDiv.textContent = msg; - } else { - const errorMsg = getAdminErrorMessage(res.error, res.status); - statusDiv.textContent = `✗ ${res.errors?.join(", ") || errorMsg}`; - statusDiv.className = "openclaw-status openclaw-status moltbot-status error"; - } - saveBtn.disabled = false; - }; - btnRow.appendChild(saveBtn); - - // Test button - const testBtn = document.createElement("button"); - testBtn.className = "openclaw-btn openclaw-btn moltbot-btn openclaw-btn-secondary openclaw-btn-secondary moltbot-btn-secondary"; - testBtn.textContent = "Test Connection"; - - // R54: Debounced Test Action to prevent spam - // We use a separate handler because we need to manage button state (disabled/enabled) - // which debounce interferes with if not careful. - // Better strategy: Disable button immediately on click, re-enable after completion. - // Debounce is less critical here if we disable the button, but good for "auto-test on change" (future). - // For now, implementing "Disable while testing" is the better guard than generic debounce for a button click. - testBtn.onclick = async () => { - if (testBtn.disabled) return; - - const token = (tokenInput.value || OpenClawSession.getAdminToken() || "").trim(); - if (token) OpenClawSession.setAdminToken(token); - - testBtn.disabled = true; - statusDiv.textContent = "Testing..."; - statusDiv.className = "openclaw-status openclaw-status moltbot-status"; - - // IMPORTANT (provider mismatch): "Test Connection" must test the provider/model currently - // selected in the UI, even if the user hasn't clicked Save yet. Otherwise, the backend - // falls back to the effective config (often "openai") and produces confusing errors like: - // "API key not configured for provider 'openai'" while the UI is set to Gemini. - try { - const res = await openclawApi.testLLM(token, { - provider: providerSelect.value, - model: modelInput.value, - base_url: baseUrlInput.value, - timeout_sec: parseInt(timeoutInput.value) || 120, - max_retries: parseInt(retriesInput.value) || 3, - }); - if (res.ok) { - statusDiv.textContent = "✓ Success! " + (res.response ? `"${res.response}"` : ""); - statusDiv.className = "openclaw-status openclaw-status moltbot-status ok"; - } else { - const errorMsg = getAdminErrorMessage(res.error, res.status); - statusDiv.textContent = `✗ ${errorMsg}`; - statusDiv.className = "openclaw-status openclaw-status moltbot-status error"; - } - } finally { - testBtn.disabled = false; - } - }; - btnRow.appendChild(testBtn); - - llmSec.appendChild(btnRow); - - // API Key instructions - const keyNote = document.createElement("div"); - keyNote.className = "openclaw-note openclaw-note moltbot-note"; - keyNote.innerHTML = `API Key: Use OPENCLAW_LLM_API_KEY (or provider-specific keys) via environment variable (recommended), or enable the UI Key Store below (server-side storage; never stored in browser).`; - llmSec.appendChild(keyNote); - - } else { - const detail = [ - configRes.status ? `HTTP ${configRes.status}` : null, - configRes.error || "Failed to load config", - ].filter(Boolean).join(" — "); - addRow(llmSec, "Error", detail); - } - scroll.appendChild(llmSec); - - // --- S26: Collapsible Secrets Section (always visible) --- - if (configRes.ok) { - const { config, sources, providers } = configRes.data; - - const secretsSec = createCollapsibleSection( - "UI Key Store (Advanced)", - `Server-side API key storage for portability. Recommended: Use ENV. Acceptable: Localhost-only single-user setups.`, - false // Default collapsed - ); - - const secretsContent = secretsSec.content; - - const secretProviderRow = createFormRow("Store For"); - const secretProviderSelect = document.createElement("select"); - secretProviderSelect.className = "openclaw-input openclaw-input moltbot-input"; - // Build options from provider catalog + generic fallback - const providerOptions = []; - providers.forEach(p => providerOptions.push({ id: p.id, label: p.label, requires_key: p.requires_key })); - providerOptions.push({ id: "generic", label: "Generic (fallback)", requires_key: true }); - providerOptions.forEach(p => { - // Skip local providers (no key required) unless "generic" - if (p.id !== "generic" && p.requires_key === false) return; - const opt = document.createElement("option"); - opt.value = p.id; - opt.textContent = p.label; - secretProviderSelect.appendChild(opt); - }); - secretProviderRow.appendChild(secretProviderSelect); - secretsContent.appendChild(secretProviderRow); - - const secretKeyRow = createFormRow("API Key"); - const secretKeyWrap = document.createElement("div"); - secretKeyWrap.style.display = "flex"; - secretKeyWrap.style.gap = "8px"; - secretKeyWrap.style.alignItems = "center"; - - const secretKeyInput = document.createElement("input"); - secretKeyInput.type = "password"; - secretKeyInput.className = "openclaw-input openclaw-input moltbot-input"; - secretKeyInput.placeholder = "Paste provider API key (not stored in browser)"; - secretKeyInput.value = ""; - secretKeyInput.autocomplete = "off"; - secretKeyInput.style.flex = "1"; - - const secretKeyClearBtn = document.createElement("button"); - secretKeyClearBtn.className = "openclaw-btn openclaw-btn moltbot-btn openclaw-btn-secondary openclaw-btn-secondary moltbot-btn-secondary"; - secretKeyClearBtn.textContent = "Clear"; - secretKeyClearBtn.onclick = () => { - secretKeyInput.value = ""; - }; - - secretKeyWrap.appendChild(secretKeyInput); - secretKeyWrap.appendChild(secretKeyClearBtn); - secretKeyRow.appendChild(secretKeyWrap); - secretsContent.appendChild(secretKeyRow); - - const secretsStatus = document.createElement("div"); - secretsStatus.className = "openclaw-status openclaw-status moltbot-status"; - secretsContent.appendChild(secretsStatus); - - const getAdminToken = () => { - const tok = (container.querySelector('input[type="password"][placeholder*="OPENCLAW_ADMIN_TOKEN"]')?.value || OpenClawSession.getAdminToken() || "").trim(); - return tok; - }; - - const refreshSecretsStatus = async () => { - const token = getAdminToken(); - - secretsStatus.textContent = "Loading..."; - secretsStatus.className = "openclaw-status openclaw-status moltbot-status"; - const res = await openclawApi.getSecretsStatus(token); - if (res.ok) { - const secrets = res.data?.secrets || {}; - const keys = Object.keys(secrets); - if (keys.length === 0) { - secretsStatus.textContent = "✓ No stored keys."; - secretsStatus.className = "openclaw-status openclaw-status moltbot-status ok"; - } else { - secretsStatus.textContent = `✓ Stored keys: ${keys.join(", ")}`; - secretsStatus.className = "openclaw-status openclaw-status moltbot-status ok"; - } - } else { - const detail = [ - res.status ? `HTTP ${res.status}` : null, - res.error || "Failed", - ].filter(Boolean).join(" — "); - secretsStatus.textContent = `✗ ${detail}`; - secretsStatus.className = "openclaw-status openclaw-status moltbot-status error"; - } - }; - - const secretsBtnRow = document.createElement("div"); - secretsBtnRow.className = "openclaw-btn-row openclaw-btn-row moltbot-btn-row"; - - const secretsStatusBtn = document.createElement("button"); - secretsStatusBtn.className = "openclaw-btn openclaw-btn moltbot-btn openclaw-btn-secondary openclaw-btn-secondary moltbot-btn-secondary"; - secretsStatusBtn.textContent = "Check Status"; - secretsStatusBtn.onclick = async () => { - secretsStatusBtn.disabled = true; - await refreshSecretsStatus(); - secretsStatusBtn.disabled = false; - }; - secretsBtnRow.appendChild(secretsStatusBtn); - - const secretsSaveBtn = document.createElement("button"); - secretsSaveBtn.className = "openclaw-btn openclaw-btn moltbot-btn"; - secretsSaveBtn.textContent = "Save Key"; - secretsSaveBtn.onclick = async () => { - const token = getAdminToken(); - const apiKey = (secretKeyInput.value || "").trim(); - if (!apiKey) { - secretsStatus.textContent = "Please paste an API key first."; - secretsStatus.className = "openclaw-status openclaw-status moltbot-status error"; - return; - } - if (token) OpenClawSession.setAdminToken(token); - - secretsSaveBtn.disabled = true; - secretsStatus.textContent = "Saving..."; - secretsStatus.className = "openclaw-status openclaw-status moltbot-status"; - - const res = await openclawApi.saveSecret(secretProviderSelect.value, apiKey, token); - if (res.ok) { - secretKeyInput.value = ""; - secretsStatus.textContent = "✓ Saved to server store. Restart ComfyUI if needed."; - secretsStatus.className = "openclaw-status openclaw-status moltbot-status ok"; - await refreshSecretsStatus(); - } else { - const detail = [ - res.status ? `HTTP ${res.status}` : null, - res.error || "Failed", - ].filter(Boolean).join(" — "); - secretsStatus.textContent = `✗ ${detail}`; - secretsStatus.className = "openclaw-status openclaw-status moltbot-status error"; - } - secretsSaveBtn.disabled = false; - }; - secretsBtnRow.appendChild(secretsSaveBtn); - - const secretsClearBtn = document.createElement("button"); - secretsClearBtn.className = "openclaw-btn openclaw-btn moltbot-btn openclaw-btn-danger openclaw-btn-danger moltbot-btn-danger"; - secretsClearBtn.textContent = "Clear Stored Key"; - secretsClearBtn.onclick = async () => { - const token = getAdminToken(); - if (token) OpenClawSession.setAdminToken(token); - - secretsClearBtn.disabled = true; - secretsStatus.textContent = "Clearing..."; - secretsStatus.className = "openclaw-status openclaw-status moltbot-status"; - - const res = await openclawApi.clearSecret(secretProviderSelect.value, token); - if (res.ok) { - secretsStatus.textContent = "✓ Cleared."; - secretsStatus.className = "openclaw-status openclaw-status moltbot-status ok"; - await refreshSecretsStatus(); - } else { - const detail = [ - res.status ? `HTTP ${res.status}` : null, - res.error || "Failed", - ].filter(Boolean).join(" — "); - secretsStatus.textContent = `✗ ${detail}`; - secretsStatus.className = "openclaw-status openclaw-status moltbot-status error"; - } - secretsClearBtn.disabled = false; - }; - secretsBtnRow.appendChild(secretsClearBtn); - - secretsContent.appendChild(secretsBtnRow); - - scroll.appendChild(secretsSec.container); - } - - // -- Logs Section -- - const logsSec = createSection("Recent Logs"); - const logView = document.createElement("div"); - logView.className = "openclaw-log-viewer openclaw-log-viewer moltbot-log-viewer"; - - if (logRes.ok) { - const content = logRes.data?.content; - logView.textContent = Array.isArray(content) ? content.join("\n") : String(content ?? ""); - } else { - const detail = [ - logRes.status ? `HTTP ${logRes.status}` : null, - logRes.error || "request_failed", - ].filter(Boolean).join(" — "); - logView.textContent = `Failed to load logs: ${detail}`; - } - - logsSec.appendChild(logView); - scroll.appendChild(logsSec); - - // F48: Deep Link Handling - // Format: #settings/sectionId - // We need to map known sections or just rely on text content matching if we didn't add IDs? - // Let's rely on checking hash after render. - setTimeout(() => { - const hash = window.location.hash; - if (hash && hash.startsWith("#settings/")) { - const sectionKey = hash.split("/")[1]; - let target = null; - - // Simple mapping based on section titles we created - // "LLM Settings" -> "llm" - // "UI Key Store" -> "secrets" - // "Recent Logs" -> "logs" - // "System Health" -> "health" - - const sections = Array.from(scroll.querySelectorAll(".openclaw-section")); - if (sectionKey === "llm") target = sections.find(s => s.textContent.includes("LLM Settings")); - else if (sectionKey === "secrets") { - target = sections.find(s => s.textContent.includes("UI Key Store")); - // Auto-expand if targeted - if (target) { - const content = target.querySelector(".openclaw-collapsible-content"); - const toggle = target.querySelector(".openclaw-collapsible-header span:last-child"); - if (content) content.style.display = "block"; - if (toggle) toggle.textContent = "▼"; - } - } - else if (sectionKey === "logs") target = sections.find(s => s.textContent.includes("Recent Logs")); - else if (sectionKey === "health") target = sections.find(s => s.textContent.includes("System Health")); - - if (target) { - target.scrollIntoView({ behavior: "smooth", block: "start" }); - target.style.outline = "2px solid var(--primary-color, #2196F3)"; - target.style.transition = "outline 1s"; - setTimeout(() => target.style.outline = "none", 2000); - } - } - }, 100); + await renderSettingsStatus({ scroll, healthRes, logRes, configRes, capabilities, api: openclawApi }); + if (!lifecycle.isCurrent()) return; + renderSettingsLlm({ + scroll, configRes, api: openclawApi, session: OpenClawSession, + getAdminErrorMessage, isCurrent: lifecycle.isCurrent, + }); + renderSettingsSecrets({ + scroll, configRes, api: openclawApi, session: OpenClawSession, + container, isCurrent: lifecycle.isCurrent, + }); + renderSettingsLogs({ scroll, logRes, schedule: lifecycle.schedule }); + finishSettingsRender(lifecycle); }, + dispose: (container) => disposeSettingsRender(container), }; - -function createSection(title) { - const div = document.createElement("div"); - div.className = "openclaw-section openclaw-section moltbot-section"; - const h4 = document.createElement("h4"); - h4.textContent = title; - div.appendChild(h4); - return div; -} - -function createCollapsibleSection(title, description, defaultExpanded = false) { - const container = document.createElement("div"); - container.className = "openclaw-section openclaw-section moltbot-section openclaw-collapsible-section openclaw-collapsible-section moltbot-collapsible-section"; - - const header = document.createElement("div"); - header.className = "openclaw-collapsible-header openclaw-collapsible-header moltbot-collapsible-header"; - header.style.cursor = "pointer"; - header.style.display = "flex"; - header.style.justifyContent = "space-between"; - header.style.alignItems = "center"; - header.style.userSelect = "none"; - - const titleWrap = document.createElement("div"); - titleWrap.style.display = "flex"; - titleWrap.style.alignItems = "center"; - titleWrap.style.gap = "8px"; - - const h4 = document.createElement("h4"); - h4.style.margin = "0"; - h4.innerHTML = title; - titleWrap.appendChild(h4); - - // Add help button inline - const helpBtn = createHelpButton( - "UI Key Store (Security & Usage)", - ` -

This feature lets you paste an LLM provider API key in the UI and save it to the server-side secret store ({STATE_DIR}/secrets.json).

-

Important:

-
    -
  • Recommended: use environment variables for API keys.
  • -
  • Only use UI storage on a single-user, localhost-only setup.
  • -
  • ENV keys always take priority over stored keys.
  • -
  • Secrets are stored as plaintext JSON on disk (protected by OS permissions).
  • -
  • Outbound LLM requests are protected by an SSRF policy. Built-in providers are allowlisted by default; custom Base URL hosts must be added via OPENCLAW_LLM_ALLOWED_HOSTS (or use OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1 at your own risk).
  • -
-

PowerShell: $env:OPENCLAW_LLM_API_KEY="<YOUR_API_KEY>"

-

CMD: set OPENCLAW_LLM_API_KEY=<YOUR_API_KEY>

- ` - ); - titleWrap.appendChild(helpBtn); - - - - const toggle = document.createElement("span"); - toggle.textContent = defaultExpanded ? "▼" : "►"; - toggle.style.fontSize = "12px"; - toggle.style.transition = "transform 0.2s"; - - header.appendChild(titleWrap); - header.appendChild(toggle); - container.appendChild(header); - - const descDiv = document.createElement("div"); - descDiv.className = "openclaw-note openclaw-note moltbot-note"; - descDiv.style.margin = "8px 0"; - descDiv.innerHTML = description; - container.appendChild(descDiv); - - const content = document.createElement("div"); - content.className = "openclaw-collapsible-content openclaw-collapsible-content moltbot-collapsible-content"; - content.style.display = defaultExpanded ? "block" : "none"; - content.style.marginTop = "8px"; - container.appendChild(content); - - header.onclick = () => { - const isExpanded = content.style.display !== "none"; - content.style.display = isExpanded ? "none" : "block"; - toggle.textContent = isExpanded ? "►" : "▼"; - }; - - return { container, content }; -} - -function createFormRow(label, locked = false, helpBtn = null) { - const row = document.createElement("div"); - row.className = "openclaw-form-row openclaw-form-row moltbot-form-row"; - const header = document.createElement("div"); - header.style.display = "flex"; - header.style.alignItems = "center"; - header.style.justifyContent = "space-between"; - header.style.gap = "8px"; - - const lbl = document.createElement("label"); - lbl.className = "openclaw-label openclaw-label moltbot-label"; - lbl.textContent = label + (locked ? " 🔒" : ""); - if (locked) lbl.title = "Locked (env override)"; - - header.appendChild(lbl); - if (helpBtn) header.appendChild(helpBtn); - row.appendChild(header); - return row; -} - -function createHelpButton(title, html) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "openclaw-help-btn openclaw-help-btn moltbot-help-btn"; - btn.textContent = "?"; - btn.title = "Help"; - btn.onclick = (e) => { - e.stopPropagation(); // Prevent collapsible toggle - showHelpModal(title, html); - }; - return btn; -} - -function showHelpModal(title, html) { - // Remove any existing modal overlay - const existing = document.querySelector(".openclaw-modal-overlay"); - if (existing) existing.remove(); - - const overlay = document.createElement("div"); - overlay.className = "openclaw-modal-overlay openclaw-modal-overlay moltbot-modal-overlay"; - overlay.addEventListener("click", (e) => { - if (e.target === overlay) overlay.remove(); - }); - - const modal = document.createElement("div"); - modal.className = "openclaw-modal openclaw-modal moltbot-modal"; - - const header = document.createElement("div"); - header.className = "openclaw-modal-header openclaw-modal-header moltbot-modal-header"; - header.textContent = title; - - const closeBtn = document.createElement("button"); - closeBtn.className = "openclaw-btn openclaw-btn moltbot-btn openclaw-btn-secondary openclaw-btn-secondary moltbot-btn-secondary"; - closeBtn.textContent = "Close"; - closeBtn.onclick = () => overlay.remove(); - header.appendChild(closeBtn); - - const body = document.createElement("div"); - body.className = "openclaw-modal-body openclaw-modal-body moltbot-modal-body"; - body.innerHTML = html; - - modal.appendChild(header); - modal.appendChild(body); - overlay.appendChild(modal); - document.body.appendChild(overlay); -} - -function addRow(container, key, val, valClass = "") { - const row = document.createElement("div"); - row.className = "openclaw-kv-row openclaw-kv-row moltbot-kv-row"; - - const k = document.createElement("span"); - k.className = "openclaw-kv-key openclaw-kv-key moltbot-kv-key"; - k.textContent = key; - - const v = document.createElement("span"); - v.className = `openclaw-kv-val openclaw-kv-val moltbot-kv-val ${valClass}`; - v.textContent = val; - - row.appendChild(k); - row.appendChild(v); - container.appendChild(row); -} - -async function detectComfyUiVersion(api) { - const candidates = [ - () => window?.COMFYUI_VERSION, - () => window?.comfyui_version, - () => window?.ComfyUI?.version, - () => window?.app?.version, - () => window?.app?.ui?.settings?.getSettingValue?.("ComfyUI.Version", null), - () => window?.app?.ui?.settings?.getSettingValue?.("comfyui.version", null), - ]; - - for (const get of candidates) { - try { - const v = normalizeVersion(get?.()); - if (v) return v; - } catch { } - } - - const endpoints = ["/system_stats", "/system_info", "/version"]; - for (const path of endpoints) { - try { - const res = await api.fetch(path, { timeout: 1500 }); - if (!res.ok) continue; - const v = extractComfyVersion(res.data); - if (v) return v; - } catch { } - } - - return null; -} - -function extractComfyVersion(data) { - if (!data) return null; - if (typeof data === "string") return normalizeVersion(data); - if (typeof data !== "object") return null; - - const direct = normalizeVersion(data.comfyui_version || data.comfyuiVersion); - if (direct) return direct; - - const nested = normalizeVersion(data.comfyui?.version || data.comfyui?.comfyui_version); - if (nested) return nested; - - const system = normalizeVersion(data.system?.comfyui_version || data.system?.version); - if (system) return system; - - const name = String(data.name || data.app || "").toLowerCase(); - const namedVersion = normalizeVersion(data.version); - if (namedVersion && name.includes("comfy")) return namedVersion; - - return null; -} - -function normalizeVersion(value) { - if (value === null || value === undefined) return null; - const str = String(value).trim(); - return str ? str : null; -} diff --git a/web/tabs/settings_tab_dom.js b/web/tabs/settings_tab_dom.js new file mode 100644 index 0000000..05c0ce6 --- /dev/null +++ b/web/tabs/settings_tab_dom.js @@ -0,0 +1,224 @@ +/** Stable DOM builders shared by Settings section owners. */ + +export function createSection(title) { + const div = document.createElement("div"); + div.className = "openclaw-section openclaw-section moltbot-section"; + const h4 = document.createElement("h4"); + h4.textContent = title; + div.appendChild(h4); + return div; +} + +export function createCollapsibleSection(title, description, defaultExpanded = false) { + const container = document.createElement("div"); + container.className = "openclaw-section openclaw-section moltbot-section openclaw-collapsible-section openclaw-collapsible-section moltbot-collapsible-section"; + + const header = document.createElement("div"); + header.className = "openclaw-collapsible-header openclaw-collapsible-header moltbot-collapsible-header"; + header.style.cursor = "pointer"; + header.style.display = "flex"; + header.style.justifyContent = "space-between"; + header.style.alignItems = "center"; + header.style.userSelect = "none"; + + const titleWrap = document.createElement("div"); + titleWrap.style.display = "flex"; + titleWrap.style.alignItems = "center"; + titleWrap.style.gap = "8px"; + + const h4 = document.createElement("h4"); + h4.style.margin = "0"; + h4.innerHTML = title; + titleWrap.appendChild(h4); + + // Add help button inline + const helpBtn = createHelpButton( + "UI Key Store (Security & Usage)", + ` +

This feature lets you paste an LLM provider API key in the UI and save it to the server-side secret store ({STATE_DIR}/secrets.json).

+

Important:

+
    +
  • Recommended: use environment variables for API keys.
  • +
  • Only use UI storage on a single-user, localhost-only setup.
  • +
  • ENV keys always take priority over stored keys.
  • +
  • Secrets are stored as plaintext JSON on disk (protected by OS permissions).
  • +
  • Outbound LLM requests are protected by an SSRF policy. Built-in providers are allowlisted by default; custom Base URL hosts must be added via OPENCLAW_LLM_ALLOWED_HOSTS (or use OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1 at your own risk).
  • +
+

PowerShell: $env:OPENCLAW_LLM_API_KEY="<YOUR_API_KEY>"

+

CMD: set OPENCLAW_LLM_API_KEY=<YOUR_API_KEY>

+ ` + ); + titleWrap.appendChild(helpBtn); + + + + const toggle = document.createElement("span"); + toggle.textContent = defaultExpanded ? "▼" : "►"; + toggle.style.fontSize = "12px"; + toggle.style.transition = "transform 0.2s"; + + header.appendChild(titleWrap); + header.appendChild(toggle); + container.appendChild(header); + + const descDiv = document.createElement("div"); + descDiv.className = "openclaw-note openclaw-note moltbot-note"; + descDiv.style.margin = "8px 0"; + descDiv.innerHTML = description; + container.appendChild(descDiv); + + const content = document.createElement("div"); + content.className = "openclaw-collapsible-content openclaw-collapsible-content moltbot-collapsible-content"; + content.style.display = defaultExpanded ? "block" : "none"; + content.style.marginTop = "8px"; + container.appendChild(content); + + header.onclick = () => { + const isExpanded = content.style.display !== "none"; + content.style.display = isExpanded ? "none" : "block"; + toggle.textContent = isExpanded ? "►" : "▼"; + }; + + return { container, content }; +} + +export function createFormRow(label, locked = false, helpBtn = null) { + const row = document.createElement("div"); + row.className = "openclaw-form-row openclaw-form-row moltbot-form-row"; + const header = document.createElement("div"); + header.style.display = "flex"; + header.style.alignItems = "center"; + header.style.justifyContent = "space-between"; + header.style.gap = "8px"; + + const lbl = document.createElement("label"); + lbl.className = "openclaw-label openclaw-label moltbot-label"; + lbl.textContent = label + (locked ? " 🔒" : ""); + if (locked) lbl.title = "Locked (env override)"; + + header.appendChild(lbl); + if (helpBtn) header.appendChild(helpBtn); + row.appendChild(header); + return row; +} + +export function createHelpButton(title, html) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "openclaw-help-btn openclaw-help-btn moltbot-help-btn"; + btn.textContent = "?"; + btn.title = "Help"; + btn.onclick = (e) => { + e.stopPropagation(); // Prevent collapsible toggle + showHelpModal(title, html); + }; + return btn; +} + +export function showHelpModal(title, html) { + // Remove any existing modal overlay + const existing = document.querySelector(".openclaw-modal-overlay"); + if (existing) existing.remove(); + + const overlay = document.createElement("div"); + overlay.className = "openclaw-modal-overlay openclaw-modal-overlay moltbot-modal-overlay"; + overlay.addEventListener("click", (e) => { + if (e.target === overlay) overlay.remove(); + }); + + const modal = document.createElement("div"); + modal.className = "openclaw-modal openclaw-modal moltbot-modal"; + + const header = document.createElement("div"); + header.className = "openclaw-modal-header openclaw-modal-header moltbot-modal-header"; + header.textContent = title; + + const closeBtn = document.createElement("button"); + closeBtn.className = "openclaw-btn openclaw-btn moltbot-btn openclaw-btn-secondary openclaw-btn-secondary moltbot-btn-secondary"; + closeBtn.textContent = "Close"; + closeBtn.onclick = () => overlay.remove(); + header.appendChild(closeBtn); + + const body = document.createElement("div"); + body.className = "openclaw-modal-body openclaw-modal-body moltbot-modal-body"; + body.innerHTML = html; + + modal.appendChild(header); + modal.appendChild(body); + overlay.appendChild(modal); + document.body.appendChild(overlay); +} + +export function addRow(container, key, val, valClass = "") { + const row = document.createElement("div"); + row.className = "openclaw-kv-row openclaw-kv-row moltbot-kv-row"; + + const k = document.createElement("span"); + k.className = "openclaw-kv-key openclaw-kv-key moltbot-kv-key"; + k.textContent = key; + + const v = document.createElement("span"); + v.className = `openclaw-kv-val openclaw-kv-val moltbot-kv-val ${valClass}`; + v.textContent = val; + + row.appendChild(k); + row.appendChild(v); + container.appendChild(row); +} + +export async function detectComfyUiVersion(api) { + const candidates = [ + () => window?.COMFYUI_VERSION, + () => window?.comfyui_version, + () => window?.ComfyUI?.version, + () => window?.app?.version, + () => window?.app?.ui?.settings?.getSettingValue?.("ComfyUI.Version", null), + () => window?.app?.ui?.settings?.getSettingValue?.("comfyui.version", null), + ]; + + for (const get of candidates) { + try { + const v = normalizeVersion(get?.()); + if (v) return v; + } catch { } + } + + const endpoints = ["/system_stats", "/system_info", "/version"]; + for (const path of endpoints) { + try { + const res = await api.fetch(path, { timeout: 1500 }); + if (!res.ok) continue; + const v = extractComfyVersion(res.data); + if (v) return v; + } catch { } + } + + return null; +} + +export function extractComfyVersion(data) { + if (!data) return null; + if (typeof data === "string") return normalizeVersion(data); + if (typeof data !== "object") return null; + + const direct = normalizeVersion(data.comfyui_version || data.comfyuiVersion); + if (direct) return direct; + + const nested = normalizeVersion(data.comfyui?.version || data.comfyui?.comfyui_version); + if (nested) return nested; + + const system = normalizeVersion(data.system?.comfyui_version || data.system?.version); + if (system) return system; + + const name = String(data.name || data.app || "").toLowerCase(); + const namedVersion = normalizeVersion(data.version); + if (namedVersion && name.includes("comfy")) return namedVersion; + + return null; +} + +export function normalizeVersion(value) { + if (value === null || value === undefined) return null; + const str = String(value).trim(); + return str ? str : null; +} diff --git a/web/tabs/settings_tab_lifecycle.js b/web/tabs/settings_tab_lifecycle.js new file mode 100644 index 0000000..722336d --- /dev/null +++ b/web/tabs/settings_tab_lifecycle.js @@ -0,0 +1,47 @@ +/** Generation-owned lifecycle for async Settings renders. */ +let activeRender = null; +let nextGeneration = 0; + +function cancel(record) { + record.controller.abort(); + for (const cleanup of record.cleanups) cleanup(); + record.cleanups.clear(); +} + +export function beginSettingsRender(container) { + if (activeRender) cancel(activeRender); + const record = { + generation: ++nextGeneration, + container, + controller: new AbortController(), + cleanups: new Set(), + finished: false, + }; + const context = Object.freeze({ + container, + signal: record.controller.signal, + isCurrent: () => activeRender === record && !record.controller.signal.aborted, + schedule: (callback, delay) => { + const timer = setTimeout(() => { + if (activeRender === record && !record.controller.signal.aborted) callback(); + }, delay); + record.cleanups.add(() => clearTimeout(timer)); + return timer; + }, + }); + record.context = context; + activeRender = record; + return context; +} + +export function finishSettingsRender(context) { + if (activeRender?.context === context) activeRender.finished = true; +} + +export function disposeSettingsRender(container) { + if (!activeRender || activeRender.container !== container) return false; + const requiresRerender = !activeRender.finished; + cancel(activeRender); + activeRender = null; + return requiresRerender; +} diff --git a/web/tabs/settings_tab_llm.js b/web/tabs/settings_tab_llm.js new file mode 100644 index 0000000..38c0cab --- /dev/null +++ b/web/tabs/settings_tab_llm.js @@ -0,0 +1,386 @@ +/** Render LLM configuration controls behind the stable Settings facade. */ +import { addRow, createFormRow, createHelpButton, createSection } from "./settings_tab_dom.js"; + +export function renderSettingsLlm({ scroll, configRes, api, session, getAdminErrorMessage, isCurrent }) { + // -- LLM Settings Section -- + const llmSec = createSection("LLM Settings"); + if (configRes.ok) { + // R54: Null-safe destructuring with defaults + const data = configRes.data || {}; + const config = data.config || {}; + const sources = data.sources || {}; + const providers = data.providers || []; + // R53: Apply feedback (optional, for debug/toast later) + const applyInfo = data.apply || {}; + // R70: Settings schema (for frontend validation) + const schema = data.schema || {}; + + + // Provider dropdown + const providerRow = createFormRow("Provider", sources.provider === "env"); + const providerSelect = document.createElement("select"); + providerSelect.className = "openclaw-input openclaw-input moltbot-input"; + providerSelect.disabled = sources.provider === "env"; + providers.forEach(p => { + const opt = document.createElement("option"); + opt.value = p.id; + opt.textContent = p.label; + if (p.id === config.provider) opt.selected = true; + providerSelect.appendChild(opt); + }); + providerRow.appendChild(providerSelect); + llmSec.appendChild(providerRow); + + // R60: Reset model list when provider changes (avoids showing stale models from another provider). + const resetModelList = () => { + modelsLoaded = false; + lastLoadedModels = []; + modelSelect.innerHTML = ""; + modelDatalist.innerHTML = ""; + modelsStatus.textContent = ""; + modelsStatus.className = "openclaw-status openclaw-status moltbot-status"; + updateModelUiVisibility(); + }; + providerSelect.onchange = () => resetModelList(); + + // Model input + const modelRow = createFormRow("Model", sources.model === "env"); + const modelWrap = document.createElement("div"); + modelWrap.style.display = "flex"; + modelWrap.style.gap = "8px"; + modelWrap.style.alignItems = "center"; + + // Model selection UX: + // - Default: free-text input (works even if model listing isn't supported). + // - After "Load Models": show a real visible and use "Custom…" as a bridge. + const showSelect = modelsLoaded; + const showInput = !modelsLoaded || modelSelect.value === MODEL_CUSTOM; + + modelSelect.style.display = showSelect ? "" : "none"; + modelInput.style.display = showInput ? "" : "none"; + }; + + const populateModelSelect = (models) => { + modelSelect.innerHTML = ""; + + const customOpt = document.createElement("option"); + customOpt.value = MODEL_CUSTOM; + customOpt.textContent = "Custom…"; + modelSelect.appendChild(customOpt); + + models.slice(0, 5000).forEach((m) => { + const opt = document.createElement("option"); + opt.value = m; + opt.textContent = m; + modelSelect.appendChild(opt); + }); + + modelsLoaded = true; + const current = (modelInput.value || "").trim(); + if (current && models.includes(current)) { + modelSelect.value = current; + } else { + modelSelect.value = MODEL_CUSTOM; + } + updateModelUiVisibility(); + }; + + modelSelect.onchange = () => { + const v = modelSelect.value; + if (v === MODEL_CUSTOM) { + updateModelUiVisibility(); + modelInput.focus(); + return; + } + modelInput.value = v; + updateModelUiVisibility(); + }; + + const refreshModelsBtn = document.createElement("button"); + refreshModelsBtn.className = "openclaw-btn openclaw-btn moltbot-btn openclaw-btn-secondary openclaw-btn-secondary moltbot-btn-secondary"; + refreshModelsBtn.textContent = "Load Models"; + refreshModelsBtn.disabled = false; + refreshModelsBtn.title = "Fetch remote model list (admin boundary)."; + + const modelsStatus = document.createElement("div"); + modelsStatus.className = "openclaw-status openclaw-status moltbot-status"; + modelsStatus.style.minWidth = "120px"; + + let tokenInput; // Will be set below + + refreshModelsBtn.onclick = async () => { + const token = (tokenInput?.value || session.getAdminToken() || "").trim(); + refreshModelsBtn.disabled = true; + modelsStatus.textContent = "Loading..."; + modelsStatus.className = "openclaw-status openclaw-status moltbot-status"; + + const res = await api.getModelList(providerSelect.value, token); + if (!isCurrent()) return; + if (res.ok) { + modelDatalist.innerHTML = ""; + const models = Array.isArray(res.data?.models) ? res.data.models : []; + lastLoadedModels = models; + models.slice(0, 5000).forEach(m => { + const opt = document.createElement("option"); + opt.value = m; + modelDatalist.appendChild(opt); + }); + populateModelSelect(models); + modelsStatus.textContent = `✓ ${models.length} models`; + modelsStatus.className = "openclaw-status openclaw-status moltbot-status ok"; + } else { + const detail = [ + res.status ? `HTTP ${res.status}` : null, + res.error || "Failed", + ].filter(Boolean).join(" — "); + modelsStatus.textContent = `✗ ${detail}`; + modelsStatus.className = "openclaw-status openclaw-status moltbot-status error"; + } + refreshModelsBtn.disabled = false; + }; + + modelWrap.appendChild(modelSelect); + modelWrap.appendChild(modelInput); + modelWrap.appendChild(refreshModelsBtn); + modelWrap.appendChild(modelsStatus); + + modelRow.appendChild(modelWrap); + modelRow.appendChild(modelDatalist); + llmSec.appendChild(modelRow); + + // Base URL input + const baseUrlRow = createFormRow("Base URL", sources.base_url === "env"); + const baseUrlInput = document.createElement("input"); + baseUrlInput.type = "text"; + baseUrlInput.className = "openclaw-input openclaw-input moltbot-input"; + baseUrlInput.value = config.base_url || ""; + baseUrlInput.placeholder = "Leave empty for provider default"; + baseUrlInput.disabled = sources.base_url === "env"; + baseUrlRow.appendChild(baseUrlInput); + llmSec.appendChild(baseUrlRow); + + // R60: Reset model list when base URL changes (cache key includes base_url). + baseUrlInput.onchange = () => resetModelList(); + + // Timeout + const timeoutRow = createFormRow("Timeout (sec)", sources.timeout_sec === "env"); + const timeoutInput = document.createElement("input"); + timeoutInput.type = "number"; + timeoutInput.className = "openclaw-input openclaw-input moltbot-input openclaw-input-sm openclaw-input-sm moltbot-input-sm"; + timeoutInput.value = config.timeout_sec || 120; + timeoutInput.min = 5; + timeoutInput.max = 300; + timeoutInput.disabled = sources.timeout_sec === "env"; + timeoutRow.appendChild(timeoutInput); + llmSec.appendChild(timeoutRow); + + // Max Retries + const retriesRow = createFormRow("Max Retries", sources.max_retries === "env"); + const retriesInput = document.createElement("input"); + retriesInput.type = "number"; + retriesInput.className = "openclaw-input openclaw-input moltbot-input openclaw-input-sm openclaw-input-sm moltbot-input-sm"; + retriesInput.value = config.max_retries || 3; + retriesInput.min = 0; + retriesInput.max = 10; + retriesInput.disabled = sources.max_retries === "env"; + retriesRow.appendChild(retriesInput); + llmSec.appendChild(retriesRow); + + // --- Admin Token Section --- + const tokenRow = createFormRow( + "Admin Token", + false, + createHelpButton( + "Admin Token", + ` +

The Admin Token authorizes write actions (save config, test LLM, store keys).

+
    +
  • If OPENCLAW_ADMIN_TOKEN (or legacy MOLTBOT_ADMIN_TOKEN) is set on the server, you must enter the same token here.
  • +
  • If no server token is configured, admin actions are allowed on localhost only (convenience mode).
  • +
  • Never expose ComfyUI/OpenClaw to the public internet without proper access controls.
  • +
+

PowerShell: $env:OPENCLAW_ADMIN_TOKEN="your-secret-token"

+

CMD: set OPENCLAW_ADMIN_TOKEN=your-secret-token

+ ` + ) + ); + tokenInput = document.createElement("input"); + tokenInput.type = "password"; + tokenInput.className = "openclaw-input openclaw-input moltbot-input"; + tokenInput.placeholder = "Enter OPENCLAW_ADMIN_TOKEN if required (localhost-only if not configured)"; + tokenInput.value = ""; + tokenInput.autocomplete = "off"; + + const tokenClearBtn = document.createElement("button"); + tokenClearBtn.className = "openclaw-btn openclaw-btn moltbot-btn openclaw-btn-secondary openclaw-btn-secondary moltbot-btn-secondary"; + tokenClearBtn.textContent = "Clear"; + tokenClearBtn.style.marginLeft = "4px"; + tokenClearBtn.onclick = () => { + tokenInput.value = ""; + session.setAdminToken(""); + }; + + tokenRow.appendChild(tokenInput); + tokenRow.appendChild(tokenClearBtn); + llmSec.appendChild(tokenRow); + + // Status message area + const statusDiv = document.createElement("div"); + statusDiv.className = "openclaw-status openclaw-status moltbot-status"; + llmSec.appendChild(statusDiv); + + // Buttons row + const btnRow = document.createElement("div"); + btnRow.className = "openclaw-btn-row openclaw-btn-row moltbot-btn-row"; + + // Save button + const saveBtn = document.createElement("button"); + saveBtn.className = "openclaw-btn openclaw-btn moltbot-btn"; + saveBtn.textContent = "Save"; + saveBtn.onclick = async () => { + const token = (tokenInput.value || session.getAdminToken() || "").trim(); + if (token) session.setAdminToken(token); + + saveBtn.disabled = true; + statusDiv.textContent = "Saving..."; + statusDiv.className = "openclaw-status openclaw-status moltbot-status"; + + const updates = { + provider: providerSelect.value, + model: modelInput.value, + base_url: baseUrlInput.value, + timeout_sec: parseInt(timeoutInput.value) || 120, + max_retries: parseInt(retriesInput.value) || 3, + }; + + // R70: Client-side schema coercion (if schema available) + if (schema && Object.keys(schema).length > 0) { + for (const [k, v] of Object.entries(updates)) { + const def = schema[k]; + if (!def) continue; + if (def.type === "int" && typeof v !== "number") { + updates[k] = parseInt(v) || def.default; + } + } + } + + const res = await api.putConfig(updates, token); + if (!isCurrent()) return; + // R53: Hot-Reload Feedback + if (res.ok) { + const apply = res.data?.apply || {}; + let msg = "✓ Saved!"; + + if (apply.restart_required?.length > 0) { + msg += " Restart required for: " + apply.restart_required.join(", "); + statusDiv.className = "openclaw-status openclaw-status moltbot-status warning"; // Yellow/Orange + } else if (apply.applied_now?.length > 0) { + msg += " Applied immediately (Hot Reload)."; + statusDiv.className = "openclaw-status openclaw-status moltbot-status ok"; + } else { + // No changes or unknown + statusDiv.className = "openclaw-status openclaw-status moltbot-status ok"; + } + statusDiv.textContent = msg; + } else { + const errorMsg = getAdminErrorMessage(res.error, res.status); + statusDiv.textContent = `✗ ${res.errors?.join(", ") || errorMsg}`; + statusDiv.className = "openclaw-status openclaw-status moltbot-status error"; + } + saveBtn.disabled = false; + }; + btnRow.appendChild(saveBtn); + + // Test button + const testBtn = document.createElement("button"); + testBtn.className = "openclaw-btn openclaw-btn moltbot-btn openclaw-btn-secondary openclaw-btn-secondary moltbot-btn-secondary"; + testBtn.textContent = "Test Connection"; + + // R54: Debounced Test Action to prevent spam + // We use a separate handler because we need to manage button state (disabled/enabled) + // which debounce interferes with if not careful. + // Better strategy: Disable button immediately on click, re-enable after completion. + // Debounce is less critical here if we disable the button, but good for "auto-test on change" (future). + // For now, implementing "Disable while testing" is the better guard than generic debounce for a button click. + testBtn.onclick = async () => { + if (testBtn.disabled) return; + + const token = (tokenInput.value || session.getAdminToken() || "").trim(); + if (token) session.setAdminToken(token); + + testBtn.disabled = true; + statusDiv.textContent = "Testing..."; + statusDiv.className = "openclaw-status openclaw-status moltbot-status"; + + // IMPORTANT (provider mismatch): "Test Connection" must test the provider/model currently + // selected in the UI, even if the user hasn't clicked Save yet. Otherwise, the backend + // falls back to the effective config (often "openai") and produces confusing errors like: + // "API key not configured for provider 'openai'" while the UI is set to Gemini. + try { + const res = await api.testLLM(token, { + provider: providerSelect.value, + model: modelInput.value, + base_url: baseUrlInput.value, + timeout_sec: parseInt(timeoutInput.value) || 120, + max_retries: parseInt(retriesInput.value) || 3, + }); + if (!isCurrent()) return; + if (res.ok) { + statusDiv.textContent = "✓ Success! " + (res.response ? `"${res.response}"` : ""); + statusDiv.className = "openclaw-status openclaw-status moltbot-status ok"; + } else { + const errorMsg = getAdminErrorMessage(res.error, res.status); + statusDiv.textContent = `✗ ${errorMsg}`; + statusDiv.className = "openclaw-status openclaw-status moltbot-status error"; + } + } finally { + if (isCurrent()) testBtn.disabled = false; + } + }; + btnRow.appendChild(testBtn); + + llmSec.appendChild(btnRow); + + // API Key instructions + const keyNote = document.createElement("div"); + keyNote.className = "openclaw-note openclaw-note moltbot-note"; + keyNote.innerHTML = `API Key: Use OPENCLAW_LLM_API_KEY (or provider-specific keys) via environment variable (recommended), or enable the UI Key Store below (server-side storage; never stored in browser).`; + llmSec.appendChild(keyNote); + + } else { + const detail = [ + configRes.status ? `HTTP ${configRes.status}` : null, + configRes.error || "Failed to load config", + ].filter(Boolean).join(" — "); + addRow(llmSec, "Error", detail); + } + scroll.appendChild(llmSec); +} diff --git a/web/tabs/settings_tab_logs.js b/web/tabs/settings_tab_logs.js new file mode 100644 index 0000000..193fa08 --- /dev/null +++ b/web/tabs/settings_tab_logs.js @@ -0,0 +1,63 @@ +/** Render recent logs and bounded deep-link highlighting. */ +import { createSection } from "./settings_tab_dom.js"; + +export function renderSettingsLogs({ scroll, logRes, schedule }) { + // -- Logs Section -- + const logsSec = createSection("Recent Logs"); + const logView = document.createElement("div"); + logView.className = "openclaw-log-viewer openclaw-log-viewer moltbot-log-viewer"; + + if (logRes.ok) { + const content = logRes.data?.content; + logView.textContent = Array.isArray(content) ? content.join("\n") : String(content ?? ""); + } else { + const detail = [ + logRes.status ? `HTTP ${logRes.status}` : null, + logRes.error || "request_failed", + ].filter(Boolean).join(" — "); + logView.textContent = `Failed to load logs: ${detail}`; + } + + logsSec.appendChild(logView); + scroll.appendChild(logsSec); + + // F48: Deep Link Handling + // Format: #settings/sectionId + // We need to map known sections or just rely on text content matching if we didn't add IDs? + // Let's rely on checking hash after render. + schedule(() => { + const hash = window.location.hash; + if (hash && hash.startsWith("#settings/")) { + const sectionKey = hash.split("/")[1]; + let target = null; + + // Simple mapping based on section titles we created + // "LLM Settings" -> "llm" + // "UI Key Store" -> "secrets" + // "Recent Logs" -> "logs" + // "System Health" -> "health" + + const sections = Array.from(scroll.querySelectorAll(".openclaw-section")); + if (sectionKey === "llm") target = sections.find(s => s.textContent.includes("LLM Settings")); + else if (sectionKey === "secrets") { + target = sections.find(s => s.textContent.includes("UI Key Store")); + // Auto-expand if targeted + if (target) { + const content = target.querySelector(".openclaw-collapsible-content"); + const toggle = target.querySelector(".openclaw-collapsible-header span:last-child"); + if (content) content.style.display = "block"; + if (toggle) toggle.textContent = "▼"; + } + } + else if (sectionKey === "logs") target = sections.find(s => s.textContent.includes("Recent Logs")); + else if (sectionKey === "health") target = sections.find(s => s.textContent.includes("System Health")); + + if (target) { + target.scrollIntoView({ behavior: "smooth", block: "start" }); + target.style.outline = "2px solid var(--primary-color, #2196F3)"; + target.style.transition = "outline 1s"; + schedule(() => target.style.outline = "none", 2000); + } + } + }, 100); +} diff --git a/web/tabs/settings_tab_secrets.js b/web/tabs/settings_tab_secrets.js new file mode 100644 index 0000000..b8377e6 --- /dev/null +++ b/web/tabs/settings_tab_secrets.js @@ -0,0 +1,179 @@ +/** Render server-side secret-store controls without retaining secret values. */ +import { createCollapsibleSection, createFormRow } from "./settings_tab_dom.js"; + +export function renderSettingsSecrets({ scroll, configRes, api, session, container, isCurrent }) { + // --- S26: Collapsible Secrets Section (always visible) --- + if (configRes.ok) { + const { config, sources, providers } = configRes.data; + + const secretsSec = createCollapsibleSection( + "UI Key Store (Advanced)", + `Server-side API key storage for portability. Recommended: Use ENV. Acceptable: Localhost-only single-user setups.`, + false // Default collapsed + ); + + const secretsContent = secretsSec.content; + + const secretProviderRow = createFormRow("Store For"); + const secretProviderSelect = document.createElement("select"); + secretProviderSelect.className = "openclaw-input openclaw-input moltbot-input"; + // Build options from provider catalog + generic fallback + const providerOptions = []; + providers.forEach(p => providerOptions.push({ id: p.id, label: p.label, requires_key: p.requires_key })); + providerOptions.push({ id: "generic", label: "Generic (fallback)", requires_key: true }); + providerOptions.forEach(p => { + // Skip local providers (no key required) unless "generic" + if (p.id !== "generic" && p.requires_key === false) return; + const opt = document.createElement("option"); + opt.value = p.id; + opt.textContent = p.label; + secretProviderSelect.appendChild(opt); + }); + secretProviderRow.appendChild(secretProviderSelect); + secretsContent.appendChild(secretProviderRow); + + const secretKeyRow = createFormRow("API Key"); + const secretKeyWrap = document.createElement("div"); + secretKeyWrap.style.display = "flex"; + secretKeyWrap.style.gap = "8px"; + secretKeyWrap.style.alignItems = "center"; + + const secretKeyInput = document.createElement("input"); + secretKeyInput.type = "password"; + secretKeyInput.className = "openclaw-input openclaw-input moltbot-input"; + secretKeyInput.placeholder = "Paste provider API key (not stored in browser)"; + secretKeyInput.value = ""; + secretKeyInput.autocomplete = "off"; + secretKeyInput.style.flex = "1"; + + const secretKeyClearBtn = document.createElement("button"); + secretKeyClearBtn.className = "openclaw-btn openclaw-btn moltbot-btn openclaw-btn-secondary openclaw-btn-secondary moltbot-btn-secondary"; + secretKeyClearBtn.textContent = "Clear"; + secretKeyClearBtn.onclick = () => { + secretKeyInput.value = ""; + }; + + secretKeyWrap.appendChild(secretKeyInput); + secretKeyWrap.appendChild(secretKeyClearBtn); + secretKeyRow.appendChild(secretKeyWrap); + secretsContent.appendChild(secretKeyRow); + + const secretsStatus = document.createElement("div"); + secretsStatus.className = "openclaw-status openclaw-status moltbot-status"; + secretsContent.appendChild(secretsStatus); + + const getAdminToken = () => { + const tok = (container.querySelector('input[type="password"][placeholder*="OPENCLAW_ADMIN_TOKEN"]')?.value || session.getAdminToken() || "").trim(); + return tok; + }; + + const refreshSecretsStatus = async () => { + const token = getAdminToken(); + + secretsStatus.textContent = "Loading..."; + secretsStatus.className = "openclaw-status openclaw-status moltbot-status"; + const res = await api.getSecretsStatus(token); + if (!isCurrent()) return; + if (res.ok) { + const secrets = res.data?.secrets || {}; + const keys = Object.keys(secrets); + if (keys.length === 0) { + secretsStatus.textContent = "✓ No stored keys."; + secretsStatus.className = "openclaw-status openclaw-status moltbot-status ok"; + } else { + secretsStatus.textContent = `✓ Stored keys: ${keys.join(", ")}`; + secretsStatus.className = "openclaw-status openclaw-status moltbot-status ok"; + } + } else { + const detail = [ + res.status ? `HTTP ${res.status}` : null, + res.error || "Failed", + ].filter(Boolean).join(" — "); + secretsStatus.textContent = `✗ ${detail}`; + secretsStatus.className = "openclaw-status openclaw-status moltbot-status error"; + } + }; + + const secretsBtnRow = document.createElement("div"); + secretsBtnRow.className = "openclaw-btn-row openclaw-btn-row moltbot-btn-row"; + + const secretsStatusBtn = document.createElement("button"); + secretsStatusBtn.className = "openclaw-btn openclaw-btn moltbot-btn openclaw-btn-secondary openclaw-btn-secondary moltbot-btn-secondary"; + secretsStatusBtn.textContent = "Check Status"; + secretsStatusBtn.onclick = async () => { + secretsStatusBtn.disabled = true; + await refreshSecretsStatus(); + if (isCurrent()) secretsStatusBtn.disabled = false; + }; + secretsBtnRow.appendChild(secretsStatusBtn); + + const secretsSaveBtn = document.createElement("button"); + secretsSaveBtn.className = "openclaw-btn openclaw-btn moltbot-btn"; + secretsSaveBtn.textContent = "Save Key"; + secretsSaveBtn.onclick = async () => { + const token = getAdminToken(); + const apiKey = (secretKeyInput.value || "").trim(); + if (!apiKey) { + secretsStatus.textContent = "Please paste an API key first."; + secretsStatus.className = "openclaw-status openclaw-status moltbot-status error"; + return; + } + if (token) session.setAdminToken(token); + + secretsSaveBtn.disabled = true; + secretsStatus.textContent = "Saving..."; + secretsStatus.className = "openclaw-status openclaw-status moltbot-status"; + + const res = await api.saveSecret(secretProviderSelect.value, apiKey, token); + if (!isCurrent()) return; + if (res.ok) { + secretKeyInput.value = ""; + secretsStatus.textContent = "✓ Saved to server store. Restart ComfyUI if needed."; + secretsStatus.className = "openclaw-status openclaw-status moltbot-status ok"; + await refreshSecretsStatus(); + } else { + const detail = [ + res.status ? `HTTP ${res.status}` : null, + res.error || "Failed", + ].filter(Boolean).join(" — "); + secretsStatus.textContent = `✗ ${detail}`; + secretsStatus.className = "openclaw-status openclaw-status moltbot-status error"; + } + if (isCurrent()) secretsSaveBtn.disabled = false; + }; + secretsBtnRow.appendChild(secretsSaveBtn); + + const secretsClearBtn = document.createElement("button"); + secretsClearBtn.className = "openclaw-btn openclaw-btn moltbot-btn openclaw-btn-danger openclaw-btn-danger moltbot-btn-danger"; + secretsClearBtn.textContent = "Clear Stored Key"; + secretsClearBtn.onclick = async () => { + const token = getAdminToken(); + if (token) session.setAdminToken(token); + + secretsClearBtn.disabled = true; + secretsStatus.textContent = "Clearing..."; + secretsStatus.className = "openclaw-status openclaw-status moltbot-status"; + + const res = await api.clearSecret(secretProviderSelect.value, token); + if (!isCurrent()) return; + if (res.ok) { + secretsStatus.textContent = "✓ Cleared."; + secretsStatus.className = "openclaw-status openclaw-status moltbot-status ok"; + await refreshSecretsStatus(); + } else { + const detail = [ + res.status ? `HTTP ${res.status}` : null, + res.error || "Failed", + ].filter(Boolean).join(" — "); + secretsStatus.textContent = `✗ ${detail}`; + secretsStatus.className = "openclaw-status openclaw-status moltbot-status error"; + } + if (isCurrent()) secretsClearBtn.disabled = false; + }; + secretsBtnRow.appendChild(secretsClearBtn); + + secretsContent.appendChild(secretsBtnRow); + + scroll.appendChild(secretsSec.container); + } +} diff --git a/web/tabs/settings_tab_status.js b/web/tabs/settings_tab_status.js new file mode 100644 index 0000000..a1b8376 --- /dev/null +++ b/web/tabs/settings_tab_status.js @@ -0,0 +1,115 @@ +/** Render backend diagnostics and health without owning lifecycle state. */ +import { addRow, createSection, detectComfyUiVersion } from "./settings_tab_dom.js"; + +export async function renderSettingsStatus({ scroll, healthRes, logRes, configRes, capabilities, api }) { + // If everything is 404, backend routes not registered + + // -- UI Boot Diagnostics / Backend Warning -- + const all404 = [healthRes, logRes, configRes].every(r => r && r.ok === false && r.status === 404); + if (all404) { + const warn = createSection("Backend Not Loaded"); + const hint = document.createElement("div"); + hint.className = "openclaw-note openclaw-note moltbot-note"; + hint.style.borderLeft = "4px solid #ff4444"; + hint.innerHTML = ` + OpenClaw UI loaded, but the server endpoints returned HTTP 404. + This usually means ComfyUI did not load the Python part of this custom node pack. +

+ Check ComfyUI startup logs for errors while importing ComfyUI-OpenClaw/Comfyui-OpenClaw, + then restart ComfyUI. +

+ Expected endpoints: +
    +
  • /openclaw/health (legacy: /moltbot/health)
  • +
  • /openclaw/config
  • +
  • /openclaw/logs/tail
  • +
+ `; + warn.appendChild(hint); + scroll.appendChild(warn); + } + + // F39: Show degraded-state banner when capabilities are missing or partial + if (!all404 && healthRes.ok && Object.keys(capabilities).length === 0) { + const degradedWarn = createSection("Limited Mode"); + const degradedHint = document.createElement("div"); + degradedHint.className = "openclaw-note openclaw-note moltbot-note"; + degradedHint.style.borderLeft = "4px solid #ffaa00"; + degradedHint.innerHTML = ` + ⚠ Capabilities endpoint unavailable. Some features may be hidden or behave differently. + This can happen if the backend pack version is older than the frontend UI. +
Consider updating ComfyUI-OpenClaw to the latest version. + `; + degradedWarn.appendChild(degradedHint); + scroll.appendChild(degradedWarn); + } + + // -- System Health & Diagnostics -- + const healthSec = createSection("System Health"); + + // F26: Diagnostics Block (Shim status + ComfyUI version) + const diagDetails = document.createElement("details"); + diagDetails.style.marginBottom = "10px"; + diagDetails.style.padding = "8px"; + diagDetails.style.background = "var(--comfy-input-bg)"; + diagDetails.style.borderRadius = "4px"; + diagDetails.style.fontSize = "12px"; + diagDetails.style.color = "var(--input-text)"; + + // Detect Shim + const hasShim = typeof window.comfyAPI?.fetchApi === "function" || typeof window.fetchApi === "function" || !!healthRes.ok; + // Note: fetchApi is imported in module scope, not global. If request worked, shim worked. + // Actually best check is if healthRes.ok or we can inspect 'openclawApi.prefix' implicitly. + + const packVer = (healthRes.ok && healthRes.data?.pack?.version) || "Unknown"; + const basePath = (healthRes.ok && healthRes.data?.pack?.base_path) || "/openclaw (inferred)"; + const comfyVersion = await detectComfyUiVersion(api); + + // Collapsed by default; auto-expand if errors + diagDetails.open = all404 || !hasShim; + + const summary = document.createElement("summary"); + summary.style.display = "flex"; + summary.style.justifyContent = "space-between"; + summary.style.alignItems = "center"; + summary.style.cursor = "pointer"; + summary.innerHTML = ` + UI Boot Status + ${all404 ? "⚠️ Backend 404" : "✓ Connected"} + `; + + const body = document.createElement("div"); + body.innerHTML = ` +
+ ComfyUI: ${comfyVersion || "Unknown"} | Pack: ${packVer} | Prefix: ${basePath} +
+
+ Shim: ${hasShim ? "✓ Detected" : "⚠️ Missing (shim broken)"} +
+ `; + + diagDetails.appendChild(summary); + diagDetails.appendChild(body); + healthSec.appendChild(diagDetails); + + if (healthRes.ok) { + const { pack, config, uptime_sec } = healthRes.data; + addRow(healthSec, "Uptime", `${Math.floor(uptime_sec)}s`); + + const keyStatus = config.llm_key_configured + ? "Configured" + : (config.llm_key_required ? "Missing" : "Not Req"); + const keyClass = (config.llm_key_configured || !config.llm_key_required) ? "ok" : "error"; + addRow(healthSec, "API Key", keyStatus, keyClass); + } else { + if (!all404) { + addRow(healthSec, "Status", "Error", "error"); + const detail = [ + healthRes.status ? `HTTP ${healthRes.status}` : null, + healthRes.error || "request_failed", + ].filter(Boolean).join(" — "); + addRow(healthSec, "Detail", detail); + } + } + scroll.appendChild(healthSec); +} diff --git a/web/tests/fixtures/frontend_decomposition_contract_r224.json b/web/tests/fixtures/frontend_decomposition_contract_r224.json new file mode 100644 index 0000000..8121c2a --- /dev/null +++ b/web/tests/fixtures/frontend_decomposition_contract_r224.json @@ -0,0 +1,180 @@ +{ + "schema_version": 1, + "api": { + "exports": [ + "OpenClawAPI", + "openclawApi" + ], + "methods": { + "_adminTokenHeaders": "token", + "_candidatePaths": "url", + "_fetchWithCandidates": "url, options = {}", + "_getAdminToken": "", + "_parseSSEChunk": "rawChunk", + "_path": "suffix", + "approveRequest": "id, { actor = \"web_user\", autoExecute = true } = {}", + "buildViewUrl": "filename, subfolder = \"\", type = \"output\"", + "buildViewUrlForRef": "imageRef", + "cancelModelDownloadTask": "taskId", + "clearSecret": "provider, adminToken", + "constructor": "", + "createCheckpoint": "name, workflow, description = \"\"", + "createModelDownloadTask": "payload", + "createPreset": "data", + "deleteCheckpoint": "id", + "deletePack": "name, version", + "deletePreset": "id", + "exportPack": "name, version", + "fetch": "url, options = {}", + "getApproval": "id", + "getApprovals": "{ status, limit = 100, offset = 0 } = {}", + "getCapabilities": "", + "getCheckpoint": "id", + "getConfig": "", + "getEvents": "lastSeq = 0", + "getHealth": "", + "getHistory": "promptId", + "getInventory": "", + "getLogs": "lines = 200", + "getModelDownloadTask": "taskId", + "getModelList": "providerId, adminToken", + "getPacks": "", + "getPreset": "id", + "getPromptQueue": "", + "getSecretsStatus": "adminToken", + "getTrace": "promptId", + "importDownloadedModel": "payload", + "importPack": "file, overwrite = false", + "listCheckpoints": "", + "listModelDownloadTasks": "params = {}", + "listModelInstallations": "params = {}", + "listPlannerProfiles": "signal = null", + "listPresets": "category", + "parsePngInfo": "imageB64", + "putConfig": "config, adminToken", + "rejectRequest": "id, { actor = \"web_user\" } = {}", + "runLLMTest": "", + "runPlanner": "params, signal = null", + "runPlannerStream": "params, { signal = null, onEvent = null } = {}", + "runPreflight": "workflow", + "runRefiner": "params, signal = null", + "runRefinerStream": "params, { signal = null, onEvent = null } = {}", + "saveSecret": "provider, apiKey, adminToken", + "searchModels": "params = {}", + "streamSSEPost": "url, payload, { signal = null, timeout = 60000, onEvent = null } = {}", + "submitWebhook": "payload", + "subscribeEvents": "onEvent, onError", + "supportsAssistStreaming": "", + "testLLM": "adminToken, overrides = null", + "updatePreset": "id, data", + "validateWebhook": "payload" + }, + "constructor_state": [ + "_capabilitiesCache", + "_capabilitiesCacheTs", + "_decoratedFetchApi", + "_decoratedNativeFetch" + ], + "path_suffixes": [ + "/approvals", + "/assist/planner", + "/assist/planner/profiles", + "/assist/planner/stream", + "/assist/refiner", + "/assist/refiner/stream", + "/capabilities", + "/checkpoints", + "/config", + "/events", + "/events/stream", + "/health", + "/llm/models", + "/llm/test", + "/logs/tail", + "/models/downloads", + "/models/import", + "/models/installations", + "/models/search", + "/packs", + "/packs/export", + "/packs/import", + "/pnginfo", + "/preflight", + "/preflight/inventory", + "/presets", + "/secrets", + "/secrets/status", + "/trace", + "/webhook", + "/webhook/submit" + ], + "compatibility_seams": [ + "fetch", + "_fetchWithCandidates", + "_capabilitiesCache", + "_capabilitiesCacheTs", + "streamSSEPost", + "subscribeEvents" + ] + }, + "settings": { + "exports": [ + "settingsTab" + ], + "identity": { + "id": "settings", + "title": "Settings", + "icon": "pi pi-cog" + }, + "dom_ids": [ + "openclaw-settings-scroll" + ], + "class_tokens": [ + "openclaw-btn", + "openclaw-btn-danger", + "openclaw-btn-row", + "openclaw-btn-secondary", + "openclaw-collapsible-content", + "openclaw-collapsible-header", + "openclaw-collapsible-section", + "openclaw-content", + "openclaw-form-row", + "openclaw-help-btn", + "openclaw-input", + "openclaw-input-sm", + "openclaw-kv-key", + "openclaw-kv-row", + "openclaw-kv-val", + "openclaw-label", + "openclaw-loading-gate", + "openclaw-log-viewer", + "openclaw-modal", + "openclaw-modal-body", + "openclaw-modal-header", + "openclaw-modal-overlay", + "openclaw-model-list", + "openclaw-note", + "openclaw-panel", + "openclaw-scroll-area", + "openclaw-section", + "openclaw-settings-scroll", + "openclaw-status" + ], + "section_headings": [ + "Backend Not Loaded", + "LLM Settings", + "Limited Mode", + "Recent Logs", + "System Health" + ], + "compatibility_seams": [ + "settingsTab", + "settingsTab.render" + ] + }, + "upstream_contract_digests": { + "tests/api_route_contract_r220.json": "17c804dc80f35ddf774e5a941ab4fd4404f55f1e37a8cafd3b3a9238a5e12c9e", + "tests/api_config_contract_r221.json": "496063d3a838c5cfa884952215049054cdf6ff030c98a5b02802ae7f64f2d57e", + "tests/platform_adapter_contract_r223.json": "a23402432a51a57ad13a5de777f3c34b6d58743179524c09a90b004f98f3dd2c" + } +} diff --git a/web/tests/unit/openclaw_tabs.test.js b/web/tests/unit/openclaw_tabs.test.js index 06efeed..1fa4a79 100644 --- a/web/tests/unit/openclaw_tabs.test.js +++ b/web/tests/unit/openclaw_tabs.test.js @@ -68,6 +68,23 @@ describe("TabManager", () => { expect(contentEl.querySelector("#openclaw-tab-jobs").textContent).toBe("Jobs loaded"); }); + it("disposes pending work before switching and rerenders when revisited", () => { + const manager = new TabManager(); + const { tabsEl, contentEl } = makeMount(); + const render = vi.fn(pane => { pane.textContent = "pending"; }); + const dispose = vi.fn(() => true); + manager.registerTab({ id: "settings", title: "Settings", render, dispose }); + manager.registerTab({ id: "jobs", title: "Jobs", render: () => {} }); + manager.init(tabsEl, contentEl); + + manager.activateTab("jobs"); + expect(dispose).toHaveBeenCalledWith(contentEl.querySelector("#openclaw-tab-settings")); + expect(contentEl.querySelector("#openclaw-tab-settings").hasChildNodes()).toBe(false); + + manager.activateTab("settings"); + expect(render).toHaveBeenCalledTimes(2); + }); + it("routes async render failures through the tab error boundary", async () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const manager = new TabManager(); diff --git a/web/tests/unit/r224_frontend_decomposition.test.js b/web/tests/unit/r224_frontend_decomposition.test.js new file mode 100644 index 0000000..1a0aebe --- /dev/null +++ b/web/tests/unit/r224_frontend_decomposition.test.js @@ -0,0 +1,75 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + beginSettingsRender, + disposeSettingsRender, + finishSettingsRender, +} from "../../tabs/settings_tab_lifecycle.js"; + +import { + buildContract, + verifyContract, +} from "../../../scripts/verify_frontend_decomposition_contract.mjs"; + +const ROOT = path.resolve(import.meta.dirname, "../../.."); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + document.body.replaceChildren(); +}); + +describe("R224 frontend decomposition contract", () => { + it("matches the frozen API and Settings contract byte-for-byte", () => { + expect(verifyContract()).toEqual({ ok: true, message: "FRONTEND-CONTRACT-PASS" }); + }); + + it("has substantive native owner modules", () => { + const owners = [ + "web/openclaw_api_config.js", + "web/openclaw_api_generation.js", + "web/openclaw_api_resources.js", + "web/openclaw_api_models.js", + "web/openclaw_api_events.js", + "web/tabs/settings_tab_lifecycle.js", + "web/tabs/settings_tab_status.js", + "web/tabs/settings_tab_llm.js", + "web/tabs/settings_tab_secrets.js", + "web/tabs/settings_tab_logs.js", + "web/tabs/settings_tab_dom.js", + ]; + for (const owner of owners) { + const source = fs.readFileSync(path.join(ROOT, owner), "utf8"); + expect(source.split(/\r?\n/).length, owner).toBeGreaterThan(20); + expect(source, owner).not.toContain('from "./openclaw_api.js"'); + expect(source, owner).not.toContain('from "./settings_tab.js"'); + } + }); + + it("keeps one API singleton and the upstream contracts", () => { + const contract = buildContract(); + const facade = fs.readFileSync(path.join(ROOT, "web/openclaw_api.js"), "utf8"); + expect((facade.match(/new OpenClawAPI\(\)/g) || [])).toHaveLength(1); + expect(Object.keys(contract.upstream_contract_digests)).toHaveLength(3); + }); + + it("rejects an earlier Settings generation after a remount", () => { + vi.useFakeTimers(); + const staleContainer = document.createElement("div"); + const currentContainer = document.createElement("div"); + const stale = beginSettingsRender(staleContainer); + const callback = vi.fn(); + stale.schedule(callback, 0); + + const current = beginSettingsRender(currentContainer); + expect(stale.signal.aborted).toBe(true); + expect(stale.isCurrent()).toBe(false); + expect(current.isCurrent()).toBe(true); + + finishSettingsRender(current); + expect(disposeSettingsRender(currentContainer)).toBe(false); + vi.runAllTimers(); + expect(callback).not.toHaveBeenCalled(); + }); +});