From c0fd854d64d0ab7b09bf1638dfb9f9c599ae48a2 Mon Sep 17 00:00:00 2001 From: wangym Date: Mon, 26 Jan 2026 16:02:44 +0800 Subject: [PATCH 1/3] # Summary --- go-version/cmd/qkflow/commands/ai.go | 217 ++++++++++++++++++ go-version/cmd/qkflow/commands/pr_create.go | 233 +++++++++++--------- go-version/cmd/qkflow/commands/root.go | 61 ++++- go-version/internal/ai/client.go | 113 ++++++++-- go-version/pkg/config/config.go | 13 +- 5 files changed, 502 insertions(+), 135 deletions(-) create mode 100644 go-version/cmd/qkflow/commands/ai.go diff --git a/go-version/cmd/qkflow/commands/ai.go b/go-version/cmd/qkflow/commands/ai.go new file mode 100644 index 0000000..0360c0c --- /dev/null +++ b/go-version/cmd/qkflow/commands/ai.go @@ -0,0 +1,217 @@ +package commands + +import ( + "fmt" + + "github.com/Wangggym/quick-workflow/internal/ui" + "github.com/Wangggym/quick-workflow/pkg/config" + "github.com/spf13/cobra" +) + +var aiCmd = &cobra.Command{ + Use: "ai", + Short: "Manage AI provider settings", + Long: `View and switch between AI providers (Cerebras, DeepSeek, OpenAI).`, + Run: runAIStatus, +} + +var aiSwitchCmd = &cobra.Command{ + Use: "switch [provider]", + Short: "Switch AI provider", + Long: `Switch to a specific AI provider. +Available providers: auto, cerebras, deepseek, openai + +Examples: + qkflow ai switch cerebras # Switch to Cerebras + qkflow ai switch deepseek # Switch to DeepSeek + qkflow ai switch openai # Switch to OpenAI + qkflow ai switch auto # Auto-select best available`, + Args: cobra.ExactArgs(1), + Run: runAISwitch, +} + +var aiSetCmd = &cobra.Command{ + Use: "set [key] [value]", + Short: "Set AI configuration", + Long: `Set AI provider configuration values. + +Examples: + qkflow ai set cerebras-key YOUR_API_KEY + qkflow ai set cerebras-url https://api.cerebras.ai/v1 + qkflow ai set deepseek-key YOUR_API_KEY + qkflow ai set openai-key YOUR_API_KEY`, + Args: cobra.ExactArgs(2), + Run: runAISet, +} + +func init() { + aiCmd.AddCommand(aiSwitchCmd) + aiCmd.AddCommand(aiSetCmd) +} + +func runAIStatus(cmd *cobra.Command, args []string) { + cfg := config.Get() + if cfg == nil { + ui.Error("No configuration found. Run 'qkflow init' first.") + return + } + + fmt.Println("🤖 AI Provider Status:") + fmt.Println() + + // Current provider + provider := cfg.AIProvider + if provider == "" { + provider = "auto" + } + fmt.Printf(" Current Mode: %s\n", provider) + fmt.Println() + + // Available providers + fmt.Println(" Available Providers:") + + // Cerebras + if cfg.CerebrasKey != "" { + if provider == "cerebras" || (provider == "auto" && cfg.CerebrasKey != "") { + fmt.Printf(" ✅ Cerebras: %s (Active)\n", maskToken(cfg.CerebrasKey)) + } else { + fmt.Printf(" ⚪ Cerebras: %s\n", maskToken(cfg.CerebrasKey)) + } + if cfg.CerebrasURL != "" { + fmt.Printf(" URL: %s\n", cfg.CerebrasURL) + } + } else { + fmt.Printf(" ❌ Cerebras: not configured\n") + } + + // DeepSeek + if cfg.DeepSeekKey != "" { + isActive := provider == "deepseek" || (provider == "auto" && cfg.CerebrasKey == "" && cfg.DeepSeekKey != "") + if isActive { + fmt.Printf(" ✅ DeepSeek: %s (Active)\n", maskToken(cfg.DeepSeekKey)) + } else { + fmt.Printf(" ⚪ DeepSeek: %s\n", maskToken(cfg.DeepSeekKey)) + } + } else { + fmt.Printf(" ❌ DeepSeek: not configured\n") + } + + // OpenAI + if cfg.OpenAIKey != "" { + isActive := provider == "openai" || (provider == "auto" && cfg.CerebrasKey == "" && cfg.DeepSeekKey == "" && cfg.OpenAIKey != "") + if isActive { + fmt.Printf(" ✅ OpenAI: %s (Active)\n", maskToken(cfg.OpenAIKey)) + } else { + fmt.Printf(" ⚪ OpenAI: %s\n", maskToken(cfg.OpenAIKey)) + } + if cfg.OpenAIProxyURL != "" { + fmt.Printf(" Proxy: %s\n", cfg.OpenAIProxyURL) + } + } else { + fmt.Printf(" ❌ OpenAI: not configured\n") + } + + fmt.Println() + fmt.Println(" Commands:") + fmt.Println(" qkflow ai switch - Switch AI provider (auto/cerebras/deepseek/openai)") + fmt.Println(" qkflow ai set - Set API key or URL") +} + +func runAISwitch(cmd *cobra.Command, args []string) { + provider := args[0] + + // Validate provider + validProviders := map[string]bool{ + "auto": true, + "cerebras": true, + "deepseek": true, + "openai": true, + } + + if !validProviders[provider] { + ui.Error(fmt.Sprintf("Invalid provider: %s. Valid options: auto, cerebras, deepseek, openai", provider)) + return + } + + cfg := config.Get() + if cfg == nil { + ui.Error("No configuration found. Run 'qkflow init' first.") + return + } + + // Check if the selected provider has a key configured + switch provider { + case "cerebras": + if cfg.CerebrasKey == "" { + ui.Warning("Cerebras API key not configured. Set it with: qkflow ai set cerebras-key YOUR_KEY") + } + case "deepseek": + if cfg.DeepSeekKey == "" { + ui.Warning("DeepSeek API key not configured. Set it with: qkflow ai set deepseek-key YOUR_KEY") + } + case "openai": + if cfg.OpenAIKey == "" { + ui.Warning("OpenAI API key not configured. Set it with: qkflow ai set openai-key YOUR_KEY") + } + } + + cfg.AIProvider = provider + + if err := config.Save(cfg); err != nil { + ui.Error(fmt.Sprintf("Failed to save configuration: %v", err)) + return + } + + ui.Success(fmt.Sprintf("Switched AI provider to: %s", provider)) + + // Show which provider will be active + if provider == "auto" { + if cfg.CerebrasKey != "" { + ui.Info("Auto mode will use: Cerebras (highest priority)") + } else if cfg.DeepSeekKey != "" { + ui.Info("Auto mode will use: DeepSeek") + } else if cfg.OpenAIKey != "" { + ui.Info("Auto mode will use: OpenAI") + } else { + ui.Warning("No AI keys configured. Run 'qkflow ai set' to add one.") + } + } +} + +func runAISet(cmd *cobra.Command, args []string) { + key := args[0] + value := args[1] + + cfg := config.Get() + if cfg == nil { + ui.Error("No configuration found. Run 'qkflow init' first.") + return + } + + switch key { + case "cerebras-key": + cfg.CerebrasKey = value + ui.Success("Cerebras API key set successfully") + case "cerebras-url": + cfg.CerebrasURL = value + ui.Success(fmt.Sprintf("Cerebras URL set to: %s", value)) + case "deepseek-key": + cfg.DeepSeekKey = value + ui.Success("DeepSeek API key set successfully") + case "openai-key": + cfg.OpenAIKey = value + ui.Success("OpenAI API key set successfully") + case "openai-proxy-url": + cfg.OpenAIProxyURL = value + ui.Success(fmt.Sprintf("OpenAI Proxy URL set to: %s", value)) + default: + ui.Error(fmt.Sprintf("Unknown key: %s", key)) + fmt.Println("Valid keys: cerebras-key, cerebras-url, deepseek-key, openai-key, openai-proxy-url") + return + } + + if err := config.Save(cfg); err != nil { + ui.Error(fmt.Sprintf("Failed to save configuration: %v", err)) + return + } +} diff --git a/go-version/cmd/qkflow/commands/pr_create.go b/go-version/cmd/qkflow/commands/pr_create.go index f74837e..75e1f51 100644 --- a/go-version/cmd/qkflow/commands/pr_create.go +++ b/go-version/cmd/qkflow/commands/pr_create.go @@ -8,7 +8,6 @@ import ( "time" "github.com/Wangggym/quick-workflow/internal/ai" - "github.com/Wangggym/quick-workflow/internal/editor" "github.com/Wangggym/quick-workflow/internal/git" "github.com/Wangggym/quick-workflow/internal/github" "github.com/Wangggym/quick-workflow/internal/jira" @@ -18,6 +17,13 @@ import ( "github.com/spf13/cobra" ) +var ( + prDesc string + prTypes []string + noTicket bool + prTitle string +) + var prCreateCmd = &cobra.Command{ Use: "create [jira-ticket]", Short: "Create a PR and update Jira status", @@ -32,6 +38,13 @@ var prCreateCmd = &cobra.Command{ Run: runPRCreate, } +func init() { + prCreateCmd.Flags().StringVar(&prDesc, "pr-desc", "", "PR description body content") + prCreateCmd.Flags().StringSliceVar(&prTypes, "types", []string{}, "Change types (e.g., feat,fix,docs)") + prCreateCmd.Flags().BoolVar(&noTicket, "no-ticket", false, "Skip Jira ticket (proceed without ticket)") + prCreateCmd.Flags().StringVar(&prTitle, "title", "", "PR title (if not provided, will be generated from description)") +} + func runPRCreate(cmd *cobra.Command, args []string) { // 检查是否在 Git 仓库中 if !git.IsGitRepository() { @@ -59,7 +72,10 @@ func runPRCreate(cmd *cobra.Command, args []string) { // 获取或输入 Jira ticket var jiraTicket string - if len(args) > 0 { + if noTicket { + // 明确指定跳过 ticket + jiraTicket = "" + } else if len(args) > 0 { jiraTicket = args[0] } else { jiraTicket, err = ui.PromptInput("Jira ticket (optional, press Enter to skip):", false) @@ -96,31 +112,22 @@ func runPRCreate(cmd *cobra.Command, args []string) { } // 选择变更类型 - prTypes := ui.PRTypeOptions() - selectedTypes, err := ui.PromptMultiSelect("Select type(s) of changes:", prTypes) - if err != nil { - if err.Error() == "interrupt" { - ui.Warning("Operation cancelled by user") - os.Exit(0) - } - ui.Warning("No types selected, continuing...") - selectedTypes = []string{} - } - - // 询问是否添加说明/截图 (空格选择,Enter 跳过) - var editorResult *editor.EditorResult - addDescription, err := ui.PromptOptional("Add detailed description with images/videos?") - if err == nil && addDescription { - ui.Info("Opening web editor...") - editorResult, err = editor.StartEditor() + var selectedTypes []string + if len(prTypes) > 0 { + // 使用命令行提供的类型 + selectedTypes = prTypes + ui.Info(fmt.Sprintf("Using provided types: %v", prTypes)) + } else { + // 交互式选择 + prTypeOptions := ui.PRTypeOptions() + selectedTypes, err = ui.PromptMultiSelect("Select type(s) of changes:", prTypeOptions) if err != nil { - ui.Warning(fmt.Sprintf("Failed to start editor: %v", err)) - editorResult = nil - } else if editorResult.Content == "" && len(editorResult.Files) == 0 { - ui.Info("No content added, skipping...") - editorResult = nil - } else { - ui.Success(fmt.Sprintf("Content saved! (%d characters, %d files)", len(editorResult.Content), len(editorResult.Files))) + if err.Error() == "interrupt" { + ui.Warning("Operation cancelled by user") + os.Exit(0) + } + ui.Warning("No types selected, continuing...") + selectedTypes = []string{} } } @@ -151,15 +158,55 @@ func runPRCreate(cmd *cobra.Command, args []string) { ui.Success(fmt.Sprintf("Generated title: %s", title)) } } else { - // 没有 Jira,手动输入 - title, err = ui.PromptInput("Enter PR title:", true) - if err != nil { - if err.Error() == "interrupt" { - ui.Warning("Operation cancelled by user") - os.Exit(0) + // 没有 Jira + if prTitle != "" { + // 使用提供的标题 + title = prTitle + ui.Success(fmt.Sprintf("Using provided title: %s", title)) + } else if prDesc != "" { + // 从描述的第一行提取标题 + lines := strings.Split(strings.TrimSpace(prDesc), "\n") + if len(lines) > 0 { + // 移除 markdown 标题标记 + firstLine := strings.TrimSpace(lines[0]) + firstLine = strings.TrimPrefix(firstLine, "# ") + firstLine = strings.TrimPrefix(firstLine, "## ") + if len(firstLine) > 0 && len(firstLine) <= 100 { + title = firstLine + ui.Success(fmt.Sprintf("Generated title from description: %s", title)) + } else { + // 如果第一行太长,使用类型 + 简短描述 + if len(selectedTypes) > 0 { + prType := ui.ExtractPRType(selectedTypes[0]) + title = fmt.Sprintf("%s: %s", prType, truncateString(firstLine, 50)) + } else { + title = truncateString(firstLine, 80) + } + ui.Success(fmt.Sprintf("Generated title: %s", title)) + } + } else { + // 描述为空,需要手动输入 + title, err = ui.PromptInput("Enter PR title:", true) + if err != nil { + if err.Error() == "interrupt" { + ui.Warning("Operation cancelled by user") + os.Exit(0) + } + ui.Error(fmt.Sprintf("Failed to get title: %v", err)) + return + } + } + } else { + // 没有描述,手动输入 + title, err = ui.PromptInput("Enter PR title:", true) + if err != nil { + if err.Error() == "interrupt" { + ui.Warning("Operation cancelled by user") + os.Exit(0) + } + ui.Error(fmt.Sprintf("Failed to get title: %v", err)) + return } - ui.Error(fmt.Sprintf("Failed to get title: %v", err)) - return } } @@ -273,71 +320,26 @@ func runPRCreate(cmd *cobra.Command, args []string) { ui.Success(fmt.Sprintf("Pull request created: %s", pr.HTMLURL)) - // 处理编辑器内容(上传文件并添加评论) - if editorResult != nil && (editorResult.Content != "" || len(editorResult.Files) > 0) { - ui.Info("Processing description and files...") - - // 创建 Jira 客户端(如果需要) - var jiraClient *jira.Client - if jiraTicket != "" && jira.ValidateIssueKey(jiraTicket) { - jiraClient, err = jira.NewClient() - if err != nil { - ui.Warning(fmt.Sprintf("Failed to create Jira client for file upload: %v", err)) - jiraClient = nil - } - } - - // 上传文件 - var uploadResults []editor.UploadResult - if len(editorResult.Files) > 0 { - ui.Info(fmt.Sprintf("Uploading %d file(s)...", len(editorResult.Files))) - uploadResults, err = editor.UploadFiles( - editorResult.Files, - ghClient, - jiraClient, - pr.Number, - owner, - repo, - jiraTicket, - ) - if err != nil { - ui.Warning(fmt.Sprintf("Failed to upload files: %v", err)) - } else { - ui.Success(fmt.Sprintf("Uploaded %d file(s)", len(uploadResults))) - } - } - - // 替换 markdown 中的本地路径为在线 URL - content := editorResult.Content - if len(uploadResults) > 0 { - content = editor.ReplaceLocalPathsWithURLs(content, uploadResults) - } - - // 添加评论到 GitHub PR - if content != "" { - ui.Info("Adding description to GitHub PR...") - if err := ghClient.AddPRComment(owner, repo, pr.Number, content); err != nil { - ui.Warning(fmt.Sprintf("Failed to add comment to GitHub: %v", err)) - } else { - ui.Success("Description added to GitHub PR") - } - } - - // 添加评论到 Jira - if jiraClient != nil && jiraTicket != "" && content != "" { - ui.Info("Adding description to Jira...") - jiraComment := fmt.Sprintf("*PR Description:*\n\n%s\n\n[View PR|%s]", content, pr.HTMLURL) - if err := jiraClient.AddComment(jiraTicket, jiraComment); err != nil { - ui.Warning(fmt.Sprintf("Failed to add comment to Jira: %v", err)) - } else { - ui.Success("Description added to Jira") - } + // 如果提供了 PR 描述,添加为评论 + if prDesc != "" { + ui.Info("Adding PR description as comment...") + if err := ghClient.AddPRComment(owner, repo, pr.Number, prDesc); err != nil { + ui.Warning(fmt.Sprintf("Failed to add PR description: %v", err)) + } else { + ui.Success("PR description added") } - // 清理临时文件 - if len(editorResult.Files) > 0 { - for _, file := range editorResult.Files { - os.Remove(file) + // 如果有 Jira,也添加评论 + if jiraTicket != "" && jira.ValidateIssueKey(jiraTicket) { + jiraClient, err := jira.NewClient() + if err == nil { + ui.Info("Adding description to Jira...") + jiraComment := fmt.Sprintf("*PR Description:*\n\n%s\n\n[View PR|%s]", prDesc, pr.HTMLURL) + if err := jiraClient.AddComment(jiraTicket, jiraComment); err != nil { + ui.Warning(fmt.Sprintf("Failed to add comment to Jira: %v", err)) + } else { + ui.Success("Description added to Jira") + } } } } @@ -376,17 +378,25 @@ func runPRCreate(cmd *cobra.Command, args []string) { if err != nil { ui.Warning(fmt.Sprintf("Failed to get cached status: %v", err)) } else if mapping == nil { - // 第一次使用,配置状态映射 - ui.Info(fmt.Sprintf("First time using project %s, please configure status mappings", projectKey)) - mapping, err = setupProjectStatusMapping(jiraClient, projectKey) - if err != nil { - ui.Warning(fmt.Sprintf("Failed to setup status mapping: %v", err)) - } else if mapping != nil { - // 保存配置 - if err := statusCache.SaveProjectStatus(mapping); err != nil { - ui.Warning(fmt.Sprintf("Failed to save status mapping: %v", err)) - } else { - ui.Success("Status mapping saved!") + // 第一次使用,需要配置状态映射 + // 检查是否有提供 --types 或 --pr-desc(表示可能是非交互模式) + if len(prTypes) > 0 || prDesc != "" { + ui.Error(fmt.Sprintf("❌ No status mapping found for project %s", projectKey)) + ui.Info("💡 Please run 'qkflow pr create' interactively first to configure status mappings") + ui.Info(" Then you can use --types and --pr-desc flags for automation") + } else { + // 交互式配置状态映射 + ui.Info(fmt.Sprintf("First time using project %s, please configure status mappings", projectKey)) + mapping, err = setupProjectStatusMapping(jiraClient, projectKey) + if err != nil { + ui.Warning(fmt.Sprintf("Failed to setup status mapping: %v", err)) + } else if mapping != nil { + // 保存配置 + if err := statusCache.SaveProjectStatus(mapping); err != nil { + ui.Warning(fmt.Sprintf("Failed to save status mapping: %v", err)) + } else { + ui.Success("Status mapping saved!") + } } } } @@ -503,6 +513,13 @@ func setupProjectStatusMapping(client *jira.Client, projectKey string) (*jira.St }, nil } +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + func generateSimpleTitle(jiraSummary, prType, description string) string { // 如果有简短描述,使用描述 if description != "" { diff --git a/go-version/cmd/qkflow/commands/root.go b/go-version/cmd/qkflow/commands/root.go index 43502c5..97264dc 100644 --- a/go-version/cmd/qkflow/commands/root.go +++ b/go-version/cmd/qkflow/commands/root.go @@ -71,6 +71,7 @@ func init() { rootCmd.AddCommand(jiraCmd) rootCmd.AddCommand(updateCmd) rootCmd.AddCommand(watchCmd) + rootCmd.AddCommand(aiCmd) } var versionCmd = &cobra.Command{ @@ -140,21 +141,68 @@ var configCmd = &cobra.Command{ fmt.Println() fmt.Println("🤖 AI (optional):") - // Determine which AI service is active + // Show AI provider mode + provider := cfg.AIProvider + if provider == "" { + provider = "auto" + } + fmt.Printf(" Provider Mode: %s\n", provider) + + // Determine which AI service is active based on provider setting + hasCerebras := cfg.CerebrasKey != "" hasDeepSeek := cfg.DeepSeekKey != "" hasOpenAI := cfg.OpenAIKey != "" + // Determine active provider + var activeProvider string + switch provider { + case "cerebras": + activeProvider = "cerebras" + case "deepseek": + activeProvider = "deepseek" + case "openai": + activeProvider = "openai" + default: // auto + if hasCerebras { + activeProvider = "cerebras" + } else if hasDeepSeek { + activeProvider = "deepseek" + } else if hasOpenAI { + activeProvider = "openai" + } + } + + // Show Cerebras status + if hasCerebras { + if activeProvider == "cerebras" { + fmt.Printf(" Cerebras Key: %s ✅ (Active)\n", maskToken(cfg.CerebrasKey)) + } else { + fmt.Printf(" Cerebras Key: %s\n", maskToken(cfg.CerebrasKey)) + } + if cfg.CerebrasURL != "" { + fmt.Printf(" Cerebras URL: %s\n", cfg.CerebrasURL) + } + } else { + fmt.Printf(" Cerebras Key: not configured\n") + } + + // Show DeepSeek status if hasDeepSeek { - fmt.Printf(" DeepSeek Key: %s ✅ (Active)\n", maskToken(cfg.DeepSeekKey)) + if activeProvider == "deepseek" { + fmt.Printf(" DeepSeek Key: %s ✅ (Active)\n", maskToken(cfg.DeepSeekKey)) + } else { + fmt.Printf(" DeepSeek Key: %s\n", maskToken(cfg.DeepSeekKey)) + } } else { fmt.Printf(" DeepSeek Key: not configured\n") } + // Show OpenAI status if hasOpenAI { - if hasDeepSeek { - fmt.Printf(" OpenAI Key: %s ✅ (Backup)\n", maskToken(cfg.OpenAIKey)) - } else { + if activeProvider == "openai" { fmt.Printf(" OpenAI Key: %s ✅ (Active)\n", maskToken(cfg.OpenAIKey)) + } else { + fmt.Printf(" OpenAI Key: %s\n", maskToken(cfg.OpenAIKey)) } } else { fmt.Printf(" OpenAI Key: not configured\n") @@ -164,9 +212,10 @@ var configCmd = &cobra.Command{ fmt.Printf(" OpenAI Proxy URL: %s\n", cfg.OpenAIProxyURL) } - if !hasDeepSeek && !hasOpenAI { + if !hasCerebras && !hasDeepSeek && !hasOpenAI { fmt.Println() fmt.Println(" 💡 Tip: Configure AI for automatic PR title/description generation") + fmt.Println(" Run 'qkflow ai set cerebras-key YOUR_KEY' to get started") } }, } diff --git a/go-version/internal/ai/client.go b/go-version/internal/ai/client.go index 0ef1d54..d9c7324 100644 --- a/go-version/internal/ai/client.go +++ b/go-version/internal/ai/client.go @@ -17,7 +17,7 @@ type Client struct { apiKey string apiURL string model string - provider string // "openai" or "deepseek" + provider string // "openai", "deepseek", or "cerebras" } // TranslationResult represents the result of AI translation @@ -34,30 +34,103 @@ func NewClient() (*Client, error) { return nil, fmt.Errorf("config not loaded") } - // 优先使用 DeepSeek,然后是 OpenAI - if cfg.DeepSeekKey != "" { - return &Client{ - apiKey: cfg.DeepSeekKey, - apiURL: "https://api.deepseek.com/v1/chat/completions", - model: "deepseek-chat", - provider: "deepseek", - }, nil + // 根据 ai_provider 配置选择 AI 服务 + provider := cfg.AIProvider + if provider == "" { + provider = "auto" } - if cfg.OpenAIKey != "" { - apiURL := "https://api.openai.com/v1/chat/completions" - if cfg.OpenAIProxyURL != "" { - apiURL = cfg.OpenAIProxyURL + switch provider { + case "cerebras": + if cfg.CerebrasKey != "" { + apiURL := cfg.CerebrasURL + if apiURL == "" { + apiURL = "https://cerebras-proxy.brain.loocaa.com:1443/v1/chat/completions" + } else if !strings.HasSuffix(apiURL, "/chat/completions") { + apiURL = strings.TrimSuffix(apiURL, "/") + "/chat/completions" + } + return &Client{ + apiKey: cfg.CerebrasKey, + apiURL: apiURL, + model: "llama-3.3-70b", + provider: "cerebras", + }, nil + } + return nil, fmt.Errorf("cerebras selected but CEREBRAS_API_KEY not configured") + + case "deepseek": + if cfg.DeepSeekKey != "" { + return &Client{ + apiKey: cfg.DeepSeekKey, + apiURL: "https://api.deepseek.com/v1/chat/completions", + model: "deepseek-chat", + provider: "deepseek", + }, nil + } + return nil, fmt.Errorf("deepseek selected but DEEPSEEK_KEY not configured") + + case "openai": + if cfg.OpenAIKey != "" { + apiURL := "https://api.openai.com/v1/chat/completions" + if cfg.OpenAIProxyURL != "" { + apiURL = cfg.OpenAIProxyURL + if !strings.HasSuffix(apiURL, "/chat/completions") { + apiURL = strings.TrimSuffix(apiURL, "/") + "/chat/completions" + } + } + return &Client{ + apiKey: cfg.OpenAIKey, + apiURL: apiURL, + model: "gpt-3.5-turbo", + provider: "openai", + }, nil + } + return nil, fmt.Errorf("openai selected but OPENAI_KEY not configured") + + default: // "auto" - 自动选择可用的 AI 服务 + // 优先级: Cerebras > DeepSeek > OpenAI + if cfg.CerebrasKey != "" { + apiURL := cfg.CerebrasURL + if apiURL == "" { + apiURL = "https://cerebras-proxy.brain.loocaa.com:1443/v1/chat/completions" + } else if !strings.HasSuffix(apiURL, "/chat/completions") { + apiURL = strings.TrimSuffix(apiURL, "/") + "/chat/completions" + } + return &Client{ + apiKey: cfg.CerebrasKey, + apiURL: apiURL, + model: "llama-3.3-70b", + provider: "cerebras", + }, nil + } + + if cfg.DeepSeekKey != "" { + return &Client{ + apiKey: cfg.DeepSeekKey, + apiURL: "https://api.deepseek.com/v1/chat/completions", + model: "deepseek-chat", + provider: "deepseek", + }, nil + } + + if cfg.OpenAIKey != "" { + apiURL := "https://api.openai.com/v1/chat/completions" + if cfg.OpenAIProxyURL != "" { + apiURL = cfg.OpenAIProxyURL + if !strings.HasSuffix(apiURL, "/chat/completions") { + apiURL = strings.TrimSuffix(apiURL, "/") + "/chat/completions" + } + } + return &Client{ + apiKey: cfg.OpenAIKey, + apiURL: apiURL, + model: "gpt-3.5-turbo", + provider: "openai", + }, nil } - return &Client{ - apiKey: cfg.OpenAIKey, - apiURL: apiURL, - model: "gpt-3.5-turbo", - provider: "openai", - }, nil } - return nil, fmt.Errorf("no AI API key configured (OPENAI_KEY or DEEPSEEK_KEY)") + return nil, fmt.Errorf("no AI API key configured (CEREBRAS_API_KEY, DEEPSEEK_KEY, or OPENAI_KEY)") } // ChatCompletionRequest represents the request to chat completion API diff --git a/go-version/pkg/config/config.go b/go-version/pkg/config/config.go index 414dc25..b2ca6fd 100644 --- a/go-version/pkg/config/config.go +++ b/go-version/pkg/config/config.go @@ -21,6 +21,9 @@ type Config struct { DeepSeekKey string `mapstructure:"deepseek_key"` OpenAIProxyURL string `mapstructure:"openai_proxy_url"` OpenAIProxyKey string `mapstructure:"openai_proxy_key"` + CerebrasKey string `mapstructure:"cerebras_key"` + CerebrasURL string `mapstructure:"cerebras_url"` + AIProvider string `mapstructure:"ai_provider"` // "auto", "deepseek", "openai", "cerebras" AutoUpdate bool `mapstructure:"auto_update"` } @@ -58,6 +61,9 @@ func Load() (*Config, error) { viper.BindEnv("deepseek_key", "DEEPSEEK_KEY") viper.BindEnv("openai_proxy_url", "OPENAI_PROXY_URL") viper.BindEnv("openai_proxy_key", "OPENAI_PROXY_KEY") + viper.BindEnv("cerebras_key", "CEREBRAS_API_KEY") + viper.BindEnv("cerebras_url", "CEREBRAS_BASE_URL") + viper.BindEnv("ai_provider", "AI_PROVIDER") viper.BindEnv("email", "EMAIL") viper.BindEnv("auto_update", "AUTO_UPDATE") @@ -113,6 +119,9 @@ func Save(cfg *Config) error { viper.Set("deepseek_key", cfg.DeepSeekKey) viper.Set("openai_proxy_url", cfg.OpenAIProxyURL) viper.Set("openai_proxy_key", cfg.OpenAIProxyKey) + viper.Set("cerebras_key", cfg.CerebrasKey) + viper.Set("cerebras_url", cfg.CerebrasURL) + viper.Set("ai_provider", cfg.AIProvider) viper.Set("auto_update", cfg.AutoUpdate) // 写入文件 @@ -152,7 +161,9 @@ func IsConfigured() bool { func setDefaults() { viper.SetDefault("branch_prefix", "") - viper.SetDefault("auto_update", true) // 默认启用自动更新 + viper.SetDefault("auto_update", true) // 默认启用自动更新 + viper.SetDefault("ai_provider", "auto") // 默认自动选择 AI provider + viper.SetDefault("cerebras_url", "https://cerebras-proxy.brain.loocaa.com:1443/v1") // 默认 Cerebras URL } // GetConfigDir returns the config directory path From 708eb018b1c1e91f50366f0fd3619303c6811102 Mon Sep 17 00:00:00 2001 From: wangym Date: Mon, 26 Jan 2026 16:09:07 +0800 Subject: [PATCH 2/3] update --- go-version/cmd/qkflow/commands/pr_create.go | 159 ++++++++++++-------- 1 file changed, 100 insertions(+), 59 deletions(-) diff --git a/go-version/cmd/qkflow/commands/pr_create.go b/go-version/cmd/qkflow/commands/pr_create.go index 75e1f51..ee7a1db 100644 --- a/go-version/cmd/qkflow/commands/pr_create.go +++ b/go-version/cmd/qkflow/commands/pr_create.go @@ -18,10 +18,10 @@ import ( ) var ( - prDesc string - prTypes []string - noTicket bool - prTitle string + prDesc string + prTypes []string + noTicket bool + prTitle string ) var prCreateCmd = &cobra.Command{ @@ -150,6 +150,8 @@ func runPRCreate(cmd *cobra.Command, args []string) { // 回退到简单格式 title = generateSimpleTitle(jiraIssue.Summary, prType, "") } else { + // 确保标题有类型前缀且格式正确 + title = ensureTitlePrefix(title, prType) ui.Success(fmt.Sprintf("Generated title: %s", title)) } } else { @@ -160,42 +162,53 @@ func runPRCreate(cmd *cobra.Command, args []string) { } else { // 没有 Jira if prTitle != "" { - // 使用提供的标题 - title = prTitle + // 使用提供的标题,确保有类型前缀 + if len(selectedTypes) > 0 { + prType := ui.ExtractPRType(selectedTypes[0]) + title = ensureTitlePrefix(prTitle, prType) + } else { + title = prTitle + } ui.Success(fmt.Sprintf("Using provided title: %s", title)) } else if prDesc != "" { - // 从描述的第一行提取标题 + // 从描述中提取标题 lines := strings.Split(strings.TrimSpace(prDesc), "\n") - if len(lines) > 0 { - // 移除 markdown 标题标记 - firstLine := strings.TrimSpace(lines[0]) - firstLine = strings.TrimPrefix(firstLine, "# ") - firstLine = strings.TrimPrefix(firstLine, "## ") - if len(firstLine) > 0 && len(firstLine) <= 100 { - title = firstLine - ui.Success(fmt.Sprintf("Generated title from description: %s", title)) + var titleText string + + // 查找 "## Summary" 后面的内容,或者使用第一行 + for i, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "## Summary") && i+1 < len(lines) { + // 使用 Summary 后面的第一行 + titleText = strings.TrimSpace(lines[i+1]) + break + } else if !strings.HasPrefix(line, "#") && line != "" && titleText == "" { + // 使用第一个非标题行 + titleText = line + break + } + } + + // 如果没有找到,使用第一行 + if titleText == "" && len(lines) > 0 { + titleText = strings.TrimSpace(lines[0]) + titleText = strings.TrimPrefix(titleText, "# ") + titleText = strings.TrimPrefix(titleText, "## ") + } + + // 确保标题有类型前缀 + if len(selectedTypes) > 0 { + prType := ui.ExtractPRType(selectedTypes[0]) + // 如果标题已经有类型前缀,不重复添加 + if !strings.HasPrefix(strings.ToLower(titleText), strings.ToLower(prType)+":") { + title = fmt.Sprintf("%s: %s", capitalizeFirst(prType), truncateString(titleText, 80)) } else { - // 如果第一行太长,使用类型 + 简短描述 - if len(selectedTypes) > 0 { - prType := ui.ExtractPRType(selectedTypes[0]) - title = fmt.Sprintf("%s: %s", prType, truncateString(firstLine, 50)) - } else { - title = truncateString(firstLine, 80) - } - ui.Success(fmt.Sprintf("Generated title: %s", title)) + title = truncateString(titleText, 100) } } else { - // 描述为空,需要手动输入 - title, err = ui.PromptInput("Enter PR title:", true) - if err != nil { - if err.Error() == "interrupt" { - ui.Warning("Operation cancelled by user") - os.Exit(0) - } - ui.Error(fmt.Sprintf("Failed to get title: %v", err)) - return - } + title = truncateString(titleText, 100) } + ui.Success(fmt.Sprintf("Generated title: %s", title)) } else { // 没有描述,手动输入 title, err = ui.PromptInput("Enter PR title:", true) @@ -210,8 +223,8 @@ func runPRCreate(cmd *cobra.Command, args []string) { } } - // 构建 PR body - prBody := buildPRBody(selectedTypes, jiraTicket) + // 构建 PR body(包含描述) + prBody := buildPRBody(selectedTypes, jiraTicket, prDesc) // 创建分支名 branchName := buildBranchName(jiraTicket, title) @@ -320,26 +333,16 @@ func runPRCreate(cmd *cobra.Command, args []string) { ui.Success(fmt.Sprintf("Pull request created: %s", pr.HTMLURL)) - // 如果提供了 PR 描述,添加为评论 - if prDesc != "" { - ui.Info("Adding PR description as comment...") - if err := ghClient.AddPRComment(owner, repo, pr.Number, prDesc); err != nil { - ui.Warning(fmt.Sprintf("Failed to add PR description: %v", err)) - } else { - ui.Success("PR description added") - } - - // 如果有 Jira,也添加评论 - if jiraTicket != "" && jira.ValidateIssueKey(jiraTicket) { - jiraClient, err := jira.NewClient() - if err == nil { - ui.Info("Adding description to Jira...") - jiraComment := fmt.Sprintf("*PR Description:*\n\n%s\n\n[View PR|%s]", prDesc, pr.HTMLURL) - if err := jiraClient.AddComment(jiraTicket, jiraComment); err != nil { - ui.Warning(fmt.Sprintf("Failed to add comment to Jira: %v", err)) - } else { - ui.Success("Description added to Jira") - } + // 如果有 Jira ticket,添加 PR 链接到 Jira(描述已经在 PR body 中了) + if jiraTicket != "" && jira.ValidateIssueKey(jiraTicket) { + jiraClient, err := jira.NewClient() + if err == nil { + ui.Info("Adding PR link to Jira...") + jiraComment := fmt.Sprintf("*PR Created:*\n\n[View PR|%s]", pr.HTMLURL) + if err := jiraClient.AddComment(jiraTicket, jiraComment); err != nil { + ui.Warning(fmt.Sprintf("Failed to add comment to Jira: %v", err)) + } else { + ui.Success("PR link added to Jira") } } } @@ -460,7 +463,7 @@ func buildBranchName(jiraTicket, title string) string { return sanitized } -func buildPRBody(types []string, jiraTicket string) string { +func buildPRBody(types []string, jiraTicket string, prDesc string) string { var body strings.Builder body.WriteString("# PR Ready\n\n") @@ -476,7 +479,14 @@ func buildPRBody(types []string, jiraTicket string) string { if jiraTicket != "" { cfg := config.Get() jiraURL := fmt.Sprintf("%s/browse/%s", cfg.JiraServiceAddress, jiraTicket) - body.WriteString(fmt.Sprintf("#### Jira Link:\n\n%s\n", jiraURL)) + body.WriteString(fmt.Sprintf("#### Jira Link:\n\n%s\n\n", jiraURL)) + } + + // 如果提供了描述,添加到 body 中 + if prDesc != "" { + body.WriteString("---\n\n") + body.WriteString(prDesc) + body.WriteString("\n") } return body.String() @@ -520,11 +530,42 @@ func truncateString(s string, maxLen int) string { return s[:maxLen-3] + "..." } +func capitalizeFirst(s string) string { + if len(s) == 0 { + return s + } + return strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) +} + +func ensureTitlePrefix(title, prType string) string { + if prType == "" { + return title + } + + // 检查标题是否已经有类型前缀 + prTypeLower := strings.ToLower(prType) + titleLower := strings.ToLower(title) + + // 检查各种可能的格式 + if strings.HasPrefix(titleLower, prTypeLower+":") || + strings.HasPrefix(titleLower, capitalizeFirst(prType)+":") { + // 已经有前缀,确保格式正确(首字母大写) + parts := strings.SplitN(title, ":", 2) + if len(parts) == 2 { + return fmt.Sprintf("%s:%s", capitalizeFirst(prType), parts[1]) + } + return title + } + + // 没有前缀,添加 + return fmt.Sprintf("%s: %s", capitalizeFirst(prType), title) +} + func generateSimpleTitle(jiraSummary, prType, description string) string { // 如果有简短描述,使用描述 if description != "" { if prType != "" { - return fmt.Sprintf("%s: %s", prType, description) + return fmt.Sprintf("%s: %s", capitalizeFirst(prType), description) } return description } @@ -536,7 +577,7 @@ func generateSimpleTitle(jiraSummary, prType, description string) string { } if prType != "" { - return fmt.Sprintf("%s: %s", prType, summary) + return fmt.Sprintf("%s: %s", capitalizeFirst(prType), summary) } return summary } From f4a3b52b6daceec80566909cdaa2ddd76fb2c547 Mon Sep 17 00:00:00 2001 From: wangym Date: Mon, 26 Jan 2026 16:18:43 +0800 Subject: [PATCH 3/3] # Refactor: Fix PR title extraction and optimize body generation --- go-version/cmd/qkflow/commands/pr_create.go | 24 +++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/go-version/cmd/qkflow/commands/pr_create.go b/go-version/cmd/qkflow/commands/pr_create.go index ee7a1db..6964c40 100644 --- a/go-version/cmd/qkflow/commands/pr_create.go +++ b/go-version/cmd/qkflow/commands/pr_create.go @@ -175,13 +175,21 @@ func runPRCreate(cmd *cobra.Command, args []string) { lines := strings.Split(strings.TrimSpace(prDesc), "\n") var titleText string - // 查找 "## Summary" 后面的内容,或者使用第一行 + // 查找 "## Summary" 后面的第一个非空行 for i, line := range lines { line = strings.TrimSpace(line) - if strings.HasPrefix(line, "## Summary") && i+1 < len(lines) { - // 使用 Summary 后面的第一行 - titleText = strings.TrimSpace(lines[i+1]) - break + if strings.HasPrefix(line, "## Summary") { + // 跳过 Summary 行和后续的空行,找到第一个非空行 + for j := i + 1; j < len(lines); j++ { + nextLine := strings.TrimSpace(lines[j]) + if nextLine != "" && !strings.HasPrefix(nextLine, "#") { + titleText = nextLine + break + } + } + if titleText != "" { + break + } } else if !strings.HasPrefix(line, "#") && line != "" && titleText == "" { // 使用第一个非标题行 titleText = line @@ -468,7 +476,11 @@ func buildPRBody(types []string, jiraTicket string, prDesc string) string { body.WriteString("# PR Ready\n\n") - if len(types) > 0 { + // 检查 prDesc 中是否已经包含 "Types of changes" 部分 + hasTypesInDesc := strings.Contains(prDesc, "## Types of changes") || strings.Contains(prDesc, "Types of changes") + + if len(types) > 0 && !hasTypesInDesc { + // prDesc 中没有 Types of changes,自动添加 body.WriteString("## Types of changes\n\n") for _, t := range types { body.WriteString(fmt.Sprintf("- [x] %s\n", t))