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:
parent
f63496d47f
commit
d02443be95
157
frontend/src/component/ai/AIChat.tsx
Normal file
157
frontend/src/component/ai/AIChat.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
61
frontend/src/component/ai/AIChatHeader.tsx
Normal file
61
frontend/src/component/ai/AIChatHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
86
frontend/src/component/ai/AIChatInput.tsx
Normal file
86
frontend/src/component/ai/AIChatInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
97
frontend/src/component/ai/AIChatMessage.tsx
Normal file
97
frontend/src/component/ai/AIChatMessage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
@ -19,6 +19,7 @@ import { NavigationSidebar } from './NavigationSidebar/NavigationSidebar';
|
|||||||
import { useUiFlag } from 'hooks/useUiFlag';
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
import { MainLayoutEventTimeline } from './MainLayoutEventTimeline';
|
import { MainLayoutEventTimeline } from './MainLayoutEventTimeline';
|
||||||
import { EventTimelineProvider } from 'component/events/EventTimeline/EventTimelineProvider';
|
import { EventTimelineProvider } from 'component/events/EventTimeline/EventTimelineProvider';
|
||||||
|
import { AIChat } from 'component/ai/AIChat';
|
||||||
|
|
||||||
interface IMainLayoutProps {
|
interface IMainLayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@ -194,6 +195,7 @@ export const MainLayout = forwardRef<HTMLDivElement, IMainLayoutProps>(
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</MainLayoutContentWrapper>
|
</MainLayoutContentWrapper>
|
||||||
|
<AIChat />
|
||||||
<Footer />
|
<Footer />
|
||||||
</MainLayoutContainer>
|
</MainLayoutContainer>
|
||||||
</EventTimelineProvider>
|
</EventTimelineProvider>
|
||||||
|
Loading…
Reference in New Issue
Block a user