diff --git a/.env b/.env index 64c8d4b9a6eb8967d150f2f0b378984c809fce91..c6677efa614503dfed6fcc5dc5d3cb4b6a4c38dd 100755 --- a/.env +++ b/.env @@ -1,2 +1,3 @@ VITE_APP_NAME=ZBH Portal VITE_PAGE_AFTER_LOGIN=/ +VITE_LOCALE=en-US diff --git a/src/components/chat/Chat.jsx b/src/components/chat/Chat.jsx new file mode 100644 index 0000000000000000000000000000000000000000..80e82ab6f77becd359647b36ded1cc190649ddae --- /dev/null +++ b/src/components/chat/Chat.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { useChat } from '../../contexts/Chat/ChatState'; + + +const Chat = ({ id, title, time }) => { + // ################################# + // HOOKS + // ################################# + // ### CONNECT CONTEXT + const { currentChatId, selectChat } = useChat(); + + + // ################################# + // FUNCTIONS + // ################################# + // ### mark active chat + const active = (currentChatId === id ? 'bg-gray-200' : 'bg-white'); + + // ################################# + // OUTPUT + // ################################# + return ( + <button onClick={() => { selectChat(id); }} className={`block p-1 hover:bg-gray-200 m-1 rounded-md ${active}`} title={title}> + <div className={'flex items-center p-2 cursor-pointer '}> + <div className="flex-grow p-2"> + <div className="flex justify-between text-md"> + + <div className="text-xs text-gray-400 dark:text-gray-300">{time}</div> + </div> + <div className="text-sm text-gray-500 dark:text-gray-400 w-40 truncate"> + {title} + </div> + </div> + </div> + </button> + ); +}; + +export default Chat; \ No newline at end of file diff --git a/src/components/chat/Chats.jsx b/src/components/chat/Chats.jsx new file mode 100644 index 0000000000000000000000000000000000000000..be28c0202ae974c3b14dcc6941e72836166d56f3 --- /dev/null +++ b/src/components/chat/Chats.jsx @@ -0,0 +1,77 @@ +import React, { useEffect, useState } from 'react'; +import Chat from './Chat'; +import Heading from '../font/Heading'; +import { RiArrowLeftCircleLine, RiArrowRightCircleLine } from 'react-icons/ri'; +import { useChat } from '../../contexts/Chat/ChatState'; +import { useParams } from 'react-router-dom'; + +const Chats = () => { + // ################################# + // HOOKS + // ################################# + // ### showSidebar + const initialShowSidebar = true; + const [showSidebar, setShowSidebar] = useState(initialShowSidebar); + + // FETCH CHAT ID FROM URL + const { id } = useParams(); + + // ### CONNECT CONTEXT + const { fetchAllChats, chatHeadings } = useChat(); + + // ### FETCH CHATS; + useEffect(() => { + // ### on run exec this code + const controller = new AbortController(); + + const getChats = async () => { + try { + // fetch all chats (and directly show id if provided) + await fetchAllChats(id); + } catch (error) { + console.error(error); + mergeBackendValidation(error.response.status, error.response.data); + } + }; + getChats(); + + // ### return will be executed on unmounting this component + return () => { + // on unmount abort request + controller.abort(); + }; + }, []); + + // ################################# + // FUNCTIONS + // ################################# + + // ################################# + // OUTPUT + // ################################# + return ( + <div className="row-start-2 row-span-2 border-r-2 border-UhhGrey flex flex-col"> + <Heading level="6" className="text-center">Recent</Heading> + <div className="p-1"> + {showSidebar && + chatHeadings.map((chat, index) => ( + <Chat + key={chat.id} + id={chat.id} + title={chat.title} + time={new Intl.DateTimeFormat(import.meta.env.VITE_LOCALE).format(new Date(chat.created))} + /> + )) + } + </div> + <div className="mt-auto mb-8 flex justify-end text text-4xl" title='show / hide Chat'> + <button onClick={() => setShowSidebar(!showSidebar)}> + {showSidebar ? <RiArrowLeftCircleLine /> : <RiArrowRightCircleLine />} + + </button> + </div> + </div> + ); +}; + +export default Chats; \ No newline at end of file diff --git a/src/components/chat/Message.jsx b/src/components/chat/Message.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5b83f88472035bc4d1425d2ae8d0f0894cc48221 --- /dev/null +++ b/src/components/chat/Message.jsx @@ -0,0 +1,49 @@ +import React from 'react'; + +function Message({ sender, message }) { + // ################################# + // HOOKS + // ################################# + + + // ################################# + // FUNCTIONS + // ################################# + // TODO: use tailwind merge + // AI css + let tilePosition = ''; + let tileColor = 'bg-UhhGrey'; + let tileBorder = 'rounded-bl-none'; + let tileMargin = 'mr-8'; + let senderClasses = 'text-UhhLightGrey'; + let messageClasses = 'text-UhhWhite'; + + // user css + if (sender === 'human') { + tilePosition = 'justify-end'; + tileColor = 'bg-UhhLightGrey'; + tileBorder = 'rounded-br-none'; + tileMargin = 'ml-8'; + senderClasses = 'text-UhhGrey'; + messageClasses = 'text-UhhGrey'; + } + + // ################################# + // OUTPUT + // ################################# + return ( + <div className={`flex ${tilePosition}`} > + <div className={`p-3 mx-3 my-1 rounded-2xl ${tileBorder} ${tileColor} ${tileMargin}`}> + <div className={`text-xs flex justify-between ${senderClasses}`} > + <div>{sender}</div> + </div> + <div className={`${messageClasses} whitespace-pre-line`}> + {message} + </div> + + </div> + </div> + ); +} + +export default React.memo(Message); \ No newline at end of file diff --git a/src/components/chat/Messages.jsx b/src/components/chat/Messages.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8e4fdcc1be29c22bdb6b3fbe502b34af539a651c --- /dev/null +++ b/src/components/chat/Messages.jsx @@ -0,0 +1,51 @@ +import React, { useEffect } from 'react'; +import Message from './Message'; +import { useChat } from '../../contexts/Chat/ChatState'; +import { Link } from 'react-router-dom'; +import { RxBookmark } from 'react-icons/rx'; + + +const Messages = () => { + // ################################# + // HOOKS + // ################################# + // ### CONNECT CONTEXT + const { currentChatId, fetchChatHistory, chatHistory } = useChat(); + + useEffect(() => { + // ### on run exec this code + const controller = new AbortController(); + // ### fetch chat history based on current chat id + fetchChatHistory(currentChatId); + // ### return will be executed on unmounting this component + return () => { + // on unmount abort request + controller.abort(); + }; + }, [currentChatId]); + + + // ################################# + // FUNCTIONS + // ################################# + + // ################################# + // OUTPUT + // ################################# + return ( + <div className="row-start-2 overflow-auto"> + {<Link to={`/onboarding/${currentChatId}`} className='text-UhhBlue' target='_blank' rel='noopener noreferrer'><RxBookmark /></Link>} + {chatHistory.map((prompt, index) => ( + <Message + key={index} + sender={prompt.type} + message={prompt.data.content} + /> + )) + } + {!chatHistory.length && <div className="text-center text-gray-500">No messages yet</div>} + </div> + ); +}; + +export default Messages; \ No newline at end of file diff --git a/src/components/chat/PromptInput.jsx b/src/components/chat/PromptInput.jsx new file mode 100644 index 0000000000000000000000000000000000000000..cb5a328e30df151b670e91cf82fe23ffe57a365c --- /dev/null +++ b/src/components/chat/PromptInput.jsx @@ -0,0 +1,81 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { RiSendPlane2Line } from 'react-icons/ri'; +import { z } from 'zod'; + +import Input from '../form/Input'; +import { mergeBackendValidation } from '../../utils/ErrorHandling'; +import api from '../../utils/AxiosConfig'; +import { useChat } from '../../contexts/Chat/ChatState'; + +function PromptInput() { + // ################################# + // VALIDATION SCHEMA + // ################################# + const schema = z.object({ + input: z.string().min(1), + model: z.string().min(1) + }); + + // ################################# + // HOOKS + // ################################# + // ### CONNECT CONTEXT + const { currentChatId } = useChat(); + + // ### PREPARE FORM + const methods = useForm({ + resolver: zodResolver(schema), + mode: 'onSubmit', + defaultValues: { + input: '', + model: 'llama2' + } + }); + + // ################################# + // FUNCTIONS + // ################################# + // ### HANDLE SUBMITTING FORM + async function handleSendForm(inputs) { + // invoke chatID if available + if (currentChatId) { inputs.chatId = currentChatId; }; + // send data to api + try { + const result = await api.post('/ai/chat', inputs); + // TODO: update chat context + console.log("🚀 ~ handleSendForm ~ result:", result); + + + } catch (error) { + // merge front & backend validation errors + mergeBackendValidation(error.response.status, error.response.data, methods.setError); + } + } + + // TODO fetch available model names from backend + // TODO make model a dropdown + // ################################# + // OUTPUT + // ################################# + return ( + <div className="row-start-3 p-3 border-t-2 border-UhhGrey"> + <FormProvider {...methods} > + <form onSubmit={methods.handleSubmit(handleSendForm)}> + <div className="flex content-center h-14"> + <Input name="model" /> + <Input name="input" type="text" placeholder="Type a message" className="block w-full h-8" /> + + <button type="submit" className="h-8 justify-center items-center bg-UhhBlue text-UhhWhite p-1 text-xs"> + <RiSendPlane2Line /> + </button> + + </div> + </form> + </FormProvider> + </div> + ); +} + +export default React.memo(PromptInput); \ No newline at end of file diff --git a/src/components/form/Input.jsx b/src/components/form/Input.jsx index 8d9f8c655a134e1b174605e30e8954d04be79dac..7ecb868c48c428db11e624bd462e24f38b6ae72e 100755 --- a/src/components/form/Input.jsx +++ b/src/components/form/Input.jsx @@ -42,7 +42,7 @@ function Input({ title, name, type, className, tooltip, ...props }) { <> <label htmlFor={id}> {props.required && <RequiredBadge />} - {capitalizeFirstLetter(title)} + {title && capitalizeFirstLetter(title)} {tooltip && <Tooltip>{tooltip}</Tooltip>} <input {...register(name)} diff --git a/src/contexts/Chat/ChatContext.jsx b/src/contexts/Chat/ChatContext.jsx new file mode 100644 index 0000000000000000000000000000000000000000..fe0db341b20e7c64e9c40f9038b1fa7a2e565e4b --- /dev/null +++ b/src/contexts/Chat/ChatContext.jsx @@ -0,0 +1,5 @@ +import { createContext } from "react"; + +const ChatContext = createContext(); + +export default ChatContext; \ No newline at end of file diff --git a/src/contexts/Chat/ChatReducer.jsx b/src/contexts/Chat/ChatReducer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d81d48e3fa9c8774df90e17a0bf18efa443a72c2 --- /dev/null +++ b/src/contexts/Chat/ChatReducer.jsx @@ -0,0 +1,15 @@ +import { CHAT_ACTIONS } from './ChatTypes'; + +const chatReducer = (state, action) => { + switch (action.type) { + case CHAT_ACTIONS.SET_CHATS: + case CHAT_ACTIONS.SET_HEADINGS: + case CHAT_ACTIONS.UPDATE_CHATID: + case CHAT_ACTIONS.SET_HISTORY: + return action.payload; + default: + return state; + } +}; + +export default chatReducer; \ No newline at end of file diff --git a/src/contexts/Chat/ChatState.jsx b/src/contexts/Chat/ChatState.jsx new file mode 100644 index 0000000000000000000000000000000000000000..bb3cf8ddbbfdc45c1f651132c15131ddc74561da --- /dev/null +++ b/src/contexts/Chat/ChatState.jsx @@ -0,0 +1,101 @@ +import React, { useContext, useReducer, useState } from 'react'; +import ChatContext from './ChatContext'; +import chatReducer from './ChatReducer'; +import { CHAT_ACTIONS } from './ChatTypes'; +import api from '../../utils/AxiosConfig'; +import { mergeBackendValidation } from '../../utils/ErrorHandling'; + +// ### EXPORT useContext TO REDUCE NEEDED CODE IN CLIENT FILES +export function useChat() { + return useContext(ChatContext); +} + +function ChatState({ children }) { + // ################################# + // HOOKS + // ################################# + + const [chats, dispatchChats] = useReducer(chatReducer, []); + const [chatHeadings, dispatchChatHeadings] = useReducer(chatReducer, []); + const [currentChatId, dispatchCurrentChatId] = useReducer(chatReducer, null); + const [chatHistory, dispatchChatHistory] = useReducer(chatReducer, []); + + + + // ################################# + // FUNCTIONS + // ################################# + // ### FETCH CHATS + async function fetchAllChats(id = null) { + try { + // load all chats and save them + const items = await api.get('/ai/chats'); + dispatchChats({ type: CHAT_ACTIONS.SET_CHATS, payload: items.data.chats }); + // fetch headings from chats + fetchChatHeadings(items.data.chats); + // select chat if id is provided + if (id) selectChat(id); + } catch (error) { + // display errors + mergeBackendValidation(error.response.status, error.response.data); + } + } + + + // ### FETCH HEADINGS FROM CHATS + function fetchChatHeadings(chats) { + // create new array + const headings = []; + // loop through chats + chats.forEach(chat => { + // split history from chat object + const removedKey = 'chatHistory'; + const { [removedKey]: removed, ...heading } = chat; + // save in array + headings.push(heading); + }); + // save array in state + dispatchChatHeadings({ type: CHAT_ACTIONS.SET_HEADINGS, payload: headings }); + } + + + // ### FETCH CHAT HISTORY + function fetchChatHistory(id) { + // return empty if no chat was chosen + if (!id) return []; + // define a function to find the chat + const isSelectedChatId = element => element.id === id; + // get index of matching item + const index = chats.findIndex(isSelectedChatId); + // return history of chat + const history = chats[index].chatHistory; + // save history in state + dispatchChatHistory({ type: CHAT_ACTIONS.SET_HISTORY, payload: history }); + } + + + // ### SELECT CHAT + function selectChat(id) { + // return null if no id is provided (just to prevent switching between null and undefined) + if (!id) id = null; + // save chat id in state + dispatchCurrentChatId({ type: CHAT_ACTIONS.UPDATE_CHATID, payload: id }); + } + // ################################# + // OUTPUT + // ################################# + + return ( + <ChatContext.Provider value={{ + fetchAllChats, + chatHeadings, + currentChatId, + selectChat, + fetchChatHistory, + chatHistory + }}> + {children} + </ChatContext.Provider> + ); +} +export default React.memo(ChatState); diff --git a/src/contexts/Chat/ChatTypes.js b/src/contexts/Chat/ChatTypes.js new file mode 100644 index 0000000000000000000000000000000000000000..7b4fd86be1be13e03d8e09623cd15ef9c6186541 --- /dev/null +++ b/src/contexts/Chat/ChatTypes.js @@ -0,0 +1,6 @@ +export const CHAT_ACTIONS = { + SET_CHATS: 'set_chats', + SET_HEADINGS: 'set_headings', + SET_HISTORY: 'set_history', + UPDATE_CHATID: 'update_chatid' +}; \ No newline at end of file diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index 87e754dadcad50514fc91cc2a23430be7935bb7b..790eb305948417f0d4e64e1b8e1fb28c60385b18 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import Heading from '../components/font/Heading'; function Home() { // ################################# @@ -13,9 +14,7 @@ function Home() { // OUTPUT // ################################# return ( - <h1 className="text-3xl font-bold underline"> - Hello world! - </h1> + <Heading level="1">Home</Heading> ); } diff --git a/src/pages/Onboarding/Onboarding.jsx b/src/pages/Onboarding/Onboarding.jsx index d9dd41470e023d38c843394df8fd6b94b304925a..2a2640b37639658a5600876d16e3b5a3f22e07b9 100644 --- a/src/pages/Onboarding/Onboarding.jsx +++ b/src/pages/Onboarding/Onboarding.jsx @@ -1,10 +1,18 @@ import React from 'react'; +import { Helmet } from 'react-helmet-async'; +import Heading from '../../components/font/Heading'; +import Chats from '../../components/chat/Chats'; +import Messages from '../../components/chat/Messages'; +import PromptInput from '../../components/chat/PromptInput'; +import ChatState from '../../contexts/Chat/ChatState'; + function Onboarding() { // ################################# // HOOKS // ################################# + // ################################# // FUNCTIONS // ################################# @@ -13,8 +21,18 @@ function Onboarding() { // OUTPUT // ################################# return ( - <div>TEST Onboarding</div> + <div className="h-full grid grid-rows-[auto_1fr_auto] grid-cols-[auto_1fr]"> + {/* render page title */} + <Helmet><title>[{import.meta.env.VITE_APP_NAME}] Onboarding</title></Helmet> + + <Heading level="1" className="col-span-2">Onboarding</Heading> + <ChatState> + <Chats /> + <Messages /> + <PromptInput /> + </ChatState> + </div> ); } -export default React.memo(Onboarding); \ No newline at end of file +export default React.memo(Onboarding);; \ No newline at end of file diff --git a/src/routes/Sitemap.jsx b/src/routes/Sitemap.jsx index 0b7664b104e5268d7e4bbaf0f5433e408912d7b3..ddb8a2262b6213cf53a0aeec0430c33f9b6ebbcd 100644 --- a/src/routes/Sitemap.jsx +++ b/src/routes/Sitemap.jsx @@ -20,8 +20,12 @@ export const sitemap = [{ { title: 'Onboarding', path: '/onboarding', - element: loadComponent('Onboarding/Onboarding', true, true), - handle: { crumb: () => <Link to="/onboarding">Onboarding</Link> } + handle: { crumb: () => <Link to="/onboarding">Onboarding</Link> }, + children: [ + { index: true, element: loadComponent('Onboarding/Onboarding') }, + { title: 'Chat', path: ':id', element: loadComponent('Onboarding/Onboarding') } + + ] }, // REQUEST CHANGE EMAIL ADDRESS {