Files
aiops/backend/controller/llm_chat.go
T
LoganandClaude 471353e3d7 feat: 监控告警体系统一重构 + 自动化任务调度引擎 + 监控大屏拆分
告警中心
- 新增统一告警(alert_unified)、监控告警规则(monitor_alert_rule)、指标目录(alert_metric_catalog)
- 通知策略与消息分发(alert_notify_policy/dispatch/message)、静默规则
- 告警引擎按阈值/SNMP 拆分(alert_engine_monitor/snmp)

监控采集
- SNMP 常量/模板与历史指标(snmp templates/constants、snmp_metrics_history、snmp_collect)
- 中间件监控 Redis 采集与种子(middleware_monitor_redis/seed)
- 站点探测 pkg(siteprobe) + 站点视图(site_view)

自动化任务
- cron 调度器(cronscheduler)、脚本运行器(scriptrunner)、系统任务(systask)及运行记录(cron_job_run、system_task)

审计与凭证
- 审计设置(audit_setting) + 历史清理(auditcleaner)

前端
- 监控页面(host/middleware/site/snmp)拆分为子模块并大屏化
- 告警中心多 Tab(规则/指标目录/通知策略/静默)、任务管理组件重构、Agent CLI Markdown 渲染
- 移除旧脚本卡片/详情/日志组件,统一为 ScriptTable/ScriptCodeEditor/TerminalOutput

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-01 23:44:45 +08:00

257 lines
8.9 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()
}
// 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,
}})
}