mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
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:
@ -779,6 +779,34 @@ type DiskEntry struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
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.
|
||||
@ -810,6 +838,10 @@ func (ps *Plugins) OnDisk() []DiskEntry {
|
||||
de.Version = p.Info.Version
|
||||
de.Description = p.Info.Description
|
||||
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 {
|
||||
// On disk but not in the running set: either it failed so badly
|
||||
// that LoadSource never produced a record, or the dir was written
|
||||
|
||||
@ -375,3 +375,73 @@ return p
|
||||
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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user