Text-to-SQL proof of concept (#5788)
Added: - An "Ask AI" command to the command menu. - A simple GraphQL resolver that converts the user's question into a relevant SQL query using an LLM, runs the query, and returns the result. <img width="428" alt="Screenshot 2024-06-09 at 20 53 09" src="https://github.com/twentyhq/twenty/assets/171685816/57127f37-d4a6-498d-b253-733ffa0d209f"> No security concerns have been addressed, this is only a proof-of-concept and not intended to be enabled in production. All changes are behind a feature flag called `IS_ASK_AI_ENABLED`. --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
@ -0,0 +1,54 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
|
||||
import { copilotQueryState } from '@/activities/copilot/right-drawer/states/copilotQueryState';
|
||||
import {
|
||||
AutosizeTextInput,
|
||||
AutosizeTextInputVariant,
|
||||
} from '@/ui/input/components/AutosizeTextInput';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: flex-start;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledChatArea = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: scroll;
|
||||
padding: ${({ theme }) => theme.spacing(6)};
|
||||
padding-bottom: 0px;
|
||||
`;
|
||||
|
||||
const StyledNewMessageArea = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: ${({ theme }) => theme.spacing(6)};
|
||||
padding-top: 0px;
|
||||
`;
|
||||
|
||||
export const RightDrawerAIChat = () => {
|
||||
const setCopilotQuery = useSetRecoilState(copilotQueryState);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledChatArea>{/* TODO */}</StyledChatArea>
|
||||
<StyledNewMessageArea>
|
||||
<AutosizeTextInput
|
||||
autoFocus
|
||||
placeholder="Ask anything"
|
||||
variant={AutosizeTextInputVariant.Icon}
|
||||
onValidate={(text) => {
|
||||
setCopilotQuery(text);
|
||||
}}
|
||||
/>
|
||||
</StyledNewMessageArea>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
|
||||
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
|
||||
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
|
||||
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
|
||||
|
||||
export const useOpenCopilotRightDrawer = () => {
|
||||
const { openRightDrawer } = useRightDrawer();
|
||||
const setHotkeyScope = useSetHotkeyScope();
|
||||
|
||||
return () => {
|
||||
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
|
||||
openRightDrawer(RightDrawerPages.Copilot);
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const copilotQueryState = createState({
|
||||
key: 'activities/copilot-query',
|
||||
defaultValue: '',
|
||||
});
|
||||
@ -1,10 +1,12 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { Avatar, IconNotes } from 'twenty-ui';
|
||||
import { Avatar, IconNotes, IconSparkles } from 'twenty-ui';
|
||||
|
||||
import { useOpenCopilotRightDrawer } from '@/activities/copilot/right-drawer/hooks/useOpenCopilotRightDrawer';
|
||||
import { copilotQueryState } from '@/activities/copilot/right-drawer/states/copilotQueryState';
|
||||
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
|
||||
import { Activity } from '@/activities/types/Activity';
|
||||
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
|
||||
@ -21,6 +23,7 @@ import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { getLogoUrlFromDomainName } from '~/utils';
|
||||
import { generateILikeFiltersForCompositeFields } from '~/utils/array/generateILikeFiltersForCompositeFields';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
@ -248,8 +251,27 @@ export const CommandMenu = () => {
|
||||
callback: closeCommandMenu,
|
||||
});
|
||||
|
||||
const selectableItemIds = matchingCreateCommand
|
||||
const isCopilotEnabled = useIsFeatureEnabled('IS_COPILOT_ENABLED');
|
||||
const setCopilotQuery = useSetRecoilState(copilotQueryState);
|
||||
const openCopilotRightDrawer = useOpenCopilotRightDrawer();
|
||||
|
||||
const copilotCommand: Command = {
|
||||
id: 'copilot',
|
||||
to: '', // TODO
|
||||
Icon: IconSparkles,
|
||||
label: 'Open Copilot',
|
||||
type: CommandType.Navigate,
|
||||
onCommandClick: () => {
|
||||
setCopilotQuery(commandMenuSearch);
|
||||
openCopilotRightDrawer();
|
||||
},
|
||||
};
|
||||
|
||||
const copilotCommands: Command[] = isCopilotEnabled ? [copilotCommand] : [];
|
||||
|
||||
const selectableItemIds = copilotCommands
|
||||
.map((cmd) => cmd.id)
|
||||
.concat(matchingCreateCommand.map((cmd) => cmd.id))
|
||||
.concat(matchingNavigateCommand.map((cmd) => cmd.id))
|
||||
.concat(people.map((person) => person.id))
|
||||
.concat(companies.map((company) => company.id))
|
||||
@ -275,6 +297,7 @@ export const CommandMenu = () => {
|
||||
hotkeyScope={AppHotkeyScope.CommandMenu}
|
||||
onEnter={(itemId) => {
|
||||
const command = [
|
||||
...copilotCommands,
|
||||
...commandMenuCommands,
|
||||
...otherCommands,
|
||||
].find((cmd) => cmd.id === itemId);
|
||||
@ -292,6 +315,22 @@ export const CommandMenu = () => {
|
||||
!activities.length && (
|
||||
<StyledEmpty>No results found</StyledEmpty>
|
||||
)}
|
||||
{isCopilotEnabled && (
|
||||
<CommandGroup heading="Copilot">
|
||||
<SelectableItem itemId={copilotCommand.id}>
|
||||
<CommandMenuItem
|
||||
id={copilotCommand.id}
|
||||
Icon={copilotCommand.Icon}
|
||||
label={`${copilotCommand.label} ${
|
||||
commandMenuSearch.length > 2
|
||||
? `"${commandMenuSearch}"`
|
||||
: ''
|
||||
}`}
|
||||
onClick={copilotCommand.onCommandClick}
|
||||
/>
|
||||
</SelectableItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandGroup heading="Create">
|
||||
{matchingCreateCommand.map((cmd) => (
|
||||
<SelectableItem itemId={cmd.id} key={cmd.id}>
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const getCopilot = gql`
|
||||
query GetAISQLQuery($text: String!) {
|
||||
getAISQLQuery(text: $text) {
|
||||
sqlQuery
|
||||
sqlQueryResult
|
||||
queryFailedErrorMessage
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -30,6 +30,8 @@ type AutosizeTextInputProps = {
|
||||
value?: string;
|
||||
className?: string;
|
||||
onBlur?: () => void;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
@ -123,6 +125,8 @@ export const AutosizeTextInput = ({
|
||||
value = '',
|
||||
className,
|
||||
onBlur,
|
||||
autoFocus,
|
||||
disabled,
|
||||
}: AutosizeTextInputProps) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isHidden, setIsHidden] = useState(
|
||||
@ -212,7 +216,9 @@ export const AutosizeTextInput = ({
|
||||
{!isHidden && (
|
||||
<StyledTextArea
|
||||
ref={textInputRef}
|
||||
autoFocus={variant === AutosizeTextInputVariant.Button}
|
||||
autoFocus={
|
||||
autoFocus || variant === AutosizeTextInputVariant.Button
|
||||
}
|
||||
placeholder={placeholder ?? 'Write a comment'}
|
||||
maxRows={MAX_ROWS}
|
||||
minRows={computedMinRows}
|
||||
@ -221,6 +227,7 @@ export const AutosizeTextInput = ({
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
variant={variant}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{variant === AutosizeTextInputVariant.Icon && (
|
||||
|
||||
@ -2,6 +2,7 @@ import styled from '@emotion/styled';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
|
||||
import { RightDrawerCalendarEvent } from '@/activities/calendar/right-drawer/components/RightDrawerCalendarEvent';
|
||||
import { RightDrawerAIChat } from '@/activities/copilot/right-drawer/components/RightDrawerAIChat';
|
||||
import { RightDrawerEmailThread } from '@/activities/emails/right-drawer/components/RightDrawerEmailThread';
|
||||
import { RightDrawerCreateActivity } from '@/activities/right-drawer/components/create/RightDrawerCreateActivity';
|
||||
import { RightDrawerEditActivity } from '@/activities/right-drawer/components/edit/RightDrawerEditActivity';
|
||||
@ -50,6 +51,10 @@ const RIGHT_DRAWER_PAGES_CONFIG = {
|
||||
page: <RightDrawerRecord />,
|
||||
topBar: <RightDrawerTopBar page={RightDrawerPages.ViewRecord} />,
|
||||
},
|
||||
[RightDrawerPages.Copilot]: {
|
||||
page: <RightDrawerAIChat />,
|
||||
topBar: <RightDrawerTopBar page={RightDrawerPages.Copilot} />,
|
||||
},
|
||||
};
|
||||
|
||||
export const RightDrawerRouter = () => {
|
||||
|
||||
@ -6,4 +6,5 @@ export const RIGHT_DRAWER_PAGE_ICONS = {
|
||||
[RightDrawerPages.ViewEmailThread]: 'IconMail',
|
||||
[RightDrawerPages.ViewCalendarEvent]: 'IconCalendarEvent',
|
||||
[RightDrawerPages.ViewRecord]: 'Icon123',
|
||||
[RightDrawerPages.Copilot]: 'IconSparkles',
|
||||
};
|
||||
|
||||
@ -6,4 +6,5 @@ export const RIGHT_DRAWER_PAGE_TITLES = {
|
||||
[RightDrawerPages.ViewEmailThread]: 'Email Thread',
|
||||
[RightDrawerPages.ViewCalendarEvent]: 'Calendar Event',
|
||||
[RightDrawerPages.ViewRecord]: 'Record Editor',
|
||||
[RightDrawerPages.Copilot]: 'Copilot',
|
||||
};
|
||||
|
||||
@ -4,4 +4,5 @@ export enum RightDrawerPages {
|
||||
ViewEmailThread = 'view-email-thread',
|
||||
ViewCalendarEvent = 'view-calendar-event',
|
||||
ViewRecord = 'view-record',
|
||||
Copilot = 'copilot',
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ type TabProps = {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
hasBetaPill?: boolean;
|
||||
pill?: string;
|
||||
};
|
||||
|
||||
const StyledTab = styled.div<{ active?: boolean; disabled?: boolean }>`
|
||||
@ -59,7 +59,7 @@ export const Tab = ({
|
||||
onClick,
|
||||
className,
|
||||
disabled,
|
||||
hasBetaPill,
|
||||
pill,
|
||||
}: TabProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
@ -73,7 +73,7 @@ export const Tab = ({
|
||||
<StyledHover>
|
||||
{Icon && <Icon size={theme.icon.size.md} />}
|
||||
{title}
|
||||
{hasBetaPill && <Pill label="Beta" />}
|
||||
{pill && <Pill label={pill} />}
|
||||
</StyledHover>
|
||||
</StyledTab>
|
||||
);
|
||||
|
||||
@ -15,7 +15,7 @@ type SingleTabProps = {
|
||||
id: string;
|
||||
hide?: boolean;
|
||||
disabled?: boolean;
|
||||
hasBetaPill?: boolean;
|
||||
pill?: string;
|
||||
};
|
||||
|
||||
type TabListProps = {
|
||||
@ -62,7 +62,7 @@ export const TabList = ({ tabs, tabListId, loading }: TabListProps) => {
|
||||
setActiveTabId(tab.id);
|
||||
}}
|
||||
disabled={tab.disabled ?? loading}
|
||||
hasBetaPill={tab.hasBetaPill}
|
||||
pill={tab.pill}
|
||||
/>
|
||||
))}
|
||||
</StyledContainer>
|
||||
|
||||
@ -4,4 +4,5 @@ export type FeatureFlagKey =
|
||||
| 'IS_EVENT_OBJECT_ENABLED'
|
||||
| 'IS_AIRTABLE_INTEGRATION_ENABLED'
|
||||
| 'IS_POSTGRESQL_INTEGRATION_ENABLED'
|
||||
| 'IS_STRIPE_INTEGRATION_ENABLED';
|
||||
| 'IS_STRIPE_INTEGRATION_ENABLED'
|
||||
| 'IS_COPILOT_ENABLED';
|
||||
|
||||
Reference in New Issue
Block a user