From 737c4d61a28df4b8346e67a47520236158b970b5 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sat, 27 Jun 2026 23:11:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E4=BF=AE=E5=A4=8D=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E4=BB=A3=E7=A0=81=E5=9D=97=E9=97=AA=E7=83=81=E3=80=81?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E9=94=99=E4=BD=8D=E5=8F=8A=E6=95=B0=E5=AD=A6?= =?UTF-8?q?=E5=85=AC=E5=BC=8F=E5=AE=9E=E6=97=B6=E5=A4=9A=E8=A1=8C=E6=B8=B2?= =?UTF-8?q?=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将聊天消息中的代码块提取为独立 Vue 组件,稳定 key 避免流式输出时重建 - 代码块复制按钮直接复制当前组件内容,全局复制逻辑改为按容器查找 - 预处理 LaTeX 公式占位符并在组件 onUpdated 中实时渲染,支持多行 49888...49888 - 公式块过长时提供横向滚动,渲染后主动追底避免滚动锁定失效 --- static/src/app/methods/ui/system.ts | 20 +- static/src/components/chat/ChatArea.vue | 32 ++- static/src/components/chat/CodeBlock.vue | 73 +++++ .../src/components/chat/MarkdownRenderer.vue | 74 +++++ static/src/components/chat/MinimalBlocks.vue | 13 +- .../components/chat/actions/TextAction.vue | 6 +- static/src/composables/useMarkdownRenderer.ts | 253 +++++++++++++++--- .../styles/components/chat/_chat-area.scss | 36 +++ 8 files changed, 431 insertions(+), 76 deletions(-) create mode 100644 static/src/components/chat/CodeBlock.vue create mode 100644 static/src/components/chat/MarkdownRenderer.vue diff --git a/static/src/app/methods/ui/system.ts b/static/src/app/methods/ui/system.ts index 10dfdb7..f7bbe46 100644 --- a/static/src/app/methods/ui/system.ts +++ b/static/src/app/methods/ui/system.ts @@ -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; } diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index d17a50d..4150aa5 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -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 @@
-
-
+
@@ -439,11 +437,10 @@
-
-
+
@@ -608,12 +605,13 @@ diff --git a/static/src/components/chat/MarkdownRenderer.vue b/static/src/components/chat/MarkdownRenderer.vue new file mode 100644 index 0000000..4de5c22 --- /dev/null +++ b/static/src/components/chat/MarkdownRenderer.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/static/src/components/chat/MinimalBlocks.vue b/static/src/components/chat/MinimalBlocks.vue index 9ec8b43..e02f922 100644 --- a/static/src/components/chat/MinimalBlocks.vue +++ b/static/src/components/chat/MinimalBlocks.vue @@ -116,8 +116,7 @@ :class="{ 'streaming-text': group.streaming }" >
-
-
+
@@ -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; formatSearchTime?: (filters: Record) => string; formatSearchDomains?: (filters: Record) => 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, '
'); }; -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; diff --git a/static/src/components/chat/actions/TextAction.vue b/static/src/components/chat/actions/TextAction.vue index a28d605..d215707 100644 --- a/static/src/components/chat/actions/TextAction.vue +++ b/static/src/components/chat/actions/TextAction.vue @@ -1,17 +1,17 @@ diff --git a/static/src/composables/useMarkdownRenderer.ts b/static/src/composables/useMarkdownRenderer.ts index 3ec1df1..833fab1 100644 --- a/static/src/composables/useMarkdownRenderer.ts +++ b/static/src/composables/useMarkdownRenderer.ts @@ -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, '''); +} + const SHOW_HTML_ALLOWED_RATIOS = ['1:1', '16:9', '9:16', '4:3', '3:4'] as const; const SHOW_HTML_RATIO_VALUES: Record = { '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 ``; + }); + + // 行内 $...$(排除 $ 本身和反斜杠转义) + protectedText = protectedText.replace( + /(? { + const trimmed = latex.replace(/\s+/g, ' ').trim(); + if (!trimmed) return match; + return ``; + } + ); + + // 还原保护内容 + 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; function readAttr(props: RehypeProps | undefined, name: string): string { @@ -467,6 +530,7 @@ const sanitizedSchema: Record = { 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( /
]*)>([\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, '
'); + } + + 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); }); }); } diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss index be71f12..e58e021 100644 --- a/static/src/styles/components/chat/_chat-area.scss +++ b/static/src/styles/components/chat/_chat-area.scss @@ -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; +}