diff --git a/index.html b/index.html index 37ea02a..090bc91 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,17 @@ - - - - - Ayesha of Nigcomsat - - -
- - - + + + + + + + Ayesha of Nigcomsat + + + +
+ + + + \ No newline at end of file diff --git a/package.json b/package.json index 4ffc0b3..b977705 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@google/model-viewer": "^3.3.0", "@heroicons/react": "^2.0.18", "@tanstack/react-query": "^5.7.0", "axios": "^1.6.0", @@ -19,8 +20,11 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.48.2", "react-loader-spinner": "^5.3.4", - "react-router-dom": "^6.18.0", - "socket.io-client": "^4.7.2" + "react-markdown": "^9.0.1", + "react-router-dom": "^6.20.1", + "rehype-sanitize": "^6.0.0", + "socket.io-client": "^4.7.2", + "three": "^0.160.0" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/public/avatar.png b/public/avatar.png index 519fa8b..dbf9ec9 100644 Binary files a/public/avatar.png and b/public/avatar.png differ diff --git a/src/assets/avatar.png b/src/assets/avatar.png deleted file mode 100644 index 519fa8b..0000000 Binary files a/src/assets/avatar.png and /dev/null differ diff --git a/src/assets/mic-filled.svg b/src/assets/mic-filled.svg new file mode 100644 index 0000000..ae11cbf --- /dev/null +++ b/src/assets/mic-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/mic-off.svg b/src/assets/mic-off.svg deleted file mode 100644 index e4f66c4..0000000 --- a/src/assets/mic-off.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/assets/send-icon.svg b/src/assets/send-icon.svg index 1d85dc6..f2bfe4c 100644 --- a/src/assets/send-icon.svg +++ b/src/assets/send-icon.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 2a79765..5152419 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -1,155 +1,134 @@ -import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/solid'; -import { useMutation } from '@tanstack/react-query'; -import axios from 'axios'; -import { cx } from 'class-variance-authority'; -import { useAtom } from 'jotai'; -import React, { forwardRef, useEffect, useRef, useState } from 'react'; -import { ThreeDots } from 'react-loader-spinner'; -import { connectionAtom, messageAtom } from '../state/atoms'; -import { MessageType } from '../types'; +import { useMutation } from "@tanstack/react-query"; +import axios from "axios"; +import { cx } from "class-variance-authority"; +import { useAtom } from "jotai"; +import React, { forwardRef, useEffect, useRef, useState } from "react"; +import { ThreeDots } from "react-loader-spinner"; +import { connectionAtom, messageAtom } from "../state/atoms"; +import { MessageType } from "../types"; +import ReactMarkdown from "react-markdown"; +import rehypeSanitize from "rehype-sanitize"; type Message = { - message: MessageType; - createFeedback: (data: unknown) => void; - isLast?: boolean; - isFirst?: boolean; - isConnected?: boolean; + message: MessageType; + createFeedback: (data: unknown) => void; + isLast?: boolean; + isFirst?: boolean; + isConnected?: boolean; }; const Message = forwardRef( - ({ message, isFirst, isLast, isConnected, createFeedback }, ref) => { - const [feedback, setFeedback] = useState<'like' | 'dislike' | undefined>(); - - const containerClass = cx('flex w-full', { - 'justify-start rounded-md relative': message.role === 'assistant', - 'animate-pop': message.role === 'assistant' && isLast && !message.typing, - 'justify-end rounded-md': message.role === 'user', - }); - - const itemClass = cx('p-4 dark:text-white text-black rounded-b-xl max-w-[35rem]', { - 'rounded-tr-xl bg-[#ffcb0520] relative': message.role === 'assistant', - 'rounded-tl-xl dark:bg-[#ffffff26] bg-[#00000026] ': message.role === 'user', - }); - - const nowTyping = message.typing && isLast; - const wasTyping = message.typing && !isLast; - - const handleFeedback = (which: 'like' | 'dislike') => { - if (!isFirst) { - setFeedback(which); - createFeedback({ - orgId: message.orgId, - sessionId: message.sessionId, - feedback: which, - }); - } - }; - - if (wasTyping) return null; - - return ( -
- - {nowTyping ? ( - - ) : ( -

- {message.message} - - - - -

- )} -
-
- ); - }, + ({ message, isLast }, ref) => { + const containerClass = cx("flex w-full text-xs md:text-base", { + "justify-start rounded-md relative": message.role === "assistant", + "animate-pop": message.role === "assistant" && isLast && !message.typing, + "justify-end rounded-md": message.role === "user", + }); + + const itemClass = cx( + "p-4 text-white rounded-b-xl max-w-[80%] md:max-w-[35rem]", + { + "rounded-tr-xl bg-white bg-opacity-10 relative": message.role === "assistant", + "rounded-tl-xl user-message-gradient": + message.role === "user", + } + ); + + const nowTyping = message.typing && isLast; + const wasTyping = message.typing && !isLast; + + if (wasTyping) return null; + + return ( +
+ + {nowTyping ? ( + + ) : ( + <> + {children}; + }, + }} + className="[&>pre]:whitespace-pre-line" + rehypePlugins={[rehypeSanitize]} + > + {message.message} + + + )} + +
+ ); + } ); const Dialog: React.FC = () => { - const [messages] = useAtom(messageAtom); - const [{ totalDislikes, messenger, connected }, setConnection] = useAtom(connectionAtom); - const [isEscalated, setIsEscalated] = useState(false); - - const { mutate: createFeedback } = useMutation({ - mutationKey: ['createFeedback'], - mutationFn: (data: unknown) => axios.post(`${import.meta.env.VITE_BASE_URL}/feedback`, data), - onSuccess: () => { - setConnection((prev) => ({ - ...prev, - totalDislikes: prev?.totalDislikes ? prev.totalDislikes + 1 : 1, - })); - }, - }); - - if (!isEscalated && totalDislikes === 3) { - setIsEscalated(true); - } - - useEffect(() => { - if (isEscalated) { - setIsEscalated(false); - messenger?.call({ - action: 'prompt', - adhoc: 'escalate-level-1', - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isEscalated]); - - const lastMessageRef = useRef(null); - - useEffect(() => { - if (lastMessageRef.current) { - lastMessageRef.current.scrollIntoView({ behavior: 'smooth' }); - } - }, [messages]); - - return ( -
- {messages.map((message, index, arr) => ( - - ))} -
- ); + const [messages] = useAtom(messageAtom); + const [{ totalDislikes, messenger, connected }, setConnection] = + useAtom(connectionAtom); + const [isEscalated, setIsEscalated] = useState(false); + + const { mutate: createFeedback } = useMutation({ + mutationKey: ["createFeedback"], + mutationFn: (data: unknown) => + axios.post(`${import.meta.env.VITE_BASE_URL}/feedback`, data), + onSuccess: () => { + setConnection((prev) => ({ + ...prev, + totalDislikes: prev?.totalDislikes ? prev.totalDislikes + 1 : 1, + })); + }, + }); + + if (!isEscalated && totalDislikes === 3) { + setIsEscalated(true); + } + + useEffect(() => { + if (isEscalated) { + setIsEscalated(false); + messenger?.call({ + action: "prompt", + adhoc: "escalate-level-1", + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEscalated]); + + const lastMessageRef = useRef(null); + + useEffect(() => { + if (lastMessageRef.current) { + lastMessageRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [messages]); + + return ( +
+ {messages.map((message, index, arr) => ( + + ))} +
+ ); }; export default Dialog; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index e0e6336..af2f764 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,7 +1,7 @@ import { useAtom } from 'jotai'; import { useEffect } from 'react'; import { Radio } from 'react-loader-spinner'; -import avatar from '../assets/avatar.png'; +import avatar from '/avatar.png'; import cancelIcon from '../assets/cancel.svg'; import wifiIcon from '../assets/wifi.svg'; import { connectionAtom, socketAtom } from '../state/atoms'; @@ -30,13 +30,13 @@ const Header: React.FC = ({ agentName }) => { return ( <> -
-
-
+
+
+
avatar
-

Ask {agentName}

+

Ask {agentName}

{connected ? ( = ({ agentName }) => { wrapperClass='radio-wrapper' /> ) : ( -
+
- - + +
-

+

Your chat session has ended due to inactivity.

diff --git a/src/components/HomePage.tsx b/src/components/HomePage.tsx index c7f7042..b58e6ba 100644 --- a/src/components/HomePage.tsx +++ b/src/components/HomePage.tsx @@ -1,41 +1,45 @@ -import { useAtom } from 'jotai'; -import { BallTriangle } from 'react-loader-spinner'; -import { connectionAtom, createSocketAtom } from '../state/atoms'; -import Main from './Main'; -import Navbar from './Navbar'; +import { useAtom } from "jotai"; +import { BallTriangle } from "react-loader-spinner"; +import { connectionAtom, createSocketAtom } from "../state/atoms"; +import Main from "./Main"; +import Sidebar from "./Sidebar"; const HomePage = () => { - const [, createConnection] = useAtom(createSocketAtom); - createConnection(); - const [{ connected, agentName, timedOut, logoUrl }] = useAtom(connectionAtom); - // const [accessKey] = useAtom(accessKeyAtom); + const [, createConnection] = useAtom(createSocketAtom); + createConnection(); + const [{ connected, agentName, timedOut, logoUrl }] = useAtom(connectionAtom); - // if (!accessKey) { - // return
Landing Page
; - // } + if (!connected && !timedOut && !agentName) { + return ( +
+ +

+ Establishing connection to the customer agent. Please wait... +

+
+ ); + } - if (!connected && !timedOut && !agentName) { - return ( -
- -

Establishing connection to the customer agent. Please wait...

-
- ); - } - - return ( -
- -
-
- ); + return ( +
+
+
+
+
+
+ +
+
+
+
+ ); }; export default HomePage; diff --git a/src/components/Input.tsx b/src/components/Input.tsx index 2f3eb82..1f762a1 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -1,129 +1,160 @@ -import { useAtom } from 'jotai'; -import { useEffect, useState } from 'react'; -import { Bars } from 'react-loader-spinner'; -import micOffIcon from '../assets/mic-off.svg'; -import sendIcon from '../assets/send-icon.svg'; -import { capitalize } from '../helpers'; -import useSpeechRecognition from '../hooks/useSpeechRecognition'; -import { connectionAtom, formAtom } from '../state/atoms'; -import { renderForm } from './renderForm'; +import { useAtom } from "jotai"; +import { useEffect, useState } from "react"; +import { Bars } from "react-loader-spinner"; +import micFilledIcon from "../assets/mic-filled.svg"; +import sendIcon from "../assets/send-icon.svg"; +import { capitalize } from "../helpers"; +import useSpeechRecognition from "../hooks/useSpeechRecognition"; +import { connectionAtom, formAtom } from "../state/atoms"; +import { renderForm } from "./renderForm"; const Input: React.FC = () => { - const [input, setInput] = useState(''); - const [filler, setFiller] = useState(''); - const [{ messenger, timedOut, connected, reconnect }] = useAtom(connectionAtom); - const [form] = useAtom(formAtom); + const [input, setInput] = useState(""); + const [filler, setFiller] = useState(""); + const [{ messenger, timedOut, connected, reconnect }] = + useAtom(connectionAtom); + const [form] = useAtom(formAtom); - const { listening, toggleListening, transcript, interim } = useSpeechRecognition(); + const { listening, toggleListening, transcript, interim } = + useSpeechRecognition(); - const handleChange = (e: React.ChangeEvent) => { - setInput(e.target.value); - }; + const handleChange = (e: React.ChangeEvent) => { + setInput(e.target.value); + }; - const handleSubmit = () => { - if (input.trim().length > 0) - messenger?.call({ - action: 'prompt', - message: input, - }); - setInput(''); - }; + const handleSubmit = () => { + if (input.trim().length > 0) + messenger?.call({ + action: "prompt", + message: input, + }); + setInput(""); + }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; - useEffect(() => { - if (transcript.length > 0) { - setInput((prev) => prev + `${prev.length > 0 ? '. ' : ''}` + capitalize(transcript)); - setFiller(''); - } else if (interim.length > 0) { - setFiller(interim); - } - }, [transcript, interim]); + useEffect(() => { + if (transcript.length > 0) { + setInput( + (prev) => + prev + `${prev.length > 0 ? ". " : ""}` + capitalize(transcript) + ); + setFiller(""); + } else if (interim.length > 0) { + setFiller(interim); + } + }, [transcript, interim]); - return ( -
- {(!form || timedOut) && ( -
- {listening ? ( -

- {input} - - {filler.length < 1 && input.length < 1 ? 'Listening...' : filler} - -

- ) : ( -