Skip to content

Commit 983661a

Browse files
authored
Merge pull request #13 from giovanni-vaccarino/ui-multi-chat
feat(UI): add multi-chat support for the UI
2 parents 817c888 + ce9555c commit 983661a

10 files changed

Lines changed: 628 additions & 54 deletions

File tree

frontend/src/api/chatbot.ts

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,65 @@
11
import { type Message } from '../model/Message';
2-
import { API_BASE_URL } from '../config';
32
import { getChatbotText } from '../data/chatbotTexts';
43
import { v4 as uuidv4 } from 'uuid';
4+
import { CHATBOT_API_TIMEOUTS_MS } from '../config';
5+
import { callChatbotApi } from '../utils/callChatbotApi';
6+
7+
/**
8+
* Send a request to the backend to create a new chat session and returns the id of the
9+
* chat session created.
10+
*
11+
* @returns A Promise resolving to the id of the new chat session
12+
*/
13+
export const createChatSession = async (): Promise<string> => {
14+
const data = await callChatbotApi<{ session_id: string }>(
15+
'sessions',
16+
{ method: 'POST' },
17+
{ session_id: '' },
18+
CHATBOT_API_TIMEOUTS_MS.CREATE_SESSION
19+
);
20+
21+
return data.session_id;
22+
};
523

624
/**
725
* Sends the user's message to the backend chatbot API and returns the bot's response.
826
* If the API call fails or returns an invalid response, a fallback error message is used.
927
*
28+
* @param sessionId - The session id of the chat
1029
* @param userMessage - The message input from the user
1130
* @returns A Promise resolving to a bot-generated Message
1231
*/
13-
export const fetchChatbotReply = async (userMessage: string): Promise<Message> => {
14-
try {
15-
const response = await fetch(`${API_BASE_URL}/api/chatbot/reply`, {
32+
export const fetchChatbotReply = async (sessionId: string, userMessage: string): Promise<Message> => {
33+
const data = await callChatbotApi<{ reply?: string }>(
34+
`sessions/${sessionId}/message`,
35+
{
1636
method: 'POST',
17-
headers: {
18-
'Content-Type': 'application/json',
19-
},
37+
headers: { 'Content-Type': 'application/json' },
2038
body: JSON.stringify({ message: userMessage }),
21-
});
39+
},
40+
{},
41+
CHATBOT_API_TIMEOUTS_MS.GENERATE_MESSAGE
42+
);
2243

23-
if (!response.ok) {
24-
throw new Error(`Server error: ${response.status}`);
25-
}
26-
27-
const data = await response.json();
44+
const botReply = data.reply || getChatbotText('errorMessage');
45+
return createBotMessage(botReply);
46+
};
2847

29-
return createBotMessage(data.reply || getChatbotText('noResponseMessage'));
30-
} catch (error) {
31-
console.error('Chatbot API error:', error);
32-
return createBotMessage(getChatbotText('errorMessage'));
33-
}
48+
/**
49+
* Sends a request to the backend to delete the chat session with session id sessionId.
50+
*
51+
* @param sessionId - The session id of the chat to delete
52+
*/
53+
export const deleteChatSession = async (sessionId: string): Promise<void> => {
54+
await callChatbotApi<void>(
55+
`sessions/${sessionId}`,
56+
{ method: 'DELETE' },
57+
undefined,
58+
CHATBOT_API_TIMEOUTS_MS.DELETE_SESSION
59+
);
3460
};
3561

62+
3663
/**
3764
* Utility function to create a Message object from the bot,
3865
* using a UUID as the message ID.

frontend/src/components/Chatbot.tsx

Lines changed: 198 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { useState, useEffect } from 'react';
22
import { type Message } from '../model/Message';
3-
import { fetchChatbotReply } from '../api/chatbot';
3+
import { type ChatSession } from '../model/ChatSession';
4+
import { fetchChatbotReply, createChatSession, deleteChatSession } from '../api/chatbot';
45
import { Header } from './Header';
56
import { Messages } from './Messages';
7+
import { Sidebar } from './Sidebar';
68
import { Input } from './Input';
79
import { chatbotStyles } from '../styles/styles';
810
import { getChatbotText } from '../data/chatbotTexts';
11+
import { loadChatbotSessions, loadChatbotLastSessionId } from '../utils/chatbotStorage';
912
import { v4 as uuidv4 } from 'uuid';
1013

1114
/**
@@ -14,45 +17,197 @@ import { v4 as uuidv4 } from 'uuid';
1417
export const Chatbot = () => {
1518
const [isOpen, setIsOpen] = useState(false);
1619
const [input, setInput] = useState('');
20+
const [sessions, setSessions] = useState<ChatSession[]>(loadChatbotSessions);
21+
const [currentSessionId, setCurrentSessionId] = useState<string | null>(loadChatbotLastSessionId);
22+
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false);
23+
const [isPopupOpen, setIsPopupOpen] = useState<boolean>(false);
24+
const [sessionIdToDelete, setSessionIdToDelete] = useState<string | null>(null);
1725

1826
/**
19-
* Messages shown in the chat.
20-
* Initialized from sessionStorage to keep messages between refreshes.
27+
* Saving the chat sessions in the session storage only
28+
* when the component unmounts to avoid continuos savings.
2129
*/
22-
const [messages, setMessages] = useState<Message[]>(() => {
23-
const saved = sessionStorage.getItem('chatbot-messages');
24-
return saved ? JSON.parse(saved) : [];
25-
});
26-
const [loading, setLoading] = useState(false);
27-
2830
useEffect(() => {
29-
sessionStorage.setItem('chatbot-messages', JSON.stringify(messages));
30-
}, [messages]);
31+
const handleBeforeUnload = () => {
32+
sessionStorage.setItem('chatbot-sessions', JSON.stringify(sessions));
33+
sessionStorage.setItem('chatbot-last-session-id', currentSessionId || '');
34+
};
35+
36+
window.addEventListener('beforeunload', handleBeforeUnload);
37+
38+
return () => {
39+
window.removeEventListener('beforeunload', handleBeforeUnload);
40+
};
41+
}, [sessions, currentSessionId]);
42+
43+
/**
44+
* Returns the messages of a chat session.
45+
* @param sessionId The sessionId of the chat session.
46+
* @returns The messages of the chat with id equals to sessionId
47+
*/
48+
const getSessionMessages = (sessionId: string | null) => {
49+
if (currentSessionId === null){
50+
console.error("No current session")
51+
return []
52+
}
53+
const chatSession = sessions.find(item => item.id === sessionId);
54+
55+
if (chatSession){
56+
return chatSession.messages;
57+
}
58+
59+
console.error(`No session found with sessionId ${sessionId}`)
60+
return []
61+
}
62+
63+
/**
64+
* Handles the delete process of a chat session.
65+
*/
66+
const handleDeleteChat = async () => {
67+
if (sessionIdToDelete === null){
68+
console.error("No current selected to delete")
69+
return
70+
}
71+
72+
await deleteChatSession(sessionIdToDelete);
73+
const updatedSessions = sessions.filter(s => s.id !== sessionIdToDelete);
74+
setSessions(updatedSessions);
75+
setIsPopupOpen(false);
76+
if (updatedSessions.length === 0) {
77+
setCurrentSessionId(null);
78+
} else {
79+
setCurrentSessionId(updatedSessions[0].id);
80+
}
81+
};
82+
83+
/**
84+
* Handles the creation process of a chat session.
85+
*/
86+
const handleNewChat = async () => {
87+
const id = await createChatSession();
88+
89+
if (id === "") {
90+
console.error("Add error showage for a couple of seconds.")
91+
return
92+
}
93+
const newSession: ChatSession = { id, messages: [], createdAt: new Date().toISOString(), isLoading: false };
94+
setSessions(prev => [newSession, ...prev]);
95+
setCurrentSessionId(id);
96+
};
3197

