From f50d87fc7971459afdb2aee618328c001ee91f1a Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 7 Jun 2026 03:34:00 +0800 Subject: [PATCH] feat(chat): render streaming code blocks --- static/src/composables/useMarkdownRenderer.ts | 36 +++++++++++++++---- .../styles/components/chat/_chat-area.scss | 8 +++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/static/src/composables/useMarkdownRenderer.ts b/static/src/composables/useMarkdownRenderer.ts index 0e0017f..a86522a 100644 --- a/static/src/composables/useMarkdownRenderer.ts +++ b/static/src/composables/useMarkdownRenderer.ts @@ -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(); 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( /
]*)>([\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, '&')
         .replace(/
+            
${language} @@ -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); diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss index 0e4d328..9f840b9 100644 --- a/static/src/styles/components/chat/_chat-area.scss +++ b/static/src/styles/components/chat/_chat-area.scss @@ -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;