feat(gui): WebUI 与 Electron 壳的插件安装/删除/禁用/编辑

## WebUI:新增「插件」页
- 列表来自 on_disk(不是 loaded 集合)——**加载失败的插件也必须显示并带错误**,
  否则一个语法错误看起来和"插件没装"完全一样
- 启用/禁用(PUT {"enabled":bool})、删除、编辑源码、安装/覆盖
- 显示 hook_errors:插件抛异常在别处毫无痕迹,没有这一栏的症状就是
  "功能就是不work"
- 插到 dropzone 与代码编辑器都做了泛型化(bindDropzone / openCodeModal),
  适配器与插件共用一份,而不是复制第二份只改 4 个 id 的函数

## TABS 收敛为单一常量
tab 清单原本是字面量散在三处:goTab、refresh()、admin-only 隐藏列表。
加一个 tab 意味着三处都要记得改,漏一处就是"路由认得但界面不显示"——
和今天早些时候 chain_step 漏报同一类静默缺口。现在只有 const TABS。

## Electron 壳:设置面板里的插件管理
渲染进程不能直连内嵌核心(没有 key、不知道端口),所以走 IPC:
  renderer → plugins:proxy → main → HTTP /api/plugins
代理是 (method, path, body) 透传而不是固定命令表:固定表每加一个端点就要扩,
而"按钮存在但什么都不做"比"没有这个按钮"更糟。透传让渲染层能调用核心将来
新增的任何 /api/plugins 路由,路径在主进程校验。

## ★ GUI 此前零测试,而本次改动就引入了三类"看起来没事"的问题
1. 引用了不存在的 CSS 类(.tag / .sm)——渲染成无样式文本
2. 引用了不存在的 helper(esc / escAttr)——那是 WebUI 的,renderer/app.js
   是独立文档,点击时 ReferenceError
3. .ghost/.primary 只在 .form .actions 作用域内生效,插件按钮在 .pl-acts 里
   于是是无样式裸按钮

补 4 个静态判据(不启动 Electron,守卫的正是"打开应用才看得见"那一类):
  TestGUICSSClassesExist          用到的类必须在样式表里定义
  TestGUIHelperFunctionsAreDefined 被调用的函数必须有定义
  TestGUIPluginPanelIsReachable  面板在 overlay 内、按钮已绑定、打开设置会加载
  TestGUIIPCPathIsConstrained    代理必须限定 /api/plugins 前缀并拒绝路径穿越

写第一个判据时我错了三次:CSS 解析器先丢最后一个 selector、再把变量块当
selector、最后漏掉复合选择器(.tb-btn.tb-close)。两次"判据自己坏了"的
教训和本项目一贯一致——**判据出错的信号是它报了一个假问题**。现在改用宽松的
token 提取 + 显式的 guiKnownUnstyled 豁免表(blob/tgl/rail 是既有无样式类,
不是本次引入,失败它们只会让判据对新工作失去意义)。

## 变异验证
  改坏唯一的 CSS 定义(.pl-empty)→ TestGUICSSClassesExist 红
  改坏 helper 名 → TestGUIHelperFunctionsAreDefined 红
★ 第一次变异我改了 .pl-broken,判据**正确地没报**——因为它还被另一条规则定义。
  这是变异选错目标,不是判据有洞;换 .pl-empty 后如期变红。

363 个测试全绿。
This commit is contained in:
JianFeeeee
2026-10-02 08:47:08 +08:00
parent a78f7cb6c5
commit 1c690611f8
8 changed files with 837 additions and 10 deletions

View File

