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 }