From 1f38fe20121219150d1e62fe075be5b2b703266a Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Wed, 29 Nov 2023 10:12:50 +0100 Subject: [PATCH] feat: New Policy Editor (#19166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add two more sizes to the Panel component. * Add alias for the older openai-api library. The new one is added under the openai name. * Add API routes * Add components for the new AI RLS panel. * Bunch of changes to the AI Policy Editor. * Add a button for opening the new Policy Editor. * Add a feature flag for the new editor. * Add a confirmation modal when closing the panel. * Fix leftover data when closing the panel. * Make the copy button work. * Add the next/swc packages to package-lock.json. * Merge master * Scaffold debug sql in rls editor * Small improvements to policy chat * Hook up debug to ai assistant panel * Improve debug UX * Add debug request badge * Some styling fix * Small styling fix * Another small styling fix * Shift create new policy ai button + fix error stylign with code editor height * Add tooltips to apply changes and copy code from assistant message * Hide assistant button is not platform * Small lint * Add default error handlers to all AI RQ mutations * Small fix * Remove IS PLATFORM check for rls assistant * Add placeholder to RLS code editor * Fix diff + rls code editor * Add placeholder message after sending prompt * Small style * RLSCodeEditor hit tab if empty to populate placeholder text * Light mode nudeges * Update logic for when confirmation close modal should show * Set render overview ruler as false for rls diff editor * improve chat UX to make it smoother (thank you alaister for your help 🙏) * Dynamically do keepPreviousData * Gracefully handle errors for add prompt * Use animated ai icon while message is loading * using Sheet component * Address commernts * Bit more improvements --------- Co-authored-by: Joshen Lim Co-authored-by: Jonathan Summers-Muir --- .../AIPolicyEditorPanel/AIPolicyChat.tsx | 142 +++++++ .../AIPolicyEditorPanel.utils.ts | 35 ++ .../AIPolicyEditorPanel/AIPolicyHeader.tsx | 39 ++ .../Policies/AIPolicyEditorPanel/Message.tsx | 152 +++++++ .../AIPolicyEditorPanel/QueryError.tsx | 102 +++++ .../AIPolicyEditorPanel/RLSCodeEditor.tsx | 135 +++++++ .../Policies/AIPolicyEditorPanel/index.tsx | 372 ++++++++++++++++++ .../interfaces/Auth/Policies/Policies.tsx | 26 +- .../Auth/Policies/PolicyEditorModal/index.tsx | 8 +- .../Policies/PolicyTableRow/PolicyRow.tsx | 2 +- .../to-be-cleaned/SimpleCodeBlock.tsx | 14 +- .../components/ui/CodeEditor/CodeEditor.tsx | 6 +- apps/studio/data/ai/keys.ts | 3 + apps/studio/data/ai/rls-suggest-mutation.ts | 59 +++ apps/studio/data/ai/rls-suggest-query.ts | 37 ++ apps/studio/data/ai/sql-debug-mutation.ts | 10 + apps/studio/data/ai/sql-edit-mutation.ts | 10 + apps/studio/data/ai/sql-generate-mutation.ts | 10 + apps/studio/data/ai/sql-title-mutation.ts | 10 + apps/studio/data/sql/execute-sql-mutation.ts | 19 +- apps/studio/package.json | 3 +- apps/studio/pages/api/ai/sql/debug.ts | 2 +- apps/studio/pages/api/ai/sql/edit.ts | 2 +- apps/studio/pages/api/ai/sql/generate.ts | 2 +- .../sql/suggest/[thread_id]/[run_id]/index.ts | 54 +++ apps/studio/pages/api/ai/sql/suggest/index.ts | 68 ++++ apps/studio/pages/api/ai/sql/title.ts | 2 +- .../pages/project/[ref]/auth/policies.tsx | 70 +++- package-lock.json | 91 ++++- packages/ui/index.tsx | 17 + packages/ui/src/components/Button/Button.tsx | 7 +- .../ui/src/components/SidePanel/SidePanel.tsx | 6 +- .../ui/src/components/shadcn/ui/sheet.tsx | 25 +- .../ai-icon-animation-style.module.css | 3 + packages/ui/src/lib/theme/defaultTheme.ts | 2 + 35 files changed, 1467 insertions(+), 78 deletions(-) create mode 100644 apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyChat.tsx create mode 100644 apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils.ts create mode 100644 apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyHeader.tsx create mode 100644 apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/Message.tsx create mode 100644 apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/QueryError.tsx create mode 100644 apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx create mode 100644 apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx create mode 100644 apps/studio/data/ai/keys.ts create mode 100644 apps/studio/data/ai/rls-suggest-mutation.ts create mode 100644 apps/studio/data/ai/rls-suggest-query.ts create mode 100644 apps/studio/pages/api/ai/sql/suggest/[thread_id]/[run_id]/index.ts create mode 100644 apps/studio/pages/api/ai/sql/suggest/index.ts diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyChat.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyChat.tsx new file mode 100644 index 00000000000..1cb65058957 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyChat.tsx @@ -0,0 +1,142 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { compact, last, sortBy } from 'lodash' +import { Loader2 } from 'lucide-react' +import OpenAI from 'openai' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useForm } from 'react-hook-form' +import { + AiIcon, + FormControl_Shadcn_, + FormField_Shadcn_, + FormItem_Shadcn_, + Form_Shadcn_, + Input_Shadcn_, +} from 'ui' +import * as z from 'zod' + +import { useProfile } from 'lib/profile' +import Message from './Message' + +export const AIPolicyChat = ({ + messages, + loading, + onSubmit, + onDiff, + onChange, +}: { + messages: OpenAI.Beta.Threads.Messages.ThreadMessage[] + loading: boolean + onSubmit: (s: string) => void + onDiff: (s: string) => void + onChange: (value: boolean) => void +}) => { + const { profile } = useProfile() + const bottomRef = useRef(null) + const name = compact([profile?.first_name, profile?.last_name]).join(' ') + const sorted = useMemo(() => { + return sortBy(messages, (m) => m.created_at).filter((m) => { + if (m.content[0].type === 'text') { + return !m.content[0].text.value.startsWith('Here is my database schema for reference:') + } + return false + }) + }, [messages]) + + const FormSchema = z.object({ chat: z.string() }) + const form = useForm>({ + mode: 'onBlur', + reValidateMode: 'onBlur', + resolver: zodResolver(FormSchema), + defaultValues: { chat: '' }, + }) + const formChatValue = form.getValues().chat + const pendingReply = loading && last(sorted)?.role === 'user' + + useEffect(() => { + // 👇️ scroll to bottom every time messages change + if (bottomRef.current) { + setTimeout(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, 500) + } + }, [messages.length]) + + useEffect(() => { + if (!loading) { + form.setValue('chat', '') + form.setFocus('chat') + } + }, [loading]) + + useEffect(() => { + onChange(formChatValue.length === 0) + }, [formChatValue]) + + return ( +
+
+ + + {sorted.map((m, idx) => ( + + ))} + + {pendingReply && } + +
+
+ + +
) => { + onSubmit(data.chat) + })} + > + ( + + +
+ + + {loading && } +
+
+
+ )} + /> + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils.ts b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils.ts new file mode 100644 index 00000000000..875aeb4b6cf --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils.ts @@ -0,0 +1,35 @@ +import { uuidv4 } from 'lib/helpers' +import { ThreadMessage } from 'openai/resources/beta/threads/messages/messages' + +export const generateThreadMessage = ({ + id, + threadId, + runId, + content, + metadata = {}, +}: { + id?: string + threadId?: string + runId?: string + content: string + metadata?: any +}) => { + const message: ThreadMessage = { + id: id ?? uuidv4(), + object: 'thread.message', + role: 'assistant', + file_ids: [], + metadata, + content: [ + { + type: 'text', + text: { value: content, annotations: [] }, + }, + ], + created_at: Math.floor(Number(new Date()) / 1000), + assistant_id: null, + thread_id: threadId ?? '', + run_id: runId ?? '', + } + return message +} diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyHeader.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyHeader.tsx new file mode 100644 index 00000000000..e3d0d866e12 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyHeader.tsx @@ -0,0 +1,39 @@ +import { AiIcon, Button, SheetClose_Shadcn_, SheetHeader_Shadcn_, SheetTitle_Shadcn_, cn } from 'ui' + +import styles from '@ui/layout/ai-icon-animation/ai-icon-animation-style.module.css' +import { X } from 'lucide-react' + +export const AIPolicyHeader = ({ + assistantVisible, + setAssistantVisible, +}: { + assistantVisible: boolean + setAssistantVisible: (v: boolean) => void +}) => { + return ( + +
+ + + Close + +
+ Create a new row level security policy +
+ +
+ ) +} diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/Message.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/Message.tsx new file mode 100644 index 00000000000..805f9592dc6 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/Message.tsx @@ -0,0 +1,152 @@ +import * as Tooltip from '@radix-ui/react-tooltip' +import dayjs from 'dayjs' +import { kebabCase, noop, take } from 'lodash' +import { Copy, FileDiff } from 'lucide-react' +import Image from 'next/image' +import { memo, useMemo } from 'react' +import ReactMarkdown from 'react-markdown' +import { format } from 'sql-formatter' +import { AiIcon, AiIconAnimation, Badge, Button } from 'ui' + +import CodeEditor from 'components/ui/CodeEditor' +import { useProfile } from 'lib/profile' + +const Message = memo(function Message({ + name, + role, + content, + createdAt, + isDebug, + onDiff = noop, +}: { + name?: string + role: 'user' | 'assistant' + content?: string + createdAt?: number + isDebug?: boolean + onDiff?: (s: string) => void +}) { + const { profile } = useProfile() + + const icon = useMemo(() => { + return role === 'assistant' ? ( + + ) : ( +
+ avatar +
+ ) + }, [role]) + + if (!content) return null + + return ( +
+
+ {icon} + {role === 'assistant' ? 'Assistant' : name ? name : 'You'} + {createdAt && ( + {dayjs(createdAt * 1000).fromNow()} + )} + {isDebug && Debug request} +
+
{children}
, + // intentionally rendering as pre. The other approach would be to render as code element, + // but that will render elements which appear in the explanations as Monaco editors. + pre: ({ children }) => { + const code = (children[0] as any).props.children[0] as string + let formatted = code + try { + formatted = format(code) + } catch {} + + // create a key from the name of the generated policy so that we're sure it's unique + const key = kebabCase(take(code.split(' '), 3).join(' ')) + + return ( +
+ +
+ + + + + + + +
+ Apply changes +
+
+
+
+ + + + + + + +
+ Copy code +
+
+
+
+
+
+ ) + }, + }} + > + {content} +
+
+ ) +}) + +export default Message diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/QueryError.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/QueryError.tsx new file mode 100644 index 00000000000..3b355cd4453 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/QueryError.tsx @@ -0,0 +1,102 @@ +import styles from '@ui/layout/ai-icon-animation/ai-icon-animation-style.module.css' +import { QueryResponseError } from 'data/sql/execute-sql-mutation' +import { useState } from 'react' +import { + AiIcon, + AlertTitle_Shadcn_, + Alert_Shadcn_, + Button, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, + Collapsible_Shadcn_, + cn, +} from 'ui' + +const QueryError = ({ + error, + onSelectDebug, +}: { + error: QueryResponseError + onSelectDebug: () => void +}) => { + const [open, setOpen] = useState(true) + + const formattedError = + (error?.formattedError?.split('\n') ?? [])?.filter((x: string) => x.length > 0) ?? [] + + return ( +
+ + + + +
+ Error running SQL query + + setOpen(!open)} + > +
+ + + + +
+ + {formattedError.length > 0 ? ( + formattedError.map((x: string, i: number) => ( +
+                    {x.split(' ').map((x: string, i: number) => (
+                      
+                        {x}{' '}
+                      
+                    ))}
+                  
+ )) + ) : ( +

{error.error}

+ )} +
+
+
+
+
+
+ ) +} + +export default QueryError diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx new file mode 100644 index 00000000000..dcaff4c2b9d --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx @@ -0,0 +1,135 @@ +import Editor, { OnChange, OnMount } from '@monaco-editor/react' +import { editor } from 'monaco-editor' +import { MutableRefObject, useRef } from 'react' +import { cn } from 'ui' + +import { alignEditor } from 'components/ui/CodeEditor' +import { Markdown } from 'components/interfaces/Markdown' + +// [Joshen] Is there a way we can just have one single MonacoEditor component that's shared across the dashboard? +// Feels like we're creating multiple copies of Editor + +interface RLSCodeEditorProps { + id: string + defaultValue?: string + onInputChange?: (value?: string) => void + wrapperClassName?: string + className?: string + value?: string + editorRef: MutableRefObject +} + +// const placeholderText = ` +// CREATE POLICY *name* ON *table_name*\n +// [ AS { PERMISSIVE | RESTRICTIVE } ]\n +// [ FOR { ALL | SELECT | INSERT | UPDATE | DELETE } ]\n +// [ TO *role_name* ]\n +// [ USING ( *using_expression* ) ]\n +// [ WITH CHECK ( *check_expression* ) ]; +// `.trim() + +const placeholderText = ` +CREATE POLICY *name* ON *table_name*\n +AS PERMISSIVE -- PERMISSIVE | RESTRICTIVE\n +FOR ALL -- ALL | SELECT | INSERT | UPDATE | DELETE\n +TO *role_name* -- Default: public\n +USING ( *using_expression* )\n +WITH CHECK ( *check_expression* );\n + \n +-- Docs: https://www.postgresql.org/docs/current/sql-createpolicy.html +`.trim() + +const RLSCodeEditor = ({ + id, + defaultValue, + wrapperClassName, + className, + value, + editorRef, +}: RLSCodeEditorProps) => { + const hasValue = useRef() + + const onMount: OnMount = async (editor, monaco) => { + editorRef.current = editor + alignEditor(editor) + + hasValue.current = editor.createContextKey('hasValue', false) + + const placeholder = document.querySelector('.monaco-placeholder') as HTMLElement | null + if (placeholder) placeholder.style.display = 'block' + + editor.addCommand( + monaco.KeyCode.Tab, + () => { + editor.executeEdits('source', [ + { + // @ts-ignore + identifier: 'add-placeholder', + range: new monaco.Range(1, 1, 1, 1), + text: placeholderText + .split('\n\n') + .join('\n') + .replaceAll('*', '') + .replaceAll(' ', ''), + }, + ]) + }, + '!hasValue' + ) + + editor.focus() + } + + const onChange: OnChange = (value) => { + hasValue.current.set((value ?? '').length > 0) + + const placeholder = document.querySelector('.monaco-placeholder') as HTMLElement | null + if (placeholder) { + if (!value) { + placeholder.style.display = 'block' + } else { + placeholder.style.display = 'none' + } + } + } + + const options = { + tabSize: 2, + fontSize: 13, + readOnly: false, + minimap: { enabled: false }, + wordWrap: 'on' as const, + fixedOverflowWidgets: true, + contextmenu: true, + lineNumbers: undefined, + glyphMargin: undefined, + lineNumbersMinChars: 4, + folding: undefined, + scrollBeyondLastLine: false, + } + + return ( + <> + +
+ +
+ + ) +} + +export default RLSCodeEditor diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx new file mode 100644 index 00000000000..f3efad37c04 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx @@ -0,0 +1,372 @@ +import { FileDiff } from 'lucide-react' +import dynamic from 'next/dynamic' +import { ThreadMessage } from 'openai/resources/beta/threads/messages/messages' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import toast from 'react-hot-toast' +import { Button, Modal, SheetContent_Shadcn_, SheetFooter_Shadcn_, Sheet_Shadcn_, cn } from 'ui' + +import { + IStandaloneCodeEditor, + IStandaloneDiffEditor, +} from 'components/interfaces/SQLEditor/SQLEditor.types' +import ConfirmationModal from 'components/ui/ConfirmationModal' +import { useRlsSuggestMutation } from 'data/ai/rls-suggest-mutation' +import { useRlsSuggestQuery } from 'data/ai/rls-suggest-query' +import { useSqlDebugMutation } from 'data/ai/sql-debug-mutation' +import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query' +import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' +import { useSelectedProject, useStore } from 'hooks' +import { uuidv4 } from 'lib/helpers' +import { AIPolicyChat } from './AIPolicyChat' +import { generateThreadMessage } from './AIPolicyEditorPanel.utils' +import { AIPolicyHeader } from './AIPolicyHeader' +import QueryError from './QueryError' +import RLSCodeEditor from './RLSCodeEditor' + +const DiffEditor = dynamic( + () => import('@monaco-editor/react').then(({ DiffEditor }) => DiffEditor), + { ssr: false } +) + +interface AIPolicyEditorPanelProps { + visible: boolean + onSelectCancel: () => void +} + +/** + * Using memo for this component because everything rerenders on window focus because of outside fetches + */ +export const AIPolicyEditorPanel = memo(function ({ + visible, + onSelectCancel, +}: AIPolicyEditorPanelProps) { + const { meta } = useStore() + const selectedProject = useSelectedProject() + + const editorRef = useRef(null) + const diffEditorRef = useRef(null) + + const [error, setError] = useState() + // [Joshen] Separate state here as there's a delay between submitting and the API updating the loading status + const [loading, setLoading] = useState(false) + const [keepPreviousData, setKeepPreviousData] = useState(false) + const [debugThread, setDebugThread] = useState([]) + const [assistantVisible, setAssistantPanel] = useState(false) + const [ids, setIds] = useState<{ threadId: string; runId: string } | undefined>(undefined) + const [isAssistantChatInputEmpty, setIsAssistantChatInputEmpty] = useState(true) + const [incomingChange, setIncomingChange] = useState(undefined) + // used for confirmation when closing the panel with unsaved changes + const [isClosingPolicyEditorPanel, setIsClosingPolicyEditorPanel] = useState(false) + + const { data } = useRlsSuggestQuery( + { thread_id: ids?.threadId!, run_id: ids?.runId! }, + { + enabled: !!(ids?.runId && ids.threadId), + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchInterval: (data) => { + if (data && data.status === 'completed') { + return Infinity + } + return 5000 + }, + keepPreviousData, + } + ) + + const { data: entities } = useEntityDefinitionsQuery( + { + projectRef: selectedProject?.ref, + connectionString: selectedProject?.connectionString, + }, + { enabled: true, refetchOnWindowFocus: false } + ) + + const entityDefinitions = entities?.map((def) => def.sql.trim()) + + const { mutate: addPromptMutation } = useRlsSuggestMutation({ + onSuccess: (data) => { + setIds({ threadId: data.threadId, runId: data.runId }) + }, + onError: (error) => { + const threadMessage = generateThreadMessage({ + threadId: ids?.threadId, + runId: ids?.runId, + content: error.message.includes('No OPENAI_KEY set') + ? `Seems like you haven't set an OPENAI_KEY in your environment variables of the dashboard. Update your .env file with that environment variable to use all the AI features of the dashboard!` + : error.message, + }) + setDebugThread([...debugThread, threadMessage]) + setLoading(false) + }, + }) + + const { mutate: executeMutation, isLoading: isExecuting } = useExecuteSqlMutation({ + onSuccess: () => { + // refresh all policies + meta.policies.load() + toast.success('Successfully created new policy') + onSelectCancel() + }, + onError: (error) => { + setError(error) + }, + }) + + const { mutateAsync: debugSql, isLoading: isDebugSqlLoading } = useSqlDebugMutation() + + const addPrompt = useCallback( + (message: string) => { + setLoading(true) + if (ids?.threadId) { + addPromptMutation({ + thread_id: ids?.threadId, + prompt: message, + }) + } else { + addPromptMutation({ + thread_id: ids?.threadId, + entityDefinitions, + prompt: message, + }) + } + }, + [addPromptMutation, entityDefinitions, ids?.threadId] + ) + + const messages = useMemo( + () => [...(data?.messages ?? []), ...debugThread], + [data?.messages, debugThread] + ) + + const errorLines = + error?.formattedError.split('\n').filter((x: string) => x.length > 0).length ?? 0 + + const createNewPolicy = useCallback(() => { + // clean up the sql before sending + const policy = editorRef.current?.getValue().replaceAll('\n', ' ').replaceAll(' ', ' ') + + if (policy) { + setError(undefined) + executeMutation({ + sql: policy, + projectRef: selectedProject?.ref, + connectionString: selectedProject?.connectionString, + }) + } + }, [executeMutation, selectedProject?.connectionString, selectedProject?.ref]) + + const acceptChange = useCallback(async () => { + if (!incomingChange) { + return + } + + if (!editorRef.current || !diffEditorRef.current) { + return + } + + const editorModel = editorRef.current.getModel() + const diffModel = diffEditorRef.current.getModel() + + if (!editorModel || !diffModel) { + return + } + + const sql = diffModel.modified.getValue() + + // apply the incoming change in the editor directly so that Undo/Redo work properly + editorRef.current.executeEdits('apply-ai-edit', [ + { + text: sql, + range: editorModel.getFullModelRange(), + }, + ]) + + // remove the incoming change to revert to the original editor + setIncomingChange(undefined) + }, [incomingChange]) + + const onClosingPanel = useCallback(() => { + const policy = editorRef.current?.getValue() + if (policy || messages.length > 0 || !isAssistantChatInputEmpty) { + setIsClosingPolicyEditorPanel(true) + } else { + onSelectCancel() + } + }, [onSelectCancel, messages, isAssistantChatInputEmpty]) + + const onSelectDebug = async () => { + const policy = editorRef.current?.getValue().replaceAll('\n', ' ').replaceAll(' ', ' ') + if (error === undefined || policy === undefined) return + + setAssistantPanel(true) + const messageId = uuidv4() + + const assistantMessageBefore = generateThreadMessage({ + id: messageId, + threadId: ids?.threadId, + runId: ids?.runId, + content: 'Thinking...', + metadata: { type: 'debug' }, + }) + setDebugThread([...debugThread, assistantMessageBefore]) + + const { solution, sql } = await debugSql({ + sql: policy.trim(), + errorMessage: error.message, + entityDefinitions, + }) + + const assistantMessageAfter = generateThreadMessage({ + id: messageId, + threadId: ids?.threadId, + runId: ids?.runId, + content: `${solution}\n\`\`\`sql\n${sql}\n\`\`\``, + metadata: { type: 'debug' }, + }) + setDebugThread([...debugThread, assistantMessageAfter]) + } + + const onDiff = useCallback((v: string) => setIncomingChange(v), []) + + // when the panel is closed, reset all values + useEffect(() => { + if (!visible) { + const policy = editorRef.current?.getValue() + if (policy) editorRef.current?.setValue('') + if (incomingChange) setIncomingChange(undefined) + if (assistantVisible) setAssistantPanel(false) + setIsClosingPolicyEditorPanel(false) + setIds(undefined) + setError(undefined) + setDebugThread([]) + setKeepPreviousData(false) + } else { + setKeepPreviousData(true) + } + }, [visible]) + + useEffect(() => { + if (data?.status === 'completed') setLoading(false) + }, [data?.status]) + + return ( + <> + onClosingPanel()}> + +
+ +
+ {incomingChange ? ( +
+
+ + Apply changes from assistant +
+
+ + +
+
+ ) : null} + + {incomingChange ? ( + (diffEditorRef.current = editor)} + options={{ + renderSideBySide: false, + scrollBeyondLastLine: false, + renderOverviewRuler: false, + }} + /> + ) : null} +
+ +
+ +
+ {error !== undefined && } + +
+ + +
+
+
+
+
+ {assistantVisible && ( +
+ addPrompt(message)} + onDiff={onDiff} + onChange={setIsAssistantChatInputEmpty} + loading={loading || data?.status === 'loading'} + /> +
+ )} + + setIsClosingPolicyEditorPanel(false)} + onSelectConfirm={() => { + onSelectCancel() + setIsClosingPolicyEditorPanel(false) + }} + > + +

