Skip to content

Commit

Permalink
chore: Unleash AI chat UI
Browse files Browse the repository at this point in the history
  • Loading branch information
nunogois committed Oct 14, 2024
1 parent f63496d commit c9c5a2f
Show file tree
Hide file tree
Showing 5 changed files with 400 additions and 0 deletions.
156 changes: 156 additions & 0 deletions frontend/src/component/ai/AIChat.tsx
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>
);
};
61 changes: 61 additions & 0 deletions frontend/src/component/ai/AIChatHeader.tsx
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>
);
};
84 changes: 84 additions & 0 deletions frontend/src/component/ai/AIChatInput.tsx
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>
);
};
Loading

0 comments on commit c9c5a2f

Please sign in to comment.