1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-23 00:22:19 +01:00

chore: Unleash AI chat UI (#8445)

https://linear.app/unleash/issue/2-2792/create-the-aichat-component

Implements the Unleash AI chat UI.

This is essentially a polished version from the hackathon.

It will show up in the bottom right corner when the respective
prerequisites are met.

<img width="1508" alt="image"
src="https://github.com/user-attachments/assets/80da15a5-e638-4ccf-850b-508fcfd4991a">

<img width="1507" alt="image"
src="https://github.com/user-attachments/assets/8690cd42-1106-4f42-b459-41e574ab282f">

<img width="1506" alt="image"
src="https://github.com/user-attachments/assets/ea243828-ffcd-4243-b40c-6fa6357c3e70">
This commit is contained in:
Nuno Góis 2024-10-15 08:14:04 +01:00 committed by GitHub
parent f63496d47f
commit d02443be95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 403 additions and 0 deletions

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -0,0 +1,97 @@
import { Avatar, styled } from '@mui/material';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import { Markdown } from 'component/common/Markdown/Markdown';
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
import type { ChatMessage } from 'hooks/api/actions/useAIApi/useAIApi';
const StyledMessageContainer = styled('div')(({ theme }) => ({
display: 'flex',
justifyContent: 'flex-start',
gap: theme.spacing(1),
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
'&:first-of-type': {
marginTop: 0,
},
'&:last-of-type': {
marginBottom: 0,
},
wordBreak: 'break-word',
}));
const StyledUserMessageContainer = styled(StyledMessageContainer)({
justifyContent: 'end',
});
const StyledAIMessage = styled('div')(({ theme }) => ({
background: theme.palette.secondary.light,
color: theme.palette.secondary.contrastText,
border: `1px solid ${theme.palette.secondary.border}`,
borderRadius: theme.shape.borderRadius,
display: 'inline-block',
wordWrap: 'break-word',
padding: theme.spacing(0.75),
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
top: '12px',
left: '-10px',
width: '0',
height: '0',
borderStyle: 'solid',
borderWidth: '5px',
borderColor: `transparent ${theme.palette.secondary.border} transparent transparent`,
},
}));
const StyledUserMessage = styled(StyledAIMessage)(({ theme }) => ({
background: theme.palette.neutral.light,
color: theme.palette.neutral.contrastText,
borderColor: theme.palette.neutral.border,
'&::before': {
left: 'auto',
right: '-10px',
borderColor: `transparent transparent transparent ${theme.palette.neutral.border}`,
},
}));
const StyledAvatar = styled(Avatar)(({ theme }) => ({
width: theme.spacing(4.5),
height: theme.spacing(4.5),
backgroundColor: theme.palette.primary.light,
color: theme.palette.primary.contrastText,
}));
interface IAIChatMessageProps {
from: ChatMessage['role'];
children: string;
}
export const AIChatMessage = ({ from, children }: IAIChatMessageProps) => {
const { user } = useAuthUser();
if (from === 'user') {
return (
<StyledUserMessageContainer>
<StyledUserMessage>
<Markdown>{children}</Markdown>
</StyledUserMessage>
<StyledAvatar src={user?.imageUrl} />
</StyledUserMessageContainer>
);
}
if (from === 'assistant') {
return (
<StyledMessageContainer>
<StyledAvatar>
<SmartToyIcon />
</StyledAvatar>
<StyledAIMessage>
<Markdown>{children}</Markdown>
</StyledAIMessage>
</StyledMessageContainer>
);
}
};

View File

@ -19,6 +19,7 @@ import { NavigationSidebar } from './NavigationSidebar/NavigationSidebar';
import { useUiFlag } from 'hooks/useUiFlag';
import { MainLayoutEventTimeline } from './MainLayoutEventTimeline';
import { EventTimelineProvider } from 'component/events/EventTimeline/EventTimelineProvider';
import { AIChat } from 'component/ai/AIChat';
interface IMainLayoutProps {
children: ReactNode;
@ -194,6 +195,7 @@ export const MainLayout = forwardRef<HTMLDivElement, IMainLayoutProps>(
}
/>
</MainLayoutContentWrapper>
<AIChat />
<Footer />
</MainLayoutContainer>
</EventTimelineProvider>