mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
298 lines
8.5 KiB
JavaScript
298 lines
8.5 KiB
JavaScript
|
|
/**
|
|
* Shared Utilities for Moltbot UI
|
|
*/
|
|
|
|
/**
|
|
* Simple DOM factory helper.
|
|
* @param {string} tag - HTML tag name
|
|
* @param {string} className - Optional class name
|
|
* @param {string} text - Optional text content
|
|
*/
|
|
export function makeEl(tag, className = "", text = "") {
|
|
const el = document.createElement(tag);
|
|
if (className) el.className = className;
|
|
if (text !== undefined && text !== null && text !== "") {
|
|
el.textContent = text;
|
|
}
|
|
return el;
|
|
}
|
|
|
|
/**
|
|
* F63: prefer canonical `openclaw-*` classes when both legacy and canonical
|
|
* variants are present on the same node.
|
|
*/
|
|
export function normalizeLegacyClassTokens(className = "") {
|
|
const tokens = String(className)
|
|
.split(/\s+/)
|
|
.map((token) => token.trim())
|
|
.filter(Boolean);
|
|
const canonical = new Set(
|
|
tokens.filter((token) => token.startsWith("openclaw-"))
|
|
);
|
|
const seen = new Set();
|
|
const normalized = [];
|
|
|
|
tokens.forEach((token) => {
|
|
if (token.startsWith("moltbot-")) {
|
|
const suffix = token.slice("moltbot-".length);
|
|
if (canonical.has(`openclaw-${suffix}`)) {
|
|
return;
|
|
}
|
|
}
|
|
if (seen.has(token)) {
|
|
return;
|
|
}
|
|
seen.add(token);
|
|
normalized.push(token);
|
|
});
|
|
|
|
return normalized.join(" ");
|
|
}
|
|
|
|
export function normalizeLegacyClassNames(root) {
|
|
if (!root) return root;
|
|
const nodes = [];
|
|
if (typeof root.className === "string") {
|
|
nodes.push(root);
|
|
}
|
|
if (typeof root.querySelectorAll === "function") {
|
|
nodes.push(...root.querySelectorAll("[class]"));
|
|
}
|
|
|
|
nodes.forEach((node) => {
|
|
if (typeof node.className !== "string") return;
|
|
const normalized = normalizeLegacyClassTokens(node.className);
|
|
if (normalized !== node.className) {
|
|
node.className = normalized;
|
|
}
|
|
});
|
|
|
|
return root;
|
|
}
|
|
|
|
/**
|
|
* Lightweight toast helper for UI feedback.
|
|
* @param {string} message
|
|
* @param {"info"|"error"|"success"} variant
|
|
*/
|
|
export function showToast(message, variant = "info") {
|
|
const toast = document.createElement("div");
|
|
toast.className = `openclaw-toast moltbot-toast openclaw-toast-${variant} moltbot-toast-${variant}`;
|
|
toast.textContent = message;
|
|
toast.style.position = "fixed";
|
|
toast.style.right = "16px";
|
|
toast.style.bottom = "16px";
|
|
toast.style.padding = "8px 12px";
|
|
toast.style.borderRadius = "6px";
|
|
toast.style.background = variant === "error" ? "#5a1e1e" : (variant === "success" ? "#1e5a2b" : "#2d2d2d");
|
|
toast.style.color = "#fff";
|
|
toast.style.zIndex = "9999";
|
|
toast.style.boxShadow = "0 4px 12px rgba(0,0,0,0.3)";
|
|
document.body.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.remove();
|
|
}, 2500);
|
|
}
|
|
|
|
/**
|
|
* Display an error message within a container.
|
|
* Looks for an existing .openclaw-error-box (legacy: .moltbot-error-box), or creates one at the top.
|
|
*/
|
|
export function showError(container, message) {
|
|
let errorBox = container.querySelector('.openclaw-error-box');
|
|
|
|
if (!errorBox) {
|
|
// Try to find one by ID pattern if specific class missing? No, stick to class.
|
|
// If not found, inject at top of panel
|
|
const panel = container.querySelector('.openclaw-panel') || container;
|
|
errorBox = document.createElement("div");
|
|
errorBox.className = "openclaw-error-box moltbot-error-box";
|
|
// Insert after panel header or at top
|
|
const header = panel.querySelector('.openclaw-section-header');
|
|
if (header && header.nextSibling) {
|
|
panel.insertBefore(errorBox, header.nextSibling);
|
|
} else {
|
|
panel.prepend(errorBox);
|
|
}
|
|
}
|
|
|
|
errorBox.textContent = message;
|
|
errorBox.style.display = "block";
|
|
|
|
// Auto-scroll to error
|
|
errorBox.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
}
|
|
|
|
/**
|
|
* Clear error message in container.
|
|
*/
|
|
export function clearError(container) {
|
|
const errorBox = container.querySelector('.openclaw-error-box');
|
|
if (errorBox) {
|
|
errorBox.style.display = "none";
|
|
errorBox.textContent = "";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Copy text to clipboard and show ephemeral tooltip/toast
|
|
*/
|
|
export async function copyToClipboard(text, btnElement) {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
|
|
// Show feedback on button
|
|
const origText = btnElement.textContent;
|
|
btnElement.textContent = "Copied!";
|
|
btnElement.classList.add("openclaw-btn-success", "moltbot-btn-success");
|
|
|
|
setTimeout(() => {
|
|
btnElement.textContent = origText;
|
|
btnElement.classList.remove("openclaw-btn-success", "moltbot-btn-success");
|
|
}, 1500);
|
|
|
|
} catch (err) {
|
|
console.error("Failed to copy:", err);
|
|
alert("Failed to copy to clipboard");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* R55: Safe JSON parse helper with deterministic fallback semantics.
|
|
*/
|
|
export function parseJsonSafe(raw, fallbackValue = null) {
|
|
if (typeof raw !== "string") {
|
|
return {
|
|
ok: false,
|
|
value: fallbackValue,
|
|
error: new Error("json_input_must_be_string"),
|
|
};
|
|
}
|
|
try {
|
|
return { ok: true, value: JSON.parse(raw), error: null };
|
|
} catch (error) {
|
|
return { ok: false, value: fallbackValue, error };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* R55: Parse JSON or throw with a consistent message.
|
|
*/
|
|
export function parseJsonOrThrow(raw, message = "Invalid JSON") {
|
|
const parsed = parseJsonSafe(raw);
|
|
if (parsed.ok) return parsed.value;
|
|
const suffix = parsed.error?.message ? `: ${parsed.error.message}` : "";
|
|
throw new Error(`${message}${suffix}`);
|
|
}
|
|
|
|
/**
|
|
* R55: Link an external AbortSignal to a local AbortController.
|
|
* Returns a cleanup function to remove listeners.
|
|
*/
|
|
export function linkAbortSignal(externalSignal, controller, onAbort = null) {
|
|
if (!externalSignal || !controller) {
|
|
return () => { };
|
|
}
|
|
|
|
const handleAbort = () => {
|
|
if (typeof onAbort === "function") onAbort();
|
|
controller.abort();
|
|
};
|
|
|
|
if (externalSignal.aborted) {
|
|
handleAbort();
|
|
return () => { };
|
|
}
|
|
|
|
externalSignal.addEventListener("abort", handleAbort, { once: true });
|
|
return () => externalSignal.removeEventListener("abort", handleAbort);
|
|
}
|
|
|
|
/**
|
|
* R55: Normalize abort error detection across modules.
|
|
*/
|
|
export function isAbortError(err) {
|
|
return Boolean(err && err.name === "AbortError");
|
|
}
|
|
|
|
/**
|
|
* R38-Lite: Create a shared request lifecycle controller for staged loading + elapsed timer + cancel.
|
|
*
|
|
* @param {HTMLElement} container
|
|
* @param {object} selectors
|
|
* @param {string} selectors.loading
|
|
* @param {string} selectors.runButton
|
|
* @param {string} selectors.stage
|
|
* @param {string} selectors.elapsed
|
|
*/
|
|
export function createRequestLifecycleController(container, selectors) {
|
|
const loadingEl = container.querySelector(selectors.loading);
|
|
const runBtnEl = container.querySelector(selectors.runButton);
|
|
const stageEl = container.querySelector(selectors.stage);
|
|
const elapsedEl = container.querySelector(selectors.elapsed);
|
|
|
|
let abortController = null;
|
|
let timerInterval = null;
|
|
let startTime = 0;
|
|
|
|
const showLoading = (show) => {
|
|
if (loadingEl) loadingEl.style.display = show ? "block" : "none";
|
|
if (runBtnEl) runBtnEl.style.display = show ? "none" : "block";
|
|
};
|
|
|
|
const stopTimer = () => {
|
|
if (timerInterval) {
|
|
clearInterval(timerInterval);
|
|
timerInterval = null;
|
|
}
|
|
};
|
|
|
|
const setStage = (text) => {
|
|
if (stageEl) stageEl.textContent = text;
|
|
};
|
|
|
|
const startTimer = () => {
|
|
startTime = Date.now();
|
|
if (elapsedEl) elapsedEl.textContent = "Elapsed: 0s";
|
|
stopTimer();
|
|
timerInterval = setInterval(() => {
|
|
if (!elapsedEl) return;
|
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
elapsedEl.textContent = `Elapsed: ${elapsed}s`;
|
|
}, 500);
|
|
};
|
|
|
|
const begin = (initialStage = "Preparing request...") => {
|
|
if (abortController) {
|
|
abortController.abort();
|
|
}
|
|
abortController = new AbortController();
|
|
setStage(initialStage);
|
|
showLoading(true);
|
|
startTimer();
|
|
return abortController.signal;
|
|
};
|
|
|
|
const end = () => {
|
|
stopTimer();
|
|
showLoading(false);
|
|
abortController = null;
|
|
};
|
|
|
|
const cancel = () => {
|
|
if (!abortController) return false;
|
|
abortController.abort();
|
|
end();
|
|
return true;
|
|
};
|
|
|
|
return {
|
|
begin,
|
|
end,
|
|
cancel,
|
|
setStage,
|
|
};
|
|
}
|