fix(chat): 修复流式代码块闪烁、复制错位及数学公式实时多行渲染

- 将聊天消息中的代码块提取为独立 Vue 组件,稳定 key 避免流式输出时重建

- 代码块复制按钮直接复制当前组件内容,全局复制逻辑改为按容器查找

- 预处理 LaTeX 公式占位符并在组件 onUpdated 中实时渲染,支持多行 49888...49888

- 公式块过长时提供横向滚动,渲染后主动追底避免滚动锁定失效
This commit is contained in:
JOJO 2026-06-27 23:11:09 +08:00
parent f7a4708a84
commit 737c4d61a2
8 changed files with 431 additions and 76 deletions

View File

@ -158,18 +158,24 @@ export const systemMethods = {
return;
}
const blockId = target.getAttribute('data-code');
if (!blockId) {
return;
}
// 防止重复点击
if (target.classList.contains('copied')) {
return;
}
const selector = `[data-code-id="${blockId.replace(/"/g, '\\"')}"]`;
const codeEl = document.querySelector(selector);
// 优先从按钮所在的代码块容器直接读取,避免依赖可能不稳定的 blockId
const wrapper = target.closest('.code-block-wrapper');
let codeEl = wrapper ? wrapper.querySelector('pre code') : null;
// 兜底:旧版 renderMarkdown 输出的代码块仍带有 data-code
if (!codeEl) {
const blockId = target.getAttribute('data-code');
if (blockId) {
const selector = `[data-code-id="${blockId.replace(/"/g, '\\"')}"]`;
codeEl = document.querySelector(selector);
}
}
if (!codeEl) {
return;
}

View File

@ -105,7 +105,6 @@
:format-search-topic="formatSearchTopic"
:format-search-time="formatSearchTime"
:format-search-domains="formatSearchDomains"
:render-markdown="renderMarkdown"
:register-thinking-ref="registerThinkingRef"
:handle-thinking-scroll="props.handleThinkingScroll"
@group-toggle="handleMinimalGroupToggle"
@ -215,11 +214,10 @@
<div v-else-if="group.action?.type === 'text'" class="text-output">
<div class="text-content" :class="{ 'streaming-text': group.action.streaming }">
<div
v-if="group.action.streaming"
v-html="renderMarkdown(group.action.content, true)"
></div>
<div v-else v-html="renderMarkdown(group.action.content, false)"></div>
<MarkdownRenderer
:content="group.action.content || ''"
:is-streaming="group.action.streaming"
/>
</div>
</div>
@ -439,11 +437,10 @@
<div v-else-if="action.type === 'text'" class="text-output">
<div class="text-content" :class="{ 'streaming-text': action.streaming }">
<div
v-if="action.streaming"
v-html="renderMarkdown(action.content, true)"
></div>
<div v-else v-html="renderMarkdown(action.content, false)"></div>
<MarkdownRenderer
:content="action.content || ''"
:is-streaming="action.streaming"
/>
</div>
</div>
@ -608,12 +605,13 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
import { useStickToBottom } from 'vue-stick-to-bottom';
import { useBlockExpansionAnchor } from '@/composables/useBlockExpansionAnchor';
import ToolAction from '@/components/chat/actions/ToolAction.vue';
import StackedBlocks from './StackedBlocks.vue';
import MinimalBlocks from './MinimalBlocks.vue';
import MarkdownRenderer from './MarkdownRenderer.vue';
import { usePersonalizationStore } from '@/stores/personalization';
import { getMessageVisibility, messageStartsWork } from '@/utils/messageVisibility';
@ -804,6 +802,16 @@ const {
},
initial: 'instant'
});
// KaTeX stick-to-bottom
provide('mathRenderedCallback', () => {
if (isNearBottom.value && stickScrollToBottom) {
requestAnimationFrame(() => {
stickScrollToBottom({ animation: 'instant', preserveScrollPosition: false });
});
}
});
const rootEl = scrollRef;
const { anchorBlockElement, stopAll: stopBlockExpansionAnchors } = useBlockExpansionAnchor(
scrollRef,

View File

@ -0,0 +1,73 @@
<template>
<div
class="code-block-wrapper"
:data-streaming="isStreaming ? '1' : undefined"
data-md-code-block="1"
>
<div class="code-block-header">
<span class="code-language">{{ displayLanguage }}</span>
<button
class="copy-code-btn"
:class="{ copied }"
title="复制代码"
aria-label="复制代码"
@click="handleCopy"
></button>
</div>
<pre><code ref="codeEl" :class="codeClass">{{ content }}</code></pre>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import Prism from 'prismjs';
defineOptions({ name: 'CodeBlock' });
const props = defineProps<{
content: string;
language?: string;
isStreaming?: boolean;
}>();
const codeEl = ref<HTMLElement | null>(null);
const copied = ref(false);
const displayLanguage = computed(() => props.language || 'text');
const codeClass = computed(() => {
if (!props.language || props.language === 'text') {
return 'language-plain';
}
return `language-${props.language}`;
});
function highlight() {
nextTick(() => {
if (!codeEl.value || typeof Prism === 'undefined') return;
try {
Prism.highlightElement(codeEl.value);
} catch (error) {
console.warn('代码高亮失败:', error);
}
});
}
watch(() => props.content, highlight, { immediate: true });
onMounted(highlight);
async function handleCopy(event: MouseEvent) {
event.stopPropagation();
if (copied.value || !props.content) return;
try {
await navigator.clipboard.writeText(props.content);
copied.value = true;
window.setTimeout(() => {
copied.value = false;
}, 5000);
} catch (error) {
console.warn('复制失败:', error);
}
}
</script>

View File

@ -0,0 +1,74 @@
<template>
<div ref="containerRef" class="markdown-renderer">
<template v-for="segment in segments" :key="segment.key">
<div
v-if="segment.type === 'text'"
class="markdown-text-segment"
v-html="renderText(segment.content)"
></div>
<CodeBlock
v-else
:content="segment.content"
:language="segment.language"
:is-streaming="!segment.closed"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, inject, nextTick, onMounted, onUpdated, ref, watch } from 'vue';
import CodeBlock from './CodeBlock.vue';
import {
parseMarkdownSegments,
renderMarkdownText,
renderMathBlocks
} from '@/composables/useMarkdownRenderer';
defineOptions({ name: 'MarkdownRenderer' });
const props = defineProps<{
content: string;
isStreaming?: boolean;
}>();
const containerRef = ref<HTMLElement | null>(null);
const onMathRendered = inject<() => void>('mathRenderedCallback', () => {});
const segments = computed(() => parseMarkdownSegments(props.content || '', props.isStreaming));
function renderText(text: string) {
return renderMarkdownText(text);
}
function renderMath() {
nextTick(() => {
if (containerRef.value) {
renderMathBlocks(containerRef.value);
onMathRendered();
}
});
}
onMounted(renderMath);
onUpdated(renderMath);
watch(() => props.content, renderMath, { immediate: true });
</script>
<style scoped>
.markdown-renderer {
display: contents;
}
.markdown-text-segment {
display: block;
}
.markdown-text-segment > *:first-child {
margin-top: 0;
}
.markdown-text-segment > *:last-child {
margin-bottom: 0;
}
</style>

View File

