UNPKG

@nuskin/chat-bot

Version:

React Chat Bot component for GenAI interaction with Amazon Bedrock

296 lines (279 loc) 13 kB
import React, { useEffect, useRef } from 'react' import { AppBar, Box, Button, Card, CardContent, Chip, FormControl, FormGroup, InputLabel, MenuItem, Select, Stack, TextField, Toolbar, Typography } from '@mui/material' import SendIcon from '@mui/icons-material/Send' import { exists, isNull } from '@nuskin/uncle-buck' import './chatbot.css' import { useState } from 'react' import { deleteSessionCookie, getProxyAnswer } from './chatUtil.js' import RenderMarkdown from '../markdown/renderMarkdown.jsx' import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline' import AssistantIcon from '@mui/icons-material/Assistant' import LinearProgress from '@mui/material/LinearProgress' import ErrorAlert from '../errorAlert/errorAlert.jsx' import Logo from '../logo/logo.jsx' const exampleQuestions = ['What is the capital of France?', 'What is GenAI?', 'What is Amazon Bedrock?', 'How can I get started using Amazon Bedrock?'] export function ChatBot({ title, questions, agents, agentId, agentAliasId, hideTitleBar, apiEndpoint, apiHeaders }) { const [history, setHistory] = useState([]) const [question, setQuestion] = useState('') const [bgOpen, setBgOpen] = useState(false) const textFieldRef = useRef(null) const [prompts, setPrompts] = useState(questions || exampleQuestions) const [questionBottom, setQuestionBottom] = useState(false) const [llm, setLlm] = useState(agents && agents.length > 0 ? agents[0].value : 'haiku') const [aId, setAId] = useState(agentId || agents?.[0]?.agentId) const [aAliasId, setAAliasId] = useState(agentAliasId || agents?.[0]?.agentAliasId) const [errors, setErrors] = useState([]) const chatContainerRef = useRef(null) const formRef = useRef(null) useEffect(() => { if (!bgOpen && textFieldRef.current) { textFieldRef.current.focus() } if (chatContainerRef.current) { chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight } }, [bgOpen, history]) // Only update llm, aId, and aAliasId when agents prop changes useEffect(() => { if (!isNull(agents) && agents.length > 0) { setLlm(agents[0].value) setAId(agents[0].agentId) setAAliasId(agents[0].agentAliasId) } }, [agents]) const onQuestionChanged = (e) => { setQuestion(e.target.value) } const handleClearSession = async (e) => { e.preventDefault() await deleteSessionCookie() setHistory([]) setQuestionBottom(false) setPrompts(questions || exampleQuestions) } let getAnswerAttempts = 0 const handleNewQuestion = async (e) => { e.preventDefault() setQuestionBottom(true) if (question.trim() !== '') { try { setBgOpen(true) let answer = await getProxyAnswer(question, aId, aAliasId, apiEndpoint, apiHeaders) if (exists(answer, 'error')) { setErrors((prevHistory) => [...prevHistory, { question, error: answer.error }]) setQuestion('') setBgOpen(false) } else if (isNull(answer) && getAnswerAttempts < 3) { getAnswerAttempts++ handleNewQuestion({ preventDefault: () => {} }) } else { setHistory((prevHistory) => [...prevHistory, { question, answer }]) setQuestion('') setBgOpen(false) getAnswerAttempts = 0 } } catch (error) { console.log(error) setBgOpen(false) } } } const handleKeyDown = (event) => { if (event.key === 'Enter' && !event.shiftKey) { // Regular Enter: submit the form event.preventDefault() handleNewQuestion(event) } else if (event.key === 'Enter' && event.shiftKey) { // Shift + Enter: add a new line event.preventDefault() setQuestion((prevQuestion) => prevQuestion + '\n') } } const handlePromptSelected = async (promptItem) => { // Remove the selected prompt from the prompts list await setQuestion(promptItem) const qPrompts = prompts.filter((item) => item !== promptItem) setPrompts(qPrompts) // Create a new event object to simulate form submission const event = new Event('submit', { bubbles: true, cancelable: true }) // Dispatch the event on the form element if (formRef.current) { formRef.current.dispatchEvent(event) } } const handleLlmChange = (e) => { const llm = e.target.value setLlm(llm) const agentObject = agents.find((agent) => agent.value === llm) if (agentObject) { setAId(agentObject.agentId) setAAliasId(agentObject.agentAliasId) } } return ( <div className="chatbot"> {!hideTitleBar && ( <AppBar position="fixed" sx={{ backgroundColor: '#3783b9' }}> <Toolbar sx={{ maxWidth: '1450px', margin: 'auto', alignContent: 'space-between', width: '100%' }}> <Logo color="#ffffff" width={40} /> <Typography variant="h6" component="div" sx={{ flexGrow: 1, margin: 'auto', paddingLeft: '15px' }}> {title ? `${title}` : 'Nu Skin Chatbot'} </Typography> <Box> <Button onClick={handleClearSession} color="inherit"> Clear Chat Session </Button> </Box> </Toolbar> </AppBar> )} <Stack sx={{ width: '100%', position: 'fixed', zIndex: 10 }}> {errors.map((e, index) => ( <ErrorAlert key={index} e={e} /> ))} </Stack> <Card sx={{ padding: 0, margin: 'auto', maxWidth: '1600px', boxShadow: 'none', width: '100%' }}> <CardContent sx={{ position: 'relative', border: 'none', marginBottom: '150px' }}> <Box ref={chatContainerRef} className="scroll-container" data-testid="scroll-container" sx={{ maxHeight: '90vh', overflowY: 'auto' }} > {history.map((item, index) => ( <div className="listed-question" key={index}> <div className="align-right"> <DriveFileRenameOutlineIcon /> <Box sx={{ borderRadius: '5px', padding: '10px', backgroundColor: 'lightgray' }} > {item.question} </Box> </div> <div> <AssistantIcon /> <Box sx={{ borderRadius: '5px', padding: '0 10px' }} > <RenderMarkdown tab={item.answer.value} /> </Box> </div> </div> ))} {bgOpen && ( <Box sx={{ border: 'none', minHeight: '100px', margin: '10px' }}> <div className="listed-question"> <div className="align-right"> <DriveFileRenameOutlineIcon style={{ display: 'inline-block' }} /> <Box sx={{ borderRadius: '5px', padding: '10px', backgroundColor: 'lightgray' }} > {question} </Box> </div> <div> <AssistantIcon /> <Box sx={{ position: 'relative', borderRadius: '5px', padding: '10px', minHeight: 75 }} > <LinearProgress /> </Box> </div> </div> </Box> )} </Box> </CardContent> </Card> <div className="ask-box" style={questionBottom ? { bottom: 0 } : { bottom: '50vh' }}> <div className="ask-wrapper"> <div className="example-questions"> {prompts.map((item, index) => ( <Chip key={index} variant="outlined" sx={{ fontSize: '16px' }} onClick={() => handlePromptSelected(item)} label={item} data-testid="chip" /> ))} </div> <div className="ask-form"> {agents && agents.length > 0 && ( <div className="llm-selector"> <FormControl> <InputLabel id="select-llm-label">LLM</InputLabel> <Select labelId="select-llm-label" id="select-llm" value={llm} label="LLM" onChange={handleLlmChange} data-testid="select"> {agents.map((agent) => ( <MenuItem key={agent.value} value={agent.value}> {agent.label} </MenuItem> ))} </Select> </FormControl> </div> )} <form ref={formRef} onSubmit={handleNewQuestion} id="ask-chatbot" style={{ flexGrow: 1 }}> <FormGroup className="ask-question"> <TextField disabled={bgOpen} className="question" placeholder="Message Chatbot" value={question} onChange={onQuestionChanged} onKeyDown={handleKeyDown} multiline inputRef={textFieldRef} style={bgOpen ? { maxHeight: '50px' } : {}} data-testid="text-field" /> <div className="send-btn-wrapper"> <Button type="submit" disabled={bgOpen} variant="contained" sx={{ backgroundColor: '#3783b9' }} size="large"> Send <SendIcon sx={{ marginLeft: '10px' }} /> </Button> </div> </FormGroup> </form> </div> </div> </div> </div> ) } export default ChatBot