diff --git a/handlers/generate.go b/handlers/generate.go index 3b9a2ca..fed97e2 100644 --- a/handlers/generate.go +++ b/handlers/generate.go @@ -75,19 +75,15 @@ func (h *GenerateHandler) Generate(c *gin.Context) { // Update last_used h.db.Exec(`UPDATE repositories SET last_used = NOW() WHERE id = ?`, id) - // Generate PR description (pass user ID for per-user LLM config) - pr, err := services.GeneratePR(h.db, localPath, req.Base, req.Head, user.ID, sendEvent) + // Generate PR description (streams markdown via content events) + markdown, err := services.GeneratePR(h.db, localPath, req.Base, req.Head, user.ID, sendEvent) if err != nil { sendEvent("error", map[string]interface{}{"message": err.Error()}) return } - // Save analysis to DB with user_id - resultJSON, err := json.Marshal(pr) - if err != nil { - sendEvent("error", map[string]interface{}{"message": "序列化结果失败: " + err.Error()}) - return - } + // Save analysis to DB + resultJSON, _ := json.Marshal(map[string]string{"markdown": markdown}) 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 { sendEvent("error", map[string]interface{}{"message": "保存分析结果失败: " + err.Error()}) diff --git a/services/generate.go b/services/generate.go index 56a15f8..204f9e3 100644 --- a/services/generate.go +++ b/services/generate.go @@ -2,9 +2,7 @@ package services import ( "database/sql" - "encoding/json" "fmt" - "regexp" "strings" "github.com/go-git/go-git/v5/plumbing" @@ -12,34 +10,25 @@ import ( 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) { +// GeneratePR generates a PR description as Markdown from commit history and diff. +// It streams the LLM output via callback ("content" events) and returns the full markdown. +func GeneratePR(db *sql.DB, repoPath, base, head string, userID int64, callback StreamCallback) (string, error) { // Read LLM config (per-user) config, err := GetLLMConfig(db, userID) if err != nil { - return nil, err + return "", err } // Open repo repo, err := OpenRepo(repoPath) if err != nil { - return nil, fmt.Errorf("open repo: %w", err) + return "", 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) + return "", fmt.Errorf("get commits: %w", err) } // 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 diff, err := GetDiff(repo, base, head) if err != nil { - return nil, fmt.Errorf("get diff: %w", err) + return "", fmt.Errorf("get diff: %w", err) } // 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]" } - prompt := fmt.Sprintf(`你是一个专业的技术文档撰写助手。根据以下 Git 变更信息,生成一份结构化的 PR 描述。 + prompt := fmt.Sprintf(`你是一个专业的技术文档撰写助手。根据以下 Git 变更信息,生成一份 PR 描述。 ## Commit 记录 %s @@ -105,65 +94,40 @@ func GeneratePR(db *sql.DB, repoPath, base, head string, userID int64, callback ## 代码变更 (Diff) %s -请按以下 JSON 格式输出(直接输出 JSON,不要包含 markdown 代码块标记): -{ - "title": "简洁的 PR 标题", - "type": "变更类型: feat|fix|refactor|docs|chore|style|test|perf", - "summary": "一段话概述变更内容", - "details": "详细的变更说明,按模块分组,使用 Markdown 格式", - "impact": "影响范围说明" -} +请直接输出 Markdown 格式的 PR 描述,包含以下部分: -重要格式要求: -- details 字段中引用文件名、函数名、变量名等代码标识时,必须用反引号包裹,例如:`+"`services/generate.go`"+`, `+"`GeneratePR()`"+` -- 直接写出实际的代码名称,不要用任何占位符替代`, commitStr, diff) +# 标题 + +**类型**: feat|fix|refactor|docs|chore|style|test|perf + +## 概述 +一段话概述变更内容 + +## 详细说明 +按模块分组的详细变更说明 + +## 影响范围 +影响范围说明 + +格式要求: +- 引用文件名、函数名、变量名等代码标识时,必须用反引号包裹 +- 直接写出实际的代码名称,不要用任何占位符替代 +- 不要输出 JSON,直接输出 Markdown`, commitStr, diff) messages := []goopenai.ChatCompletionMessage{ {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) if err != nil { - return nil, fmt.Errorf("LLM call: %w", err) + return "", 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 + // Signal completion 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] + "..." + return fullResponse, nil } diff --git a/templates/pages/generate.html b/templates/pages/generate.html index 95b4bc0..d0aff29 100644 --- a/templates/pages/generate.html +++ b/templates/pages/generate.html @@ -184,85 +184,27 @@ indicator.classList.remove('hidden'); // Clear previous results - document.getElementById('pr-rendered').innerHTML = '正在生成...'; + document.getElementById('pr-rendered').innerHTML = ''; document.getElementById('pr-markdown').textContent = ''; markdownContent = ''; - const fields = { title: '', type: '', summary: '', details: '', impact: '' }; - 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; - } - + // LLM outputs markdown directly — just accumulate and render SSE.post(`/api/repos/${repoId}/generate`, { base: baseRef, head: headRef, }, { content(data) { - if (done) return; - rawText += data.content || ''; - // Parse partial JSON to extract fields progressively - 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 = '解析影响范围...'; + markdownContent += data.content || ''; + document.getElementById('pr-rendered').innerHTML = Markdown.render(markdownContent); + document.getElementById('pr-markdown').textContent = markdownContent; }, done() { - done = true; - updateColumns(); indicator.classList.add('hidden'); btn.disabled = false; btn.textContent = '生成 PR 描述'; btn.classList.remove('opacity-70', 'cursor-not-allowed'); }, error(data) { - done = true; showToast('生成失败: ' + (data.message || '未知错误'), 'error'); indicator.classList.add('hidden'); 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() { const markdown = markdownContent || document.getElementById('pr-markdown').textContent; if (!markdown.trim()) {