@nuskin/chat-bot
Version:
React Chat Bot component for GenAI interaction with Amazon Bedrock
296 lines (279 loc) • 13 kB
JSX
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