feat: Conversational AI for the SQL Editor (#21388)

* Add a API endpoint for generating queries for the SQL editor.

* Merge all multiline props.

* Add a new component for diff actions.

* Copy components for the AI panel.

* Add useChat hook.

* Add a feature preview for the this feature. The preview is dependent on the feature flag.

* Reorder the nesting in the SQL editor to accomodate the AI assistant.

* Try to fit both AI assistants in the SQL editor, available via a feature preview.

* Refactor the SQL editor to make the diff work correctly in all cases.

* Minor fixes for the old AI feature.

* Fix the debug functionality to work with both assistants.

* Fix some copy-paste leftovers.

* Remove unneeded code.

* Make the icons softer.

* Fix the name of the panel component.

* Fix console.logs.

* Add overflow to the AI assistant.

* surface opt in config in ai settings button

* Skip diffing if editor is empty

* Add selected state when selecting a message to insert/replace code

* Add sample prompts

* Add SQL ai dislaimer when replacing code

* Light mode action bar nudges

* Add text for the feature preview.

* lang nudges

* Hide the command suggestions for now.

* Set the discussion url to undefined.

* Don't add the disclaimer twice.

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
Co-authored-by: Terry Sutton <saltcod@gmail.com>
This commit is contained in:
Ivan Vasilov
2024-03-05 17:26:11 +01:00
committed by GitHub
parent 010aa4bf90
commit 7548fd1850
19 changed files with 1386 additions and 533 deletions

View File

@@ -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]
}

View File

@@ -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: <CLSPreview />,
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: <SQLEditorAIAssistantPreview />,
discussionsUrl: undefined,
},
]
: []),
]
const router = useRouter()

View File

@@ -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 (
<div className="space-y-2">
<div className="mb-4 flex flex-col gap-y-2">
<Markdown
className="text-foreground-light max-w-full"
content="When using the [SQL Editor](http://supabase.com/project/_/sql) you'll have access to a new and improved AI Assistant which you can use to help you write your queries."
/>
<Markdown
className="text-foreground-light max-w-full"
content={`Let our AI Assistant handle the SQL while you focus on building your app.`}
/>
</div>
<Image
src={`${BASE_PATH}/img/previews/rls-ai-assistant-preview.png`}
width={1860}
height={970}
alt="api-docs-side-panel-preview"
className="rounded border"
/>
<div className="space-y-2 !mt-4">
<p className="text-sm">Enabling this preview will:</p>
<ul className="list-disc pl-6 text-sm text-foreground-light space-y-1">
<li>
<Markdown
className="text-foreground-light"
content={`Replace the existing single-input UI with a side panel where you can have a full conversation.`}
/>
</li>
<li>
<Markdown
className="text-foreground-light"
content={`Supabase Assistant will iteratively generate SQL from your natural language prompts.`}
/>
</li>
</ul>
</div>
</div>
)
}

View File

