Skip to content

Commit f0cec5f

Browse files
committed
perf(frontend): lazy-load markdown rendering, KaTeX assets, charts, and session UI to cut initial payloads
- move chat markdown rendering onto light vs rich on-demand paths - lazy-load Mermaid, code highlighting, tool-call blocks, and thinking message rendering - remove global KaTeX CSS import and load KaTeX CSS/fonts on demand from static assets - lazy-load admin dashboard charts and sandbox session manager UI - switch production build to webpack after bundle comparison showed smaller output Measured results: - build output: - /: 811 KB gzip -> 233 KB gzip - /home: 811 KB gzip -> 233 KB gzip - /message/[id]: 736 KB gzip -> 183 KB gzip - /share/[id]: 735 KB gzip -> 184 KB gzip - /admin/dashboard: 306 KB gzip -> 128 KB gzip - browser DevTools experiments: - empty chat: 3339 KB -> 1478 KB - markdown chat: 3339 KB -> 2263 KB - mermaid chart: 3468 KB -> 2947 KB - katex formula: 3381 KB -> 1840 KB - all features combined: 3518 KB -> 3318 KB
1 parent 2a6df98 commit f0cec5f

32 files changed

Lines changed: 337 additions & 167 deletions

src/FE/components/Chat/CodeExecutionControl.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,29 @@
11
import { useState, useMemo, useContext } from 'react';
22
import { createPortal } from 'react-dom';
33

4+
import dynamic from 'next/dynamic';
5+
46
import useTranslation from '@/hooks/useTranslation';
57

68
import { AdminModelDto } from '@/types/adminApis';
79
import { ChatSpanDto } from '@/types/clientApis';
810

911
import { IconDocker } from '@/components/Icons';
1012
import Tips from '@/components/Tips/Tips';
11-
import ChatSessionManagerWindow from '@/components/ChatSessionManager/ChatSessionManagerWindow';
1213

1314
import HomeContext from '@/contexts/home.context';
1415
import { setChats } from '@/actions/chat.actions';
1516
import { putChatSpan } from '@/apis/clientApis';
1617
import { cn } from '@/lib/utils';
1718

