mirror of
https://github.com/supabase/supabase.git
synced 2026-06-13 01:39:53 +08:00
When Assistant requests confirmation to run a query or deploy an edge function if the user doesn't skip or run and instead sends a follow-up message it errors out. This allows follow-up messages and treats them as "skips" which means adjusting confirmation message state as part of the follow-up. This also uses toModelOutput to cleanse data based on permissions. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Enhanced tool approval workflow: pending approvals are now automatically resolved as denied when submitting new messages * Improved chat input state management with better handling of approval states * Customizable loading messages for tool operations * **Bug Fixes** * Fixed chat input availability during pending tool approval states * Improved tool execution feedback during approval workflows <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46052?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
266 lines
9.0 KiB
TypeScript
266 lines
9.0 KiB
TypeScript
import { acceptUntrustedSql, type UntrustedSqlFragment } from '@supabase/pg-meta'
|
|
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import type { ToolUIPart } from 'ai'
|
|
import { useParams } from 'common'
|
|
import { useRouter } from 'next/router'
|
|
import { useRef, useState, type DragEvent, type PropsWithChildren } from 'react'
|
|
|
|
import { DEFAULT_CHART_CONFIG, QueryBlock } from '../QueryBlock/QueryBlock'
|
|
import { identifyQueryType } from './AIAssistant.utils'
|
|
import { ConfirmFooter } from './ConfirmFooter'
|
|
import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
|
|
import { entityTypeKeys } from '@/data/entity-types/keys'
|
|
import { lintKeys } from '@/data/lint/keys'
|
|
import { usePrimaryDatabase } from '@/data/read-replicas/replicas-query'
|
|
import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
|
|
import { useChangedSync } from '@/hooks/misc/useChanged'
|
|
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
|
import { useProfile } from '@/lib/profile'
|
|
import { useTrack } from '@/lib/telemetry/track'
|
|
|
|
interface DisplayBlockRendererProps {
|
|
messageId: string
|
|
toolCallId: string
|
|
initialArgs: {
|
|
sql: UntrustedSqlFragment
|
|
label?: string
|
|
isWriteQuery?: boolean
|
|
view?: 'table' | 'chart'
|
|
xAxis?: string
|
|
yAxis?: string
|
|
}
|
|
initialResults?: unknown
|
|
/** Called when locally running SQL fails before or during client-side execution. */
|
|
onError?: (args: { messageId: string; errorText: string }) => void
|
|
/** Responds affirmatively to an AI SDK tool approval request; does not run SQL directly. */
|
|
onApprove?: () => void
|
|
/** Responds negatively to an AI SDK tool approval request; does not run SQL directly. */
|
|
onDeny?: () => void
|
|
/** AI SDK tool state used to show approval UI for pending tool calls. */
|
|
toolState?: ToolUIPart['state']
|
|
toolApprovalRespondedApproved?: boolean
|
|
isLastPart?: boolean
|
|
isLastMessage?: boolean
|
|
showConfirmFooter?: boolean
|
|
onChartConfigChange?: (chartConfig: ChartConfig) => void
|
|
/** Called when the user clicks the query block play button to run SQL locally. */
|
|
onQueryRun?: (queryType: 'select' | 'mutation') => void
|
|
}
|
|
|
|
export const DisplayBlockRenderer = ({
|
|
messageId,
|
|
toolCallId,
|
|
initialArgs,
|
|
initialResults,
|
|
onError,
|
|
onApprove,
|
|
onDeny,
|
|
toolState,
|
|
toolApprovalRespondedApproved,
|
|
isLastPart = false,
|
|
isLastMessage = false,
|
|
showConfirmFooter = true,
|
|
onChartConfigChange,
|
|
onQueryRun,
|
|
}: PropsWithChildren<DisplayBlockRendererProps>) => {
|
|
const queryClient = useQueryClient()
|
|
|
|
const savedInitialArgs = useRef(initialArgs)
|
|
const savedInitialResults = useRef(initialResults)
|
|
const savedInitialConfig = useRef<ChartConfig>({
|
|
...DEFAULT_CHART_CONFIG,
|
|
view: initialArgs.view === 'chart' ? 'chart' : 'table',
|
|
xKey: initialArgs.xAxis ?? '',
|
|
yKey: initialArgs.yAxis ?? '',
|
|
})
|
|
|
|
const router = useRouter()
|
|
const { ref } = useParams()
|
|
const { profile } = useProfile()
|
|
|
|
const track = useTrack()
|
|
const { can: canCreateSQLSnippet } = useAsyncCheckPermissions(
|
|
PermissionAction.CREATE,
|
|
'user_content',
|
|
{
|
|
resource: { type: 'sql', owner_id: profile?.id },
|
|
subject: { id: profile?.id },
|
|
}
|
|
)
|
|
|
|
const [chartConfig, setChartConfig] = useState<ChartConfig>(() => ({
|
|
...DEFAULT_CHART_CONFIG,
|
|
view: initialArgs.view === 'chart' ? 'chart' : 'table',
|
|
xKey: initialArgs.xAxis ?? '',
|
|
yKey: initialArgs.yAxis ?? '',
|
|
}))
|
|
|
|
const [rows, setRows] = useState<any[] | undefined>(
|
|
Array.isArray(initialResults) ? initialResults : undefined
|
|
)
|
|
const isReportsPage = router.pathname.endsWith('/reports/[id]')
|
|
const isHomePage = router.pathname === '/project/[ref]'
|
|
const isDraggableToReports = canCreateSQLSnippet && (isReportsPage || isHomePage)
|
|
const label = initialArgs.label || 'SQL Results'
|
|
const [isWriteQuery, setIsWriteQuery] = useState<boolean>(initialArgs.isWriteQuery || false)
|
|
const sqlQuery = initialArgs.sql
|
|
|
|
const { database: primaryDatabase } = usePrimaryDatabase({ projectRef: ref })
|
|
|
|
const readOnlyConnectionString = primaryDatabase?.connection_string_read_only
|
|
const postgresConnectionString = primaryDatabase?.connectionString
|
|
|
|
const {
|
|
mutate: executeSql,
|
|
error: executeSqlError,
|
|
isPending: executeSqlLoading,
|
|
} = useExecuteSqlMutation({
|
|
onError: () => {
|
|
// Suppress toast because error message is displayed inline
|
|
},
|
|
})
|
|
|
|
const toolCallIdChanged = useChangedSync(toolCallId)
|
|
if (toolCallIdChanged) {
|
|
setChartConfig(savedInitialConfig.current)
|
|
onChartConfigChange?.(savedInitialConfig.current)
|
|
setIsWriteQuery(savedInitialArgs.current.isWriteQuery || false)
|
|
setRows(Array.isArray(savedInitialResults.current) ? savedInitialResults.current : undefined)
|
|
}
|
|
|
|
const initialResultsChanged = useChangedSync(initialResults)
|
|
if (initialResultsChanged) {
|
|
const normalized = Array.isArray(initialResults) ? initialResults : undefined
|
|
if (!normalized || normalized === rows) return
|
|
setRows(normalized)
|
|
}
|
|
|
|
const handleRunQuery = (queryType: 'select' | 'mutation') => {
|
|
if (!sqlQuery) return
|
|
|
|
onQueryRun?.(queryType)
|
|
|
|
track('assistant_suggestion_run_query_clicked', {
|
|
queryType,
|
|
...(queryType === 'mutation'
|
|
? { mutationType: identifyQueryType(sqlQuery) ?? 'unknown' }
|
|
: {}),
|
|
})
|
|
}
|
|
|
|
const runQuery = (queryType: 'select' | 'mutation') => {
|
|
if (!ref || !sqlQuery) return
|
|
|
|
const connectionString =
|
|
queryType === 'mutation'
|
|
? postgresConnectionString
|
|
: (readOnlyConnectionString ?? postgresConnectionString)
|
|
|
|
if (!connectionString) {
|
|
const fallbackMessage = 'Unable to find a database connection to execute this query.'
|
|
onError?.({ messageId, errorText: fallbackMessage })
|
|
return
|
|
}
|
|
|
|
if (queryType === 'mutation') {
|
|
setIsWriteQuery(true)
|
|
}
|
|
executeSql(
|
|
{ projectRef: ref, connectionString, sql: acceptUntrustedSql(sqlQuery) },
|
|
{
|
|
onSuccess: (data) => {
|
|
setRows(Array.isArray(data.result) ? data.result : undefined)
|
|
setIsWriteQuery(queryType === 'mutation' || initialArgs.isWriteQuery || false)
|
|
if (queryType === 'mutation') {
|
|
queryClient.invalidateQueries({ queryKey: lintKeys.lint(ref) })
|
|
queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(ref) })
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
const lowerMessage = error.message.toLowerCase()
|
|
const isReadOnlyError =
|
|
lowerMessage.includes('read-only transaction') ||
|
|
lowerMessage.includes('permission denied') ||
|
|
lowerMessage.includes('must be owner')
|
|
|
|
if (queryType === 'select' && isReadOnlyError) {
|
|
setIsWriteQuery(true)
|
|
}
|
|
|
|
onError?.({ messageId, errorText: error.message })
|
|
},
|
|
}
|
|
)
|
|
}
|
|
|
|
const handleExecute = (queryType: 'select' | 'mutation') => {
|
|
handleRunQuery(queryType)
|
|
runQuery(queryType)
|
|
}
|
|
|
|
const handleUpdateChartConfig = ({
|
|
chartConfig: updatedValues,
|
|
}: {
|
|
chartConfig: Partial<ChartConfig>
|
|
}) => {
|
|
setChartConfig((prev) => {
|
|
const next = { ...prev, ...updatedValues }
|
|
onChartConfigChange?.(next)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const handleDragStart = (e: DragEvent<Element>) => {
|
|
e.dataTransfer.setData(
|
|
'application/json',
|
|
JSON.stringify({ label, sql: sqlQuery, config: chartConfig })
|
|
)
|
|
}
|
|
|
|
const isApprovalRequested = toolState === 'approval-requested'
|
|
const isApprovalResponded = toolState === 'approval-responded'
|
|
const isApprovalDenied = isApprovalResponded && toolApprovalRespondedApproved === false
|
|
const shouldShowConfirmFooter =
|
|
showConfirmFooter &&
|
|
(isApprovalRequested || (isApprovalResponded && !isApprovalDenied)) &&
|
|
isLastPart &&
|
|
isLastMessage &&
|
|
(isApprovalResponded || (!!onApprove && !!onDeny))
|
|
const isRunningApprovedTool = (isApprovalResponded && !isApprovalDenied) || executeSqlLoading
|
|
|
|
return (
|
|
<div className="display-block w-auto overflow-x-hidden">
|
|
<div className="relative z-10">
|
|
<QueryBlock
|
|
label={label}
|
|
isWriteQuery={isWriteQuery}
|
|
sql={sqlQuery}
|
|
results={rows}
|
|
errorText={executeSqlError?.message}
|
|
chartConfig={chartConfig}
|
|
onExecute={handleExecute}
|
|
onUpdateChartConfig={handleUpdateChartConfig}
|
|
draggable={isDraggableToReports}
|
|
onDragStart={handleDragStart}
|
|
disabled={shouldShowConfirmFooter}
|
|
isExecuting={isRunningApprovedTool}
|
|
/>
|
|
</div>
|
|
{shouldShowConfirmFooter && (
|
|
<div className="mx-4">
|
|
<ConfirmFooter
|
|
message="Assistant wants to run this query"
|
|
cancelLabel="Skip"
|
|
confirmLabel="Run Query"
|
|
confirmLabelLoading="Running..."
|
|
isLoading={isApprovalResponded || executeSqlLoading}
|
|
onCancel={isApprovalRequested ? onDeny : undefined}
|
|
onConfirm={isApprovalRequested ? onApprove : undefined}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|