diff --git a/packages/ui/src/components/Command/Command.constants.ts b/packages/ui/src/components/Command/Command.constants.ts index 93b5618910a..a82feca5f68 100644 --- a/packages/ui/src/components/Command/Command.constants.ts +++ b/packages/ui/src/components/Command/Command.constants.ts @@ -1,6 +1,7 @@ export const COMMAND_ROUTES = { AI: 'Supabase AI', DOCS_SEARCH: 'Docs Search', + GENERATE_SQL: 'Generate SQL', THEME: 'Theme', AI_ASK_ANYTHING: 'Ask anything', AI_RLS_POLICY: 'Help me create a RLS policy', diff --git a/packages/ui/src/components/Command/Command.icons.tsx b/packages/ui/src/components/Command/Command.icons.tsx index 07d7cf16d3b..504f109f660 100644 --- a/packages/ui/src/components/Command/Command.icons.tsx +++ b/packages/ui/src/components/Command/Command.icons.tsx @@ -1,12 +1,17 @@ import React from 'react' -export const AiIcon = () => ( + +interface AiIconProps { + className?: string +} + +export const AiIcon = ({ className = 'text-brand-900' }: AiIconProps) => ( { )} - {site === 'studio' && ( + {site === 'studio' && [ + + setPages([...pages, COMMAND_ROUTES.GENERATE_SQL])} + > + + Generate SQL with Supabase AI + + , {sharedItems.tools.map((item) => { - const itemUrl = projectRef ? item.url.replace('_', projectRef) : item.url + const itemUrl = ( + projectRef ? item.url.replace('_', projectRef) : item.url + ).split('https://app.supabase.com')[1] return ( router.push(itemUrl)}> @@ -207,8 +221,8 @@ const CommandMenu = ({ projectRef }: CommandMenuProps) => { ) })} - - )} + , + ]} {/* */} @@ -244,6 +258,7 @@ const CommandMenu = ({ projectRef }: CommandMenuProps) => { )} {currentPage === COMMAND_ROUTES.AI && } {currentPage === COMMAND_ROUTES.DOCS_SEARCH && } + {currentPage === COMMAND_ROUTES.GENERATE_SQL && } {currentPage === COMMAND_ROUTES.THEME && } diff --git a/packages/ui/src/components/Command/CommandMenuProvider.tsx b/packages/ui/src/components/Command/CommandMenuProvider.tsx index 591f00dcf36..0caf455f69b 100644 --- a/packages/ui/src/components/Command/CommandMenuProvider.tsx +++ b/packages/ui/src/components/Command/CommandMenuProvider.tsx @@ -19,6 +19,9 @@ export interface CommandMenuContextValue { // to do: remove this prop // this is a temporary hack as ReactMarkdown fails our jest tests if we import the package within this UI package MarkdownHandler: (props: any) => React.ReactNode // to do: remove this. although it breaks our jest tests + + // Optional callback to save a generated SQL output + onSaveGeneratedSQL?: (answer: string, resolve: any) => void } export const CommandMenuContext = createContext(undefined) export const useCommandMenu = () => { @@ -41,6 +44,7 @@ export interface CommandMenuProviderProps { // to do: remove this prop // this is a temporary hack as ReactMarkdown fails our jest tests if we import the package within this UI package MarkdownHandler: (props: any) => React.ReactNode + onSaveGeneratedSQL?: (answer: string, resolve: any) => void } const CommandMenuProvider = ({ @@ -48,6 +52,7 @@ const CommandMenuProvider = ({ site, projectRef, MarkdownHandler, + onSaveGeneratedSQL, }: PropsWithChildren) => { const [isOpen, setIsOpen] = useState(false) const [isLoading, setIsLoading] = useState(false) @@ -77,6 +82,7 @@ const CommandMenuProvider = ({ currentPage, site, MarkdownHandler, + onSaveGeneratedSQL, }} > {children} diff --git a/packages/ui/src/components/Command/GenerateSQL.tsx b/packages/ui/src/components/Command/GenerateSQL.tsx new file mode 100644 index 00000000000..14d30cb4d8b --- /dev/null +++ b/packages/ui/src/components/Command/GenerateSQL.tsx @@ -0,0 +1,417 @@ +import { SSE } from 'sse.js' +import type { CreateCompletionResponse } from 'openai' +import { useCallback, useEffect, useReducer, useRef, useState } from 'react' +import { + Button, + CodeBlock, + IconAlertCircle, + IconAlertTriangle, + IconCheck, + IconClipboard, + IconCornerDownLeft, + IconSave, + IconUser, + Input, +} from 'ui' + +import { cn } from './../../utils/cn' +import { AiIcon, AiIconChat } from './Command.icons' +import { CommandGroup, CommandItem } from './Command.utils' +import { useCommandMenu } from './CommandMenuProvider' +import CopyToClipboard from 'react-copy-to-clipboard' +import { noop } from 'lodash' + +const SAMPLE_QUERIES = [ + 'Create a table that stores a list of cities, and insert 10 rows of sample data into it', + 'Generate tables (with id bigserial & FK relationships) for blog posts and comments', + 'Create a trigger that updates the updated_at column on the orders table with the current time when the row of the orders table is updated', +] + +function getEdgeFunctionUrl() { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.replace(/\/$/, '') + + if (!supabaseUrl) return undefined + + // https://github.com/supabase/supabase-js/blob/10d3423506cbd56345f7f6ab2ec2093c8db629d4/src/SupabaseClient.ts#L96 + const isPlatform = supabaseUrl.match(/(supabase\.co)|(supabase\.in)/) + + if (isPlatform) { + const [schemeAndProjectId, domain, tld] = supabaseUrl.split('.') + return `${schemeAndProjectId}.functions.${domain}.${tld}` + } else { + return `${supabaseUrl}/functions/v1` + } +} + +function promptDataReducer( + state: any[], + action: { + index?: number + answer?: string | undefined + status?: string + query?: string | undefined + type?: 'remove-last-item' | string + } +) { + // set a standard state to use later + let current = [...state] + + if (action.type) { + switch (action.type) { + case 'remove-last-item': + current.pop() + return [...current] + default: + break + } + } + + // check that an index is present + if (action.index === undefined) return [...state] + + if (!current[action.index]) { + current[action.index] = { query: '', answer: '', status: '' } + } + + current[action.index].answer = action.answer + + if (action.query) { + current[action.index].query = action.query + } + if (action.status) { + current[action.index].status = action.status + } + + return [...current] +} + +const SQLOutputActions = ({ + answer, + onSaveGeneratedSQL, +}: { + answer: string + onSaveGeneratedSQL?: (answer: string, resolve: any) => void +}) => { + const [showCopied, setShowCopied] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [isSaved, setIsSaved] = useState(false) + + const applyCallback = () => + onSaveGeneratedSQL !== undefined + ? new Promise((resolve) => onSaveGeneratedSQL(answer, resolve)) + : {} + + const onSelectSaveSnippet = async () => { + setIsSaving(true) + await applyCallback() + setIsSaved(true) + setIsSaving(false) + } + + useEffect(() => { + if (!showCopied) return + const timer = setTimeout(() => setShowCopied(false), 2000) + return () => clearTimeout(timer) + }, [showCopied]) + + useEffect(() => { + if (!isSaved) return + const timer = setTimeout(() => setIsSaved(false), 2000) + return () => clearTimeout(timer) + }, [isSaved]) + + return ( +
+ + + + {onSaveGeneratedSQL !== undefined && ( + + )} +
+ ) +} + +const GenerateSQL = () => { + const [promptIndex, setPromptIndex] = useState(0) + const [answer, setAnswer] = useState('') + const [isResponding, setIsResponding] = useState(false) + const [hasClippyError, setHasClippyError] = useState(false) + + const eventSourceRef = useRef() + const [promptData, dispatchPromptData] = useReducer(promptDataReducer, []) + const { isLoading, setIsLoading, search, setSearch, MarkdownHandler, onSaveGeneratedSQL } = + useCommandMenu() + + const cantHelp = answer?.trim() === "Sorry, I don't know how to help with that." + + const handleConfirm = useCallback( + async (query: string) => { + const edgeFunctionUrl = getEdgeFunctionUrl() + + if (!edgeFunctionUrl) { + return console.error('No edge function url') + } + + setAnswer(undefined) + setSearch('') + dispatchPromptData({ index: promptIndex, answer: undefined, query }) + setIsResponding(false) + setHasClippyError(false) + setIsLoading(true) + + // [Joshen] Eventually we need to pass the table data in here as well + const queryToSend = ` +Generate a Postgres SQL query based on the following natural language prompt: +${query} + +Postgres SQL query: +`.trim() + + const eventSource = new SSE(`${edgeFunctionUrl}/clippy-search`, { + headers: { + apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '', + Authorization: `Bearer ${process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY}`, + 'Content-Type': 'application/json', + }, + payload: JSON.stringify({ query: queryToSend, context: promptData }), + }) + + function handleError(err: T) { + setIsLoading(false) + setIsResponding(false) + setHasClippyError(true) + console.error(err) + } + + eventSource.addEventListener('error', handleError) + eventSource.addEventListener('message', (e: any) => { + try { + setIsLoading(false) + if (e.data === '[DONE]') { + setIsResponding(false) + setAnswer(undefined) + setPromptIndex((x) => { + return x + 1 + }) + return + } + setIsResponding(true) + + const completionResponse: CreateCompletionResponse = JSON.parse(e.data) + const [{ text: content }] = completionResponse.choices + const text = content ?? '' + + setAnswer((answer) => { + const currentAnswer = answer ?? '' + dispatchPromptData({ index: promptIndex, answer: currentAnswer + text }) + return (answer ?? '') + text + }) + } catch (err) { + handleError(err) + } + }) + + eventSource.stream() + eventSourceRef.current = eventSource + setIsLoading(true) + }, + [promptIndex, promptData] + ) + + function handleResetPrompt() { + eventSourceRef.current?.close() + eventSourceRef.current = undefined + setSearch('') + setAnswer(undefined) + setIsResponding(false) + setHasClippyError(false) + } + + useEffect(() => { + if (search) { + handleConfirm(search) + } + }, []) + + return ( +
e.stopPropagation()}> +
+ {promptData.map((prompt, i) => { + if (!prompt.query) return <> + + return ( + <> + {prompt.query && ( +
+
+ +
+
{prompt.query}
+
+ )} + +
+ {cantHelp ? ( +

+

+ +

Sorry, I don't know how to help with that.

+
+ +

+ ) : ( +
+
+ +
+ <> + {isLoading && promptIndex === i ? ( +
+ ) : ( +
+ {/* @ts-expect-error */} + + {prompt.answer} + + {!isResponding && ( + + )} +
+ )} + +
+ )} +
+ + ) + })} + + {promptData.length === 0 && !hasClippyError && ( + + {SAMPLE_QUERIES.map((question) => { + const key = question.replace(/\s+/g, '_') + return ( + { + if (!search) { + handleConfirm(question) + } + }} + forceMount + key={key} + > +
+ +
+

{question}

+
+ ) + })} +
+ )} + {hasClippyError && ( +
+ +

+ Sorry, looks like Clippy is having a hard time! +

+

Please try again in a bit.

+ +
+ )} + +
+
+
+ + {!isLoading && !isResponding ? ( +
+ Submit message +
+ +
+
+ ) : null} + + } + onChange={(e) => { + if (!isLoading || !isResponding) { + setSearch(e.target.value) + } + }} + onKeyDown={(e) => { + switch (e.key) { + case 'Enter': + if (!search) { + return + } + if (isLoading || isResponding) { + return + } + handleConfirm(search) + return + default: + return + } + }} + /> +
+
+ ) +} + +export default GenerateSQL diff --git a/studio/components/interfaces/App/CommandMenuWrapper.tsx b/studio/components/interfaces/App/CommandMenuWrapper.tsx new file mode 100644 index 00000000000..fa7de049d17 --- /dev/null +++ b/studio/components/interfaces/App/CommandMenuWrapper.tsx @@ -0,0 +1,55 @@ +import remarkGfm from 'remark-gfm' +import ReactMarkdown from 'react-markdown' +import { PropsWithChildren } from 'react' +import { CommandMenuProvider } from 'ui' +import { checkPermissions, useOptimisticSqlSnippetCreate, useParams, useStore } from 'hooks' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useProfileQuery } from 'data/profile/profile-query' +import { uuidv4 } from 'lib/helpers' +import { createSqlSnippetSkeleton } from 'components/to-be-cleaned/SqlEditor/SqlEditor.utils' + +const CommandMenuWrapper = ({ children }: PropsWithChildren<{}>) => { + const { ref } = useParams() + const { content } = useStore() + + const { data: profile } = useProfileQuery() + // const canCreateSQLSnippet = checkPermissions(PermissionAction.CREATE, 'user_content', { + // resource: { type: 'sql', owner_id: profile?.id }, + // subject: { id: profile?.id }, + // }) + + const onSaveGeneratedSQL = async (answer: string, resolve: any) => { + const formattedOutput = ` +-- Note: This query was generated via Supabase AI, please do verify the correctness of the +-- SQL snippet before running it against your database as we are not able to guarantee the +-- correctness of the snippet that was generated. + +${answer} +`.trim() + + console.log('onSaveGeneratedSQL', { formattedOutput }) + const snippet = createSqlSnippetSkeleton({ + owner_id: profile?.id, + name: 'Generated query', + sql: formattedOutput, + }) + const payload = { id: uuidv4(), ...snippet } + await content.create(payload) + resolve() + } + + return ( + ( + + )} + onSaveGeneratedSQL={onSaveGeneratedSQL} + > + {children} + + ) +} + +export default CommandMenuWrapper diff --git a/studio/components/interfaces/App/index.ts b/studio/components/interfaces/App/index.ts index 593df54f0f6..3d82814f936 100644 --- a/studio/components/interfaces/App/index.ts +++ b/studio/components/interfaces/App/index.ts @@ -1,5 +1,6 @@ import PortalToast from './PortalToast' import RouteValidationWrapper from './RouteValidationWrapper' import AppBannerWrapper from './AppBannerWrapper' +import CommandMenuWrapper from './CommandMenuWrapper' -export { PortalToast, RouteValidationWrapper, AppBannerWrapper } +export { PortalToast, RouteValidationWrapper, AppBannerWrapper, CommandMenuWrapper } diff --git a/studio/pages/_app.tsx b/studio/pages/_app.tsx index 4f9a88c1a1d..1f980b652b3 100644 --- a/studio/pages/_app.tsx +++ b/studio/pages/_app.tsx @@ -31,25 +31,25 @@ import { Hydrate, QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { RootStore } from 'stores' import HCaptchaLoadedStore from 'stores/hcaptcha-loaded-store' -import { StoreProvider, useParams } from 'hooks' +import { StoreProvider } from 'hooks' import { AuthProvider } from 'lib/auth' import { dart } from 'lib/constants/prism' import { useRootQueryClient } from 'data/query-client' -import { PortalToast, RouteValidationWrapper, AppBannerWrapper } from 'components/interfaces/App' +import { + PortalToast, + RouteValidationWrapper, + AppBannerWrapper, + CommandMenuWrapper, +} from 'components/interfaces/App' import PageTelemetry from 'components/ui/PageTelemetry' import FlagProvider from 'components/ui/Flag/FlagProvider' import useAutoAuthRedirect from 'hooks/misc/useAutoAuthRedirect' import { TooltipProvider } from '@radix-ui/react-tooltip' import { IS_PLATFORM } from 'lib/constants' -import { createBrowserSupabaseClient } from '@supabase/auth-helpers-nextjs' import { SessionContextProvider } from '@supabase/auth-helpers-react' import { createClient } from '@supabase/supabase-js' -import { CommandMenuProvider } from 'ui' - -import remarkGfm from 'remark-gfm' -import ReactMarkdown from 'react-markdown' dayjs.extend(customParseFormat) dayjs.extend(utc) @@ -59,7 +59,6 @@ dayjs.extend(relativeTime) dart(Prism) function CustomApp({ Component, pageProps }: AppPropsWithLayout) { - const { ref } = useParams() const queryClient = useRootQueryClient() const [rootStore] = useState(() => new RootStore()) @@ -146,15 +145,9 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { - ( - - )} - > + {getLayout()} - +