fix(webui,plugin): 内核控制按钮晚绑定 + 按钮布局;plugin_start 报错给候选名

1. 内核控制通道晚绑定(真机 503 实测)
   POST /api/v1/kernel/restart 一直返回 503:webui 由 plugin-host 在装配期
   拉起,而 main() 里 SetKernelControl 还没执行 ⇒ 插件 Start 拿到 nil。
   依赖图保证不了 main 里的语句顺序,所以改成让插件拿**解析器**
   (lazyKernelControl),调用那一刻再取真实通道;授权仍在每次调用时
   按 kernelControlAllowed/kernelRestartAllowed 复核,不因晚绑定放宽。

2. WebUI 重启/关机按钮布局
   原先用 .theme-btn(顶栏图标样式:pill + margin-right + 无文字)混在
   侧栏底部的文字按钮里 ⇒ 大小不一、挤在一起、关机看不出是危险操作。
   改为与「退出登录」同族的 btn-sm,带图标+文字,关机用 danger 悬停。

3. plugin_start 失败报错
   原来只说加载出错,或插件不存在,一个可用名字都不给,模型只能盲猜
   (实测把 files 写成 file)。现在给:失败原因分类(目录不存在/没清单/
   自身加载失败)、最接近的名字(Levenshtein ≤2)、已安装清单(最多 12)。

4. scripts/migrate-plugins-to-org.sh:插件迁到 GitHub 组织独立仓的脚本收口

测试:go build ./... 通过;go test ./internal/plugin ./internal/plugins/webui 全绿。
This commit is contained in:
JianFeeeee
2026-10-27 01:43:19 +08:00
parent 55fa2685ae
commit c33199cc5a
5 changed files with 386 additions and 113 deletions

View File

