'use client' import type OpenAI from 'openai' import { Dispatch, SetStateAction, useCallback, useEffect, useReducer, useRef, useState, } from 'react' import { SSE } from 'sse.js' import { AiIconAnimation, Button, IconCornerDownLeft, IconUser, Input, markdownComponents, } from 'ui' import { AiIconChat } from './Command.icons' import { CommandGroup, CommandItem, useAutoInputFocus, useHistoryKeys } from './Command.utils' import { AiWarning } from './Command.alerts' import { useCommandMenu } from './CommandMenuContext' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { cn } from 'ui/src/lib/utils' import { StatusIcon } from '../Icons/StatusIcons' import { isBrowser } from 'common' const questions = [ 'How do I get started with Supabase?', 'How do I run Supabase locally?', 'How do I connect to my database?', 'How do I run migrations? ', 'How do I listen to changes in a table?', 'How do I set up authentication?', ] export const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '' export enum MessageRole { User = 'user', Assistant = 'assistant', } export enum MessageStatus { Pending = 'pending', InProgress = 'in-progress', Complete = 'complete', } export interface Message { role: MessageRole content: string status: MessageStatus } interface NewMessageAction { type: 'new' message: Message } interface UpdateMessageAction { type: 'update' index: number message: Partial } interface AppendContentAction { type: 'append-content' index: number content: string } interface ResetAction { type: 'reset' } type MessageAction = NewMessageAction | UpdateMessageAction | AppendContentAction | ResetAction function messageReducer(state: Message[], messageAction: MessageAction) { let current = [...state] const { type } = messageAction switch (type) { case 'new': { const { message } = messageAction current.push(message) break } case 'update': { const { index, message } = messageAction if (current[index]) { Object.assign(current[index], message) } break } case 'append-content': { const { index, content } = messageAction if (current[index]) { current[index].content += content } break } case 'reset': { current = [] break } default: { throw new Error(`Unknown message action '${type}'`) } } return current } export interface UseAiChatOptions { messageTemplate?: (message: string) => string setIsLoading?: Dispatch> } export function useAiChat({ messageTemplate = (message) => message, setIsLoading, }: UseAiChatOptions) { const eventSourceRef = useRef() const [isResponding, setIsResponding] = useState(false) const [hasError, setHasError] = useState(false) const [currentMessageIndex, setCurrentMessageIndex] = useState(1) const [messages, dispatchMessage] = useReducer(messageReducer, []) const submit = useCallback( async (query: string) => { dispatchMessage({ type: 'new', message: { status: MessageStatus.Complete, role: MessageRole.User, content: query, }, }) dispatchMessage({ type: 'new', message: { status: MessageStatus.Pending, role: MessageRole.Assistant, content: '', }, }) setIsResponding(false) setHasError(false) setIsLoading?.(true) const eventSource = new SSE(`${BASE_PATH}/api/ai/docs`, { 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({ messages: messages .filter(({ status }) => status === MessageStatus.Complete) .map(({ role, content }) => ({ role, content })) .concat({ role: MessageRole.User, content: messageTemplate(query) }), }), }) function handleError(err: T) { setIsLoading?.(false) setIsResponding(false) setHasError(true) console.error(err) } eventSource.addEventListener('error', handleError) eventSource.addEventListener('message', (e: MessageEvent) => { try { setIsLoading?.(false) if (e.data === '[DONE]') { setIsResponding(false) dispatchMessage({ type: 'update', index: currentMessageIndex, message: { status: MessageStatus.Complete, }, }) setCurrentMessageIndex((x) => x + 2) return } dispatchMessage({ type: 'update', index: currentMessageIndex, message: { status: MessageStatus.InProgress, }, }) setIsResponding(true) const completionChunk: OpenAI.Chat.Completions.ChatCompletionChunk = JSON.parse(e.data) const [ { delta: { content }, }, ] = completionChunk.choices if (content) { dispatchMessage({ type: 'append-content', index: currentMessageIndex, content, }) } } catch (err) { handleError(err) } }) eventSource.stream() eventSourceRef.current = eventSource setIsLoading?.(true) }, [currentMessageIndex, messages, messageTemplate] ) function reset() { eventSourceRef.current?.close() eventSourceRef.current = undefined setIsResponding(false) setHasError(false) dispatchMessage({ type: 'reset', }) } return { submit, reset, messages, isResponding, hasError, } } /** * Perform a one-off query to AI based on a snapshot of messages */ export function queryAi(messages: Message[], timeout = 0) { return new Promise((resolve, reject) => { const eventSource = new SSE(`${BASE_PATH}/api/ai/docs`, { 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({ messages: messages.map(({ role, content }) => ({ role, content })), }), }) let timeoutId: number | undefined function handleError(err: T) { if (timeoutId) { clearTimeout(timeoutId) } console.error(err) reject(err) } if (timeout > 0) { timeoutId = window.setTimeout(() => { handleError(new Error('AI query timed out')) }, timeout) } let answer = '' eventSource.addEventListener('error', handleError) eventSource.addEventListener('message', (e: MessageEvent) => { try { if (e.data === '[DONE]') { if (timeoutId) { clearTimeout(timeoutId) } resolve(answer) return } const completionChunk: OpenAI.Chat.Completions.ChatCompletionChunk = JSON.parse(e.data) const [ { delta: { content }, }, ] = completionChunk.choices if (content) { answer += content } } catch (err) { handleError(err) } }) eventSource.stream() }) } const AiCommand = () => { const { isLoading, setIsLoading, search, setSearch } = useCommandMenu() const isMobile = isBrowser && window.screen.width <= 640 const { submit, reset, messages, isResponding, hasError } = useAiChat({ setIsLoading, }) /** * Disable autofocusing on the input on mobile * since it messes up with the dialog height and positioning * */ const inputRef = useAutoInputFocus(!isMobile) useHistoryKeys({ enable: !isResponding, messages: messages .filter(({ role }) => role === MessageRole.User) .map(({ content }) => content), setPrompt: setSearch, }) const handleSubmit = useCallback( (message: string) => { setSearch('') submit(message) }, [submit] ) const handleReset = useCallback(() => { setSearch('') reset() }, [reset]) useEffect(() => { if (search) { handleSubmit(search) } }, []) // Detect an IME composition (so that we can ignore Enter keypress) const [isImeComposing, setIsImeComposing] = useState(false) const showSubmitButton = !isLoading && !isResponding && search return (
e.stopPropagation()}>
{showSubmitButton ? ( ) : null} } onChange={(e) => { if (!isLoading || !isResponding) { setSearch(e.target.value) } }} onCompositionStart={() => setIsImeComposing(true)} onCompositionEnd={() => setIsImeComposing(false)} onKeyDown={(e) => { switch (e.key) { case 'Enter': if (!search || isLoading || isResponding || isImeComposing) { return } return handleSubmit(search) default: return } }} />
{messages.length > 0 && !hasError && }
{!hasError && messages.map((message, index) => { switch (message.role) { case MessageRole.User: return (
{message.content}
) case MessageRole.Assistant: return ( ) } })} {messages.length === 0 && !hasError && ( {questions.map((question) => { const key = question.replace(/\s+/g, '_') return ( { if (!search) { handleSubmit(question) } }} key={key} > {question} ) })} )} {hasError && (

Sorry, looks like Supabase AI is having a hard time!

Please try again in a bit.

)}
) } export default AiCommand