refactor(frontend): decompose settings and API clients

This commit is contained in:
rookiestar28
2026-07-11 10:07:55 +08:00
parent 11faa8a789
commit bc06b56a5b
18 changed files with 2106 additions and 1564 deletions
@@ -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);
}
+15 -612
View File
@@ -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: { "<prompt_id>": { ...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();
+215
View File
@@ -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: { "<prompt_id>": { ...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) ---
};
+50
View File
@@ -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;
},
};
+63
View File
@@ -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 ---
};
+97
View File
@@ -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
*/
};
+228
View File
@@ -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 ---
};
+16
View File
@@ -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<void>)} 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);
+25 -952
View File
File diff suppressed because it is too large Load Diff
+224
View File
@@ -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)",
`
<p>This feature lets you paste an LLM provider API key in the UI and save it to the <b>server-side</b> secret store (<code>{STATE_DIR}/secrets.json</code>).</p>
<p><b>Important</b>:</p>
<ul>
<li>Recommended: use environment variables for API keys.</li>
<li>Only use UI storage on a single-user, localhost-only setup.</li>
<li>ENV keys always take priority over stored keys.</li>
<li>Secrets are stored as plaintext JSON on disk (protected by OS permissions).</li>
<li>Outbound LLM requests are protected by an SSRF policy. Built-in providers are allowlisted by default; custom Base URL hosts must be added via <code>OPENCLAW_LLM_ALLOWED_HOSTS</code> (or use <code>OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1</code> at your own risk).</li>
</ul>
<p><b>PowerShell</b>: <code>$env:OPENCLAW_LLM_API_KEY="&lt;YOUR_API_KEY&gt;"</code></p>
<p><b>CMD</b>: <code>set OPENCLAW_LLM_API_KEY=&lt;YOUR_API_KEY&gt;</code></p>
`
);
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;
}
+47
View File
@@ -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;
}
+386
View File
@@ -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 <select> for discoverability + still allow "Custom…".
const modelInput = document.createElement("input");
modelInput.type = "text";
modelInput.className = "openclaw-input openclaw-input moltbot-input";
modelInput.value = config.model || "";
modelInput.disabled = sources.model === "env";
modelInput.style.flex = "1";
const modelSelect = document.createElement("select");
modelSelect.className = "openclaw-input openclaw-input moltbot-input";
modelSelect.disabled = sources.model === "env";
modelSelect.style.flex = "1";
modelSelect.style.display = "none"; // shown after models load
const MODEL_CUSTOM = "__custom__";
// Datalist for remote suggestions (used in custom/free-text mode)
const modelListId = "openclaw-model-list";
modelInput.setAttribute("list", modelListId);
const modelDatalist = document.createElement("datalist");
modelDatalist.id = modelListId;
let lastLoadedModels = [];
let modelsLoaded = false;
const updateModelUiVisibility = () => {
// IMPORTANT (UX): Users expect an actual dropdown after "Load Models" even if the current
// model is not in the returned list (e.g., switching provider but model still set to an
// old value like "gpt-4o-mini"). Keep the <select> 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",
`
<p>The Admin Token authorizes <b>write</b> actions (save config, test LLM, store keys).</p>
<ul>
<li>If <code>OPENCLAW_ADMIN_TOKEN</code> (or legacy <code>MOLTBOT_ADMIN_TOKEN</code>) is set on the server, you must enter the same token here.</li>
<li>If no server token is configured, admin actions are allowed on <b>localhost only</b> (convenience mode).</li>
<li>Never expose ComfyUI/OpenClaw to the public internet without proper access controls.</li>
</ul>
<p><b>PowerShell</b>: <code>$env:OPENCLAW_ADMIN_TOKEN="your-secret-token"</code></p>
<p><b>CMD</b>: <code>set OPENCLAW_ADMIN_TOKEN=your-secret-token</code></p>
`
)
);
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 = `<b>API Key</b>: Use <code>OPENCLAW_LLM_API_KEY</code> (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);
}
+63
View File
@@ -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);
}
+179
View File
@@ -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. <b>Recommended:</b> Use ENV. <b>Acceptable:</b> 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);
}
}
+115
View File
@@ -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 <code>HTTP 404</code>.
This usually means ComfyUI did not load the Python part of this custom node pack.
<br/><br/>
Check ComfyUI startup logs for errors while importing <code>ComfyUI-OpenClaw</code>/<code>Comfyui-OpenClaw</code>,
then restart ComfyUI.
<br/><br/>
Expected endpoints:
<ul>
<li><code>/openclaw/health</code> (legacy: <code>/moltbot/health</code>)</li>
<li><code>/openclaw/config</code></li>
<li><code>/openclaw/logs/tail</code></li>
</ul>
`;
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 = `
<b>⚠ Capabilities endpoint unavailable.</b> Some features may be hidden or behave differently.
This can happen if the backend pack version is older than the frontend UI.
<br/>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 = `
<span><b>UI Boot Status</b></span>
<span>${all404 ? "⚠️ Backend 404" : "✓ Connected"}</span>
`;
const body = document.createElement("div");
body.innerHTML = `
<div style="margin-top:4px; opacity:0.8;">
ComfyUI: ${comfyVersion || "Unknown"} | Pack: ${packVer} | Prefix: ${basePath}
</div>
<div style="margin-top:4px; font-size:11px; color:${hasShim ? "var(--input-text)" : "#ff6666"}">
Shim: ${hasShim ? "✓ Detected" : "⚠️ Missing (shim broken)"}
</div>
`;
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);
}
@@ -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"
}
}
+17
View File
@@ -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();
@@ -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();
});
});