feat: Agent CLI 与终端审计回放全面优化

- Agent CLI: 多轮对话、SSH 长连接、流式推理、Evolver 按用户隔离、历史侧栏
- 终端审计: 回放播放器、命令书签、筛选分页导出、命令输出统计、录制落盘修复
- 修复 session_commands 表迁移、Kill 竞态导致录制丢失、无录制会话回放提示
- 审计历史页只读(移除删除),移除回放弹框录制搜索
This commit is contained in:
Logan committed 2026-07-01 12:13:53 +08:00
1 parent 9a3dd304f7
commit 1acb0c49af
81 files changed
+3916 -1655

No files matched your search

+171 -37
View File
@@ -54,9 +54,10 @@ type wsEvent struct {
// wsControl 前端→后端控制消息 // Logan.Li
type wsControl struct {
Type string `json:"type"` // start/confirm/deny/abort
Query string `json:"query,omitempty"` // start 时任务描述
ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时
Type string `json:"type"` // start/continue/confirm/deny/abort
Query string `json:"query,omitempty"` // start 时任务描述
SafetyMode string `json:"safety_mode,omitempty"` // strict/balanced/permissive
ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时
}
// writeWSCloseError 在 agentConn 建立前,直接通过 conn 推送一条 error 事件再关闭连接 // Logan.Li
@@ -73,15 +74,21 @@ func writeWSCloseError(conn *websocket.Conn, msg string) {
// agentConn 单个 WS 连接的运行态 // Logan.Li
type agentConn struct {
conn *websocket.Conn
db *gorm.DB
userID uint
server model.Server
session *model.AgentCLISession
cancel context.CancelFunc
writeMu sync.Mutex
confirms map[string]chan bool
cfMu sync.Mutex
conn *websocket.Conn
db *gorm.DB
userID uint
server model.Server
session *model.AgentCLISession
taskCtx context.Context
cancel context.CancelFunc
totalTokens int
tokenMu sync.Mutex
writeMu sync.Mutex
confirms map[string]chan bool
cfMu sync.Mutex
convHistory []llm.Message // 多轮追问对话历史(不含 system) // Logan.Li
running bool
runningMu sync.Mutex
}
// Run WebSocket 入口:GET /ws/agent-cli?id=<serverID>&token=<jwt> // Logan.Li
@@ -121,6 +128,7 @@ func (h *AgentCLIHandler) Run(c *gin.Context) {
ac := &agentConn{
conn: conn, db: h.db,
userID: middleware.GetUserID(c), server: server,
taskCtx: context.Background(),
confirms: make(map[string]chan bool),
}
// 推送主机上下文
@@ -144,7 +152,13 @@ func (h *AgentCLIHandler) Run(c *gin.Context) {
ac.write(wsEvent{Type: "error", Message: "任务描述不能为空"})
continue
}
ac.startTask(sshCfg, llmCfg, ctrl.Query)
ac.startTask(sshCfg, ctrl.Query, ctrl.SafetyMode)
case "continue":
if ctrl.Query == "" {
ac.write(wsEvent{Type: "error", Message: "追问内容不能为空"})
continue
}
ac.continueTask(sshCfg, ctrl.Query, ctrl.SafetyMode)
case "confirm":
ac.resolveConfirm(ctrl.ConfirmID, true)
case "deny":
@@ -155,11 +169,28 @@ func (h *AgentCLIHandler) Run(c *gin.Context) {
}
}
// startTask 启动一次任务(新会话 + 新引擎) // Logan.Li
func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConfig, query string) {
ac.abort() // 取消上一次任务
// parseSafetyMode 解析前端传入的安全模式 // Logan.Li
func parseSafetyMode(mode string) safety.Mode {
switch mode {
case "strict":
return safety.ModeStrict
case "permissive":
return safety.ModePermissive
default:
return safety.ModeBalanced
}
}
// startTask 启动新任务(新会话,清空对话历史) // Logan.Li
func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, query, safetyMode string) {
ac.abort()
var llmCfg model.LLMConfig
if err := ac.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled {
ac.write(wsEvent{Type: "error", Message: "LLM 服务未配置或未启用,请先在「大模型设置」中配置"})
return
}
// 创建 DB 会话
title := query
if r := []rune(title); len(r) > 60 {
title = string(r[:60]) + "..."
@@ -175,38 +206,96 @@ func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConf
return
}
ac.session = sess
ac.convHistory = nil
ac.setTotalTokens(0)
ac.runTask(sshCfg, sess, query, safetyMode, llmCfg, false)
}
// 存用户消息
ac.persistMsg(sess.ID, 0, "user", "user", query, "", "", "", "", false, "", 0)
// continueTask 在同一会话内追问(保留上下文) // Logan.Li
func (ac *agentConn) continueTask(sshCfg remoteexec.SSHConfig, query, safetyMode string) {
if ac.isRunning() {
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
return
}
if ac.session == nil {
ac.write(wsEvent{Type: "error", Message: "请先开始新任务"})
return
}
if ac.session.Status == "running" {
ac.write(wsEvent{Type: "error", Message: "当前会话仍在执行中"})
return
}
var llmCfg model.LLMConfig
if err := ac.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled {
ac.write(wsEvent{Type: "error", Message: "LLM 服务未配置或未启用,请先在「大模型设置」中配置"})
return
}
ac.runTask(sshCfg, ac.session, query, safetyMode, llmCfg, true)
}
// runTask 执行一轮 ReAct(新建或追问共用) // Logan.Li
func (ac *agentConn) runTask(sshCfg remoteexec.SSHConfig, sess *model.AgentCLISession, query, safetyMode string, llmCfg model.LLMConfig, isContinue bool) {
if ac.isRunning() {
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
return
}
prior := ac.convHistory
priorTokens := ac.getTotalTokens()
roundOffset := 0
if !isContinue {
prior = nil
priorTokens = 0
} else {
roundOffset = ac.lastRound()
}
coreCfg := core.DefaultConfig()
coreCfg.RoundOffset = roundOffset
// 构建引擎
engine, err := agentcli.NewEngine(agentcli.EngineConfig{
LLM: llm.Config{
Protocol: resolveProtocol(llmCfg.Provider),
Endpoint: resolveEndpoint(llmCfg.Provider, llmCfg.Endpoint),
APIKey: llmCfg.APIKey, Model: llmCfg.Model,
Temperature: llmCfg.Temperature, MaxTokens: llmCfg.MaxTokens,
},
LLM: buildLLMConfigFromModel(llmCfg),
SSH: sshCfg,
SafetyMode: safety.ModeBalanced,
SafetyMode: parseSafetyMode(safetyMode),
MemoryDir: fmt.Sprintf("data/agentcli/user_%d", ac.userID),
Core: coreCfg,
})
if err != nil {
ac.finish(sess, "error", "引擎初始化失败: "+err.Error(), 0, time.Duration(0))
ac.finish(sess, "error", "引擎初始化失败: "+err.Error(), ac.lastRound(), 0)
return
}
engine.SetCallback(ac.onEvent)
engine.SetConfirmFn(ac.confirmFn)
ctx, cancel := context.WithCancel(context.Background())
ac.taskCtx = ctx
ac.cancel = cancel
serverInfo := fmt.Sprintf("主机名: %s | IP: %s:%d | 用户: %s | OS: %s", ac.server.Hostname, ac.server.IP, ac.server.Port, ac.server.Username, ac.server.OS)
ac.setRunning(true)
ac.db.Model(sess).Update("status", "running")
ac.write(wsEvent{Type: "session", SessionID: sess.ID, Message: "任务已开始"})
ac.persistMsg(sess.ID, 0, "user", "user", query, "", "", "", "", false, "", 0)
serverInfo := fmt.Sprintf("主机名: %s | IP: %s:%d | 用户: %s | OS: %s",
ac.server.Hostname, ac.server.IP, ac.server.Port, ac.server.Username, ac.server.OS)
msg := "任务已开始"
if isContinue {
msg = "继续追问"
}
ac.write(wsEvent{Type: "session", SessionID: sess.ID, Message: msg})
go func() {
defer engine.Close()
defer func() {
ac.setRunning(false)
engine.Close()
}()
start := time.Now()
_, err := engine.Run(ctx, query, serverInfo)
result, err := engine.Run(ctx, query, serverInfo, prior, priorTokens)
if result != nil && len(result.History) > 0 {
ac.convHistory = result.History
ac.setTotalTokens(result.TotalTokens)
}
dur := time.Since(start)
if ctx.Err() != nil {
ac.finish(sess, "aborted", "任务已中断", ac.lastRound(), dur)
@@ -218,8 +307,37 @@ func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConf
}()
}
func (ac *agentConn) isRunning() bool {
ac.runningMu.Lock()
defer ac.runningMu.Unlock()
return ac.running
}
func (ac *agentConn) setRunning(v bool) {
ac.runningMu.Lock()
ac.running = v
ac.runningMu.Unlock()
}
// setTotalTokens 更新当前任务 token 计数 // Logan.Li
func (ac *agentConn) setTotalTokens(n int) {
ac.tokenMu.Lock()
ac.totalTokens = n
ac.tokenMu.Unlock()
}
func (ac *agentConn) getTotalTokens() int {
ac.tokenMu.Lock()
defer ac.tokenMu.Unlock()
return ac.totalTokens
}
// onEvent 引擎事件回调:推 WS + 持久化 // Logan.Li
func (ac *agentConn) onEvent(e core.Event) {
if e.Type == core.EventProgress && e.Tokens > 0 {
ac.setTotalTokens(e.Tokens)
}
ev := wsEvent{
Type: string(e.Type), Round: e.Round, Thought: e.Thought,
ToolName: e.ToolName, ToolArgs: e.ToolArgs, Risk: e.Risk,
@@ -231,7 +349,7 @@ func (ac *agentConn) onEvent(e core.Event) {
}
ac.write(ev)
// 持久化(progress 太频繁不入库)
// 持久化(progress / thought_delta 太频繁不入库) // Logan.Li
if ac.session == nil {
return
}
@@ -245,6 +363,7 @@ func (ac *agentConn) onEvent(e core.Event) {
case core.EventToolResult:
ac.persistMsg(sid, e.Round, "tool_result", "tool", e.Output, e.ToolName, "", "", "", e.Success, e.Duration, 0)
case core.EventDone:
ac.setTotalTokens(e.Tokens)
ac.persistMsg(sid, e.Round, "done", "assistant", e.Answer, "", "", "", "", true, "", e.Tokens)
case core.EventError:
ac.persistMsg(sid, e.Round, "error", "assistant", e.Message, "", "", "", "", false, "", 0)
@@ -265,6 +384,9 @@ func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, ris
select {
case approved := <-ch:
return approved, nil
case <-ac.taskCtx.Done():
ac.delConfirm(confirmID)
return false, ac.taskCtx.Err()
case <-time.After(5 * time.Minute):
ac.delConfirm(confirmID)
return false, fmt.Errorf("确认超时")
@@ -290,12 +412,21 @@ func (ac *agentConn) delConfirm(id string) {
ac.cfMu.Unlock()
}
// abort 中断当前任务 // Logan.Li
// abort 中断当前任务并唤醒所有等待中的确认 // Logan.Li
func (ac *agentConn) abort() {
if ac.cancel != nil {
ac.cancel()
ac.cancel = nil
}
ac.cfMu.Lock()
for id, ch := range ac.confirms {
select {
case ch <- false:
default:
}
delete(ac.confirms, id)
}
ac.cfMu.Unlock()
}
// lastRound 估算已完成轮次(从 DB 取该会话最大 round) // Logan.Li
@@ -309,13 +440,16 @@ func (ac *agentConn) lastRound() int {
return maxRound
}
// finish 结束会话并更新 DB // Logan.Li
// finish 结束本轮执行并更新会话 DB // Logan.Li
func (ac *agentConn) finish(sess *model.AgentCLISession, status, errMsg string, rounds int, dur time.Duration) {
ac.setRunning(false)
tokens := ac.getTotalTokens()
if status == "done" || status == "aborted" || status == "error" {
ac.write(wsEvent{Type: status, SessionID: sess.ID, Message: errMsg, Round: rounds})
ac.write(wsEvent{Type: status, SessionID: sess.ID, Message: errMsg, Round: rounds, Tokens: tokens})
}
updates := map[string]interface{}{
"status": status, "rounds": rounds, "duration": dur.Milliseconds(),
"total_tokens": tokens,
}
if errMsg != "" {
updates["error"] = errMsg
@@ -342,4 +476,4 @@ func (ac *agentConn) persistMsg(sid uint, round int, typ, role, content, toolNam
// 持久化失败不阻断主流程
_ = err
}
}
}