341 lines
11 KiB
Go
341 lines
11 KiB
Go
package handlers
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"ezops/internal/api/middleware"
|
||
"ezops/internal/models"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// LLMHandler 大语言模型配置 + 对话代理 // Logan.Li
|
||
type LLMHandler struct {
|
||
db *gorm.DB
|
||
client *http.Client
|
||
}
|
||
|
||
// NewLLMHandler 创建 LLM 处理器 // Logan.Li
|
||
func NewLLMHandler(db *gorm.DB) *LLMHandler {
|
||
return &LLMHandler{
|
||
db: db,
|
||
// 超时控制:LLM 推理可能较慢,给 120s
|
||
client: &http.Client{Timeout: 120 * time.Second},
|
||
}
|
||
}
|
||
|
||
// maskAPIKey 脱敏 API Key(仅保留后 4 位) // Logan.Li
|
||
func maskAPIKey(key string) string {
|
||
if len(key) <= 4 {
|
||
return strings.Repeat("*", len(key))
|
||
}
|
||
return strings.Repeat("*", len(key)-4) + key[len(key)-4:]
|
||
}
|
||
|
||
// getActiveConfig 读取启用的配置(脱敏后) // Logan.Li
|
||
func (h *LLMHandler) getActiveConfig(maskKey bool) (models.LLMConfig, error) {
|
||
var cfg models.LLMConfig
|
||
if err := h.db.First(&cfg).Error; err != nil {
|
||
return cfg, err
|
||
}
|
||
if maskKey {
|
||
cfg.APIKey = maskAPIKey(cfg.APIKey)
|
||
}
|
||
return cfg, nil
|
||
}
|
||
|
||
// GetLLMConfig 获取 LLM 配置(API Key 脱敏) // Logan.Li
|
||
// GET /api/v1/aiops/llm-config
|
||
func (h *LLMHandler) GetLLMConfig(c *gin.Context) {
|
||
cfg, err := h.getActiveConfig(true)
|
||
if err != nil {
|
||
// 未配置返回空对象(前端渲染空表单)
|
||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{
|
||
"provider": "", "model": "", "api_key": "",
|
||
"endpoint": "", "temperature": 0.7, "max_tokens": 2000,
|
||
"system_prompt": "", "enabled": false, "configured": false,
|
||
}})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, models.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,
|
||
}})
|
||
}
|
||
|
||
// SaveLLMConfig 保存 LLM 配置(upsert 单行) // Logan.Li
|
||
// PUT /api/v1/aiops/llm-config
|
||
func (h *LLMHandler) SaveLLMConfig(c *gin.Context) {
|
||
var req models.SaveLLMConfigRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
|
||
return
|
||
}
|
||
var cfg models.LLMConfig
|
||
h.db.First(&cfg)
|
||
// 若前端回传脱敏值(含 ****),保留原 Key
|
||
if strings.Contains(req.APIKey, "****") && cfg.ID != 0 {
|
||
req.APIKey = cfg.APIKey
|
||
}
|
||
cfg.Provider = req.Provider
|
||
cfg.Model = req.Model
|
||
cfg.APIKey = req.APIKey
|
||
cfg.Endpoint = req.Endpoint
|
||
cfg.Temperature = req.Temperature
|
||
cfg.MaxTokens = req.MaxTokens
|
||
cfg.SystemPrompt = req.SystemPrompt
|
||
cfg.Enabled = req.Enabled
|
||
if cfg.ID == 0 {
|
||
if err := h.db.Create(&cfg).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "保存失败: " + err.Error()})
|
||
return
|
||
}
|
||
} else {
|
||
if err := h.db.Save(&cfg).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "保存失败: " + err.Error()})
|
||
return
|
||
}
|
||
}
|
||
// 返回脱敏后的配置
|
||
cfg.APIKey = maskAPIKey(cfg.APIKey)
|
||
c.JSON(http.StatusOK, models.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,
|
||
}})
|
||
}
|
||
|
||
// OpenAI 兼容请求/响应结构(仅取必要字段) // Logan.Li
|
||
type openaiChatRequest struct {
|
||
Model string `json:"model"`
|
||
Messages []models.ChatMessage `json:"messages"`
|
||
Temperature float64 `json:"temperature"`
|
||
MaxTokens int `json:"max_tokens,omitempty"`
|
||
}
|
||
type openaiChoice struct {
|
||
Message models.ChatMessage `json:"message"`
|
||
}
|
||
type openaiChatResponse struct {
|
||
Choices []openaiChoice `json:"choices"`
|
||
Usage struct {
|
||
TotalTokens int `json:"total_tokens"`
|
||
} `json:"usage"`
|
||
}
|
||
|
||
// resolveEndpoint 根据提供商补全默认 endpoint // Logan.Li
|
||
func resolveEndpoint(provider, endpoint string) string {
|
||
if endpoint != "" {
|
||
return strings.TrimRight(endpoint, "/")
|
||
}
|
||
switch provider {
|
||
case "deepseek":
|
||
return "https://api.deepseek.com/v1"
|
||
case "qwen":
|
||
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||
case "moonshot":
|
||
return "https://api.moonshot.cn/v1"
|
||
case "ollama":
|
||
return "http://localhost:11434/v1"
|
||
default: // openai / custom
|
||
return "https://api.openai.com/v1"
|
||
}
|
||
}
|
||
|
||
// defaultSystemPrompt AIOps 运维助手默认系统提示词 // Logan.Li
|
||
const defaultSystemPrompt = `你是 EZOps AIOps 智能运维助手,专注于企业级运维场景。你可以:
|
||
1. 分析服务器、容器、K8s 集群的性能瓶颈与异常
|
||
2. 解读告警事件并给出根因分析与处置建议
|
||
3. 生成 Shell / Ansible / Python 运维脚本
|
||
4. 提供容量规划、成本优化、高可用架构建议
|
||
回答请简洁、专业、可执行,优先给出具体命令或步骤。使用中文回答。`
|
||
|
||
// Chat AI 对话代理(OpenAI 兼容接口) // Logan.Li
|
||
// POST /api/v1/aiops/chat
|
||
func (h *LLMHandler) Chat(c *gin.Context) {
|
||
var cfg models.LLMConfig
|
||
if err := h.db.First(&cfg).Error; err != nil || !cfg.Enabled {
|
||
c.JSON(http.StatusOK, models.Response{Code: 400, Message: "LLM 服务未配置或未启用,请在「模型配置」中完成设置"})
|
||
return
|
||
}
|
||
|
||
var req models.ChatRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
|
||
return
|
||
}
|
||
|
||
// 组装系统提示词
|
||
systemPrompt := cfg.SystemPrompt
|
||
if strings.TrimSpace(systemPrompt) == "" {
|
||
systemPrompt = defaultSystemPrompt
|
||
}
|
||
messages := append([]models.ChatMessage{{Role: "system", Content: systemPrompt}}, req.Messages...)
|
||
|
||
// 构造 OpenAI 兼容请求
|
||
payload := openaiChatRequest{
|
||
Model: cfg.Model,
|
||
Messages: messages,
|
||
Temperature: cfg.Temperature,
|
||
MaxTokens: cfg.MaxTokens,
|
||
}
|
||
body, _ := json.Marshal(payload)
|
||
|
||
endpoint := resolveEndpoint(cfg.Provider, cfg.Endpoint)
|
||
url := endpoint + "/chat/completions"
|
||
|
||
httpReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, url, bytes.NewReader(body))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, models.Response{Code: 500, Message: "构造请求失败: " + err.Error()})
|
||
return
|
||
}
|
||
httpReq.Header.Set("Content-Type", "application/json")
|
||
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||
|
||
resp, err := h.client.Do(httpReq)
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, models.Response{Code: 502, Message: "LLM 服务连接失败,请检查网络或 Endpoint 配置: " + err.Error()})
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
raw, _ := io.ReadAll(resp.Body)
|
||
if resp.StatusCode != http.StatusOK {
|
||
// 提取上游错误信息
|
||
msg := fmt.Sprintf("LLM 服务返回错误 (HTTP %d)", resp.StatusCode)
|
||
if upErr := parseOpenAIError(raw); upErr != "" {
|
||
msg += ": " + upErr
|
||
}
|
||
c.JSON(http.StatusOK, models.Response{Code: resp.StatusCode, Message: msg})
|
||
return
|
||
}
|
||
|
||
var chatResp openaiChatResponse
|
||
if err := json.Unmarshal(raw, &chatResp); err != nil || len(chatResp.Choices) == 0 {
|
||
c.JSON(http.StatusOK, models.Response{Code: 500, Message: "解析 LLM 响应失败"})
|
||
return
|
||
}
|
||
replyContent := chatResp.Choices[0].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()
|
||
h.db.Create(&[]models.ChatHistory{
|
||
{UserID: uid, Role: "user", Content: lastUser.Content, CreatedAt: now},
|
||
{UserID: uid, Role: "assistant", Content: replyContent, Model: cfg.Model, CreatedAt: now.Add(time.Millisecond)},
|
||
})
|
||
}
|
||
|
||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ChatResponse{
|
||
Content: replyContent,
|
||
Model: cfg.Model,
|
||
TokensUsed: chatResp.Usage.TotalTokens,
|
||
}})
|
||
}
|
||
|
||
// parseOpenAIError 从上游错误响应中提取 message 字段 // Logan.Li
|
||
func parseOpenAIError(raw []byte) string {
|
||
var m struct {
|
||
Error struct {
|
||
Message string `json:"message"`
|
||
} `json:"error"`
|
||
}
|
||
if json.Unmarshal(raw, &m) == nil && m.Error.Message != "" {
|
||
return m.Error.Message
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// TestLLMConnection 测试 LLM 连接(发送一条 ping 消息) // Logan.Li
|
||
// POST /api/v1/aiops/llm-config/test
|
||
func (h *LLMHandler) TestLLMConnection(c *gin.Context) {
|
||
var cfg models.LLMConfig
|
||
if err := h.db.First(&cfg).Error; err != nil {
|
||
c.JSON(http.StatusOK, models.Response{Code: 400, Message: "请先保存配置"})
|
||
return
|
||
}
|
||
payload := openaiChatRequest{
|
||
Model: cfg.Model,
|
||
Messages: []models.ChatMessage{{Role: "user", Content: "ping"}},
|
||
Temperature: 0,
|
||
MaxTokens: 16,
|
||
}
|
||
body, _ := json.Marshal(payload)
|
||
endpoint := resolveEndpoint(cfg.Provider, cfg.Endpoint)
|
||
httpReq, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint+"/chat/completions", bytes.NewReader(body))
|
||
httpReq.Header.Set("Content-Type", "application/json")
|
||
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||
resp, err := h.client.Do(httpReq)
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, models.Response{Code: 502, Message: "连接失败: " + err.Error()})
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
raw, _ := io.ReadAll(resp.Body)
|
||
msg := fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||
if e := parseOpenAIError(raw); e != "" {
|
||
msg += ": " + e
|
||
}
|
||
c.JSON(http.StatusOK, models.Response{Code: resp.StatusCode, Message: msg})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "连接成功"})
|
||
}
|
||
|
||
// GetChatHistory 获取当前用户的对话历史(按时间正序,仅返回最近 100 条避免过大) // Logan.Li
|
||
// GET /api/v1/aiops/chat-history
|
||
func (h *LLMHandler) GetChatHistory(c *gin.Context) {
|
||
uid := middleware.GetUserID(c)
|
||
var rows []models.ChatHistory
|
||
// 子查询取最近 100 条后再正序返回,保证前端渲染顺序正确 // Logan.Li
|
||
h.db.Where("user_id = ?", uid).
|
||
Order("id DESC").
|
||
Limit(100).
|
||
Find(&rows)
|
||
// 反转为时间正序(最早在前)
|
||
for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 {
|
||
rows[i], rows[j] = rows[j], rows[i]
|
||
}
|
||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{
|
||
"list": rows,
|
||
"total": len(rows),
|
||
}})
|
||
}
|
||
|
||
// ClearChatHistory 清空当前用户的对话历史 // Logan.Li
|
||
// DELETE /api/v1/aiops/chat-history
|
||
func (h *LLMHandler) ClearChatHistory(c *gin.Context) {
|
||
uid := middleware.GetUserID(c)
|
||
if err := h.db.Where("user_id = ?", uid).Delete(&models.ChatHistory{}).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "清除失败: " + err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "对话历史已清空"})
|
||
}
|