From 581d0a8351424e45a45f87109346a8c05b82bec4 Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Tue, 27 Oct 2026 09:07:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Jenkins=20Job=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=8F=91=E5=B8=83=20+=20=E5=85=A8=E5=B1=80=20AI=20=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E5=8A=A9=E6=89=8B=20(v0.3.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参考 opsx-jenkins 发布清单: - 批量发布页新增「粘贴清单」模式:多行 Job 名粘贴(#注释/内联对/ 名称版本交替)、TAG/分支切换、分支快捷 chips、700ms 防抖解析预览、 串行/并发发布;未导入的 Jenkins Job 自动创建流水线后触发 - 解析器移植 opsx-jenkins parser(参数头识别/成对校验/重名检测) 全局 AI 智能助手(右下角悬浮): - 自然语言「发布一下 cex-agent-server 等 5 个」→ 解析服务+分支+环境 → 发布计划卡片(匹配状态着色)→ 确认 → 真实触发 → runId 回显 + 跳转发布记录 - LLM 接入:OpenAI 兼容接口(DeepSeek/GLM/Qwen),系统设置页配置 API Key(加密存储)+ 测试连接;未配置时自动降级本地规则模式, 服务名/分支关键词匹配,功能不中断 - 候选池 = 流水线 + Jenkins Job(60s 缓存);精确/忽略大小写/包含 三级匹配 新端点:POST /api/ai/chat、/api/ai/release、/api/batch/parse、 POST /api/settings/test-llm;SystemSetting 扩展 LLM 字段 --- backend/internal/handler/ai.go | 486 ++++++++++++++++++ backend/internal/handler/pipelines.go | 57 +- backend/internal/llm/client.go | 113 ++++ backend/internal/model/models.go | 6 + backend/internal/parser/parser.go | 168 ++++++ backend/main.go | 5 + frontend/src/App.tsx | 2 + frontend/src/components/ai/AiAssistant.tsx | 267 ++++++++++ .../src/components/batch/PasteRelease.tsx | 212 ++++++++ frontend/src/lib/api.ts | 8 + frontend/src/pages/BatchRelease.tsx | 21 +- frontend/src/pages/SystemSettings.tsx | 72 +++ 12 files changed, 1415 insertions(+), 2 deletions(-) create mode 100644 backend/internal/handler/ai.go create mode 100644 backend/internal/llm/client.go create mode 100644 backend/internal/parser/parser.go create mode 100644 frontend/src/components/ai/AiAssistant.tsx create mode 100644 frontend/src/components/batch/PasteRelease.tsx diff --git a/backend/internal/handler/ai.go b/backend/internal/handler/ai.go new file mode 100644 index 0000000..0a334e0 --- /dev/null +++ b/backend/internal/handler/ai.go @@ -0,0 +1,486 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + + "flowstack/internal/crypto" + "flowstack/internal/database" + "flowstack/internal/llm" + "flowstack/internal/model" + "flowstack/internal/parser" +) + +// ============================================================ +// 候选池:流水线 + Jenkins Job(60s 内存缓存) +// ============================================================ + +type Candidate struct { + Name string `json:"name"` // 匹配用名称(jenkins 为压平全路径) + PipelineID uint `json:"pipelineId"` + Type string `json:"type"` // native | jenkins + Source string `json:"source"` // pipeline | jenkins + ServerID uint `json:"serverId"` +} + +var ( + jobsCacheMu sync.Mutex + jobsCacheData []Candidate // 仅 jenkins 来源 + jobsCacheAt time.Time +) + +// jenkinsCandidates 从各启用的 Jenkins 服务器拉取 Job 列表(60s 缓存) +func jenkinsCandidates() []Candidate { + jobsCacheMu.Lock() + defer jobsCacheMu.Unlock() + if time.Since(jobsCacheAt) < 60*time.Second && jobsCacheData != nil { + return jobsCacheData + } + var servers []model.JenkinsServer + database.DB.Where("enabled = ?", true).Find(&servers) + var out []Candidate + for _, s := range servers { + jobs, err := clientFromServer(s).ListJobs() + if err != nil { + continue + } + for _, j := range jobs { + if j.Type == "folder" { + continue + } + out = append(out, Candidate{Name: j.Name, Type: "jenkins", Source: "jenkins", ServerID: s.ID}) + } + } + jobsCacheData = out + jobsCacheAt = time.Now() + return out +} + +// allCandidates 流水线优先(去重),Jenkins Job 补充 +func allCandidates() []Candidate { + var pipelines []model.Pipeline + database.DB.Find(&pipelines) + var out []Candidate + seen := map[string]bool{} + for _, p := range pipelines { + out = append(out, Candidate{Name: p.Name, PipelineID: p.ID, Type: p.Type, Source: "pipeline"}) + seen[strings.ToLower(p.Name)] = true + if p.JenkinsJobName != "" && p.JenkinsJobName != p.Name { + if !seen[strings.ToLower(p.JenkinsJobName)] { + out = append(out, Candidate{Name: p.JenkinsJobName, PipelineID: p.ID, Type: "jenkins", Source: "pipeline", ServerID: p.JenkinsServerID}) + seen[strings.ToLower(p.JenkinsJobName)] = true + } + } + } + for _, j := range jenkinsCandidates() { + if !seen[strings.ToLower(j.Name)] { + out = append(out, j) + seen[strings.ToLower(j.Name)] = true + } + } + return out +} + +// matchCandidate 精确 → 忽略大小写 → 包含 +func matchCandidate(name string, candidates []Candidate) *Candidate { + lower := strings.ToLower(name) + var contains *Candidate + for i, c := range candidates { + if c.Name == name { + return &candidates[i] + } + if strings.ToLower(c.Name) == lower { + return &candidates[i] + } + if contains == nil && lower != "" && strings.Contains(strings.ToLower(c.Name), lower) { + contains = &candidates[i] + } + } + return contains +} + +// ============================================================ +// LLM +// ============================================================ + +func llmClientFromSettings() *llm.Client { + var s model.SystemSetting + if err := database.DB.First(&s, 1).Error; err != nil { + return nil + } + if !s.LlmEnabled || s.LlmApiKey == "" { + return nil + } + apiKey := s.LlmApiKey + if crypto.IsEncrypted(apiKey) { + if plain, err := crypto.DecryptField(apiKey); err == nil { + apiKey = plain + } + } + return llm.NewClient(s.LlmEndpoint, apiKey, s.LlmModel, s.LlmMaxTokens) +} + +// ============================================================ +// 规则解析(无 LLM 的兜底) +// ============================================================ + +var branchKeywords = []string{"pre", "test", "main", "master", "release", "dev", "develop", "staging", "production", "prod"} + +type ruleParseResult struct { + Jobs []string + Branch string + Env string +} + +// parseByRules 从自然语言/清单文本中直接匹配候选名 + 分支/环境关键词 +func parseByRules(text string, candidates []Candidate) ruleParseResult { + var res ruleParseResult + lowerText := strings.ToLower(text) + + // 长名优先匹配,避免短名误命中(如 api 命中 xxx-api) + sorted := make([]Candidate, len(candidates)) + copy(sorted, candidates) + for i := 0; i < len(sorted); i++ { + for j := i + 1; j < len(sorted); j++ { + if len(sorted[j].Name) > len(sorted[i].Name) { + sorted[i], sorted[j] = sorted[j], sorted[i] + } + } + } + for _, c := range sorted { + if c.Name == "" { + continue + } + if strings.Contains(lowerText, strings.ToLower(c.Name)) { + res.Jobs = append(res.Jobs, c.Name) + } + } + + // 分支关键词(优先匹配更长的,如 origin/pre 优于 pre) + for _, kw := range []string{"origin/pre", "origin/test", "origin/main"} { + if strings.Contains(lowerText, kw) { + res.Branch = kw + break + } + } + if res.Branch == "" { + for _, kw := range branchKeywords { + if strings.Contains(lowerText, kw) { + res.Branch = kw + break + } + } + } + for _, env := range []string{"production", "prod", "staging", "test", "dev"} { + if strings.Contains(lowerText, env) { + res.Env = env + break + } + } + return res +} + +// ============================================================ +// POST /api/ai/chat +// ============================================================ + +type ChatMsg struct { + Role string `json:"role"` + Content string `json:"content"` +} + +func AiChat(c *gin.Context) { + var body struct { + Messages []ChatMsg `json:"messages" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil || len(body.Messages) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "messages 不能为空"}) + return + } + // 只取用户侧最后一条作为解析输入 + var lastUser string + for i := len(body.Messages) - 1; i >= 0; i-- { + if body.Messages[i].Role == "user" { + lastUser = body.Messages[i].Content + break + } + } + if lastUser == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "没有用户消息"}) + return + } + + candidates := allCandidates() + names := make([]string, 0, len(candidates)) + if len(candidates) > 200 { + candidates = candidates[:200] + } + for _, cd := range candidates { + names = append(names, cd.Name) + } + + // 规则结果(兜底 + 校验基准) + ruleRes := parseByRules(lastUser, candidates) + + reply := "" + viaAI := false + var action *aiReleaseAction + + client := llmClientFromSettings() + if client != nil { + system := fmt.Sprintf(`你是 FlowStack 发布系统的智能发布助手。用户会用自然语言描述要发布的服务。 +可发布的服务清单(流水线与 Jenkins Job 名称)如下: +%s + +请从用户消息中识别:要发布哪些服务(必须来自上面的清单,按原文匹配)、目标分支(如有)、目标环境(如有)。 +严格输出 JSON(不要输出其他内容、不要 markdown 代码块): +{"reply":"一句中文确认语","jobs":["服务名1"],"branch":"分支或留空","env":"环境或留空"} +jobs 为空数组时 reply 中说明未识别到服务。`, strings.Join(names, "\n")) + + ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second) + defer cancel() + if out, err := client.Chat(ctx, system, lastUser); err == nil { + viaAI = true + reply, action = parseLLMJson(out, candidates) + } + } + + // 兜底或 LLM 无结果:规则结果 + if action == nil || len(action.Jobs) == 0 { + if len(ruleRes.Jobs) == 0 { + if reply == "" { + reply = "未识别到要发布的服务。请直接告诉我服务名,例如:发布 cex-agent-server、cex-agent-api 到 pre 分支。" + } + c.JSON(http.StatusOK, gin.H{"reply": reply, "viaAI": viaAI}) + return + } + jobs := make([]aiActionJob, 0, len(ruleRes.Jobs)) + for _, name := range ruleRes.Jobs { + m := matchCandidate(name, candidates) + jobs = append(jobs, aiActionJob{Name: name, Matched: m != nil, Source: sourceOf(m)}) + } + action = &aiReleaseAction{Type: "release", Jobs: jobs, Branch: ruleRes.Branch, Env: ruleRes.Env} + if reply == "" { + reply = fmt.Sprintf("识别到 %d 个服务%s,确认后开始发布。", len(jobs), branchSuffix(ruleRes.Branch)) + } + } + + c.JSON(http.StatusOK, gin.H{ + "reply": reply, "viaAI": viaAI, "action": action, + }) +} + +type aiActionJob struct { + Name string `json:"name"` + Matched bool `json:"matched"` + Source string `json:"source"` +} + +type aiReleaseAction struct { + Type string `json:"type"` + Jobs []aiActionJob `json:"jobs"` + Branch string `json:"branch,omitempty"` + Env string `json:"env,omitempty"` +} + +func sourceOf(m *Candidate) string { + if m == nil { + return "unknown" + } + return m.Source +} + +func branchSuffix(branch string) string { + if branch == "" { + return "" + } + return "(分支: " + branch + ")" +} + +// parseLLMJson 容错解析 LLM 输出(允许 ```json 包裹),把识别出的服务匹配到候选池 +func parseLLMJson(out string, candidates []Candidate) (string, *aiReleaseAction) { + cleaned := strings.TrimSpace(out) + cleaned = strings.TrimPrefix(cleaned, "```json") + cleaned = strings.TrimPrefix(cleaned, "```") + cleaned = strings.TrimSuffix(cleaned, "```") + // 截取首个 { 到最后一个 } 之间的内容 + if idx := strings.Index(cleaned, "{"); idx >= 0 { + if last := strings.LastIndex(cleaned, "}"); last > idx { + cleaned = cleaned[idx : last+1] + } + } + var parsed struct { + Reply string `json:"reply"` + Jobs []string `json:"jobs"` + Branch string `json:"branch"` + Env string `json:"env"` + } + if err := json.Unmarshal([]byte(cleaned), &parsed); err != nil { + return "", nil + } + if len(parsed.Jobs) == 0 { + if parsed.Reply != "" { + return parsed.Reply, nil + } + return "", nil + } + action := &aiReleaseAction{Type: "release", Branch: parsed.Branch, Env: parsed.Env} + for _, name := range parsed.Jobs { + m := matchCandidate(name, candidates) + action.Jobs = append(action.Jobs, aiActionJob{Name: name, Matched: m != nil, Source: sourceOf(m)}) + } + reply := parsed.Reply + if reply == "" { + reply = fmt.Sprintf("识别到 %d 个服务%s,确认后开始发布。", len(action.Jobs), branchSuffix(parsed.Branch)) + } + return reply, action +} + +// ============================================================ +// POST /api/ai/release —— 解析→(自动建)→触发 +// ============================================================ + +func AiRelease(hub *Hub) gin.HandlerFunc { + return func(c *gin.Context) { + var body struct { + Jobs []string `json:"jobs" binding:"required"` + Branch string `json:"branch"` + Env string `json:"env"` + Parallel bool `json:"parallel"` + JenkinsServerID uint `json:"jenkinsServerId"` + AutoCreate *bool `json:"autoCreate"` + } + if err := c.ShouldBindJSON(&body); err != nil || len(body.Jobs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "jobs 不能为空"}) + return + } + autoCreate := body.AutoCreate == nil || *body.AutoCreate + + candidates := allCandidates() + type triggered struct { + Job string `json:"job"` + PipelineID uint `json:"pipelineId"` + RunID string `json:"runId"` + Created bool `json:"created"` + } + type failed struct { + Job string `json:"job"` + Reason string `json:"reason"` + } + var okList []triggered + var failList []failed + + for _, name := range body.Jobs { + p, created, err := resolvePipeline(name, candidates, autoCreate, body.JenkinsServerID) + if err != nil { + failList = append(failList, failed{Job: name, Reason: err.Error()}) + continue + } + run, err := triggerPipelineRun(hub, *p, body.Branch, body.Env, "admin", "batch") + if err != nil { + failList = append(failList, failed{Job: name, Reason: "触发失败: " + err.Error()}) + continue + } + okList = append(okList, triggered{Job: name, PipelineID: p.ID, RunID: run.RunID, Created: created}) + if !body.Parallel { + time.Sleep(500 * time.Millisecond) + } + } + c.JSON(http.StatusOK, gin.H{"triggered": okList, "failed": failList}) + } +} + +// resolvePipeline 名称 → 流水线;未导入的 Jenkins Job 自动创建 +func resolvePipeline(name string, candidates []Candidate, autoCreate bool, serverID uint) (*model.Pipeline, bool, error) { + var p model.Pipeline + if err := database.DB.Where("name = ? OR jenkins_job_name = ?", name, name).First(&p).Error; err == nil { + return &p, false, nil + } + if !autoCreate { + return nil, false, fmt.Errorf("未找到流水线(未开启自动创建)") + } + // 在 Jenkins 候选中确认存在并定位服务器 + m := matchCandidate(name, candidates) + if m == nil || m.Source != "jenkins" { + return nil, false, fmt.Errorf("流水线与 Jenkins Job 中均未找到") + } + sid := m.ServerID + if sid == 0 { + sid = serverID + } + if sid == 0 { + return nil, false, fmt.Errorf("无法确定 Jenkins 服务器") + } + np := model.Pipeline{ + Name: m.Name, Description: "AI 助手自动创建", + Type: "jenkins", SourceBranch: "main", DeployType: "host", Env: "staging", + JenkinsJobName: m.Name, JenkinsServerID: sid, + } + if err := database.DB.Create(&np).Error; err != nil { + return nil, false, fmt.Errorf("自动创建流水线失败: %s", err.Error()) + } + return &np, true, nil +} + +// ============================================================ +// POST /api/batch/parse —— 批量发布页粘贴清单解析(纯规则) +// ============================================================ + +func BatchParse(c *gin.Context) { + var body struct { + Text string `json:"text" binding:"required"` + RefType string `json:"refType"` + FixedRef string `json:"fixedRef"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "text 不能为空"}) + return + } + res, err := parser.Parse(body.Text, body.RefType, body.FixedRef) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + candidates := allCandidates() + type parsedJob struct { + parser.Job + Matched bool `json:"matched"` + Source string `json:"source"` + PipelineID uint `json:"pipelineId"` + AutoCreatable bool `json:"autoCreatable"` // Jenkins 中存在可自动创建 + } + jobs := make([]parsedJob, 0, len(res.Jobs)) + for _, j := range res.Jobs { + m := matchCandidate(j.Name, candidates) + pj := parsedJob{Job: j, Matched: m != nil, Source: sourceOf(m)} + if m != nil { + pj.PipelineID = m.PipelineID + } else { + // 未匹配到流水线,但 Jenkins 里有同名 Job → 可自动创建 + jm := matchCandidate(j.Name, filterJenkins(candidates)) + if jm != nil { + pj.AutoCreatable = true + pj.Source = "jenkins" + } + } + jobs = append(jobs, pj) + } + c.JSON(http.StatusOK, gin.H{"jobs": jobs, "param": res.ParamName, "refType": res.RefType, "count": len(jobs)}) +} + +func filterJenkins(candidates []Candidate) []Candidate { + var out []Candidate + for _, c := range candidates { + if c.Source == "jenkins" { + out = append(out, c) + } + } + return out +} diff --git a/backend/internal/handler/pipelines.go b/backend/internal/handler/pipelines.go index 952edbe..1f7dfac 100644 --- a/backend/internal/handler/pipelines.go +++ b/backend/internal/handler/pipelines.go @@ -1,12 +1,15 @@ package handler import ( + "context" "net/http" "time" "github.com/gin-gonic/gin" + "flowstack/internal/crypto" "flowstack/internal/database" + "flowstack/internal/llm" "flowstack/internal/model" ) @@ -83,9 +86,10 @@ func GetSummary(c *gin.Context) { func GetSettings(c *gin.Context) { var s model.SystemSetting if err := database.DB.First(&s, 1).Error; err != nil { - s = model.SystemSetting{ID: 1} + s = model.SystemSetting{ID: 1, LlmEndpoint: "https://api.deepseek.com/v1", LlmModel: "deepseek-chat", LlmMaxTokens: 1200} database.DB.Create(&s) } + s.LlmApiKeySet = s.LlmApiKey != "" c.JSON(http.StatusOK, s) } @@ -114,10 +118,61 @@ func UpdateSettings(c *gin.Context) { if v, ok := body["extra"].(string); ok { updates["extra"] = v } + if v, ok := body["llmEnabled"].(bool); ok { + updates["llm_enabled"] = v + } + if v, ok := body["llmEndpoint"].(string); ok { + updates["llm_endpoint"] = v + } + if v, ok := body["llmModel"].(string); ok { + updates["llm_model"] = v + } + if v, ok := body["llmMaxTokens"].(float64); ok { + updates["llm_max_tokens"] = int(v) + } + // API Key 只写不读:非空才更新 + if v, ok := body["llmApiKey"].(string); ok && v != "" { + enc, err := crypto.EncryptField(v) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "API Key 加密失败"}) + return + } + updates["llm_api_key"] = enc + } database.DB.Model(&s).Updates(updates) + database.DB.First(&s, 1) + s.LlmApiKeySet = s.LlmApiKey != "" c.JSON(http.StatusOK, s) } +// TestLLM POST /api/settings/test-llm —— 用已保存配置做 ping 对话 +func TestLLM(c *gin.Context) { + var s model.SystemSetting + if err := database.DB.First(&s, 1).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请先保存设置"}) + return + } + if !s.LlmEnabled || s.LlmApiKey == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "AI 未启用或未配置 API Key"}) + return + } + apiKey := s.LlmApiKey + if crypto.IsEncrypted(apiKey) { + if plain, err := crypto.DecryptField(apiKey); err == nil { + apiKey = plain + } + } + client := llm.NewClient(s.LlmEndpoint, apiKey, s.LlmModel, 128) + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + reply, err := client.Chat(ctx, "你是连通性检查助手,只回复:连接成功。", "ping") + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "测试失败: " + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": reply}) +} + // CreatePipeline POST /api/pipelines func CreatePipeline(c *gin.Context) { var input struct { diff --git a/backend/internal/llm/client.go b/backend/internal/llm/client.go new file mode 100644 index 0000000..4122f4b --- /dev/null +++ b/backend/internal/llm/client.go @@ -0,0 +1,113 @@ +// Package llm OpenAI 兼容 Chat 客户端(移植自 opsx-jenkins 生产实现)。 +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type Client struct { + Endpoint string + APIKey string + Model string + MaxTokens int +} + +func NewClient(endpoint, apiKey, model string, maxTokens int) *Client { + if maxTokens <= 0 { + maxTokens = 1200 + } + return &Client{ + Endpoint: strings.TrimRight(endpoint, "/"), + APIKey: apiKey, + Model: model, + MaxTokens: maxTokens, + } +} + +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatRequest struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` +} + +type chatResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +// Chat 单轮对话;endpoint/apiKey 为空时返回错误由调用方降级 +func (c *Client) Chat(ctx context.Context, system, user string) (string, error) { + if c.Endpoint == "" || c.APIKey == "" || c.Model == "" { + return "", fmt.Errorf("LLM 未配置") + } + payload := chatRequest{ + Model: c.Model, + Messages: []chatMessage{ + {Role: "system", Content: system}, + {Role: "user", Content: user}, + }, + Temperature: 0.2, + MaxTokens: c.MaxTokens, + } + body, err := json.Marshal(payload) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoint+"/chat/completions", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + + resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", err + } + var out chatResponse + if err := json.Unmarshal(data, &out); err != nil { + return "", fmt.Errorf("响应解析失败: %s", truncate(string(data), 200)) + } + if out.Error != nil && out.Error.Message != "" { + return "", fmt.Errorf("LLM 错误: %s", out.Error.Message) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(data), 200)) + } + if len(out.Choices) == 0 { + return "", fmt.Errorf("空响应") + } + return strings.TrimSpace(out.Choices[0].Message.Content), nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index c0bd662..1107b5b 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -267,6 +267,12 @@ type SystemSetting struct { Theme string `gorm:"type:text" json:"theme"` // JSON {mode,primaryColor,...} Notification string `gorm:"type:text" json:"notification"` // JSON {email,browser,...} ToolMode string `gorm:"default:local" json:"toolMode"` // local|jenkins + LlmEnabled bool `gorm:"default:false" json:"llmEnabled"` + LlmEndpoint string `gorm:"default:https://api.deepseek.com/v1" json:"llmEndpoint"` + LlmModel string `gorm:"default:deepseek-chat" json:"llmModel"` + LlmApiKey string `gorm:"type:text" json:"-"` // enc:v1: 加密,只写不读 + LlmApiKeySet bool `gorm:"-" json:"llmApiKeySet"` + LlmMaxTokens int `gorm:"default:1200" json:"llmMaxTokens"` Extra string `gorm:"type:text" json:"extra"` UpdatedAt time.Time `json:"updatedAt"` } diff --git a/backend/internal/parser/parser.go b/backend/internal/parser/parser.go new file mode 100644 index 0000000..d6dbfdc --- /dev/null +++ b/backend/internal/parser/parser.go @@ -0,0 +1,168 @@ +// Package parser 发布清单文本解析(规则移植自 opsx-jenkins parser.go)。 +package parser + +import ( + "fmt" + "strings" +) + +type Job struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type Result struct { + Jobs []Job `json:"jobs"` + ParamName string `json:"param"` + RefType string `json:"refType"` // tag | branch +} + +var skipHeaders = map[string]bool{ + "jenkins服务": true, "jenkins": true, "服务": true, "发布": true, + "发布清单": true, "jobs": true, "job": true, "服务列表": true, +} + +// Parse 解析多行发布文本。 +// refType: "branch" 每行一个应用名(版本由 fixedRef 统一覆盖); +// "tag" 支持:参数头行、"name version" 内联对(=/:/tab/空格分隔)、名称/版本交替行。 +func Parse(text, refType, fixedRef string) (*Result, error) { + if refType != "branch" { + refType = "tag" + } + lines := splitNonEmptyLines(text) + if len(lines) == 0 { + return nil, fmt.Errorf("发布文本为空") + } + + res := &Result{RefType: refType} + if refType == "branch" { + if fixedRef == "" { + fixedRef = "test" + } + for _, l := range lines { + name := l + if fields := strings.Fields(l); len(fields) >= 2 { + name = fields[0] + } + res.Jobs = append(res.Jobs, Job{Name: name, Version: fixedRef}) + } + if err := validate(res.Jobs); err != nil { + return nil, err + } + return res, nil + } + + // tag 模式 + var tokens []string + for _, l := range lines { + norm := strings.ToLower(strings.ReplaceAll(strings.TrimRight(l, ":"), " ", "")) + if skipHeaders[norm] { + continue + } + if res.ParamName == "" && isParamHeader(l) { + res.ParamName = l + continue + } + if name, ver, ok := splitInlinePair(l); ok { + res.Jobs = append(res.Jobs, Job{Name: name, Version: ver}) + continue + } + tokens = append(tokens, l) + } + if len(tokens)%2 != 0 { + last := tokens[len(tokens)-1] + return nil, fmt.Errorf("发布文本无法成对解析,剩余孤立行: %s", last) + } + for i := 0; i < len(tokens); i += 2 { + res.Jobs = append(res.Jobs, Job{Name: tokens[i], Version: tokens[i+1]}) + } + if res.ParamName == "" { + res.ParamName = "TAG" + } + if err := validate(res.Jobs); err != nil { + return nil, err + } + return res, nil +} + +func splitNonEmptyLines(text string) []string { + text = strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n") + var out []string + for _, l := range strings.Split(text, "\n") { + l = strings.TrimSpace(l) + if l == "" || strings.HasPrefix(l, "#") { + continue + } + out = append(out, l) + } + return out +} + +// isParamHeader 识别参数头行:TAG/BRANCH/VERSION/SELECT 等显式词,或全大写+下划线的 Jenkins 参数名 +func isParamHeader(line string) bool { + explicit := map[string]bool{ + "tag": true, "branch": true, "version": true, "select": true, + "git_tag": true, "image_tag": true, "git_branch": true, "ref": true, + } + norm := strings.ToLower(strings.TrimSpace(strings.TrimRight(line, ":"))) + if explicit[norm] { + return true + } + if len(line) <= 24 && len(line) >= 2 && !strings.ContainsAny(line, "/-. ") { + for _, r := range line { + if r != '_' && (r < 'A' || r > 'Z') { + return false + } + } + return true + } + return false +} + +// splitInlinePair "name ver" / "name=ver" / "name:ver" / tab 分隔 +func splitInlinePair(line string) (string, string, bool) { + for _, sep := range []string{"=", ":"} { + if idx := strings.Index(line, sep); idx > 0 { + name := strings.TrimSpace(line[:idx]) + ver := strings.TrimSpace(line[idx+len(sep):]) + if name != "" && ver != "" && looksLikeName(name) && looksLikeVersion(ver) { + return name, ver, true + } + } + } + fields := strings.FieldsFunc(line, func(r rune) bool { return r == ' ' || r == '\t' }) + if len(fields) == 2 && looksLikeName(fields[0]) && looksLikeVersion(fields[1]) { + return fields[0], fields[1], true + } + return "", "", false +} + +func looksLikeName(s string) bool { + return len(s) >= 2 && !looksLikeVersion(s) +} + +// looksLikeVersion v 前缀含数字,或含数字且连字符少于 2 个 +func looksLikeVersion(s string) bool { + rest := strings.TrimPrefix(strings.ToLower(s), "v") + if strings.ContainsAny(rest, "0123456789") && strings.Count(rest, "-") < 2 { + return true + } + return false +} + +func validate(jobs []Job) error { + if len(jobs) == 0 { + return fmt.Errorf("未解析到任何应用") + } + seen := map[string]int{} + for i, j := range jobs { + if j.Name == "" || j.Version == "" { + return fmt.Errorf("第 %d 项名称或版本为空", i+1) + } + if prev, ok := seen[j.Name]; ok { + return fmt.Errorf("应用 %s 重复出现(第 %d 与第 %d 项)", j.Name, prev+1, i+1) + } + seen[j.Name] = i + } + return nil +} diff --git a/backend/main.go b/backend/main.go index 0d788be..5e96a28 100644 --- a/backend/main.go +++ b/backend/main.go @@ -54,6 +54,11 @@ func main() { api.GET("/summary", handler.GetSummary) api.GET("/settings", handler.GetSettings) api.PUT("/settings", handler.UpdateSettings) + api.POST("/settings/test-llm", handler.TestLLM) + + api.POST("/ai/chat", handler.AiChat) + api.POST("/ai/release", handler.AiRelease(hub)) + api.POST("/batch/parse", handler.BatchParse) api.GET("/pipelines", handler.ListPipelines) api.POST("/pipelines", handler.CreatePipeline) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c82a691..cac42b0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import WebhookConfig from '@/pages/WebhookConfig'; import GitOpsDeploy from '@/pages/GitOpsDeploy'; import PluginManager from '@/pages/PluginManager'; import SystemSettings from '@/pages/SystemSettings'; +import AiAssistant from '@/components/ai/AiAssistant'; import { apiClient } from '@/lib/api'; export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings'; @@ -166,6 +167,7 @@ export default function App() {
{renderContent()}
+ setActiveModule(m as ModuleType)} /> ); } diff --git a/frontend/src/components/ai/AiAssistant.tsx b/frontend/src/components/ai/AiAssistant.tsx new file mode 100644 index 0000000..998185a --- /dev/null +++ b/frontend/src/components/ai/AiAssistant.tsx @@ -0,0 +1,267 @@ +import { useState, useRef, useEffect } from 'react'; +import { Sparkles, Send, X, Loader2, CheckCircle2, XCircle, History, Bot } from 'lucide-react'; +import { toast } from 'sonner'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api'; + +interface Props { + onNavigate?: (module: string) => void; +} + +interface ChatItem { + role: 'user' | 'assistant'; + content: string; + action?: any; // {type:'release', jobs, branch, env} + actionState?: 'pending' | 'done' | 'cancelled'; + result?: { triggered: any[]; failed: any[] }; +} + +const suggestions = [ + '发布 aiops-backend、aiops-web 到 test 分支', + '把后端两个服务发到 pre', +]; + +export default function AiAssistant({ onNavigate }: Props) { + const [open, setOpen] = useState(false); + const [input, setInput] = useState(''); + const [sending, setSending] = useState(false); + const [messages, setMessages] = useState([ + { role: 'assistant', content: '你好,我是 FlowStack 智能发布助手。告诉我你要发布哪些服务(支持自然语言),例如:发布 cex-agent-server、cex-agent-api 到 pre 分支。' }, + ]); + const listRef = useRef(null); + const queryClient = useQueryClient(); + + const { data: settings } = useQuery({ + queryKey: ['settings'], + queryFn: apiClient.getSettings, + enabled: open, + }); + const aiConnected = settings?.llmEnabled && settings?.llmApiKeySet; + + useEffect(() => { + if (listRef.current) { + listRef.current.scrollTop = listRef.current.scrollHeight; + } + }, [messages, open]); + + const send = async (text?: string) => { + const content = (text ?? input).trim(); + if (!content || sending) return; + setInput(''); + const userMsg: ChatItem = { role: 'user', content }; + const next = [...messages, userMsg]; + setMessages(next); + setSending(true); + try { + const history = next.slice(-10).map((m) => ({ role: m.role, content: m.content })); + const res = await apiClient.aiChat(history); + setMessages((prev) => [...prev, { role: 'assistant', content: res.reply, action: res.action, actionState: res.action ? 'pending' : undefined }]); + } catch (err) { + setMessages((prev) => [...prev, { role: 'assistant', content: '请求失败:' + (err as Error).message }]); + } finally { + setSending(false); + } + }; + + const confirmRelease = async (index: number) => { + const msg = messages[index]; + if (!msg?.action) return; + const jobs = msg.action.jobs.filter((j: any) => j.matched || j.source === 'jenkins' || true).map((j: any) => j.name); + setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, actionState: 'done' } : m))); + try { + const res = await apiClient.aiRelease({ + jobs, + branch: msg.action.branch, + env: msg.action.env, + autoCreate: true, + }); + setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, result: res } : m))); + const okN = res.triggered?.length || 0; + const failN = res.failed?.length || 0; + if (okN > 0) toast.success(`已触发 ${okN} 个服务${failN ? `,${failN} 个失败` : ''}`); + queryClient.invalidateQueries({ queryKey: ['runs'] }); + queryClient.invalidateQueries({ queryKey: ['pipelines'] }); + } catch (err) { + toast.error('发布失败:' + (err as Error).message); + setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, actionState: 'pending' } : m))); + } + }; + + const cancelAction = (index: number) => { + setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, actionState: 'cancelled' } : m))); + }; + + return ( + <> + {/* 悬浮按钮 */} + + + {/* 对话面板 */} + {open && ( +
+ {/* 头部 */} +
+
+ +
+
+
FlowStack 智能助手
+
+ + {aiConnected ? 'AI 已连接' : '本地规则模式(系统设置可接入 AI)'} +
+
+ +
+ + {/* 消息列表 */} +
+ {messages.map((m, i) => ( +
+
+
+ {m.content} +
+ + {/* 发布计划卡片 */} + {m.action && m.action.type === 'release' && ( +
+
+ + 发布计划 · {m.action.jobs.length} 个服务 + {m.action.branch && @{m.action.branch}} + {m.action.env && env:{m.action.env}} +
+
+ {m.action.jobs.map((j: any, ji: number) => ( + + {j.name} + + ))} +
+ + {m.actionState === 'pending' && ( +
+ + +
+ )} + + {m.result && ( +
+ {m.result.triggered?.map((t: any, ti: number) => ( +
+ + {t.job} + {t.created && 自动创建} + {t.runId} +
+ ))} + {m.result.failed?.map((f: any, fi: number) => ( +
+ + {f.job} + {f.reason} +
+ ))} + {(m.result.triggered?.length > 0) && ( + + )} +
+ )} +
+ )} +
+
+ ))} + {sending && ( +
+
+ + 思考中… +
+
+ )} +
+ + {/* 快捷示例 */} + {messages.length <= 1 && ( +
+ {suggestions.map((s) => ( + + ))} +
+ )} + + {/* 输入区 */} +
+ setInput(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) send(); }} + placeholder="描述要发布的服务,如:发布 agent 三件套到 pre" + className="flex-1 px-3 py-2 text-[12px] rounded-lg border outline-none" + style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} + /> + +
+
+ )} + + ); +} diff --git a/frontend/src/components/batch/PasteRelease.tsx b/frontend/src/components/batch/PasteRelease.tsx new file mode 100644 index 0000000..0e6f188 --- /dev/null +++ b/frontend/src/components/batch/PasteRelease.tsx @@ -0,0 +1,212 @@ +import { useEffect, useRef, useState } from 'react'; +import { ClipboardPaste, Play, Loader2, CheckCircle2, XCircle, History, Wand2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { useMutation } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api'; + +interface Props { + onViewRecords?: () => void; +} + +const branchHints = ['pre', 'origin/pre', 'test', 'origin/test', 'main']; + +const exampleText = 'cex-agent-server\ncex-agent-api\ncex-agent-admin\ncex-cex-web-nextjs\ncex-agent-admin-web'; + +export default function PasteRelease({ onViewRecords }: Props) { + const [text, setText] = useState(''); + const [refType, setRefType] = useState<'branch' | 'tag'>('branch'); + const [fixedRef, setFixedRef] = useState('test'); + const [parallel, setParallel] = useState(false); + const [autoCreate, setAutoCreate] = useState(true); + const [preview, setPreview] = useState(null); + const [parseError, setParseError] = useState(''); + const [result, setResult] = useState(null); + const timerRef = useRef | null>(null); + + const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }; + + // 700ms 防抖自动解析 + useEffect(() => { + if (timerRef.current) clearTimeout(timerRef.current); + if (!text.trim()) { + setPreview(null); + setParseError(''); + return; + } + timerRef.current = setTimeout(async () => { + try { + const res = await apiClient.batchParse({ text, refType, fixedRef }); + setPreview(res); + setParseError(''); + } catch (err) { + setPreview(null); + setParseError((err as Error).message); + } + }, 700); + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [text, refType, fixedRef]); + + const releaseMutation = useMutation({ + mutationFn: () => + apiClient.aiRelease({ + jobs: (preview?.jobs || []).map((j: any) => j.name), + branch: refType === 'branch' ? fixedRef : undefined, + parallel, + autoCreate, + }), + onSuccess: (res) => { + setResult(res); + const okN = res.triggered?.length || 0; + const failN = res.failed?.length || 0; + if (okN > 0) toast.success(`已触发 ${okN} 个服务${failN ? `,${failN} 个失败` : ''}`); + else toast.error('全部触发失败'); + }, + onError: (err: Error) => toast.error('发布失败', { description: err.message }), + }); + + const jobs = preview?.jobs || []; + const matchedCount = jobs.filter((j: any) => j.matched || j.autoCreatable).length; + + return ( +
+ {/* 粘贴区 */} +
+
+
+ +

发布清单

+ 每行一个服务;# 开头为注释 +
+ +
+