From c9c5a2f521a829a57eaca360dde90f3e7d45c334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Mon, 14 Oct 2024 16:54:57 +0100 Subject: [PATCH] chore: Unleash AI chat UI --- frontend/src/component/ai/AIChat.tsx | 156 ++++++++++++++++++ frontend/src/component/ai/AIChatHeader.tsx | 61 +++++++ frontend/src/component/ai/AIChatInput.tsx | 84 ++++++++++ frontend/src/component/ai/AIChatMessage.tsx | 97 +++++++++++ .../layout/MainLayout/MainLayout.tsx | 2 + 5 files changed, 400 insertions(+) create mode 100644 frontend/src/component/ai/AIChat.tsx create mode 100644 frontend/src/component/ai/AIChatHeader.tsx create mode 100644 frontend/src/component/ai/AIChatInput.tsx create mode 100644 frontend/src/component/ai/AIChatMessage.tsx diff --git a/frontend/src/component/ai/AIChat.tsx b/frontend/src/component/ai/AIChat.tsx new file mode 100644 index 000000000000..19737ac7c9f5 --- /dev/null +++ b/frontend/src/component/ai/AIChat.tsx @@ -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(initialMessages); + + const chatEndRef = useRef(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 ( + + setOpen(true)}> + + + + ); + } + + return ( + + + setMessages(initialMessages)} + onClose={() => setOpen(false)} + /> + + + Hello, how can I assist you? + + {messages.map(({ role, content }, index) => ( + + {content} + + ))} +
+ + + + + ); +}; diff --git a/frontend/src/component/ai/AIChatHeader.tsx b/frontend/src/component/ai/AIChatHeader.tsx new file mode 100644 index 000000000000..4c5ff467022f --- /dev/null +++ b/frontend/src/component/ai/AIChatHeader.tsx @@ -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 ( + + + + Unleash AI + + + + + + + + + + + + + + + ); +}; diff --git a/frontend/src/component/ai/AIChatInput.tsx b/frontend/src/component/ai/AIChatInput.tsx new file mode 100644 index 000000000000..e2e1df3d77e9 --- /dev/null +++ b/frontend/src/component/ai/AIChatInput.tsx @@ -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 ( + + setMessage(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + send(); + } + }} + InputProps={{ + sx: { paddingRight: 1 }, + endAdornment: ( + + +
+ + + +
+
+
+ ), + }} + /> +
+ ); +}; diff --git a/frontend/src/component/ai/AIChatMessage.tsx b/frontend/src/component/ai/AIChatMessage.tsx new file mode 100644 index 000000000000..d6286a721a84 --- /dev/null +++ b/frontend/src/component/ai/AIChatMessage.tsx @@ -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 ( + + + {children} + + + + ); + } + + if (from === 'assistant') { + return ( + + + + + + {children} + + + ); + } +}; diff --git a/frontend/src/component/layout/MainLayout/MainLayout.tsx b/frontend/src/component/layout/MainLayout/MainLayout.tsx index 88460c8ebbe3..4a07dd3ddccd 100644 --- a/frontend/src/component/layout/MainLayout/MainLayout.tsx +++ b/frontend/src/component/layout/MainLayout/MainLayout.tsx @@ -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( } /> +