Files
PR-Helper/services/generate.go
T
wonder 4a8e43e698
Deploy PR-Helper / deploy (push) Successful in 26s
fix: 修复 PR 生成页 INLINECODE 显示及复制 Markdown 失败问题
- 改进 LLM 提示词,明确要求用反引号包裹代码引用,避免输出 INLINECODE 占位符
- 后端新增 fixInlineCode 后处理,将残留 INLINECODE 占位符转为反引号代码
- 前端在 done 回调中从结构化字段组装完整 markdown 文本,修复复制按钮复制空内容
- 复制按钮增加空内容检查和错误提示
- Markdown 预览区域初始隐藏,生成完成后自动显示
2026-06-21 15:37:10 +08:00

170 lines
5.0 KiB
Go

package services
import (
"database/sql"
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
goopenai "github.com/sashabaranov/go-openai"
)
// PRDescription is the structured output from PR description generation.
type PRDescription struct {
Title string `json:"title"`
Type string `json:"type"`
Summary string `json:"summary"`
Details string `json:"details"`
Impact string `json:"impact"`
}
// GeneratePR generates a structured PR description from commit history and diff.
// It streams progress via the callback and returns the parsed PR description.
func GeneratePR(db *sql.DB, repoPath, base, head string, userID int64, callback StreamCallback) (*PRDescription, error) {
// Read LLM config (per-user)
config, err := GetLLMConfig(db, userID)
if err != nil {
return nil, err
}
// Open repo
repo, err := OpenRepo(repoPath)
if err != nil {
return nil, fmt.Errorf("open repo: %w", err)
}
// Get commits between base and head
commits, err := GetCommitLog(repo, head, 100)
if err != nil {
return nil, fmt.Errorf("get commits: %w", err)
}
// Filter commits to only those reachable from head but not from base
baseHash, err := repo.ResolveRevision(plumbing.Revision(base))
if err == nil {
baseCommit, bcErr := repo.CommitObject(*baseHash)
if bcErr == nil {
baseSet := make(map[string]bool)
baseQueue := []*object.Commit{baseCommit}
for len(baseQueue) > 0 {
c := baseQueue[0]
baseQueue = baseQueue[1:]
if baseSet[c.Hash.String()] {
continue
}
baseSet[c.Hash.String()] = true
for _, p := range c.ParentHashes {
pc, err := repo.CommitObject(p)
if err == nil {
baseQueue = append(baseQueue, pc)
}
}
}
var filtered []CommitInfo
for _, ci := range commits {
if !baseSet[ci.Hash] {
filtered = append(filtered, ci)
}
}
if len(filtered) > 0 {
commits = filtered
}
}
}
// Get diff
diff, err := GetDiff(repo, base, head)
if err != nil {
return nil, fmt.Errorf("get diff: %w", err)
}
// Build prompt
var commitLines []string
for _, c := range commits {
commitLines = append(commitLines, fmt.Sprintf("- %s %s", c.ShortHash, c.Message))
}
commitStr := strings.Join(commitLines, "\n")
if commitStr == "" {
commitStr = "(no commits)"
}
// Truncate diff if too large (approx 60k chars to stay within token limits)
if len(diff) > 60000 {
diff = diff[:60000] + "\n\n... [diff truncated due to size]"
}
prompt := fmt.Sprintf(`你是一个专业的技术文档撰写助手。根据以下 Git 变更信息,生成一份结构化的 PR 描述。
## Commit 记录
%s
## 代码变更 (Diff)
%s
请按以下 JSON 格式输出(直接输出 JSON,不要包含 markdown 代码块标记):
{
"title": "简洁的 PR 标题",
"type": "变更类型: feat|fix|refactor|docs|chore|style|test|perf",
"summary": "一段话概述变更内容",
"details": "详细的变更说明,按模块分组,使用 Markdown 格式",
"impact": "影响范围说明"
}
重要格式要求:
- details 字段中引用文件名、函数名、变量名等代码标识时,必须用反引号包裹,例如:`+"`services/generate.go`"+`, `+"`GeneratePR()`"+`
- 直接写出实际的代码名称,不要用任何占位符替代`, commitStr, diff)
messages := []goopenai.ChatCompletionMessage{
{Role: goopenai.ChatMessageRoleUser, Content: prompt},
}
// Call LLM with streaming
fullResponse, err := ChatStream(config, messages, callback)
if err != nil {
return nil, fmt.Errorf("LLM call: %w", err)
}
// Parse JSON response
jsonStr := extractJSON(fullResponse)
var pr PRDescription
if err := json.Unmarshal([]byte(jsonStr), &pr); err != nil {
return nil, fmt.Errorf("parse PR description: %w (raw: %s)", err, truncateString(fullResponse, 200))
}
// Post-process: convert INLINECODE placeholders back to backtick-enclosed code
pr.Details = fixInlineCode(pr.Details)
// Send structured events
if callback != nil {
callback("title", map[string]interface{}{"content": pr.Title})
callback("type", map[string]interface{}{"content": pr.Type})
callback("summary", map[string]interface{}{"content": pr.Summary})
callback("detail", map[string]interface{}{"content": pr.Details})
callback("impact", map[string]interface{}{"content": pr.Impact})
callback("done", map[string]interface{}{"content": ""})
}
return &pr, nil
}
// fixInlineCode converts LLM-generated INLINECODE placeholders back to backtick-enclosed inline code.
// Some LLMs output INLINECODE0, INLINECODE1, etc. instead of `code` in JSON string values.
// This wraps them in backticks so the frontend markdown renderer displays them as inline code.
func fixInlineCode(text string) string {
re := regexp.MustCompile(`(?i)INLINECODE[_]?(\d+)`)
return re.ReplaceAllStringFunc(text, func(match string) string {
return "`" + match + "`"
})
}
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}