流式输出: - 恢复 content 事件处理,实时累积 LLM 原始输出 - parsePartialJSON 从不完整 JSON 中渐进提取已闭合的字段 - 每次提取到新字段即更新两栏,实现左栏渐进渲染 - 结构化事件(title/detail等)覆盖解析结果,确保最终一致 Markdown 渲染: - 重写为逐行处理(_renderLines),不再按双换行分块 - ### 及更深层级标题在单换行后也能正确识别为独立块 - 列表项、水平线等同样受益于逐行解析
This commit is contained in:
+57
-35
@@ -29,10 +29,8 @@ const Markdown = {
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// Phase 4: Block-level processing (split into blocks by blank lines)
|
||||
const blocks = text.split(/\n{2,}/);
|
||||
const htmlBlocks = blocks.map(block => this._renderBlock(block.trim()));
|
||||
text = htmlBlocks.join('\n');
|
||||
// Phase 4: Block-level processing (line-by-line to handle single-newline headers/lists)
|
||||
text = this._renderLines(text);
|
||||
|
||||
// Phase 5: Restore fenced code blocks
|
||||
codeBlocks.forEach((cb, idx) => {
|
||||
@@ -55,39 +53,63 @@ const Markdown = {
|
||||
return text;
|
||||
},
|
||||
|
||||
_renderLines(text) {
|
||||
const lines = text.split('\n');
|
||||
const out = [];
|
||||
let para = [];
|
||||
|
||||
const flushPara = () => {
|
||||
if (para.length) {
|
||||
out.push(`<p class="md-p">${this._renderInline(para.join('<br>'))}</p>`);
|
||||
para = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Blank line — flush paragraph
|
||||
if (!trimmed) { flushPara(); continue; }
|
||||
|
||||
// Horizontal rule
|
||||
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { flushPara(); out.push('<hr class="md-hr">'); continue; }
|
||||
|
||||
// Header (h1–h6)
|
||||
const hm = trimmed.match(/^(#{1,6}) (.+)$/);
|
||||
if (hm) { flushPara(); out.push(`<h${hm[1].length} class="md-h${hm[1].length}">${this._renderInline(hm[2])}</h${hm[1].length}>`); continue; }
|
||||
|
||||
// Blockquote
|
||||
if (/^>\s?/.test(trimmed)) { flushPara(); out.push(`<blockquote class="md-blockquote">${this._renderInline(trimmed.replace(/^>\s?/, ''))}</blockquote>`); continue; }
|
||||
|
||||
// Unordered list item
|
||||
if (/^[\-\*]\s/.test(trimmed)) { flushPara(); out.push(this._renderListItem(trimmed, 'ul')); continue; }
|
||||
|
||||
// Ordered list item
|
||||
if (/^\d+\.\s/.test(trimmed)) { flushPara(); out.push(this._renderListItem(trimmed, 'ol')); continue; }
|
||||
|
||||
// Regular text — accumulate into paragraph
|
||||
para.push(trimmed);
|
||||
}
|
||||
flushPara();
|
||||
return out.join('\n');
|
||||
},
|
||||
|
||||
_renderListItem(line, tag) {
|
||||
const m = line.match(/^[\s]*(?:[\-\*]|\d+\.)\s+(.*)$/);
|
||||
const content = m ? m[1] : line;
|
||||
// Return just the li; caller can wrap in ul/ol if needed
|
||||
return `<li class="md-li">${this._renderInline(content)}</li>`;
|
||||
},
|
||||
|
||||
_renderBlock(block) {
|
||||
// Kept as fallback; primary rendering now uses _renderLines
|
||||
if (!block) return '';
|
||||
|
||||
// Horizontal rule
|
||||
if (/^(-{3,}|\*{3,}|_{3,})$/.test(block)) {
|
||||
return '<hr class="md-hr">';
|
||||
}
|
||||
|
||||
// Headers
|
||||
const headerMatch = block.match(/^(#{1,6}) (.+)$/);
|
||||
if (headerMatch) {
|
||||
const level = headerMatch[1].length;
|
||||
const content = this._renderInline(headerMatch[2]);
|
||||
return `<h${level} class="md-h${level}">${content}</h${level}>`;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if (/^>\s?/.test(block)) {
|
||||
const inner = block.replace(/^>\s?/gm, '').trim();
|
||||
return `<blockquote class="md-blockquote">${this._renderInline(inner)}</blockquote>`;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
if (/^[\-\*]\s/.test(block)) {
|
||||
return this._renderList(block, 'ul');
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
if (/^\d+\.\s/.test(block)) {
|
||||
return this._renderList(block, 'ol');
|
||||
}
|
||||
|
||||
// Paragraph
|
||||
if (/^(-{3,}|\*{3,}|_{3,})$/.test(block)) return '<hr class="md-hr">';
|
||||
const hm = block.match(/^(#{1,6}) (.+)$/);
|
||||
if (hm) return `<h${hm[1].length} class="md-h${hm[1].length}">${this._renderInline(hm[2])}</h${hm[1].length}>`;
|
||||
if (/^>\s?/.test(block)) { const inner = block.replace(/^>\s?/gm, '').trim(); return `<blockquote class="md-blockquote">${this._renderInline(inner)}</blockquote>`; }
|
||||
if (/^[\-\*]\s/.test(block)) return this._renderList(block, 'ul');
|
||||
if (/^\d+\.\s/.test(block)) return this._renderList(block, 'ol');
|
||||
return `<p class="md-p">${this._renderInline(block)}</p>`;
|
||||
},
|
||||
|
||||
|
||||
@@ -184,48 +184,77 @@
|
||||
indicator.classList.remove('hidden');
|
||||
|
||||
// Clear previous results
|
||||
document.getElementById('pr-rendered').innerHTML = '';
|
||||
document.getElementById('pr-rendered').innerHTML = '<span class="animate-pulse text-gray-400">正在生成...</span>';
|
||||
document.getElementById('pr-markdown').textContent = '';
|
||||
markdownContent = '';
|
||||
|
||||
// Helper: update both columns from current fields
|
||||
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;
|
||||
}
|
||||
|
||||
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 = data.content || fields.title;
|
||||
updateColumns();
|
||||
document.getElementById('streaming-status').textContent = '解析标题...';
|
||||
},
|
||||
type(data) {
|
||||
fields.type += data.content || '';
|
||||
fields.type = data.content || fields.type;
|
||||
updateColumns();
|
||||
document.getElementById('streaming-status').textContent = '解析类型...';
|
||||
},
|
||||
summary(data) {
|
||||
fields.summary += data.content || '';
|
||||
fields.summary = data.content || fields.summary;
|
||||
updateColumns();
|
||||
document.getElementById('streaming-status').textContent = '解析摘要...';
|
||||
},
|
||||
detail(data) {
|
||||
fields.details += data.content || '';
|
||||
fields.details = data.content || fields.details;
|
||||
updateColumns();
|
||||
document.getElementById('streaming-status').textContent = '解析详细说明...';
|
||||
},
|
||||
impact(data) {
|
||||
fields.impact += data.content || '';
|
||||
fields.impact = data.content || fields.impact;
|
||||
updateColumns();
|
||||
document.getElementById('streaming-status').textContent = '解析影响范围...';
|
||||
},
|
||||
done() {
|
||||
done = true;
|
||||
updateColumns();
|
||||
indicator.classList.add('hidden');
|
||||
btn.disabled = false;
|
||||
@@ -233,6 +262,7 @@
|
||||
btn.classList.remove('opacity-70', 'cursor-not-allowed');
|
||||
},
|
||||
error(data) {
|
||||
done = true;
|
||||
showToast('生成失败: ' + (data.message || '未知错误'), 'error');
|
||||
indicator.classList.add('hidden');
|
||||
btn.disabled = false;
|
||||
|
||||
Reference in New Issue
Block a user