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
}
}
}
+8 -1
View File
@@ -43,9 +43,10 @@ func (h *AgentCLIHandler) ListSessions(c *gin.Context) {
// GetSession 会话详情(含完整推理消息,供回放) // Logan.Li
// GET /api/v1/aiops/agent-cli/sessions/:id
func (h *AgentCLIHandler) GetSession(c *gin.Context) {
uid := middleware.GetUserID(c)
id := c.Param("id")
var sess model.AgentCLISession
if err := h.db.First(&sess, id).Error; err != nil {
if err := h.db.Where("user_id = ?", uid).First(&sess, id).Error; err != nil {
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"})
return
}
@@ -57,7 +58,13 @@ func (h *AgentCLIHandler) GetSession(c *gin.Context) {
// DeleteSession 删除会话及其消息 // Logan.Li
// DELETE /api/v1/aiops/agent-cli/sessions/:id
func (h *AgentCLIHandler) DeleteSession(c *gin.Context) {
uid := middleware.GetUserID(c)
id := c.Param("id")
var sess model.AgentCLISession
if err := h.db.Where("user_id = ?", uid).First(&sess, id).Error; err != nil {
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"})
return
}
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
+222 -22
View File
@@ -1,6 +1,7 @@
package controller
import (
"context"
"fmt"
"log"
"net"
@@ -25,6 +26,56 @@ func NewAgentInstallHandler(db *gorm.DB) *AgentInstallHandler {
return &AgentInstallHandler{db: db}
}
// auditCtx 快照请求上下文(用户身份 + 来源 IP),用于在异步 goroutine 中安全记录审计 // Logan.Li
// gin.Context 在请求返回后会被 gin 池复用,goroutine 内禁止直接访问 c,必须提前快照
type auditCtx struct {
userID uint
username string
ip string
userAgent string
}
// newAuditCtx 从 gin.Context 提取审计所需上下文快照 // Logan.Li
func newAuditCtx(c *gin.Context) auditCtx {
uid, _ := c.Get("userID")
uname, _ := c.Get("username")
id, _ := uid.(uint)
name, _ := uname.(string)
return auditCtx{
userID: id,
username: name,
ip: c.ClientIP(),
userAgent: c.Request.UserAgent(),
}
}
// truncateAuditText 截断过长输出,避免审计 Error 字段膨胀 // Logan.Li
func truncateAuditText(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) > n {
return s[:n] + "..."
}
return s
}
// recordAgentAudit 记录 Agent 安装/卸载/升级操作审计 // Logan.Li
// status: 1=成功, 0=失败;errMsg 记录失败原因(SSH 错误、脚本输出等),用于追溯"安装过程遇到什么问题"
func (h *AgentInstallHandler) recordAgentAudit(ac auditCtx, action, resource, resourceID string, status int, errMsg string) {
uid := ac.userID
h.db.Create(&model.AuditLog{
UserID: &uid,
Username: ac.username,
IP: ac.ip,
UserAgent: ac.userAgent,
Module: "agent",
Action: action,
Resource: resource,
ResourceID: resourceID,
Status: status,
Error: errMsg,
})
}
// sshDial 复用 webshell.go 的 SSH 连接逻辑(TOFU HostKey 验证)
func sshDial(server model.Server) (*ssh.Client, error) {
config := &ssh.ClientConfig{
@@ -54,16 +105,77 @@ func sshDial(server model.Server) (*ssh.Client, error) {
return ssh.Dial("tcp", addr, config)
}
// runSSHCommand 在远程主机执行命令并返回输出
// runSSHCommand 在远程主机执行命令并返回输出(默认 60s 超时,防止 sudo 密码等待导致挂死)// Logan.Li
func runSSHCommand(client *ssh.Client, cmd string) (string, error) {
return runSSHCommandWithTimeout(client, cmd, 60*time.Second)
}
// runSSHCommandWithTimeout 支持自定义超时
func runSSHCommandWithTimeout(client *ssh.Client, cmd string, timeout time.Duration) (string, error) {
session, err := client.NewSession()
if err != nil {
return "", err
}
defer session.Close()
output, err := session.CombinedOutput(cmd)
return string(output), err
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
type result struct {
output []byte
err error
}
done := make(chan result, 1)
go func() {
out, err := session.CombinedOutput(cmd)
done <- result{output: out, err: err}
}()
select {
case <-ctx.Done():
session.Signal(ssh.SIGTERM)
return "", fmt.Errorf("命令执行超时(%s): %s", timeout, cmd)
case r := <-done:
return string(r.output), r.err
}
}
// installServerURL 根据请求构造后端可访问地址,兼容 Nginx 等反向代理 // Logan.Li
func installServerURL(c *gin.Context) string {
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
scheme = proto
}
// 优先使用 X-Forwarded-Host(Nginx 暴露给客户端的真实域名),其次用 Host // Logan.Li
host := c.Request.Host
if fwd := c.GetHeader("X-Forwarded-Host"); fwd != "" {
host = fwd
}
// 去掉 scheme 的默认端口,避免生成 http://example.com:80 这类无效地址 // Logan.Li
if scheme == "http" && strings.HasSuffix(host, ":80") {
host = strings.TrimSuffix(host, ":80")
}
if scheme == "https" && strings.HasSuffix(host, ":443") {
host = strings.TrimSuffix(host, ":443")
}
return fmt.Sprintf("%s://%s", scheme, host)
}
// detectAgentVersion SSH 到目标主机读取已安装 Agent 的真实版本号 // Logan.Li
func detectAgentVersion(client *ssh.Client) string {
verOut, _ := runSSHCommand(client, "/usr/local/bin/aiops-agent version 2>/dev/null || /usr/local/bin/aiops-agent -v 2>/dev/null || echo ''")
verOut = strings.TrimSpace(verOut)
// 输出形如: AIOps Agent v2.1.0 (built ...) go=go1.24...
if strings.HasPrefix(verOut, "AIOps Agent ") {
fields := strings.Fields(verOut)
if len(fields) >= 3 {
return fields[2]
}
}
return ""
}
// InstallAgent 远程安装 Agent 到指定服务器
@@ -98,6 +210,11 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
// 允许安装,不强制 running 状态
}
// 审计上下文 + 资源描述(同步流程,提前快照)// Logan.Li
ac := newAuditCtx(c)
resource := server.Hostname + " (" + server.IP + ")"
resourceID := strconv.Itoa(int(server.ID))
// 如果请求中提供了凭据,使用请求凭据覆盖服务器存储的凭据进行 SSH
effectiveServer := server
if req.Username != "" {
@@ -118,6 +235,7 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
client, err := sshDial(effectiveServer)
if err != nil {
log.Printf("[AgentInstall] SSH连接失败 %s: %v", server.IP, err)
h.recordAgentAudit(ac, "install", resource, resourceID, 0, "SSH连接失败: "+formatSSHError(err))
c.JSON(http.StatusOK, model.Response{
Code: 500,
Message: "SSH 连接失败: " + formatSSHError(err),
@@ -153,21 +271,35 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
}
// 3. 构造安装命令(使用 Agent API 服务地址)
// 从请求头获取后端可访问地址
serverURL := fmt.Sprintf("http://%s", c.Request.Host)
token := c.Query("token")
serverURL := installServerURL(c)
token := req.Token
if token == "" {
token = c.Query("token")
}
// 4. 构造安装命令:root 用户直接 bash,非 root 用 sudo -n(无密码才执行,避免挂死)// Logan.Li
installCmd := fmt.Sprintf(
`curl -fsSL %s/agent/install.sh | sudo bash -s -- --server %s --token %s`,
`if [ "$(id -u)" -eq 0 ]; then curl -fsSL %s/agent/install.sh | bash -s -- --server %s --token %s; else curl -fsSL %s/agent/install.sh | sudo -n bash -s -- --server %s --token %s; fi`,
serverURL, serverURL, token,
serverURL, serverURL, token,
)
// 4. 执行安装
// 5. 执行安装
installOut, err := runSSHCommand(client, installCmd)
if err != nil {
msg := "安装命令执行失败"
lowerOut := strings.ToLower(installOut)
if strings.Contains(lowerOut, "sudo: a password is required") || strings.Contains(lowerOut, "需要密码") {
msg = "目标主机 SSH 用户没有免密 sudo 权限,请使用 root 账号或配置 passwordless sudo"
} else if strings.Contains(lowerOut, "curl") && (strings.Contains(lowerOut, "not found") || strings.Contains(lowerOut, "未找到")) {
msg = "目标主机未安装 curl"
} else if strings.Contains(lowerOut, "下载失败") || strings.Contains(lowerOut, "failed to connect") || strings.Contains(lowerOut, "connection refused") {
msg = "目标主机无法下载安装脚本,请检查后端地址 " + serverURL + " 是否可达"
}
h.recordAgentAudit(ac, "install", resource, resourceID, 0, msg+": "+truncateAuditText(installOut, 300))
c.JSON(http.StatusOK, model.Response{
Code: 500,
Message: "安装命令执行失败(可能需要 sudo 权限或 curl 未安装)",
Message: msg,
Data: installOut,
})
return
@@ -178,12 +310,22 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
installed := contains(verifyOut, "active") || contains(verifyOut, "running")
if installed {
// 更新服务器 Agent 状态(版本号:安装成功后由 Agent 下一次上报自动更新)
h.db.Model(&server).Updates(map[string]interface{}{
// 只更新在线状态;版本号仅在能读到真实版本时才写入,未上报时不写假版本(等 Agent 首次上报真实数据)// Logan.Li
updates := map[string]interface{}{
"agent_status": "online",
"agent_version": "v0.1.0",
"last_check_at": time.Now(),
})
}
if agentVersion := detectAgentVersion(client); agentVersion != "" {
updates["agent_version"] = agentVersion
}
h.db.Model(&server).Updates(updates)
}
// 审计:成功或"命令已执行但服务未运行"均记录,便于追溯 // Logan.Li
if installed {
h.recordAgentAudit(ac, "install", resource, resourceID, 1, "")
} else {
h.recordAgentAudit(ac, "install", resource, resourceID, 0, "安装命令已执行但服务未检测到运行: "+truncateAuditText(verifyOut, 300))
}
c.JSON(http.StatusOK, model.Response{
@@ -214,7 +356,8 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s
// 不强制 server.Status==running:只要 SSH 可达即可安装(DB 状态可能滞后于实际开机状态) // Logan.Li
installCmd := fmt.Sprintf(
`curl -fsSL %s/agent/install.sh | sudo bash -s -- --server %s --token %s`,
`if [ "$(id -u)" -eq 0 ]; then curl -fsSL %s/agent/install.sh | bash -s -- --server %s --token %s; else curl -fsSL %s/agent/install.sh | sudo -n bash -s -- --server %s --token %s; fi`,
serverURL, serverURL, token,
serverURL, serverURL, token,
)
@@ -227,11 +370,15 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s
installed := contains(verifyOut, "active") || contains(verifyOut, "running")
if installed {
h.db.Model(&server).Updates(map[string]interface{}{
// 只更新在线状态;版本号仅在能读到真实版本时才写入,未上报时不写假版本 // Logan.Li
updates := map[string]interface{}{
"agent_status": "online",
"agent_version": "v0.1.0",
"last_check_at": time.Now(),
})
}
if agentVersion := detectAgentVersion(client); agentVersion != "" {
updates["agent_version"] = agentVersion
}
h.db.Model(&server).Updates(updates)
}
return installed, installOut, verifyOut, nil
@@ -239,17 +386,25 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s
// BatchInstallAgent 批量安装 Agent(异步执行,立即返回)// Logan.Li
// POST /servers/install-agent
// 支持传入临时凭据:key 为服务器 ID,首次连接成功后自动保存到服务器记录
func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
var req struct {
ServerIDs []uint `json:"server_ids"`
ServerIDs []uint `json:"server_ids"`
Credentials map[string]struct {
Username string `json:"username"`
Password string `json:"password"`
SSHKey string `json:"ssh_key"`
AuthType string `json:"auth_type"`
} `json:"credentials"`
}
if err := c.ShouldBindJSON(&req); err != nil || len(req.ServerIDs) == 0 {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请提供要安装的服务器 ID 列表"})
return
}
serverURL := fmt.Sprintf("http://%s", c.Request.Host)
serverURL := installServerURL(c)
token := c.Query("token")
ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li
// 异步执行:立即返回,后台 goroutine 逐台安装
go func() {
@@ -260,15 +415,39 @@ func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
log.Printf("[BatchInstall] 服务器 %d 不存在,跳过", sid)
continue
}
installed, _, _, err := h.installOne(server, serverURL, token)
// 如果请求提供了该服务器的临时凭据,覆盖使用(不立即写入 DB,由 installOne 首次连接成功后保存) // Logan.Li
effectiveServer := server
if cred, ok := req.Credentials[fmt.Sprintf("%d", sid)]; ok {
if cred.Username != "" {
effectiveServer.Username = cred.Username
}
if cred.Password != "" {
effectiveServer.Password = cred.Password
}
if cred.SSHKey != "" {
effectiveServer.SSHKey = cred.SSHKey
effectiveServer.AuthType = "key"
}
if cred.AuthType != "" {
effectiveServer.AuthType = cred.AuthType
}
}
installed, _, verifyOut, err := h.installOne(effectiveServer, serverURL, token)
res := server.Hostname + " (" + server.IP + ")"
rid := strconv.Itoa(int(server.ID))
if err != nil {
log.Printf("[BatchInstall] %s (%s) 安装失败: %v", server.Hostname, server.IP, err)
h.recordAgentAudit(ac, "install", res, rid, 0, truncateAuditText(err.Error(), 500))
} else {
successCount++
if installed {
log.Printf("[BatchInstall] %s Agent 安装成功", server.Hostname)
h.recordAgentAudit(ac, "install", res, rid, 1, "")
} else {
log.Printf("[BatchInstall] %s Agent 安装命令已执行但服务未检测到运行", server.Hostname)
h.recordAgentAudit(ac, "install", res, rid, 0, "服务未检测到运行: "+truncateAuditText(verifyOut, 300))
}
}
}
@@ -310,17 +489,23 @@ func (h *AgentInstallHandler) UninstallAgent(c *gin.Context) {
return
}
ac := newAuditCtx(c) // Logan.Li
resource := server.Hostname + " (" + server.IP + ")"
resourceID := strconv.Itoa(int(server.ID))
// 无 SSH 凭据的主机(自动注册的)直接改本地状态;注意 SSH key 认证的主机 Password 为空但 SSHKey 非空,仍可远程卸载 // Logan.Li
if server.Username == "" || (server.Password == "" && server.SSHKey == "") {
h.db.Model(&server).Updates(map[string]interface{}{
"agent_status": "offline", "agent_version": "",
})
h.recordAgentAudit(ac, "uninstall", resource, resourceID, 1, "无SSH凭据,已标记离线")
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已标记为离线(该主机无SSH凭据,无法远程卸载,请手动执行 systemctl stop aiops-agent)"})
return
}
client, err := sshDial(server)
if err != nil {
h.recordAgentAudit(ac, "uninstall", resource, resourceID, 0, "SSH连接失败: "+formatSSHError(err))
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "SSH连接失败: " + formatSSHError(err)})
return
}
@@ -331,6 +516,7 @@ func (h *AgentInstallHandler) UninstallAgent(c *gin.Context) {
h.db.Model(&server).Updates(map[string]interface{}{
"agent_status": "offline", "agent_version": "",
})
h.recordAgentAudit(ac, "uninstall", resource, resourceID, 1, "")
c.JSON(http.StatusOK, model.Response{Code: 200, Message: fmt.Sprintf("已卸载 %s (%s) 的 Agent", server.Hostname, server.IP)})
}
@@ -346,6 +532,8 @@ func (h *AgentInstallHandler) BatchUninstallAgent(c *gin.Context) {
return
}
ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li
// 异步执行:立即返回,后台逐台卸载
go func() {
successCount := 0
@@ -355,21 +543,26 @@ func (h *AgentInstallHandler) BatchUninstallAgent(c *gin.Context) {
log.Printf("[BatchUninstall] 服务器 %d 不存在,跳过", id)
continue
}
res := server.Hostname + " (" + server.IP + ")"
rid := strconv.Itoa(int(server.ID))
if server.Username == "" || (server.Password == "" && server.SSHKey == "") {
h.db.Model(&server).Updates(map[string]interface{}{"agent_status": "offline", "agent_version": ""})
log.Printf("[BatchUninstall] %s 无SSH凭据,已标记离线", server.Hostname)
h.recordAgentAudit(ac, "uninstall", res, rid, 1, "无SSH凭据,已标记离线")
successCount++
continue
}
client, err := sshDial(server)
if err != nil {
log.Printf("[BatchUninstall] %s SSH失败: %v", server.Hostname, err)
h.recordAgentAudit(ac, "uninstall", res, rid, 0, "SSH连接失败: "+formatSSHError(err))
continue
}
runSSHCommand(client, "systemctl stop aiops-agent 2>/dev/null; systemctl disable aiops-agent 2>/dev/null; rm -f /usr/local/bin/aiops-agent /etc/systemd/system/aiops-agent.service; systemctl daemon-reload 2>/dev/null")
client.Close()
h.db.Model(&server).Updates(map[string]interface{}{"agent_status": "offline", "agent_version": ""})
log.Printf("[BatchUninstall] %s Agent 已卸载", server.Hostname)
h.recordAgentAudit(ac, "uninstall", res, rid, 1, "")
successCount++
}
log.Printf("[BatchUninstall] 批量卸载完成: %d/%d 成功", successCount, len(req.ServerIDs))
@@ -393,7 +586,8 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
return
}
serverURL := fmt.Sprintf("http://%s", c.Request.Host)
serverURL := installServerURL(c)
ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li
// 异步执行:立即返回,后台逐台升级
go func() {
@@ -409,10 +603,14 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
continue
}
res := server.Hostname + " (" + server.IP + ")"
rid := strconv.Itoa(int(server.ID))
// 检测架构
client, err := sshDial(server)
if err != nil {
log.Printf("[BatchUpgrade] %s SSH连接失败: %v", server.Hostname, err)
h.recordAgentAudit(ac, "upgrade", res, rid, 0, "SSH连接失败: "+formatSSHError(err))
continue
}
@@ -424,7 +622,7 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
// 下载新二进制 + 获取版本号 + 重启服务
upgradeScript := fmt.Sprintf(
`curl -fsSL -o /tmp/aiops-agent %s/api/agent/bin/%s && chmod +x /tmp/aiops-agent && systemctl stop aiops-agent 2>/dev/null; mv /tmp/aiops-agent /usr/local/bin/aiops-agent && systemctl start aiops-agent; ver=$(/usr/local/bin/aiops-agent output 2>/dev/null | grep 'agent.version' | awk -F'[|[|]' '{print $2}' | tr -d ' '); echo "UPGRADE_OK ver=$ver"`,
`curl -fsSL -o /tmp/aiops-agent %s/api/agent/bin/%s && chmod +x /tmp/aiops-agent && systemctl stop aiops-agent 2>/dev/null; mv /tmp/aiops-agent /usr/local/bin/aiops-agent && systemctl start aiops-agent; ver=$(/usr/local/bin/aiops-agent version 2>/dev/null | awk '/AIOps Agent/{print $3}'); echo "UPGRADE_OK ver=$ver"`,
serverURL, binaryFile,
)
@@ -433,6 +631,7 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
if err != nil || !contains(out, "UPGRADE_OK") {
log.Printf("[BatchUpgrade] %s 升级失败: output=%s err=%v", server.Hostname, out, err)
h.recordAgentAudit(ac, "upgrade", res, rid, 0, "升级失败: "+truncateAuditText(out, 300))
continue
}
@@ -448,6 +647,7 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
"last_check_at": time.Now(),
})
log.Printf("[BatchUpgrade] %s Agent 升级成功", server.Hostname)
h.recordAgentAudit(ac, "upgrade", res, rid, 1, "")
successCount++
}
log.Printf("[BatchUpgrade] 批量升级完成: %d/%d 成功", successCount, len(req.ServerIDs))
+1
View File
@@ -145,6 +145,7 @@ func (h *AuditHandler) GetAuditModules(c *gin.Context) {
{"value": "navigation", "label": "站内导航"},
{"value": "cloud_account", "label": "云账号管理"},
{"value": "domain", "label": "域名管理"},
{"value": "agent", "label": "Agent管理"},
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: modules}) // Logan.Li
+15
View File
@@ -73,6 +73,21 @@ func (h *DashboardHandler) GetStats(c *gin.Context) {
serverStats.AvgMemory = math.Round(avgMemory*100) / 100
serverStats.AvgDisk = math.Round(avgDisk*100) / 100
// 总资源概览:按 asset_servers 的 cpu/memory/disk 字段汇总(memory 单位为 MB,需转 GB)// Logan.Li
var totalCPU, totalMemoryMB, totalDiskGB int64
h.db.Model(&model.Server{}).Select("COALESCE(SUM(cpu),0)").Scan(&totalCPU)
h.db.Model(&model.Server{}).Select("COALESCE(SUM(memory),0)").Scan(&totalMemoryMB)
h.db.Model(&model.Server{}).Select("COALESCE(SUM(disk),0)").Scan(&totalDiskGB)
serverStats.TotalCPU = totalCPU
serverStats.TotalMemory = totalMemoryMB / 1024 // MB -> GB
serverStats.TotalDisk = totalDiskGB
if serverStats.TotalMemory > 0 && serverStats.AvgMemory > 0 {
serverStats.UsedMemory = int64(math.Round(float64(serverStats.TotalMemory) * serverStats.AvgMemory / 100))
}
if serverStats.TotalDisk > 0 && serverStats.AvgDisk > 0 {
serverStats.UsedDisk = int64(math.Round(float64(serverStats.TotalDisk) * serverStats.AvgDisk / 100))
}
stats.ServerStats = serverStats
// K8s集群统计
+24 -21
View File
@@ -55,21 +55,22 @@ func (h *LLMHandler) GetLLMConfig(c *gin.Context) {
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
"provider": "", "model": "", "api_key": "",
"endpoint": "", "temperature": 0.7, "max_tokens": 2000,
"system_prompt": "", "enabled": false, "configured": false,
"system_prompt": "", "enabled": false, "reasoning_enabled": false, "configured": false,
}})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
"id": cfg.ID,
"provider": cfg.Provider,
"model": cfg.Model,
"api_key": cfg.APIKey,
"endpoint": cfg.Endpoint,
"temperature": cfg.Temperature,
"max_tokens": cfg.MaxTokens,
"system_prompt": cfg.SystemPrompt,
"enabled": cfg.Enabled,
"configured": true,
"id": cfg.ID,
"provider": cfg.Provider,
"model": cfg.Model,
"api_key": cfg.APIKey,
"endpoint": cfg.Endpoint,
"temperature": cfg.Temperature,
"max_tokens": cfg.MaxTokens,
"system_prompt": cfg.SystemPrompt,
"enabled": cfg.Enabled,
"reasoning_enabled": cfg.ReasoningEnabled,
"configured": true,
}})
}
@@ -95,6 +96,7 @@ func (h *LLMHandler) SaveLLMConfig(c *gin.Context) {
cfg.MaxTokens = req.MaxTokens
cfg.SystemPrompt = req.SystemPrompt
cfg.Enabled = req.Enabled
cfg.ReasoningEnabled = req.ReasoningEnabled
if cfg.ID == 0 {
if err := h.db.Create(&cfg).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "保存失败: " + err.Error()})
@@ -109,16 +111,17 @@ func (h *LLMHandler) SaveLLMConfig(c *gin.Context) {
// 返回脱敏后的配置
cfg.APIKey = maskAPIKey(cfg.APIKey)
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "保存成功", Data: gin.H{
"id": cfg.ID,
"provider": cfg.Provider,
"model": cfg.Model,
"api_key": cfg.APIKey,
"endpoint": cfg.Endpoint,
"temperature": cfg.Temperature,
"max_tokens": cfg.MaxTokens,
"system_prompt": cfg.SystemPrompt,
"enabled": cfg.Enabled,
"configured": true,
"id": cfg.ID,
"provider": cfg.Provider,
"model": cfg.Model,
"api_key": cfg.APIKey,
"endpoint": cfg.Endpoint,
"temperature": cfg.Temperature,
"max_tokens": cfg.MaxTokens,
"system_prompt": cfg.SystemPrompt,
"enabled": cfg.Enabled,
"reasoning_enabled": cfg.ReasoningEnabled,
"configured": true,
}})
}
+16 -18
View File
@@ -14,18 +14,6 @@ import (
"github.com/gin-gonic/gin"
)
// buildLLMClient 从数据库配置构造 LLM 客户端(按 provider 协议自动路由 OpenAI/Anthropic) // Logan.Li
func (h *LLMHandler) buildLLMClient(cfg model.LLMConfig) llm.Client {
return llm.NewClient(llm.Config{
Protocol: resolveProtocol(cfg.Provider),
Endpoint: resolveEndpoint(cfg.Provider, cfg.Endpoint),
APIKey: cfg.APIKey,
Model: cfg.Model,
Temperature: cfg.Temperature,
MaxTokens: cfg.MaxTokens,
})
}
// toLLMMessages model.ChatMessage[] → llm.Message[](智能助手纯对话,仅填 Role/Content) // Logan.Li
func toLLMMessages(msgs []model.ChatMessage) []llm.Message {
out := make([]llm.Message, len(msgs))
@@ -57,7 +45,7 @@ func (h *LLMHandler) Chat(c *gin.Context) {
}
messages := append([]model.ChatMessage{{Role: "system", Content: systemPrompt}}, req.Messages...)
resp, err := h.buildLLMClient(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{
resp, err := buildLLMClientFromConfig(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{
Messages: toLLMMessages(messages),
})
if err != nil {
@@ -127,7 +115,7 @@ func (h *LLMHandler) ChatStream(c *gin.Context) {
// 流式补全:逐 token 回调转发 SSE;ctx 取消(客户端断开)由 llm.Client 内部终止上游读取
var contentBuilder strings.Builder
_, streamErr := h.buildLLMClient(cfg).Stream(c.Request.Context(), &llm.CompletionRequest{
_, streamErr := buildLLMClientFromConfig(cfg).Stream(c.Request.Context(), &llm.CompletionRequest{
Messages: toLLMMessages(messages),
}, func(delta string) {
contentBuilder.WriteString(delta)
@@ -188,7 +176,7 @@ func writeSSEError(c *gin.Context, code int, message string) {
c.Writer.Flush()
}
// TestLLMConnection 测试 LLM 连接(发送一条 ping 消息) // Logan.Li
// TestLLMConnection 测试 LLM 连接(询问模型身份并返回回答) // Logan.Li
// POST /api/v1/aiops/llm-config/test
func (h *LLMHandler) TestLLMConnection(c *gin.Context) {
var cfg model.LLMConfig
@@ -196,12 +184,22 @@ func (h *LLMHandler) TestLLMConnection(c *gin.Context) {
c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先保存配置"})
return
}
_, err := h.buildLLMClient(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{
Messages: []llm.Message{{Role: "user", Content: "ping"}},
if !cfg.Enabled {
c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先启用 LLM 服务"})
return
}
resp, err := buildLLMClientFromConfig(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{
Messages: []llm.Message{{Role: "user", Content: "你是什么模型?请简短回答。"}},
})
if err != nil {
c.JSON(http.StatusOK, model.Response{Code: 502, Message: "连接失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "连接成功"})
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "连接成功", Data: gin.H{
"answer": resp.Message.Content,
"model": resp.Model,
"tokens_used": resp.Usage.TotalTokens,
"provider": cfg.Provider,
"reasoning_enabled": cfg.ReasoningEnabled,
}})
}
+26
View File
@@ -0,0 +1,26 @@
// llm_client.go - LLM 客户端构造公共函数 // Logan.Li
// Agent CLI、智能助手等模块共用,避免字段遗漏(如 ReasoningEnabled)。
package controller
import (
"ezops/model"
"ezops/pkg/agentcli/llm"
)
// buildLLMConfigFromModel 从 model.LLMConfig 构造引擎用 llm.Config // Logan.Li
func buildLLMConfigFromModel(cfg model.LLMConfig) llm.Config {
return llm.Config{
Protocol: resolveProtocol(cfg.Provider),
Endpoint: resolveEndpoint(cfg.Provider, cfg.Endpoint),
APIKey: cfg.APIKey,
Model: cfg.Model,
Temperature: cfg.Temperature,
MaxTokens: cfg.MaxTokens,
ReasoningEnabled: cfg.ReasoningEnabled,
}
}
// buildLLMClientFromConfig 从 model.LLMConfig 构造 LLM 客户端(OpenAI/Anthropic 自动路由) // Logan.Li
func buildLLMClientFromConfig(cfg model.LLMConfig) llm.Client {
return llm.NewClient(buildLLMConfigFromModel(cfg))
}
+1 -1
View File
@@ -22,7 +22,7 @@ type LogProxyHandler struct {
ds *LogDSHandler
}
const maxLogQueryLimit = 500
const maxLogQueryLimit = 1000 // 单次查询返回日志行数硬上限(Loki limit / ES size 共用)// Logan.Li
func NewLogProxyHandler(ds *LogDSHandler) *LogProxyHandler {
return &LogProxyHandler{ds: ds}
+41 -4
View File
@@ -2,6 +2,7 @@ package controller
import (
"net/http"
"time"
"ezops/model"
@@ -47,6 +48,16 @@ func (h *SessionCommandHandler) ListSessionCommands(c *gin.Context) {
if req.Command != "" {
query = query.Where("command LIKE ?", "%"+req.Command+"%")
}
if req.StartDate != "" {
if t, err := time.ParseInLocation("2006-01-02", req.StartDate, time.Local); err == nil {
query = query.Where("timestamp >= ?", t)
}
}
if req.EndDate != "" {
if t, err := time.ParseInLocation("2006-01-02", req.EndDate, time.Local); err == nil {
query = query.Where("timestamp < ?", t.Add(24*time.Hour))
}
}
var total int64
if err := query.Count(&total).Error; err != nil {
@@ -54,17 +65,43 @@ func (h *SessionCommandHandler) ListSessionCommands(c *gin.Context) {
return
}
var commands []model.SessionCommand
if err := query.Order("id DESC").
// 联表带出会话录制大小,前端可判断能否回放 // Logan.Li
var items []model.SessionCommandItem
listQuery := h.db.Table("sys_session_commands AS sc").
Select("sc.*, COALESCE(ts.record_size, 0) AS session_record_size").
Joins("LEFT JOIN sys_terminal_sessions AS ts ON ts.id = sc.session_id")
if req.SessionID > 0 {
listQuery = listQuery.Where("sc.session_id = ?", req.SessionID)
}
if req.Username != "" {
listQuery = listQuery.Where("sc.username LIKE ?", "%"+req.Username+"%")
}
if req.ServerID > 0 {
listQuery = listQuery.Where("sc.server_id = ?", req.ServerID)
}
if req.Command != "" {
listQuery = listQuery.Where("sc.command LIKE ?", "%"+req.Command+"%")
}
if req.StartDate != "" {
if t, err := time.ParseInLocation("2006-01-02", req.StartDate, time.Local); err == nil {
listQuery = listQuery.Where("sc.timestamp >= ?", t)
}
}
if req.EndDate != "" {
if t, err := time.ParseInLocation("2006-01-02", req.EndDate, time.Local); err == nil {
listQuery = listQuery.Where("sc.timestamp < ?", t.Add(24*time.Hour))
}
}
if err := listQuery.Order("sc.id DESC").
Offset((req.Page - 1) * req.PageSize).
Limit(req.PageSize).
Find(&commands).Error; err != nil {
Scan(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "查询失败"})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ListResponse{
List: commands,
List: items,
Total: total,
}})
}
+60 -2
View File
@@ -4,8 +4,10 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"ezops/middleware"
"ezops/model"
"github.com/gin-gonic/gin"
@@ -48,6 +50,26 @@ func (h *TerminalSessionHandler) ListTerminalSessions(c *gin.Context) {
if req.Status != "" {
query = query.Where("status = ?", req.Status)
}
if kw := strings.TrimSpace(req.Keyword); kw != "" {
like := "%" + kw + "%"
query = query.Where(
"username LIKE ? OR server_name LIKE ? OR server_ip LIKE ? OR client_ip LIKE ?",
like, like, like, like,
)
}
if req.StartDate != "" {
if t, err := time.ParseInLocation("2006-01-02", req.StartDate, time.Local); err == nil {
query = query.Where("start_time >= ?", t)
}
}
if req.EndDate != "" {
if t, err := time.ParseInLocation("2006-01-02", req.EndDate, time.Local); err == nil {
query = query.Where("start_time < ?", t.Add(24*time.Hour))
}
}
if req.HasRecord == "1" || strings.EqualFold(req.HasRecord, "true") {
query = query.Where("record_size > 0")
}
var total int64
if err := query.Count(&total).Error; err != nil {
@@ -173,11 +195,17 @@ func (h *TerminalSessionHandler) KillSession(c *gin.Context) {
return
}
// 强制终止 WebShell 连接
// 强制终止 WebShell 连接;在线连接由 webshell finalize 落盘录制,勿抢先 UPDATE 覆盖 // Logan.Li
killed := false
if h.killFunc != nil {
h.killFunc(session.ID)
killed = h.killFunc(session.ID)
}
if killed {
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已终止"})
return
}
// 僵尸会话(连接已断开、服务重启等):无录制可落盘,仅关闭记录
now := time.Now()
duration := int(now.Sub(session.StartTime).Seconds())
if err := h.db.Model(&session).Updates(map[string]interface{}{
@@ -222,6 +250,36 @@ func (h *TerminalSessionHandler) DownloadSession(c *gin.Context) {
c.String(http.StatusOK, session.RecordData)
}
// LogReplayView 记录终端回放查看审计(二次审计留痕) // Logan.Li
// POST /api/v1/terminal-sessions/:id/replay-view
func (h *TerminalSessionHandler) LogReplayView(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的会话ID"})
return
}
var session model.TerminalSession
if err := h.db.Select("id", "username", "server_name", "server_ip").First(&session, id).Error; err != nil {
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"})
return
}
uid := middleware.GetUserID(c)
log := model.AuditLog{
UserID: &uid,
Username: middleware.GetUsername(c),
IP: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"),
Module: "terminal_audit",
Action: "replay_view",
Resource: "terminal_session",
ResourceID: strconv.FormatUint(id, 10),
Request: fmt.Sprintf("回放终端会话 #%d(%s → %s/%s)", id, session.Username, session.ServerName, session.ServerIP),
Status: 200,
}
_ = h.db.Create(&log).Error // 落库失败不阻断回放
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "ok"})
}
// StreamSession 终端会话实时流(WebSocket 暂未实现)
// GET /api/v1/terminal-sessions/:id/stream
func (h *TerminalSessionHandler) StreamSession(c *gin.Context) {
+74 -24
View File
@@ -190,23 +190,50 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
recorder := newAsciicastRecorder()
const maxRecordSize = 1024 * 1024
var cmdBuf strings.Builder
sessionStatus := "closed"
var finalizeOnce sync.Once
finalizeSession := func(status string) {
endTime := time.Now()
duration := int(endTime.Sub(termSession.StartTime).Seconds())
recData := recorder.String()
recSize := recorder.Len()
log.Printf("[WebShell] Finalize session=%d status=%s duration=%ds rec=%d bytes", termSession.ID, status, duration, recSize)
// Raw SQL with error logging
result := h.db.Exec("UPDATE sys_terminal_sessions SET status=?, duration=?, end_time=?, record_data=?, record_size=? WHERE id=?",
status, duration, endTime, recData, recSize, termSession.ID)
if result.Error != nil {
log.Printf("[WebShell] ERROR finalize session=%d: %v", termSession.ID, result.Error)
} else {
log.Printf("[WebShell] Finalize OK session=%d rows_affected=%d", termSession.ID, result.RowsAffected)
// 命令输出字节统计:每条命令对应「下一条命令前」的 stdout 累计量 // Logan.Li
var cmdTrack struct {
mu sync.Mutex
lastCmdID uint
stdoutBytes int
}
flushCmdOutput := func() {
cmdTrack.mu.Lock()
prevID := cmdTrack.lastCmdID
bytes := cmdTrack.stdoutBytes
cmdTrack.stdoutBytes = 0
cmdTrack.mu.Unlock()
if prevID > 0 {
_ = h.db.Model(&model.SessionCommand{}).Where("id = ?", prevID).
Update("output_size", bytes).Error
}
}
// finalizeSession 会话结束落盘(sync.Once 防重复写入) // Logan.Li
finalizeSession := func(status string) {
sessionStatus = status
finalizeOnce.Do(func() {
endTime := time.Now()
duration := int(endTime.Sub(termSession.StartTime).Seconds())
recData := recorder.String()
recSize := recorder.Len()
log.Printf("[WebShell] Finalize session=%d status=%s duration=%ds rec=%d bytes", termSession.ID, status, duration, recSize)
result := h.db.Exec("UPDATE sys_terminal_sessions SET status=?, duration=?, end_time=?, record_data=?, record_size=? WHERE id=?",
status, duration, endTime, recData, recSize, termSession.ID)
if result.Error != nil {
log.Printf("[WebShell] ERROR finalize session=%d: %v", termSession.ID, result.Error)
} else {
log.Printf("[WebShell] Finalize OK session=%d rows_affected=%d", termSession.ID, result.RowsAffected)
}
})
}
defer func() {
flushCmdOutput()
finalizeSession(sessionStatus)
}()
connectingMsg := "\r\n\x1b[36mConnecting to " + server.Hostname + " (" + server.IP + ":" + strconv.Itoa(server.Port) + ")...\x1b[0m\r\n"
conn.WriteJSON(WSMessage{Status: 0, Data: connectingMsg})
@@ -335,6 +362,9 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
if recorder.Len() < maxRecordSize {
recorder.WriteRow(data)
}
cmdTrack.mu.Lock()
cmdTrack.stdoutBytes += n
cmdTrack.mu.Unlock()
if h.auditHub != nil {
h.auditHub.Broadcast(termSession.ID, data)
}
@@ -366,13 +396,21 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
case 0:
if msg.Data != "" {
stdinPipe.Write([]byte(msg.Data))
// 录入用户输入,回放可见键入内容 // Logan.Li
if recorder.Len() < maxRecordSize {
recorder.WriteRow([]byte(msg.Data))
}
// Command extraction from stdin
cmdBuf.WriteString(msg.Data)
if strings.Contains(msg.Data, "\r") || strings.Contains(msg.Data, "\n") {
cmd := extractCommand(cmdBuf.String())
if cmd != "" {
recordCommand(h.db, termSession.ID, termSession.ServerID, usernameStr, cmd)
flushCmdOutput()
newID := recordCommand(h.db, termSession.ID, termSession.ServerID, termSession.ServerName, usernameStr, cmd)
cmdTrack.mu.Lock()
cmdTrack.lastCmdID = newID
cmdTrack.mu.Unlock()
}
cmdBuf.Reset()
}
@@ -392,10 +430,16 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
sshClient.Close()
<-done
stdinPipe.Close()
finalizeSession("closed")
sessionStatus = "closed"
log.Printf("[WebShell] Disconnected %s (%s)", server.Hostname, addr)
}
// shortShellCmds 常见 2 字符 shell 命令白名单 // Logan.Li
var shortShellCmds = map[string]bool{
"ls": true, "cd": true, "ps": true, "df": true, "id": true, "wc": true,
"mv": true, "cp": true, "vi": true, "sh": true, "ll": true, "pwd": true,
}
// extractCommand extracts a clean command from raw terminal input
func extractCommand(input string) string {
cleaned := strings.Map(func(r rune) rune {
@@ -411,25 +455,31 @@ func extractCommand(input string) string {
if len(cleaned) < 2 || len(cleaned) > 500 {
return ""
}
// 过滤输入中途误触回车产生的 2 字符残片(如 up/uo),保留 ls/cd 等 // Logan.Li
if len(cleaned) == 2 && !shortShellCmds[cleaned] {
return ""
}
if strings.HasPrefix(cleaned, "\x1b[") {
return ""
}
return cleaned
}
// recordCommand records a terminal command to sys_session_commands
func recordCommand(db *gorm.DB, sessionID, serverID uint, username, command string) {
// recordCommand 写入命令审计并返回记录 ID // Logan.Li
func recordCommand(db *gorm.DB, sessionID, serverID uint, serverName, username, command string) uint {
now := time.Now()
cmd := model.SessionCommand{
SessionID: sessionID,
ServerID: serverID,
Username: username,
Command: command,
Timestamp: now,
SessionID: sessionID,
ServerID: serverID,
ServerName: serverName,
Username: username,
Command: command,
Timestamp: now,
}
if err := db.Create(&cmd).Error; err != nil {
log.Printf("[WebShell] Command record failed: %v", err)
} else {
log.Printf("[WebShell] Command recorded session=%d cmd=%q", sessionID, command)
return 0
}
log.Printf("[WebShell] Command recorded session=%d cmd=%q", sessionID, command)
return cmd.ID
}
+14 -12
View File
@@ -12,10 +12,11 @@ type LLMConfig struct {
Endpoint string `gorm:"size:500" json:"endpoint"` // 基础地址,如 https://api.openai.com/v1
Temperature float64 `gorm:"default:0.7" json:"temperature"` // 采样温度 0-2
MaxTokens int `gorm:"default:2000" json:"max_tokens"` // 最大输出 token
SystemPrompt string `gorm:"type:text" json:"system_prompt"` // 系统提示词(AIOps 运维助手角色)
Enabled bool `gorm:"default:false" json:"enabled"` // 是否启用
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SystemPrompt string `gorm:"type:text" json:"system_prompt"` // 系统提示词(AIOps 运维助手角色)
Enabled bool `gorm:"default:false" json:"enabled"` // 是否启用
ReasoningEnabled bool `gorm:"default:false" json:"reasoning_enabled"` // 是否启用推理/Plan 模式(OpenAI o-series / DeepSeek R1 / Claude thinking 等) // Logan.Li
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName 表名 // Logan.Li
@@ -23,14 +24,15 @@ func (LLMConfig) TableName() string { return "ai_llm_configs" }
// SaveLLMConfigRequest 保存 LLM 配置请求 // Logan.Li
type SaveLLMConfigRequest struct {
Provider string `json:"provider"`
Model string `json:"model"`
APIKey string `json:"api_key"` // 若为脱敏值(含 ****)则后端保留原值
Endpoint string `json:"endpoint"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
SystemPrompt string `json:"system_prompt"`
Enabled bool `json:"enabled"`
Provider string `json:"provider"`
Model string `json:"model"`
APIKey string `json:"api_key"` // 若为脱敏值(含 ****)则后端保留原值
Endpoint string `json:"endpoint"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
SystemPrompt string `json:"system_prompt"`
Enabled bool `json:"enabled"`
ReasoningEnabled bool `json:"reasoning_enabled"` // 是否启用推理/Plan 模式
}
// ChatMessage 对话消息 // Logan.Li
+6
View File
@@ -216,4 +216,10 @@ type ServerStats struct {
AvgCPU float64 `json:"avg_cpu"`
AvgMemory float64 `json:"avg_memory"`
AvgDisk float64 `json:"avg_disk"`
// 总资源概览(GB/核),用于仪表盘资源使用率卡片展示具体容量 // Logan.Li
TotalCPU int64 `json:"total_cpu"` // CPU 核心数合计
TotalMemory int64 `json:"total_memory"` // 内存总量 GB
UsedMemory int64 `json:"used_memory"` // 内存已用 GB
TotalDisk int64 `json:"total_disk"` // 磁盘总量 GB
UsedDisk int64 `json:"used_disk"` // 磁盘已用 GB
}
+4 -1
View File
@@ -11,6 +11,7 @@ type SessionCommand struct {
ServerID uint `json:"server_id"`
ServerName string `gorm:"size:128" json:"server_name"`
Command string `gorm:"type:text" json:"command"` // 执行的原始命令
OutputSize *int `json:"output_size"` // 下一条命令前累计的终端输出字节数,nil=历史未统计 // Logan.Li
Timestamp time.Time `json:"timestamp"` // 命令执行时间
CreatedAt time.Time `json:"created_at"`
}
@@ -19,12 +20,14 @@ func (SessionCommand) TableName() string {
return "sys_session_commands"
}
// SessionCommandQuery 会话命令查询
// SessionCommandQuery 会话命令查询 // Logan.Li
type SessionCommandQuery struct {
SessionID uint `form:"session_id"`
Username string `form:"username"`
ServerID uint `form:"server_id"`
Command string `form:"command"`
StartDate string `form:"start_date"` // YYYY-MM-DD
EndDate string `form:"end_date"`
Page int `form:"page,default=1"`
PageSize int `form:"page_size,default=20"`
}
+7
View File
@@ -0,0 +1,7 @@
package model
// SessionCommandItem 命令列表项(附带关联会话录制大小) // Logan.Li
type SessionCommandItem struct {
SessionCommand
SessionRecordSize int `gorm:"column:session_record_size" json:"session_record_size"` // 关联会话 record_size,0=无录制 // Logan.Li
}
+10 -6
View File
@@ -26,11 +26,15 @@ func (TerminalSession) TableName() string {
return "sys_terminal_sessions"
}
// TerminalSessionQuery 终端会话查询
// TerminalSessionQuery 终端会话查询 // Logan.Li
type TerminalSessionQuery struct {
ServerID uint `form:"server_id"`
Username string `form:"username"`
Status string `form:"status"`
Page int `form:"page,default=1"`
PageSize int `form:"page_size,default=20"`
ServerID uint `form:"server_id"`
Username string `form:"username"`
Status string `form:"status"`
Keyword string `form:"keyword"` // 模糊匹配用户/主机名/IP/客户端IP
StartDate string `form:"start_date"` // YYYY-MM-DD
EndDate string `form:"end_date"` // YYYY-MM-DD
HasRecord string `form:"has_record"` // 1/true 仅含录制
Page int `form:"page,default=1"`
PageSize int `form:"page_size,default=20"`
}
+14 -13
View File
@@ -11,6 +11,7 @@ package agentcli
import (
"context"
"fmt"
"ezops/pkg/agentcli/core"
"ezops/pkg/agentcli/evolver"
@@ -54,9 +55,13 @@ func NewEngine(cfg EngineConfig) (*Engine, error) {
// LLM 客户端
client := llm.NewClient(cfg.LLM)
// 工具注册中心:ssh_execute(注入目标主机凭据)+ analyze_output
// 工具注册中心:ssh_execute(SSH 长连接)+ analyze_output
registry := tools.NewRegistry()
registry.Register(tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout))
sshTool, err := tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout)
if err != nil {
return nil, fmt.Errorf("ssh_execute 初始化失败: %w", err)
}
registry.Register(sshTool)
registry.Register(tools.NewAnalyzeOutputTool())
// 安全控制器
@@ -81,16 +86,9 @@ func (e *Engine) SetConfirmFn(fn safety.ConfirmFn) { e.safety.SetConfirmFn(fn) }
// SetAutoApprove 测试用:自动批准(生产禁用) // Logan.Li
func (e *Engine) SetAutoApprove(b bool) { e.safety.SetAutoApprove(b) }
// Run 执行任务(ReAct 循环),serverInfo 为目标主机摘要(注入 system prompt) // Logan.Li
func (e *Engine) Run(ctx context.Context, query, serverInfo string) (string, error) {
res, err := e.agent.Run(ctx, query, serverInfo)
if err != nil {
return "", err
}
if res != nil {
return res.Output, nil
}
return "", nil
// Run 执行任务(ReAct 循环);prior 为上一轮对话历史(不含 system),priorTokens 为累计 token // Logan.Li
func (e *Engine) Run(ctx context.Context, query, serverInfo string, prior []llm.Message, priorTokens int) (*core.RunResult, error) {
return e.agent.Run(ctx, query, serverInfo, prior, priorTokens)
}
// EvolveStats 进化统计(供审计展示) // Logan.Li
@@ -101,8 +99,11 @@ func (e *Engine) EvolveStats() map[string]interface{} {
return e.evolver.GetStats()
}
// Close 释放资源 // Logan.Li
// Close 释放资源(含 SSH 长连接) // Logan.Li
func (e *Engine) Close() {
if e.tools != nil {
e.tools.Close()
}
if e.agent != nil {
e.agent.Close()
}
+83 -22
View File
@@ -23,8 +23,9 @@ import (
type EventType string
const (
EventThought EventType = "thought" // AI 思考/推理文本
EventToolCall EventType = "tool_call" // 即将执行的工具(名/参数/风险)
EventThought EventType = "thought" // AI 思考/推理文本(流式结束后的完整内容)
EventThoughtDelta EventType = "thought_delta" // AI 思考流式增量 token
EventToolCall EventType = "tool_call" // 即将执行的工具(名/参数/风险)
EventToolResult EventType = "tool_result" // 工具执行结果(输出/成败/耗时)
EventProgress EventType = "progress" // 轮次进度 + token
EventDone EventType = "done" // 任务完成(最终答案)
@@ -50,6 +51,14 @@ type Event struct {
// EventCallback 事件回调(controller 注入) // Logan.Li
type EventCallback func(Event)
// RunResult ReAct 执行结果(含对话历史供多轮追问) // Logan.Li
type RunResult struct {
Success bool
Output string
History []llm.Message // system 之后的对话历史
TotalTokens int
}
// Config Agent 运行配置 // Logan.Li
type Config struct {
MaxIterations int
@@ -60,6 +69,7 @@ type Config struct {
OutputMaxLength int
MaxContextTokens int
MemoryDir string // Evolver 记忆目录
RoundOffset int // 轮次偏移(多轮追问时接续上一轮编号) // Logan.Li
}
// DefaultConfig 默认配置 // Logan.Li
@@ -112,9 +122,9 @@ func (a *Agent) emit(e Event) {
}
}
// Run 执行单次任务(ReAct 循环 + Evolver 集成) // Logan.Li
func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Result, error) {
a.totalTokens = 0
// Run 执行单次任务(ReAct 循环 + Evolver 集成);prior 为不含 system 的历史对话 // Logan.Li
func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.Message, priorTokens int) (*RunResult, error) {
a.totalTokens = priorTokens
start := time.Now()
// Evolver PREDICT:获取历史经验注入 system prompt
@@ -127,10 +137,11 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
toolDescs := a.buildToolDescriptions()
systemPrompt := prompt.BuildSystemPrompt(serverInfo, toolDescs, evolveCtx)
messages := []llm.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: query},
messages := []llm.Message{{Role: "system", Content: systemPrompt}}
if len(prior) > 0 {
messages = append(messages, prior...)
}
messages = append(messages, llm.Message{Role: "user", Content: query})
llmTools := a.registry.ToLLMTools()
toolCallHistory := make(map[string]int) // 循环检测
@@ -144,7 +155,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
return nil, err
}
a.emit(Event{Type: EventProgress, Round: iter + 1})
a.emit(Event{Type: EventProgress, Round: a.roundNum(iter)})
// 自适应检查点
if hint := a.checkAdaptiveCheckpoint(iter, toolCallHistory, consecutiveFailures, totalToolCalls); hint != "" {
@@ -154,12 +165,17 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
// 裁剪历史
trimmed := a.trimMessages(messages)
// 调用 LLM
resp, err := a.llm.Complete(ctx, &llm.CompletionRequest{
// 调用 LLM(流式推送思考增量,降低长推理等待感) // Logan.Li
round := a.roundNum(iter)
resp, err := a.llm.Stream(ctx, &llm.CompletionRequest{
Messages: trimmed,
Tools: llmTools,
Temperature: a.config.Temperature,
MaxTokens: a.config.MaxTokens,
}, func(delta string) {
if strings.TrimSpace(delta) != "" {
a.emit(Event{Type: EventThoughtDelta, Round: round, Thought: delta})
}
})
if err != nil {
a.emit(Event{Type: EventError, Message: "LLM 调用失败: " + err.Error()})
@@ -169,9 +185,9 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
a.totalTokens += resp.Usage.TotalTokens
a.emit(Event{Type: EventProgress, Tokens: a.totalTokens})
// 推送思考文本
// 推送完整思考文本(供持久化与回放) // Logan.Li
if strings.TrimSpace(resp.Message.Content) != "" {
a.emit(Event{Type: EventThought, Round: iter + 1, Thought: resp.Message.Content})
a.emit(Event{Type: EventThought, Round: round, Thought: resp.Message.Content})
}
messages = append(messages, resp.Message)
@@ -180,11 +196,14 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
answer := resp.Message.Content
a.emit(Event{Type: EventDone, Answer: answer, Tokens: a.totalTokens})
a.triggerEvolve(ctx, query, serverInfo, answer, toolRecords, true, start)
return &tools.Result{Success: true, Output: answer}, nil
return &RunResult{
Success: true, Output: answer,
History: a.extractHistory(messages), TotalTokens: a.totalTokens,
}, nil
}
// 处理工具调用
observations, records, failInc := a.processToolCalls(ctx, resp.Message.ToolCalls, toolCallHistory, iter+1)
observations, records, failInc := a.processToolCalls(ctx, resp.Message.ToolCalls, toolCallHistory, round)
toolRecords = append(toolRecords, records...)
messages = append(messages, observations...)
totalToolCalls += len(resp.Message.ToolCalls)
@@ -195,7 +214,9 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
msg := fmt.Sprintf("达到最大迭代次数(%d),任务未能完成。可能任务过于复杂或需要更多步骤。", a.config.MaxIterations)
a.emit(Event{Type: EventError, Message: msg, Tokens: a.totalTokens})
a.triggerEvolve(ctx, query, serverInfo, "", toolRecords, false, start)
return nil, fmt.Errorf("%s", msg)
return &RunResult{
Success: false, History: a.extractHistory(messages), TotalTokens: a.totalTokens,
}, fmt.Errorf("%s", msg)
}
// processToolCalls 处理工具调用,返回 observation 消息 + 记录 // Logan.Li
@@ -264,6 +285,13 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist
success = false
}
// 超长 ssh 输出自动分析,避免 token 爆炸 // Logan.Li
if tc.Function.Name == "ssh_execute" && success && len(output) > a.autoAnalyzeThreshold() {
if analyzed := a.autoAnalyzeOutput(ctx, output); analyzed != "" {
output = analyzed
}
}
// 输出截断
if len(output) > a.config.OutputMaxLength {
output = output[:a.config.OutputMaxLength] +
@@ -313,6 +341,43 @@ func (a *Agent) triggerEvolve(ctx context.Context, query, serverHost, answer str
}()
}
// roundNum 计算对外展示的 ReAct 轮次(含多轮追问偏移) // Logan.Li
func (a *Agent) roundNum(iter int) int {
return a.config.RoundOffset + iter + 1
}
// extractHistory 提取 system 之后的对话历史(供多轮追问) // Logan.Li
func (a *Agent) extractHistory(messages []llm.Message) []llm.Message {
if len(messages) <= 1 {
return nil
}
out := make([]llm.Message, len(messages)-1)
copy(out, messages[1:])
return out
}
// autoAnalyzeThreshold 触发自动 analyze_output 的输出长度阈值 // Logan.Li
func (a *Agent) autoAnalyzeThreshold() int {
t := a.config.OutputMaxLength / 2
if t < 4000 {
return 4000
}
return t
}
// autoAnalyzeOutput 对超长命令输出自动调用 analyze_output // Logan.Li
func (a *Agent) autoAnalyzeOutput(ctx context.Context, output string) string {
tool, err := a.registry.Get("analyze_output")
if err != nil {
return ""
}
r, err := tool.Execute(ctx, map[string]interface{}{"output": output})
if err != nil || r == nil || !r.Success || strings.TrimSpace(r.Output) == "" {
return ""
}
return r.Output + "\n[系统自动分析超长输出]"
}
// buildToolDescriptions 工具描述文本 // Logan.Li
func (a *Agent) buildToolDescriptions() string {
var sb strings.Builder
@@ -428,9 +493,5 @@ func estimateTokens(s string) int {
return len([]rune(s))/3 + 1
}
// Close 释放资源 // Logan.Li
func (a *Agent) Close() {
if a.registry != nil {
// Registry 无 Closer 实现(Web 工具无连接池),保留接口位
}
}
// Close 释放资源(SSH 长连接由 Engine.Close 统一释放) // Logan.Li
func (a *Agent) Close() {}
+36
View File
@@ -42,6 +42,7 @@ type EvolveResult struct {
LearnedHint string `json:"learned_hint"`
ToolSequence []string `json:"tool_sequence"`
ExperienceAdded bool `json:"experience_added"`
SkillAdded bool `json:"skill_added"`
Consolidated bool `json:"consolidated"`
}
@@ -107,6 +108,21 @@ func (e *EvolverEngine) Evolve(ctx context.Context, exec *TaskExecution) *Evolve
go e.memorizeFact(ctx, exec) // 异步,不阻塞进化主流程
}
// Step 6: PROCEDURAL —— 成功任务沉淀程序记忆(工具命令序列) // Logan.Li
if exec.Success {
if steps := extractSSHSteps(exec.ToolCalls); len(steps) >= 2 {
skill := SkillEntry{
Name: result.TaskType + "标准流程",
When: result.TaskType,
Steps: steps,
Timestamp: time.Now().Unix(),
}
if err := e.mem.AddSkill(skill); err == nil {
result.SkillAdded = true
}
}
}
return result
}
@@ -194,6 +210,12 @@ func (e *EvolverEngine) GetContextForPrompt(query string) string {
if hosts := e.mem.Hosts(); len(hosts) > 0 {
sb.WriteString(fmt.Sprintf(" 【已知主机】%s\n", strings.Join(hosts, ", ")))
}
if skills := e.mem.FindSkills(taskType, 2); len(skills) > 0 {
sb.WriteString(" 【推荐流程】\n")
for _, sk := range skills {
sb.WriteString(fmt.Sprintf(" - %s: %s\n", sk.Name, strings.Join(sk.Steps, " → ")))
}
}
return sb.String()
}
@@ -258,6 +280,20 @@ func resultToolNames(calls []ToolCallRecord) []string {
return out
}
// extractSSHSteps 从工具调用记录提取 ssh_execute 命令序列 // Logan.Li
func extractSSHSteps(calls []ToolCallRecord) []string {
out := make([]string, 0, len(calls))
for _, c := range calls {
if c.ToolName != "ssh_execute" {
continue
}
if cmd, ok := c.Args["command"].(string); ok && strings.TrimSpace(cmd) != "" {
out = append(out, strings.TrimSpace(cmd))
}
}
return out
}
func uniqueStrings(in []string) []string {
seen := make(map[string]bool)
out := make([]string, 0, len(in))
+15
View File
@@ -235,6 +235,21 @@ func (m *Memories) Hosts() []string {
return readJSON(m.envPath, KnownHosts{}).Hosts
}
// FindSkills 按任务类型查找程序记忆(运维流程,最多 n 条) // Logan.Li
func (m *Memories) FindSkills(taskType string, n int) []SkillEntry {
all := readJSONL[SkillEntry](m.sklPath)
var matched []SkillEntry
for _, s := range all {
if taskType == "" || s.When == taskType {
matched = append(matched, s)
}
}
if len(matched) > n {
matched = matched[len(matched)-n:]
}
return matched
}
// CountStats 统计各层记忆条目数 // Logan.Li
func (m *Memories) CountStats() map[string]int {
return map[string]int{
+26
View File
@@ -42,6 +42,12 @@ type anthropicMessage struct {
Content json.RawMessage `json:"content"`
}
// anthropicThinking 启用 Claude thinking 模式(Claude 3.7 Sonnet 支持) // Logan.Li
type anthropicThinking struct {
Type string `json:"type"`
BudgetTokens int `json:"budget_tokens"`
}
// anthropicRequest Anthropic /v1/messages 请求体 // Logan.Li
type anthropicRequest struct {
Model string `json:"model"`
@@ -51,6 +57,7 @@ type anthropicRequest struct {
Tools []anthropicTool `json:"tools,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Stream bool `json:"stream,omitempty"`
Thinking *anthropicThinking `json:"thinking,omitempty"` // Claude 3.7 Sonnet 推理模式 // Logan.Li
}
// anthropicTool Anthropic 工具定义(input_schema,对应 OpenAI 的 parameters) // Logan.Li
@@ -157,6 +164,12 @@ func buildTools(tools []Tool) []anthropicTool {
return out
}
// isAnthropicThinkingModel 判断是否为支持 thinking 的 Claude 模型 // Logan.Li
func isAnthropicThinkingModel(model string) bool {
m := strings.ToLower(model)
return strings.Contains(m, "claude-3-7") || strings.Contains(m, "claude-3.7")
}
// buildRequest 构造 Anthropic 请求体(Complete 与 Stream 共用) // Logan.Li
func (c *AnthropicClient) buildRequest(req *CompletionRequest, stream bool) ([]byte, error) {
system, msgs := c.buildMessages(req)
@@ -168,6 +181,19 @@ func (c *AnthropicClient) buildRequest(req *CompletionRequest, stream bool) ([]b
if payload.MaxTokens <= 0 {
payload.MaxTokens = 4096
}
// 启用 Claude thinking 模式时,temperature 必须固定为 1,max_tokens 需大于 budget_tokens // Logan.Li
enabled := req.ReasoningEnabled || c.cfg.ReasoningEnabled
if enabled && isAnthropicThinkingModel(payload.Model) {
payload.Temperature = 1
budget := payload.MaxTokens / 2
if budget < 1024 {
budget = 1024
}
if payload.MaxTokens <= budget {
payload.MaxTokens = budget + 1024
}
payload.Thinking = &anthropicThinking{Type: "enabled", BudgetTokens: budget}
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
+47 -14
View File
@@ -17,13 +17,14 @@ import (
// Config LLM 客户端配置(endpoint 已由上层解析为完整 base url) // Logan.Li
type Config struct {
Protocol string // "openai" | "anthropic",由 client.go 工厂决定具体实现
Endpoint string // 完整 base url,如 https://api.deepseek.com/v1
APIKey string // Bearer token
Model string // 模型名,如 deepseek-chat / gpt-4o-mini
Temperature float64 // 采样温度
MaxTokens int // 单次最大输出 token
Timeout time.Duration // HTTP 超时(0 表示使用默认 180s)
Protocol string // "openai" | "anthropic",由 client.go 工厂决定具体实现
Endpoint string // 完整 base url,如 https://api.deepseek.com/v1
APIKey string // Bearer token
Model string // 模型名,如 deepseek-chat / gpt-4o-mini
Temperature float64 // 采样温度
MaxTokens int // 单次最大输出 token
ReasoningEnabled bool // 是否启用推理/Plan 模式(OpenAI o-series / DeepSeek R1 等) // Logan.Li
Timeout time.Duration // HTTP 超时(0 表示使用默认 180s)
}
// OpenAIClient OpenAI 兼容客户端 // Logan.Li
@@ -48,15 +49,45 @@ func NewOpenAIClient(cfg Config) Client {
// Name 返回客户端名称 // Logan.Li
func (c *OpenAIClient) Name() string { return "openai-compatible(" + c.cfg.Model + ")" }
// isOpenAIReasoningModel 判断是否为需要 reasoning_effort 的 OpenAI o-series 模型 // Logan.Li
func isOpenAIReasoningModel(model string) bool {
m := strings.ToLower(model)
return strings.Contains(m, "o1") || strings.Contains(m, "o3")
}
// isGenericReasoningModel 判断是否为通用推理开关模型(DeepSeek R1 等) // Logan.Li
func isGenericReasoningModel(model string) bool {
m := strings.ToLower(model)
return strings.Contains(m, "reasoning") || strings.Contains(m, "r1") || strings.Contains(m, "deepseek-reasoner")
}
// applyReasoningParams 根据模型类型应用 reasoning 参数 // Logan.Li
func applyReasoningParams(payload *openaiRequest, enabled bool) {
if !enabled {
return
}
if isOpenAIReasoningModel(payload.Model) {
// OpenAI o-series 使用 reasoning_effort,temperature 必须固定为 1(不传) // Logan.Li
payload.ReasoningEffort = "medium"
payload.Temperature = 0
return
}
if isGenericReasoningModel(payload.Model) {
payload.Reasoning = true
}
}
// openaiRequest OpenAI chat/completions 请求体 // Logan.Li
type openaiRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Tools []Tool `json:"tools,omitempty"`
ToolChoice string `json:"tool_choice,omitempty"` // "auto" 让模型自主决定
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream,omitempty"` // 流式补全(Stream 方法置 true)
Model string `json:"model"`
Messages []Message `json:"messages"`
Tools []Tool `json:"tools,omitempty"`
ToolChoice string `json:"tool_choice,omitempty"` // "auto" 让模型自主决定
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream,omitempty"` // 流式补全(Stream 方法置 true)
ReasoningEffort string `json:"reasoning_effort,omitempty"` // OpenAI o-series 推理强度 // Logan.Li
Reasoning bool `json:"reasoning,omitempty"` // DeepSeek 等部分平台推理开关 // Logan.Li
}
// openaiResponse OpenAI chat/completions 响应体 // Logan.Li
@@ -89,6 +120,7 @@ func (c *OpenAIClient) Complete(ctx context.Context, req *CompletionRequest) (*C
if payload.MaxTokens <= 0 {
payload.MaxTokens = 4096
}
applyReasoningParams(&payload, req.ReasoningEnabled || c.cfg.ReasoningEnabled)
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
@@ -151,6 +183,7 @@ func (c *OpenAIClient) Stream(ctx context.Context, req *CompletionRequest, onDel
if payload.MaxTokens <= 0 {
payload.MaxTokens = 4096
}
applyReasoningParams(&payload, req.ReasoningEnabled || c.cfg.ReasoningEnabled)
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
+5 -4
View File
@@ -43,10 +43,11 @@ type ToolFunction struct {
// CompletionRequest 补全请求参数 // Logan.Li
type CompletionRequest struct {
Messages []Message `json:"messages"`
Tools []Tool `json:"tools,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Messages []Message `json:"messages"`
Tools []Tool `json:"tools,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
ReasoningEnabled bool `json:"reasoning_enabled,omitempty"` // 是否启用推理/Plan 模式(协议相关) // Logan.Li
}
// CompletionResponse 补全响应(取首个 choice) // Logan.Li
+11
View File
@@ -153,6 +153,17 @@ func StringParam(args map[string]interface{}, key string) string {
return ""
}
// Close 关闭所有实现了 Close() 的工具(如 ssh_execute 长连接) // Logan.Li
func (r *Registry) Close() {
r.mu.RLock()
defer r.mu.RUnlock()
for _, t := range r.tools {
if c, ok := t.(interface{ Close() error }); ok {
_ = c.Close()
}
}
}
// IntParam 取整型参数(兼容 int/float64/int64) // Logan.Li
func IntParam(args map[string]interface{}, key string) int {
if v, ok := args[key]; ok {
+24 -18
View File
@@ -1,5 +1,6 @@
// ssh_execute.go - 远程命令执行工具 // Logan.Li
// 复用 aiops 既有 pkg/remoteexec(Go 原生 SSH),凭据由 controller 从 model.Server 构建后注入。
// 任务级复用 PersistentClient 长连接,避免每条命令重建 SSH 握手。
// 实现 DynamicRiskTool:依据具体命令动态评估风险,决定是否触发用户确认。
package tools
@@ -14,16 +15,28 @@ import (
// SSHExecuteTool 远程命令执行工具 // Logan.Li
type SSHExecuteTool struct {
cfg remoteexec.SSHConfig // 目标主机连接配置(含凭据)
timeout time.Duration // 默认命令超时
client *remoteexec.PersistentClient // 任务级 SSH 长连接
timeout time.Duration // 默认命令超时
}
// NewSSHExecuteTool 创建远程执行工具 // Logan.Li
func NewSSHExecuteTool(cfg remoteexec.SSHConfig, timeout time.Duration) *SSHExecuteTool {
// NewSSHExecuteTool 创建远程执行工具(内部建立 SSH 长连接) // Logan.Li
func NewSSHExecuteTool(cfg remoteexec.SSHConfig, timeout time.Duration) (*SSHExecuteTool, error) {
if timeout <= 0 {
timeout = 60 * time.Second
}
return &SSHExecuteTool{cfg: cfg, timeout: timeout}
client, err := remoteexec.NewPersistentClient(cfg)
if err != nil {
return nil, fmt.Errorf("SSH 长连接建立失败: %w", err)
}
return &SSHExecuteTool{client: client, timeout: timeout}, nil
}
// Close 释放 SSH 长连接 // Logan.Li
func (t *SSHExecuteTool) Close() error {
if t.client == nil {
return nil
}
return t.client.Close()
}
// Name 工具名 // Logan.Li
@@ -31,14 +44,9 @@ func (t *SSHExecuteTool) Name() string { return "ssh_execute" }
// Description 给 LLM 的工具说明 // Logan.Li
func (t *SSHExecuteTool) Description() string {
host := t.cfg.Host
if host == "" {
host = "目标主机"
}
return fmt.Sprintf(
"在远程服务器 %s(用户 %s)上执行 shell 命令并返回合并的 stdout+stderr 输出与退出码。"+
"用于排障(查看日志/进程/磁盘/网络)、安装与配置软件、重启服务等。"+
"高危命令(rm -rf /、mkfs、reboot 等)需用户确认。", host, t.cfg.User)
return "在远程目标服务器上执行 shell 命令并返回合并的 stdout+stderr 输出与退出码。" +
"用于排障(查看日志/进程/磁盘/网络)、安装与配置软件、重启服务等。" +
"高危命令(rm -rf /、mkfs、reboot 等)需用户确认。"
}
// Parameters JSON Schema 参数定义 // Logan.Li
@@ -79,13 +87,11 @@ func (t *SSHExecuteTool) Execute(ctx context.Context, args map[string]interface{
timeout = time.Duration(sec) * time.Second
}
res, err := remoteexec.Exec(t.cfg, command, timeout)
res, err := t.client.Exec(command, timeout)
if err != nil {
// 连接级失败(无法 SSH)
return &Result{Success: false, Error: fmt.Sprintf("SSH 连接失败: %v", err)}, nil
}
// 附带退出码,便于 agent 判断命令是否成功(排障关键信号)
output := res.Output
if !strings.HasSuffix(output, "\n") && output != "" {
output += "\n"
@@ -93,8 +99,8 @@ func (t *SSHExecuteTool) Execute(ctx context.Context, args map[string]interface{
output += fmt.Sprintf("[exit_code=%d]", res.ExitCode)
return &Result{
Success: res.Success, // exit_code==0
Success: res.Success,
Output: output,
Error: res.Error,
}, nil
}
}
+1
View File
@@ -120,6 +120,7 @@ func AutoMigrate(db *gorm.DB) error {
&model.LogEntry{},
&model.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
&model.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
&model.SessionCommand{}, // 终端会话命令审计 // Logan.Li
&model.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li
&model.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li
&model.MetricsAggregate{}, // 指标时序聚合(Redis → MySQL)// Logan.Li
+2 -69
View File
@@ -5,7 +5,6 @@ package remoteexec
import (
"bytes"
"context"
"fmt"
"log"
"net"
@@ -70,10 +69,7 @@ type UploadResult struct {
// 返回合并的 stdout+stderr 输出、退出码和错误。
func Exec(cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, error) {
start := time.Now()
result := &ExecResult{
Host: cfg.Host,
Port: cfg.Port,
}
result := &ExecResult{Host: cfg.Host, Port: cfg.Port}
client, err := connect(cfg)
if err != nil {
@@ -83,70 +79,7 @@ func Exec(cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, er
}
defer client.Close()
// 创建 session
session, err := client.NewSession()
if err != nil {
result.Duration = time.Since(start).Milliseconds()
result.Error = fmt.Sprintf("创建SSH会话失败: %v", err)
return result, err
}
defer session.Close()
// 设置超时
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// 在 goroutine 中执行,支持超时
type execOut struct {
output []byte
err error
}
ch := make(chan execOut, 1)
go func() {
var stdout, stderr bytes.Buffer
session.Stdout = &stdout
session.Stderr = &stderr
err := session.Run(command)
// 合并输出
output := stdout.Bytes()
if stderr.Len() > 0 {
if len(output) > 0 {
output = append(output, '\n')
}
output = append(output, stderr.Bytes()...)
}
ch <- execOut{output: output, err: err}
}()
select {
case <-ctx.Done():
result.Duration = time.Since(start).Milliseconds()
result.Error = fmt.Sprintf("执行超时(%v)", timeout)
result.ExitCode = -1
return result, fmt.Errorf("exec timeout after %v", timeout)
case out := <-ch:
result.Duration = time.Since(start).Milliseconds()
result.Output = string(out.output)
if out.err != nil {
if exitErr, ok := out.err.(*ssh.ExitError); ok {
result.ExitCode = exitErr.ExitStatus()
result.Success = false
} else {
result.ExitCode = -1
result.Error = out.err.Error()
result.Success = false
return result, out.err
}
} else {
result.ExitCode = 0
result.Success = true
}
}
return result, nil
return execWithClient(client, cfg, command, timeout)
}
// BatchExec 在多台远程主机上并发执行相同命令。
+144
View File
@@ -0,0 +1,144 @@
// persistent_client.go - 任务级 SSH 长连接,多次 Exec 复用同一 client // Logan.Li
package remoteexec
import (
"bytes"
"context"
"fmt"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
// PersistentClient 可复用的 SSH 长连接(任务生命周期内持有,Close 释放) // Logan.Li
type PersistentClient struct {
cfg SSHConfig
client *ssh.Client
mu sync.Mutex
}
// NewPersistentClient 建立 SSH 长连接 // Logan.Li
func NewPersistentClient(cfg SSHConfig) (*PersistentClient, error) {
client, err := connect(cfg)
if err != nil {
return nil, err
}
return &PersistentClient{cfg: cfg, client: client}, nil
}
// Exec 在长连接上执行命令;连接断开时自动重连一次 // Logan.Li
func (p *PersistentClient) Exec(command string, timeout time.Duration) (*ExecResult, error) {
p.mu.Lock()
defer p.mu.Unlock()
result, err := p.execLocked(command, timeout)
if err == nil {
return result, nil
}
// 连接级失败:重连后重试一次 // Logan.Li
if p.client != nil {
_ = p.client.Close()
p.client = nil
}
client, connErr := connect(p.cfg)
if connErr != nil {
return result, err
}
p.client = client
return p.execLocked(command, timeout)
}
// execLocked 假定已持锁,在当前 client 上执行 // Logan.Li
func (p *PersistentClient) execLocked(command string, timeout time.Duration) (*ExecResult, error) {
if p.client == nil {
client, err := connect(p.cfg)
if err != nil {
return nil, err
}
p.client = client
}
return execWithClient(p.client, p.cfg, command, timeout)
}
// Close 关闭长连接 // Logan.Li
func (p *PersistentClient) Close() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.client == nil {
return nil
}
err := p.client.Close()
p.client = nil
return err
}
// execWithClient 在已有 SSH client 上执行命令(供 Exec / PersistentClient 共用) // Logan.Li
func execWithClient(client *ssh.Client, cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, error) {
start := time.Now()
result := &ExecResult{Host: cfg.Host, Port: cfg.Port}
session, err := client.NewSession()
if err != nil {
result.Duration = time.Since(start).Milliseconds()
result.Error = fmt.Sprintf("创建SSH会话失败: %v", err)
return result, err
}
defer session.Close()
if timeout <= 0 {
timeout = 60 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
type execOut struct {
output []byte
err error
}
ch := make(chan execOut, 1)
go func() {
var stdout, stderr bytes.Buffer
session.Stdout = &stdout
session.Stderr = &stderr
runErr := session.Run(command)
output := stdout.Bytes()
if stderr.Len() > 0 {
if len(output) > 0 {
output = append(output, '\n')
}
output = append(output, stderr.Bytes()...)
}
ch <- execOut{output: output, err: runErr}
}()
select {
case <-ctx.Done():
result.Duration = time.Since(start).Milliseconds()
result.Error = fmt.Sprintf("执行超时(%v)", timeout)
result.ExitCode = -1
return result, fmt.Errorf("exec timeout after %v", timeout)
case out := <-ch:
result.Duration = time.Since(start).Milliseconds()
result.Output = string(out.output)
if out.err != nil {
if exitErr, ok := out.err.(*ssh.ExitError); ok {
result.ExitCode = exitErr.ExitStatus()
result.Success = false
} else {
result.ExitCode = -1
result.Error = out.err.Error()
result.Success = false
return result, out.err
}
} else {
result.ExitCode = 0
result.Success = true
}
}
return result, nil
}
+1
View File
@@ -803,6 +803,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
ts.DELETE("/:id", terminalSessionHandler.DeleteTerminalSession)
ts.POST("/:id/kill", terminalSessionHandler.KillSession)
ts.GET("/:id/download", terminalSessionHandler.DownloadSession)
ts.POST("/:id/replay-view", terminalSessionHandler.LogReplayView)
ts.GET("/:id/stream", terminalSessionHandler.StreamSession)
}