mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-10 17:23:25 +08:00
Ship Cursor-only MCP admin and modular Admin/Chat UI.
Plugins/Skills install and edit via mcpServers JSON, with clearer row actions, soft reloads, and instant loading placeholders; also drop MCP market and harden related scheduler/logging/exec guards. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2aff012b48
commit
f6f129931f
61 changed files with 18057 additions and 16327 deletions
742
interfaces/admin/static/js/core.js
Normal file
742
interfaces/admin/static/js/core.js
Normal file
|
|
@ -0,0 +1,742 @@
|
|||
import { state } from "./state.js";
|
||||
import { t, tf, applyI18nStatic, toggleLang } from "./i18n/index.js";
|
||||
|
||||
const SESSION_MONITOR_ROLE_FILTER_KEY = "ops_session_monitor_role_filter";
|
||||
const AUTH_TOKEN_KEY = "ops_admin_token";
|
||||
const AUTH_SESSION_KEY = "ops_admin_session";
|
||||
const CHAT_MEMORY_MODE_KEY = "ops_chat_memory_mode";
|
||||
/** 与 localStorage 不同:sessionStorage 按标签页隔离,避免「管理员页 + 普通用户控制台」互相覆盖 token。 */
|
||||
function authStoreGet(key) {
|
||||
try {
|
||||
return sessionStorage.getItem(key);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function authStoreSet(key, value) {
|
||||
try {
|
||||
sessionStorage.setItem(key, value);
|
||||
} catch (_) {}
|
||||
}
|
||||
function authStoreRemove(key) {
|
||||
try {
|
||||
sessionStorage.removeItem(key);
|
||||
} catch (_) {}
|
||||
}
|
||||
/** 历史版本把 token 放在 localStorage,多标签会串号;启动时清掉以免误读。 */
|
||||
try {
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
localStorage.removeItem(AUTH_SESSION_KEY);
|
||||
} catch (_) {}
|
||||
/**
|
||||
* /chat 与 /admin 历史上使用不同 key;当用户在同一窗口从 /chat 跳到 /admin 时,
|
||||
* 没有 opener 可用,需要从 chat 存储主动迁移一次以复用登录态。
|
||||
*/
|
||||
(function seedAdminAuthFromChatStorageIfNeeded() {
|
||||
try {
|
||||
if (authStoreGet(AUTH_TOKEN_KEY)) return;
|
||||
const pairs = [
|
||||
[sessionStorage, "ops_chat_token", "ops_chat_session"],
|
||||
[localStorage, "ops_chat_token", "ops_chat_session"],
|
||||
];
|
||||
for (const [store, kTok, kSess] of pairs) {
|
||||
let tok = null;
|
||||
let sess = null;
|
||||
try {
|
||||
tok = store.getItem(kTok);
|
||||
sess = store.getItem(kSess);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
const t = String(tok || "").trim();
|
||||
const s = String(sess || "").trim();
|
||||
if (t && s) {
|
||||
authStoreSet(AUTH_TOKEN_KEY, t);
|
||||
authStoreSet(AUTH_SESSION_KEY, s);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
/** 从聊天页新开 /admin(审计、设置等)时,复用 opener 同一登录态(chat 或 admin token)。勿对 window.open 使用 noopener,否则读不到 opener。 */
|
||||
(function seedAdminAuthFromOpenerIfNeeded() {
|
||||
try {
|
||||
if (authStoreGet(AUTH_TOKEN_KEY)) return;
|
||||
const op = window.opener;
|
||||
if (!op || op === window) return;
|
||||
if (String(location.origin) !== String(op.location.origin)) return;
|
||||
const pairs = [
|
||||
["ops_admin_token", "ops_admin_session"],
|
||||
["ops_chat_token", "ops_chat_session"],
|
||||
];
|
||||
for (const [kTok, kSess] of pairs) {
|
||||
let tok = null;
|
||||
let sess = null;
|
||||
try {
|
||||
tok = op.sessionStorage.getItem(kTok) || op.localStorage.getItem(kTok);
|
||||
sess = op.sessionStorage.getItem(kSess) || op.localStorage.getItem(kSess);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
const t = String(tok || "").trim();
|
||||
const s = String(sess || "").trim();
|
||||
if (t && s) {
|
||||
authStoreSet(AUTH_TOKEN_KEY, t);
|
||||
authStoreSet(AUTH_SESSION_KEY, s);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
/** 合并展示名与登录名(列表/团队页共用) */
|
||||
function formatUserLabel(u) {
|
||||
const un = String(u.username ?? "").trim();
|
||||
const dn = String(u.display_name ?? "").trim();
|
||||
if (dn && un && dn.toLowerCase() !== un.toLowerCase()) {
|
||||
return `${dn} (${un})`;
|
||||
}
|
||||
return dn || un || "—";
|
||||
}
|
||||
|
||||
function formatAuditActor(r) {
|
||||
const un = String(r.actor_username ?? "").trim();
|
||||
const dn = String(r.actor_display_name ?? "").trim();
|
||||
const id = String(r.actor_user_id ?? "").trim();
|
||||
if (dn && un && dn.toLowerCase() !== un.toLowerCase()) {
|
||||
return `${dn} (${un})`;
|
||||
}
|
||||
if (dn || un) return dn || un;
|
||||
return id ? id.slice(0, 8) + (id.length > 8 ? "…" : "") : "—";
|
||||
}
|
||||
|
||||
function getRoute() {
|
||||
const raw = (location.hash || "#/stack").replace(/^#\//, "");
|
||||
const [pageRaw, queryRaw] = raw.split("?", 2);
|
||||
let page = (pageRaw || "stack").trim();
|
||||
if (page === "channels") page = "stack";
|
||||
if (page === "tenants") page = "users";
|
||||
const params = new URLSearchParams(queryRaw || "");
|
||||
return { page, params };
|
||||
}
|
||||
|
||||
/** 当控制台挂在子路径(如 /gw/admin)时,把 /admin/api/... 解析为 /gw/admin/api/... */
|
||||
function resolveAdminApiUrl(path) {
|
||||
const p = String(path || "");
|
||||
if (!p.startsWith("/admin/")) return p;
|
||||
const pathname = (location.pathname || "").replace(/\/+$/, "") || "/";
|
||||
const marker = "/admin";
|
||||
const pos = pathname.lastIndexOf(marker);
|
||||
if (pos <= 0) return p;
|
||||
return pathname.slice(0, pos) + p;
|
||||
}
|
||||
|
||||
function resolveChatUrl() {
|
||||
const pathname = (location.pathname || "").replace(/\/+$/, "") || "/";
|
||||
const marker = "/admin";
|
||||
if (pathname.endsWith(marker)) {
|
||||
return `${pathname.slice(0, -marker.length) || ""}/chat`;
|
||||
}
|
||||
return "/chat";
|
||||
}
|
||||
|
||||
function getStoredAuthToken() {
|
||||
return String(authStoreGet(AUTH_TOKEN_KEY) || "").trim();
|
||||
}
|
||||
|
||||
/** 会话在服务端失效或本地缺 token 时清理,并下一帧回到登录(避免在 router 内部 await router 盖住登录页) */
|
||||
function scheduleReauthAfter401(requestUrl) {
|
||||
const u = String(requestUrl || "");
|
||||
if (u.includes("/admin/api/auth/login") || u.includes("/admin/api/auth/bootstrap")) return;
|
||||
authStoreRemove(AUTH_TOKEN_KEY);
|
||||
authStoreRemove(AUTH_SESSION_KEY);
|
||||
state.authSession = null;
|
||||
setTimeout(() => {
|
||||
const fn = state.reauthHandler;
|
||||
if (typeof fn === "function") fn();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function _haltAfter401() {
|
||||
// 401 means "login required". We re-route to login; callers should not show a "request failed" popup.
|
||||
// Returning a never-resolving promise avoids bubbling errors into UI code that would flash an alert.
|
||||
return new Promise(() => {});
|
||||
}
|
||||
|
||||
async function apiGet(path) {
|
||||
const url = resolveAdminApiUrl(path);
|
||||
const token = getStoredAuthToken();
|
||||
const headers = { "accept": "application/json" };
|
||||
if (token) headers["authorization"] = `Bearer ${token}`;
|
||||
const res = await fetch(url, { headers });
|
||||
if (res.status === 401) {
|
||||
scheduleReauthAfter401(url);
|
||||
return await _haltAfter401();
|
||||
}
|
||||
if (!res.ok) throw new Error(`GET ${url} ${res.status}`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function apiGetOptional(path) {
|
||||
try {
|
||||
return await apiGet(path);
|
||||
} catch (err) {
|
||||
console.warn(`apiGetOptional failed path=${String(path || "")} err=${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function apiGetNoHang(path) {
|
||||
const url = resolveAdminApiUrl(path);
|
||||
const token = getStoredAuthToken();
|
||||
const headers = { "accept": "application/json" };
|
||||
if (token) headers["authorization"] = `Bearer ${token}`;
|
||||
const res = await fetch(url, { headers });
|
||||
if (res.status === 401) {
|
||||
scheduleReauthAfter401(url);
|
||||
// Returning null avoids Promise.all / await from hanging forever.
|
||||
return null;
|
||||
}
|
||||
if (!res.ok) throw new Error(`GET ${url} ${res.status}`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function apiPost(path, body) {
|
||||
const url = resolveAdminApiUrl(path);
|
||||
const token = getStoredAuthToken();
|
||||
const headers = { "content-type": "application/json", "accept": "application/json" };
|
||||
if (token) headers["authorization"] = `Bearer ${token}`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch (_) {
|
||||
data = null;
|
||||
}
|
||||
if (res.status === 401) {
|
||||
scheduleReauthAfter401(url);
|
||||
return await _haltAfter401();
|
||||
}
|
||||
if (!res.ok) {
|
||||
const d = data && typeof data === "object" ? data : {};
|
||||
const detail = (d).detail != null ? String((d).detail) : "";
|
||||
const errKey = (d).error != null ? String((d).error) : "";
|
||||
const msg = detail || errKey || `POST ${url} ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data ?? {};
|
||||
}
|
||||
|
||||
/** Skills endpoints often return HTTP 200 with `{ ok: false, result: {...} }` on failure — treat as error for UX. */
|
||||
function assertSkillMutationOk(r, fallbackMessage) {
|
||||
if (!r || r.ok !== false) return;
|
||||
const res = r.result && typeof r.result === "object" ? r.result : {};
|
||||
const code = res.error_code != null ? String(res.error_code).trim() : "";
|
||||
const detail = res.detail != null ? String(res.detail).trim() : "";
|
||||
const msg = [code, detail].filter(Boolean).join(": ") || String(fallbackMessage || "skill operation failed");
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
async function apiRequest(method, path, body) {
|
||||
const url = resolveAdminApiUrl(path);
|
||||
const token = getStoredAuthToken();
|
||||
const headers = { "accept": "application/json" };
|
||||
if (token) headers["authorization"] = `Bearer ${token}`;
|
||||
if (body !== undefined && method !== "GET" && method !== "HEAD") {
|
||||
headers["content-type"] = "application/json";
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined && method !== "GET" && method !== "HEAD" ? JSON.stringify(body ?? {}) : undefined,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
scheduleReauthAfter401(url);
|
||||
return await _haltAfter401();
|
||||
}
|
||||
if (!res.ok) {
|
||||
let msg = `${method} ${url} ${res.status}`;
|
||||
try {
|
||||
const j = await res.json();
|
||||
if (j && j.detail !== undefined) {
|
||||
msg = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
|
||||
}
|
||||
} catch (_) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
const ct = res.headers.get("content-type") || "";
|
||||
if (!ct.includes("application/json")) return {};
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function apiPostFormData(path, formData) {
|
||||
const url = resolveAdminApiUrl(path);
|
||||
const token = getStoredAuthToken();
|
||||
const headers = {};
|
||||
if (token) headers.authorization = `Bearer ${token}`;
|
||||
const res = await fetch(url, { method: "POST", headers, body: formData });
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch (_) {
|
||||
data = null;
|
||||
}
|
||||
if (res.status === 401) {
|
||||
scheduleReauthAfter401(url);
|
||||
return await _haltAfter401();
|
||||
}
|
||||
if (!res.ok) {
|
||||
const d = data && typeof data === "object" ? data : {};
|
||||
const detail = d.detail != null ? String(d.detail) : "";
|
||||
const errKey = d.error != null ? String(d.error) : "";
|
||||
throw new Error(detail || errKey || `POST ${url} ${res.status}`);
|
||||
}
|
||||
return data ?? {};
|
||||
}
|
||||
|
||||
async function apiDeleteJson(path) {
|
||||
const url = resolveAdminApiUrl(path);
|
||||
const token = getStoredAuthToken();
|
||||
const headers = { accept: "application/json" };
|
||||
if (token) headers.authorization = `Bearer ${token}`;
|
||||
const res = await fetch(url, { method: "DELETE", headers });
|
||||
if (res.status === 401) {
|
||||
scheduleReauthAfter401(url);
|
||||
return await _haltAfter401();
|
||||
}
|
||||
if (!res.ok) {
|
||||
let msg = `DELETE ${url} ${res.status}`;
|
||||
try {
|
||||
const j = await res.json();
|
||||
if (j && j.detail !== undefined) msg = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
|
||||
} catch (_) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
try {
|
||||
return await res.json();
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function _activeCopyCell() {
|
||||
return document.querySelector(".cell-copyable.cell-selected");
|
||||
}
|
||||
|
||||
function _selectCopyCell(cell) {
|
||||
const prev = _activeCopyCell();
|
||||
if (prev && prev !== cell) prev.classList.remove("cell-selected");
|
||||
if (cell) cell.classList.add("cell-selected");
|
||||
}
|
||||
|
||||
function _shouldIgnoreCopyShortcut(ev) {
|
||||
const t = ev && ev.target ? ev.target : null;
|
||||
if (!t || !(t instanceof HTMLElement)) return false;
|
||||
const tag = String(t.tagName || "").toLowerCase();
|
||||
if (t.isContentEditable) return true;
|
||||
return tag === "input" || tag === "textarea" || tag === "select";
|
||||
}
|
||||
|
||||
function _installCellCopyKeyboardShortcut() {
|
||||
if (window.__adminCellCopyShortcutInstalled) return;
|
||||
window.__adminCellCopyShortcutInstalled = true;
|
||||
document.addEventListener("keydown", async (ev) => {
|
||||
if (_shouldIgnoreCopyShortcut(ev)) return;
|
||||
if (!(ev.ctrlKey || ev.metaKey) || String(ev.key || "").toLowerCase() !== "c") return;
|
||||
const active = _activeCopyCell();
|
||||
if (!active) return;
|
||||
try {
|
||||
const txt = String(active.getAttribute("data-copy-text") || active.getAttribute("title") || active.textContent || "");
|
||||
if (!txt.trim()) return;
|
||||
ev.preventDefault();
|
||||
await navigator.clipboard.writeText(txt);
|
||||
active.classList.add("cell-copied");
|
||||
setTimeout(() => active.classList.remove("cell-copied"), 2600);
|
||||
} catch (_) {
|
||||
// no-op
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function attachCellCopyBehavior(cell, getCopyText) {
|
||||
if (!cell || cell.dataset.copyBound === "1") return;
|
||||
_installCellCopyKeyboardShortcut();
|
||||
cell.dataset.copyBound = "1";
|
||||
cell.classList.add("cell-copyable");
|
||||
cell.addEventListener("click", () => {
|
||||
_selectCopyCell(cell);
|
||||
});
|
||||
cell.addEventListener("dblclick", async () => {
|
||||
try {
|
||||
const txt = String(typeof getCopyText === "function" ? getCopyText() : (cell.textContent || ""));
|
||||
if (!txt) return;
|
||||
cell.setAttribute("data-copy-text", txt);
|
||||
await navigator.clipboard.writeText(txt);
|
||||
cell.classList.add("cell-copied");
|
||||
setTimeout(() => cell.classList.remove("cell-copied"), 2600);
|
||||
} catch (_) {
|
||||
// keep tooltip fallback when clipboard API unavailable
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function el(tag, attrs = {}, children = []) {
|
||||
const e = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (v === undefined || v === null) continue;
|
||||
if (k === "class") e.className = v;
|
||||
else if (k === "text") e.textContent = v;
|
||||
else if (k.startsWith("on") && typeof v === "function") e.addEventListener(k.slice(2), v);
|
||||
else if (k === "disabled" && v === false) {
|
||||
e.removeAttribute("disabled");
|
||||
} else e.setAttribute(k, v);
|
||||
}
|
||||
for (const c of children) e.appendChild(c);
|
||||
if (String(tag || "").toLowerCase() === "td" && String(e.getAttribute("data-copy-disabled") || "") !== "1") {
|
||||
const txt = String(e.textContent || "").trim();
|
||||
if (txt) {
|
||||
if (!e.getAttribute("title")) e.setAttribute("title", txt);
|
||||
attachCellCopyBehavior(e, () => String(e.getAttribute("title") || e.textContent || ""));
|
||||
}
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
function tdCell(value, maxLen = 120) {
|
||||
const full = String(value ?? "");
|
||||
const shown = formatSystemLocalDateTime(full);
|
||||
const title = shown === full ? full : `${shown}\n${full}`;
|
||||
const td = el("td", { text: shortText(shown, maxLen), title });
|
||||
td.setAttribute("data-copy-text", shown);
|
||||
attachCellCopyBehavior(td, () => shown);
|
||||
return td;
|
||||
}
|
||||
|
||||
function enableTableColumnResize(tableEl, columnIndexes = []) {
|
||||
if (!tableEl) return;
|
||||
const heads = Array.from(tableEl.querySelectorAll("thead th"));
|
||||
if (!heads.length) return;
|
||||
tableEl.classList.add("table--resizable");
|
||||
heads.forEach((th) => th.querySelectorAll(".table-col-resizer").forEach((x) => x.remove()));
|
||||
const enabled = new Set(Array.isArray(columnIndexes) ? columnIndexes : []);
|
||||
heads.forEach((th, idx) => {
|
||||
if (enabled.size && !enabled.has(idx)) return;
|
||||
const handle = document.createElement("span");
|
||||
handle.className = "table-col-resizer";
|
||||
handle.addEventListener("mousedown", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const startX = ev.clientX;
|
||||
const startW = th.getBoundingClientRect().width;
|
||||
document.body.classList.add("col-resize-active");
|
||||
const onMove = (mv) => {
|
||||
const next = Math.max(72, Math.round(startW + (mv.clientX - startX)));
|
||||
th.style.width = `${next}px`;
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.body.classList.remove("col-resize-active");
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
});
|
||||
th.appendChild(handle);
|
||||
});
|
||||
}
|
||||
|
||||
function setActive(page) {
|
||||
document.querySelectorAll(".nav__item").forEach((a) => {
|
||||
a.classList.toggle("nav__item--active", a.dataset.page === page);
|
||||
});
|
||||
document.getElementById("topTitle").textContent = ({
|
||||
stack: t("title.stack"),
|
||||
"scheduled-jobs": t("title.scheduledJobs"),
|
||||
users: t("title.users"),
|
||||
memory: t("title.memory"),
|
||||
models: t("title.models"),
|
||||
"api-grants": t("title.apiGrants"),
|
||||
audit: t("title.audit"),
|
||||
"session-monitor": t("title.sessionMonitor"),
|
||||
"admin-audit": t("title.adminAudit"),
|
||||
plugins: t("title.plugins"),
|
||||
skills: t("title.skills"),
|
||||
attachments: t("title.attachments"),
|
||||
"workspace-paths": t("title.workspacePaths"),
|
||||
profile: t("title.profile"),
|
||||
}[page] || page);
|
||||
}
|
||||
|
||||
function mount(node) {
|
||||
const c = document.getElementById("content");
|
||||
c.innerHTML = "";
|
||||
c.appendChild(node);
|
||||
}
|
||||
|
||||
function renderPageShell(opts = {}, children = []) {
|
||||
const title = String(opts.title || "").trim();
|
||||
const subtitle = String(opts.subtitle || "").trim();
|
||||
const actions = Array.isArray(opts.actions) ? opts.actions.filter(Boolean) : [];
|
||||
const sections = Array.isArray(opts.sections) ? opts.sections.filter((x) => x && x.id && x.label) : [];
|
||||
const head = [];
|
||||
if (title || subtitle || actions.length) {
|
||||
const left = [];
|
||||
if (title) left.push(el("h1", { class: "page-shell__title", text: title }));
|
||||
if (subtitle) left.push(el("div", { class: "page-shell__subtitle muted", text: subtitle }));
|
||||
head.push(
|
||||
el("div", { class: "page-shell__header" }, [
|
||||
el("div", { class: "page-shell__headline" }, left),
|
||||
el("div", { class: "page-shell__actions row" }, actions),
|
||||
]),
|
||||
);
|
||||
if (sections.length > 1) {
|
||||
head.push(
|
||||
el(
|
||||
"div",
|
||||
{ class: "page-shell__toc", role: "navigation", "aria-label": "page sections" },
|
||||
sections.map((s) =>
|
||||
el("button", {
|
||||
class: "page-shell__tocItem",
|
||||
type: "button",
|
||||
text: String(s.label),
|
||||
onclick: () => {
|
||||
const target = document.getElementById(String(s.id));
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return el("section", { class: "page-shell" }, [...head, ...children.filter(Boolean)]);
|
||||
}
|
||||
|
||||
function renderSectionCard(title, subtitle, bodyNodes = [], opts = {}) {
|
||||
const headlineKids = [el("div", { class: "card__title", text: title })];
|
||||
if (subtitle) headlineKids.push(el("div", { class: "card__subtitle muted", text: subtitle }));
|
||||
const headKids = [el("div", { class: "card__headline" }, headlineKids)];
|
||||
if (Array.isArray(opts.actions) && opts.actions.length) {
|
||||
headKids.push(el("div", { class: "card__actions row" }, opts.actions));
|
||||
}
|
||||
const nodes = [el("div", { class: "card__head" }, headKids)];
|
||||
bodyNodes.filter(Boolean).forEach((node) => nodes.push(node));
|
||||
const attrs = { class: "card section-card" };
|
||||
if (opts.id) attrs.id = String(opts.id);
|
||||
return el("div", attrs, nodes);
|
||||
}
|
||||
|
||||
function renderMetaChips(entries) {
|
||||
const items = [];
|
||||
const src = entries && typeof entries === "object" ? entries : {};
|
||||
Object.keys(src).forEach((k) => {
|
||||
const v = src[k];
|
||||
if (v == null || v === "") return;
|
||||
items.push(el("span", { class: "meta-chip", text: `${k}=${v}` }));
|
||||
});
|
||||
return el("div", { class: "meta-line" }, items.length ? items : [el("span", { class: "muted", text: "—" })]);
|
||||
}
|
||||
|
||||
function shortText(v, maxLen = 200) {
|
||||
const s = String(v ?? "");
|
||||
if (s.length <= maxLen) return s;
|
||||
return s.slice(0, Math.max(0, maxLen - 3)) + "...";
|
||||
}
|
||||
|
||||
function formatSystemLocalDateTime(value) {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "";
|
||||
// Keep non-datetime strings unchanged.
|
||||
if (!/^\d{4}-\d{2}-\d{2}T/.test(raw)) return raw;
|
||||
const d = new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) return raw;
|
||||
const locale = state.currentLang === "zh" ? "zh-CN" : "en-US";
|
||||
try {
|
||||
return d.toLocaleString(locale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
} catch (_) {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function formatIds(v) {
|
||||
if (!Array.isArray(v)) return "";
|
||||
const vals = v.map((x) => String(x ?? "").trim()).filter(Boolean);
|
||||
if (!vals.length) return "";
|
||||
if (vals.length > 4) return vals.slice(0, 4).join(", ") + ` (+${vals.length - 4})`;
|
||||
return vals.join(", ");
|
||||
}
|
||||
|
||||
function yesNo(v) {
|
||||
return v ? "yes" : "no";
|
||||
}
|
||||
|
||||
let runtimePrewarmReminder = "";
|
||||
function markPrewarmReminder(reason) {
|
||||
const why = String(reason || "").trim();
|
||||
const base = t("stack.configChangedPrewarm");
|
||||
runtimePrewarmReminder = why ? `${base} [${why}]` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact row action menu (button + fixed popover).
|
||||
* Must NOT use nested <details> inside plugins-fold details — clicks get swallowed.
|
||||
* items: [{ label, onClick, danger?, disabled? }]
|
||||
*/
|
||||
function rowActions(label, items) {
|
||||
let menuEl = null;
|
||||
let onDoc = null;
|
||||
const close = () => {
|
||||
if (onDoc) {
|
||||
document.removeEventListener("click", onDoc, true);
|
||||
onDoc = null;
|
||||
}
|
||||
if (menuEl && menuEl.parentNode) menuEl.parentNode.removeChild(menuEl);
|
||||
menuEl = null;
|
||||
};
|
||||
const btn = el("button", {
|
||||
class: "chat-sess-more row-actions__btn",
|
||||
type: "button",
|
||||
text: String(label || "⋯"),
|
||||
title: "actions",
|
||||
"aria-label": "actions",
|
||||
onclick: (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (menuEl) {
|
||||
close();
|
||||
try {
|
||||
btn.blur();
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
const menu = el("div", { class: "chat-sess-menu-pop row-actions__menu u-overlay-fixed" });
|
||||
menu.style.visibility = "hidden";
|
||||
(Array.isArray(items) ? items : []).forEach((it) => {
|
||||
if (!it || !it.label) return;
|
||||
menu.appendChild(
|
||||
el("button", {
|
||||
class: "chat-sess-menu-item" + (it.danger ? " row-actions__item--danger" : ""),
|
||||
type: "button",
|
||||
text: String(it.label),
|
||||
disabled: !!it.disabled,
|
||||
onclick: async (e2) => {
|
||||
e2.preventDefault();
|
||||
e2.stopPropagation();
|
||||
close();
|
||||
try {
|
||||
btn.blur();
|
||||
} catch (_) {}
|
||||
if (typeof it.onClick !== "function") return;
|
||||
try {
|
||||
await it.onClick();
|
||||
} catch (err) {
|
||||
console.warn("row action failed", err);
|
||||
window.alert(String((err && err.message) || err || "action failed"));
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
document.body.appendChild(menu);
|
||||
const rect = ev.currentTarget.getBoundingClientRect();
|
||||
const pad = 8;
|
||||
const mrect = menu.getBoundingClientRect();
|
||||
// Prefer opening to the left of the ⋯ (actions column is trailing).
|
||||
let left = rect.right - mrect.width;
|
||||
let top = rect.bottom + 4;
|
||||
if (left < pad) {
|
||||
left = Math.min(rect.left, window.innerWidth - pad - mrect.width);
|
||||
}
|
||||
if (top + mrect.height > window.innerHeight - pad) {
|
||||
top = Math.max(pad, rect.top - 4 - mrect.height);
|
||||
}
|
||||
left = Math.max(pad, Math.min(left, window.innerWidth - pad - mrect.width));
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
menu.style.visibility = "visible";
|
||||
menuEl = menu;
|
||||
try {
|
||||
btn.blur();
|
||||
} catch (_) {}
|
||||
onDoc = (e3) => {
|
||||
if (menu.contains(e3.target) || btn.contains(e3.target)) return;
|
||||
close();
|
||||
};
|
||||
setTimeout(() => document.addEventListener("click", onDoc, true), 0);
|
||||
},
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
/** Navigate/reload admin SPA without importing router (avoids cycles). */
|
||||
function navigateAdmin() {
|
||||
const fn = state.navigate;
|
||||
if (typeof fn === "function") return fn();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
export {
|
||||
SESSION_MONITOR_ROLE_FILTER_KEY,
|
||||
AUTH_TOKEN_KEY,
|
||||
AUTH_SESSION_KEY,
|
||||
CHAT_MEMORY_MODE_KEY,
|
||||
authStoreGet,
|
||||
authStoreSet,
|
||||
authStoreRemove,
|
||||
formatUserLabel,
|
||||
formatAuditActor,
|
||||
getRoute,
|
||||
resolveAdminApiUrl,
|
||||
resolveChatUrl,
|
||||
getStoredAuthToken,
|
||||
scheduleReauthAfter401,
|
||||
_haltAfter401,
|
||||
apiGet,
|
||||
apiGetOptional,
|
||||
apiGetNoHang,
|
||||
apiPost,
|
||||
assertSkillMutationOk,
|
||||
apiRequest,
|
||||
apiPostFormData,
|
||||
apiDeleteJson,
|
||||
_activeCopyCell,
|
||||
_selectCopyCell,
|
||||
_shouldIgnoreCopyShortcut,
|
||||
_installCellCopyKeyboardShortcut,
|
||||
attachCellCopyBehavior,
|
||||
el,
|
||||
tdCell,
|
||||
enableTableColumnResize,
|
||||
setActive,
|
||||
mount,
|
||||
renderPageShell,
|
||||
renderSectionCard,
|
||||
renderMetaChips,
|
||||
shortText,
|
||||
formatSystemLocalDateTime,
|
||||
formatIds,
|
||||
yesNo,
|
||||
runtimePrewarmReminder,
|
||||
markPrewarmReminder,
|
||||
rowActions,
|
||||
navigateAdmin,
|
||||
state,
|
||||
t,
|
||||
tf,
|
||||
applyI18nStatic,
|
||||
toggleLang,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue