fix(chat): 修复流式代码块闪烁、复制错位及数学公式实时多行渲染
- 将聊天消息中的代码块提取为独立 Vue 组件,稳定 key 避免流式输出时重建 - 代码块复制按钮直接复制当前组件内容,全局复制逻辑改为按容器查找 - 预处理 LaTeX 公式占位符并在组件 onUpdated 中实时渲染,支持多行 49888...49888 - 公式块过长时提供横向滚动,渲染后主动追底避免滚动锁定失效
This commit is contained in:
parent
f7a4708a84
commit
737c4d61a2
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
73
static/src/components/chat/CodeBlock.vue
Normal file
73
static/src/components/chat/CodeBlock.vue
Normal 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>
|
||||
74
static/src/components/chat/MarkdownRenderer.vue
Normal file
74
static/src/components/chat/MarkdownRenderer.vue
Normal 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>
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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, '&')
|
||||
@ -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, '&')
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user