diff --git a/handlers/pages.go b/handlers/pages.go index 1e8cd1d..0337143 100644 --- a/handlers/pages.go +++ b/handlers/pages.go @@ -39,9 +39,12 @@ func (h *PageHandler) Generate(c *gin.Context) { func (h *PageHandler) Review(c *gin.Context) { topN := "20" h.db.QueryRow(`SELECT value FROM settings WHERE key = 'review.top_n'`).Scan(&topN) + concurrency := "5" + h.db.QueryRow(`SELECT value FROM settings WHERE key = 'review.concurrency'`).Scan(&concurrency) c.HTML(http.StatusOK, "pages/review.html", gin.H{ - "ID": c.Param("id"), - "TopN": topN, + "ID": c.Param("id"), + "TopN": topN, + "Concurrency": concurrency, }) } diff --git a/handlers/review.go b/handlers/review.go index 60aab41..8c1a602 100644 --- a/handlers/review.go +++ b/handlers/review.go @@ -38,9 +38,10 @@ func (h *ReviewHandler) Review(c *gin.Context) { // Parse request var req struct { - Base string `json:"base" binding:"required"` - Head string `json:"head" binding:"required"` - TopN *int `json:"top_n"` + Base string `json:"base" binding:"required"` + Head string `json:"head" binding:"required"` + TopN *int `json:"top_n"` + Concurrency *int `json:"concurrency"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "base and head are required"}) @@ -61,6 +62,20 @@ func (h *ReviewHandler) Review(c *gin.Context) { } } + // Determine concurrency: request value > settings default (5) + concurrency := 5 + if req.Concurrency != nil { + concurrency = *req.Concurrency + } else { + var concStr string + h.db.QueryRow(`SELECT value FROM settings WHERE key = 'review.concurrency'`).Scan(&concStr) + if concStr != "" { + if n, err := strconv.Atoi(concStr); err == nil && n > 0 { + concurrency = n + } + } + } + // Set SSE headers c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") @@ -84,7 +99,7 @@ func (h *ReviewHandler) Review(c *gin.Context) { h.db.Exec(`UPDATE repositories SET last_used = datetime('now') WHERE id = ?`, id) // Run AI review - reviewResult, err := services.GenerateReview(h.db, localPath, req.Base, req.Head, topN, sendEvent) + reviewResult, err := services.GenerateReview(h.db, localPath, req.Base, req.Head, topN, concurrency, sendEvent) if err != nil { sendEvent("error", map[string]interface{}{"message": err.Error()}) return diff --git a/models/settings.go b/models/settings.go index 9f44eeb..fd5c428 100644 --- a/models/settings.go +++ b/models/settings.go @@ -5,6 +5,7 @@ var DefaultSettings = map[string]string{ "llm.api_key": "", "llm.model": "gpt-4o", "review.top_n": "20", + "review.concurrency": "5", "cache.max_age_days": "7", "cache.max_size_mb": "5000", } diff --git a/services/review.go b/services/review.go index 6558b65..ca1d852 100644 --- a/services/review.go +++ b/services/review.go @@ -6,6 +6,7 @@ import ( "fmt" "sort" "strings" + "sync" goopenai "github.com/sashabaranov/go-openai" ) @@ -57,7 +58,7 @@ func countDiffLines(patch string) int { // GenerateReview performs AI code review on diff files with Top-N strategy. // It streams events (file_start, suggestion, file_end, summary, done) via callback // and returns the complete ReviewResult for persistence. -func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback StreamCallback) (*ReviewResult, error) { +func GenerateReview(db *sql.DB, repoPath, base, head string, topN, concurrency int, callback StreamCallback) (*ReviewResult, error) { // Read LLM config config, err := GetLLMConfig(db) if err != nil { @@ -107,27 +108,51 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback }) } - // Review each file - var fileReviews []FileReview + // Review each file concurrently (max 5 workers) + fileReviews := make([]FileReview, reviewedFiles) + var mu sync.Mutex + if concurrency < 1 { + concurrency = 1 + } + sem := make(chan struct{}, concurrency) + var wg sync.WaitGroup + + // Thread-safe callback wrapper — all SSE writes go through this + safeCallback := callback + if callback != nil { + safeCallback = func(event string, data interface{}) { + mu.Lock() + defer mu.Unlock() + callback(event, data) + } + } + for i, file := range files { - if callback != nil { - callback("file_start", map[string]interface{}{ - "file": file.Filename, - "index": i + 1, - "total": reviewedFiles, - }) - } + wg.Add(1) + go func(idx int, f FileDiff) { + defer wg.Done() + sem <- struct{}{} // acquire slot + defer func() { <-sem }() // release slot - changeLines := countDiffLines(file.Patch) + // Send file_start (order doesn't matter, frontend tracks by filename) + if safeCallback != nil { + safeCallback("file_start", map[string]interface{}{ + "file": f.Filename, + "index": idx + 1, + "total": reviewedFiles, + }) + } - // Truncate per-file diff if too large - patch := file.Patch - if len(patch) > 30000 { - patch = patch[:30000] + "\n\n... [diff truncated due to size]" - } + changeLines := countDiffLines(f.Patch) - // Build review prompt - prompt := fmt.Sprintf(`你是一个资深代码审查专家。请审查以下代码变更,给出专业的 Review 意见。 + // Truncate per-file diff if too large + patch := f.Patch + if len(patch) > 30000 { + patch = patch[:30000] + "\n\n... [diff truncated due to size]" + } + + // Build review prompt + prompt := fmt.Sprintf(`你是一个资深代码审查专家。请审查以下代码变更,给出专业的 Review 意见。 ## 文件: %s ## 变更行数: +%d / -%d @@ -151,88 +176,87 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback - info: 提示信息(最佳实践、可选优化) 如果代码没有问题,输出空数组 []。 -请用中文回复。`, file.Filename, changeLines/2+changeLines%2, changeLines/2, patch) +请用中文回复。`, f.Filename, changeLines/2+changeLines%2, changeLines/2, patch) - messages := []goopenai.ChatCompletionMessage{ - {Role: goopenai.ChatMessageRoleUser, Content: prompt}, - } - - // Call LLM - fullResponse, err := ChatStream(config, messages, callback) - if err != nil { - // Continue with other files on error - if callback != nil { - callback("error", map[string]interface{}{ - "file": file.Filename, - "message": err.Error(), - }) + messages := []goopenai.ChatCompletionMessage{ + {Role: goopenai.ChatMessageRoleUser, Content: prompt}, } - fileReviews = append(fileReviews, FileReview{ - FileName: file.Filename, + + // Call LLM + fullResponse, err := ChatStream(config, messages, safeCallback) + if err != nil { + if safeCallback != nil { + safeCallback("error", map[string]interface{}{ + "file": f.Filename, + "message": err.Error(), + }) + } + fileReviews[idx] = FileReview{ + FileName: f.Filename, + ChangeLines: changeLines, + Suggestions: nil, + RawReview: fmt.Sprintf("Error: %s", err.Error()), + } + return + } + + // Parse suggestions + jsonStr := extractJSON(fullResponse) + var suggestions []ReviewSuggestion + if err := json.Unmarshal([]byte(jsonStr), &suggestions); err != nil { + var single ReviewSuggestion + if err2 := json.Unmarshal([]byte(jsonStr), &single); err2 == nil { + suggestions = []ReviewSuggestion{single} + } else { + suggestions = []ReviewSuggestion{{ + Severity: "info", + Description: fullResponse, + }} + } + } + + // Validate severity values + for j := range suggestions { + switch suggestions[j].Severity { + case "critical", "warning", "info": + default: + suggestions[j].Severity = "info" + } + } + + // Send suggestion events + for _, s := range suggestions { + content := s.Description + if s.Suggestion != "" { + content += "\n\n**建议修改:** " + s.Suggestion + } + if s.CodeExample != "" { + content += "\n\n```\n" + s.CodeExample + "\n```" + } + if safeCallback != nil { + safeCallback("suggestion", map[string]interface{}{ + "file": f.Filename, + "severity": s.Severity, + "content": content, + }) + } + } + + fileReviews[idx] = FileReview{ + FileName: f.Filename, ChangeLines: changeLines, - Suggestions: nil, - RawReview: fmt.Sprintf("Error: %s", err.Error()), - }) - continue - } - - // Parse suggestions - jsonStr := extractJSON(fullResponse) - var suggestions []ReviewSuggestion - if err := json.Unmarshal([]byte(jsonStr), &suggestions); err != nil { - // If parsing fails, try single object - var single ReviewSuggestion - if err2 := json.Unmarshal([]byte(jsonStr), &single); err2 == nil { - suggestions = []ReviewSuggestion{single} - } else { - // Fall back to raw text as info suggestion - suggestions = []ReviewSuggestion{{ - Severity: "info", - Description: fullResponse, - }} + Suggestions: suggestions, + RawReview: fullResponse, } - } - // Validate severity values - for i := range suggestions { - switch suggestions[i].Severity { - case "critical", "warning", "info": - // valid - default: - suggestions[i].Severity = "info" + if safeCallback != nil { + safeCallback("file_end", map[string]interface{}{"file": f.Filename}) } - } - - // Send suggestion events - for _, s := range suggestions { - content := s.Description - if s.Suggestion != "" { - content += "\n\n**建议修改:** " + s.Suggestion - } - if s.CodeExample != "" { - content += "\n\n```\n" + s.CodeExample + "\n```" - } - if callback != nil { - callback("suggestion", map[string]interface{}{ - "file": file.Filename, - "severity": s.Severity, - "content": content, - }) - } - } - - fileReviews = append(fileReviews, FileReview{ - FileName: file.Filename, - ChangeLines: changeLines, - Suggestions: suggestions, - RawReview: fullResponse, - }) - - if callback != nil { - callback("file_end", map[string]interface{}{"file": file.Filename}) - } + }(i, file) } + wg.Wait() + // Generate summary var summary ReviewSummary if len(fileReviews) > 0 { diff --git a/templates/pages/review.html b/templates/pages/review.html index 2351db3..c769b5e 100644 --- a/templates/pages/review.html +++ b/templates/pages/review.html @@ -18,7 +18,7 @@
同时分析的文件数 (1-20)
+