feat: AIOps智能助手接入真实LLM后端(OpenAI兼容代理+配置持久化)
后端: - models/llm.go: LLMConfig模型(单行配置)+请求/响应struct - handlers/llm.go: GetLLMConfig/SaveLLMConfig(脱敏+upsert)/Chat(OpenAI兼容代理)/TestLLMConnection - routes.go: 注册 /aiops/llm-config(GET/PUT/test)+/aiops/chat(POST) - database.go: AutoMigrate LLMConfig - 支持OpenAI/DeepSeek/通义千问/Moonshot/Ollama/自定义6种提供商 - API Key脱敏(仅保留后4位)+回传脱敏值保留原值 - 优雅错误处理(连接超时/401/未配置均返回友好消息) - 120s超时+AIOps运维助手默认系统提示词 前端: - AIOpsAssistant.tsx: 全量重写接入真实后端 · 配置弹窗从后端加载(持久化),提供商切换自动填充endpoint/模型 · 启用开关+测试连接按钮+保存反馈 · 真实对话(发送最近10条上下文),思考中spinner,错误红色气泡 · 状态徽章(未配置/在线+模型名),新对话清空 - api.ts: aiApi替代mock aiAssistantApi(getConfig/saveConfig/testConnection/chat) 验证: tsc零错误+build通过+/health正常+前后端联调通过(配置CRUD+API Key脱敏+masked-key保留+chat错误优雅降级) // Logan.Li
This commit is contained in:
1 parent
2e174fe7eb
commit
b8cab7ed0f
6 files changed
+627
-176
No files matched your search
@@ -0,0 +1,294 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ChatResponse{
|
||||
Content: chatResp.Choices[0].Message.Content,
|
||||
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: "连接成功"})
|
||||
}
|
||||
@@ -413,6 +413,16 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) {
|
||||
agentQuery.POST("/alerts/:id/resolve", taskHandler.ResolveAlert)
|
||||
}
|
||||
|
||||
// AI 智能助手 - LLM 配置 + 对话代理(OpenAI 兼容) // Logan.Li
|
||||
llmHandler := handlers.NewLLMHandler(db)
|
||||
aiopsGroup := authorized.Group("/aiops")
|
||||
{
|
||||
aiopsGroup.GET("/llm-config", llmHandler.GetLLMConfig)
|
||||
aiopsGroup.PUT("/llm-config", llmHandler.SaveLLMConfig)
|
||||
aiopsGroup.POST("/llm-config/test", llmHandler.TestLLMConnection)
|
||||
aiopsGroup.POST("/chat", llmHandler.Chat)
|
||||
}
|
||||
|
||||
// 启动告警引擎(每 30 秒检查一次)
|
||||
alertEngine := handlers.NewAlertEngine(db)
|
||||
go func() {
|
||||
|
||||
Reference in new issue
Block a user