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 @@
]*)>([\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;
+}