feat: Jenkins Job 批量发布 + 全局 AI 智能助手 (v0.3.0)
参考 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 字段
This commit is contained in:
1 parent
88d8fc282e
commit
581d0a8351
12 files changed
+1415
-2
No files matched your search
@@ -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
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"flowstack/internal/crypto"
|
||||||
"flowstack/internal/database"
|
"flowstack/internal/database"
|
||||||
|
"flowstack/internal/llm"
|
||||||
"flowstack/internal/model"
|
"flowstack/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -83,9 +86,10 @@ func GetSummary(c *gin.Context) {
|
|||||||
func GetSettings(c *gin.Context) {
|
func GetSettings(c *gin.Context) {
|
||||||
var s model.SystemSetting
|
var s model.SystemSetting
|
||||||
if err := database.DB.First(&s, 1).Error; err != nil {
|
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)
|
database.DB.Create(&s)
|
||||||
}
|
}
|
||||||
|
s.LlmApiKeySet = s.LlmApiKey != ""
|
||||||
c.JSON(http.StatusOK, s)
|
c.JSON(http.StatusOK, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,10 +118,61 @@ func UpdateSettings(c *gin.Context) {
|
|||||||
if v, ok := body["extra"].(string); ok {
|
if v, ok := body["extra"].(string); ok {
|
||||||
updates["extra"] = v
|
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.Model(&s).Updates(updates)
|
||||||
|
database.DB.First(&s, 1)
|
||||||
|
s.LlmApiKeySet = s.LlmApiKey != ""
|
||||||
c.JSON(http.StatusOK, s)
|
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
|
// CreatePipeline POST /api/pipelines
|
||||||
func CreatePipeline(c *gin.Context) {
|
func CreatePipeline(c *gin.Context) {
|
||||||
var input struct {
|
var input struct {
|
||||||
|
|||||||
@@ -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] + "..."
|
||||||
|
}
|
||||||
@@ -267,6 +267,12 @@ type SystemSetting struct {
|
|||||||
Theme string `gorm:"type:text" json:"theme"` // JSON {mode,primaryColor,...}
|
Theme string `gorm:"type:text" json:"theme"` // JSON {mode,primaryColor,...}
|
||||||
Notification string `gorm:"type:text" json:"notification"` // JSON {email,browser,...}
|
Notification string `gorm:"type:text" json:"notification"` // JSON {email,browser,...}
|
||||||
ToolMode string `gorm:"default:local" json:"toolMode"` // local|jenkins
|
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"`
|
Extra string `gorm:"type:text" json:"extra"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -54,6 +54,11 @@ func main() {
|
|||||||
api.GET("/summary", handler.GetSummary)
|
api.GET("/summary", handler.GetSummary)
|
||||||
api.GET("/settings", handler.GetSettings)
|
api.GET("/settings", handler.GetSettings)
|
||||||
api.PUT("/settings", handler.UpdateSettings)
|
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.GET("/pipelines", handler.ListPipelines)
|
||||||
api.POST("/pipelines", handler.CreatePipeline)
|
api.POST("/pipelines", handler.CreatePipeline)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import WebhookConfig from '@/pages/WebhookConfig';
|
|||||||
import GitOpsDeploy from '@/pages/GitOpsDeploy';
|
import GitOpsDeploy from '@/pages/GitOpsDeploy';
|
||||||
import PluginManager from '@/pages/PluginManager';
|
import PluginManager from '@/pages/PluginManager';
|
||||||
import SystemSettings from '@/pages/SystemSettings';
|
import SystemSettings from '@/pages/SystemSettings';
|
||||||
|
import AiAssistant from '@/components/ai/AiAssistant';
|
||||||
import { apiClient } from '@/lib/api';
|
import { apiClient } from '@/lib/api';
|
||||||
|
|
||||||
export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings';
|
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() {
|
|||||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
|
<AiAssistant onNavigate={(m) => setActiveModule(m as ModuleType)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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<ChatItem[]>([
|
||||||
|
{ role: 'assistant', content: '你好,我是 FlowStack 智能发布助手。告诉我你要发布哪些服务(支持自然语言),例如:发布 cex-agent-server、cex-agent-api 到 pre 分支。' },
|
||||||
|
]);
|
||||||
|
const listRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<>
|
||||||
|
{/* 悬浮按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="fixed bottom-6 right-6 z-50 w-12 h-12 rounded-full shadow-lg flex items-center justify-center transition-transform hover:scale-105"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #0969da, #8250df)' }}
|
||||||
|
title="FlowStack 智能助手"
|
||||||
|
>
|
||||||
|
{open ? <X className="w-5 h-5 text-white" /> : <Sparkles className="w-5 h-5 text-white" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 对话面板 */}
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-22 right-6 z-50 w-[400px] rounded-xl border shadow-2xl flex flex-col overflow-hidden"
|
||||||
|
style={{ bottom: 84, height: 520, background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||||
|
>
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="px-4 py-3 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color)', background: 'linear-gradient(135deg, rgba(9,105,218,0.06), rgba(130,80,223,0.06))' }}>
|
||||||
|
<div className="w-7 h-7 rounded-full flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #0969da, #8250df)' }}>
|
||||||
|
<Bot className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>FlowStack 智能助手</div>
|
||||||
|
<div className="text-[10px] flex items-center gap-1" style={{ color: aiConnected ? '#1a7f37' : 'var(--text-tertiary)' }}>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full" style={{ background: aiConnected ? '#1a7f37' : '#9a6700' }} />
|
||||||
|
{aiConnected ? 'AI 已连接' : '本地规则模式(系统设置可接入 AI)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-black/5">
|
||||||
|
<X className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消息列表 */}
|
||||||
|
<div ref={listRef} className="flex-1 overflow-y-auto custom-scrollbar px-4 py-3 space-y-3">
|
||||||
|
{messages.map((m, i) => (
|
||||||
|
<div key={i} className={m.role === 'user' ? 'flex justify-end' : 'flex justify-start'}>
|
||||||
|
<div className="max-w-[85%]">
|
||||||
|
<div
|
||||||
|
className="px-3 py-2 rounded-xl text-[12px] leading-5 whitespace-pre-wrap"
|
||||||
|
style={
|
||||||
|
m.role === 'user'
|
||||||
|
? { background: 'var(--accent-blue)', color: '#fff', borderBottomRightRadius: 4 }
|
||||||
|
: { background: 'var(--panel-bg-secondary)', color: 'var(--text-primary)', borderBottomLeftRadius: 4 }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{m.content}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 发布计划卡片 */}
|
||||||
|
{m.action && m.action.type === 'release' && (
|
||||||
|
<div className="mt-2 rounded-lg border p-3" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg)' }}>
|
||||||
|
<div className="text-[11px] font-semibold mb-2 flex items-center gap-1" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
<Sparkles className="w-3 h-3" style={{ color: '#8250df' }} />
|
||||||
|
发布计划 · {m.action.jobs.length} 个服务
|
||||||
|
{m.action.branch && <span className="font-normal" style={{ color: 'var(--text-tertiary)' }}>@{m.action.branch}</span>}
|
||||||
|
{m.action.env && <span className="font-normal" style={{ color: 'var(--text-tertiary)' }}>env:{m.action.env}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
|
{m.action.jobs.map((j: any, ji: number) => (
|
||||||
|
<span
|
||||||
|
key={ji}
|
||||||
|
className="text-[10px] px-2 py-[2px] rounded-md font-mono"
|
||||||
|
style={{
|
||||||
|
background: j.matched ? '#dafbe1' : j.source === 'jenkins' ? '#ddf4ff' : '#ffebe9',
|
||||||
|
color: j.matched ? '#1a7f37' : j.source === 'jenkins' ? '#0969da' : '#cf222e',
|
||||||
|
}}
|
||||||
|
title={j.matched ? '已匹配流水线' : j.source === 'jenkins' ? 'Jenkins Job(发布时自动创建流水线)' : '未匹配'}
|
||||||
|
>
|
||||||
|
{j.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{m.actionState === 'pending' && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => confirmRelease(i)}
|
||||||
|
className="px-3 py-1 text-[11px] rounded-md text-white"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
>
|
||||||
|
确认发布
|
||||||
|
</button>
|
||||||
|
<button onClick={() => cancelAction(i)} className="px-3 py-1 text-[11px] rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{m.result && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{m.result.triggered?.map((t: any, ti: number) => (
|
||||||
|
<div key={ti} className="flex items-center gap-1.5 text-[10px]">
|
||||||
|
<CheckCircle2 className="w-3 h-3" style={{ color: '#1a7f37' }} />
|
||||||
|
<span className="font-mono" style={{ color: 'var(--text-secondary)' }}>{t.job}</span>
|
||||||
|
{t.created && <span className="px-1 rounded" style={{ background: '#ddf4ff', color: '#0969da' }}>自动创建</span>}
|
||||||
|
<span className="font-mono" style={{ color: 'var(--text-tertiary)' }}>{t.runId}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{m.result.failed?.map((f: any, fi: number) => (
|
||||||
|
<div key={fi} className="flex items-center gap-1.5 text-[10px]">
|
||||||
|
<XCircle className="w-3 h-3" style={{ color: '#cf222e' }} />
|
||||||
|
<span className="font-mono" style={{ color: 'var(--text-secondary)' }}>{f.job}</span>
|
||||||
|
<span style={{ color: '#cf222e' }}>{f.reason}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(m.result.triggered?.length > 0) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { onNavigate?.('release-records'); setOpen(false); }}
|
||||||
|
className="mt-1 flex items-center gap-1 text-[10px] px-2 py-1 rounded border"
|
||||||
|
style={{ borderColor: 'var(--border-color-light)', color: 'var(--accent-blue)' }}
|
||||||
|
>
|
||||||
|
<History className="w-3 h-3" />查看发布记录
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sending && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="px-3 py-2 rounded-xl flex items-center gap-1.5" style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" style={{ color: 'var(--accent-blue)' }} />
|
||||||
|
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>思考中…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快捷示例 */}
|
||||||
|
{messages.length <= 1 && (
|
||||||
|
<div className="px-4 pb-2 flex flex-wrap gap-1.5">
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => send(s)}
|
||||||
|
className="text-[10px] px-2 py-1 rounded-full border"
|
||||||
|
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 输入区 */}
|
||||||
|
<div className="px-3 py-2.5 border-t flex items-center gap-2" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => 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)' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => send()}
|
||||||
|
disabled={sending || !input.trim()}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-white disabled:opacity-40"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
>
|
||||||
|
<Send className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<any>(null);
|
||||||
|
const [parseError, setParseError] = useState('');
|
||||||
|
const [result, setResult] = useState<any>(null);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-4">
|
||||||
|
{/* 粘贴区 */}
|
||||||
|
<div className="rounded-lg border p-5" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardPaste className="w-4.5 h-4.5" style={{ color: 'var(--accent-blue)' }} />
|
||||||
|
<h3 className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>发布清单</h3>
|
||||||
|
<span className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>每行一个服务;# 开头为注释</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setText(exampleText)} className="text-[11px] px-2 py-1 rounded border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
|
填入示例
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder={refType === 'branch' ? 'cex-agent-server\ncex-agent-api\ncex-agent-admin' : 'cex-openapi-api\nV2.08\ncex-openapi-svc\nV2.09'}
|
||||||
|
rows={7}
|
||||||
|
className="w-full px-3 py-2.5 text-[12px] font-mono rounded-md border outline-none resize-y"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 配置行 */}
|
||||||
|
<div className="flex items-center gap-3 mt-3 flex-wrap">
|
||||||
|
<div className="flex items-center rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
{(['branch', 'tag'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setRefType(t)}
|
||||||
|
className="px-3 py-1 text-[11px] font-medium"
|
||||||
|
style={{ background: refType === t ? 'var(--accent-blue)' : 'transparent', color: refType === t ? '#fff' : 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{t === 'branch' ? '分支' : 'TAG'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{refType === 'branch' && (
|
||||||
|
<>
|
||||||
|
<input value={fixedRef} onChange={(e) => setFixedRef(e.target.value)} placeholder="分支名" className="px-2.5 py-1 text-[11px] rounded-md border outline-none font-mono w-28" style={inputStyle} />
|
||||||
|
{branchHints.map((b) => (
|
||||||
|
<button key={b} onClick={() => setFixedRef(b)} className="text-[10px] px-2 py-[3px] rounded-full border font-mono"
|
||||||
|
style={{ borderColor: fixedRef === b ? 'var(--accent-blue)' : 'var(--border-color-light)', color: fixedRef === b ? 'var(--accent-blue)' : 'var(--text-tertiary)' }}>
|
||||||
|
{b}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px] ml-auto cursor-pointer" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<input type="checkbox" checked={autoCreate} onChange={(e) => setAutoCreate(e.target.checked)} />
|
||||||
|
未导入的 Jenkins Job 自动创建流水线
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<input type="checkbox" checked={parallel} onChange={(e) => setParallel(e.target.checked)} />
|
||||||
|
并发发布(默认串行,逐个触发)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 解析预览 */}
|
||||||
|
{(parseError || preview) && (
|
||||||
|
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: parseError ? '#cf222e' : 'var(--border-color)' }}>
|
||||||
|
<div className="px-4 py-2.5 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wand2 className="w-3.5 h-3.5" style={{ color: parseError ? '#cf222e' : 'var(--accent-blue)' }} />
|
||||||
|
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>{parseError ? '解析失败' : '解析结果'}</span>
|
||||||
|
{!parseError && <span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{preview.count} 个 · 可发布 {matchedCount} 个{preview.param ? ` · 参数 ${preview.param}` : ''}</span>}
|
||||||
|
</div>
|
||||||
|
{!parseError && (
|
||||||
|
<button
|
||||||
|
onClick={() => releaseMutation.mutate()}
|
||||||
|
disabled={releaseMutation.isPending || matchedCount === 0}
|
||||||
|
className="flex items-center gap-1.5 px-3.5 py-1 text-[12px] rounded-md text-white disabled:opacity-50"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
>
|
||||||
|
{releaseMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
|
||||||
|
开始{parallel ? '并发' : '串行'}发布
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{parseError ? (
|
||||||
|
<div className="px-4 py-3 text-[12px]" style={{ color: '#cf222e' }}>{parseError}</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{jobs.map((j: any, i: number) => (
|
||||||
|
<div key={i} className="grid grid-cols-[36px_1fr_130px_110px] gap-2 px-4 py-2 border-t items-center text-[12px]" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)' }}>{i + 1}</span>
|
||||||
|
<span className="font-mono" style={{ color: 'var(--text-primary)' }}>{j.name}</span>
|
||||||
|
<span className="font-mono" style={{ color: 'var(--text-tertiary)' }}>{j.version}</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-[1px] rounded w-fit" style={{
|
||||||
|
background: j.matched ? '#dafbe1' : j.autoCreatable ? '#ddf4ff' : '#ffebe9',
|
||||||
|
color: j.matched ? '#1a7f37' : j.autoCreatable ? '#0969da' : '#cf222e',
|
||||||
|
}}>
|
||||||
|
{j.matched ? (j.source === 'jenkins' ? 'Jenkins 已导入' : '流水线') : j.autoCreatable ? 'Jenkins · 将自动创建' : '未找到'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 执行结果 */}
|
||||||
|
{result && (
|
||||||
|
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="px-4 py-2.5 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
|
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
触发结果 · 成功 {result.triggered?.length || 0} / 失败 {result.failed?.length || 0}
|
||||||
|
</span>
|
||||||
|
<button onClick={onViewRecords} className="flex items-center gap-1 text-[11px] px-2 py-1 rounded border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--accent-blue)' }}>
|
||||||
|
<History className="w-3 h-3" />查看发布记录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{result.triggered?.map((t: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 px-4 py-1.5 border-t text-[11px]" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" style={{ color: '#1a7f37' }} />
|
||||||
|
<span className="font-mono" style={{ color: 'var(--text-primary)' }}>{t.job}</span>
|
||||||
|
{t.created && <span className="text-[9px] px-1 rounded" style={{ background: '#ddf4ff', color: '#0969da' }}>自动创建</span>}
|
||||||
|
<span className="font-mono ml-auto" style={{ color: 'var(--text-tertiary)' }}>{t.runId}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{result.failed?.map((f: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 px-4 py-1.5 border-t text-[11px]" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
<XCircle className="w-3.5 h-3.5" style={{ color: '#cf222e' }} />
|
||||||
|
<span className="font-mono" style={{ color: 'var(--text-primary)' }}>{f.job}</span>
|
||||||
|
<span className="ml-auto" style={{ color: '#cf222e' }}>{f.reason}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{text && !preview && !parseError && (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-4 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />解析中…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -145,4 +145,12 @@ export const apiClient = {
|
|||||||
listJenkinsJobs: (id: any) => api<any[]>(`/api/jenkins-servers/${id}/jobs`),
|
listJenkinsJobs: (id: any) => api<any[]>(`/api/jenkins-servers/${id}/jobs`),
|
||||||
importJenkinsJobs: (id: any, jobs: string[]) =>
|
importJenkinsJobs: (id: any, jobs: string[]) =>
|
||||||
api<any>(`/api/jenkins-servers/${id}/import`, { method: 'POST', body: JSON.stringify({ jobs }) }),
|
api<any>(`/api/jenkins-servers/${id}/import`, { method: 'POST', body: JSON.stringify({ jobs }) }),
|
||||||
|
// AI 助手与批量发布
|
||||||
|
aiChat: (messages: { role: string; content: string }[]) =>
|
||||||
|
api<any>('/api/ai/chat', { method: 'POST', body: JSON.stringify({ messages }) }),
|
||||||
|
aiRelease: (b: { jobs: string[]; branch?: string; env?: string; parallel?: boolean; jenkinsServerId?: number; autoCreate?: boolean }) =>
|
||||||
|
api<any>('/api/ai/release', { method: 'POST', body: JSON.stringify(b) }),
|
||||||
|
batchParse: (b: { text: string; refType?: string; fixedRef?: string }) =>
|
||||||
|
api<any>('/api/batch/parse', { method: 'POST', body: JSON.stringify(b) }),
|
||||||
|
testLLM: () => api<any>('/api/settings/test-llm', { method: 'POST' }),
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api';
|
import { apiClient } from '@/lib/api';
|
||||||
|
import PasteRelease from '@/components/batch/PasteRelease';
|
||||||
|
|
||||||
// ---- 类型(与后端 GET /api/pipelines、GET /api/runs 对齐)----
|
// ---- 类型(与后端 GET /api/pipelines、GET /api/runs 对齐)----
|
||||||
interface PipelineDTO {
|
interface PipelineDTO {
|
||||||
@@ -64,6 +65,7 @@ const stamp = () => new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
|||||||
export default function BatchRelease({ onViewRecords }: { onViewRecords?: () => void } = {}) {
|
export default function BatchRelease({ onViewRecords }: { onViewRecords?: () => void } = {}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [inputMode, setInputMode] = useState<'paste' | 'wizard'>('paste');
|
||||||
const [currentStep, setCurrentStep] = useState<StepType>('select');
|
const [currentStep, setCurrentStep] = useState<StepType>('select');
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
// 发布配置(本地状态)
|
// 发布配置(本地状态)
|
||||||
@@ -220,10 +222,25 @@ export default function BatchRelease({ onViewRecords }: { onViewRecords?: () =>
|
|||||||
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>批量发布</h1>
|
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>批量发布</h1>
|
||||||
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>向导</span>
|
{/* 输入方式切换:粘贴清单 / 手动向导 */}
|
||||||
|
<div className="flex items-center rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
{[{ id: 'paste', label: '粘贴清单' }, { id: 'wizard', label: '手动勾选' }].map((m) => (
|
||||||
|
<button key={m.id} onClick={() => setInputMode(m.id as 'paste' | 'wizard')} className="px-3 py-[3px] text-[11px] font-medium"
|
||||||
|
style={{ background: inputMode === m.id ? 'var(--accent-blue)' : 'transparent', color: inputMode === m.id ? '#fff' : 'var(--text-secondary)' }}>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{inputMode === 'wizard' && <span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>向导</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{inputMode === 'paste' ? (
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||||
|
<PasteRelease onViewRecords={handleViewRecords} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="px-5 py-3 border-b flex items-center justify-center gap-0 flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="px-5 py-3 border-b flex items-center justify-center gap-0 flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
{steps.map((step, idx) => {
|
{steps.map((step, idx) => {
|
||||||
const isActive = idx === stepIndex;
|
const isActive = idx === stepIndex;
|
||||||
@@ -446,6 +463,8 @@ export default function BatchRelease({ onViewRecords }: { onViewRecords?: () =>
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -66,6 +66,7 @@ export default function SystemSettings() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [theme, setTheme] = useState<ThemeConfig>(defaultTheme);
|
const [theme, setTheme] = useState<ThemeConfig>(defaultTheme);
|
||||||
const [notifications, setNotifications] = useState<NotificationConfig>(defaultNotifications);
|
const [notifications, setNotifications] = useState<NotificationConfig>(defaultNotifications);
|
||||||
|
const [llm, setLlm] = useState({ enabled: false, endpoint: '', model: '', apiKey: '' });
|
||||||
|
|
||||||
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
|
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
|
||||||
|
|
||||||
@@ -74,8 +75,38 @@ export default function SystemSettings() {
|
|||||||
if (!settings) return;
|
if (!settings) return;
|
||||||
setTheme(safeParse<ThemeConfig>(settings.theme, defaultTheme));
|
setTheme(safeParse<ThemeConfig>(settings.theme, defaultTheme));
|
||||||
setNotifications(safeParse<NotificationConfig>(settings.notification, defaultNotifications));
|
setNotifications(safeParse<NotificationConfig>(settings.notification, defaultNotifications));
|
||||||
|
setLlm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabled: !!settings.llmEnabled,
|
||||||
|
endpoint: settings.llmEndpoint || '',
|
||||||
|
model: settings.llmModel || '',
|
||||||
|
apiKey: '', // 只写:回显永远留空
|
||||||
|
}));
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
|
const saveLlmMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const body: any = { llmEnabled: llm.enabled, llmEndpoint: llm.endpoint, llmModel: llm.model };
|
||||||
|
if (llm.apiKey) body.llmApiKey = llm.apiKey;
|
||||||
|
return apiClient.updateSettings(body);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('AI 配置已保存');
|
||||||
|
setLlm((p) => ({ ...p, apiKey: '' }));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const testLlmMutation = useMutation({
|
||||||
|
mutationFn: apiClient.testLLM,
|
||||||
|
onSuccess: (res) => {
|
||||||
|
if (res.success) toast.success('AI 连接成功', { description: res.message });
|
||||||
|
else toast.error('AI 连接失败', { description: res.message });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('测试失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: () => apiClient.updateSettings({
|
mutationFn: () => apiClient.updateSettings({
|
||||||
theme: JSON.stringify(theme),
|
theme: JSON.stringify(theme),
|
||||||
@@ -282,6 +313,47 @@ export default function SystemSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AI 助手配置 */}
|
||||||
|
<div className="rounded-lg border overflow-hidden mt-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="px-5 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>AI 助手(智能发布)</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: settings?.llmEnabled && settings?.llmApiKeySet ? '#dafbe1' : '#fff8c5', color: settings?.llmEnabled && settings?.llmApiKeySet ? '#1a7f37' : '#9a6700' }}>
|
||||||
|
{settings?.llmEnabled && settings?.llmApiKeySet ? '已启用' : '未启用(规则模式)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={llm.enabled} onChange={(e) => setLlm({ ...llm, enabled: e.target.checked })} />
|
||||||
|
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>启用 AI(OpenAI 兼容接口:DeepSeek / GLM / Qwen 等)</span>
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>接口地址</label>
|
||||||
|
<input value={llm.endpoint} onChange={(e) => setLlm({ ...llm, endpoint: e.target.value })} placeholder="https://api.deepseek.com/v1" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>模型</label>
|
||||||
|
<input value={llm.model} onChange={(e) => setLlm({ ...llm, model: e.target.value })} placeholder="deepseek-chat" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 col-span-2">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>API Key</label>
|
||||||
|
<input type="password" value={llm.apiKey} onChange={(e) => setLlm({ ...llm, apiKey: e.target.value })} placeholder={settings?.llmApiKeySet ? '已配置,留空不修改' : 'sk-...'} className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>未启用 AI 时,智能助手自动降级为本地规则模式(按服务名匹配,仍可正常发布)</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => saveLlmMutation.mutate()} disabled={saveLlmMutation.isPending} className="px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
|
||||||
|
{saveLlmMutation.isPending ? '保存中…' : '保存 AI 配置'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => testLlmMutation.mutate()} disabled={testLlmMutation.isPending} className="px-3 py-1 text-[12px] rounded-md border disabled:opacity-60" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
|
{testLlmMutation.isPending ? '测试中…' : '测试连接(需先保存)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user