-
-
Notifications
You must be signed in to change notification settings - Fork 736
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
400 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,156 @@ | ||
import { mutate } from 'swr'; | ||
import SmartToyIcon from '@mui/icons-material/SmartToy'; | ||
import { IconButton, styled } from '@mui/material'; | ||
import { useEffect, useRef, useState } from 'react'; | ||
import useToast from 'hooks/useToast'; | ||
import { formatUnknownError } from 'utils/formatUnknownError'; | ||
import { | ||
type ChatMessage, | ||
useAIApi, | ||
} from 'hooks/api/actions/useAIApi/useAIApi'; | ||
import { useUiFlag } from 'hooks/useUiFlag'; | ||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; | ||
import { AIChatInput } from './AIChatInput'; | ||
import { AIChatMessage } from './AIChatMessage'; | ||
import { AIChatHeader } from './AIChatHeader'; | ||
|
||
const StyledAIIconContainer = styled('div')(({ theme }) => ({ | ||
position: 'fixed', | ||
bottom: 20, | ||
right: 20, | ||
zIndex: theme.zIndex.fab, | ||
animation: 'fadeInBottom 0.5s', | ||
'@keyframes fadeInBottom': { | ||
from: { | ||
opacity: 0, | ||
transform: 'translateY(200px)', | ||
}, | ||
to: { | ||
opacity: 1, | ||
transform: 'translateY(0)', | ||
}, | ||
}, | ||
})); | ||
|
||
const StyledAIChatContainer = styled(StyledAIIconContainer)({ | ||
bottom: 10, | ||
right: 10, | ||
}); | ||
|
||
const StyledAIIconButton = styled(IconButton)(({ theme }) => ({ | ||
background: theme.palette.primary.light, | ||
color: theme.palette.primary.contrastText, | ||
boxShadow: theme.boxShadows.popup, | ||
'&:hover': { | ||
background: theme.palette.primary.dark, | ||
}, | ||
})); | ||
|
||
const StyledChat = styled('div')(({ theme }) => ({ | ||
borderRadius: theme.shape.borderRadiusLarge, | ||
overflow: 'hidden', | ||
boxShadow: theme.boxShadows.popup, | ||
background: theme.palette.background.paper, | ||
})); | ||
|
||
const StyledChatContent = styled('div')(({ theme }) => ({ | ||
display: 'flex', | ||
flexDirection: 'column', | ||
padding: theme.spacing(2), | ||
width: '30vw', | ||
height: '50vh', | ||
overflow: 'auto', | ||
})); | ||
|
||
const initialMessages: ChatMessage[] = [ | ||
{ | ||
role: 'system', | ||
content: `You are an assistant that helps users interact with Unleash. You should ask the user in case you're missing any required information.`, | ||
}, | ||
]; | ||
|
||
export const AIChat = () => { | ||
const unleashAIEnabled = useUiFlag('unleashAI'); | ||
const { | ||
uiConfig: { unleashAIAvailable }, | ||
} = useUiConfig(); | ||
const [open, setOpen] = useState(false); | ||
const [loading, setLoading] = useState(false); | ||
const { setToastApiError } = useToast(); | ||
const { chat } = useAIApi(); | ||
|
||
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages); | ||
|
||
const chatEndRef = useRef<HTMLDivElement | null>(null); | ||
|
||
const scrollToEnd = (options?: ScrollIntoViewOptions) => { | ||
if (chatEndRef.current) { | ||
chatEndRef.current.scrollIntoView(options); | ||
} | ||
}; | ||
|
||
useEffect(() => { | ||
scrollToEnd({ behavior: 'smooth' }); | ||
}, [messages]); | ||
|
||
useEffect(() => { | ||
scrollToEnd(); | ||
}, [open]); | ||
|
||
const onSend = async (message: string) => { | ||
if (!message.trim() || loading) return; | ||
|
||
try { | ||
setLoading(true); | ||
const tempMessages: ChatMessage[] = [ | ||
...messages, | ||
{ role: 'user', content: message }, | ||
{ role: 'assistant', content: '_Unleash AI is typing..._' }, | ||
]; | ||
setMessages(tempMessages); | ||
const newMessages = await chat(tempMessages.slice(0, -1)); | ||
mutate(() => true); | ||
setMessages(newMessages); | ||
setLoading(false); | ||
} catch (error: unknown) { | ||
setToastApiError(formatUnknownError(error)); | ||
} | ||
}; | ||
|
||
if (!unleashAIEnabled || !unleashAIAvailable) { | ||
return null; | ||
} | ||
|
||
if (!open) { | ||
return ( | ||
<StyledAIIconContainer> | ||
<StyledAIIconButton size='large' onClick={() => setOpen(true)}> | ||
<SmartToyIcon /> | ||
</StyledAIIconButton> | ||
</StyledAIIconContainer> | ||
); | ||
} | ||
|
||
return ( | ||
<StyledAIChatContainer> | ||
<StyledChat> | ||
<AIChatHeader | ||
onNew={() => setMessages(initialMessages)} | ||
onClose={() => setOpen(false)} | ||
/> | ||
<StyledChatContent> | ||
<AIChatMessage from='assistant'> | ||
Hello, how can I assist you? | ||
</AIChatMessage> | ||
{messages.map(({ role, content }, index) => ( | ||
<AIChatMessage key={index} from={role}> | ||
{content} | ||
</AIChatMessage> | ||
))} | ||
<div ref={chatEndRef} /> | ||
</StyledChatContent> | ||
<AIChatInput onSend={onSend} loading={loading} /> | ||
</StyledChat> | ||
</StyledAIChatContainer> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
import { IconButton, styled, Tooltip, Typography } from '@mui/material'; | ||
import SmartToyIcon from '@mui/icons-material/SmartToy'; | ||
import EditNoteIcon from '@mui/icons-material/EditNote'; | ||
import CloseIcon from '@mui/icons-material/Close'; | ||
|
||
const StyledHeader = styled('div')(({ theme }) => ({ | ||
background: theme.palette.primary.light, | ||
color: theme.palette.primary.contrastText, | ||
display: 'flex', | ||
alignItems: 'center', | ||
justifyContent: 'space-between', | ||
padding: theme.spacing(0.5), | ||
})); | ||
|
||
const StyledTitleContainer = styled('div')(({ theme }) => ({ | ||
display: 'flex', | ||
alignItems: 'center', | ||
gap: theme.spacing(1), | ||
marginLeft: theme.spacing(1), | ||
})); | ||
|
||
const StyledTitle = styled(Typography)({ | ||
fontWeight: 'bold', | ||
}); | ||
|
||
const StyledActionsContainer = styled('div')({ | ||
display: 'flex', | ||
alignItems: 'center', | ||
}); | ||
|
||
const StyledIconButton = styled(IconButton)(({ theme }) => ({ | ||
color: theme.palette.primary.contrastText, | ||
})); | ||
|
||
interface IAIChatHeaderProps { | ||
onNew: () => void; | ||
onClose: () => void; | ||
} | ||
|
||
export const AIChatHeader = ({ onNew, onClose }: IAIChatHeaderProps) => { | ||
return ( | ||
<StyledHeader> | ||
<StyledTitleContainer> | ||
<SmartToyIcon /> | ||
<StyledTitle>Unleash AI</StyledTitle> | ||
</StyledTitleContainer> | ||
<StyledActionsContainer> | ||
<Tooltip title='New chat' arrow> | ||
<StyledIconButton size='small' onClick={onNew}> | ||
<EditNoteIcon /> | ||
</StyledIconButton> | ||
</Tooltip> | ||
<Tooltip title='Close chat' arrow> | ||
<StyledIconButton size='small' onClick={onClose}> | ||
<CloseIcon /> | ||
</StyledIconButton> | ||
</Tooltip> | ||
</StyledActionsContainer> | ||
</StyledHeader> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
import { useState } from 'react'; | ||
import { | ||
IconButton, | ||
InputAdornment, | ||
styled, | ||
TextField, | ||
Tooltip, | ||
} from '@mui/material'; | ||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | ||
|
||
const StyledAIChatInputContainer = styled('div')(({ theme }) => ({ | ||
background: theme.palette.background.paper, | ||
display: 'flex', | ||
alignItems: 'center', | ||
padding: theme.spacing(1), | ||
})); | ||
|
||
const StyledAIChatInput = styled(TextField)(({ theme }) => ({ | ||
margin: theme.spacing(0.5), | ||
})); | ||
|
||
const StyledInputAdornment = styled(InputAdornment)({ | ||
marginLeft: 0, | ||
}); | ||
|
||
const StyledIconButton = styled(IconButton)({ | ||
padding: 0, | ||
}); | ||
|
||
export interface IAIChatInputProps { | ||
onSend: (message: string) => void; | ||
loading: boolean; | ||
} | ||
|
||
export const AIChatInput = ({ onSend, loading }: IAIChatInputProps) => { | ||
const [message, setMessage] = useState(''); | ||
|
||
const send = () => { | ||
if (!message.trim() || loading) return; | ||
onSend(message); | ||
setMessage(''); | ||
}; | ||
|
||
return ( | ||
<StyledAIChatInputContainer> | ||
<StyledAIChatInput | ||
autoFocus | ||
size='small' | ||
variant='outlined' | ||
placeholder='Type your message here' | ||
fullWidth | ||
multiline | ||
maxRows={20} | ||
value={message} | ||
onChange={(e) => setMessage(e.target.value)} | ||
onKeyDown={(e) => { | ||
if (e.key === 'Enter' && !e.shiftKey) { | ||
e.preventDefault(); | ||
send(); | ||
} | ||
}} | ||
InputProps={{ | ||
sx: { paddingRight: 1 }, | ||
endAdornment: ( | ||
<StyledInputAdornment position='end'> | ||
<Tooltip title='Send message' arrow> | ||
<div> | ||
<StyledIconButton | ||
onClick={send} | ||
size='small' | ||
color='primary' | ||
disabled={!message.trim() || loading} | ||
> | ||
<ArrowUpwardIcon /> | ||
</StyledIconButton> | ||
</div> | ||
</Tooltip> | ||
</StyledInputAdornment> | ||
), | ||
}} | ||
/> | ||
</StyledAIChatInputContainer> | ||
); | ||
}; |
Oops, something went wrong.