yangdx commited on
Commit
d8300f7
·
1 Parent(s): ac8ad08

Stablize mermaid render in history messages

Browse files
lightrag_webui/src/components/retrieval/ChatMessage.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { ReactNode, useCallback, useEffect, useRef } from 'react'
2
  import { Message } from '@/api/lightrag'
3
  import useTheme from '@/hooks/useTheme'
4
  import Button from '@/components/ui/Button'
@@ -19,10 +19,17 @@ import { LoaderIcon, CopyIcon } from 'lucide-react'
19
  import { useTranslation } from 'react-i18next'
20
 
21
  export type MessageWithError = Message & {
 
22
  isError?: boolean
 
 
 
 
 
23
  }
24
 
25
- export const ChatMessage = ({ message, isComplete = true }: { message: MessageWithError, isComplete?: boolean }) => {
 
26
  const { t } = useTranslation()
27
  const handleCopyMarkdown = useCallback(async () => {
28
  if (message.content) {
@@ -50,17 +57,23 @@ export const ChatMessage = ({ message, isComplete = true }: { message: MessageWi
50
  remarkPlugins={[remarkGfm, remarkMath]}
51
  rehypePlugins={[rehypeReact]}
52
  skipHtml={false}
53
- components={{
54
- code: (props) => <CodeHighlight {...props} isComplete={isComplete} />,
55
- p: ({ children }) => <p className="my-2">{children}</p>,
56
- h1: ({ children }) => <h1 className="text-xl font-bold mt-4 mb-2">{children}</h1>,
57
- h2: ({ children }) => <h2 className="text-lg font-bold mt-4 mb-2">{children}</h2>,
58
- h3: ({ children }) => <h3 className="text-base font-bold mt-3 mb-2">{children}</h3>,
59
- h4: ({ children }) => <h4 className="text-base font-semibold mt-3 mb-2">{children}</h4>,
60
- ul: ({ children }) => <ul className="list-disc pl-5 my-2">{children}</ul>,
61
- ol: ({ children }) => <ol className="list-decimal pl-5 my-2">{children}</ol>,
62
- li: ({ children }) => <li className="my-1">{children}</li>
63
- }}
 
 
 
 
 
 
64
  >
65
  {message.content}
66
  </ReactMarkdown>
@@ -81,12 +94,14 @@ export const ChatMessage = ({ message, isComplete = true }: { message: MessageWi
81
  )
82
  }
83
 
 
 
84
  interface CodeHighlightProps {
85
  inline?: boolean
86
  className?: string
87
  children?: ReactNode
88
  node?: Element // Keep node for inline check
89
- isComplete?: boolean // Flag to indicate if the message is complete
90
  }
91
 
92
  // Helper function remains the same
@@ -101,8 +116,10 @@ const isInlineCode = (node?: Element): boolean => {
101
  };
102
 
103
 
104
- const CodeHighlight = ({ className, children, node, isComplete = true, ...props }: CodeHighlightProps) => {
 
105
  const { theme } = useTheme();
 
106
  const match = className?.match(/language-(\w+)/);
107
  const language = match ? match[1] : undefined;
108
  const inline = isInlineCode(node); // Use the helper function
@@ -111,35 +128,37 @@ const CodeHighlight = ({ className, children, node, isComplete = true, ...props
111
 
112
  // Handle Mermaid rendering with debounce
113
  useEffect(() => {
114
- // Clear any existing timer when dependencies change
115
- if (debounceTimerRef.current) {
116
- clearTimeout(debounceTimerRef.current);
117
- }
118
 
119
- if (language === 'mermaid' && mermaidRef.current) {
120
- const container = mermaidRef.current; // Capture ref value for use inside timeout/callbacks
 
 
121
 
122
- // Set a new timer to render after a short delay
123
  debounceTimerRef.current = setTimeout(() => {
124
- // Ensure container still exists when timer fires
125
- if (!container) return;
 
 
126
 
127
  try {
128
- // Initialize mermaid config (safe to call multiple times)
129
  mermaid.initialize({
130
  startOnLoad: false,
131
  theme: theme === 'dark' ? 'dark' : 'default',
132
  securityLevel: 'loose',
133
  });
134
 
135
- // Show loading indicator while processing
136
  container.innerHTML = '<div class="flex justify-center items-center p-4"><svg class="animate-spin h-5 w-5 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg></div>';
137
 
138
  // Preprocess mermaid content
139
- const rawContent = String(children).replace(/\n$/, '').trim(); // Trim whitespace as well
140
 
141
  // Heuristic check for potentially complete graph definition
142
- // Looks for graph type declaration and some content beyond it.
143
  const looksPotentiallyComplete = rawContent.length > 10 && (
144
  rawContent.startsWith('graph') ||
145
  rawContent.startsWith('sequenceDiagram') ||
@@ -151,19 +170,17 @@ const CodeHighlight = ({ className, children, node, isComplete = true, ...props
151
  rawContent.startsWith('erDiagram')
152
  );
153
 
154
-
155
  if (!looksPotentiallyComplete) {
156
  console.log('Mermaid content might be incomplete, skipping render attempt:', rawContent);
157
- // Keep loading indicator or show a message
158
  // container.innerHTML = '<p class="text-sm text-muted-foreground">Waiting for complete diagram...</p>';
159
- return; // Don't attempt to render potentially incomplete content
160
  }
161
 
162
  const processedContent = rawContent
163
  .split('\n')
164
  .map(line => {
165
  const trimmedLine = line.trim();
166
- // Keep subgraph processing
167
  if (trimmedLine.startsWith('subgraph')) {
168
  const parts = trimmedLine.split(' ');
169
  if (parts.length > 1) {
@@ -173,26 +190,25 @@ const CodeHighlight = ({ className, children, node, isComplete = true, ...props
173
  }
174
  return trimmedLine;
175
  })
176
- .filter(line => !line.trim().startsWith('linkStyle')) // Keep filtering linkStyle
177
  .join('\n');
178
 
179
  const mermaidId = `mermaid-${Date.now()}`;
180
  mermaid.render(mermaidId, processedContent)
181
  .then(({ svg, bindFunctions }) => {
182
- // Check ref again inside async callback
183
- // Ensure the container is still the one we intended to update
184
- if (mermaidRef.current === container) {
185
  container.innerHTML = svg;
 
186
  if (bindFunctions) {
187
- try { // Add try-catch around bindFunctions as it can also throw
188
  bindFunctions(container);
189
  } catch (bindError) {
190
  console.error('Mermaid bindFunctions error:', bindError);
191
- // Optionally display a message in the container
192
  container.innerHTML += '<p class="text-orange-500 text-xs">Diagram interactions might be limited.</p>';
193
  }
194
  }
195
- } else {
196
  console.log('Mermaid container changed before rendering completed.');
197
  }
198
  })
@@ -201,11 +217,10 @@ const CodeHighlight = ({ className, children, node, isComplete = true, ...props
201
  console.error('Failed content (debounced):', processedContent);
202
  if (mermaidRef.current === container) {
203
  const errorMessage = error instanceof Error ? error.message : String(error);
204
- // Make error display more robust
205
  const errorPre = document.createElement('pre');
206
  errorPre.className = 'text-red-500 text-xs whitespace-pre-wrap break-words';
207
  errorPre.textContent = `Mermaid diagram error: ${errorMessage}\n\nContent:\n${processedContent}`;
208
- container.innerHTML = ''; // Clear previous content
209
  container.appendChild(errorPre);
210
  }
211
  });
@@ -218,24 +233,28 @@ const CodeHighlight = ({ className, children, node, isComplete = true, ...props
218
  const errorPre = document.createElement('pre');
219
  errorPre.className = 'text-red-500 text-xs whitespace-pre-wrap break-words';
220
  errorPre.textContent = `Mermaid diagram setup error: ${errorMessage}`;
221
- container.innerHTML = ''; // Clear previous content
222
  container.appendChild(errorPre);
223
  }
224
  }
225
- }, 300); // 300ms debounce delay
226
  }
227
 
228
- // Cleanup function to clear the timer
229
  return () => {
230
  if (debounceTimerRef.current) {
231
  clearTimeout(debounceTimerRef.current);
232
  }
233
  };
234
- }, [language, children, theme]); // Dependencies
 
 
 
 
235
 
236
  // Render based on language type
237
- // If it's a mermaid language block and the message is not complete, display as plain text
238
- if (language === 'mermaid' && !isComplete) {
239
  return (
240
  <SyntaxHighlighter
241
  style={theme === 'dark' ? oneDark : oneLight}
@@ -273,4 +292,7 @@ const CodeHighlight = ({ className, children, node, isComplete = true, ...props
273
  {children}
274
  </code>
275
  );
276
- };
 
 
 
 
1
+ import { ReactNode, useCallback, useEffect, useMemo, useRef, memo, useState } from 'react' // Import useMemo
2
  import { Message } from '@/api/lightrag'
3
  import useTheme from '@/hooks/useTheme'
4
  import Button from '@/components/ui/Button'
 
19
  import { useTranslation } from 'react-i18next'
20
 
21
  export type MessageWithError = Message & {
22
+ id: string // Unique identifier for stable React keys
23
  isError?: boolean
24
+ /**
25
+ * Indicates if the mermaid diagram in this message has been rendered.
26
+ * Used to persist the rendering state across updates and prevent flickering.
27
+ */
28
+ mermaidRendered?: boolean
29
  }
30
 
31
+ // Restore original component definition and export
32
+ export const ChatMessage = ({ message }: { message: MessageWithError }) => { // Remove isComplete prop
33
  const { t } = useTranslation()
34
  const handleCopyMarkdown = useCallback(async () => {
35
  if (message.content) {
 
57
  remarkPlugins={[remarkGfm, remarkMath]}
58
  rehypePlugins={[rehypeReact]}
59
  skipHtml={false}
60
+ // Memoize the components object to prevent unnecessary re-renders of ReactMarkdown children
61
+ components={useMemo(() => ({
62
+ code: (props: any) => ( // Add type annotation if needed, e.g., props: CodeProps from 'react-markdown/lib/ast-to-react'
63
+ <CodeHighlight
64
+ {...props}
65
+ renderAsDiagram={message.mermaidRendered ?? false}
66
+ />
67
+ ),
68
+ p: ({ children }: { children?: ReactNode }) => <p className="my-2">{children}</p>,
69
+ h1: ({ children }: { children?: ReactNode }) => <h1 className="text-xl font-bold mt-4 mb-2">{children}</h1>,
70
+ h2: ({ children }: { children?: ReactNode }) => <h2 className="text-lg font-bold mt-4 mb-2">{children}</h2>,
71
+ h3: ({ children }: { children?: ReactNode }) => <h3 className="text-base font-bold mt-3 mb-2">{children}</h3>,
72
+ h4: ({ children }: { children?: ReactNode }) => <h4 className="text-base font-semibold mt-3 mb-2">{children}</h4>,
73
+ ul: ({ children }: { children?: ReactNode }) => <ul className="list-disc pl-5 my-2">{children}</ul>,
74
+ ol: ({ children }: { children?: ReactNode }) => <ol className="list-decimal pl-5 my-2">{children}</ol>,
75
+ li: ({ children }: { children?: ReactNode }) => <li className="my-1">{children}</li>
76
+ }), [message.mermaidRendered])} // Dependency ensures update if mermaid state changes
77
  >
78
  {message.content}
79
  </ReactMarkdown>
 
94
  )
95
  }
96
 
97
+ // Remove the incorrect memo export line
98
+
99
  interface CodeHighlightProps {
100
  inline?: boolean
101
  className?: string
102
  children?: ReactNode
103
  node?: Element // Keep node for inline check
104
+ renderAsDiagram?: boolean // Flag to indicate if rendering as diagram should be attempted
105
  }
106
 
107
  // Helper function remains the same
 
116
  };
117
 
118
 
119
+ // Memoize the CodeHighlight component
120
+ const CodeHighlight = memo(({ className, children, node, renderAsDiagram = false, ...props }: CodeHighlightProps) => {
121
  const { theme } = useTheme();
122
+ const [hasRendered, setHasRendered] = useState(false); // State to track successful render
123
  const match = className?.match(/language-(\w+)/);
124
  const language = match ? match[1] : undefined;
125
  const inline = isInlineCode(node); // Use the helper function
 
128
 
129
  // Handle Mermaid rendering with debounce
130
  useEffect(() => {
131
+ // Effect should run when renderAsDiagram becomes true or hasRendered changes.
132
+ // The actual rendering logic inside checks language and hasRendered state.
133
+ if (renderAsDiagram && !hasRendered && language === 'mermaid' && mermaidRef.current) {
134
+ const container = mermaidRef.current; // Capture ref value
135
 
136
+ // Clear previous timer if dependencies change before timeout (e.g., renderAsDiagram flips quickly)
137
+ if (debounceTimerRef.current) {
138
+ clearTimeout(debounceTimerRef.current);
139
+ }
140
 
 
141
  debounceTimerRef.current = setTimeout(() => {
142
+ if (!container) return; // Container might have unmounted
143
+
144
+ // Double check hasRendered state inside timeout, in case it changed rapidly
145
+ if (hasRendered) return;
146
 
147
  try {
148
+ // Initialize mermaid config
149
  mermaid.initialize({
150
  startOnLoad: false,
151
  theme: theme === 'dark' ? 'dark' : 'default',
152
  securityLevel: 'loose',
153
  });
154
 
155
+ // Show loading indicator
156
  container.innerHTML = '<div class="flex justify-center items-center p-4"><svg class="animate-spin h-5 w-5 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg></div>';
157
 
158
  // Preprocess mermaid content
159
+ const rawContent = String(children).replace(/\n$/, '').trim();
160
 
161
  // Heuristic check for potentially complete graph definition
 
162
  const looksPotentiallyComplete = rawContent.length > 10 && (
163
  rawContent.startsWith('graph') ||
164
  rawContent.startsWith('sequenceDiagram') ||
 
170
  rawContent.startsWith('erDiagram')
171
  );
172
 
 
173
  if (!looksPotentiallyComplete) {
174
  console.log('Mermaid content might be incomplete, skipping render attempt:', rawContent);
175
+ // Optionally keep loading indicator or show a message
176
  // container.innerHTML = '<p class="text-sm text-muted-foreground">Waiting for complete diagram...</p>';
177
+ return;
178
  }
179
 
180
  const processedContent = rawContent
181
  .split('\n')
182
  .map(line => {
183
  const trimmedLine = line.trim();
 
184
  if (trimmedLine.startsWith('subgraph')) {
185
  const parts = trimmedLine.split(' ');
186
  if (parts.length > 1) {
 
190
  }
191
  return trimmedLine;
192
  })
193
+ .filter(line => !line.trim().startsWith('linkStyle'))
194
  .join('\n');
195
 
196
  const mermaidId = `mermaid-${Date.now()}`;
197
  mermaid.render(mermaidId, processedContent)
198
  .then(({ svg, bindFunctions }) => {
199
+ // Check ref and hasRendered state again inside async callback
200
+ if (mermaidRef.current === container && !hasRendered) {
 
201
  container.innerHTML = svg;
202
+ setHasRendered(true); // Mark as rendered successfully
203
  if (bindFunctions) {
204
+ try {
205
  bindFunctions(container);
206
  } catch (bindError) {
207
  console.error('Mermaid bindFunctions error:', bindError);
 
208
  container.innerHTML += '<p class="text-orange-500 text-xs">Diagram interactions might be limited.</p>';
209
  }
210
  }
211
+ } else if (mermaidRef.current !== container) {
212
  console.log('Mermaid container changed before rendering completed.');
213
  }
214
  })
 
217
  console.error('Failed content (debounced):', processedContent);
218
  if (mermaidRef.current === container) {
219
  const errorMessage = error instanceof Error ? error.message : String(error);
 
220
  const errorPre = document.createElement('pre');
221
  errorPre.className = 'text-red-500 text-xs whitespace-pre-wrap break-words';
222
  errorPre.textContent = `Mermaid diagram error: ${errorMessage}\n\nContent:\n${processedContent}`;
223
+ container.innerHTML = '';
224
  container.appendChild(errorPre);
225
  }
226
  });
 
233
  const errorPre = document.createElement('pre');
234
  errorPre.className = 'text-red-500 text-xs whitespace-pre-wrap break-words';
235
  errorPre.textContent = `Mermaid diagram setup error: ${errorMessage}`;
236
+ container.innerHTML = '';
237
  container.appendChild(errorPre);
238
  }
239
  }
240
+ }, 300); // Debounce delay
241
  }
242
 
243
+ // Cleanup function to clear the timer on unmount or before re-running effect
244
  return () => {
245
  if (debounceTimerRef.current) {
246
  clearTimeout(debounceTimerRef.current);
247
  }
248
  };
249
+ // Dependencies: renderAsDiagram ensures effect runs when diagram should be shown.
250
+ // children, language, theme trigger re-render if code/context changes.
251
+ // Dependencies are minimal: only run when the intent to render changes or the rendered state changes.
252
+ // Access children, theme, language inside the effect when needed.
253
+ }, [renderAsDiagram, hasRendered, language]); // Keep language to ensure it IS mermaid
254
 
255
  // Render based on language type
256
+ // If it's a mermaid language block and rendering as diagram is not requested (e.g., incomplete stream), display as plain text
257
+ if (language === 'mermaid' && !renderAsDiagram) {
258
  return (
259
  <SyntaxHighlighter
260
  style={theme === 'dark' ? oneDark : oneLight}
 
292
  {children}
293
  </code>
294
  );
295
+ });
296
+
297
+ // Assign display name for React DevTools
298
+ CodeHighlight.displayName = 'CodeHighlight';
lightrag_webui/src/features/RetrievalTesting.tsx CHANGED
@@ -2,7 +2,7 @@ import Input from '@/components/ui/Input'
2
  import Button from '@/components/ui/Button'
3
  import { useCallback, useEffect, useRef, useState } from 'react'
4
  import { throttle } from '@/lib/utils'
5
- import { queryText, queryTextStream, Message } from '@/api/lightrag'
6
  import { errorMessage } from '@/lib/utils'
7
  import { useSettingsStore } from '@/stores/settings'
8
  import { useDebounce } from '@/hooks/useDebounce'
@@ -14,9 +14,18 @@ import type { QueryMode } from '@/api/lightrag'
14
 
15
  export default function RetrievalTesting() {
16
  const { t } = useTranslation()
17
- const [messages, setMessages] = useState<MessageWithError[]>(
18
- () => useSettingsStore.getState().retrievalHistory || []
19
- )
 
 
 
 
 
 
 
 
 
20
  const [inputValue, setInputValue] = useState('')
21
  const [isLoading, setIsLoading] = useState(false)
22
  const [inputError, setInputError] = useState('') // Error message for input
@@ -81,14 +90,17 @@ export default function RetrievalTesting() {
81
 
82
  // Create messages
83
  // Save the original input (with prefix if any) in userMessage.content for display
84
- const userMessage: Message = {
 
85
  content: inputValue,
86
  role: 'user'
87
  }
88
 
89
- const assistantMessage: Message = {
 
90
  content: '',
91
- role: 'assistant'
 
92
  }
93
 
94
  const prevMessages = [...messages]
@@ -113,12 +125,28 @@ export default function RetrievalTesting() {
113
  // Create a function to update the assistant's message
114
  const updateAssistantMessage = (chunk: string, isError?: boolean) => {
115
  assistantMessage.content += chunk
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  setMessages((prev) => {
117
  const newMessages = [...prev]
118
  const lastMessage = newMessages[newMessages.length - 1]
119
  if (lastMessage.role === 'assistant') {
120
  lastMessage.content = assistantMessage.content
121
  lastMessage.isError = isError
 
122
  }
123
  return newMessages
124
  })
@@ -279,20 +307,14 @@ export default function RetrievalTesting() {
279
  {t('retrievePanel.retrieval.startPrompt')}
280
  </div>
281
  ) : (
282
- messages.map((message, idx) => {
283
- // Determine if this message is complete:
284
- // 1. If it's not the last message, it's complete
285
- // 2. If it's the last message but we're not receiving a streaming response, it's complete
286
- // 3. If it's the last message and we're receiving a streaming response, it's not complete
287
- const isLastMessage = idx === messages.length - 1;
288
- const isMessageComplete = !isLastMessage || !isReceivingResponseRef.current;
289
-
290
  return (
291
  <div
292
- key={idx}
293
  className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
294
  >
295
- {<ChatMessage message={message} isComplete={isMessageComplete} />}
296
  </div>
297
  );
298
  })
 
2
  import Button from '@/components/ui/Button'
3
  import { useCallback, useEffect, useRef, useState } from 'react'
4
  import { throttle } from '@/lib/utils'
5
+ import { queryText, queryTextStream } from '@/api/lightrag'
6
  import { errorMessage } from '@/lib/utils'
7
  import { useSettingsStore } from '@/stores/settings'
8
  import { useDebounce } from '@/hooks/useDebounce'
 
14
 
15
  export default function RetrievalTesting() {
16
  const { t } = useTranslation()
17
+ const [messages, setMessages] = useState<MessageWithError[]>(() => {
18
+ const history = useSettingsStore.getState().retrievalHistory || []
19
+ // Ensure each message from history has a unique ID and mermaidRendered status
20
+ return history.map((msg, index) => {
21
+ const msgWithError = msg as MessageWithError // Cast to access potential properties
22
+ return {
23
+ ...msg,
24
+ id: msgWithError.id || `hist-${Date.now()}-${index}`, // Add ID if missing
25
+ mermaidRendered: msgWithError.mermaidRendered ?? true // Assume historical mermaid is rendered
26
+ }
27
+ })
28
+ })
29
  const [inputValue, setInputValue] = useState('')
30
  const [isLoading, setIsLoading] = useState(false)
31
  const [inputError, setInputError] = useState('') // Error message for input
 
90
 
91
  // Create messages
92
  // Save the original input (with prefix if any) in userMessage.content for display
93
+ const userMessage: MessageWithError = {
94
+ id: crypto.randomUUID(), // Add unique ID
95
  content: inputValue,
96
  role: 'user'
97
  }
98
 
99
+ const assistantMessage: MessageWithError = {
100
+ id: crypto.randomUUID(), // Add unique ID
101
  content: '',
102
+ role: 'assistant',
103
+ mermaidRendered: false
104
  }
105
 
106
  const prevMessages = [...messages]
 
125
  // Create a function to update the assistant's message
126
  const updateAssistantMessage = (chunk: string, isError?: boolean) => {
127
  assistantMessage.content += chunk
128
+
129
+ // Detect if the assistant message contains a complete mermaid code block
130
+ // Simple heuristic: look for ```mermaid ... ```
131
+ const mermaidBlockRegex = /```mermaid\s+([\s\S]+?)```/g
132
+ let mermaidRendered = false
133
+ let match
134
+ while ((match = mermaidBlockRegex.exec(assistantMessage.content)) !== null) {
135
+ // If the block is not too short, consider it complete
136
+ if (match[1] && match[1].trim().length > 10) {
137
+ mermaidRendered = true
138
+ break
139
+ }
140
+ }
141
+ assistantMessage.mermaidRendered = mermaidRendered
142
+
143
  setMessages((prev) => {
144
  const newMessages = [...prev]
145
  const lastMessage = newMessages[newMessages.length - 1]
146
  if (lastMessage.role === 'assistant') {
147
  lastMessage.content = assistantMessage.content
148
  lastMessage.isError = isError
149
+ lastMessage.mermaidRendered = assistantMessage.mermaidRendered
150
  }
151
  return newMessages
152
  })
 
307
  {t('retrievePanel.retrieval.startPrompt')}
308
  </div>
309
  ) : (
310
+ messages.map((message) => { // Remove unused idx
311
+ // isComplete logic is now handled internally based on message.mermaidRendered
 
 
 
 
 
 
312
  return (
313
  <div
314
+ key={message.id} // Use stable ID for key
315
  className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
316
  >
317
+ {<ChatMessage message={message} />}
318
  </div>
319
  );
320
  })