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() } // llmConfigFromTestRequest 将测试请求转为运行时配置(脱敏 Key 回退已保存值) // Logan.Li func (h *LLMHandler) llmConfigFromTestRequest(req model.SaveLLMConfigRequest) (model.LLMConfig, string) { cfg := model.LLMConfig{ Provider: req.Provider, Model: req.Model, APIKey: req.APIKey, Endpoint: req.Endpoint, Temperature: req.Temperature, MaxTokens: req.MaxTokens, ReasoningEnabled: req.ReasoningEnabled, } if cfg.Temperature == 0 { cfg.Temperature = 0.7 } if cfg.MaxTokens == 0 { cfg.MaxTokens = 8192 } if strings.Contains(req.APIKey, "****") { var saved model.LLMConfig if err := h.db.First(&saved).Error; err != nil { if !isLocalLLMProvider(req.Provider) { return cfg, "请填写 API Key" } } else { cfg.APIKey = saved.APIKey } } if cfg.Provider == "" { return cfg, "请选择提供商" } if cfg.Model == "" { return cfg, "请填写模型名" } if cfg.APIKey == "" && !isLocalLLMProvider(cfg.Provider) { return cfg, "请填写 API Key" } return cfg, "" } // TestLLMConnection 测试 LLM 连接(支持草稿配置,保存前即可验证) // Logan.Li // POST /api/v1/aiops/llm-config/test func (h *LLMHandler) TestLLMConnection(c *gin.Context) { var req model.SaveLLMConfigRequest _ = c.ShouldBindJSON(&req) // 空 body 时回退已保存配置 // Logan.Li var cfg model.LLMConfig var errMsg string if req.Provider != "" { cfg, errMsg = h.llmConfigFromTestRequest(req) } else { if err := h.db.First(&cfg).Error; err != nil { c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先填写配置并测试连接"}) return } if cfg.APIKey == "" && !isLocalLLMProvider(cfg.Provider) { c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请填写 API Key"}) return } } if errMsg != "" { c.JSON(http.StatusOK, model.Response{Code: 400, Message: errMsg}) 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, }}) }