mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
refactor(frontend): decompose settings and API clients
This commit is contained in:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
};
|
||||
@@ -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 ---
|
||||
|
||||
};
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
};
|
||||
@@ -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 ---
|
||||
|
||||
};
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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="<YOUR_API_KEY>"</code></p>
|
||||
<p><b>CMD</b>: <code>set OPENCLAW_LLM_API_KEY=<YOUR_API_KEY></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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user