refactor(terminal): 内核开终端/命令历史权威视图,WebUI 与 CLI 都改接内核

按「内核开,两个插件接」重构终端与命令历史的数据归属。

背景:此前 WebUI 与 CLI 各订 EventToolCall/EventTerminalOutput 攅一份状态,
同一件事两份推导,还各自踩过同一个坑——工具 result 是 Go 的 map 文本
(map[cols:80 ... id:term_2 ...]),断言成 map[string]interface{} 永远失败,
terminal_create 的 id 回填不生效,/terminals 因此恒空(WebUI 也一样)。
实测确认:WebUI 自己的 /api/v1/terminals 与 /api/v1/cmd/history 同样是空的。

内核开(权威唯一真相):
- internal/agent/core/terminal_registry.go:TerminalRegistry 归并两类事件——
  EventToolCall(terminal_create/close、cmd_run,id/command 从 args 或 Go map
  文本回填)与 EventTerminalOutput(agentcli 生命周期 + 输出,含 64KB 缓冲上限、
  100 条命令历史、50 个终端上限)。
- internal/sdk/terminal.go:新增 TerminalAPI(ListTerminals/CmdHistory)与
  TerminalStatus/CmdExecStatus DTO。**不塞进 KernelStatus**:那是全量快照,
  前端每 3 秒轮询 /kernel,背上每终端最多 64KB 输出会让轮询成本爆炸;
  终端输出是按需拉取的明细,另开接口。
- Agent 订阅自己的事件总线(subscribeTerminalRegistry),且**只根 agent 建**
  (驻留子共用同一总线,每个子都建会 N+1 份重复记账)。
- SDKConfig/Registry/bootstrap 接线:pluginReg.SetTerminalAPI(agent)。

生产者补全(agentcli):终端无输出时 ticker 不发事件,内核就无从知道终端
存在。新增 emitTermState,在 handleCreate/handleClose/readLoop 退出(超时/
进程结束/读取错误/stopCh)显式上报 running 状态,并给输出事件补 command 字段。
handleClose 改为接收 *sdk.PluginSDK 以便上报。

两个插件接(消费方):
- WebUI:删掉本地 termStates/cmdHistory/subscribeTerminalStream/handleToolEvent
  及不再使用的 getStr;/terminals 与 /cmd/history 直接读 s.Terminal()。
- CLI:删掉上一轮刚加的 subscribeToolEvents 与 cliTermState/cliCmdExec;
  /terminals 与 /cmd/history 直接读 s.Terminal()。两条路(local/remote)都通。

测试:新增 terminal_registry_test.go,锁死 Go map 文本解析(旧缺陷根因)、
生命周期、CLI 直调路径(无 EventToolCall 仅凭 output 事件建条目)、历史与
终端数量上限。全量 go test ./internal/... ./cmd/... 通过。
This commit is contained in:
JianFeeeee
2026-09-17 18:56:15 +08:00
parent 3cce605722
commit e70d2171ee
14 changed files with 595 additions and 330 deletions

View File

