mirror of
https://github.com/supabase/supabase.git
synced 2026-06-08 10:33:55 +08:00
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:
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
70
apps/studio/pages/api/ai/sql/generate-v2.ts
Normal file
70
apps/studio/pages/api/ai/sql/generate-v2.ts
Normal 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' },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,7 @@ const SqlEditor: NextPageWithLayout = () => {
|
||||
}, [isPgInfoReady])
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<SQLEditor />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user