Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: Unleash AI chat UI #8445

Merged
merged 2 commits into from
Oct 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions frontend/src/component/ai/AIChat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
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),
paddingBottom: theme.spacing(1),
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>
);
};
86 changes: 86 additions & 0 deletions frontend/src/component/ai/AIChatInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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),
paddingTop: 0,
}));

const StyledAIChatInput = styled(TextField)(({ theme }) => ({
margin: theme.spacing(1),
marginTop: 0,
}));

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
Loading