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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockId = target.getAttribute('data-code');
|
|
||||||
if (!blockId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 防止重复点击
|
// 防止重复点击
|
||||||
if (target.classList.contains('copied')) {
|
if (target.classList.contains('copied')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 优先从按钮所在的代码块容器直接读取,避免依赖可能不稳定的 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, '\\"')}"]`;
|
const selector = `[data-code-id="${blockId.replace(/"/g, '\\"')}"]`;
|
||||||
const codeEl = document.querySelector(selector);
|
codeEl = document.querySelector(selector);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!codeEl) {
|
if (!codeEl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -105,7 +105,6 @@
|
|||||||
:format-search-topic="formatSearchTopic"
|
:format-search-topic="formatSearchTopic"
|
||||||
:format-search-time="formatSearchTime"
|
:format-search-time="formatSearchTime"
|
||||||
:format-search-domains="formatSearchDomains"
|
:format-search-domains="formatSearchDomains"
|
||||||
:render-markdown="renderMarkdown"
|
|
||||||
:register-thinking-ref="registerThinkingRef"
|
:register-thinking-ref="registerThinkingRef"
|
||||||
:handle-thinking-scroll="props.handleThinkingScroll"
|
:handle-thinking-scroll="props.handleThinkingScroll"
|
||||||
@group-toggle="handleMinimalGroupToggle"
|
@group-toggle="handleMinimalGroupToggle"
|
||||||
@ -215,11 +214,10 @@
|
|||||||
|
|
||||||
<div v-else-if="group.action?.type === 'text'" class="text-output">
|
<div v-else-if="group.action?.type === 'text'" class="text-output">
|
||||||
<div class="text-content" :class="{ 'streaming-text': group.action.streaming }">
|
<div class="text-content" :class="{ 'streaming-text': group.action.streaming }">
|
||||||
<div
|
<MarkdownRenderer
|
||||||
v-if="group.action.streaming"
|
:content="group.action.content || ''"
|
||||||
v-html="renderMarkdown(group.action.content, true)"
|
:is-streaming="group.action.streaming"
|
||||||
></div>
|
/>
|
||||||
<div v-else v-html="renderMarkdown(group.action.content, false)"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -439,11 +437,10 @@
|
|||||||
|
|
||||||
<div v-else-if="action.type === 'text'" class="text-output">
|
<div v-else-if="action.type === 'text'" class="text-output">
|
||||||
<div class="text-content" :class="{ 'streaming-text': action.streaming }">
|
<div class="text-content" :class="{ 'streaming-text': action.streaming }">
|
||||||
<div
|
<MarkdownRenderer
|
||||||
v-if="action.streaming"
|
:content="action.content || ''"
|
||||||
v-html="renderMarkdown(action.content, true)"
|
:is-streaming="action.streaming"
|
||||||
></div>
|
/>
|
||||||
<div v-else v-html="renderMarkdown(action.content, false)"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -608,12 +605,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useStickToBottom } from 'vue-stick-to-bottom';
|
||||||
import { useBlockExpansionAnchor } from '@/composables/useBlockExpansionAnchor';
|
import { useBlockExpansionAnchor } from '@/composables/useBlockExpansionAnchor';
|
||||||
import ToolAction from '@/components/chat/actions/ToolAction.vue';
|
import ToolAction from '@/components/chat/actions/ToolAction.vue';
|
||||||
import StackedBlocks from './StackedBlocks.vue';
|
import StackedBlocks from './StackedBlocks.vue';
|
||||||
import MinimalBlocks from './MinimalBlocks.vue';
|
import MinimalBlocks from './MinimalBlocks.vue';
|
||||||
|
import MarkdownRenderer from './MarkdownRenderer.vue';
|
||||||
import { usePersonalizationStore } from '@/stores/personalization';
|
import { usePersonalizationStore } from '@/stores/personalization';
|
||||||
import { getMessageVisibility, messageStartsWork } from '@/utils/messageVisibility';
|
import { getMessageVisibility, messageStartsWork } from '@/utils/messageVisibility';
|
||||||
|
|
||||||
@ -804,6 +802,16 @@ const {
|
|||||||
},
|
},
|
||||||
initial: 'instant'
|
initial: 'instant'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 公式渲染后如果已经在底部附近,主动追底,避免 KaTeX 渲染导致的高度跳变让 stick-to-bottom 锁不住
|
||||||
|
provide('mathRenderedCallback', () => {
|
||||||
|
if (isNearBottom.value && stickScrollToBottom) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
stickScrollToBottom({ animation: 'instant', preserveScrollPosition: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const rootEl = scrollRef;
|
const rootEl = scrollRef;
|
||||||
const { anchorBlockElement, stopAll: stopBlockExpansionAnchors } = useBlockExpansionAnchor(
|
const { anchorBlockElement, stopAll: stopBlockExpansionAnchors } = useBlockExpansionAnchor(
|
||||||
scrollRef,
|
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 }"
|
:class="{ 'streaming-text': group.streaming }"
|
||||||
>
|
>
|
||||||
<div class="text-content" :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>
|
<MarkdownRenderer :content="group.content || ''" :is-streaming="group.streaming" />
|
||||||
<div v-else v-html="renderMarkdown(group.content || '', false)"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="group.type === 'system'" class="summary-group sub-agent-system-group">
|
<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 { usePersonalizationStore } from '@/stores/personalization';
|
||||||
import { renderEnhancedToolResult } from './actions/toolRenderers';
|
import { renderEnhancedToolResult } from './actions/toolRenderers';
|
||||||
import { getRandomLoader } from './loaders/index';
|
import { getRandomLoader } from './loaders/index';
|
||||||
|
import MarkdownRenderer from './MarkdownRenderer.vue';
|
||||||
|
|
||||||
interface Action {
|
interface Action {
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -196,7 +196,6 @@ const props = defineProps<{
|
|||||||
formatSearchTopic?: (filters: Record<string, any>) => string;
|
formatSearchTopic?: (filters: Record<string, any>) => string;
|
||||||
formatSearchTime?: (filters: Record<string, any>) => string;
|
formatSearchTime?: (filters: Record<string, any>) => string;
|
||||||
formatSearchDomains?: (filters: Record<string, any>) => string;
|
formatSearchDomains?: (filters: Record<string, any>) => string;
|
||||||
renderMarkdown?: (content: string, isStreaming: boolean) => string;
|
|
||||||
registerThinkingRef?: (key: string, el: Element | null) => void;
|
registerThinkingRef?: (key: string, el: Element | null) => void;
|
||||||
handleThinkingScroll?: (blockId: string, event: Event) => void;
|
handleThinkingScroll?: (blockId: string, event: Event) => void;
|
||||||
}>();
|
}>();
|
||||||
@ -786,14 +785,6 @@ const renderContent = (content: string) => {
|
|||||||
return escapeHtml(content).replace(/\n/g, '<br>');
|
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 escapeHtml = (text: string) => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="text-output">
|
<div class="text-output">
|
||||||
<div class="text-content" :class="{ 'streaming-text': action.streaming }">
|
<div class="text-content" :class="{ 'streaming-text': action.streaming }">
|
||||||
<div v-if="action.streaming" v-html="renderMarkdown(action.content, true)"></div>
|
<MarkdownRenderer :content="action.content || ''" :is-streaming="action.streaming" />
|
||||||
<div v-else v-html="renderMarkdown(action.content, false)"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import MarkdownRenderer from '../MarkdownRenderer.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'TextAction' });
|
defineOptions({ name: 'TextAction' });
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
action: any;
|
action: any;
|
||||||
renderMarkdown: (text: string, isStreaming?: boolean) => string;
|
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import Prism from 'prismjs';
|
import Prism from 'prismjs';
|
||||||
import renderMathInElement from 'katex/contrib/auto-render';
|
import katex from 'katex';
|
||||||
import { unified } from 'unified';
|
import { unified } from 'unified';
|
||||||
import remarkParse from 'remark-parse';
|
import remarkParse from 'remark-parse';
|
||||||
import remarkGfm from 'remark-gfm';
|
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_ALLOWED_RATIOS = ['1:1', '16:9', '9:16', '4:3', '3:4'] as const;
|
||||||
const SHOW_HTML_RATIO_VALUES: Record<string, number> = {
|
const SHOW_HTML_RATIO_VALUES: Record<string, number> = {
|
||||||
'1:1': 1,
|
'1:1': 1,
|
||||||
@ -304,6 +313,60 @@ function transformShowFileBlocks(raw: string) {
|
|||||||
return output;
|
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>;
|
type RehypeProps = Record<string, unknown>;
|
||||||
|
|
||||||
function readAttr(props: RehypeProps | undefined, name: string): string {
|
function readAttr(props: RehypeProps | undefined, name: string): string {
|
||||||
@ -467,6 +530,7 @@ const sanitizedSchema: Record<string, any> = {
|
|||||||
attributes: {
|
attributes: {
|
||||||
...(defaultSchema.attributes || {}),
|
...(defaultSchema.attributes || {}),
|
||||||
div: [...((defaultSchema.attributes || {}).div || []), 'className', 'data-md-table-scroll'],
|
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: [
|
a: [
|
||||||
'ariaDescribedBy', 'ariaLabel', 'ariaLabelledBy',
|
'ariaDescribedBy', 'ariaLabel', 'ariaLabelledBy',
|
||||||
'dataFootnoteBackref', 'dataFootnoteRef',
|
'dataFootnoteBackref', 'dataFootnoteRef',
|
||||||
@ -517,13 +581,12 @@ function buildCacheKey(text: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function wrapCodeBlocks(html: string, isStreaming = false) {
|
function wrapCodeBlocks(html: string, isStreaming = false) {
|
||||||
let counter = 0;
|
|
||||||
return html.replace(
|
return html.replace(
|
||||||
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
|
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
|
||||||
(match, attributes, content) => {
|
(match, attributes, content) => {
|
||||||
const langMatch = attributes.match(/class="[^"]*language-([\w-]+)/);
|
const langMatch = attributes.match(/class="[^"]*language-([\w-]+)/);
|
||||||
const language = langMatch ? langMatch[1] : 'text';
|
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 streamingAttr = isStreaming ? ' data-streaming="1"' : '';
|
||||||
const escapedContent = content
|
const escapedContent = content
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@ -568,6 +631,143 @@ function renderMarkdownToHtml(text: string): string {
|
|||||||
return String(file);
|
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) {
|
function escapeHtml(input: string) {
|
||||||
return input
|
return input
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@ -589,8 +789,10 @@ export function renderMarkdown(text: string, isStreaming = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeText = transformShowFileBlocks(
|
const safeText = transformMathBlocks(
|
||||||
|
transformShowFileBlocks(
|
||||||
transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming))
|
transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming))
|
||||||
|
)
|
||||||
);
|
);
|
||||||
let html = '';
|
let html = '';
|
||||||
try {
|
try {
|
||||||
@ -632,29 +834,7 @@ export function renderMarkdown(text: string, isStreaming = false) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof renderMathInElement !== 'undefined') {
|
renderMathBlocksForSelector('.text-output .text-content:not(.streaming-text)');
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -662,7 +842,7 @@ export function renderMarkdown(text: string, isStreaming = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderLatexInRealtime() {
|
export function renderLatexInRealtime() {
|
||||||
if (typeof renderMathInElement === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -673,20 +853,7 @@ export function renderLatexInRealtime() {
|
|||||||
latexRenderTimer = requestAnimationFrame(() => {
|
latexRenderTimer = requestAnimationFrame(() => {
|
||||||
const elements = document.querySelectorAll('.text-output .streaming-text');
|
const elements = document.querySelectorAll('.text-output .streaming-text');
|
||||||
elements.forEach((element) => {
|
elements.forEach((element) => {
|
||||||
try {
|
renderMathBlocks(element as HTMLElement);
|
||||||
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
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1549,3 +1549,39 @@ a.md-download-link {
|
|||||||
opacity: 0.8;
|
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