@ -0,0 +1,73 @@
package plugin
// 内核控制通道**晚绑定**的判据。
//
// 现场(2026-10-26 真机):WebUI 重启按钮点击返回 503。
// 日志:POST /api/v1/kernel/restart auth=session status=503
//
// 根因不是授权,而是**时序**:webui 由 plugin-host 在装配期拉起,
// 而 main() 里 SetKernelControl 那一行还没执行 ⇒ 插件 Start 时拿到 nil。
// 靠"调整顺序"修不了(依赖图不保证 main 里的语句顺序),
// 只能让插件拿**解析器**,调用那一刻再取真实通道。
import (
"testing"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
type fakeCtl struct {
restarts int
shutdowns int
}
func (f *fakeCtl) RequestKernelRestart(reason string) error {
f.restarts++
return nil
}
func (f *fakeCtl) RequestKernelShutdown(reason string) error {
f.shutdowns++
return nil
}
// ① 先给通道、后注入 ⇒ 调用时仍能成功(这是真机那条修复的核心)。
func TestLazyKernelControlWorksWhenInjectedLater(t *testing.T) {
r := NewRegistry()
ctl := kernelControlFor("webui", r) // 此时 registry 里还没有通道
if ctl == nil {
t.Fatal("按授权表 webui 应拿到控制通道(晚绑定)")
}
// 注入前调用 ⇒ 明确报不可用(不能静默成功)
if err := ctl.RequestKernelRestart("before"); err == nil {
t.Fatal("未注入时应报错,不能假装成功")
}
fake := &fakeCtl{}
r.SetKernelControl(fake)
if err := ctl.RequestKernelRestart("after"); err != nil {
t.Fatalf("注入后应成功,实际: %v", err)
}
if fake.restarts != 1 {
t.Fatalf("应调用真实通道 1 次,实际 %d", fake.restarts)
}
}
// ② 晚绑定**不放宽授权**:未获授权的插件仍然拿不到通道。
func TestLazyKernelControlRespectsAuthorization(t *testing.T) {
r := NewRegistry()
if ctl := kernelControlFor("some-random-plugin", r); ctl != nil {
t.Fatal("未授权插件不应拿到控制通道")
}
// pluginmgr 只有重启权限:关机必须被拒
r.SetKernelControl(&fakeCtl{})
pm := kernelControlFor("pluginmgr", r)
if pm == nil {
t.Fatal("pluginmgr 应有仅重启通道")
}
if err := pm.RequestKernelShutdown("x"); err == nil {
t.Fatal("pluginmgr 不应能关机")
}
}
var _ sdk.KernelControlAPI = lazyKernelControl{}

View File

@ -4,6 +4,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"os"
@ -349,6 +350,56 @@ func (r *Registry) kernelControlFor(name string) sdk.KernelControlAPI {
//
// 拆出来的原因:buildSDK 要在**一次** RLock 里取走整组依赖(见 snapshotDeps),
// 不能再回头单独取锁 —— RWMutex 不可重入,持锁再 RLock 在写者排队时会自锁。
// lazyKernelControl 是**晚绑定**的内核控制通道。
//
// 它不持有具体实现,只持有 registry:每次调用时按授权表重新解析。
// 这样"插件先启动、通道后注入"不会丢能力,而授权仍由
// kernelControlAllowed / kernelRestartAllowed 决定(不因晚绑定放宽)。
type lazyKernelControl struct {
r *Registry
name string
}
func (l lazyKernelControl) resolve() sdk.KernelControlAPI {
return kernelControlForCtl(l.r.currentKernelControl(), l.name)
}
func (l lazyKernelControl) RequestKernelRestart(reason string) error {
if c := l.resolve(); c != nil {
return c.RequestKernelRestart(reason)
}
return errKernelControlUnavailable
}
func (l lazyKernelControl) RequestKernelShutdown(reason string) error {
if c := l.resolve(); c != nil {
return c.RequestKernelShutdown(reason)
}
return errKernelControlUnavailable
}
// errKernelControlUnavailable 表示通道尚未注入(装配期)或本插件未获授权。
var errKernelControlUnavailable = errors.New(
"内核控制通道不可用:未注入(装配未完成)或本插件未获授权")
// currentKernelControl 线程安全地取当前通道。
func (r *Registry) currentKernelControl() sdk.KernelControlAPI {
r.mu.RLock()
defer r.mu.RUnlock()
return r.kernelCtl
}
// kernelControlFor 给插件返回**晚绑定**控制通道。
//
// 返回非 nil 即表示"该插件按授权表**可能**有这个能力"——真正的授权仍在
// 每次调用时按 kernelControlForCtl 复核(所以晚绑定不放宽任何权限)。
func kernelControlFor(name string, r *Registry) sdk.KernelControlAPI {
if !kernelControlAllowed[name] && !kernelRestartAllowed[name] {
return nil
}
return lazyKernelControl{r: r, name: name}
}
func kernelControlForCtl(ctl sdk.KernelControlAPI, name string) sdk.KernelControlAPI {
if ctl == nil {
return nil
@ -710,7 +761,16 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
RegOutputUnreg: regOutputUnreg,
RegInput: regInput,
PluginMgr: r,
KernelControl: kernelControlForCtl(deps.kernelCtl, name),
// ★ 晚绑定(2026-10-26 真机 503 实测)。
//
// 原先把 deps.kernelCtl 的**当时的取值**交给插件,而 webui 由
// plugin-host 在装配期就拉起,main() 里 SetKernelControl 那一行
// 还没执行 ⇒ 插件拿到 nil ⇒ 按钮点了永远 503
// (日志证据:POST /api/v1/kernel/restart status=503)。
//
// 依赖图保证不了"main 里某一行先于插件启动",所以不能靠调整顺序,
// 只能让插件拿到**解析器**:调用那一刻再取真实通道。
KernelControl: kernelControlFor(name, r),
Status: deps.status,
Supervisor: deps.sup,
@ -1761,7 +1821,18 @@ func (r *Registry) StartPlugin(name string) error {
r.runtimeStopped[name] = true
}
r.mu.Unlock()
return fmt.Errorf("启动插件 %s 失败(本身加载出错,或插件不存在)", name)
// ★ 报错要**带上可用名字**:模型最常见的错是拼错(实测把 files 写成 file),
// 而原来的"加载出错,或插件不存在"两者都提、却一个可用名字都不给,
// 模型只能盲猜下一个名字(连续撞 3 次都不奇怪)。
//
// 这里列目录里的真实插件名(最多 12 个)并指出最接近的那个。
avail := r.installedPluginNames()
hint := ""
if near := nearestName(name, avail); near != "" {
hint = fmt.Sprintf(";你要找的是不是 %q?", near)
}
return fmt.Errorf("启动插件 %s 失败(%s)%s 已安装: %v",
name, r.loadFailureReason(name), hint, avail)
}
// refuseSelfLock 拦住会自锁的关闭/禁用操作(设计 §2.6.7.4)。
@ -2045,3 +2116,92 @@ func (r *Registry) readConfig(plgDir string) map[string]interface{} {
}
return cfg
}
// installedPluginNames 返回 plugins 目录下真实存在的插件名(排序,最多 12 个)。
func (r *Registry) installedPluginNames() []string {
dir := r.pluginDirOrEmpty()
if dir == "" {
return nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var out []string
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
out = append(out, e.Name())
}
sort.Strings(out)
if len(out) > 12 {
out = out[:12]
}
return out
}
// loadFailureReason 说明"为什么没加载起来":目录不存在 / 没清单 / 加载报错。
//
// 区分三者是必要的:目录不存在(名字错)与清单损坏(插件坏了)对模型
// 意味着完全不同的下一步动作。
func (r *Registry) loadFailureReason(name string) string {
dir := filepath.Join(r.pluginDirOrEmpty(), name)
if _, err := os.Stat(dir); err != nil {
return "该名字没有对应目录,可能拼错了"
}
hasManifest := false
for _, n := range []string{"plugin.json", "plg.json"} {
if _, err := os.Stat(filepath.Join(dir, n)); err == nil {
hasManifest = true
break
}
}
if !hasManifest {
return "目录存在但没有 plugin.json/plg.json 清单"
}
return "插件自身加载失败,原因见内核日志"
}
// nearestName 在一个候选集合里找与 name 最接近的(差一个字符以内视为接近)。
func nearestName(name string, candidates []string) string {
best, bestD := "", 3
for _, c := range candidates {
if d := editDistance(strings.ToLower(name), strings.ToLower(c)); d < bestD {
best, bestD = c, d
}
}
if bestD <= 2 {
return best
}
return ""
}
// editDistance 是经典 Levenshtein 距离(只用于给"拼错"提建议,规模极小)。
func editDistance(a, b string) int {
ra, rb := []rune(a), []rune(b)
prev := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
cur := make([]int, len(rb)+1)
cur[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
m := prev[j] + 1
if cur[j-1]+1 < m {
m = cur[j-1] + 1
}
if prev[j-1]+cost < m {
m = prev[j-1] + cost
}
cur[j] = m
}
prev = cur
}
return prev[len(rb)]
}