@ -116,8 +116,7 @@
:class="{ 'streaming-text': group.streaming }"
>
<div class="text-content" :class="{ 'streaming-text': group.streaming }">
<div v-if="group.streaming" v-html="renderMarkdown(group.content || '', true)"></div>
<div v-else v-html="renderMarkdown(group.content || '', false)"></div>
<MarkdownRenderer :content="group.content || ''" :is-streaming="group.streaming" />
</div>
</div>
<div v-else-if="group.type === 'system'" class="summary-group sub-agent-system-group">
@ -150,6 +149,7 @@ import { ref, reactive, computed, watch, nextTick, onBeforeUnmount, Component }
import { usePersonalizationStore } from '@/stores/personalization';
import { renderEnhancedToolResult } from './actions/toolRenderers';
import { getRandomLoader } from './loaders/index';
import MarkdownRenderer from './MarkdownRenderer.vue';
interface Action {
id?: string;
@ -196,7 +196,6 @@ const props = defineProps<{
formatSearchTopic?: (filters: Record<string, any>) => string;
formatSearchTime?: (filters: Record<string, any>) => string;
formatSearchDomains?: (filters: Record<string, any>) => string;
renderMarkdown?: (content: string, isStreaming: boolean) => string;
registerThinkingRef?: (key: string, el: Element | null) => void;
handleThinkingScroll?: (blockId: string, event: Event) => void;
}>();
@ -786,14 +785,6 @@ const renderContent = (content: string) => {
return escapeHtml(content).replace(/\n/g, '<br>');
};
const renderMarkdown = (content: string, isStreaming: boolean) => {
if (props.renderMarkdown) {
return props.renderMarkdown(content, isStreaming);
}
// renderMarkdown使HTML
return renderContent(content);
};
const escapeHtml = (text: string) => {
const div = document.createElement('div');
div.textContent = text;

View File

@ -1,17 +1,17 @@
<template>
<div class="text-output">
<div class="text-content" :class="{ 'streaming-text': action.streaming }">
<div v-if="action.streaming" v-html="renderMarkdown(action.content, true)"></div>
<div v-else v-html="renderMarkdown(action.content, false)"></div>
<MarkdownRenderer :content="action.content || ''" :is-streaming="action.streaming" />
</div>
</div>
</template>
<script setup lang="ts">
import MarkdownRenderer from '../MarkdownRenderer.vue';
defineOptions({ name: 'TextAction' });
defineProps<{
action: any;
renderMarkdown: (text: string, isStreaming?: boolean) => string;
}>();
</script>

View File

@ -1,5 +1,5 @@
import Prism from 'prismjs';
import renderMathInElement from 'katex/contrib/auto-render';
import katex from 'katex';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
@ -50,6 +50,15 @@ function encodeBase64Utf8(input: string) {
}
}
function escapeHtmlAttribute(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
const SHOW_HTML_ALLOWED_RATIOS = ['1:1', '16:9', '9:16', '4:3', '3:4'] as const;
const SHOW_HTML_RATIO_VALUES: Record<string, number> = {
'1:1': 1,
@ -304,6 +313,60 @@ function transformShowFileBlocks(raw: string) {
return output;
}
/**
* LaTeX markdown parser
* $$...$$ $...$
* renderMathBlocks / renderMathInElement
*/
function transformMathBlocks(raw: string): string {
if (!raw || raw.indexOf('$') === -1) return raw;
// 先保护代码块和行内代码,避免其中的 $ 被误替换
const CODE_BLOCK_PLACEHOLDER = '__MATH_PROTECT_CODE_BLOCK_';
const INLINE_CODE_PLACEHOLDER = '__MATH_PROTECT_INLINE_CODE_';
const codeBlocks: string[] = [];
let protectedText = raw.replace(/```[\s\S]*?```/g, (match) => {
codeBlocks.push(match);
return `${CODE_BLOCK_PLACEHOLDER}${codeBlocks.length - 1}__`;
});
const inlineCodes: string[] = [];
protectedText = protectedText.replace(/`[^`\n]+`/g, (match) => {
inlineCodes.push(match);
return `${INLINE_CODE_PLACEHOLDER}${inlineCodes.length - 1}__`;
});
// 块级 $$...$$
protectedText = protectedText.replace(/\$\$([\s\S]*?)\$\$/g, (match, latex: string) => {
const trimmed = latex.replace(/\s+/g, ' ').trim();
if (!trimmed) return match;
return `<span class="math-block" data-latex="${escapeHtmlAttribute(trimmed)}" data-display="block"></span>`;
});
// 行内 $...$(排除 $ 本身和反斜杠转义)
protectedText = protectedText.replace(
/(?<![\\$])\$([^$\n]+?)\$(?![\\$])/g,
(match, latex: string) => {
const trimmed = latex.replace(/\s+/g, ' ').trim();
if (!trimmed) return match;
return `<span class="math-inline" data-latex="${escapeHtmlAttribute(trimmed)}" data-display="inline"></span>`;
}
);
// 还原保护内容
protectedText = protectedText.replace(
new RegExp(`${CODE_BLOCK_PLACEHOLDER}(\\d+)__`, 'g'),
(_, i: string) => codeBlocks[Number(i)]
);
protectedText = protectedText.replace(
new RegExp(`${INLINE_CODE_PLACEHOLDER}(\\d+)__`, 'g'),
(_, i: string) => inlineCodes[Number(i)]
);
return protectedText;
}
type RehypeProps = Record<string, unknown>;
function readAttr(props: RehypeProps | undefined, name: string): string {
@ -467,6 +530,7 @@ const sanitizedSchema: Record<string, any> = {
attributes: {
...(defaultSchema.attributes || {}),
div: [...((defaultSchema.attributes || {}).div || []), 'className', 'data-md-table-scroll'],
span: [...((defaultSchema.attributes || {}).span || []), 'className', 'dataLatex', 'dataDisplay', 'dataMathRendered', 'data-latex', 'data-display', 'data-math-rendered'],
a: [
'ariaDescribedBy', 'ariaLabel', 'ariaLabelledBy',
'dataFootnoteBackref', 'dataFootnoteRef',
@ -517,13 +581,12 @@ function buildCacheKey(text: string): string {
}
function wrapCodeBlocks(html: string, isStreaming = false) {
let counter = 0;
return html.replace(
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
(match, attributes, content) => {
const langMatch = attributes.match(/class="[^"]*language-([\w-]+)/);
const language = langMatch ? langMatch[1] : 'text';
const blockId = `code-${stableHash(`${attributes}|${counter++}`)}`;
const blockId = `code-${stableHash(`${attributes}|${content}`)}`;
const streamingAttr = isStreaming ? ' data-streaming="1"' : '';
const escapedContent = content
.replace(/&/g, '&amp;')
@ -568,6 +631,143 @@ function renderMarkdownToHtml(text: string): string {
return String(file);
}
export interface MarkdownSegment {
type: 'text' | 'code';
content: string;
language?: string;
closed: boolean;
key: string;
}
function hashCodeBlock(language: string, content: string): string {
return stableHash(`${language || ''}|${content}`);
}
export function parseMarkdownSegments(text: string, isStreaming = false): MarkdownSegment[] {
if (!text) {
return [{ type: 'text', content: '', closed: true, key: 'text-0' }];
}
const segments: MarkdownSegment[] = [];
let cursor = 0;
const regex = /^```([^\n]*)\n([\s\S]*?)^```/gm;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > cursor) {
segments.push({
type: 'text',
content: text.slice(cursor, match.index),
closed: true,
key: `text-${cursor}`
});
}
const language = match[1].trim();
const content = match[2];
const key = `code-${hashCodeBlock(language, content)}`;
segments.push({
type: 'code',
content,
language,
closed: true,
key
});
cursor = regex.lastIndex;
}
if (cursor < text.length) {
const remaining = text.slice(cursor);
if (isStreaming) {
const openMatch = remaining.match(/^```([^\n]*)\n([\s\S]*)$/m);
if (openMatch) {
if (openMatch.index && openMatch.index > 0) {
segments.push({
type: 'text',
content: remaining.slice(0, openMatch.index),
closed: true,
key: `text-${cursor}`
});
}
const language = openMatch[1].trim();
const content = openMatch[2];
segments.push({
type: 'code',
content,
language,
closed: false,
key: 'code-streaming-active'
});
return segments;
}
}
segments.push({
type: 'text',
content: remaining,
closed: true,
key: `text-${cursor}`
});
}
return segments;
}
export function renderMarkdownText(text: string): string {
if (!text) return '';
const safeText = transformMathBlocks(
transformShowFileBlocks(
transformShowImageBlocks(transformShowHtmlBlocks(text, false))
)
);
let html = '';
try {
html = renderMarkdownToHtml(safeText);
} catch (error) {
console.warn('Markdown 渲染失败,降级为纯文本渲染:', error);
html = escapeHtml(safeText).replace(/\n/g, '<br>');
}
return html;
}
export function renderMathBlocks(container: HTMLElement) {
if (!container || typeof window === 'undefined') return;
const elements = container.querySelectorAll(
'.math-block:not([data-math-rendered]), .math-inline:not([data-math-rendered])'
);
elements.forEach((el) => {
const latex = el.getAttribute('data-latex');
if (!latex) return;
const display = el.getAttribute('data-display') === 'block';
try {
el.innerHTML = katex.renderToString(latex, {
throwOnError: false,
displayMode: display,
trust: true
});
el.setAttribute('data-math-rendered', 'true');
} catch (error) {
console.warn('LaTeX渲染失败:', error);
el.setAttribute('data-math-rendered', 'error');
}
});
}
function renderMathBlocksForSelector(selector: string) {
if (typeof document === 'undefined') return;
const elements = document.querySelectorAll(selector);
elements.forEach((el) => renderMathBlocks(el as HTMLElement));
}
function escapeHtml(input: string) {
return input
.replace(/&/g, '&amp;')
@ -589,8 +789,10 @@ export function renderMarkdown(text: string, isStreaming = false) {
}
}
const safeText = transformShowFileBlocks(
transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming))
const safeText = transformMathBlocks(
transformShowFileBlocks(
transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming))
)
);
let html = '';
try {
@ -632,29 +834,7 @@ export function renderMarkdown(text: string, isStreaming = false) {
});
}
if (typeof renderMathInElement !== 'undefined') {
const elements = document.querySelectorAll(
'.text-output .text-content:not(.streaming-text)'
);
elements.forEach((element) => {
if (element.hasAttribute('data-math-rendered')) return;
try {
renderMathInElement(element as HTMLElement, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '\\(', right: '\\)', display: false }
],
throwOnError: false,
trust: true
});
element.setAttribute('data-math-rendered', 'true');
} catch (error) {
console.warn('LaTeX渲染失败:', error);
}
});
}
renderMathBlocksForSelector('.text-output .text-content:not(.streaming-text)');
}, 100);
}
@ -662,7 +842,7 @@ export function renderMarkdown(text: string, isStreaming = false) {
}
export function renderLatexInRealtime() {
if (typeof renderMathInElement === 'undefined') {
if (typeof window === 'undefined') {
return;
}
@ -673,20 +853,7 @@ export function renderLatexInRealtime() {
latexRenderTimer = requestAnimationFrame(() => {
const elements = document.querySelectorAll('.text-output .streaming-text');
elements.forEach((element) => {
try {
renderMathInElement(element as HTMLElement, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '\\(', right: '\\)', display: false }
],
throwOnError: false,
trust: true
});
} catch {
// ignore
}
renderMathBlocks(element as HTMLElement);
});
});
}

View File

@ -1549,3 +1549,39 @@ a.md-download-link {
opacity: 0.8;
}
}
// ===== 数学公式样式 =====
.math-block {
display: block;
margin: 16px 0;
overflow-x: auto;
overflow-y: hidden;
text-align: center;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--claude-text-secondary) 55%, transparent) transparent;
&::-webkit-scrollbar {
height: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--claude-text-secondary) 55%, transparent);
border-radius: 999px;
}
.katex-display {
display: inline-block;
margin: 0;
text-align: initial;
}
}
.math-inline {
display: inline-block;
vertical-align: middle;
line-height: 1;
}