mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 09:17:31 +00:00
feat(webui+guard): 内核重启/关机按钮,指令走心跳复用既有协议
## 需求
WebUI 加关机与重启按钮,内核提供对应内部接口。有些状态变更只能靠重启
生效(换 LLM 源、换主密钥),没有这条通道时用户只能 ssh 上去敲 systemctl。
## 设计:复用心跳,不新开通道(用户指出的方向)
guard 与 worker 之间已经有一条现成的、每 5s 一次的单向通报(心跳文件,
内容原本是 `t=<unix>`,只用来刷 mtime)。用户点按钮本质就是"我要你在
下一次心跳时知道这件事"——再开 unix socket / 信号 / 临时文件,只会多出
一套要防重、防丢、防竞态的机制。
WebUI 点按钮
→ POST /api/v1/kernel/{restart,shutdown}
→ sdk.KernelControlAPI(只投递,立即返回)
→ 下一次心跳把 `cmd=restart|shutdown reason=...` 写进 <data>/heartbeat
→ 重启:worker 清理 → exit 42 → guard 重拉(**既有机制,零改动**)
关机:guard 在 workerCleanExit 分支读到 cmd=shutdown
→ systemctl stop → 不再拉起
★ 关机为什么必须由 guard 做:
unit 是 Restart=always,guard 自己 return 会被 systemd 立刻拉起
(用户点了"关机"、几秒后服务又活了)。必须显式 systemctl stop。
而不让 worker 自己停:它是被守护方,且**只有 guard 知道这次该不该重拉**。
★ 指令必须一次性消费:guard 读完立即原子重写文件清除 cmd 行,
否则每次心跳都读到同一条 ⇒ 无限重启/关机。worker 侧还要检查
"指令写过但文件里没了" ⇒ 已被消费 ⇒ 清本地 pending,否则又写回去。
## 顺带修的真缺陷:心跳文件无限增长
生产实测 **14.6MB**(纯 O_APPEND,约 70 天)。心跳的唯一职责是刷 mtime,
历史行没有任何读者 ⇒ 每次 consume 要整读 14.6MB(实测 27ms)。
改为 O_TRUNC 滚动写:只保留当前一行。真机验证 15,322,164 → 13 字节。
consume 也加了尺寸守卫(大文件只读尾部 8KB)。
## 安全:能力分级 + 白名单(两道)
1. proc 桥的 method 能力表:新增 `CapKernelControl` 一档。
★ 判据 `TestCapability_AllMethodsClassified` 当场报出
"未登记 ⇒ 会按 CapCore 放行,绕过权限检查" —— 那意味着任何插件都能关机。
2. 内置插件走 `SDKConfig.KernelControl`,第一版我写成"有就发",
等于全部内置插件都能关机。改为按名字白名单(目前只有 webui)。
## 判据(7 条)
心跳:解析 / 消费后必须清除 / worker 不得写回已消费指令 / 未消费时必须续写
能力:只有白名单插件拿得到 / 未装配时返回 nil 不 panic / SDK nil 安全
心跳文件:不得无限增长 / 大文件只读尾部且读后收敛
变异 6/6 咬住。全量 53 包 ok。
## UI
导航底栏两个图标按钮(与主题/语言/退出并列):重启有确认框 + 轮询等它
回来并自动刷新;关机有**二次确认且写清后果**(必须人工 systemctl start)。
两者都先把响应写回浏览器并 Flush,再让内核退出 —— 否则用户看到的是
connection reset,无法区分"正在重启"和"真出故障了"。
This commit is contained in:
89
internal/plugin/kernel_control_gate_test.go
Normal file
89
internal/plugin/kernel_control_gate_test.go
Normal file
@ -0,0 +1,89 @@
|
||||
package plugin
|
||||
|
||||
// 内核控制通道的**授权边界**判据(2026-10-17)。
|
||||
//
|
||||
// ## 为什么这条判据必须存在
|
||||
//
|
||||
// 重启/关机通道能让**整个服务停下来**,而且关机后不会自己回来
|
||||
// (guard 显式 systemctl stop 压制了 Restart=always)。
|
||||
//
|
||||
// 我做这个功能时第一版是"有就发":`KernelControl: r.kernelCtl` 无条件
|
||||
// 传给**所有**内置插件。那意味着任何一个内置插件(包括将来新加的)
|
||||
// 都能一键关掉整个服务 —— 攻击面 = 全部内置插件。
|
||||
//
|
||||
// ⇒ 必须按插件名白名单授予,且这条边界要有判据钉住。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// fakeKernelCtl 记录收到的请求(判据只关心"能不能拿到/调用")。
|
||||
type fakeKernelCtl struct {
|
||||
restartCalls int
|
||||
shutdownCalls int
|
||||
}
|
||||
|
||||
func (f *fakeKernelCtl) RequestKernelRestart(reason string) error {
|
||||
f.restartCalls++
|
||||
return nil
|
||||
}
|
||||
func (f *fakeKernelCtl) RequestKernelShutdown(reason string) error {
|
||||
f.shutdownCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
// ★★★ 判据:只有白名单里的插件能拿到内核控制通道。
|
||||
func TestKernelControlOnlyForAllowlistedPlugins(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
fake := &fakeKernelCtl{}
|
||||
r.SetKernelControl(fake)
|
||||
|
||||
if got := r.kernelControlFor("webui"); got == nil {
|
||||
t.Fatal("★ webui 应拿到内核控制通道(它是有鉴权的管理界面)")
|
||||
}
|
||||
// 典型的内置插件都不该拿到
|
||||
for _, name := range []string{"cmd", "files", "memo", "healthcheck", "pluginmgr", "timer"} {
|
||||
if got := r.kernelControlFor(name); got != nil {
|
||||
t.Fatalf("★ 内置插件 %q 拿到了内核控制通道 —— "+
|
||||
"任何插件都能关机整个服务,攻击面失控", name)
|
||||
}
|
||||
}
|
||||
// 未知/外部插件名
|
||||
if got := r.kernelControlFor("some_third_party"); got != nil {
|
||||
t.Fatal("★ 未登记插件拿到了内核控制通道")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:内核未装配控制通道时,白名单插件也只拿到 nil(不 panic)。
|
||||
func TestKernelControlNilWhenNotWired(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
// 故意不 SetKernelControl(模拟非 systemd 环境/未启用)
|
||||
if got := r.kernelControlFor("webui"); got != nil {
|
||||
t.Fatal("★ 通道未装配时不该返回非 nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:SDK 侧 KernelControl() 未注入时返回 nil,且调用方判 nil 不 panic。
|
||||
func TestSDKKernelControlIsNilSafe(t *testing.T) {
|
||||
s := sdk.New("probe", sdk.SDKConfig{})
|
||||
if kc := s.KernelControl(); kc != nil {
|
||||
t.Fatal("未注入时 KernelControl() 应为 nil")
|
||||
}
|
||||
// 注入后能拿到
|
||||
fake := &fakeKernelCtl{}
|
||||
s2 := sdk.New("probe2", sdk.SDKConfig{KernelControl: fake})
|
||||
if s2.KernelControl() == nil {
|
||||
t.Fatal("注入后应能拿到")
|
||||
}
|
||||
if err := s2.KernelControl().RequestKernelRestart("t"); err != nil {
|
||||
t.Fatalf("调用失败: %v", err)
|
||||
}
|
||||
if fake.restartCalls != 1 {
|
||||
t.Fatalf("fake 未收到请求(%d)", fake.restartCalls)
|
||||
}
|
||||
}
|
||||
|
||||
var _ = context.Background
|
||||
@ -63,6 +63,20 @@ const (
|
||||
// 这是**最敏感**的一组:能重载其他插件意味着能间接影响它们的状态。
|
||||
CapPluginMgr Capability = "plugin_mgr"
|
||||
|
||||
// CapKernelControl 控制**内核本身**的生死(重启/关机)。
|
||||
//
|
||||
// 这是整个能力表里**最危险**的一档:它不改变某个插件的状态、
|
||||
// 也不改配置,而是让**整个服务**停下来。
|
||||
//
|
||||
// 与 CapPluginMgr 的区别(别混):
|
||||
// CapPluginMgr 能重载**别的插件** —— 影响一个插件
|
||||
// CapKernelControl 能重启/关机**内核** —— 影响全部插件 + 全部用户
|
||||
//
|
||||
// 关机之后服务不会自己回来(systemd Restart=always 由 guard 显式
|
||||
// systemctl stop 压制),必须人工介入。因此默认**不授予**任何插件,
|
||||
// 必须由 manifest 显式声明。
|
||||
CapKernelControl Capability = "kernel_control"
|
||||
|
||||
// CapCrossPluginSettings 读写**其他插件**的配置与内核核心配置。
|
||||
//
|
||||
// 与 CapCore 里的「读写自己的配置」区分开:跨插件配置读写能改别人的行为,
|
||||
@ -160,11 +174,14 @@ var methodCapability = map[string]Capability{
|
||||
MethodEventsUnsubscribe: CapEvents,
|
||||
|
||||
// ---- 插件管理 ----
|
||||
MethodPluginReloadOne: CapPluginMgr,
|
||||
MethodPluginListLoaded: CapPluginMgr,
|
||||
MethodPluginIsDisabled: CapPluginMgr,
|
||||
MethodPluginMgrStop: CapPluginMgr,
|
||||
MethodPluginMgrStart: CapPluginMgr,
|
||||
MethodPluginReloadOne: CapPluginMgr,
|
||||
MethodPluginListLoaded: CapPluginMgr,
|
||||
MethodPluginIsDisabled: CapPluginMgr,
|
||||
MethodPluginMgrStop: CapPluginMgr,
|
||||
MethodPluginMgrStart: CapPluginMgr,
|
||||
// 内核生命周期控制:单独一档,见 CapKernelControl 的注释。
|
||||
MethodKernelRestart: CapKernelControl,
|
||||
MethodKernelShutdown: CapKernelControl,
|
||||
MethodPluginMgrIsRuntimeStopped: CapPluginMgr,
|
||||
|
||||
// ---- 跨插件 / 核心配置 ----
|
||||
|
||||
@ -213,6 +213,14 @@ const (
|
||||
MethodPluginMgrStart = "plugin.mgrStart" // 52
|
||||
MethodPluginMgrIsRuntimeStopped = "plugin.mgrIsRuntimeStopped" // 53
|
||||
|
||||
// ---- 内核控制(重启/关机,供 WebUI 等界面插件用)----
|
||||
//
|
||||
// ★ 同样是**必须桥接**的方法:子进程插件走本桥,未登记的方法会被
|
||||
// corehandler 直接拒("未知 method")。WebUI 是子进程插件,
|
||||
// 不走这里就点不动按钮。
|
||||
MethodKernelRestart = "kernel.restart" // 54
|
||||
MethodKernelShutdown = "kernel.shutdown" // 55
|
||||
|
||||
// 共享段锁仲裁(新增,无对应 method id —— C ABI 下不存在跨进程锁概念)
|
||||
MethodStageLock = "stage.lock"
|
||||
MethodStageUnlock = "stage.unlock"
|
||||
|
||||
@ -75,6 +75,9 @@ type PluginStageCleaner interface {
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
// kernelCtl 是内核重启/关机通道(装配时注入,可 nil)。
|
||||
// 只对声明了该能力的内置插件可见(目前是 WebUI)。
|
||||
kernelCtl sdk.KernelControlAPI
|
||||
mu sync.RWMutex
|
||||
plugins map[string]sdk.Plugin
|
||||
instances []sdk.Plugin
|
||||
@ -228,15 +231,48 @@ func (r *Registry) SetPluginDir(dir string) { r.
|
||||
func (r *Registry) SetDataDir(dir string) { r.dataDir = dir }
|
||||
func (r *Registry) SetLuaVM(vm *luaVM.VM) { r.lua = vm }
|
||||
func (r *Registry) SetBaseAPIKey(key string) { r.baseKey = key }
|
||||
func (r *Registry) SetToolRegistrar(fn sdk.ToolRegistrar) { r.regTool = fn }
|
||||
func (r *Registry) SetStageRegistrar(fn sdk.StageRegistrar) { r.regStage = fn }
|
||||
func (r *Registry) SetAPIRegistrar(fn sdk.APIRegistrar) { r.regAPI = fn }
|
||||
func (r *Registry) SetToolCleaner(tc PluginToolCleaner) { r.toolCleaner = tc }
|
||||
func (r *Registry) SetStatusProvider(sp sdk.StatusAPI) { r.status = sp }
|
||||
func (r *Registry) SetSupervisor(sup sdk.SupervisorAPI) { r.sup = sup }
|
||||
func (r *Registry) SetTracker(trk *tracker.Tracker) { r.trk = trk }
|
||||
func (r *Registry) SetConfig(cfg *types.Config) { r.cfg = cfg }
|
||||
func (r *Registry) SetStageHost(sh sdk.ToolSource) { r.stageHost = sh }
|
||||
|
||||
// kernelControlAllowed 决定某个内置插件能否拿到内核重启/关机通道。
|
||||
//
|
||||
// ★ 为什么必须白名单,而不是"有就给":
|
||||
//
|
||||
// 这条通道能让**整个服务停下来**(关机后不会自己回来)。
|
||||
// 内置插件是编译进内核的,但它们的代码同样可能被改坏或有 bug;
|
||||
// 把这种能力发给每一个内置插件,等于整个攻击面 = 全部内置插件。
|
||||
//
|
||||
// 目前只给 WebUI:它是有登录鉴权的管理界面,用户已经决定信任它。
|
||||
// 将来要开放给别的插件,必须逐个评估(它有没有鉴权、会不会误触发)。
|
||||
var kernelControlAllowed = map[string]bool{
|
||||
"webui": true,
|
||||
}
|
||||
|
||||
// SetKernelControl 注入内核重启/关机通道(由 cmd/homed 装配时调用)。
|
||||
//
|
||||
// ★ 为何用 setter 而不是构造参数:internal/plugin 不能 import cmd/homed
|
||||
//
|
||||
// (那是反向依赖,且 cmd 包不可被 import)。用接口 + setter 打破方向。
|
||||
func (r *Registry) SetKernelControl(k sdk.KernelControlAPI) { r.kernelCtl = k }
|
||||
|
||||
// kernelControlFor 按插件名决定是否授予内核控制通道。
|
||||
//
|
||||
// 未在白名单里的插件拿到 nil —— SDK 侧 KernelControl() 返回 nil,
|
||||
// 调用方(插件自己)必须判 nil 并如实告知用户"当前环境不支持"。
|
||||
func (r *Registry) kernelControlFor(name string) sdk.KernelControlAPI {
|
||||
if r.kernelCtl == nil || !kernelControlAllowed[name] {
|
||||
return nil
|
||||
}
|
||||
return r.kernelCtl
|
||||
}
|
||||
|
||||
func (r *Registry) SetToolRegistrar(fn sdk.ToolRegistrar) { r.regTool = fn }
|
||||
func (r *Registry) SetStageRegistrar(fn sdk.StageRegistrar) { r.regStage = fn }
|
||||
func (r *Registry) SetAPIRegistrar(fn sdk.APIRegistrar) { r.regAPI = fn }
|
||||
func (r *Registry) SetToolCleaner(tc PluginToolCleaner) { r.toolCleaner = tc }
|
||||
func (r *Registry) SetStatusProvider(sp sdk.StatusAPI) { r.status = sp }
|
||||
func (r *Registry) SetSupervisor(sup sdk.SupervisorAPI) { r.sup = sup }
|
||||
func (r *Registry) SetTracker(trk *tracker.Tracker) { r.trk = trk }
|
||||
func (r *Registry) SetConfig(cfg *types.Config) { r.cfg = cfg }
|
||||
func (r *Registry) SetStageHost(sh sdk.ToolSource) { r.stageHost = sh }
|
||||
|
||||
// SetDeviceAuthQuery 注入"设备是否已授权"的查询(D4)。
|
||||
//
|
||||
@ -410,6 +446,7 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
|
||||
RegOutputUnreg: regOutputUnreg,
|
||||
RegInput: regInput,
|
||||
PluginMgr: r,
|
||||
KernelControl: r.kernelControlFor(name),
|
||||
|
||||
Status: r.status,
|
||||
Supervisor: r.sup,
|
||||
|
||||
@ -140,6 +140,35 @@
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<!-- 内核控制:重启 / 关机。
|
||||
放在导航底部而不是页面里,是因为它们作用于**整个服务**,
|
||||
不属于任何一个标签页。 -->
|
||||
<button
|
||||
class="theme-btn"
|
||||
id="kernel-restart-btn"
|
||||
onclick="kernelRestart()"
|
||||
title="重启内核(服务约断线数秒后自动重连)"
|
||||
aria-label="重启内核"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M21 12a9 9 0 1 1-2.6-6.4" />
|
||||
<path d="M21 3v6h-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="theme-btn"
|
||||
id="kernel-shutdown-btn"
|
||||
onclick="kernelShutdown()"
|
||||
title="关闭内核(需人工 systemctl start 才能再启动)"
|
||||
aria-label="关闭内核"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M12 3v9" />
|
||||
<path d="M6.3 6.3a8 8 0 1 0 11.4 0" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick="logout()"
|
||||
|
||||
@ -118,10 +118,12 @@ type Handler struct {
|
||||
knowledge sdk.KnowledgeAPI
|
||||
tracker sdk.TrackerAPI
|
||||
settings sdk.SettingsAPI
|
||||
pluginMgr sdk.PluginManager
|
||||
status sdk.StatusAPI
|
||||
term sdk.TerminalAPI
|
||||
llm sdk.LLMAPI
|
||||
// kernelCtl 是内核重启/关机通道。nil = 该部署没接(端点会明确报不可用)。
|
||||
kernelCtl sdk.KernelControlAPI
|
||||
pluginMgr sdk.PluginManager
|
||||
status sdk.StatusAPI
|
||||
term sdk.TerminalAPI
|
||||
llm sdk.LLMAPI
|
||||
|
||||
// hostPort 是 webui 实际监听的 ":port"(用于推导服务入口链接)。
|
||||
hostPort string
|
||||
@ -183,6 +185,8 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
|
||||
st sdk.StatusAPI
|
||||
term sdk.TerminalAPI
|
||||
llm sdk.LLMAPI
|
||||
// kc 是内核重启/关机通道;nil 表示该部署没接(端点会明确报不可用)。
|
||||
kc sdk.KernelControlAPI
|
||||
)
|
||||
if s != nil {
|
||||
sup, mem, idx = s.Supervisor(), s.Memory(), s.Indexer()
|
||||
@ -191,6 +195,7 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
|
||||
se, pm = s.Settings(), s.PluginMgr()
|
||||
st, llm = s.Status(), s.LLM()
|
||||
term = s.Terminal()
|
||||
kc = s.KernelControl()
|
||||
}
|
||||
h := &Handler{
|
||||
sdk: s,
|
||||
@ -204,6 +209,7 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
|
||||
knowledge: ks,
|
||||
tracker: tr,
|
||||
settings: se,
|
||||
kernelCtl: kc,
|
||||
pluginMgr: pm,
|
||||
status: st,
|
||||
term: term,
|
||||
@ -474,6 +480,12 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/login", h.handleLogin)
|
||||
mux.HandleFunc("/api/v1/logout", h.handleLogout)
|
||||
mux.HandleFunc("/api/v1/status", h.requireAPI(h.handleStatus))
|
||||
// 内核控制:重启 / 关机。
|
||||
//
|
||||
// 都要鉴权:能停服务的人必须是已认证的管理者。这两个端点比"读状态"
|
||||
// 危险一个量级 —— 关机之后只能 ssh 上去才能恢复。
|
||||
mux.HandleFunc("/api/v1/kernel/restart", h.requireAPI(h.handleKernelRestart))
|
||||
mux.HandleFunc("/api/v1/kernel/shutdown", h.requireAPI(h.handleKernelShutdown))
|
||||
mux.HandleFunc("/api/v1/agents", h.requireAPI(h.handleAgents))
|
||||
mux.HandleFunc("/api/v1/agents/", h.requireAPI(h.handleAgentByID))
|
||||
mux.HandleFunc("/api/v1/memory", h.requireAPI(h.handleMemory))
|
||||
|
||||
102
internal/plugins/webui/kernel_control.go
Normal file
102
internal/plugins/webui/kernel_control.go
Normal file
@ -0,0 +1,102 @@
|
||||
package webui
|
||||
|
||||
// 内核重启 / 关机的 HTTP 端点。
|
||||
//
|
||||
// ## 为什么这两个端点必须在 WebUI 里
|
||||
//
|
||||
// 有些状态变更只能靠重启内核生效(换 LLM 源、换主密钥、装配期配置)。
|
||||
// 没有这两个按钮时,用户只能自己 ssh 上去敲 systemctl —— 而 WebUI
|
||||
// 是大多数人唯一的界面。
|
||||
//
|
||||
// ## 断线是**预期行为**,不是故障
|
||||
//
|
||||
// 两个动作都会让内核进程消失:
|
||||
// · 重启:guard 用当前二进制重拉,断线几秒
|
||||
// · 关机:systemd 不再拉起,断线到人工启动
|
||||
//
|
||||
// ⇒ 必须**先把响应写回浏览器**,再让内核退出。否则用户看到的是
|
||||
// "点了一下,连接被重置",无法区分"正在重启"和"真出故障了"。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleKernelRestart 处理 POST /api/v1/kernel/restart
|
||||
func (h *Handler) handleKernelRestart(w http.ResponseWriter, r *http.Request) {
|
||||
h.kernelControl(w, r, "restart")
|
||||
}
|
||||
|
||||
// handleKernelShutdown 处理 POST /api/v1/kernel/shutdown
|
||||
func (h *Handler) handleKernelShutdown(w http.ResponseWriter, r *http.Request) {
|
||||
h.kernelControl(w, r, "shutdown")
|
||||
}
|
||||
|
||||
func (h *Handler) kernelControl(w http.ResponseWriter, r *http.Request, action string) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSONError(w, http.StatusMethodNotAllowed, "只接受 POST")
|
||||
return
|
||||
}
|
||||
kc := h.kernelCtl
|
||||
if kc == nil {
|
||||
// 明确说"不可用"而不是静默失败:用户点按钮没反应会以为界面坏了
|
||||
writeJSONError(w, http.StatusServiceUnavailable,
|
||||
"当前部署没有接入内核控制通道(该构建未启用,或插件未声明 kernel-control 能力)")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
// body 可为空(curl 直接 POST 不带内容)
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
reason := strings.TrimSpace(body.Reason)
|
||||
if reason == "" {
|
||||
reason = "WebUI 操作"
|
||||
}
|
||||
|
||||
var err error
|
||||
if action == "restart" {
|
||||
err = kc.RequestKernelRestart(reason)
|
||||
} else {
|
||||
err = kc.RequestKernelShutdown(reason)
|
||||
}
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// ★ 先把响应写回去(Flush),再让内核消失。
|
||||
//
|
||||
// 不 Flush 的话响应可能还在 net/http 的缓冲里,进程就退出了 ⇒
|
||||
// 浏览器收到的是 connection reset,用户以为出错了。
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
msg := map[string]interface{}{
|
||||
"status": "ok",
|
||||
"action": action,
|
||||
"reason": reason,
|
||||
}
|
||||
if action == "restart" {
|
||||
msg["message"] = "内核正在重启(约数秒)。页面稍后会自动重连。"
|
||||
msg["reconnect_hint_ms"] = 8000
|
||||
} else {
|
||||
msg["message"] = "内核正在关闭。需要人工执行 systemctl start 才能再启动。"
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(msg)
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
|
||||
// 给浏览器一点时间收包,再让内核真退出
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
|
||||
// writeJSONError 是 handler 里统一的错误输出(保持与其它端点一致)。
|
||||
func writeJSONError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
9
internal/sdk/kernel.go
Normal file
9
internal/sdk/kernel.go
Normal file
@ -0,0 +1,9 @@
|
||||
package sdk
|
||||
|
||||
import pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||
|
||||
// KernelControlAPI 是内核重启/关机通道(转发公开 SDK 的同名接口)。
|
||||
//
|
||||
// 内置插件(WebUI)与外部插件面对的是同一份契约 —— 只做类型别名,
|
||||
// 不额外加方法:加方法会让"内置插件能调、外部插件调不到"成为既成事实。
|
||||
type KernelControlAPI = pubsdk.KernelControlAPI
|
||||
@ -317,6 +317,8 @@ type SDKConfig struct {
|
||||
RegOutputUnreg OutputChannelUnregistrar
|
||||
RegInput InputChannelRegistrar
|
||||
PluginMgr PluginManager
|
||||
// KernelControl 是内核重启/关机通道(WebUI 用)。nil = 未接该能力。
|
||||
KernelControl KernelControlAPI
|
||||
|
||||
Status StatusAPI
|
||||
Supervisor SupervisorAPI
|
||||
@ -353,6 +355,9 @@ func New(name string, cfg SDKConfig) *PluginSDK {
|
||||
base.SetDocMemoryAPI(cfg.DocMemory)
|
||||
base.SetKnowledgeAPI(cfg.Knowledge)
|
||||
base.SetLLMAPI(cfg.LLM)
|
||||
if cfg.KernelControl != nil {
|
||||
base.SetKernelControlAPI(cfg.KernelControl)
|
||||
}
|
||||
return &PluginSDK{
|
||||
PluginSDK: base,
|
||||
settings: cfg.Settings,
|
||||
|
||||
Reference in New Issue
Block a user