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) {
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,
"Concurrency": concurrency,
})
}
+16 -1
View File
@@ -41,6 +41,7 @@ func (h *ReviewHandler) Review(c *gin.Context) {
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
+1
View File
@@ -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",
}
+57 -33
View File
@@ -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,21 +108,45 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback
})
}
// Review each file
var fileReviews []FileReview
for i, file := range files {
// 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 {
callback("file_start", map[string]interface{}{
"file": file.Filename,
"index": i + 1,
safeCallback = func(event string, data interface{}) {
mu.Lock()
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,
})
}
changeLines := countDiffLines(file.Patch)
changeLines := countDiffLines(f.Patch)
// Truncate per-file diff if too large
patch := file.Patch
patch := f.Patch
if len(patch) > 30000 {
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: 提示信息(最佳实践、可选优化)
如果代码没有问题,输出空数组 []。
请用中文回复。`, 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)
fullResponse, err := ChatStream(config, messages, safeCallback)
if err != nil {
// Continue with other files on error
if callback != nil {
callback("error", map[string]interface{}{
"file": file.Filename,
if safeCallback != nil {
safeCallback("error", map[string]interface{}{
"file": f.Filename,
"message": err.Error(),
})
}
fileReviews = append(fileReviews, FileReview{
FileName: file.Filename,
fileReviews[idx] = FileReview{
FileName: f.Filename,
ChangeLines: changeLines,
Suggestions: nil,
RawReview: fmt.Sprintf("Error: %s", err.Error()),
})
continue
}
return
}
// 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,
@@ -194,12 +216,11 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback
}
// Validate severity values
for i := range suggestions {
switch suggestions[i].Severity {
for j := range suggestions {
switch suggestions[j].Severity {
case "critical", "warning", "info":
// valid
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 != "" {
content += "\n\n```\n" + s.CodeExample + "\n```"
}
if callback != nil {
callback("suggestion", map[string]interface{}{
"file": file.Filename,
if safeCallback != nil {
safeCallback("suggestion", map[string]interface{}{
"file": f.Filename,
"severity": s.Severity,
"content": content,
})
}
}
fileReviews = append(fileReviews, FileReview{
FileName: file.Filename,
fileReviews[idx] = FileReview{
FileName: f.Filename,
ChangeLines: changeLines,
Suggestions: suggestions,
RawReview: fullResponse,
})
}
if callback != nil {
callback("file_end", map[string]interface{}{"file": file.Filename})
if safeCallback != nil {
safeCallback("file_end", map[string]interface{}{"file": f.Filename})
}
}(i, file)
}
wg.Wait()
// Generate summary
var summary ReviewSummary
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>
<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>
<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>
@@ -37,6 +37,12 @@
placeholder="留空=全部">
<p class="text-xs text-gray-400 mt-1">设为 0 分析全部文件</p>
</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>
<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 currentFile = '';
const concurrency = document.getElementById('concurrency').value;
currentSSE = SSE.post(`/api/repos/${repoId}/review`, {
base: baseRef,
head: headRef,
top_n: parseInt(topN) || 0,
concurrency: parseInt(concurrency) || 5,
}, {
start(data) {
totalFiles = data.total_files || 0;
+8
View File
@@ -32,12 +32,20 @@
<!-- Review Settings -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold mb-4">审查配置</h2>
<div class="space-y-4">
<div>
<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>
<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">
</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>
<!-- Cache Settings -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">