Files
aiops/backend/controller/llm_chat.go
T
LoganandClaude 0b2e43ea9e feat(llm): 大模型双协议(OpenAI+Anthropic)接入 + SSH凭证修复 + 容量预测AI解读
后端双协议: 新增 anthropic.go(Complete/Stream/工具转换) + client.go 工厂按 Protocol 路由; llm.Client 加 Stream; llm_chat 三方法收敛复用 llm.Client; resolveProtocol 按 provider 路由; Agent CLI 注入协议。编码套餐(GLM-5.2/Kimi/MiMo/Anthropic)走 Anthropic /v1/messages。

SSH凭证修复: applyCredential 正确解析凭证 JSON 的 private_key(旧实现把整个 JSON 当私钥 → ssh.ParsePrivateKey 失败 → ssh-key 登录不通); AssignCredential/批量导入/单台 CRUD 三处统一; 单台补齐 credential_id 字段(原被忽略)。

容量预测: 新增 AI 深度解读(buildCapacityInsightPrompt + PredictiveCapacityTab 按钮, 复用 aiApi.chat)。

其他: DeepSeek 模型名更新为 deepseek-v4-flash/pro; 智能助手去掉重复模型配置按钮; LLMConfig 精简生成参数(Claude Code 式)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-28 22:37:46 +08:00

208 lines
7.5 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"
)
// buildLLMClient 从数据库配置构造 LLM 客户端(按 provider 协议自动路由 OpenAI/Anthropic) // Logan.Li
func (h *LLMHandler) buildLLMClient(cfg model.LLMConfig) llm.Client {
return llm.NewClient(llm.Config{
Protocol: resolveProtocol(cfg.Provider),
Endpoint: resolveEndpoint(cfg.Provider, cfg.Endpoint),
APIKey: cfg.APIKey,
Model: cfg.Model,
Temperature: cfg.Temperature,
MaxTokens: cfg.MaxTokens,
})
}
// 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 := h.buildLLMClient(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 := h.buildLLMClient(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 连接(发送一条 ping 消息) // 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
}
_, err := h.buildLLMClient(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{
Messages: []llm.Message{{Role: "user", Content: "ping"}},
})
if err != nil {
c.JSON(http.StatusOK, model.Response{Code: 502, Message: "连接失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "连接成功"})
}