mirror of
https://github.com/supabase/supabase.git
synced 2026-10-16 06:39:09 +03:00
604 lines
22 KiB
TypeScript
604 lines
22 KiB
TypeScript
import type { Message as MessageType } from '@ai-sdk/react'
|
|
import { useChat } from '@ai-sdk/react'
|
|
import { AnimatePresence, motion } from 'framer-motion'
|
|
import { ArrowDown, FileText, Info, RefreshCw, Settings, X } from 'lucide-react'
|
|
import { useRouter } from 'next/router'
|
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
import { LOCAL_STORAGE_KEYS } from 'common'
|
|
import { useParams, useSearchParamsShallow } from 'common/hooks'
|
|
import { Markdown } from 'components/interfaces/Markdown'
|
|
import { useCheckOpenAIKeyQuery } from 'data/ai/check-api-key-query'
|
|
import { constructHeaders } from 'data/fetchers'
|
|
import { useTablesQuery } from 'data/tables/tables-query'
|
|
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
|
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
|
import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi'
|
|
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
|
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
|
|
import { useFlag } from 'hooks/ui/useFlag'
|
|
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
|
|
import uuidv4 from 'lib/uuid'
|
|
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
|
|
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
|
|
import { AiIconAnimation, Button, cn } from 'ui'
|
|
import { Admonition, GenericSkeletonLoader } from 'ui-patterns'
|
|
import { ButtonTooltip } from '../ButtonTooltip'
|
|
import { ErrorBoundary } from '../ErrorBoundary'
|
|
import { onErrorChat } from './AIAssistant.utils'
|
|
import { AIAssistantChatSelector } from './AIAssistantChatSelector'
|
|
import { AIOnboarding } from './AIOnboarding'
|
|
import { AIOptInModal } from './AIOptInModal'
|
|
import { AssistantChatForm } from './AssistantChatForm'
|
|
import { type SqlSnippet } from './AIAssistant.types'
|
|
import { Message } from './Message'
|
|
import { useAutoScroll } from './hooks'
|
|
import type { AssistantMessageType } from 'state/ai-assistant-state'
|
|
|
|
const MemoizedMessage = memo(
|
|
({
|
|
message,
|
|
isLoading,
|
|
onResults,
|
|
}: {
|
|
message: MessageType
|
|
isLoading: boolean
|
|
onResults: ({
|
|
messageId,
|
|
resultId,
|
|
results,
|
|
}: {
|
|
messageId: string
|
|
resultId?: string
|
|
results: any[]
|
|
}) => void
|
|
}) => {
|
|
return (
|
|
<Message
|
|
key={message.id}
|
|
id={message.id}
|
|
message={message}
|
|
readOnly={message.role === 'user'}
|
|
isLoading={isLoading}
|
|
onResults={onResults}
|
|
/>
|
|
)
|
|
}
|
|
)
|
|
|
|
MemoizedMessage.displayName = 'MemoizedMessage'
|
|
|
|
interface AIAssistantProps {
|
|
initialMessages?: MessageType[] | undefined
|
|
className?: string
|
|
}
|
|
|
|
export const AIAssistant = ({ className }: AIAssistantProps) => {
|
|
const router = useRouter()
|
|
const project = useSelectedProject()
|
|
const selectedOrganization = useSelectedOrganization()
|
|
const { ref, id: entityId } = useParams()
|
|
const searchParams = useSearchParamsShallow()
|
|
|
|
const newOrgAiOptIn = useFlag('newOrgAiOptIn')
|
|
const disablePrompts = useFlag('disableAssistantPrompts')
|
|
const useBedrockAssistant = useFlag('useBedrockAssistant')
|
|
const { snippets } = useSqlEditorV2StateSnapshot()
|
|
const snap = useAiAssistantStateSnapshot()
|
|
|
|
const [updatedOptInSinceMCP] = useLocalStorageQuery(
|
|
LOCAL_STORAGE_KEYS.AI_ASSISTANT_MCP_OPT_IN,
|
|
false
|
|
)
|
|
|
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
|
const { ref: scrollContainerRef, isSticky, scrollToEnd } = useAutoScroll()
|
|
|
|
const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel()
|
|
const showMetadataWarning =
|
|
IS_PLATFORM &&
|
|
!!selectedOrganization &&
|
|
((!useBedrockAssistant && aiOptInLevel === 'disabled') ||
|
|
(useBedrockAssistant && (aiOptInLevel === 'disabled' || aiOptInLevel === 'schema')))
|
|
|
|
// Add a ref to store the last user message
|
|
const lastUserMessageRef = useRef<MessageType | null>(null)
|
|
|
|
const [value, setValue] = useState<string>(snap.initialInput || '')
|
|
const [isConfirmOptInModalOpen, setIsConfirmOptInModalOpen] = useState(false)
|
|
|
|
const { data: check, isSuccess } = useCheckOpenAIKeyQuery()
|
|
const isApiKeySet = IS_PLATFORM || !!check?.hasKey
|
|
|
|
const isInSQLEditor = router.pathname.includes('/sql/[id]')
|
|
const snippet = snippets[entityId ?? '']
|
|
const snippetContent = snippet?.snippet?.content?.sql
|
|
|
|
const { data: tables, isLoading: isLoadingTables } = useTablesQuery(
|
|
{
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString,
|
|
schema: 'public',
|
|
},
|
|
{ enabled: isApiKeySet }
|
|
)
|
|
|
|
const currentTable = tables?.find((t) => t.id.toString() === entityId)
|
|
const currentSchema = searchParams?.get('schema') ?? 'public'
|
|
const currentChat = snap.activeChat?.name
|
|
|
|
const { mutate: sendEvent } = useSendEventMutation()
|
|
|
|
// Handle completion of the assistant's response
|
|
const handleChatFinish = useCallback(
|
|
(message: MessageType, options: { finishReason: string }) => {
|
|
if (lastUserMessageRef.current) {
|
|
snap.saveMessage([lastUserMessageRef.current, message])
|
|
lastUserMessageRef.current = null
|
|
} else {
|
|
snap.saveMessage(message)
|
|
}
|
|
},
|
|
[snap]
|
|
)
|
|
|
|
// TODO(refactor): This useChat hook should be moved down into each chat session.
|
|
// That way we won't have to disable switching chats while the chat is loading,
|
|
// and don't run the risk of messages getting mixed up between chats.
|
|
const {
|
|
messages: chatMessages,
|
|
isLoading: isChatLoading,
|
|
append,
|
|
setMessages,
|
|
error,
|
|
reload,
|
|
} = useChat({
|
|
id: snap.activeChatId,
|
|
api: useBedrockAssistant
|
|
? `${BASE_PATH}/api/ai/sql/generate-v4`
|
|
: `${BASE_PATH}/api/ai/sql/generate-v3`,
|
|
maxSteps: 5,
|
|
// [Alaister] typecast is needed here because valtio returns readonly arrays
|
|
// and useChat expects a mutable array
|
|
initialMessages: snap.activeChat?.messages as unknown as MessageType[] | undefined,
|
|
async onToolCall({ toolCall }) {
|
|
if (toolCall.toolName === 'rename_chat') {
|
|
const { newName } = toolCall.args as { newName: string }
|
|
if (snap.activeChatId && newName?.trim()) {
|
|
snap.renameChat(snap.activeChatId, newName.trim())
|
|
return `Chat renamed to "${newName.trim()}"`
|
|
}
|
|
return 'Failed to rename chat: Invalid chat or name'
|
|
}
|
|
},
|
|
experimental_prepareRequestBody: ({ messages }) => {
|
|
// [Joshen] Specifically limiting the chat history that get's sent to reduce the
|
|
// size of the context that goes into the model. This should always be an odd number
|
|
// as much as possible so that the first message is always the user's
|
|
const MAX_CHAT_HISTORY = 5
|
|
|
|
const slicedMessages = messages.slice(-MAX_CHAT_HISTORY)
|
|
|
|
// Filter out results from messages before sending to the model
|
|
const cleanedMessages = slicedMessages.map((message) => {
|
|
const cleanedMessage = { ...message } as AssistantMessageType
|
|
if (message.role === 'assistant' && (message as AssistantMessageType).results) {
|
|
delete cleanedMessage.results
|
|
}
|
|
return cleanedMessage
|
|
})
|
|
|
|
return JSON.stringify({
|
|
messages: cleanedMessages,
|
|
aiOptInLevel,
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString,
|
|
schema: currentSchema,
|
|
table: currentTable?.name,
|
|
chatName: currentChat,
|
|
includeSchemaMetadata: !useBedrockAssistant
|
|
? !IS_PLATFORM || aiOptInLevel !== 'disabled'
|
|
: undefined,
|
|
orgSlug: selectedOrganization?.slug,
|
|
})
|
|
},
|
|
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const headers = await constructHeaders()
|
|
const existingHeaders = new Headers(init?.headers)
|
|
for (const [key, value] of headers.entries()) {
|
|
existingHeaders.set(key, value)
|
|
}
|
|
return fetch(input, { ...init, headers: existingHeaders })
|
|
},
|
|
onError: onErrorChat,
|
|
onFinish: handleChatFinish,
|
|
})
|
|
|
|
const updateMessage = useCallback(
|
|
({
|
|
messageId,
|
|
resultId,
|
|
results,
|
|
}: {
|
|
messageId: string
|
|
resultId?: string
|
|
results: any[]
|
|
}) => {
|
|
snap.updateMessage({ id: messageId, resultId, results })
|
|
},
|
|
[snap]
|
|
)
|
|
|
|
const renderedMessages = useMemo(
|
|
() =>
|
|
chatMessages.map((message) => {
|
|
return (
|
|
<MemoizedMessage
|
|
key={message.id}
|
|
message={message}
|
|
isLoading={isChatLoading && message.id === chatMessages[chatMessages.length - 1].id}
|
|
onResults={updateMessage}
|
|
/>
|
|
)
|
|
}),
|
|
[chatMessages, isChatLoading]
|
|
)
|
|
|
|
const hasMessages = chatMessages.length > 0
|
|
const isShowingOnboarding = !hasMessages && isApiKeySet
|
|
|
|
const sendMessageToAssistant = (finalContent: string) => {
|
|
const payload = {
|
|
role: 'user',
|
|
createdAt: new Date(),
|
|
content: finalContent,
|
|
id: uuidv4(),
|
|
} as MessageType
|
|
|
|
snap.clearSqlSnippets()
|
|
lastUserMessageRef.current = payload
|
|
append(payload)
|
|
setValue('')
|
|
|
|
if (finalContent.includes('Help me to debug')) {
|
|
sendEvent({
|
|
action: 'assistant_debug_submitted',
|
|
groups: {
|
|
project: ref ?? 'Unknown',
|
|
organization: selectedOrganization?.slug ?? 'Unknown',
|
|
},
|
|
})
|
|
} else {
|
|
sendEvent({
|
|
action: 'assistant_prompt_submitted',
|
|
groups: {
|
|
project: ref ?? 'Unknown',
|
|
organization: selectedOrganization?.slug ?? 'Unknown',
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleClearMessages = () => {
|
|
snap.clearMessages()
|
|
setMessages([])
|
|
lastUserMessageRef.current = null
|
|
}
|
|
|
|
// Update scroll behavior for new messages
|
|
useEffect(() => {
|
|
if (!isChatLoading) {
|
|
if (inputRef.current) inputRef.current.focus()
|
|
}
|
|
|
|
if (isSticky) {
|
|
setTimeout(scrollToEnd, 0)
|
|
}
|
|
}, [isChatLoading, isSticky, scrollToEnd])
|
|
|
|
useEffect(() => {
|
|
setValue(snap.initialInput || '')
|
|
if (inputRef.current && snap.initialInput) {
|
|
inputRef.current.focus()
|
|
inputRef.current.setSelectionRange(snap.initialInput.length, snap.initialInput.length)
|
|
}
|
|
}, [snap.initialInput])
|
|
|
|
useEffect(() => {
|
|
if (snap.open && isInSQLEditor && !!snippetContent) {
|
|
snap.setSqlSnippets([{ label: 'Current Query', content: snippetContent }])
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [snap.open, isInSQLEditor, snippetContent])
|
|
|
|
return (
|
|
<ErrorBoundary
|
|
message="Something went wrong with the AI Assistant"
|
|
sentryContext={{
|
|
component: 'AIAssistant',
|
|
feature: 'AI Assistant Panel',
|
|
projectRef: project?.ref,
|
|
organizationSlug: selectedOrganization?.slug,
|
|
}}
|
|
actions={[
|
|
{
|
|
label: 'Clear messages and refresh',
|
|
onClick: () => {
|
|
handleClearMessages()
|
|
window.location.reload()
|
|
},
|
|
},
|
|
]}
|
|
>
|
|
<div className={cn('flex flex-col h-full', className)}>
|
|
<div ref={scrollContainerRef} className={cn('flex-grow overflow-auto flex flex-col')}>
|
|
<div className="z-30 sticky top-0">
|
|
<div className="border-b border-b-muted flex items-center bg gap-x-4 px-3 h-[46px]">
|
|
<div className="text-sm flex-1 flex items-center">
|
|
<AiIconAnimation size={20} allowHoverEffect={false} />
|
|
<span className="text-border-stronger dark:text-border-strong ml-3">
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
width="16"
|
|
height="16"
|
|
stroke="currentColor"
|
|
strokeWidth="1"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
fill="none"
|
|
shapeRendering="geometricPrecision"
|
|
>
|
|
<path d="M16 3.549L7.12 20.600"></path>
|
|
</svg>
|
|
</span>
|
|
<AIAssistantChatSelector disabled={isChatLoading} />
|
|
</div>
|
|
<div className="flex items-center gap-x-4">
|
|
<div className="flex items-center">
|
|
<ButtonTooltip
|
|
type="text"
|
|
size="tiny"
|
|
icon={<Settings strokeWidth={1.5} size={14} />}
|
|
onClick={() => setIsConfirmOptInModalOpen(true)}
|
|
className="h-7 w-7 p-0"
|
|
disabled={isChatLoading}
|
|
tooltip={{
|
|
content: {
|
|
side: 'bottom',
|
|
text: 'Permission settings',
|
|
},
|
|
}}
|
|
/>
|
|
<ButtonTooltip
|
|
type="text"
|
|
size="tiny"
|
|
icon={<RefreshCw strokeWidth={1.5} size={14} />}
|
|
onClick={handleClearMessages}
|
|
className="h-7 w-7 p-0"
|
|
disabled={isChatLoading}
|
|
tooltip={{ content: { side: 'bottom', text: 'Clear messages' } }}
|
|
/>
|
|
<ButtonTooltip
|
|
type="text"
|
|
className="w-7 h-7"
|
|
onClick={snap.closeAssistant}
|
|
icon={<X strokeWidth={1.5} size={14} />}
|
|
tooltip={{ content: { side: 'bottom', text: 'Close assistant' } }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{showMetadataWarning && (
|
|
<Admonition
|
|
type="default"
|
|
title={
|
|
newOrgAiOptIn && !updatedOptInSinceMCP
|
|
? 'The Assistant has just been updated to help you better!'
|
|
: isHipaaProjectDisallowed
|
|
? 'Project metadata is not shared due to HIPAA'
|
|
: aiOptInLevel === 'disabled'
|
|
? 'Project metadata is currently not shared'
|
|
: 'Limited metadata is shared to the Assistant'
|
|
}
|
|
description={
|
|
newOrgAiOptIn && !updatedOptInSinceMCP
|
|
? 'You may now opt-in to share schema metadata and even logs for better results'
|
|
: isHipaaProjectDisallowed
|
|
? 'Your organization has the HIPAA addon and will not send project metadata with your prompts for projects marked as HIPAA.'
|
|
: aiOptInLevel === 'disabled'
|
|
? 'The Assistant can provide better answers if you opt-in to share schema metadata.'
|
|
: aiOptInLevel === 'schema'
|
|
? 'Sharing query data in addition to schema can further improve responses. Update AI settings to enable this.'
|
|
: ''
|
|
}
|
|
className="border-0 border-b rounded-none bg-background mb-0"
|
|
>
|
|
{!isHipaaProjectDisallowed && (
|
|
<Button
|
|
type="default"
|
|
className="w-fit mt-4"
|
|
onClick={() => setIsConfirmOptInModalOpen(true)}
|
|
>
|
|
Update AI settings
|
|
</Button>
|
|
)}
|
|
</Admonition>
|
|
)}
|
|
</div>
|
|
{hasMessages ? (
|
|
<div className="w-full px-7 py-8 space-y-6">
|
|
{renderedMessages}
|
|
{error && (
|
|
<div className="border rounded-md pl-2 pr-1 py-1 flex items-center justify-between">
|
|
<div className="flex items-center gap-2 text-foreground-light text-sm">
|
|
<Info size={16} />
|
|
<p>Sorry, I'm having trouble responding right now</p>
|
|
</div>
|
|
<Button type="text" size="tiny" onClick={() => reload()} className="text-xs">
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<AnimatePresence>
|
|
{isChatLoading && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -10 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="flex gap-4 w-auto overflow-hidden"
|
|
>
|
|
<div className="text-foreground-lighter text-sm flex gap-1.5 items-center">
|
|
<span>Thinking</span>
|
|
<div className="flex gap-1">
|
|
<motion.span
|
|
animate={{ opacity: [0, 1, 0] }}
|
|
transition={{ duration: 1.5, repeat: Infinity, delay: 0 }}
|
|
>
|
|
.
|
|
</motion.span>
|
|
<motion.span
|
|
animate={{ opacity: [0, 1, 0] }}
|
|
transition={{ duration: 1.5, repeat: Infinity, delay: 0.3 }}
|
|
>
|
|
.
|
|
</motion.span>
|
|
<motion.span
|
|
animate={{ opacity: [0, 1, 0] }}
|
|
transition={{ duration: 1.5, repeat: Infinity, delay: 0.6 }}
|
|
>
|
|
.
|
|
</motion.span>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
) : isLoadingTables && isApiKeySet ? (
|
|
<div className="w-full h-full flex-1 flex flex-col justify-center items-center p-5">
|
|
<GenericSkeletonLoader className="w-4/5 flex flex-col items-center" />
|
|
</div>
|
|
) : isShowingOnboarding ? (
|
|
<AIOnboarding
|
|
onMessageSend={sendMessageToAssistant}
|
|
value={value}
|
|
onValueChange={setValue}
|
|
sqlSnippets={snap.sqlSnippets as SqlSnippet[] | undefined}
|
|
onRemoveSnippet={(index) => {
|
|
const newSnippets = [...(snap.sqlSnippets ?? [])]
|
|
newSnippets.splice(index, 1)
|
|
snap.setSqlSnippets(newSnippets)
|
|
}}
|
|
suggestions={
|
|
snap.suggestions as
|
|
| { title?: string; prompts?: { label: string; description: string }[] }
|
|
| undefined
|
|
}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{!isSticky && (
|
|
<>
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="pointer-events-none z-10 -mt-24"
|
|
>
|
|
<div className="h-24 w-full bg-gradient-to-t from-background to-transparent relative">
|
|
<motion.div
|
|
className="absolute z-20 bottom-8 left-1/2 -translate-x-1/2 pointer-events-auto"
|
|
variants={{
|
|
hidden: { y: 5, opacity: 0 },
|
|
show: { y: 0, opacity: 1 },
|
|
}}
|
|
transition={{ duration: 0.1 }}
|
|
initial="hidden"
|
|
animate="show"
|
|
exit="hidden"
|
|
>
|
|
<Button
|
|
type="default"
|
|
className="rounded-full w-8 h-8 p-1.5"
|
|
onClick={() => {
|
|
scrollToEnd()
|
|
if (inputRef.current) inputRef.current.focus()
|
|
}}
|
|
>
|
|
<ArrowDown size={16} />
|
|
</Button>
|
|
</motion.div>
|
|
</div>
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{!isShowingOnboarding && (
|
|
<div className="px-3 pb-3 z-20 relative">
|
|
{disablePrompts && (
|
|
<Admonition
|
|
showIcon={false}
|
|
type="default"
|
|
title="Assistant has been temporarily disabled"
|
|
description="We're currently looking into getting it back online"
|
|
/>
|
|
)}
|
|
|
|
{isSuccess && !isApiKeySet && (
|
|
<Admonition
|
|
type="default"
|
|
title="OpenAI API key not set"
|
|
description={
|
|
<Markdown
|
|
content={
|
|
'Add your `OPENAI_API_KEY` to your environment variables to use the AI Assistant.'
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
<AssistantChatForm
|
|
textAreaRef={inputRef}
|
|
className={cn(
|
|
'z-20 [&>form>textarea]:text-base [&>form>textarea]:md:text-sm [&>form>textarea]:border-1 [&>form>textarea]:rounded-md [&>form>textarea]:!outline-none [&>form>textarea]:!ring-offset-0 [&>form>textarea]:!ring-0'
|
|
)}
|
|
loading={isChatLoading}
|
|
disabled={!isApiKeySet || disablePrompts || isChatLoading}
|
|
placeholder={
|
|
hasMessages
|
|
? 'Ask a follow up question...'
|
|
: (snap.sqlSnippets ?? [])?.length > 0
|
|
? 'Ask a question or make a change...'
|
|
: 'Chat to Postgres...'
|
|
}
|
|
value={value}
|
|
onValueChange={(e) => setValue(e.target.value)}
|
|
onSubmit={(finalMessage) => {
|
|
sendMessageToAssistant(finalMessage)
|
|
scrollToEnd()
|
|
}}
|
|
sqlSnippets={snap.sqlSnippets as SqlSnippet[] | undefined}
|
|
onRemoveSnippet={(index) => {
|
|
const newSnippets = [...(snap.sqlSnippets ?? [])]
|
|
newSnippets.splice(index, 1)
|
|
snap.setSqlSnippets(newSnippets)
|
|
}}
|
|
includeSnippetsInMessage={aiOptInLevel !== 'disabled'}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<AIOptInModal
|
|
visible={isConfirmOptInModalOpen}
|
|
onCancel={() => setIsConfirmOptInModalOpen(false)}
|
|
/>
|
|
</ErrorBoundary>
|
|
)
|
|
}
|