import { SSE } from 'sse.js'
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 { CommandGroup, CommandItem } from './Command.utils'
import { useCommandMenu } from './CommandMenuProvider'
import CopyToClipboard from 'react-copy-to-clipboard'
import { noop } from 'lodash'
const SAMPLE_QUERIES = [
'Create a table that stores a list of cities, and insert 10 rows of sample data into it',
'Generate tables (with id bigserial & FK relationships) for blog posts and comments',
'Create a trigger that updates the updated_at column on the orders table with the current time when the row of the orders table is updated',
]
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,
onSaveGeneratedSQL,
}: {
answer: string
onSaveGeneratedSQL?: (answer: string, resolve: any) => void
}) => {
const [showCopied, setShowCopied] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isSaved, setIsSaved] = useState(false)
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'}
{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 eventSourceRef = useRef()
const [promptData, dispatchPromptData] = useReducer(promptDataReducer, [])
const { isLoading, setIsLoading, search, setSearch, MarkdownHandler, onSaveGeneratedSQL } =
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)
}
}, [])
return (
e.stopPropagation()}>
{promptData.map((prompt, i) => {
if (!prompt.query) return <>>
return (
<>
{prompt.query && (
)}
{cantHelp ? (
Sorry, I don't know how to help with that.
) : (
<>
{isLoading && promptIndex === i ? (
) : (
{/* @ts-expect-error */}
{prompt.answer}
{!isResponding && (
)}
)}
>
)}
>
)
})}
{promptData.length === 0 && !hasClippyError && (
{SAMPLE_QUERIES.map((question) => {
const key = question.replace(/\s+/g, '_')
return (
{
if (!search) {
handleConfirm(question)
}
}}
forceMount
key={key}
>
{question}
)
})}
)}
{hasClippyError && (
Sorry, looks like Clippy is having a hard time!
Please try again in a bit.
)}
)
}
export default GenerateSQL