@ -14,7 +14,6 @@ import (
"strconv"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
@ -50,32 +49,6 @@ type Plugin struct {
ln net.Listener
mu sync.Mutex
wg sync.WaitGroup
// 终端会话与命令历史:订阅内核事件攒出来的,与 WebUI 同源同口径。
// 不是 WebUI 私有数据——它也是订 EventToolCall/EventTerminalOutput 自己攒的。
termMu sync.Mutex
termStates map[string]*cliTermState
cmdMu sync.Mutex
cmdHistory []cliCmdExec
}
// cliTermState 与 webui 的 termState 同字段(/terminals 输出口径)。
type cliTermState struct {
ID string `json:"id"`
Command string `json:"command"`
Running bool `json:"running"`
Output string `json:"output"`
CreatedAt string `json:"created_at"`
}
// cliCmdExec 与 webui 的 CmdExec 同字段(/cmd/history 输出口径)。
type cliCmdExec struct {
Command string `json:"command"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
Status string `json:"status"`
Time string `json:"time"`
}
func New(name, socketPath string) *Plugin {
@ -89,8 +62,9 @@ func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.termStates = make(map[string]*cliTermState)
p.subscribeToolEvents(s)
// 终端与命令历史的权威视图在内核(「内核开,两个插件接」),
// /terminals 与 /cmd/history 直接从 s.Status().GetKernelStatus() 读,
// 无需在此订阅事件自攒。
// inputch 先登记:本插件既用 "cli" 作输出目标,也用它注入输入(终端行)。
// 输入侧必须显式登记,否则"把 inputch 划给驻留子"会找不到它。
@ -350,9 +324,9 @@ func (p *Plugin) handleBuiltin(conn net.Conn, line string, s *sdk.PluginSDK) boo
case "/persona":
p.cmdPersona(conn, parts, s)
case "/terminals":
p.cmdTerminals(conn)
p.cmdTerminals(conn, s)
case "/cmd/history":
p.cmdCmdHistory(conn)
p.cmdCmdHistory(conn, s)
case "/terminal":
p.cmdTerminal(conn, parts, s)
default:
@ -982,124 +956,41 @@ func (p *Plugin) cmdAgents(conn net.Conn, s *sdk.PluginSDK) {
// ======== /terminals /cmd/history /terminal ========
// subscribeToolEvents 订阅内核工具与终端事件,维护命令历史与终端会话视图
// 终端与命令历史的权威视图在内核(「内核开,两个插件接」)
//
// 这两份数据不是 WebUI 插件私有的WebUI 也是订阅同样的 EventToolCall /
// EventTerminalOutput 自己攒出来的(见 handler_chat.go 的 handleToolEvent、
// handler_terminal.go 的 subscribeTerminalStream。事件面本就是 SDK 对内部
// 插件开放的,所以 CLI 能做到同口径,不需要新增内核接口。
func (p *Plugin) subscribeToolEvents(s *sdk.PluginSDK) {
s.Subscribe(sdk.EventToolCall, func(ev *sdk.Event) {
payload := ev.Payload
tool, _ := payload["tool"].(string)
args, _ := payload["args"].(map[string]interface{})
status, _ := payload["status"].(string)
switch tool {
case "cmd_run":
cmd := ""
if args != nil {
cmd, _ = args["command"].(string)
}
p.cmdMu.Lock()
p.cmdHistory = append(p.cmdHistory, cliCmdExec{
Command: cmd, Status: status, Time: time.Now().Format(time.RFC3339),
})
if len(p.cmdHistory) > 100 {
p.cmdHistory = p.cmdHistory[len(p.cmdHistory)-100:]
}
p.cmdMu.Unlock()
case "terminal_create":
id := ""
if args != nil {
id, _ = args["id"].(string)
}
if id == "" {
// agent 调用时不知道生成的 id从工具结果中回填同 WebUI
if res, ok := payload["result"].(map[string]interface{}); ok {
id, _ = res["id"].(string)
}
}
if id == "" {
return
}
cmd := ""
if args != nil {
cmd, _ = args["command"].(string)
}
p.termMu.Lock()
if old, ok := p.termStates[id]; ok {
old.Command = cmd
old.Running = true
} else {
p.termStates[id] = &cliTermState{
ID: id, Command: cmd, Running: true,
CreatedAt: time.Now().Format(time.RFC3339),
}
}
p.termMu.Unlock()
case "terminal_close":
id := ""
if args != nil {
id, _ = args["id"].(string)
}
if id != "" {
p.termMu.Lock()
if t, ok := p.termStates[id]; ok {
t.Running = false
}
p.termMu.Unlock()
}
}
})
s.Subscribe(sdk.EventTerminalOutput, func(ev *sdk.Event) {
payload := ev.Payload
id, _ := payload["terminal_id"].(string)
if id == "" {
return
}
output, _ := payload["output"].(string)
running, _ := payload["running"].(bool)
p.termMu.Lock()
ts, ok := p.termStates[id]
if !ok {
ts = &cliTermState{ID: id, CreatedAt: time.Now().Format(time.RFC3339)}
p.termStates[id] = ts
}
ts.Running = running
if output != "" {
const maxTermOutput = 64 * 1024
if len(ts.Output)+len(output) > maxTermOutput {
excess := len(ts.Output) + len(output) - maxTermOutput
if len(ts.Output) > excess {
ts.Output = ts.Output[excess:]
} else {
ts.Output = ""
}
}
ts.Output += output
}
p.termMu.Unlock()
})
}
// 内核订自己的事件总线归并 EventToolCallterminal_create/close、cmd_run
// EventTerminalOutputagentcli 生命周期 + 输出),产出唯一真相挂在
// TerminalAPI见 internal/agent/core/terminal_registry.go
// WebUI 与 CLI 都从 s.Status().GetKernelStatus() 读同一份,不再各自订阅推导——
// 之前两份推导还各自踩过同一个坑:工具 result 是 Go map 文本,断言成
// map[string]interface{} 永远失败 → /terminals 空空如也。
// cmdTerminals 与 WebUI 的 GET /api/v1/terminals 同口径
func (p *Plugin) cmdTerminals(conn net.Conn) {
p.termMu.Lock()
list := make([]*cliTermState, 0, len(p.termStates))
for _, t := range p.termStates {
list = append(list, t)
// cmdTerminals 读内核权威快照,与 WebUI 的 GET /api/v1/terminals 同
func (p *Plugin) cmdTerminals(conn net.Conn, s *sdk.PluginSDK) {
t := s.Terminal()
if t == nil {
writeJSONContent(conn, map[string]interface{}{"terminals": []interface{}{}})
return
}
p.termMu.Unlock()
writeJSONContent(conn, map[string]interface{}{"terminals": list})
terms := t.ListTerminals()
if terms == nil {
terms = []sdk.TerminalStatus{}
}
writeJSONContent(conn, map[string]interface{}{"terminals": terms})
}
// cmdCmdHistory 与 WebUI 的 GET /api/v1/cmd/history 同口径
func (p *Plugin) cmdCmdHistory(conn net.Conn) {
p.cmdMu.Lock()
out := make([]cliCmdExec, len(p.cmdHistory))
copy(out, p.cmdHistory)
p.cmdMu.Unlock()
writeJSONContent(conn, map[string]interface{}{"history": out})
// cmdCmdHistory 读内核权威快照,与 WebUI 的 GET /api/v1/cmd/history 同
func (p *Plugin) cmdCmdHistory(conn net.Conn, s *sdk.PluginSDK) {
t := s.Terminal()
if t == nil {
writeJSONContent(conn, map[string]interface{}{"history": []interface{}{}})
return
}
cmds := t.CmdHistory()
if cmds == nil {
cmds = []sdk.CmdExecStatus{}
}
writeJSONContent(conn, map[string]interface{}{"history": cmds})
}
// cmdTerminal 通过 ToolAPI.ExecuteTool 调 agentcli 的终端工具。