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 (
+
+
+
+
+
+
+
+
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.`}
>
-
- }
- onClick={() => snap.setShowAiSettingsModal(true)}
- >
- AI Settings
-
-
+
+ }
+ onClick={() => snap.setShowAiSettingsModal(true)}
+ >
+ {includeSchemaMetadata ? 'Include' : 'Exclude'} database metadata in queries
+
{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
+ />
+
+
+
+ {
+ onDiff(DiffType.Addition, formatted)
+ Telemetry.sendEvent(
+ {
+ category: 'sql_editor_ai_assistant',
+ action: 'ai_suggestion_inserted',
+ label: 'sql-editor-ai-assistant',
+ },
+ telemetryProps,
+ router
+ )
+ }}
+ >
+
+
+
+
+ Insert code
+
+
+
+
+
+ {
+ onDiff(DiffType.Modification, formatted)
+ Telemetry.sendEvent(
+ {
+ category: 'sql_editor_ai_assistant',
+ action: 'ai_suggestion_replaced',
+ label: 'sql-editor-ai-assistant',
+ },
+ telemetryProps,
+ router
+ )
+ }}
+ >
+
+
+
+
+ Replace code
+
+
+
+
+
+ {
+ handleCopy(formatted)
+ Telemetry.sendEvent(
+ {
+ category: 'sql_editor_ai_assistant',
+ action: 'ai_suggestion_copied',
+ label: 'sql-editor-ai-assistant',
+ },
+ telemetryProps,
+ router
+ )
+ }}
+ >
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+ 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' ? (
+
+ ) : (
+
+
+
+ )
+ }, [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
+
+ }
+ >
+
+ }
+ onClick={() => snap.setShowAiSettingsModal(true)}
+ >
+ {includeSchemaMetadata ? 'Include' : 'Exclude'} database metadata in queries
+
+
+
+ {messages.map((m) => (
+
onDiff({ id: m.id, diffType, sql })}
+ />
+ ))}
+
+ {pendingReply && }
+
+
+ {/*
+ {ASSISTANT_TEMPLATES.map((template) => (
+ onSubmit(template.prompt)}
+ />
+ ))}
+
*/}
+
+
+
+
+
+
+ )
+}
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 (
+ <>
+
+
}
+ iconRight={
}
+ onClick={onAccept}
+ >
+ {getDiffTypeButtonLabel(selectedDiffType)}
+
+
+
+ }
+ />
+
+
+ {Object.values(DiffType)
+ .filter((diffType) => diffType !== selectedDiffType)
+ .map((diffType) => (
+ onChangeDiffType(diffType)}>
+ {getDiffTypeDropdownLabel(diffType)}
+
+ ))}
+
+
+
+ ESC}
+ onClick={onCancel}
+ >
+ Discard
+
+ >
+ )
+}
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 ? (
- <>
-
-
- ) : (
-
- )
- }
- iconRight={
-
-
-
- }
- onClick={acceptAiHandler}
- >
- {getDiffTypeButtonLabel(selectedDiffType)}
-
-
-
- }
- />
-
-
- {Object.values(DiffType)
- .filter((diffType) => diffType !== selectedDiffType)
- .map((diffType) => (
- {
- setSelectedDiffType(diffType)
- switch (diffType) {
- case DiffType.Modification:
- return compareAsModification()
- case DiffType.Addition:
- return compareAsAddition()
- case DiffType.NewSnippet:
- return compareAsNewSnippet()
- default:
- throw new Error(`Unknown diff type '${diffType}'`)
- }
- }}
- >
- {getDiffTypeDropdownLabel(diffType)}
-
- ))}
-
-
-
-
}
- iconRight={
ESC }
- onClick={discardAiHandler}
- >
- Discard
-
- >
- ) : (
- <>
-
-
-
-
{
- setIsSchemaSuggestionDismissed(true)
- appSnap.setShowAiSettingsModal(true)
- }}
- className="text-brand-600 hover:text-brand-600 transition"
- >
-
-
-
setIsAiOpen(false)}
- >
-
-
- >
- )}
-
-
-
-
- )}
+
-
- {!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}
+ />
+ ) : (
+ <>
+
+
+
+ {
+ setIsSchemaSuggestionDismissed(true)
+ appSnap.setShowAiSettingsModal(true)
+ }}
+ className="text-brand-600 hover:text-brand-600 transition"
+ >
+
+
+ setIsAiOpen(false)}
+ >
+
+
+ >
+ )}
+
+
+
+ ) : 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.
+
+
+
+
+ Head to organization 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,