From 0b2e43ea9ec55021e2d0a07d71847dcceebf2bb4 Mon Sep 17 00:00:00 2001 From: Logan Date: Sun, 28 Jun 2026 22:37:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(llm):=20=E5=A4=A7=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=8F=8C=E5=8D=8F=E8=AE=AE(OpenAI+Anthropic)=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=20+=20SSH=E5=87=AD=E8=AF=81=E4=BF=AE=E5=A4=8D=20+=20=E5=AE=B9?= =?UTF-8?q?=E9=87=8F=E9=A2=84=E6=B5=8BAI=E8=A7=A3=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端双协议: 新增 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 --- backend/controller/agent_cli.go | 1 + backend/controller/credential_assign.go | 38 +- backend/controller/llm.go | 36 +- backend/controller/llm_chat.go | 246 +++---------- backend/controller/server.go | 34 ++ backend/controller/server_batch.go | 27 +- backend/model/llm.go | 6 +- backend/model/server.go | 2 + backend/pkg/agentcli/llm/anthropic.go | 337 ++++++++++++++++++ backend/pkg/agentcli/llm/client.go | 14 + backend/pkg/agentcli/llm/openai.go | 91 ++++- backend/pkg/agentcli/llm/types.go | 4 + .../src/components/aiops/AIOpsAssistant.tsx | 19 - frontend/src/components/aiops/LLMConfig.tsx | 53 +-- .../aiops/PredictiveCapacityTab.tsx | 54 ++- frontend/src/components/aiops/predictUtils.ts | 23 ++ 16 files changed, 689 insertions(+), 296 deletions(-) create mode 100644 backend/pkg/agentcli/llm/anthropic.go create mode 100644 backend/pkg/agentcli/llm/client.go diff --git a/backend/controller/agent_cli.go b/backend/controller/agent_cli.go index e85f5bd..128181e 100644 --- a/backend/controller/agent_cli.go +++ b/backend/controller/agent_cli.go @@ -166,6 +166,7 @@ func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConf // 构建引擎 engine, err := agentcli.NewEngine(agentcli.EngineConfig{ LLM: llm.Config{ + Protocol: resolveProtocol(llmCfg.Provider), Endpoint: resolveEndpoint(llmCfg.Provider, llmCfg.Endpoint), APIKey: llmCfg.APIKey, Model: llmCfg.Model, Temperature: llmCfg.Temperature, MaxTokens: llmCfg.MaxTokens, diff --git a/backend/controller/credential_assign.go b/backend/controller/credential_assign.go index 81432c8..8fed486 100644 --- a/backend/controller/credential_assign.go +++ b/backend/controller/credential_assign.go @@ -1,6 +1,7 @@ package controller import ( + "encoding/json" "net/http" "strconv" @@ -43,15 +44,7 @@ func (h *CredentialHandler) AssignCredential(c *gin.Context) { return } - if cred.SecretType == "ssh" { - server.SSHKey = plain - server.AuthType = "key" - server.Password = "" - } else { - server.Password = plain - server.AuthType = "password" - server.SSHKey = "" - } + applyCredential(cred, plain, &server) if err := h.db.Save(&server).Error; err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "分配失败: " + err.Error()}) @@ -60,3 +53,30 @@ func (h *CredentialHandler) AssignCredential(c *gin.Context) { c.JSON(http.StatusOK, model.Response{Code: 200, Message: "凭据已关联"}) } + +// applyCredential 将解密后的明文 plain 填充到 server 认证字段(统一入口,避免 plain JSON 误用) // Logan.Li +// ssh 凭证 plain 是 JSON {"private_key":...,"ssh_user":...,"ssh_port":...},须解析出 private_key(旧实现直接把 +// 整个 JSON 当私钥,导致 ssh.ParsePrivateKey 失败 → ssh-key 登录不通);其余类型 plain 即密码。 +// 供 AssignCredential / 单台服务器 CRUD / 批量导入 三处复用。 +func applyCredential(cred model.Credential, plain string, server *model.Server) { + if cred.SecretType == "ssh" { + privateKey := plain + var sshc model.SSHSecretContent + if json.Unmarshal([]byte(plain), &sshc) == nil && sshc.PrivateKey != "" { + privateKey = sshc.PrivateKey + if sshc.SSHUser != "" { + server.Username = sshc.SSHUser + } + if sshc.SSHPort > 0 { + server.Port = sshc.SSHPort + } + } + server.SSHKey = privateKey + server.AuthType = "key" + server.Password = "" + } else { + server.Password = plain + server.AuthType = "password" + server.SSHKey = "" + } +} diff --git a/backend/controller/llm.go b/backend/controller/llm.go index 5426331..3d683a6 100644 --- a/backend/controller/llm.go +++ b/backend/controller/llm.go @@ -122,23 +122,6 @@ func (h *LLMHandler) SaveLLMConfig(c *gin.Context) { }}) } -// OpenAI 兼容请求/响应结构(仅取必要字段) // Logan.Li -type openaiChatRequest struct { - Model string `json:"model"` - Messages []model.ChatMessage `json:"messages"` - Temperature float64 `json:"temperature"` - MaxTokens int `json:"max_tokens,omitempty"` -} -type openaiChoice struct { - Message model.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 != "" { @@ -147,10 +130,20 @@ func resolveEndpoint(provider, endpoint string) string { switch provider { case "deepseek": return "https://api.deepseek.com/v1" + case "glm": // 智谱 GLM 通用 OpenAI 兼容端点(编码套餐改用 https://open.bigmodel.cn/api/coding/paas/v4) // Logan.Li + return "https://open.bigmodel.cn/api/paas/v4" case "qwen": return "https://dashscope.aliyuncs.com/compatible-mode/v1" case "moonshot": return "https://api.moonshot.cn/v1" + case "kimi": // Kimi 编码套餐 OpenAI 兼容端点(注意:该端点有客户端白名单,通用调用可能被拒) // Logan.Li + return "https://api.kimi.com/coding/v1" + case "mimo": // 小米 MiMo OpenAI 兼容端点 // Logan.Li + return "https://api.mimo.mi.com/v1" + case "anthropic": // Anthropic 官方 Messages 协议(anthropic.go 客户端拼 /v1/messages) // Logan.Li + return "https://api.anthropic.com" + case "glm-coding": // 智谱 GLM 编码套餐 Anthropic 协议端点(模型 GLM-5.2) // Logan.Li + return "https://open.bigmodel.cn/api/anthropic" case "ollama": return "http://localhost:11434/v1" default: // openai / custom @@ -158,6 +151,15 @@ func resolveEndpoint(provider, endpoint string) string { } } +// resolveProtocol 根据提供商推断接入协议(决定走 OpenAI 还是 Anthropic 客户端) // Logan.Li +// 编码套餐(智谱 GLM 编码 / Anthropic 官方)走 Anthropic Messages 协议;其余走 OpenAI 兼容协议。 +func resolveProtocol(provider string) string { + if provider == "anthropic" || provider == "glm-coding" { + return "anthropic" + } + return "openai" +} + // defaultSystemPrompt AIOps 运维助手默认系统提示词 // Logan.Li const defaultSystemPrompt = `你是 AIOps 智能运维助手,专注于企业级运维场景。你可以: 1. 分析服务器、容器、K8s 集群的性能瓶颈与异常 diff --git a/backend/controller/llm_chat.go b/backend/controller/llm_chat.go index ab03383..1f0ffe2 100644 --- a/backend/controller/llm_chat.go +++ b/backend/controller/llm_chat.go @@ -1,22 +1,41 @@ package controller import ( - "bufio" - "bytes" "encoding/json" "fmt" - "io" "net/http" "strings" "time" "ezops/middleware" "ezops/model" + "ezops/pkg/agentcli/llm" "github.com/gin-gonic/gin" ) -// Chat AI 对话代理(OpenAI 兼容接口) // Logan.Li +// 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 @@ -38,60 +57,22 @@ func (h *LLMHandler) Chat(c *gin.Context) { } messages := append([]model.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)) + resp, err := h.buildLLMClient(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{ + Messages: toLLMMessages(messages), + }) if err != nil { - c.JSON(http.StatusOK, model.Response{Code: 500, Message: "构造请求失败: " + err.Error()}) + c.JSON(http.StatusOK, model.Response{Code: 502, Message: "LLM 调用失败: " + 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, model.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, model.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, model.Response{Code: 500, Message: "解析 LLM 响应失败"}) - return - } - replyContent := chatResp.Choices[0].Message.Content + 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() - // session_id:前端传入或后端生成(兼容旧版客户端) // Logan.Li - sid := req.SessionID + sid := req.SessionID // 前端传入或后端生成(兼容旧版客户端) if sid == "" { sid = fmt.Sprintf("sess-%d", now.UnixMilli()) } @@ -104,15 +85,15 @@ func (h *LLMHandler) Chat(c *gin.Context) { c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ChatResponse{ Content: replyContent, Model: cfg.Model, - TokensUsed: chatResp.Usage.TotalTokens, + TokensUsed: resp.Usage.TotalTokens, }}) } // ChatStream AI 流式对话代理(SSE) // Logan.Li // POST /api/v1/aiops/chat/stream -// 客户端用 EventSource/fetch-stream 消费,每条 SSE data 为 {"content": "..."} -// 出错时发送 event: error,data 为 {"message": "..."} -// 完成后服务端持久化 user + assistant 两条历史,event: done 结束 +// 客户端用 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 { @@ -136,127 +117,37 @@ func (h *LLMHandler) ChatStream(c *gin.Context) { c.Writer.Header().Set("Connection", "keep-alive") c.Writer.Header().Set("X-Accel-Buffering", "no") // Nginx 关闭缓冲 c.Writer.WriteHeader(http.StatusOK) - // 注册写入心跳(立即 flush 一个注释行让客户端确认连接建立) - c.Writer.Flush() + c.Writer.Flush() // 立即 flush 让客户端确认连接建立 - // 组装系统提示词 + 上下文 systemPrompt := cfg.SystemPrompt if strings.TrimSpace(systemPrompt) == "" { systemPrompt = defaultSystemPrompt } messages := append([]model.ChatMessage{{Role: "system", Content: systemPrompt}}, req.Messages...) - // 构造 OpenAI 兼容流式请求 - payload := struct { - Model string `json:"model"` - Messages []model.ChatMessage `json:"messages"` - Temperature float64 `json:"temperature"` - MaxTokens int `json:"max_tokens,omitempty"` - Stream bool `json:"stream"` - }{ - Model: cfg.Model, - Messages: messages, - Temperature: cfg.Temperature, - MaxTokens: cfg.MaxTokens, - Stream: true, - } - 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 { - writeSSEError(c, 500, "构造请求失败: "+err.Error()) - return - } - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey) - httpReq.Header.Set("Accept", "text/event-stream") - - // 流式请求不能用全局 120s 超时客户端,构造无超时 client 让 ctx 控制 // Logan.Li - streamClient := &http.Client{Timeout: 0} - resp, err := streamClient.Do(httpReq) - if err != nil { - writeSSEError(c, 502, "LLM 服务连接失败,请检查网络或 Endpoint 配置: "+err.Error()) - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - raw, _ := io.ReadAll(resp.Body) - msg := fmt.Sprintf("LLM 服务返回错误 (HTTP %d)", resp.StatusCode) - if e := parseOpenAIError(raw); e != "" { - msg += ": " + e - } - writeSSEError(c, resp.StatusCode, msg) - return - } - - // 逐行解析上游 SSE,转发增量内容给前端 // Logan.Li + // 流式补全:逐 token 回调转发 SSE;ctx 取消(客户端断开)由 llm.Client 内部终止上游读取 var contentBuilder strings.Builder - reader := bufio.NewReader(resp.Body) - clientGone := false - for { - // 客户端断开时停止读取(c.Request.Context().Done() 在客户端断开时触发) // Logan.Li - select { - case <-c.Request.Context().Done(): - clientGone = true - default: - } - if clientGone { - break - } - - line, readErr := reader.ReadString('\n') - line = strings.TrimSpace(line) - if line != "" { - // 仅处理 data: 开头的行 - if strings.HasPrefix(line, "data:") { - data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) - if data == "[DONE]" { - // 上游结束,跳出循环准备持久化 + 发送 [DONE] - break - } - // 解析 {"choices":[{"delta":{"content":"..."}}]} - var chunk struct { - Choices []struct { - Delta struct { - Content string `json:"content"` - } `json:"delta"` - } `json:"choices"` - } - if json.Unmarshal([]byte(data), &chunk) == nil && len(chunk.Choices) > 0 { - delta := chunk.Choices[0].Delta.Content - if delta != "" { - contentBuilder.WriteString(delta) - // 转发给前端:data: {"content":"..."} - writeSSEChunk(c, delta) - } - } - } - } - if readErr != nil { - if readErr != io.EOF { - // 读取出错(非正常结束) - if contentBuilder.Len() == 0 { - writeSSEError(c, 502, "LLM 流式响应读取失败: "+readErr.Error()) - return - } - // 已有部分内容则正常结束 - } - break - } - } - + _, 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() - // 持久化对话历史(仅在成功且非空时入库;错误/中断不入库避免污染历史) // Logan.Li + // 出错且无任何内容 → 发 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() - // session_id:前端传入或后端生成(兼容旧版客户端) // Logan.Li sid := req.SessionID if sid == "" { sid = fmt.Sprintf("sess-%d", now.UnixMilli()) @@ -266,7 +157,6 @@ func (h *LLMHandler) ChatStream(c *gin.Context) { {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 { @@ -298,19 +188,6 @@ func writeSSEError(c *gin.Context, code int, message string) { c.Writer.Flush() } -// 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) { @@ -319,31 +196,12 @@ func (h *LLMHandler) TestLLMConnection(c *gin.Context) { c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先保存配置"}) return } - payload := openaiChatRequest{ - Model: cfg.Model, - Messages: []model.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) + _, 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 } - 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, model.Response{Code: resp.StatusCode, Message: msg}) - return - } c.JSON(http.StatusOK, model.Response{Code: 200, Message: "连接成功"}) } diff --git a/backend/controller/server.go b/backend/controller/server.go index 6157628..47a0756 100644 --- a/backend/controller/server.go +++ b/backend/controller/server.go @@ -223,6 +223,21 @@ func (h *ServerHandler) CreateServer(c *gin.Context) { server.AuthType = "password" } + // 关联 SSH 凭证:解密后覆盖认证字段(同 AssignCredential,避免单台 CRUD 忽略 credential_id) // Logan.Li + if req.CredentialID != nil && *req.CredentialID > 0 { + var cred model.Credential + if err := h.db.First(&cred, *req.CredentialID).Error; err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "凭据不存在"}) + return + } + plain, err := h.crypt.Decrypt(cred.Content) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "凭据解密失败"}) + return + } + applyCredential(cred, plain, &server) + } + if err := h.db.Create(&server).Error; err != nil { c.JSON(http.StatusInternalServerError, model.Response{ Code: 500, @@ -297,6 +312,25 @@ func (h *ServerHandler) UpdateServer(c *gin.Context) { if req.AuthType != "" { updates["auth_type"] = req.AuthType } + // 关联 SSH 凭证:解密后覆盖认证字段(applyCredential 填充已 load 的 server,再同步到 updates) // Logan.Li + if req.CredentialID != nil && *req.CredentialID > 0 { + var cred model.Credential + if err := h.db.First(&cred, *req.CredentialID).Error; err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "凭据不存在"}) + return + } + plain, err := h.crypt.Decrypt(cred.Content) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "凭据解密失败"}) + return + } + applyCredential(cred, plain, &server) + updates["ssh_key"] = server.SSHKey + updates["password"] = server.Password + updates["auth_type"] = server.AuthType + updates["username"] = server.Username + updates["port"] = server.Port + } if req.OS != "" { updates["os"] = req.OS } diff --git a/backend/controller/server_batch.go b/backend/controller/server_batch.go index 88fa65f..c06da7f 100644 --- a/backend/controller/server_batch.go +++ b/backend/controller/server_batch.go @@ -30,26 +30,24 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) { authType = "password" } - // 如果指定了凭据,提前解密 // Logan.Li - var decryptedPwd, decryptedKey string + // 如果指定了凭据,提前解密(plain 在循环内由 applyCredential 解析填充) // Logan.Li + var ( + hasCred bool + cred model.Credential + credPlain string + ) if req.CredentialID != nil && *req.CredentialID > 0 { - var cred model.Credential if err := h.db.First(&cred, *req.CredentialID).Error; err != nil { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "凭据不存在"}) return } - plain, err := h.crypt.Decrypt(cred.Content) + p, err := h.crypt.Decrypt(cred.Content) if err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "凭据解密失败"}) return } - if cred.SecretType == "ssh" { - decryptedKey = plain - authType = "key" - } else { - decryptedPwd = plain - authType = "password" - } + credPlain = p + hasCred = true } result := model.BatchCreateResult{Errors: []string{}} @@ -84,9 +82,7 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) { IP: ip, Port: port, Username: username, - AuthType: authType, - Password: decryptedPwd, - SSHKey: decryptedKey, + AuthType: authType, // 无凭证时用请求 authType(默认 password) OS: item.OS, CPU: item.CPU, Memory: item.Memory * 1024, // GB → MB @@ -95,6 +91,9 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) { RegionID: req.RegionID, Status: "stopped", } + if hasCred { + applyCredential(cred, credPlain, &server) // 有凭证则覆盖认证字段(ssh→私钥/ssh_user/ssh_port) // Logan.Li + } if err := h.db.Create(&server).Error; err != nil { result.Failed++ result.Errors = append(result.Errors, fmt.Sprintf("%s(%s): %s", hostname, ip, err.Error())) diff --git a/backend/model/llm.go b/backend/model/llm.go index 5f7494c..d3b4f13 100644 --- a/backend/model/llm.go +++ b/backend/model/llm.go @@ -3,11 +3,11 @@ package model import "time" // LLMConfig 大语言模型配置(单行配置,类似 LDAPConfig) // Logan.Li -// 支持 OpenAI 兼容接口(OpenAI / DeepSeek / 通义千问 / Moonshot / Ollama / 自定义) +// 支持 OpenAI 兼容接口(OpenAI / DeepSeek / 智谱GLM / 通义千问 / Moonshot / Kimi / 小米MiMo / Anthropic(需代理) / Ollama / 自定义) type LLMConfig struct { ID uint `gorm:"primarykey" json:"id"` - Provider string `gorm:"size:50;not null;default:openai" json:"provider"` // openai/deepseek/qwen/moonshot/ollama/custom - Model string `gorm:"size:100;not null;default:gpt-4o-mini" json:"model"` // 模型名,如 gpt-4o-mini / deepseek-chat + Provider string `gorm:"size:50;not null;default:openai" json:"provider"` // openai/deepseek/glm/qwen/moonshot/kimi/mimo/anthropic/ollama/custom + Model string `gorm:"size:100;not null;default:gpt-4o-mini" json:"model"` // 模型名,如 gpt-4o-mini / deepseek-v4-flash APIKey string `gorm:"size:500" json:"api_key"` // API Key(GET 时会被脱敏) Endpoint string `gorm:"size:500" json:"endpoint"` // 基础地址,如 https://api.openai.com/v1 Temperature float64 `gorm:"default:0.7" json:"temperature"` // 采样温度 0-2 diff --git a/backend/model/server.go b/backend/model/server.go index acf57be..8e30286 100644 --- a/backend/model/server.go +++ b/backend/model/server.go @@ -137,6 +137,7 @@ type CreateServerRequest struct { Password string `json:"password"` SSHKey string `json:"ssh_key"` AuthType string `json:"auth_type" binding:"omitempty,oneof=password key"` + CredentialID *uint `json:"credential_id,omitempty"` // 关联 SSH 凭证,非空则后端解密覆盖认证字段 OS string `json:"os"` CPU int `json:"cpu"` Memory int64 `json:"memory"` @@ -158,6 +159,7 @@ type UpdateServerRequest struct { Password string `json:"password"` SSHKey string `json:"ssh_key"` AuthType string `json:"auth_type" binding:"omitempty,oneof=password key"` + CredentialID *uint `json:"credential_id,omitempty"` // 关联 SSH 凭证,非空则后端解密覆盖认证字段 OS string `json:"os"` CPU int `json:"cpu"` Memory int64 `json:"memory"` diff --git a/backend/pkg/agentcli/llm/anthropic.go b/backend/pkg/agentcli/llm/anthropic.go new file mode 100644 index 0000000..396578f --- /dev/null +++ b/backend/pkg/agentcli/llm/anthropic.go @@ -0,0 +1,337 @@ +// anthropic.go - Anthropic Messages 协议 LLM 客户端实现 // Logan.Li +// 支持官方 Claude(api.anthropic.com)及智谱 GLM 编码套餐(open.bigmodel.cn/api/anthropic)等 +// 所有 Anthropic /v1/messages 兼容端点。与 openai.go 实现同一 Client 接口,由 client.go 按 Protocol 路由。 +// 鉴权统一用 Authorization: Bearer(智谱编码套餐实测通过;官方 Claude 较新版本亦支持)+ anthropic-version。 +package llm + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// AnthropicClient Anthropic Messages 协议客户端 // Logan.Li +type AnthropicClient struct { + cfg Config + client *http.Client +} + +// NewAnthropicClient 创建 Anthropic 兼容客户端 // Logan.Li +func NewAnthropicClient(cfg Config) Client { + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 180 * time.Second + } + return &AnthropicClient{cfg: cfg, client: &http.Client{Timeout: timeout}} +} + +// Name 返回客户端名称 // Logan.Li +func (c *AnthropicClient) Name() string { return "anthropic(" + c.cfg.Model + ")" } + +// ---- 请求体结构(Anthropic Messages 格式) // Logan.Li ---- + +// anthropicMessage Anthropic 消息:content 可为字符串或 block 数组,用 RawMessage 原样嵌入。 +type anthropicMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` +} + +// anthropicRequest Anthropic /v1/messages 请求体 // Logan.Li +type anthropicRequest struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + System string `json:"system,omitempty"` // system 抽到顶层(不在 messages 内) + Messages []anthropicMessage `json:"messages"` + Tools []anthropicTool `json:"tools,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +// anthropicTool Anthropic 工具定义(input_schema,对应 OpenAI 的 parameters) // Logan.Li +type anthropicTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]interface{} `json:"input_schema"` +} + +// 响应 content block:text 或 tool_use(互斥字段) // Logan.Li +type anthropicBlock struct { + Type string `json:"type"` // text | tool_use + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` // tool_use + Name string `json:"name,omitempty"` // tool_use + Input json.RawMessage `json:"input,omitempty"` // tool_use +} + +// 请求侧 content block(assistant 输出 tool_use / user 输出 tool_result) // Logan.Li +type textBlock struct { + Type string `json:"type"` // 固定 "text" + Text string `json:"text"` +} +type toolUseBlock struct { + Type string `json:"type"` // 固定 "tool_use" + ID string `json:"id"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` +} +type toolResultBlock struct { + Type string `json:"type"` // 固定 "tool_result" + ToolUseID string `json:"tool_use_id"` + Content string `json:"content"` +} + +// anthropicResponse Anthropic /v1/messages 响应体 // Logan.Li +type anthropicResponse struct { + ID string `json:"id"` + Model string `json:"model"` + Content []anthropicBlock `json:"content"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` + Error *struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// buildMessages 把 OpenAI 风格 Message[] 转成 Anthropic messages + 顶层 system // Logan.Li +// - system role 抽到顶层 system 字段 +// - tool role(工具结果)→ user 消息的 tool_result 块 +// - assistant 带 tool_calls → content 含 tool_use 块 +func (c *AnthropicClient) buildMessages(req *CompletionRequest) (string, []anthropicMessage) { + var system string + var msgs []anthropicMessage + for _, m := range req.Messages { + switch m.Role { + case "system": + if system == "" { + system = m.Content + } else { + system += "\n" + m.Content + } + case "tool": + raw, _ := json.Marshal([]toolResultBlock{{ + Type: "tool_result", ToolUseID: m.ToolCallID, Content: m.Content, + }}) + msgs = append(msgs, anthropicMessage{Role: "user", Content: raw}) + case "assistant": + // assistant 消息可能含文本 + tool_use 计划,组装成 content block 数组 + blocks := make([]interface{}, 0, 1+len(m.ToolCalls)) + if m.Content != "" { + blocks = append(blocks, textBlock{Type: "text", Text: m.Content}) + } + for _, tc := range m.ToolCalls { + args := json.RawMessage("{}") + if strings.TrimSpace(tc.Function.Arguments) != "" { + args = json.RawMessage(tc.Function.Arguments) + } + blocks = append(blocks, toolUseBlock{ + Type: "tool_use", ID: tc.ID, Name: tc.Function.Name, Input: args, + }) + } + raw, _ := json.Marshal(blocks) + msgs = append(msgs, anthropicMessage{Role: "assistant", Content: raw}) + default: // user + raw, _ := json.Marshal(m.Content) + msgs = append(msgs, anthropicMessage{Role: "user", Content: raw}) + } + } + return system, msgs +} + +// buildTools OpenAI Tool[](parameters) → Anthropic [](input_schema) // Logan.Li +func buildTools(tools []Tool) []anthropicTool { + out := make([]anthropicTool, 0, len(tools)) + for _, t := range tools { + out = append(out, anthropicTool{ + Name: t.Function.Name, Description: t.Function.Description, InputSchema: t.Function.Parameters, + }) + } + return out +} + +// buildRequest 构造 Anthropic 请求体(Complete 与 Stream 共用) // Logan.Li +func (c *AnthropicClient) buildRequest(req *CompletionRequest, stream bool) ([]byte, error) { + system, msgs := c.buildMessages(req) + payload := anthropicRequest{ + Model: c.cfg.Model, MaxTokens: c.cfg.MaxTokens, System: system, + Messages: msgs, Tools: buildTools(req.Tools), + Temperature: c.cfg.Temperature, Stream: stream, + } + if payload.MaxTokens <= 0 { + payload.MaxTokens = 4096 + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("序列化请求失败: %w", err) + } + return body, nil +} + +// newHTTPRequest 构造 POST /v1/messages 请求(Bearer + anthropic-version) // Logan.Li +func (c *AnthropicClient) newHTTPRequest(ctx context.Context, body []byte) (*http.Request, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.Endpoint+"/v1/messages", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("构造请求失败: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey) + httpReq.Header.Set("anthropic-version", "2023-06-01") + return httpReq, nil +} + +// Complete 一次性补全(Anthropic /v1/messages) // Logan.Li +// 响应 content blocks:text 拼为 Content,tool_use 转为 ToolCalls;usage 映射。 +func (c *AnthropicClient) Complete(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) { + body, err := c.buildRequest(req, false) + if err != nil { + return nil, err + } + httpReq, err := c.newHTTPRequest(ctx, body) + if err != nil { + return nil, err + } + resp, err := c.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("LLM 连接失败: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("LLM 返回错误 (HTTP %d): %s", resp.StatusCode, extractErrorMessage(raw)) + } + var parsed anthropicResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("解析 LLM 响应失败: %w", err) + } + if parsed.Error != nil && parsed.Error.Message != "" { + return nil, fmt.Errorf("LLM 错误: %s", parsed.Error.Message) + } + + var textBuf strings.Builder + var toolCalls []ToolCall + for _, b := range parsed.Content { + switch b.Type { + case "text": + textBuf.WriteString(b.Text) + case "tool_use": + args := string(b.Input) + if strings.TrimSpace(args) == "" { + args = "{}" + } + toolCalls = append(toolCalls, ToolCall{ + ID: b.ID, Type: "function", + Function: FunctionCall{Name: b.Name, Arguments: args}, + }) + } + } + return &CompletionResponse{ + ID: parsed.ID, + Model: parsed.Model, + Message: Message{ + Role: "assistant", + Content: textBuf.String(), + ToolCalls: toolCalls, + }, + Usage: Usage{ + PromptTokens: parsed.Usage.InputTokens, + CompletionTokens: parsed.Usage.OutputTokens, + TotalTokens: parsed.Usage.InputTokens + parsed.Usage.OutputTokens, + }, + }, nil +} + +// Stream 流式补全(Anthropic SSE) // Logan.Li +// 逐 token 回调 text_delta;usage 从 message_start(input) + message_delta(output) 取。 +// 注:流式主要用于纯对话(智能助手),tool_use 流式增量在此不组装(Agent CLI 走 Complete)。 +func (c *AnthropicClient) Stream(ctx context.Context, req *CompletionRequest, onDelta func(string)) (*CompletionResponse, error) { + body, err := c.buildRequest(req, true) + if err != nil { + return nil, err + } + httpReq, err := c.newHTTPRequest(ctx, body) + if err != nil { + return nil, err + } + httpReq.Header.Set("Accept", "text/event-stream") + + streamClient := &http.Client{Timeout: 0} // 流式由 ctx 控制超时 + resp, err := streamClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("LLM 连接失败: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("LLM 返回错误 (HTTP %d): %s", resp.StatusCode, extractErrorMessage(raw)) + } + + var contentBuilder strings.Builder + var usage Usage + var eventType string + reader := bufio.NewReader(resp.Body) + for { + line, readErr := reader.ReadString('\n') + trimmed := strings.TrimSpace(line) + switch { + case strings.HasPrefix(trimmed, "event:"): + eventType = strings.TrimSpace(strings.TrimPrefix(trimmed, "event:")) + case strings.HasPrefix(trimmed, "data:"): + data := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) + if data != "" && data != "[DONE]" { + switch eventType { + case "content_block_delta": + var d struct { + Delta struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"delta"` + } + if json.Unmarshal([]byte(data), &d) == nil && d.Delta.Type == "text_delta" && d.Delta.Text != "" { + contentBuilder.WriteString(d.Delta.Text) + if onDelta != nil { + onDelta(d.Delta.Text) + } + } + case "message_start": + var d struct { + Message struct { + Usage struct { + InputTokens int `json:"input_tokens"` + } `json:"usage"` + } `json:"message"` + } + if json.Unmarshal([]byte(data), &d) == nil { + usage.PromptTokens = d.Message.Usage.InputTokens + } + case "message_delta": + var d struct { + Usage struct { + OutputTokens int `json:"output_tokens"` + } `json:"usage"` + } + if json.Unmarshal([]byte(data), &d) == nil { + usage.CompletionTokens = d.Usage.OutputTokens + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + } + } + eventType = "" // 消费完 data 重置,防默认事件串用 + } + if readErr != nil { + break + } + } + return &CompletionResponse{ + Model: c.cfg.Model, + Message: Message{Role: "assistant", Content: contentBuilder.String()}, + Usage: usage, + }, nil +} diff --git a/backend/pkg/agentcli/llm/client.go b/backend/pkg/agentcli/llm/client.go new file mode 100644 index 0000000..0d853ad --- /dev/null +++ b/backend/pkg/agentcli/llm/client.go @@ -0,0 +1,14 @@ +// client.go - LLM 客户端工厂(按 Config.Protocol 路由到具体实现) // Logan.Li +// 上层(controller/agentcli)统一调 NewClient,无需感知协议差异。 +// 新增协议只需在此分支 + 实现对应 Client。 +package llm + +// NewClient 按 Config.Protocol 选择 LLM 客户端实现 // Logan.Li +// "anthropic" → Anthropic Messages 协议(Claude 官方 / 智谱 GLM 编码套餐等); +// 其余(含空值)→ OpenAI 兼容协议。 +func NewClient(cfg Config) Client { + if cfg.Protocol == "anthropic" { + return NewAnthropicClient(cfg) + } + return NewOpenAIClient(cfg) +} diff --git a/backend/pkg/agentcli/llm/openai.go b/backend/pkg/agentcli/llm/openai.go index 4ca038d..ed47b14 100644 --- a/backend/pkg/agentcli/llm/openai.go +++ b/backend/pkg/agentcli/llm/openai.go @@ -4,17 +4,20 @@ package llm import ( + "bufio" "bytes" "context" "encoding/json" "fmt" "io" "net/http" + "strings" "time" ) // Config LLM 客户端配置(endpoint 已由上层解析为完整 base url) // Logan.Li type Config struct { + Protocol string // "openai" | "anthropic",由 client.go 工厂决定具体实现 Endpoint string // 完整 base url,如 https://api.deepseek.com/v1 APIKey string // Bearer token Model string // 模型名,如 deepseek-chat / gpt-4o-mini @@ -29,8 +32,9 @@ type OpenAIClient struct { client *http.Client } -// NewClient 创建 OpenAI 兼容客户端 // Logan.Li -func NewClient(cfg Config) Client { +// NewOpenAIClient 创建 OpenAI 兼容客户端 // Logan.Li +// 顶层工厂在 client.go(按 Protocol 路由到 OpenAI / Anthropic 实现)。 +func NewOpenAIClient(cfg Config) Client { timeout := cfg.Timeout if timeout <= 0 { timeout = 180 * time.Second @@ -52,6 +56,7 @@ type openaiRequest struct { ToolChoice string `json:"tool_choice,omitempty"` // "auto" 让模型自主决定 Temperature float64 `json:"temperature,omitempty"` MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` // 流式补全(Stream 方法置 true) } // openaiResponse OpenAI chat/completions 响应体 // Logan.Li @@ -131,6 +136,88 @@ func (c *OpenAIClient) Complete(ctx context.Context, req *CompletionRequest) (*C }, nil } +// Stream 流式补全(OpenAI 兼容 SSE),逐 token 回调 onDelta,返回聚合响应 // Logan.Li +// 解析 data: {"choices":[{"delta":{"content":"..."}}]} 增量;[DONE] 结束;末块 usage 若有则取。 +func (c *OpenAIClient) Stream(ctx context.Context, req *CompletionRequest, onDelta func(string)) (*CompletionResponse, error) { + payload := openaiRequest{ + Model: c.cfg.Model, + Messages: req.Messages, + Tools: req.Tools, + ToolChoice: "auto", + Temperature: c.cfg.Temperature, + MaxTokens: c.cfg.MaxTokens, + Stream: true, + } + if payload.MaxTokens <= 0 { + payload.MaxTokens = 4096 + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("序列化请求失败: %w", err) + } + url := c.cfg.Endpoint + "/chat/completions" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("构造请求失败: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey) + httpReq.Header.Set("Accept", "text/event-stream") + + streamClient := &http.Client{Timeout: 0} // 流式由 ctx 控制超时 + resp, err := streamClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("LLM 连接失败: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("LLM 返回错误 (HTTP %d): %s", resp.StatusCode, extractErrorMessage(raw)) + } + + var contentBuilder strings.Builder + var usage Usage + reader := bufio.NewReader(resp.Body) + for { + line, readErr := reader.ReadString('\n') + line = strings.TrimSpace(line) + if line != "" && strings.HasPrefix(line, "data:") { + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "[DONE]" { + break + } + var chunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + Usage *Usage `json:"usage"` + } + if json.Unmarshal([]byte(data), &chunk) == nil { + if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" { + delta := chunk.Choices[0].Delta.Content + contentBuilder.WriteString(delta) + if onDelta != nil { + onDelta(delta) + } + } + if chunk.Usage != nil { + usage = *chunk.Usage + } + } + } + if readErr != nil { + break + } + } + return &CompletionResponse{ + Model: c.cfg.Model, + Message: Message{Role: "assistant", Content: contentBuilder.String()}, + Usage: usage, + }, nil +} + // extractErrorMessage 从上游错误响应提取 message 字段 // Logan.Li func extractErrorMessage(raw []byte) string { var m struct { diff --git a/backend/pkg/agentcli/llm/types.go b/backend/pkg/agentcli/llm/types.go index 4aec90a..a9f90d8 100644 --- a/backend/pkg/agentcli/llm/types.go +++ b/backend/pkg/agentcli/llm/types.go @@ -65,9 +65,13 @@ type Usage struct { } // Client LLM 客户端接口 // Logan.Li +// 实现按协议分两种:OpenAI 兼容(openai.go)与 Anthropic 兼容(anthropic.go), +// 由 client.go 工厂按 Config.Protocol 路由。上层(controller/agentcli)只面向本接口。 type Client interface { // Complete 一次性补全(返回思考文本 + 工具调用计划)。 Complete(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) + // Stream 流式补全,逐 token 回调 onDelta;返回最终聚合响应(含完整 content + usage)。 + Stream(ctx context.Context, req *CompletionRequest, onDelta func(string)) (*CompletionResponse, error) // Name 返回客户端名称(用于日志/审计)。 Name() string } diff --git a/frontend/src/components/aiops/AIOpsAssistant.tsx b/frontend/src/components/aiops/AIOpsAssistant.tsx index 1f44a33..dd6d380 100644 --- a/frontend/src/components/aiops/AIOpsAssistant.tsx +++ b/frontend/src/components/aiops/AIOpsAssistant.tsx @@ -27,16 +27,6 @@ import { } from 'lucide-react'; import type { JsonData } from '@/types/common'; -// LLM 提供商列表(OpenAI 兼容接口) // Logan.Li -const LLM_PROVIDERS: { id: string; name: string; defaultEndpoint: string; models: string[] }[] = [ - { id: 'openai', name: 'OpenAI', defaultEndpoint: 'https://api.openai.com/v1', models: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo'] }, - { id: 'deepseek', name: 'DeepSeek', defaultEndpoint: 'https://api.deepseek.com/v1', models: ['deepseek-chat', 'deepseek-reasoner'] }, - { id: 'qwen', name: '通义千问 (DashScope)', defaultEndpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1', models: ['qwen-plus', 'qwen-turbo', 'qwen-max'] }, - { id: 'moonshot', name: 'Moonshot 月之暗面', defaultEndpoint: 'https://api.moonshot.cn/v1', models: ['moonshot-v1-8k', 'moonshot-v1-32k'] }, - { id: 'ollama', name: 'Ollama 本地', defaultEndpoint: 'http://localhost:11434/v1', models: ['qwen2.5:7b', 'llama3.2', 'deepseek-r1:7b'] }, - { id: 'custom', name: '自定义 (OpenAI 兼容)', defaultEndpoint: '', models: [] }, -]; - const defaultConfig: LLMConfigData = { provider: 'openai', model: 'gpt-4o-mini', @@ -323,15 +313,6 @@ export function AIOpsAssistant() { {config.model} ) : null} - diff --git a/frontend/src/components/aiops/LLMConfig.tsx b/frontend/src/components/aiops/LLMConfig.tsx index 62499a9..cfd942c 100644 --- a/frontend/src/components/aiops/LLMConfig.tsx +++ b/frontend/src/components/aiops/LLMConfig.tsx @@ -13,19 +13,27 @@ import { toast } from 'sonner'; import { Loader2, Save, Wifi, KeyRound, Brain } from 'lucide-react'; import { aiApi, type LLMConfigData } from '@/api'; -// OpenAI 兼容提供商列表 // Logan.Li +// 提供商列表(后端按 provider 自动路由协议) // Logan.Li +// Anthropic 协议(glm-coding / anthropic):端点不含 /v1,后端拼 /v1/messages。 +// OpenAI 协议(其余):端点含 /v1,后端拼 /chat/completions。 const PROVIDERS = [ - { id: 'openai', name: 'OpenAI', endpoint: 'https://api.openai.com/v1', models: 'gpt-4o-mini, gpt-4o' }, - { id: 'deepseek', name: 'DeepSeek', endpoint: 'https://api.deepseek.com/v1', models: 'deepseek-chat, deepseek-reasoner' }, + { id: 'openai', name: 'OpenAI', endpoint: 'https://api.openai.com/v1', models: 'gpt-4o-mini, gpt-4o, gpt-4.1' }, + { id: 'deepseek', name: 'DeepSeek', endpoint: 'https://api.deepseek.com/v1', models: 'deepseek-v4-flash, deepseek-v4-pro' }, + { id: 'glm', name: '智谱 GLM (通用)', endpoint: 'https://open.bigmodel.cn/api/paas/v4', models: 'glm-4.6, glm-4.5, glm-4-flash' }, + { id: 'glm-coding', name: '智谱 GLM (编码套餐)', endpoint: 'https://open.bigmodel.cn/api/anthropic', models: 'GLM-5.2' }, { id: 'qwen', name: '通义千问 (DashScope)', endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1', models: 'qwen-plus, qwen-turbo, qwen-max' }, - { id: 'moonshot', name: 'Moonshot 月之暗面', endpoint: 'https://api.moonshot.cn/v1', models: 'moonshot-v1-8k, moonshot-v1-32k' }, + { id: 'moonshot', name: 'Moonshot (Kimi)', endpoint: 'https://api.moonshot.cn/v1', models: 'kimi-latest, moonshot-v1-8k, moonshot-v1-32k' }, + { id: 'kimi', name: 'Kimi 编码套餐', endpoint: 'https://api.kimi.com/coding/v1', models: 'kimi-for-coding' }, + { id: 'mimo', name: '小米 MiMo', endpoint: 'https://api.mimo.mi.com/v1', models: 'mimo-v2-flash, mimo-v2' }, + { id: 'anthropic', name: 'Anthropic Claude', endpoint: 'https://api.anthropic.com', models: 'claude-sonnet-4-6, claude-opus-4-8, claude-haiku-4-5' }, { id: 'ollama', name: 'Ollama 本地', endpoint: 'http://localhost:11434/v1', models: 'qwen2.5:7b, llama3.2' }, { id: 'custom', name: '自定义 (OpenAI 兼容)', endpoint: '', models: '' }, ]; +// 生成参数(温度/max_tokens/系统提示词)已按 Claude Code 模式精简:UI 不再暴露,走内置默认。 // Logan.Li const defaultCfg: LLMConfigData = { - provider: 'deepseek', model: 'deepseek-chat', api_key: '', - endpoint: 'https://api.deepseek.com/v1', temperature: 0.3, max_tokens: 4096, + provider: 'deepseek', model: 'deepseek-v4-flash', api_key: '', + endpoint: 'https://api.deepseek.com/v1', temperature: 0.7, max_tokens: 8192, system_prompt: '', enabled: false, }; @@ -105,36 +113,11 @@ export default function LLMConfigPage() { - - 生成参数 - -
-
- - set('temperature', Number(e.target.value))} - className="bg-slate-800 border-slate-700 text-slate-200" /> -
-
- - set('max_tokens', Number(e.target.value))} - className="bg-slate-800 border-slate-700 text-slate-200" /> -
-
-
- -