feat(gui): 插件面板补齐安装/编辑/删除,on_disk 报告页面

桌面版插件面板此前只有启用/禁用,装不了新插件、读不回源码、删不掉坏的
插件——而 WebUI 有完整能力。两边不一致,且缺失的那三个恰好是出问题时唯一
能用的三个操作。

补齐(复用后端已有契约,未新增端点):
- POST /api/plugins 安装并保存源码。保存走安装路径是有意的:它会校验
  名称、编译并热重载,而 PUT /{name} 只切 enabled 开关,指过去会静默
  什么都没存。
- GET /api/plugins/{name} 读源码(读的是磁盘上的源,与写回可往返)。
- DELETE /api/plugins/{name} 删除。内置插件不显示删除按钮。
- 内置插件的"编辑"改为"查看源码"并置只读:它的文件在二进制里,给一个
  保存后被丢弃的文本框比直接说明更糟。
- 三个弹窗复用既有的 overlay / overlay-card,不新造一套弹窗体系。

后端补 DiskEntry.Pages:GUI 读的是 on_disk 而非 List,pages 只在 List
里,所以面板里那行"页面:…"永远不显示。新增 pageNames() 复刻加载期的
合并规则(page + pages,去重保序)。判据覆盖 pages 与单页 ui.page 两种
写法,并断言 JSON 里带 pages——GUI 读的是 HTTP body,不是 Go 结构体。
变异去掉赋值后两条判据都失败。

CDP 实测 10 项全部通过:安装带页面的插件、源码往返一致、禁用/启用、
热重载后版本号变化、坏插件在列表可见并带错误信息、非法名拦截、语法错误
被后端拒绝而非静默写入、路径穿越被 IPC 代理拦截。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
This commit is contained in:
JianFeeeee
2026-10-03 08:48:46 +08:00
parent 13e2397e9c
commit ed9baae8e4
5 changed files with 328 additions and 1 deletions

View File

