Files
supabase/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx
Joshen Lim 225d461381 Hook up RLS to new assistant if feature flag is enabled (#30357)
* Hook up RLS to new assistant if feature flag is enabled

* Reset conversation if opening a different entity

* Add prompt to use alter policy if policy definition is provided

* Remove console log
2024-11-08 18:03:30 +08:00

781 lines
31 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { AnimatePresence, motion } from 'framer-motion'
import { last } from 'lodash'
import { FileText, MessageCircleMore, Plus, WandSparkles } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import type { Message as MessageType } from 'ai/react'
import { useChat } from 'ai/react'
import { useParams } from 'common'
import { Markdown } from 'components/interfaces/Markdown'
import OptInToOpenAIToggle from 'components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle'
import { MessageWithDebug } from 'components/interfaces/SQLEditor/AiAssistantPanel'
import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types'
import { useCheckOpenAIKeyQuery } from 'data/ai/check-api-key-query'
import { useSqlDebugMutation } from 'data/ai/sql-debug-mutation'
import { useEntityDefinitionQuery } from 'data/database/entity-definition-query'
import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query'
import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi'
import { useSchemasForAi } from 'hooks/misc/useSchemasForAi'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useFlag } from 'hooks/ui/useFlag'
import {
BASE_PATH,
IS_PLATFORM,
OPT_IN_TAGS,
TELEMETRY_ACTIONS,
TELEMETRY_CATEGORIES,
TELEMETRY_LABELS,
} from 'lib/constants'
import { useAppStateSnapshot } from 'state/app-state'
import {
AiIconAnimation,
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
SheetHeader,
SheetSection,
Tooltip_Shadcn_,
TooltipContent_Shadcn_,
TooltipTrigger_Shadcn_,
} from 'ui'
import { Admonition, AssistantChatForm } from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { DocsButton } from '../DocsButton'
import { ASSISTANT_SUPPORT_ENTITIES } from './AiAssistant.constants'
import { SupportedAssistantEntities, SupportedAssistantQuickPromptTypes } from './AIAssistant.types'
import { generatePrompt, retrieveDocsUrl } from './AIAssistant.utils'
import { ContextBadge } from './ContextBadge'
import { EntitiesDropdownMenu } from './EntitiesDropdownMenu'
import { Message } from './Message'
import { SchemasDropdownMenu } from './SchemasDropdownMenu'
import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query'
const ANIMATION_DURATION = 0.3
interface AIAssistantProps {
id: string
className?: string
debugThread: MessageWithDebug[]
onDiff: ({ id, diffType, sql }: { id: string; diffType: DiffType; sql: string }) => void
onResetConversation: () => void
}
// [Joshen] For some reason I'm having issues working with dropdown menu and scroll area
export const AIAssistant = ({
id,
className,
debugThread,
onDiff,
onResetConversation,
}: AIAssistantProps) => {
const { ref } = useParams()
const project = useSelectedProject()
const isOptedInToAI = useOrgOptedIntoAi()
const selectedOrganization = useSelectedOrganization()
const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM
const disablePrompts = useFlag('disableAssistantPrompts')
const { aiAssistantPanel, setAiAssistantPanel } = useAppStateSnapshot()
const { editor, entity, tables: selectedTables } = aiAssistantPanel
const inputRef = useRef<HTMLTextAreaElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const [value, setValue] = useState<string>('')
const [selectedDatabaseEntity, setSelectedDatabaseEntity] = useState<
SupportedAssistantEntities | ''
>('')
const [selectedSchemas, setSelectedSchemas] = useSchemasForAi(project?.ref!)
const [contextHistory, setContextHistory] = useState<{
[key: string]: { entity: string; schemas: string[]; tables: string[] }
}>({})
// [Joshen] Mainly for error handling on useChat - cause last sent messages will be voided
const [assistantError, setAssistantError] = useState<string>()
const [lastSentMessage, setLastSentMessage] = useState<MessageType>()
const [isConfirmOptInModalOpen, setIsConfirmOptInModalOpen] = useState(false)
const docsUrl = retrieveDocsUrl(selectedDatabaseEntity as SupportedAssistantEntities)
const entityContext = ASSISTANT_SUPPORT_ENTITIES.find((x) => x.id === selectedDatabaseEntity)
const noContextAdded =
selectedDatabaseEntity.length === 0 &&
selectedSchemas.length === 0 &&
selectedTables.length === 0
const { data: check } = useCheckOpenAIKeyQuery()
const isApiKeySet = IS_PLATFORM || !!check?.hasKey
const { data: policies } = useDatabasePoliciesQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
},
{ enabled: editor === 'rls-policies' }
)
const existingPolicies = (policies ?? [])
.filter((policy) =>
selectedTables.some((x) => policy.schema === x.schema && policy.table === x.name)
)
.sort((a, b) => a.name.localeCompare(b.name))
const { data: existingDefinition } = useEntityDefinitionQuery({
id: entity?.id,
type: editor,
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data } = useEntityDefinitionsQuery(
{
schemas: selectedSchemas,
projectRef: project?.ref,
connectionString: project?.connectionString,
},
{ enabled: includeSchemaMetadata }
)
const tableDefinitions =
selectedTables.length === 0
? data?.map((def) => def.sql.trim()) ?? []
: data
?.filter((def) => {
return selectedTables.some((table) => {
return def.sql.startsWith(`CREATE TABLE ${table.schema}.${table.name}`)
})
})
.map((def) => def.sql.trim()) ?? []
const entityDefinitions = includeSchemaMetadata ? tableDefinitions : undefined
const { mutate: sendEvent } = useSendEventMutation()
const sendTelemetryEvent = (action: string) => {
sendEvent({
action,
category: TELEMETRY_CATEGORIES.AI_ASSISTANT,
label: TELEMETRY_LABELS.QUICK_SQL_EDITOR,
})
}
const {
messages: chatMessages,
isLoading: isChatLoading,
append,
} = useChat({
id,
api: `${BASE_PATH}/api/ai/sql/generate-v2`,
body: {
entityDefinitions,
context: selectedDatabaseEntity,
existingSql: existingDefinition,
existingPolicies,
},
onError: (error) => setAssistantError(JSON.parse(error.message).error),
})
const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations')
const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation()
const { isLoading: isDebugSqlLoading } = useSqlDebugMutation()
const isLoading = isChatLoading || isDebugSqlLoading
const messages = useMemo(() => {
const merged = [
...debugThread,
...chatMessages.map((m) => ({ ...m, isDebug: false })),
...(assistantError !== undefined && lastSentMessage !== undefined ? [lastSentMessage] : []),
]
return merged.sort(
(a, b) =>
(a.createdAt?.getTime() ?? 0) - (b.createdAt?.getTime() ?? 0) ||
a.role.localeCompare(b.role)
)
}, [chatMessages, debugThread, assistantError, lastSentMessage])
const hasMessages = messages.length > 0
const lastMessage = last(messages)
const pendingChatReply = isLoading && lastMessage?.role === 'user'
const pendingDebugReply =
(lastMessage as MessageWithDebug)?.isDebug &&
lastMessage?.role === 'assistant' &&
lastMessage.content === 'Thinking...'
const pendingReply = pendingChatReply || pendingDebugReply
const sendMessageToAssistant = (content: string) => {
const payload = { role: 'user', createdAt: new Date(), content } as MessageType
append(payload)
setAssistantError(undefined)
setLastSentMessage(payload)
sendTelemetryEvent(TELEMETRY_ACTIONS.PROMPT_SUBMITTED)
}
const toggleSchema = (schema: string) => {
if (selectedSchemas.includes(schema)) {
setSelectedSchemas(selectedSchemas.filter((s) => s !== schema))
} else {
const newSelectedSchemas = [...selectedSchemas, schema].sort((a, b) => a.localeCompare(b))
setSelectedSchemas(newSelectedSchemas)
sendTelemetryEvent(TELEMETRY_ACTIONS.SCHEMA_CONTEXT_ADDED)
}
}
const toggleEntity = ({ schema, name }: { schema: string; name: string }) => {
const isExisting = selectedTables.find((x) => x.schema === schema && x.name === name)
if (isExisting) {
setAiAssistantPanel({
tables: selectedTables.filter((x) => !(x.schema === schema && x.name === name)),
})
} else {
const newselectedTables = [...selectedTables, { schema, name }].sort(
(a, b) => a.schema.localeCompare(b.schema) || a.name.localeCompare(b.name)
)
setAiAssistantPanel({ tables: newselectedTables })
sendTelemetryEvent(TELEMETRY_ACTIONS.TABLE_CONTEXT_ADDED)
}
}
const onClickQuickPrompt = (type: SupportedAssistantQuickPromptTypes) => {
const prompt = generatePrompt({
type,
context: selectedDatabaseEntity as any,
schemas: selectedSchemas,
tables: selectedTables,
})
if (prompt) {
setValue(prompt)
sendMessageToAssistant(prompt)
sendTelemetryEvent(TELEMETRY_ACTIONS.QUICK_PROMPT_SELECTED(type))
}
}
const confirmOptInToShareSchemaData = async () => {
if (!canUpdateOrganization) {
return toast.error('You do not have the required permissions to update this organization')
}
if (!selectedOrganization?.slug) return console.error('Organization slug is required')
const existingOptInTags = selectedOrganization?.opt_in_tags ?? []
const updatedOptInTags = existingOptInTags.includes(OPT_IN_TAGS.AI_SQL)
? existingOptInTags
: [...existingOptInTags, OPT_IN_TAGS.AI_SQL]
updateOrganization(
{ slug: selectedOrganization?.slug, opt_in_tags: updatedOptInTags },
{
onSuccess: () => {
toast.success('Successfully opted-in')
setIsConfirmOptInModalOpen(false)
},
}
)
}
useEffect(() => {
if (editor) {
const mode = ASSISTANT_SUPPORT_ENTITIES.find((x) => x.id === editor)
if (mode) setSelectedDatabaseEntity(mode.id)
}
}, [editor])
useEffect(() => {
if (!isLoading) {
setValue('')
if (inputRef.current) inputRef.current.focus()
}
setTimeout(
() => {
if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'smooth' })
},
isLoading ? 100 : 500
)
}, [isLoading])
useEffect(() => {
if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'smooth' })
}, [messages])
useEffect(() => {
const lastMessage = last(messages)
if (lastMessage?.role === 'user') {
const entity = entityContext?.label ?? selectedDatabaseEntity
setContextHistory({
...contextHistory,
[lastMessage.id]: {
entity,
schemas: selectedSchemas,
tables: selectedTables.map((x) =>
selectedSchemas.length > 1 ? `${x.schema}.${x.name}` : x.name
),
},
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages.length])
return (
<>
<div className={cn('flex flex-col', className)}>
<SheetHeader className="flex items-center justify-between py-3">
<div className="flex items-center gap-x-2">
<AiIconAnimation
allowHoverEffect
className="[&>div>div]:border-black dark:[&>div>div]:border-white"
/>
<p>Assistant</p>
</div>
<AnimatePresence>
{hasMessages && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 100 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<Button type="default" disabled={isLoading} onClick={() => onResetConversation()}>
Reset conversation
</Button>
</motion.div>
)}
</AnimatePresence>
</SheetHeader>
<SheetSection
className={cn(
'flex-grow flex flex-col items-center !p-0',
hasMessages ? 'justify-between h-[90%]' : 'justify-center h-full'
)}
>
{hasMessages && (
<motion.div
initial={{ height: 0 }}
transition={{ duration: ANIMATION_DURATION }}
className="w-full overflow-auto flex-1"
>
{messages.map((m, index) => {
const isFirstUserMessage =
m.role === 'user' && messages.slice(0, index).every((msg) => msg.role !== 'user')
return (
<Message
key={`message-${m.id}`}
name={m.name}
role={m.role}
content={m.content}
createdAt={new Date(m.createdAt || new Date()).getTime()}
isDebug={(m as MessageWithDebug).isDebug}
context={contextHistory[m.id]}
onDiff={(diffType, sql) => onDiff({ id: m.id, diffType, sql })}
>
{isFirstUserMessage && !includeSchemaMetadata && (
<Admonition
type="default"
title="Project metadata is not shared with the Assistant"
description="The Assistant can improve the quality of the answers if you send project metadata along with your prompts. Opt into sending anonymous data to share your schema and table definitions."
>
<Button
type="default"
className="w-fit"
onClick={() => setIsConfirmOptInModalOpen(true)}
>
Update AI settings
</Button>
</Admonition>
)}
{isFirstUserMessage &&
includeSchemaMetadata &&
selectedSchemas.length === 0 && (
<Admonition
type="default"
title="We recommend including schemas for better answers from the Assistant"
/>
)}
</Message>
)
})}
{assistantError !== undefined && (
<Message
key="assistant-error"
role="assistant"
variant="warning"
createdAt={new Date().getTime()}
content={`Sorry! We ran into the following error while trying to respond to your message: ${assistantError}. Please try again shortly or reach out to us via support if the issue still persists!`}
>
<Button asChild type="default" className="w-min">
<Link
target="_blank"
rel="noreferrer"
href={`/support/new?ref=${ref}&category=dashboard_bug&subject=Error%20with%20assistant%20response&message=Assistant%20error:%20${assistantError}`}
>
Contact support
</Link>
</Button>
</Message>
)}
{!isLoading && !pendingReply && assistantError === undefined && (
<p className="px-content text-xs text-right text-foreground-lighter pb-2">
Please verify all responses as the Assistant can make mistakes
</p>
)}
{pendingChatReply && (
<Message key="thinking" role="assistant" content="Thinking..." />
)}
<div ref={bottomRef} className="h-1" />
</motion.div>
)}
<div
className={cn(
'w-full px-content py-content',
hasMessages ? 'sticky flex-0 border-t' : 'flex flex-col gap-y-4'
)}
>
<AnimatePresence>
{!hasMessages && (
<motion.div
exit={{ opacity: 0 }}
initial={{ opacity: 100 }}
transition={{ duration: ANIMATION_DURATION }}
>
<p className="text-center text-base text-foreground-light">
{entity !== undefined && !!entityContext ? (
<>
Need help with updating this{' '}
<span className="text-foreground">{entityContext.name}</span>
</>
) : (
<>
How can I help you
{!!entityContext ? (
<>
{' '}
with{' '}
<span className="text-foreground">
{entityContext.id === 'rls-policies'
? entityContext.label
: `Database ${entityContext.label}`}
</span>
</>
) : (
' today'
)}
</>
)}
?
</p>
</motion.div>
)}
</AnimatePresence>
<div className="flex flex-col gap-y-2">
{disablePrompts && (
<Admonition
type="default"
title="Assistant has been temporarily disabled"
description="Give us a moment while we work on bringing the Assistant back online"
/>
)}
{!isApiKeySet && (
<Admonition
type="warning"
title="OpenAI API key not set"
description={
<Markdown
content={
'Add your `OPENAI_API_KEY` to `./docker/.env` to use the AI Assistant.'
}
/>
}
/>
)}
<div className="w-full border rounded">
<div className="py-2 px-3 border-b flex gap-2 flex-wrap">
<DropdownMenu>
<DropdownMenuTrigger>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<Button
type="default"
icon={<Plus />}
className={cn(noContextAdded ? '' : 'px-1.5 !space-x-0')}
>
<span className={noContextAdded ? '' : 'sr-only'}>Add context</span>
</Button>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side={hasMessages ? 'top' : 'bottom'}>
Add context for the assistant
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[310px]">
<DropdownMenuLabel>
Improve the output quality of the assistant by giving it context about what
you need help with
</DropdownMenuLabel>
<DropdownMenuSeparator />
{editor === null && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div className="flex flex-col gap-y-1">
<p>Database Entity</p>
<p className="text-foreground-lighter">
Inform about what you're working with
</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={selectedDatabaseEntity}
onValueChange={(value) =>
setSelectedDatabaseEntity(value as SupportedAssistantEntities)
}
>
{ASSISTANT_SUPPORT_ENTITIES.map((x) => (
<DropdownMenuRadioItem key={x.id} value={x.id}>
{x.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{includeSchemaMetadata && (
<>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-x-2">
<div className="flex flex-col gap-y-1">
<p>Schemas</p>
<p className="text-foreground-lighter">
Share table definitions in the selected schemas
</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="p-0 w-52">
<SchemasDropdownMenu
selectedSchemas={selectedSchemas}
onToggleSchema={toggleSchema}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
{selectedSchemas.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-x-2">
<div className="flex flex-col gap-y-1">
<p>Tables</p>
<p className="text-foreground-lighter">
Select specific tables to share definitions for
</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="p-0 w-52">
<EntitiesDropdownMenu
selectedSchemas={selectedSchemas}
selectedEntities={selectedTables}
onToggleEntity={toggleEntity}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{!!entityContext && (
<ContextBadge
label="Entity"
value={entityContext.label}
onRemove={editor === null ? () => setSelectedDatabaseEntity('') : undefined}
/>
)}
{selectedSchemas.length > 0 && (
<ContextBadge
label="Schemas"
value={`${selectedSchemas.slice(0, 2).join(', ')}${selectedSchemas.length > 2 ? ` and ${selectedSchemas.length - 2} other${selectedSchemas.length > 3 ? 's' : ''}` : ''}`}
onRemove={() => {
setSelectedSchemas([])
setAiAssistantPanel({ tables: [] })
}}
tooltip={
selectedSchemas.length > 2 ? (
<>
<p className="text-foreground-light">
{selectedSchemas.length} schemas selected:
</p>
<ul className="list-disc pl-4">
{selectedSchemas.map((x) => (
<li key={x}>{x}</li>
))}
</ul>
</>
) : undefined
}
/>
)}
{selectedTables.length > 0 && (
<ContextBadge
label="Tables"
value={`${selectedTables
.slice(0, 2)
.map((x) => x.name)
.join(
', '
)}${selectedTables.length > 2 ? ` and ${selectedTables.length - 2} other${selectedTables.length > 3 ? 's' : ''}` : ''}`}
onRemove={() => setAiAssistantPanel({ tables: [] })}
tooltip={
selectedTables.length > 2 ? (
<>
<p className="text-foreground-light">
{selectedTables.length} tables selected:
</p>
<ul className="list-disc pl-4">
{selectedTables.map((x) => (
<li key={`${x.schema}.${x.name}`}>
{x.schema}.{x.name}
</li>
))}
</ul>
</>
) : undefined
}
/>
)}
</div>
<AssistantChatForm
textAreaRef={inputRef}
className={cn(
'[&>textarea]:rounded-none [&>textarea]:border-0 [&>textarea]:!outline-none [&>textarea]:!ring-offset-0 [&>textarea]:!ring-0'
)}
loading={isLoading}
disabled={!isApiKeySet || disablePrompts || isLoading}
placeholder={
hasMessages ? 'Reply to the assistant...' : 'How can we help you today?'
}
value={value}
onValueChange={(e) => setValue(e.target.value)}
onSubmit={(event) => {
event.preventDefault()
sendMessageToAssistant(value)
}}
/>
{!hasMessages && IS_PLATFORM && (
<div className="text-xs text-foreground-lighter text-opacity-60 bg-control px-3 pb-2">
The Assistant is in Alpha and your prompts might be rate limited
</div>
)}
</div>
<AnimatePresence>
{!hasMessages && (
<motion.div
exit={{ opacity: 0 }}
initial={{ opacity: 100 }}
transition={{ duration: ANIMATION_DURATION }}
className={cn('w-full')}
>
<div
className={cn(
'flex items-center gap-x-2 transition',
entityContext !== undefined ? 'opacity-100' : 'opacity-0'
)}
>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<Button
type="default"
icon={<WandSparkles />}
onClick={() => onClickQuickPrompt('suggest')}
>
Suggest
</Button>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom">
Suggest some{' '}
{entityContext?.id === 'rls-policies'
? entityContext.label
: `database ${entityContext?.label.toLowerCase()}`}
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<Button
type="default"
icon={<FileText />}
onClick={() => onClickQuickPrompt('examples')}
>
Examples
</Button>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom">
Provide some examples of{' '}
{entityContext?.id === 'rls-policies'
? entityContext.label
: `database ${entityContext?.label.toLowerCase()}`}
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<Button
type="default"
icon={<MessageCircleMore />}
onClick={() => onClickQuickPrompt('ask')}
>
Ask
</Button>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom">
What are{' '}
{entityContext?.id === 'rls-policies'
? entityContext.label
: `database ${entityContext?.label.toLowerCase()}`}
?
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
{docsUrl !== undefined && <DocsButton href={docsUrl} />}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</SheetSection>
</div>
<ConfirmationModal
visible={isConfirmOptInModalOpen}
size="large"
title="Confirm sending anonymous data to OpenAI"
confirmLabel="Confirm"
onCancel={() => setIsConfirmOptInModalOpen(false)}
onConfirm={confirmOptInToShareSchemaData}
loading={isUpdating}
>
<p className="text-sm text-foreground-light">
By opting into sending anonymous data, Supabase AI can improve the answers it shows you.
This is an organization-wide setting, and affects all projects in your organization.
</p>
<OptInToOpenAIToggle />
</ConfirmationModal>
</>
)
}