fix: 真正的流式输出 + 修复 Markdown 标题渲染
Deploy PR-Helper / deploy (push) Successful in 27s

流式输出:
- 恢复 content 事件处理,实时累积 LLM 原始输出
- parsePartialJSON 从不完整 JSON 中渐进提取已闭合的字段
- 每次提取到新字段即更新两栏,实现左栏渐进渲染
- 结构化事件(title/detail等)覆盖解析结果,确保最终一致

Markdown 渲染:
- 重写为逐行处理(_renderLines),不再按双换行分块
- ### 及更深层级标题在单换行后也能正确识别为独立块
- 列表项、水平线等同样受益于逐行解析
This commit is contained in:
2026-06-21 15:57:54 +08:00
parent cd1141d099
commit 779bd9e332
2 changed files with 94 additions and 42 deletions
+57 -35
View File
@@ -29,10 +29,8 @@ const Markdown = {
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// 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 (/^&gt;\s?/.test(trimmed)) { flushPara(); out.push(`<blockquote class="md-blockquote">${this._renderInline(trimmed.replace(/^&gt;\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 (/^&gt;\s?/.test(block)) {
const inner = block.replace(/^&gt;\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 (/^&gt;\s?/.test(block)) { const inner = block.replace(/^&gt;\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>`;
},