oclaw/interfaces/admin/static/js/pages/plugins.js
2026-08-12 22:59:17 +08:00

937 lines
37 KiB
JavaScript

import { t, el, tdCell, apiGet, apiGetNoHang, apiPost, renderPageShell, markPrewarmReminder, tf, rowActions } from "../core.js";
const PLUGINS_PAGE_SIZE = 15;
/** Compact Admin status line — never dump full tools[] / schemas (blows page width + noise). */
function summarizeApiStatus(prefix, r) {
const p = String(prefix || "").trim() || "[api]";
if (r == null) return `${p} (empty)`;
if (typeof r !== "object") return `${p} ${String(r).slice(0, 160)}`;
if (r.ok === false) {
const err = String(r.error_code || r.error || r.detail || "error").slice(0, 120);
return `${p} fail: ${err}`;
}
const prewarmTail =
r.prewarm && typeof r.prewarm === "object" && r.prewarm.accepted ? " · prewarm=async" : "";
if (Array.isArray(r.tools)) {
const names = r.tools
.map((x) => String((x && (x.tool_name || x.name)) || "").trim())
.filter(Boolean);
const shown = names.slice(0, 10).join(", ");
const more = names.length > 10 ? ` (+${names.length - 10})` : "";
const sid = r.server_id ? ` server=${r.server_id}` : "";
return `${p} ok${sid} · ${names.length} tools${shown ? `: ${shown}${more}` : ""}${prewarmTail}`;
}
if (Array.isArray(r.tool_names)) {
const names = r.tool_names.map((x) => String(x || "").trim()).filter(Boolean);
const shown = names.slice(0, 10).join(", ");
const more = names.length > 10 ? ` (+${names.length - 10})` : "";
const n = Number(r.synced_tools || names.length || 0);
const sid = r.server_id ? ` server=${r.server_id}` : "";
return `${p} ok${sid} · ${n} tools${shown ? `: ${shown}${more}` : ""}${prewarmTail}`;
}
if (Array.isArray(r.items)) {
return `${p} ok · ${r.items.length} item(s)${prewarmTail}`;
}
if (Array.isArray(r.results)) {
const okN = r.results.filter((x) => x && x.ok !== false).length;
return `${p} ok · ${okN}/${r.results.length} result(s)${prewarmTail}`;
}
if (r.status != null) {
const sid = r.server_id ? ` server=${r.server_id}` : "";
return `${p} ${String(r.status)}${sid}${prewarmTail}`;
}
const bits = [];
for (const k of ["ok", "server_id", "enabled", "deleted", "synced_tools", "compat_mode"]) {
if (r[k] == null || typeof r[k] === "object") continue;
bits.push(`${k}=${String(r[k]).slice(0, 40)}`);
if (bits.length >= 5) break;
}
if (prewarmTail) bits.push("prewarm=async");
return bits.length ? `${p} ${bits.join(" · ")}` : `${p} ok`;
}
function pluginsFold(summaryText, innerNodes) {
const det = el("details", { class: "details plugins-fold" });
det.appendChild(el("summary", { text: summaryText }));
const inner = el("div", { class: "plugins-fold__inner" });
// Keep interactive controls inside the fold from bubbling to <details>.
inner.addEventListener("click", (e) => e.stopPropagation());
innerNodes.forEach((n) => inner.appendChild(n));
det.appendChild(inner);
return det;
}
/** totalHolder.value = row count; pageRef.value = 1-based page */
function pluginsPagerBar(totalHolder, pageRef, onRepaint) {
const wrap = el("div", {
class: "row plugins-pager",
style: "gap:8px;align-items:center;margin-top:6px;flex-wrap:wrap;",
});
const lab = el("span", { class: "muted", text: "" });
const maxP = () => Math.max(1, Math.ceil(Math.max(0, totalHolder.value) / PLUGINS_PAGE_SIZE));
const prevBtn = el("button", { class: "btn btn--small", text: t("plugins.prevPage") });
const nextBtn = el("button", { class: "btn btn--small", text: t("plugins.nextPage") });
const sync = () => {
const total = totalHolder.value;
let cur = pageRef.value;
const mp = maxP();
if (cur > mp) {
cur = mp;
pageRef.value = cur;
}
if (cur < 1) {
cur = 1;
pageRef.value = 1;
}
const start = total === 0 ? 0 : (cur - 1) * PLUGINS_PAGE_SIZE + 1;
const end = Math.min(cur * PLUGINS_PAGE_SIZE, total);
lab.textContent = total ? tf("plugins.pageRange", { start, end, total }) : t("plugins.noData");
prevBtn.disabled = cur <= 1 || total === 0;
nextBtn.disabled = cur >= mp || total === 0;
};
prevBtn.addEventListener("click", () => {
if (pageRef.value > 1) {
pageRef.value--;
onRepaint();
sync();
}
});
nextBtn.addEventListener("click", () => {
if (pageRef.value < maxP()) {
pageRef.value++;
onRepaint();
sync();
}
});
wrap.appendChild(prevBtn);
wrap.appendChild(lab);
wrap.appendChild(nextBtn);
sync();
return { wrap, sync };
}
function cursorCommandSummary(cursor) {
if (!cursor || typeof cursor !== "object") return "-";
const url = String(cursor.url || cursor.baseUrl || "").trim();
if (url) return url;
const cmd = String(cursor.command || "").trim();
const args = Array.isArray(cursor.args) ? cursor.args.map((x) => String(x)).join(" ") : "";
const line = (cmd + (args ? " " + args : "")).trim();
return line || "-";
}
async function renderPlugins() {
document.querySelectorAll("[data-mcp-edit-modal]").forEach((n) => n.remove());
document.querySelectorAll("body > .row-actions__menu, body > .chat-sess-menu-pop.row-actions__menu").forEach((n) => n.remove());
const defaultToolPolicy = {
disable_tool_confirm: false,
enforced_retry_mode: "first_round_only",
tool_loop_state_machine: true,
tool_signature_budget: 2,
turn_max_tool_workers: 8,
turn_max_tool_rounds: 100,
turn_max_context_messages: 80,
turn_runner_impl: "oclaw",
sse_queue_maxsize: 2000,
tool_log_max_chars: 200000,
enable_mcp_tools: true,
enable_plugin_tools: false,
enable_run_command: true,
tool_context_truncate_enabled: true,
chat_show_ttft_debug: false,
tool_llm_message_max_chars: 0,
mcp_filesystem_extra_roots: "",
mcp_env_allowlist: "",
oclaw_retryable_error_codes: "",
oclaw_retry_codes_strict_mode: false,
wecom_longconn_workers: 2,
wecom_longconn_inbound_queue_maxsize: 200,
};
const [p, mcp, mcpBinding, toolPolicyRaw] = await Promise.all([
apiGetNoHang("/admin/api/plugins").then((r) => r || { plugins: [] }),
apiGetNoHang("/admin/api/mcp/servers").then((r) => r || { servers: [] }),
apiGetNoHang("/admin/api/mcp/binding").then(
(r) =>
r || {
available_specialists: ["generalist"],
servers: [],
mapping: {},
},
),
apiGetNoHang("/admin/api/tool-policy"),
]);
let toolPolicy = toolPolicyRaw && typeof toolPolicyRaw === "object" ? toolPolicyRaw : defaultToolPolicy;
const pluginCatalog = Array.isArray(p.plugins) ? p.plugins : [];
const pluginPageRef = { value: 1 };
const pluginTotalHolder = { value: pluginCatalog.length };
const pluginTbody = el("tbody");
const buildPluginRow = (x) =>
el("tr", {}, [
el("td", { text: String(x.plugin_name || "") }),
el("td", { text: String(x.plugin_version || "") }),
el("td", { text: String(x.entry_point || "") }),
el("td", { text: String(x.enabled ? 1 : 0) }),
]);
const repaintPlugins = () => {
pluginTbody.innerHTML = "";
const start = (pluginPageRef.value - 1) * PLUGINS_PAGE_SIZE;
pluginCatalog.slice(start, start + PLUGINS_PAGE_SIZE).forEach((x) => {
pluginTbody.appendChild(buildPluginRow(x));
});
};
repaintPlugins();
const pluginPager = pluginsPagerBar(pluginTotalHolder, pluginPageRef, repaintPlugins);
const bindingStatus = el("div", { class: "muted plugins-status", text: "" });
const installStatus = el("div", { class: "muted plugins-status", text: "" });
const toolPolicyStatus = el("div", { class: "muted plugins-status", text: "" });
const turnMaxWorkersInput = el("input", {
class: "input u-max-w-120",
type: "number",
min: "1",
max: "32",
value: String(Number(toolPolicy.turn_max_tool_workers || 8)),
});
const turnMaxRoundsInput = el("input", {
class: "input u-max-w-120",
type: "number",
min: "1",
max: "300",
value: String(Number(toolPolicy.turn_max_tool_rounds || 100)),
});
const turnMaxCtxInput = el("input", {
class: "input u-max-w-120",
type: "number",
min: "10",
max: "400",
value: String(Number(toolPolicy.turn_max_context_messages || 80)),
});
const sseQueueMaxsizeInput = el("input", {
class: "input u-max-w-140",
type: "number",
min: "200",
max: "50000",
value: String(Number(toolPolicy.sse_queue_maxsize || 2000)),
});
const toolLogMaxCharsInput = el("input", {
class: "input u-max-w-140",
type: "number",
min: "20000",
max: "2000000",
value: String(Number(toolPolicy.tool_log_max_chars || 200000)),
});
const enableMcpToolsCb = el("input", { type: "checkbox" });
enableMcpToolsCb.checked = !!toolPolicy.enable_mcp_tools;
const enablePluginToolsCb = el("input", { type: "checkbox" });
enablePluginToolsCb.checked = !!toolPolicy.enable_plugin_tools;
const enableRunCommandCb = el("input", { type: "checkbox" });
enableRunCommandCb.checked = !!toolPolicy.enable_run_command;
const toolContextTruncateCb = el("input", { type: "checkbox" });
toolContextTruncateCb.checked = !!toolPolicy.tool_context_truncate_enabled;
const chatShowTtftDebugCb = el("input", { type: "checkbox" });
chatShowTtftDebugCb.checked = !!toolPolicy.chat_show_ttft_debug;
const toolLlmMessageMaxCharsInput = el("input", {
class: "input u-max-w-140",
type: "number",
min: "0",
max: "500000",
value: String(Number(toolPolicy.tool_llm_message_max_chars ?? 0)),
});
const mcpFilesystemExtraRootsInput = el("input", {
class: "input",
value: String(toolPolicy.mcp_filesystem_extra_roots || ""),
placeholder: "D:\\work|D:\\docs",
});
const mcpEnvAllowlistInput = el("input", {
class: "input",
value: String(toolPolicy.mcp_env_allowlist || ""),
placeholder: "BRAVE_API_KEY,GOOGLE_OAUTH_CREDENTIALS,...",
});
const oclawRetryableErrorCodesInput = el("input", {
class: "input",
value: String(toolPolicy.oclaw_retryable_error_codes || ""),
placeholder: "provider_timeout,provider_rate_limited,...",
});
const oclawRetryCodesStrictModeCb = el("input", { type: "checkbox" });
oclawRetryCodesStrictModeCb.checked = !!toolPolicy.oclaw_retry_codes_strict_mode;
const wecomLongconnWorkersInput = el("input", {
class: "input u-max-w-120",
type: "number",
min: "1",
max: "8",
value: String(Number(toolPolicy.wecom_longconn_workers || 2)),
});
const wecomLongconnInboundQueueInput = el("input", {
class: "input u-max-w-140",
type: "number",
min: "20",
max: "5000",
value: String(Number(toolPolicy.wecom_longconn_inbound_queue_maxsize || 200)),
});
const saveToolPolicyBtn = el("button", {
class: "btn",
text: t("plugins.action.saveToolPolicy"),
onclick: async () => {
const r = await apiPost("/admin/api/tool-policy", {
turn_max_tool_workers: Number(turnMaxWorkersInput.value || 8),
turn_max_tool_rounds: Number(turnMaxRoundsInput.value || 100),
turn_max_context_messages: Number(turnMaxCtxInput.value || 80),
sse_queue_maxsize: Number(sseQueueMaxsizeInput.value || 2000),
tool_log_max_chars: Number(toolLogMaxCharsInput.value || 200000),
enable_mcp_tools: !!enableMcpToolsCb.checked,
enable_plugin_tools: !!enablePluginToolsCb.checked,
enable_run_command: !!enableRunCommandCb.checked,
tool_context_truncate_enabled: !!toolContextTruncateCb.checked,
chat_show_ttft_debug: !!chatShowTtftDebugCb.checked,
tool_llm_message_max_chars: Number(toolLlmMessageMaxCharsInput.value || 0),
mcp_filesystem_extra_roots: String(mcpFilesystemExtraRootsInput.value || ""),
mcp_env_allowlist: String(mcpEnvAllowlistInput.value || ""),
oclaw_retryable_error_codes: String(oclawRetryableErrorCodesInput.value || ""),
oclaw_retry_codes_strict_mode: !!oclawRetryCodesStrictModeCb.checked,
wecom_longconn_workers: Number(wecomLongconnWorkersInput.value || 2),
wecom_longconn_inbound_queue_maxsize: Number(wecomLongconnInboundQueueInput.value || 200),
});
const unknownRetry = Array.isArray(r.unknown_retryable_error_codes) ? r.unknown_retryable_error_codes : [];
if (unknownRetry.length) {
toolPolicyStatus.textContent =
summarizeApiStatus("[tool-policy]", r) +
` · warn unknown_retryable_error_codes=${unknownRetry.join(", ")}`;
} else {
toolPolicyStatus.textContent = summarizeApiStatus("[tool-policy]", r);
}
toolPolicyStatus.textContent += " | restart gateway/desktop to apply run_command toggle";
},
});
const availableSpecialists =
Array.isArray(mcpBinding.available_specialists) && mcpBinding.available_specialists.length
? mcpBinding.available_specialists.map((x) => String(x))
: ["generalist"];
let bindingServers = Array.isArray(mcpBinding.servers)
? mcpBinding.servers.filter((x) => x && String(x.server_id || "").trim())
: [];
let bindingDraft = mcpBinding.mapping && typeof mcpBinding.mapping === "object" ? { ...mcpBinding.mapping } : {};
const allSpecialistIds = () => {
const s = new Set(availableSpecialists.map((x) => String(x)));
Object.keys(bindingDraft).forEach((k) => s.add(String(k)));
return Array.from(s).sort();
};
let repaintExpertBindingDashboard = () => {};
const specialistSelect = el(
"select",
{ class: "input" },
availableSpecialists.map((sp) => el("option", { value: sp, text: sp })),
);
const bindingListWrap = el("div");
const bindingListRowsMount = el("div");
const bindingListPagerMount = el("div");
bindingListWrap.appendChild(bindingListRowsMount);
bindingListWrap.appendChild(bindingListPagerMount);
const bindingListPageRef = { value: 1 };
const bindingListTotalHolder = { value: 0 };
const bindingReverseWrap = el("div");
const bindingReverseTableMount = el("div");
const bindingReversePagerMount = el("div");
bindingReverseWrap.appendChild(bindingReverseTableMount);
bindingReverseWrap.appendChild(bindingReversePagerMount);
const bindingReversePageRef = { value: 1 };
const bindingReverseTotalHolder = { value: 0 };
const flashInstalledRow = (sid) => {
const row = document.getElementById(`mcp-installed-${sid}`);
if (!row) {
bindingStatus.textContent = `[binding] installed row not found: ${sid}`;
return;
}
row.scrollIntoView({ behavior: "smooth", block: "center" });
const oldBg = row.style.backgroundColor;
row.style.backgroundColor = "rgba(255, 215, 0, 0.22)";
setTimeout(() => {
row.style.backgroundColor = oldBg || "";
}, 1800);
};
const renderBindingReverse = () => {
bindingReverseTotalHolder.value = bindingServers.length;
const start = (bindingReversePageRef.value - 1) * PLUGINS_PAGE_SIZE;
const slice = bindingServers.slice(start, start + PLUGINS_PAGE_SIZE);
const rows = slice.map((srv) => {
const sid = String(srv.server_id || "");
const specialists = allSpecialistIds().filter((sp) => {
const mapped = Array.isArray(bindingDraft[sp]) ? bindingDraft[sp].map((x) => String(x)) : [];
return mapped.includes(sid);
});
const locateBtn = el("button", {
class: "btn btn--small",
text: t("plugins.action.locate"),
onclick: () => flashInstalledRow(sid),
});
return el("tr", {}, [
tdCell(sid, 38),
tdCell(specialists.length ? specialists.join(", ") : "-", 62),
el("td", {}, [locateBtn]),
]);
});
bindingReverseTableMount.innerHTML = "";
bindingReverseTableMount.appendChild(
el("div", { class: "table-wrap" }, [
el("table", { class: "table table--compact" }, [
el("thead", {}, [
el("tr", {}, [
el("th", { text: t("plugins.col.serverId") }),
el("th", { text: t("plugins.col.boundSpecialists") }),
el("th", { text: t("plugins.col.action") }),
]),
]),
el("tbody", {}, rows.length ? rows : [el("tr", {}, [el("td", { text: "-", colspan: "3" })])]),
]),
]),
);
bindingReversePagerMount.innerHTML = "";
const brBar = pluginsPagerBar(bindingReverseTotalHolder, bindingReversePageRef, renderBindingReverse);
bindingReversePagerMount.appendChild(brBar.wrap);
repaintExpertBindingDashboard();
};
const renderBindingList = () => {
const current = String(specialistSelect.value || "");
const existing = Array.isArray(bindingDraft[current]) ? bindingDraft[current].map((x) => String(x)) : [];
const selected = new Set(existing);
bindingListTotalHolder.value = bindingServers.length;
const start = (bindingListPageRef.value - 1) * PLUGINS_PAGE_SIZE;
const slice = bindingServers.slice(start, start + PLUGINS_PAGE_SIZE);
bindingListRowsMount.innerHTML = "";
if (!bindingServers.length) {
bindingListRowsMount.appendChild(el("div", { class: "muted", text: t("plugins.binding.empty") }));
bindingListPagerMount.innerHTML = "";
const blBar0 = pluginsPagerBar(bindingListTotalHolder, bindingListPageRef, renderBindingList);
bindingListPagerMount.appendChild(blBar0.wrap);
repaintExpertBindingDashboard();
return;
}
slice.forEach((srv) => {
const sid = String(srv.server_id || "");
const cb = el("input", { type: "checkbox" });
cb.checked = selected.has(sid);
cb.addEventListener("change", () => {
const prev = new Set(
Array.isArray(bindingDraft[current]) ? bindingDraft[current].map((x) => String(x)) : [],
);
if (cb.checked) prev.add(sid);
else prev.delete(sid);
bindingDraft[current] = Array.from(prev);
renderBindingReverse();
});
const label = `${sid}${srv.enabled ? "" : " (disabled)"}`;
bindingListRowsMount.appendChild(el("label", { class: "row" }, [cb, el("span", { text: label })]));
});
bindingListPagerMount.innerHTML = "";
const blBar = pluginsPagerBar(bindingListTotalHolder, bindingListPageRef, renderBindingList);
bindingListPagerMount.appendChild(blBar.wrap);
repaintExpertBindingDashboard();
};
specialistSelect.addEventListener("change", () => {
bindingListPageRef.value = 1;
bindingReversePageRef.value = 1;
renderBindingList();
renderBindingReverse();
});
const selectAllBindingBtn = el("button", {
class: "btn",
text: t("plugins.action.selectAll"),
onclick: () => {
const current = String(specialistSelect.value || "");
bindingDraft[current] = bindingServers.map((x) => String(x.server_id || "")).filter((x) => x);
renderBindingList();
renderBindingReverse();
},
});
const clearBindingBtn = el("button", {
class: "btn",
text: t("plugins.action.clear"),
onclick: () => {
const current = String(specialistSelect.value || "");
bindingDraft[current] = [];
renderBindingList();
renderBindingReverse();
},
});
const saveBindingBtn = el("button", {
class: "btn",
text: t("plugins.action.saveBinding"),
onclick: async () => {
const r = await apiPost("/admin/api/mcp/binding", { mapping: bindingDraft });
bindingStatus.textContent = summarizeApiStatus("[binding]", r);
markPrewarmReminder("mcp_binding_changed");
try {
const fresh = await apiGet("/admin/api/mcp/binding");
if (fresh && fresh.mapping && typeof fresh.mapping === "object") {
bindingDraft = { ...fresh.mapping };
}
if (Array.isArray(fresh && fresh.servers)) {
bindingServers = fresh.servers.filter((x) => x && String(x.server_id || "").trim());
}
} catch (_) {}
renderBindingList();
renderBindingReverse();
},
});
renderBindingList();
renderBindingReverse();
const jsonInstallInput = el("textarea", {
class: "input",
placeholder:
'{\n "mcpServers": {\n "my-server": { "command": "npx", "args": ["-y", "mcp-fetch-server"] }\n }\n}',
rows: "10",
});
const installBtn = el("button", {
class: "btn",
text: t("plugins.action.install"),
onclick: async () => {
const raw = String(jsonInstallInput.value || "").trim();
if (!raw) {
installStatus.textContent = "[install] empty JSON";
return;
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch (err) {
installStatus.textContent = `[install] invalid JSON: ${String((err && err.message) || err || "parse_failed")}`;
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
installStatus.textContent = "[install] body must be a JSON object containing mcpServers";
return;
}
if (!parsed.mcpServers || typeof parsed.mcpServers !== "object") {
installStatus.textContent = "[install] mcpServers required";
return;
}
installStatus.textContent = "[install] installing...";
try {
const res = await apiPost("/admin/api/mcp/install", parsed);
installStatus.textContent = summarizeApiStatus("[install]", res);
if (res && res.ok) {
markPrewarmReminder("mcp_installed");
await softReloadMcpServers();
}
} catch (err) {
installStatus.textContent = `[install] failed: ${String((err && err.message) || err || "install_failed")}`;
}
},
});
const mcpExportJsonBtn = el("button", {
class: "btn",
text: t("plugins.action.exportJson"),
title: t("plugins.action.exportJsonTitle"),
onclick: async () => {
let r;
try {
r = await apiGet("/admin/api/mcp/export");
} catch (err) {
installStatus.textContent = "[export] " + String((err && err.message) || err);
return;
}
if (!r || r.ok !== true || !r.document) {
installStatus.textContent = "[export] failed: " + summarizeApiStatus("[export]", r);
return;
}
const text = JSON.stringify(r.document, null, 2) + "\n";
const blob = new Blob([text], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "mcp_registry_migrated.json";
a.click();
URL.revokeObjectURL(a.href);
installStatus.textContent =
"[export] downloaded mcp_registry_migrated.json" + (r.local_path ? " ; on server: " + r.local_path : "");
},
});
let mcpServerList = Array.isArray(mcp.servers) ? mcp.servers : [];
const expertBindingDashTbody = el("tbody");
repaintExpertBindingDashboard = () => {
expertBindingDashTbody.innerHTML = "";
const ids = allSpecialistIds();
if (!ids.length) {
expertBindingDashTbody.appendChild(el("tr", {}, [el("td", { class: "muted", text: "—", colspan: "3" })]));
return;
}
ids.forEach((sp) => {
const sids = new Set((bindingDraft[sp] || []).map(String).filter(Boolean));
let toolCnt = 0;
mcpServerList.forEach((row) => {
const rsid = String(row.server_id || "");
if (!sids.has(rsid)) return;
const tools = Array.isArray(row.tools) ? row.tools : [];
toolCnt += tools.length;
});
expertBindingDashTbody.appendChild(
el("tr", {}, [tdCell(sp, 28), tdCell(String(sids.size), 10), tdCell(String(toolCnt), 12)]),
);
});
};
repaintExpertBindingDashboard();
let editingServer = null;
const editModal = el("div", { class: "session-monitor-modal u-hidden", "data-mcp-edit-modal": "1" });
const editTitle = el("div", { class: "card__title", text: t("plugins.edit.title") });
const editServerIdLab = el("div", { class: "muted", text: "" });
const editCursorInput = el("textarea", {
class: "input",
rows: "12",
placeholder: '{ "command": "npx", "args": ["-y", "..."] }',
});
const editEnabledCb = el("input", { type: "checkbox" });
const editTimeoutInput = el("input", {
class: "input u-max-w-120",
type: "number",
min: "1",
max: "600",
value: "30",
});
const editStatus = el("div", { class: "muted", text: "" });
const closeEditModal = () => {
editingServer = null;
editStatus.textContent = "";
editModal.classList.add("u-hidden");
editModal.style.display = "";
};
const openEditModal = (row) => {
if (!row) return;
editingServer = row;
const sid = String(row.server_id || "");
editTitle.textContent = tf("plugins.edit.titleNamed", { sid });
editServerIdLab.textContent = `${t("plugins.col.serverId")}: ${sid}`;
const cursor = row.cursor && typeof row.cursor === "object" ? row.cursor : {};
editCursorInput.value = JSON.stringify(cursor, null, 2);
editEnabledCb.checked = !!row.enabled;
editTimeoutInput.value = String(Number(row.timeout_s || 30));
editStatus.textContent = "";
if (editModal.parentNode !== document.body) document.body.appendChild(editModal);
editModal.classList.remove("u-hidden");
editModal.style.display = "";
setTimeout(() => editCursorInput.focus(), 0);
};
editModal.addEventListener("click", (e) => {
if (e.target === editModal) closeEditModal();
});
const editSaveBtn = el("button", {
class: "btn btn--primary",
text: t("plugins.action.save"),
onclick: async () => {
if (!editingServer) return;
const sid = String(editingServer.server_id || "").trim();
if (!sid) return;
let parsed;
try {
parsed = JSON.parse(String(editCursorInput.value || "").trim() || "{}");
} catch (err) {
editStatus.textContent = `[edit] invalid JSON: ${String((err && err.message) || err || "parse_failed")}`;
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
editStatus.textContent = "[edit] Cursor config must be a JSON object";
return;
}
editStatus.textContent = "[edit] saving...";
try {
const r = await apiPost("/admin/api/mcp/config", {
server_id: sid,
server: parsed,
enabled: !!editEnabledCb.checked,
timeout_s: Number(editTimeoutInput.value || 30),
});
editStatus.textContent = summarizeApiStatus("[edit]", r);
if (r && r.ok) {
closeEditModal();
markPrewarmReminder("mcp_config_updated");
await softReloadMcpServers();
repaintExpertBindingDashboard();
}
} catch (err) {
editStatus.textContent = `[edit] failed: ${String((err && err.message) || err || "save_failed")}`;
}
},
});
editModal.appendChild(
el("div", { class: "card session-monitor-modal__card", style: "width:min(720px,96vw);" }, [
editTitle,
editServerIdLab,
el("div", { class: "muted", text: t("plugins.edit.configHint") }),
editCursorInput,
el("div", { class: "row", style: "align-items:center;gap:10px;flex-wrap:wrap;" }, [
el("label", { class: "kv" }, [editEnabledCb, document.createTextNode(` ${t("plugins.edit.enabled")}`)]),
el("label", { text: "timeout_s" }),
editTimeoutInput,
]),
editStatus,
el("div", { class: "row u-row-end", style: "gap:8px;margin-top:10px;" }, [
el("button", { class: "btn", text: t("plugins.action.cancel"), onclick: closeEditModal }),
editSaveBtn,
]),
]),
);
const mcpInstalledPageRef = { value: 1 };
const mcpInstalledTotalHolder = { value: mcpServerList.length };
const mcpInstalledTbody = el("tbody");
let foldInstalledSummary = null;
const softReloadMcpServers = async () => {
const resp = await apiGet("/admin/api/mcp/servers");
mcpServerList = Array.isArray(resp.servers) ? resp.servers : [];
bindingServers = mcpServerList.filter((x) => x && String(x.server_id || "").trim());
mcpInstalledTotalHolder.value = mcpServerList.length;
if (foldInstalledSummary) {
foldInstalledSummary.textContent = tf("plugins.fold.installed", { n: mcpServerList.length });
}
repaintMcpInstalled();
mcpInstalledPager.sync();
renderBindingList();
renderBindingReverse();
};
const buildMcpInstalledRow = (x) => {
const sid = String(x.server_id || "");
const cursor = x.cursor && typeof x.cursor === "object" ? x.cursor : {};
const actions = rowActions("⋯", [
{
label: t("plugins.action.edit"),
onClick: () => openEditModal(x),
},
{
label: x.enabled ? t("plugins.action.disable") : t("plugins.action.enable"),
onClick: async () => {
await apiPost("/admin/api/mcp/toggle", { server_id: sid, enabled: !x.enabled });
markPrewarmReminder("mcp_toggled");
installStatus.textContent = `[toggle:${sid}] enabled=${!x.enabled ? 1 : 0}`;
await softReloadMcpServers();
},
},
{
label: t("plugins.action.health"),
onClick: async () => {
const r = await apiPost("/admin/api/mcp/healthcheck", { server_id: sid });
installStatus.textContent = summarizeApiStatus(`[health:${sid}]`, r);
await softReloadMcpServers();
},
},
{
label: t("plugins.action.syncTools"),
onClick: async () => {
const r = await apiPost("/admin/api/mcp/tools/sync", { server_id: sid });
installStatus.textContent = summarizeApiStatus(`[sync:${sid}]`, r);
await softReloadMcpServers();
},
},
{
label: t("plugins.action.delete"),
danger: true,
onClick: async () => {
if (!window.confirm(tf("plugins.confirm.delete", { sid }))) return;
const r = await apiPost("/admin/api/mcp/delete", { server_id: sid });
installStatus.textContent = summarizeApiStatus(`[delete:${sid}]`, r);
markPrewarmReminder("mcp_deleted");
await softReloadMcpServers();
},
},
]);
const tools = Array.isArray(x.tools) ? x.tools.map((t0) => String(t0.tool_name || "")).join(", ") : "";
const healthObj = x.health && typeof x.health === "object" ? x.health : {};
const healthStatus = String(healthObj.status || "-");
const healthDetail = healthObj.detail && typeof healthObj.detail === "object" ? healthObj.detail : {};
const healthErrCode = String(healthDetail.error_code || "");
const healthErrMsg = String(healthDetail.error || "");
const healthText = healthErrCode ? `${healthStatus}:${healthErrCode}` : healthStatus;
const healthTitle =
healthErrCode || healthErrMsg
? `${healthErrCode || "error"} ${healthErrMsg}`.trim()
: healthDetail.synced_tools != null
? `synced_tools=${Number(healthDetail.synced_tools || 0)}`
: healthStatus;
return el("tr", { id: `mcp-installed-${sid}` }, [
tdCell(sid, 28),
tdCell(cursorCommandSummary(cursor), 56),
tdCell(tools || "-", 48),
tdCell(String(x.enabled ? 1 : 0), 8),
el("td", { text: healthText, title: healthTitle }),
el("td", { class: "table__cell--actions", "data-copy-disabled": "1" }, [
el("div", { class: "table__cell-actions" }, [actions]),
]),
]);
};
const repaintMcpInstalled = () => {
mcpInstalledTbody.innerHTML = "";
const start = (mcpInstalledPageRef.value - 1) * PLUGINS_PAGE_SIZE;
mcpServerList.slice(start, start + PLUGINS_PAGE_SIZE).forEach((x) => {
mcpInstalledTbody.appendChild(buildMcpInstalledRow(x));
});
};
repaintMcpInstalled();
const mcpInstalledPager = pluginsPagerBar(mcpInstalledTotalHolder, mcpInstalledPageRef, repaintMcpInstalled);
const foldToolPolicy = pluginsFold(tf("plugins.fold.toolPolicy", { n: pluginCatalog.length }), [
el("div", { class: "muted", text: t("plugins.fold.toolPolicyHint") }),
el("div", { class: "row" }, [el("label", { text: "Turn max tool workers (1-32)" }), turnMaxWorkersInput]),
el("div", { class: "row" }, [el("label", { text: "Turn max tool rounds (1-300)" }), turnMaxRoundsInput]),
el("div", { class: "row" }, [el("label", { text: "Turn max context messages (10-400)" }), turnMaxCtxInput]),
el("div", { class: "row" }, [el("label", { text: "SSE queue maxsize (200-50000)" }), sseQueueMaxsizeInput]),
el("div", { class: "row" }, [el("label", { text: "Tool log max chars (20000-2000000)" }), toolLogMaxCharsInput]),
el("div", { class: "row" }, [
el("label", { class: "kv" }, [enableMcpToolsCb, document.createTextNode(" Enable MCP tools")]),
]),
el("div", { class: "row" }, [
el("label", { class: "kv" }, [enablePluginToolsCb, document.createTextNode(" Enable plugin tools")]),
]),
el("div", { class: "row" }, [
el("label", { class: "kv" }, [enableRunCommandCb, document.createTextNode(" Enable run_command (high-risk)")]),
]),
el("div", { class: "row" }, [
el("label", { class: "kv" }, [
toolContextTruncateCb,
document.createTextNode(" Compress tool result in agent context (50 chars + hint)"),
]),
]),
el("div", { class: "row" }, [
el("label", { class: "kv" }, [
chatShowTtftDebugCb,
document.createTextNode(" Show TTFT debug timings in chat status"),
]),
]),
el("div", { class: "row" }, [
el("label", { text: "Tool message max chars to LLM (0=unlimited, 4096-500000 recommended)" }),
toolLlmMessageMaxCharsInput,
]),
el("div", {
class: "muted",
text: "Set 0 to disable truncation. If some gateways return 400 for oversized tool messages, set back to 24000.",
}),
el("div", { class: "row" }, [
el("label", { text: "MCP filesystem extra roots (| separated)" }),
mcpFilesystemExtraRootsInput,
]),
el("div", { class: "row" }, [el("label", { text: "MCP env allowlist (comma separated)" }), mcpEnvAllowlistInput]),
el("div", { class: "row" }, [
el("label", { text: "oclaw retryable error codes (comma separated)" }),
oclawRetryableErrorCodesInput,
]),
el("div", { class: "row" }, [
el("label", { class: "kv" }, [
oclawRetryCodesStrictModeCb,
document.createTextNode(" Strict mode: reject unknown retry codes"),
]),
]),
el("div", { class: "row" }, [el("label", { text: "WeCom longconn workers (1-8)" }), wecomLongconnWorkersInput]),
el("div", { class: "row" }, [
el("label", { text: "WeCom inbound queue maxsize (20-5000)" }),
wecomLongconnInboundQueueInput,
]),
el("div", { class: "row" }, [saveToolPolicyBtn]),
toolPolicyStatus,
el("div", { class: "table-wrap" }, [
el("table", { class: "table" }, [
el("thead", {}, [
el("tr", {}, [
el("th", { text: t("table.name") }),
el("th", { text: t("table.version") }),
el("th", { text: t("table.entryPoint") }),
el("th", { text: t("table.enabled") }),
]),
]),
pluginTbody,
]),
]),
pluginPager.wrap,
]);
const foldMcpInstall = pluginsFold(t("plugins.fold.installCursor"), [
el("div", { class: "muted", text: t("plugins.fold.installCursorHint") }),
jsonInstallInput,
el("div", { class: "row", style: "flex-wrap:wrap;gap:8px;align-items:center;" }, [
installBtn,
mcpExportJsonBtn,
]),
el("div", { class: "muted", text: t("plugins.fold.migrateHint") }),
]);
const foldMcpInstalled = pluginsFold(tf("plugins.fold.installed", { n: mcpServerList.length }), [
installStatus,
el("div", { class: "table-wrap" }, [
el("table", { class: "table table--compact" }, [
el("thead", {}, [
el("tr", {}, [
el("th", { text: t("plugins.col.serverId") }),
el("th", { text: t("plugins.col.commandUrl") }),
el("th", { text: t("plugins.col.tools") }),
el("th", { text: t("plugins.col.enabled") }),
el("th", { text: t("plugins.col.health") }),
el("th", { class: "table__cell--actions", text: t("tenants.rowActions") }),
]),
]),
mcpInstalledTbody,
]),
]),
mcpInstalledPager.wrap,
]);
foldMcpInstalled.open = true;
foldInstalledSummary = foldMcpInstalled.querySelector("summary");
const foldExpertBindingDash = pluginsFold(t("plugins.fold.bindingDash"), [
el("div", {
class: "muted",
text: t("plugins.fold.bindingDashHint"),
}),
el("div", { class: "table-wrap" }, [
el("table", { class: "table table--compact" }, [
el("thead", {}, [
el("tr", {}, [
el("th", { text: t("plugins.colSpecialist") }),
el("th", { text: t("plugins.colBoundMcp") }),
el("th", { text: t("plugins.colTools") }),
]),
]),
expertBindingDashTbody,
]),
]),
]);
const foldMcpBinding = pluginsFold(t("plugins.fold.bindingEdit"), [
el("div", { class: "muted", text: t("plugins.binding.hint") }),
el("div", { class: "row" }, [
el("label", { text: t("plugins.binding.specialist") }),
specialistSelect,
selectAllBindingBtn,
clearBindingBtn,
saveBindingBtn,
]),
bindingListWrap,
el("div", { class: "muted", text: t("plugins.binding.reverse") }),
bindingReverseWrap,
bindingStatus,
]);
return renderPageShell(
{
title: t("plugins.title"),
subtitle: tf("plugins.subtitle", { n: PLUGINS_PAGE_SIZE }),
sections: [
{ id: "plugins-tool-policy", label: t("plugins.toc.policy") },
{ id: "plugins-install", label: t("plugins.toc.install") },
{ id: "plugins-instances", label: t("plugins.toc.instances") },
{ id: "plugins-binding", label: t("plugins.toc.binding") },
],
},
[
el("div", { class: "page-grid page-grid--single" }, [
el("div", { id: "plugins-tool-policy" }, [foldToolPolicy]),
el("div", { id: "plugins-install" }, [foldMcpInstall]),
el("div", { id: "plugins-instances" }, [foldMcpInstalled]),
foldExpertBindingDash,
el("div", { id: "plugins-binding" }, [foldMcpBinding]),
]),
],
);
}
export { pluginsFold, pluginsPagerBar, renderPlugins };