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 166c0a4eaf
commit dbc493a545
14 changed files with 595 additions and 330 deletions

View File

@ -0,0 +1,280 @@
package core
import (
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// TerminalRegistry 是**内核侧**的终端会话与命令历史权威视图(「内核开,两个插件接」)。
//
// 此前 WebUI 与 CLI 插件各自订阅 EventToolCall / EventTerminalOutput 攒一份
// 状态:同一件事两份推导,还各自踩过同一个坑(工具 result 是 Go map 文本,
// 断言成 map[string]interface{} 永远失败 → /terminals 空空如也)。
//
// 现在权威状态收归内核一份:内核订阅自己的事件总线,把 terminal_* / cmd_run
// 的工具调用与 agentcli 的 terminal_output 事件归并成唯一真相;
// WebUI 和 CLI 都从 s.Status().GetKernelStatus() 读取,不再各自推导。
type TerminalRegistry struct {
mu sync.Mutex
terms map[string]*TermState
cmds []CmdExec
}
// TermState 与 WebUI 的 termState / CLI 的 cliTermState 同字段(/terminals 口径)。
type TermState struct {
ID string `json:"id"`
Command string `json:"command"`
Running bool `json:"running"`
Output string `json:"output"`
CreatedAt string `json:"created_at"`
Uptime string `json:"uptime"`
created time.Time
}
// CmdExec 与 WebUI 的 CmdExec 同字段(/cmd/history 口径)。
type CmdExec 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"`
}
const (
maxCmdHistory = 100
maxTerminals = 50
maxTermOutput = 64 * 1024
)
func NewTerminalRegistry() *TerminalRegistry {
return &TerminalRegistry{
terms: make(map[string]*TermState),
}
}
// OnToolCall 归并内核自己发布的 EventToolCall(agent 路径;result 是 Go map
// 文本,id/command 从 args 或 map 文本里回填)。
func (r *TerminalRegistry) OnToolCall(payload map[string]interface{}) {
tool, _ := payload["tool"].(string)
args, _ := payload["args"].(map[string]interface{})
status, _ := payload["status"].(string)
switch tool {
case "cmd_run":
r.mu.Lock()
r.cmds = append(r.cmds, CmdExec{
Command: getStr2(args, "command"),
Status: status,
Time: time.Now().Format(time.RFC3339),
})
if len(r.cmds) > maxCmdHistory {
r.cmds = r.cmds[len(r.cmds)-maxCmdHistory:]
}
r.mu.Unlock()
case "terminal_create":
id := getStr2(args, "id")
if id == "" {
id = terminalIDFromResultPayload(payload)
}
if id == "" {
return
}
cmd := getStr2(args, "command")
if cmd == "" {
cmd = mapFieldFromResultPayload(payload, "command")
}
r.mu.Lock()
if old, ok := r.terms[id]; ok {
old.Command = cmd
old.Running = true
old.created = time.Now()
} else {
r.terms[id] = &TermState{
ID: id,
Command: cmd,
Running: true,
CreatedAt: time.Now().Format(time.RFC3339),
created: time.Now(),
}
}
if len(r.terms) > maxTerminals {
for k := range r.terms {
delete(r.terms, k)
break
}
}
r.mu.Unlock()
case "terminal_close":
id := getStr2(args, "id")
if id != "" {
r.mu.Lock()
if t, ok := r.terms[id]; ok {
t.Running = false
}
r.mu.Unlock()
}
}
}
// OnTerminalOutput 归并 agentcli 的 terminal_output 事件(含生命周期事件:
// 创建时带 command,关闭/退出/超时带 running=false)。
func (r *TerminalRegistry) OnTerminalOutput(payload map[string]interface{}) {
id, _ := payload["terminal_id"].(string)
if id == "" {
return
}
output, _ := payload["output"].(string)
running, _ := payload["running"].(bool)
command, _ := payload["command"].(string)
r.mu.Lock()
ts, ok := r.terms[id]
if !ok {
ts = &TermState{ID: id, created: time.Now()}
if command != "" {
ts.Command = command
}
ts.CreatedAt = time.Now().Format(time.RFC3339)
r.terms[id] = ts
}
if command != "" {
ts.Command = command
}
ts.Running = running
if output != "" {
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
}
if len(r.terms) > maxTerminals {
for k := range r.terms {
delete(r.terms, k)
break
}
}
r.mu.Unlock()
}
// ListTerminals / CmdHistory 实现 sdk.TerminalAPI(内核对插件开放的终端接口)。
func (r *TerminalRegistry) ListTerminals() []sdk.TerminalStatus {
terms, _ := r.Snapshot()
return terms
}
func (r *TerminalRegistry) CmdHistory() []sdk.CmdExecStatus {
_, cmds := r.Snapshot()
return cmds
}
// Snapshot 返回加过 Uptime 的终端列表与命令历史快照(拷贝,调用方可随意改)。
func (r *TerminalRegistry) Snapshot() ([]sdk.TerminalStatus, []sdk.CmdExecStatus) {
r.mu.Lock()
defer r.mu.Unlock()
terms := make([]sdk.TerminalStatus, 0, len(r.terms))
for _, t := range r.terms {
terms = append(terms, sdk.TerminalStatus{
ID: t.ID,
Command: t.Command,
Running: t.Running,
Output: t.Output,
CreatedAt: t.CreatedAt,
Uptime: time.Since(t.created).Round(time.Second).String(),
})
}
cmds := make([]sdk.CmdExecStatus, len(r.cmds))
for i, c := range r.cmds {
cmds[i] = sdk.CmdExecStatus(c)
}
return terms, cmds
}
func getStr2(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
v, _ := m[key].(string)
return v
}
// terminalIDFromResultPayload 从 EventToolCall payload 的 result 里抠 terminal id。
// result 是 Go map 文本(map[cols:80 ... id:term_2 ...]),不是结构化对象。
func terminalIDFromResultPayload(payload map[string]interface{}) string {
res, _ := payload["result"].(string)
return terminalIDFromMapText(res)
}
func mapFieldFromResultPayload(payload map[string]interface{}, key string) string {
res, _ := payload["result"].(string)
return mapFieldFromMapText(res, key)
}
// terminalIDFromMapText / mapFieldFromMapText 解析 Go map 文本的字段。
//
// 为什么不能信 payload["result"] 是 map[string]interface{}:工具结果在
// executeToolCall → ToolResultItem.Output 就已被 fmt 序列化成文本
// (map[cols:80 command:sleep 120 id:term_2 ...]),事件负载里拿到的
// 永远是字符串。用正则按空格切字段即可,id/command 不含空格。
func terminalIDFromMapText(s string) string {
return mapFieldFromMapText(s, "id")
}
func mapFieldFromMapText(s, key string) string {
s = strings.TrimSpace(s)
// 剥掉 Go 的 map[...] 外壳,否则外层中括号把深度抬到 1,
// 内部所有空格都不再被当成字段分隔。
if strings.HasPrefix(s, "map[") && strings.HasSuffix(s, "]") {
s = s[len("map[") : len(s)-1]
}
for _, f := range splitMapTextFields(s) {
k, v, ok := parseMapField(f)
if ok && k == key {
return v
}
}
return ""
}
func splitMapTextFields(s string) []string {
var fields []string
depth := 0
cur := ""
for _, c := range s {
switch c {
case '[', '{', '(':
depth++
case ']', '}', ')':
if depth > 0 {
depth--
}
case ' ':
if depth == 0 && cur != "" {
fields = append(fields, cur)
cur = ""
continue
}
}
cur += string(c)
}
if cur != "" {
fields = append(fields, cur)
}
return fields
}
func parseMapField(f string) (k, v string, ok bool) {
for i := 0; i < len(f); i++ {
if f[i] == ':' {
return f[:i], f[i+1:], true
}
}
return "", "", false
}