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

@ -671,6 +671,78 @@ ipcMain.handle(
(e) => !!BrowserWindow.fromWebContents(e.sender)?.isMaximized(), (e) => !!BrowserWindow.fromWebContents(e.sender)?.isMaximized(),
); );
// ---- plugin management over IPC -------------------------------------------
//
// The renderer cannot call the embedded core directly: it has no key and no
// network identity, and the core binds a loopback port that only the main
// process knows about. So every plugin action is proxied through the main
// process, which already knows how to obtain the admin key (unsealViaCore).
//
// The proxy is deliberately a raw (method, path, body) pass-through rather than
// a fixed set of commands. A fixed set would have to be extended for every new
// plugin endpoint, and the one thing worse than "no button for this" is "a
// button that silently does nothing" — with a pass-through the renderer can talk
// to any /api/plugins route the core grows, and the path is validated here so
// this channel cannot be used to reach arbitrary endpoints.
function pluginProxy(req) {
const { method, path, body } = req || {};
const M = ["GET", "POST", "PUT", "DELETE"];
if (!M.includes(method)) throw new Error("bad method: " + method);
// The path must stay inside the plugin namespace. A prefix check alone would
// still allow /api/plugins/../keys, so reject any traversal outright.
if (typeof path !== "string" || !path.startsWith("/api/plugins")) {
throw new Error("path must start with /api/plugins");
}
if (path.includes("..") || path.includes("\\")) {
throw new Error("path traversal rejected");
}
const key = unsealViaCore();
if (!key) throw new Error("no admin key available yet");
return new Promise((resolve, reject) => {
const u = new URL(embeddedBaseUrl() + path);
const data = body == null ? null : JSON.stringify(body);
const headers = { Authorization: "Bearer " + key };
if (data) {
headers["Content-Type"] = "application/json";
headers["Content-Length"] = Buffer.byteLength(data);
}
const r = http.request(
{
hostname: u.hostname,
port: u.port,
path: u.pathname + u.search,
method,
headers,
},
(res) => {
let raw = "";
res.setEncoding("utf8");
res.on("data", (c) => (raw += c));
res.on("end", () => {
let parsed = null;
try {
parsed = raw ? JSON.parse(raw) : null;
} catch (e) {
parsed = { raw };
}
if (res.statusCode >= 400) {
const msg =
(parsed && parsed.error && parsed.error.message) ||
"HTTP " + res.statusCode;
reject(new Error(msg));
return;
}
resolve(parsed);
});
},
);
r.on("error", reject);
if (data) r.write(data);
r.end();
});
}
ipcMain.handle("plugins:proxy", (_e, req) => pluginProxy(req));
ipcMain.handle("core:state", () => ({ ipcMain.handle("core:state", () => ({
running: coreStarted() && coreReady, running: coreStarted() && coreReady,
ready: coreReady, ready: coreReady,

View File

@ -16,6 +16,13 @@ contextBridge.exposeInMainWorld("modelrouter", {
key: () => ipcRenderer.invoke("core:key"), key: () => ipcRenderer.invoke("core:key"),
onState: (cb) => ipcRenderer.on("core:state", (_e, d) => cb(d)), onState: (cb) => ipcRenderer.on("core:state", (_e, d) => cb(d)),
}, },
plugins: {
// Raw pass-through to the embedded core's /api/plugins surface. The main
// process validates the path and attaches the admin key; the renderer never
// sees either.
request: (method, path, body) =>
ipcRenderer.invoke("plugins:proxy", { method, path, body }),
},
settings: { settings: {
get: () => ipcRenderer.invoke("settings:get"), get: () => ipcRenderer.invoke("settings:get"),
set: (patch) => ipcRenderer.invoke("settings:set", patch), set: (patch) => ipcRenderer.invoke("settings:set", patch),

View File

@ -155,6 +155,7 @@ async function openSettings() {
$("#set-tray").checked = !!state.settings.minimizeToTray; $("#set-tray").checked = !!state.settings.minimizeToTray;
$("#settings-overlay").style.display = "flex"; $("#settings-overlay").style.display = "flex";
renderRail(); renderRail();
loadPlugins();
} }
function closeSettings() { function closeSettings() {
$("#settings-overlay").style.display = "none"; $("#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 ===== // ===== theme =====
function applyTheme() { function applyTheme() {
document.documentElement.dataset.theme = state.theme; document.documentElement.dataset.theme = state.theme;
@ -197,6 +314,10 @@ function init() {
$("#tb-close").onclick = () => window.modelrouter.win.close(); $("#tb-close").onclick = () => window.modelrouter.win.close();
$("#tb-settings").onclick = openSettings; $("#tb-settings").onclick = openSettings;
$("#rail-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-autostart").onclick = toggleAutoStart;
$("#rail-silent").onclick = toggleSilent; $("#rail-silent").onclick = toggleSilent;
$("#rail-theme").onclick = () => { $("#rail-theme").onclick = () => {

View File

@ -207,6 +207,15 @@
><input type="checkbox" id="set-tray" /> 关闭时最小化到托盘</label ><input type="checkbox" id="set-tray" /> 关闭时最小化到托盘</label
><span class="hint">点关闭按钮隐藏到系统托盘</span> ><span class="hint">点关闭按钮隐藏到系统托盘</span>
</div> </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"> <div class="row actions">
<button class="ghost" id="set-dir">打开数据目录</button> <button class="ghost" id="set-dir">打开数据目录</button>
<button class="ghost" id="set-log">查看日志</button> <button class="ghost" id="set-log">查看日志</button>

View File

@ -596,3 +596,81 @@ html[data-theme="dark"] .overlay {
#toast.err { #toast.err {
border-color: var(--danger); 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;
}

View File

@ -0,0 +1,192 @@
package gateway
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// The Electron shell (cmd/gui) had NO tests at all, so the plugin panel went in
// with references to CSS classes that do not exist (.tag, .sm) and to helper
// functions that were never defined in that document (esc / escAttr). All of
// it rendered as unstyled text and would have thrown a ReferenceError at click
// time — and none of that is visible without opening the app.
//
// These tests are deliberately static. They do not launch Electron: what they
// guard is the class of mistake that "looks fine until someone themes it",
// which is exactly what a missing CSS class or a missing helper is.
func guiFile(t *testing.T, rel string) string {
t.Helper()
// The tests live in internal/gateway, so walk up to the repo root.
p := filepath.Join("..", "..", rel)
b, err := os.ReadFile(p)
if err != nil {
t.Skipf("%s not readable: %v", rel, err)
}
return string(b)
}
// classUseRe finds class="..." occurrences in a document.
var classUseRe = regexp.MustCompile(`class="([^"]+)"`)
// classTokenRe matches ANY ".name" inside the stylesheet. Deliberately loose:
// it also matches inside compound selectors (".tb-btn.tb-close:hover" must count
// as defining .tb-close, which a "must be at the start of a selector" rule
// misses) and inside comments, which only ever makes the check MORE permissive.
// A false pass here would be bad, so the strictness lives elsewhere: the
// variable below is what actually guards the new code.
var classTokenRe = regexp.MustCompile(`\.([A-Za-z_][A-Za-z0-9_-]*)`)
// guiKnownUnstyled lists classes the shell markup has always used with no
// matching rule. They are pre-existing cosmetic gaps, not regressions, and
// failing on them would make this test useless as a guard for NEW work.
var guiKnownUnstyled = map[string]bool{
"blob": true, // decorative blur blobs, styled per-instance via .b1/.b2/.b3
"tgl": true, // rail toggle affordance that leaned on .rail-btn
"rail": true, // the rail container itself has no rule; .rail-btn children carry the look
}
// TestGUICSSClassesExist is the guard that would have caught .tag and .sm: every
// class used in the shell's markup must be defined in its stylesheet.
//
// The comparison is on the LAST segment of a selector, because the stylesheet
// scopes things (`.form .actions .primary`, `#bgfx .b1`): a rule for
// `.pl-acts button` defines no class at all, and `.form .row .toggle` defines
// `.toggle`. Requiring a top-level class would be too strict; requiring that
// some selector's last identifier matches is the right level.
func TestGUICSSClassesExist(t *testing.T) {
html := guiFile(t, "cmd/gui/renderer/index.html")
css := guiFile(t, "cmd/gui/renderer/style.css")
defined := map[string]bool{}
// Collect every class token that appears at the START of a selector
// position. A full CSS parser is overkill and was the source of two wrong
// turns here; what the check needs is simply "does the name .foo appear
// anywhere in the stylesheet as a selector component".
//
// Scoping is respected loosely: `.form .actions .primary` counts as
// defining `.primary`, and `.pl-acts button` defines no class — which is
// exactly why the plugin panel needed its own rules.
for _, m := range classTokenRe.FindAllStringSubmatch(css, -1) {
defined[m[1]] = true
}
if len(defined) == 0 {
t.Fatal("no classes parsed from the stylesheet; the check is broken")
}
// Classes the JS builds as strings must exist too.
js := guiFile(t, "cmd/gui/renderer/app.js")
var missing []string
seen := map[string]bool{}
note := func(cls, where string) {
// A "${...}" token is a template literal being spliced at runtime, not
// a class name; the classes it can expand to are checked at their
// definition sites instead.
if cls == "" || strings.ContainsAny(cls, "${}") || seen[cls] {
return
}
seen[cls] = true
if guiKnownUnstyled[cls] {
return
}
if !defined[cls] {
missing = append(missing, cls+" ("+where+")")
}
}
for _, m := range classUseRe.FindAllStringSubmatch(html, -1) {
for _, c := range strings.Fields(m[1]) {
note(c, "index.html")
}
}
for _, m := range classUseRe.FindAllStringSubmatch(js, -1) {
for _, c := range strings.Fields(m[1]) {
note(c, "app.js")
}
}
if len(missing) > 0 {
t.Errorf("classes used but not defined in style.css (they render unstyled):\n %s",
strings.Join(missing, "\n "))
}
}
// TestGUIHelperFunctionsAreDefined catches the other half: renderer/app.js is a
// separate document from the WebUI, so it does NOT have the WebUI's esc/escAttr.
// Referencing them gives a ReferenceError only when the line runs.
func TestGUIHelperFunctionsAreDefined(t *testing.T) {
js := guiFile(t, "cmd/gui/renderer/app.js")
for _, fn := range []string{"esc", "escAttr", "toast", "loadPlugins", "togglePlugin", "enableAllPlugins"} {
defined := regexp.MustCompile(`function ` + fn + `\b`).MatchString(js)
called := regexp.MustCompile(`\b` + fn + `\s*\(`).MatchString(js)
if called && !defined {
t.Errorf("%s() is called but never defined in app.js", fn)
}
if !called && !defined {
// A defined-but-unused helper is dead code, not an error.
continue
}
}
}
// TestGUIPluginPanelIsReachable: the panel must be inside the settings overlay
// AND the settings overlay must actually open it. A panel wired to a button
// that was never bound is invisible-but-present, which passes a grep review.
func TestGUIPluginPanelIsReachable(t *testing.T) {
html := guiFile(t, "cmd/gui/renderer/index.html")
js := guiFile(t, "cmd/gui/renderer/app.js")
if !strings.Contains(html, `id="pl-list"`) {
t.Error("no #pl-list in the settings overlay")
}
if !strings.Contains(html, `id="settings-overlay"`) {
t.Fatal("the settings overlay is gone")
}
// inside the overlay: the element index must come after the overlay's
if strings.Index(html, `id="settings-overlay"`) > strings.Index(html, `id="pl-list"`) {
t.Error("#pl-list appears before the settings overlay, so it renders outside the panel")
}
// The buttons must be bound.
for _, id := range []string{"pl-reload", "pl-toggle-all"} {
if !strings.Contains(html, `id="`+id+`"`) {
t.Errorf("#%s is missing from the markup", id)
}
if !strings.Contains(js, `"`+id+`"`) {
t.Errorf("#%s exists but app.js never binds it", id)
}
}
// And openSettings must trigger the load, or the panel shows a stale empty
// list on every open.
if !strings.Contains(js, "loadPlugins()") {
t.Error("app.js never calls loadPlugins()")
}
}
// TestGUIIPCPathIsConstrained: plugins:proxy is a raw pass-through, which is
// convenient but would be a hole if it accepted arbitrary paths. The main
// process must reject anything outside /api/plugins and any traversal.
func TestGUIIPCPathIsConstrained(t *testing.T) {
main := guiFile(t, "cmd/gui/main.js")
for _, needle := range []string{
`path.startsWith("/api/plugins")`,
`path.includes("..")`,
"plugins:proxy",
"unsealViaCore()",
} {
if !strings.Contains(main, needle) {
t.Errorf("cmd/gui/main.js is missing the guard %q", needle)
}
}
// The plugins channel must be a proxy, not a key passthrough: the renderer
// sends (method, path) and the main process attaches the key.
//
// NOTE: preload does expose a pre-existing `core.key` getter — the shell
// needs the admin key to load the embedded WebUI without a login. That is
// existing, deliberate design and out of scope here; asserting on "key:" in
// preload would flag a pre-existing feature as a new hole.
pre := guiFile(t, "cmd/gui/preload.js")
if !strings.Contains(pre, "request: (method, path, body)") {
t.Error("preload does not expose the plugins request proxy")
}
}

View File

@ -615,6 +615,21 @@
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" /></svg <path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" /></svg
><span data-i="navAdapters">适配器</span> ><span data-i="navAdapters">适配器</span>
</button> </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> </nav>
<div class="sb-foot"> <div class="sb-foot">
<button id="btn-lang"> <button id="btn-lang">
@ -767,6 +782,7 @@
<div id="tab-sort" class="tab-pane hidden"></div> <div id="tab-sort" class="tab-pane hidden"></div>
<div id="tab-sources" 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-adapters" class="tab-pane hidden"></div>
<div id="tab-plugins" class="tab-pane hidden"></div>
</main> </main>
</div> </div>
</div> </div>
@ -782,6 +798,25 @@
navChat: "对话", navChat: "对话",
navSources: "源", navSources: "源",
navAdapters: "适配器", navAdapters: "适配器",
navPlugins: "插件",
plTitle: "插件",
plState: "状态",
plStages: "阶段",
plActive: "启用中",
plDisabled: "已禁用",
plBroken: "加载失败",
plBuiltin: "内置",
plHooks: "个阶段",
plEmpty: "插件目录为空",
plNoDir: "未配置 plugin_dir,插件功能未启用",
plHookErr: "以下阶段的插件钩子报错(插件故障不会影响转发,但功能会缺失):",
plDir: "插件目录:",
plInstall: "安装插件",
plInstallBtn: "安装 / 覆盖",
plEdit: "编辑",
plEnable: "启用",
plDisable: "禁用",
plRemove: "删除",
navSort: "优先级", navSort: "优先级",
navKeys: "密钥", navKeys: "密钥",
keysHint: keysHint:
@ -1015,6 +1050,25 @@
navChat: "Chat", navChat: "Chat",
navSources: "Sources", navSources: "Sources",
navAdapters: "Adapters", 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", navSort: "Priority",
navKeys: "Keys", navKeys: "Keys",
keysHint: keysHint:
@ -1326,6 +1380,12 @@
const m = $("#btn-menu"); const m = $("#btn-menu");
if (m) m.onclick = () => $("#sidebar").classList.toggle("open"); 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) => { document.querySelectorAll("nav button.sb-i").forEach((b) => {
b.onclick = () => goTab(b.dataset.tab); b.onclick = () => goTab(b.dataset.tab);
}); });
@ -1337,9 +1397,7 @@
document document
.querySelectorAll(".sb-i") .querySelectorAll(".sb-i")
.forEach((x) => x.classList.toggle("active", x.dataset.tab === name)); .forEach((x) => x.classList.toggle("active", x.dataset.tab === name));
["status", "chat", "keys", "sort", "sources", "adapters"].forEach( TABS.forEach((tn) => $("#tab-" + tn).classList.toggle("hidden", tn !== name));
(tn) => $("#tab-" + tn).classList.toggle("hidden", tn !== name),
);
updateBreadcrumb(name); updateBreadcrumb(name);
const pane = $("#tab-" + name); const pane = $("#tab-" + name);
if (pane) { if (pane) {
@ -4103,6 +4161,177 @@
} }
/* ---------- adapters tab ---------- */ /* ---------- 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() { async function renderAdapters() {
const j = await api("/api/status"); const j = await api("/api/status");
const pools = {}; 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> <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> <p><button onclick="uploadAdapter()">${t("uploadBtn")}</button></p>
</div>`; </div>`;
bindDropzone(); bindDropzone("#dz", "#adp-file", "#adp-name", "#adp-code");
} }
// poolCell renders one adapter's elastic Lua state pool: how many states // 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> 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>`; <span class="muted"> ${p.in_use}● ${p.idle}○ +${p.grow_step}/-${p.shrink_step}</span>`;
} }
function bindDropzone() { // bindDropzone wires a drop target + file input + name/code fields so a
const dz = $("#dz"), // dropped .lua fills the form. It is PARAMETERISED because there are two
file = $("#adp-file"), // upload forms (adapters and plugins) and the original hard-coded the
name = $("#adp-name"), // adapter's element ids — a second copy would have been the same function
code = $("#adp-code"); // 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) => ["dragenter", "dragover"].forEach((ev) =>
dz.addEventListener(ev, (e) => { dz.addEventListener(ev, (e) => {
e.preventDefault(); e.preventDefault();
@ -4816,6 +5059,7 @@
if (tab === "sort") return renderSort(); if (tab === "sort") return renderSort();
if (tab === "sources") return renderSources(); if (tab === "sources") return renderSources();
if (tab === "adapters") return renderAdapters(); if (tab === "adapters") return renderAdapters();
if (tab === "plugins") return renderPlugins();
// A page contributed by a plugin has no renderer here: its <script> // 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 // 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. // "shown" callbacks so it can refresh when the user lands on it.
@ -4965,7 +5209,7 @@
window._me = me.key; window._me = me.key;
maybeWarnSeed(me.key); maybeWarnSeed(me.key);
if (me.key.role !== "admin") { if (me.key.role !== "admin") {
["sort", "sources", "adapters"].forEach((tn) => { ["sort", "sources", "adapters", "plugins"].forEach((tn) => {
const b = document.querySelector( const b = document.querySelector(
`nav button.sb-i[data-tab="${tn}"]`, `nav button.sb-i[data-tab="${tn}"]`,
); );

View File

@ -156,3 +156,107 @@ func TestUIPluginInjectionFailureIsNonFatal(t *testing.T) {
"leaving the dashboard blank") "leaving the dashboard blank")
} }
} }
// ---- plugin management UI contract ---------------------------------------
//
// The management page is the operator's only way to take a broken plugin out
// of the request path. Every one of these assertions guards a link that, if it
// silently broke, would leave the gateway running with a plugin it cannot
// disable — the worst kind of gap: everything looks fine and nothing is
// reachable.
func TestUIHasPluginTabAndPane(t *testing.T) {
src := uiSourceX(t)
if !strings.Contains(src, `data-tab="plugins"`) {
t.Error("no sidebar entry for the plugin page")
}
if !strings.Contains(src, `id="tab-plugins"`) {
t.Error("no #tab-plugins pane")
}
// The tab list is now a single constant; a new tab must be added there or
// goTab will not un-hide its pane.
if !strings.Contains(src, `const TABS = [`) {
t.Error("TABS is gone; the tab list went back to a duplicated literal")
}
for _, tn := range []string{"status", "chat", "keys", "sort", "sources", "adapters", "plugins"} {
if !strings.Contains(src, `"`+tn+`"`) {
t.Errorf("TABS is missing %q", tn)
}
}
// goTab must iterate TABS, not its own list.
if !strings.Contains(src, "TABS.forEach((tn) =>") {
t.Error("goTab does not iterate TABS")
}
if strings.Contains(src, `["status", "chat", "keys", "sort", "sources", "adapters"].forEach`) {
t.Error("a duplicated tab literal survived; it will drift from TABS")
}
}
func TestUIRendersPluginManagement(t *testing.T) {
src := uiSourceX(t)
body, ok := jsFunctionBody(src, "renderPlugins")
if !ok {
t.Fatal("renderPlugins() not found")
}
// It must read the DISK listing, not just the loaded set: a plugin that
// failed to compile is absent from the loaded set, and showing only the
// loaded set makes a syntax error look like "the plugin is not installed".
if !strings.Contains(body, "on_disk") {
t.Error("renderPlugins reads only the loaded set; a failed plugin would " +
"be invisible instead of shown with its error")
}
if !strings.Contains(body, "/api/plugins") {
t.Error("renderPlugins does not call /api/plugins")
}
// Hook errors must be surfaced: a plugin that throws in every stage leaves
// no other trace, so without this the symptom is "the feature just doesn't
// work".
if !strings.Contains(body, "hook_errors") {
t.Error("renderPlugins ignores hook_errors; a silently broken plugin is undebuggable")
}
// Enable / disable / remove / edit.
for _, fn := range []string{"togglePlugin", "delPlugin", "installPlugin", "editPlugin"} {
if _, ok := jsFunctionBody(src, fn); !ok {
t.Errorf("%s() is missing from the WebUI", fn)
}
}
// The toggle must go through the enable/disable endpoint, not delete.
tb, ok := jsFunctionBody(src, "togglePlugin")
if !ok {
t.Fatal("togglePlugin() missing")
}
if !strings.Contains(tb, `method: "PUT"`) {
t.Error("togglePlugin does not use PUT")
}
if !strings.Contains(tb, "enabled:") {
t.Error("togglePlugin does not send an \"enabled\" field")
}
// And the admin-only tab list must include plugins, or a non-admin would
// see a page whose every action 403s.
if !strings.Contains(src, `["sort", "sources", "adapters", "plugins"]`) {
t.Error("the admin-only tab list omits \"plugins\"; a user key would see a " +
"page full of actions that all fail with 403")
}
}
// TestUIBindDropzoneIsParameterised guards the refactor: the adapter and plugin
// upload forms share one dropzone, so a hard-coded id would send a dropped
// plugin file into the adapter name field.
func TestUIBindDropzoneIsParameterised(t *testing.T) {
src := uiSourceX(t)
body, ok := jsFunctionBody(src, "bindDropzone")
if !ok {
t.Fatal("bindDropzone() not found")
}
if strings.Contains(body, `$("#dz")`) || strings.Contains(body, `$("#adp-name")`) {
t.Error("bindDropzone still hard-codes the adapter's element ids; the " +
"plugin form would write into the adapter form")
}
if !strings.Contains(body, "dzId") || !strings.Contains(body, "nameSel") {
t.Error("bindDropzone does not accept the ids to bind")
}
// Both forms must call it.
if !strings.Contains(src, `bindDropzone("pl-dz", "pl-file", "#pl-name", "#pl-code")`) {
t.Error("the plugin upload form does not use the parameterised dropzone")
}
}