+ Are you sure you want to close the editor? Any unsaved changes on your policy and + conversations with the Assistant will be lost. +

+
+
+
+
+ + ) +}) + +AIPolicyEditorPanel.displayName = 'AIPolicyEditorPanel' diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.tsx b/apps/studio/components/interfaces/Auth/Policies/Policies.tsx index 29bed846b61..c0e2b921b45 100644 --- a/apps/studio/components/interfaces/Auth/Policies/Policies.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/Policies.tsx @@ -1,18 +1,18 @@ -import type { PostgresRole, PostgresTable } from '@supabase/postgres-meta' +import type { PostgresPolicy, PostgresRole, PostgresTable } from '@supabase/postgres-meta' import { useParams } from 'common/hooks' import { PolicyEditorModal, PolicyTableRow } from 'components/interfaces/Auth/Policies' import { useStore } from 'hooks' import { isEmpty } from 'lodash' import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' -import { useState } from 'react' +import { useCallback, useState } from 'react' import { IconHelpCircle } from 'ui' +import { useQueryClient } from '@tanstack/react-query' import NoSearchResults from 'components/to-be-cleaned/NoSearchResults' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' import InformationBox from 'components/ui/InformationBox' -import { useQueryClient } from '@tanstack/react-query' import { tableKeys } from 'data/tables/keys' interface PoliciesProps { @@ -31,13 +31,17 @@ const Policies = ({ tables, hasTables, isLocked }: PoliciesProps) => { const [selectedSchemaAndTable, setSelectedSchemaAndTable] = useState({}) const [selectedTableToToggleRLS, setSelectedTableToToggleRLS] = useState({}) - const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState({}) + const [RLSEditorWithAIShown, showRLSEditorWithAI] = useState(false) + const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState({}) const [selectedPolicyToDelete, setSelectedPolicyToDelete] = useState({}) - const closePolicyEditorModal = () => { + const closePolicyEditorModal = useCallback(() => { setSelectedPolicyToEdit({}) setSelectedSchemaAndTable({}) - } + if (RLSEditorWithAIShown) { + showRLSEditorWithAI(false) + } + }, [RLSEditorWithAIShown]) const closeConfirmModal = () => { setSelectedPolicyToDelete({}) @@ -61,10 +65,10 @@ const Policies = ({ tables, hasTables, isLocked }: PoliciesProps) => { setSelectedPolicyToDelete(policy) } - const onSavePolicySuccess = async () => { + const onSavePolicySuccess = useCallback(async () => { ui.setNotification({ category: 'success', message: 'Policy successfully saved!' }) closePolicyEditorModal() - } + }, [closePolicyEditorModal]) // Methods that involve some API const onToggleRLS = async () => { @@ -85,7 +89,7 @@ const Policies = ({ tables, hasTables, isLocked }: PoliciesProps) => { closeConfirmModal() } - const onCreatePolicy = async (payload: any) => { + const onCreatePolicy = useCallback(async (payload: any) => { const res = await meta.policies.create(payload) if (res.error) { ui.setNotification({ @@ -95,7 +99,7 @@ const Policies = ({ tables, hasTables, isLocked }: PoliciesProps) => { return true } return false - } + }, []) const onUpdatePolicy = async (payload: any) => { const res = await meta.policies.update(payload.id, payload) @@ -181,9 +185,7 @@ const Policies = ({ tables, hasTables, isLocked }: PoliciesProps) => { table={selectedSchemaAndTable.table} selectedPolicyToEdit={selectedPolicyToEdit} onSelectCancel={closePolicyEditorModal} - // @ts-ignore onCreatePolicy={onCreatePolicy} - // @ts-ignore onUpdatePolicy={onUpdatePolicy} onSaveSuccess={onSavePolicySuccess} /> diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx index cbf027aff31..af607712f2c 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx @@ -31,8 +31,8 @@ interface PolicyEditorModalProps { table: string selectedPolicyToEdit: any onSelectCancel: () => void - onCreatePolicy: (payload: PostgresPolicyCreatePayload) => boolean - onUpdatePolicy: (payload: PostgresPolicyUpdatePayload) => boolean + onCreatePolicy: (payload: PostgresPolicyCreatePayload) => Promise + onUpdatePolicy: (payload: PostgresPolicyUpdatePayload) => Promise onSaveSuccess: () => void } @@ -43,8 +43,8 @@ const PolicyEditorModal = ({ table = '', selectedPolicyToEdit = {}, onSelectCancel = noop, - onCreatePolicy = () => false, - onUpdatePolicy = () => false, + onCreatePolicy, + onUpdatePolicy, onSaveSuccess = noop, }: PolicyEditorModalProps) => { const { ui } = useStore() diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx index 249e3398285..d6bfc95f5eb 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx @@ -84,7 +84,7 @@ const PolicyRow = ({ icon={} /> - + onSelectEditPolicy(policy)}>

Edit

diff --git a/apps/studio/components/to-be-cleaned/SimpleCodeBlock.tsx b/apps/studio/components/to-be-cleaned/SimpleCodeBlock.tsx index 0aa96ee8352..be6d2969a6a 100644 --- a/apps/studio/components/to-be-cleaned/SimpleCodeBlock.tsx +++ b/apps/studio/components/to-be-cleaned/SimpleCodeBlock.tsx @@ -22,12 +22,14 @@ const prism = { interface SimpleCodeBlockProps { className?: string metastring?: string + showCopy?: boolean } const SimpleCodeBlock = ({ children, className: languageClassName, metastring, + showCopy = true, }: PropsWithChildren) => { const [showCopied, setShowCopied] = useState(false) const target = useRef(null) @@ -82,11 +84,13 @@ const SimpleCodeBlock = ({ ) })} -
- -
+ {showCopy && ( +
+ +
+ )}
) }} diff --git a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx index 6dd28561652..cc41d4b060f 100644 --- a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx +++ b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx @@ -1,4 +1,4 @@ -import Editor, { EditorProps } from '@monaco-editor/react' +import Editor, { EditorProps, OnMount } from '@monaco-editor/react' import { merge, noop } from 'lodash' import { useRef } from 'react' @@ -36,9 +36,9 @@ const CodeEditor = ({ options, value, }: CodeEditorProps) => { - const editorRef = useRef() + const editorRef = useRef() - const onMount = async (editor: any, monaco: any) => { + const onMount: OnMount = async (editor, monaco) => { editorRef.current = editor alignEditor(editor) diff --git a/apps/studio/data/ai/keys.ts b/apps/studio/data/ai/keys.ts new file mode 100644 index 00000000000..67eae9d36c9 --- /dev/null +++ b/apps/studio/data/ai/keys.ts @@ -0,0 +1,3 @@ +export const aiKeys = { + rlsSuggest: (threadId: string, runId: string) => [threadId, runId] as const, +} diff --git a/apps/studio/data/ai/rls-suggest-mutation.ts b/apps/studio/data/ai/rls-suggest-mutation.ts new file mode 100644 index 00000000000..2fdf15e8926 --- /dev/null +++ b/apps/studio/data/ai/rls-suggest-mutation.ts @@ -0,0 +1,59 @@ +import { useMutation, UseMutationOptions } from '@tanstack/react-query' +import toast from 'react-hot-toast' + +import { isResponseOk, post } from 'lib/common/fetch' +import { BASE_PATH } from 'lib/constants' +import { ResponseError } from 'types' + +export type RlsSuggestResponse = { + threadId: string + runId: string +} + +export type RlsSuggestVariables = { + thread_id?: string + entityDefinitions?: string[] + prompt: string +} + +export async function rlsSuggest({ thread_id, entityDefinitions, prompt }: RlsSuggestVariables) { + const response = await post(BASE_PATH + '/api/ai/sql/suggest', { + thread_id, + entityDefinitions, + prompt, + }) + + if (!isResponseOk(response)) { + throw response.error + } + + return response +} + +type RlsSuggestData = Awaited> + +export const useRlsSuggestMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + return useMutation( + (vars) => rlsSuggest(vars), + { + async onSuccess(data, variables, context) { + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to prompt suggestion: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/ai/rls-suggest-query.ts b/apps/studio/data/ai/rls-suggest-query.ts new file mode 100644 index 00000000000..ed67cc78a15 --- /dev/null +++ b/apps/studio/data/ai/rls-suggest-query.ts @@ -0,0 +1,37 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { BASE_PATH } from 'lib/constants' +import OpenAI from 'openai' +import { aiKeys } from './keys' +import { ResponseError } from 'types' + +export type RlsSuggestVariables = { + thread_id: string + run_id: string +} + +export type RlsSuggestResponse = { + id: string + status: 'completed' | 'loading' + messages: OpenAI.Beta.Threads.Messages.ThreadMessage[] +} + +export async function rlsSuggest({ thread_id, run_id }: RlsSuggestVariables, signal?: AbortSignal) { + const response = await get(`${BASE_PATH}/api/ai/sql/suggest/${thread_id}/${run_id}`, { signal }) + if (response.error) throw response.error + + return response as RlsSuggestResponse +} + +export type RlsSuggestData = Awaited> +export type RlsSuggestDataError = ResponseError + +export const useRlsSuggestQuery = ( + { thread_id, run_id }: RlsSuggestVariables, + options: UseQueryOptions = {} +) => + useQuery( + aiKeys.rlsSuggest(thread_id, run_id), + ({ signal }) => rlsSuggest({ thread_id, run_id }, signal), + options + ) diff --git a/apps/studio/data/ai/sql-debug-mutation.ts b/apps/studio/data/ai/sql-debug-mutation.ts index e294faf1207..61843e3f308 100644 --- a/apps/studio/data/ai/sql-debug-mutation.ts +++ b/apps/studio/data/ai/sql-debug-mutation.ts @@ -1,4 +1,6 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query' +import toast from 'react-hot-toast' + import { isResponseOk, post } from 'lib/common/fetch' import { BASE_PATH } from 'lib/constants' import { ResponseError } from 'types' @@ -32,12 +34,20 @@ type SqlDebugData = Awaited> export const useSqlDebugMutation = ({ onSuccess, + onError, ...options }: Omit, 'mutationFn'> = {}) => { return useMutation((vars) => debugSql(vars), { async onSuccess(data, variables, context) { await onSuccess?.(data, variables, context) }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to debug SQL: ${data.message}`) + } else { + onError(data, variables, context) + } + }, ...options, }) } diff --git a/apps/studio/data/ai/sql-edit-mutation.ts b/apps/studio/data/ai/sql-edit-mutation.ts index 4842c95c7b8..891bd7ace12 100644 --- a/apps/studio/data/ai/sql-edit-mutation.ts +++ b/apps/studio/data/ai/sql-edit-mutation.ts @@ -1,4 +1,6 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query' +import toast from 'react-hot-toast' + import { isResponseOk, post } from 'lib/common/fetch' import { BASE_PATH } from 'lib/constants' import { ResponseError } from 'types' @@ -31,12 +33,20 @@ type SqlEditData = Awaited> export const useSqlEditMutation = ({ onSuccess, + onError, ...options }: Omit, 'mutationFn'> = {}) => { return useMutation((vars) => editSql(vars), { async onSuccess(data, variables, context) { await onSuccess?.(data, variables, context) }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to edit SQL: ${data.message}`) + } else { + onError(data, variables, context) + } + }, ...options, }) } diff --git a/apps/studio/data/ai/sql-generate-mutation.ts b/apps/studio/data/ai/sql-generate-mutation.ts index db2f155f771..fa076045b2c 100644 --- a/apps/studio/data/ai/sql-generate-mutation.ts +++ b/apps/studio/data/ai/sql-generate-mutation.ts @@ -1,4 +1,6 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query' +import toast from 'react-hot-toast' + import { isResponseOk, post } from 'lib/common/fetch' import { BASE_PATH } from 'lib/constants' import { ResponseError } from 'types' @@ -30,6 +32,7 @@ type SqlGenerateData = Awaited> export const useSqlGenerateMutation = ({ onSuccess, + onError, ...options }: Omit< UseMutationOptions, @@ -41,6 +44,13 @@ export const useSqlGenerateMutation = ({ async onSuccess(data, variables, context) { await onSuccess?.(data, variables, context) }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to generate SQL: ${data.message}`) + } else { + onError(data, variables, context) + } + }, ...options, } ) diff --git a/apps/studio/data/ai/sql-title-mutation.ts b/apps/studio/data/ai/sql-title-mutation.ts index 9e3fecd1959..339cb0ec66a 100644 --- a/apps/studio/data/ai/sql-title-mutation.ts +++ b/apps/studio/data/ai/sql-title-mutation.ts @@ -1,4 +1,6 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query' +import toast from 'react-hot-toast' + import { isResponseOk, post } from 'lib/common/fetch' import { BASE_PATH } from 'lib/constants' import { ResponseError } from 'types' @@ -26,6 +28,7 @@ type SqlTitleGenerateData = Awaited> export const useSqlTitleGenerateMutation = ({ onSuccess, + onError, ...options }: Omit< UseMutationOptions, @@ -37,6 +40,13 @@ export const useSqlTitleGenerateMutation = ({ async onSuccess(data, variables, context) { await onSuccess?.(data, variables, context) }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to generate title: ${data.message}`) + } else { + onError(data, variables, context) + } + }, ...options, } ) diff --git a/apps/studio/data/sql/execute-sql-mutation.ts b/apps/studio/data/sql/execute-sql-mutation.ts index bb6a136d845..51a9370643b 100644 --- a/apps/studio/data/sql/execute-sql-mutation.ts +++ b/apps/studio/data/sql/execute-sql-mutation.ts @@ -1,19 +1,30 @@ import { toast } from 'react-hot-toast' import { useMutation, UseMutationOptions } from '@tanstack/react-query' import { executeSql, ExecuteSqlData, ExecuteSqlVariables } from './execute-sql-query' -import { ResponseError } from 'types' -/* Execute Query */ +export type QueryResponseError = { + code: string + message: string + error: string + formattedError: string + file: string + length: number + line: string + name: string + position: string + routine: string + severity: string +} export const useExecuteSqlMutation = ({ onSuccess, onError, ...options }: Omit< - UseMutationOptions, + UseMutationOptions, 'mutationFn' > = {}) => { - return useMutation( + return useMutation( (args) => executeSql(args), { async onSuccess(data, variables, context) { diff --git a/apps/studio/package.json b/apps/studio/package.json index 474df4dd384..327d0fab581 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -63,7 +63,8 @@ "monaco-editor": "0.33.0", "next": "^13.5.3", "next-themes": "^0.2.1", - "openai": "^3.3.0", + "openai-old": "npm:openai@^3.3.0", + "openai": "^4.0.0", "openapi-fetch": "^0.6.1", "p-queue": "^6.6.2", "papaparse": "^5.3.1", diff --git a/apps/studio/pages/api/ai/sql/debug.ts b/apps/studio/pages/api/ai/sql/debug.ts index c562cbccf23..7f517157d1f 100644 --- a/apps/studio/pages/api/ai/sql/debug.ts +++ b/apps/studio/pages/api/ai/sql/debug.ts @@ -9,7 +9,7 @@ import type { CreateChatCompletionRequest, CreateChatCompletionResponse, ErrorResponse, -} from 'openai' +} from 'openai-old' const openAiKey = process.env.OPENAI_KEY diff --git a/apps/studio/pages/api/ai/sql/edit.ts b/apps/studio/pages/api/ai/sql/edit.ts index 209cb02ccf0..e4d3373df2a 100644 --- a/apps/studio/pages/api/ai/sql/edit.ts +++ b/apps/studio/pages/api/ai/sql/edit.ts @@ -9,7 +9,7 @@ import type { CreateChatCompletionRequest, CreateChatCompletionResponse, ErrorResponse, -} from 'openai' +} from 'openai-old' const openAiKey = process.env.OPENAI_KEY diff --git a/apps/studio/pages/api/ai/sql/generate.ts b/apps/studio/pages/api/ai/sql/generate.ts index e8447f1812b..0b1214558ef 100644 --- a/apps/studio/pages/api/ai/sql/generate.ts +++ b/apps/studio/pages/api/ai/sql/generate.ts @@ -9,7 +9,7 @@ import type { CreateChatCompletionRequest, CreateChatCompletionResponse, ErrorResponse, -} from 'openai' +} from 'openai-old' const openAiKey = process.env.OPENAI_KEY diff --git a/apps/studio/pages/api/ai/sql/suggest/[thread_id]/[run_id]/index.ts b/apps/studio/pages/api/ai/sql/suggest/[thread_id]/[run_id]/index.ts new file mode 100644 index 00000000000..8fdeefb83ee --- /dev/null +++ b/apps/studio/pages/api/ai/sql/suggest/[thread_id]/[run_id]/index.ts @@ -0,0 +1,54 @@ +import apiWrapper from 'lib/api/apiWrapper' +import { NextApiRequest, NextApiResponse } from 'next' +// This API function uses the new Threads API, which is only available on the v4 OpenAI lib. +import { OpenAI } from 'openai' + +const openAiKey = process.env.OPENAI_KEY + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!openAiKey) { + return res.status(500).json({ + error: 'No OPENAI_KEY set. Create this environment variable to use AI features.', + }) + } + + const { method } = req + + switch (method) { + case 'GET': + return handleGet(req, res) + default: + res.setHeader('Allow', ['GET']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +export async function handleGet(req: NextApiRequest, res: NextApiResponse) { + const openai = new OpenAI({ apiKey: openAiKey }) + + const { thread_id, run_id } = req.query as { thread_id: string; run_id: string } + + const [run, { data: messages }] = await Promise.all([ + openai.beta.threads.runs.retrieve(thread_id, run_id), + openai.beta.threads.messages.list(thread_id), + ]) + + let status = 'loading' + if (run.status === 'completed') { + status = 'completed' + } else if (run.status === 'failed') { + status = 'failed' + } + + const result = { + id: thread_id, + status, + messages: messages, + } + + return res.json(result) +} + +const wrapper = (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +export default wrapper diff --git a/apps/studio/pages/api/ai/sql/suggest/index.ts b/apps/studio/pages/api/ai/sql/suggest/index.ts new file mode 100644 index 00000000000..07437d3e01f --- /dev/null +++ b/apps/studio/pages/api/ai/sql/suggest/index.ts @@ -0,0 +1,68 @@ +import { codeBlock } from 'common-tags' +import apiWrapper from 'lib/api/apiWrapper' +import { NextApiRequest, NextApiResponse } from 'next' +// This API function uses the new Threads API, which is only available on the v4 OpenAI lib. +import { OpenAI } from 'openai' + +const openAiKey = process.env.OPENAI_KEY + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!openAiKey) { + return res.status(500).json({ + error: 'No OPENAI_KEY set. Create this environment variable to use AI features.', + }) + } + + const { method } = req + + switch (method) { + case 'POST': + return handlePost(req, res) + default: + res.setHeader('Allow', ['POST']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +export async function handlePost(req: NextApiRequest, res: NextApiResponse) { + const openai = new OpenAI({ apiKey: openAiKey }) + + try { + let { + body: { thread_id, entityDefinitions, prompt }, + } = req + + if (!thread_id) { + const thread = await openai.beta.threads.create() + thread_id = thread.id + } + + if (entityDefinitions) { + const one = await openai.beta.threads.messages.create(thread_id, { + role: 'user', + content: codeBlock` + Here is my database schema for reference: + ${entityDefinitions.join('\n\n')} + `, + }) + } + + const two = await openai.beta.threads.messages.create(thread_id, { + role: 'user', + content: prompt, + }) + + const run = await openai.beta.threads.runs.create(thread_id, { + assistant_id: 'asst_O89dyQ2ttPVWfs8YEjKPFqAK', + }) + + return res.json({ threadId: thread_id, runId: run.id }) + } catch (e) { + console.log(e) + return res.status(500) + } +} + +const wrapper = (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +export default wrapper diff --git a/apps/studio/pages/api/ai/sql/title.ts b/apps/studio/pages/api/ai/sql/title.ts index f7b88340acb..9a04a1d8a9d 100644 --- a/apps/studio/pages/api/ai/sql/title.ts +++ b/apps/studio/pages/api/ai/sql/title.ts @@ -9,7 +9,7 @@ import type { CreateChatCompletionRequest, CreateChatCompletionResponse, ErrorResponse, -} from 'openai' +} from 'openai-old' const openAiKey = process.env.OPENAI_KEY diff --git a/apps/studio/pages/project/[ref]/auth/policies.tsx b/apps/studio/pages/project/[ref]/auth/policies.tsx index f1e64851cfc..17a299fd8fa 100644 --- a/apps/studio/pages/project/[ref]/auth/policies.tsx +++ b/apps/studio/pages/project/[ref]/auth/policies.tsx @@ -1,11 +1,14 @@ +import * as Tooltip from '@radix-ui/react-tooltip' import { PostgresPolicy, PostgresTable } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useParams } from 'common' import { partition } from 'lodash' import { observer } from 'mobx-react-lite' import { useEffect, useState } from 'react' +import { Button, IconExternalLink, IconSearch, Input } from 'ui' -import { useParams } from 'common/hooks' import { Policies } from 'components/interfaces/Auth/Policies' +import { AIPolicyEditorPanel } from 'components/interfaces/Auth/Policies/AIPolicyEditorPanel' import { AuthLayout } from 'components/layouts' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import AlertError from 'components/ui/AlertError' @@ -14,11 +17,10 @@ import SchemaSelector from 'components/ui/SchemaSelector' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useSchemasQuery } from 'data/database/schemas-query' import { useTablesQuery } from 'data/tables/tables-query' -import { useCheckPermissions, useStore } from 'hooks' +import { useCheckPermissions, useFlag, useStore } from 'hooks' import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' import { useTableEditorStateSnapshot } from 'state/table-editor' import { NextPageWithLayout } from 'types' -import { Button, IconExternalLink, IconSearch, Input } from 'ui' /** * Filter tables by table name and policy name @@ -61,6 +63,9 @@ const AuthPoliciesPage: NextPageWithLayout = () => { const { meta } = useStore() const { search } = useParams() const [searchString, setSearchString] = useState('') + const canCreatePolicyWithAi = useFlag('policyEditorWithAi') + + const [showPolicyAiEditor, setShowPolicyAiEditor] = useState(false) useEffect(() => { if (search) setSearchString(search) @@ -92,6 +97,7 @@ const AuthPoliciesPage: NextPageWithLayout = () => { const filteredTables = onFilterTables(tables ?? [], policies, searchString) const canReadPolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'policies') + const canCreatePolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'policies') if (!canReadPolicies) { return @@ -121,22 +127,62 @@ const AuthPoliciesPage: NextPageWithLayout = () => { icon={} /> - - - +
+ + + + {canCreatePolicyWithAi && ( + + + + + {!canCreatePolicies && ( + + + +
+ + You need additional permissions to create RLS policies + +
+
+
+ )} +
+ )} +
+ {isLoading && } + {isError && } + {isSuccess && ( 0} isLocked={isLocked} /> )} + + setShowPolicyAiEditor(false)} + /> ) } diff --git a/package-lock.json b/package-lock.json index 8e34e80ad89..0faa48229ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -212,7 +212,8 @@ }, "apps/database-new/node_modules/@tanstack/query-core": { "version": "5.8.3", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.8.3.tgz", + "integrity": "sha512-SWFMFtcHfttLYif6pevnnMYnBvxKf3C+MHMH7bevyYfpXpTMsLB9O6nNGBdWSoPwnZRXFNyNeVZOw25Wmdasow==", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" @@ -220,7 +221,8 @@ }, "apps/database-new/node_modules/@tanstack/react-query": { "version": "5.8.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.8.4.tgz", + "integrity": "sha512-CD+AkXzg8J72JrE6ocmuBEJfGzEzu/bzkD6sFXFDDB5yji9N20JofXZlN6n0+CaPJuIi+e4YLCbGsyPFKkfNQA==", "dependencies": { "@tanstack/query-core": "5.8.3" }, @@ -252,10 +254,13 @@ }, "apps/database-new/node_modules/argparse": { "version": "2.0.1", - "license": "Python-2.0" + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "apps/database-new/node_modules/autoprefixer": { "version": "10.4.14", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz", + "integrity": "sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==", "dev": true, "funding": [ { @@ -267,7 +272,6 @@ "url": "https://tidelift.com/funding/github/npm/autoprefixer" } ], - "license": "MIT", "dependencies": { "browserslist": "^4.21.5", "caniuse-lite": "^1.0.30001464", @@ -396,7 +400,8 @@ }, "apps/database-new/node_modules/lucide-react": { "version": "0.292.0", - "license": "ISC", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.292.0.tgz", + "integrity": "sha512-rRgUkpEHWpa5VCT66YscInCQmQuPCB1RFRzkkxMxg4b+jaL0V12E3riWWR2Sh5OIiUhCwGW/ZExuEO4Az32E6Q==", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } @@ -474,8 +479,9 @@ } }, "apps/database-new/node_modules/openai": { - "version": "4.19.0", - "license": "Apache-2.0", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.20.0.tgz", + "integrity": "sha512-VbAYerNZFfIIeESS+OL9vgDkK8Mnri55n+jN0UN/HZeuM0ghGh6nDN6UGRZxslNgyJ7XmY/Ca9DO4YYyvrszGA==", "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", @@ -509,7 +515,8 @@ }, "apps/database-new/node_modules/sql-formatter": { "version": "13.1.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-13.1.0.tgz", + "integrity": "sha512-/nZQXuN7KzipFNM20ko+dHY4kOr9rymSfZLUDED8rhx3m8OK5y74jcyN+y1L51ZqHqiB0kp40VdpZP99uWvQdA==", "dependencies": { "argparse": "^2.0.1", "get-stdin": "=8.0.0", @@ -780,7 +787,8 @@ "monaco-editor": "0.33.0", "next": "^13.5.3", "next-themes": "^0.2.1", - "openai": "^3.3.0", + "openai": "^4.0.0", + "openai-old": "npm:openai@^3.3.0", "openapi-fetch": "^0.6.1", "p-queue": "^6.6.2", "papaparse": "^5.3.1", @@ -879,6 +887,13 @@ "glob": "7.1.7" } }, + "apps/studio/node_modules/@types/node": { + "version": "18.18.10", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, "apps/studio/node_modules/ajv": { "version": "8.12.0", "license": "MIT", @@ -1009,6 +1024,24 @@ "version": "1.0.0", "license": "MIT" }, + "apps/studio/node_modules/openai": { + "version": "4.19.0", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "digest-fetch": "^1.3.0", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7", + "web-streams-polyfill": "^3.2.1" + }, + "bin": { + "openai": "bin/cli" + } + }, "apps/studio/node_modules/resolve": { "version": "2.0.0-next.5", "dev": true, @@ -3907,7 +3940,8 @@ }, "node_modules/@emnapi/runtime": { "version": "0.43.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.43.1.tgz", + "integrity": "sha512-Q5sMc4Z4gsD4tlmlyFu+MpNAwpR7Gv2errDhVJ+SOhNjWcx8UTqy+hswb8L31RfC8jBvDgcnT87l3xI2w08rAg==", "dependencies": { "tslib": "^2.4.0" } @@ -4134,8 +4168,9 @@ }, "node_modules/@gregnr/libpg-query": { "version": "13.4.0-dev.12", + "resolved": "https://registry.npmjs.org/@gregnr/libpg-query/-/libpg-query-13.4.0-dev.12.tgz", + "integrity": "sha512-t3cQXXwTPeYJEIiNl3HioRdkRTq8bAblS3+ZfKBlRYL57uwFsibeBcqO8zdMA7FEwCqDhyBy0I3SMCHc3ZTb2w==", "hasInstallScript": true, - "license": "LICENSE IN LICENSE", "dependencies": { "@emnapi/runtime": "^0.43.1", "@mapbox/node-pre-gyp": "^1.0.8", @@ -4145,7 +4180,8 @@ }, "node_modules/@gregnr/libpg-query/node_modules/node-addon-api": { "version": "7.0.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.0.0.tgz", + "integrity": "sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==" }, "node_modules/@hcaptcha/react-hcaptcha": { "version": "1.8.1", @@ -10094,8 +10130,7 @@ }, "node_modules/@supabase/ssr": { "version": "0.0.10", - "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.0.10.tgz", - "integrity": "sha512-eVs7+bNlff8Fd79x8K3Jbfpmf8P8QRA1Z6rUDN+fi4ReWvRBZyWOFfR6eqlsX6vTjvGgTiEqujFSkv2PYW5kbQ==", + "license": "MIT", "dependencies": { "cookie": "^0.5.0", "ramda": "^0.29.0" @@ -15490,8 +15525,7 @@ }, "node_modules/dayjs": { "version": "1.11.10", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", - "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" + "license": "MIT" }, "node_modules/debounce": { "version": "1.2.1", @@ -25836,8 +25870,9 @@ }, "node_modules/nextjs-node-loader": { "version": "1.1.5-alpha.0", + "resolved": "https://registry.npmjs.org/nextjs-node-loader/-/nextjs-node-loader-1.1.5-alpha.0.tgz", + "integrity": "sha512-EaY047JUWPlD2pOwRdcYPeyJrUTOfNOGcLyPaiped1bFHvHGIAN8596IbFAM4vwaOMpICodC1mEoOXTKvtcrCA==", "dev": true, - "license": "MIT", "dependencies": { "loader-utils": "^2.0.3" }, @@ -25850,8 +25885,9 @@ }, "node_modules/nextjs-node-loader/node_modules/loader-utils": { "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", "dev": true, - "license": "MIT", "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", @@ -26570,6 +26606,22 @@ "form-data": "^4.0.0" } }, + "node_modules/openai-old": { + "name": "openai", + "version": "3.3.0", + "license": "MIT", + "dependencies": { + "axios": "^0.26.0", + "form-data": "^4.0.0" + } + }, + "node_modules/openai-old/node_modules/axios": { + "version": "0.26.1", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.8" + } + }, "node_modules/openai/node_modules/axios": { "version": "0.26.1", "license": "MIT", @@ -29515,7 +29567,8 @@ }, "node_modules/react-flow": { "version": "1.0.3", - "license": "ISC" + "resolved": "https://registry.npmjs.org/react-flow/-/react-flow-1.0.3.tgz", + "integrity": "sha512-Rx4Oqqbe9y7NyrUGzCrmtNvuGuMJBVjfIeMFuwbtbMc9f22sUSmxMjIA0c8r6Z7iMtB1zOWoGkTgfpmmpH9ReQ==" }, "node_modules/react-from-dom": { "version": "0.6.2", diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index a4399ff1c98..52f08cddb89 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -147,17 +147,34 @@ export { } from './src/components/shadcn/ui/accordion' export { Input as Input_Shadcn_ } from './src/components/shadcn/ui/input' + export { TextArea as TextArea_Shadcn_ } from './src/components/shadcn/ui/text-area' + export { Label as Label_Shadcn_ } from './src/components/shadcn/ui/label' + export * from './src/components/shadcn/ui/switch' + export { Checkbox as Checkbox_Shadcn_ } from './src/components/shadcn/ui/checkbox' + export * from './src/components/shadcn/ui/scroll-area' + export { Collapsible as Collapsible_Shadcn_, CollapsibleTrigger as CollapsibleTrigger_Shadcn_, CollapsibleContent as CollapsibleContent_Shadcn_, } from './src/components/shadcn/ui/collapsible' +export { + Sheet as Sheet_Shadcn_, + SheetTrigger as SheetTrigger_Shadcn_, + SheetClose as SheetClose_Shadcn_, + SheetContent as SheetContent_Shadcn_, + SheetHeader as SheetHeader_Shadcn_, + SheetFooter as SheetFooter_Shadcn_, + SheetTitle as SheetTitle_Shadcn_, + SheetDescription as SheetDescription_Shadcn_, +} from './src/components/shadcn/ui/sheet' + export { ScrollArea, ScrollBar } from './src/components/shadcn/ui/scroll-area' export { Separator } from './src/components/shadcn/ui/separator' diff --git a/packages/ui/src/components/Button/Button.tsx b/packages/ui/src/components/Button/Button.tsx index 627d66920dd..e03cffa6b21 100644 --- a/packages/ui/src/components/Button/Button.tsx +++ b/packages/ui/src/components/Button/Button.tsx @@ -115,6 +115,9 @@ const buttonVariants = cva( disabled: { true: 'opacity-50 cursor-default', }, + rounded: { + true: 'rounded-full', + }, defaultVariants: { // variant: 'default', // size: 'default', @@ -146,6 +149,7 @@ export interface ButtonProps icon?: React.ReactNode iconLeft?: React.ReactNode iconRight?: React.ReactNode + rounded?: boolean } const Button = React.forwardRef( @@ -161,6 +165,7 @@ const Button = React.forwardRef( iconRight, iconLeft, htmlType = 'button', + rounded, ...props }, ref @@ -178,7 +183,7 @@ const Button = React.forwardRef( ref={ref} type={htmlType} {...props} - className={cn(buttonVariants({ type, size, disabled, block }), className)} + className={cn(buttonVariants({ type, size, disabled, block, rounded }), className)} > {asChild ? ( React.isValidElement(children) ? ( diff --git a/packages/ui/src/components/SidePanel/SidePanel.tsx b/packages/ui/src/components/SidePanel/SidePanel.tsx index 4ac7a46ee06..d3e57942d51 100644 --- a/packages/ui/src/components/SidePanel/SidePanel.tsx +++ b/packages/ui/src/components/SidePanel/SidePanel.tsx @@ -1,8 +1,8 @@ +import * as Dialog from '@radix-ui/react-dialog' +import * as Tooltip from '@radix-ui/react-tooltip' import React from 'react' import { Button } from '../../../index' -import * as Dialog from '@radix-ui/react-dialog' import styleHandler from '../../lib/theme/styleHandler' -import * as Tooltip from '@radix-ui/react-tooltip' export type SidePanelProps = RadixProps & CustomProps @@ -24,7 +24,7 @@ interface CustomProps { children?: React.ReactNode header?: string | React.ReactNode visible: boolean - size?: 'medium' | 'large' | 'xlarge' | 'xxlarge' + size?: 'medium' | 'large' | 'xlarge' | 'xxlarge' | 'xxxlarge' | 'xxxxlarge' loading?: boolean align?: 'right' | 'left' hideFooter?: boolean diff --git a/packages/ui/src/components/shadcn/ui/sheet.tsx b/packages/ui/src/components/shadcn/ui/sheet.tsx index 45abb9374f1..5fb7bbea31e 100644 --- a/packages/ui/src/components/shadcn/ui/sheet.tsx +++ b/packages/ui/src/components/shadcn/ui/sheet.tsx @@ -6,6 +6,7 @@ import { cva, type VariantProps } from 'class-variance-authority' import { X } from 'lucide-react' import { cn } from '@ui/lib/utils' +import { DialogCloseProps } from '@radix-ui/react-alert-dialog' const Sheet = SheetPrimitive.Root @@ -13,7 +14,7 @@ const SheetTrigger = SheetPrimitive.Trigger const SheetClose = SheetPrimitive.Close -const portalVariants = cva('fixed inset-0 z-50 flex', { +const portalVariants = cva('fixed inset-0 z-40 flex', { variants: { position: { top: 'items-start', @@ -29,8 +30,8 @@ interface SheetPortalProps extends SheetPrimitive.DialogPortalProps, VariantProps {} -const SheetPortal = ({ position, className, children, ...props }: SheetPortalProps) => ( - +const SheetPortal = ({ position, children, ...props }: SheetPortalProps) => ( +
{children}
) @@ -42,7 +43,7 @@ const SheetOverlay = React.forwardRef< >(({ className, children, ...props }, ref) => ( {children} - + {/* Close - + */} )) SheetContent.displayName = SheetPrimitive.Content.displayName const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
+
) SheetHeader.displayName = 'SheetHeader' @@ -183,7 +190,7 @@ const SheetTitle = React.forwardRef< >(({ className, ...props }, ref) => ( )) diff --git a/packages/ui/src/layout/ai-icon-animation/ai-icon-animation-style.module.css b/packages/ui/src/layout/ai-icon-animation/ai-icon-animation-style.module.css index 47b81ce6df8..bfb60450743 100644 --- a/packages/ui/src/layout/ai-icon-animation/ai-icon-animation-style.module.css +++ b/packages/ui/src/layout/ai-icon-animation/ai-icon-animation-style.module.css @@ -495,6 +495,7 @@ height: 8px; border-radius: 0%; } + .ai-icon__container--allow-hover-effect:hover .ai-icon__grid__square--static:nth-child(2), [data-selected='true'] .ai-icon__grid__square--static:nth-child(2) { transform: rotate(45deg); @@ -504,6 +505,7 @@ height: 24px; border-radius: 100%; } + .ai-icon__container--allow-hover-effect:hover .ai-icon__grid__square--static:nth-child(3), [data-selected='true'] .ai-icon__grid__square--static:nth-child(3) { transform: rotate(45deg); @@ -513,6 +515,7 @@ height: 24px; border-radius: 100%; } + .ai-icon__container--allow-hover-effect:hover .ai-icon__grid__square--static:nth-child(4), [data-selected='true'] .ai-icon__grid__square--static:nth-child(4) { transform: rotate(45deg); diff --git a/packages/ui/src/lib/theme/defaultTheme.ts b/packages/ui/src/lib/theme/defaultTheme.ts index c60405d6c5f..71fd50e4c56 100644 --- a/packages/ui/src/lib/theme/defaultTheme.ts +++ b/packages/ui/src/lib/theme/defaultTheme.ts @@ -879,6 +879,8 @@ export default { large: `w-screen max-w-2xl h-full`, xlarge: `w-screen max-w-3xl h-full`, xxlarge: `w-screen max-w-4xl h-full`, + xxxlarge: `w-screen max-w-5xl h-full`, + xxxxlarge: `w-screen max-w-6xl h-full`, }, align: { left: `