feat(chat): render streaming code blocks

This commit is contained in:
JOJO 2026-06-07 03:34:00 +08:00
parent f383b0ec71
commit f50d87fc79
2 changed files with 37 additions and 7 deletions

View File

@ -11,6 +11,7 @@ import rehypeStringify from 'rehype-stringify';
import { visit } from 'unist-util-visit';
let latexRenderTimer: number | null = null;
let streamingCodeHighlightTimer: number | null = null;
const markdownCache = new Map<string, string>();
let showHtmlDebugCount = 0;
const SHOW_HTML_DEBUG_MAX = 500;
@ -395,17 +396,14 @@ function buildCacheKey(text: string): string {
}
function wrapCodeBlocks(html: string, isStreaming = false) {
if (isStreaming) {
return html;
}
let counter = 0;
return html.replace(
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
(match, attributes, content) => {
const langMatch = attributes.match(/class="[^"]*language-(\w+)/);
const langMatch = attributes.match(/class="[^"]*language-([\w-]+)/);
const language = langMatch ? langMatch[1] : 'text';
const blockId = `code-${stableHash(`${attributes}|${content}|${counter++}`)}`;
const blockId = `code-${stableHash(`${attributes}|${counter++}`)}`;
const streamingAttr = isStreaming ? ' data-streaming="1"' : '';
const escapedContent = content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
@ -413,7 +411,7 @@ function wrapCodeBlocks(html: string, isStreaming = false) {
.replace(/"/g, '&quot;');
return `
<div class="code-block-wrapper">
<div class="code-block-wrapper"${streamingAttr} data-md-code-block="1">
<div class="code-block-header">
<span class="code-language">${language}</span>
<button class="copy-code-btn" data-code="${blockId}" title="复制代码" aria-label="复制代码"></button>
@ -424,6 +422,26 @@ function wrapCodeBlocks(html: string, isStreaming = false) {
);
}
function scheduleStreamingCodeHighlight() {
if (typeof window === 'undefined') return;
if (streamingCodeHighlightTimer !== null) return;
streamingCodeHighlightTimer = window.setTimeout(() => {
streamingCodeHighlightTimer = null;
if (typeof Prism === 'undefined') return;
const codeBlocks = document.querySelectorAll('.code-block-wrapper[data-streaming="1"] pre code');
codeBlocks.forEach((block) => {
try {
Prism.highlightElement(block as HTMLElement);
} catch (error) {
console.warn('流式代码高亮失败:', error);
}
});
}, 120);
}
function renderMarkdownToHtml(text: string): string {
const file = markdownProcessor.processSync(text);
return String(file);
@ -460,6 +478,10 @@ export function renderMarkdown(text: string, isStreaming = false) {
}
html = wrapCodeBlocks(html, isStreaming);
if (isStreaming) {
scheduleStreamingCodeHighlight();
}
if (!isStreaming && text.length < 10000) {
const cacheKey = buildCacheKey(text);
markdownCache.set(cacheKey, html);

View File

@ -1371,6 +1371,10 @@ show-html:not([data-rendered='1'])[ratio='3:4'] {
box-shadow: none;
}
.code-block-wrapper[data-streaming='1'] {
min-height: 92px;
}
.code-block-header {
min-height: 44px;
background: var(--theme-surface-strong);
@ -1448,6 +1452,10 @@ show-html:not([data-rendered='1'])[ratio='3:4'] {
box-sizing: border-box;
}
.code-block-wrapper[data-streaming='1'] pre {
min-height: 40px;
}
.code-block-wrapper pre code {
background: transparent !important;
padding: 0 !important;