Files
supabase/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx
Saxon Fletcher 626eb30e77
Some checks failed
Generate Embeddings for Search / deploy (push) Has been cancelled
[Docs] Lint v2 (scheduled) / lint-all (push) Has been cancelled
[Docs] Update last-changed dates / deploy (push) Has been cancelled
Automatically label stale issues / build (push) Has been cancelled
Docs Production Smoke Tests / build (push) Has been cancelled
Publish to Image Registry / settings (push) Has been cancelled
Publish to Image Registry / release_x86 (push) Has been cancelled
Publish to Image Registry / release_arm (push) Has been cancelled
Publish to Image Registry / merge_manifest (push) Has been cancelled
Publish to Image Registry / publish (push) Has been cancelled
Update Mgmt Api Docs / update-docs (push) Has been cancelled
AI Unit Tests & Type Check / test (push) Has been cancelled
Assistant action orientated approach (#38806)
* update onboarding

* update model and fix part issue

* action orientated assistant

* fix tool

* lock

* remove unused filter

* fix tests

* fix again

* update package

* update container

* fix tests

* refactor(ai assistant): break out message markdown and profile picture

* wip

* refactor(ai assistant): break up message component

* refactor: break ai assistant message down into multiple files

* refactor: simplify ReportBlock state

* fix: styling of draggable report block header

When the drag handle is showing, it overlaps with the block header.
Decrease the opacity of the header so the handle can be seen and the two
can be distinguished.

* fix: minor tweaks to tool ui

* refactor: simplify DisplayBlockRenderer state

* fix: remove double deploy button in edge function block

When the confirm footer is shown, the deploy button on the top right should be
hidden (not just disabled) to avoid confusion.

* refactor, test: message sanitization by opt-in level

Refactor the message sanitization to have more type safety and be more testable.
Add tests to ensure:

- Message sanitization always runs on generate-v4
- Message sanitization correctly works by opt-in level

* Fix conflicts in pnpm lock

* Couple of nits and refactors

* Revert casing for report block snippet

* adjust sanitised prompt

* Fix tests

---------

Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2025-09-29 03:57:36 +00:00

575 lines
20 KiB
TypeScript

import type { UIMessage as MessageType } from '@ai-sdk/react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from 'ai'
import { AnimatePresence, motion } from 'framer-motion'
import { Eraser, Info, Pencil, X } from 'lucide-react'
import { useRouter } from 'next/router'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { LOCAL_STORAGE_KEYS, useFlag } 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 { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useHotKey } from 'hooks/ui/useHotKey'
import { prepareMessagesForAPI } from 'lib/ai/message-utils'
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 { Button, cn, KeyboardShortcut } from 'ui'
import { Admonition } from 'ui-patterns'
import { ButtonTooltip } from '../ButtonTooltip'
import { ErrorBoundary } from '../ErrorBoundary'
import type { SqlSnippet } from './AIAssistant.types'
import { onErrorChat } from './AIAssistant.utils'
import { AIAssistantHeader } from './AIAssistantHeader'
import { AIOnboarding } from './AIOnboarding'
import { AssistantChatForm } from './AssistantChatForm'
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from './elements/Conversation'
import { Message } from './Message'
interface AIAssistantProps {
initialMessages?: MessageType[] | undefined
className?: string
}
export const AIAssistant = ({ className }: AIAssistantProps) => {
const router = useRouter()
const { data: project } = useSelectedProjectQuery()
const { data: selectedOrganization, isLoading: isLoadingOrganization } =
useSelectedOrganizationQuery()
const { ref, id: entityId } = useParams()
const searchParams = useSearchParamsShallow()
useHotKey(() => cancelEdit(), 'Escape')
const disablePrompts = useFlag('disableAssistantPrompts')
const { snippets } = useSqlEditorV2StateSnapshot()
const snap = useAiAssistantStateSnapshot()
const [updatedOptInSinceMCP] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.AI_ASSISTANT_MCP_OPT_IN,
false
)
const inputRef = useRef<HTMLTextAreaElement>(null)
const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel()
const showMetadataWarning =
IS_PLATFORM &&
!!selectedOrganization &&
(aiOptInLevel === 'disabled' || aiOptInLevel === 'schema')
// Add a ref to store the last user message
const lastUserMessageRef = useRef<MessageType | null>(null)
// Keep latest selected organization to avoid stale values in useChat transport
const selectedOrganizationRef = useRef(selectedOrganization)
useEffect(() => {
selectedOrganizationRef.current = selectedOrganization
}, [selectedOrganization])
const [value, setValue] = useState<string>(snap.initialInput || '')
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
const [isResubmitting, setIsResubmitting] = 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 } = 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()
const updateMessage = useCallback(
(updatedMessage: MessageType) => {
snap.updateMessage(updatedMessage)
},
[snap]
)
// Handle completion of the assistant's response
const handleChatFinish = useCallback(
({ message }: { message: MessageType }) => {
if (lastUserMessageRef.current) {
snap.saveMessage([lastUserMessageRef.current, message])
lastUserMessageRef.current = null
} else {
updateMessage(message)
}
},
[snap, updateMessage]
)
// 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.
// Sanitize messages to remove Valtio proxy wrappers that can't be cloned
const sanitizedMessages = useMemo(() => {
if (!snap.activeChat?.messages) return undefined
return snap.activeChat.messages.map((msg: any) => {
// Convert proxy objects to plain objects
const plainMessage = JSON.parse(JSON.stringify(msg))
return plainMessage
})
}, [snap.activeChat?.messages])
const {
messages: chatMessages,
status: chatStatus,
error,
sendMessage,
setMessages,
addToolResult,
stop,
regenerate,
} = useChat({
id: snap.activeChatId,
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
messages: sanitizedMessages,
async onToolCall({ toolCall }) {
if (toolCall.dynamic) {
return
}
if (toolCall.toolName === 'rename_chat') {
const { newName } = toolCall.input as { newName: string }
if (snap.activeChatId && newName?.trim()) {
snap.renameChat(snap.activeChatId, newName.trim())
addToolResult({
tool: toolCall.toolName,
toolCallId: toolCall.toolCallId,
output: 'Chat renamed',
})
} else {
addToolResult({
tool: toolCall.toolName,
toolCallId: toolCall.toolCallId,
output: 'Failed to rename chat: Invalid chat or name',
})
}
}
},
transport: new DefaultChatTransport({
api: `${BASE_PATH}/api/ai/sql/generate-v4`,
async prepareSendMessagesRequest({ messages, ...options }) {
const cleanedMessages = prepareMessagesForAPI(messages)
const headerData = await constructHeaders()
const authorizationHeader = headerData.get('Authorization')
return {
...options,
body: {
messages: cleanedMessages,
aiOptInLevel,
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: currentSchema,
table: currentTable?.name,
chatName: currentChat,
orgSlug: selectedOrganizationRef.current?.slug,
},
headers: { Authorization: authorizationHeader ?? '' },
}
},
}),
onError: onErrorChat,
onFinish: handleChatFinish,
})
const isChatLoading = chatStatus === 'submitted' || chatStatus === 'streaming'
const deleteMessageFromHere = useCallback(
(messageId: string) => {
// Find the message index in current chatMessages
const messageIndex = chatMessages.findIndex((msg) => msg.id === messageId)
if (messageIndex === -1) return
if (isChatLoading) stop()
snap.deleteMessagesAfter(messageId, { includeSelf: true })
const updatedMessages = chatMessages.slice(0, messageIndex)
setMessages(updatedMessages)
},
[snap, setMessages, chatMessages, isChatLoading, stop]
)
const editMessage = useCallback(
(messageId: string) => {
const messageIndex = chatMessages.findIndex((msg) => msg.id === messageId)
if (messageIndex === -1) return
// Target message
const messageToEdit = chatMessages[messageIndex]
// Activate editing mode
setEditingMessageId(messageId)
const textContent =
messageToEdit.parts
?.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('') ?? ''
setValue(textContent)
setTimeout(() => {
if (inputRef.current) {
inputRef?.current?.focus()
// [Joshen] This is just to make the cursor go to the end of the text when focusing
const val = inputRef.current.value
inputRef.current.value = ''
inputRef.current.value = val
}
}, 100)
},
[chatMessages, setValue]
)
const cancelEdit = useCallback(() => {
setEditingMessageId(null)
setValue('')
}, [setValue])
const renderedMessages = useMemo(
() =>
chatMessages.map((message, index) => {
const isBeingEdited = editingMessageId === message.id
const isAfterEditedMessage = editingMessageId
? chatMessages.findIndex((m) => m.id === editingMessageId) < index
: false
const isLastMessage = index === chatMessages.length - 1
return (
<Message
id={message.id}
key={message.id}
message={message}
isLoading={chatStatus === 'submitted' || chatStatus === 'streaming'}
readOnly={message.role === 'user'}
addToolResult={addToolResult}
onDelete={deleteMessageFromHere}
onEdit={editMessage}
isAfterEditedMessage={isAfterEditedMessage}
isBeingEdited={isBeingEdited}
onCancelEdit={cancelEdit}
isLastMessage={isLastMessage}
/>
)
}),
[
chatMessages,
deleteMessageFromHere,
editMessage,
cancelEdit,
editingMessageId,
chatStatus,
addToolResult,
]
)
const hasMessages = chatMessages.length > 0
const sendMessageToAssistant = (finalContent: string) => {
if (editingMessageId) {
// Handling when the user is in edit mode
// delete the message(s) from the chat just like the delete button
setIsResubmitting(true)
deleteMessageFromHere(editingMessageId)
setEditingMessageId(null)
}
const payload = {
role: 'user',
createdAt: new Date(),
parts: [{ type: 'text', text: finalContent }],
id: uuidv4(),
} as MessageType
snap.clearSqlSnippets()
lastUserMessageRef.current = payload
sendMessage(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
setEditingMessageId(null)
}
useEffect(() => {
// Keep "Thinking" visible while stopping and resubmitting during edit
// Only clear once the new response actually starts streaming (or errors)
if (isResubmitting && (chatStatus === 'streaming' || !!error)) {
setIsResubmitting(false)
}
}, [isResubmitting, chatStatus, error])
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)}>
<AIAssistantHeader
isChatLoading={isChatLoading}
onNewChat={snap.newChat}
onCloseAssistant={snap.closeAssistant}
showMetadataWarning={showMetadataWarning}
updatedOptInSinceMCP={updatedOptInSinceMCP}
isHipaaProjectDisallowed={isHipaaProjectDisallowed as boolean}
aiOptInLevel={aiOptInLevel}
/>
{hasMessages ? (
<Conversation className={cn('flex-1')}>
<ConversationContent className="w-full px-7 py-8 mb-10">
{renderedMessages}
{error && (
<div className="border rounded-md px-2 py-2 flex items-center justify-between gap-x-4">
<div className="flex items-start gap-2 text-foreground-light text-sm">
<div>
<Info size={16} className="mt-0.5" />
</div>
<div>
<p>
Sorry, I'm having trouble responding right now. If the error persists while
retrying, you may try clearing the conversation's messages and try again.
</p>
</div>
</div>
<div className="flex items-center gap-x-2">
<Button
type="default"
size="tiny"
onClick={() => regenerate()}
className="text-xs"
>
Retry
</Button>
<ButtonTooltip
type="default"
size="tiny"
onClick={handleClearMessages}
className="w-7 h-7"
icon={<Eraser />}
tooltip={{ content: { side: 'bottom', text: 'Clear messages' } }}
/>
</div>
</div>
)}
{isChatLoading && (
<motion.span
animate={{ opacity: [1, 0] }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
className="inline-block w-1.5 h-4 bg-foreground-lighter mt-4"
/>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
) : (
<AIOnboarding
sqlSnippets={snap.sqlSnippets as SqlSnippet[] | undefined}
suggestions={
snap.suggestions as
| { title?: string; prompts?: { label: string; description: string }[] }
| undefined
}
onValueChange={(val) => setValue(val)}
onFocusInput={() => inputRef.current?.focus()}
/>
)}
<AnimatePresence>
{editingMessageId && (
<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 left-1/2 z-20 bottom-8 pointer-events-auto"
variants={{
hidden: { y: 5, opacity: 0 },
show: { y: 0, opacity: 1 },
}}
transition={{ duration: 0.1 }}
initial="hidden"
animate="show"
exit="hidden"
>
<div className="-translate-x-1/2 bg-alternative dark:bg-muted border rounded-md px-3 py-2 min-w-[180px] flex items-center justify-between gap-x-2">
<div className="flex items-center gap-x-2 text-sm text-foreground">
<Pencil size={14} />
<span>Editing message</span>
</div>
<ButtonTooltip
type="outline"
size="tiny"
icon={<X size={14} />}
onClick={cancelEdit}
className="w-6 h-6 p-0"
title="Cancel editing"
aria-label="Cancel editing"
tooltip={{
content: { side: 'top', text: <KeyboardShortcut keys={['Meta', 'Esc']} /> },
}}
/>
</div>
</motion.div>
</div>
</motion.div>
)}
</AnimatePresence>
<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}
isEditing={!!editingMessageId}
disabled={
!isApiKeySet ||
disablePrompts ||
isLoadingOrganization ||
(isChatLoading && !editingMessageId)
}
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)
}}
onStop={() => {
stop()
// to save partial responses from the AI
const lastMessage = chatMessages[chatMessages.length - 1]
if (lastMessage && lastMessage.role === 'assistant') {
handleChatFinish({ message: lastMessage })
}
}}
sqlSnippets={snap.sqlSnippets as SqlSnippet[] | undefined}
onRemoveSnippet={(index) => {
const newSnippets = [...(snap.sqlSnippets ?? [])]
newSnippets.splice(index, 1)
snap.setSqlSnippets(newSnippets)
}}
includeSnippetsInMessage={aiOptInLevel !== 'disabled'}
/>
</div>
</div>
</ErrorBoundary>
)
}