mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
按用户指正调整架构:终端是 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 文本解析、生命周期、上限裁剪)
277 lines
7.3 KiB
Go
277 lines
7.3 KiB
Go
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
|
||
}
|