feat: Agent CLI 与终端审计回放全面优化
- Agent CLI: 多轮对话、SSH 长连接、流式推理、Evolver 按用户隔离、历史侧栏 - 终端审计: 回放播放器、命令书签、筛选分页导出、命令输出统计、录制落盘修复 - 修复 session_commands 表迁移、Kill 竞态导致录制丢失、无录制会话回放提示 - 审计历史页只读(移除删除),移除回放弹框录制搜索
This commit is contained in:
1 parent
9a3dd304f7
commit
1acb0c49af
81 files changed
+3916
-1655
No files matched your search
+171
-37
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user