19+
const ChatSessionManagerWindow = dynamic(
20+
() => import('@/components/ChatSessionManager/ChatSessionManagerWindow'),
21+
{
22+
ssr: false,
23+
loading: () => null,
24+
},
25+
);
26+
1827
interface CodeExecutionControlProps {
1928
chatId: string;
2029
spans: ChatSpanDto[];
@@ -186,7 +195,8 @@ const CodeExecutionControl: React.FC<CodeExecutionControlProps> = ({
186195
</div>
187196

188197
{/* 沙盒管理窗口 - 使用 Portal 渲染到 body,避免被父容器限制 */}
189-
{typeof document !== 'undefined' &&
198+
{isSessionManagerOpen &&
199+
typeof document !== 'undefined' &&
190200
createPortal(
191201
<ChatSessionManagerWindow
192202
chatId={chatId}

src/FE/components/ChatMessage/ResponseMessage.tsx

Lines changed: 51 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useEffect, useMemo, useRef, useState } from 'react';
22

3+
import dynamic from 'next/dynamic';
4+
35
import useTranslation from '@/hooks/useTranslation';
46
import useMathCopy from '@/hooks/useMathCopy';
57

@@ -18,9 +20,12 @@ import {
1820
} from '@/types/chat';
1921
import { IChatMessage, IStep, getMessageContents } from '@/types/chatMessage';
2022

21-
import { CodeBlock } from '@/components/Markdown/CodeBlock';
22-
import { MemoizedReactMarkdown } from '@/components/Markdown/MemoizedReactMarkdown';
23-
import ToolCallBlock from '@/components/Markdown/ToolCallBlock';
23+
import LightMarkdown from '@/components/Markdown/LightMarkdown';
24+
import {
25+
MarkdownLoadingFallback,
26+
appendStreamingCursor,
27+
hasMathMarkdown,
28+
} from '@/components/Markdown/markdownShared';
2429
import ImagePreview from '@/components/ImagePreview/ImagePreview';
2530
import FilePreview from '@/components/FilePreview/FilePreview';
2631
import StepInfoBubble from './StepInfoBubble';
@@ -29,21 +34,28 @@ import ChatError from '../ChatError/ChatError';
2934
import { IconCopy, IconEdit } from '../Icons';
3035
import { Button } from '../ui/button';
3136
import { Textarea } from '../ui/textarea';
32-
import ThinkingMessage from './ThinkingMessage';
3337

3438
import { cn } from '@/lib/utils';
35-
import rehypeKatex from 'rehype-katex';
36-
import { rehypeKatexDataMath } from '@/components/Markdown/rehypeKatexWithCopy';
37-
import remarkGfm from 'remark-gfm';
38-
import remarkMath from 'remark-math';
39-
import remarkBreaks from 'remark-breaks';
40-
import type { Components as MarkdownComponents } from 'react-markdown';
41-
import type {
42-
CodeProps,
43-
ReactMarkdownProps,
44-
TableDataCellProps,
45-
TableHeaderCellProps,
46-
} from 'react-markdown/lib/ast-to-react';
39+
40+
const RichMarkdown = dynamic(
41+
() => import('@/components/Markdown/RichMarkdown'),
42+
{
43+
loading: () => <MarkdownLoadingFallback />,
44+
},
45+
);
46+
47+
const ToolCallBlock = dynamic(
48+
() => import('@/components/Markdown/ToolCallBlock'),
49+
{
50+
loading: () => (
51+
<div className="h-8 w-40 animate-pulse rounded-md bg-muted" />
52+
),
53+
},
54+
);
55+
56+
const ThinkingMessage = dynamic(() => import('./ThinkingMessage'), {
57+
loading: () => <MarkdownLoadingFallback />,
58+
});
4759

4860
// 骨架动画组件
4961
const SkeletonLine = ({ width = '100%', height = '1rem', delay = '0s' }: { width?: string; height?: string; delay?: string }) => (
@@ -71,59 +83,6 @@ const MessageSkeleton = () => (
7183
</div>
7284
);
7385

74-
const markdownComponents = {
75-
code({ node, className, inline, children, ...props }: CodeProps) {
76-
if (children.length) {
77-
if (children[0] == '▍') {
78-
return (
79-
<span className="animate-pulse cursor-default mt-1">
80-
81-
</span>
82-
);
83-
}
84-
}
85-
86-
const match = /language-(\w+)/.exec(className || '');
87-
88-
return !inline ? (
89-
<CodeBlock
90-
key={Math.random()}
91-
language={(match && match[1]) || ''}
92-
value={String(children).replace(/\n$/, '')}
93-
{...props}
94-
/>
95-
) : (
96-
<code className={className} {...props}>
97-
{children}
98-
</code>
99-
);
100-
},
101-
p({ children }: ReactMarkdownProps) {
102-
return <p className="md-p">{children}</p>;
103-
},
104-
table({ children }: ReactMarkdownProps) {
105-
return (
106-
<table className="border-collapse border border-black px-3 py-1 dark:border-white">
107-
{children}
108-
</table>
109-
);
110-
},
111-
th({ children }: TableHeaderCellProps) {
112-
return (
113-
<th className="break-words border border-black bg-gray-500 px-3 py-1 text-white dark:border-white">
114-
{children}
115-
</th>
116-
);
117-
},
118-
td({ children }: TableDataCellProps) {
119-
return (
120-
<td className="break-words border border-black px-3 py-1 dark:border-white">
121-
{children}
122-
</td>
123-
);
124-
},
125-
} as unknown as MarkdownComponents;
126-
12786
interface Props {
12887
message: IChatMessage;
12988
chatStatus: ChatStatus;
@@ -539,19 +498,29 @@ const ResponseMessage = (props: Props) => {
539498
<div className="whitespace-pre-wrap font-mono">{c.c}</div>
540499
</div>
541500
) : (
542-
<MemoizedReactMarkdown
543-
className="prose dark:prose-invert [--tw-prose-body:#000] [--tw-prose-headings:#000] leading-4 font-normal prose-p:text-sm prose-p:leading-4 prose-p:font-normal prose-li:text-sm prose-li:leading-4 prose-li:font-normal"
544-
// 顺序:math -> gfm -> breaks,确保数学与 GFM 处理后,再将 softbreak 转为 <br/>
545-
remarkPlugins={[remarkMath, remarkGfm, remarkBreaks]}
546-
rehypePlugins={[rehypeKatex as any, rehypeKatexDataMath]}
547-
components={markdownComponents}
548-
>
549-
{`${preprocessLaTeX(c.c!)}${
550-
(messageStatus === ChatSpanStatus.Pending || messageStatus === ChatSpanStatus.Chatting) &&
551-
index === processedContent.length - 1 &&
552-
c.$type === MessageContentType.text ? '▍' : ''
553-
}`}
554-
</MemoizedReactMarkdown>
501+
(() => {
502+
const renderedMarkdown = appendStreamingCursor(
503+
preprocessLaTeX(c.c!),
504+
(messageStatus === ChatSpanStatus.Pending ||
505+
messageStatus === ChatSpanStatus.Chatting) &&
506+
index === processedContent.length - 1 &&
507+
c.$type === MessageContentType.text,
508+
);
509+
const markdownClassName =
510+
'prose dark:prose-invert [--tw-prose-body:#000] [--tw-prose-headings:#000] leading-4 font-normal prose-p:text-sm prose-p:leading-4 prose-p:font-normal prose-li:text-sm prose-li:leading-4 prose-li:font-normal';
511+
512+
return hasMathMarkdown(c.c ?? '') ? (
513+
<RichMarkdown
514+
className={markdownClassName}
515+
content={renderedMarkdown}
516+
/>
517+
) : (
518+
<LightMarkdown
519+
className={markdownClassName}
520+
content={renderedMarkdown}
521+
/>
522+
);
523+
})()
555524
)}
556525
<div className="absolute -bottom-0.5 right-0 z-10 flex items-center gap-0.5">
557526
{!isChatting(chatStatus) && !readonly && (

src/FE/components/ChatMessage/ThinkingMessage.tsx

Lines changed: 25 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useCallback, useEffect, useMemo, useState } from 'react';
22

3+
import dynamic from 'next/dynamic';
4+
35
import useTranslation from '@/hooks/useTranslation';
46

57
import { preprocessLaTeX } from '@/utils/chats';
@@ -13,75 +15,21 @@ import {
1315
import { ChatStatus } from '@/types/chat';
1416
import { IStepGenerateInfo, ResponseMessageTempId } from '@/types/chatMessage';
1517

16-
import { CodeBlock } from '@/components/Markdown/CodeBlock';
17-
import { MemoizedReactMarkdown } from '@/components/Markdown/MemoizedReactMarkdown';
18+
import LightMarkdown from '@/components/Markdown/LightMarkdown';
19+
import {
20+
MarkdownLoadingFallback,
21+
appendStreamingCursor,
22+
hasMathMarkdown,
23+
} from '@/components/Markdown/markdownShared';
1824

1925
import { IconChevronRight, IconThink } from '../Icons';
2026

21-
import rehypeKatex from 'rehype-katex';
22-
import { rehypeKatexDataMath } from '@/components/Markdown/rehypeKatexWithCopy';
23-
import remarkGfm from 'remark-gfm';
24-
import remarkMath from 'remark-math';
25-
import type { Components as MarkdownComponents } from 'react-markdown';
26-
import type {
27-
CodeProps,
28-
ReactMarkdownProps,
29-
TableDataCellProps,
30-
TableHeaderCellProps,
31-
} from 'react-markdown/lib/ast-to-react';
32-
33-
const thinkingMarkdownComponents = {
34-
code({ node, className, inline, children, ...props }: CodeProps) {
35-
if (children.length) {
36-
if (children[0] == '▍') {
37-
return (
38-
<span className="animate-pulse cursor-default mt-1">
39-
40-
</span>
41-
);
42-
}
43-
}
44-
45-
const match = /language-(\w+)/.exec(className || '');
46-
47-
return !inline ? (
48-
<CodeBlock
49-
key={Math.random()}
50-
language={(match && match[1]) || ''}
51-
value={String(children).replace(/\n$/, '')}
52-
{...props}
53-
/>
54-
) : (
55-
<code className={className} {...props}>
56-
{children}
57-
</code>
58-
);
59-
},
60-
p({ children }: ReactMarkdownProps) {
61-
return <p className="md-p">{children}</p>;
62-
},
63-
table({ children }: ReactMarkdownProps) {
64-
return (
65-
<table className="border-collapse border border-black px-3 py-1 dark:border-white">
66-
{children}
67-
</table>
68-
);
69-
},
70-
th({ children }: TableHeaderCellProps) {
71-
return (
72-
<th className="break-words border border-black bg-gray-500 px-3 py-1 text-white dark:border-white">
73-
{children}
74-
</th>
75-
);
76-
},
77-
td({ children }: TableDataCellProps) {
78-
return (
79-
<td className="break-words border border-black px-3 py-1 dark:border-white">
80-
{children}
81-
</td>
82-
);
27+
const RichMarkdown = dynamic(
28+
() => import('@/components/Markdown/RichMarkdown'),
29+
{
30+
loading: () => <MarkdownLoadingFallback />,
8331
},
84-
} as unknown as MarkdownComponents;
32+
);
8533

8634
interface Props {
8735
readonly?: boolean;
@@ -254,13 +202,18 @@ const ThinkingMessage = (props: Props) => {
254202
>
255203
<div className="overflow-hidden">
256204
<div className="px-2 text-gray-400 text-sm mt-2">
257-
<MemoizedReactMarkdown
258-
remarkPlugins={[remarkMath, remarkGfm]}
259-
rehypePlugins={[rehypeKatex as any, rehypeKatexDataMath]}
260-
components={thinkingMarkdownComponents}
261-
>
262-
{`${preprocessLaTeX(content!)}${finished === false ? '▍' : ''}`}
263-
</MemoizedReactMarkdown>
205+
{(() => {
206+
const renderedMarkdown = appendStreamingCursor(
207+
preprocessLaTeX(content!),
208+
finished === false,
209+
);
210+
211+
return hasMathMarkdown(content ?? '') ? (
212+
<RichMarkdown content={renderedMarkdown} />
213+
) : (
214+
<LightMarkdown content={renderedMarkdown} />
215+
);
216+
})()}
264217
</div>
265218
</div>
266219
</div>
Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,38 @@
11
import { FC, memo } from 'react';
2-
import { MermaidBlock } from './MermaidBlock';
3-
import { CodeBlockCore } from './CodeBlockCore';
2+
3+
import dynamic from 'next/dynamic';
44

55
interface Props {
66
language: string;
77
value: string;
88
}
99

10+
const CodeBlockLoading = () => (
11+
<div className="rounded-md border bg-muted p-3">
12+
<div className="h-4 w-24 animate-pulse rounded bg-background/60" />
13+
<div className="mt-3 h-4 w-full animate-pulse rounded bg-background/60" />
14+
<div className="mt-2 h-4 w-4/5 animate-pulse rounded bg-background/60" />
15+
</div>
16+
);
17+
18+
const LazyCodeBlockCore = dynamic<Props>(
19+
() => import('./CodeBlockCore').then((mod) => mod.CodeBlockCore),
20+
{
21+
loading: () => <CodeBlockLoading />,
22+
},
23+
);
24+
25+
const LazyMermaidBlock = dynamic<{ value: string }>(
26+
() => import('./MermaidBlock').then((mod) => mod.MermaidBlock),
27+
{
28+
loading: () => <CodeBlockLoading />,
29+
},
30+
);
31+
1032
export const CodeBlock: FC<Props> = memo(({ language, value }) => {
11-
// 如果是mermaid语言,使用MermaidBlock组件
1233
if (language === 'mermaid') {
13-
return <MermaidBlock value={value} />;
34+
return <LazyMermaidBlock value={value} />;
1435
}
15-
return <CodeBlockCore language={language} value={value} />;
36+
return <LazyCodeBlockCore language={language} value={value} />;
1637
});
1738
CodeBlock.displayName = 'CodeBlock';

0 commit comments

Comments
 (0)