import { useQueryClient } from '@tanstack/react-query' import { uniqBy } from 'lodash' import { Command, CornerDownLeft, Loader2, X } from 'lucide-react' import { useRouter } from 'next/router' import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { useChat } from 'ai/react' import { useParams } from 'common' import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { generateThreadMessage } from 'components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils' import { MessageWithDebug } from 'components/interfaces/SQLEditor/AiAssistantPanel' import { sqlAiDisclaimerComment } from 'components/interfaces/SQLEditor/SQLEditor.constants' import { DiffType, IStandaloneCodeEditor } from 'components/interfaces/SQLEditor/SQLEditor.types' import { suffixWithLimit } from 'components/interfaces/SQLEditor/SQLEditor.utils' import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results' import { useSqlDebugMutation } from 'data/ai/sql-debug-mutation' import { databasePoliciesKeys } from 'data/database-policies/keys' import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' import { sqlKeys } from 'data/sql/keys' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { usePrevious } from 'hooks/deprecated' import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { SqlEditor } from 'icons' import { BASE_PATH, LOCAL_STORAGE_KEYS, TELEMETRY_ACTIONS, TELEMETRY_CATEGORIES, TELEMETRY_LABELS, } from 'lib/constants' import { detectOS, uuidv4 } from 'lib/helpers' import { useAppStateSnapshot } from 'state/app-state' import { Button, cn, Sheet, SheetContent, SheetFooter, SheetHeader, Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, } from 'ui' import { Admonition } from 'ui-patterns' import CodeEditor from '../CodeEditor/CodeEditor' import { AIAssistant } from './AIAssistant' import { generateCTA, generatePlaceholder, generateTitle, validateQuery } from './AIAssistant.utils' import { ASSISTANT_SUPPORT_ENTITIES } from './AiAssistant.constants' import { useEntityDefinitionQuery } from 'data/database/entity-definition-query' export const AiAssistantPanel = () => { const os = detectOS() const router = useRouter() const { ref } = useParams() const project = useSelectedProject() const queryClient = useQueryClient() const { aiAssistantPanel, setAiAssistantPanel } = useAppStateSnapshot() const isEnabled = useIsDatabaseFunctionsAssistantEnabled() const { open, editor, content, entity } = aiAssistantPanel const previousEditor = usePrevious(editor) const [isAcknowledged, setIsAcknowledged] = useLocalStorage( LOCAL_STORAGE_KEYS.SQL_SCRATCH_PAD_BANNER_ACKNOWLEDGED, false ) const [chatId, setChatId] = useState(() => uuidv4()) const [error, setError] = useState() const [results, setResults] = useState(undefined) const [showResults, setShowResults] = useState(false) const [showWarning, setShowWarning] = useState(false) const [debugThread, setDebugThread] = useState([]) const { data: existingDefinition } = useEntityDefinitionQuery({ id: entity?.id, type: editor, projectRef: project?.ref, connectionString: project?.connectionString, }) // [Joshen] JFYI I'm opting to just have the assistant always show and not toggle-able // I don't really see any negatives of keeping it open (or benefits from hiding) tbh // const [showAssistant, setShowAssistant] = useState(true) const title = generateTitle(editor, entity) const placeholder = generatePlaceholder(editor, entity, existingDefinition) const ctaText = generateCTA(editor) const editorRef = useRef() const editorModel = editorRef.current?.getModel() const numResults = (results ?? []).length const [errorHeader, ...errorContent] = (error?.formattedError?.split('\n') ?? [])?.filter((x: string) => x.length > 0) ?? [] const entityContext = ASSISTANT_SUPPORT_ENTITIES.find((x) => x.id === editor) const { mutate: sendEvent } = useSendEventMutation() const sendTelemetryEvent = (action: string) => { sendEvent({ action, category: TELEMETRY_CATEGORIES.AI_ASSISTANT, label: TELEMETRY_LABELS.QUICK_SQL_EDITOR, }) } const { mutate: executeSql, isLoading: isExecuting } = useExecuteSqlMutation({ onSuccess: async (res) => { // [Joshen] If in a specific editor context mode, assume that intent was to create/update // a database entity - so close it once success. Otherwise it's in Quick SQL mode and we // show the results. Currently though it assumes we're "creating", thinking need to support "updating" too if (editor !== null) { switch (editor) { case 'functions': await queryClient.invalidateQueries(sqlKeys.query(ref, ['functions-list'])) break case 'rls-policies': await queryClient.invalidateQueries(databasePoliciesKeys.list(ref)) break } toast.success(`Successfully created ${entityContext?.name}!`) setAiAssistantPanel({ open: false }) } else { setShowResults(true) setResults(res.result) } }, onError: (error) => { setError(error) setResults([]) }, }) const { append } = useChat({ id: chatId, api: `${BASE_PATH}/api/ai/sql/generate-v2`, // [Joshen] Don't need entity definitions if calling here cause support action // via AI here is just to explain code segment, no need for entity definitions context body: {}, }) const { mutateAsync: debugSql } = useSqlDebugMutation({ onError: () => {} }) const onExecuteSql = (skipValidation = false) => { setError(undefined) setShowWarning(false) const query = editorRef.current?.getValue() ?? '' if (query.length === 0) return if (editor !== undefined && !skipValidation) { // [Joshen] Some basic validation logic const validated = validateQuery(editor, query) if (!validated) { return setShowWarning(true) } } executeSql({ sql: suffixWithLimit(query, 100), projectRef: project?.ref, connectionString: project?.connectionString, handleError: (error) => { throw error }, }) } const onFixWithAssistant = async () => { if (!error) return const messageId = uuidv4() const query = editorRef.current?.getValue() ?? '' const assistantMessageBefore = generateThreadMessage({ id: messageId, content: 'Thinking...', isDebug: true, }) setDebugThread([...debugThread, assistantMessageBefore]) sendTelemetryEvent(TELEMETRY_ACTIONS.FIX_WITH_ASSISTANT) try { const { solution, sql } = await debugSql({ sql: query, errorMessage: error.message, }) const assistantMessageAfter = generateThreadMessage({ id: messageId, content: `${solution}\n\`\`\`sql\n${sql}\n\`\`\``, isDebug: true, }) const cleanedMessages = uniqBy([...debugThread, assistantMessageAfter], (m) => m.id) setDebugThread(cleanedMessages) } catch (error) { const assistantMessageAfter = generateThreadMessage({ id: messageId, content: `Hmm, Sorry but I'm unable to find a solution for the error that you're facing`, isDebug: true, }) const cleanedMessages = uniqBy([...debugThread, assistantMessageAfter], (m) => m.id) setDebugThread(cleanedMessages) } } const onExplainSql = (value: string) => { append({ role: 'user', createdAt: new Date(), content: ` Can you explain this section to me in more detail?\n ${value} `.trim(), }) sendTelemetryEvent(TELEMETRY_ACTIONS.EXPLAIN_CODE) } const updateEditorWithCheckForDiff = useCallback( ({ id, diffType, sql }: { id: string; diffType: DiffType; sql: string }) => { if (!editorModel) return const existingValue = editorRef.current?.getValue() ?? '' if (existingValue.length === 0) { // if the editor is empty, just copy over the code editorRef.current?.executeEdits('apply-ai-message', [ { text: `${sqlAiDisclaimerComment}\n\n${sql}`, range: editorModel.getFullModelRange(), }, ]) } else { const currentSql = editorRef.current?.getValue() editorRef.current?.executeEdits('apply-ai-message', [ { text: `${currentSql}\n\n${sqlAiDisclaimerComment}\n\n${sql}`, range: editorModel.getFullModelRange(), }, ]) } }, [editorModel] ) const onTogglePanel = () => { if (open) { // [Joshen] Save the code content when closing - should allow users to continue from // where they left off IF they are opening the assistant again in the same "editor" context const existingValue = editorRef.current?.getValue() ?? '' setAiAssistantPanel({ open: false, content: existingValue }) } else { setAiAssistantPanel({ open: true, editor: null, entity: undefined }) } } useEffect(() => { if (open) { // [Joshen] Only reset the assistant if the editor changed if (previousEditor !== editor) { setChatId(uuidv4()) setError(undefined) setShowWarning(false) setDebugThread([]) setResults(undefined) setShowResults(false) } else { // [Joshen] Repopulate the code editor with the content from where the user left off // setTimeout is just to give time for the editorRef to get associated setTimeout(() => { const editorModel = editorRef.current?.getModel() if (content && editorModel) { editorRef.current?.executeEdits('apply-ai-message', [ { text: content, range: editorModel.getFullModelRange() }, ]) } }, 100) } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) // [Joshen] Just to test the concept of a universal assistant of sorts useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.metaKey && e.code === 'KeyI') onTogglePanel() } if (project !== undefined && isEnabled) window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) // eslint-disable-next-line react-hooks/exhaustive-deps }, [project, isEnabled, open]) // [Joshen] Whenever the deps change recalculate the height of the editor useEffect(() => { editorRef.current?.layout({ width: 0, height: 0 }) window.requestAnimationFrame(() => editorRef.current?.layout()) }, [error, showWarning, isExecuting, numResults, showResults]) return ( onTogglePanel()}> {/* Assistant */} setChatId(uuidv4())} /> {/* Editor */}
{title} } /> )} {showWarning && (

Are you sure you want to run this query?

} /> )} {results !== undefined && results.length > 0 && ( <>

{results.length} rows {results.length >= 100 && ` (Limited to only 100 rows)`}

)} {results !== undefined && results.length === 0 && (

Success. No rows returned.

)}
) }