diff --git a/cmd/gui/renderer/style.css b/cmd/gui/renderer/style.css
index b3661aa..05dd59c 100644
--- a/cmd/gui/renderer/style.css
+++ b/cmd/gui/renderer/style.css
@@ -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;
+}
diff --git a/internal/gateway/gui_contract_test.go b/internal/gateway/gui_contract_test.go
new file mode 100644
index 0000000..8fd754a
--- /dev/null
+++ b/internal/gateway/gui_contract_test.go
@@ -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")
+ }
+}
diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html
index b8033ed..f320636 100644
--- a/internal/gateway/ui/index.html
+++ b/internal/gateway/ui/index.html
@@ -615,6 +615,21 @@
适配器
+
@@ -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 =
+ `
`;
+ 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 @@
`;
- 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 `${p.created} / ${p.max} ${p.in_use}● ${p.idle}○ +${p.grow_step}/-${p.shrink_step}`;
}
- 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