Files
HomeAgent/internal/pluginview/terminal.go
root 3119d48625 refactor(terminal): 终端逻辑回归 agentcli,内核不再编码终端语义
按用户指正调整架构:终端是 agentcli 的职责,内核不该有终端逻辑;插件与
agentcli 的联动应走事件总线,而不是内核内部组件。

## 删掉内核里的终端语义

`internal/agent/core/terminal_registry.go`(280 行)整体删除。它做了两件不该
内核做的事:定义「什么是终端会话」、从 EventToolCall 的 Go map 文本里解析
terminal id / command 并归并会话与命令历史。连带移除:

- Agent 的 terminalReg 字段、构造、subscribeTerminalRegistry 订阅
- StatusAPI 上的 ListTerminals / CmdHistory
- sdk.TerminalAPI + PluginSDK.Terminal() + Registry.SetTerminalAPI 及接线
- 对应测试(迁到插件侧视图包)

保留 `sdk.TerminalStatus` / `CmdExecStatus`:它们只是线上 JSON 格式,WebUI/CLI
返回给前端时口径一致。

## 插件侧订阅事件总线自攒视图

新增 `internal/pluginview`(TerminalView):把原先内核那份归并逻辑原样搬过来,
由插件自己持有。数据源仍是两条总线事件:

    EventTerminalOutput(agentcli 发)→ 会话输出 + 生命周期
    EventToolCall      (内核发)    → cmd_run 历史、terminal_create/close 归并

WebUI 在 NewHandler 订阅、Close 退订;CLI 在 Start 订阅、Stop 退订。
两边共用同一实现,避免历史上「两份推导各踩一次 Go map 文本」的坑。
视图不是权威:真值在 agentcli,插件晚于会话启动会缺早期会话(注释写明)。

## agentcli 保持内置

它现在仍需要 `Publish`(进程内事件总线写入)——外部插件没有上行事件通道
(`EventPublish` 在 withheldCapabilities 里,proc 只有 events.subscribe)。
按判定标准留内置;将来要外置,须先开一条受控的插件→内核上行事件通道,
本次刻意不开(用户决定:先保持内置)。

## 文档

中英 ARCHITECTURE 新增「终端归属」小节:说明职责边界、两条事件、视图非权威、
以及 agentcli 为何仍内置;内置判定表里 cli/webui 去掉 TerminalAPI 一项。

## 验收(本机实例实测)

- 内核编译通过,删除后 `go build ./... && go vet ./...` 干净,`go test ./internal/...` 全绿
- 实例重启后:让 agent 开终端跑 `echo term-view-test` →
  WebUI `GET /api/v1/terminals` 返回 term_1(output 正确、running=false)
  CLI `/terminals`(unix socket)返回同一份;
  两处都是从事件总线自攒,内核侧无终端代码参与
- cmd 历史分支由迁过来的单测覆盖(Go map 文本解析、生命周期、上限裁剪)
2026-10-20 09:33:08 +08:00

277 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pluginview
import (
"strings"
"sync"
"time"
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
)
// TerminalView 是**插件侧**的终端会话与命令历史视图。
//
// 职责边界(2026-10-20 调整):终端逻辑只归 agentcli ——PTY、会话、历史都在它
// 那里;内核**不**再维护终端语义(旧的 internal/agent/core/terminal_registry.go
// 已删除)。插件要展示终端,就订阅事件总线自己攒视图:
//
// EventTerminalOutput(agentcli 发)→ 会话输出 + 生命周期
// EventToolCall (内核发) → cmd_run 历史、terminal_create/close 归并
//
// 为什么放在一个共享包里而不是各写一份:WebUI 与 CLI 都需要同一份口径,
// 且历史上两份推导踩过同一个坑(工具 result 是 Go map 文本,断言成
// map[string]interface{} 永远失败 → /terminals 空空如也)。共享实现保证
// 两边解析口径一致。
//
// ❗这是**视图**不是权威:状态真值在 agentcli。插件晚于会话启动时会缺早期
// 会话(下次生命周期事件会补回),不要把这里当持久记录。
type TerminalView 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 NewTerminalView() *TerminalView {
return &TerminalView{
terms: make(map[string]*TermState),
}
}
// OnToolCall 归并内核自己发布的 EventToolCall(agent 路径;result 是 Go map
// 文本,id/command 从 args 或 map 文本里回填)。
func (r *TerminalView) 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 *TerminalView) 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()
}
// Snapshot 返回加过 Uptime 的终端列表与命令历史快照(拷贝,调用方可随意改)。
func (r *TerminalView) 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
}