feat: 并发文件审查,可配置并行度

This commit is contained in:
2026-06-19 23:40:13 +08:00
parent e4df6487ea
commit a66074e49c
6 changed files with 164 additions and 104 deletions
+3
View File
@@ -39,9 +39,12 @@ func (h *PageHandler) Generate(c *gin.Context) {
func (h *PageHandler) Review(c *gin.Context) { func (h *PageHandler) Review(c *gin.Context) {
topN := "20" topN := "20"
h.db.QueryRow(`SELECT value FROM settings WHERE key = 'review.top_n'`).Scan(&topN) 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{ c.HTML(http.StatusOK, "pages/review.html", gin.H{
"ID": c.Param("id"), "ID": c.Param("id"),
"TopN": topN, "TopN": topN,
"Concurrency": concurrency,
}) })
} }
+16 -1
View File
@@ -41,6 +41,7 @@ func (h *ReviewHandler) Review(c *gin.Context) {
Base string `json:"base" binding:"required"` Base string `json:"base" binding:"required"`
Head string `json:"head" binding:"required"` Head string `json:"head" binding:"required"`
TopN *int `json:"top_n"` TopN *int `json:"top_n"`
Concurrency *int `json:"concurrency"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "base and head are required"}) 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 // Set SSE headers
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache") 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) h.db.Exec(`UPDATE repositories SET last_used = datetime('now') WHERE id = ?`, id)
// Run AI review // 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 { if err != nil {
sendEvent("error", map[string]interface{}{"message": err.Error()}) sendEvent("error", map[string]interface{}{"message": err.Error()})
return return
+1
View File
@@ -5,6 +5,7 @@ var DefaultSettings = map[string]string{
"llm.api_key": "", "llm.api_key": "",
"llm.model": "gpt-4o", "llm.model": "gpt-4o",
"review.top_n": "20", "review.top_n": "20",
"review.concurrency": "5",
"cache.max_age_days": "7", "cache.max_age_days": "7",
"cache.max_size_mb": "5000", "cache.max_size_mb": "5000",
} }
+57 -33
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
"sync"
goopenai "github.com/sashabaranov/go-openai" 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. // GenerateReview performs AI code review on diff files with Top-N strategy.
// It streams events (file_start, suggestion, file_end, summary, done) via callback // It streams events (file_start, suggestion, file_end, summary, done) via callback
// and returns the complete ReviewResult for persistence. // 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 // Read LLM config
config, err := GetLLMConfig(db) config, err := GetLLMConfig(db)
if err != nil { if err != nil {
@@ -107,21 +108,45 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback
}) })
} }
// Review each file // Review each file concurrently (max 5 workers)
var fileReviews []FileReview fileReviews := make([]FileReview, reviewedFiles)
for i, file := range files { 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 { if callback != nil {
callback("file_start", map[string]interface{}{ safeCallback = func(event string, data interface{}) {
"file": file.Filename, mu.Lock()
"index": i + 1, defer mu.Unlock()
callback(event, data)
}
}
for i, file := range files {
wg.Add(1)
go func(idx int, f FileDiff) {
defer wg.Done()
sem <- struct{}{} // acquire slot
defer func() { <-sem }() // release slot
// 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, "total": reviewedFiles,
}) })
} }
changeLines := countDiffLines(file.Patch) changeLines := countDiffLines(f.Patch)
// Truncate per-file diff if too large // Truncate per-file diff if too large
patch := file.Patch patch := f.Patch
if len(patch) > 30000 { if len(patch) > 30000 {
patch = patch[:30000] + "\n\n... [diff truncated due to size]" patch = patch[:30000] + "\n\n... [diff truncated due to size]"
} }
@@ -151,41 +176,38 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback
- info: 提示信息(最佳实践、可选优化) - info: 提示信息(最佳实践、可选优化)
如果代码没有问题,输出空数组 []。 如果代码没有问题,输出空数组 []。
请用中文回复。`, file.Filename, changeLines/2+changeLines%2, changeLines/2, patch) 请用中文回复。`, f.Filename, changeLines/2+changeLines%2, changeLines/2, patch)
messages := []goopenai.ChatCompletionMessage{ messages := []goopenai.ChatCompletionMessage{
{Role: goopenai.ChatMessageRoleUser, Content: prompt}, {Role: goopenai.ChatMessageRoleUser, Content: prompt},
} }
// Call LLM // Call LLM
fullResponse, err := ChatStream(config, messages, callback) fullResponse, err := ChatStream(config, messages, safeCallback)
if err != nil { if err != nil {
// Continue with other files on error if safeCallback != nil {
if callback != nil { safeCallback("error", map[string]interface{}{
callback("error", map[string]interface{}{ "file": f.Filename,
"file": file.Filename,
"message": err.Error(), "message": err.Error(),
}) })
} }
fileReviews = append(fileReviews, FileReview{ fileReviews[idx] = FileReview{
FileName: file.Filename, FileName: f.Filename,
ChangeLines: changeLines, ChangeLines: changeLines,
Suggestions: nil, Suggestions: nil,
RawReview: fmt.Sprintf("Error: %s", err.Error()), RawReview: fmt.Sprintf("Error: %s", err.Error()),
}) }
continue return
} }
// Parse suggestions // Parse suggestions
jsonStr := extractJSON(fullResponse) jsonStr := extractJSON(fullResponse)
var suggestions []ReviewSuggestion var suggestions []ReviewSuggestion
if err := json.Unmarshal([]byte(jsonStr), &suggestions); err != nil { if err := json.Unmarshal([]byte(jsonStr), &suggestions); err != nil {
// If parsing fails, try single object
var single ReviewSuggestion var single ReviewSuggestion
if err2 := json.Unmarshal([]byte(jsonStr), &single); err2 == nil { if err2 := json.Unmarshal([]byte(jsonStr), &single); err2 == nil {
suggestions = []ReviewSuggestion{single} suggestions = []ReviewSuggestion{single}
} else { } else {
// Fall back to raw text as info suggestion
suggestions = []ReviewSuggestion{{ suggestions = []ReviewSuggestion{{
Severity: "info", Severity: "info",
Description: fullResponse, Description: fullResponse,
@@ -194,12 +216,11 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback
} }
// Validate severity values // Validate severity values
for i := range suggestions { for j := range suggestions {
switch suggestions[i].Severity { switch suggestions[j].Severity {
case "critical", "warning", "info": case "critical", "warning", "info":
// valid
default: default:
suggestions[i].Severity = "info" suggestions[j].Severity = "info"
} }
} }
@@ -212,27 +233,30 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback
if s.CodeExample != "" { if s.CodeExample != "" {
content += "\n\n```\n" + s.CodeExample + "\n```" content += "\n\n```\n" + s.CodeExample + "\n```"
} }
if callback != nil { if safeCallback != nil {
callback("suggestion", map[string]interface{}{ safeCallback("suggestion", map[string]interface{}{
"file": file.Filename, "file": f.Filename,
"severity": s.Severity, "severity": s.Severity,
"content": content, "content": content,
}) })
} }
} }
fileReviews = append(fileReviews, FileReview{ fileReviews[idx] = FileReview{
FileName: file.Filename, FileName: f.Filename,
ChangeLines: changeLines, ChangeLines: changeLines,
Suggestions: suggestions, Suggestions: suggestions,
RawReview: fullResponse, RawReview: fullResponse,
}) }
if callback != nil { if safeCallback != nil {
callback("file_end", map[string]interface{}{"file": file.Filename}) safeCallback("file_end", map[string]interface{}{"file": f.Filename})
} }
}(i, file)
} }
wg.Wait()
// Generate summary // Generate summary
var summary ReviewSummary var summary ReviewSummary
if len(fileReviews) > 0 { if len(fileReviews) > 0 {
+10 -1
View File
@@ -18,7 +18,7 @@
<h1 class="text-2xl font-bold text-gray-900 mb-6">AI 代码审查</h1> <h1 class="text-2xl font-bold text-gray-900 mb-6">AI 代码审查</h1>
<div class="bg-white rounded-lg shadow-md p-6 mb-6"> <div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="grid grid-cols-3 gap-4 mb-4"> <div class="grid grid-cols-4 gap-4 mb-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Base</label> <label class="block text-sm font-medium text-gray-700 mb-1">Base</label>
<select id="base-ref" class="w-full border rounded-md px-3 py-2" required> <select id="base-ref" class="w-full border rounded-md px-3 py-2" required>
@@ -37,6 +37,12 @@
placeholder="留空=全部"> placeholder="留空=全部">
<p class="text-xs text-gray-400 mt-1">设为 0 分析全部文件</p> <p class="text-xs text-gray-400 mt-1">设为 0 分析全部文件</p>
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">并发分析数</label>
<input type="number" id="concurrency" value="{{.Concurrency}}" min="1" max="20" class="w-full border rounded-md px-3 py-2"
placeholder="默认 5">
<p class="text-xs text-gray-400 mt-1">同时分析的文件数 (1-20)</p>
</div>
</div> </div>
<button onclick="startReview()" id="btn-review" class="w-full bg-purple-600 text-white py-2 px-4 rounded-md hover:bg-purple-700"> <button onclick="startReview()" id="btn-review" class="w-full bg-purple-600 text-white py-2 px-4 rounded-md hover:bg-purple-700">
开始审查 开始审查
@@ -254,10 +260,13 @@
let completedFiles = 0; let completedFiles = 0;
let currentFile = ''; let currentFile = '';
const concurrency = document.getElementById('concurrency').value;
currentSSE = SSE.post(`/api/repos/${repoId}/review`, { currentSSE = SSE.post(`/api/repos/${repoId}/review`, {
base: baseRef, base: baseRef,
head: headRef, head: headRef,
top_n: parseInt(topN) || 0, top_n: parseInt(topN) || 0,
concurrency: parseInt(concurrency) || 5,
}, { }, {
start(data) { start(data) {
totalFiles = data.total_files || 0; totalFiles = data.total_files || 0;
+8
View File
@@ -32,12 +32,20 @@
<!-- Review Settings --> <!-- Review Settings -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6"> <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold mb-4">审查配置</h2> <h2 class="text-lg font-semibold mb-4">审查配置</h2>
<div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Top-N 文件数</label> <label class="block text-sm font-medium text-gray-700 mb-1">Top-N 文件数</label>
<p class="text-xs text-gray-400 mb-2">大 diff 时优先分析变更最大的 N 个文件,设为 0 分析全部</p> <p class="text-xs text-gray-400 mb-2">大 diff 时优先分析变更最大的 N 个文件,设为 0 分析全部</p>
<input type="number" name="review.top_n" value="{{index .Settings "review.top_n"}}" min="0" <input type="number" name="review.top_n" value="{{index .Settings "review.top_n"}}" min="0"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"> class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">并发分析数</label>
<p class="text-xs text-gray-400 mb-2">同时向 LLM 发送的文件评审请求数,越大越快但占用更多 API 额度</p>
<input type="number" name="review.concurrency" value="{{index .Settings "review.concurrency"}}" min="1" max="20"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
</div>
</div>
</div> </div>
<!-- Cache Settings --> <!-- Cache Settings -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6"> <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">