diff --git a/web/src/api/buster_socket/chats/chatRequests.ts b/web/src/api/buster_socket/chats/chatRequests.ts index 9006b22b9..8b8492d07 100644 --- a/web/src/api/buster_socket/chats/chatRequests.ts +++ b/web/src/api/buster_socket/chats/chatRequests.ts @@ -20,6 +20,8 @@ export type ChatCreateNewChat = BusterSocketRequestBase< message_id?: string; /** Optional ID of a metric to initialize the chat from */ metric_id?: string; + /** Optional ID of a dashboard to initialize the chat from */ + dashboard_id?: string; } >; diff --git a/web/src/app/app/_components/NewChatModal/NewChatModal.tsx b/web/src/app/app/_components/NewChatModal/NewChatModal.tsx index 4ea44b9ec..ae191241e 100644 --- a/web/src/app/app/_components/NewChatModal/NewChatModal.tsx +++ b/web/src/app/app/_components/NewChatModal/NewChatModal.tsx @@ -34,6 +34,7 @@ export const NewChatModal = React.memo<{ open: boolean; onClose: () => void; }>(({ open, onClose }) => { + const token = useAntToken(); const searchParams = useParams(); const onChangePage = useAppLayoutContextSelector((x) => x.onChangePage); const { openErrorNotification } = useBusterNotifications(); @@ -43,10 +44,9 @@ export const NewChatModal = React.memo<{ (x) => x.onSetSelectedChatDataSource ); const selectedChatDataSource = useBusterNewChatContextSelector((x) => x.selectedChatDataSource); - const onSetPrompt = useBusterNewChatContextSelector((x) => x.onSetPrompt); - const prompt = useBusterNewChatContextSelector((x) => x.prompt); const onBusterSearch = useBusterSearchContextSelector((x) => x.onBusterSearch); - const token = useAntToken(); + + const [prompt, setPrompt] = useState(''); const [openNewDatasetModal, setOpenNewDatasetModal] = useState(false); const [suggestedPrompts, setSuggestedPrompts] = useState([]); const [activeItem, setActiveItem] = useState(null); @@ -97,7 +97,7 @@ export const NewChatModal = React.memo<{ useEffect(() => { if (open) { - onSetPrompt(''); + setPrompt(''); if (defaultSuggestedPrompts.length === 0) { getDefaultSuggestedPrompts(); diff --git a/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatContent.tsx b/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatContent.tsx index 727ac165b..8c4ddf0b2 100644 --- a/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatContent.tsx +++ b/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatContent.tsx @@ -3,6 +3,7 @@ import { useChatContextSelector } from '../../ChatContext'; import { ChatMessageBlock } from './ChatMessageBlock'; import { ChatInput } from './ChatInput'; import { createStyles } from 'antd-style'; +import { useBusterNewChatContextSelector } from '@/context/Chats'; export const ChatContent: React.FC<{ chatContentRef: React.RefObject }> = React.memo(({ chatContentRef }) => { diff --git a/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatInput/AIWarning.tsx b/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatInput/AIWarning.tsx new file mode 100644 index 000000000..0f5de7454 --- /dev/null +++ b/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatInput/AIWarning.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Text } from '@/components/text'; + +export const AIWarning = React.memo(() => { + return ( +
+ + Our AI may make mistakes. Check important info. + +
+ ); +}); + +AIWarning.displayName = 'AIWarning'; diff --git a/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatInput/ChatInput.tsx b/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatInput/ChatInput.tsx index d3ffcb273..ddd5e088a 100644 --- a/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatInput/ChatInput.tsx +++ b/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatInput/ChatInput.tsx @@ -1,34 +1,36 @@ import React, { useMemo, useState } from 'react'; -import { Input, Button } from 'antd'; -import { Text } from '@/components/text'; +import { Input } from 'antd'; import { createStyles } from 'antd-style'; import { useMemoizedFn } from 'ahooks'; -import { AppMaterialIcons } from '@/components/icons'; import { inputHasText } from '@/utils'; +import { useBusterNewChatContextSelector } from '@/context/Chats'; +import { AIWarning } from './AIWarning'; +import { SubmitButton } from './SubmitButton'; +import { useChatInputFlow } from './useChatInputFlow'; +import { useChatContextSelector } from '../../../ChatContext'; const autoSize = { minRows: 3, maxRows: 4 }; -export const ChatInput: React.FC = React.memo(() => { +export const ChatInput: React.FC<{}> = React.memo(({}) => { const { styles, cx } = useStyles(); - const [inputValue, setInputValue] = useState(''); + const loading = useBusterNewChatContextSelector((state) => state.loadingNewChat); + const selectedFileId = useChatContextSelector((x) => x.selectedFileId); - const loading = false; + const [inputValue, setInputValue] = useState(''); + const [isFocused, setIsFocused] = React.useState(false); const disableSendButton = useMemo(() => { return !inputHasText(inputValue); }, [inputValue]); - const onSubmit = useMemoizedFn(async () => { - if (disableSendButton) return; + const { onSubmitPreflight } = useChatInputFlow({ + disableSendButton, + inputValue }); - const disableSubmit = !inputHasText(inputValue); - const [isFocused, setIsFocused] = React.useState(false); - const onPressEnter = useMemoizedFn((e: React.KeyboardEvent) => { if (e.metaKey && e.key === 'Enter') { - onSubmit(); - return; + onSubmitPreflight(); } }); @@ -70,10 +72,10 @@ export const ChatInput: React.FC = React.memo(() => { autoSize={autoSize} />
-
@@ -103,15 +105,3 @@ const useStyles = createStyles(({ token, css }) => ({ } ` })); - -const AIWarning = React.memo(() => { - return ( -
- - Our AI may make mistakes. Check important info. - -
- ); -}); - -AIWarning.displayName = 'AIWarning'; diff --git a/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatInput/SubmitButton.tsx b/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatInput/SubmitButton.tsx new file mode 100644 index 000000000..613715cbe --- /dev/null +++ b/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatInput/SubmitButton.tsx @@ -0,0 +1,125 @@ +import React, { useState } from 'react'; +import { AppMaterialIcons } from '@/components/icons'; +import { createStyles } from 'antd-style'; +import { AnimatePresence, motion } from 'framer-motion'; +import { AppTooltip } from '@/components'; + +interface SubmitButtonProps { + disableSendButton: boolean; + loading: boolean; + onSubmitPreflight: () => void; +} + +const animationIcon = { + initial: { opacity: 0, scale: 0.8 }, + animate: { opacity: 1, scale: 1 }, + exit: { opacity: 0, scale: 0.8 } +}; + +export const SubmitButton: React.FC = React.memo( + ({ disableSendButton, onSubmitPreflight }) => { + const { styles } = useStyles(); + + const [loading, setLoading] = useState(false); + + const onTest = () => { + setLoading(!loading); + }; + + const tooltipText = loading ? 'Stop' : 'Send message'; + const tooltipShortcuts = loading ? [] : ['⌘', '↵']; + + return ( + + + + ); + } +); + +SubmitButton.displayName = 'SubmitButton'; + +const useStyles = createStyles(({ token, css }) => ({ + button: css` + width: 26px; + height: 26px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + background: ${token.colorBgContainer}; + border: 0.5px solid ${token.colorBorder}; + padding: 0; + outline: none; + + .material-symbols { + transition: color 0.2s ease; + } + + &:not(:disabled):hover { + border-color: ${token.colorPrimary}; + transform: scale(1.075); + box-shadow: ${token.boxShadowTertiary}; + } + + &:not(:disabled):active { + transform: scale(0.95); + } + + &:disabled { + cursor: not-allowed; + } + `, + disabled: css` + background: transparent; + border: 0.5px solid ${token.colorBorderSecondary}; + + .material-symbols { + color: ${token.colorTextTertiary} !important; + } + `, + loading: css` + background: ${token.colorText}; + border: 0.5px solid ${token.colorBorder}; + color: ${token.colorBgLayout}; + + &:hover { + background: ${token.colorTextSecondary}; + border-color: ${token.colorTextSecondary} !important; + } + + .material-symbols { + color: ${token.colorBgLayout} !important; + } + `, + iconWrapper: css` + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + + .material-symbols { + color: ${token.colorText}; + } + ` +})); diff --git a/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatInput/useChatInputFlow.ts b/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatInput/useChatInputFlow.ts new file mode 100644 index 000000000..9a70d65e0 --- /dev/null +++ b/web/src/app/app/_layouts/ChatLayout/ChatContainer/ChatContent/ChatInput/useChatInputFlow.ts @@ -0,0 +1,61 @@ +import { useMemo } from 'react'; +import { useChatContextSelector } from '../../../ChatContext'; +import { useMemoizedFn } from 'ahooks'; +import { useBusterNewChatContextSelector } from '@/context/Chats'; + +type FlowType = 'followup-chat' | 'followup-metric' | 'followup-dashboard' | 'new'; + +export const useChatInputFlow = ({ + disableSendButton, + inputValue +}: { + disableSendButton: boolean; + inputValue: string; +}) => { + const hasChat = useChatContextSelector((x) => x.hasChat); + const selectedFileType = useChatContextSelector((x) => x.selectedFileType); + const selectedFileId = useChatContextSelector((x) => x.selectedFileId); + const onStartNewChat = useBusterNewChatContextSelector((state) => state.onStartNewChat); + const onFollowUpChat = useBusterNewChatContextSelector((state) => state.onFollowUpChat); + const onStartChatFromFile = useBusterNewChatContextSelector((state) => state.onStartChatFromFile); + const currentMessageId = useChatContextSelector((x) => x.currentMessageId); + + const flow: FlowType = useMemo(() => { + if (hasChat) return 'followup-chat'; + if (selectedFileType === 'metric' && selectedFileId) return 'followup-metric'; + if (selectedFileType === 'dashboard' && selectedFileId) return 'followup-dashboard'; + return 'new'; + }, [hasChat, selectedFileType, selectedFileId]); + + const onSubmitPreflight = useMemoizedFn(async () => { + if (disableSendButton) return; + + switch (flow) { + case 'followup-chat': + return onFollowUpChat({ prompt: inputValue, messageId: currentMessageId! }); + + case 'followup-metric': + return onStartChatFromFile({ + prompt: inputValue, + fileId: selectedFileId!, + fileType: 'metric' + }); + + case 'followup-dashboard': + return onStartChatFromFile({ + prompt: inputValue, + fileId: selectedFileId!, + fileType: 'dashboard' + }); + + case 'new': + return onStartNewChat(inputValue); + + default: + const _exhaustiveCheck: never = flow; + return _exhaustiveCheck; + } + }); + + return { onSubmitPreflight }; +}; diff --git a/web/src/app/app/_layouts/ChatLayout/ChatContext/ChatContext.tsx b/web/src/app/app/_layouts/ChatLayout/ChatContext/ChatContext.tsx index 7d968944d..e012c6932 100644 --- a/web/src/app/app/_layouts/ChatLayout/ChatContext/ChatContext.tsx +++ b/web/src/app/app/_layouts/ChatLayout/ChatContext/ChatContext.tsx @@ -23,18 +23,25 @@ export const useChatContext = ({ chatId, metricId }); + const hasChat = !!chatId && !!chat; const chatTitle = chat?.title; const chatMessages = chat?.messages ?? []; //FILE const hasFile = !!defaultSelectedFile?.id; + //MESSAGES + const currentMessageId = chatMessages[chatMessages.length - 1]?.id; + return { + hasChat, hasFile, selectedFileId, + currentMessageId, chatTitle, selectedFileType, - chatMessages + chatMessages, + chatId }; }; diff --git a/web/src/components/tooltip/AppTooltipShortcutPill.tsx b/web/src/components/tooltip/AppTooltipShortcutPill.tsx index b9095195a..f280fc746 100644 --- a/web/src/components/tooltip/AppTooltipShortcutPill.tsx +++ b/web/src/components/tooltip/AppTooltipShortcutPill.tsx @@ -25,7 +25,7 @@ const TooltipShortcut: React.FC<{ shortcut: string }> = ({ shortcut }) => {
{ } }); - useHotkeys('f', () => { - const chatId = Object.keys(chatsRef.current)[0]; - if (chatId) { - const chat = chatsRef.current[chatId]; - const mockMessage = createMockResponseMessageFile(); - const newChat = { ...chat }; - const firstMessage = { - ...newChat.messages[0], - isCompletedStream: false, - response_messages: [...newChat.messages[0].response_messages, mockMessage] - }; - newChat.messages = [firstMessage]; - chatsRef.current[chatId] = newChat; - startTransition(() => { - //just used to trigger UI update - }); - } - }); - - useHotkeys('r', () => { - const chatId = Object.keys(chatsRef.current)[0]; - if (chatId) { - const chat = chatsRef.current[chatId]; - const mockMessage = createMockResponseMessageText(); - const newChat = { ...chat }; - const firstMessage = { - ...newChat.messages[0], - isCompletedStream: false, - response_messages: [...newChat.messages[0].response_messages, mockMessage] - }; - newChat.messages = [firstMessage]; - chatsRef.current[chatId] = newChat; - startTransition(() => { - //just used to trigger UI update - }); - } - }); - - useHotkeys('h', () => { - const chatId = Object.keys(chatsRef.current)[0]; - if (chatId) { - const chat = chatsRef.current[chatId]; - - const newChat = { ...chat }; - const firstMessage = newChat.messages[0]; - const updatedResponseMessages = firstMessage.response_messages.map((msg) => { - if (msg.type === 'thought') { - return { - ...msg, - hidden: true - }; - } - return msg; - }); - - const updatedMessage = { - ...firstMessage, - response_messages: updatedResponseMessages - }; - - newChat.messages = [updatedMessage]; - chatsRef.current[chatId] = newChat; - startTransition(() => { - // Trigger UI update - }); - } - }); - return { chats: chatsRef.current, unsubscribeFromChat, diff --git a/web/src/context/Chats/NewChatProvider.tsx b/web/src/context/Chats/NewChatProvider.tsx index 55b635941..3dc84a0c8 100644 --- a/web/src/context/Chats/NewChatProvider.tsx +++ b/web/src/context/Chats/NewChatProvider.tsx @@ -5,13 +5,13 @@ import { useContextSelector } from '@fluentui/react-context-selector'; import { useMemoizedFn } from 'ahooks'; -import type { BusterDatasetListItem, BusterSearchResult } from '@/api/asset_interfaces'; +import type { BusterDatasetListItem, BusterSearchResult, FileType } from '@/api/asset_interfaces'; export const useBusterNewChat = () => { - const [prompt, setPrompt] = useState(''); const [selectedChatDataSource, setSelectedChatDataSource] = useState(null); const [loadingNewChat, setLoadingNewChat] = useState(false); + const [prompt, setPrompt] = useState(''); const onSetPrompt = useMemoizedFn((prompt: string) => { setPrompt(prompt); @@ -25,18 +25,37 @@ export const useBusterNewChat = () => { setLoadingNewChat(false); }); + const onStartChatFromFile = useMemoizedFn( + async ({}: { prompt: string; fileId: string; fileType: FileType }) => {} + ); + + const onFollowUpChat = useMemoizedFn( + async ({ prompt, messageId }: { prompt: string; messageId: string }) => { + setLoadingNewChat(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + setLoadingNewChat(false); + } + ); + + const onReplaceMessageInChat = useMemoizedFn( + async ({ prompt, messageId }: { prompt: string; messageId: string }) => {} + ); + const onSetSelectedChatDataSource = useMemoizedFn((dataSource: BusterDatasetListItem | null) => { setSelectedChatDataSource(dataSource); }); return { onStartNewChat, - prompt, - onSetPrompt, loadingNewChat, onSelectSearchAsset, selectedChatDataSource, - onSetSelectedChatDataSource + onSetSelectedChatDataSource, + onSetPrompt, + onFollowUpChat, + prompt, + onStartChatFromFile, + onReplaceMessageInChat }; };