@ -226,6 +226,7 @@ async function loadPlugins() {
return; return;
} }
const rows = j.on_disk || []; const rows = j.on_disk || [];
window.__plRows = rows; // read by editPlugin/deletePlugin for the builtin flag
const active = rows.filter((p) => p.loaded && !p.disabled).length; const active = rows.filter((p) => p.loaded && !p.disabled).length;
const broken = rows.filter((p) => !p.loaded).length; const broken = rows.filter((p) => !p.loaded).length;
hint.textContent = hint.textContent =
@ -250,11 +251,30 @@ async function loadPlugins() {
const builtin = p.builtin const builtin = p.builtin
? '<span class="pl-builtin">内置</span>' ? '<span class="pl-builtin">内置</span>'
: ""; : "";
// Edit / view source / delete are the operations an operator
// actually needs after enable/disable: a plugin that cannot be
// read back cannot be fixed, and one that cannot be removed cannot
// be rolled back.
const acts = [
btn,
`<button class="ghost" data-act="code" data-name="${escAttr(p.name)}">${
p.builtin ? "查看源码" : "编辑"
}</button>`,
builtin
? ""
: `<button class="ghost danger" data-act="del" data-name="${escAttr(
p.name,
)}">删除</button>`,
]
.filter(Boolean)
.join("");
const pages = Array.isArray(p.pages) ? p.pages : [];
return `<div class="pl-item ${cls}"> return `<div class="pl-item ${cls}">
<div class="pl-head"><b>${esc(p.name)}</b>${builtin}<span class="pl-state">${label}</span></div> <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.description ? `<div class="pl-desc">${esc(p.description)}</div>` : ""}
${pages.length ? `<div class="pl-desc">页面:${esc(pages.join(", "))}</div>` : ""}
${p.error ? `<div class="pl-err">${esc(String(p.error).slice(0, 160))}</div>` : ""} ${p.error ? `<div class="pl-err">${esc(String(p.error).slice(0, 160))}</div>` : ""}
<div class="pl-acts">${btn}</div> <div class="pl-acts">${acts}</div>
</div>`; </div>`;
}) })
.join("") .join("")
@ -262,6 +282,160 @@ async function loadPlugins() {
list.querySelectorAll('button[data-act="toggle"]').forEach((b) => { list.querySelectorAll('button[data-act="toggle"]').forEach((b) => {
b.onclick = () => togglePlugin(b.dataset.name, b.dataset.en === "1"); b.onclick = () => togglePlugin(b.dataset.name, b.dataset.en === "1");
}); });
list.querySelectorAll('button[data-act="code"]').forEach((b) => {
b.onclick = () => editPlugin(b.dataset.name);
});
list.querySelectorAll('button[data-act="del"]').forEach((b) => {
b.onclick = () => deletePlugin(b.dataset.name);
});
}
// editPlugin shows the plugin source in a modal. Built-in plugins are
// read-only: their file ships inside the binary, so a save would either be
// ignored or silently do nothing — showing an editable text area that
// discards on save is worse than saying so up front.
function editPlugin(name) {
// The plugin list is the only place `builtin` is reported; keep the last
// fetch so the modal does not need a second round trip (and cannot guess).
const row = (window.__plRows || []).find((p) => p.name === name);
const isBuiltin = !!(row && row.builtin);
const wrap = document.createElement("div");
wrap.className = "overlay";
wrap.innerHTML = `<div class="overlay-card pl-wide">
<div class="overlay-head"><h2>${esc(name)}${isBuiltin ? "(内置,只读)" : ""}</h2></div>
${isBuiltin
? `<p class="hint">内置插件随内核发布,源码在二进制内,不可编辑。</p>`
: `<textarea id="pl-code" spellcheck="false"></textarea>
<p class="hint" id="pl-code-err"></p>`}
<div class="row actions">
${isBuiltin ? "" : `<button id="pl-code-save">保存并热重载</button>`}
<button class="ghost" id="pl-code-close">关闭</button>
</div>
</div>`;
document.body.appendChild(wrap);
wrap.addEventListener("click", (e) => {
if (e.target === wrap) wrap.remove();
});
$("#pl-code-close").onclick = () => wrap.remove();
const save = $("#pl-code-save");
if (save) {
save.onclick = async () => {
save.disabled = true;
try {
// Saving writes through POST /api/plugins — the same install path the
// installer uses, which validates the name, compiles the source and
// hot-reloads. PUT /{name} only toggles enable/disable, so pointing the
// save there would silently disable nothing and store nothing.
await window.modelrouter.plugins.request("POST", "/api/plugins", {
name,
code: $("#pl-code").value,
});
toast(`${name} 已保存`);
wrap.remove();
await loadPlugins();
} catch (e) {
$("#pl-code-err").textContent = e.message;
save.disabled = false;
}
};
}
if (isBuiltin) return;
(async () => {
const ta = $("#pl-code");
try {
const j = await window.modelrouter.plugins.request(
"GET",
`/api/plugins/${encodeURIComponent(name)}`,
);
ta.value = j.code || "";
const err = $("#pl-code-err");
if (err && j.parse_error) err.textContent = String(j.parse_error);
} catch (e) {
$("#pl-code-err").textContent = e.message;
}
})();
}
function deletePlugin(name) {
const wrap = document.createElement("div");
wrap.className = "overlay";
wrap.innerHTML = `<div class="overlay-card">
<div class="overlay-head"><h2>删除插件</h2></div>
<p>确定删除 <b>${esc(name)}</b>?插件目录中的文件会被移除,核心将不再加载它。</p>
<div class="row actions">
<button class="danger" id="pl-del-ok">删除</button>
<button class="ghost" id="pl-del-no">取消</button>
</div>
</div>`;
document.body.appendChild(wrap);
wrap.addEventListener("click", (e) => {
if (e.target === wrap) wrap.remove();
});
$("#pl-del-no").onclick = () => wrap.remove();
$("#pl-del-ok").onclick = async () => {
try {
await window.modelrouter.plugins.request(
"DELETE",
`/api/plugins/${encodeURIComponent(name)}`,
);
toast(`已删除 ${name}`);
wrap.remove();
await loadPlugins();
} catch (e) {
toast(e.message, true);
}
};
}
// installPlugin creates a new plugin from source. The name becomes the file
// name, so it is validated client-side to save a round trip on the obvious
// mistakes (empty, path separators, non-portable characters).
function installPlugin() {
const wrap = document.createElement("div");
wrap.className = "overlay";
wrap.innerHTML = `<div class="overlay-card pl-wide">
<div class="overlay-head"><h2>安装插件</h2></div>
<div class="row"><label>名称</label><input id="pl-new-name" placeholder="my_plugin" /></div>
<p class="hint" id="pl-new-err"></p>
<textarea id="pl-new-code" spellcheck="false" placeholder="-- 单文件 Lua 插件&#10;plugin = {}&#10;..."></textarea>
<div class="row actions">
<button id="pl-new-ok">安装</button>
<button class="ghost" id="pl-new-no">取消</button>
</div>
</div>`;
document.body.appendChild(wrap);
wrap.addEventListener("click", (e) => {
if (e.target === wrap) wrap.remove();
});
$("#pl-new-no").onclick = () => wrap.remove();
$("#pl-new-ok").onclick = async () => {
const name = $("#pl-new-name").value.trim();
const code = $("#pl-new-code").value;
const err = $("#pl-new-err");
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
err.textContent = "名称只能包含字母、数字、下划线和连字符";
return;
}
if (!code.trim()) {
err.textContent = "源码不能为空";
return;
}
$("#pl-new-ok").disabled = true;
try {
await window.modelrouter.plugins.request("POST", "/api/plugins", {
// The backend appends .lua itself (installPlugin writes name+".lua"),
// so passing it here would create name.lua.lua.
name,
code,
});
toast(`已安装 ${name}`);
wrap.remove();
await loadPlugins();
} catch (e) {
err.textContent = e.message;
$("#pl-new-ok").disabled = false;
}
};
} }
async function togglePlugin(name, disabled) { async function togglePlugin(name, disabled) {
@ -318,6 +492,8 @@ function init() {
if (plReload) plReload.onclick = loadPlugins; if (plReload) plReload.onclick = loadPlugins;
const plAll = document.getElementById("pl-toggle-all"); const plAll = document.getElementById("pl-toggle-all");
if (plAll) plAll.onclick = enableAllPlugins; if (plAll) plAll.onclick = enableAllPlugins;
const plInstall = document.getElementById("pl-install");
if (plInstall) plInstall.onclick = installPlugin;
$("#rail-autostart").onclick = toggleAutoStart; $("#rail-autostart").onclick = toggleAutoStart;
$("#rail-silent").onclick = toggleSilent; $("#rail-silent").onclick = toggleSilent;
$("#rail-theme").onclick = () => { $("#rail-theme").onclick = () => {

View File

@ -214,6 +214,7 @@
<div id="pl-list" class="pl-list"></div> <div id="pl-list" class="pl-list"></div>
<div class="row actions"> <div class="row actions">
<button class="ghost" id="pl-toggle-all">全部启用</button> <button class="ghost" id="pl-toggle-all">全部启用</button>
<button class="ghost" id="pl-install">安装插件</button>
<button class="ghost" id="pl-reload">刷新</button> <button class="ghost" id="pl-reload">刷新</button>
</div> </div>
<div class="row actions"> <div class="row actions">

View File

@ -674,3 +674,51 @@ html[data-theme="dark"] .overlay {
color: var(--muted); color: var(--muted);
padding: 10px 0; padding: 10px 0;
} }
/* ---- plugin editor / installer --------------------------------------------
The plugin source editor and the install dialog reuse the settings overlay
(overlay / overlay-card) rather than a second popup system. They need three
things the settings card does not: a wider column for code, a monospace
textarea, and a destructive button for delete. */
.overlay-card.pl-wide {
width: 760px;
}
.pl-wide textarea {
width: 100%;
min-height: 380px;
resize: vertical;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--line-strong);
background: var(--bg-sunken, rgba(127, 127, 127, 0.06));
color: inherit;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.55;
tab-size: 2;
}
.pl-wide textarea:focus {
outline: none;
border-color: var(--primary);
}
.pl-wide .row input {
flex: 1;
padding: 7px 10px;
border-radius: 9px;
border: 1px solid var(--line-strong);
background: var(--bg-sunken, rgba(127, 127, 127, 0.06));
color: inherit;
}
.pl-editor .hint,
.pl-wide .hint {
min-height: 16px;
color: var(--muted, #888);
}
button.danger {
color: #e5484d;
border-color: rgba(229, 72, 77, 0.4);
}
button.danger:hover {
border-color: #e5484d;
color: #e5484d;
}

View File

@ -779,6 +779,34 @@ type DiskEntry struct {
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Hooks int `json:"hooks"` Hooks int `json:"hooks"`
// Pages lists the UI page ids the plugin contributes. The desktop panel
// shows it so an operator can tell at a glance that a plugin actually
// renders something, without opening its source.
Pages []string `json:"pages,omitempty"`
}
// pageNames returns the plugin's contributed UI page ids, de-duplicated.
// A plugin may declare `page` or `pages` or both, and the same id can appear
// twice; the loader's merge keeps the first, so this mirrors that order.
func pageNames(p *Plugin) []string {
if p == nil || p.UI == nil {
return nil
}
declared := make([]*UIPage, 0, len(p.UI.Pages)+1)
if p.UI.Page != nil {
declared = append(declared, p.UI.Page)
}
declared = append(declared, p.UI.Pages...)
var out []string
seen := map[string]bool{}
for _, pg := range declared {
if pg == nil || seen[pg.PageID] {
continue
}
seen[pg.PageID] = true
out = append(out, pg.PageID)
}
return out
} }
// OnDisk lists every .lua file in the plugin directory with its load state. // OnDisk lists every .lua file in the plugin directory with its load state.
@ -810,6 +838,10 @@ func (ps *Plugins) OnDisk() []DiskEntry {
de.Version = p.Info.Version de.Version = p.Info.Version
de.Description = p.Info.Description de.Description = p.Info.Description
de.Hooks = len(p.Hooks) de.Hooks = len(p.Hooks)
// A plugin may declare a single page or several; both forms end
// up in Pages (see the merge at load time), so reporting that one
// field covers either.
de.Pages = pageNames(p)
} else { } else {
// On disk but not in the running set: either it failed so badly // On disk but not in the running set: either it failed so badly
// that LoadSource never produced a record, or the dir was written // that LoadSource never produced a record, or the dir was written

View File

@ -375,3 +375,73 @@ return p
t.Error("a plugin registered for chain_step is not reported as such") t.Error("a plugin registered for chain_step is not reported as such")
} }
} }
// TestOnDiskReportsPages pins that the disk listing carries the plugin's UI
// page ids. The desktop plugin panel reads on_disk (not List) and shows the
// pages so an operator can see at a glance that a plugin renders something;
// without this field the GUI reads p.pages === undefined and the line simply
// never appears, which is the kind of omission nobody reports as a bug.
func TestOnDiskReportsPages(t *testing.T) {
_, ps, _ := newPluginVM(t)
code := `
local p = { name = "paged", version = "1.0.0" }
p.ui = { pages = {} }
table.insert(p.ui.pages, { page_id = "alpha", title = "Alpha", mount = "<b>a</b>" })
table.insert(p.ui.pages, { page_id = "beta", title = "Beta", mount = "<b>b</b>" })
return p
`
if err := loadPlugin(t, ps, "paged", code); err != nil {
t.Fatalf("load: %v", err)
}
var found *DiskEntry
for _, de := range ps.OnDisk() {
if de.Name == "paged" {
d := de
found = &d
}
}
if found == nil {
t.Fatal("paged plugin missing from OnDisk")
}
if len(found.Pages) != 2 {
t.Fatalf("Pages = %v, want [alpha beta]", found.Pages)
}
if found.Pages[0] != "alpha" || found.Pages[1] != "beta" {
t.Fatalf("Pages = %v, want [alpha beta] in declaration order", found.Pages)
}
// The field must survive JSON too — the desktop panel reads the HTTP body,
// not the Go struct.
b, err := json.Marshal(found)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"pages":["alpha","beta"]`) {
t.Fatalf("json omits pages: %s", b)
}
}
// The single-page form (`ui.page`) must also surface, since a plugin using it
// is just as rendered as one using `ui.pages`.
func TestOnDiskReportsSinglePage(t *testing.T) {
_, ps, _ := newPluginVM(t)
code := `
local p = { name = "single", version = "1.0.0" }
p.ui = { page = { page_id = "solo", title = "Solo", mount = "<b>x</b>" } }
return p
`
if err := loadPlugin(t, ps, "single", code); err != nil {
t.Fatalf("load: %v", err)
}
for _, de := range ps.OnDisk() {
if de.Name == "single" {
if len(de.Pages) != 1 || de.Pages[0] != "solo" {
t.Fatalf("Pages = %v, want [solo]", de.Pages)
}
return
}
}
t.Fatal("single plugin missing from OnDisk")
}