@@ -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<HTMLDivElement>(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.`}
>
<div>
<Button
type="default"
icon={<IconSettings strokeWidth={1.5} />}
onClick={() => snap.setShowAiSettingsModal(true)}
>
AI Settings
</Button>
</div>
<Button
type="default"
className="w-min"
icon={
<div
className={cn(
'w-2 h-2 rounded-full',
includeSchemaMetadata ? 'bg-brand' : 'border border-stronger'
)}
/>
}
onClick={() => snap.setShowAiSettingsModal(true)}
>
{includeSchemaMetadata ? 'Include' : 'Exclude'} database metadata in queries
</Button>
</Message>
{messages.map((m) => (

View File

@@ -82,9 +82,10 @@ const Message = memo(function Message({
return (
<AIPolicyPre
onDiff={onDiff}
className={
className={cn(
'transition',
isSelected ? '[&>div>pre]:!border-stronger [&>div>pre]:!bg-surface-200' : ''
}
)}
>
{props.children[0].props.children}
</AIPolicyPre>

View File

@@ -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 (
<pre className={cn('rounded-md relative group', className)}>
<CodeBlock
value={formatted}
language="sql"
className={cn(
'!bg-transparent !py-3 !px-3.5 prose dark:prose-dark',
// change the look of the code block. The flex hack is so that the code is wrapping since
// every word is a separate span
'[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap'
)}
hideCopy
hideLineNumbers
/>
<div className="absolute top-5 right-2 bg-surface-100 border-muted border rounded-lg h-[28px] hidden group-hover:block">
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<Button
type="text"
size="tiny"
onClick={() => {
onDiff(DiffType.Addition, formatted)
Telemetry.sendEvent(
{
category: 'sql_editor_ai_assistant',
action: 'ai_suggestion_inserted',
label: 'sql-editor-ai-assistant',
},
telemetryProps,
router
)
}}
>
<InsertCode className="h-4 w-4 text-foreground-light" strokeWidth={1.5} />
</Button>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom" className="font-sans">
Insert code
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<Button
type="text"
size="tiny"
onClick={() => {
onDiff(DiffType.Modification, formatted)
Telemetry.sendEvent(
{
category: 'sql_editor_ai_assistant',
action: 'ai_suggestion_replaced',
label: 'sql-editor-ai-assistant',
},
telemetryProps,
router
)
}}
>
<ReplaceCode className="h-4 w-4 text-foreground-light" strokeWidth={1.5} />
</Button>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom" className="font-sans">
Replace code
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<Button
type="text"
size="tiny"
onClick={() => {
handleCopy(formatted)
Telemetry.sendEvent(
{
category: 'sql_editor_ai_assistant',
action: 'ai_suggestion_copied',
label: 'sql-editor-ai-assistant',
},
telemetryProps,
router
)
}}
>
{copied ? (
<Check size={16} className="text-brand-600" />
) : (
<Copy size={16} className="text-foreground-light" strokeWidth={1.5} />
)}
</Button>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom" className="font-sans">
Copy code
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
</div>
</pre>
)
}

View File

@@ -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<MessageProps>) {
const { profile } = useProfile()
const icon = useMemo(() => {
return role === 'assistant' ? (
<AiIconAnimation
loading={content === 'Thinking...'}
className="[&>div>div]:border-black dark:[&>div>div]:border-white"
/>
) : (
<div className="relative border shadow-lg w-8 h-8 rounded-full overflow-hidden">
<Image
src={`https://github.com/${profile?.username}.png` || ''}
width={30}
height={30}
alt="avatar"
className="relative"
/>
</div>
)
}, [content, profile?.username, role])
if (!content) return null
return (
<div className="flex flex-col py-4 gap-4 border-t px-5 text-foreground-light text-sm">
<div className="flex flex-row justify-between items-center">
<div className="flex flex-row gap-3 items-center">
{icon}
<span className="text-sm">
{role === 'assistant' ? 'Assistant' : name ? name : 'You'}
</span>
{createdAt && (
<span className="text-xs text-foreground-muted">{dayjs(createdAt).fromNow()}</span>
)}
{isDebug && <Badge color="amber">Debug request</Badge>}
</div>{' '}
{action}
</div>
<ReactMarkdown
className="gap-2.5 flex flex-col [&>*>code]:text-xs [&>*>*>code]:text-xs"
remarkPlugins={[remarkGfm]}
components={{
...markdownComponents,
pre: (props: any) => {
return (
<AiMessagePre
onDiff={onDiff}
className={cn(
'transition',
isSelected ? '[&>div>pre]:!border-stronger [&>div>pre]:!bg-surface-200' : ''
)}
>
{props.children[0].props.children}
</AiMessagePre>
)
},
}}
>
{content}
</ReactMarkdown>
{children}
</div>
)
})
export default Message

View File

