oclaw/interfaces/admin/static/js/pages/stack.js
oliver 9cf026f605 Fix WhatsApp Admin QR bind and show the scan prompt only after Bind.
Use a current WA protocol version, start the sidecar via local tsx with inherited proxy, and stop auto-opening the QR UI on unbound connecting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 23:54:48 +08:00

1048 lines
43 KiB
JavaScript

import { t, el, tdCell, apiGet, apiGetNoHang, apiPost, apiDeleteJson, formatSystemLocalDateTime, renderMetaChips, navigateAdmin, tf, runtimePrewarmReminder, clearPrewarmReminder } from "../core.js";
async function renderStack() {
const results = await Promise.allSettled([
apiGetNoHang("/admin/api/runtime/scan-artifacts"),
apiGetNoHang("/admin/api/runtime/prewarm/status"),
apiGetNoHang("/admin/api/runtime/prewarm/prompts?role=generalist"),
apiGetNoHang("/admin/api/chat/settings/specialist-flags"),
apiGetNoHang("/admin/api/chat/settings/channel-dispatch/weixin"),
apiGetNoHang("/admin/api/chat/settings/channel-dispatch/whatsapp"),
apiGetNoHang("/admin/api/whatsapp/groups?tenant_id=default"),
apiGetNoHang("/admin/api/whatsapp/access?tenant_id=default"),
apiGetNoHang("/admin/api/whatsapp/session"),
]);
const scanResp = results[0].status === "fulfilled" ? results[0].value : null;
const prewarmStatusResp = results[1].status === "fulfilled" ? results[1].value : null;
const prewarmPromptsResp = results[2].status === "fulfilled" ? results[2].value : null;
const channelSpecResp = results[3].status === "fulfilled" ? results[3].value : null;
const weixinDispatchResp = results[4].status === "fulfilled" ? results[4].value : null;
const whatsappDispatchResp = results[5].status === "fulfilled" ? results[5].value : null;
const whatsappGroupsResp = results[6].status === "fulfilled" ? results[6].value : null;
const whatsappAccessResp = results[7].status === "fulfilled" ? results[7].value : null;
const whatsappSessionResp = results[8].status === "fulfilled" ? results[8].value : null;
// If auth failed (401), show a gentle message instead of an infinite spinner.
if (!channelSpecResp && !weixinDispatchResp && !prewarmStatusResp) {
return el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("title.stack") || "Stack" }),
el("div", { class: "muted", text: t("common.notLoggedIn") }),
]);
}
const availableDispatchSpecialists = Array.isArray(channelSpecResp && channelSpecResp.available_specialists) && channelSpecResp.available_specialists.length
? channelSpecResp.available_specialists.map((x) => String(x || "").trim()).filter(Boolean)
: ["generalist"];
const createChannelDispatchCard = (channel, title, initial) => {
const curMode = String((initial && initial.interaction_mode) || "expert").trim() || "expert";
const curSpecialist = String((initial && initial.specialist) || "generalist").trim() || "generalist";
const curLang = String((initial && initial.lang) || "auto").trim().toLowerCase() || "auto";
const specialistSel = el("select", { class: "input" }, availableDispatchSpecialists.map((sid) =>
el("option", { value: sid, text: sid, selected: sid === curSpecialist ? "selected" : undefined }),
));
const langSel = el("select", { class: "input" }, [
el("option", { value: "auto", text: t("common.auto"), selected: curLang === "auto" ? "selected" : undefined }),
el("option", { value: "zh", text: t("common.chinese"), selected: curLang === "zh" ? "selected" : undefined }),
el("option", { value: "en", text: t("common.english"), selected: curLang === "en" ? "selected" : undefined }),
]);
const status = renderMetaChips({ mode: curMode, specialist: curSpecialist, lang: curLang });
const paintStatus = (obj) => {
const fresh = renderMetaChips(obj);
status.replaceChildren(...Array.from(fresh.childNodes));
};
const saveExpertBtn = el("button", {
class: "btn btn--primary",
text: t("wa.bindSpecialist"),
onclick: async () => {
const specialist = String(specialistSel.value || "generalist").trim() || "generalist";
const lang = String(langSel.value || "auto").trim().toLowerCase() || "auto";
const resp = await apiPost(`/admin/api/chat/settings/channel-dispatch/${encodeURIComponent(channel)}`, {
interaction_mode: "expert",
specialist,
lang,
});
paintStatus({
mode: String(resp.interaction_mode || "expert"),
specialist: String(resp.specialist || specialist),
lang: String(resp.lang || lang),
});
},
});
return el("div", { class: "card" }, [
el("div", { class: "card__head" }, [
el("div", { class: "card__headline" }, [
el("div", { class: "card__title", text: title }),
el("div", {
class: "card__subtitle",
text: t("wa.channelExpertOnly"),
}),
]),
]),
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field" }, [
el("label", { text: t("wa.specialist") }),
specialistSel,
]),
el("div", { class: "form-inline__field" }, [
el("label", { text: t("wa.lang") }),
langSel,
]),
el("div", { class: "form-inline__actions" }, [saveExpertBtn]),
]),
status,
]);
};
const weixinDispatchCard = createChannelDispatchCard("weixin", "Weixin dispatch", weixinDispatchResp || {});
const whatsappDispatchCard = createChannelDispatchCard("whatsapp", "WhatsApp dispatch", whatsappDispatchResp || {});
let waSessionLatest = whatsappSessionResp && typeof whatsappSessionResp === "object" ? whatsappSessionResp : {};
let waSessionBindInProgress = false;
let waSessionBindRequested = false;
let waSessionWatchGen = 0;
let waLastQrKey = "";
const waSessionHintAlert = el("div", { class: "alert alert--warning u-hidden", text: "" });
const paintWaSession = (st) => {
const s = st && typeof st === "object" ? st : {};
waSessionLatest = s;
const lifecycle = String(s.lifecycle || "unbound").trim() || "unbound";
const lifeLabel = t(`wa.lifecycle.${lifecycle}`) || lifecycle;
const chips = renderMetaChips({
lifecycle: lifeLabel,
connection: String(s.connection || "-"),
phone: String(s.phone || "-"),
sidecar: s.sidecar_running ? `pid ${s.pid || "-"}` : "stopped",
reconnect: Number(s.reconnect_attempt || 0),
});
waSessionStatus.replaceChildren(...Array.from(chips.childNodes));
const showDisconnect = lifecycle !== "unbound" && lifecycle !== "awaiting_scan";
const errText = String(s.last_error || "");
const discStatus = Number(s.last_disconnect_status || 0);
const pairingFail =
lifecycle === "connecting" &&
(errText.includes("pairing_rejected") || discStatus === 405 || /connection failure/i.test(errText));
const networkFail =
lifecycle === "connecting" &&
/certificate|econnreset|timedOut|408|fake-ip|198\.18/i.test(`${errText} ${String(s.last_disconnect_reason || "")}`);
const runnerWaiting =
lifecycle === "connecting" &&
/runner_waiting|runner_starting/i.test(errText);
const detailBits = [
s.display_name ? `name=${s.display_name}` : "",
s.me ? `me=${s.me}` : "",
showDisconnect && errText ? `err=${errText.slice(0, 120)}` : "",
showDisconnect && s.last_disconnect_reason ? `disconnect=${s.last_disconnect_reason}` : "",
s.log_path ? `log=${s.log_path}` : "",
s.bridge_updated_at ? `updated=${s.bridge_updated_at}` : "",
].filter(Boolean);
waSessionDetail.textContent = detailBits.join(" | ") || (s.installed ? "" : t("wa.sessionNotInstalled"));
if (lifecycle === "needs_rebind" || lifecycle === "logged_out") {
waSessionHintAlert.textContent = t("wa.sessionNeedsRebind");
waSessionHintAlert.classList.remove("u-hidden");
} else if (waSessionBindRequested && runnerWaiting) {
waSessionHintAlert.textContent = t("wa.sessionRunnerWaiting");
waSessionHintAlert.classList.remove("u-hidden");
} else if (waSessionBindRequested && pairingFail) {
waSessionHintAlert.textContent = t("wa.sessionPairingFail");
waSessionHintAlert.classList.remove("u-hidden");
} else if (waSessionBindRequested && networkFail) {
waSessionHintAlert.textContent = t("wa.sessionNetworkFail");
waSessionHintAlert.classList.remove("u-hidden");
} else {
waSessionHintAlert.textContent = "";
waSessionHintAlert.classList.add("u-hidden");
}
const qrUrl = String(s.qr_data_url || "").trim();
const awaiting = Boolean(waSessionBindRequested) && (lifecycle === "awaiting_scan" || waSessionBindInProgress);
waSessionQrWrap.style.display = awaiting ? "" : "none";
waSessionQrHint.textContent = awaiting
? (s.qr_stale ? t("wa.sessionQrStale") : t("wa.sessionScanHint"))
: "";
const qrKey = `${String(s.qr || "").slice(0, 48)}|${s.bridge_updated_at || ""}|${qrUrl.length}`;
if (awaiting && qrUrl) {
if (qrKey !== waLastQrKey) {
waLastQrKey = qrKey;
waSessionQrImg.src = qrUrl;
}
waSessionQrImg.style.display = "";
waSessionQrWaiting.style.display = "none";
} else if (awaiting) {
waLastQrKey = "";
waSessionQrImg.removeAttribute("src");
waSessionQrImg.style.display = "none";
waSessionQrWaiting.style.display = "";
waSessionQrWaiting.textContent = t("wa.sessionQrWaiting");
} else {
waLastQrKey = "";
waSessionQrImg.removeAttribute("src");
waSessionQrImg.style.display = "none";
waSessionQrWaiting.style.display = "none";
}
const rebindModes = new Set(["needs_rebind", "logged_out", "bound_stopped", "bound_offline", "online"]);
if (waSessionBindBtn) {
waSessionBindBtn.textContent = rebindModes.has(lifecycle)
? t("wa.sessionRebind")
: t("wa.sessionBind");
waSessionBindBtn.disabled = Boolean(waSessionBindInProgress);
}
};
const waSessionStatus = el("div", { class: "muted" });
const waSessionDetail = el("div", { class: "muted u-mt-8" });
const waSessionBindBtn = el("button", {
class: "btn btn--primary",
text: t("wa.sessionBind"),
});
const waSessionQrImg = el("img", {
alt: "WhatsApp QR",
style: "width:240px;height:240px;background:#fff;border:1px solid var(--border, #ddd);display:none;",
});
const waSessionQrWaiting = el("div", { class: "muted", style: "display:none;" });
const waSessionQrHint = el("div", { class: "card__hint" });
const waSessionQrWrap = el("div", { class: "u-mt-10", style: "display:none;" }, [
waSessionQrHint,
waSessionQrImg,
waSessionQrWaiting,
]);
const waSessionActionStatus = el("div", { class: "muted u-mt-8", text: "" });
const pollWaSession = async (opts = {}) => {
const maxMs = Number(opts.maxMs || 60000);
const stepMs = Number(opts.stepMs || 2000);
const stopWhen = opts.stopWhen || ((st) => {
const life = String((st && st.lifecycle) || "");
return ["online", "needs_rebind", "logged_out", "bound_stopped", "unbound"].includes(life);
});
waSessionActionStatus.textContent = t("wa.sessionPolling");
let waited = 0;
let latest = waSessionLatest;
const watchGen = Number(opts.gen || 0);
while (waited < maxMs) {
if (watchGen && watchGen !== waSessionWatchGen) return latest;
const st = await apiGet("/admin/api/whatsapp/session");
paintWaSession(st);
latest = st;
if (stopWhen(st)) break;
await new Promise((r) => setTimeout(r, stepMs));
waited += stepMs;
}
waSessionActionStatus.textContent = "";
return latest;
};
const withWaSessionAction = async (fn, opts = {}) => {
waSessionActionStatus.textContent = "…";
try {
const resp = await fn();
const st = (resp && resp.status) || resp || {};
paintWaSession(st.ok === false && !st.lifecycle ? (await apiGet("/admin/api/whatsapp/session")) : st);
if (resp && resp.ok === false) {
waSessionActionStatus.textContent = String(resp.error || resp.stderr || "failed");
} else {
waSessionActionStatus.textContent = "ok";
}
if (opts.poll !== false && resp && resp.ok !== false) {
await pollWaSession(opts.poll || {});
}
return resp;
} catch (err) {
waSessionActionStatus.textContent = String(err);
throw err;
}
};
const waSessionRefreshBtn = el("button", {
class: "btn",
text: t("wa.sessionRefresh"),
onclick: async () => {
await withWaSessionAction(async () => apiGet("/admin/api/whatsapp/session"), { poll: false });
},
});
const waSessionStartBtn = el("button", {
class: "btn",
text: t("wa.sessionStart"),
onclick: async () => {
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/start", {}));
},
});
const waSessionStopBtn = el("button", {
class: "btn",
text: t("wa.sessionStop"),
onclick: async () => {
waSessionBindRequested = false;
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/stop", { force: true }), { poll: false });
},
});
const watchWaBindUntilOnline = async () => {
const gen = ++waSessionWatchGen;
waSessionBindRequested = true;
waSessionBindInProgress = true;
paintWaSession(waSessionLatest);
try {
await pollWaSession({
maxMs: 10 * 60 * 1000,
stepMs: 1500,
gen,
stopWhen: (st) => {
const life = String((st && st.lifecycle) || "");
if (life === "online") return true;
if (life === "bound_stopped") return true;
return false;
},
});
} finally {
if (gen === waSessionWatchGen) {
waSessionBindInProgress = false;
const life = String((waSessionLatest && waSessionLatest.lifecycle) || "");
if (life === "online" || life === "unbound" || life === "bound_stopped") {
waSessionBindRequested = false;
}
paintWaSession(waSessionLatest);
}
}
};
const waSessionBindBtnClick = async () => {
const curLife = String((waSessionLatest && waSessionLatest.lifecycle) || "").trim();
const autoClear = ["needs_rebind", "logged_out"].includes(curLife);
const needClear = autoClear || ["bound_stopped", "bound_offline", "online"].includes(curLife);
if (needClear && !autoClear && !window.confirm(t("wa.sessionRebindConfirm"))) return;
waSessionBindRequested = true;
waSessionBindInProgress = true;
waLastQrKey = "";
paintWaSession(waSessionLatest);
const watch = watchWaBindUntilOnline();
try {
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/bind", {
clear_auth: Boolean(needClear),
}), { poll: false });
await watch;
} catch (err) {
waSessionWatchGen += 1;
waSessionBindInProgress = false;
waSessionBindRequested = false;
paintWaSession(waSessionLatest);
throw err;
}
};
waSessionBindBtn.onclick = waSessionBindBtnClick;
const waSessionUnbindBtn = el("button", {
class: "btn btn--danger",
text: t("wa.sessionUnbind"),
onclick: async () => {
if (!window.confirm(t("wa.sessionUnbindConfirm"))) return;
waSessionBindRequested = false;
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/unbind", {}), { poll: false });
},
});
const whatsappSessionCard = el("div", { class: "card" }, [
el("div", { class: "card__head" }, [
el("div", { class: "card__headline" }, [
el("div", { class: "card__title", text: t("wa.sessionTitle") }),
el("div", { class: "card__subtitle", text: t("wa.sessionHint") }),
]),
el("div", { class: "card__actions" }, [
waSessionRefreshBtn,
waSessionStartBtn,
waSessionStopBtn,
waSessionBindBtn,
waSessionUnbindBtn,
]),
]),
waSessionStatus,
waSessionHintAlert,
waSessionDetail,
waSessionQrWrap,
waSessionActionStatus,
]);
paintWaSession(whatsappSessionResp || {});
if (whatsappSessionResp && whatsappSessionResp.sidecar_running) {
const initLife = String(whatsappSessionResp.lifecycle || "");
if (initLife === "bound_offline" || initLife === "online") {
void pollWaSession({ maxMs: 30000 });
}
}
const waGroups = Array.isArray(whatsappGroupsResp && whatsappGroupsResp.items) ? whatsappGroupsResp.items : [];
const waBinding = (whatsappGroupsResp && whatsappGroupsResp.binding) || {};
const waGroupSel = el(
"select",
{ class: "input" },
[
el("option", { value: "", text: t("wa.selectGroup") }),
...waGroups.map((g) => {
const jid = String((g && g.group_jid) || "").trim();
const name = String((g && g.group_name) || "").trim();
const label = name ? `${name} (${jid})` : jid;
return el("option", {
value: jid,
text: label || jid,
selected: jid && jid === String(waBinding.group_jid || "") ? "selected" : undefined,
});
}),
],
);
const waBindingStatus = el("div", {
class: "muted",
text: waBinding.group_jid
? `binding=${waBinding.group_jid} enabled=${Boolean(waBinding.enabled)}`
: (t("wa.noAlertGroup")),
});
const waAccessCfg = (whatsappAccessResp && whatsappAccessResp.config) || {};
const waContacts = Array.isArray(whatsappAccessResp && whatsappAccessResp.contacts) ? whatsappAccessResp.contacts : [];
const waPending = Array.isArray(whatsappAccessResp && whatsappAccessResp.pending) ? whatsappAccessResp.pending : [];
const waDenied = Array.isArray(whatsappAccessResp && whatsappAccessResp.denied) ? whatsappAccessResp.denied : [];
const waPhoneDisplay = (row) => {
const phone = String((row && row.phone) || "").trim();
if (phone) return phone;
const jid = String((row && row.external_user_id) || "").trim();
const base = jid.split("@")[0] || "";
const digits = base.replace(/\D/g, "");
return digits || base || "-";
};
const waAccessModeSel = el("select", { class: "input" }, [
el("option", {
value: "blacklist",
text: t("wa.blacklistMode"),
selected: String(waAccessCfg.access_mode || "blacklist") === "blacklist" ? "selected" : undefined,
}),
el("option", {
value: "whitelist",
text: t("wa.whitelistMode"),
selected: String(waAccessCfg.access_mode || "") === "whitelist" ? "selected" : undefined,
}),
]);
const waAccessLangSel = el("select", { class: "input" }, [
el("option", { value: "en", text: "English", selected: String(waAccessCfg.lang || "en") === "en" ? "selected" : undefined }),
el("option", { value: "zh", text: t("common.chinese"), selected: String(waAccessCfg.lang || "") === "zh" ? "selected" : undefined }),
]);
const waCounts = { admin: 0, whitelist: 0, blacklist: 0 };
waContacts.forEach((c) => {
const lt = String((c && c.list_type) || "").trim().toLowerCase();
if (lt === "admin") waCounts.admin += 1;
else if (lt === "whitelist") waCounts.whitelist += 1;
else if (lt === "blacklist") waCounts.blacklist += 1;
});
const waAccessStatus = renderMetaChips({
mode: String(waAccessCfg.access_mode || "blacklist"),
lang: String(waAccessCfg.lang || "en"),
admin: waCounts.admin,
whitelist: waCounts.whitelist,
blacklist: waCounts.blacklist,
pending: waPending.length,
denied: waDenied.length,
});
const waContactFilterSel = el("select", { class: "input" }, [
el("option", { value: "all", text: t("common.all") }),
el("option", { value: "admin", text: t("common.admin") }),
el("option", { value: "whitelist", text: t("common.whitelist") }),
el("option", { value: "blacklist", text: t("common.blacklist") }),
]);
const waContactsTbody = el("tbody", {});
const waContactPhone = (row) => {
const phone = String((row && row.phone) || "").trim();
if (phone) return phone;
return waPhoneDisplay(row);
};
const waSaveContact = async (phone, pushName, listType) => {
const phoneVal = String(phone || "").trim();
const list_type = String(listType || "").trim().toLowerCase();
if (!phoneVal) return;
if (!list_type) {
window.alert(t("common.selectType"));
return;
}
try {
const resp = await apiPost("/admin/api/whatsapp/access/contacts", {
phone: phoneVal,
push_name: String(pushName || "").trim(),
list_type,
});
if (!resp || resp.ok === false) {
window.alert(String((resp && resp.error) || (t("common.updateFailed"))));
return;
}
navigateAdmin();
} catch (err) {
window.alert(String(err));
}
};
const renderWaContactRows = (filterValue) => {
const filter = String(filterValue || "all").trim().toLowerCase() || "all";
const rows = waContacts
.filter((c) => {
const lt = String((c && c.list_type) || "").trim().toLowerCase();
if (filter === "all") return true;
return lt === filter;
})
.map((c) => {
const name = String((c && c.push_name) || "");
const lt = String((c && c.list_type) || "").trim().toLowerCase();
const phone = waContactPhone(c);
const typeSel = el("select", { class: "input" }, [
el("option", { value: "admin", text: t("common.admin") }),
el("option", { value: "whitelist", text: t("common.whitelist") }),
el("option", { value: "blacklist", text: t("common.blacklist") }),
]);
typeSel.value = lt || "whitelist";
const actionBtns = [
el("button", {
class: "btn",
text: t("common.update"),
onclick: async () => {
await waSaveContact(phone, name, String(typeSel.value || "").trim());
},
}),
];
if (lt === "blacklist") {
actionBtns.push(el("span", { text: " " }));
actionBtns.push(el("button", {
class: "btn btn--primary",
text: t("wa.addWhitelist"),
onclick: async () => {
await waSaveContact(phone, name, "whitelist");
},
}));
}
actionBtns.push(el("span", { text: " " }));
actionBtns.push(el("button", {
class: "btn btn--danger",
text: t("common.remove"),
onclick: async () => {
try {
const resp = await apiDeleteJson(
`/admin/api/whatsapp/access/contacts?phone=${encodeURIComponent(phone)}`,
);
if (resp && resp.deleted === false) {
window.alert(t("wa.contactNotFound"));
return;
}
navigateAdmin();
} catch (err) {
window.alert(String(err));
}
},
}));
return el("tr", {}, [
el("td", { "data-copy-disabled": "1" }, [typeSel]),
el("td", { text: name || "-" }),
el("td", { text: phone || "-" }),
el("td", { "data-copy-disabled": "1" }, actionBtns),
]);
});
waContactsTbody.replaceChildren(
...(rows.length
? rows
: [el("tr", {}, [el("td", { colspan: "4", text: t("wa.noContacts") })])]),
);
};
waContactFilterSel.addEventListener("change", () => {
renderWaContactRows(String(waContactFilterSel.value || "all"));
});
renderWaContactRows("all");
const waContactPhoneInput = el("input", { class: "input", placeholder: t("wa.phonePlaceholder") });
const waContactNameInput = el("input", { class: "input", placeholder: t("wa.namePlaceholder") });
const waContactTypeSel = el("select", { class: "input" }, [
el("option", { value: "admin", text: t("common.admin") }),
el("option", { value: "whitelist", text: t("common.whitelist") }),
el("option", { value: "blacklist", text: t("common.blacklist") }),
]);
const waPendingPickSel = el(
"select",
{ class: "input" },
[
el("option", { value: "", text: t("wa.pickPending") }),
...waPending.map((p, idx) => {
const phone = waPhoneDisplay(p);
const name = String((p && p.push_name) || "").trim();
const label = name ? `${name} (${phone})` : phone;
return el("option", { value: String(idx), text: label || phone });
}),
],
);
waPendingPickSel.addEventListener("change", () => {
const idx = Number(String(waPendingPickSel.value || "").trim());
if (!Number.isFinite(idx) || idx < 0 || idx >= waPending.length) return;
const picked = waPending[idx] || {};
const phone = waPhoneDisplay(picked);
const name = String((picked && picked.push_name) || "").trim();
if (phone && phone !== "-") waContactPhoneInput.value = phone;
waContactNameInput.value = name || "";
});
const waResolvePending = async (pendingId, action) => {
if (!pendingId) return;
try {
const resp = await apiPost("/admin/api/whatsapp/access/pending/resolve", {
pending_id: pendingId,
action,
});
if (!resp || resp.ok === false) {
window.alert(String((resp && resp.error) || (t("common.requestFailed"))));
return;
}
navigateAdmin();
} catch (err) {
window.alert(String(err));
}
};
const waDeletePending = async (pendingId) => {
if (!pendingId) return;
try {
const resp = await apiDeleteJson(
`/admin/api/whatsapp/access/pending?pending_id=${encodeURIComponent(pendingId)}`,
);
if (!resp || resp.ok === false) {
window.alert(String((resp && resp.error) || (t("common.deleteFailed"))));
return;
}
navigateAdmin();
} catch (err) {
window.alert(String(err));
}
};
const waDeniedRows = waDenied.map((p) => {
const pendingId = String((p && p.id) || "").trim();
const phone = waPhoneDisplay(p);
const name = String((p && p.push_name) || "").trim();
return el("tr", {}, [
el("td", { text: name || "-" }),
el("td", { text: phone }),
el("td", { text: String((p && p.request_text) || "").slice(0, 80) }),
el("td", { text: String((p && p.resolved_at) || p.created_at || "") }),
el("td", {}, [
el("button", {
class: "btn btn--primary",
text: t("wa.addWhitelist"),
onclick: async () => { await waResolvePending(pendingId, "approve"); },
}),
el("span", { text: " " }),
el("button", {
class: "btn btn--danger",
text: t("common.delete"),
onclick: async () => { await waDeletePending(pendingId); },
}),
]),
]);
});
const waPendingRows = waPending.map((p) => {
const pendingId = String((p && p.id) || "").trim();
return el("tr", {}, [
el("td", { text: String((p && p.push_name) || "-") }),
el("td", { text: waPhoneDisplay(p) }),
el("td", { text: String((p && p.external_user_id) || "") }),
el("td", { text: String((p && p.request_text) || "").slice(0, 80) }),
el("td", { text: String((p && p.created_at) || "") }),
el("td", {}, [
el("button", {
class: "btn btn--primary",
text: t("wa.approve"),
onclick: async () => { await waResolvePending(pendingId, "approve"); },
}),
el("span", { text: " " }),
el("button", {
class: "btn",
text: t("wa.deny"),
onclick: async () => { await waResolvePending(pendingId, "deny"); },
}),
el("span", { text: " " }),
el("button", {
class: "btn btn--danger",
text: t("common.delete"),
onclick: async () => { await waDeletePending(pendingId); },
}),
]),
]);
});
const whatsappAccessCard = el("div", { class: "card" }, [
el("div", { class: "card__head" }, [
el("div", { class: "card__headline" }, [
el("div", { class: "card__title", text: t("wa.accessTitle") }),
el("div", {
class: "card__subtitle",
text: t("wa.accessHint"),
}),
]),
]),
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field" }, [
el("label", { text: t("common.mode") }),
waAccessModeSel,
]),
el("div", { class: "form-inline__field" }, [
el("label", { text: t("wa.messageLang") }),
waAccessLangSel,
]),
el("div", { class: "form-inline__actions" }, [
el("button", {
class: "btn btn--primary",
text: t("wa.saveConfig"),
onclick: async () => {
const resp = await apiPost("/admin/api/whatsapp/access/config", {
tenant_id: "default",
access_mode: String(waAccessModeSel.value || "blacklist"),
lang: String(waAccessLangSel.value || "en"),
});
const cfg = (resp && resp.config) || {};
waAccessStatus.replaceChildren(
...Array.from(
renderMetaChips({
mode: String(cfg.access_mode || "blacklist"),
lang: String(cfg.lang || "en"),
admin: waCounts.admin,
whitelist: waCounts.whitelist,
blacklist: waCounts.blacklist,
pending: waPending.length,
denied: waDenied.length,
}).childNodes,
),
);
},
}),
]),
]),
waAccessStatus,
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field" }, [
el("label", { text: t("wa.filter") }),
waContactFilterSel,
]),
]),
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field form-inline__field--wide" }, [waPendingPickSel]),
]),
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field" }, [waContactPhoneInput]),
el("div", { class: "form-inline__field" }, [waContactNameInput]),
el("div", { class: "form-inline__field" }, [waContactTypeSel]),
el("div", { class: "form-inline__actions" }, [
el("button", {
class: "btn",
text: t("wa.addContact"),
onclick: async () => {
const phone = String(waContactPhoneInput.value || "").trim();
if (!phone) return;
try {
const resp = await apiPost("/admin/api/whatsapp/access/contacts", {
phone,
push_name: String(waContactNameInput.value || "").trim(),
list_type: String(waContactTypeSel.value || "whitelist"),
});
if (!resp || resp.ok === false) {
window.alert(String((resp && resp.error) || (t("wa.addFailed"))));
return;
}
navigateAdmin();
} catch (err) {
window.alert(String(err));
}
},
}),
]),
]),
el("div", { class: "table-wrap" }, [
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: t("wa.colType") }),
el("th", { text: t("wa.colName") }),
el("th", { text: t("wa.colPhone") }),
el("th", { text: t("wa.colAction") }),
])]),
waContactsTbody,
]),
]),
el("div", { class: "card__title", text: t("wa.pendingTitle") }),
el("div", { class: "table-wrap" }, [
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: t("wa.colName") }),
el("th", { text: t("wa.colPhone") }),
el("th", { text: "JID" }),
el("th", { text: t("wa.colMessage") }),
el("th", { text: t("wa.colTime") }),
el("th", { text: t("wa.colAction") }),
])]),
el("tbody", {}, waPendingRows.length ? waPendingRows : [el("tr", {}, [el("td", { colspan: "6", text: t("wa.nonePending") })])]),
]),
]),
el("div", { class: "card__title", text: t("wa.deniedTitle") }),
el("div", { class: "card__hint", text: t("wa.deniedHint") }),
el("div", { class: "table-wrap" }, [
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: t("wa.colName") }),
el("th", { text: t("wa.colPhone") }),
el("th", { text: t("wa.colMessage") }),
el("th", { text: t("wa.colDeniedAt") }),
el("th", { text: t("wa.colAction") }),
])]),
el("tbody", {}, waDeniedRows.length ? waDeniedRows : [el("tr", {}, [el("td", { colspan: "5", text: t("wa.noneDenied") })])]),
]),
]),
]);
const whatsappAlertBindingCard = el("div", { class: "card" }, [
el("div", { class: "card__head" }, [
el("div", { class: "card__headline" }, [
el("div", { class: "card__title", text: t("wa.alertGroupTitle") }),
el("div", {
class: "card__subtitle",
text: t("wa.alertGroupHint"),
}),
]),
]),
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field form-inline__field--wide" }, [waGroupSel]),
el("div", { class: "form-inline__actions" }, [
el("button", {
class: "btn btn--primary",
text: t("wa.saveBinding"),
onclick: async () => {
const group_jid = String(waGroupSel.value || "").trim();
if (!group_jid) return;
const picked = waGroups.find((g) => String((g && g.group_jid) || "").trim() === group_jid) || {};
const resp = await apiPost("/admin/api/whatsapp/alert-binding", {
tenant_id: "default",
group_jid,
group_name: String((picked && picked.group_name) || ""),
enabled: true,
});
const b = (resp && resp.binding) || {};
waBindingStatus.textContent = b.group_jid
? `binding=${b.group_jid} enabled=${Boolean(b.enabled)}`
: (t("wa.bindFailed"));
},
}),
el("button", {
class: "btn",
text: t("wa.testPush"),
onclick: async () => {
const resp = await apiPost("/admin/api/whatsapp/alert-binding/test", { tenant_id: "default" });
waBindingStatus.textContent = resp && resp.ok
? `test ok outbound_id=${String(resp.outbound_id || "")}`
: `test failed: ${String((resp && resp.error) || "unknown")}`;
},
}),
]),
]),
waBindingStatus,
]);
const scanItems = Array.isArray(scanResp && scanResp.items) ? scanResp.items : [];
const scanDir = String((scanResp && scanResp.dir) || "");
const scanStatus = el("div", { class: "muted", text: "" });
const keepLatestInput = el("input", { class: "input", type: "number", value: "20", min: "0", max: "500" });
const maxAgeDaysInput = el("input", { class: "input", type: "number", value: "7", min: "0", max: "3650" });
const btnCleanScan = el("button", {
class: "btn btn--danger",
text: t("stack.clearScanCache"),
onclick: async () => {
const resp = await apiPost("/admin/api/runtime/scan-artifacts/cleanup", {});
scanStatus.textContent = `removed=${Number((resp && resp.removed) || 0)}`;
navigateAdmin();
},
});
const btnPruneScan = el("button", {
class: "btn",
text: t("stack.clearByPolicy"),
onclick: async () => {
const keepLatest = Number(keepLatestInput.value || 20);
const maxAgeDays = Number(maxAgeDaysInput.value || 7);
const resp = await apiPost("/admin/api/runtime/scan-artifacts/prune", {
keep_latest: Number.isFinite(keepLatest) ? keepLatest : 20,
max_age_days: Number.isFinite(maxAgeDays) ? maxAgeDays : 7,
});
scanStatus.textContent = `removed=${Number((resp && resp.removed) || 0)} keep_latest=${Number((resp && resp.keep_latest) || 0)} max_age_days=${Number((resp && resp.max_age_days) || 0)}`;
navigateAdmin();
},
});
const scanRows = scanItems.length
? scanItems.map((x) => el("tr", {}, [
tdCell(String(x.name || ""), 26),
tdCell(String(x.bytes || 0), 12),
tdCell(String(x.modified_at || ""), 28),
]))
: [el("tr", {}, [el("td", { class: "muted", text: "—", colspan: "3" })])];
const prewarmInfo = prewarmStatusResp && typeof prewarmStatusResp === "object" ? prewarmStatusResp : {};
const prewarmLast = prewarmInfo.last && typeof prewarmInfo.last === "object" ? prewarmInfo.last : {};
const prewarmHistory = Array.isArray(prewarmInfo.history) ? prewarmInfo.history : [];
const freeze = prewarmInfo.freeze && typeof prewarmInfo.freeze === "object" ? prewarmInfo.freeze : {};
const prewarmStatus = el("div", { class: "muted", text: "" });
const btnPrewarm = el("button", {
class: "btn btn--primary",
text: t("stack.prewarmNow"),
onclick: async () => {
prewarmStatus.textContent = t("stack.prewarmSubmitting");
const resp = await apiPost("/admin/api/runtime/prewarm", { mode: "async", reason: "admin_manual" });
if (!(resp && resp.accepted)) {
prewarmStatus.textContent = `[prewarm] accepted=${Boolean(resp && resp.accepted)}`;
return;
}
prewarmStatus.textContent =
t("auto.prewarm_running_in_background_refreshing_this_page_when_");
clearPrewarmReminder();
const maxWaitMs = 120000;
const stepMs = 400;
let waited = 0;
while (waited < maxWaitMs) {
await new Promise((r) => setTimeout(r, stepMs));
waited += stepMs;
const st = await apiGet("/admin/api/runtime/prewarm/status");
if (!(st && st.running)) break;
}
await navigateAdmin();
},
});
const prewarmSummary = [
`running=${Boolean(prewarmInfo.running)}`,
`last_ok=${Boolean(prewarmLast.ok)}`,
`elapsed_ms=${Number(prewarmLast.elapsed_ms || 0)}`,
`freeze_enabled=${Boolean(freeze.enabled)}`,
`frozen=${Boolean(freeze.frozen)}`,
`last_warm=${
Number(freeze.last_warm_ts_ms || 0) > 0
? formatSystemLocalDateTime(new Date(Number(freeze.last_warm_ts_ms || 0)).toISOString())
: "-"
}`,
].join(" | ");
const historyRows = prewarmHistory.length
? prewarmHistory.slice(0, 20).map((x) =>
el("tr", {}, [
tdCell(formatSystemLocalDateTime(new Date(Number(x.finished_at_ms || 0)).toISOString()), 24),
tdCell(String(x.reason || "-"), 20),
tdCell(String(Boolean(x.ok)), 8),
tdCell(String(Number(x.elapsed_ms || 0)), 10),
tdCell(String(x.error || "-"), 38),
]))
: [el("tr", {}, [el("td", { class: "muted", text: "—", colspan: "5" })])];
const promptsSectionBody = el("div");
const promptsRoleSelect = el("select", { class: "input" }, [
el("option", { value: "generalist", text: "generalist (default)" }),
el("option", { value: "", text: t("stack.allRoles") }),
]);
const renderPromptCards = (resp) => {
const promptsObj = resp && typeof resp === "object" ? resp : {};
const promptsMap = promptsObj.prompts && typeof promptsObj.prompts === "object" ? promptsObj.prompts : {};
const promptRoleCards = Object.keys(promptsMap)
.sort((a, b) => String(a).localeCompare(String(b)))
.map((rid) => {
const item = promptsMap[rid] && typeof promptsMap[rid] === "object" ? promptsMap[rid] : {};
const finalSystem = String(item.system_prompt || "");
return el("div", { class: "card u-mt-10" }, [
el("div", { class: "card__title", text: `role: ${rid}` }),
el("div", {}, [
el("div", { class: "muted", text: "system_prompt" }),
el("textarea", {
class: "input",
rows: "12",
readonly: "readonly",
text: finalSystem,
style: "width:100%;box-sizing:border-box;",
}),
]),
]);
});
promptsSectionBody.innerHTML = "";
if (promptRoleCards.length) {
promptRoleCards.forEach((n) => promptsSectionBody.appendChild(n));
} else {
promptsSectionBody.appendChild(el("div", { class: "muted", text: "-" }));
}
};
const btnReloadPrompts = el("button", {
class: "btn",
text: t("stack.reloadPrompts"),
onclick: async () => {
const role = String(promptsRoleSelect.value || "").trim();
const q = role ? `?role=${encodeURIComponent(role)}` : "";
const resp = await apiGet(`/admin/api/runtime/prewarm/prompts${q}`);
renderPromptCards(resp);
},
});
promptsRoleSelect.addEventListener("change", async () => {
const role = String(promptsRoleSelect.value || "").trim();
const q = role ? `?role=${encodeURIComponent(role)}` : "";
const resp = await apiGet(`/admin/api/runtime/prewarm/prompts${q}`);
renderPromptCards(resp);
});
renderPromptCards(prewarmPromptsResp);
return el("div", { class: "page-shell" }, [
el("div", { class: "page-grid page-grid--two" }, [
weixinDispatchCard,
whatsappDispatchCard,
]),
whatsappSessionCard,
whatsappAccessCard,
whatsappAlertBindingCard,
el("div", { class: "card" }, [
el("div", { class: "card__head" }, [
el("div", { class: "card__headline" }, [
el("div", { class: "card__title", text: t("stack.prewarmTitle") }),
el("div", { class: "card__subtitle", text: prewarmSummary }),
]),
el("div", { class: "card__actions" }, [btnPrewarm]),
]),
runtimePrewarmReminder ? el("div", { class: "alert alert--warning", text: runtimePrewarmReminder }) : null,
el(
"div",
{
class: "card__hint",
text:
t("auto.after_any_skill_tool_role_prompt_change_run_prewarm_imme"),
},
),
prewarmStatus,
].filter(Boolean)),
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("stack.prewarmHistory") }),
el("div", { class: "table-wrap" }, [
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: "finished_at" }),
el("th", { text: "reason" }),
el("th", { text: "ok" }),
el("th", { text: "elapsed_ms" }),
el("th", { text: "error" }),
])]),
el("tbody", {}, historyRows),
]),
]),
]),
el("div", { class: "card" }, [
el("div", { class: "card__head" }, [
el("div", { class: "card__headline" }, [
el("div", { class: "card__title", text: t("stack.prewarmedPrompts") }),
el("div", {
class: "card__subtitle",
text: t("stack.prewarmedPromptsHint"),
}),
]),
]),
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field" }, [
el("label", { text: t("stack.roleFilter") }),
promptsRoleSelect,
]),
el("div", { class: "form-inline__actions" }, [btnReloadPrompts]),
]),
promptsSectionBody,
]),
el("div", { class: "card" }, [
el("div", { class: "card__head" }, [
el("div", { class: "card__headline" }, [
el("div", { class: "card__title", text: t("stack.scanCacheTitle") }),
el("div", { class: "card__subtitle", text: tf("stack.scanCacheSubtitle", { dir: scanDir || "-" }) }),
]),
]),
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field" }, [
el("label", { text: "keep_latest" }),
keepLatestInput,
]),
el("div", { class: "form-inline__field" }, [
el("label", { text: "max_age_days" }),
maxAgeDaysInput,
]),
el("div", { class: "form-inline__actions" }, [btnPruneScan, btnCleanScan]),
]),
scanStatus,
el("div", { class: "table-wrap" }, [
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [el("th", { text: "name" }), el("th", { text: "bytes" }), el("th", { text: "modified_at" })])]),
el("tbody", {}, scanRows),
]),
]),
]),
]);
}
export { renderStack };