Skip to content

Commit 6712d92

Browse files
committed
feat: optimized chat render
1 parent 86dc3d4 commit 6712d92

File tree

2 files changed

+223
-140
lines changed

2 files changed

+223
-140
lines changed

web/src/components/chat/chat-list.tsx

Lines changed: 10 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,17 @@
11
import React, { useEffect, useRef, useState } from "react";
22
import Image from "next/image";
33
import Logo from "../../../public/logo.png";
4-
import CodeDisplayBlock from "../code-display-block";
5-
import Markdown from "react-markdown";
6-
import remarkGfm from "remark-gfm";
7-
import remarkMath from "remark-math";
8-
import rehypeKatex from "rehype-katex";
9-
import "katex/dist/katex.min.css";
104
import { Message } from "ai";
11-
import ThinkBlock from "./think-block";
125
import { cn } from "@/lib/utils";
136
import { ArrowDownIcon } from "@radix-ui/react-icons";
147
import { Button } from "../ui/button";
8+
import ChatMessage from "./chat-message"; // Import the new component
159

1610
interface ChatListProps {
1711
messages: Message[];
1812
isLoading: boolean;
1913
}
2014

21-
const parseMessageContent = (
22-
content: string,
23-
isLastMessage: boolean,
24-
isLoading: boolean
25-
) => {
26-
let thinkMatch = /<think>([\s\S]*?)(?:<\/think>|$)/i.exec(content);
27-
if (!thinkMatch && content.includes("</think>")) {
28-
thinkMatch = /^([\s\S]*?)(?:<\/think>)/i.exec(content);
29-
}
30-
31-
let thinkContent = null;
32-
let mainContent = content;
33-
34-
if (thinkMatch) {
35-
thinkContent = thinkMatch[1].trim();
36-
mainContent = content.replace(thinkMatch[0], "").trim();
37-
mainContent = mainContent.replace(/<\/think>/gi, "").trim();
38-
}
39-
40-
const isThinkingLive =
41-
isLoading &&
42-
isLastMessage &&
43-
(content.includes("<think>") || !content.includes("</think>")) &&
44-
!content.includes("</think>");
45-
46-
return { thinkContent, mainContent, isThinkingLive };
47-
};
48-
4915
export default function ChatList({ messages, isLoading }: ChatListProps) {
5016
const scrollRef = useRef<HTMLDivElement>(null);
5117
const bottomRef = useRef<HTMLDivElement>(null);
@@ -75,6 +41,7 @@ export default function ChatList({ messages, isLoading }: ChatListProps) {
7541
if (messages.length > 0) {
7642
const lastMessage = messages[messages.length - 1];
7743

44+
// Auto-scroll if we are already at the bottom OR if the last message is from the user
7845
if (isAtBottom || lastMessage.role === "user") {
7946
bottomRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
8047
}
@@ -112,113 +79,16 @@ export default function ChatList({ messages, isLoading }: ChatListProps) {
11279
{messages
11380
.filter((message) => message.role !== "system")
11481
.map((message, index) => {
82+
// We pass "isLast" so the component knows if it should be watching for updates
11583
const isLastMessage = index === messages.length - 1;
116-
const { thinkContent, mainContent, isThinkingLive } =
117-
parseMessageContent(message.content, isLastMessage, isLoading);
118-
119-
const isUser = message.role === "user";
120-
84+
12185
return (
122-
<div
123-
key={index}
124-
className={cn(
125-
"flex w-full",
126-
isUser ? "justify-end" : "justify-start"
127-
)}
128-
>
129-
<div
130-
className={cn(
131-
"flex flex-col",
132-
// Added min-w-0 to prevent flex item from growing beyond parent
133-
isUser ? "items-end max-w-[85%] md:max-w-[75%] min-w-0" : "w-full items-start min-w-0"
134-
)}
135-
>
136-
<div
137-
className={cn(
138-
"relative text-[15px] leading-relaxed",
139-
isUser
140-
? "px-5 py-3 rounded-2xl rounded-tr-sm bg-blue-600 text-white dark:bg-zinc-800 dark:text-foreground shadow-sm break-words" // Added break-words
141-
: "w-full px-0 py-0"
142-
)}
143-
>
144-
{!isUser && thinkContent && (
145-
<ThinkBlock
146-
content={thinkContent}
147-
isLive={isThinkingLive}
148-
/>
149-
)}
150-
151-
<div className={cn(!isUser && thinkContent && "mt-4")}>
152-
<Markdown
153-
remarkPlugins={[remarkGfm, remarkMath]}
154-
rehypePlugins={[rehypeKatex]}
155-
components={{
156-
code({ node, inline, className, children, ...props }: any) {
157-
const match = /language-(\w+)/.exec(className || "");
158-
const lang = match ? match[1] : "";
159-
return !inline && match ? (
160-
<div className="my-6 rounded-md overflow-hidden border border-border">
161-
<CodeDisplayBlock
162-
code={String(children).replace(/\n$/, "")}
163-
lang={lang}
164-
/>
165-
</div>
166-
) : (
167-
<code
168-
className={cn(
169-
"px-1.5 py-0.5 rounded-md font-mono text-[13px] border break-all", // break-all for inline code
170-
isUser
171-
? "bg-white/20 text-white border-transparent"
172-
: "bg-muted text-foreground border-border"
173-
)}
174-
{...props}
175-
>
176-
{children}
177-
</code>
178-
);
179-
},
180-
h1: ({children}) => <h1 className="text-3xl font-bold mt-8 mb-4 break-words">{children}</h1>,
181-
h2: ({children}) => <h2 className="text-2xl font-semibold mt-8 mb-4 border-b pb-2 break-words">{children}</h2>,
182-
h3: ({children}) => <h3 className="text-xl font-semibold mt-6 mb-3 break-words">{children}</h3>,
183-
h4: ({children}) => <h4 className="text-lg font-semibold mt-6 mb-3 break-words">{children}</h4>,
184-
185-
// Paragraphs: whitespace-pre-wrap ensures formatting is kept, break-words handles long strings
186-
p: ({children}) => <p className="mb-5 last:mb-0 leading-7 whitespace-pre-wrap break-words">{children}</p>,
187-
188-
a: ({children, ...props}: any) => (
189-
<a className="text-blue-500 hover:underline cursor-pointer font-medium break-all" {...props}>{children}</a>
190-
),
191-
ul: ({children}) => <ul className="list-disc pl-6 mb-5 space-y-2">{children}</ul>,
192-
ol: ({children}) => <ol className="list-decimal pl-6 mb-5 space-y-2">{children}</ol>,
193-
li: ({children}) => <li className="pl-1 leading-7">{children}</li>,
194-
blockquote: ({children}) => (
195-
<blockquote className="border-l-4 border-primary/20 bg-muted/40 pl-4 py-2 my-4 rounded-r-md italic">
196-
{children}
197-
</blockquote>
198-
),
199-
table: ({children}) => <div className="overflow-x-auto my-6 border rounded-md"><table className="w-full text-sm text-left">{children}</table></div>,
200-
th: ({children}) => <th className="bg-muted px-4 py-3 border-b font-semibold">{children}</th>,
201-
td: ({children}) => <td className="px-4 py-3 border-b last:border-0">{children}</td>,
202-
}}
203-
>
204-
{isUser ? message.content : mainContent}
205-
</Markdown>
206-
</div>
207-
208-
{isLoading &&
209-
isLastMessage &&
210-
!isUser &&
211-
!isThinkingLive &&
212-
mainContent.length === 0 && (
213-
<div className="flex items-center gap-1 h-6 mt-2 opacity-50">
214-
<span className="w-1.5 h-1.5 bg-foreground rounded-full animate-bounce [animation-delay:-0.3s]"></span>
215-
<span className="w-1.5 h-1.5 bg-foreground rounded-full animate-bounce [animation-delay:-0.15s]"></span>
216-
<span className="w-1.5 h-1.5 bg-foreground rounded-full animate-bounce"></span>
217-
</div>
218-
)}
219-
</div>
220-
</div>
221-
</div>
86+
<ChatMessage
87+
key={index} // Ideally use message.id if available, but index works for simple lists
88+
message={message}
89+
isLast={isLastMessage}
90+
isLoading={isLoading}
91+
/>
22292
);
22393
})}
22494
</div>
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
"use client";
2+
3+
import React, { useMemo } from "react";
4+
import Markdown from "react-markdown";
5+
import remarkGfm from "remark-gfm";
6+
import remarkMath from "remark-math";
7+
import rehypeKatex from "rehype-katex";
8+
import { Message } from "ai";
9+
import { cn } from "@/lib/utils";
10+
import CodeDisplayBlock from "../code-display-block";
11+
import ThinkBlock from "./think-block";
12+
import "katex/dist/katex.min.css";
13+
14+
// Helper to parse thinking blocks
15+
const parseMessageContent = (content: string) => {
16+
let thinkMatch = /<think>([\s\S]*?)(?:<\/think>|$)/i.exec(content);
17+
if (!thinkMatch && content.includes("</think>")) {
18+
thinkMatch = /^([\s\S]*?)(?:<\/think>)/i.exec(content);
19+
}
20+
21+
let thinkContent = null;
22+
let mainContent = content;
23+
24+
if (thinkMatch) {
25+
thinkContent = thinkMatch[1].trim();
26+
mainContent = content.replace(thinkMatch[0], "").trim();
27+
mainContent = mainContent.replace(/<\/think>/gi, "").trim();
28+
}
29+
30+
return { thinkContent, mainContent };
31+
};
32+
33+
interface ChatMessageProps {
34+
message: Message;
35+
isLast: boolean;
36+
isLoading: boolean;
37+
}
38+
39+
const ChatMessage = React.memo(
40+
({ message, isLast, isLoading }: ChatMessageProps) => {
41+
const isUser = message.role === "user";
42+
43+
// Memoize parsing to avoid regex overhead on every render
44+
const { thinkContent, mainContent } = useMemo(
45+
() => parseMessageContent(message.content),
46+
[message.content]
47+
);
48+
49+
const isThinkingLive =
50+
isLoading &&
51+
isLast &&
52+
(message.content.includes("<think>") || !message.content.includes("</think>")) &&
53+
!message.content.includes("</think>");
54+
55+
return (
56+
<div
57+
className={cn(
58+
"flex w-full",
59+
isUser ? "justify-end" : "justify-start"
60+
)}
61+
>
62+
<div
63+
className={cn(
64+
"flex flex-col",
65+
isUser ? "items-end max-w-[85%] md:max-w-[75%] min-w-0" : "w-full items-start min-w-0"
66+
)}
67+
>
68+
<div
69+
className={cn(
70+
"relative text-[15px] leading-relaxed",
71+
isUser
72+
? "px-5 py-3 rounded-2xl rounded-tr-sm bg-blue-600 text-white dark:bg-zinc-800 dark:text-foreground shadow-sm break-words"
73+
: "w-full px-0 py-0"
74+
)}
75+
>
76+
{!isUser && thinkContent && (
77+
<ThinkBlock content={thinkContent} isLive={isThinkingLive} />
78+
)}
79+
80+
<div className={cn(!isUser && thinkContent && "mt-4")}>
81+
<Markdown
82+
remarkPlugins={[remarkGfm, remarkMath]}
83+
rehypePlugins={[rehypeKatex]}
84+
components={{
85+
code({ node, inline, className, children, ...props }: any) {
86+
const match = /language-(\w+)/.exec(className || "");
87+
const lang = match ? match[1] : "";
88+
return !inline && match ? (
89+
<div className="my-6 rounded-md overflow-hidden border border-border">
90+
<CodeDisplayBlock
91+
code={String(children).replace(/\n$/, "")}
92+
lang={lang}
93+
/>
94+
</div>
95+
) : (
96+
<code
97+
className={cn(
98+
"px-1.5 py-0.5 rounded-md font-mono text-[13px] border break-all",
99+
isUser
100+
? "bg-white/20 text-white border-transparent"
101+
: "bg-muted text-foreground border-border"
102+
)}
103+
{...props}
104+
>
105+
{children}
106+
</code>
107+
);
108+
},
109+
h1: ({ children }) => (
110+
<h1 className="text-3xl font-bold mt-8 mb-4 break-words">
111+
{children}
112+
</h1>
113+
),
114+
h2: ({ children }) => (
115+
<h2 className="text-2xl font-semibold mt-8 mb-4 border-b pb-2 break-words">
116+
{children}
117+
</h2>
118+
),
119+
h3: ({ children }) => (
120+
<h3 className="text-xl font-semibold mt-6 mb-3 break-words">
121+
{children}
122+
</h3>
123+
),
124+
h4: ({ children }) => (
125+
<h4 className="text-lg font-semibold mt-6 mb-3 break-words">
126+
{children}
127+
</h4>
128+
),
129+
p: ({ children }) => (
130+
<p className="mb-5 last:mb-0 leading-7 whitespace-pre-wrap break-words">
131+
{children}
132+
</p>
133+
),
134+
a: ({ children, ...props }: any) => (
135+
<a
136+
className="text-blue-500 hover:underline cursor-pointer font-medium break-all"
137+
{...props}
138+
>
139+
{children}
140+
</a>
141+
),
142+
ul: ({ children }) => (
143+
<ul className="list-disc pl-6 mb-5 space-y-2">{children}</ul>
144+
),
145+
ol: ({ children }) => (
146+
<ol className="list-decimal pl-6 mb-5 space-y-2">
147+
{children}
148+
</ol>
149+
),
150+
li: ({ children }) => (
151+
<li className="pl-1 leading-7">{children}</li>
152+
),
153+
blockquote: ({ children }) => (
154+
<blockquote className="border-l-4 border-primary/20 bg-muted/40 pl-4 py-2 my-4 rounded-r-md italic">
155+
{children}
156+
</blockquote>
157+
),
158+
table: ({ children }) => (
159+
<div className="overflow-x-auto my-6 border rounded-md">
160+
<table className="w-full text-sm text-left">{children}</table>
161+
</div>
162+
),
163+
th: ({ children }) => (
164+
<th className="bg-muted px-4 py-3 border-b font-semibold">
165+
{children}
166+
</th>
167+
),
168+
td: ({ children }) => (
169+
<td className="px-4 py-3 border-b last:border-0">
170+
{children}
171+
</td>
172+
),
173+
}}
174+
>
175+
{isUser ? message.content : mainContent}
176+
</Markdown>
177+
</div>
178+
179+
{isLoading &&
180+
isLast &&
181+
!isUser &&
182+
!isThinkingLive &&
183+
mainContent.length === 0 && (
184+
<div className="flex items-center gap-1 h-6 mt-2 opacity-50">
185+
<span className="w-1.5 h-1.5 bg-foreground rounded-full animate-bounce [animation-delay:-0.3s]"></span>
186+
<span className="w-1.5 h-1.5 bg-foreground rounded-full animate-bounce [animation-delay:-0.15s]"></span>
187+
<span className="w-1.5 h-1.5 bg-foreground rounded-full animate-bounce"></span>
188+
</div>
189+
)}
190+
</div>
191+
</div>
192+
</div>
193+
);
194+
},
195+
(prevProps, nextProps) => {
196+
// Custom comparison function for React.memo
197+
// Returns true if props are equal (DO NOT RE-RENDER)
198+
// Returns false if props are different (RE-RENDER)
199+
200+
// 1. If it was NOT the last message, and is still NOT the last message,
201+
// the content is static history. Do not re-render.
202+
const isHistory = !prevProps.isLast && !nextProps.isLast;
203+
if (isHistory) return true;
204+
205+
// 2. If loading state changed (e.g. stream finished), re-render
206+
if (prevProps.isLoading !== nextProps.isLoading) return false;
207+
208+
// 3. Otherwise, check if content changed
209+
return prevProps.message.content === nextProps.message.content;
210+
}
211+
);
212+
213+
export default ChatMessage;

0 commit comments

Comments
 (0)