feat(cli): /terminals、/cmd/history、/terminal 对齐 WebUI(事件面 + ToolAPI,无需新接口)

去看了一遍源码,纠正上轮判断:终端与命令历史也不是 WebUI 插件私有。
- 终端会话:agentcli 插件持有,发 EventTerminalOutput;WebUI 只是订阅该事件
  自己攒视图。命令历史:WebUI 订阅 EventToolCall 的 cmd_run 攒的。
- 于是 CLI 插件订阅同样两个事件即可同口径:/terminals、/cmd/history。
- 开/写/读/关终端:SDK 的 ToolAPI.ExecuteTool 已允许跨插件调用工具,
  CLI 直接调 agentcli 的 terminal_create/write/read/close,新增 /terminal 子命令。

waiter 侧同步:/terminals、/cmd/history 两条路(local/remote)都接;/terminal
仅在 local 可用(远端 WebUI 无对应 REST 端点,明确提示而不是当聊天发出去)。
This commit is contained in:
JianFeeeee
2026-09-15 15:31:32 +08:00
parent 0227fc2d4d
commit 3cce605722
2 changed files with 246 additions and 0 deletions

View File

@ -14,6 +14,7 @@ import (
"strconv"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
@ -49,6 +50,32 @@ 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 {
@ -62,6 +89,8 @@ 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)
// inputch 先登记:本插件既用 "cli" 作输出目标,也用它注入输入(终端行)。
// 输入侧必须显式登记,否则"把 inputch 划给驻留子"会找不到它。
@ -320,6 +349,12 @@ func (p *Plugin) handleBuiltin(conn net.Conn, line string, s *sdk.PluginSDK) boo
p.cmdRuntime(conn, s)
case "/persona":
p.cmdPersona(conn, parts, s)
case "/terminals":
p.cmdTerminals(conn)
case "/cmd/history":
p.cmdCmdHistory(conn)
case "/terminal":
p.cmdTerminal(conn, parts, s)
default:
return false
}
@ -358,6 +393,9 @@ func (p *Plugin) cmdHelp(conn net.Conn) {
/network 网络状态与 LLM 端点
/runtime 调度器/驻留子/通道拓扑快照
/persona 当前人格设定(/persona set <mode> [内容] 修改)
/terminals 终端会话列表(与 WebUI /terminals 同源)
/cmd/history 命令执行历史cmd_run
/terminal create|write|read|close … 创建/写入/读取/关闭终端(调 agentcli 工具)
/agents 列出 Agent
其他文本直接发送给 Agent 处理。`,
@ -942,6 +980,183 @@ func (p *Plugin) cmdAgents(conn net.Conn, s *sdk.PluginSDK) {
writeJSONContent(conn, map[string]interface{}{"agents": []interface{}{map[string]string{"id": ks.AgentID, "state": "running"}}})
}
// ======== /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()
})
}
// 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)
}
p.termMu.Unlock()
writeJSONContent(conn, map[string]interface{}{"terminals": list})
}
// 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})
}
// cmdTerminal 通过 ToolAPI.ExecuteTool 调 agentcli 的终端工具。
//
// 终端本体属于 agentcli 插件terminal_create/write/read/closeSDK 的
// ToolAPI.ExecuteTool 已允许跨插件调用工具,所以 CLI 不必新增接口就能开/写/读/关。
func (p *Plugin) cmdTerminal(conn net.Conn, parts []string, s *sdk.PluginSDK) {
if len(parts) < 2 {
writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /terminal create <命令> | write <id> <输入> | read <id> | close <id>"})
return
}
tools := s.Tool()
if tools == nil {
writeLine(conn, map[string]interface{}{"type": "error", "error": "tool api not available"})
return
}
switch parts[1] {
case "create":
if len(parts) < 3 {
writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /terminal create <命令>"})
return
}
res, err := tools.ExecuteTool("terminal_create", map[string]interface{}{"command": strings.Join(parts[2:], " ")})
if err != nil {
writeLine(conn, map[string]interface{}{"type": "error", "error": err.Error()})
return
}
writeJSONContent(conn, res)
case "write":
if len(parts) < 4 {
writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /terminal write <id> <输入>"})
return
}
res, err := tools.ExecuteTool("terminal_write", map[string]interface{}{
"id": parts[2], "input": strings.Join(parts[3:], " "),
})
if err != nil {
writeLine(conn, map[string]interface{}{"type": "error", "error": err.Error()})
return
}
writeJSONContent(conn, res)
case "read", "close":
if len(parts) < 3 {
writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /terminal " + parts[1] + " <id>"})
return
}
res, err := tools.ExecuteTool("terminal_"+parts[1], map[string]interface{}{"id": parts[2]})
if err != nil {
writeLine(conn, map[string]interface{}{"type": "error", "error": err.Error()})
return
}
writeJSONContent(conn, res)
default:
writeLine(conn, map[string]interface{}{"type": "response", "content": "用法: /terminal create|write|read|close …"})
}
}
// ======== helpers ========
// truncateOneLine 将多行文本压成单行并按 rune 截断,用于事件结果预览。