32-
const clearMessages = () => {
33-
setMessages([]);
34-
// Once backend is ready, add logic to clear history chat
98+
const appendMessageToCurrentSession = (message: Message) => {
99+
setSessions(prevSessions =>
100+
prevSessions.map(session =>
101+
session.id === currentSessionId
102+
? { ...session, messages: [...session.messages, message] }
103+
: session
104+
)
105+
);
35106
};
36107

108+
/**
109+
* Handles the send process in a chat session.
110+
*/
37111
const sendMessage = async () => {
38112
const trimmed = input.trim();
39-
if (!trimmed) return;
40-
113+
if (!trimmed || !currentSessionId) {
114+
console.error("No sessions available.")
115+
return;
116+
}
117+
if (!trimmed) {
118+
console.error("Empty message provided.")
119+
return;
120+
}
41121
const userMessage: Message = {
42122
id: uuidv4(),
43123
sender: 'user',
44124
text: trimmed,
45125
};
46126

47-
setMessages(prev => [...prev, userMessage]);
48127
setInput('');
49-
setLoading(true);
128+
setSessions(prevSessions =>
129+
prevSessions.map(session =>
130+
session.id === currentSessionId
131+
? { ...session, isLoading: true }
132+
: session
133+
)
134+
);
135+
appendMessageToCurrentSession(userMessage);
50136

51-
const botReply = await fetchChatbotReply(trimmed);
52-
setLoading(false);
53-
setMessages(prev => [...prev, botReply]);
137+
const botReply = await fetchChatbotReply(currentSessionId!, trimmed);
138+
setSessions(prevSessions =>
139+
prevSessions.map(session =>
140+
session.id === currentSessionId
141+
? { ...session, isLoading: false }
142+
: session
143+
)
144+
);
145+
appendMessageToCurrentSession(botReply);
54146
};
55147

148+
const getChatLoading = () : boolean => {
149+
const currentChat = sessions.find(chat => chat.id === currentSessionId);
150+
151+
return currentChat ? currentChat.isLoading : false;
152+
}
153+
154+
const openSideBar = () => {
155+
setIsSidebarOpen(!isSidebarOpen)
156+
}
157+
158+
const onSwitchChat = (chatSessionId: string) => {
159+
openSideBar();
160+
setCurrentSessionId(chatSessionId);
161+
}
162+
163+
const openConfirmDeleteChatPopup = (chatSessionId: string) => {
164+
setSessionIdToDelete(chatSessionId);
165+
setIsPopupOpen(true);
166+
}
167+
168+
const getWelcomePage = () => {
169+
return(
170+
<div
171+
style={chatbotStyles.containerWelcomePage}
172+
>
173+
<div style={chatbotStyles.boxWelcomePage}>
174+
<h2 style={chatbotStyles.welcomePageH2}>{getChatbotText("welcomeMessage")}</h2>
175+
<p>{getChatbotText("welcomeDescription")}</p>
176+
<button style={chatbotStyles.welcomePageNewChatButton}
177+
onClick={handleNewChat}
178+
>
179+
{getChatbotText("createNewChat")}
180+
</button>
181+
</div>
182+
</div>
183+
)
184+
}
185+
186+
const getDeletePopup = () => {
187+
return(
188+
189+
<div
190+
style={chatbotStyles.popupContainer}
191+
>
192+
<h2 style={chatbotStyles.popupTitle}>{getChatbotText("popupTitle")}</h2>
193+
<p style={chatbotStyles.popupMessage}>
194+
{getChatbotText("popupMessage")}
195+
</p>
196+
<div style={chatbotStyles.popupButtonsContainer}>
197+
<button style={chatbotStyles.popupDeleteButton} onClick={handleDeleteChat}>
198+
{getChatbotText("popupDeleteButton")}
199+
</button>
200+
<button style={chatbotStyles.popupCancelButton} onClick={() => {
201+
setIsPopupOpen(false);
202+
setSessionIdToDelete(null);
203+
}}>
204+
{getChatbotText("popupCancelButton")}
205+
</button>
206+
</div>
207+
</div>
208+
)
209+
}
210+
56211
return (
57212
<>
58213
<button
@@ -64,11 +219,29 @@ export const Chatbot = () => {
64219

65220
{isOpen && (
66221
<div
67-
style={chatbotStyles.container}
222+
style={{...chatbotStyles.container, pointerEvents: isPopupOpen ? 'none' : 'auto'}}
68223
>
69-
<Header clearMessages={clearMessages} />
70-
<Messages messages={messages} loading={loading} />
71-
<Input input={input} setInput={setInput} onSend={sendMessage} />
224+
{isSidebarOpen && (
225+
<Sidebar
226+
onClose={() => setIsSidebarOpen(false)}
227+
onCreateChat={handleNewChat}
228+
onSwitchChat={onSwitchChat}
229+
chatList={sessions}
230+
activeChatId={currentSessionId}
231+
openConfirmDeleteChatPopup={openConfirmDeleteChatPopup}
232+
/>
233+
)}
234+
{isPopupOpen && (
235+
getDeletePopup()
236+
)}
237+
<Header currentSessionId={currentSessionId} openSideBar={openSideBar} clearMessages={openConfirmDeleteChatPopup} />
238+
{(currentSessionId !== null) ?
239+
<>
240+
<Messages messages={getSessionMessages(currentSessionId)} loading={getChatLoading()} />
241+
<Input input={input} setInput={setInput} onSend={sendMessage} />
242+
</>
243+
: getWelcomePage()
244+
}
72245
</div>
73246
)}
74247
</>

frontend/src/components/Header.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,35 @@ import { chatbotStyles } from '../styles/styles';
55
* Props for the Header component.
66
*/
77
interface HeaderProps {
8-
clearMessages: () => void;
8+
currentSessionId: string | null;
9+
clearMessages: (chatSessionId: string) => void;
10+
openSideBar: () => void;
911
}
1012

1113
/**
1214
* Header renders the top section of the chatbot panel, including the title and
1315
* a button to clear the current conversation. It receives a callback to handle
1416
* message clearing, typically triggered by user interaction.
1517
*/
16-
export const Header = ({ clearMessages }: HeaderProps) => (
18+
export const Header = ({ currentSessionId, clearMessages, openSideBar }: HeaderProps) => {
19+
return(
1720
<div
1821
style={chatbotStyles.chatbotHeader}
1922
>
20-
<p>{getChatbotText('title')}</p>
2123
<button
22-
onClick={clearMessages}
24+
onClick={openSideBar}
25+
style={chatbotStyles.openSidebarButton}
26+
aria-label="Toggle sidebar"
27+
>
28+
{getChatbotText("sidebarLabel")}
29+
</button>
30+
{(currentSessionId !== null) &&
31+
<button
32+
onClick={() => clearMessages(currentSessionId)}
2333
style={chatbotStyles.clearButton}
2434
>
2535
{getChatbotText("clearChat")}
26-
</button>
36+
</button>
37+
}
2738
</div>
28-
);
39+
)};

0 commit comments

Comments
 (0)