服务端: - prompt 改为要求直接输出 Markdown,不再输出 JSON - GeneratePR 返回 string 而非 PRDescription 结构体 - 移除 JSON 解析、extractJSON、fixInlineCode 等逻辑 - ChatStream 的 content 事件直接携带 Markdown 片段流式传输 前端: - generate() 仅处理 content + done + error 三个事件 - content 事件累积 Markdown 文本,实时渲染到两栏 - 移除 parsePartialJSON、buildMarkdown 等中间层 - 代码量从 ~100 行减至 ~50 行
This commit is contained in:
@@ -75,19 +75,15 @@ func (h *GenerateHandler) Generate(c *gin.Context) {
|
|||||||
// Update last_used
|
// Update last_used
|
||||||
h.db.Exec(`UPDATE repositories SET last_used = NOW() WHERE id = ?`, id)
|
h.db.Exec(`UPDATE repositories SET last_used = NOW() WHERE id = ?`, id)
|
||||||
|
|
||||||
// Generate PR description (pass user ID for per-user LLM config)
|
// Generate PR description (streams markdown via content events)
|
||||||
pr, err := services.GeneratePR(h.db, localPath, req.Base, req.Head, user.ID, sendEvent)
|
markdown, err := services.GeneratePR(h.db, localPath, req.Base, req.Head, user.ID, 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save analysis to DB with user_id
|
// Save analysis to DB
|
||||||
resultJSON, err := json.Marshal(pr)
|
resultJSON, _ := json.Marshal(map[string]string{"markdown": markdown})
|
||||||
if err != nil {
|
|
||||||
sendEvent("error", map[string]interface{}{"message": "序列化结果失败: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := h.db.Exec(`INSERT INTO analyses (user_id, repo_id, type, base_ref, head_ref, result) VALUES (?, ?, 'pr_description', ?, ?, ?)`,
|
if _, err := h.db.Exec(`INSERT INTO analyses (user_id, repo_id, type, base_ref, head_ref, result) VALUES (?, ?, 'pr_description', ?, ?, ?)`,
|
||||||
user.ID, id, req.Base, req.Head, string(resultJSON)); err != nil {
|
user.ID, id, req.Base, req.Head, string(resultJSON)); err != nil {
|
||||||
sendEvent("error", map[string]interface{}{"message": "保存分析结果失败: " + err.Error()})
|
sendEvent("error", map[string]interface{}{"message": "保存分析结果失败: " + err.Error()})
|
||||||
|
|||||||
+30
-66
@@ -2,9 +2,7 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-git/go-git/v5/plumbing"
|
"github.com/go-git/go-git/v5/plumbing"
|
||||||
@@ -12,34 +10,25 @@ import (
|
|||||||
goopenai "github.com/sashabaranov/go-openai"
|
goopenai "github.com/sashabaranov/go-openai"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PRDescription is the structured output from PR description generation.
|
// GeneratePR generates a PR description as Markdown from commit history and diff.
|
||||||
type PRDescription struct {
|
// It streams the LLM output via callback ("content" events) and returns the full markdown.
|
||||||
Title string `json:"title"`
|
func GeneratePR(db *sql.DB, repoPath, base, head string, userID int64, callback StreamCallback) (string, error) {
|
||||||
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)
|
// Read LLM config (per-user)
|
||||||
config, err := GetLLMConfig(db, userID)
|
config, err := GetLLMConfig(db, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open repo
|
// Open repo
|
||||||
repo, err := OpenRepo(repoPath)
|
repo, err := OpenRepo(repoPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open repo: %w", err)
|
return "", fmt.Errorf("open repo: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get commits between base and head
|
// Get commits between base and head
|
||||||
commits, err := GetCommitLog(repo, head, 100)
|
commits, err := GetCommitLog(repo, head, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get commits: %w", err)
|
return "", fmt.Errorf("get commits: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter commits to only those reachable from head but not from base
|
// Filter commits to only those reachable from head but not from base
|
||||||
@@ -78,7 +67,7 @@ func GeneratePR(db *sql.DB, repoPath, base, head string, userID int64, callback
|
|||||||
// Get diff
|
// Get diff
|
||||||
diff, err := GetDiff(repo, base, head)
|
diff, err := GetDiff(repo, base, head)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get diff: %w", err)
|
return "", fmt.Errorf("get diff: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build prompt
|
// Build prompt
|
||||||
@@ -97,7 +86,7 @@ func GeneratePR(db *sql.DB, repoPath, base, head string, userID int64, callback
|
|||||||
diff = diff[:60000] + "\n\n... [diff truncated due to size]"
|
diff = diff[:60000] + "\n\n... [diff truncated due to size]"
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt := fmt.Sprintf(`你是一个专业的技术文档撰写助手。根据以下 Git 变更信息,生成一份结构化的 PR 描述。
|
prompt := fmt.Sprintf(`你是一个专业的技术文档撰写助手。根据以下 Git 变更信息,生成一份 PR 描述。
|
||||||
|
|
||||||
## Commit 记录
|
## Commit 记录
|
||||||
%s
|
%s
|
||||||
@@ -105,65 +94,40 @@ func GeneratePR(db *sql.DB, repoPath, base, head string, userID int64, callback
|
|||||||
## 代码变更 (Diff)
|
## 代码变更 (Diff)
|
||||||
%s
|
%s
|
||||||
|
|
||||||
请按以下 JSON 格式输出(直接输出 JSON,不要包含 markdown 代码块标记):
|
请直接输出 Markdown 格式的 PR 描述,包含以下部分:
|
||||||
{
|
|
||||||
"title": "简洁的 PR 标题",
|
|
||||||
"type": "变更类型: feat|fix|refactor|docs|chore|style|test|perf",
|
|
||||||
"summary": "一段话概述变更内容",
|
|
||||||
"details": "详细的变更说明,按模块分组,使用 Markdown 格式",
|
|
||||||
"impact": "影响范围说明"
|
|
||||||
}
|
|
||||||
|
|
||||||
重要格式要求:
|
# 标题
|
||||||
- details 字段中引用文件名、函数名、变量名等代码标识时,必须用反引号包裹,例如:`+"`services/generate.go`"+`, `+"`GeneratePR()`"+`
|
|
||||||
- 直接写出实际的代码名称,不要用任何占位符替代`, commitStr, diff)
|
**类型**: feat|fix|refactor|docs|chore|style|test|perf
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
一段话概述变更内容
|
||||||
|
|
||||||
|
## 详细说明
|
||||||
|
按模块分组的详细变更说明
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
影响范围说明
|
||||||
|
|
||||||
|
格式要求:
|
||||||
|
- 引用文件名、函数名、变量名等代码标识时,必须用反引号包裹
|
||||||
|
- 直接写出实际的代码名称,不要用任何占位符替代
|
||||||
|
- 不要输出 JSON,直接输出 Markdown`, commitStr, diff)
|
||||||
|
|
||||||
messages := []goopenai.ChatCompletionMessage{
|
messages := []goopenai.ChatCompletionMessage{
|
||||||
{Role: goopenai.ChatMessageRoleUser, Content: prompt},
|
{Role: goopenai.ChatMessageRoleUser, Content: prompt},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call LLM with streaming
|
// Call LLM with streaming — content events carry markdown chunks in real-time
|
||||||
fullResponse, err := ChatStream(config, messages, callback)
|
fullResponse, err := ChatStream(config, messages, callback)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("LLM call: %w", err)
|
return "", fmt.Errorf("LLM call: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse JSON response
|
// Signal completion
|
||||||
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 {
|
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": ""})
|
callback("done", map[string]interface{}{"content": ""})
|
||||||
}
|
}
|
||||||
|
|
||||||
return &pr, nil
|
return fullResponse, 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] + "..."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,85 +184,27 @@
|
|||||||
indicator.classList.remove('hidden');
|
indicator.classList.remove('hidden');
|
||||||
|
|
||||||
// Clear previous results
|
// Clear previous results
|
||||||
document.getElementById('pr-rendered').innerHTML = '<span class="animate-pulse text-gray-400">正在生成...</span>';
|
document.getElementById('pr-rendered').innerHTML = '';
|
||||||
document.getElementById('pr-markdown').textContent = '';
|
document.getElementById('pr-markdown').textContent = '';
|
||||||
markdownContent = '';
|
markdownContent = '';
|
||||||
|
|
||||||
const fields = { title: '', type: '', summary: '', details: '', impact: '' };
|
// LLM outputs markdown directly — just accumulate and render
|
||||||
let rawText = '';
|
|
||||||
let done = false;
|
|
||||||
|
|
||||||
function updateColumns() {
|
|
||||||
markdownContent = buildMarkdown(fields);
|
|
||||||
document.getElementById('pr-rendered').innerHTML = renderMarkdown(markdownContent);
|
|
||||||
document.getElementById('pr-markdown').textContent = markdownContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to extract completed fields from partial JSON
|
|
||||||
function parsePartialJSON(text) {
|
|
||||||
const result = {};
|
|
||||||
for (const key of ['title', 'type', 'summary', 'details', 'impact']) {
|
|
||||||
// Match "key": "value" where value's closing quote exists
|
|
||||||
const re = new RegExp('"' + key + '"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"');
|
|
||||||
const m = text.match(re);
|
|
||||||
if (m) result[key] = m[1].replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\\\/g, '\\');
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
SSE.post(`/api/repos/${repoId}/generate`, {
|
SSE.post(`/api/repos/${repoId}/generate`, {
|
||||||
base: baseRef,
|
base: baseRef,
|
||||||
head: headRef,
|
head: headRef,
|
||||||
}, {
|
}, {
|
||||||
content(data) {
|
content(data) {
|
||||||
if (done) return;
|
markdownContent += data.content || '';
|
||||||
rawText += data.content || '';
|
document.getElementById('pr-rendered').innerHTML = Markdown.render(markdownContent);
|
||||||
// Parse partial JSON to extract fields progressively
|
document.getElementById('pr-markdown').textContent = markdownContent;
|
||||||
const partial = parsePartialJSON(rawText);
|
|
||||||
let changed = false;
|
|
||||||
for (const key of ['title', 'type', 'summary', 'details', 'impact']) {
|
|
||||||
if (partial[key] && partial[key] !== fields[key]) {
|
|
||||||
fields[key] = partial[key];
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed) updateColumns();
|
|
||||||
},
|
|
||||||
title(data) {
|
|
||||||
fields.title = data.content || fields.title;
|
|
||||||
updateColumns();
|
|
||||||
document.getElementById('streaming-status').textContent = '解析标题...';
|
|
||||||
},
|
|
||||||
type(data) {
|
|
||||||
fields.type = data.content || fields.type;
|
|
||||||
updateColumns();
|
|
||||||
document.getElementById('streaming-status').textContent = '解析类型...';
|
|
||||||
},
|
|
||||||
summary(data) {
|
|
||||||
fields.summary = data.content || fields.summary;
|
|
||||||
updateColumns();
|
|
||||||
document.getElementById('streaming-status').textContent = '解析摘要...';
|
|
||||||
},
|
|
||||||
detail(data) {
|
|
||||||
fields.details = data.content || fields.details;
|
|
||||||
updateColumns();
|
|
||||||
document.getElementById('streaming-status').textContent = '解析详细说明...';
|
|
||||||
},
|
|
||||||
impact(data) {
|
|
||||||
fields.impact = data.content || fields.impact;
|
|
||||||
updateColumns();
|
|
||||||
document.getElementById('streaming-status').textContent = '解析影响范围...';
|
|
||||||
},
|
},
|
||||||
done() {
|
done() {
|
||||||
done = true;
|
|
||||||
updateColumns();
|
|
||||||
indicator.classList.add('hidden');
|
indicator.classList.add('hidden');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = '生成 PR 描述';
|
btn.textContent = '生成 PR 描述';
|
||||||
btn.classList.remove('opacity-70', 'cursor-not-allowed');
|
btn.classList.remove('opacity-70', 'cursor-not-allowed');
|
||||||
},
|
},
|
||||||
error(data) {
|
error(data) {
|
||||||
done = true;
|
|
||||||
showToast('生成失败: ' + (data.message || '未知错误'), 'error');
|
showToast('生成失败: ' + (data.message || '未知错误'), 'error');
|
||||||
indicator.classList.add('hidden');
|
indicator.classList.add('hidden');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@@ -272,21 +214,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Markdown rendering (delegates to shared Markdown.render)
|
|
||||||
function renderMarkdown(text) {
|
|
||||||
return Markdown.render(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMarkdown(f) {
|
|
||||||
const parts = [];
|
|
||||||
if (f.title) parts.push(`# ${f.title}`);
|
|
||||||
if (f.type) parts.push(`**类型**: ${f.type}`);
|
|
||||||
if (f.summary) parts.push(`## 摘要\n${f.summary}`);
|
|
||||||
if (f.details) parts.push(`## 详细说明\n${f.details}`);
|
|
||||||
if (f.impact) parts.push(`## 影响\n${f.impact}`);
|
|
||||||
return parts.join('\n\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyMarkdown() {
|
function copyMarkdown() {
|
||||||
const markdown = markdownContent || document.getElementById('pr-markdown').textContent;
|
const markdown = markdownContent || document.getElementById('pr-markdown').textContent;
|
||||||
if (!markdown.trim()) {
|
if (!markdown.trim()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user