diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index d047d8cdb10..b70197b5551 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -22,6 +22,7 @@ export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren<{} [LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL]: false, [LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_AI_ASSISTANT]: false, [LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: false, + [LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_AI_ASSISTANT]: false, }) useEffect(() => { @@ -35,6 +36,8 @@ export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren<{} localStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_AI_ASSISTANT) === 'true', [LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: localStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS) === 'true', + [LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_AI_ASSISTANT]: + localStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_AI_ASSISTANT) === 'true', }) } }, []) @@ -69,3 +72,8 @@ export const useIsColumnLevelPrivilegesEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS] } + +export const useIsSQLEditorAiAssistantEnabled = () => { + const { flags } = useFeaturePreviewContext() + return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_AI_ASSISTANT] +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index 5636661f4b2..8a7186cb1d0 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { Button, IconExternalLink, IconEye, IconEyeOff, Modal, ScrollArea, cn } from 'ui' +import { useFlag } from 'hooks' import { LOCAL_STORAGE_KEYS } from 'lib/constants' import Telemetry from 'lib/telemetry' import { useAppStateSnapshot } from 'state/app-state' @@ -12,8 +13,11 @@ import APISidePanelPreview from './APISidePanelPreview' import CLSPreview from './CLSPreview' import { useFeaturePreviewContext } from './FeaturePreviewContext' import RLSAIAssistantPreview from './RLSAIAssistantPreview' +import { SQLEditorAIAssistantPreview } from './SQLEditorAIAssistantPreview' const FeaturePreviewModal = () => { + const isAIConversational = useFlag('sqlEditorConversationalAi') + // [Ivan] We should probably move this to a separate file, together with LOCAL_STORAGE_KEYS. We should make adding new feature previews as simple as possible. const FEATURE_PREVIEWS: { key: string; name: string; content: any; discussionsUrl?: string }[] = [ { @@ -34,6 +38,17 @@ const FeaturePreviewModal = () => { content: , discussionsUrl: 'https://github.com/orgs/supabase/discussions/20295', }, + // the user should only be able to see the panel for the AI assistant if the feature flag is true + ...(isAIConversational + ? [ + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_AI_ASSISTANT, + name: 'Supabase Assistant for SQL editor', + content: , + discussionsUrl: undefined, + }, + ] + : []), ] const router = useRouter() diff --git a/apps/studio/components/interfaces/App/FeaturePreview/SQLEditorAIAssistantPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/SQLEditorAIAssistantPreview.tsx new file mode 100644 index 00000000000..6e59f3be437 --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/SQLEditorAIAssistantPreview.tsx @@ -0,0 +1,47 @@ +import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' +import { BASE_PATH } from 'lib/constants' +import Image from 'next/image' + +export const SQLEditorAIAssistantPreview = () => { + const { ref } = useParams() + + return ( +
+
+ + +
+ api-docs-side-panel-preview +
+

Enabling this preview will:

+
    +
  • + +
  • +
  • + +
  • +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyChat.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyChat.tsx index 51541a729d4..9f43f34f5e0 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyChat.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyChat.tsx @@ -1,6 +1,9 @@ import { zodResolver } from '@hookform/resolvers/zod' +import { useTelemetryProps } from 'common' +import Telemetry from 'lib/telemetry' import { compact, last } from 'lodash' import { Loader2 } from 'lucide-react' +import { useRouter } from 'next/router' import { useEffect, useRef } from 'react' import { useForm } from 'react-hook-form' import { @@ -10,14 +13,13 @@ import { FormField_Shadcn_, FormItem_Shadcn_, Form_Shadcn_, - IconSettings, Input_Shadcn_, + cn, } from 'ui' import * as z from 'zod' -import Telemetry from 'lib/telemetry' -import { useTelemetryProps } from 'common' -import { useRouter } from 'next/router' +import { useLocalStorageQuery, useSelectedOrganization } from 'hooks' +import { IS_PLATFORM, LOCAL_STORAGE_KEYS, OPT_IN_TAGS } from 'lib/constants' import { useProfile } from 'lib/profile' import { useAppStateSnapshot } from 'state/app-state' import { MessageWithDebug } from './AIPolicyEditorPanel.utils' @@ -40,12 +42,17 @@ export const AIPolicyChat = ({ onDiff, onChange, }: AIPolicyChatProps) => { + const router = useRouter() const { profile } = useProfile() const snap = useAppStateSnapshot() + const organization = useSelectedOrganization() const bottomRef = useRef(null) - const router = useRouter() const telemetryProps = useTelemetryProps() + const isOptedInToAI = organization?.opt_in_tags?.includes(OPT_IN_TAGS.AI_SQL) ?? false + const [hasEnabledAISchema] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_AI_SCHEMA, true) + const includeSchemaMetadata = (isOptedInToAI || !IS_PLATFORM) && hasEnabledAISchema + const name = compact([profile?.first_name, profile?.last_name]).join(' ') const FormSchema = z.object({ chat: z.string() }) @@ -90,15 +97,21 @@ export const AIPolicyChat = ({ Make sure to verify any generated code or suggestions, and share feedback so that we can learn and improve.`} > -
- -
+ {messages.map((m) => ( diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/Message.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/Message.tsx index 2a2e8c9c79e..fbc544fe9c0 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/Message.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/Message.tsx @@ -82,9 +82,10 @@ const Message = memo(function Message({ return ( div>pre]:!border-stronger [&>div>pre]:!bg-surface-200' : '' - } + )} > {props.children[0].props.children} diff --git a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/AiMessagePre.tsx b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/AiMessagePre.tsx new file mode 100644 index 00000000000..e6da9d54c80 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/AiMessagePre.tsx @@ -0,0 +1,149 @@ +import { useTelemetryProps } from 'common' +import { InsertCode, ReplaceCode } from 'icons' +import { Check, Copy } from 'lucide-react' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { format } from 'sql-formatter' +import { + Button, + CodeBlock, + TooltipContent_Shadcn_, + TooltipTrigger_Shadcn_, + Tooltip_Shadcn_, + cn, +} from 'ui' + +import Telemetry from 'lib/telemetry' +import { DiffType } from '../SQLEditor.types' + +interface AiMessagePreProps { + onDiff: (type: DiffType, s: string) => void + children: string[] + className?: string +} + +export const AiMessagePre = ({ onDiff, children, className }: AiMessagePreProps) => { + const [copied, setCopied] = useState(false) + const router = useRouter() + const telemetryProps = useTelemetryProps() + + useEffect(() => { + if (!copied) return + const timer = setTimeout(() => setCopied(false), 2000) + return () => clearTimeout(timer) + }, [copied]) + + let formatted = (children || [''])[0] + try { + formatted = format(formatted, { language: 'postgresql', keywordCase: 'upper' }) + } catch {} + + if (formatted.length === 0) { + return null + } + + function handleCopy(formatted: string) { + navigator.clipboard.writeText(formatted).then() + setCopied(true) + } + + return ( +
+      code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap'
+        )}
+        hideCopy
+        hideLineNumbers
+      />
+      
+ + + + + + Insert code + + + + + + + + + Replace code + + + + + + + + + Copy code + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/Message.tsx b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/Message.tsx new file mode 100644 index 00000000000..efc128f7dad --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/Message.tsx @@ -0,0 +1,101 @@ +import dayjs from 'dayjs' +import { noop } from 'lodash' +import Image from 'next/image' +import { PropsWithChildren, memo, useMemo } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { AiIconAnimation, Badge, cn, markdownComponents } from 'ui' + +import { useProfile } from 'lib/profile' +import { DiffType } from '../SQLEditor.types' +import { AiMessagePre } from './AiMessagePre' + +interface MessageProps { + name?: string + role: 'function' | 'system' | 'user' | 'assistant' | 'data' | 'tool' + content?: string + createdAt?: number + isDebug?: boolean + isSelected?: boolean + onDiff?: (type: DiffType, s: string) => void + action?: React.ReactNode +} + +const Message = memo(function Message({ + name, + role, + content, + createdAt, + isDebug, + isSelected = false, + onDiff = noop, + children, + action = <>, +}: PropsWithChildren) { + const { profile } = useProfile() + + const icon = useMemo(() => { + return role === 'assistant' ? ( + + ) : ( +
+ avatar +
+ ) + }, [content, profile?.username, role]) + + if (!content) return null + + return ( +
+
+
+ {icon} + + + {role === 'assistant' ? 'Assistant' : name ? name : 'You'} + + {createdAt && ( + {dayjs(createdAt).fromNow()} + )} + {isDebug && Debug request} +
{' '} + {action} +
+ { + return ( + div>pre]:!border-stronger [&>div>pre]:!bg-surface-200' : '' + )} + > + {props.children[0].props.children} + + ) + }, + }} + > + {content} + + {children} +
+ ) +}) + +export default Message diff --git a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/index.tsx b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/index.tsx new file mode 100644 index 00000000000..341dd18cf71 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/index.tsx @@ -0,0 +1,206 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { Message as MessageType } from 'ai' +import { useTelemetryProps } from 'common' +import Telemetry from 'lib/telemetry' +import { compact, last } from 'lodash' +import { Loader2 } from 'lucide-react' +import { useRouter } from 'next/router' +import { useEffect, useRef } from 'react' +import { useForm } from 'react-hook-form' +import { + AiIcon, + Button, + FormControl_Shadcn_, + FormField_Shadcn_, + FormItem_Shadcn_, + Form_Shadcn_, + Input_Shadcn_, + cn, +} from 'ui' +import * as z from 'zod' + +import { useLocalStorageQuery, useSelectedOrganization } from 'hooks' +import { IS_PLATFORM, LOCAL_STORAGE_KEYS, OPT_IN_TAGS } from 'lib/constants' +import { useProfile } from 'lib/profile' +import { useAppStateSnapshot } from 'state/app-state' +import { DiffType } from '../SQLEditor.types' +import Message from './Message' + +export type MessageWithDebug = MessageType & { isDebug: boolean } + +interface AiAssistantPanelProps { + messages: MessageWithDebug[] + selectedMessage?: string + loading: boolean + onSubmit: (s: string) => void + onDiff: ({ id, diffType, sql }: { id: string; diffType: DiffType; sql: string }) => void + onClose: () => void + onChange: (value: boolean) => void +} + +export const AiAssistantPanel = ({ + messages, + selectedMessage, + loading, + onSubmit, + onDiff, + onClose, + onChange, +}: AiAssistantPanelProps) => { + const router = useRouter() + const { profile } = useProfile() + const snap = useAppStateSnapshot() + const organization = useSelectedOrganization() + const bottomRef = useRef(null) + const telemetryProps = useTelemetryProps() + + const isOptedInToAI = organization?.opt_in_tags?.includes(OPT_IN_TAGS.AI_SQL) ?? false + const [hasEnabledAISchema] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_AI_SCHEMA, true) + const includeSchemaMetadata = (isOptedInToAI || !IS_PLATFORM) && hasEnabledAISchema + + const name = compact([profile?.first_name, profile?.last_name]).join(' ') + + const FormSchema = z.object({ chat: z.string() }) + const form = useForm>({ + mode: 'onBlur', + reValidateMode: 'onBlur', + resolver: zodResolver(FormSchema), + defaultValues: { chat: '' }, + }) + const formChatValue = form.getValues().chat + const pendingReply = loading && last(messages)?.role === 'user' + + // try to scroll on each rerender to the bottom + useEffect(() => { + if (loading && bottomRef.current) { + setTimeout(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, 500) + } + }) + + useEffect(() => { + if (!loading) { + form.setValue('chat', '') + form.setFocus('chat') + } + }, [loading]) + + useEffect(() => { + onChange(formChatValue.length === 0) + }, [formChatValue]) + + return ( +
+
+ + Close Assistant + + } + > + + + + {messages.map((m) => ( + onDiff({ id: m.id, diffType, sql })} + /> + ))} + + {pendingReply && } + +
+ {/*
+ {ASSISTANT_TEMPLATES.map((template) => ( + onSubmit(template.prompt)} + /> + ))} +
*/} +
+ + +
) => { + onSubmit(data.chat) + Telemetry.sendEvent( + { + category: 'sql_editor_ai_assistant', + action: 'ai_suggestion_asked', + label: 'sql-editor-ai-assistant', + }, + telemetryProps, + router + ) + })} + > + ( + + +
+ + + {loading && } +
+
+
+ )} + /> + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/DiffActionBar.tsx b/apps/studio/components/interfaces/SQLEditor/DiffActionBar.tsx new file mode 100644 index 00000000000..62d9b91e8a5 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/DiffActionBar.tsx @@ -0,0 +1,73 @@ +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + IconChevronDown, + IconCornerDownLeft, + IconLoader, +} from 'ui' + +import { DiffType } from './SQLEditor.types' +import { getDiffTypeButtonLabel, getDiffTypeDropdownLabel } from './SQLEditor.utils' + +type DiffActionBarProps = { + loading: boolean + selectedDiffType: DiffType + onChangeDiffType: (type: DiffType) => void + onAccept: () => void + onCancel: () => void +} + +export const DiffActionBar = ({ + loading, + selectedDiffType, + onChangeDiffType, + onAccept, + onCancel, +}: DiffActionBarProps) => { + return ( + <> +
+ + + +
+ + + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts index df5798c3ceb..c200724b39e 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts @@ -23,3 +23,28 @@ export const sqlAiDisclaimerComment = ` export const untitledSnippetTitle = 'Untitled query' export const destructiveSqlRegex = [/^(.*;)?\s*(drop|delete|truncate)\s/is] + +export const ASSISTANT_TEMPLATES = [ + { + name: 'Twitter clone', + description: 'Simplified schema that mimics the Twitter application', + prompt: 'Create a twitter clone', + }, + { + name: 'Chat application', + description: 'Send messages through channels or direct messages', + prompt: + 'Create a chat application that supports sending messages either through channels or directly between users', + }, + { + name: 'User management schema', + description: 'With role based access control', + prompt: 'Create a simple user management schema that supports role based access control', + }, + { + name: 'Countries and Cities', + description: 'With each city belonging to a country', + prompt: + 'Create a table of countries and a table of cities, with each city belonging to a country', + }, +] diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 713ddefb011..4a8b257afea 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -1,4 +1,5 @@ import type { Monaco } from '@monaco-editor/react' +import { useChat } from 'ai/react' import { useParams, useTelemetryProps } from 'common' import { AnimatePresence, motion } from 'framer-motion' import dynamic from 'next/dynamic' @@ -8,15 +9,7 @@ import toast from 'react-hot-toast' import { format } from 'sql-formatter' import { AiIconAnimation, - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - IconCheck, - IconChevronDown, IconCornerDownLeft, - IconLoader, IconSettings, IconX, Input_Shadcn_, @@ -38,8 +31,14 @@ import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' import { useFormatQueryMutation } from 'data/sql/format-sql-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { isError } from 'data/utils/error-check' -import { useLocalStorageQuery, useSelectedOrganization, useSelectedProject, useStore } from 'hooks' -import { IS_PLATFORM, LOCAL_STORAGE_KEYS, OPT_IN_TAGS } from 'lib/constants' +import { + useFlag, + useLocalStorageQuery, + useSelectedOrganization, + useSelectedProject, + useStore, +} from 'hooks' +import { BASE_PATH, IS_PLATFORM, LOCAL_STORAGE_KEYS, OPT_IN_TAGS } from 'lib/constants' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' import { wrapWithRoleImpersonation } from 'lib/role-impersonation' @@ -48,8 +47,11 @@ import { useAppStateSnapshot } from 'state/app-state' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { isRoleImpersonationEnabled, useGetImpersonatedRole } from 'state/role-impersonation-state' import { getSqlEditorStateSnapshot, useSqlEditorStateSnapshot } from 'state/sql-editor' +import { useIsSQLEditorAiAssistantEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils' import AISchemaSuggestionPopover from './AISchemaSuggestionPopover' +import { AiAssistantPanel } from './AiAssistantPanel' +import { DiffActionBar } from './DiffActionBar' import { sqlAiDisclaimerComment, untitledSnippetTitle } from './SQLEditor.constants' import { ContentDiff, @@ -60,9 +62,10 @@ import { } from './SQLEditor.types' import { checkDestructiveQuery, + compareAsAddition, + compareAsModification, + compareAsNewSnippet, createSqlSnippetSkeleton, - getDiffTypeButtonLabel, - getDiffTypeDropdownLabel, } from './SQLEditor.utils' import UtilityPanel from './UtilityPanel/UtilityPanel' @@ -104,6 +107,10 @@ const SQLEditor = () => { const snap = useSqlEditorStateSnapshot() const databaseSelectorState = useDatabaseSelectorStateSnapshot() + const aiAssistantFlag = useFlag('sqlEditorConversationalAi') + const aiAssistantFeaturePreview = useIsSQLEditorAiAssistantEnabled() + const isAiAssistantOn = aiAssistantFlag && aiAssistantFeaturePreview + const { mutate: formatQuery } = useFormatQueryMutation() const { mutateAsync: generateSql, isLoading: isGenerateSqlLoading } = useSqlGenerateMutation() const { mutateAsync: editSql, isLoading: isEditSqlLoading } = useSqlEditMutation() @@ -111,8 +118,9 @@ const SQLEditor = () => { const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation() const [aiInput, setAiInput] = useState('') + const [selectedMessage, setSelectedMessage] = useState() const [debugSolution, setDebugSolution] = useState() - const [sqlDiff, setSqlDiff] = useState() + const [sourceSqlDiff, setSourceSqlDiff] = useState() const [pendingTitle, setPendingTitle] = useState() const [hasSelection, setHasSelection] = useState(false) const inputRef = useRef(null) @@ -133,7 +141,7 @@ const SQLEditor = () => { const selectedOrganization = useSelectedOrganization() const selectedProject = useSelectedProject() const isOptedInToAI = selectedOrganization?.opt_in_tags?.includes(OPT_IN_TAGS.AI_SQL) ?? false - const [hasEnabledAISchema] = useLocalStorageQuery('supabase_sql-editor-ai-schema-enabled', true) + const [hasEnabledAISchema] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_AI_SCHEMA, true) const [isAcceptDiffLoading, setIsAcceptDiffLoading] = useState(false) const [, setAiQueryCount] = useLocalStorageQuery('supabase_sql-editor-ai-query-count', 0) const [, setIsSchemaSuggestionDismissed] = useLocalStorageQuery( @@ -143,7 +151,7 @@ const SQLEditor = () => { const includeSchemaMetadata = (isOptedInToAI || !IS_PLATFORM) && hasEnabledAISchema - const [selectedDiffType, setSelectedDiffType] = useState(DiffType.Modification) + const [selectedDiffType, setSelectedDiffType] = useState(undefined) const [isFirstRender, setIsFirstRender] = useState(true) const [lineHighlights, setLineHighlights] = useState([]) @@ -171,7 +179,33 @@ const SQLEditor = () => { const entityDefinitions = includeSchemaMetadata ? data?.map((def) => def.sql.trim()) : undefined - const isDiffOpen = !!sqlDiff + const isDiffOpen = !!sourceSqlDiff + + const editorRef = useRef(null) + const monacoRef = useRef(null) + const diffEditorRef = useRef(null) + + const { + messages: chatMessages, + append, + isLoading: isLoadingChat, + } = useChat({ + api: `${BASE_PATH}/api/ai/sql/generate-v2`, + body: { + existingSql: editorRef.current?.getValue(), + entityDefinitions: isOptedInToAI ? entityDefinitions : undefined, + }, + }) + + const messages = useMemo(() => { + const merged = [...chatMessages.map((m) => ({ ...m, isDebug: false }))] + + return merged.sort( + (a, b) => + (a.createdAt?.getTime() ?? 0) - (b.createdAt?.getTime() ?? 0) || + a.role.localeCompare(b.role) + ) + }, [chatMessages]) const { mutate: execute, isLoading: isExecuting } = useExecuteSqlMutation({ onSuccess(data) { @@ -222,10 +256,6 @@ const SQLEditor = () => { const isLoading = urlId === 'new' ? false : !(id && ref && snap.loaded[ref]) - const editorRef = useRef(null) - const monacoRef = useRef(null) - const diffEditorRef = useRef(null) - /** * Sets the snippet title using AI. */ @@ -374,11 +404,42 @@ const SQLEditor = () => { [profile?.id, project?.id, ref, router, snap, ui] ) + const updateEditorWithCheckForDiff = ({ + id, + diffType, + sql, + }: { + id: string + diffType: DiffType + sql: string + }) => { + const editorModel = editorRef.current?.getModel() + if (!editorModel) return + + setAiQueryCount((count) => count + 1) + + const existingValue = editorRef.current?.getValue() ?? '' + if (existingValue.length === 0) { + editorRef.current?.executeEdits('apply-ai-message', [ + { + text: `${sqlAiDisclaimerComment}\n\n${sql}`, + range: editorModel.getFullModelRange(), + }, + ]) + } else { + setSelectedMessage(id) + const currentSql = editorRef.current?.getValue() + const diff = { original: currentSql || '', modified: sql } + setSourceSqlDiff(diff) + setSelectedDiffType(diffType) + } + } + const acceptAiHandler = useCallback(async () => { try { setIsAcceptDiffLoading(true) - if (!sqlDiff) { + if (!sourceSqlDiff) { return } @@ -423,15 +484,16 @@ const SQLEditor = () => { ) setAiInput('') + setSelectedMessage(undefined) setSelectedDiffType(DiffType.Modification) setDebugSolution(undefined) - setSqlDiff(undefined) + setSourceSqlDiff(undefined) setPendingTitle(undefined) } finally { setIsAcceptDiffLoading(false) } }, [ - sqlDiff, + sourceSqlDiff, selectedDiffType, handleNewQuery, titleSql, @@ -454,8 +516,9 @@ const SQLEditor = () => { router ) + setSelectedMessage(undefined) setDebugSolution(undefined) - setSqlDiff(undefined) + setSourceSqlDiff(undefined) setPendingTitle(undefined) }, [debugSolution, telemetryProps, router]) @@ -480,68 +543,78 @@ const SQLEditor = () => { return () => window.removeEventListener('keydown', handler) }, [isDiffOpen, acceptAiHandler, discardAiHandler]) - const compareAsModification = useCallback(() => { + useEffect(() => { + const applyDiff = ({ original, modified }: { original: string; modified: string }) => { + const model = diffEditorRef.current?.getModel() + if (model && model.original && model.modified) { + model.original.setValue(original) + model.modified.setValue(modified) + } + } + const model = diffEditorRef.current?.getModel() + try { + if (model?.original && model.modified && sourceSqlDiff) { + switch (selectedDiffType) { + case DiffType.Modification: { + const transformedDiff = compareAsModification(sourceSqlDiff) + applyDiff(transformedDiff) + return + } - if (!model) { - throw new Error("Diff editor's model not available") + case DiffType.Addition: { + const transformedDiff = compareAsAddition(sourceSqlDiff) + applyDiff(transformedDiff) + return + } + + case DiffType.NewSnippet: { + const transformedDiff = compareAsNewSnippet(sourceSqlDiff) + applyDiff(transformedDiff) + return + } + + default: + throw new Error(`Unknown diff type '${selectedDiffType}'`) + } + } + } catch (e) { + console.log(e) } + }, [selectedDiffType, sourceSqlDiff]) - if (!sqlDiff) { - throw new Error('Returned SQL diff not available') + const defaultSqlDiff = useMemo(() => { + if (!sourceSqlDiff) { + return { original: '', modified: '' } } + switch (selectedDiffType) { + case DiffType.Modification: { + return compareAsModification(sourceSqlDiff) + } - model.original.setValue(sqlDiff.original) - model.modified.setValue(sqlDiff.modified) - }, [sqlDiff]) + case DiffType.Addition: { + return compareAsAddition(sourceSqlDiff) + } - const compareAsAddition = useCallback(() => { - const model = diffEditorRef.current?.getModel() + case DiffType.NewSnippet: { + return compareAsNewSnippet(sourceSqlDiff) + } - if (!model) { - throw new Error("Diff editor's model not available") + default: + return { original: '', modified: '' } } - - if (!sqlDiff) { - throw new Error('Returned SQL diff not available') - } - - const formattedOriginal = sqlDiff.original.replace(sqlAiDisclaimerComment, '').trim() - const formattedModified = sqlDiff.modified.replace(sqlAiDisclaimerComment, '').trim() - const newModified = - sqlAiDisclaimerComment + - '\n\n' + - (formattedOriginal ? formattedOriginal + '\n\n' : '') + - formattedModified - - model.original.setValue(sqlDiff.original) - model.modified.setValue(newModified) - }, [sqlDiff]) - - const compareAsNewSnippet = useCallback(() => { - const model = diffEditorRef.current?.getModel() - - if (!model) { - throw new Error("Diff editor's model not available") - } - - if (!sqlDiff) { - throw new Error('Returned SQL diff not available') - } - - model.original.setValue('') - model.modified.setValue(sqlDiff.modified) - }, [sqlDiff]) + }, [selectedDiffType, sourceSqlDiff]) return ( { executeQuery(true) }} /> -
- {isAiOpen && !hasHipaaAddon && ( - { - appSnap.setShowAiSettingsModal(true) - }} - > - -
- - - - - - {debugSolution && ( -
- {debugSolution} -
- )} - {!isAiLoading && !debugSolution && ( - - setAiInput(e.currentTarget.value)} - disabled={isDiffOpen} - ref={inputRef} - className={cn( - '!p-0 bg-transparent border-transparent text-sm text-brand-600 placeholder:text-brand-500 focus:!ring-0', - 'focus-visible:ring-0 focus-visible:ring-offset-0', - 'appearance-none outline-none' - )} - placeholder={ - !debugSolution - ? !snippet?.snippet.content.sql.trim() - ? 'Ask Supabase AI to build a query' - : 'Ask Supabase AI to modify your query' - : '' - } - onKeyDown={(e) => { - if (e.key === 'Escape' && !aiInput) { - setIsAiOpen(false) - } - }} - onKeyPress={async (e) => { - if (e.key === 'Enter') { - try { - const prompt = e.currentTarget.value - - if (!prompt) { - return - } - - const currentSql = editorRef.current?.getValue() - - let sql: string | undefined - let title: string | undefined - - if (!currentSql) { - ;({ sql, title } = await generateSql({ - prompt, - entityDefinitions, - })) - } else { - ;({ sql } = await editSql({ - prompt, - sql: currentSql.replace(sqlAiDisclaimerComment, '').trim(), - entityDefinitions, - })) - } - - setAiQueryCount((count) => count + 1) - - const formattedSql = - sqlAiDisclaimerComment + - '\n\n' + - format(sql, { - language: 'postgresql', - keywordCase: 'lower', - }) - - // If this was an edit and AI returned the same SQL as before - if (currentSql && formattedSql.trim() === currentSql.trim()) { - ui.setNotification({ - category: 'error', - message: - 'Unable to edit SQL. Try adding more details to your prompt.', - }) - return - } - - setSqlDiff({ - original: currentSql ?? '', - modified: formattedSql, - }) - - if (title) { - setPendingTitle(title) - } - } catch (error: unknown) { - if (isError(error)) { - ui.setNotification({ - category: 'error', - message: error.message, - }) - } - } - } - }} - /> - - )} - {isAiLoading && ( - - - Thinking... - - - )} -
-
- {isDiffOpen ? ( - <> -
- - - -
- - - ) : ( - <> -
- -
- - - - )} -
-
-
-
- )} +
- - {!isAiOpen && ( - setIsAiOpen(!isAiOpen)} - className={cn( - 'group', - 'absolute z-10', - 'rounded-lg', - 'right-[24px] top-4', - 'transition-all duration-200', - 'ease-out' - )} - > - - - )} - - {isLoading ? ( -
- - <> - -
- ) : ( + {isAiOpen && !hasHipaaAddon && ( + { + appSnap.setShowAiSettingsModal(true) + }} + > <> - {isDiffOpen && ( + {!isAiAssistantOn ? ( - { - diffEditorRef.current = editor - let isFirstLoad = true +
+ + + - editor.onDidUpdateDiff(() => { - if (!isFirstLoad) { - return - } + + {debugSolution && ( +
+ {debugSolution} +
+ )} + {!isAiLoading && !debugSolution && ( + + setAiInput(e.currentTarget.value)} + disabled={isDiffOpen} + ref={inputRef} + className={cn( + '!p-0 bg-transparent border-transparent text-sm text-brand-600 placeholder:text-brand-500 focus:!ring-0', + 'focus-visible:ring-0 focus-visible:ring-offset-0', + 'appearance-none outline-none' + )} + placeholder={ + !debugSolution + ? !snippet?.snippet.content.sql.trim() + ? 'Ask Supabase AI to build a query' + : 'Ask Supabase AI to modify your query' + : '' + } + onKeyDown={(e) => { + if (e.key === 'Escape' && !aiInput) { + setIsAiOpen(false) + } + }} + onKeyPress={async (e) => { + if (e.key === 'Enter') { + try { + const prompt = e.currentTarget.value - const model = editor.getModel() - const lineChanges = editor.getLineChanges() + if (!prompt) { + return + } - if (!model || !lineChanges || lineChanges.length === 0) { - return - } + const currentSql = editorRef.current?.getValue() - const original = model.original.getValue() - const formattedOriginal = format( - original.replace(sqlAiDisclaimerComment, '').trim(), - { - language: 'postgresql', - keywordCase: 'lower', - } - ) - const modified = model.modified.getValue() + let sql: string | undefined + let title: string | undefined - const lineStart = original.includes(sqlAiDisclaimerComment) - ? (sqlAiDisclaimerComment + '\n\n').split('\n').length - : 0 - const lineEnd = model.original.getLineCount() - const totalLines = lineEnd - lineStart + if (!currentSql) { + ;({ sql, title } = await generateSql({ + prompt, + entityDefinitions, + })) + } else { + ;({ sql } = await editSql({ + prompt, + sql: currentSql.replace(sqlAiDisclaimerComment, '').trim(), + entityDefinitions, + })) + } - // If any change overwrites >50% of the original code, - // and the the modified code doesn't contain the original code, - // predict that this is an addition instead of a modification - const isAddition = - lineChanges.some( - (lineChange) => - lineChange.originalEndLineNumber - - lineChange.originalStartLineNumber > - totalLines * 0.5 - ) && !modified.includes(formattedOriginal) + setAiQueryCount((count) => count + 1) - if (isAddition) { - setSelectedDiffType(DiffType.Addition) - compareAsAddition() - } + const formattedSql = format(sql, { + language: 'postgresql', + keywordCase: 'lower', + }) - isFirstLoad = false - }) - }} - options={{ - fontSize: 13, - }} + // If this was an edit and AI returned the same SQL as before + if (currentSql && formattedSql.trim() === currentSql.trim()) { + ui.setNotification({ + category: 'error', + message: + 'Unable to edit SQL. Try adding more details to your prompt.', + }) + return + } + + setSourceSqlDiff({ + original: currentSql ?? '', + modified: formattedSql, + }) + setSelectedDiffType(DiffType.Modification) + + if (title) { + setPendingTitle(title) + } + } catch (error: unknown) { + if (isError(error)) { + ui.setNotification({ + category: 'error', + message: error.message, + }) + } + } + } + }} + /> + + )} + {isAiLoading && ( + + + Thinking... + + + )} +
+
+ {isDiffOpen ? ( + setSelectedDiffType(diffType)} + onAccept={acceptAiHandler} + onCancel={discardAiHandler} + /> + ) : ( + <> +
+ +
+ + + + )} +
+
+
+ ) : isDiffOpen ? ( + + {debugSolution && ( +
+ {debugSolution} +
+ )} + setSelectedDiffType(diffType)} + onAccept={acceptAiHandler} + onCancel={discardAiHandler} />
- )} - - - + ) : null} - )} +
+ )} + +
+ {!isAiOpen && ( + setIsAiOpen(!isAiOpen)} + className={cn( + 'group absolute z-10 rounded-lg right-[24px] top-4 transition-all duration-200 ease-out' + )} + > + + + )} + + {isLoading ? ( +
+ + <> + +
+ ) : ( + <> + {isDiffOpen && ( + + { + diffEditorRef.current = editor + + // This logic deducts whether the diff should be addition or replacement on initial diffing. + // With the AI assistant is not necessary because it has separate buttons for addition and + // replacement. Using this logic with the AI assistant would probably annoy the users. + if (isAiAssistantOn) { + return + } + let isFirstLoad = true + + editor.onDidUpdateDiff(() => { + if (!isFirstLoad) { + return + } + + const model = editor.getModel() + const lineChanges = editor.getLineChanges() + + if (!model || !lineChanges || lineChanges.length === 0) { + return + } + + const original = model.original.getValue() + const formattedOriginal = format( + original.replace(sqlAiDisclaimerComment, '').trim(), + { + language: 'postgresql', + keywordCase: 'lower', + } + ) + + const modified = model.modified.getValue() + + const lineStart = original.includes(sqlAiDisclaimerComment) + ? (sqlAiDisclaimerComment + '\n\n').split('\n').length + : 0 + const lineEnd = model.original.getLineCount() + const totalLines = lineEnd - lineStart + + // If any change overwrites >50% of the original code, + // and the the modified code doesn't contain the original code, + // predict that this is an addition instead of a modification + const isAddition = + lineChanges.some( + (lineChange) => + lineChange.originalEndLineNumber - + lineChange.originalStartLineNumber > + totalLines * 0.5 + ) && !modified.includes(formattedOriginal) + + if (isAddition) { + setSelectedDiffType(DiffType.Addition) + } + isFirstLoad = false + }) + }} + options={{ fontSize: 13 }} + /> + + )} + + + + + )} +
@@ -1005,6 +1007,23 @@ const SQLEditor = () => { )}
+ {isAiOpen && isAiAssistantOn && ( + + append({ + content: message, + role: 'user', + createdAt: new Date(), + }) + } + onDiff={updateEditorWithCheckForDiff} + onChange={() => {}} + onClose={() => setIsAiOpen(false)} + /> + )}
) diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts index 5eb20e019ac..a01bbb7697c 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts @@ -24,6 +24,7 @@ export type SQLEditorContextValues = { setSqlDiff: Dispatch> debugSolution?: string setDebugSolution: Dispatch> + setSelectedDiffType: Dispatch> } export enum DiffType { diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index 3d5478114ab..3a9d362e977 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -1,6 +1,10 @@ -import { NEW_SQL_SNIPPET_SKELETON, destructiveSqlRegex } from './SQLEditor.constants' +import { + NEW_SQL_SNIPPET_SKELETON, + destructiveSqlRegex, + sqlAiDisclaimerComment, +} from './SQLEditor.constants' import type { SqlSnippets, UserContent } from 'types' -import { DiffType } from './SQLEditor.types' +import { ContentDiff, DiffType } from './SQLEditor.types' import { removeCommentsFromSql } from 'lib/helpers' export const createSqlSnippetSkeleton = ({ @@ -74,3 +78,32 @@ export const generateFileCliCommand = (id: string, name: string, isNpx = false) ${isNpx ? 'npx ' : ''}supabase snippets download ${id} > \\ ${name}.sql ` + +export const compareAsModification = (sqlDiff: ContentDiff) => { + return { + original: sqlDiff.original, + modified: `${sqlAiDisclaimerComment}\n\n${sqlDiff.modified}`, + } +} + +export const compareAsAddition = (sqlDiff: ContentDiff) => { + const formattedOriginal = sqlDiff.original.replace(sqlAiDisclaimerComment, '').trim() + const formattedModified = sqlDiff.modified.replace(sqlAiDisclaimerComment, '').trim() + const newModified = + sqlAiDisclaimerComment + + '\n\n' + + (formattedOriginal ? formattedOriginal + '\n\n' : '') + + formattedModified + + return { + original: sqlDiff.original, + modified: newModified, + } +} + +export const compareAsNewSnippet = (sqlDiff: ContentDiff) => { + return { + original: '', + modified: sqlDiff.modified, + } +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx index 44def70aaf4..a7037c1418e 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx @@ -1,16 +1,18 @@ +import { format } from 'sql-formatter' +import { AiIconAnimation, Button } from 'ui' + import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils' import { useSqlDebugMutation } from 'data/ai/sql-debug-mutation' import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { isError } from 'data/utils/error-check' import { useLocalStorageQuery, useSelectedOrganization, useSelectedProject, useStore } from 'hooks' -import { IS_PLATFORM, OPT_IN_TAGS } from 'lib/constants' -import { format } from 'sql-formatter' +import { IS_PLATFORM, LOCAL_STORAGE_KEYS, OPT_IN_TAGS } from 'lib/constants' import { useSqlEditorStateSnapshot } from 'state/sql-editor' -import { AiIconAnimation, Button } from 'ui' import { useSqlEditor } from '../SQLEditor' import { sqlAiDisclaimerComment } from '../SQLEditor.constants' +import { DiffType } from '../SQLEditor.types' import Results from './Results' -import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' export type UtilityTabResultsProps = { id: string @@ -22,11 +24,11 @@ const UtilityTabResults = ({ id, isExecuting }: UtilityTabResultsProps) => { const organization = useSelectedOrganization() const snap = useSqlEditorStateSnapshot() const { mutateAsync: debugSql, isLoading: isDebugSqlLoading } = useSqlDebugMutation() - const { setDebugSolution, setAiInput, setSqlDiff, sqlDiff } = useSqlEditor() + const { setDebugSolution, setAiInput, setSqlDiff, sqlDiff, setSelectedDiffType } = useSqlEditor() const selectedOrganization = useSelectedOrganization() const selectedProject = useSelectedProject() const isOptedInToAI = selectedOrganization?.opt_in_tags?.includes(OPT_IN_TAGS.AI_SQL) ?? false - const [hasEnabledAISchema] = useLocalStorageQuery('supabase_sql-editor-ai-schema-enabled', true) + const [hasEnabledAISchema] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_AI_SCHEMA, true) const includeSchemaMetadata = (isOptedInToAI || !IS_PLATFORM) && hasEnabledAISchema @@ -123,19 +125,17 @@ const UtilityTabResults = ({ id, isExecuting }: UtilityTabResultsProps) => { entityDefinitions, }) - const formattedSql = - sqlAiDisclaimerComment + - '\n\n' + - format(sql, { - language: 'postgresql', - keywordCase: 'lower', - }) + const formattedSql = format(sql, { + language: 'postgresql', + keywordCase: 'lower', + }) setAiInput('') setDebugSolution(solution) setSqlDiff({ original: snippet.snippet.content.sql, modified: formattedSql, }) + setSelectedDiffType(DiffType.Modification) } catch (error: unknown) { if (isError(error)) { ui.setNotification({ diff --git a/apps/studio/components/ui/AISettingsModal.tsx b/apps/studio/components/ui/AISettingsModal.tsx index 6772239ca45..5bf8c5c6e0a 100644 --- a/apps/studio/components/ui/AISettingsModal.tsx +++ b/apps/studio/components/ui/AISettingsModal.tsx @@ -1,53 +1,45 @@ import Link from 'next/link' -import { Alert, IconExternalLink, Modal, Toggle } from 'ui' +import toast from 'react-hot-toast' +import { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + Button, + Modal, + Toggle, +} from 'ui' -import { useLocalStorageQuery, useSelectedOrganization, useStore } from 'hooks' -import { IS_PLATFORM, OPT_IN_TAGS } from 'lib/constants' +import { useLocalStorageQuery, useSelectedOrganization } from 'hooks' +import { IS_PLATFORM, LOCAL_STORAGE_KEYS, OPT_IN_TAGS } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' +import { WarningIcon } from './Icons' const AISettingsModal = () => { const snap = useAppStateSnapshot() - const selectedOrganization = useSelectedOrganization() const isOptedInToAI = selectedOrganization?.opt_in_tags?.includes(OPT_IN_TAGS.AI_SQL) ?? false + const [hasEnabledAISchema, setHasEnabledAISchema] = useLocalStorageQuery( - 'supabase_sql-editor-ai-schema-enabled', + LOCAL_STORAGE_KEYS.SQL_EDITOR_AI_SCHEMA, true ) - const { ui } = useStore() const includeSchemaMetadata = (isOptedInToAI || !IS_PLATFORM) && hasEnabledAISchema const handleOptInToggle = () => { setHasEnabledAISchema((prev) => !prev) - ui.setNotification({ category: 'success', message: 'Successfully saved settings' }) + toast.success('Successfully saved settings') } return ( snap.setShowAiSettingsModal(false)} > -
- {IS_PLATFORM && !isOptedInToAI && selectedOrganization && ( - - - Go to your organization's settings to opt-in. - - - - )} -
+
+
{

+ {IS_PLATFORM && !isOptedInToAI && selectedOrganization && ( + + + + Your organization does not allow sending anonymous data to OpenAI + + + This option is only available if your organization has opted-in to sending anonymous + data to OpenAI. You may configure your opt-in preferences through your organization's + settings. + + + + + + )}
) diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts index 7646cc7b481..1c6db8c8f77 100644 --- a/apps/studio/lib/constants/index.ts +++ b/apps/studio/lib/constants/index.ts @@ -29,14 +29,18 @@ export const USAGE_APPROACHING_THRESHOLD = 0.75 export const LOCAL_STORAGE_KEYS = { RECENTLY_VISITED_ORGANIZATION: 'supabase-organization', TELEMETRY_CONSENT: 'supabase-consent', + UI_PREVIEW_NAVIGATION_LAYOUT: 'supabase-ui-preview-nav-layout', UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel', UI_PREVIEW_RLS_AI_ASSISTANT: 'supabase-ui-rls-ai-assistant', - DASHBOARD_HISTORY: (ref: string) => `dashboard-history-${ref}`, UI_PREVIEW_CLS: 'supabase-ui-cls', + UI_PREVIEW_SQL_EDITOR_AI_ASSISTANT: 'supabase-ui-sql-editor-ai-assistant', + + DASHBOARD_HISTORY: (ref: string) => `dashboard-history-${ref}`, SQL_EDITOR_INTELLISENSE: 'supabase_sql-editor-intellisense-enabled', SQL_EDITOR_SPLIT_SIZE: 'supabase_sql-editor-split-size', + SQL_EDITOR_AI_SCHEMA: 'supabase_sql-editor-ai-schema-enabled', LOG_EXPLORER_SPLIT_SIZE: 'supabase_log-explorer-split-size', GRAPHIQL_RLS_BYPASS_WARNING: 'graphiql-rls-bypass-warning-dismissed', CLS_DIFF_WARNING: 'cls-diff-warning-dismissed', diff --git a/apps/studio/pages/api/ai/sql/generate-v2.ts b/apps/studio/pages/api/ai/sql/generate-v2.ts new file mode 100644 index 00000000000..144f01a93bd --- /dev/null +++ b/apps/studio/pages/api/ai/sql/generate-v2.ts @@ -0,0 +1,70 @@ +import { StreamingTextResponse } from 'ai' +import { generateV2 } from 'ai-commands/edge' +import { NextRequest } from 'next/server' +import OpenAI from 'openai' + +export const runtime = 'edge' + +const openAiKey = process.env.OPENAI_KEY + +export default async function handler(req: NextRequest) { + if (!openAiKey) { + return new Response( + JSON.stringify({ + error: 'No OPENAI_KEY set. Create this environment variable to use AI features.', + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + + const { method } = req + + switch (method) { + case 'POST': + return handlePost(req) + default: + return new Response( + JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }), + { + status: 405, + headers: { 'Content-Type': 'application/json', Allow: 'POST' }, + } + ) + } +} + +async function handlePost(request: NextRequest) { + const openai = new OpenAI({ apiKey: openAiKey }) + + const body = await (request.json() as Promise<{ + messages: { content: string; role: 'user' | 'assistant' }[] + existingSql?: string + entityDefinitions: string[] + }>) + + const { messages, existingSql, entityDefinitions } = body + + try { + const stream = await generateV2(openai, messages, existingSql, entityDefinitions) + return new StreamingTextResponse(stream) + } catch (error) { + if (error instanceof Error) { + console.error(`AI SQL generation-v2 failed: ${error.message}`) + } else { + console.error(`AI SQL generation-v2 failed: ${error}`) + } + + return new Response( + JSON.stringify({ + error: 'There was an error processing your request', + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ) + } +} diff --git a/apps/studio/pages/project/[ref]/sql/[id].tsx b/apps/studio/pages/project/[ref]/sql/[id].tsx index accfced29d6..57c26080ae9 100644 --- a/apps/studio/pages/project/[ref]/sql/[id].tsx +++ b/apps/studio/pages/project/[ref]/sql/[id].tsx @@ -150,7 +150,7 @@ const SqlEditor: NextPageWithLayout = () => { }, [isPgInfoReady]) return ( -
+
) diff --git a/packages/ai-commands/src/sql.edge.ts b/packages/ai-commands/src/sql.edge.ts index dad7514d62f..fb5164e9bc2 100644 --- a/packages/ai-commands/src/sql.edge.ts +++ b/packages/ai-commands/src/sql.edge.ts @@ -94,6 +94,77 @@ export async function chatRlsPolicy( } } +/** + * Responds to a conversation about writing a SQL query. + * + * @returns A `ReadableStream` containing the response text and SQL. + */ +export async function generateV2( + openai: OpenAI, + messages: Message[], + existingSql?: string, + entityDefinitions?: string[] +): Promise> { + const initMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { + role: 'system', + content: stripIndent` + The generated SQL (must be valid SQL). + - For primary keys, always use "id bigint primary key generated always as identity" (not serial) + - Prefer creating foreign key references in the create statement + - Prefer 'text' over 'varchar' + - Prefer 'timestamp with time zone' over 'date' + - Use vector(384) data type for any embedding/vector related query + - Always use double apostrophe in SQL strings (eg. 'Night''s watch') + - Always use semicolons + - Output as markdown + - Always include code snippets if available + `, + }, + ] + + if (entityDefinitions) { + const definitions = codeBlock`${entityDefinitions.join('\n\n')}` + initMessages.push({ + role: 'user', + content: oneLine`Here is my database schema for reference: ${definitions}`, + }) + } + + if (existingSql !== undefined && existingSql.length > 0) { + const sqlBlock = codeBlock`${existingSql}` + initMessages.push({ + role: 'user', + content: codeBlock` + Here is the existing SQL I wrote for reference: + ${sqlBlock} + `.trim(), + }) + } + + if (messages) { + initMessages.push(...messages) + } + + try { + const response = await openai.chat.completions.create({ + model: 'gpt-3.5-turbo-0125', + messages: initMessages, + max_tokens: 1024, + temperature: 0, + stream: true, + }) + + // Transform the streamed SSE response from OpenAI to a ReadableStream + return OpenAIStream(response) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'context_length_exceeded') { + throw new ContextLengthError() + } + throw error + } +} + export async function clippy( openai: OpenAI, supabaseClient: SupabaseClient,