feat: New Policy Editor (#19166)

* 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 <joshenlimek@gmail.com>
Co-authored-by: Jonathan Summers-Muir <MildTomato@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2023-11-29 17:12:50 +08:00
1 parent f07fc0bfa3
commit 1f38fe2012
35 files changed
+1467 -78

No files matched your search

@@ -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<HTMLDivElement>(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<z.infer<typeof FormSchema>>({
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 (
<div id={'ai-chat-assistant'} className="flex flex-col h-full">
<div className="overflow-auto flex-1">
<Message
role="assistant"
content={`Hi${
name ? ' ' + name : ''
}, how can I help you? I'm powered by AI, so surprises and mistakes are possible.
Make sure to verify any generated code or suggestions, and share feedback so that we can
learn and improve.`}
/>
{sorted.map((m, idx) => (
<Message
key={`message-${idx}`}
name={name}
role={m.role}
content={
m.content[0] && m.content[0].type === 'text' ? m.content[0].text.value : undefined
}
createdAt={m.created_at}
isDebug={(m.metadata as any).type === 'debug'}
onDiff={onDiff}
/>
))}
{pendingReply && <Message role="assistant" content="Thinking..." />}
<div ref={bottomRef} className="h-1" />
</div>
<Form_Shadcn_ {...form}>
<form
id="rls-chat"
className="sticky p-5 flex-0 border-t"
onSubmit={form.handleSubmit((data: z.infer<typeof FormSchema>) => {
onSubmit(data.chat)
})}
>
<FormField_Shadcn_
control={form.control}
name="chat"
render={({ field }) => (
<FormItem_Shadcn_>
<FormControl_Shadcn_>
<div className="relative">
<AiIcon className="absolute top-2 left-3 [&>div>div]:border-black dark:[&>div>div]:border-white" />
<Input_Shadcn_
{...field}
autoComplete="off"
disabled={loading}
autoFocus
className={`bg-surface-300 dark:bg-black rounded-full pl-10 ${
loading ? 'pr-10' : ''
}`}
placeholder="Ask for some changes to your policy"
/>
{loading && <Loader2 className="absolute top-2 right-3 animate-spin" />}
</div>
</FormControl_Shadcn_>
</FormItem_Shadcn_>
)}
/>
</form>
</Form_Shadcn_>
</div>
)
}
@@ -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
}
@@ -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 (
<SheetHeader_Shadcn_ className="py-3 flex flex-row justify-between items-center">
<div className="flex flex-row gap-3 items-center">
<SheetClose_Shadcn_ className="text-light hover:text ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-3 w-3" />
<span className="sr-only">Close</span>
</SheetClose_Shadcn_>
<div className="h-[24px] w-[1px] bg-border"></div>
<SheetTitle_Shadcn_>Create a new row level security policy</SheetTitle_Shadcn_>
</div>
<Button
aria-expanded={assistantVisible}
aria-controls="ai-chat-assistant"
size="tiny"
type="outline"
className={cn('group', styles['ai-icon__container--allow-hover-effect'], 'px-3 py-0.5')}
rounded
icon={
<AiIcon className="scale-75 [&>div>div]:border-foreground-light [&>div>div]:group-hover:border-foreground -mr-0.5" />
}
onClick={() => setAssistantVisible(!assistantVisible)}
>
{assistantVisible ? <>Close Assistant</> : <>Open Assistant</>}
</Button>
</SheetHeader_Shadcn_>
)
}
@@ -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' ? (
<AiIconAnimation
loading={content === 'Thinking...'}
className="[&>div>div]:border-black dark:[&>div>div]:border-white"
/>
) : (
<div className="relative border shadow-lg w-8 h-8 rounded-full overflow-hidden">
<Image
src={`https://github.com/${profile?.username}.png` || ''}
width={30}
height={30}
alt="avatar"
className="relative"
/>
</div>
)
}, [role])
if (!content) return null
return (
<div className="flex flex-col py-4 gap-4 border-t px-5">
<div className="flex flex-row gap-3 items-center">
{icon}
<span className="text-sm">{role === 'assistant' ? 'Assistant' : name ? name : 'You'}</span>
{createdAt && (
<span className="text-xs text-foreground-muted">{dayjs(createdAt * 1000).fromNow()}</span>
)}
{isDebug && <Badge color="amber">Debug request</Badge>}
</div>
<ReactMarkdown
className="gap-2.5 flex flex-col"
components={{
p: ({ children }) => <div className="text-foreground-light text-sm">{children}</div>,
// intentionally rendering as pre. The other approach would be to render as code element,
// but that will render <code> 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 (
<div
className="rounded-md border border-control overflow-hidden relative group"
key={key}
>
<CodeEditor
id={`rls-sql_${key}`}
language="pgsql"
className="h-48"
value={formatted}
autofocus={false}
options={{
scrollBeyondLastLine: false,
lineDecorationsWidth: 0,
lineNumbersMinChars: 3,
}}
/>
<div className="absolute top-3 right-3 bg-surface-100 border-muted border rounded-lg h-[28px] hidden group-hover:block">
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button type="text" size="tiny" onClick={() => onDiff(formatted)}>
<FileDiff className="h-4 w-4" />
</Button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background',
].join(' ')}
>
<span className="text-xs text-foreground">Apply changes</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button
type="text"
size="tiny"
onClick={() => navigator.clipboard.writeText(formatted).then()}
>
<Copy className="h-4 w-4" />
</Button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background',
].join(' ')}
>
<span className="text-xs text-foreground">Copy code</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</div>
</div>
)
},
}}
>
{content}
</ReactMarkdown>
</div>
)
})
export default Message
@@ -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 (
<div className="flex flex-col gap-y-3 px-5">
<Alert_Shadcn_ variant="destructive">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd"
/>
</svg>
<div className="flex flex-col gap-3">
<AlertTitle_Shadcn_ className="m-0">Error running SQL query</AlertTitle_Shadcn_>
<Collapsible_Shadcn_
defaultOpen
className="flex flex-col gap-3"
open={open}
onOpenChange={() => setOpen(!open)}
>
<div className="flex gap-2">
<CollapsibleTrigger_Shadcn_ asChild>
<Button
size={'tiny'}
type="outline"
className={cn('group', styles['ai-icon__container--allow-hover-effect'])}
>
{open ? 'Hide error details' : 'Show error details'}
</Button>
</CollapsibleTrigger_Shadcn_>
<Button
size={'tiny'}
type="default"
className={cn(
'group',
styles['ai-icon__container--allow-hover-effect h-[21px] !py-0']
)}
// icon={
// <AiIcon className="scale-50 [&>div>div]:border-warning/50 [&>div>div]:group-hover:border-warning" />
// }
onClick={() => onSelectDebug()}
>
Fix with Assistant
</Button>
</div>
<CollapsibleContent_Shadcn_ className="overflow-auto">
{formattedError.length > 0 ? (
formattedError.map((x: string, i: number) => (
<pre key={`error-${i}`} className="font-mono text-xs overflow">
{x.split(' ').map((x: string, i: number) => (
<span
className={cn(x === 'ERROR:' && 'text-destructive')}
key={`error-${i}-${x}`}
>
{x}{' '}
</span>
))}
</pre>
))
) : (
<p className="font-mono text-xs">{error.error}</p>
)}
</CollapsibleContent_Shadcn_>
</Collapsible_Shadcn_>
</div>
</Alert_Shadcn_>
<div className="overflow-x-auto"></div>
</div>
)
}
export default QueryError
@@ -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<editor.IStandaloneCodeEditor | null>
}
// 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
&nbsp;\n
-- Docs: https://www.postgresql.org/docs/current/sql-createpolicy.html
`.trim()
const RLSCodeEditor = ({
id,
defaultValue,
wrapperClassName,
className,
value,
editorRef,
}: RLSCodeEditorProps) => {
const hasValue = useRef<any>()
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('&nbsp;', ''),
},
])
},
'!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 (
<>
<Editor
path={id}
theme="supabase"
wrapperProps={{ className: cn(wrapperClassName) }}
className={cn(className, 'monaco-editor')}
value={value ?? undefined}
defaultLanguage="pgsql"
defaultValue={defaultValue ?? undefined}
options={options}
onMount={onMount}
onChange={onChange}
/>
<div
className="monaco-placeholder absolute top-[3px] left-[57px] text-sm pointer-events-none font-mono [&>div>p]:text-foreground-lighter [&>div>p]:!m-0 tracking-tighter"
style={{ display: 'none' }}
>
<Markdown content={placeholderText} />
</div>
</>
)
}
export default RLSCodeEditor
@@ -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<IStandaloneCodeEditor | null>(null)
const diffEditorRef = useRef<IStandaloneDiffEditor | null>(null)
const [error, setError] = useState<QueryResponseError>()
// [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<ThreadMessage[]>([])
const [assistantVisible, setAssistantPanel] = useState(false)
const [ids, setIds] = useState<{ threadId: string; runId: string } | undefined>(undefined)
const [isAssistantChatInputEmpty, setIsAssistantChatInputEmpty] = useState(true)
const [incomingChange, setIncomingChange] = useState<string | undefined>(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 (
<>
<Sheet_Shadcn_ open={visible} onOpenChange={() => onClosingPanel()}>
<SheetContent_Shadcn_
size="lg"
className={cn('p-0 flex flex-row gap-0', assistantVisible && '!min-w-[1024px]')}
>
<div className={cn('flex flex-col grow w-full', assistantVisible && 'w-[60%]')}>
<AIPolicyHeader
assistantVisible={assistantVisible}
setAssistantVisible={setAssistantPanel}
/>
<div className="flex flex-col h-full w-full justify-between">
{incomingChange ? (
<div className="px-5 py-3 flex justify-between gap-3 bg-muted">
<div className="flex gap-2 items-center text-foreground-light">
<FileDiff className="h-4 w-4" />
<span className="text-sm">Apply changes from assistant</span>
</div>
<div className="flex gap-3">
<Button type="default" onClick={() => setIncomingChange(undefined)}>
Discard
</Button>
<Button type="primary" onClick={() => acceptChange()}>
Apply
</Button>
</div>
</div>
) : null}
{incomingChange ? (
<DiffEditor
theme="supabase"
language="pgsql"
className="flex grow"
original={editorRef.current?.getValue()}
modified={incomingChange}
onMount={(editor) => (diffEditorRef.current = editor)}
options={{
renderSideBySide: false,
scrollBeyondLastLine: false,
renderOverviewRuler: false,
}}
/>
) : null}
<div
// [Joshen] Not the cleanest but its to force the editor to re-render its height
// for now, till we can find a better solution
className={`relative ${incomingChange ? 'hidden' : 'block'}`}
style={{
height:
error === undefined
? 'calc(100vh - 58px - 54px)'
: `calc(100vh - 58px - 151px - ${20 * errorLines}px)`,
}}
>
<RLSCodeEditor
id="rls-sql-policy"
wrapperClassName={incomingChange ? '!hidden' : ''}
defaultValue={''}
editorRef={editorRef}
/>
</div>
<div className="flex flex-col">
{error !== undefined && <QueryError error={error} onSelectDebug={onSelectDebug} />}
<SheetFooter_Shadcn_ className="flex flex-col gap-12 px-5 py-4 w-full">
<div className="flex justify-end gap-x-2">
<Button type="default" disabled={isExecuting} onClick={() => onSelectCancel()}>
Cancel
</Button>
<Button
loading={isExecuting}
htmlType="submit"
disabled={isExecuting || incomingChange !== undefined}
onClick={() => createNewPolicy()}
>
Save policy
</Button>
</div>
</SheetFooter_Shadcn_>
</div>
</div>
</div>
{assistantVisible && (
<div className={cn('flex border-l grow w-full', assistantVisible && 'w-[40%]')}>
<AIPolicyChat
messages={messages}
onSubmit={(message: string) => addPrompt(message)}
onDiff={onDiff}
onChange={setIsAssistantChatInputEmpty}
loading={loading || data?.status === 'loading'}
/>
</div>
)}
<ConfirmationModal
visible={isClosingPolicyEditorPanel}
header="Discard changes"
buttonLabel="Discard"
onSelectCancel={() => setIsClosingPolicyEditorPanel(false)}
onSelectConfirm={() => {
onSelectCancel()
setIsClosingPolicyEditorPanel(false)
}}
>
<Modal.Content>
<p className="py-4 text-sm text-foreground-light">
Are you sure you want to close the editor? Any unsaved changes on your policy and
conversations with the Assistant will be lost.
</p>
</Modal.Content>
</ConfirmationModal>
</SheetContent_Shadcn_>
</Sheet_Shadcn_>
</>
)
})
AIPolicyEditorPanel.displayName = 'AIPolicyEditorPanel'
@@ -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<any>({})
const [selectedTableToToggleRLS, setSelectedTableToToggleRLS] = useState<any>({})
const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState<any>({})
const [RLSEditorWithAIShown, showRLSEditorWithAI] = useState(false)
const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState<PostgresPolicy | {}>({})
const [selectedPolicyToDelete, setSelectedPolicyToDelete] = useState<any>({})
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}
/>
@@ -31,8 +31,8 @@ interface PolicyEditorModalProps {
table: string
selectedPolicyToEdit: any
onSelectCancel: () => void
onCreatePolicy: (payload: PostgresPolicyCreatePayload) => boolean
onUpdatePolicy: (payload: PostgresPolicyUpdatePayload) => boolean
onCreatePolicy: (payload: PostgresPolicyCreatePayload) => Promise<boolean>
onUpdatePolicy: (payload: PostgresPolicyUpdatePayload) => Promise<boolean>
onSaveSuccess: () => void
}
@@ -43,8 +43,8 @@ const PolicyEditorModal = ({
table = '',
selectedPolicyToEdit = {},
onSelectCancel = noop,
onCreatePolicy = () => false,
onUpdatePolicy = () => false,
onCreatePolicy,
onUpdatePolicy,
onSaveSuccess = noop,
}: PolicyEditorModalProps) => {
const { ui } = useStore()
@@ -84,7 +84,7 @@ const PolicyRow = ({
icon={<IconMoreVertical />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuContent side="bottom" align="end" className="w-32">
<DropdownMenuItem className="space-x-2" onClick={() => onSelectEditPolicy(policy)}>
<IconEdit size={14} />
<p>Edit</p>
@@ -22,12 +22,14 @@ const prism = {
interface SimpleCodeBlockProps {
className?: string
metastring?: string
showCopy?: boolean
}
const SimpleCodeBlock = ({
children,
className: languageClassName,
metastring,
showCopy = true,
}: PropsWithChildren<SimpleCodeBlockProps>) => {
const [showCopied, setShowCopied] = useState(false)
const target = useRef(null)
@@ -82,11 +84,13 @@ const SimpleCodeBlock = ({
)
})}
</pre>
<div className="invisible absolute right-0 top-0 opacity-0 transition-opacity group-hover:visible group-hover:opacity-100">
<Button size="tiny" type="default" onClick={() => handleCopyCode(children)}>
{showCopied ? 'Copied' : 'Copy'}
</Button>
</div>
{showCopy && (
<div className="invisible absolute right-0 top-0 opacity-0 transition-opacity group-hover:visible group-hover:opacity-100">
<Button size="tiny" type="default" onClick={() => handleCopyCode(children)}>
{showCopied ? 'Copied' : 'Copy'}
</Button>
</div>
)}
</div>
)
}}
@@ -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<any>()
const onMount = async (editor: any, monaco: any) => {
const onMount: OnMount = async (editor, monaco) => {
editorRef.current = editor
alignEditor(editor)
+3
View File
@@ -0,0 +1,3 @@
export const aiKeys = {
rlsSuggest: (threadId: string, runId: string) => [threadId, runId] as const,
}
@@ -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<RlsSuggestResponse>(BASE_PATH + '/api/ai/sql/suggest', {
thread_id,
entityDefinitions,
prompt,
})
if (!isResponseOk(response)) {
throw response.error
}
return response
}
type RlsSuggestData = Awaited<ReturnType<typeof rlsSuggest>>
export const useRlsSuggestMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<RlsSuggestData, ResponseError, RlsSuggestVariables>,
'mutationFn'
> = {}) => {
return useMutation<RlsSuggestData, ResponseError, RlsSuggestVariables>(
(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,
}
)
}
+37
View File
@@ -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<ReturnType<typeof rlsSuggest>>
export type RlsSuggestDataError = ResponseError
export const useRlsSuggestQuery = <TData = RlsSuggestData>(
{ thread_id, run_id }: RlsSuggestVariables,
options: UseQueryOptions<RlsSuggestData, RlsSuggestDataError, TData> = {}
) =>
useQuery<RlsSuggestData, RlsSuggestDataError, TData>(
aiKeys.rlsSuggest(thread_id, run_id),
({ signal }) => rlsSuggest({ thread_id, run_id }, signal),
options
)
+10
View File
@@ -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<ReturnType<typeof debugSql>>
export const useSqlDebugMutation = ({
onSuccess,
onError,
...options
}: Omit<UseMutationOptions<SqlDebugData, ResponseError, SqlDebugVariables>, 'mutationFn'> = {}) => {
return useMutation<SqlDebugData, ResponseError, SqlDebugVariables>((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,
})
}
+10
View File
@@ -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<ReturnType<typeof editSql>>
export const useSqlEditMutation = ({
onSuccess,
onError,
...options
}: Omit<UseMutationOptions<SqlEditData, ResponseError, SqlEditVariables>, 'mutationFn'> = {}) => {
return useMutation<SqlEditData, ResponseError, SqlEditVariables>((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,
})
}
@@ -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<ReturnType<typeof generateSql>>
export const useSqlGenerateMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<SqlGenerateData, ResponseError, SqlGenerateVariables>,
@@ -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,
}
)
+10
View File
@@ -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<ReturnType<typeof generateSqlTitle>>
export const useSqlTitleGenerateMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<SqlTitleGenerateData, ResponseError, SqlTitleGenerateVariables>,
@@ -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,
}
)
+15 -4
View File
@@ -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<ExecuteSqlData, ResponseError, ExecuteSqlVariables>,
UseMutationOptions<ExecuteSqlData, QueryResponseError, ExecuteSqlVariables>,
'mutationFn'
> = {}) => {
return useMutation<ExecuteSqlData, ResponseError, ExecuteSqlVariables>(
return useMutation<ExecuteSqlData, QueryResponseError, ExecuteSqlVariables>(
(args) => executeSql(args),
{
async onSuccess(data, variables, context) {
+2 -1
View File
@@ -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",
+1 -1
View File
@@ -9,7 +9,7 @@ import type {
CreateChatCompletionRequest,
CreateChatCompletionResponse,
ErrorResponse,
} from 'openai'
} from 'openai-old'
const openAiKey = process.env.OPENAI_KEY
+1 -1
View File
@@ -9,7 +9,7 @@ import type {
CreateChatCompletionRequest,
CreateChatCompletionResponse,
ErrorResponse,
} from 'openai'
} from 'openai-old'
const openAiKey = process.env.OPENAI_KEY
+1 -1
View File
@@ -9,7 +9,7 @@ import type {
CreateChatCompletionRequest,
CreateChatCompletionResponse,
ErrorResponse,
} from 'openai'
} from 'openai-old'
const openAiKey = process.env.OPENAI_KEY
@@ -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
@@ -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
+1 -1
View File
@@ -9,7 +9,7 @@ import type {
CreateChatCompletionRequest,
CreateChatCompletionResponse,
ErrorResponse,
} from 'openai'
} from 'openai-old'
const openAiKey = process.env.OPENAI_KEY
@@ -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<string>('')
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 <NoPermission isFullPage resourceText="view this project's RLS policies" />
@@ -121,22 +127,62 @@ const AuthPoliciesPage: NextPageWithLayout = () => {
icon={<IconSearch size="tiny" />}
/>
</div>
<a
target="_blank"
rel="noreferrer"
href="https://supabase.com/docs/learn/auth-deep-dive/auth-row-level-security"
>
<Button type="link" icon={<IconExternalLink size={14} strokeWidth={1.5} />}>
What is RLS?
</Button>
</a>
<div className="flex items-center gap-x-2">
<a
target="_blank"
rel="noreferrer"
href="https://supabase.com/docs/learn/auth-deep-dive/auth-row-level-security"
>
<Button type="default" icon={<IconExternalLink size={14} strokeWidth={1.5} />}>
Documentation
</Button>
</a>
{canCreatePolicyWithAi && (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button
type="primary"
disabled={!canCreatePolicies}
onClick={() => setShowPolicyAiEditor(true)}
>
Create a new policy
</Button>
</Tooltip.Trigger>
{!canCreatePolicies && (
<Tooltip.Portal>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background',
].join(' ')}
>
<span className="text-xs text-foreground">
You need additional permissions to create RLS policies
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
)}
</Tooltip.Root>
)}
</div>
</div>
</div>
{isLoading && <GenericSkeletonLoader />}
{isError && <AlertError error={error} subject="Failed to retrieve tables" />}
{isSuccess && (
<Policies tables={filteredTables} hasTables={tables.length > 0} isLocked={isLocked} />
)}
<AIPolicyEditorPanel
visible={showPolicyAiEditor}
onSelectCancel={() => setShowPolicyAiEditor(false)}
/>
</div>
)
}
+72 -19
View File
@@ -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",
+17
View File
@@ -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'
+6 -1
View File
@@ -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<HTMLButtonElement, ButtonProps>(
@@ -161,6 +165,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
iconRight,
iconLeft,
htmlType = 'button',
rounded,
...props
},
ref
@@ -178,7 +183,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
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) ? (
@@ -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
+16 -9
View File
@@ -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<typeof portalVariants> {}
const SheetPortal = ({ position, className, children, ...props }: SheetPortalProps) => (
<SheetPrimitive.Portal className={cn(className)} {...props}>
const SheetPortal = ({ position, children, ...props }: SheetPortalProps) => (
<SheetPrimitive.Portal {...props}>
<div className={portalVariants({ position })}>{children}</div>
</SheetPrimitive.Portal>
)
@@ -42,7 +43,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, children, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in',
'fixed inset-0 z-40 bg-background/80 backdrop-blur-sm transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in',
className
)}
{...props}
@@ -52,7 +53,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
'fixed z-50 scale-100 gap-4 bg-background p-6 opacity-100 shadow-lg border',
'fixed z-40 scale-100 gap-4 bg-background p-6 opacity-100 shadow-lg border',
{
variants: {
position: {
@@ -67,6 +68,7 @@ const sheetVariants = cva(
sm: '',
lg: '',
xl: '',
xxl: '',
full: '',
},
},
@@ -124,6 +126,11 @@ const sheetVariants = cva(
{
position: ['right', 'left'],
size: 'xl',
class: 'w-4/6',
},
{
position: ['right', 'left'],
size: 'xxl',
class: 'w-5/6',
},
{
@@ -155,17 +162,17 @@ const SheetContent = React.forwardRef<
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
{/* <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Close> */}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
<div className={cn('px-5 py-4 text-center sm:text-left', className)} {...props} />
)
SheetHeader.displayName = 'SheetHeader'
@@ -183,7 +190,7 @@ const SheetTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
className={cn('text-base text-foreground', className)}
{...props}
/>
))
@@ -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);
@@ -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: `