import { SSE } from 'sse.js'
import { format } from 'sql-formatter'
import type { CreateCompletionResponse } from 'openai'
import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import {
Button,
CodeBlock,
IconAlertCircle,
IconAlertTriangle,
IconCheck,
IconClipboard,
IconCornerDownLeft,
IconSave,
IconUser,
Input,
} from 'ui'
import { cn } from './../../utils/cn'
import { AiIcon, AiIconChat } from './Command.icons'
import { CommandItem } from './Command.utils'
import { useCommandMenu } from './CommandMenuProvider'
import CopyToClipboard from 'react-copy-to-clipboard'
import { SAMPLE_QUERIES } from './Command.constants'
function getEdgeFunctionUrl() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.replace(/\/$/, '')
if (!supabaseUrl) return undefined
// https://github.com/supabase/supabase-js/blob/10d3423506cbd56345f7f6ab2ec2093c8db629d4/src/SupabaseClient.ts#L96
const isPlatform = supabaseUrl.match(/(supabase\.co)|(supabase\.in)/)
if (isPlatform) {
const [schemeAndProjectId, domain, tld] = supabaseUrl.split('.')
return `${schemeAndProjectId}.functions.${domain}.${tld}`
} else {
return `${supabaseUrl}/functions/v1`
}
}
function promptDataReducer(
state: any[],
action: {
index?: number
answer?: string | undefined
status?: string
query?: string | undefined
type?: 'remove-last-item' | string
}
) {
// set a standard state to use later
let current = [...state]
if (action.type) {
switch (action.type) {
case 'remove-last-item':
current.pop()
return [...current]
default:
break
}
}
// check that an index is present
if (action.index === undefined) return [...state]
if (!current[action.index]) {
current[action.index] = { query: '', answer: '', status: '' }
}
current[action.index].answer = action.answer
if (action.query) {
current[action.index].query = action.query
}
if (action.status) {
current[action.index].status = action.status
}
return [...current]
}
const SQLOutputActions = ({ answer }: { answer: string }) => {
const [showCopied, setShowCopied] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isSaved, setIsSaved] = useState(false)
const { project, onSaveGeneratedSQL } = useCommandMenu()
const applyCallback = () =>
onSaveGeneratedSQL !== undefined
? new Promise((resolve) => onSaveGeneratedSQL(answer, resolve))
: {}
const onSelectSaveSnippet = async () => {
setIsSaving(true)
await applyCallback()
setIsSaved(true)
setIsSaving(false)
}
useEffect(() => {
if (!showCopied) return
const timer = setTimeout(() => setShowCopied(false), 2000)
return () => clearTimeout(timer)
}, [showCopied])
useEffect(() => {
if (!isSaved) return
const timer = setTimeout(() => setIsSaved(false), 2000)
return () => clearTimeout(timer)
}, [isSaved])
return (
) : (
)
}
onClick={() => setShowCopied(true)}
>
{showCopied ? 'Copied' : 'Copy SQL'}
{project?.ref !== undefined && onSaveGeneratedSQL !== undefined && (
) : (
)
}
onClick={() => onSelectSaveSnippet()}
>
{isSaved ? 'Snippet saved!' : 'Save into new snippet'}
)}
)
}
const GenerateSQL = () => {
const [promptIndex, setPromptIndex] = useState(0)
const [answer, setAnswer] = useState('')
const [isResponding, setIsResponding] = useState(false)
const [hasClippyError, setHasClippyError] = useState(false)
const [selectedCategory, setSelectedCategory] = useState(SAMPLE_QUERIES[0].category)
const eventSourceRef = useRef()
const [promptData, dispatchPromptData] = useReducer(promptDataReducer, [])
const { isLoading, setIsLoading, search, setSearch } = useCommandMenu()
const cantHelp = answer?.trim() === "Sorry, I don't know how to help with that."
const handleConfirm = useCallback(
async (query: string) => {
const edgeFunctionUrl = getEdgeFunctionUrl()
if (!edgeFunctionUrl) {
return console.error('No edge function url')
}
setAnswer(undefined)
setSearch('')
dispatchPromptData({ index: promptIndex, answer: undefined, query })
setIsResponding(false)
setHasClippyError(false)
setIsLoading(true)
// [Joshen] Eventually we need to pass the table data in here as well
const queryToSend = `
Generate a Postgres SQL query based on the following natural language prompt. For primary keys, always use "integer primary key generated always as identity":
${query}
Postgres SQL query:
`.trim()
const eventSource = new SSE(`${edgeFunctionUrl}/clippy-search`, {
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '',
Authorization: `Bearer ${process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY}`,
'Content-Type': 'application/json',
},
payload: JSON.stringify({ query: queryToSend, context: promptData }),
})
function handleError(err: T) {
setIsLoading(false)
setIsResponding(false)
setHasClippyError(true)
console.error(err)
}
eventSource.addEventListener('error', handleError)
eventSource.addEventListener('message', (e: any) => {
try {
setIsLoading(false)
if (e.data === '[DONE]') {
setIsResponding(false)
setAnswer(undefined)
setPromptIndex((x) => {
return x + 1
})
return
}
setIsResponding(true)
const completionResponse: CreateCompletionResponse = JSON.parse(e.data)
const [{ text: content }] = completionResponse.choices
const text = content ?? ''
setAnswer((answer) => {
const currentAnswer = answer ?? ''
dispatchPromptData({ index: promptIndex, answer: currentAnswer + text })
return (answer ?? '') + text
})
} catch (err) {
handleError(err)
}
})
eventSource.stream()
eventSourceRef.current = eventSource
setIsLoading(true)
},
[promptIndex, promptData]
)
function handleResetPrompt() {
eventSourceRef.current?.close()
eventSourceRef.current = undefined
setSearch('')
setAnswer(undefined)
setIsResponding(false)
setHasClippyError(false)
}
useEffect(() => {
if (search) {
handleConfirm(search)
}
}, [])
const formatAnswer = (answer: string) => {
try {
return format(answer, {
language: 'postgresql',
keywordCase: 'lower',
})
} catch (error: any) {
return answer
}
}
return (
e.stopPropagation()}>
{promptData.map((prompt, i) => {
if (!prompt.query) return <>>
const formattedPromptAnwswer = (prompt?.answer ?? '')
.replace(/`/g, '')
.replace(/sql\n/g, '')
.trim()
const promptAnswer = isResponding
? formattedPromptAnwswer
: formatAnswer(formattedPromptAnwswer)
return (
<>
{prompt.query && (
)}
{cantHelp ? (
Sorry, I don't know how to help with that.
) : (
<>
{isLoading && promptIndex === i ? (
) : (
{promptAnswer}
{!isResponding && }
)}
>
)}
>
)
})}
{promptData.length === 0 && !hasClippyError && (
Example queries
Use these example queries to help get your project started quickly.
{SAMPLE_QUERIES.map((item, index) => (
- setSelectedCategory(item.category)}
className={cn(
'px-4 py-1 cursor-pointer text-sm hover:bg-slate-300 rounded-md',
selectedCategory === item.category && 'bg-slate-400 '
)}
>
{item.category}
))}
{SAMPLE_QUERIES.find((item) => item.category === selectedCategory)?.queries.map(
(query, index) => (
{
if (!search) {
handleConfirm(query)
}
}}
forceMount
key={query.replace(/\s+/g, '_')}
>
)
)}
)}
{hasClippyError && (
Sorry, looks like Clippy is having a hard time!
Please try again in a bit.
)}
)
}
export default GenerateSQL