feat: Phase 3 — 前端交互功能完成

This commit is contained in:
2026-06-18 23:37:19 +08:00
parent 886e0d598d
commit 7ac3a3726b
10 changed files with 1387 additions and 261 deletions
+446 -32
View File
@@ -1,32 +1,219 @@
// Diff Viewer using diff2html
// Diff Viewer using diff2html with file tree sidebar
const DiffViewer = {
init(containerId) {
init(containerId, options = {}) {
this.container = document.getElementById(containerId);
this.options = options;
this.rawDiff = null;
this.currentMode = 'side-by-side';
this.files = []; // parsed file info for tree
this.observer = null; // scroll observer for highlight sync
if (!this.container) {
console.error('Diff container not found:', containerId);
return;
}
},
// Render unified diff string
// Parse unified diff string to extract per-file info
_parseDiffFiles(diffString) {
if (!diffString) return [];
const files = [];
const fileRegex = /^diff --git a\/(.*?) b\/(.*?)$/gm;
let match;
const positions = [];
while ((match = fileRegex.exec(diffString)) !== null) {
positions.push({ name: match[2] || match[1], index: match.index });
}
for (let i = 0; i < positions.length; i++) {
const start = positions[i].index;
const end = i + 1 < positions.length ? positions[i + 1].index : diffString.length;
const chunk = diffString.substring(start, end);
// Count additions and deletions
let additions = 0, deletions = 0;
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('+') && !line.startsWith('+++')) additions++;
if (line.startsWith('-') && !line.startsWith('---')) deletions++;
}
// Determine change type
let changeType = 'modified';
if (chunk.includes('new file mode')) changeType = 'added';
else if (chunk.includes('deleted file mode')) changeType = 'deleted';
else if (chunk.includes('rename from')) changeType = 'renamed';
files.push({
name: positions[i].name,
path: positions[i].name,
additions,
deletions,
changeType,
});
}
return files;
},
// Build tree structure from flat file list
_buildTree(files) {
const root = { name: '', path: '', children: [], isDir: true };
files.forEach(file => {
const parts = file.path.split('/');
let current = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const isFile = i === parts.length - 1;
if (isFile) {
current.children.push({
...file,
name: part,
isDir: false,
});
} else {
let child = current.children.find(c => c.isDir && c.name === part);
if (!child) {
child = {
name: part,
path: parts.slice(0, i + 1).join('/'),
children: [],
isDir: true,
};
current.children.push(child);
}
current = child;
}
}
});
// Sort: directories first, then files, alphabetically
const sortTree = (node) => {
if (node.children) {
node.children.sort((a, b) => {
if (a.isDir && !b.isDir) return -1;
if (!a.isDir && b.isDir) return 1;
return a.name.localeCompare(b.name);
});
node.children.forEach(sortTree);
}
};
sortTree(root);
return root;
},
// Render file tree sidebar HTML
_renderFileTree(tree) {
const totalFiles = this.files.length;
const totalAdd = this.files.reduce((s, f) => s + f.additions, 0);
const totalDel = this.files.reduce((s, f) => s + f.deletions, 0);
let html = `
<div class="file-tree-header">
<div class="file-tree-stats">
<span class="text-sm font-medium text-gray-700">${totalFiles} 文件</span>
<span class="text-xs text-green-600">+${totalAdd}</span>
<span class="text-xs text-red-600">-${totalDel}</span>
</div>
</div>
<div class="file-tree-list">`;
html += this._renderTreeNode(tree, 0);
html += '</div>';
return html;
},
_renderTreeNode(node, depth) {
let html = '';
if (node.isDir) {
// Only render directory label if not root
if (depth > 0) {
html += `<div class="file-tree-dir" style="padding-left:${depth * 16}px" data-path="${node.path}">
<span class="file-tree-toggle">▶</span>
<span class="file-tree-dirname">${node.name}/</span>
</div>`;
}
// Render children
const childDepth = depth > 0 ? depth + 1 : depth;
if (node.children) {
node.children.forEach(child => {
if (child.isDir) {
html += this._renderTreeNode(child, childDepth);
} else {
html += this._renderTreeNode(child, childDepth);
}
});
}
} else {
// File node
const icon = this._getFileIcon(node.changeType);
html += `<div class="file-tree-file" style="padding-left:${(depth + 1) * 16}px"
data-filename="${node.path}" title="${node.path}">
<span class="file-tree-icon">${icon}</span>
<span class="file-tree-name">${node.name}</span>
<span class="file-tree-stats-inline">
<span class="text-green-600">+${node.additions}</span>
<span class="text-red-600">-${node.deletions}</span>
</span>
</div>`;
}
return html;
},
_getFileIcon(changeType) {
switch (changeType) {
case 'added': return '<span class="text-green-500">A</span>';
case 'deleted': return '<span class="text-red-500">D</span>';
case 'renamed': return '<span class="text-yellow-500">R</span>';
default: return '<span class="text-gray-400">M</span>';
}
},
// Render diff with file tree sidebar
renderDiff(diffString, options = {}) {
if (!this.container) return;
// Store raw diff for view mode switching
this.rawDiff = diffString;
// Parse files for tree
this.files = this._parseDiffFiles(diffString);
const tree = this._buildTree(this.files);
const config = {
drawFileList: options.drawFileList !== false,
fileListToggle: options.fileListToggle !== false,
fileListStartVisible: options.fileListStartVisible !== false,
fileContentToggle: options.fileContentToggle !== false,
drawFileList: false, // We use our own file tree
fileListToggle: false,
fileContentToggle: true,
matching: options.matching || 'lines',
outputFormat: options.outputFormat || 'side-by-side',
outputFormat: options.outputFormat || this.currentMode === 'unified' ? 'line-by-line' : 'side-by-side',
synchronisedScroll: true,
highlight: true,
renderNothingWhenEmpty: false,
};
// Use diff2html to render
this.currentMode = config.outputFormat === 'line-by-line' ? 'unified' : 'side-by-side';
// Build layout: sidebar + diff area
const diffHtml = Diff2Html.html(diffString, config);
this.container.innerHTML = diffHtml;
const treeHtml = this._renderFileTree(tree);
this.container.innerHTML = `
<div class="diff-layout">
<div class="diff-sidebar" id="diff-file-tree">
${treeHtml}
</div>
<div class="diff-main" id="diff-content">
${diffHtml}
</div>
</div>`;
// Add syntax highlighting
this.container.querySelectorAll('pre code').forEach(block => {
@@ -34,6 +221,15 @@ const DiffViewer = {
hljs.highlightElement(block);
}
});
// Add data-filename attributes to file wrappers for scroll sync
this._annotateFileSections();
// Bind file tree click events
this._bindFileTreeEvents();
// Set up scroll spy
this._setupScrollSpy();
},
// Render per-file diffs
@@ -45,9 +241,174 @@ const DiffViewer = {
return;
}
// Combine all patches
// Store files for tree
this.files = files.map(f => {
let additions = 0, deletions = 0;
const lines = (f.patch || '').split('\n');
for (const line of lines) {
if (line.startsWith('+') && !line.startsWith('+++')) additions++;
if (line.startsWith('-') && !line.startsWith('---')) deletions++;
}
return {
name: f.filename,
path: f.filename,
additions,
deletions,
changeType: 'modified',
};
});
const combinedDiff = files.map(f => f.patch).join('\n');
this.renderDiff(combinedDiff, options);
this.rawDiff = combinedDiff;
const tree = this._buildTree(this.files);
const config = {
drawFileList: false,
fileListToggle: false,
fileContentToggle: true,
matching: options.matching || 'lines',
outputFormat: options.outputFormat || (this.currentMode === 'unified' ? 'line-by-line' : 'side-by-side'),
synchronisedScroll: true,
highlight: true,
renderNothingWhenEmpty: false,
};
const diffHtml = Diff2Html.html(combinedDiff, config);
const treeHtml = this._renderFileTree(tree);
this.container.innerHTML = `
<div class="diff-layout">
<div class="diff-sidebar" id="diff-file-tree">
${treeHtml}
</div>
<div class="diff-main" id="diff-content">
${diffHtml}
</div>
</div>`;
this.container.querySelectorAll('pre code').forEach(block => {
if (window.hljs) {
hljs.highlightElement(block);
}
});
this._annotateFileSections();
this._bindFileTreeEvents();
this._setupScrollSpy();
},
// Annotate diff2html file wrappers with data-filename
_annotateFileSections() {
const fileWrappers = this.container.querySelectorAll('.d2h-file-wrapper');
fileWrappers.forEach(wrapper => {
const header = wrapper.querySelector('.d2h-file-name');
if (header) {
const name = header.textContent.trim();
// Extract just the filename from the header (diff2html adds a prefix)
const cleanName = name.replace(/^\s*(Modified|Added|Deleted|Rename)\s*/i, '').trim();
wrapper.setAttribute('data-filename', cleanName);
}
});
},
// Bind click events on file tree items
_bindFileTreeEvents() {
const treeItems = this.container.querySelectorAll('.file-tree-file');
treeItems.forEach(item => {
item.addEventListener('click', (e) => {
const filename = item.getAttribute('data-filename');
this.scrollToFile(filename);
// Highlight active file in tree
this.container.querySelectorAll('.file-tree-file').forEach(fi => fi.classList.remove('active'));
item.classList.add('active');
});
});
// Directory toggle
const dirItems = this.container.querySelectorAll('.file-tree-dir');
dirItems.forEach(dir => {
dir.addEventListener('click', (e) => {
const toggle = dir.querySelector('.file-tree-toggle');
const path = dir.getAttribute('data-path');
const isExpanded = toggle.textContent === '▼';
toggle.textContent = isExpanded ? '▶' : '▼';
// Toggle children visibility
let sibling = dir.nextElementSibling;
while (sibling) {
// Check if this sibling is still a child (starts with same path or is a file at same level)
const sibPath = sibling.getAttribute('data-path') || sibling.getAttribute('data-filename') || '';
if (!sibPath.startsWith(path + '/') && sibling.classList.contains('file-tree-dir')) {
break;
}
sibling.style.display = isExpanded ? 'none' : '';
sibling = sibling.nextElementSibling;
}
});
});
},
// Scroll to a specific file section in the diff
scrollToFile(filename) {
const diffContent = this.container.querySelector('#diff-content');
if (!diffContent) return;
const fileWrappers = diffContent.querySelectorAll('.d2h-file-wrapper');
for (const wrapper of fileWrappers) {
const wrapperName = wrapper.getAttribute('data-filename');
if (wrapperName && (wrapperName === filename || wrapperName.endsWith('/' + filename) || filename.endsWith('/' + wrapperName))) {
wrapper.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Brief highlight effect
wrapper.style.outline = '2px solid #3b82f6';
setTimeout(() => { wrapper.style.outline = ''; }, 2000);
return;
}
}
},
// Set up scroll spy to highlight current file in tree
_setupScrollSpy() {
const diffContent = this.container.querySelector('#diff-content');
if (!diffContent) return;
// Clean up old observer
if (this.observer) {
this.observer.disconnect();
}
const fileWrappers = diffContent.querySelectorAll('.d2h-file-wrapper');
if (fileWrappers.length === 0) return;
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const filename = entry.target.getAttribute('data-filename');
if (filename) {
// Highlight in tree
this.container.querySelectorAll('.file-tree-file').forEach(item => {
const treeName = item.getAttribute('data-filename');
if (treeName && (treeName === filename || treeName.endsWith('/' + filename) || filename.endsWith('/' + treeName))) {
item.classList.add('active');
// Scroll tree item into view if needed
item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else {
item.classList.remove('active');
}
});
}
}
});
}, {
root: diffContent,
rootMargin: '-10% 0px -80% 0px',
threshold: 0,
});
fileWrappers.forEach(wrapper => {
this.observer.observe(wrapper);
});
},
// Load and render diff from API
@@ -77,7 +438,7 @@ const DiffViewer = {
async loadFiles(repoId, base, head) {
if (!this.container) return;
this.container.innerHTML = '<div class="text-center p-8"><div class="spinner inline-block"></div><p class="mt-2 text-gray-500">Loading files...</p></div>';
this.container.innerHTML = '<div class="text-center p-8"><div class="spinner inline-block"></div><p class="text-gray-500">Loading files...</p></div>';
try {
const url = `/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}&per_file=true`;
@@ -93,29 +454,16 @@ const DiffViewer = {
// Switch view mode (side-by-side or line-by-line)
setViewMode(mode) {
if (!this.container) return;
const config = {
drawFileList: true,
outputFormat: mode === 'unified' ? 'line-by-line' : 'side-by-side',
};
// Re-render with existing diff content
const existingDiff = this.container.querySelector('.d2h-diff-table');
if (existingDiff) {
// Get the raw diff from the container's data
const rawDiff = this.container.dataset.rawDiff;
if (rawDiff) {
this.renderDiff(rawDiff, config);
}
}
if (!this.container || !this.rawDiff) return;
this.currentMode = mode;
this.renderDiff(this.rawDiff, { outputFormat: mode === 'unified' ? 'line-by-line' : 'side-by-side' });
},
// Toggle file list visibility
toggleFileList() {
const fileList = this.container.querySelector('.d2h-file-list');
if (fileList) {
fileList.style.display = fileList.style.display === 'none' ? 'block' : 'none';
const sidebar = this.container.querySelector('.diff-sidebar');
if (sidebar) {
sidebar.style.display = sidebar.style.display === 'none' ? '' : 'none';
}
},
@@ -128,6 +476,72 @@ const DiffViewer = {
content.style.display = expand ? 'block' : 'none';
}
});
},
// Insert a review suggestion inline at a specific line
insertSuggestion(filename, line, side, severity, content, suggestionId) {
const diffContent = this.container.querySelector('#diff-content');
if (!diffContent) return;
// Find the target file wrapper
const fileWrappers = diffContent.querySelectorAll('.d2h-file-wrapper');
let targetWrapper = null;
for (const wrapper of fileWrappers) {
const wrapperName = wrapper.getAttribute('data-filename');
if (wrapperName && (wrapperName === filename || wrapperName.endsWith('/' + filename) || filename.endsWith('/' + wrapperName))) {
targetWrapper = wrapper;
break;
}
}
if (!targetWrapper) return;
// Find the target line
const table = targetWrapper.querySelector('.d2h-diff-table');
if (!table) return;
const rows = table.querySelectorAll('tr');
for (const row of rows) {
// diff2html uses data-line-number on td elements
const lineNumCell = side === 'left'
? row.querySelector('.d2h-code-linenumber .d2h-code-side-linenumber')
: row.querySelector('.d2h-code-linenumber:not(.d2h-code-side-linenumber)');
if (!lineNumCell) continue;
const lineText = lineNumCell.textContent.trim();
const lineNum = parseInt(lineText);
if (lineNum === line) {
// Create suggestion card
const severityStyles = {
critical: 'border-l-4 border-red-500 bg-red-50',
warning: 'border-l-4 border-yellow-500 bg-yellow-50',
info: 'border-l-4 border-green-500 bg-green-50',
};
const severityLabels = {
critical: '🔴 严重',
warning: '🟡 建议',
info: '🟢 提示',
};
const suggestionRow = document.createElement('tr');
suggestionRow.className = 'review-suggestion-row';
suggestionRow.setAttribute('data-suggestion-id', suggestionId || '');
suggestionRow.innerHTML = `
<td colspan="3" class="p-0">
<div class="review-suggestion-card ${severityStyles[severity] || severityStyles.info} p-3 mx-2 my-1 rounded">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-medium">${severityLabels[severity] || severityLabels.info}</span>
</div>
<p class="text-sm text-gray-700">${content}</p>
<div class="mt-2 note-editor-placeholder" data-scope="suggestion" data-scope-key="${suggestionId || ''}"></div>
</div>
</td>`;
// Insert after the current row
row.parentNode.insertBefore(suggestionRow, row.nextSibling);
return;
}
}
}
};
+93 -8
View File
@@ -1,4 +1,4 @@
// Git Graph visualization using D3.js
// Git Graph visualization using D3.js with interactive base/head selection
const GitGraph = {
init(containerId, repoId) {
this.container = document.getElementById(containerId);
@@ -7,6 +7,11 @@ const GitGraph = {
this.refs = [];
this.edges = [];
// Selection state
this.selectedBase = null;
this.selectedHead = null;
this.onSelectionChange = null; // callback(baseHash, headHash)
if (!this.container) {
console.error('Graph container not found:', containerId);
return;
@@ -31,6 +36,13 @@ const GitGraph = {
}
},
// Set selection programmatically (e.g. from dropdowns)
setSelection(baseHash, headHash) {
this.selectedBase = baseHash;
this.selectedHead = headHash;
this._updateSelectionVisuals();
},
render() {
if (this.commits.length === 0) {
this.container.innerHTML = '<div class="text-gray-500 p-4">No commits found</div>';
@@ -57,7 +69,7 @@ const GitGraph = {
const nodeMap = {};
const laneWidth = 24;
// Assign lanes to commits (simple algorithm: each branch gets its own lane)
// Assign lanes to commits
const lanes = this.assignLanes();
this.commits.forEach((commit, i) => {
@@ -86,12 +98,15 @@ const GitGraph = {
.attr('stroke-width', 2);
// Draw commit nodes
const self = this;
const nodes = g.selectAll('.node')
.data(this.commits)
.enter()
.append('g')
.attr('class', 'node')
.attr('transform', (d, i) => `translate(${nodeMap[d.hash]?.x || 20},${nodeMap[d.hash]?.y || i * nodeHeight + 20})`);
.attr('data-hash', d => d.hash)
.attr('transform', (d, i) => `translate(${nodeMap[d.hash]?.x || 20},${nodeMap[d.hash]?.y || i * nodeHeight + 20})`)
.style('cursor', 'pointer');
nodes.append('circle')
.attr('r', 6)
@@ -158,16 +173,86 @@ const GitGraph = {
.attr('height', textBBox.height + 6);
});
// Add hover effect
// Hover effect
nodes.on('mouseover', function(event, d) {
d3.select(this).select('circle')
.attr('r', 8)
.attr('fill', '#2563eb');
}).on('mouseout', function() {
d3.select(this).select('circle')
.attr('r', 6)
.attr('fill', '#3b82f6');
}).on('mouseout', function(event, d) {
const node = d3.select(this);
const hash = d.hash;
if (hash === self.selectedBase) {
node.select('circle').attr('r', 9).attr('fill', '#10b981').attr('stroke', '#059669');
} else if (hash === self.selectedHead) {
node.select('circle').attr('r', 9).attr('fill', '#f59e0b').attr('stroke', '#d97706');
} else {
node.select('circle').attr('r', 6).attr('fill', '#3b82f6').attr('stroke', '#1e40af');
}
});
// Click handler for base/head selection
nodes.on('click', function(event, d) {
const hash = d.hash;
if (!self.selectedBase) {
// First click: set base
self.selectedBase = hash;
} else if (!self.selectedHead) {
// Second click: set head
if (hash === self.selectedBase) {
// Clicked same node, deselect base
self.selectedBase = null;
} else {
self.selectedHead = hash;
}
} else {
// Both selected: reset and start new selection
self.selectedBase = hash;
self.selectedHead = null;
}
self._updateSelectionVisuals();
// Notify callback
if (self.onSelectionChange) {
self.onSelectionChange(self.selectedBase, self.selectedHead);
}
});
// Store refs for later updates
this._nodes = nodes;
this._nodeMap = nodeMap;
// Apply any pre-existing selection
this._updateSelectionVisuals();
},
// Update visual state of selected nodes
_updateSelectionVisuals() {
if (!this._nodes) return;
const self = this;
this._nodes.each(function(d) {
const node = d3.select(this);
const circle = node.select('circle');
const hash = d.hash;
if (hash === self.selectedBase) {
circle.attr('r', 9).attr('fill', '#10b981').attr('stroke', '#059669');
} else if (hash === self.selectedHead) {
circle.attr('r', 9).attr('fill', '#f59e0b').attr('stroke', '#d97706');
} else {
circle.attr('r', 6).attr('fill', '#3b82f6').attr('stroke', '#1e40af');
}
});
},
// Get the ref name (branch/tag) for a commit hash, or short hash if none
getRefName(hash) {
const ref = this.refs.find(r => r.hash === hash);
if (ref) return ref.name;
const commit = this.commits.find(c => c.hash === hash);
return commit ? commit.short_hash : hash.substring(0, 7);
},
assignLanes() {
+126
View File
@@ -0,0 +1,126 @@
// Review Inline: renders AI review suggestions inline in a diff view
const ReviewInline = {
suggestions: [],
init(diffContainerId, suggestionsContainerId) {
this.diffContainer = document.getElementById(diffContainerId);
this.suggestionsContainer = document.getElementById(suggestionsContainerId);
this.suggestions = [];
},
// Add a suggestion to the collection
addSuggestion(suggestion) {
this.suggestions.push(suggestion);
},
// Render all collected suggestions inline in the diff
renderInline() {
if (!this.diffContainer) return;
this.suggestions.forEach((s, index) => {
DiffViewer.insertSuggestion(
s.file,
s.line,
s.side || 'right',
s.severity || 'info',
s.content || '',
`suggestion-${index}`
);
});
},
// Render a single suggestion card (for the card-based view)
renderSuggestionCard(suggestion) {
const severityStyles = {
critical: {
border: 'border-l-4 border-red-500',
bg: 'bg-red-50',
badge: 'bg-red-100 text-red-800',
icon: '🔴',
label: '严重',
},
warning: {
border: 'border-l-4 border-yellow-500',
bg: 'bg-yellow-50',
badge: 'bg-yellow-100 text-yellow-800',
icon: '🟡',
label: '建议',
},
info: {
border: 'border-l-4 border-green-500',
bg: 'bg-green-50',
badge: 'bg-green-100 text-green-800',
icon: '🟢',
label: '提示',
},
};
const style = severityStyles[suggestion.severity] || severityStyles.info;
return `
<div class="review-card ${style.border} ${style.bg} rounded-lg p-4 mb-3">
<div class="flex items-center gap-2 mb-2">
<span class="${style.badge} px-2 py-0.5 rounded text-xs font-medium">
${style.icon} ${style.label}
</span>
${suggestion.file ? `<span class="text-xs text-gray-500 font-mono">${suggestion.file}</span>` : ''}
${suggestion.line ? `<span class="text-xs text-gray-400">行 ${suggestion.line}</span>` : ''}
</div>
<div class="text-sm text-gray-700 review-content">${suggestion.content || ''}</div>
${suggestion.code_example ? `
<pre class="mt-2 p-2 bg-gray-100 rounded text-xs overflow-x-auto"><code>${this._escapeHtml(suggestion.code_example)}</code></pre>
` : ''}
</div>`;
},
// Render file review section
renderFileReview(fileReview) {
const severityOrder = { critical: 0, warning: 1, info: 2 };
const suggestions = (fileReview.suggestions || []).sort((a, b) =>
(severityOrder[a.severity] || 2) - (severityOrder[b.severity] || 2)
);
const maxSeverity = suggestions.length > 0 ? suggestions[0].severity : 'info';
const severityColors = {
critical: 'border-red-500',
warning: 'border-yellow-500',
info: 'border-green-500',
};
let html = `
<div class="file-review bg-white rounded-lg shadow-md ${severityColors[maxSeverity] || ''} border-l-4 mb-4">
<div class="p-4 border-b border-gray-100">
<h3 class="font-medium text-gray-900 font-mono text-sm">${fileReview.filename || ''}</h3>
${fileReview.summary ? `<p class="text-sm text-gray-600 mt-1">${fileReview.summary}</p>` : ''}
</div>
<div class="p-4 space-y-3">`;
suggestions.forEach(s => {
html += this.renderSuggestionCard(s);
});
html += `
</div>
</div>`;
return html;
},
// Render overall summary
renderSummary(summary) {
if (!summary) return '';
return `
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-3">整体评估</h2>
<div class="prose text-sm text-gray-700">${summary}</div>
</div>`;
},
_escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
};
window.ReviewInline = ReviewInline;
+86
View File
@@ -0,0 +1,86 @@
// SSE (Server-Sent Events) client for POST-based streaming endpoints
// Works with fetch() + ReadableStream since EventSource only supports GET
const SSE = {
/**
* POST to a streaming endpoint and handle SSE events.
* @param {string} url - The endpoint URL
* @param {object} body - JSON request body
* @param {object} handlers - Map of event name → callback(data)
* Special handlers:
* 'error' - called on error events or fetch failures
* 'done' - called when stream ends
* 'start' - called when stream starts (first event)
* @returns {object} controller with abort() method
*/
async post(url, body, handlers = {}) {
const controller = new AbortController();
const run = async () => {
try {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
});
if (!resp.ok) {
const errText = await resp.text();
let msg = `HTTP ${resp.status}`;
try {
const errJson = JSON.parse(errText);
msg = errJson.error || errJson.message || msg;
} catch (_) {
msg = errText || msg;
}
if (handlers.error) handlers.error({ message: msg });
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentEvent = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
const raw = line.slice(6);
let data;
try {
data = JSON.parse(raw);
} catch (_) {
data = raw;
}
// Call the matching handler
if (currentEvent && handlers[currentEvent]) {
handlers[currentEvent](data);
}
}
}
}
// Stream ended
if (handlers.done) handlers.done();
} catch (err) {
if (err.name === 'AbortError') return;
if (handlers.error) handlers.error({ message: err.message });
}
};
run();
return { abort: () => controller.abort() };
}
};
window.SSE = SSE;