4a8e43e698
Deploy PR-Helper / deploy (push) Successful in 26s
- 改进 LLM 提示词,明确要求用反引号包裹代码引用,避免输出 INLINECODE 占位符 - 后端新增 fixInlineCode 后处理,将残留 INLINECODE 占位符转为反引号代码 - 前端在 done 回调中从结构化字段组装完整 markdown 文本,修复复制按钮复制空内容 - 复制按钮增加空内容检查和错误提示 - Markdown 预览区域初始隐藏,生成完成后自动显示
320 lines
14 KiB
HTML
320 lines
14 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
{{template "head" .}}
|
|
<script src="/static/js/sse.js"></script>
|
|
<script src="/static/js/markdown.js"></script>
|
|
</head>
|
|
<body class="bg-gray-50 min-h-screen">
|
|
{{template "nav" .}}
|
|
|
|
<main class="max-w-4xl mx-auto px-4 py-8">
|
|
<h1 class="text-2xl font-bold text-gray-900 mb-6">生成 PR 描述</h1>
|
|
|
|
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
|
<div class="grid grid-cols-2 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>
|
|
<option value="">加载中...</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Head</label>
|
|
<select id="head-ref" class="w-full border rounded-md px-3 py-2" required>
|
|
<option value="">加载中...</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<button onclick="generate()" id="btn-generate" class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700">
|
|
生成 PR 描述
|
|
</button>
|
|
<div id="load-error" class="hidden mt-3 p-3 bg-red-50 border border-red-200 rounded-md text-red-700 text-sm"></div>
|
|
</div>
|
|
|
|
<!-- Output -->
|
|
<div id="output" class="bg-white rounded-lg shadow-md p-6 hidden">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h2 class="text-lg font-semibold">PR 描述</h2>
|
|
<button onclick="copyMarkdown()" class="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200">
|
|
复制 Markdown
|
|
</button>
|
|
</div>
|
|
|
|
<div id="output-content">
|
|
<div class="mb-4">
|
|
<label class="block text-sm font-medium text-gray-500">标题</label>
|
|
<p id="pr-title" class="text-lg font-semibold text-gray-900"></p>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<label class="block text-sm font-medium text-gray-500">类型</label>
|
|
<span id="pr-type" class="inline-block px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm"></span>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<label class="block text-sm font-medium text-gray-500">摘要</label>
|
|
<p id="pr-summary" class="text-gray-700"></p>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<label class="block text-sm font-medium text-gray-500">详细说明</label>
|
|
<div id="pr-details" class="text-gray-700 prose text-sm"></div>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<label class="block text-sm font-medium text-gray-500">影响</label>
|
|
<p id="pr-impact" class="text-gray-700"></p>
|
|
</div>
|
|
|
|
<div id="markdown-preview" class="mt-6 p-4 bg-gray-50 rounded-md hidden">
|
|
<label class="block text-sm font-medium text-gray-500 mb-2">Markdown 预览</label>
|
|
<pre id="pr-markdown" class="whitespace-pre-wrap text-sm text-gray-800 font-mono"></pre>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Streaming indicator -->
|
|
<div id="streaming" class="text-center py-8 hidden">
|
|
<div class="spinner inline-block"></div>
|
|
<p class="mt-2 text-gray-500">正在生成 PR 描述...</p>
|
|
<p id="streaming-status" class="mt-1 text-xs text-gray-400"></p>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
{{template "footer" .}}
|
|
|
|
<script>
|
|
const repoId = '{{.ID}}';
|
|
let markdownContent = '';
|
|
|
|
// Load refs on page load
|
|
async function loadRefs() {
|
|
try {
|
|
const resp = await fetch(`/api/repos/${repoId}/refs`, { credentials: 'same-origin' });
|
|
if (!resp.ok) throw new Error('加载分支列表失败');
|
|
const refs = await resp.json();
|
|
|
|
// Fetch recent commits for the "Recent Commits" group
|
|
let commits = [];
|
|
try {
|
|
const graphResp = await fetch(`/api/repos/${repoId}/graph?max_commits=20`, { credentials: 'same-origin' });
|
|
if (graphResp.ok) {
|
|
const gd = await graphResp.json();
|
|
commits = gd.commits || [];
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to load recent commits:', e);
|
|
}
|
|
|
|
const selects = ['base-ref', 'head-ref'];
|
|
const branches = refs.filter(r => !r.is_tag);
|
|
const tags = refs.filter(r => r.is_tag);
|
|
|
|
selects.forEach(selectId => {
|
|
const select = document.getElementById(selectId);
|
|
select.innerHTML = '<option value="">选择分支/标签</option>';
|
|
|
|
if (branches.length > 0) {
|
|
const group = document.createElement('optgroup');
|
|
group.label = '分支';
|
|
branches.forEach(ref => {
|
|
const opt = document.createElement('option');
|
|
opt.value = ref.name;
|
|
opt.textContent = ref.name + (ref.is_head ? ' (HEAD)' : '');
|
|
group.appendChild(opt);
|
|
});
|
|
select.appendChild(group);
|
|
}
|
|
|
|
if (tags.length > 0) {
|
|
const group = document.createElement('optgroup');
|
|
group.label = '标签';
|
|
tags.forEach(ref => {
|
|
const opt = document.createElement('option');
|
|
opt.value = ref.name;
|
|
opt.textContent = ref.name;
|
|
group.appendChild(opt);
|
|
});
|
|
select.appendChild(group);
|
|
}
|
|
|
|
if (commits.length > 0) {
|
|
const group = document.createElement('optgroup');
|
|
group.label = '最近提交';
|
|
commits.forEach(commit => {
|
|
const opt = document.createElement('option');
|
|
opt.value = commit.hash;
|
|
opt.textContent = `${commit.short_hash} ${commit.message.substring(0, 40)}`;
|
|
group.appendChild(opt);
|
|
});
|
|
select.appendChild(group);
|
|
}
|
|
});
|
|
|
|
// Auto-select from URL params, or fall back to main/master and HEAD
|
|
const params = new URLSearchParams(window.location.search);
|
|
const urlBase = params.get('base');
|
|
const urlHead = params.get('head');
|
|
const baseSelect = document.getElementById('base-ref');
|
|
const headSelect = document.getElementById('head-ref');
|
|
|
|
const mainBranch = branches.find(b => b.name === 'main' || b.name === 'master');
|
|
const headBranch = refs.find(r => r.is_head);
|
|
|
|
// Check if value exists in select options
|
|
const hasOption = (select, val) => Array.from(select.options).some(o => o.value === val);
|
|
|
|
if (urlBase && hasOption(baseSelect, urlBase)) {
|
|
baseSelect.value = urlBase;
|
|
} else if (mainBranch) {
|
|
baseSelect.value = mainBranch.name;
|
|
}
|
|
|
|
if (urlHead && hasOption(headSelect, urlHead)) {
|
|
headSelect.value = urlHead;
|
|
} else if (headBranch) {
|
|
headSelect.value = headBranch.name;
|
|
}
|
|
} catch (err) {
|
|
console.error('Load refs error:', err);
|
|
const errDiv = document.getElementById('load-error');
|
|
errDiv.textContent = '加载分支列表失败: ' + err.message;
|
|
errDiv.classList.remove('hidden');
|
|
document.getElementById('base-ref').innerHTML = '<option value="">加载失败</option>';
|
|
document.getElementById('head-ref').innerHTML = '<option value="">加载失败</option>';
|
|
}
|
|
}
|
|
|
|
function generate() {
|
|
const baseRef = document.getElementById('base-ref').value;
|
|
const headRef = document.getElementById('head-ref').value;
|
|
|
|
if (!baseRef || !headRef) {
|
|
showToast('请选择 Base 和 Head 分支', 'warning');
|
|
return;
|
|
}
|
|
|
|
const btn = document.getElementById('btn-generate');
|
|
const output = document.getElementById('output');
|
|
const streaming = document.getElementById('streaming');
|
|
const outputContent = document.getElementById('output-content');
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = '生成中...';
|
|
output.classList.remove('hidden');
|
|
outputContent.classList.add('hidden');
|
|
streaming.classList.remove('hidden');
|
|
|
|
// Clear previous results
|
|
document.getElementById('pr-title').textContent = '';
|
|
document.getElementById('pr-type').textContent = '';
|
|
document.getElementById('pr-summary').textContent = '';
|
|
document.getElementById('pr-details').innerHTML = '';
|
|
document.getElementById('pr-impact').textContent = '';
|
|
document.getElementById('pr-markdown').textContent = '';
|
|
document.getElementById('markdown-preview').classList.add('hidden');
|
|
markdownContent = '';
|
|
|
|
// Accumulate streaming content
|
|
const fields = { title: '', type: '', summary: '', details: '', impact: '' };
|
|
|
|
SSE.post(`/api/repos/${repoId}/generate`, {
|
|
base: baseRef,
|
|
head: headRef,
|
|
}, {
|
|
title(data) {
|
|
fields.title += data.content || '';
|
|
document.getElementById('pr-title').textContent = fields.title;
|
|
document.getElementById('streaming-status').textContent = '生成标题...';
|
|
},
|
|
type(data) {
|
|
fields.type += data.content || '';
|
|
document.getElementById('pr-type').textContent = fields.type;
|
|
document.getElementById('streaming-status').textContent = '生成类型...';
|
|
},
|
|
summary(data) {
|
|
fields.summary += data.content || '';
|
|
document.getElementById('pr-summary').textContent = fields.summary;
|
|
document.getElementById('streaming-status').textContent = '生成摘要...';
|
|
},
|
|
detail(data) {
|
|
fields.details += data.content || '';
|
|
document.getElementById('pr-details').innerHTML = renderMarkdown(fields.details);
|
|
document.getElementById('streaming-status').textContent = '生成详细说明...';
|
|
},
|
|
impact(data) {
|
|
fields.impact += data.content || '';
|
|
document.getElementById('pr-impact').textContent = fields.impact;
|
|
document.getElementById('streaming-status').textContent = '生成影响范围...';
|
|
},
|
|
markdown(data) {
|
|
markdownContent += data.content || '';
|
|
document.getElementById('pr-markdown').textContent = markdownContent;
|
|
},
|
|
done() {
|
|
// Assemble full markdown from structured fields for copy
|
|
markdownContent = buildMarkdown(fields);
|
|
document.getElementById('pr-markdown').textContent = markdownContent;
|
|
document.getElementById('markdown-preview').classList.remove('hidden');
|
|
streaming.classList.add('hidden');
|
|
outputContent.classList.remove('hidden');
|
|
btn.disabled = false;
|
|
btn.textContent = '生成 PR 描述';
|
|
},
|
|
error(data) {
|
|
showToast('生成失败: ' + (data.message || '未知错误'), 'error');
|
|
streaming.classList.add('hidden');
|
|
output.classList.add('hidden');
|
|
btn.disabled = false;
|
|
btn.textContent = '生成 PR 描述';
|
|
},
|
|
});
|
|
}
|
|
|
|
// 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()) {
|
|
showToast('没有可复制的内容,请先生成 PR 描述', 'warning');
|
|
return;
|
|
}
|
|
navigator.clipboard.writeText(markdown).then(() => {
|
|
showToast('已复制到剪贴板', 'success');
|
|
}).catch(() => {
|
|
// Fallback for non-HTTPS or permission-denied contexts
|
|
const textarea = document.createElement('textarea');
|
|
textarea.value = markdown;
|
|
textarea.style.position = 'fixed';
|
|
textarea.style.opacity = '0';
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
try {
|
|
document.execCommand('copy');
|
|
showToast('已复制到剪贴板', 'success');
|
|
} catch (e) {
|
|
showToast('复制失败,请手动选择文本复制', 'error');
|
|
}
|
|
document.body.removeChild(textarea);
|
|
});
|
|
}
|
|
|
|
loadRefs();
|
|
</script>
|
|
</body>
|
|
</html>
|