diff --git a/.eslintrc.json b/.eslintrc.json index 6d03bc4a..b67d5e8e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -20,6 +20,7 @@ "simple-import-sort/imports": "error", "simple-import-sort/exports": "error", "prettier/prettier": "error", + "no-nested-ternary": "warn", "react/react-in-jsx-scope": "off", "jsx-a11y/anchor-is-valid": "off", "react/require-default-props": "off", diff --git a/.gitignore b/.gitignore index 45c1abce..2990e412 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +.yarn +.idea # dependencies /node_modules diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 00000000..3186f3f0 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/package.json b/package.json index 5bbcee0d..5d4d978f 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,9 @@ "@nextui-org/card": "^2.0.34", "@nextui-org/chip": "^2.0.33", "@nextui-org/code": "2.0.33", + "@nextui-org/dropdown": "^2.1.31", "@nextui-org/image": "^2.0.32", "@nextui-org/input": "2.2.5", - "@nextui-org/dropdown": "^2.1.31", "@nextui-org/kbd": "2.0.34", "@nextui-org/link": "2.0.35", "@nextui-org/listbox": "^2.1.27", @@ -44,8 +44,10 @@ "leaflet": "^1.9.4", "leaflet-defaulticon-compatibility": "^0.1.2", "leaflet-geosearch": "^4.0.0", + "lucide-react": "^0.454.0", "next": "14.2.10", "next-themes": "^0.2.1", + "nextui-cli": "^0.3.4", "react": "18.3.1", "react-dom": "18.3.1", "react-leaflet": "^4.2.1", @@ -94,4 +96,4 @@ ] } } -} \ No newline at end of file +} diff --git a/public/wfp-logo.png b/public/wfp-logo.png new file mode 100644 index 00000000..de8f9c90 Binary files /dev/null and b/public/wfp-logo.png differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7dab0b22..46edbe06 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx'; import { Metadata, Viewport } from 'next'; import { AlertsMenuWrapper } from '@/components/AlertsMenu/AlertsMenuWrapper'; +import Chatbot from '@/components/Chatbot/Chatbot'; import { Sidebar } from '@/components/Sidebar/Sidebar'; import { fontSans } from '@/config/fonts'; import { siteConfig } from '@/config/site'; @@ -35,6 +36,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
+
{children}
diff --git a/src/components/Chatbot/Chatbot.tsx b/src/components/Chatbot/Chatbot.tsx new file mode 100644 index 00000000..c486abcb --- /dev/null +++ b/src/components/Chatbot/Chatbot.tsx @@ -0,0 +1,353 @@ +/* eslint-disable no-nested-ternary */ + +'use client'; + +import { Button, Card, CardBody, CardFooter, CardHeader, Divider, Tooltip } from '@nextui-org/react'; +import clsx from 'clsx'; +import { CloseCircle, Send2, SidebarLeft, SidebarRight } from 'iconsax-react'; +import { Bot, Maximize2, Minimize2 } from 'lucide-react'; +import Image from 'next/image'; +import { useEffect, useRef, useState } from 'react'; + +import TypingText from '@/components/TypingText/TypingText'; +import container from '@/container'; +import { + CHAT_TITLE, + DATA_SOURCES, + DEFAULT_DATA_SOURCES, + DEFAULT_PROMPT, + ENTER_FULL_SCREEN, + EXIT_FULL_SCREEN, + SUB_WELCOME_MESSAGE, + TYPING_PLACEHOLDER, + WELCOME_MESSAGE, +} from '@/domain/constant/chatbot/Chatbot'; +import { APIError } from '@/domain/entities/chatbot/BackendCommunication'; +import { IChat } from '@/domain/entities/chatbot/Chatbot'; +import { SenderRole } from '@/domain/enums/SenderRole'; +import ChatbotRepository from '@/domain/repositories/ChatbotRepository'; +import { useMediaQuery } from '@/utils/resolution'; + +import TypingDots from '../TypingText/TypingDot'; +import ChatbotSidebar from './ChatbotSidebar'; + +export default function HungerMapChatbot() { + const chatbot = container.resolve('ChatbotRepository'); + const [isOpen, setIsOpen] = useState(false); + const [isFullScreen, setIsFullScreen] = useState(false); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [isUserMessageSent, setIsUserMessageSent] = useState(false); + const [chats, setChats] = useState([{ id: 1, title: 'Chat 1', messages: [] }]); + const [currentChatIndex, setCurrentChatIndex] = useState(0); + const [input, setInput] = useState(''); + const [isTyping, setIsTyping] = useState(false); + const inputRef = useRef(null); + const chatEndRef = useRef(null); + const isMobile = useMediaQuery('(max-width: 640px)'); + + const toggleChat = (): void => { + if (isMobile) { + setIsFullScreen(!isOpen); + } else if (isOpen) { + // if close chat, then should exit full screen + setIsFullScreen(false); + } + setIsOpen(!isOpen); + }; + + const toggleFullScreen = (): void => { + if (!isMobile) { + setIsFullScreen(!isFullScreen); + } + }; + + const startNewChat = (): void => { + const newChat: IChat = { id: chats.length + 1, title: `Chat ${chats.length + 1}`, messages: [] }; + setChats([...chats, newChat]); + setCurrentChatIndex(chats.length); + if (isMobile) { + setIsSidebarOpen(false); + } + }; + + /** + * Select chat in side bar + * @param index is the index of the chat to select + */ + const selectChat = (index: number): void => { + setCurrentChatIndex(index); + if (isMobile) { + setIsSidebarOpen(false); + } + }; + + /** + * Handle AI response + * @param text is user input text + * @param updatedChats is the updated chats object + */ + const handleAIResponse = async (text: string): Promise => { + const previousMessages = chats[currentChatIndex].messages; + let aiResponse = ''; + try { + const response = await chatbot.sendMessage(text, { previous_messages: previousMessages }); + aiResponse = response.response; + } catch (err) { + if (err instanceof APIError) { + aiResponse = `Ups! Unfortunately, it seems like there was a problem connecting to the server...\n ${err.status}: ${err.message}`; + } + } + // TODO: get data sources from response later + const dataSources = DEFAULT_DATA_SOURCES; + const updatedChatsWithAI = structuredClone(chats); + updatedChatsWithAI[currentChatIndex].messages.push({ + id: crypto.randomUUID(), + content: aiResponse, + role: SenderRole.ASSISTANT, + dataSources, + }); + setChats(updatedChatsWithAI); + }; + + /** + * Handle form submit + * @param fromEvent is form event including key down triggered submit + * @param promptText is requested text from user + */ + const handleSubmit = (fromEvent: React.FormEvent, promptText: string | null = null): void => { + fromEvent.preventDefault(); + const text = promptText || input; + if (isTyping) return; // prevent multiple submission + if (text.trim()) { + const updatedChats = structuredClone(chats); + updatedChats[currentChatIndex].messages.push({ id: crypto.randomUUID(), content: text, role: SenderRole.USER }); + if (updatedChats[currentChatIndex].title === `Chat ${updatedChats[currentChatIndex].id}`) { + updatedChats[currentChatIndex].title = text.slice(0, 30) + (text.length > 30 ? '...' : ''); + } + setChats(updatedChats); + setInput(''); + setIsUserMessageSent(true); + setIsTyping(true); + } + }; + + const handleKeyDown = (keyboardEvent: React.KeyboardEvent): void => { + if (keyboardEvent.key === 'Enter' && !keyboardEvent.shiftKey) { + keyboardEvent.preventDefault(); + if (isTyping) return; // prevent multiple submission + handleSubmit(keyboardEvent); + } + }; + + /** + * Since React's setState is asynchronous, + * Updating the chat state when handleSubmit is not immediately reflected in the handleAIResponse. + * So, we need to clone the chats object to make sure the state is updated before calling handleAIResponse. + * And trigger handleAIResponse only when isUserMessageSent is true. + */ + useEffect(() => { + if (isUserMessageSent) { + const latestMessage = chats[currentChatIndex].messages.slice(-1)[0]; + if (latestMessage.role === SenderRole.USER) { + handleAIResponse(latestMessage.content).then(() => { + setIsUserMessageSent(false); + }); + } + } + }, [isUserMessageSent]); + + // use to scroll to the end of the chat when new messages are added + useEffect(() => { + chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [chats, currentChatIndex]); + + // listen to isOpen and isMobile to set isFullScreen + useEffect(() => { + if (isMobile && isOpen) { + setIsFullScreen(true); + } + }, [isMobile, isOpen]); + + // used to auto resize the input textarea when input is too long + useEffect(() => { + if (inputRef.current) { + inputRef.current.style.height = 'auto'; + inputRef.current.style.height = `${inputRef.current.scrollHeight}px`; + } + }, [input]); + + return ( +
+ {!isOpen && ( + + )} + {/* chatbot interface */} + {isOpen && ( + <> + {isSidebarOpen && ( + + )} + + +
+ + WFP Logo +

{CHAT_TITLE}

+
+
+ {!isMobile && ( + + + + )} + + + +
+
+ + +
+ {/* overlay area in mobile version */} + {isMobile && isSidebarOpen && ( + /* since it has been show as overlay style here, once click this area then close side panel better not use button here */ + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
setIsSidebarOpen((previousValue) => !previousValue)} + /> + )} + {/* chat area */} + +
+ {chats[currentChatIndex].messages.length === 0 ? ( +
+

{WELCOME_MESSAGE}

+

{SUB_WELCOME_MESSAGE}

+
+ {DEFAULT_PROMPT.map((prompt) => ( + + ))} +
+
+ ) : ( + chats[currentChatIndex].messages.map((message) => ( +
+ {message.role === SenderRole.ASSISTANT && ( +
+ +
+ )} +
+ {message.role === SenderRole.USER ? ( +

{message.content}

+ ) : ( + setIsTyping(false)} + /> + )} + {message.dataSources && ( +
+

{DATA_SOURCES}

+
    + {message.dataSources.map((source) => ( +
  • {source}
  • + ))} +
+
+ )} +
+
+ )) + )} + + {isTyping && ( +
+
+ +
+ +
+ )} +
+
+
+ + + +
+
+