Skip to content

Commit 367e504

Browse files
committed
add loading state to the message
1 parent 53ceef7 commit 367e504

File tree

3 files changed

+209
-114
lines changed

3 files changed

+209
-114
lines changed

chatbot-ui/src/components/App.tsx

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface Message {
2121
content: string | MessageContent[];
2222
model?: string;
2323
timestamp?: string;
24+
loading?: boolean;
2425
}
2526

2627
interface ImageUrl {
@@ -67,6 +68,31 @@ const API_CONFIG: ApiConfig = {
6768
}
6869
};
6970

71+
// Helper function to clean message content
72+
const cleanMessageContent = (content: string | MessageContent[]): string | MessageContent[] => {
73+
if (Array.isArray(content)) {
74+
const cleanedContent = content.filter(item =>
75+
item.type === 'image_url' || (item.type === 'text' && item.text.trim() !== '')
76+
);
77+
return cleanedContent.length > 0 ? cleanedContent : '';
78+
}
79+
return content.trim();
80+
};
81+
82+
// Helper function to prepare messages for API
83+
const prepareMessagesForAPI = (messages: Message[], model: string): Message[] => {
84+
return messages
85+
.map(msg => ({
86+
role: msg.role,
87+
content: cleanMessageContent(msg.content),
88+
model: msg.model || model
89+
}))
90+
.filter(msg => {
91+
const content = msg.content;
92+
return Array.isArray(content) ? content.length > 0 : content !== '';
93+
});
94+
};
95+
7096
function App() {
7197
// State variables with proper types
7298
const [messages, setMessages] = useState<Message[]>([]);
@@ -301,12 +327,23 @@ function App() {
301327
setAbortController(controller);
302328

303329
try {
330+
const assistantMessage: Message = {
331+
role: 'assistant',
332+
content: '',
333+
loading: true,
334+
model: model
335+
};
336+
updatedMessages.push(assistantMessage);
337+
updateMessagesInUI(conversationId, updatedMessages);
338+
339+
const cleanMessages = prepareMessagesForAPI(updatedMessages, model);
340+
304341
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.STREAM_CHAT}`, {
305342
method: 'POST',
306343
headers: { 'Content-Type': 'application/json' },
307344
body: JSON.stringify({
308345
conversation_id: conversationId,
309-
messages: updatedMessages,
346+
messages: cleanMessages,
310347
model: model,
311348
}),
312349
signal: controller.signal,
@@ -316,8 +353,10 @@ function App() {
316353
throw new Error(`HTTP error! Status: ${response.status}`);
317354
}
318355

319-
const assistantMessage: Message = { role: 'assistant', content: '' };
320-
updatedMessages.push(assistantMessage);
356+
// Remove loading state once we start receiving the response
357+
assistantMessage.loading = false;
358+
assistantMessage.content = '';
359+
updatedMessages[updatedMessages.length - 1] = { ...assistantMessage };
321360

322361
const throttledUpdate = throttleUpdate((messages: Message[]) => {
323362
updateMessagesInUI(conversationId, messages);
@@ -344,8 +383,16 @@ function App() {
344383
} catch (error) {
345384
if (error instanceof Error && error.name === 'AbortError') {
346385
console.log('Stream aborted');
386+
updatedMessages.pop();
387+
updateMessagesInUI(conversationId, updatedMessages);
347388
} else {
348389
handleError(error as Error, 'Error during streaming');
390+
const lastMessage = updatedMessages[updatedMessages.length - 1];
391+
if (lastMessage?.loading) {
392+
lastMessage.loading = false;
393+
lastMessage.content = 'Error: Failed to get response from the server.';
394+
updateMessagesInUI(conversationId, updatedMessages);
395+
}
349396
}
350397
return updatedMessages;
351398
} finally {

chatbot-ui/src/components/MessageBubble.tsx

Lines changed: 122 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface Message {
2424
content: string | MessageContent[];
2525
model?: string;
2626
timestamp?: string;
27+
loading?: boolean;
2728
}
2829

2930
interface MessageBubbleProps {
@@ -100,119 +101,129 @@ const MessageBubble: React.FC<MessageBubbleProps> = React.memo(({ message, role
100101
{!isUser && (
101102
<img src={botAvatar} alt="bot avatar" className="avatar" />
102103
)}
103-
<div className={`message-bubble ${isUser ? 'user' : 'bot'}`}>
104-
{images.length > 0 && (
105-
<div className="message-images">
106-
{images.map((image, index) => (
107-
<div
108-
key={index}
109-
className="message-image"
110-
onClick={() => handleImageClick(image)}
111-
>
112-
<img src={image} alt={`User uploaded ${index + 1}`} />
113-
</div>
114-
))}
104+
<div className={`message-bubble ${isUser ? 'user' : 'bot'} ${message.loading ? 'loading' : ''}`}>
105+
{message.loading ? (
106+
<div className="typing-indicator">
107+
<span></span>
108+
<span></span>
109+
<span></span>
115110
</div>
116-
)}
117-
{text && (
118-
<ReactMarkdown
119-
remarkPlugins={[remarkGfm]}
120-
components={{
121-
code({ node, inline, className, children, ...props }) {
122-
const match = /language-(\w+)/.exec(className || '');
123-
const codeString = String(children).replace(/\n$/, '');
124-
125-
if (!inline && match) {
126-
const lineNumber = node?.position?.start.line;
127-
return (
128-
<div className="code-block-wrapper">
129-
<div className="code-block-header">
130-
<span className="code-block-language">
131-
{match[1]}
132-
</span>
133-
<button
134-
className="copy-button"
135-
onClick={() => lineNumber && copyToClipboard(codeString, lineNumber)}
136-
title={copiedIndex === lineNumber ? 'Copied!' : 'Copy code'}
137-
>
138-
<svg
139-
width="16"
140-
height="16"
141-
viewBox="0 0 24 24"
142-
fill="none"
143-
stroke="currentColor"
144-
strokeWidth="2"
145-
strokeLinecap="round"
146-
strokeLinejoin="round"
147-
>
148-
{copiedIndex === lineNumber ? (
149-
<path d="M20 6L9 17l-5-5" />
150-
) : (
151-
<>
152-
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
153-
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
154-
</>
155-
)}
156-
</svg>
157-
</button>
158-
</div>
159-
<div className="syntax-highlighter-wrapper">
160-
<SyntaxHighlighter
161-
style={oneDark}
162-
language={match[1]}
163-
PreTag="div"
164-
{...props}
165-
customStyle={{
166-
margin: 0,
167-
background: '#282c34',
168-
padding: '16px',
169-
}}
170-
>
171-
{codeString}
172-
</SyntaxHighlighter>
173-
</div>
174-
</div>
175-
);
176-
}
177-
return (
178-
<code className={className} {...props}>
179-
{children}
180-
</code>
181-
);
182-
},
183-
}}
184-
>
185-
{text}
186-
</ReactMarkdown>
187-
)}
188-
{!isUser && (
189-
<div className="message-actions">
190-
<button
191-
className="message-copy-button"
192-
onClick={copyMessage}
193-
title={copiedMessage ? 'Copied!' : 'Copy response'}
194-
>
195-
<svg
196-
width="16"
197-
height="16"
198-
viewBox="0 0 24 24"
199-
fill="none"
200-
stroke="currentColor"
201-
strokeWidth="2"
202-
strokeLinecap="round"
203-
strokeLinejoin="round"
111+
) : (
112+
<>
113+
{images.length > 0 && (
114+
<div className="message-images">
115+
{images.map((image, index) => (
116+
<div
117+
key={index}
118+
className="message-image"
119+
onClick={() => handleImageClick(image)}
120+
>
121+
<img src={image} alt={`User uploaded ${index + 1}`} />
122+
</div>
123+
))}
124+
</div>
125+
)}
126+
{text && (
127+
<ReactMarkdown
128+
remarkPlugins={[remarkGfm]}
129+
components={{
130+
code({ node, inline, className, children, ...props }) {
131+
const match = /language-(\w+)/.exec(className || '');
132+
const codeString = String(children).replace(/\n$/, '');
133+
134+
if (!inline && match) {
135+
const lineNumber = node?.position?.start.line;
136+
return (
137+
<div className="code-block-wrapper">
138+
<div className="code-block-header">
139+
<span className="code-block-language">
140+
{match[1]}
141+
</span>
142+
<button
143+
className="copy-button"
144+
onClick={() => lineNumber && copyToClipboard(codeString, lineNumber)}
145+
title={copiedIndex === lineNumber ? 'Copied!' : 'Copy code'}
146+
>
147+
<svg
148+
width="16"
149+
height="16"
150+
viewBox="0 0 24 24"
151+
fill="none"
152+
stroke="currentColor"
153+
strokeWidth="2"
154+
strokeLinecap="round"
155+
strokeLinejoin="round"
156+
>
157+
{copiedIndex === lineNumber ? (
158+
<path d="M20 6L9 17l-5-5" />
159+
) : (
160+
<>
161+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
162+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
163+
</>
164+
)}
165+
</svg>
166+
</button>
167+
</div>
168+
<div className="syntax-highlighter-wrapper">
169+
<SyntaxHighlighter
170+
style={oneDark}
171+
language={match[1]}
172+
PreTag="div"
173+
{...props}
174+
customStyle={{
175+
margin: 0,
176+
background: '#282c34',
177+
padding: '16px',
178+
}}
179+
>
180+
{codeString}
181+
</SyntaxHighlighter>
182+
</div>
183+
</div>
184+
);
185+
}
186+
return (
187+
<code className={className} {...props}>
188+
{children}
189+
</code>
190+
);
191+
},
192+
}}
204193
>
205-
{copiedMessage ? (
206-
<path d="M20 6L9 17l-5-5" />
207-
) : (
208-
<>
209-
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
210-
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
211-
</>
212-
)}
213-
</svg>
214-
</button>
215-
</div>
194+
{text}
195+
</ReactMarkdown>
196+
)}
197+
{!isUser && (
198+
<div className="message-actions">
199+
<button
200+
className="message-copy-button"
201+
onClick={copyMessage}
202+
title={copiedMessage ? 'Copied!' : 'Copy response'}
203+
>
204+
<svg
205+
width="16"
206+
height="16"
207+
viewBox="0 0 24 24"
208+
fill="none"
209+
stroke="currentColor"
210+
strokeWidth="2"
211+
strokeLinecap="round"
212+
strokeLinejoin="round"
213+
>
214+
{copiedMessage ? (
215+
<path d="M20 6L9 17l-5-5" />
216+
) : (
217+
<>
218+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
219+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
220+
</>
221+
)}
222+
</svg>
223+
</button>
224+
</div>
225+
)}
226+
</>
216227
)}
217228
</div>
218229
{selectedImage && (

chatbot-ui/src/styles/MessageBubble.css

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,3 +300,40 @@ body {
300300
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
301301
}
302302

303+
@keyframes typing {
304+
0% { opacity: 0.3; }
305+
50% { opacity: 1; }
306+
100% { opacity: 0.3; }
307+
}
308+
309+
.message-bubble.bot.loading {
310+
min-height: 32px;
311+
display: flex;
312+
align-items: flex-start;
313+
justify-content: flex-start;
314+
padding: 12px 16px;
315+
}
316+
317+
.typing-indicator {
318+
display: flex;
319+
gap: 4px;
320+
margin-top: 4px;
321+
}
322+
323+
.typing-indicator span {
324+
width: 6px;
325+
height: 6px;
326+
background-color: #8e8e93;
327+
border-radius: 50%;
328+
display: inline-block;
329+
animation: typing 1.4s infinite;
330+
}
331+
332+
.typing-indicator span:nth-child(2) {
333+
animation-delay: 0.2s;
334+
}
335+
336+
.typing-indicator span:nth-child(3) {
337+
animation-delay: 0.4s;
338+
}
339+

0 commit comments

Comments
 (0)