feat: 并发文件审查,可配置并行度
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user