feat: 新增 Agent CLI — Web 版 AI 运维 Agent(参考 opsxcli 移植)
后端引擎 backend/pkg/agentcli/(纯引擎库,零业务依赖,剥离 CLI/TUI 外壳): - llm: OpenAI 兼容客户端,复用 model.LLMConfig - tools: ssh_execute(复用 pkg/remoteexec)+ analyze_output + 风险评估黑名单 - safety: 三级安全模式 + 五级风险 + WebSocket 异步确认 - prompt: 五层 System Prompt - core: ReAct 16 轮循环 + 自适应检查点 + 循环检测 - evolver: 四层记忆 + 进化循环,data/agentcli/ 文件存储 集成层: - controller/agent_cli.go: WebSocket 控制器(事件流推流 + 高危确认 + 会话持久化) - model/agent_cli.go: agent_cli_sessions/messages 表 + AutoMigrate - 路由 /ws/agent-cli + /api/v1/aiops/agent-cli/sessions - seed 权限 aiops:agent-cli / aiops:llm-config 前端: - AgentCLI.tsx: 对话流 + 工具块 + 高危确认 + 四模块 tips 面板 - LLMConfig.tsx: 大模型设置独立页 - api/agentCli.ts + Sidebar/App 菜单路由 文档: - docs/agent/ 11 篇(opsxcli 同名文档适配改写 + 00-README 映射总览) 其他: - CLAUDE.md 补充 agent-cli 模块说明 - 修正 .gitignore 根级二进制规则(前导 /,避免误伤 frontend/src/components/aiops/) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
f2069fe3f0
commit
92a2247390
37 files changed
+6449
-6
No files matched your search
@@ -0,0 +1,328 @@
|
||||
// agent_cli.go - Agent CLI WebSocket 控制器 // Logan.Li
|
||||
// 驱动 pkg/agentcli 引擎:关联主机(model.Server 凭据)→ 构建引擎 → ReAct 循环,
|
||||
// 通过 WebSocket 实时推送思考/工具调用/输出/结果,高危命令经前端确认,过程持久化到 DB。
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ezops/middleware"
|
||||
"ezops/model"
|
||||
"ezops/pkg/agentcli"
|
||||
"ezops/pkg/agentcli/core"
|
||||
"ezops/pkg/agentcli/llm"
|
||||
"ezops/pkg/agentcli/safety"
|
||||
"ezops/pkg/agentcli/tools"
|
||||
"ezops/pkg/remoteexec"
|
||||
)
|
||||
|
||||
// AgentCLIHandler Agent CLI 控制器 // Logan.Li
|
||||
type AgentCLIHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAgentCLIHandler 创建控制器 // Logan.Li
|
||||
func NewAgentCLIHandler(db *gorm.DB) *AgentCLIHandler { return &AgentCLIHandler{db: db} }
|
||||
|
||||
// wsEvent WebSocket 事件信封(后端→前端) // Logan.Li
|
||||
type wsEvent struct {
|
||||
Type string `json:"type"`
|
||||
Round int `json:"round,omitempty"`
|
||||
SessionID uint `json:"session_id,omitempty"`
|
||||
Thought string `json:"thought,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
ToolArgs map[string]interface{} `json:"tool_args,omitempty"`
|
||||
Risk string `json:"risk,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Success bool `json:"success,omitempty"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
Tokens int `json:"tokens,omitempty"`
|
||||
Answer string `json:"answer,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
ConfirmID string `json:"confirm_id,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
}
|
||||
|
||||
// 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 时
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Run WebSocket 入口:GET /ws/agent-cli?id=<serverID>&token=<jwt> // Logan.Li
|
||||
func (h *AgentCLIHandler) Run(c *gin.Context) {
|
||||
serverID := c.Query("id")
|
||||
if serverID == "" {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少主机 id"})
|
||||
return
|
||||
}
|
||||
var server model.Server
|
||||
if err := h.db.First(&server, serverID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "主机不存在"})
|
||||
return
|
||||
}
|
||||
sshCfg, err := buildSSHConfig(h.db, server)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
// LLM 配置
|
||||
var llmCfg model.LLMConfig
|
||||
if err := h.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "LLM 服务未配置或未启用,请先在「大模型设置」中配置"})
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ac := &agentConn{
|
||||
conn: conn, db: h.db,
|
||||
userID: middleware.GetUserID(c), server: server,
|
||||
confirms: make(map[string]chan bool),
|
||||
}
|
||||
// 推送主机上下文
|
||||
ac.write(wsEvent{Type: "session", SessionID: 0,
|
||||
Message: fmt.Sprintf("已连接主机 %s(%s),LLM: %s。请描述要执行的任务。", server.Hostname, server.IP, llmCfg.Model)})
|
||||
|
||||
// 读循环:处理前端控制消息
|
||||
for {
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
ac.abort()
|
||||
return
|
||||
}
|
||||
var ctrl wsControl
|
||||
if json.Unmarshal(data, &ctrl) != nil {
|
||||
continue
|
||||
}
|
||||
switch ctrl.Type {
|
||||
case "start":
|
||||
if ctrl.Query == "" {
|
||||
ac.write(wsEvent{Type: "error", Message: "任务描述不能为空"})
|
||||
continue
|
||||
}
|
||||
ac.startTask(sshCfg, llmCfg, ctrl.Query)
|
||||
case "confirm":
|
||||
ac.resolveConfirm(ctrl.ConfirmID, true)
|
||||
case "deny":
|
||||
ac.resolveConfirm(ctrl.ConfirmID, false)
|
||||
case "abort":
|
||||
ac.abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startTask 启动一次任务(新会话 + 新引擎) // Logan.Li
|
||||
func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConfig, query string) {
|
||||
ac.abort() // 取消上一次任务
|
||||
|
||||
// 创建 DB 会话
|
||||
title := query
|
||||
if r := []rune(title); len(r) > 60 {
|
||||
title = string(r[:60]) + "..."
|
||||
}
|
||||
sess := &model.AgentCLISession{
|
||||
UserID: ac.userID, ServerID: ac.server.ID,
|
||||
ServerName: ac.server.Hostname, ServerHost: ac.server.IP,
|
||||
Title: title, Status: "running",
|
||||
Provider: llmCfg.Provider, Model: llmCfg.Model,
|
||||
}
|
||||
if err := ac.db.Create(sess).Error; err != nil {
|
||||
ac.write(wsEvent{Type: "error", Message: "创建会话失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
ac.session = sess
|
||||
|
||||
// 存用户消息
|
||||
ac.persistMsg(sess.ID, 0, "user", "user", query, "", "", "", "", false, "", 0)
|
||||
|
||||
// 构建引擎
|
||||
engine, err := agentcli.NewEngine(agentcli.EngineConfig{
|
||||
LLM: llm.Config{
|
||||
Endpoint: resolveEndpoint(llmCfg.Provider, llmCfg.Endpoint),
|
||||
APIKey: llmCfg.APIKey, Model: llmCfg.Model,
|
||||
Temperature: llmCfg.Temperature, MaxTokens: llmCfg.MaxTokens,
|
||||
},
|
||||
SSH: sshCfg,
|
||||
SafetyMode: safety.ModeBalanced,
|
||||
})
|
||||
if err != nil {
|
||||
ac.finish(sess, "error", "引擎初始化失败: "+err.Error(), 0, time.Duration(0))
|
||||
return
|
||||
}
|
||||
engine.SetCallback(ac.onEvent)
|
||||
engine.SetConfirmFn(ac.confirmFn)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
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.write(wsEvent{Type: "session", SessionID: sess.ID, Message: "任务已开始"})
|
||||
|
||||
go func() {
|
||||
defer engine.Close()
|
||||
start := time.Now()
|
||||
_, err := engine.Run(ctx, query, serverInfo)
|
||||
dur := time.Since(start)
|
||||
if ctx.Err() != nil {
|
||||
ac.finish(sess, "aborted", "任务已中断", ac.lastRound(), dur)
|
||||
} else if err != nil {
|
||||
ac.finish(sess, "error", err.Error(), ac.lastRound(), dur)
|
||||
} else {
|
||||
ac.finish(sess, "done", "", ac.lastRound(), dur)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// onEvent 引擎事件回调:推 WS + 持久化 // Logan.Li
|
||||
func (ac *agentConn) onEvent(e core.Event) {
|
||||
ev := wsEvent{
|
||||
Type: string(e.Type), Round: e.Round, Thought: e.Thought,
|
||||
ToolName: e.ToolName, ToolArgs: e.ToolArgs, Risk: e.Risk,
|
||||
Output: e.Output, Success: e.Success, Duration: e.Duration,
|
||||
Tokens: e.Tokens, Answer: e.Answer, Message: e.Message,
|
||||
}
|
||||
if ac.session != nil {
|
||||
ev.SessionID = ac.session.ID
|
||||
}
|
||||
ac.write(ev)
|
||||
|
||||
// 持久化(progress 太频繁不入库)
|
||||
if ac.session == nil {
|
||||
return
|
||||
}
|
||||
sid := ac.session.ID
|
||||
switch e.Type {
|
||||
case core.EventThought:
|
||||
ac.persistMsg(sid, e.Round, "thought", "assistant", e.Thought, "", "", "", "", false, "", 0)
|
||||
case core.EventToolCall:
|
||||
argsJSON, _ := json.Marshal(e.ToolArgs)
|
||||
ac.persistMsg(sid, e.Round, "tool_call", "tool", "", e.ToolName, string(argsJSON), "", e.Risk, false, "", 0)
|
||||
case core.EventToolResult:
|
||||
ac.persistMsg(sid, e.Round, "tool_result", "tool", e.Output, e.ToolName, "", "", "", e.Success, e.Duration, 0)
|
||||
case core.EventDone:
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// confirmFn 高危命令确认回调:推 confirm 事件并阻塞等前端回传 // Logan.Li
|
||||
func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, risk tools.RiskLevel) (bool, error) {
|
||||
confirmID := fmt.Sprintf("cf-%d", time.Now().UnixNano())
|
||||
cmd, _ := args["command"].(string)
|
||||
ac.write(wsEvent{Type: "confirm", ConfirmID: confirmID, ToolName: toolName, Command: cmd, Risk: risk.Display(), ToolArgs: args})
|
||||
|
||||
ch := make(chan bool, 1)
|
||||
ac.cfMu.Lock()
|
||||
ac.confirms[confirmID] = ch
|
||||
ac.cfMu.Unlock()
|
||||
|
||||
select {
|
||||
case approved := <-ch:
|
||||
return approved, nil
|
||||
case <-time.After(5 * time.Minute):
|
||||
ac.delConfirm(confirmID)
|
||||
return false, fmt.Errorf("确认超时")
|
||||
}
|
||||
}
|
||||
|
||||
// resolveConfirm 前端回传确认结果 // Logan.Li
|
||||
func (ac *agentConn) resolveConfirm(confirmID string, approved bool) {
|
||||
ac.cfMu.Lock()
|
||||
ch, ok := ac.confirms[confirmID]
|
||||
if ok {
|
||||
delete(ac.confirms, confirmID)
|
||||
}
|
||||
ac.cfMu.Unlock()
|
||||
if ok {
|
||||
ch <- approved
|
||||
}
|
||||
}
|
||||
|
||||
func (ac *agentConn) delConfirm(id string) {
|
||||
ac.cfMu.Lock()
|
||||
delete(ac.confirms, id)
|
||||
ac.cfMu.Unlock()
|
||||
}
|
||||
|
||||
// abort 中断当前任务 // Logan.Li
|
||||
func (ac *agentConn) abort() {
|
||||
if ac.cancel != nil {
|
||||
ac.cancel()
|
||||
ac.cancel = nil
|
||||
}
|
||||
}
|
||||
|
||||
// lastRound 估算已完成轮次(从 DB 取该会话最大 round) // Logan.Li
|
||||
func (ac *agentConn) lastRound() int {
|
||||
if ac.session == nil {
|
||||
return 0
|
||||
}
|
||||
var maxRound int
|
||||
ac.db.Model(&model.AgentCLIMessage{}).Where("session_id = ?", ac.session.ID).
|
||||
Select("COALESCE(MAX(round),0)").Scan(&maxRound)
|
||||
return maxRound
|
||||
}
|
||||
|
||||
// finish 结束会话并更新 DB // Logan.Li
|
||||
func (ac *agentConn) finish(sess *model.AgentCLISession, status, errMsg string, rounds int, dur time.Duration) {
|
||||
if status == "done" || status == "aborted" || status == "error" {
|
||||
ac.write(wsEvent{Type: status, SessionID: sess.ID, Message: errMsg, Round: rounds})
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"status": status, "rounds": rounds, "duration": dur.Milliseconds(),
|
||||
}
|
||||
if errMsg != "" {
|
||||
updates["error"] = errMsg
|
||||
}
|
||||
ac.db.Model(sess).Updates(updates)
|
||||
}
|
||||
|
||||
// write 线程安全写 WS 消息 // Logan.Li
|
||||
func (ac *agentConn) write(ev wsEvent) {
|
||||
ac.writeMu.Lock()
|
||||
defer ac.writeMu.Unlock()
|
||||
b, _ := json.Marshal(ev)
|
||||
_ = ac.conn.WriteMessage(websocket.TextMessage, b)
|
||||
}
|
||||
|
||||
// persistMsg 持久化一条消息 // Logan.Li
|
||||
func (ac *agentConn) persistMsg(sid uint, round int, typ, role, content, toolName, args, output, risk string, success bool, dur string, tokens int) {
|
||||
msg := &model.AgentCLIMessage{
|
||||
SessionID: sid, Round: round, Type: typ, Role: role, Content: content,
|
||||
ToolName: toolName, ToolArgs: args, ToolOutput: output, Risk: risk,
|
||||
Success: success, Duration: dur, Tokens: tokens,
|
||||
}
|
||||
if err := ac.db.Create(msg).Error; err != nil {
|
||||
// 持久化失败不阻断主流程
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// agent_cli_api.go - Agent CLI 会话历史 REST 接口 // Logan.Li
|
||||
// 供前端展示历史任务列表与回放某次任务的完整推理过程。
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ezops/middleware"
|
||||
"ezops/model"
|
||||
)
|
||||
|
||||
// ListSessions 会话列表(按当前用户隔离) // Logan.Li
|
||||
// GET /api/v1/aiops/agent-cli/sessions?server_id=&page=&page_size=
|
||||
func (h *AgentCLIHandler) ListSessions(c *gin.Context) {
|
||||
uid := middleware.GetUserID(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if pageSize <= 0 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
q := h.db.Model(&model.AgentCLISession{}).Where("user_id = ?", uid)
|
||||
if sid := c.Query("server_id"); sid != "" {
|
||||
q = q.Where("server_id = ?", sid)
|
||||
}
|
||||
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var sessions []model.AgentCLISession
|
||||
if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&sessions).Error; err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "查询失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": sessions, "total": total}})
|
||||
}
|
||||
|
||||
// GetSession 会话详情(含完整推理消息,供回放) // Logan.Li
|
||||
// GET /api/v1/aiops/agent-cli/sessions/:id
|
||||
func (h *AgentCLIHandler) GetSession(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var sess model.AgentCLISession
|
||||
if err := h.db.First(&sess, id).Error; err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"})
|
||||
return
|
||||
}
|
||||
var msgs []model.AgentCLIMessage
|
||||
h.db.Where("session_id = ?", id).Order("id ASC").Find(&msgs)
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"session": sess, "messages": msgs}})
|
||||
}
|
||||
|
||||
// DeleteSession 删除会话及其消息 // Logan.Li
|
||||
// DELETE /api/v1/aiops/agent-cli/sessions/:id
|
||||
func (h *AgentCLIHandler) DeleteSession(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.db.Where("session_id = ?", id).Delete(&model.AgentCLIMessage{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "删除消息失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.db.Delete(&model.AgentCLISession{}, id).Error; err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "删除会话失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已删除"})
|
||||
}
|
||||
Reference in new issue
Block a user