@@ -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<HTMLDivElement>(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<z.infer<typeof FormSchema>>({
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 (
<div className="flex flex-col h-full min-w-[400px] w-[400px] border-l border-control">
<div
className={cn(
'overflow-auto flex-1',
messages.length === 0 ? 'flex flex-col justify-between' : ''
)}
>
<Message
key="zero"
role="assistant"
content={`Hi${
name ? ' ' + name : ''
}, how can I help you? I'm powered by AI, so surprises and mistakes are possible.
Make sure to verify any generated code or suggestions, and share feedback so that we can
learn and improve.`}
action={
<Button type="default" onClick={onClose}>
Close Assistant
</Button>
}
>
<Button
type="default"
className="w-min"
icon={
<div
className={cn(
'w-2 h-2 rounded-full',
includeSchemaMetadata ? 'bg-brand' : 'border border-stronger'
)}
/>
}
onClick={() => snap.setShowAiSettingsModal(true)}
>
{includeSchemaMetadata ? 'Include' : 'Exclude'} database metadata in queries
</Button>
</Message>
{messages.map((m) => (
<Message
key={`message-${m.id}`}
name={m.name}
role={m.role}
content={m.content}
createdAt={new Date(m.createdAt || new Date()).getTime()}
isDebug={m.isDebug}
isSelected={selectedMessage === m.id}
onDiff={(diffType, sql) => onDiff({ id: m.id, diffType, sql })}
/>
))}
{pendingReply && <Message key="thinking" role="assistant" content="Thinking..." />}
<div ref={bottomRef} className="h-1" />
{/* <div className="grid grid-cols-12 gap-2 p-2">
{ASSISTANT_TEMPLATES.map((template) => (
<CardButton
hideChevron
key={template.name}
title={template.name}
description={template.description}
className="!p-3 col-span-12 h-auto [&>div>div]:!mt-0 [&>div>h5]:text-sm"
onClick={() => onSubmit(template.prompt)}
/>
))}
</div> */}
</div>
<Form_Shadcn_ {...form}>
<form
className="sticky p-5 flex-0 border-t"
onSubmit={form.handleSubmit((data: z.infer<typeof FormSchema>) => {
onSubmit(data.chat)
Telemetry.sendEvent(
{
category: 'sql_editor_ai_assistant',
action: 'ai_suggestion_asked',
label: 'sql-editor-ai-assistant',
},
telemetryProps,
router
)
})}
>
<FormField_Shadcn_
control={form.control}
name="chat"
render={({ field }) => (
<FormItem_Shadcn_>
<FormControl_Shadcn_>
<div className="relative">
<AiIcon className="absolute top-2 left-3 [&>div>div]:border-black dark:[&>div>div]:border-white" />
<Input_Shadcn_
{...field}
autoComplete="off"
disabled={loading}
autoFocus
className={`bg-surface-300 dark:bg-black rounded-full pl-10 ${
loading ? 'pr-10' : ''
}`}
placeholder="Ask a question about your SQL query"
/>
{loading && <Loader2 className="absolute top-2 right-3 animate-spin" />}
</div>
</FormControl_Shadcn_>
</FormItem_Shadcn_>
)}
/>
</form>
</Form_Shadcn_>
</div>
)
}

View File

@@ -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 (
<>
<div className="flex items-center">
<Button
className="rounded-r-none"
type="primary"
size="tiny"
icon={loading && <IconLoader className="animate-spin" size={14} />}
iconRight={<IconCornerDownLeft size={12} strokeWidth={1.5} />}
onClick={onAccept}
>
{getDiffTypeButtonLabel(selectedDiffType)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="primary"
className="rounded-l-none border-l-0 px-[4px] py-[5px] flex"
icon={<IconChevronDown />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
{Object.values(DiffType)
.filter((diffType) => diffType !== selectedDiffType)
.map((diffType) => (
<DropdownMenuItem key={diffType} onClick={() => onChangeDiffType(diffType)}>
<p>{getDiffTypeDropdownLabel(diffType)}</p>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button
type="alternative"
size="tiny"
className="bg-brand-300 hover:bg-brand-400 dark:bg-brand-400 dark:hover:bg-brand-500 text-brand-600 group"
iconRight={<span className="dark:text-brand-500 group-hover:text-brand-600">ESC</span>}
onClick={onCancel}
>
Discard
</Button>
</>
)
}

View File

@@ -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',
},
]

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@ export type SQLEditorContextValues = {
setSqlDiff: Dispatch<SetStateAction<ContentDiff | undefined>>
debugSolution?: string
setDebugSolution: Dispatch<SetStateAction<string | undefined>>
setSelectedDiffType: Dispatch<SetStateAction<DiffType | undefined>>
}
export enum DiffType {

View File

@@ -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,
}
}

View File

@@ -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({

View File

@@ -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 (
<Modal
header="Supabase AI Settings"
hideFooter
header="Supabase AI Settings"
visible={snap.showAiSettingsModal}
onCancel={() => snap.setShowAiSettingsModal(false)}
>
<div className="flex flex-col items-start justify-between gap-4 px-6 py-3">
{IS_PLATFORM && !isOptedInToAI && selectedOrganization && (
<Alert
variant="warning"
title="This option is only available if your organization has opted-in to sending anonymous data to OpenAI."
>
<Link
href={`/org/${selectedOrganization.slug}/general`}
className="flex flex-row gap-1 items-center"
target="_blank"
rel="noopener"
>
Go to your organization's settings to opt-in.
<IconExternalLink className="inline-block w-3 h-3" />
</Link>
</Alert>
)}
<div className="flex justify-between gap-8 mr-8 my-4">
<div className="flex flex-col items-start justify-between gap-y-4 px-6 py-3">
<div className="flex justify-between gap-x-8 mr-8 my-4">
<Toggle
disabled={IS_PLATFORM && !isOptedInToAI}
checked={includeSchemaMetadata}
@@ -61,6 +53,31 @@ const AISettingsModal = () => {
</p>
</div>
</div>
{IS_PLATFORM && !isOptedInToAI && selectedOrganization && (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>
Your organization does not allow sending anonymous data to OpenAI
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
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.
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_ className="mt-3">
<Button asChild type="default">
<Link
target="_blank"
rel="noreferrer"
href={`/org/${selectedOrganization.slug}/general`}
className="flex flex-row gap-1 items-center"
>
Head to organization settings
</Link>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
</div>
</Modal>
)

View File

@@ -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',

View File

@@ -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' },
}
)
}
}

View File

@@ -150,7 +150,7 @@ const SqlEditor: NextPageWithLayout = () => {
}, [isPgInfoReady])
return (
<div className="flex-1">
<div className="flex-1 overflow-auto">
<SQLEditor />
</div>
)

View File

@@ -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<ReadableStream<Uint8Array>> {
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<any, 'public', any>,