feat: 持久化 AI 审查结果,文件级内联展示与页面集成

This commit is contained in:
2026-06-19 23:24:06 +08:00
parent a89d057716
commit 0528eee4de
7 changed files with 518 additions and 23 deletions
+166 -1
View File
@@ -9,6 +9,7 @@
<script src="/static/js/sse.js"></script>
<script src="/static/js/graph.js"></script>
<script src="/static/js/diff-viewer.js"></script>
<script src="/static/js/review-inline.js"></script>
<style>
.graph-container {
background: #f8fafc;
@@ -98,9 +99,12 @@
<button onclick="loadDiff()" class="px-4 py-1.5 bg-blue-600 text-white rounded text-sm hover:bg-blue-700">
查看 Diff
</button>
<div class="flex gap-1 ml-auto">
<div class="flex gap-1 ml-auto items-center">
<button onclick="setDiffView('side-by-side')" id="btn-side-by-side" class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded font-medium">并排</button>
<button onclick="setDiffView('unified')" id="btn-unified" class="px-3 py-1 text-xs bg-gray-100 text-gray-600 rounded hover:bg-gray-200">统一</button>
<button onclick="startInlineReview()" id="btn-inline-review" class="px-3 py-1 text-xs bg-purple-100 text-purple-700 rounded font-medium hover:bg-purple-200 ml-2">
🤖 AI 评审
</button>
</div>
</div>
<div class="mt-2 flex items-center gap-3">
@@ -136,6 +140,28 @@
<!-- Diff Tab -->
<div id="panel-diff" class="p-4 hidden">
<div id="diff-container"></div>
<!-- Inline review progress -->
<div id="review-progress" class="hidden mt-4 bg-white rounded-lg border p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">🤖 AI 代码审查</span>
<button onclick="cancelInlineReview()" class="text-xs text-gray-400 hover:text-gray-600">取消</button>
</div>
<div class="w-full bg-gray-200 rounded-full h-2 mb-2">
<div id="review-progress-bar" class="bg-purple-600 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<p id="review-progress-text" class="text-xs text-gray-500">准备中...</p>
</div>
<!-- Review summary (shown after completion) -->
<div id="review-summary" class="hidden mt-4 bg-white rounded-lg border p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">审查总结</span>
<div class="flex gap-2">
<a id="btn-view-full-review" href="#" class="text-xs text-purple-600 hover:underline">查看完整评审</a>
<button onclick="clearInlineReview()" class="text-xs text-gray-400 hover:text-gray-600">清除</button>
</div>
</div>
<div id="review-summary-content" class="text-sm text-gray-700"></div>
</div>
</div>
</div>
</div>
@@ -400,6 +426,145 @@
}
}
// ── Inline AI Review ────────────────────────────────────────
let inlineReviewSSE = null;
let inlineSuggestions = [];
function startInlineReview() {
const base = document.getElementById('select-base').value;
const head = document.getElementById('select-head').value;
if (!base || !head) {
alert('请先选择 Base 和 Head 并查看 Diff');
return;
}
// Cancel previous if running
if (inlineReviewSSE) {
inlineReviewSSE.abort();
}
const btn = document.getElementById('btn-inline-review');
const progress = document.getElementById('review-progress');
const summary = document.getElementById('review-summary');
btn.disabled = true;
btn.textContent = '⏳ 审查中...';
progress.classList.remove('hidden');
summary.classList.add('hidden');
inlineSuggestions = [];
let totalFiles = 0;
let completedFiles = 0;
inlineReviewSSE = SSE.post(`/api/repos/${repoId}/review`, {
base, head, top_n: 0,
}, {
start(data) {
totalFiles = data.total_files || 0;
document.getElementById('review-progress-text').textContent = `准备审查 ${totalFiles} 个文件...`;
document.getElementById('review-progress-bar').style.width = '5%';
},
file_start(data) {
completedFiles++;
const pct = totalFiles > 0 ? Math.round((completedFiles / totalFiles) * 80 + 10) : 50;
document.getElementById('review-progress-bar').style.width = pct + '%';
document.getElementById('review-progress-text').textContent = `正在审查: ${data.file || ''} (${completedFiles}/${totalFiles})`;
},
suggestion(data) {
inlineSuggestions.push(data);
},
summary(data) {
document.getElementById('review-summary-content').innerHTML = renderInlineMarkdown(data.content || '');
document.getElementById('review-progress-bar').style.width = '90%';
},
done() {
document.getElementById('review-progress-bar').style.width = '100%';
document.getElementById('review-progress-text').textContent = '审查完成!';
setTimeout(() => {
progress.classList.add('hidden');
summary.classList.remove('hidden');
btn.disabled = false;
btn.textContent = '🤖 AI 评审';
}, 500);
// Insert suggestions into the existing diff
insertInlineSuggestions();
inlineReviewSSE = null;
},
analysis_saved(data) {
const viewLink = document.getElementById('btn-view-full-review');
viewLink.href = `/repo/${repoId}/review?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}`;
},
error(data) {
alert('审查失败: ' + (data.message || '未知错误'));
progress.classList.add('hidden');
btn.disabled = false;
btn.textContent = '🤖 AI 评审';
inlineReviewSSE = null;
},
});
}
function cancelInlineReview() {
if (inlineReviewSSE) {
inlineReviewSSE.abort();
inlineReviewSSE = null;
}
document.getElementById('review-progress').classList.add('hidden');
const btn = document.getElementById('btn-inline-review');
btn.disabled = false;
btn.textContent = '🤖 AI 评审';
}
function insertInlineSuggestions() {
const fileSeverities = {};
const severityOrder = { critical: 3, warning: 2, info: 1 };
inlineSuggestions.forEach((s, i) => {
if (s.file) {
DiffViewer.insertFileSuggestion(s.file, s.severity, s.content, 'inline-sug-' + i);
const cur = fileSeverities[s.file] || 'info';
if ((severityOrder[s.severity] || 0) > (severityOrder[cur] || 0)) {
fileSeverities[s.file] = s.severity;
}
}
});
Object.entries(fileSeverities).forEach(([file, sev]) => {
DiffViewer.markFileSeverity(file, sev);
});
// Re-initialize note editor for any new placeholders
if (typeof NoteEditor !== 'undefined') {
NoteEditor._observeNewPlaceholders && NoteEditor._observeNewPlaceholders();
}
}
function clearInlineReview() {
// Remove all suggestion elements from the diff
const container = document.getElementById('diff-container');
container.querySelectorAll('.file-suggestions-container').forEach(el => el.remove());
container.querySelectorAll('.file-tree-severity').forEach(el => el.remove());
document.getElementById('review-summary').classList.add('hidden');
inlineSuggestions = [];
}
function renderInlineMarkdown(text) {
if (!text) return '';
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/^### (.+)$/gm, '<h3 class="font-semibold text-sm mt-2">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 class="font-semibold mt-2">$1</h2>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.+?)`/g, '<code class="bg-gray-100 px-1 rounded text-xs">$1</code>')
.replace(/^- (.+)$/gm, '<li class="ml-4">$1</li>')
.replace(/\n/g, '<br>');
}
init();
</script>
</body>
+141 -3
View File
@@ -41,6 +41,16 @@
开始审查
</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>
<!-- History -->
<div class="mt-4 pt-4 border-t border-gray-200">
<div class="flex items-center gap-3">
<label class="text-sm font-medium text-gray-600">📋 历史评审:</label>
<select id="history-select" class="flex-1 border rounded-md px-3 py-1.5 text-sm" onchange="loadHistoryReview(this.value)">
<option value="">加载中...</option>
</select>
</div>
</div>
</div>
<!-- Progress -->
@@ -376,12 +386,26 @@
DiffViewer.init('review-diff-container');
DiffViewer.renderDiff(diffString, {
onComplete() {
// Insert suggestions after all files are rendered
// Track max severity per file for sidebar markers
const fileSeverities = {};
const severityOrder = { critical: 3, warning: 2, info: 1 };
// Insert file-level suggestions after all files are rendered
allSuggestions.forEach((s, i) => {
if (s.file && s.line) {
DiffViewer.insertSuggestion(s.file, s.line, s.side || 'right', s.severity, s.content, 'sug-' + i);
if (s.file) {
DiffViewer.insertFileSuggestion(s.file, s.severity, s.content, 'sug-' + i);
// Track max severity per file
const cur = fileSeverities[s.file] || 'info';
if ((severityOrder[s.severity] || 0) > (severityOrder[cur] || 0)) {
fileSeverities[s.file] = s.severity;
}
}
});
// Mark severity in file tree sidebar
Object.entries(fileSeverities).forEach(([file, sev]) => {
DiffViewer.markFileSeverity(file, sev);
});
}
});
}
@@ -418,7 +442,121 @@
return html;
}
// ── History loading ────────────────────────────────────────
async function loadHistoryList() {
try {
const resp = await fetch(`/api/repos/${repoId}/review/analyses`);
if (!resp.ok) return;
const analyses = await resp.json();
const select = document.getElementById('history-select');
select.innerHTML = '<option value="">选择历史评审...</option>';
analyses.forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
const date = (a.created_at || '').substring(0, 16).replace('T', ' ');
opt.textContent = `${a.base_ref} → ${a.head_ref} (${date})`;
select.appendChild(opt);
});
if (analyses.length === 0) {
select.innerHTML = '<option value="">暂无历史评审</option>';
}
} catch (e) {
console.warn('Failed to load history:', e);
}
}
async function loadHistoryReview(analysisId) {
if (!analysisId) return;
try {
// Fetch the review result
const resp = await fetch(`/api/repos/${repoId}/review/analyses/${analysisId}`);
if (!resp.ok) throw new Error('Failed to load review');
const data = await resp.json();
const result = data.result;
if (!result || !result.file_reviews) {
alert('该历史记录没有完整的评审数据');
return;
}
// Set current analysis ID for notes
currentAnalysisId = parseInt(analysisId);
// Populate suggestions from stored data
allSuggestions = [];
fileReviews = {};
result.file_reviews.forEach(fr => {
fileReviews[fr.file_name] = { filename: fr.file_name, suggestions: [], summary: '' };
(fr.suggestions || []).forEach(s => {
const content = s.description || '';
const fullContent = content +
(s.suggestion ? '\n\n**建议修改:** ' + s.suggestion : '') +
(s.code_example ? '\n\n```\n' + s.code_example + '\n```' : '');
const sug = { file: fr.file_name, severity: s.severity, content: fullContent };
allSuggestions.push(sug);
fileReviews[fr.file_name].suggestions.push(sug);
});
});
// Render summary
const summary = result.summary || {};
let summaryText = '';
if (summary.score) summaryText += `整体评分: ${summary.score}/10\n\n`;
if (summary.overall) summaryText += summary.overall;
if (summary.findings) summaryText += '\n\n**主要发现:**\n' + summary.findings;
if (summary.recommendations) summaryText += '\n\n**改进建议:**\n' + summary.recommendations;
document.getElementById('summary-content').innerHTML = renderMarkdown(summaryText);
// Clear and rebuild card view
document.getElementById('file-reviews').innerHTML = '';
allSuggestions.forEach(s => appendSuggestionCard(s));
// Show results
document.getElementById('results').classList.remove('hidden');
document.getElementById('review-actions').classList.remove('hidden');
// Load diff and render inline
const base = data.base_ref;
const head = data.head_ref;
// Load diff from API
const diffResp = await fetch(`/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}`);
if (diffResp.ok) {
const diffData = await diffResp.json();
const diffString = diffData.diff || '';
if (diffString) {
renderInlineReview(diffString);
}
}
// Init note editor
NoteEditor.init(repoId, currentAnalysisId);
NoteEditor.loadNotes();
} catch (e) {
console.error('Load history error:', e);
alert('加载历史评审失败: ' + e.message);
}
}
// Auto-load history from URL param
const urlParams = new URLSearchParams(window.location.search);
const urlAnalysisId = urlParams.get('analysis_id');
loadRefs();
loadHistoryList();
// If analysis_id in URL, auto-load after refs are ready
if (urlAnalysisId) {
setTimeout(() => loadHistoryReview(urlAnalysisId), 500);
}
</script>
</body>
</html>