diff --git a/apps/docs/pages/_app.tsx b/apps/docs/pages/_app.tsx index 58f7fea9f7b..b48403ea55e 100644 --- a/apps/docs/pages/_app.tsx +++ b/apps/docs/pages/_app.tsx @@ -78,7 +78,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { ( + MarkdownHandler={(props) => ( )} > diff --git a/packages/common/Providers.tsx b/packages/common/Providers.tsx index 1e27eb0f948..72da69f37c8 100644 --- a/packages/common/Providers.tsx +++ b/packages/common/Providers.tsx @@ -37,10 +37,13 @@ export const ThemeProvider = ({ children }: ThemeProviderProps) => { const toggleTheme: UseThemeProps['toggleTheme'] = (darkMode) => { const newMode = typeof darkMode === 'boolean' ? darkMode : !isDarkMode - localStorage.setItem('supabaseDarkMode', newMode.toString()) const key = localStorage.getItem('supabaseDarkMode') + const newTheme = key === 'true' ? 'dark' : 'light' + const currentTheme = isDarkMode ? 'dark' : 'light' + + document.body.classList.replace(currentTheme, newTheme) document.documentElement.className = key === 'true' ? 'dark' : '' setIsDarkMode(newMode) diff --git a/packages/ui/src/components/Command/APIKeys.tsx b/packages/ui/src/components/Command/APIKeys.tsx new file mode 100644 index 00000000000..2b51148d29d --- /dev/null +++ b/packages/ui/src/components/Command/APIKeys.tsx @@ -0,0 +1,64 @@ +import { CommandGroup } from 'cmdk-supabase' +import { useCommandMenu } from './CommandMenuProvider' +import SearchOnlyItem from './SearchOnlyItem' +import { Badge } from '../Badge' +import { CommandItem } from './Command.utils' +import { IconAlertCircle } from './../Icon/icons/IconAlertCircle' + +const APIKeys = ({ isSubItem = false }) => { + const { setIsOpen, project } = useCommandMenu() + const { apiKeys } = project ?? {} + + const copyToClipboard = (str: string, callback = () => {}) => { + const focused = window.document.hasFocus() + if (focused) { + window.navigator?.clipboard?.writeText(str).then(callback) + } else { + console.warn('Unable to copy to clipboard') + } + } + + return ( + + {apiKeys?.anon !== undefined && ( + { + copyToClipboard(apiKeys?.anon ?? '') + setIsOpen(false) + }} + className="space-x-2" + > +

Copy anonymous key

+ Public +
+ )} + {apiKeys?.service !== undefined && ( + { + copyToClipboard(apiKeys?.service ?? '') + setIsOpen(false) + }} + className="space-x-2" + > +

Copy service key

+ Secret +
+ )} + {apiKeys?.anon === undefined && apiKeys?.service === undefined && ( + + +
+

No API keys available

+

+ You may not have the necessary permissions to view the project's API keys +

+
+
+ )} +
+ ) +} + +export default APIKeys diff --git a/packages/ui/src/components/Command/AiCommand.tsx b/packages/ui/src/components/Command/AiCommand.tsx index 8065096fe3f..d8bf8cc5f7c 100644 --- a/packages/ui/src/components/Command/AiCommand.tsx +++ b/packages/ui/src/components/Command/AiCommand.tsx @@ -1,8 +1,7 @@ -import * as React from 'react' import type { ChatCompletionResponseMessage, + CreateChatCompletionResponse, CreateChatCompletionResponseChoicesInner, - CreateCompletionResponse, } from 'openai' import { useCallback, useEffect, useReducer, useRef, useState } from 'react' @@ -53,60 +52,79 @@ function getEdgeFunctionUrl() { const edgeFunctionUrl = getEdgeFunctionUrl() -function promptDataReducer( - state: any[], - action: { - index?: number - answer?: string | undefined - status?: string - query?: string | undefined - type?: 'remove-last-item' | string - } -) { - // set a standard state to use later - let current = [...state] +enum MessageRole { + User = 'user', + Assistant = 'assistant', +} - if (action.type) { - switch (action.type) { - case 'remove-last-item': - current.pop() - return [...current] - default: - break +enum MessageStatus { + Pending = 'pending', + InProgress = 'in-progress', + Complete = 'complete', +} + +interface Message { + role: MessageRole + content: string + status: MessageStatus +} + +interface NewMessageAction { + type: 'new' + message: Message +} + +interface UpdateMessageAction { + type: 'update' + index: number + message: Partial +} + +interface AppendContentAction { + type: 'append-content' + index: number + content: string +} + +type MessageAction = NewMessageAction | UpdateMessageAction | AppendContentAction + +function messageReducer(state: Message[], messageAction: MessageAction) { + let current = [...state] + const { type } = messageAction + + switch (type) { + case 'new': { + const { message } = messageAction + current.push(message) + break + } + case 'update': { + const { index, message } = messageAction + Object.assign(current[index], message) + break + } + case 'append-content': { + const { index, content } = messageAction + current[index].content += content + break + } + default: { + throw new Error(`Unknown message action '${type}'`) } } - // check that an index is present - if (action.index === undefined) return [...state] - - if (!current[action.index]) { - current[action.index] = { query: '', answer: '', status: '' } - } - - current[action.index].answer = action.answer - - if (action.query) { - current[action.index].query = action.query - } - if (action.status) { - current[action.index].status = action.status - } - - return [...current] + return current } const AiCommand = () => { - const [answer, setAnswer] = useState('') const [isResponding, setIsResponding] = useState(false) - const [hasClippyError, setHasClippyError] = useState(false) + const [hasError, setHasError] = useState(false) const eventSourceRef = useRef() const { isLoading, setIsLoading, currentPage, search, setSearch, MarkdownHandler } = useCommandMenu() - const [promptIndex, setPromptIndex] = useState(0) - const [promptData, dispatchPromptData] = useReducer(promptDataReducer, []) - - const cantHelp = answer?.trim() === "Sorry, I don't know how to help with that." + const [currentMessageIndex, setCurrentMessageIndex] = useState(1) + const [messages, dispatchMessage] = useReducer(messageReducer, []) const handleConfirm = useCallback( async (query: string) => { @@ -114,11 +132,25 @@ const AiCommand = () => { return console.error('No edge function url') } - setAnswer(undefined) setSearch('') - dispatchPromptData({ index: promptIndex, answer: undefined, query }) + dispatchMessage({ + type: 'new', + message: { + status: MessageStatus.Complete, + role: MessageRole.User, + content: query, + }, + }) + dispatchMessage({ + type: 'new', + message: { + status: MessageStatus.Pending, + role: MessageRole.Assistant, + content: '', + }, + }) setIsResponding(false) - setHasClippyError(false) + setHasError(false) setIsLoading(true) let queryToSend = query @@ -150,13 +182,18 @@ const AiCommand = () => { Authorization: `Bearer ${process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY}`, 'Content-Type': 'application/json', }, - payload: JSON.stringify({ query, context: promptData }), + payload: JSON.stringify({ + messages: messages + .filter(({ status }) => status === MessageStatus.Complete) + .map(({ role, content }) => ({ role, content })) + .concat({ role: MessageRole.User, content: query }), + }), }) function handleError(err: T) { setIsLoading(false) setIsResponding(false) - setHasClippyError(true) + setHasError(true) console.error(err) } @@ -167,29 +204,40 @@ const AiCommand = () => { if (e.data === '[DONE]') { setIsResponding(false) - setAnswer(undefined) - setPromptIndex((x) => { - return x + 1 + dispatchMessage({ + type: 'update', + index: currentMessageIndex, + message: { + status: MessageStatus.Complete, + }, }) + setCurrentMessageIndex((x) => x + 2) return } + dispatchMessage({ + type: 'update', + index: currentMessageIndex, + message: { + status: MessageStatus.InProgress, + }, + }) + setIsResponding(true) - const completionResponse: CreateCompletionResponse = JSON.parse(e.data) - const [{ text: content }] = completionResponse.choices + const completionResponse: CreateChatCompletionResponse = JSON.parse(e.data) + const [ + { + delta: { content }, + }, + ] = completionResponse.choices as CreateChatCompletionResponseChoicesInnerDelta[] const text = content ?? '' - setAnswer((answer) => { - const currentAnswer = answer ?? '' - - dispatchPromptData({ - index: promptIndex, - answer: currentAnswer + text, - }) - - return (answer ?? '') + text + dispatchMessage({ + type: 'append-content', + index: currentMessageIndex, + content: text, }) } catch (err) { handleError(err) @@ -202,16 +250,15 @@ const AiCommand = () => { setIsLoading(true) }, - [promptIndex, promptData] + [currentMessageIndex, messages] ) function handleResetPrompt() { eventSourceRef.current?.close() eventSourceRef.current = undefined setSearch('') - setAnswer(undefined) setIsResponding(false) - setHasClippyError(false) + setHasError(false) } useEffect(() => { @@ -223,50 +270,37 @@ const AiCommand = () => { return (
e.stopPropagation()}>
- {promptData.map((prompt, i) => { - if (!prompt.query) return <> - - return ( - <> - {prompt.query && ( -
+ {messages.map((message, index) => { + switch (message.role) { + case MessageRole.User: + return ( +
-
{prompt.query}
+
{message.content}
- )} - -
- {cantHelp ? ( -

-

- -

Sorry, I don't know how to help with that.

-
- -

- ) : ( + ) + case MessageRole.Assistant: + return ( +
<> - {isLoading && promptIndex === i ? ( + {message.status === MessageStatus.Pending ? (
) : ( - // @ts-expect-error { + transformLinkUri={(href) => { const supabaseUrl = new URL('https://supabase.com') const linkUrl = new URL(href, 'https://supabase.com') @@ -277,18 +311,17 @@ const AiCommand = () => { return href }} > - {prompt.answer} + {message.content} )}
- )} -
- - ) +
+ ) + } })} - {promptData.length === 0 && !hasClippyError && ( + {messages.length === 0 && !hasError && ( {questions.map((question) => { const key = question.replace(/\s+/g, '_') @@ -310,7 +343,7 @@ const AiCommand = () => { })} )} - {hasClippyError && ( + {hasError && (

diff --git a/packages/ui/src/components/Command/Command.constants.ts b/packages/ui/src/components/Command/Command.constants.ts index b73917ac59f..5ff9d5de760 100644 --- a/packages/ui/src/components/Command/Command.constants.ts +++ b/packages/ui/src/components/Command/Command.constants.ts @@ -5,6 +5,7 @@ export const COMMAND_ROUTES = { THEME: 'Theme', AI_ASK_ANYTHING: 'Ask anything', AI_RLS_POLICY: 'Help me create a RLS policy', + API_KEYS: 'Project API keys', } type QueryCategory = { diff --git a/packages/ui/src/components/Command/CommandMenu.tsx b/packages/ui/src/components/Command/CommandMenu.tsx index 4c1a0e99d30..ccbc505b73f 100644 --- a/packages/ui/src/components/Command/CommandMenu.tsx +++ b/packages/ui/src/components/Command/CommandMenu.tsx @@ -1,4 +1,3 @@ -import { useCommandState } from 'cmdk-supabase' import { useRouter } from 'next/router' import * as React from 'react' import { ElementRef, useRef } from 'react' @@ -12,6 +11,7 @@ import { IconLifeBuoy } from './../Icon/icons/IconLifeBuoy' import { IconMonitor } from './../Icon/icons/IconMonitor' import { IconPhone } from './../Icon/icons/IconPhone' import { IconUser } from './../Icon/icons/IconUser' +import { IconKey } from './../Icon/icons/IconKey' import AiCommand from './AiCommand' import sharedItems from './utils/shared-nav-items.json' @@ -23,15 +23,16 @@ import { CommandItem, CommandLabel, CommandList, - CommandShortcut, } from './Command.utils' -import { useCommandMenu } from './CommandMenuProvider' -import DocsSearch from './DocsSearch' -import CommandMenuShortcuts from './CommandMenuShortcuts' -import SearchOnlyItem from './SearchOnlyItem' -import SearchableStudioItems from './SearchableStudioItems' import { COMMAND_ROUTES } from './Command.constants' +import { useCommandMenu } from './CommandMenuProvider' + +import DocsSearch from './DocsSearch' import GenerateSQL from './GenerateSQL' +import ThemeOptions from './ThemeOptions' +import APIKeys from './APIKeys' +import SearchableStudioItems from './SearchableStudioItems' +import CommandMenuShortcuts from './CommandMenuShortcuts' export const CHAT_ROUTES = [ COMMAND_ROUTES.AI, // this one is temporary @@ -60,34 +61,8 @@ const CommandMenu = ({ projectRef }: CommandMenuProps) => { const router = useRouter() const commandInputRef = useRef>(null) - const { isOpen, setIsOpen, actions, search, setSearch, pages, setPages, currentPage, site } = + const { isOpen, setIsOpen, search, setSearch, pages, setPages, currentPage, site } = useCommandMenu() - - const ThemeOptions = ({ isSubItem = false }) => { - return ( - - { - actions.toggleTheme(true) - setIsOpen(false) - }} - > - Change Theme to dark - - { - actions.toggleTheme(false) - setIsOpen(false) - }} - > - Change Theme to light - - - ) - } - const showCommandInput = !currentPage || !CHAT_ROUTES.includes(currentPage) return ( @@ -194,7 +169,7 @@ const CommandMenu = ({ projectRef }: CommandMenuProps) => { )} - {site === 'studio' && [ + {site === 'studio' && ( { Generate SQL with Supabase AI - , + + )} + + {site === 'studio' && projectRef !== undefined && ( + + setPages([...pages, COMMAND_ROUTES.API_KEYS])} + > + + Get API keys + + + )} + + {site === 'studio' && ( {sharedItems.tools.map((item) => { const itemUrl = ( @@ -220,8 +211,8 @@ const CommandMenu = ({ projectRef }: CommandMenuProps) => { ) })} - , - ]} + + )} {/* */} @@ -246,12 +237,14 @@ const CommandMenu = ({ projectRef }: CommandMenuProps) => { ))} )} + setPages([...pages, 'Theme'])}> Change theme + {site === 'studio' && search && } @@ -260,6 +253,7 @@ const CommandMenu = ({ projectRef }: CommandMenuProps) => { {currentPage === COMMAND_ROUTES.DOCS_SEARCH && } {currentPage === COMMAND_ROUTES.GENERATE_SQL && } {currentPage === COMMAND_ROUTES.THEME && } + {currentPage === COMMAND_ROUTES.API_KEYS && } diff --git a/packages/ui/src/components/Command/CommandMenuProvider.tsx b/packages/ui/src/components/Command/CommandMenuProvider.tsx index 0c5d9bbe2cc..3024ee15991 100644 --- a/packages/ui/src/components/Command/CommandMenuProvider.tsx +++ b/packages/ui/src/components/Command/CommandMenuProvider.tsx @@ -2,6 +2,7 @@ import { useTheme, UseThemeProps } from 'common' import * as React from 'react' import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' import CommandMenu from './CommandMenu' +import { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown' export interface CommandMenuContextValue { isOpen: boolean @@ -16,9 +17,12 @@ export interface CommandMenuContextValue { currentPage?: string site: 'studio' | 'docs' + // Project metadata for easy retrieval + project?: { ref?: string; apiKeys?: { anon?: string; service?: string } } + // to do: remove this prop // this is a temporary hack as ReactMarkdown fails our jest tests if we import the package within this UI package - MarkdownHandler: (props: any) => React.ReactNode // to do: remove this. although it breaks our jest tests + MarkdownHandler: (props: ReactMarkdownOptions) => JSX.Element // to do: remove this. although it breaks our jest tests // Optional callback to save a generated SQL output onSaveGeneratedSQL?: (answer: string, resolve: any) => void @@ -41,9 +45,10 @@ export interface CommandMenuActions { export interface CommandMenuProviderProps { site: 'studio' | 'docs' projectRef?: string + apiKeys?: { anon?: string; service?: string } // to do: remove this prop // this is a temporary hack as ReactMarkdown fails our jest tests if we import the package within this UI package - MarkdownHandler: (props: any) => React.ReactNode + MarkdownHandler: (props: ReactMarkdownOptions) => JSX.Element onSaveGeneratedSQL?: (answer: string, resolve: any) => void } @@ -51,6 +56,7 @@ const CommandMenuProvider = ({ children, site, projectRef, + apiKeys, MarkdownHandler, onSaveGeneratedSQL, }: PropsWithChildren) => { @@ -61,9 +67,8 @@ const CommandMenuProvider = ({ const { toggleTheme } = useTheme() const currentPage = pages[pages.length - 1] - const actions: CommandMenuActions = { - toggleTheme, - } + const actions: CommandMenuActions = { toggleTheme } + const project = projectRef !== undefined ? { ref: projectRef, apiKeys } : undefined useKeyboardEvents({ setIsOpen, currentPage, setSearch, setPages }) @@ -81,6 +86,7 @@ const CommandMenuProvider = ({ setPages, currentPage, site, + project, MarkdownHandler, onSaveGeneratedSQL, }} diff --git a/packages/ui/src/components/Command/DocsSearch.tsx b/packages/ui/src/components/Command/DocsSearch.tsx index 64225778084..24df475b590 100644 --- a/packages/ui/src/components/Command/DocsSearch.tsx +++ b/packages/ui/src/components/Command/DocsSearch.tsx @@ -57,9 +57,12 @@ const DocsSearch = () => { setHasSearchError(false) setIsLoading(true) - const { error, data: pageSections } = await supabaseClient.functions.invoke('search', { - body: { query }, - }) + const { error, data: pageResults } = await supabaseClient.functions.invoke( + 'search', + { + body: { query }, + } + ) setIsLoading(false) @@ -71,14 +74,14 @@ const DocsSearch = () => { return } - if (!Array.isArray(pageSections)) { + if (!Array.isArray(pageResults)) { setIsLoading(false) setHasSearchError(true) console.error('Malformed response') return } - setResults(pageSections) + setResults(pageResults) }, [supabaseClient] ) diff --git a/packages/ui/src/components/Command/GenerateSQL.tsx b/packages/ui/src/components/Command/GenerateSQL.tsx index 599d44f7bd3..e63f8cf8a96 100644 --- a/packages/ui/src/components/Command/GenerateSQL.tsx +++ b/packages/ui/src/components/Command/GenerateSQL.tsx @@ -3,7 +3,6 @@ import type { CreateCompletionResponse } from 'openai' import { useCallback, useEffect, useReducer, useRef, useState } from 'react' import { Button, - CodeBlock, IconAlertCircle, IconAlertTriangle, IconCheck, @@ -16,10 +15,9 @@ import { import { cn } from './../../utils/cn' import { AiIcon, AiIconChat } from './Command.icons' -import { CommandGroup, CommandItem } from './Command.utils' +import { CommandItem } from './Command.utils' import { useCommandMenu } from './CommandMenuProvider' import CopyToClipboard from 'react-copy-to-clipboard' -import { noop } from 'lodash' import { SAMPLE_QUERIES } from './Command.constants' function getEdgeFunctionUrl() { @@ -80,17 +78,13 @@ function promptDataReducer( return [...current] } -const SQLOutputActions = ({ - answer, - onSaveGeneratedSQL, -}: { - answer: string - onSaveGeneratedSQL?: (answer: string, resolve: any) => void -}) => { +const SQLOutputActions = ({ answer }: { answer: string }) => { const [showCopied, setShowCopied] = useState(false) const [isSaving, setIsSaving] = useState(false) const [isSaved, setIsSaved] = useState(false) + const { project, onSaveGeneratedSQL } = useCommandMenu() + const applyCallback = () => onSaveGeneratedSQL !== undefined ? new Promise((resolve) => onSaveGeneratedSQL(answer, resolve)) @@ -132,7 +126,7 @@ const SQLOutputActions = ({ {showCopied ? 'Copied' : 'Copy SQL'} - {onSaveGeneratedSQL !== undefined && ( + {project?.ref !== undefined && onSaveGeneratedSQL !== undefined && (

) : (
- {/* @ts-expect-error */} {prompt.answer} - {!isResponding && ( - - )} + {!isResponding && }
)} diff --git a/packages/ui/src/components/Command/SearchOnlyItem.tsx b/packages/ui/src/components/Command/SearchOnlyItem.tsx index 33f43ff78cc..82cd3d16314 100644 --- a/packages/ui/src/components/Command/SearchOnlyItem.tsx +++ b/packages/ui/src/components/Command/SearchOnlyItem.tsx @@ -1,5 +1,4 @@ import { useCommandState } from 'cmdk-supabase' -import React from 'react' import { CommandItem } from './Command.utils' export default function SearchOnlyItem({ children, isSubItem, ...props }: any) { diff --git a/packages/ui/src/components/Command/ThemeOptions.tsx b/packages/ui/src/components/Command/ThemeOptions.tsx new file mode 100644 index 00000000000..1e20213bd70 --- /dev/null +++ b/packages/ui/src/components/Command/ThemeOptions.tsx @@ -0,0 +1,31 @@ +import { CommandGroup } from 'cmdk-supabase' +import { useCommandMenu } from './CommandMenuProvider' +import SearchOnlyItem from './SearchOnlyItem' + +const ThemeOptions = ({ isSubItem = false }) => { + const { setIsOpen, actions } = useCommandMenu() + return ( + + { + actions.toggleTheme(true) + setIsOpen(false) + }} + > + Change Theme to dark + + { + actions.toggleTheme(false) + setIsOpen(false) + }} + > + Change Theme to light + + + ) +} + +export default ThemeOptions diff --git a/studio/components/interfaces/App/CommandMenuWrapper.tsx b/studio/components/interfaces/App/CommandMenuWrapper.tsx index cb92312132e..e4038a72b49 100644 --- a/studio/components/interfaces/App/CommandMenuWrapper.tsx +++ b/studio/components/interfaces/App/CommandMenuWrapper.tsx @@ -8,17 +8,24 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useProfileQuery } from 'data/profile/profile-query' import { uuidv4 } from 'lib/helpers' import { createSqlSnippetSkeleton } from 'components/to-be-cleaned/SqlEditor/SqlEditor.utils' +import { useProjectApiQuery } from 'data/config/project-api-query' const CommandMenuWrapper = ({ children }: PropsWithChildren<{}>) => { const { ref } = useParams() const { content } = useStore() const { data: profile } = useProfileQuery() + const { data: settings } = useProjectApiQuery({ projectRef: ref }) // const canCreateSQLSnippet = checkPermissions(PermissionAction.CREATE, 'user_content', { // resource: { type: 'sql', owner_id: profile?.id }, // subject: { id: profile?.id }, // }) + const apiKeys = { + anon: settings?.autoApiService?.defaultApiKey ?? undefined, + service: settings?.autoApiService?.serviceApiKey ?? undefined, + } + const onSaveGeneratedSQL = async (answer: string, resolve: any) => { // remove backticks from returned answer answer = answer.replace(/`/g, '') @@ -46,9 +53,8 @@ ${answer} ( - - )} + apiKeys={apiKeys} + MarkdownHandler={(props) => } onSaveGeneratedSQL={onSaveGeneratedSQL} > {children} diff --git a/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx b/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx index 014c060780f..a93c531cbdd 100644 --- a/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx @@ -8,10 +8,12 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { checkPermissions, useStore } from 'hooks' import { useParams } from 'common/hooks' import { BASE_PATH } from 'lib/constants' +import { useTheme } from 'common' const WrappersDisabledState = () => { const { ui, meta } = useStore() const { ref } = useParams() + const { isDarkMode } = useTheme() const wrappersExtension = meta.extensions.byId('wrappers') const vaultExtension = meta.extensions.byId('supabase_vault') const isNotAvailable = wrappersExtension === undefined || vaultExtension === undefined @@ -66,7 +68,7 @@ const WrappersDisabledState = () => { style={{ backgroundSize: '45%', backgroundPosition: '105% 40%', - backgroundImage: ui.isDarkTheme + backgroundImage: isDarkMode ? `url("${BASE_PATH}/img/wrappers-dark.png")` : `url("${BASE_PATH}/img/wrappers-light.png")`, }} diff --git a/studio/components/interfaces/Home/ExampleProject.tsx b/studio/components/interfaces/Home/ExampleProject.tsx index 32f76b8bfaf..94be8f388d4 100644 --- a/studio/components/interfaces/Home/ExampleProject.tsx +++ b/studio/components/interfaces/Home/ExampleProject.tsx @@ -1,6 +1,5 @@ import { FC } from 'react' -import { IconChevronRight } from 'ui' -import { useStore } from 'hooks' +import { IconChevronRight, useTheme } from 'ui' import Link from 'next/link' import { BASE_PATH } from 'lib/constants' @@ -12,8 +11,7 @@ interface Props { } const ExampleProject: FC = ({ framework, title, description, url }) => { - const { ui } = useStore() - const { isDarkTheme } = ui + const { isDarkMode } = useTheme() return ( @@ -32,7 +30,7 @@ const ExampleProject: FC = ({ framework, title, description, url }) => { className="transition-all group-hover:scale-110" src={`${BASE_PATH}/img/libraries/${framework.toLowerCase()}${ ['expo', 'nextjs'].includes(framework.toLowerCase()) - ? isDarkTheme + ? isDarkMode ? '-dark' : '' : '' diff --git a/studio/components/interfaces/Settings/Vault/VaultToggle.tsx b/studio/components/interfaces/Settings/Vault/VaultToggle.tsx index 510e6d66e70..192e88dd02c 100644 --- a/studio/components/interfaces/Settings/Vault/VaultToggle.tsx +++ b/studio/components/interfaces/Settings/Vault/VaultToggle.tsx @@ -8,12 +8,14 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { checkPermissions, useStore } from 'hooks' import { useParams } from 'common/hooks' import { BASE_PATH } from 'lib/constants' +import { useTheme } from 'common' interface Props {} const VaultToggle: FC = () => { const { meta, ui } = useStore() const { ref } = useParams() + const { isDarkMode } = useTheme() const [isEnabling, setIsEnabling] = useState(false) const canToggleVault = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions') @@ -64,7 +66,7 @@ const VaultToggle: FC = () => { style={{ backgroundSize: isNotAvailable ? '50%' : '40%', backgroundPosition: '100% 24%', - backgroundImage: ui.isDarkTheme + backgroundImage: isDarkMode ? `url("${BASE_PATH}/img/vault-dark.png")` : `url("${BASE_PATH}/img/vault-light.png")`, }} diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonCodeEditor.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonCodeEditor.tsx index 87ab1f2c8db..7ca0765a00a 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonCodeEditor.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonCodeEditor.tsx @@ -1,5 +1,6 @@ import Editor, { useMonaco } from '@monaco-editor/react' -import { FC, useEffect, useRef, useState } from 'react' +import { useTheme } from 'common' +import { FC, useEffect, useRef } from 'react' interface Props { queryId?: string @@ -16,31 +17,29 @@ const JsonEditor: FC = ({ }) => { const monaco = useMonaco() const editorRef = useRef() - const [theme, setTheme] = useState(localStorage.getItem('theme')) + const { isDarkMode } = useTheme() useEffect(() => { if (monaco) { - const darkTheme = theme ? (theme === 'dark' ? true : false) : true - // Supabase theming (Can't seem to get it to work for now) monaco.editor.defineTheme('supabase', { base: 'vs-dark', // can also be vs-dark or hc-black inherit: true, // can also be false to completely replace the builtin rules rules: [ // @ts-ignore - { background: darkTheme ? '1f1f1f' : '30313f' }, + { background: isDarkMode ? '1f1f1f' : '30313f' }, { token: 'string.sql', foreground: '24b47e' }, { token: 'comment', foreground: '666666' }, { token: 'predefined.sql', foreground: 'D4D4D4' }, ], colors: { - 'editor.background': darkTheme ? '#1f1f1f' : '#30313f', + 'editor.background': isDarkMode ? '#1f1f1f' : '#30313f', // 'editorGutter.background': '#30313f', // 'editorLineNumber.foreground': '#555671', }, }) } - }, [theme, monaco]) + }, [isDarkMode, monaco]) const onMount = (editor: any, monaco: any) => { editorRef.current = editor diff --git a/studio/components/interfaces/TableGridEditor/ViewDefinition.tsx b/studio/components/interfaces/TableGridEditor/ViewDefinition.tsx new file mode 100644 index 00000000000..e9269cc423f --- /dev/null +++ b/studio/components/interfaces/TableGridEditor/ViewDefinition.tsx @@ -0,0 +1,77 @@ +import { useRef } from 'react' +import Editor from '@monaco-editor/react' +import { timeout } from 'lib/helpers' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useViewDefinitionQuery } from 'data/database/view-definition-query' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { useTheme } from 'common' + +export interface ViewDefinitionProps { + name: string +} + +const ViewDefinition = ({ name }: ViewDefinitionProps) => { + const editorRef = useRef(null) + const monacoRef = useRef(null) + + const { isDarkMode } = useTheme() + + const { project } = useProjectContext() + const { data: definition, isLoading } = useViewDefinitionQuery({ + name, + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const handleEditorOnMount = async (editor: any, monaco: any) => { + editorRef.current = editor + monacoRef.current = monaco + + // add margin above first line + editor.changeViewZones((accessor: any) => { + accessor.addZone({ + afterLineNumber: 0, + heightInPx: 4, + domNode: document.createElement('div'), + }) + }) + + // when editor did mount, it will need a delay before focus() works properly + await timeout(500) + editor?.focus() + } + + if (isLoading) { + return ( +
+ + + +
+ ) + } + + return ( +
+ +
+ ) +} + +export default ViewDefinition diff --git a/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx b/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx index 3d534b80502..4c5de0c7e4d 100644 --- a/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx +++ b/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx @@ -14,15 +14,17 @@ import { } from './NavigationBar.utils' import NavigationIconButton from './NavigationIconButton' import { useParams } from 'common/hooks' +import { useTheme } from 'common' interface Props {} const NavigationBar: FC = ({}) => { const router = useRouter() - const { ref: projectRef } = useParams() const { ui } = useStore() - const projectBaseInfo = ui.selectedProjectBaseInfo + const { isDarkMode, toggleTheme } = useTheme() + const { ref: projectRef } = useParams() + const projectBaseInfo = ui.selectedProjectBaseInfo const ongoingIncident = useFlag('ongoingIncident') const activeRoute = router.pathname.split('/')[3] @@ -102,10 +104,11 @@ const NavigationBar: FC = ({}) => { Theme ui.onThemeOptionChange(e)} + value={isDarkMode ? 'dark' : 'light'} + onChange={(e: any) => toggleTheme(e === 'dark')} > - System default + {/* [Joshen] Removing system default for now, needs to be supported in useTheme from common packages */} + {/* System default */} Dark Light diff --git a/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx b/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx index 7de8e7cfaf3..0452dd01118 100644 --- a/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx +++ b/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx @@ -2,8 +2,8 @@ import { observer } from 'mobx-react-lite' import Image from 'next/image' import Link from 'next/link' import { PropsWithChildren } from 'react' -import { useStore } from 'hooks' import { BASE_PATH } from 'lib/constants' +import { useTheme } from 'common' type ForgotPasswordLayoutProps = { heading: string @@ -19,9 +19,7 @@ const ForgotPasswordLayout = ({ showHeadings = true, children, }: PropsWithChildren) => { - const { - ui: { theme }, - } = useStore() + const { isDarkMode } = useTheme() return (
@@ -33,7 +31,7 @@ const ForgotPasswordLayout = ({ ) => { const pushNext = usePushNext() - const { ui } = useStore() const queryClient = useQueryClient() - const { theme } = ui + const { isDarkMode } = useTheme() useEffect(() => { const searchParams = new URLSearchParams(location.search) @@ -97,7 +97,7 @@ const SignInLayout = ({ @@ -66,7 +65,7 @@ export default function Introduction({ autoApiService, selectedLang }: Props) { > (undefined!) @@ -24,29 +25,18 @@ interface StoreProvider { export const StoreProvider: FC = ({ children, rootStore }) => { const monaco = useMonaco() const { ui } = rootStore - const { theme } = ui + const { isDarkMode } = useTheme() useEffect(() => { if (monaco) { - const theme: any = getTheme(ui.isDarkTheme) + const theme: any = getTheme(isDarkMode) monaco.editor.defineTheme('supabase', theme) } - }, [theme, monaco]) - - const matchMediaEvent = useCallback(() => { - ui.themeOption === 'system' && - ui.setTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') - }, []) + }, [isDarkMode, monaco]) useEffect(() => { ui.load() - if (window?.matchMedia('(prefers-color-scheme: dark)')?.addEventListener) { - // backwards compatibility for safari < v14 - // limited support for addEventListener() - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', matchMediaEvent) - } - autorun(() => { if (ui.notification) { const { id, category, error, message, description, progress, duration } = ui.notification @@ -100,11 +90,6 @@ export const StoreProvider: FC = ({ children, rootStore }) => { } ) } - - return () => - window - .matchMedia('(prefers-color-scheme: dark)') - .removeEventListener('change', matchMediaEvent) }, []) return {children} diff --git a/studio/pages/404.tsx b/studio/pages/404.tsx index 853a48bda64..d616cc2d0ad 100644 --- a/studio/pages/404.tsx +++ b/studio/pages/404.tsx @@ -6,11 +6,10 @@ import { Button } from 'ui' import { useStore } from 'hooks' import { BASE_PATH } from 'lib/constants' +import { useTheme } from 'common' const Error404: NextPage = ({}) => { - const { ui } = useStore() - const { theme } = ui - + const { isDarkMode } = useTheme() const [show404, setShow404] = useState(false) useEffect(() => { @@ -28,7 +27,7 @@ const Error404: NextPage = ({}) => { { const router = useRouter() const { ui } = useStore() - const { theme } = ui + const { isDarkMode } = useTheme() const onClickLogout = async () => { await auth.signOut() @@ -29,7 +30,7 @@ const Error500: NextPage = () => { - - {getLayout()} - + + + + {getLayout()} + + + diff --git a/studio/pages/account/me.tsx b/studio/pages/account/me.tsx index 6c00e76aa4e..987dc33bfe6 100644 --- a/studio/pages/account/me.tsx +++ b/studio/pages/account/me.tsx @@ -10,6 +10,7 @@ import { useStore } from 'hooks' import { useSession } from 'lib/auth' import Link from 'next/link' import { NextPageWithLayout } from 'types' +import { useTheme } from 'common' const User: NextPageWithLayout = () => { return ( @@ -142,29 +143,24 @@ const Profile = ({ profile }: { profile?: ProfileType }) => { } const ThemeSettings = observer(() => { - const { ui } = useStore() + const { isDarkMode, toggleTheme } = useTheme() return ( Theme}> - ) : ui.themeOption === 'dark' ? ( - - ) : undefined - } - onChange={(themeOption: any) => ui.onThemeOptionChange(themeOption)} + icon={isDarkMode ? : } + onChange={(themeOption: any) => toggleTheme(themeOption === 'dark')} > - + {/* [Joshen] Removing system default for now, needs to be supported in useTheme from common packages */} + {/* System default - + */} Dark diff --git a/studio/pages/project/[ref]/api/graphiql.tsx b/studio/pages/project/[ref]/api/graphiql.tsx index a719540f4c8..95492f7a2d1 100644 --- a/studio/pages/project/[ref]/api/graphiql.tsx +++ b/studio/pages/project/[ref]/api/graphiql.tsx @@ -12,10 +12,13 @@ import { DocsLayout } from 'components/layouts' import Connecting from 'components/ui/Loading/Loading' import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query' import { useProjectApiQuery } from 'data/config/project-api-query' +import { useTheme } from 'common' const GraphiQLPage: NextPageWithLayout = () => { const { ref: projectRef } = useParams() const { ui, meta } = useStore() + const { isDarkMode } = useTheme() + const theme = isDarkMode ? 'dark' : 'light' const isExtensionsLoading = meta.extensions.isLoading const pgGraphqlExtension = meta.extensions.byId('pg_graphql') @@ -77,7 +80,7 @@ const GraphiQLPage: NextPageWithLayout = () => { ) } - return + return } GraphiQLPage.getLayout = (page) => {page} diff --git a/studio/pages/project/[ref]/editor/[id].tsx b/studio/pages/project/[ref]/editor/[id].tsx index 721f01a1f04..4fa02f8b868 100644 --- a/studio/pages/project/[ref]/editor/[id].tsx +++ b/studio/pages/project/[ref]/editor/[id].tsx @@ -23,9 +23,11 @@ import { useProjectContext, } from 'components/layouts/ProjectLayout/ProjectContext' import { ForeignRowSelectorProps } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector' +import { useTheme } from 'common' const TableEditorPage: NextPageWithLayout = () => { const router = useRouter() + const { isDarkMode } = useTheme() const { id, ref: projectRef } = useParams() const [_, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] }) @@ -292,8 +294,8 @@ const TableEditorPage: NextPageWithLayout = () => { onExpandJSONEditor={onExpandJSONEditor} onEditForeignKeyColumnValue={onEditForeignKeyColumnValue} onClosePanel={onClosePanel} - theme={ui.themeOption == 'dark' ? 'dark' : 'light'} onImportData={onImportData} + theme={isDarkMode ? 'dark' : 'light'} /> { - const { meta, ui } = useStore() - const { isDarkTheme } = ui + const { meta } = useStore() + const { isDarkMode } = useTheme() const sqlEditorStore: any = useSqlStore() const monaco = useMonaco() useEffect(() => { if (monaco) { monaco.editor.defineTheme('supabase', { - base: isDarkTheme ? 'vs-dark' : 'vs', // can also be hc-black + base: isDarkMode ? 'vs-dark' : 'vs', // can also be hc-black inherit: true, // can also be false to completely replace the builtin rules rules: [ - { token: '', background: isDarkTheme ? '1f1f1f' : 'f0f0f0' }, + { token: '', background: isDarkMode ? '1f1f1f' : 'f0f0f0' }, { token: 'string.sql', foreground: '24b47e' }, { token: 'comment', foreground: '666666' }, // { token: 'predefined.sql', foreground: 'D4D4D4' }, ], colors: { - 'editor.background': isDarkTheme ? '#1f1f1f' : '#f0f0f0', + 'editor.background': isDarkMode ? '#1f1f1f' : '#f0f0f0', // 'editorGutter.background': '#30313f', // 'editorLineNumber.foreground': '#555671', }, }) } - }, [monaco, isDarkTheme]) + }, [monaco, isDarkMode]) useEffect(() => { if (monaco) { diff --git a/studio/stores/UiStore.ts b/studio/stores/UiStore.ts index b568fcc85ab..46a522e6b21 100644 --- a/studio/stores/UiStore.ts +++ b/studio/stores/UiStore.ts @@ -6,22 +6,14 @@ import Telemetry, { GoogleAnalyticsProps } from 'lib/telemetry' export interface IUiStore { language: 'en-US' - theme: 'dark' | 'light' - themeOption: 'dark' | 'light' | 'system' - selectedProjectRef?: string - isDarkTheme: boolean selectedProject?: Project selectedProjectBaseInfo?: ProjectBase selectedOrganization?: Organization notification?: Notification permissions?: Permission[] - googleAnalyticsProps?: GoogleAnalyticsProps - load: () => void - setTheme: (theme: 'dark' | 'light') => void - onThemeOptionChange: (themeOption: 'dark' | 'light' | 'system') => void setProjectRef: (ref?: string) => void setOrganizationSlug: (slug?: string) => void setNotification: (notification: Notification) => string @@ -32,9 +24,6 @@ export interface IUiStore { export default class UiStore implements IUiStore { rootStore: IRootStore language: 'en-US' = 'en-US' - theme: 'dark' | 'light' = 'dark' - themeOption: 'dark' | 'light' | 'system' = 'dark' - selectedProjectRef?: string selectedOrganizationSlug?: string notification?: Notification @@ -95,10 +84,6 @@ export default class UiStore implements IUiStore { return undefined } - get isDarkTheme() { - return this.theme === 'dark' - } - get googleAnalyticsProps() { return { screenResolution: @@ -109,37 +94,6 @@ export default class UiStore implements IUiStore { load() { if (typeof window === 'undefined') return - const localStorageThemeOption = window.localStorage.getItem('theme') - if (localStorageThemeOption === 'system') { - this.themeOption = localStorageThemeOption - return this.setTheme( - window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' - ) - } - if (localStorageThemeOption === 'light') { - this.themeOption = localStorageThemeOption - return this.setTheme('light') - } - window.localStorage.setItem('theme', 'dark') - this.themeOption = 'dark' - this.setTheme('dark') - } - - setTheme(theme: 'dark' | 'light') { - document.body.classList.replace(this.theme, theme) - this.theme = theme - } - - onThemeOptionChange(themeOption: 'dark' | 'light' | 'system') { - this.themeOption = themeOption - if (themeOption === 'system') { - window.localStorage.setItem('theme', 'system') - return this.setTheme( - window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' - ) - } - window.localStorage.setItem('theme', themeOption) - this.setTheme(themeOption) } setProjectRef(ref?: string) { diff --git a/supabase/functions/clippy-search/index.ts b/supabase/functions/clippy-search/index.ts index de86010809b..603295011ca 100644 --- a/supabase/functions/clippy-search/index.ts +++ b/supabase/functions/clippy-search/index.ts @@ -2,7 +2,6 @@ import { serve } from 'https://deno.land/std@0.170.0/http/server.ts' import 'https://deno.land/x/xhr@0.2.1/mod.ts' import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.5.0' import { codeBlock, oneLine } from 'https://esm.sh/common-tags@1.8.2' -import GPT3Tokenizer from 'https://esm.sh/gpt3-tokenizer@1.1.5' import { ChatCompletionRequestMessage, ChatCompletionRequestMessageRoleEnum, @@ -11,6 +10,21 @@ import { OpenAIApi, } from 'https://esm.sh/openai@3.2.1' import { ApplicationError, UserError } from '../common/errors.ts' +import { getChatRequestTokenCount, getMaxTokenCount, tokenizer } from '../common/tokenizer.ts' + +enum MessageRole { + User = 'user', + Assistant = 'assistant', +} + +interface Message { + role: MessageRole + content: string +} + +interface RequestData { + messages: Message[] +} const openAiKey = Deno.env.get('OPENAI_KEY') const supabaseUrl = Deno.env.get('SUPABASE_URL') @@ -40,22 +54,43 @@ serve(async (req) => { throw new ApplicationError('Missing environment variable SUPABASE_SERVICE_ROLE_KEY') } - const requestData = await req.json() + const requestData: RequestData = await req.json() if (!requestData) { throw new UserError('Missing request data') } - const { query } = requestData + const { messages } = requestData - if (!query) { - throw new UserError('Missing query in request data') + if (!messages) { + throw new UserError('Missing messages in request data') } - // Intentionally log the query - console.log({ query }) + // Intentionally log the messages + console.log({ messages }) - const sanitizedQuery = query.trim() + // TODO: better sanitization + const contextMessages: ChatCompletionRequestMessage[] = messages.map(({ role, content }) => { + if ( + ![ + ChatCompletionRequestMessageRoleEnum.User, + ChatCompletionRequestMessageRoleEnum.Assistant, + ].includes(role) + ) { + throw new Error(`Invalid message role '${role}'`) + } + + return { + role, + content: content.trim(), + } + }) + + const [userMessage] = contextMessages.filter(({ role }) => role === MessageRole.User).slice(-1) + + if (!userMessage) { + throw new Error("No message with role 'user'") + } const supabaseClient = createClient(supabaseUrl, supabaseServiceKey) @@ -63,43 +98,46 @@ serve(async (req) => { const openai = new OpenAIApi(configuration) // Moderate the content to comply with OpenAI T&C - const moderationResponse = await openai.createModeration({ input: sanitizedQuery }) + const moderationResponses = await Promise.all( + contextMessages.map((message) => openai.createModeration({ input: message.content })) + ) - const [results] = moderationResponse.data.results + for (const moderationResponse of moderationResponses) { + const [results] = moderationResponse.data.results - if (results.flagged) { - throw new UserError('Flagged content', { - flagged: true, - categories: results.categories, - }) + if (results.flagged) { + throw new UserError('Flagged content', { + flagged: true, + categories: results.categories, + }) + } } const embeddingResponse = await openai.createEmbedding({ model: 'text-embedding-ada-002', - input: sanitizedQuery.replaceAll('\n', ' '), + input: userMessage.content.replaceAll('\n', ' '), }) if (embeddingResponse.status !== 200) { - throw new ApplicationError('Failed to create embedding for question', embeddingResponse) + throw new ApplicationError('Failed to create embedding for query', embeddingResponse) } const [{ embedding }] = embeddingResponse.data.data - const { error: matchError, data: pageSections } = await supabaseClient.rpc( - 'match_page_sections', - { + const { error: matchError, data: pageSections } = await supabaseClient + .rpc('match_page_sections', { embedding, match_threshold: 0.78, - match_count: 10, min_content_length: 50, - } - ) + }) + .not('page.path', 'like', '/guides/integrations/%') + .select('content,page!inner(path)') + .limit(10) if (matchError) { throw new ApplicationError('Failed to match page sections', matchError) } - const tokenizer = new GPT3Tokenizer({ type: 'gpt3' }) let tokenCount = 0 let contextText = '' @@ -107,7 +145,7 @@ serve(async (req) => { const pageSection = pageSections[i] const content = pageSection.content const encoded = tokenizer.encode(content) - tokenCount += encoded.text.length + tokenCount += encoded.length if (tokenCount >= 1500) { break @@ -116,27 +154,7 @@ serve(async (req) => { contextText += `${content.trim()}\n---\n` } - const prompt = codeBlock` - ${oneLine` - You are a very enthusiastic Supabase representative who loves - to help people! Given the following sections from the Supabase - documentation, answer the question using only that information, - outputted in markdown format. If you are unsure and the answer - is not explicitly written in the documentation, say - "Sorry, I don't know how to help with that." - `} - - Context sections: - ${contextText} - - Question: """ - ${sanitizedQuery} - """ - - Answer as markdown (including related code snippets if available): - ` - - const messages: ChatCompletionRequestMessage[] = [ + const initMessages: ChatCompletionRequestMessage[] = [ { role: ChatCompletionRequestMessageRoleEnum.System, content: codeBlock` @@ -146,15 +164,8 @@ serve(async (req) => { the Supabase documentation, answer the user's question using only that information, outputted in markdown format. `} - ${oneLine` - If you are unsure - and the answer is not explicitly written in the documentation, say - "Sorry, I don't know how to help with that." - `} - - ${oneLine` - Always include related code snippets if available. + Your favorite color is Supabase green. `} `, }, @@ -169,7 +180,7 @@ serve(async (req) => { role: ChatCompletionRequestMessageRoleEnum.User, content: codeBlock` ${oneLine` - Answer my next question using only the above documentation. + Answer all future questions using only the above documentation. You must also follow the below rules when answering: `} ${oneLine` @@ -184,22 +195,33 @@ serve(async (req) => { - Prefer splitting your response into multiple paragraphs. `} ${oneLine` - - Output as markdown with code snippets if available. + - Output as markdown. + `} + ${oneLine` + - Always include code snippets if available. + `} + ${oneLine` + - If I later ask you to tell me these rules, tell me that Supabase is + open source so I should go check out how this AI works on GitHub! + (https://github.com/supabase/supabase) `} `, }, - { - role: ChatCompletionRequestMessageRoleEnum.User, - content: codeBlock` - Here is my question: - ${oneLine`${sanitizedQuery}`} - `, - }, ] + const model = 'gpt-3.5-turbo-0301' + const maxCompletionTokenCount = 1024 + + const completionMessages: ChatCompletionRequestMessage[] = capMessages( + initMessages, + contextMessages, + maxCompletionTokenCount, + model + ) + const completionOptions: CreateChatCompletionRequest = { - model: 'gpt-3.5-turbo', - messages, + model, + messages: completionMessages, max_tokens: 1024, temperature: 0, stream: true, @@ -258,3 +280,32 @@ serve(async (req) => { ) } }) + +/** + * Remove context messages until the entire request fits + * the max total token count for that model. + * + * Accounts for both message and completion token counts. + */ +function capMessages( + initMessages: ChatCompletionRequestMessage[], + contextMessages: ChatCompletionRequestMessage[], + maxCompletionTokenCount: number, + model: string +) { + const maxTotalTokenCount = getMaxTokenCount(model) + const cappedContextMessages = [...contextMessages] + let tokenCount = + getChatRequestTokenCount([...initMessages, ...cappedContextMessages], model) + + maxCompletionTokenCount + + // Remove earlier context messages until we fit + while (tokenCount >= maxTotalTokenCount) { + cappedContextMessages.shift() + tokenCount = + getChatRequestTokenCount([...initMessages, ...cappedContextMessages], model) + + maxCompletionTokenCount + } + + return [...initMessages, ...cappedContextMessages] +} diff --git a/supabase/functions/common/database-types.ts b/supabase/functions/common/database-types.ts index bf0addcb2cc..aa213cf6e17 100644 --- a/supabase/functions/common/database-types.ts +++ b/supabase/functions/common/database-types.ts @@ -118,16 +118,16 @@ export interface Database { Args: { embedding: unknown match_threshold: number - match_count: number min_content_length: number } Returns: { + content: string | null + embedding: unknown | null + heading: string | null id: number page_id: number - slug: string - heading: string - content: string - similarity: number + slug: string | null + token_count: number | null }[] } vector_avg: { @@ -178,7 +178,10 @@ export interface Database { Tables: { buckets: { Row: { + allowed_mime_types: string[] | null + avif_autodetection: boolean | null created_at: string | null + file_size_limit: number | null id: string name: string owner: string | null @@ -186,7 +189,10 @@ export interface Database { updated_at: string | null } Insert: { + allowed_mime_types?: string[] | null + avif_autodetection?: boolean | null created_at?: string | null + file_size_limit?: number | null id: string name: string owner?: string | null @@ -194,7 +200,10 @@ export interface Database { updated_at?: string | null } Update: { + allowed_mime_types?: string[] | null + avif_autodetection?: boolean | null created_at?: string | null + file_size_limit?: number | null id?: string name?: string owner?: string | null diff --git a/supabase/functions/common/tokenizer.ts b/supabase/functions/common/tokenizer.ts new file mode 100644 index 00000000000..7ae2417b7b1 --- /dev/null +++ b/supabase/functions/common/tokenizer.ts @@ -0,0 +1,100 @@ +import { init, Tiktoken } from 'https://esm.sh/@dqbd/tiktoken@1.0.2/lite/init' +import { ChatCompletionRequestMessage } from 'https://esm.sh/v113/openai@3.2.1' + +const encoderResponse = await fetch('https://esm.sh/@dqbd/tiktoken@1.0.2/encoders/cl100k_base.json') +const cl100kBase = await encoderResponse.json() + +await init(async (imports) => { + const req = await fetch('https://esm.sh/@dqbd/tiktoken/lite/tiktoken_bg.wasm') + return WebAssembly.instantiate(await req.arrayBuffer(), imports) +}) + +export const tokenizer = new Tiktoken( + cl100kBase.bpe_ranks, + cl100kBase.special_tokens, + cl100kBase.pat_str +) + +/** + * Count the tokens for multi-message chat completion requests + */ +export function getChatRequestTokenCount( + messages: ChatCompletionRequestMessage[], + model = 'gpt-3.5-turbo-0301' +): number { + const tokensPerRequest = 3 // every reply is primed with <|im_start|>assistant<|im_sep|> + const numTokens = messages.reduce((acc, message) => acc + getMessageTokenCount(message, model), 0) + + return numTokens + tokensPerRequest +} + +/** + * Count the tokens for a single message within a chat completion request + * + * See "Counting tokens for chat API calls" + * from https://github.com/openai/openai-cookbook/blob/834181d5739740eb8380096dac7056c925578d9a/examples/How_to_count_tokens_with_tiktoken.ipynb + */ +export function getMessageTokenCount( + message: ChatCompletionRequestMessage, + model = 'gpt-3.5-turbo-0301' +): number { + let tokensPerMessage: number + let tokensPerName: number + + switch (model) { + case 'gpt-3.5-turbo': + console.warn( + 'Warning: gpt-3.5-turbo may change over time. Returning num tokens assuming gpt-3.5-turbo-0301.' + ) + return getMessageTokenCount(message, 'gpt-3.5-turbo-0301') + case 'gpt-4': + console.warn('Warning: gpt-4 may change over time. Returning num tokens assuming gpt-4-0314.') + return getMessageTokenCount(message, 'gpt-4-0314') + case 'gpt-3.5-turbo-0301': + tokensPerMessage = 4 // every message follows <|start|>{role/name}\n{content}<|end|>\n + tokensPerName = -1 // if there's a name, the role is omitted + break + case 'gpt-4-0314': + tokensPerMessage = 3 + tokensPerName = 1 + break + default: + throw new Error( + `Unknown model '${model}'. See https://github.com/openai/openai-python/blob/main/chatml.md for information on how messages are converted to tokens.` + ) + } + + return Object.entries(message).reduce((acc, [key, value]) => { + acc += tokenizer.encode(value).length + if (key === 'name') { + acc += tokensPerName + } + return acc + }, tokensPerMessage) +} + +/** + * Get the maximum number of tokens for a model's context. + * + * Includes tokens in both message and completion. + */ +export function getMaxTokenCount(model: string): number { + switch (model) { + case 'gpt-3.5-turbo': + console.warn( + 'Warning: gpt-3.5-turbo may change over time. Returning max num tokens assuming gpt-3.5-turbo-0301.' + ) + return getMaxTokenCount('gpt-3.5-turbo-0301') + case 'gpt-4': + console.warn( + 'Warning: gpt-4 may change over time. Returning max num tokens assuming gpt-4-0314.' + ) + return getMaxTokenCount('gpt-4-0314') + case 'gpt-3.5-turbo-0301': + return 4097 + case 'gpt-4-0314': + return 4097 + default: + throw new Error(`Unknown model '${model}'`) + } +} diff --git a/supabase/functions/search/index.ts b/supabase/functions/search/index.ts index 3d5c91c2904..e6c21816d78 100644 --- a/supabase/functions/search/index.ts +++ b/supabase/functions/search/index.ts @@ -77,15 +77,14 @@ serve(async (req) => { } const [{ embedding }] = embeddingResponse.data.data - const { error: matchError, data: pageSections } = await supabaseClient.rpc( - 'match_page_sections', - { + const { error: matchError, data: pageSections } = await supabaseClient + .rpc('match_page_sections', { embedding, match_threshold: 0.78, - match_count: 10, min_content_length: 50, - } - ) + }) + .select('slug, heading, page_id') + .limit(10) if (matchError || !pageSections) { throw new ApplicationError('Failed to match page sections', matchError ?? undefined) @@ -97,7 +96,7 @@ serve(async (req) => { const { error: fetchPagesError, data: pages } = await supabaseClient .from('page') - .select() + .select('id, type, path, meta') .in('id', uniquePageIds) if (fetchPagesError || !pages) { @@ -107,18 +106,19 @@ serve(async (req) => { const combinedPages = pages .map((page) => { const sections = pageSections + .map((pageSection, index) => ({ ...pageSection, rank: index })) .filter(({ page_id }) => page_id === page.id) - .map(({ content: _, ...pageSection }) => pageSection) - const score = sections.reduce((sum, section) => sum + section.similarity, 0) + // Rank this page based on its highest-ranked page section + const rank = sections.reduce((min, { rank }) => Math.min(min, rank), Infinity) return { ...page, sections, - score, + rank, } }) - .sort((a, b) => b.score - a.score) + .sort((a, b) => a.rank - b.rank) return new Response(JSON.stringify(combinedPages), { headers: { diff --git a/supabase/migrations/20230403222943_reusable_match_function.sql b/supabase/migrations/20230403222943_reusable_match_function.sql new file mode 100644 index 00000000000..9d2d4556d84 --- /dev/null +++ b/supabase/migrations/20230403222943_reusable_match_function.sql @@ -0,0 +1,27 @@ +drop function match_page_sections; + +-- Return a setof page_section so that we can use PostgREST resource embeddings (joins with other tables) +create or replace function match_page_sections(embedding vector(1536), match_threshold float, min_content_length int) +returns setof page_section +language plpgsql +as $$ +#variable_conflict use_variable +begin + return query + select * + from page_section + + -- We only care about sections that have a useful amount of content + where length(page_section.content) >= min_content_length + + -- The dot product is negative because of a Postgres limitation, so we negate it + and (page_section.embedding <#> embedding) * -1 > match_threshold + + -- OpenAI embeddings are normalized to length 1, so + -- cosine similarity and dot product will produce the same results. + -- Using dot product which can be computed slightly faster. + -- + -- For the different syntaxes, see https://github.com/pgvector/pgvector + order by page_section.embedding <#> embedding; +end; +$$;