plugin disable system: kernel→SDK PluginManager + WebUI/CLI

- New disabled_plugins table (name, disabled_at, disabled_by)
- SDK.PluginManager interface: DisablePlugin/EnablePlugin/ListDisabledPlugins
- Registry implements PluginManager, wired into PluginSDK
- WebUI: POST /api/v1/plugins/<name>/disable|enable + plugins page with toggle
- Disabling webui shows confirmation dialog
- CLI: /plugin disable <name> / /plugin enable <name>
- pluginmgr removePlugin sync-cleanup from disabled_plugins table
This commit is contained in:
root
2026-07-29 15:07:32 +08:00
parent a899d777c3
commit 796a48dae5
8 changed files with 339 additions and 39 deletions

View File

@ -221,7 +221,7 @@ func (p *Plugin) handleBuiltin(conn net.Conn, line string, s *sdk.PluginSDK) boo
case "/settings":
p.cmdSettings(conn, parts)
case "/plugin":
p.cmdPlugin(conn, parts)
p.cmdPlugin(conn, parts, s)
case "/memory":
p.cmdMemory(conn, parts, s)
case "/knowledge":
@ -244,9 +244,11 @@ func (p *Plugin) cmdHelp(conn net.Conn) {
/settings 列出所有配置
/settings set <key> <val> 修改配置项
/settings core.llm 按前缀筛选
/plugin list 列出已安装插件
/plugin list 列出所有插件
/plugin install <url> 安装插件(需回环网络)
/plugin remove <name> 卸载插件
/plugin disable <name> 禁用插件
/plugin enable <name> 启用插件
/plugin info <name> 查看插件详情
/memory query <关键词> 查询图记忆
/knowledge 列出知识库
@ -347,26 +349,49 @@ func (p *Plugin) cmdSettings(conn net.Conn, parts []string) {
// ======== /plugin ========
func (p *Plugin) cmdPlugin(conn net.Conn, parts []string) {
func (p *Plugin) cmdPlugin(conn net.Conn, parts []string, s *sdk.PluginSDK) {
if len(parts) < 2 {
writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /plugin list|install <url>|remove <name>|info <name>"})
writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /plugin list|install <url>|remove <name>|info <name>|disable <name>|enable <name>"})
return
}
pmgr := s.PluginMgr()
switch parts[1] {
case "list":
if pluginReg == nil {
writeLine(conn, map[string]interface{}{"type": "error", "error": "plugin registry not available"})
if pmgr == nil {
writeLine(conn, map[string]interface{}{"type": "error", "error": "plugin manager not available"})
return
}
names := pluginReg.List()
if len(names) == 0 {
writeLine(conn, map[string]interface{}{"type": "response", "content": "无已加载插件"})
names := pmgr.ListLoadedPlugins()
disabled := pmgr.ListDisabledPlugins()
disabledNames := make(map[string]bool)
for _, d := range disabled {
disabledNames[d.Name] = true
}
all := make(map[string]string)
for _, n := range names {
all[n] = "\033[32m已加载\033[0m"
}
for _, d := range disabled {
if _, ok := all[d.Name]; !ok {
all[d.Name] = "\033[31m已禁用\033[0m"
} else {
all[d.Name] = "\033[32m已加载\033[0m (禁用将在重启后生效)"
}
}
if len(all) == 0 {
writeLine(conn, map[string]interface{}{"type": "response", "content": "无插件"})
return
}
var lines []string
for name, status := range all {
lines = append(lines, fmt.Sprintf(" %s %s", name, status))
}
sort.Strings(lines)
writeLine(conn, map[string]interface{}{
"type": "response",
"content": fmt.Sprintf("已加载插件 (%d):\n %s", len(names), strings.Join(names, "\n ")),
"content": fmt.Sprintf("插件 (%d):\n%s", len(all), strings.Join(lines, "\n")),
})
case "install":
@ -394,6 +419,10 @@ func (p *Plugin) cmdPlugin(conn net.Conn, parts []string) {
writeLine(conn, map[string]interface{}{"type": "error", "error": err.Error()})
return
}
// 同步清理禁用表
if pmgr != nil {
pmgr.EnablePlugin(name)
}
writeLine(conn, map[string]interface{}{"type": "response", "content": fmt.Sprintf("插件 %s 已删除,执行 /plugin reload 生效", name)})
case "info":
@ -407,24 +436,58 @@ func (p *Plugin) cmdPlugin(conn net.Conn, parts []string) {
}
plg := pluginReg.Get(parts[2])
if plg == nil {
writeLine(conn, map[string]interface{}{"type": "response", "content": fmt.Sprintf("插件 %q 未加载", parts[2])})
if pmgr != nil && pmgr.IsPluginDisabled(parts[2]) {
writeLine(conn, map[string]interface{}{"type": "response", "content": fmt.Sprintf("插件 %q 已禁用", parts[2])})
return
}
writeLine(conn, map[string]interface{}{"type": "response", "content": fmt.Sprintf("插件 %q 未安装", parts[2])})
return
}
writeLine(conn, map[string]interface{}{"type": "response", "content": fmt.Sprintf("名称: %s\n状态: 已加载", plg.Name())})
case "reload":
if pluginReg == nil {
writeLine(conn, map[string]interface{}{"type": "error", "error": "plugin registry not available"})
case "disable":
if len(parts) < 3 {
writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /plugin disable <name>"})
return
}
if _, err := pluginReg.Reload(pluginDir); err != nil {
if pmgr == nil {
writeLine(conn, map[string]interface{}{"type": "error", "error": "plugin manager not available"})
return
}
if err := pmgr.DisablePlugin(parts[2], "cli"); err != nil {
writeLine(conn, map[string]interface{}{"type": "error", "error": err.Error()})
return
}
writeLine(conn, map[string]interface{}{"type": "response", "content": fmt.Sprintf("插件 %s 已禁用", parts[2])})
case "enable":
if len(parts) < 3 {
writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /plugin enable <name>"})
return
}
if pmgr == nil {
writeLine(conn, map[string]interface{}{"type": "error", "error": "plugin manager not available"})
return
}
if err := pmgr.EnablePlugin(parts[2]); err != nil {
writeLine(conn, map[string]interface{}{"type": "error", "error": err.Error()})
return
}
writeLine(conn, map[string]interface{}{"type": "response", "content": fmt.Sprintf("插件 %s 已启用", parts[2])})
case "reload":
if pmgr == nil {
writeLine(conn, map[string]interface{}{"type": "error", "error": "plugin manager not available"})
return
}
if _, err := pmgr.ReloadPlugins(); err != nil {
writeLine(conn, map[string]interface{}{"type": "error", "error": err.Error()})
return
}
writeLine(conn, map[string]interface{}{"type": "response", "content": "插件已重载"})
default:
writeLine(conn, map[string]interface{}{"type": "response", "content": "未知: /plugin " + parts[1] + "。支持: list, install, remove, info, reload"})
writeLine(conn, map[string]interface{}{"type": "response", "content": "未知: /plugin " + parts[1] + "。支持: list, install, remove, info, disable, enable, reload"})
}
}

View File

@ -455,6 +455,11 @@ func (p *Plugin) removePlugin(name string) (interface{}, error) {
return map[string]interface{}{"error": err.Error()}, nil
}
// 同步清理禁用表
if Reg != nil {
Reg.EnablePlugin(name)
}
return map[string]interface{}{
"status": "removed",
"name": name,

View File

@ -107,6 +107,7 @@ tr:hover td { background:var(--bg-hover) }
.badge-red { background:#7f1d1d; color:#fca5a5 }
.badge-yellow { background:#713f12; color:#fde68a }
.badge-blue { background:#1e3a5f; color:#93c5fd }
.badge-gray { background:#374151; color:#9ca3af }
.btn { padding:6px 14px; border-radius:6px; border:none; font-size:12px; cursor:pointer; font-weight:500; transition:all .15s }
.btn-primary { background:var(--accent); color:#fff }
.btn-primary:hover { filter:brightness(1.1) }
@ -312,6 +313,7 @@ let state = {
settings: {},
meta: {},
pluginMeta: {},
disabledPlugins: [],
settingsPlugins: ['core'],
selectedSection: 'core',
messages: [],
@ -439,6 +441,7 @@ async function renderAll() {
state.meta = s.meta || {};
state.settingsPlugins = s.plugins || ['core'];
state.pluginMeta = s.plugin_meta || {};
state.disabledPlugins = s.disabled_plugins || [];
} catch(e) {}
try { state.installedPlugins = await api('/plugins') } catch(e) {}
try { await loadTerminals() } catch(e) {}
@ -1223,17 +1226,38 @@ function renderPlugins() {
+ '<button class="btn btn-primary" onclick="installPlugin()">' + __('安装','Install') + '</button></div>'
+ '<div><input type="file" id="plugin-file" accept=".hmap" style="display:inline;width:auto" onchange="installPluginFile(this.files[0])">'
+ '<label for="plugin-file" class="btn btn-ghost" style="cursor:pointer">' + __('选择 .hmap 文件上传','Upload .hmap file') + '</label></div></div>';
var disabledNames = {};
(state.disabledPlugins || []).forEach(function(d) { disabledNames[d.name] = d });
var installedNames = (state.installedPlugins || []).map(function(p) { return p.name });
html += '<div class="card"><h2>' + __('已加载插件','Loaded Plugins') + ' (' + plugins.length + ')</h2>';
if (plugins.length === 0) {
html += '<div class="empty-state"><p>' + __('暂无已加载插件','No loaded plugins') + '</p></div>';
var allPluginNames = {};
plugins.forEach(function(p) { allPluginNames[p.name] = true });
state.disabledPlugins.forEach(function(d) { allPluginNames[d.name] = true });
html += '<div class="card"><h2>' + __('所有插件','All Plugins') + ' (' + Object.keys(allPluginNames).length + ')</h2>';
var names = Object.keys(allPluginNames).sort();
if (names.length === 0) {
html += '<div class="empty-state"><p>' + __('暂无插件','No plugins') + '</p></div>';
} else {
html += '<table><tr><th>' + __('名称','Name') + '</th><th>' + __('状态','Status') + '</th><th>' + __('操作','Actions') + '</th></tr>';
plugins.forEach(function(p) {
var isExternal = installedNames.indexOf(p.name) >= 0;
html += '<tr><td>' + escHtml(p.name) + '</td>'
+ '<td><span class="badge badge-green">' + __('已加载','Loaded') + '</span></td>'
+ '<td>' + (isExternal ? '<button class="btn btn-sm btn-danger" onclick="removePlugin(\'' + escHtml(p.name) + '\')">' + __('卸载','Unload') + '</button>' : '<span class="badge badge-blue">' + __('内置','Built-in') + '</span>') + '</td></tr>';
names.forEach(function(name) {
var isExternal = installedNames.indexOf(name) >= 0;
var isDisabled = disabledNames[name];
var loaded = plugins.some(function(p) { return p.name === name });
var statusHtml = loaded && !isDisabled ? '<span class="badge badge-green">' + __('已加载','Loaded') + '</span>'
: loaded && isDisabled ? '<span class="badge badge-yellow">' + __('运行中(禁用待生效)','Running (disable pending)') + '</span>'
: isDisabled ? '<span class="badge badge-red">' + __('已禁用','Disabled') + '</span>'
: '<span class="badge badge-gray">' + __('未加载','Not Loaded') + '</span>';
var actionsHtml = '';
if (loaded) {
actionsHtml += '<button class="btn btn-sm btn-warning" onclick="disablePlugin(\'' + escHtml(name) + '\')" style="margin-right:4px">' + __('禁用','Disable') + '</button>';
} else {
actionsHtml += '<button class="btn btn-sm btn-success" onclick="enablePlugin(\'' + escHtml(name) + '\')" style="margin-right:4px">' + __('启用','Enable') + '</button>';
}
if (isExternal) {
actionsHtml += '<button class="btn btn-sm btn-danger" onclick="removePlugin(\'' + escHtml(name) + '\')">' + __('卸载','Unload') + '</button>';
}
html += '<tr><td>' + escHtml(name) + '</td>'
+ '<td>' + statusHtml + '</td>'
+ '<td>' + actionsHtml + '</td></tr>';
});
html += '</table>';
}
@ -1319,6 +1343,32 @@ async function removePlugin(name) {
} catch(e) { toast(__('卸载失败: ','Unload failed: ') + e.message, true) }
}
async function disablePlugin(name) {
if (name === 'webui') {
var r = confirm(__('禁用 WebUI 后将无法通过 URL:端口访问此管理面板,若要重新启用需要通过 CLI 命令 /plugin enable webui 恢复。\n\n确定要禁用吗','Disabling WebUI will make this management panel inaccessible via URL:port. To re-enable, use CLI command /plugin enable webui.\n\nAre you sure?'));
if (!r) return;
}
try {
var r = await api('/plugins/' + encodeURIComponent(name) + '/disable', { method: 'POST' });
toast(__('已禁用: ','Disabled: ') + name);
state.kernel = await api('/kernel');
var s = await api('/settings');
state.disabledPlugins = s.disabled_plugins || [];
renderPlugins();
} catch(e) { toast(__('禁用失败: ','Disable failed: ') + e.message, true) }
}
async function enablePlugin(name) {
try {
var r = await api('/plugins/' + encodeURIComponent(name) + '/enable', { method: 'POST' });
toast(__('已启用: ','Enabled: ') + name);
state.kernel = await api('/kernel');
var s = await api('/settings');
state.disabledPlugins = s.disabled_plugins || [];
renderPlugins();
} catch(e) { toast(__('启用失败: ','Enable failed: ') + e.message, true) }
}
async function reloadPlugins() {
try {
var r = await api('/plugins/reload', { method: 'POST' });

View File

@ -27,6 +27,7 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/skill"
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
@ -61,6 +62,7 @@ type Handler struct {
tracker *tracker.Tracker
cfgReg *internalConfig.ConfigRegistry
pluginReg *plugin.Registry
pluginMgr sdk.PluginManager
eventBus *events.Bus
statusProvider agentCore.StatusProvider
providerMgr *agentAPI.ProviderManager
@ -102,6 +104,8 @@ type termState struct {
created time.Time
}
func (h *Handler) SetPluginMgr(mgr sdk.PluginManager) { h.pluginMgr = mgr }
const maxChatHistory = 200
const maxCmdHistory = 100
const maxTerminals = 50
@ -1106,12 +1110,17 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
plugins = append(plugins, "plugin."+p)
}
}
var disabledPlugins []sdk.DisabledPluginInfo
if h.pluginMgr != nil {
disabledPlugins = h.pluginMgr.ListDisabledPlugins()
}
sort.Strings(plugins)
writeJSON(w, http.StatusOK, map[string]interface{}{
"settings": values,
"meta": meta,
"plugins": plugins,
"plugin_meta": pm,
"settings": values,
"meta": meta,
"plugins": plugins,
"plugin_meta": pm,
"disabled_plugins": disabledPlugins,
})
case http.MethodPut:
var body struct {
@ -1417,6 +1426,15 @@ func (h *Handler) handlePluginByID(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/plugins/")
path = strings.TrimSuffix(path, "/")
if path == "disabled" && r.Method == http.MethodGet {
if h.pluginMgr == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin manager not available"})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"disabled": h.pluginMgr.ListDisabledPlugins()})
return
}
if path == "reload" && r.Method == http.MethodPost {
if h.pluginReg == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin registry not available"})
@ -1436,6 +1454,38 @@ func (h *Handler) handlePluginByID(w http.ResponseWriter, r *http.Request) {
return
}
if idx := strings.LastIndex(path, "/"); idx > 0 {
name := path[:idx]
action := path[idx+1:]
if r.Method == http.MethodPost {
switch action {
case "disable":
if h.pluginMgr == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin manager not available"})
return
}
if err := h.pluginMgr.DisablePlugin(name, "webui"); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "disabled"})
return
case "enable":
if h.pluginMgr == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin manager not available"})
return
}
if err := h.pluginMgr.EnablePlugin(name); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "enabled"})
return
}
}
}
switch r.Method {
case http.MethodGet:
h.proxyToPluginmgr(w, r, "/plugins/"+path)

View File

@ -195,6 +195,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
})
h := NewHandler(p.sup, p.mem, p.sk, p.lua, p.cfg, p.iom, p.tm, p.ks, p.tr, p.cr, p.pr, p.evBus, p.statusProvider, p.providerMgr, p.baseAPIKey)
h.SetPluginMgr(s.PluginMgr())
p.handler = h
h.RegisterRoutes(p.mux)