Files
aiops/backend/controller/llm_chat.go
T
Logan 1acb0c49af feat: Agent CLI 与终端审计回放全面优化
- Agent CLI: 多轮对话、SSH 长连接、流式推理、Evolver 按用户隔离、历史侧栏
- 终端审计: 回放播放器、命令书签、筛选分页导出、命令输出统计、录制落盘修复
- 修复 session_commands 表迁移、Kill 竞态导致录制丢失、无录制会话回放提示
- 审计历史页只读(移除删除),移除回放弹框录制搜索
2026-07-01 12:13:53 +08:00

206 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controller
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"ezops/middleware"
"ezops/model"
"ezops/pkg/agentcli/llm"
"github.com/gin-gonic/gin"
)
// toLLMMessages model.ChatMessage[] → llm.Message[](智能助手纯对话,仅填 Role/Content) // Logan.Li
func toLLMMessages(msgs []model.ChatMessage) []llm.Message {
out := make([]llm.Message, len(msgs))
for i, m := range msgs {
out[i] = llm.Message{Role: m.Role, Content: m.Content}
}
return out
}
// Chat AI 对话代理(统一走 llm.Client,按配置协议自动适配 OpenAI/Anthropic) // Logan.Li
// POST /api/v1/aiops/chat
func (h *LLMHandler) Chat(c *gin.Context) {
var cfg model.LLMConfig
if err := h.db.First(&cfg).Error; err != nil || !cfg.Enabled {
c.JSON(http.StatusOK, model.Response{Code: 400, Message: "LLM 服务未配置或未启用,请在「模型配置」中完成设置"})
return
}
var req model.ChatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
return
}
// 组装系统提示词
systemPrompt := cfg.SystemPrompt
if strings.TrimSpace(systemPrompt) == "" {
systemPrompt = defaultSystemPrompt
}
messages := append([]model.ChatMessage{{Role: "system", Content: systemPrompt}}, req.Messages...)
resp, err := buildLLMClientFromConfig(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{
Messages: toLLMMessages(messages),
})
if err != nil {
c.JSON(http.StatusOK, model.Response{Code: 502, Message: "LLM 调用失败: " + err.Error()})
return
}
replyContent := resp.Message.Content
// 持久化对话历史(按用户隔离,成对写入 user + assistant) // Logan.Li
// 仅在成功时入库;错误气泡为瞬时 UI 不持久化
uid := middleware.GetUserID(c)
if uid != 0 && len(req.Messages) > 0 {
lastUser := req.Messages[len(req.Messages)-1]
now := time.Now()
sid := req.SessionID // 前端传入或后端生成(兼容旧版客户端)
if sid == "" {
sid = fmt.Sprintf("sess-%d", now.UnixMilli())
}
h.db.Create(&[]model.ChatHistory{
{UserID: uid, SessionID: sid, Role: "user", Content: lastUser.Content, CreatedAt: now},
{UserID: uid, SessionID: sid, Role: "assistant", Content: replyContent, Model: cfg.Model, CreatedAt: now.Add(time.Millisecond)},
})
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ChatResponse{
Content: replyContent,
Model: cfg.Model,
TokensUsed: resp.Usage.TotalTokens,
}})
}
// ChatStream AI 流式对话代理(SSE) // Logan.Li
// POST /api/v1/aiops/chat/stream
// 客户端用 fetch-stream 消费,每条 SSE data 为 {"content": "..."};出错发 event: error;完成发 event: done。
// 上游协议差异(OpenAI delta / Anthropic content_block_delta)由 llm.Client.Stream 屏蔽,
// controller 只负责 SSE 转发 + 客户端断开检测 + 历史持久化。
func (h *LLMHandler) ChatStream(c *gin.Context) {
var cfg model.LLMConfig
if err := h.db.First(&cfg).Error; err != nil || !cfg.Enabled {
writeSSEError(c, 400, "LLM 服务未配置或未启用,请在「模型配置」中完成设置")
return
}
var req model.ChatRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeSSEError(c, 400, "请求参数错误: "+err.Error())
return
}
if len(req.Messages) == 0 {
writeSSEError(c, 400, "消息不能为空")
return
}
// 设置 SSE 响应头 // Logan.Li
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("X-Accel-Buffering", "no") // Nginx 关闭缓冲
c.Writer.WriteHeader(http.StatusOK)
c.Writer.Flush() // 立即 flush 让客户端确认连接建立
systemPrompt := cfg.SystemPrompt
if strings.TrimSpace(systemPrompt) == "" {
systemPrompt = defaultSystemPrompt
}
messages := append([]model.ChatMessage{{Role: "system", Content: systemPrompt}}, req.Messages...)
// 流式补全:逐 token 回调转发 SSE;ctx 取消(客户端断开)由 llm.Client 内部终止上游读取
var contentBuilder strings.Builder
_, streamErr := buildLLMClientFromConfig(cfg).Stream(c.Request.Context(), &llm.CompletionRequest{
Messages: toLLMMessages(messages),
}, func(delta string) {
contentBuilder.WriteString(delta)
writeSSEChunk(c, delta)
})
fullContent := contentBuilder.String()
// 出错且无任何内容 → 发 error 事件
if streamErr != nil && fullContent == "" {
writeSSEError(c, 502, "LLM 流式响应失败: "+streamErr.Error())
return
}
// 持久化 + 结束事件(仅在非空且客户端未断开时入库;错误/中断不入库避免污染历史) // Logan.Li
clientGone := c.Request.Context().Err() != nil
if fullContent != "" && !clientGone {
uid := middleware.GetUserID(c)
if uid != 0 {
lastUser := req.Messages[len(req.Messages)-1]
now := time.Now()
sid := req.SessionID
if sid == "" {
sid = fmt.Sprintf("sess-%d", now.UnixMilli())
}
h.db.Create(&[]model.ChatHistory{
{UserID: uid, SessionID: sid, Role: "user", Content: lastUser.Content, CreatedAt: now},
{UserID: uid, SessionID: sid, Role: "assistant", Content: fullContent, Model: cfg.Model, CreatedAt: now.Add(time.Millisecond)},
})
}
c.Writer.WriteString("event: done\ndata: {\"model\":\"" + cfg.Model + "\"}\n\n")
c.Writer.Flush()
} else if !clientGone {
// 无内容时通知前端结束(避免前端无限等待)
c.Writer.WriteString("event: done\ndata: {}\n\n")
c.Writer.Flush()
}
}
// writeSSEChunk 写入一条内容增量 // Logan.Li
func writeSSEChunk(c *gin.Context, content string) {
// 转义为 JSON 字符串避免换行符破坏 SSE 协议
b, _ := json.Marshal(map[string]string{"content": content})
c.Writer.WriteString("data: " + string(b) + "\n\n")
c.Writer.Flush()
}
// writeSSEError 发送一个 error 事件(写入后立即结束流) // Logan.Li
func writeSSEError(c *gin.Context, code int, message string) {
// 若 header 尚未写入(连接前错误),需先设置 SSE 头
if !c.Writer.Written() {
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.WriteHeader(http.StatusOK)
}
b, _ := json.Marshal(map[string]any{"code": code, "message": message})
c.Writer.WriteString("event: error\ndata: " + string(b) + "\n\n")
c.Writer.Flush()
}
// TestLLMConnection 测试 LLM 连接(询问模型身份并返回回答) // Logan.Li
// POST /api/v1/aiops/llm-config/test
func (h *LLMHandler) TestLLMConnection(c *gin.Context) {
var cfg model.LLMConfig
if err := h.db.First(&cfg).Error; err != nil {
c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先保存配置"})
return
}
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: "连接成功", Data: gin.H{
"answer": resp.Message.Content,
"model": resp.Model,
"tokens_used": resp.Usage.TotalTokens,
"provider": cfg.Provider,
"reasoning_enabled": cfg.ReasoningEnabled,
}})
}