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

@ -155,6 +155,7 @@ async function openSettings() {
$("#set-tray").checked = !!state.settings.minimizeToTray;
$("#settings-overlay").style.display = "flex";
renderRail();
loadPlugins();
}
function closeSettings() {
$("#settings-overlay").style.display = "none";
@ -182,6 +183,122 @@ async function saveSettings() {
}
}
// esc / escAttr escape text for innerHTML. The WebUI has its own copies; the
// shell needs its own because renderer/app.js is a separate document that
// never loads index.html's script.
function esc(s) {
return String(s == null ? "" : s).replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],
);
}
function escAttr(s) {
return esc(s).replace(/`/g, "&#96;");
}
// ===== plugin management =====
//
// The desktop shell manages plugins through the embedded core's /api/plugins
// surface, proxied over IPC (see plugins:proxy in the main process). The
// renderer never holds the admin key.
//
// Scope note: the desktop build has no plugin_dir configured by default, so this
// panel normally reports "plugins disabled" with the one-line fix. That is a
// deliberate state, not an error — the packaged profile is a per-user directory
// and seeding a plugin tree into someone's home without asking would be rude.
async function loadPlugins() {
const list = $("#pl-list");
const hint = $("#set-plugins-hint");
if (!list || !hint) return;
let j;
try {
j = await window.modelrouter.plugins.request("GET", "/api/plugins");
} catch (e) {
hint.textContent = "内核未就绪:" + e.message;
list.innerHTML = "";
return;
}
if (!j.plugin_dir) {
hint.innerHTML =
'未配置 <code>plugin_dir</code>,插件功能未启用。在 config.yaml 加一行后重启内核即可。';
list.innerHTML = "";
return;
}
const rows = j.on_disk || [];
const active = rows.filter((p) => p.loaded && !p.disabled).length;
const broken = rows.filter((p) => !p.loaded).length;
hint.textContent =
`${rows.length} 个插件 · ${active} 个启用中` +
(broken ? ` · ${broken} 个加载失败` : "");
list.innerHTML = rows.length
? rows
.map((p) => {
const cls = !p.loaded ? "pl-broken" : p.disabled ? "pl-off" : "pl-on";
const label = !p.loaded
? "加载失败"
: p.disabled
? "已禁用"
: "启用中";
const btn = p.loaded
? `<button class="ghost" data-act="toggle" data-name="${escAttr(
p.name,
)}" data-en="${p.disabled ? "1" : "0"}">${
p.disabled ? "启用" : "禁用"
}</button>`
: "";
const builtin = p.builtin
? '<span class="pl-builtin">内置</span>'
: "";
return `<div class="pl-item ${cls}">
<div class="pl-head"><b>${esc(p.name)}</b>${builtin}<span class="pl-state">${label}</span></div>
${p.description ? `<div class="pl-desc">${esc(p.description)}</div>` : ""}
${p.error ? `<div class="pl-err">${esc(String(p.error).slice(0, 160))}</div>` : ""}
<div class="pl-acts">${btn}</div>
</div>`;
})
.join("")
: '<div class="pl-empty">插件目录为空</div>';
list.querySelectorAll('button[data-act="toggle"]').forEach((b) => {
b.onclick = () => togglePlugin(b.dataset.name, b.dataset.en === "1");
});
}
async function togglePlugin(name, disabled) {
try {
await window.modelrouter.plugins.request("PUT", `/api/plugins/${encodeURIComponent(name)}`, {
enabled: disabled,
});
toast(disabled ? `已禁用 ${name}` : `已启用 ${name}`);
await loadPlugins();
} catch (e) {
toast(e.message, true);
}
}
async function enableAllPlugins() {
let j;
try {
j = await window.modelrouter.plugins.request("GET", "/api/plugins");
} catch (e) {
return toast(e.message, true);
}
const off = (j.on_disk || []).filter((p) => p.loaded && p.disabled);
for (const p of off) {
try {
await window.modelrouter.plugins.request(
"PUT",
`/api/plugins/${encodeURIComponent(p.name)}`,
{ enabled: true },
);
} catch (e) {
toast(`${p.name}: ${e.message}`, true);
}
}
toast(off.length ? `已启用 ${off.length} 个插件` : "没有处于禁用状态的插件");
await loadPlugins();
}
// ===== theme =====
function applyTheme() {
document.documentElement.dataset.theme = state.theme;
@ -197,6 +314,10 @@ function init() {
$("#tb-close").onclick = () => window.modelrouter.win.close();
$("#tb-settings").onclick = openSettings;
$("#rail-settings").onclick = openSettings;
const plReload = document.getElementById("pl-reload");
if (plReload) plReload.onclick = loadPlugins;
const plAll = document.getElementById("pl-toggle-all");
if (plAll) plAll.onclick = enableAllPlugins;
$("#rail-autostart").onclick = toggleAutoStart;
$("#rail-silent").onclick = toggleSilent;
$("#rail-theme").onclick = () => {

View File

@ -207,6 +207,15 @@
><input type="checkbox" id="set-tray" /> 关闭时最小化到托盘</label
><span class="hint">点关闭按钮隐藏到系统托盘</span>
</div>
<div class="row">
<label>插件</label>
<span class="hint" id="set-plugins-hint">加载中…</span>
</div>
<div id="pl-list" class="pl-list"></div>
<div class="row actions">
<button class="ghost" id="pl-toggle-all">全部启用</button>
<button class="ghost" id="pl-reload">刷新</button>
</div>
<div class="row actions">
<button class="ghost" id="set-dir">打开数据目录</button>
<button class="ghost" id="set-log">查看日志</button>

View File

@ -596,3 +596,81 @@ html[data-theme="dark"] .overlay {
#toast.err {
border-color: var(--danger);
}
/* ===== plugin management panel =========================================
* The existing .ghost/.primary rules are scoped to `.form .actions`, so a
* button outside that selector gets browser defaults. The plugin rows live in
* their own list, hence their own rules — reusing a scoped class here would have
* produced unstyled buttons that still worked, which is the kind of thing that
* looks fine until someone themes the shell.
*/
.pl-list {
display: flex;
flex-direction: column;
gap: 8px;
margin: 8px 0 4px;
}
.pl-item {
border: 1px solid var(--line);
border-radius: 9px;
padding: 10px 12px;
}
.pl-item.pl-broken {
border-color: var(--danger, #d1435b);
}
.pl-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.pl-builtin {
font-size: 10px;
padding: 1px 6px;
border-radius: 999px;
background: var(--primary-50);
color: var(--primary-h);
}
.pl-state {
margin-left: auto;
font-size: 11px;
color: var(--muted);
}
.pl-item.pl-broken .pl-state {
color: var(--danger, #d1435b);
}
.pl-desc {
font-size: 12px;
color: var(--muted);
margin-top: 3px;
}
.pl-err {
font-size: 11px;
color: var(--danger, #d1435b);
margin-top: 4px;
word-break: break-word;
}
.pl-acts {
margin-top: 8px;
display: flex;
gap: 8px;
}
.pl-acts button {
padding: 5px 12px;
font-size: 12px;
border-radius: 7px;
border: 1px solid var(--line);
background: var(--bg-s2, #fff);
color: var(--fg, inherit);
cursor: pointer;
transition: all 0.15s;
}
.pl-acts button:hover {
border-color: var(--primary);
color: var(--primary-h);
}
.pl-empty {
font-size: 12px;
color: var(--muted);
padding: 10px 0;
}