@ -615,6 +615,21 @@
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" /></svg
><span data-i="navAdapters">适配器</span>
</button>
<button class="sb-i" data-tab="plugins">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M10 2v4M14 2v4M4 8h16M5 6h14a1 1 0 0 1 1 1v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a1 1 0 0 1 1-1z"
/>
<path d="M10 13h4" /></svg
><span data-i="navPlugins">插件</span>
</button>
</nav>
<div class="sb-foot">
<button id="btn-lang">
@ -767,6 +782,7 @@
<div id="tab-sort" class="tab-pane hidden"></div>
<div id="tab-sources" class="tab-pane hidden"></div>
<div id="tab-adapters" class="tab-pane hidden"></div>
<div id="tab-plugins" class="tab-pane hidden"></div>
</main>
</div>
</div>
@ -782,6 +798,25 @@
navChat: "对话",
navSources: "源",
navAdapters: "适配器",
navPlugins: "插件",
plTitle: "插件",
plState: "状态",
plStages: "阶段",
plActive: "启用中",
plDisabled: "已禁用",
plBroken: "加载失败",
plBuiltin: "内置",
plHooks: "个阶段",
plEmpty: "插件目录为空",
plNoDir: "未配置 plugin_dir,插件功能未启用",
plHookErr: "以下阶段的插件钩子报错(插件故障不会影响转发,但功能会缺失):",
plDir: "插件目录:",
plInstall: "安装插件",
plInstallBtn: "安装 / 覆盖",
plEdit: "编辑",
plEnable: "启用",
plDisable: "禁用",
plRemove: "删除",
navSort: "优先级",
navKeys: "密钥",
keysHint:
@ -1015,6 +1050,25 @@
navChat: "Chat",
navSources: "Sources",
navAdapters: "Adapters",
navPlugins: "Plugins",
plTitle: "Plugins",
plState: "State",
plStages: "Stages",
plActive: "Active",
plDisabled: "Disabled",
plBroken: "Failed to load",
plBuiltin: "Built-in",
plHooks: "stages",
plEmpty: "The plugin directory is empty",
plNoDir: "plugin_dir is not configured; plugins are disabled",
plHookErr: "Plugin hooks failed on these stages (a broken plugin never blocks forwarding, it just stops providing its feature):",
plDir: "Plugin directory:",
plInstall: "Install a plugin",
plInstallBtn: "Install / replace",
plEdit: "Edit",
plEnable: "Enable",
plDisable: "Disable",
plRemove: "Remove",
navSort: "Priority",
navKeys: "Keys",
keysHint:
@ -1326,6 +1380,12 @@
const m = $("#btn-menu");
if (m) m.onclick = () => $("#sidebar").classList.toggle("open");
}
// Single source for the tab list. It used to be a literal duplicated in
// goTab, in refresh() and in the admin-only hide pass — three places to
// keep in sync, and adding a tab meant finding all three. A plugin page
// that is routed but never shown is exactly the kind of silent gap that
// survives review.
const TABS = ["status", "chat", "keys", "sort", "sources", "adapters", "plugins"];
document.querySelectorAll("nav button.sb-i").forEach((b) => {
b.onclick = () => goTab(b.dataset.tab);
});
@ -1337,9 +1397,7 @@
document
.querySelectorAll(".sb-i")
.forEach((x) => x.classList.toggle("active", x.dataset.tab === name));
["status", "chat", "keys", "sort", "sources", "adapters"].forEach(
(tn) => $("#tab-" + tn).classList.toggle("hidden", tn !== name),
);
TABS.forEach((tn) => $("#tab-" + tn).classList.toggle("hidden", tn !== name));
updateBreadcrumb(name);
const pane = $("#tab-" + name);
if (pane) {
@ -4103,6 +4161,177 @@
}
/* ---------- adapters tab ---------- */
// ---- plugin management ----
// Install / enable / disable / remove / edit. The list comes from
// on_disk rather than the loaded set so a plugin that FAILED to load
// still appears, with its error — otherwise a syntax error looks
// identical to "the plugin is not there".
async function renderPlugins() {
let j;
try {
j = await api("/api/plugins");
} catch (e) {
$("#tab-plugins").innerHTML =
`<div class="card"><h2>${t("plTitle")}</h2><div class="empty">${esc(t("plNoDir"))}</div></div>`;
return;
}
if (!j.plugin_dir) {
$("#tab-plugins").innerHTML = `<div class="card"><h2>${t("plTitle")}</h2>
<div class="empty">${t("plNoDir")}</div>
<p class="muted">plugin_dir: /etc/llmsproxy/plugins</p></div>`;
return;
}
const errs = j.hook_errors || {};
const rows = (j.on_disk || [])
.map((p) => {
const state = !p.loaded
? `<span class="tag tag-red">${t("plBroken")}</span>`
: p.disabled
? `<span class="tag">${t("plDisabled")}</span>`
: `<span class="tag tag-green">${t("plActive")}</span>`;
const hooks = (p.hooks || 0)
? `<span class="muted">${p.hooks} ${t("plHooks")}</span>`
: `<span class="muted">—</span>`;
const builtin = p.builtin
? ` <span class="tag tag-blue">${t("plBuiltin")}</span>`
: "";
const toggle = p.loaded
? `<button class="small" onclick="togglePlugin('${escAttr(p.name)}',${p.disabled ? "true" : "false"})">${
p.disabled ? t("plEnable") : t("plDisable")
}</button>`
: "";
const err = p.error
? `<div class="muted" style="color:var(--danger)">${esc(String(p.error).slice(0, 200))}</div>`
: "";
return `<tr>
<td><b>${esc(p.name)}</b>${builtin}<div class="muted">${esc(p.description || p.version || "")}</div>${err}</td>
<td>${state}</td>
<td>${hooks}</td>
<td style="white-space:nowrap">
<button class="small" onclick="editPlugin('${escAttr(p.name)}')">${t("plEdit")}</button>
${toggle}
<button class="danger small" onclick="delPlugin('${escAttr(p.name)}')">${t("plRemove")}</button>
</td></tr>`;
})
.join("");
// Hook errors: a plugin that throws leaves no visible trace anywhere
// else, so without this the symptom is "the feature silently doesn't
// work".
const errList = Object.entries(errs)
.map(
([stage, e]) =>
`<li><code>${esc(stage)}</code> × ${e.count} — ${esc(e.last_error || "")}</li>`,
)
.join("");
$("#tab-plugins").innerHTML = `
<div class="card"><h2>${t("plTitle")} (${(j.on_disk || []).length})</h2>
<div class="tbl-wrap"><table><tr>
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th></th>
</tr>${rows || `<tr><td colspan="4" class="empty">${t("plEmpty")}</td></tr>`}</table></div>
${errList ? `<p class="muted" style="color:var(--danger)">${t("plHookErr")}</p><ul style="font-size:12px">${errList}</ul>` : ""}
<p class="muted">${t("plDir")} <code>${esc(j.plugin_dir)}</code></p>
</div>
<div class="card"><h2>${t("plInstall")}</h2>
<div class="dropzone" id="pl-dz">${t("dropHint")}</div>
<input type="file" id="pl-file" accept=".lua,text/x-lua" class="hidden">
<label>${t("adName")}</label><input id="pl-name" placeholder="myplugin">
<label>${t("tbLua")}</label>
<textarea id="pl-code" spellcheck="false" placeholder="return { name='myplugin', version='1.0.0', hooks={ request_end='on_end' }, function p.on_end(x) return nil end, ui={ page={ page_id='mine', title='Mine', mount='<div>hi</div>' } } }"></textarea>
<p><button onclick="installPlugin()">${t("plInstallBtn")}</button></p>
</div>`;
bindDropzone("pl-dz", "pl-file", "#pl-name", "#pl-code");
}
async function togglePlugin(name, disabled) {
try {
await api("/api/plugins/" + encodeURIComponent(name), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: disabled }),
});
toast(disabled ? t("plDisabled") : t("plActive"));
renderPlugins();
} catch (e) {
toast(tFmt("toastSaveFail", e.message));
}
}
async function delPlugin(name) {
if (!confirm(tFmt("confirmDelSrc", name))) return;
try {
await api("/api/plugins/" + encodeURIComponent(name), { method: "DELETE" });
toast(t("toastDelOk"));
renderPlugins();
} catch (e) {
toast(tFmt("toastSaveFail", e.message));
}
}
async function installPlugin() {
const name = $("#pl-name").value.trim();
const code = $("#pl-code").value;
if (!name) return toast(t("toastEmpty"));
try {
await api("/api/plugins", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, code }),
});
toast(t("toastSaved"));
const w = $("#pl-dz") && $("#pl-dz").closest("#modal-wrap");
if (w) w.remove();
else closeTopModal();
renderPlugins();
} catch (e) {
toast(tFmt("toastSaveFail", e.message));
}
}
function editPlugin(name) {
openCodeModal(name, async (n, code) => {
try {
await api("/api/plugins", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: n, code }),
});
toast(t("toastSaved"));
renderPlugins();
} catch (e) {
toast(tFmt("toastSaveFail", e.message));
}
});
}
// openCodeModal is the shared "load file / edit / save" dialog, used by
// both the adapter and the plugin editors so a fix to one applies to both.
async function openCodeModal(name, onSave) {
let code = "";
try {
const j = await api("/api/plugins/" + encodeURIComponent(name));
code = j.code || "";
} catch (e) {
toast(tFmt("toastSaveFail", e.message));
return;
}
const wrap = document.createElement("div");
wrap.id = "modal-wrap";
wrap.innerHTML = `<div class="card"><h2>${esc(name)}</h2>
<textarea id="pl-edit-code" spellcheck="false" style="min-height:420px;width:100%">${esc(code)}</textarea>
<p><button id="pl-edit-save">${t("mSave")}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
</div>`;
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50";
document.body.appendChild(wrap);
$("#pl-edit-save").onclick = () => {
onSave(name, $("#pl-edit-code").value);
wrap.remove();
};
}
async function renderAdapters() {
const j = await api("/api/status");
const pools = {};
@ -4131,7 +4360,7 @@
<textarea id="adp-code" spellcheck="false" placeholder="return { name='mysrc', endpoint='/chat/completions', transform_request=function(raw) return raw end, transform_response=function(raw) return raw end }"></textarea>
<p><button onclick="uploadAdapter()">${t("uploadBtn")}</button></p>
</div>`;
bindDropzone();
bindDropzone("#dz", "#adp-file", "#adp-name", "#adp-code");
}
// poolCell renders one adapter's elastic Lua state pool: how many states
@ -4153,11 +4382,25 @@
return `<span class="tag ${busy ? "tag-green" : "tag-amber"}" title="${escAttr(title)}">${p.created} / ${p.max}</span>
<span class="muted"> ${p.in_use}● ${p.idle}○ +${p.grow_step}/-${p.shrink_step}</span>`;
}
function bindDropzone() {
const dz = $("#dz"),
file = $("#adp-file"),
name = $("#adp-name"),
code = $("#adp-code");
// bindDropzone wires a drop target + file input + name/code fields so a
// dropped .lua fills the form. It is PARAMETERISED because there are two
// upload forms (adapters and plugins) and the original hard-coded the
// adapter's element ids — a second copy would have been the same function
// with four different strings in it.
// All four arguments are REQUIRED. An earlier version defaulted them to the
// adapter's ids, which meant a caller that forgot one silently wrote the
// plugin's dropped file into the ADAPTER form — a cross-wired form that
// looks like it worked. There is no safe default here.
function bindDropzone(dzId, fileId, nameSel, codeSel) {
if (!dzId || !fileId || !nameSel || !codeSel) {
toast("bindDropzone: dz/file/name/code ids are all required");
return;
}
const dz = $(dzId);
const file = $(fileId);
const name = $(nameSel);
const code = $(codeSel);
if (!dz || !file || !name || !code) return;
["dragenter", "dragover"].forEach((ev) =>
dz.addEventListener(ev, (e) => {
e.preventDefault();
@ -4816,6 +5059,7 @@
if (tab === "sort") return renderSort();
if (tab === "sources") return renderSources();
if (tab === "adapters") return renderAdapters();
if (tab === "plugins") return renderPlugins();
// A page contributed by a plugin has no renderer here: its <script>
// already ran at injection time and owns its own DOM. We only fire the
// "shown" callbacks so it can refresh when the user lands on it.
@ -4965,7 +5209,7 @@
window._me = me.key;
maybeWarnSeed(me.key);
if (me.key.role !== "admin") {
["sort", "sources", "adapters"].forEach((tn) => {
["sort", "sources", "adapters", "plugins"].forEach((tn) => {
const b = document.querySelector(
`nav button.sb-i[data-tab="${tn}"]`,
);