mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-03 15:53:56 +00:00
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:
@ -351,7 +351,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
"required": []string{"id"},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return p.handleClose(args)
|
||||
return p.handleClose(s, args)
|
||||
})
|
||||
|
||||
s.RegisterTool("terminal_list", sdk.ToolDef{
|
||||
@ -493,6 +493,10 @@ func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (in
|
||||
p.wg.Add(1)
|
||||
go p.readLoop(session, s)
|
||||
|
||||
// 生命周期事件:终端创建即时上报(带 command),让内核权威视图与所有
|
||||
// 订阅者(WebUI/CLI)即使在该终端无输出的情况下也能知道它的存在。
|
||||
emitTermState(s, session, true)
|
||||
|
||||
log.Printf("[agentcli] created terminal %s: command=%q timeout=%v rows=%d cols=%d", id, command, timeout, rows, cols)
|
||||
|
||||
return map[string]interface{}{
|
||||
@ -681,7 +685,7 @@ func (p *Plugin) handleResize(args map[string]interface{}) (interface{}, error)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleClose(args map[string]interface{}) (interface{}, error) {
|
||||
func (p *Plugin) handleClose(s *sdk.PluginSDK, args map[string]interface{}) (interface{}, error) {
|
||||
id, _ := args["id"].(string)
|
||||
if id == "" {
|
||||
return map[string]interface{}{"error": "id is required"}, nil
|
||||
@ -701,6 +705,9 @@ func (p *Plugin) handleClose(args map[string]interface{}) (interface{}, error) {
|
||||
session.Close()
|
||||
log.Printf("[agentcli] closed terminal %s", id)
|
||||
|
||||
// 生命周期事件:显式上报关闭(readLoop 退出时也会发,幂等)。
|
||||
emitTermState(s, session, false)
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": "closed",
|
||||
"terminal": id,
|
||||
@ -795,6 +802,25 @@ func (p *Plugin) handleList() (interface{}, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// emitTermState 把终端存活状态作为 EventTerminalOutput 事件上报。
|
||||
//
|
||||
// 生命周期事件(创建/关闭/退出/超时)必须显式发:终端无输出时 ticker
|
||||
// 不会发事件,内核的终端权威视图与所有插件(WebUI/CLI)都依赖这些事件
|
||||
// 才能知道终端的存在与终止。payload 带 command 供无 ToolCall 事件的
|
||||
// 直调路径(CLI /terminal create 经 ToolAPI.ExecuteTool)回填命令名。
|
||||
// 幂等:重复发同一 running 值不会产生状态跳变。
|
||||
func emitTermState(s *sdk.PluginSDK, t *TerminalSession, running bool) {
|
||||
if s == nil || t == nil {
|
||||
return
|
||||
}
|
||||
s.Publish(&sdk.Event{
|
||||
Type: sdk.EventTerminalOutput,
|
||||
Source: "agentcli",
|
||||
Payload: map[string]interface{}{"terminal_id": t.id, "command": t.command, "running": running},
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
defer p.wg.Done()
|
||||
defer close(t.done)
|
||||
@ -812,6 +838,9 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
flushTicker := time.NewTicker(200 * time.Millisecond)
|
||||
defer flushTicker.Stop()
|
||||
|
||||
// 退出时确保内核权威视图标记该终端为已停止(readLoop 的所有 return 点)。
|
||||
defer emitTermState(s, t, false)
|
||||
|
||||
// 立即发送首次"终端已启动"通知,让 agent 感知存在。
|
||||
// 用 NoMemory:这是状态提示,不是对话内容。不关掉的话每开一个终端都会
|
||||
// 在记忆里留下一条"[终端 X 已启动]",把真实内容挤掉。
|
||||
@ -888,7 +917,7 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
s.Publish(&sdk.Event{
|
||||
Type: sdk.EventTerminalOutput,
|
||||
Source: "agentcli",
|
||||
Payload: map[string]interface{}{"terminal_id": t.id, "output": streamData, "running": terminalRunning(t)},
|
||||
Payload: map[string]interface{}{"terminal_id": t.id, "command": t.command, "output": streamData, "running": terminalRunning(t)},
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -227,7 +227,12 @@ func TestCreateTerminalMissingArgs(t *testing.T) {
|
||||
t.Fatalf("expected status created, got %v", resp["status"])
|
||||
}
|
||||
id := resp["id"].(string)
|
||||
p.handleClose(map[string]interface{}{"id": id})
|
||||
// 经注册的 handler 关闭(handler 内部会带上 sdk);
|
||||
// 直接调 p.handleClose 需自备 sdk 参数。
|
||||
closeHandler := tc.handlers["terminal_close"]
|
||||
if _, err := closeHandler(map[string]interface{}{"id": id}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteToNonexistentTerminal(t *testing.T) {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
}
|
||||
// 内核订自己的事件总线归并 EventToolCall(terminal_create/close、cmd_run)
|
||||
// 与 EventTerminalOutput(agentcli 生命周期 + 输出),产出唯一真相挂在
|
||||
// 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 的终端工具。
|
||||
|
||||
@ -96,6 +96,7 @@ type Handler struct {
|
||||
settings sdk.SettingsAPI
|
||||
pluginMgr sdk.PluginManager
|
||||
status sdk.StatusAPI
|
||||
term sdk.TerminalAPI
|
||||
llm sdk.LLMAPI
|
||||
|
||||
sessionMu sync.Mutex
|
||||
@ -125,26 +126,23 @@ type Handler struct {
|
||||
chatMsgMu sync.Mutex
|
||||
chatMsgCache map[string]*chatMsgEntry // client_msg_id -> 首次处理结果
|
||||
chatMsgOrder []string // FIFO 淘汰序
|
||||
cmdMu sync.Mutex
|
||||
cmdHistory []CmdExec
|
||||
termMu sync.Mutex
|
||||
termStates map[string]*termState
|
||||
}
|
||||
|
||||
func NewHandler(s *sdk.PluginSDK) *Handler {
|
||||
var (
|
||||
sup sdk.SupervisorAPI
|
||||
mem sdk.MemoryAPI
|
||||
idx sdk.IndexerAPI
|
||||
ad sdk.AdapterAPI
|
||||
cfg sdk.ConfigAPI
|
||||
tm sdk.TextMemoryAPI
|
||||
ks sdk.KnowledgeAPI
|
||||
tr sdk.TrackerAPI
|
||||
se sdk.SettingsAPI
|
||||
pm sdk.PluginManager
|
||||
st sdk.StatusAPI
|
||||
llm sdk.LLMAPI
|
||||
sup sdk.SupervisorAPI
|
||||
mem sdk.MemoryAPI
|
||||
idx sdk.IndexerAPI
|
||||
ad sdk.AdapterAPI
|
||||
cfg sdk.ConfigAPI
|
||||
tm sdk.TextMemoryAPI
|
||||
ks sdk.KnowledgeAPI
|
||||
tr sdk.TrackerAPI
|
||||
se sdk.SettingsAPI
|
||||
pm sdk.PluginManager
|
||||
st sdk.StatusAPI
|
||||
term sdk.TerminalAPI
|
||||
llm sdk.LLMAPI
|
||||
)
|
||||
if s != nil {
|
||||
sup, mem, idx = s.Supervisor(), s.Memory(), s.Indexer()
|
||||
@ -152,6 +150,7 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
|
||||
tm, ks, tr = s.TextMemory(), s.Knowledge(), s.Tracker()
|
||||
se, pm = s.Settings(), s.PluginMgr()
|
||||
st, llm = s.Status(), s.LLM()
|
||||
term = s.Terminal()
|
||||
}
|
||||
h := &Handler{
|
||||
sdk: s,
|
||||
@ -167,9 +166,9 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
|
||||
settings: se,
|
||||
pluginMgr: pm,
|
||||
status: st,
|
||||
term: term,
|
||||
llm: llm,
|
||||
sessions: make(map[string]time.Time),
|
||||
termStates: make(map[string]*termState),
|
||||
pendingIdx: -1,
|
||||
chatMsgCache: make(map[string]*chatMsgEntry),
|
||||
sseEvents: newSSEEventRing(200),
|
||||
@ -184,21 +183,11 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
|
||||
go h.chatPersistLoop()
|
||||
h.loadChatHistory()
|
||||
if s != nil {
|
||||
go h.trackToolEvents()
|
||||
h.subscribeChatEvents()
|
||||
h.subscribeTerminalStream()
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func getStr(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
v, _ := m[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func (h *Handler) getWebUIConfig() (apiKey, username, password string, ttl time.Duration) {
|
||||
ttl = 24 * time.Hour
|
||||
if h.settings == nil {
|
||||
|
||||
@ -122,15 +122,6 @@ func (h *Handler) loadChatHistory() {
|
||||
h.chatMu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Handler) trackToolEvents() {
|
||||
if h.sdk == nil {
|
||||
return
|
||||
}
|
||||
h.sdk.Subscribe(sdk.EventToolCall, func(ev *sdk.Event) {
|
||||
h.handleToolEvent(ev)
|
||||
})
|
||||
}
|
||||
|
||||
// subscribeChatEvents 捕获所有通道(cli/qq/webui 等)的对话轮次,
|
||||
// 与 handleChat 的注入一起构成完整的全通道对话历史。
|
||||
func (h *Handler) subscribeChatEvents() {
|
||||
@ -407,80 +398,6 @@ func (h *Handler) pendingAssistantLocked() *ChatMsg {
|
||||
return msg
|
||||
}
|
||||
|
||||
func (h *Handler) handleToolEvent(ev *sdk.Event) {
|
||||
payload := ev.Payload
|
||||
tool, _ := payload["tool"].(string)
|
||||
args, _ := payload["args"].(map[string]interface{})
|
||||
status, _ := payload["status"].(string)
|
||||
ts := time.Now()
|
||||
|
||||
switch tool {
|
||||
case "cmd_run":
|
||||
exec := CmdExec{
|
||||
Command: getStr(args, "command"),
|
||||
Status: status,
|
||||
Time: ts.Format(time.RFC3339),
|
||||
}
|
||||
h.cmdMu.Lock()
|
||||
h.cmdHistory = append(h.cmdHistory, exec)
|
||||
if len(h.cmdHistory) > maxCmdHistory {
|
||||
h.cmdHistory = h.cmdHistory[len(h.cmdHistory)-maxCmdHistory:]
|
||||
}
|
||||
h.cmdMu.Unlock()
|
||||
|
||||
case "terminal_create":
|
||||
id := getStr(args, "id")
|
||||
if id == "" {
|
||||
// agent 调用时不知道生成的 id,从工具结果中回填
|
||||
if res, ok := payload["result"].(map[string]interface{}); ok {
|
||||
id = getStr(res, "id")
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
break
|
||||
}
|
||||
cmd := getStr(args, "command")
|
||||
if cmd == "" {
|
||||
if res, ok := payload["result"].(map[string]interface{}); ok {
|
||||
cmd = getStr(res, "command")
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
term := &termState{
|
||||
ID: id,
|
||||
Command: cmd,
|
||||
Running: true,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
created: now,
|
||||
}
|
||||
h.termMu.Lock()
|
||||
if old, ok := h.termStates[id]; ok {
|
||||
old.Command = cmd
|
||||
old.Running = true
|
||||
old.created = now
|
||||
} else {
|
||||
h.termStates[id] = term
|
||||
}
|
||||
if len(h.termStates) > maxTerminals {
|
||||
for k := range h.termStates {
|
||||
delete(h.termStates, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
h.termMu.Unlock()
|
||||
|
||||
case "terminal_close":
|
||||
id := getStr(args, "id")
|
||||
if id != "" {
|
||||
h.termMu.Lock()
|
||||
if t, ok := h.termStates[id]; ok {
|
||||
t.Running = false
|
||||
}
|
||||
h.termMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bumpSeqLocked 分配下一个聊天序号(调用方须持 chatMu)。
|
||||
// 序号单调递增、随记录落盘,作为 /chat/history?after= 的增量游标。
|
||||
func (h *Handler) bumpSeqLocked() int64 {
|
||||
|
||||
@ -1,88 +1,44 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"time"
|
||||
"net/http"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// 终端面:持久终端会话状态、终端接口、命令历史。
|
||||
// 终端面:终端会话列表与命令历史接口。
|
||||
//
|
||||
// 「内核开,两个插件接」后,终端会话与命令历史的权威视图在内核
|
||||
// (internal/agent/core/terminal_registry.go):内核订阅自己的事件总线
|
||||
// 归并 EventToolCall(terminal_create/close、cmd_run)与 EventTerminalOutput
|
||||
// (agentcli 生命周期 + 输出)。WebUI 不再自己订阅事件攒一份——直接读内核
|
||||
// 开放的 TerminalAPI(s.Terminal()),与 CLI 同源同口径。
|
||||
//
|
||||
// 为什么不塞进 KernelStatus:那是全量快照,前端每 3 秒轮询 /kernel,
|
||||
// 把每终端最多 64KB 的输出缓冲背进去会让轮询成本爆炸。
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const maxCmdHistory = 100
|
||||
|
||||
const maxTerminals = 50
|
||||
|
||||
// subscribeTerminalStream 常驻订阅终端实时画面推流(terminal_output 事件),
|
||||
// 维护 termStates 的 Running 状态与全量输出缓冲,供 /api/v1/terminals 与前端轮询使用。
|
||||
func (h *Handler) subscribeTerminalStream() {
|
||||
if h.sdk == nil {
|
||||
// handleTerminals 返回内核权威的终端会话快照。
|
||||
func (h *Handler) handleTerminals(w http.ResponseWriter, r *http.Request) {
|
||||
if h.term == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"terminals": []interface{}{}})
|
||||
return
|
||||
}
|
||||
h.sdk.Subscribe(sdk.EventTerminalOutput, func(ev *sdk.Event) {
|
||||
id, _ := ev.Payload["terminal_id"].(string)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
output, _ := ev.Payload["output"].(string)
|
||||
running, _ := ev.Payload["running"].(bool)
|
||||
h.termMu.Lock()
|
||||
ts, ok := h.termStates[id]
|
||||
if !ok {
|
||||
ts = &termState{ID: id, created: time.Now()}
|
||||
h.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
|
||||
}
|
||||
h.termMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleTerminals(w http.ResponseWriter, r *http.Request) {
|
||||
h.termMu.Lock()
|
||||
terms := make([]*termState, 0, len(h.termStates))
|
||||
for _, ts := range h.termStates {
|
||||
ts.Uptime = time.Since(ts.created).Round(time.Second).String()
|
||||
terms = append(terms, ts)
|
||||
terms := h.term.ListTerminals()
|
||||
if terms == nil {
|
||||
terms = []sdk.TerminalStatus{}
|
||||
}
|
||||
h.termMu.Unlock()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"terminals": terms})
|
||||
}
|
||||
|
||||
// handleCmdHistory 返回内核权威的命令执行历史快照。
|
||||
func (h *Handler) handleCmdHistory(w http.ResponseWriter, r *http.Request) {
|
||||
h.cmdMu.Lock()
|
||||
result := make([]CmdExec, len(h.cmdHistory))
|
||||
copy(result, h.cmdHistory)
|
||||
h.cmdMu.Unlock()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"history": result})
|
||||
if h.term == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"history": []interface{}{}})
|
||||
return
|
||||
}
|
||||
cmds := h.term.CmdHistory()
|
||||
if cmds == nil {
|
||||
cmds = []sdk.CmdExecStatus{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"history": cmds})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user