From 1c690611f8422e148e24c8530a9380b7636e719d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 08:47:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(gui):=20WebUI=20=E4=B8=8E=20Electron=20?= =?UTF-8?q?=E5=A3=B3=E7=9A=84=E6=8F=92=E4=BB=B6=E5=AE=89=E8=A3=85/?= =?UTF-8?q?=E5=88=A0=E9=99=A4/=E7=A6=81=E7=94=A8/=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 个测试全绿。 --- cmd/gui/main.js | 72 +++++++ cmd/gui/preload.js | 7 + cmd/gui/renderer/app.js | 121 ++++++++++++ cmd/gui/renderer/index.html | 9 + cmd/gui/renderer/style.css | 78 ++++++++ internal/gateway/gui_contract_test.go | 192 +++++++++++++++++++ internal/gateway/ui/index.html | 264 +++++++++++++++++++++++++- internal/gateway/ui_plugin_test.go | 104 ++++++++++ 8 files changed, 837 insertions(+), 10 deletions(-) create mode 100644 internal/gateway/gui_contract_test.go diff --git a/cmd/gui/main.js b/cmd/gui/main.js index ab33943..abc6ea9 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -671,6 +671,78 @@ ipcMain.handle( (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", () => ({ running: coreStarted() && coreReady, ready: coreReady, diff --git a/cmd/gui/preload.js b/cmd/gui/preload.js index c3cfca8..f9c46e4 100644 --- a/cmd/gui/preload.js +++ b/cmd/gui/preload.js @@ -16,6 +16,13 @@ contextBridge.exposeInMainWorld("modelrouter", { key: () => ipcRenderer.invoke("core:key"), 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: { get: () => ipcRenderer.invoke("settings:get"), set: (patch) => ipcRenderer.invoke("settings:set", patch), diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index 2d8d2ce..553a30b 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], + ); +} +function escAttr(s) { + return esc(s).replace(/`/g, "`"); +} + +// ===== 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 = + '未配置 plugin_dir,插件功能未启用。在 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 + ? `` + : ""; + const builtin = p.builtin + ? '内置' + : ""; + return `
+
${esc(p.name)}${builtin}${label}
+ ${p.description ? `
${esc(p.description)}
` : ""} + ${p.error ? `
${esc(String(p.error).slice(0, 160))}
` : ""} +
${btn}
+
`; + }) + .join("") + : '
插件目录为空
'; + 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 = () => { diff --git a/cmd/gui/renderer/index.html b/cmd/gui/renderer/index.html index 5e16ec6..86634c0 100644 --- a/cmd/gui/renderer/index.html +++ b/cmd/gui/renderer/index.html @@ -207,6 +207,15 @@ > 关闭时最小化到托盘点关闭按钮隐藏到系统托盘 +
+ + 加载中… +
+
+
+ + +
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 = + `

${t("plTitle")}

${esc(t("plNoDir"))}
`; + return; + } + if (!j.plugin_dir) { + $("#tab-plugins").innerHTML = `

${t("plTitle")}

+
${t("plNoDir")}
+

plugin_dir: /etc/llmsproxy/plugins

`; + return; + } + const errs = j.hook_errors || {}; + const rows = (j.on_disk || []) + .map((p) => { + const state = !p.loaded + ? `${t("plBroken")}` + : p.disabled + ? `${t("plDisabled")}` + : `${t("plActive")}`; + const hooks = (p.hooks || 0) + ? `${p.hooks} ${t("plHooks")}` + : `—`; + const builtin = p.builtin + ? ` ${t("plBuiltin")}` + : ""; + const toggle = p.loaded + ? `` + : ""; + const err = p.error + ? `
${esc(String(p.error).slice(0, 200))}
` + : ""; + return ` + ${esc(p.name)}${builtin}
${esc(p.description || p.version || "")}
${err} + ${state} + ${hooks} + + + ${toggle} + + `; + }) + .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]) => + `
  • ${esc(stage)} × ${e.count} — ${esc(e.last_error || "")}
  • `, + ) + .join(""); + + $("#tab-plugins").innerHTML = ` +

    ${t("plTitle")} (${(j.on_disk || []).length})

    +
    + + ${rows || ``}
    ${t("tName")}${t("plState")}${t("plStages")}
    ${t("plEmpty")}
    + ${errList ? `

    ${t("plHookErr")}

    ` : ""} +

    ${t("plDir")} ${esc(j.plugin_dir)}

    +
    +

    ${t("plInstall")}

    +
    ${t("dropHint")}
    + + + + +

    +
    `; + 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 = `

    ${esc(name)}

    + +

    +

    +
    `; + 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